gen-third-party-notices.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  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 { 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, the runnable
  24. * demo leaves, and the native launcher's build workspace. A runtime
  25. * declaration by anything outside these areas is a disclosure-relevant
  26. * runtime dependency, because `scripts/install.sh` installs the repository
  27. * itself and any plugin package can be mounted from a user's `cordis.yml`.
  28. */
  29. const DEV_ONLY_AREAS = [
  30. 'package.json',
  31. 'packages/support/',
  32. 'packages/client/test-runtime/',
  33. 'website/',
  34. 'examples/',
  35. 'native/',
  36. ] as const
  37. /**
  38. * First-party packages released from sibling repositories under the project's
  39. * own license: reachable from workspace manifests but not third-party.
  40. */
  41. const FIRST_PARTY = new Set([
  42. 'node-addon-landlock-run',
  43. 'node-addon-landlock-run-linux-arm64',
  44. 'node-addon-landlock-run-linux-x64',
  45. ])
  46. /**
  47. * Metadata overrides where the installed manifest is wrong or unreachable.
  48. * Each entry documents why the store cannot answer.
  49. */
  50. const OVERRIDES: Record<string, { license?: string; repo?: string }> = {
  51. // Rust workspaces publishing npm bins without `license` in package.json.
  52. 'oxlint': { license: 'MIT', repo: 'https://github.com/oxc-project/oxc' },
  53. 'oxlint-tsgolint': { license: 'MIT', repo: 'https://github.com/oxc-project/tsgolint' },
  54. // `license: SEE LICENSE IN LICENSE`: the servers repo is mid MIT→Apache-2.0
  55. // relicensing, so the effective terms are per-contribution.
  56. '@modelcontextprotocol/server-everything': { license: 'MIT / Apache-2.0', repo: 'https://github.com/modelcontextprotocol/servers' },
  57. '@modelcontextprotocol/server-filesystem': { license: 'MIT / Apache-2.0', repo: 'https://github.com/modelcontextprotocol/servers' },
  58. // No repository field in the published manifest.
  59. 'node-addon-require-builtin': { repo: 'https://www.npmjs.com/package/node-addon-require-builtin' },
  60. }
  61. /**
  62. * Python dependencies are few and named directly in `pyproject.toml` files
  63. * without installed metadata to harvest, so license/repo are recorded here and
  64. * the generator fails when a manifest names a package this map misses.
  65. */
  66. const PYTHON_METADATA: Record<string, { license: string; repo: string; role: string }> = {
  67. pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness`' },
  68. hatchling: { license: 'MIT', repo: 'https://github.com/pypa/hatch', role: 'build backend' },
  69. pytest: { license: 'MIT', repo: 'https://github.com/pytest-dev/pytest', role: 'test-only' },
  70. }
  71. type PythonMetadata = typeof PYTHON_METADATA
  72. /** Tools fetched by scripts at build time, keyed by the pin the script owns. */
  73. const BUILD_TIME_TOOLS = [
  74. {
  75. name: '@yao-pkg/pkg',
  76. license: 'MIT',
  77. repo: 'https://github.com/yao-pkg/pkg',
  78. role: 'invoked by `scripts/build-exe-for-python-sdk.ts` to assemble the single-file SDK runtime executable',
  79. pinSource: 'scripts/build-exe-for-python-sdk.ts',
  80. },
  81. ]
  82. /** The `package.json` fields this generator reads. */
  83. export interface Manifest {
  84. name?: string
  85. private?: boolean
  86. license?: string
  87. dependencies?: Record<string, string>
  88. devDependencies?: Record<string, string>
  89. optionalDependencies?: Record<string, string>
  90. peerDependencies?: Record<string, string>
  91. }
  92. /** One disclosed external npm dependency. */
  93. interface ExternalDep {
  94. name: string
  95. license: string
  96. repo: string
  97. /** True when some shipped workspace consumer reaches it through runtime dependency edges. */
  98. runtime: boolean
  99. }
  100. /** Read and parse a workspace-relative `package.json`. */
  101. function readManifest(rel: string): Manifest {
  102. return JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as Manifest
  103. }
  104. /**
  105. * Manifest globs, derived from the workspace declarations rather than listed
  106. * here, so a new member area (`tools/*`) is read the day it is declared.
  107. * @returns one glob per manifest-bearing location, repository-relative.
  108. */
  109. export function manifestPatterns(rootMembers: readonly string[], nativeMembers: readonly string[]): string[] {
  110. return [
  111. 'package.json',
  112. ...rootMembers.map(member => `${member}/package.json`),
  113. // The demo leaves join the workspace through `examples/package.json`, so
  114. // their own manifests are members of nothing and no glob above reaches them.
  115. 'examples/*/package.json',
  116. // `native/landlock-run` is a nested workspace with its own lock file.
  117. 'native/landlock-run/package.json',
  118. ...nativeMembers.map(member => `native/landlock-run/${member}/package.json`),
  119. ]
  120. }
  121. /** The `packages:` member globs declared by one pnpm workspace file. */
  122. function workspaceMembers(rel: string): string[] {
  123. const declared = (yaml.load(readFileSync(resolve(root, rel), 'utf8')) as { packages?: unknown }).packages
  124. if (!Array.isArray(declared) || declared.length === 0) {
  125. throw new Error(`gen-third-party-notices: ${rel} declares no workspace members; the manifest set cannot be derived.`)
  126. }
  127. return declared.map(member => String(member))
  128. }
  129. /** Every workspace manifest, keyed by path, plus the set of workspace package names. */
  130. function loadWorkspaceManifests(): { manifests: Map<string, Manifest>; names: Set<string> } {
  131. const patterns = manifestPatterns(workspaceMembers('pnpm-workspace.yaml'), workspaceMembers('native/landlock-run/pnpm-workspace.yaml'))
  132. const manifests = new Map<string, Manifest>()
  133. const names = new Set<string>()
  134. for (const pattern of patterns) {
  135. for (const path of globSync(pattern, { cwd: root })) {
  136. const manifest = readManifest(path)
  137. manifests.set(path, manifest)
  138. if (manifest.name !== undefined) names.add(manifest.name)
  139. }
  140. }
  141. if (manifests.size < 100) throw new Error(`gen-third-party-notices: only ${manifests.size} workspace manifests found; the glob set is stale.`)
  142. return { manifests, names }
  143. }
  144. /** License and repository URL for an installed external package, from the pnpm store. */
  145. function installedMetadata(name: string): { license: string; repo: string } {
  146. const override = OVERRIDES[name]
  147. let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined
  148. // The nested Landlock workspace installs into its own store, so a package
  149. // only that workspace depends on is unreachable from the root one.
  150. for (const store of ['node_modules', 'native/landlock-run/node_modules']) {
  151. const direct = resolve(root, store, name, 'package.json')
  152. if (existsSync(direct)) {
  153. manifest = JSON.parse(readFileSync(direct, 'utf8')) as typeof manifest
  154. break
  155. }
  156. const virtual = resolve(root, store, '.pnpm')
  157. if (!existsSync(virtual)) continue
  158. const prefix = `${name.replace('/', '+')}@`
  159. const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix))
  160. if (entry === undefined) continue
  161. manifest = JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as typeof manifest
  162. break
  163. }
  164. const license = override?.license ?? manifest?.license
  165. const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage
  166. const repo = override?.repo ?? normalizeRepo(rawRepo)
  167. if (license === undefined || repo === undefined) {
  168. throw new Error(`gen-third-party-notices: cannot resolve ${license === undefined ? 'license' : 'repository'} for ${name}; run \`pnpm install\` (or, for a Landlock-only dependency, \`pnpm --dir native/landlock-run install\`), or add an OVERRIDES entry.`)
  169. }
  170. return { license, repo }
  171. }
  172. /** Normalize a manifest repository/homepage value to a browsable https URL. */
  173. function normalizeRepo(raw: string | undefined): string | undefined {
  174. if (raw === undefined || raw === '') return undefined
  175. let url = raw
  176. .replace(/^git\+ssh:\/\/git@/, 'https://')
  177. .replace(/^git\+/, '')
  178. .replace(/^git:\/\//, 'https://')
  179. .replace(/^github:/, 'https://github.com/')
  180. .replace(/\.git$/, '')
  181. if (!url.startsWith('http')) url = `https://github.com/${url}`
  182. return url
  183. }
  184. /**
  185. * External npm dependencies, tiered by which workspace area declares them at
  186. * runtime: a package is runtime when any manifest outside `DEV_ONLY_AREAS`
  187. * names it in `dependencies`/`optionalDependencies`. A package declared only
  188. * by tooling, test infrastructure, the website, or the demo leaves — whatever
  189. * the declaring section is called — is development-only.
  190. */
  191. function collectNpmDeps(): ExternalDep[] {
  192. const { manifests, names } = loadWorkspaceManifests()
  193. return [...tierExternalDeps(manifests, names)]
  194. .filter(([name]) => !FIRST_PARTY.has(name))
  195. .sort(([a], [b]) => a.localeCompare(b))
  196. .map(([name, runtime]) => ({ name, ...installedMetadata(name), runtime }))
  197. }
  198. /**
  199. * Tier every external dependency the workspace declares.
  200. * @param manifests - workspace manifests keyed by repository-relative path.
  201. * @param names - every workspace package name, which never counts as external.
  202. * @returns each external package mapped to whether it is a runtime dependency.
  203. */
  204. export function tierExternalDeps(manifests: Map<string, Manifest>, names: Set<string>): Map<string, boolean> {
  205. const tiers = new Map<string, boolean>()
  206. // `tsx` is runtime by fiat: `bin/dsh` execs the CLI through its ESM hook.
  207. tiers.set('tsx', true)
  208. for (const [path, manifest] of manifests) {
  209. const devOnly = DEV_ONLY_AREAS.some(area => (area.endsWith('/') ? path.startsWith(area) : path === area))
  210. for (const kind of ALL_KINDS) {
  211. for (const [dep, range] of Object.entries(manifest[kind] ?? {})) {
  212. if (names.has(dep) || range.startsWith('workspace:')) continue
  213. const runtime = !devOnly && (RUNTIME_KINDS as readonly string[]).includes(kind)
  214. tiers.set(dep, (tiers.get(dep) ?? false) || runtime)
  215. }
  216. }
  217. }
  218. return tiers
  219. }
  220. /** A vendored package row parsed out of the `vendor/README.md` manifest table. */
  221. export interface VendoredRow {
  222. npmName: string
  223. upstream: string
  224. }
  225. /**
  226. * Parse the vendored-package manifest table out of `vendor/README.md`.
  227. * @param text - the complete `vendor/README.md` contents.
  228. * @returns one row per manifest-table entry, in table order.
  229. */
  230. export function parseVendoredRows(text: string): VendoredRow[] {
  231. const rows: VendoredRow[] = []
  232. for (const line of text.split('\n')) {
  233. const match = /^\| \x60\S+\/\x60 \| \x60([^\x60]+)\x60 \| \S+ \| (https:\/\/\S+?)(?: \([^)]*\))? \| \x60[0-9a-f]+\x60 \|$/.exec(line)
  234. if (match === null) continue
  235. const [, npmName, upstream] = match
  236. if (npmName === undefined || upstream === undefined) continue
  237. rows.push({ npmName, upstream })
  238. }
  239. return rows
  240. }
  241. /**
  242. * Parse the vendored manifest table and confirm it accounts for every vendored
  243. * directory. The `vendor/` tree — not the table — is the set that must be
  244. * disclosed, so a row that stops matching the table format is a hard error
  245. * rather than a package that quietly vanishes from the notices.
  246. */
  247. function collectVendored(): VendoredRow[] {
  248. const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8'))
  249. const onDisk = new Map<string, string>()
  250. for (const entry of readdirSync(resolve(root, 'vendor'), { withFileTypes: true })) {
  251. if (!entry.isDirectory()) continue
  252. const manifest = readManifest(`vendor/${entry.name}/package.json`)
  253. if (manifest.name !== undefined) onDisk.set(manifest.name, entry.name)
  254. }
  255. const parsed = new Set(rows.map(row => row.npmName))
  256. const missing = [...onDisk.keys()].filter(name => !parsed.has(name))
  257. if (missing.length > 0) {
  258. 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.`)
  259. }
  260. for (const row of rows) {
  261. const dir = onDisk.get(row.npmName)
  262. if (dir === undefined) throw new Error(`gen-third-party-notices: vendored package ${row.npmName} from vendor/README.md has no vendor/ directory.`)
  263. const license = readManifest(`vendor/${dir}/package.json`).license
  264. if (license !== 'MIT') {
  265. throw new Error(`gen-third-party-notices: vendored ${row.npmName} declares license ${JSON.stringify(license)}; the vendored section assumes MIT throughout.`)
  266. }
  267. }
  268. return rows
  269. }
  270. /** Whether a parsed TOML value is a table rather than an array or scalar. */
  271. function isTomlTable(value: TomlValueWithoutBigInt | undefined): value is TomlTableWithoutBigInt {
  272. return value !== undefined && typeof value === 'object' && !Array.isArray(value)
  273. }
  274. /** Parse one PEP 508 requirement string into its distribution name. */
  275. function parsePythonRequirement(requirement: string): string {
  276. const name = /^\s*([a-zA-Z][a-zA-Z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(?:[<>=!~;@].*)?$/.exec(requirement)?.[1]
  277. if (name === undefined) {
  278. throw new Error(`gen-third-party-notices: cannot read a distribution name from the requirement ${JSON.stringify(requirement)}.`)
  279. }
  280. return name
  281. }
  282. /** Add the string requirements from one parsed TOML array. */
  283. function collectPythonRequirementArray(
  284. names: string[],
  285. value: TomlValueWithoutBigInt | undefined,
  286. location: string,
  287. allowGroupIncludes = false,
  288. ): void {
  289. if (value === undefined) return
  290. if (!Array.isArray(value)) {
  291. throw new Error(`gen-third-party-notices: ${location} must be an array.`)
  292. }
  293. for (const item of value) {
  294. if (typeof item === 'string') {
  295. names.push(parsePythonRequirement(item))
  296. continue
  297. }
  298. if (allowGroupIncludes && isTomlTable(item) && typeof item['include-group'] === 'string' && Object.keys(item).length === 1) {
  299. continue
  300. }
  301. throw new Error(`gen-third-party-notices: ${location} contains an unsupported requirement entry.`)
  302. }
  303. }
  304. /** Read an optional TOML table and reject a present value of another shape. */
  305. function optionalTomlTable(value: TomlValueWithoutBigInt | undefined, location: string): TomlTableWithoutBigInt | undefined {
  306. if (value === undefined || isTomlTable(value)) return value
  307. throw new Error(`gen-third-party-notices: ${location} must be a table.`)
  308. }
  309. /**
  310. * Parse a `pyproject.toml` project identity and every requirement it declares:
  311. * `requires` under
  312. * `[build-system]`, `dependencies` under `[project]`, and every key under
  313. * `[project.optional-dependencies]` and `[dependency-groups]`. A TOML parser
  314. * owns comments, quoted keys, escapes, and array boundaries; unsupported
  315. * requirement shapes fail instead of disappearing from the notices.
  316. * @param text - the complete `pyproject.toml` contents.
  317. * @returns the local project name and declared requirement names.
  318. */
  319. function parsePyproject(text: string): { projectName?: string; requirements: string[] } {
  320. const names: string[] = []
  321. const document = parseToml(text, { integersAsBigInt: false })
  322. const buildSystem = optionalTomlTable(document['build-system'], '[build-system]')
  323. const project = optionalTomlTable(document.project, '[project]')
  324. const projectName = project?.name
  325. if (projectName !== undefined && typeof projectName !== 'string') {
  326. throw new Error('gen-third-party-notices: [project].name must be a string.')
  327. }
  328. collectPythonRequirementArray(names, buildSystem?.requires, '[build-system].requires')
  329. collectPythonRequirementArray(names, project?.dependencies, '[project].dependencies')
  330. const optional = optionalTomlTable(project?.['optional-dependencies'], '[project.optional-dependencies]')
  331. for (const [group, requirements] of Object.entries(optional ?? {})) {
  332. collectPythonRequirementArray(names, requirements, `[project.optional-dependencies].${group}`)
  333. }
  334. const groups = optionalTomlTable(document['dependency-groups'], '[dependency-groups]')
  335. for (const [group, requirements] of Object.entries(groups ?? {})) {
  336. collectPythonRequirementArray(names, requirements, `[dependency-groups].${group}`, true)
  337. }
  338. return projectName === undefined
  339. ? { requirements: names }
  340. : { projectName, requirements: names }
  341. }
  342. /**
  343. * Read every requirement name declared by one `pyproject.toml`.
  344. * @param text - the complete `pyproject.toml` contents.
  345. * @returns each declared requirement's distribution name, in file order.
  346. */
  347. export function parsePyprojectRequirements(text: string): string[] {
  348. return parsePyproject(text).requirements
  349. }
  350. /** Normalize a Python distribution name according to the packaging name rule. */
  351. function normalizePythonDistributionName(name: string): string {
  352. return name.toLowerCase().replace(/[-_.]+/g, '-')
  353. }
  354. /**
  355. * Resolve external Python dependencies after excluding local project names.
  356. * @param pyprojects - complete local `pyproject.toml` contents.
  357. * @param metadata - disclosure metadata for every external dependency.
  358. * @returns disclosed dependencies in normalized name order.
  359. */
  360. export function collectPythonDependencies(
  361. pyprojects: string[],
  362. metadata: PythonMetadata = PYTHON_METADATA,
  363. ): { name: string; license: string; repo: string; role: string }[] {
  364. const parsed = pyprojects.map(parsePyproject)
  365. const firstParty = new Set(parsed.flatMap(({ projectName }) => (
  366. projectName === undefined ? [] : [normalizePythonDistributionName(projectName)]
  367. )))
  368. const found = new Set(parsed
  369. .flatMap(({ requirements }) => requirements.map(normalizePythonDistributionName))
  370. .filter(name => !firstParty.has(name)))
  371. return [...found].sort((a, b) => a.localeCompare(b)).map((name) => {
  372. const entry = metadata[name]
  373. if (entry === undefined) throw new Error(`gen-third-party-notices: python dependency ${name} is missing from PYTHON_METADATA.`)
  374. return { name, ...entry }
  375. })
  376. }
  377. /** Direct Python dependencies named by the `pyproject.toml` manifests under `python/`. */
  378. function collectPython(): { name: string; license: string; repo: string; role: string }[] {
  379. const manifests = globSync('python/*/pyproject.toml', { cwd: root })
  380. if (manifests.length === 0) throw new Error('gen-third-party-notices: no python/*/pyproject.toml found; the Python tree moved.')
  381. return collectPythonDependencies(manifests.map(path => readFileSync(resolve(root, path), 'utf8')))
  382. }
  383. /** pnpm-patched external packages, from `pnpm-workspace.yaml`. */
  384. function collectPatched(): { spec: string; patch: string }[] {
  385. const workspace = yaml.load(readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8')) as { patchedDependencies?: Record<string, string> }
  386. return Object.entries(workspace.patchedDependencies ?? {}).map(([spec, patch]) => ({ spec, patch }))
  387. }
  388. /** Verify each build-time tool pin still appears in its owning script. */
  389. function verifyBuildTimePins(): void {
  390. for (const tool of BUILD_TIME_TOOLS) {
  391. const text = readFileSync(resolve(root, tool.pinSource), 'utf8')
  392. if (!text.includes(tool.name)) {
  393. throw new Error(`gen-third-party-notices: ${tool.pinSource} no longer references ${tool.name}; update BUILD_TIME_TOOLS.`)
  394. }
  395. }
  396. }
  397. /** SPDX identifiers this project may ship without further review. */
  398. 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'])
  399. /** Evaluate a parsed SPDX expression under the repository's license policy. */
  400. function isPermissiveSpdx(expression: ReturnType<typeof parseSpdx>): boolean {
  401. if ('conjunction' in expression) {
  402. return expression.conjunction === 'and'
  403. ? isPermissiveSpdx(expression.left) && isPermissiveSpdx(expression.right)
  404. : isPermissiveSpdx(expression.left) || isPermissiveSpdx(expression.right)
  405. }
  406. return expression.plus !== true
  407. && expression.exception === undefined
  408. && PERMISSIVE_LICENSES.has(expression.license)
  409. }
  410. /**
  411. * Whether an SPDX expression grants terms this project may ship under.
  412. * `OR` needs one permissive alternative, because the consumer chooses; `AND`
  413. * needs all of them, because every obligation applies. Anything that is not a
  414. * recognized permissive identifier — copyleft, an exception clause, or a
  415. * license this list has never seen — evaluates to false, so an unfamiliar
  416. * expression fails closed rather than passing on a partial match.
  417. * @param license - the SPDX expression from the package manifest.
  418. * @returns true when the expression's obligations are all permissive.
  419. */
  420. export function isPermissive(license: string): boolean {
  421. // Some npm manifests use a slash for a choice despite SPDX requiring `OR`.
  422. const normalized = license.replace(/\s*\/\s*/g, ' OR ').trim()
  423. try {
  424. return isPermissiveSpdx(parseSpdx(normalized))
  425. } catch {
  426. return false
  427. }
  428. }
  429. /**
  430. * Render the sentence that isolates non-permissive development tooling, or
  431. * nothing at all when every development dependency is permissive.
  432. * @param deps - development dependencies whose license is not permissive.
  433. * @returns the paragraph to place after the development table.
  434. */
  435. function renderNonPermissiveNote(deps: ExternalDep[]): string {
  436. if (deps.length === 0) return ''
  437. const named = deps.map(dep => `\`${dep.name}\` (${dep.license})`)
  438. const subject = named.length === 1 ? named[0] : `${named.slice(0, -1).join(', ')} and ${named.at(-1)}`
  439. 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`
  440. }
  441. /** Render one npm dependency table. */
  442. function renderNpmTable(deps: ExternalDep[]): string {
  443. const lines = ['| Package | License |', '| --- | --- |']
  444. for (const dep of deps) lines.push(`| [\`${dep.name}\`](${dep.repo}) | ${dep.license} |`)
  445. return lines.join('\n')
  446. }
  447. /**
  448. * Render the complete notices document.
  449. * @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold.
  450. */
  451. export function render(): string {
  452. verifyBuildTimePins()
  453. const npm = collectNpmDeps()
  454. const runtimeDeps = npm.filter(dep => dep.runtime)
  455. const devDeps = npm.filter(dep => !dep.runtime)
  456. const vendored = collectVendored()
  457. const python = collectPython()
  458. const patched = collectPatched()
  459. const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license))
  460. // A copyleft license reaching a shipped surface is a distribution decision,
  461. // not a rendering detail; the notices cannot quietly absorb it.
  462. const nonPermissiveRuntime = runtimeDeps.filter(dep => !isPermissive(dep.license))
  463. if (nonPermissiveRuntime.length > 0) {
  464. 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.`)
  465. }
  466. const patchedLines = patched.map(({ spec, patch }) => `- \`${spec}\` — [\`${patch}\`](${patch})`)
  467. return `<!-- Generated by scripts/gen-third-party-notices.ts — do not edit by hand.
  468. Run \`pnpm run gen-third-party-notices\` to regenerate. -->
  469. # Third-Party Notices
  470. DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms.
  471. This file lists **direct** dependencies declared by the workspace. 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.
  472. The complete npm transitive closure, with exact pinned versions, is recorded in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded in [\`python/sdk/uv.lock\`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [\`native/landlock-run/pnpm-lock.yaml\`](native/landlock-run/pnpm-lock.yaml).
  473. ## Vendored source (\`vendor/\`)
  474. The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. 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).
  475. | Package | Upstream | License |
  476. | --- | --- | --- |
  477. ${vendored.map(row => `| \`${row.npmName}\` | [${row.upstream.replace('https://', '')}](${row.upstream}) | MIT |`).join('\n')}
  478. ## Runtime npm dependencies
  479. External packages that a workspace package resolves at runtime. \`scripts/install.sh\` installs this repository itself, so the tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI/TUI, the Web UI, and the Python SDK runtime load by default.
  480. ${renderNpmTable(runtimeDeps)}
  481. 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:
  482. ${patchedLines.join('\n')}
  483. ## Development-only npm dependencies
  484. 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.
  485. ${renderNpmTable(devDeps)}
  486. ${renderNonPermissiveNote(nonPermissiveDev)}
  487. ## Python SDK dependencies (\`python/\`)
  488. Direct dependencies of the \`pyproject.toml\` manifests, plus \`uv\` as the development workflow tool.
  489. | Package | License | Role |
  490. | --- | --- | --- |
  491. ${python.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.license} | ${dep.role} |`).join('\n')}
  492. | [\`uv\`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool |
  493. ## Fetched at build time
  494. | Package | License | Role |
  495. | --- | --- | --- |
  496. ${BUILD_TIME_TOOLS.map(tool => `| [\`${tool.name}\`](${tool.repo}) | ${tool.license} | ${tool.role} |`).join('\n')}
  497. ## First-party sibling releases
  498. \`node-addon-landlock-run\` (and its platform packages) is released from a DeepSeek Harness sibling repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party.
  499. `
  500. }
  501. /** CLI entry: default writes the notices, `--check` fails if the committed copy
  502. * is stale. Guarded behind an entry-point check so importing this module for
  503. * tests neither regenerates the committed file nor calls process.exit. */
  504. function main(): void {
  505. const content = render()
  506. if (process.argv.includes('--check')) {
  507. let committed: string | null = null
  508. try {
  509. committed = readFileSync(resolve(root, OUT), 'utf8')
  510. } catch {
  511. // Only ENOENT (not yet generated) is expected; a present-but-unreadable
  512. // file is not a state this repo produces, and the remedy is the same.
  513. committed = null
  514. }
  515. if (committed === content) {
  516. console.log(`gen-third-party-notices: ${OUT} is up to date.`)
  517. process.exit(0)
  518. }
  519. console.error(`gen-third-party-notices: ${OUT} is stale. Run \`pnpm run gen-third-party-notices\` and commit ${OUT}.`)
  520. process.exit(1)
  521. }
  522. writeFileSync(resolve(root, OUT), content)
  523. console.log(`gen-third-party-notices: wrote ${OUT}.`)
  524. }
  525. // Run only when invoked as a script, not when imported by a test.
  526. if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
  527. main()
  528. }