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.
This commit is contained in:
ZiyaZhang
2026-07-30 07:56:13 -07:00
parent 19606bc331
commit 04c73df2f6
7 changed files with 40 additions and 14 deletions
@@ -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: 5b85abf5716d79213f6937b938d3f0267f7627f3
2026-07-30-generated-third-party-notices.zh.md: 26b3e6e88ec038d9107c9b78c85a8c45a1916805
2026-07-30-generated-third-party-notices.md: 2a669be4c8dcf92beb08c1305098e1baa7626ef4
2026-07-30-generated-third-party-notices.zh.md: 990d15238a2939c54b4242016f9986e3f58a260b
@@ -8,11 +8,13 @@ English | [中文](2026-07-30-generated-third-party-notices.zh.md)
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 in `doc-sync` would notice.
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`](../../../../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`. `pnpm run verify-third-party-notices` runs the generator with `--check` as a `doc-sync` leaf gate, so a dependency change that skips regeneration fails the same way a stale catalog does. 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 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`](../../../../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.
@@ -24,12 +26,14 @@ License and repository metadata come from the installed pnpm store, so the gener
## Testing
[`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 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 — 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.
@@ -40,7 +44,7 @@ License and repository metadata come from the installed pnpm store, so the gener
## Consequences
Adding or removing a dependency now requires running `pnpm run gen-third-party-notices` and committing the result; `doc-sync` fails otherwise. That is the intended cost — the disclosure cannot silently go stale.
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.
@@ -8,11 +8,13 @@ Status: implemented
本仓库开源需要披露所依赖的第三方软件及各自的许可证。这份披露必须完整,必须随依赖变化保持为真,还必须给出读者用得上的信息:哪些包最终会进到用户机器上,哪些只用于构建和测试。
手写清单无法长期满足其中任何一条。约一百行从各清单文件推导出来的包名与许可证标识,只要有依赖新增、移除或换用许可证就会悄悄失真,而 `doc-sync`会察觉。
手写清单无法长期满足其中任何一条。约一百行从各清单文件推导出来的包名与许可证标识,只要有依赖新增、移除或换用许可证就会悄悄失真,而没有任何检查会察觉。
## Decision
[`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` 生成。`pnpm run verify-third-party-notices``--check` 运行生成器,作为 `doc-sync` 的叶子门禁;依赖变了却没重新生成,会像目录过期一样直接失败。根 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`pre-commit 任务就会重新生成并一并入库,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。
文件只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml``pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。
@@ -24,12 +26,14 @@ Status: implemented
## Testing
[`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 用夹具清单钉住分层规则,覆盖促成该规则的两个场景:测试支撑包的 `dependencies` 条目,以及没有任何应用挂载的插件包。它同时钉住被收编包的表格解析器能读出已提交的清单表,且表格形态一变就解析为空——正是这一点让生成器直接失败,而不是产出一个空章节。
断言新鲜度的同一个 spec 也用夹具清单钉住分层规则,覆盖促成该规则的两个场景:测试支撑包的 `dependencies` 条目,以及没有任何应用挂载的插件包。它同时钉住被收编包的表格解析器能读出已提交的清单表,且表格形态一变就解析为空——正是这一点让生成器直接失败,而不是产出一个空章节。
## Alternatives considered
**保留手写文件,发版时人工过一遍。** 用肉眼审阅上百行推导数据,恰恰是生成器能做对的活;而且在两次发版之间,文件自称「列出全部直接依赖」这句话无人验证。
**用专门的 `doc-sync` 门禁校验。** 仓库里其他生成产物都是这么把关的,本次改动最初也是这个形态。但它要在本已冗长的矩阵里再占一个门禁进程和一个调度位;更糟的是,它唯一的失败方式,就是在别人推完一个无关的依赖升级几分钟后,通知对方回去重跑一次生成器。改为提交时重新生成消除了这次打断,而把断言放进测试 lane 本就会跑的 spec 里,则以零额外 CI 成本保住了这项保证。
**列出完整传递闭包。** 闭包有数千个包,锁文件里已带精确版本,铺开只会淹没读者真正要评估的直接依赖。文件转而指向锁文件与 `pnpm licenses list`
**按清单字段分层(`dependencies` 与 `devDependencies`)。** 机械上最省事,但在真实数据上两个方向都会出错,理由见上文分层段落。
@@ -40,7 +44,7 @@ Status: implemented
## Consequences
此后增删依赖都需要运行 `pnpm run gen-third-party-notices` 并提交结果,否则 `doc-sync` 失败。这正是预期成本——披露不可能再悄悄过期
此后改动依赖时,重新生成的披露文件会随同一个提交入库。触及清单文件的提交多付一次生成器运行——约一秒;其余提交不受影响。若禁用钩子提交,代价推迟为一次测试 lane 失败,其报错会指明补救命令
生成器需要已安装的工作树,因此比纯源码生成器更重;发布元数据不可用的新包需要补一条 `OVERRIDES`,而不是默默渲染出空白许可证。这两类失败都会明确报错并指出补救方式。
+6
View File
@@ -18,6 +18,12 @@ pre-commit:
run: node_modules/.bin/tsx scripts/run-oxlint.ts --fix --no-error-on-unmatched-pattern {staged_files}
stage_fixed: true
# Regenerate rather than reject: a dependency edit that forgot the notices
# would otherwise fail the test lane long after the commit.
- 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}'
run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md
- name: whitespace (staged)
run: git diff --cached --check
+11 -1
View File
@@ -1,10 +1,20 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { type Manifest, parseVendoredRows, tierExternalDeps } from './gen-third-party-notices.ts'
import { type Manifest, parseVendoredRows, render, tierExternalDeps } from './gen-third-party-notices.ts'
const root = resolve(import.meta.dirname, '..')
describe('THIRD_PARTY_NOTICES.md', () => {
// Freshness lives here rather than in its own doc-sync gate: this spec file
// already runs in the test lane, so the check costs no extra CI process.
// Pre-commit regenerates the file whenever a manifest is staged, so reaching
// this assertion means the notices were committed without that hook.
it('matches what the generator produces from the current manifests', () => {
expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(render())
})
})
/** Build the (manifests, names) pair `tierExternalDeps` consumes. */
function workspace(entries: Record<string, Manifest>): { manifests: Map<string, Manifest>; names: Set<string> } {
const manifests = new Map(Object.entries(entries))
+5 -2
View File
@@ -283,8 +283,11 @@ function renderNpmTable(deps: ExternalDep[]): string {
return lines.join('\n')
}
/** Render the complete notices document. */
function render(): string {
/**
* Render the complete notices document.
* @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold.
*/
export function render(): string {
verifyBuildTimePins()
const npm = collectNpmDeps()
const runtimeDeps = npm.filter(dep => dep.runtime)
-1
View File
@@ -472,7 +472,6 @@ function docSyncLeafGates(options: {
pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }),
pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }),
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
pnpmScript('third-party-notices', 'verify-third-party-notices', { label: 'third-party notices' }),
pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }),
pnpmScript('scoped-events', 'verify-scoped-events', { label: 'scoped events' }),
pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }),