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

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