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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  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('returns undefined when neither the prefix nor the content scan finds the package', () => {
  106. const root = mkdtempSync(join(tmpdir(), 'dsh-notices-miss-'))
  107. try {
  108. const store = join(root, 'store')
  109. const other = join(store, 'other-pkg@1.0.0', 'node_modules', 'other-pkg')
  110. mkdirSync(other, { recursive: true })
  111. writeFileSync(join(other, 'package.json'), JSON.stringify({ name: 'other-pkg', version: '1.0.0' }))
  112. expect(virtualManifest(store, '@scope/missing')).toBeUndefined()
  113. } finally {
  114. rmSync(root, { recursive: true, force: true })
  115. }
  116. })
  117. })
  118. describe('parseVendoredRows', () => {
  119. it('reads the committed vendor manifest table', () => {
  120. const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8'))
  121. expect(rows.length).toBeGreaterThan(0)
  122. expect(rows).toContainEqual({
  123. npmName: '@deepseek-ai/cordis',
  124. upstreamName: 'cordis',
  125. upstream: 'https://github.com/cordiverse/cordis',
  126. })
  127. // The upstream column carries a trailing package path for some rows; it is not part of the URL.
  128. expect(rows.every(row => /^https:\/\/\S+$/.test(row.upstream))).toBe(true)
  129. })
  130. it('yields nothing when the table columns change, so the generator fails loud', () => {
  131. expect(parseVendoredRows('| `cordis/` | `@deepseek-ai/cordis` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([])
  132. })
  133. it('covers every vendored directory, so no package can drop out of the notices', () => {
  134. const parsed = new Set(parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8')).map(row => row.npmName))
  135. const onDisk = readdirSync(resolve(root, 'vendor'), { withFileTypes: true })
  136. .filter(entry => entry.isDirectory())
  137. .map(entry => (JSON.parse(readFileSync(resolve(root, 'vendor', entry.name, 'package.json'), 'utf8')) as Manifest).name)
  138. expect([...onDisk].sort()).toEqual([...parsed].sort())
  139. })
  140. })
  141. describe('parsePyprojectRequirements', () => {
  142. it('reads the committed manifests', () => {
  143. expect(parsePyprojectRequirements(readFileSync(resolve(root, 'python/sdk/pyproject.toml'), 'utf8'))).toContain('pydantic')
  144. })
  145. it('locates requirement arrays by TOML table, so author-named groups are not missed', () => {
  146. expect(parsePyprojectRequirements([
  147. '[build-system]',
  148. 'requires = ["hatchling>=1.24.0"]',
  149. '',
  150. '[project]',
  151. 'name = "not-a-requirement"',
  152. 'dependencies = ["pydantic>=2.12"]',
  153. '',
  154. '[project.optional-dependencies]',
  155. 'cli = ["click"]',
  156. '',
  157. '[dependency-groups]',
  158. 'docs = ["sphinx>=7"]',
  159. '',
  160. '[tool.hatch.build.targets.wheel]',
  161. 'packages = ["src/deepseek_harness"]',
  162. '',
  163. '[tool.pytest.ini_options]',
  164. 'testpaths = ["tests"]',
  165. ].join('\n'))).toEqual(['hatchling', 'pydantic', 'click', 'sphinx'])
  166. })
  167. it('does not truncate an array at a bracket inside extras', () => {
  168. expect(parsePyprojectRequirements('[project]\ndependencies = ["httpx[http2]", "requests"]\n'))
  169. .toEqual(['httpx', 'requests'])
  170. })
  171. it('reads names whether or not requirements carry versions, extras, or markers', () => {
  172. expect(parsePyprojectRequirements("[project]\ndependencies = [\"pydantic>=2.12\", \"requests\", \"httpx[http2]\", \"tomli ; python_version < '3.11'\", \"hatchling >= 1.24.0\"]\n"))
  173. .toEqual(['pydantic', 'requests', 'httpx', 'tomli', 'hatchling'])
  174. })
  175. it('reads single-quoted TOML literals and rejects an unreadable requirement', () => {
  176. expect(parsePyprojectRequirements("[project]\ndependencies = ['requests', \"pydantic>=2\"]\n")).toEqual(['requests', 'pydantic'])
  177. expect(() => parsePyprojectRequirements('[project]\ndependencies = ["!!broken"]\n')).toThrow(/cannot read a distribution name/)
  178. })
  179. it('reads a multi-line array', () => {
  180. expect(parsePyprojectRequirements('[project]\ndependencies = [\n "pydantic>=2.12",\n "typing-extensions",\n]\n'))
  181. .toEqual(['pydantic', 'typing-extensions'])
  182. })
  183. it('obeys TOML comments, quoted keys, and escaped strings', () => {
  184. expect(parsePyprojectRequirements([
  185. '[project] # a legal header comment',
  186. 'dependencies = [',
  187. ' "pydantic", # ] does not close the array',
  188. ' # "old-package" is not a dependency',
  189. ' "tomli; python_version < \'3.11\'",',
  190. ']',
  191. '',
  192. '[dependency-groups]',
  193. '"test.docs" = ["pytest"]',
  194. ].join('\n'))).toEqual(['pydantic', 'tomli', 'pytest'])
  195. })
  196. it('accepts dependency-group includes and rejects unsupported requirement forms', () => {
  197. expect(parsePyprojectRequirements('[dependency-groups]\nbase = ["pytest"]\nall = [{ include-group = "base" }]\n'))
  198. .toEqual(['pytest'])
  199. expect(() => parsePyprojectRequirements('[project]\ndependencies = "pytest"\n')).toThrow(/must be an array/)
  200. expect(() => parsePyprojectRequirements('[dependency-groups]\ntest = [{ unknown = "pytest" }]\n')).toThrow(/unsupported requirement entry/)
  201. })
  202. })
  203. describe('collectPythonDependencies', () => {
  204. it('excludes normalized local project names without exempting a third-party prefix', () => {
  205. const pyprojects = [
  206. '[project]\nname = "deepseek-harness-runtime-bin"\ndependencies = ["pydantic"]\n',
  207. '[project]\nname = "deepseek-harness-sdk"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n',
  208. ]
  209. expect(() => collectPythonDependencies(pyprojects)).toThrow(
  210. 'python dependency deepseek-unrelated is missing from PYTHON_METADATA',
  211. )
  212. })
  213. })
  214. describe('isPermissive', () => {
  215. it('accepts the licenses this project ships and rejects copyleft or unknown ones', () => {
  216. expect(['MIT', 'ISC', 'BSD-3-Clause', 'Apache-2.0', 'MIT / Apache-2.0', '(MIT OR CC0-1.0)'].every(isPermissive)).toBe(true)
  217. expect([
  218. 'LGPL-3.0-only',
  219. 'MPL-2.0',
  220. 'GPL-3.0-or-later',
  221. 'SEE LICENSE IN LICENSE',
  222. 'SEE LICENSE IN README.md',
  223. 'SEE LICENSE IN LICENSE.md',
  224. ].some(isPermissive)).toBe(false)
  225. })
  226. it('requires every operand of an AND, so a copyleft conjunct cannot ride along', () => {
  227. expect(isPermissive('(MIT OR Apache-2.0) AND GPL-3.0-only')).toBe(false)
  228. expect(isPermissive('MIT AND ISC')).toBe(true)
  229. // An exception clause is not a recognized identifier, so it fails closed.
  230. expect(isPermissive('GPL-2.0-only WITH Classpath-exception-2.0')).toBe(false)
  231. })
  232. it('honors grouping and SPDX precedence', () => {
  233. expect(isPermissive('MIT OR (GPL-3.0-only AND GPL-2.0-only)')).toBe(true)
  234. expect(isPermissive('(MIT OR Apache-2.0) AND ISC')).toBe(true)
  235. })
  236. it('fails closed for malformed expressions, additions, and exceptions', () => {
  237. expect(['MIT)', '((MIT', '(MIT OR GPL-3.0-only', 'MIT OR OR GPL-3.0-only'].some(isPermissive)).toBe(false)
  238. expect(isPermissive('MIT+')).toBe(false)
  239. expect(isPermissive('GPL-2.0-only WITH Classpath-exception-2.0')).toBe(false)
  240. })
  241. })
  242. describe('official Claude distribution authorization', () => {
  243. it('authorizes only the direct SDK identity without relabeling its license', () => {
  244. expect(isOwnerAuthorizedRuntime(CLAUDE_AGENT_SDK_PACKAGE)).toBe(true)
  245. expect(isOwnerAuthorizedRuntime(`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`))
  246. .toBe(false)
  247. expect(isOwnerAuthorizedRuntime('@anthropic-ai/unrelated')).toBe(false)
  248. expect(isPermissive('SEE LICENSE IN README.md')).toBe(false)
  249. })
  250. it('derives version-independent platform payloads from the official SDK manifest', () => {
  251. expect(claudeDistributionFromManifest({
  252. name: CLAUDE_AGENT_SDK_PACKAGE,
  253. version: '9.8.7',
  254. license: 'future declared terms',
  255. claudeCodeVersion: '6.5.4',
  256. optionalDependencies: {
  257. [`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`]: '9.8.7',
  258. [`${CLAUDE_AGENT_SDK_PACKAGE}-darwin-arm64`]: '9.8.7',
  259. },
  260. })).toEqual({
  261. sdkVersion: '9.8.7',
  262. claudeCodeVersion: '6.5.4',
  263. payloads: [
  264. {
  265. name: `${CLAUDE_AGENT_SDK_PACKAGE}-darwin-arm64`,
  266. version: '9.8.7',
  267. },
  268. {
  269. name: `${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`,
  270. version: '9.8.7',
  271. },
  272. ],
  273. })
  274. })
  275. it('rejects a wrong SDK identity, missing payloads, and unrelated optionals', () => {
  276. expect(() => claudeDistributionFromManifest({
  277. name: '@anthropic-ai/unrelated',
  278. version: '1.0.0',
  279. claudeCodeVersion: '1.0.0',
  280. optionalDependencies: {
  281. [`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`]: '1.0.0',
  282. },
  283. })).toThrow(`expected ${CLAUDE_AGENT_SDK_PACKAGE} manifest`)
  284. expect(() => claudeDistributionFromManifest({
  285. name: CLAUDE_AGENT_SDK_PACKAGE,
  286. version: '1.0.0',
  287. claudeCodeVersion: '1.0.0',
  288. })).toThrow('declares no optional platform payloads')
  289. expect(() => claudeDistributionFromManifest({
  290. name: CLAUDE_AGENT_SDK_PACKAGE,
  291. version: '1.0.0',
  292. claudeCodeVersion: '1.0.0',
  293. optionalDependencies: {
  294. '@anthropic-ai/unrelated': '1.0.0',
  295. },
  296. })).toThrow('outside its authorized platform-payload identity')
  297. })
  298. })
  299. describe('manifestPatterns', () => {
  300. it('derives globs from the declared members, so a new member area is read', () => {
  301. expect(manifestPatterns(['packages/*/*', 'tools/*', 'native/landlock-run', 'native/landlock-run/packages/*'])).toEqual([
  302. 'package.json',
  303. 'packages/*/*/package.json',
  304. 'tools/*/package.json',
  305. 'native/landlock-run/package.json',
  306. 'native/landlock-run/packages/*/package.json',
  307. ])
  308. })
  309. })