gen-third-party-notices.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782
  1. /**
  2. * Generate `THIRD_PARTY_NOTICES.md` from the workspace manifests: every
  3. * external dependency named by a workspace `package.json`, the vendored-package
  4. * manifest in `vendor/README.md`, the Python `pyproject.toml` files, and the
  5. * pnpm patch list. License and repository metadata come from the installed
  6. * store, so the tree must be installed. `--check` verifies the committed
  7. * artifact. Tier policy and ownership live in
  8. * `.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md`.
  9. */
  10. import { existsSync, globSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
  11. import { dirname, resolve } from 'node:path'
  12. import * as yaml from 'js-yaml'
  13. import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml'
  14. import parseSpdx from 'spdx-expression-parse'
  15. const root = resolve(import.meta.dirname, '..')
  16. const OUT = 'THIRD_PARTY_NOTICES.md'
  17. /** Dependency-declaration kinds a consumer resolves at runtime. */
  18. const RUNTIME_KINDS = ['dependencies', 'optionalDependencies'] as const
  19. /** All manifest sections that name an external package this file must disclose. */
  20. const ALL_KINDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const
  21. /**
  22. * Workspace areas that never reach a user: repository tooling and gates (the
  23. * root manifest), test infrastructure, the documentation site, and the native
  24. * launcher's build workspace. A runtime
  25. * declaration by anything outside these areas is a disclosure-relevant
  26. * runtime dependency because any plugin package can be mounted from a user's
  27. * `cordis.yml`.
  28. */
  29. const DEV_ONLY_AREAS = [
  30. 'package.json',
  31. 'packages/test-support/',
  32. 'packages/test-support/client-runtime/',
  33. 'website/',
  34. 'native/',
  35. ] as const
  36. /** First-party public native packages: reachable at runtime but not third-party. */
  37. const FIRST_PARTY = new Set([
  38. '@deepseek-ai/node-addon-landlock-run',
  39. '@deepseek-ai/node-addon-landlock-run-linux-arm64',
  40. '@deepseek-ai/node-addon-landlock-run-linux-x64',
  41. ])
  42. /** Official SDK identity covered by the project's narrow owner authorization. */
  43. export const CLAUDE_AGENT_SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk'
  44. const CLAUDE_PLATFORM_PACKAGE_PREFIX = `${CLAUDE_AGENT_SDK_PACKAGE}-`
  45. const CLAUDE_PLATFORM_DECLARED_LICENSE = 'SEE LICENSE IN LICENSE.md'
  46. /**
  47. * Whether a non-permissive runtime declaration has an identity-scoped owner
  48. * authorization. This does not reclassify its terms as permissive.
  49. * @param name - exact npm package identity.
  50. * @returns true only for the official Claude Agent SDK package.
  51. */
  52. export function isOwnerAuthorizedRuntime(name: string): boolean {
  53. return name === CLAUDE_AGENT_SDK_PACKAGE
  54. }
  55. /**
  56. * Metadata overrides where the installed manifest is wrong or unreachable.
  57. * Each entry documents why the store cannot answer.
  58. */
  59. const OVERRIDES: Record<string, { license?: string; repo?: string }> = {
  60. // Rust workspaces publishing npm bins without `license` in package.json.
  61. 'oxlint': { license: 'MIT', repo: 'https://github.com/oxc-project/oxc' },
  62. 'oxlint-tsgolint': { license: 'MIT', repo: 'https://github.com/oxc-project/tsgolint' },
  63. // `license: SEE LICENSE IN LICENSE`: the servers repo is mid MIT→Apache-2.0
  64. // relicensing, so the effective terms are per-contribution.
  65. '@modelcontextprotocol/server-everything': { license: 'MIT / Apache-2.0', repo: 'https://github.com/modelcontextprotocol/servers' },
  66. '@modelcontextprotocol/server-filesystem': { license: 'MIT / Apache-2.0', repo: 'https://github.com/modelcontextprotocol/servers' },
  67. // No repository field in the published manifest.
  68. 'node-addon-require-builtin': { repo: 'https://www.npmjs.com/package/node-addon-require-builtin' },
  69. // No `license` field in the published manifest; the tarball's LICENSE.txt is the MIT text.
  70. 'fs-ext': { license: 'MIT' },
  71. }
  72. /**
  73. * Python dependencies are few and named directly in `pyproject.toml` files
  74. * without installed metadata to harvest, so license/repo are recorded here and
  75. * the generator fails when a manifest names a package this map misses.
  76. */
  77. const PYTHON_METADATA: Record<string, { license: string; repo: string; role: string }> = {
  78. pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness-sdk`' },
  79. hatchling: { license: 'MIT', repo: 'https://github.com/pypa/hatch', role: 'build backend' },
  80. pytest: { license: 'MIT', repo: 'https://github.com/pytest-dev/pytest', role: 'test-only' },
  81. }
  82. type PythonMetadata = typeof PYTHON_METADATA
  83. /** The `package.json` fields this generator reads. */
  84. export interface Manifest {
  85. name?: string
  86. version?: string
  87. private?: boolean
  88. license?: string
  89. dependencies?: Record<string, string>
  90. devDependencies?: Record<string, string>
  91. optionalDependencies?: Record<string, string>
  92. peerDependencies?: Record<string, string>
  93. }
  94. /** One disclosed external npm dependency. */
  95. interface ExternalDep {
  96. name: string
  97. license: string
  98. repo: string
  99. /** True when some shipped workspace consumer reaches it through runtime dependency edges. */
  100. runtime: boolean
  101. }
  102. /** Read and parse a workspace-relative `package.json`. */
  103. function readManifest(rel: string): Manifest {
  104. return JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as Manifest
  105. }
  106. /**
  107. * Manifest globs, derived from the workspace declarations rather than listed
  108. * here, so a new member area (`tools/*`) is read the day it is declared.
  109. * @returns one glob per manifest-bearing location, repository-relative.
  110. */
  111. export function manifestPatterns(rootMembers: readonly string[]): string[] {
  112. return [
  113. 'package.json',
  114. ...rootMembers.map(member => `${member}/package.json`),
  115. ]
  116. }
  117. /** The `packages:` member globs declared by one pnpm workspace file. */
  118. function workspaceMembers(rel: string): string[] {
  119. const declared = (yaml.load(readFileSync(resolve(root, rel), 'utf8')) as { packages?: unknown }).packages
  120. if (!Array.isArray(declared) || declared.length === 0) {
  121. throw new Error(`gen-third-party-notices: ${rel} declares no workspace members; the manifest set cannot be derived.`)
  122. }
  123. return declared.map(member => String(member))
  124. }
  125. /**
  126. * Every workspace manifest, keyed by repository-relative path, plus the set of
  127. * workspace package names. Paths are normalized to `/` at ingestion: Node's
  128. * `fs.globSync` returns OS-native separators, and the area matching in
  129. * `tierExternalDeps` compares `/`-suffixed prefixes, so Windows backslashes
  130. * would silently push dev-area manifests into the runtime tier.
  131. */
  132. function loadWorkspaceManifests(): { manifests: Map<string, Manifest>; names: Set<string> } {
  133. const patterns = manifestPatterns(workspaceMembers('pnpm-workspace.yaml'))
  134. const manifests = new Map<string, Manifest>()
  135. const names = new Set<string>()
  136. for (const pattern of patterns) {
  137. for (const path of globSync(pattern, { cwd: root })) {
  138. const normalized = path.replaceAll('\\', '/')
  139. const manifest = readManifest(normalized)
  140. manifests.set(normalized, manifest)
  141. if (manifest.name !== undefined) names.add(manifest.name)
  142. }
  143. }
  144. if (manifests.size < 100) throw new Error(`gen-third-party-notices: only ${manifests.size} workspace manifests found; the glob set is stale.`)
  145. return { manifests, names }
  146. }
  147. type VirtualManifest = Manifest & {
  148. claudeCodeVersion?: string
  149. license?: string
  150. repository?: string | { url?: string }
  151. homepage?: string
  152. }
  153. /** One platform payload declared by the official Claude Agent SDK. */
  154. export interface ClaudePlatformPayload {
  155. readonly name: string
  156. readonly version: string
  157. }
  158. /** Current SDK and CLI distribution facts derived from the installed SDK manifest. */
  159. export interface ClaudeDistribution {
  160. readonly sdkVersion: string
  161. readonly claudeCodeVersion: string
  162. readonly payloads: ClaudePlatformPayload[]
  163. }
  164. function requiredManifestString(
  165. value: string | undefined,
  166. field: string,
  167. ): string {
  168. if (value === undefined || value.length === 0) {
  169. throw new Error(`gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} has no ${field}.`)
  170. }
  171. return value
  172. }
  173. /**
  174. * Derive the official platform payload set without a version or platform
  175. * allowlist. Only identities in the SDK's own package namespace are covered.
  176. * @param manifest - installed official SDK manifest.
  177. * @returns current SDK, CLI, and optional platform payload facts.
  178. */
  179. export function claudeDistributionFromManifest(
  180. manifest: VirtualManifest,
  181. ): ClaudeDistribution {
  182. if (manifest.name !== CLAUDE_AGENT_SDK_PACKAGE) {
  183. throw new Error(
  184. `gen-third-party-notices: expected ${CLAUDE_AGENT_SDK_PACKAGE} manifest, got ${JSON.stringify(manifest.name)}.`,
  185. )
  186. }
  187. const sdkVersion = requiredManifestString(manifest.version, 'version')
  188. const claudeCodeVersion = requiredManifestString(
  189. manifest.claudeCodeVersion,
  190. 'claudeCodeVersion',
  191. )
  192. const entries = Object.entries(manifest.optionalDependencies ?? {})
  193. if (entries.length === 0) {
  194. throw new Error(
  195. `gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} declares no optional platform payloads.`,
  196. )
  197. }
  198. const payloads = entries.map(([name, version]) => {
  199. if (!name.startsWith(CLAUDE_PLATFORM_PACKAGE_PREFIX)) {
  200. throw new Error(
  201. `gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} optional dependency ${name} is outside its authorized platform-payload identity.`,
  202. )
  203. }
  204. return {
  205. name,
  206. version: requiredManifestString(version, `${name} optional dependency version`),
  207. }
  208. }).sort((left, right) => left.name.localeCompare(right.name))
  209. return { sdkVersion, claudeCodeVersion, payloads }
  210. }
  211. /**
  212. * Resolve one package's manifest inside a pnpm virtual store. The prefix scan
  213. * matches ordinary `@scope+name@version` directory names; pnpm 11 truncates
  214. * long names (a peer-suffixed name past the length limit becomes
  215. * `<prefix>_<hash>`), so a content scan falls back over the whole store when
  216. * the prefix misses.
  217. *
  218. * @param virtual - the `.pnpm` virtual store directory to scan.
  219. * @param name - the external package name, exactly as `node_modules` spells it.
  220. * @param expectedVersion - exact version required when the store retains more than one.
  221. * @returns the parsed manifest, or `undefined` when neither the prefix match
  222. * nor the content scan finds the requested package version.
  223. */
  224. export function virtualManifest(
  225. virtual: string,
  226. name: string,
  227. expectedVersion?: string,
  228. ): VirtualManifest | undefined {
  229. const prefix = `${name.replace('/', '+')}@`
  230. const entries = readdirSync(virtual)
  231. for (const entry of entries.filter(dir => dir.startsWith(prefix))) {
  232. const manifest = JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as VirtualManifest
  233. if (expectedVersion === undefined || manifest.version === expectedVersion) return manifest
  234. }
  235. for (const dir of entries) {
  236. const candidate = resolve(virtual, dir, 'node_modules', name, 'package.json')
  237. if (existsSync(candidate)) {
  238. const manifest = JSON.parse(readFileSync(candidate, 'utf8')) as VirtualManifest
  239. if (expectedVersion === undefined || manifest.version === expectedVersion) return manifest
  240. }
  241. }
  242. return undefined
  243. }
  244. const workspaceLinkedManifestCache = new Map<string, VirtualManifest | undefined>()
  245. /**
  246. * Resolve the package version selected for a declaring workspace instead of an
  247. * unrelated historical version that still occupies the shared virtual store.
  248. * @param name - external package identity.
  249. * @param manifests - workspace manifests already loaded by the caller, so one
  250. * load serves every dependency instead of a full re-read per name.
  251. * @returns the first current workspace link for that package, when installed.
  252. */
  253. function workspaceLinkedManifest(name: string, manifests: Map<string, Manifest>): VirtualManifest | undefined {
  254. if (workspaceLinkedManifestCache.has(name)) return workspaceLinkedManifestCache.get(name)
  255. for (const [path, manifest] of manifests) {
  256. if (!ALL_KINDS.some(kind => name in (manifest[kind] ?? {}))) continue
  257. const linked = resolve(root, dirname(path), 'node_modules', name, 'package.json')
  258. if (!existsSync(linked)) continue
  259. const found = JSON.parse(readFileSync(linked, 'utf8')) as VirtualManifest
  260. workspaceLinkedManifestCache.set(name, found)
  261. return found
  262. }
  263. workspaceLinkedManifestCache.set(name, undefined)
  264. return undefined
  265. }
  266. /** Resolve one installed external package manifest from either pnpm store. */
  267. function installedManifest(name: string, manifests: Map<string, Manifest>, expectedVersion?: string): VirtualManifest | undefined {
  268. const linked = workspaceLinkedManifest(name, manifests)
  269. if (linked !== undefined && (expectedVersion === undefined || linked.version === expectedVersion)) return linked
  270. let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined
  271. // Workspace-local link farms can expose a dependency that is not linked at
  272. // the repository root; both are backed by the root workspace's lockfile.
  273. for (const store of ['node_modules', 'native/landlock-run/node_modules']) {
  274. const direct = resolve(root, store, name, 'package.json')
  275. if (existsSync(direct)) {
  276. const candidate = JSON.parse(readFileSync(direct, 'utf8')) as typeof manifest
  277. if (expectedVersion === undefined || candidate?.version === expectedVersion) {
  278. manifest = candidate
  279. break
  280. }
  281. }
  282. const virtual = resolve(root, store, '.pnpm')
  283. if (!existsSync(virtual)) continue
  284. manifest = virtualManifest(virtual, name, expectedVersion)
  285. if (manifest !== undefined) break
  286. }
  287. return manifest
  288. }
  289. /** License and repository URL for an installed external package, from the pnpm store. */
  290. function installedMetadata(name: string, manifests: Map<string, Manifest>): { license: string; repo: string } {
  291. const override = OVERRIDES[name]
  292. const manifest = installedManifest(name, manifests)
  293. const license = override?.license ?? manifest?.license
  294. const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage
  295. const repo = override?.repo ?? normalizeRepo(rawRepo)
  296. if (license === undefined || repo === undefined) {
  297. throw new Error(`gen-third-party-notices: cannot resolve ${license === undefined ? 'license' : 'repository'} for ${name}; run \`pnpm install\`, or add an OVERRIDES entry.`)
  298. }
  299. return { license, repo }
  300. }
  301. function collectClaudeDistribution(manifests: Map<string, Manifest>): ClaudeDistribution {
  302. const manifest = installedManifest(CLAUDE_AGENT_SDK_PACKAGE, manifests)
  303. if (manifest === undefined) {
  304. throw new Error(
  305. `gen-third-party-notices: cannot resolve ${CLAUDE_AGENT_SDK_PACKAGE}; run \`pnpm install\`.`,
  306. )
  307. }
  308. const distribution = claudeDistributionFromManifest(manifest)
  309. let installedPayloads = 0
  310. for (const payload of distribution.payloads) {
  311. const installed = installedManifest(payload.name, manifests, payload.version)
  312. if (installed === undefined) continue
  313. installedPayloads += 1
  314. if (
  315. installed.name !== payload.name
  316. || installed.version !== payload.version
  317. || installed.license !== CLAUDE_PLATFORM_DECLARED_LICENSE
  318. ) {
  319. throw new Error(
  320. `gen-third-party-notices: installed ${payload.name} does not match its SDK-declared version and ${CLAUDE_PLATFORM_DECLARED_LICENSE} license field.`,
  321. )
  322. }
  323. }
  324. if (installedPayloads === 0) {
  325. throw new Error(
  326. 'gen-third-party-notices: no SDK-declared Claude platform payload is installed; install optional dependencies before regenerating.',
  327. )
  328. }
  329. return distribution
  330. }
  331. /** Normalize a manifest repository/homepage value to a browsable https URL. */
  332. function normalizeRepo(raw: string | undefined): string | undefined {
  333. if (raw === undefined || raw === '') return undefined
  334. let url = raw
  335. .replace(/^git\+ssh:\/\/git@/, 'https://')
  336. .replace(/^git\+/, '')
  337. .replace(/^git:\/\//, 'https://')
  338. .replace(/^github:/, 'https://github.com/')
  339. .replace(/\.git$/, '')
  340. if (!url.startsWith('http')) url = `https://github.com/${url}`
  341. return url
  342. }
  343. /**
  344. * External npm dependencies, tiered by which workspace area declares them at
  345. * runtime: a package is runtime when any manifest outside `DEV_ONLY_AREAS`
  346. * names it in `dependencies`/`optionalDependencies`. A package declared only
  347. * by tooling, test infrastructure, the website, or the demo leaves — whatever
  348. * the declaring section is called — is development-only.
  349. */
  350. function collectNpmDeps(manifests: Map<string, Manifest>, names: Set<string>): ExternalDep[] {
  351. return [...tierExternalDeps(manifests, names)]
  352. .filter(([name]) => !FIRST_PARTY.has(name))
  353. .sort(([a], [b]) => a.localeCompare(b))
  354. .map(([name, runtime]) => ({ name, ...installedMetadata(name, manifests), runtime }))
  355. }
  356. /**
  357. * Tier every external dependency the workspace declares.
  358. * @param manifests - workspace manifests keyed by repository-relative path.
  359. * @param names - every workspace package name, which never counts as external.
  360. * @returns each external package mapped to whether it is a runtime dependency.
  361. */
  362. export function tierExternalDeps(manifests: Map<string, Manifest>, names: Set<string>): Map<string, boolean> {
  363. const tiers = new Map<string, boolean>()
  364. // `tsx` is runtime by fiat: the root source-run scripts execute through its ESM hook.
  365. tiers.set('tsx', true)
  366. for (const [path, manifest] of manifests) {
  367. const devOnly = DEV_ONLY_AREAS.some(area => (area.endsWith('/') ? path.startsWith(area) : path === area))
  368. for (const kind of ALL_KINDS) {
  369. for (const [dep, range] of Object.entries(manifest[kind] ?? {})) {
  370. if (names.has(dep) || range.startsWith('workspace:')) continue
  371. const runtime = !devOnly && (RUNTIME_KINDS as readonly string[]).includes(kind)
  372. tiers.set(dep, (tiers.get(dep) ?? false) || runtime)
  373. }
  374. }
  375. }
  376. return tiers
  377. }
  378. /** A vendored package row parsed out of the `vendor/README.md` manifest table. */
  379. export interface VendoredRow {
  380. npmName: string
  381. /** The name this package carries upstream; MIT attribution names the fork's origin, not our scope. */
  382. upstreamName: string
  383. upstream: string
  384. }
  385. /**
  386. * Parse the vendored-package manifest table out of `vendor/README.md`.
  387. * @param text - the complete `vendor/README.md` contents.
  388. * @returns one row per manifest-table entry, in table order.
  389. */
  390. export function parseVendoredRows(text: string): VendoredRow[] {
  391. const rows: VendoredRow[] = []
  392. for (const line of text.split('\n')) {
  393. const match = new RegExp(String.raw`^\| \x60\S+\/\x60 \| \x60([^\x60]+)\x60 \| \x60([^\x60]+)\x60 \| \S+ \| `
  394. + String.raw`(https:\/\/\S+?)(?: \([^)]*\))? \| \x60[0-9a-f]+\x60 \|$`).exec(line)
  395. if (match === null) continue
  396. const [, npmName, upstreamName, upstream] = match
  397. if (npmName === undefined || upstreamName === undefined || upstream === undefined) continue
  398. rows.push({ npmName, upstreamName, upstream })
  399. }
  400. return rows
  401. }
  402. /**
  403. * Parse the vendored manifest table and confirm it accounts for every vendored
  404. * directory. The `vendor/` tree — not the table — is the set that must be
  405. * disclosed, so a row that stops matching the table format is a hard error
  406. * rather than a package that quietly vanishes from the notices.
  407. */
  408. function collectVendored(): VendoredRow[] {
  409. const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8'))
  410. const onDisk = new Map<string, string>()
  411. for (const entry of readdirSync(resolve(root, 'vendor'), { withFileTypes: true })) {
  412. if (!entry.isDirectory()) continue
  413. const manifest = readManifest(`vendor/${entry.name}/package.json`)
  414. if (manifest.name !== undefined) onDisk.set(manifest.name, entry.name)
  415. }
  416. const parsed = new Set(rows.map(row => row.npmName))
  417. const missing = [...onDisk.keys()].filter(name => !parsed.has(name))
  418. if (missing.length > 0) {
  419. throw new Error(`gen-third-party-notices: vendor/README.md has no manifest-table row for ${missing.join(', ')}; its table format changed or the sync is incomplete.`)
  420. }
  421. for (const row of rows) {
  422. const dir = onDisk.get(row.npmName)
  423. if (dir === undefined) throw new Error(`gen-third-party-notices: vendored package ${row.npmName} from vendor/README.md has no vendor/ directory.`)
  424. const license = readManifest(`vendor/${dir}/package.json`).license
  425. if (license !== 'MIT') {
  426. throw new Error(`gen-third-party-notices: vendored ${row.npmName} declares license ${JSON.stringify(license)}; the vendored section assumes MIT throughout.`)
  427. }
  428. }
  429. return rows
  430. }
  431. /** Whether a parsed TOML value is a table rather than an array or scalar. */
  432. function isTomlTable(value: TomlValueWithoutBigInt | undefined): value is TomlTableWithoutBigInt {
  433. return value !== undefined && typeof value === 'object' && !Array.isArray(value)
  434. }
  435. /** Parse one PEP 508 requirement string into its distribution name. */
  436. function parsePythonRequirement(requirement: string): string {
  437. const name = /^\s*([a-zA-Z][a-zA-Z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(?:[<>=!~;@].*)?$/.exec(requirement)?.[1]
  438. if (name === undefined) {
  439. throw new Error(`gen-third-party-notices: cannot read a distribution name from the requirement ${JSON.stringify(requirement)}.`)
  440. }
  441. return name
  442. }
  443. /** Add the string requirements from one parsed TOML array. */
  444. function collectPythonRequirementArray(
  445. names: string[],
  446. value: TomlValueWithoutBigInt | undefined,
  447. location: string,
  448. allowGroupIncludes = false,
  449. ): void {
  450. if (value === undefined) return
  451. if (!Array.isArray(value)) {
  452. throw new Error(`gen-third-party-notices: ${location} must be an array.`)
  453. }
  454. for (const item of value) {
  455. if (typeof item === 'string') {
  456. names.push(parsePythonRequirement(item))
  457. continue
  458. }
  459. if (allowGroupIncludes && isTomlTable(item) && typeof item['include-group'] === 'string' && Object.keys(item).length === 1) {
  460. continue
  461. }
  462. throw new Error(`gen-third-party-notices: ${location} contains an unsupported requirement entry.`)
  463. }
  464. }
  465. /** Read an optional TOML table and reject a present non-table value. */
  466. function optionalTomlTable(value: TomlValueWithoutBigInt | undefined, location: string): TomlTableWithoutBigInt | undefined {
  467. if (value === undefined || isTomlTable(value)) return value
  468. throw new Error(`gen-third-party-notices: ${location} must be a table.`)
  469. }
  470. /**
  471. * Parse a `pyproject.toml` project identity and every requirement it declares:
  472. * `requires` under
  473. * `[build-system]`, `dependencies` under `[project]`, and every key under
  474. * `[project.optional-dependencies]` and `[dependency-groups]`. A TOML parser
  475. * owns comments, quoted keys, escapes, and array boundaries; unsupported
  476. * requirement forms fail instead of disappearing from the notices.
  477. * @param text - the complete `pyproject.toml` contents.
  478. * @returns the local project name and declared requirement names.
  479. */
  480. function parsePyproject(text: string): { projectName?: string; requirements: string[] } {
  481. const names: string[] = []
  482. const document = parseToml(text, { integersAsBigInt: false })
  483. const buildSystem = optionalTomlTable(document['build-system'], '[build-system]')
  484. const project = optionalTomlTable(document.project, '[project]')
  485. const projectName = project?.name
  486. if (projectName !== undefined && typeof projectName !== 'string') {
  487. throw new Error('gen-third-party-notices: [project].name must be a string.')
  488. }
  489. collectPythonRequirementArray(names, buildSystem?.requires, '[build-system].requires')
  490. collectPythonRequirementArray(names, project?.dependencies, '[project].dependencies')
  491. const optional = optionalTomlTable(project?.['optional-dependencies'], '[project.optional-dependencies]')
  492. for (const [group, requirements] of Object.entries(optional ?? {})) {
  493. collectPythonRequirementArray(names, requirements, `[project.optional-dependencies].${group}`)
  494. }
  495. const groups = optionalTomlTable(document['dependency-groups'], '[dependency-groups]')
  496. for (const [group, requirements] of Object.entries(groups ?? {})) {
  497. collectPythonRequirementArray(names, requirements, `[dependency-groups].${group}`, true)
  498. }
  499. return projectName === undefined
  500. ? { requirements: names }
  501. : { projectName, requirements: names }
  502. }
  503. /**
  504. * Read every requirement name declared by one `pyproject.toml`.
  505. * @param text - the complete `pyproject.toml` contents.
  506. * @returns each declared requirement's distribution name, in file order.
  507. */
  508. export function parsePyprojectRequirements(text: string): string[] {
  509. return parsePyproject(text).requirements
  510. }
  511. /** Normalize a Python distribution name according to the packaging name rule. */
  512. function normalizePythonDistributionName(name: string): string {
  513. return name.toLowerCase().replace(/[-_.]+/g, '-')
  514. }
  515. /**
  516. * Resolve external Python dependencies after excluding local project names.
  517. * @param pyprojects - complete local `pyproject.toml` contents.
  518. * @param metadata - disclosure metadata for every external dependency.
  519. * @returns disclosed dependencies in normalized name order.
  520. */
  521. export function collectPythonDependencies(
  522. pyprojects: string[],
  523. metadata: PythonMetadata = PYTHON_METADATA,
  524. ): { name: string; license: string; repo: string; role: string }[] {
  525. const parsed = pyprojects.map(parsePyproject)
  526. const firstParty = new Set(parsed.flatMap(({ projectName }) => (
  527. projectName === undefined ? [] : [normalizePythonDistributionName(projectName)]
  528. )))
  529. const found = new Set(parsed
  530. .flatMap(({ requirements }) => requirements.map(normalizePythonDistributionName))
  531. .filter(name => !firstParty.has(name)))
  532. return [...found].sort((a, b) => a.localeCompare(b)).map((name) => {
  533. const entry = metadata[name]
  534. if (entry === undefined) throw new Error(`gen-third-party-notices: python dependency ${name} is missing from PYTHON_METADATA.`)
  535. return { name, ...entry }
  536. })
  537. }
  538. /** Direct Python dependencies named by the `pyproject.toml` manifests under `python/`. */
  539. function collectPython(): { name: string; license: string; repo: string; role: string }[] {
  540. const manifests = globSync('python/*/pyproject.toml', { cwd: root })
  541. if (manifests.length === 0) throw new Error('gen-third-party-notices: no python/*/pyproject.toml found; the Python tree moved.')
  542. return collectPythonDependencies(manifests.map(path => readFileSync(resolve(root, path), 'utf8')))
  543. }
  544. /** pnpm-patched external packages, from `pnpm-workspace.yaml`. */
  545. function collectPatched(): { spec: string; patch: string }[] {
  546. const workspace = yaml.load(readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8')) as { patchedDependencies?: Record<string, string> }
  547. return Object.entries(workspace.patchedDependencies ?? {}).map(([spec, patch]) => ({ spec, patch }))
  548. }
  549. /** SPDX identifiers this project may ship without further review. */
  550. const PERMISSIVE_LICENSES = new Set(['MIT', 'ISC', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', '0BSD', 'Unlicense', 'CC0-1.0', 'BlueOak-1.0.0', 'Python-2.0'])
  551. /** Evaluate a parsed SPDX expression under the repository's license policy. */
  552. function isPermissiveSpdx(expression: ReturnType<typeof parseSpdx>): boolean {
  553. if ('conjunction' in expression) {
  554. return expression.conjunction === 'and'
  555. ? isPermissiveSpdx(expression.left) && isPermissiveSpdx(expression.right)
  556. : isPermissiveSpdx(expression.left) || isPermissiveSpdx(expression.right)
  557. }
  558. return expression.plus !== true
  559. && expression.exception === undefined
  560. && PERMISSIVE_LICENSES.has(expression.license)
  561. }
  562. /**
  563. * Whether an SPDX expression grants terms this project may ship under.
  564. * `OR` needs one permissive alternative, because the consumer chooses; `AND`
  565. * needs all of them, because every obligation applies. Anything that is not a
  566. * recognized permissive identifier — copyleft, an exception clause, or a
  567. * license this list has never seen — evaluates to false, so an unfamiliar
  568. * expression fails closed rather than passing on a partial match.
  569. * @param license - the SPDX expression from the package manifest.
  570. * @returns true when the expression's obligations are all permissive.
  571. */
  572. export function isPermissive(license: string): boolean {
  573. // Some npm manifests use a slash for a choice despite SPDX requiring `OR`.
  574. const normalized = license.replace(/\s*\/\s*/g, ' OR ').trim()
  575. try {
  576. return isPermissiveSpdx(parseSpdx(normalized))
  577. } catch {
  578. return false
  579. }
  580. }
  581. /**
  582. * Render the sentence that isolates non-permissive development tooling, or
  583. * nothing at all when every development dependency is permissive.
  584. * @param deps - development dependencies whose license is not permissive.
  585. * @returns the paragraph to place after the development table.
  586. */
  587. function renderNonPermissiveNote(deps: ExternalDep[]): string {
  588. if (deps.length === 0) return ''
  589. const named = deps.map(dep => `\`${dep.name}\` (${dep.license})`)
  590. const subject = named.length === 1 ? named[0] : `${named.slice(0, -1).join(', ')} and ${named.at(-1)}`
  591. return `\n${subject} ${named.length === 1 ? 'runs' : 'run'} only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact.\n`
  592. }
  593. /** Render one npm dependency table. */
  594. function renderNpmTable(deps: ExternalDep[]): string {
  595. const lines = ['| Package | License |', '| --- | --- |']
  596. for (const dep of deps) lines.push(`| [\`${dep.name}\`](${dep.repo}) | ${dep.license} |`)
  597. return lines.join('\n')
  598. }
  599. function renderClaudeDistribution(
  600. distribution: ClaudeDistribution | undefined,
  601. ): string {
  602. if (distribution === undefined) return ''
  603. const rows = distribution.payloads.map(payload =>
  604. `| [\`${payload.name}\`](https://www.npmjs.com/package/${payload.name}) | ${payload.version} | ${CLAUDE_PLATFORM_DECLARED_LICENSE} |`,
  605. )
  606. return `
  607. ## Official Claude Code platform payloads
  608. The project owner authorizes distribution of every version of the official \`${CLAUDE_AGENT_SDK_PACKAGE}\` package and the official Claude Code CLI/platform payloads that each version declares through \`optionalDependencies\`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review.
  609. The installed SDK ${distribution.sdkVersion} declares the following optional platform packages. Each carries the official Claude Code ${distribution.claudeCodeVersion} executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host.
  610. | Optional platform package | Version | Declared license |
  611. | --- | --- | --- |
  612. ${rows.join('\n')}
  613. `
  614. }
  615. /**
  616. * Render the complete notices document.
  617. * @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold.
  618. */
  619. export function render(): string {
  620. // The linked-manifest cache is keyed by name only, so it must not outlive
  621. // the manifests map it was resolved from; render() owns that single load.
  622. workspaceLinkedManifestCache.clear()
  623. const { manifests, names } = loadWorkspaceManifests()
  624. const npm = collectNpmDeps(manifests, names)
  625. const runtimeDeps = npm.filter(dep => dep.runtime)
  626. const devDeps = npm.filter(dep => !dep.runtime)
  627. const vendored = collectVendored()
  628. const python = collectPython()
  629. const patched = collectPatched()
  630. const claudeDistribution = runtimeDeps.some(
  631. dep => dep.name === CLAUDE_AGENT_SDK_PACKAGE,
  632. )
  633. ? collectClaudeDistribution(manifests)
  634. : undefined
  635. const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license))
  636. // A copyleft license reaching a shipped surface is a distribution decision,
  637. // not a rendering detail; the notices cannot quietly absorb it.
  638. const nonPermissiveRuntime = runtimeDeps.filter(dep =>
  639. !isPermissive(dep.license)
  640. && !isOwnerAuthorizedRuntime(dep.name),
  641. )
  642. if (nonPermissiveRuntime.length > 0) {
  643. throw new Error(`gen-third-party-notices: runtime ${nonPermissiveRuntime.map(dep => `${dep.name} (${dep.license})`).join(', ')} is not a permissive license; review the distribution terms and record the decision before regenerating.`)
  644. }
  645. const patchedLines = patched.map(({ spec, patch }) => `- \`${spec}\` — [\`${patch}\`](${patch})`)
  646. return `<!-- Generated by scripts/gen-third-party-notices.ts — do not edit by hand.
  647. Run \`pnpm run gen-third-party-notices\` to regenerate. -->
  648. # Third-Party Notices
  649. DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms.
  650. This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude Code platform payload closure. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check.
  651. The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded separately in [\`python/sdk/uv.lock\`](python/sdk/uv.lock).
  652. ## Vendored source (\`vendor/\`)
  653. The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm, and republished under the \`@deepseek-ai\` scope. All are MIT-licensed; each directory preserves its upstream \`LICENSE\` file. Exact upstream commits and local modifications are recorded in [\`vendor/README.md\`](vendor/README.md).
  654. | Package | Upstream name | Upstream | License |
  655. | --- | --- | --- | --- |
  656. ${vendored.map(row => `| \`${row.npmName}\` | \`${row.upstreamName}\` | [${row.upstream.replace('https://', '')}](${row.upstream}) | MIT |`).join('\n')}
  657. ## Runtime npm dependencies
  658. External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default.
  659. ${renderNpmTable(runtimeDeps)}
  660. pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
  661. ${patchedLines.join('\n')}
  662. ${renderClaudeDistribution(claudeDistribution)}
  663. ## Development-only npm dependencies
  664. External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — \`pnpm-lock.yaml\` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles.
  665. ${renderNpmTable(devDeps)}
  666. ${renderNonPermissiveNote(nonPermissiveDev)}
  667. ## Python SDK dependencies (\`python/\`)
  668. Direct dependencies of the \`pyproject.toml\` manifests, plus \`uv\` as the development workflow tool.
  669. | Package | License | Role |
  670. | --- | --- | --- |
  671. ${python.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.license} | ${dep.role} |`).join('\n')}
  672. | [\`uv\`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool |
  673. ## First-party native packages
  674. \`@deepseek-ai/node-addon-landlock-run\` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party.
  675. `
  676. }
  677. /** CLI entry: default writes the notices, `--check` fails if the committed copy
  678. * is stale. Guarded behind an entry-point check so importing this module for
  679. * tests neither regenerates the committed file nor calls process.exit. */
  680. function main(): void {
  681. const content = render()
  682. if (process.argv.includes('--check')) {
  683. let committed: string | null = null
  684. try {
  685. committed = readFileSync(resolve(root, OUT), 'utf8')
  686. } catch {
  687. // Only ENOENT (not yet generated) is expected; a present-but-unreadable
  688. // file is not a state this repo produces, and the remedy is the same.
  689. committed = null
  690. }
  691. if (committed === content) {
  692. console.log(`gen-third-party-notices: ${OUT} is up to date.`)
  693. process.exit(0)
  694. }
  695. console.error(`gen-third-party-notices: ${OUT} is stale. Run \`pnpm run gen-third-party-notices\` and commit ${OUT}.`)
  696. process.exit(1)
  697. }
  698. writeFileSync(resolve(root, OUT), content)
  699. console.log(`gen-third-party-notices: wrote ${OUT}.`)
  700. }
  701. if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
  702. main()
  703. }