gen-third-party-notices.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  2. import { join, resolve } from 'node:path'
  3. import { tmpdir } from 'node:os'
  4. import { describe, expect, it } from 'vitest'
  5. import { collectPythonDependencies, isPermissive, type Manifest, manifestPatterns, parsePyprojectRequirements, parseVendoredRows, render, tierExternalDeps, virtualManifest } from './gen-third-party-notices.ts'
  6. const root = resolve(import.meta.dirname, '..')
  7. describe('THIRD_PARTY_NOTICES.md', () => {
  8. // Freshness lives here rather than in its own doc-sync gate: this spec file
  9. // already runs in the test lane, so the check costs no extra CI process.
  10. // Pre-commit regenerates the file whenever a manifest is staged, so reaching
  11. // this assertion means the notices were committed without that hook.
  12. it('matches what the generator produces from the current manifests', () => {
  13. expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(render())
  14. })
  15. })
  16. /** Build the (manifests, names) pair `tierExternalDeps` consumes. */
  17. function workspace(entries: Record<string, Manifest>): { manifests: Map<string, Manifest>; names: Set<string> } {
  18. const manifests = new Map(Object.entries(entries))
  19. const names = new Set<string>()
  20. for (const manifest of manifests.values()) {
  21. if (manifest.name !== undefined) names.add(manifest.name)
  22. }
  23. return { manifests, names }
  24. }
  25. describe('tierExternalDeps', () => {
  26. it('tiers by declaring area, not by the declaring section name', () => {
  27. const { manifests, names } = workspace({
  28. // Root tooling and test infrastructure never ship, whichever section declares them.
  29. 'package.json': { dependencies: { 'root-runtime-looking': '^1' }, devDependencies: { 'lint-tool': '^1' } },
  30. 'packages/support/loader-smoke/package.json': { name: '@deepseek-ai/dsh-loader-smoke', dependencies: { 'smoke-helper': '^1' } },
  31. 'packages/client/test-runtime/package.json': { name: '@deepseek-ai/dsh-client-test-runtime', dependencies: { 'test-lib': '^1' } },
  32. 'website/package.json': { devDependencies: { 'site-tool': '^1' } },
  33. // A plugin package's runtime dependency ships even when no app mounts it by default.
  34. 'packages/mcp/mcp-client/package.json': { name: '@deepseek-ai/dsh-mcp-client', dependencies: { 'protocol-sdk': '^1' }, devDependencies: { 'protocol-fixture-server': '^1' } },
  35. 'apps/cli/package.json': { name: '@deepseek-ai/dsh-cli', dependencies: { 'cli-lib': '^1', '@deepseek-ai/dsh-mcp-client': 'workspace:^' } },
  36. })
  37. expect(tierExternalDeps(manifests, names)).toEqual(new Map([
  38. ['tsx', true],
  39. ['root-runtime-looking', false],
  40. ['lint-tool', false],
  41. ['smoke-helper', false],
  42. ['test-lib', false],
  43. ['site-tool', false],
  44. ['protocol-sdk', true],
  45. ['protocol-fixture-server', false],
  46. ['cli-lib', true],
  47. ]))
  48. })
  49. it('keeps a package runtime when any shipping area declares it, and excludes workspace links', () => {
  50. const { manifests, names } = workspace({
  51. 'package.json': { devDependencies: { shared: '^1' } },
  52. 'packages/ui/tui/package.json': { name: '@deepseek-ai/dsh-tui', dependencies: { shared: '^1', '@deepseek-ai/dsh-cli': 'workspace:^' } },
  53. 'apps/cli/package.json': { name: '@deepseek-ai/dsh-cli' },
  54. })
  55. expect(tierExternalDeps(manifests, names).get('shared')).toBe(true)
  56. expect(tierExternalDeps(manifests, names).has('@deepseek-ai/dsh-cli')).toBe(false)
  57. })
  58. })
  59. describe('virtualManifest', () => {
  60. it('resolves a manifest from an ordinary prefix-matching store directory', () => {
  61. const root = mkdtempSync(join(tmpdir(), 'dsh-notices-prefix-'))
  62. try {
  63. const name = '@scope/pkg'
  64. const version = '1.0.0'
  65. const store = join(root, 'store')
  66. const manifestDir = join(store, `${name.replace('/', '+')}@${version}`, 'node_modules', name)
  67. mkdirSync(manifestDir, { recursive: true })
  68. writeFileSync(join(manifestDir, 'package.json'), JSON.stringify({ name, version, license: 'MIT' }))
  69. expect(virtualManifest(store, name)).toMatchObject({ name, version, license: 'MIT' })
  70. } finally {
  71. rmSync(root, { recursive: true, force: true })
  72. }
  73. })
  74. it('falls back to a content scan when pnpm 11 truncates the store directory name', () => {
  75. const root = mkdtempSync(join(tmpdir(), 'dsh-notices-truncated-'))
  76. try {
  77. const name = '@scope/pkg'
  78. const version = '2.0.0'
  79. const store = join(root, 'store')
  80. // The truncated name no longer starts with `@scope+pkg@`, so only the
  81. // whole-store content scan can find the package.
  82. const manifestDir = join(store, '@scope+pkg_9f1c2d3e4a5b6c7d8e9f0a1b2c3d4e5f', 'node_modules', name)
  83. mkdirSync(manifestDir, { recursive: true })
  84. writeFileSync(join(manifestDir, 'package.json'), JSON.stringify({ name, version, license: 'Apache-2.0' }))
  85. expect(virtualManifest(store, name)).toMatchObject({ name, version, license: 'Apache-2.0' })
  86. } finally {
  87. rmSync(root, { recursive: true, force: true })
  88. }
  89. })
  90. it('returns undefined when neither the prefix nor the content scan finds the package', () => {
  91. const root = mkdtempSync(join(tmpdir(), 'dsh-notices-miss-'))
  92. try {
  93. const store = join(root, 'store')
  94. const other = join(store, 'other-pkg@1.0.0', 'node_modules', 'other-pkg')
  95. mkdirSync(other, { recursive: true })
  96. writeFileSync(join(other, 'package.json'), JSON.stringify({ name: 'other-pkg', version: '1.0.0' }))
  97. expect(virtualManifest(store, '@scope/missing')).toBeUndefined()
  98. } finally {
  99. rmSync(root, { recursive: true, force: true })
  100. }
  101. })
  102. })
  103. describe('parseVendoredRows', () => {
  104. it('reads the committed vendor manifest table', () => {
  105. const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8'))
  106. expect(rows.length).toBeGreaterThan(0)
  107. expect(rows).toContainEqual({ npmName: 'cordis', upstream: 'https://github.com/cordiverse/cordis' })
  108. // The upstream column carries a trailing package path for some rows; it is not part of the URL.
  109. expect(rows.every(row => /^https:\/\/\S+$/.test(row.upstream))).toBe(true)
  110. })
  111. it('yields nothing when the table shape changes, so the generator fails loud', () => {
  112. expect(parseVendoredRows('| `cordis/` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([])
  113. })
  114. it('covers every vendored directory, so no package can drop out of the notices', () => {
  115. const parsed = new Set(parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8')).map(row => row.npmName))
  116. const onDisk = readdirSync(resolve(root, 'vendor'), { withFileTypes: true })
  117. .filter(entry => entry.isDirectory())
  118. .map(entry => (JSON.parse(readFileSync(resolve(root, 'vendor', entry.name, 'package.json'), 'utf8')) as Manifest).name)
  119. expect([...onDisk].sort()).toEqual([...parsed].sort())
  120. })
  121. })
  122. describe('parsePyprojectRequirements', () => {
  123. it('reads the committed manifests', () => {
  124. expect(parsePyprojectRequirements(readFileSync(resolve(root, 'python/sdk/pyproject.toml'), 'utf8'))).toContain('pydantic')
  125. })
  126. it('locates requirement arrays by TOML table, so author-named groups are not missed', () => {
  127. expect(parsePyprojectRequirements([
  128. '[build-system]',
  129. 'requires = ["hatchling>=1.24.0"]',
  130. '',
  131. '[project]',
  132. 'name = "not-a-requirement"',
  133. 'dependencies = ["pydantic>=2.12"]',
  134. '',
  135. '[project.optional-dependencies]',
  136. 'cli = ["click"]',
  137. '',
  138. '[dependency-groups]',
  139. 'docs = ["sphinx>=7"]',
  140. '',
  141. '[tool.hatch.build.targets.wheel]',
  142. 'packages = ["src/deepseek_harness"]',
  143. '',
  144. '[tool.pytest.ini_options]',
  145. 'testpaths = ["tests"]',
  146. ].join('\n'))).toEqual(['hatchling', 'pydantic', 'click', 'sphinx'])
  147. })
  148. it('does not truncate an array at a bracket inside extras', () => {
  149. expect(parsePyprojectRequirements('[project]\ndependencies = ["httpx[http2]", "requests"]\n'))
  150. .toEqual(['httpx', 'requests'])
  151. })
  152. it('reads names whether or not requirements carry versions, extras, or markers', () => {
  153. expect(parsePyprojectRequirements("[project]\ndependencies = [\"pydantic>=2.12\", \"requests\", \"httpx[http2]\", \"tomli ; python_version < '3.11'\", \"hatchling >= 1.24.0\"]\n"))
  154. .toEqual(['pydantic', 'requests', 'httpx', 'tomli', 'hatchling'])
  155. })
  156. it('reads single-quoted TOML literals and rejects an unreadable requirement', () => {
  157. expect(parsePyprojectRequirements("[project]\ndependencies = ['requests', \"pydantic>=2\"]\n")).toEqual(['requests', 'pydantic'])
  158. expect(() => parsePyprojectRequirements('[project]\ndependencies = ["!!broken"]\n')).toThrow(/cannot read a distribution name/)
  159. })
  160. it('reads a multi-line array', () => {
  161. expect(parsePyprojectRequirements('[project]\ndependencies = [\n "pydantic>=2.12",\n "typing-extensions",\n]\n'))
  162. .toEqual(['pydantic', 'typing-extensions'])
  163. })
  164. it('obeys TOML comments, quoted keys, and escaped strings', () => {
  165. expect(parsePyprojectRequirements([
  166. '[project] # a legal header comment',
  167. 'dependencies = [',
  168. ' "pydantic", # ] does not close the array',
  169. ' # "old-package" is not a dependency',
  170. ' "tomli; python_version < \'3.11\'",',
  171. ']',
  172. '',
  173. '[dependency-groups]',
  174. '"test.docs" = ["pytest"]',
  175. ].join('\n'))).toEqual(['pydantic', 'tomli', 'pytest'])
  176. })
  177. it('accepts dependency-group includes and rejects unsupported requirement shapes', () => {
  178. expect(parsePyprojectRequirements('[dependency-groups]\nbase = ["pytest"]\nall = [{ include-group = "base" }]\n'))
  179. .toEqual(['pytest'])
  180. expect(() => parsePyprojectRequirements('[project]\ndependencies = "pytest"\n')).toThrow(/must be an array/)
  181. expect(() => parsePyprojectRequirements('[dependency-groups]\ntest = [{ unknown = "pytest" }]\n')).toThrow(/unsupported requirement entry/)
  182. })
  183. })
  184. describe('collectPythonDependencies', () => {
  185. it('excludes normalized local project names without exempting a third-party prefix', () => {
  186. const pyprojects = [
  187. '[project]\nname = "deepseek-harness-runtime-bin"\ndependencies = ["pydantic"]\n',
  188. '[project]\nname = "deepseek-harness"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n',
  189. ]
  190. expect(() => collectPythonDependencies(pyprojects)).toThrow(
  191. 'python dependency deepseek-unrelated is missing from PYTHON_METADATA',
  192. )
  193. })
  194. })
  195. describe('isPermissive', () => {
  196. it('accepts the licenses this project ships and rejects copyleft or unknown ones', () => {
  197. expect(['MIT', 'ISC', 'BSD-3-Clause', 'Apache-2.0', 'MIT / Apache-2.0', '(MIT OR CC0-1.0)'].every(isPermissive)).toBe(true)
  198. expect(['LGPL-3.0-only', 'MPL-2.0', 'GPL-3.0-or-later', 'SEE LICENSE IN LICENSE'].some(isPermissive)).toBe(false)
  199. })
  200. it('requires every operand of an AND, so a copyleft conjunct cannot ride along', () => {
  201. expect(isPermissive('(MIT OR Apache-2.0) AND GPL-3.0-only')).toBe(false)
  202. expect(isPermissive('MIT AND ISC')).toBe(true)
  203. // An exception clause is not a recognized identifier, so it fails closed.
  204. expect(isPermissive('GPL-2.0-only WITH Classpath-exception-2.0')).toBe(false)
  205. })
  206. it('honors grouping and SPDX precedence', () => {
  207. expect(isPermissive('MIT OR (GPL-3.0-only AND GPL-2.0-only)')).toBe(true)
  208. expect(isPermissive('(MIT OR Apache-2.0) AND ISC')).toBe(true)
  209. })
  210. it('fails closed for malformed expressions, additions, and exceptions', () => {
  211. expect(['MIT)', '((MIT', '(MIT OR GPL-3.0-only', 'MIT OR OR GPL-3.0-only'].some(isPermissive)).toBe(false)
  212. expect(isPermissive('MIT+')).toBe(false)
  213. expect(isPermissive('GPL-2.0-only WITH Classpath-exception-2.0')).toBe(false)
  214. })
  215. })
  216. describe('manifestPatterns', () => {
  217. it('derives globs from the declared members, so a new member area is read', () => {
  218. expect(manifestPatterns(['packages/*/*', 'tools/*'], ['packages/*'])).toEqual([
  219. 'package.json',
  220. 'packages/*/*/package.json',
  221. 'tools/*/package.json',
  222. 'examples/*/package.json',
  223. 'native/landlock-run/package.json',
  224. 'native/landlock-run/packages/*/package.json',
  225. ])
  226. })
  227. })