diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index 288517e61e..d65dae2802 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: 53d75d1464b4ddd3cab0eeaa291dc6d41da10503 -2026-07-30-generated-third-party-notices.zh.md: b55982d94a6d9d9f9b5f6fb98a36ea1471f5eb6c +2026-07-30-generated-third-party-notices.md: e480954d29d5dc09ef8ecd4069059a1f0c8b1043 +2026-07-30-generated-third-party-notices.zh.md: 78ba7250e797c57048078d1b4f62b7a9a5d9d561 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index 53d75d1464..e480954d29 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -14,7 +14,7 @@ A hand-written inventory answers none of those durably. Roughly a hundred rows o [`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, and `pnpm-workspace.yaml`. The root README pair links the file from its License section. -**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, either lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check. +**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check. One trigger gap is accepted rather than worked around: lefthook inspects only files present on disk, so **deleting** a manifest runs no job, and removing a package reaches the assertion in the test lane instead. Reconstructing the staged file list to include deletions was tried and does not work — lefthook filters the list against the working tree either way. The assertion is the backstop for exactly this case. @@ -24,11 +24,11 @@ The file discloses **direct** dependencies only. The complete npm closure with p The runtime tier deliberately covers **every mountable plugin**, not just what the CLI, Web UI, and Python runtime load by default. `scripts/install.sh` installs the repository itself, so a user's `cordis.yml` can mount any plugin package; `@modelcontextprotocol/sdk` and the OpenTelemetry packages reach real users even though no default assembly imports them. Under-disclosure is the costly direction for a legal notice. -License and repository metadata come from the installed pnpm store, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed. +The manifest set is derived from the `packages:` members each `pnpm-workspace.yaml` declares — the root one and the nested Landlock workspace's — so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the installed pnpm stores, both the root one and the Landlock workspace's, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed. ## Testing -The same spec that asserts freshness pins the tiering rule against fixture manifests — including the two cases that motivate it, a `dependencies` entry of a test-support package and a plugin package no app mounts — and pins that the vendored-table parser reads the committed manifest and yields nothing when the table shape changes, which is what makes the generator fail loud rather than emit an empty section. +The same spec that asserts freshness pins the tiering rule against fixture manifests — including the two cases that motivate it, a `dependencies` entry of a test-support package and a plugin package no app mounts. It also pins the parsers against the shapes that would otherwise drop a package without a word: a `vendor/README.md` table that stops covering a vendored directory, a requirement array holding extras (`"httpx[http2]"`), a requirement with no version at all, an author-named `[dependency-groups]` table, and a workspace member area absent from any hardcoded list. Each of those is a silent-omission path, which is the failure mode a disclosure file cannot afford. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index b55982d94a..78ba7250e7 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -14,7 +14,7 @@ Status: implemented [`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区清单、`vendor/README.md`、`pyproject.toml` 与 `pnpm-workspace.yaml` 生成。根 README 双语两侧都从「许可证」一节链到该文件。 -**新鲜度靠维护而非拦截。** 只要暂存了生成器的任一输入——任何清单文件、两个锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成并一并入库,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。 +**新鲜度靠维护而非拦截。** 只要暂存了生成器的任一输入——任何清单文件、工作区声明、根锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成并一并入库,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。 有一处触发缺口是接受而非绕过的:lefthook 只检视磁盘上存在的文件,因此**删除**清单文件不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法试过,不成立——无论怎么给列表,lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。 @@ -24,11 +24,11 @@ Status: implemented 运行时层刻意覆盖**所有可挂载的插件**,而不止 CLI、Web UI 与 Python 运行时默认加载的那些。`scripts/install.sh` 安装的就是仓库本身,用户的 `cordis.yml` 可以挂载任何插件包;`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列即使没有任何默认装配引入,也会触达真实用户。对法务披露而言,披露不足才是代价更高的那个方向。 -许可证与仓库地址取自已安装的 pnpm store,因此生成器要求工作树已安装依赖;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布清单答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml` 的 `patchedDependencies` 列在运行时表格之后,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui` 与 `node-pty`,补丁文件本身就是改动的完整记录。 +清单集合由两个 `pnpm-workspace.yaml`——根工作区与嵌套的 Landlock 工作区——各自声明的 `packages:` 成员派生,因此新增成员区域在声明当天就会被读取,而不必等谁想起来去补一份列表。许可证与仓库地址取自已安装的 pnpm store,根 store 与 Landlock 工作区的 store 都会查;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布清单答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。运行时依赖的许可证若不在宽松清单内即为硬失败:交付 copyleft 是一项分发决策,不该被一次重新生成悄悄吸收。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml` 的 `patchedDependencies` 列在运行时表格之后,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui` 与 `node-pty`,补丁文件本身就是改动的完整记录。 ## Testing -断言新鲜度的同一个 spec 也用夹具清单钉住分层规则,覆盖促成该规则的两个场景:测试支撑包的 `dependencies` 条目,以及没有任何应用挂载的插件包。它同时钉住被收编包的表格解析器能读出已提交的清单表,且表格形态一变就解析为空——正是这一点让生成器直接失败,而不是产出一个空章节。 +断言新鲜度的同一个 spec 也用夹具清单钉住分层规则,覆盖促成该规则的两个场景:测试支撑包的 `dependencies` 条目,以及没有任何应用挂载的插件包。它还把各解析器钉在那些原本会让某个包无声消失的形态上:不再覆盖全部收编目录的 `vendor/README.md` 表、含 extras 的依赖数组(`"httpx[http2]"`)、完全不带版本的依赖、作者自取名字的 `[dependency-groups]` 表,以及任何硬编码列表都不含的工作区成员区域。这些都是静默漏报路径——正是披露文件最担不起的失败方式。 ## Alternatives considered diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 67e2ebae30..1b2bdbfe63 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -139,6 +139,7 @@ External packages declared only by repository tooling, test infrastructure, the `eslint-plugin-sonarjs` (LGPL-3.0-only) and `lightningcss` (MPL-2.0) run only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact. + ## Python SDK dependencies (`python/`) Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the development workflow tool. diff --git a/lefthook.yml b/lefthook.yml index 900a6e52bf..bdab57a9a3 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -25,7 +25,7 @@ pre-commit: # lefthook only inspects files present on disk — so that one case still # falls through to the freshness assertion in the test lane. - name: third-party notices (staged) - glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/build-exe-for-python-sdk.ts}' + glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/build-exe-for-python-sdk.ts}' run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md - name: whitespace (staged) diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index bd2107d044..29db79e3ff 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -1,7 +1,7 @@ import { readdirSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' -import { type Manifest, parsePythonRequirements, parseVendoredRows, render, tierExternalDeps } from './gen-third-party-notices.ts' +import { isPermissive, type Manifest, manifestPatterns, parsePyprojectRequirements, parsePythonRequirements, parseVendoredRows, render, tierExternalDeps } from './gen-third-party-notices.ts' const root = resolve(import.meta.dirname, '..') @@ -94,9 +94,61 @@ describe('parsePythonRequirements', () => { }) it('reads the committed manifests', () => { - const text = readFileSync(resolve(root, 'python/sdk/pyproject.toml'), 'utf8') - const block = /dependencies\s*=\s*\[([^\]]*)\]/.exec(text)?.[1] ?? '' - - expect(parsePythonRequirements(block)).toContain('pydantic') + expect(parsePyprojectRequirements(readFileSync(resolve(root, 'python/sdk/pyproject.toml'), 'utf8'))).toContain('pydantic') + }) +}) + +describe('parsePyprojectRequirements', () => { + it('locates requirement arrays by TOML table, so author-named groups are not missed', () => { + expect(parsePyprojectRequirements([ + '[build-system]', + 'requires = ["hatchling>=1.24.0"]', + '', + '[project]', + 'name = "not-a-requirement"', + 'dependencies = ["pydantic>=2.12"]', + '', + '[project.optional-dependencies]', + 'cli = ["click"]', + '', + '[dependency-groups]', + 'docs = ["sphinx>=7"]', + '', + '[tool.hatch.build.targets.wheel]', + 'packages = ["src/deepseek_harness"]', + '', + '[tool.pytest.ini_options]', + 'testpaths = ["tests"]', + ].join('\n'))).toEqual(['hatchling', 'pydantic', 'click', 'sphinx']) + }) + + it('does not truncate an array at a bracket inside extras', () => { + expect(parsePyprojectRequirements('[project]\ndependencies = ["httpx[http2]", "requests"]\n')) + .toEqual(['httpx', 'requests']) + }) + + it('reads a multi-line array', () => { + expect(parsePyprojectRequirements('[project]\ndependencies = [\n "pydantic>=2.12",\n "typing-extensions",\n]\n')) + .toEqual(['pydantic', 'typing-extensions']) + }) +}) + +describe('isPermissive', () => { + it('accepts the licenses this project ships and rejects copyleft or unknown ones', () => { + expect(['MIT', 'ISC', 'BSD-3-Clause', 'Apache-2.0', 'MIT / Apache-2.0', '(MIT OR CC0-1.0)'].every(isPermissive)).toBe(true) + expect(['LGPL-3.0-only', 'MPL-2.0', 'GPL-3.0-or-later', 'SEE LICENSE IN LICENSE'].some(isPermissive)).toBe(false) + }) +}) + +describe('manifestPatterns', () => { + it('derives globs from the declared members, so a new member area is read', () => { + expect(manifestPatterns(['packages/*/*', 'tools/*'], ['packages/*'])).toEqual([ + 'package.json', + 'packages/*/*/package.json', + 'tools/*/package.json', + 'examples/*/package.json', + 'native/landlock-run/package.json', + 'native/landlock-run/packages/*/package.json', + ]) }) }) diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index a33f0671fd..134a939662 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -110,11 +110,36 @@ function readManifest(rel: string): Manifest { return JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as Manifest } +/** + * Manifest globs, derived from the workspace declarations rather than listed + * here, so a new member area (`tools/*`) is read the day it is declared. + * @returns one glob per manifest-bearing location, repository-relative. + */ +export function manifestPatterns(rootMembers: readonly string[], nativeMembers: readonly string[]): string[] { + return [ + 'package.json', + ...rootMembers.map(member => `${member}/package.json`), + // The demo leaves join the workspace through `examples/package.json`, so + // their own manifests are members of nothing and no glob above reaches them. + 'examples/*/package.json', + // `native/landlock-run` is a nested workspace with its own lock file. + 'native/landlock-run/package.json', + ...nativeMembers.map(member => `native/landlock-run/${member}/package.json`), + ] +} + +/** The `packages:` member globs declared by one pnpm workspace file. */ +function workspaceMembers(rel: string): string[] { + const declared = (yaml.load(readFileSync(resolve(root, rel), 'utf8')) as { packages?: unknown }).packages + if (!Array.isArray(declared) || declared.length === 0) { + throw new Error(`gen-third-party-notices: ${rel} declares no workspace members; the manifest set cannot be derived.`) + } + return declared.map(member => String(member)) +} + /** Every workspace manifest, keyed by path, plus the set of workspace package names. */ function loadWorkspaceManifests(): { manifests: Map; names: Set } { - // `native/landlock-run` is a nested workspace with its own lock file; its - // leaf manifests live one level deeper than this repository's own tiers. - const patterns = ['package.json', 'vendor/*/package.json', 'packages/*/*/package.json', 'apps/*/package.json', 'website/package.json', 'examples/package.json', 'python/sdk-runtime/package.json', 'native/landlock-run/package.json', 'native/landlock-run/packages/*/package.json'] + const patterns = manifestPatterns(workspaceMembers('pnpm-workspace.yaml'), workspaceMembers('native/landlock-run/pnpm-workspace.yaml')) const manifests = new Map() const names = new Set() for (const pattern of patterns) { @@ -132,15 +157,21 @@ function loadWorkspaceManifests(): { manifests: Map; names: Se function installedMetadata(name: string): { license: string; repo: string } { const override = OVERRIDES[name] let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined - const direct = resolve(root, 'node_modules', name, 'package.json') - if (existsSync(direct)) { - manifest = JSON.parse(readFileSync(direct, 'utf8')) as typeof manifest - } else { - const prefix = `${name.replace('/', '+')}@` - const entry = readdirSync(resolve(root, 'node_modules/.pnpm')).find(dir => dir.startsWith(prefix)) - if (entry !== undefined) { - manifest = JSON.parse(readFileSync(resolve(root, 'node_modules/.pnpm', entry, 'node_modules', name, 'package.json'), 'utf8')) as typeof manifest + // The nested Landlock workspace installs into its own store, so a package + // only that workspace depends on is unreachable from the root one. + for (const store of ['node_modules', 'native/landlock-run/node_modules']) { + const direct = resolve(root, store, name, 'package.json') + if (existsSync(direct)) { + manifest = JSON.parse(readFileSync(direct, 'utf8')) as typeof manifest + break } + const virtual = resolve(root, store, '.pnpm') + if (!existsSync(virtual)) continue + const prefix = `${name.replace('/', '+')}@` + const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix)) + if (entry === undefined) continue + manifest = JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as typeof manifest + break } const license = override?.license ?? manifest?.license const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage @@ -272,20 +303,72 @@ export function parsePythonRequirements(block: string): string[] { return names } +/** + * Every requirement name a `pyproject.toml` declares, located by TOML table + * rather than by key name: `requires` under `[build-system]`, `dependencies` + * under `[project]`, and every key under `[project.optional-dependencies]` and + * `[dependency-groups]`, whose keys are author-chosen group names. Array bodies + * are scanned with quote awareness, because a requirement may itself contain + * `]` inside extras (`"httpx[http2]"`). + * @param text - the complete `pyproject.toml` contents. + * @returns each declared requirement's distribution name, in file order. + */ +export function parsePyprojectRequirements(text: string): string[] { + const names: string[] = [] + let table = '' + const lines = text.split('\n') + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? '' + const header = /^\s*\[([^\]]+)]\s*$/.exec(line) + if (header?.[1] !== undefined) { + table = header[1] + continue + } + const assignment = /^\s*([A-Za-z0-9._-]+)\s*=\s*\[/.exec(line) + if (assignment?.[1] === undefined) continue + const key = assignment[1] + const bearsRequirements = (table === 'build-system' && key === 'requires') + || (table === 'project' && key === 'dependencies') + || table === 'project.optional-dependencies' + || table === 'dependency-groups' + if (!bearsRequirements) continue + + // Consume the array body from the opening bracket to its match, ignoring + // brackets inside quoted requirements. + let body = '' + let depth = 0 + let quoted = false + let cursor = index + let column = line.indexOf('[') + scan: for (; cursor < lines.length; cursor += 1) { + const current = lines[cursor] ?? '' + for (; column < current.length; column += 1) { + const character = current[column] ?? '' + if (character === '"' || character === "'") quoted = !quoted + if (!quoted && character === '[') depth += 1 + if (!quoted && character === ']') { + depth -= 1 + if (depth === 0) break scan + } + if (depth > 0) body += character + } + body += '\n' + column = 0 + } + if (depth !== 0) throw new Error(`gen-third-party-notices: unterminated ${key} array in a pyproject.toml table [${table}].`) + names.push(...parsePythonRequirements(body)) + index = cursor + } + return names +} + /** Direct Python dependencies named by the `pyproject.toml` manifests under `python/`. */ function collectPython(): { name: string; license: string; repo: string; role: string }[] { const found = new Set() for (const path of ['python/sdk/pyproject.toml', 'python/sdk-runtime/pyproject.toml']) { - const text = readFileSync(resolve(root, path), 'utf8') - // Requirement arrays only: `[project] name`/`readme` and `[tool.*]` string - // values would otherwise read as dependencies. - for (const block of text.matchAll(/(?:^|\n)\s*(?:requires|dependencies|test|dev|lint)\s*=\s*\[([^\]]*)\]/g)) { - const body = block[1] - if (body === undefined) continue - for (const name of parsePythonRequirements(body)) { - if (name.startsWith('deepseek')) continue - found.add(name) - } + for (const name of parsePyprojectRequirements(readFileSync(resolve(root, path), 'utf8'))) { + if (name.startsWith('deepseek')) continue + found.add(name) } } return [...found].sort((a, b) => a.localeCompare(b)).map((name) => { @@ -311,6 +394,33 @@ function verifyBuildTimePins(): void { } } +/** + * Whether an SPDX expression is a permissive license this project may ship. + * Anything outside the list — copyleft or unrecognized — is reported rather + * than silently rendered, because the tier tables assert what may be linked. + * @param license - the SPDX expression from the package manifest. + * @returns true when every alternative in the expression is permissive. + */ +export function isPermissive(license: string): boolean { + const permissive = 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']) + return license.split('/').map(part => part.trim().replace(/^\(|\)$/g, '')) + .flatMap(part => part.split(' OR ').map(alternative => alternative.trim())) + .some(alternative => permissive.has(alternative)) +} + +/** + * Render the sentence that isolates non-permissive development tooling, or + * nothing at all when every development dependency is permissive. + * @param deps - development dependencies whose license is not permissive. + * @returns the paragraph to place after the development table. + */ +function renderNonPermissiveNote(deps: ExternalDep[]): string { + if (deps.length === 0) return '' + const named = deps.map(dep => `\`${dep.name}\` (${dep.license})`) + const subject = named.length === 1 ? named[0] : `${named.slice(0, -1).join(', ')} and ${named.at(-1)}` + 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` +} + /** Render one npm dependency table. */ function renderNpmTable(deps: ExternalDep[]): string { const lines = ['| Package | License |', '| --- | --- |'] @@ -331,7 +441,13 @@ export function render(): string { const python = collectPython() const patched = collectPatched() - const nonPermissiveDev = devDeps.filter(dep => dep.license.startsWith('LGPL') || dep.license.startsWith('MPL')) + const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license)) + // A copyleft license reaching a shipped surface is a distribution decision, + // not a rendering detail; the notices cannot quietly absorb it. + const nonPermissiveRuntime = runtimeDeps.filter(dep => !isPermissive(dep.license)) + if (nonPermissiveRuntime.length > 0) { + 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.`) + } const patchedLines = patched.map(({ spec, patch }) => `- \`${spec}\` — [\`${patch}\`](${patch})`) return `