docs: close the remaining silent-omission paths in the notices generator

Derive the manifest set from each pnpm-workspace.yaml members list, so a
new member area is read when declared. Locate Python requirement arrays
by TOML table and scan them quote-aware, so author-named dependency
groups and extras-bearing requirements are no longer dropped. Search the
nested Landlock store for metadata, reject a non-permissive runtime
license outright, and omit the dev-tooling sentence when it has no
subject.
This commit is contained in:
ZiyaZhang
2026-07-30 10:28:16 -07:00
parent 6427660dca
commit 44bd19056c
7 changed files with 206 additions and 38 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # 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 # 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.md: e480954d29d5dc09ef8ecd4069059a1f0c8b1043
2026-07-30-generated-third-party-notices.zh.md: b55982d94a6d9d9f9b5f6fb98a36ea1471f5eb6c 2026-07-30-generated-third-party-notices.zh.md: 78ba7250e797c57048078d1b4f62b7a9a5d9d561
@@ -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. [`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. 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. 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 ## 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 ## Alternatives considered
@@ -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 双语两侧都从「许可证」一节链到该文件。 [`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 都会拿工作树过滤一遍。这个场景正由断言兜底。 有一处触发缺口是接受而非绕过的:lefthook 只检视磁盘上存在的文件,因此**删除**清单文件不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法试过,不成立——无论怎么给列表,lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。
@@ -24,11 +24,11 @@ Status: implemented
运行时层刻意覆盖**所有可挂载的插件**,而不止 CLI、Web UI 与 Python 运行时默认加载的那些。`scripts/install.sh` 安装的就是仓库本身,用户的 `cordis.yml` 可以挂载任何插件包;`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列即使没有任何默认装配引入,也会触达真实用户。对法务披露而言,披露不足才是代价更高的那个方向。 运行时层刻意覆盖**所有可挂载的插件**,而不止 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 ## Testing
断言新鲜度的同一个 spec 也用夹具清单钉住分层规则,覆盖促成该规则的两个场景:测试支撑包的 `dependencies` 条目,以及没有任何应用挂载的插件包。它同时钉住被收编包的表格解析器能读出已提交的清单表,且表格形态一变就解析为空——正是这一点让生成器直接失败,而不是产出一个空章节 断言新鲜度的同一个 spec 也用夹具清单钉住分层规则,覆盖促成该规则的两个场景:测试支撑包的 `dependencies` 条目,以及没有任何应用挂载的插件包。它还把各解析器钉在那些原本会让某个包无声消失的形态上:不再覆盖全部收编目录的 `vendor/README.md` 表、含 extras 的依赖数组(`"httpx[http2]"`)、完全不带版本的依赖、作者自取名字的 `[dependency-groups]` 表,以及任何硬编码列表都不含的工作区成员区域。这些都是静默漏报路径——正是披露文件最担不起的失败方式
## Alternatives considered ## Alternatives considered
+1
View File
@@ -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. `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/`) ## Python SDK dependencies (`python/`)
Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the development workflow tool. Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the development workflow tool.
+1 -1
View File
@@ -25,7 +25,7 @@ pre-commit:
# lefthook only inspects files present on disk — so that one case still # lefthook only inspects files present on disk — so that one case still
# falls through to the freshness assertion in the test lane. # falls through to the freshness assertion in the test lane.
- name: third-party notices (staged) - 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 run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md
- name: whitespace (staged) - name: whitespace (staged)
+57 -5
View File
@@ -1,7 +1,7 @@
import { readdirSync, readFileSync } from 'node:fs' import { readdirSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest' 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, '..') const root = resolve(import.meta.dirname, '..')
@@ -94,9 +94,61 @@ describe('parsePythonRequirements', () => {
}) })
it('reads the committed manifests', () => { it('reads the committed manifests', () => {
const text = readFileSync(resolve(root, 'python/sdk/pyproject.toml'), 'utf8') expect(parsePyprojectRequirements(readFileSync(resolve(root, 'python/sdk/pyproject.toml'), 'utf8'))).toContain('pydantic')
const block = /dependencies\s*=\s*\[([^\]]*)\]/.exec(text)?.[1] ?? '' })
})
expect(parsePythonRequirements(block)).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',
])
}) })
}) })
+139 -24
View File
@@ -110,11 +110,36 @@ function readManifest(rel: string): Manifest {
return JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as 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. */ /** Every workspace manifest, keyed by path, plus the set of workspace package names. */
function loadWorkspaceManifests(): { manifests: Map<string, Manifest>; names: Set<string> } { function loadWorkspaceManifests(): { manifests: Map<string, Manifest>; names: Set<string> } {
// `native/landlock-run` is a nested workspace with its own lock file; its const patterns = manifestPatterns(workspaceMembers('pnpm-workspace.yaml'), workspaceMembers('native/landlock-run/pnpm-workspace.yaml'))
// 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 manifests = new Map<string, Manifest>() const manifests = new Map<string, Manifest>()
const names = new Set<string>() const names = new Set<string>()
for (const pattern of patterns) { for (const pattern of patterns) {
@@ -132,15 +157,21 @@ function loadWorkspaceManifests(): { manifests: Map<string, Manifest>; names: Se
function installedMetadata(name: string): { license: string; repo: string } { function installedMetadata(name: string): { license: string; repo: string } {
const override = OVERRIDES[name] const override = OVERRIDES[name]
let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined
const direct = resolve(root, 'node_modules', name, 'package.json') // The nested Landlock workspace installs into its own store, so a package
if (existsSync(direct)) { // only that workspace depends on is unreachable from the root one.
manifest = JSON.parse(readFileSync(direct, 'utf8')) as typeof manifest for (const store of ['node_modules', 'native/landlock-run/node_modules']) {
} else { const direct = resolve(root, store, name, 'package.json')
const prefix = `${name.replace('/', '+')}@` if (existsSync(direct)) {
const entry = readdirSync(resolve(root, 'node_modules/.pnpm')).find(dir => dir.startsWith(prefix)) manifest = JSON.parse(readFileSync(direct, 'utf8')) as typeof manifest
if (entry !== undefined) { break
manifest = JSON.parse(readFileSync(resolve(root, 'node_modules/.pnpm', entry, 'node_modules', name, 'package.json'), 'utf8')) as typeof manifest
} }
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 license = override?.license ?? manifest?.license
const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage 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 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/`. */ /** Direct Python dependencies named by the `pyproject.toml` manifests under `python/`. */
function collectPython(): { name: string; license: string; repo: string; role: string }[] { function collectPython(): { name: string; license: string; repo: string; role: string }[] {
const found = new Set<string>() const found = new Set<string>()
for (const path of ['python/sdk/pyproject.toml', 'python/sdk-runtime/pyproject.toml']) { for (const path of ['python/sdk/pyproject.toml', 'python/sdk-runtime/pyproject.toml']) {
const text = readFileSync(resolve(root, path), 'utf8') for (const name of parsePyprojectRequirements(readFileSync(resolve(root, path), 'utf8'))) {
// Requirement arrays only: `[project] name`/`readme` and `[tool.*]` string if (name.startsWith('deepseek')) continue
// values would otherwise read as dependencies. found.add(name)
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)
}
} }
} }
return [...found].sort((a, b) => a.localeCompare(b)).map((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. */ /** Render one npm dependency table. */
function renderNpmTable(deps: ExternalDep[]): string { function renderNpmTable(deps: ExternalDep[]): string {
const lines = ['| Package | License |', '| --- | --- |'] const lines = ['| Package | License |', '| --- | --- |']
@@ -331,7 +441,13 @@ export function render(): string {
const python = collectPython() const python = collectPython()
const patched = collectPatched() 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})`) const patchedLines = patched.map(({ spec, patch }) => `- \`${spec}\` — [\`${patch}\`](${patch})`)
return `<!-- Generated by scripts/gen-third-party-notices.ts — do not edit by hand. return `<!-- Generated by scripts/gen-third-party-notices.ts — do not edit by hand.
@@ -368,8 +484,7 @@ ${patchedLines.join('\n')}
External packages declared only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. They are not part of any shipped runtime artifact. External packages declared only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. They are not part of any shipped runtime artifact.
${renderNpmTable(devDeps)} ${renderNpmTable(devDeps)}
${renderNonPermissiveNote(nonPermissiveDev)}
${nonPermissiveDev.map(dep => `\`${dep.name}\` (${dep.license})`).join(' and ')} run only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact.
## Python SDK dependencies (\`python/\`) ## Python SDK dependencies (\`python/\`)