Files
deepseek-harness/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md
T
ZiyaZhang 04c73df2f6 docs: keep notices fresh at commit time instead of a new CI gate
Regenerate THIRD_PARTY_NOTICES.md from a pre-commit job whenever a
manifest, lock file, vendor manifest, or pyproject is staged, and assert
the committed bytes inside the generator spec the test lane already runs.
Drops the separate doc-sync gate: no extra CI process, and a dependency
edit no longer bounces back from CI to rerun a generator.
2026-07-30 07:56:13 -07:00

7.0 KiB

Agent Note: Generated third-party notices

Status: implemented

English | 中文

Problem

Open-sourcing this repository requires disclosing the third-party software it depends on, with each project's license. The disclosure has to be complete, has to stay true as dependencies change, and has to say something a reader can act on — which of these packages end up on a user's machine, and which only build and test the repository.

A hand-written inventory answers none of those durably. Roughly a hundred rows of names and license strings derived from manifests drift silently the moment a package is added, removed, or relicensed, and nothing would notice.

Decision

THIRD_PARTY_NOTICES.md is generated by 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 manifest, lock file, vendor/README.md, or pyproject.toml is staged, 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, 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.

The file discloses direct dependencies only. The complete npm closure with pinned versions already lives in pnpm-lock.yaml (pnpm licenses list renders it) and the Python closure in python/sdk/uv.lock; re-materializing either as prose would be a second, worse copy.

Tiering is by declaring area, not by manifest section. A package is a runtime dependency when any manifest outside DEV_ONLY_AREAS — the root manifest, packages/support/, packages/client/test-runtime/, website/, examples/, native/ — names it under dependencies or optionalDependencies. Section names alone are wrong in both directions: a test-support package declares vitest under dependencies without shipping it, and the bin/dsh launcher execs through tsx, which no manifest declares as a runtime dependency at all (the generator marks it runtime explicitly).

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.

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.

Alternatives considered

Keep the hand-written file and review it at release time. Reviewing a hundred derived rows by eye is exactly the work a generator does correctly, and the file's own claim — that it lists every direct dependency — would be unverified between releases.

Verify through a dedicated doc-sync gate. That is how every other generated artifact here is checked, and it was the first shape of this change. It costs a gate process and a scheduler slot in a matrix that is already long, and — worse — its only failure mode is telling a contributor, minutes after they pushed an unrelated dependency bump, to go rerun a generator. Regenerating at commit time removes the interruption, and the assertion inside a spec the test lane already runs keeps the guarantee at no additional CI cost.

Enumerate the full transitive closure. The closure is thousands of packages, already recorded in the lock files with exact versions, and would bury the direct dependencies that a reader actually evaluates. The file points at the lock files and the pnpm licenses list renderer instead.

Tier by manifest section (dependencies vs devDependencies). Mechanically simple and wrong on real data in both directions, as the tiering paragraph above records.

Tier by reachability from the shipped assemblies only (apps/* plus python/sdk-runtime). This produces a tighter runtime tier, but classifies the MCP client and the OpenTelemetry exporter as development-only even though a user running the installed repository can mount them. It understates the disclosure, which is the wrong direction to err for a legal notice.

Emit the notices as a bilingual pair. Every other root document is paired, but the file is a table of upstream package names, SPDX identifiers, and URLs; the translatable surface is a handful of section blurbs. scripts/translation-pairing.ts scopes discovery to README*, .agents/notes/**, docs/**, and python/**, so a root non-README file is outside the bilingual corpus by construction, and the README pair carries the bilingual entry points into it.

Consequences

A dependency edit now carries a regenerated notices file into the same commit. Contributors pay one generator run — about a second — on commits that touch a manifest, and nothing on any other commit. Committing with hooks disabled defers the cost to a test-lane failure that names the command.

The generator needs an installed tree, which makes it heavier than a pure-source generator, and a new package with unusable published metadata needs an OVERRIDES entry rather than silently rendering a blank license. Both failures are loud and name the remedy.

The tiering rule is a policy encoded in one constant. Adding a workspace area that never ships — a second test-infrastructure tier, another site — requires extending DEV_ONLY_AREAS, or its dependencies will be disclosed as runtime.