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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  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 {
  6. CLAUDE_AGENT_SDK_PACKAGE,
  7. assertRuntimeLicenses,
  8. claudeDistributionFromManifest,
  9. collectPythonDependencies,
  10. isOwnerAuthorizedRuntime,
  11. isPermissive,
  12. type Manifest,
  13. manifestPatterns,
  14. parsePyprojectRequirements,
  15. parseVendoredRows,
  16. render,
  17. tierExternalDeps,
  18. virtualManifest,
  19. } from './gen-third-party-notices.ts'
  20. const root = resolve(import.meta.dirname, '..')
  21. describe('THIRD_PARTY_NOTICES.md', () => {
  22. // Freshness lives here rather than in its own doc-sync gate: this spec file
  23. // already runs in the test lane, so the check costs no extra CI process.
  24. // Pre-commit regenerates the file whenever a manifest is staged, so reaching
  25. // this assertion means the notices were committed without that hook.
  26. // This case resolves all browser build graphs as well as installed license metadata.
  27. it('matches what the generator produces from the current manifests', {
  28. timeout: 120_000,
  29. }, async () => {
  30. const generated = await render()
  31. expect(generated).toContain('It depends on the third-party software listed below.')
  32. expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated)
  33. })
  34. })
  35. /** Build the (manifests, names) pair `tierExternalDeps` consumes. */
  36. function workspace(entries: Record<string, Manifest>): { manifests: Map<string, Manifest>; names: Set<string> } {
  37. const manifests = new Map(Object.entries(entries))
  38. const names = new Set<string>()
  39. for (const manifest of manifests.values()) {
  40. if (manifest.name !== undefined) names.add(manifest.name)
  41. }
  42. return { manifests, names }
  43. }
  44. describe('tierExternalDeps', () => {
  45. it('keeps license rejection active when a browser library is declared for development', () => {
  46. const { manifests, names } = workspace({
  47. 'packages/client/ui/package.json': { devDependencies: { 'browser-lib': '^1', 'test-tool': '^1' } },
  48. })
  49. const tiers = tierExternalDeps(manifests, names, new Set(['browser-lib']))
  50. const dependencies = [{ name: 'browser-lib', license: 'GPL-3.0-only' }, { name: 'test-tool', license: 'GPL-3.0-only' }]
  51. .filter(dep => tiers.get(dep.name))
  52. expect(dependencies.map(dep => dep.name)).toEqual(['browser-lib'])
  53. expect(() => { assertRuntimeLicenses(dependencies) }).toThrow('browser-lib (GPL-3.0-only)')
  54. expect(() => { assertRuntimeLicenses([{ name: 'browser-lib', license: 'MIT' }]) }).not.toThrow()
  55. expect(() => { assertRuntimeLicenses([{ name: CLAUDE_AGENT_SDK_PACKAGE, license: 'SEE LICENSE IN README.md' }]) })
  56. .not.toThrow()
  57. })
  58. it('keeps browser-bundled development dependencies in runtime disclosures', () => {
  59. const { manifests, names } = workspace({
  60. 'packages/client/ui/package.json': {
  61. name: '@fixture/ui', devDependencies: { react: '^18', 'browser-lib': '^1', 'type-only': '^1' },
  62. },
  63. })
  64. expect(tierExternalDeps(manifests, names, new Set(['react', 'browser-lib']))).toEqual(new Map([
  65. ['tsx', true], ['react', true], ['browser-lib', true], ['type-only', false],
  66. ]))
  67. })
  68. it('rejects a browser library missing from the disclosed declarations', () => {
  69. const { manifests, names } = workspace({})
  70. expect(() => tierExternalDeps(manifests, names, new Set(['missing-lib'])))
  71. .toThrow('browser package missing-lib has no workspace dependency declaration')
  72. })
  73. it('tiers by declaring area, not by the declaring section name', () => {
  74. const { manifests, names } = workspace({
  75. // Root tooling and test infrastructure never ship, whichever section declares them.
  76. 'package.json': { dependencies: { 'root-runtime-looking': '^1' }, devDependencies: { 'lint-tool': '^1' } },
  77. 'packages/test-support/loader-smoke/package.json': { name: '@deepseek-ai/dsh-loader-smoke', dependencies: { 'smoke-helper': '^1' } },
  78. 'packages/test-support/client-runtime/package.json': { name: '@deepseek-ai/dsh-client-test-runtime', dependencies: { 'test-lib': '^1' } },
  79. 'website/package.json': { devDependencies: { 'site-tool': '^1' } },
  80. // A plugin package's runtime dependency ships even when no app mounts it by default.
  81. 'packages/mcp/mcp-client/package.json': { name: '@deepseek-ai/dsh-mcp-client', dependencies: { 'protocol-sdk': '^1' }, devDependencies: { 'protocol-fixture-server': '^1' } },
  82. 'apps/cli/package.json': { name: '@deepseek-ai/dsh-cli', dependencies: { 'cli-lib': '^1', '@deepseek-ai/dsh-mcp-client': 'workspace:^' } },
  83. })
  84. expect(tierExternalDeps(manifests, names)).toEqual(new Map([
  85. ['tsx', true],
  86. ['root-runtime-looking', false],
  87. ['lint-tool', false],
  88. ['smoke-helper', false],
  89. ['test-lib', false],
  90. ['site-tool', false],
  91. ['protocol-sdk', true],
  92. ['protocol-fixture-server', false],
  93. ['cli-lib', true],
  94. ]))
  95. })
  96. it('keeps a package runtime when any shipping area declares it, and excludes workspace links', () => {
  97. const { manifests, names } = workspace({
  98. 'package.json': { devDependencies: { shared: '^1' } },
  99. 'packages/interaction/tui/package.json': { name: '@deepseek-ai/dsh-tui', dependencies: { shared: '^1', '@deepseek-ai/dsh-cli': 'workspace:^' } },
  100. 'apps/cli/package.json': { name: '@deepseek-ai/dsh-cli' },
  101. })
  102. expect(tierExternalDeps(manifests, names).get('shared')).toBe(true)
  103. expect(tierExternalDeps(manifests, names).has('@deepseek-ai/dsh-cli')).toBe(false)
  104. })
  105. })
  106. describe('virtualManifest', () => {
  107. it('resolves a manifest from an ordinary prefix-matching store directory', () => {
  108. const root = mkdtempSync(join(tmpdir(), 'dsh-notices-prefix-'))
  109. try {
  110. const name = '@scope/pkg'
  111. const version = '1.0.0'
  112. const store = join(root, 'store')
  113. const manifestDir = join(store, `${name.replace('/', '+')}@${version}`, 'node_modules', name)
  114. mkdirSync(manifestDir, { recursive: true })
  115. writeFileSync(join(manifestDir, 'package.json'), JSON.stringify({ name, version, license: 'MIT' }))
  116. expect(virtualManifest(store, name)).toMatchObject({ name, version, license: 'MIT' })
  117. } finally {
  118. rmSync(root, { recursive: true, force: true })
  119. }
  120. })
  121. it('falls back to a content scan when pnpm 11 truncates the store directory name', () => {
  122. const root = mkdtempSync(join(tmpdir(), 'dsh-notices-truncated-'))
  123. try {
  124. const name = '@scope/pkg'
  125. const version = '2.0.0'
  126. const store = join(root, 'store')
  127. // The truncated name no longer starts with `@scope+pkg@`, so only the
  128. // whole-store content scan can find the package.
  129. const manifestDir = join(store, '@scope+pkg_9f1c2d3e4a5b6c7d8e9f0a1b2c3d4e5f', 'node_modules', name)
  130. mkdirSync(manifestDir, { recursive: true })
  131. writeFileSync(join(manifestDir, 'package.json'), JSON.stringify({ name, version, license: 'Apache-2.0' }))
  132. expect(virtualManifest(store, name)).toMatchObject({ name, version, license: 'Apache-2.0' })
  133. } finally {
  134. rmSync(root, { recursive: true, force: true })
  135. }
  136. })
  137. it('selects the requested version when the store retains historical copies', () => {
  138. const root = mkdtempSync(join(tmpdir(), 'dsh-notices-version-'))
  139. try {
  140. const name = '@scope/pkg'
  141. const store = join(root, 'store')
  142. for (const version of ['1.0.0', '2.0.0']) {
  143. const manifestDir = join(store, `${name.replace('/', '+')}@${version}`, 'node_modules', name)
  144. mkdirSync(manifestDir, { recursive: true })
  145. writeFileSync(join(manifestDir, 'package.json'), JSON.stringify({ name, version, license: 'MIT' }))
  146. }
  147. expect(virtualManifest(store, name, '2.0.0')).toMatchObject({ name, version: '2.0.0' })
  148. expect(virtualManifest(store, name, '3.0.0')).toBeUndefined()
  149. } finally {
  150. rmSync(root, { recursive: true, force: true })
  151. }
  152. })
  153. it('returns undefined when neither the prefix nor the content scan finds the package', () => {
  154. const root = mkdtempSync(join(tmpdir(), 'dsh-notices-miss-'))
  155. try {
  156. const store = join(root, 'store')
  157. const other = join(store, 'other-pkg@1.0.0', 'node_modules', 'other-pkg')
  158. mkdirSync(other, { recursive: true })
  159. writeFileSync(join(other, 'package.json'), JSON.stringify({ name: 'other-pkg', version: '1.0.0' }))
  160. expect(virtualManifest(store, '@scope/missing')).toBeUndefined()
  161. } finally {
  162. rmSync(root, { recursive: true, force: true })
  163. }
  164. })
  165. })
  166. describe('parseVendoredRows', () => {
  167. it('reads the committed vendor manifest table', () => {
  168. const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8'))
  169. expect(rows.length).toBeGreaterThan(0)
  170. expect(rows).toContainEqual({
  171. npmName: '@deepseek-ai/cordis',
  172. upstreamName: 'cordis',
  173. upstream: 'https://github.com/cordiverse/cordis',
  174. })
  175. // The upstream column carries a trailing package path for some rows; it is not part of the URL.
  176. expect(rows.every(row => /^https:\/\/\S+$/.test(row.upstream))).toBe(true)
  177. })
  178. it('yields nothing when the table columns change, so the generator fails loud', () => {
  179. expect(parseVendoredRows('| `cordis/` | `@deepseek-ai/cordis` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([])
  180. })
  181. it('covers every vendored directory, so no package can drop out of the notices', () => {
  182. const parsed = new Set(parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8')).map(row => row.npmName))
  183. const onDisk = readdirSync(resolve(root, 'vendor'), { withFileTypes: true })
  184. .filter(entry => entry.isDirectory())
  185. .map(entry => (JSON.parse(readFileSync(resolve(root, 'vendor', entry.name, 'package.json'), 'utf8')) as Manifest).name)
  186. expect([...onDisk].sort()).toEqual([...parsed].sort())
  187. })
  188. })
  189. describe('parsePyprojectRequirements', () => {
  190. it('reads the committed manifests', () => {
  191. expect(parsePyprojectRequirements(readFileSync(resolve(root, 'python/sdk/pyproject.toml'), 'utf8'))).toContain('pydantic')
  192. })
  193. it('locates requirement arrays by TOML table, so author-named groups are not missed', () => {
  194. expect(parsePyprojectRequirements([
  195. '[build-system]',
  196. 'requires = ["hatchling>=1.24.0"]',
  197. '',
  198. '[project]',
  199. 'name = "not-a-requirement"',
  200. 'dependencies = ["pydantic>=2.12"]',
  201. '',
  202. '[project.optional-dependencies]',
  203. 'cli = ["click"]',
  204. '',
  205. '[dependency-groups]',
  206. 'docs = ["sphinx>=7"]',
  207. '',
  208. '[tool.hatch.build.targets.wheel]',
  209. 'packages = ["src/deepseek_harness"]',
  210. '',
  211. '[tool.pytest.ini_options]',
  212. 'testpaths = ["tests"]',
  213. ].join('\n'))).toEqual(['hatchling', 'pydantic', 'click', 'sphinx'])
  214. })
  215. it('does not truncate an array at a bracket inside extras', () => {
  216. expect(parsePyprojectRequirements('[project]\ndependencies = ["httpx[http2]", "requests"]\n'))
  217. .toEqual(['httpx', 'requests'])
  218. })
  219. it('reads names whether or not requirements carry versions, extras, or markers', () => {
  220. expect(parsePyprojectRequirements("[project]\ndependencies = [\"pydantic>=2.12\", \"requests\", \"httpx[http2]\", \"tomli ; python_version < '3.11'\", \"hatchling >= 1.24.0\"]\n"))
  221. .toEqual(['pydantic', 'requests', 'httpx', 'tomli', 'hatchling'])
  222. })
  223. it('reads single-quoted TOML literals and rejects an unreadable requirement', () => {
  224. expect(parsePyprojectRequirements("[project]\ndependencies = ['requests', \"pydantic>=2\"]\n")).toEqual(['requests', 'pydantic'])
  225. expect(() => parsePyprojectRequirements('[project]\ndependencies = ["!!broken"]\n')).toThrow(/cannot read a distribution name/)
  226. })
  227. it('reads a multi-line array', () => {
  228. expect(parsePyprojectRequirements('[project]\ndependencies = [\n "pydantic>=2.12",\n "typing-extensions",\n]\n'))
  229. .toEqual(['pydantic', 'typing-extensions'])
  230. })
  231. it('obeys TOML comments, quoted keys, and escaped strings', () => {
  232. expect(parsePyprojectRequirements([
  233. '[project] # a legal header comment',
  234. 'dependencies = [',
  235. ' "pydantic", # ] does not close the array',
  236. ' # "old-package" is not a dependency',
  237. ' "tomli; python_version < \'3.11\'",',
  238. ']',
  239. '',
  240. '[dependency-groups]',
  241. '"test.docs" = ["pytest"]',
  242. ].join('\n'))).toEqual(['pydantic', 'tomli', 'pytest'])
  243. })
  244. it('accepts dependency-group includes and rejects unsupported requirement forms', () => {
  245. expect(parsePyprojectRequirements('[dependency-groups]\nbase = ["pytest"]\nall = [{ include-group = "base" }]\n'))
  246. .toEqual(['pytest'])
  247. expect(() => parsePyprojectRequirements('[project]\ndependencies = "pytest"\n')).toThrow(/must be an array/)
  248. expect(() => parsePyprojectRequirements('[dependency-groups]\ntest = [{ unknown = "pytest" }]\n')).toThrow(/unsupported requirement entry/)
  249. })
  250. })
  251. describe('collectPythonDependencies', () => {
  252. it('excludes normalized local project names without exempting a third-party prefix', () => {
  253. const pyprojects = [
  254. '[project]\nname = "deepseek-harness-runtime-bin"\ndependencies = ["pydantic"]\n',
  255. '[project]\nname = "deepseek-harness-sdk"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n',
  256. ]
  257. expect(() => collectPythonDependencies(pyprojects)).toThrow(
  258. 'python dependency deepseek-unrelated is missing from PYTHON_METADATA',
  259. )
  260. })
  261. })
  262. describe('isPermissive', () => {
  263. it('accepts the licenses this project ships and rejects copyleft or unknown ones', () => {
  264. expect(['MIT', 'ISC', 'BSD-3-Clause', 'Apache-2.0', 'MIT / Apache-2.0', '(MIT OR CC0-1.0)'].every(isPermissive)).toBe(true)
  265. expect([
  266. 'LGPL-3.0-only',
  267. 'MPL-2.0',
  268. 'GPL-3.0-or-later',
  269. 'SEE LICENSE IN LICENSE',
  270. 'SEE LICENSE IN README.md',
  271. 'SEE LICENSE IN LICENSE.md',
  272. ].some(isPermissive)).toBe(false)
  273. })
  274. it('requires every operand of an AND, so a copyleft conjunct cannot ride along', () => {
  275. expect(isPermissive('(MIT OR Apache-2.0) AND GPL-3.0-only')).toBe(false)
  276. expect(isPermissive('MIT AND ISC')).toBe(true)
  277. // An exception clause is not a recognized identifier, so it fails closed.
  278. expect(isPermissive('GPL-2.0-only WITH Classpath-exception-2.0')).toBe(false)
  279. })
  280. it('honors grouping and SPDX precedence', () => {
  281. expect(isPermissive('MIT OR (GPL-3.0-only AND GPL-2.0-only)')).toBe(true)
  282. expect(isPermissive('(MIT OR Apache-2.0) AND ISC')).toBe(true)
  283. })
  284. it('fails closed for malformed expressions, additions, and exceptions', () => {
  285. expect(['MIT)', '((MIT', '(MIT OR GPL-3.0-only', 'MIT OR OR GPL-3.0-only'].some(isPermissive)).toBe(false)
  286. expect(isPermissive('MIT+')).toBe(false)
  287. expect(isPermissive('GPL-2.0-only WITH Classpath-exception-2.0')).toBe(false)
  288. })
  289. })
  290. describe('official Claude distribution authorization', () => {
  291. it('authorizes only the direct SDK identity without relabeling its license', () => {
  292. expect(isOwnerAuthorizedRuntime(CLAUDE_AGENT_SDK_PACKAGE)).toBe(true)
  293. expect(isOwnerAuthorizedRuntime(`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`))
  294. .toBe(false)
  295. expect(isOwnerAuthorizedRuntime('@anthropic-ai/unrelated')).toBe(false)
  296. expect(isPermissive('SEE LICENSE IN README.md')).toBe(false)
  297. })
  298. it('derives version-independent platform payloads from the official SDK manifest', () => {
  299. expect(claudeDistributionFromManifest({
  300. name: CLAUDE_AGENT_SDK_PACKAGE,
  301. version: '9.8.7',
  302. license: 'future declared terms',
  303. claudeCodeVersion: '6.5.4',
  304. optionalDependencies: {
  305. [`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`]: '9.8.7',
  306. [`${CLAUDE_AGENT_SDK_PACKAGE}-darwin-arm64`]: '9.8.7',
  307. },
  308. })).toEqual({
  309. sdkVersion: '9.8.7',
  310. claudeCodeVersion: '6.5.4',
  311. payloads: [
  312. {
  313. name: `${CLAUDE_AGENT_SDK_PACKAGE}-darwin-arm64`,
  314. version: '9.8.7',
  315. },
  316. {
  317. name: `${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`,
  318. version: '9.8.7',
  319. },
  320. ],
  321. })
  322. })
  323. it('rejects a wrong SDK identity, missing payloads, and unrelated optionals', () => {
  324. expect(() => claudeDistributionFromManifest({
  325. name: '@anthropic-ai/unrelated',
  326. version: '1.0.0',
  327. claudeCodeVersion: '1.0.0',
  328. optionalDependencies: {
  329. [`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`]: '1.0.0',
  330. },
  331. })).toThrow(`expected ${CLAUDE_AGENT_SDK_PACKAGE} manifest`)
  332. expect(() => claudeDistributionFromManifest({
  333. name: CLAUDE_AGENT_SDK_PACKAGE,
  334. version: '1.0.0',
  335. claudeCodeVersion: '1.0.0',
  336. })).toThrow('declares no optional platform payloads')
  337. expect(() => claudeDistributionFromManifest({
  338. name: CLAUDE_AGENT_SDK_PACKAGE,
  339. version: '1.0.0',
  340. claudeCodeVersion: '1.0.0',
  341. optionalDependencies: {
  342. '@anthropic-ai/unrelated': '1.0.0',
  343. },
  344. })).toThrow('outside its authorized platform-payload identity')
  345. })
  346. })
  347. describe('manifestPatterns', () => {
  348. it('derives globs from the declared members, so a new member area is read', () => {
  349. expect(manifestPatterns(['packages/*/*', 'tools/*', 'native/system', 'native/system/packages/*'])).toEqual([
  350. 'package.json',
  351. 'packages/*/*/package.json',
  352. 'tools/*/package.json',
  353. 'native/system/package.json',
  354. 'native/system/packages/*/package.json',
  355. ])
  356. })
  357. })