diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml index 07dedddfee..d01431c0f8 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.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-08-03-package-anchored-subsystem-pages.md -2026-08-03-package-anchored-subsystem-pages.md: 71e4c2a28f3f556e0d3df2242b410899df763e48 -2026-08-03-package-anchored-subsystem-pages.zh.md: 0f29a26e456ad7883d00ad51ae60aadc4acb5358 +2026-08-03-package-anchored-subsystem-pages.md: e5337fba0c9a69dd786fb156572e978f87435b9b +2026-08-03-package-anchored-subsystem-pages.zh.md: 25afe84faa43af59d4366b5cc0b6c2d2ad4da1ed diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md index 71e4c2a28f..e5337fba0c 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md @@ -14,7 +14,9 @@ Every `docs/subsystems/` page anchors to the package or package group that decla Every type a generated signature references must resolve somewhere in the folder: the agent ownership vocabulary moved from the generator's `TYPE_LINK_EXEMPTIONS` into `LINK_MAP → core.md`, so exemptions are reserved for genuinely service-local or vendored shapes. Each pasted declaration has one home (`SessionEvent` lives on [session.md](../../../../docs/subsystems/session.md); core.md summarizes and links). -Every `packages//README.md` pair is a thin entry point in one shape: a why-first intro paragraph, a package table (Package / Role / ctx key), and a closing pointer to the owning subsystems page. Load-bearing prose that outgrows that shape relocates to the owning subsystems page rather than being deleted. +Every `packages//README.md` pair is a thin entry point in one shape: a why-first intro paragraph, a package table (Package / Role / ctx key), and a closing pointer to the owning subsystems page. A group that declares no standalone subsystem reference is instead classified with a non-empty rationale in `GROUPS_WITHOUT_SUBSYSTEM_PAGE`. Load-bearing prose that outgrows that shape relocates to the owning subsystems page rather than being deleted. + +`verify-subsystem-pages` discovers groups from both group READMEs and child package manifests. It rejects a missing group README, a group with neither a reader-visible direct link to one English file under `docs/subsystems/` nor an explicit exemption, a blank or orphaned exemption, an exempt group that gains a link, and a link whose page is absent; code, comments, images, nested paths, and traversal do not satisfy ownership. The check runs as an independent `doc-sync` leaf, so adding a package group cannot silently omit its documentation owner. The [subsystems README](../../../../docs/subsystems/README.md) indexes every page in the folder on both language sides; `scripts/project-doc-site.spec.ts` enforces one table row per page, so an added or merged page cannot silently miss the index. @@ -29,6 +31,7 @@ The [subsystems README](../../../../docs/subsystems/README.md) indexes every pag ## Consequences - Which page documents a type is predictable from `packages//`; the subsystems README is a complete index enforced by test. +- Every package group makes its subsystem owner or justified absence reviewable, and `verify-subsystem-pages` rejects unclassified additions and stale exemptions. - Generated signature footers link the agent ownership vocabulary instead of silently exempting it. - `verify-type-equiv`'s 1:1 manifest keeps each paste single-homed; the duplicate `SessionEvent` paste is gone. - The [original catalog note](2026-06-20-core-data-structures-catalog.md) remains the owner of the `ts type-equiv` drift-gate mechanism; only its page-scoping rule is superseded here. diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md index 0f29a26e45..25afe84faa 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md @@ -14,7 +14,9 @@ Status: implemented 生成签名引用的每个类型都必须能在目录中某处解析:agent 所有权词汇从生成器的 `TYPE_LINK_EXEMPTIONS` 移入 `LINK_MAP → core.md`,因此豁免只留给确实仅用于服务内部或来自 vendored 代码的类型结构。每个粘贴的声明只有一个家(`SessionEvent` 位于 [session.md](../../../../docs/subsystems/session.zh.md);core.md 概括并链接)。 -每个 `packages//README.md` 配对都是统一形状的精简入口:一段先说明「为什么」的介绍、一张包表格(包 / 角色 / ctx 键)、一个指向对应子系统页面的收尾链接。如果承载关键信息的正文超出这一结构所能容纳的范围,就将其迁移到对应的子系统页面,而非删除。 +每个 `packages//README.md` 配对都是统一形状的精简入口:一段先说明「为什么」的介绍、一张包表格(包 / 角色 / ctx 键)、一个指向对应子系统页面的收尾链接。未声明独立子系统参考资料的分组会在 `GROUPS_WITHOUT_SUBSYSTEM_PAGE` 中附上非空理由。如果承载关键信息的正文超出这一结构所能容纳的范围,就将其迁移到对应的子系统页面,而非删除。 + +`verify-subsystem-pages` 同时从分组 README 和子包 manifest(元数据清单)发现分组。它会拒绝缺少分组 README、分组既没有面向读者且直接指向 `docs/subsystems/` 下某一个英文文件的链接也没有显式豁免、豁免为空或成为孤立项、已豁免分组新增链接,以及链接指向的页面不存在;代码、注释、图片、嵌套路径和路径穿越都不能满足所有权声明。该检查作为独立的 `doc-sync`(文档同步)叶节点运行,因此新增包分组时不能悄悄遗漏其文档拥有方。 [子系统 README](../../../../docs/subsystems/README.zh.md) 在中英文两侧索引目录中的每一页;`scripts/project-doc-site.spec.ts` 强制每个页面对应一个表格行,因此新增或合并吸收的页面无法悄悄缺席索引。 @@ -29,6 +31,7 @@ Status: implemented ## 后果 - 哪一页记录某类型可由 `packages//` 预测;子系统 README 是由测试强制的完整索引。 +- 每个包分组都会将其子系统拥有方或合理的缺席原因暴露给评审,且 `verify-subsystem-pages` 会拒绝未分类的新增项和陈旧豁免。 - 生成的签名页脚链接 agent 所有权词汇,而不是静默豁免。 - `verify-type-equiv` 的 1:1 manifest(元数据清单)保证每个粘贴单一归属;重复的 `SessionEvent` 粘贴已移除。 -- [原目录 Agent Note](2026-06-20-core-data-structures-catalog.zh.md) 仍拥有 `ts type-equiv` 漂移门禁机制;此处仅取代其页面范围界定规则。 +- [原目录 Agent Note](2026-06-20-core-data-structures-catalog.zh.md) 仍拥有 `ts type-equiv` 漂移检查机制;此处仅取代其页面范围界定规则。 diff --git a/package.json b/package.json index 54acae5cae..dc3cc5d249 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "verify-doc-site-fragments": "tsx scripts/verify-doc-site-fragments.ts", "verify-public-repository-links": "tsx scripts/verify-public-repository-links.ts", "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", + "verify-subsystem-pages": "tsx scripts/verify-subsystem-pages.ts", "verify-package-paths": "tsx scripts/verify-package-paths.ts", "verify-dsh-package-licenses": "tsx scripts/verify-dsh-package-licenses.ts", "verify-config-source-ownership": "tsx scripts/verify-config-source-ownership.ts", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index eb75c9acb8..e753147a72 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -22,6 +22,6 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), uses `rootDir: src`, `outDir: lib/types`, and references each workspace dependency plus `runtime-diagnostics/invariants`; registers in exactly one aggregate. Only `api/remotes` splits for generated contracts; ordinary two-entry Client plugins do not ([layout](../docs/development.md#typescript-project-layout)). - `src/types.ts` contains only types — no runtime code. - Tests live at package level under `tests/`, not `src/__tests__/`. -- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code. +- Update package README and JSDoc contracts in the same commit as behavior, and verify them against code with [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md). Group READMEs declare subsystem ownership through a canonical English page link or justified [exemption](../scripts/verify-subsystem-pages.ts). - Package READMEs document model, token, and KV-cache effects using the [canonical Model Experience format](../docs/cookbook/adding-a-package.md#4-write-the-package-readme). - Package READMEs put durable consumer gaps and non-obvious maintainer constraints under `## Known Limitations and Deferred Work`; ordinary cleanup stays in its TODO or Agent Note. Packages with none use a justified [allowlist entry](../scripts/verify-package-readme-limitations.ts) ([rationale](../.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.md)). diff --git a/packages/attachment/README.i18n.yaml b/packages/attachment/README.i18n.yaml index 9db0a63e47..89845aedf6 100644 --- a/packages/attachment/README.i18n.yaml +++ b/packages/attachment/README.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 packages/attachment/README.md -README.md: 61b4e5c602f475f85bbe859b8483e30b518e06c8 -README.zh.md: ac93f4870a714d3131fc47789dac9cb5ae926b66 +README.md: 556c98b40f601a631e65ccb2121d30fa74b97399 +README.zh.md: 2915e8e207acfdc389b04c609d86ec2ef9c083b9 diff --git a/packages/attachment/README.md b/packages/attachment/README.md index 61b4e5c602..556c98b40f 100644 --- a/packages/attachment/README.md +++ b/packages/attachment/README.md @@ -10,3 +10,5 @@ The durable binary attachment seam and its local filesystem implementation. Both | `attachment-local/` | Content-addressed private storage below `DSH_HOME` | (registers on `ctx.attachments`) | Unsent browser drafts are intentionally outside this capability. Bytes enter durable storage only when a user prompt is submitted or when a provider adapter commits structured model output. + +See [durable image attachments](../../docs/subsystems/attachment.md) for reference validation, storage, and verified-read contracts. diff --git a/packages/attachment/README.zh.md b/packages/attachment/README.zh.md index ac93f4870a..2915e8e207 100644 --- a/packages/attachment/README.zh.md +++ b/packages/attachment/README.zh.md @@ -10,3 +10,5 @@ | `attachment-local/` | `DSH_HOME` 下的私有内容寻址存储 | (注册至 `ctx.attachments`) | 未发送的浏览器草稿刻意位于这项能力之外。只有用户提交提示词,或提供方适配器提交结构化模型输出时,字节才进入持久存储。 + +有关引用校验、存储和经过校验的读取约定,参见[持久图片附件](../../docs/subsystems/attachment.zh.md)。 diff --git a/packages/extensions/README.i18n.yaml b/packages/extensions/README.i18n.yaml index 939ec03cc0..4132811cc4 100644 --- a/packages/extensions/README.i18n.yaml +++ b/packages/extensions/README.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 packages/extensions/README.md -README.md: 12717c43b0cf0a2a7958ed2e18f608da23c67963 -README.zh.md: df2d70d3e534b0dfefafe6e1192f20eb5f237d6a +README.md: 27957a4683edf840cbd7bad00a809ceac8944c20 +README.zh.md: 173ec99b9418015fced23e72453bcd4d814d7009 diff --git a/packages/extensions/README.md b/packages/extensions/README.md index 12717c43b0..27957a4683 100644 --- a/packages/extensions/README.md +++ b/packages/extensions/README.md @@ -10,3 +10,5 @@ Model-facing tools over the live cordis runtime the agent itself runs inside: in | [`cordis-host-runner/`](cordis-host-runner/README.md) | Definition registry, the `node:vm` sandbox for host halves, and the request-run round trip | provides `ctx.dynamicCordisRunner` | | [`cordis-client-runner/`](cordis-client-runner/README.md) | Browser half of a dual-half package: evaluates the definition into a live browser plugin and answers the run request | client face; provides the browser `ctx.dynamicCordisRunner` | | [`ui-cordis/`](ui-cordis/README.md) | Browser surfaces: the frame-wide panel that operates every definition, and the read-only define card | client face; registers slots | + +See [Extensions](../../docs/subsystems/extensions.md) for package lifecycle, sandbox behavior, and the generated Cordis API. diff --git a/packages/extensions/README.zh.md b/packages/extensions/README.zh.md index df2d70d3e5..173ec99b94 100644 --- a/packages/extensions/README.zh.md +++ b/packages/extensions/README.zh.md @@ -10,3 +10,5 @@ agent 修改自身运行时:检查已加载的插件与服务接口、定义 | [`cordis-host-runner/`](cordis-host-runner/README.zh.md) | 定义注册表、host 半的 `node:vm` 沙箱,以及 request-run 往返 | 提供 `ctx.dynamicCordisRunner` | | [`cordis-client-runner/`](cordis-client-runner/README.zh.md) | 双半包的浏览器半:把定义求值成活的浏览器插件,并应答运行请求 | client 面;提供浏览器侧 `ctx.dynamicCordisRunner` | | [`ui-cordis/`](ui-cordis/README.zh.md) | 浏览器面:操作全部定义的全局面板,与只读的 define 卡片 | client 面;注册 slot | + +有关包生命周期、沙箱行为和生成的 Cordis API,参见[扩展](../../docs/subsystems/extensions.zh.md)。 diff --git a/packages/preset/README.i18n.yaml b/packages/preset/README.i18n.yaml index bf3583068a..30688b580a 100644 --- a/packages/preset/README.i18n.yaml +++ b/packages/preset/README.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 packages/preset/README.md -README.md: 5e7805eaa303b78c4d8a1a8972e1d031ee304fae -README.zh.md: 217d807f40abc0908fb0c67fa0c94dc4d0806592 +README.md: 24d13075281689f5e3df8f91c4f13035fdb4acb9 +README.zh.md: a529eea6667da50204ccd4fa958b8c443af65e99 diff --git a/packages/preset/README.md b/packages/preset/README.md index 5e7805eaa3..24d1307528 100644 --- a/packages/preset/README.md +++ b/packages/preset/README.md @@ -14,3 +14,5 @@ The presets the deployment ships live in [`apps/cli/config/agent-presets/`](../. The composition split this group assumes: registries and cross-session facilities are process singletons and stay in the host composition, while a preset carries what one agent contributes to them. A preset that names a row publishing a process-global service is rejected at mount rather than allowed to collide with the next session. Design: [the per-session agent-preset note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md). + +See the [`AgentPresets` reference](../../docs/subsystems/core.md#ctxagentpresets--agentpresets) for discovery, mounting, inheritance, and recomposition. diff --git a/packages/preset/README.zh.md b/packages/preset/README.zh.md index 217d807f40..a529eea666 100644 --- a/packages/preset/README.zh.md +++ b/packages/preset/README.zh.md @@ -14,3 +14,5 @@ 本组假定的组装划分是:注册表与跨会话设施是进程单例,留在宿主组装中;preset 只承载单个 agent 对它们的贡献。若 preset 中某一行发布了进程级全局服务,挂载时即被拒绝,而不是留到与下一个会话相撞。 设计详见 [按会话组装 agent preset 的 Agent Note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md)。 + +有关发现、挂载、继承和重新组装的约定,参见 [`AgentPresets` 参考](../../docs/subsystems/core.zh.md#ctxagentpresets--agentpresets)。 diff --git a/packages/runtime-diagnostics/README.i18n.yaml b/packages/runtime-diagnostics/README.i18n.yaml new file mode 100644 index 0000000000..e03fbba1cb --- /dev/null +++ b/packages/runtime-diagnostics/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 packages/runtime-diagnostics/README.md +README.md: f8df688f2dc053d579437dfdd04cee790973c1db +README.zh.md: 294bf3369c4049c9c350713ed497ca4374a99c2f diff --git a/packages/runtime-diagnostics/README.md b/packages/runtime-diagnostics/README.md new file mode 100644 index 0000000000..f8df688f2d --- /dev/null +++ b/packages/runtime-diagnostics/README.md @@ -0,0 +1,11 @@ +# runtime-diagnostics/ — package-owned runtime checks + +English | [中文](README.zh.md) + +Configurable diagnostics that check package-owned runtime relationships without changing product behavior. + +| Package | Role | ctx key | +|---|---|---| +| [`invariants/`](invariants/README.md) | Registers and runs package-owned runtime invariant companions | `ctx.invariants` | + +See [runtime invariants](../../docs/subsystems/invariants.md) for selection, lifecycle, and package-attributed failure semantics. diff --git a/packages/runtime-diagnostics/README.zh.md b/packages/runtime-diagnostics/README.zh.md new file mode 100644 index 0000000000..294bf3369c --- /dev/null +++ b/packages/runtime-diagnostics/README.zh.md @@ -0,0 +1,11 @@ +# runtime-diagnostics/:包自有运行时检查 + +[English](README.md) | 中文 + +可配置诊断用于检查包自有的运行时关系,而不改变产品行为。 + +| 包 | 角色 | ctx 键 | +|---|---|---| +| [`invariants/`](invariants/README.zh.md) | 注册并运行包自有的运行时不变式配套入口 | `ctx.invariants` | + +有关选择、生命周期和归因到包的失败语义,参见[运行时不变式](../../docs/subsystems/invariants.zh.md)。 diff --git a/packages/typert/README.i18n.yaml b/packages/typert/README.i18n.yaml index 35034e4c11..ac93f32e4b 100644 --- a/packages/typert/README.i18n.yaml +++ b/packages/typert/README.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 packages/typert/README.md -README.md: ad9f843e48be0e3be85921ed8fd3ca4e2c327160 -README.zh.md: 9ebd00f71a509e24a0b6c674c56d87c411dd7794 +README.md: 36b97510ceb6524b396e53eb05b6d0e702fce9fe +README.zh.md: 0bf6b83a606f7cd812772cdd116fd65825d66fd9 diff --git a/packages/typert/README.md b/packages/typert/README.md index ad9f843e48..36b97510ce 100644 --- a/packages/typert/README.md +++ b/packages/typert/README.md @@ -9,3 +9,5 @@ Typert separates source analysis, runtime storage, and Loader discovery. | [`registry/`](registry/README.md) | Stores runtime package reflection and schemas | `ctx.typert` | | [`loader/`](loader/README.md) | Discovers Loader entries and registers generated host artifacts | consumes `ctx.loader` and `ctx.typert` | | [`generator/`](generator/README.md) | Generates runtime artifacts from source types | build-time library | + +See [TypeRT remote calls](../../docs/subsystems/typert.md) for the generated invocation, schema, and transport contracts. diff --git a/packages/typert/README.zh.md b/packages/typert/README.zh.md index 9ebd00f71a..0bf6b83a60 100644 --- a/packages/typert/README.zh.md +++ b/packages/typert/README.zh.md @@ -9,3 +9,5 @@ Typert 将源代码分析、运行时存储和 Loader 发现机制分离。 | [`registry/`](registry/README.zh.md) | 存储运行时包反射和 schema | `ctx.typert` | | [`loader/`](loader/README.zh.md) | 发现 Loader 条目并注册生成的宿主产物 | 使用 `ctx.loader`、`ctx.typert` | | [`generator/`](generator/README.zh.md) | 从源代码类型生成运行时产物 | 构建时库 | + +有关所生成的调用、schema 和传输三方面的约定,参见 [TypeRT 远程调用](../../docs/subsystems/typert.zh.md)。 diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 02dc89c868..99b4c462df 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -84,6 +84,12 @@ describe('gate graph validation', () => { expect(ids).toContain('public-repository-links') }) + it('keeps package-group subsystem ownership in the documentation gate', () => { + const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id)) + + expect(ids).toContain('subsystem-pages') + }) + it('keeps the hygiene aggregate aligned with the package script checks', () => { const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id)) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 872041c32b..9b433da374 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -674,6 +674,7 @@ function docSyncLeafGates(options: { pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }), pnpmScript('public-repository-links', 'verify-public-repository-links', { label: 'public repository links' }), pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }), + pnpmScript('subsystem-pages', 'verify-subsystem-pages', { label: 'subsystem pages' }), pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }), pnpmScript('config-source-ownership', 'verify-config-source-ownership', { label: 'config source ownership' }), pnpmScript('package-readme-model-experience', 'verify-package-readme-model-experience', { label: 'package README model experience' }), diff --git a/scripts/verify-subsystem-pages.spec.ts b/scripts/verify-subsystem-pages.spec.ts new file mode 100644 index 0000000000..45eeedb35e --- /dev/null +++ b/scripts/verify-subsystem-pages.spec.ts @@ -0,0 +1,123 @@ +/** Regression coverage for package-group subsystem-page ownership. */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { describe, expect, it, onTestFinished } from 'vitest' +import { auditSubsystemPages } from './verify-subsystem-pages.ts' + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-subsystem-pages-')) + onTestFinished(() => { + rmSync(root, { recursive: true, force: true }) + }) + return root +} + +function write(root: string, path: string, source: string): void { + const absolute = join(root, path) + mkdirSync(dirname(absolute), { recursive: true }) + writeFileSync(absolute, source) +} + +describe('package-group subsystem pages', () => { + it('accepts a direct page link and a justified no-page group', () => { + const root = fixture() + write(root, 'packages/alpha/README.md', '[types](../../docs/subsystems/alpha.md#contract)\n') + write(root, 'packages/alpha/alpha/package.json', '{}\n') + write(root, 'docs/subsystems/alpha.md', '# Alpha\n') + write(root, 'packages/adapter/README.md', '# Adapter\n') + + expect(auditSubsystemPages(root, { adapter: 'Adapter over an existing subsystem.' })).toEqual({ + groups: 2, + linked: 1, + exempt: 1, + violations: [], + }) + }) + + it('rejects a new group whose README never declares subsystem ownership', () => { + const root = fixture() + write(root, 'packages/schedule/README.md', '# Schedule\n') + write(root, 'packages/schedule/tool-schedule/package.json', '{}\n') + + expect(auditSubsystemPages(root, {}).violations).toEqual([ + 'packages/schedule/README.md: no reader-visible direct docs/subsystems/*.md link; add the owning page and link, or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry', + ]) + }) + + it('does not treat the subsystem index or a Chinese counterpart as an owning page', () => { + const root = fixture() + write( + root, + 'packages/wrong/README.md', + '[index](../../docs/subsystems/README.md) [Chinese](../../docs/subsystems/wrong.zh.md)\n', + ) + write(root, 'docs/subsystems/README.md', '# Subsystems\n') + write(root, 'docs/subsystems/wrong.zh.md', '# Wrong\n') + + expect(auditSubsystemPages(root, {}).violations).toEqual([ + 'packages/wrong/README.md: no reader-visible direct docs/subsystems/*.md link; add the owning page and link, or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry', + ]) + }) + + it('does not count links hidden in code, comments, or image syntax', () => { + const root = fixture() + write( + root, + 'packages/hidden/README.md', + [ + '`[inline](../../docs/subsystems/hidden.md)`', + '```md', + '[fenced](../../docs/subsystems/hidden.md)', + '```', + '', + '![image](../../docs/subsystems/hidden.md)', + '', + ].join('\n'), + ) + write(root, 'docs/subsystems/hidden.md', '# Hidden\n') + + expect(auditSubsystemPages(root, {}).violations).toEqual([ + 'packages/hidden/README.md: no reader-visible direct docs/subsystems/*.md link; add the owning page and link, or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry', + ]) + }) + + it('rejects a link that escapes the subsystem directory', () => { + const root = fixture() + write(root, 'packages/escape/README.md', '[escape](../../docs/subsystems/../architecture.md)\n') + write(root, 'docs/architecture.md', '# Architecture\n') + + expect(auditSubsystemPages(root, {}).violations).toEqual([ + 'packages/escape/README.md: no reader-visible direct docs/subsystems/*.md link; add the owning page and link, or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry', + ]) + }) + + it('rejects missing group READMEs and missing linked pages', () => { + const root = fixture() + write(root, 'packages/no-readme/pkg/package.json', '{}\n') + write(root, 'packages/broken/README.md', '[missing](../../docs/subsystems/missing.md)\n') + + expect(auditSubsystemPages(root, {}).violations).toEqual([ + 'packages/broken/README.md: linked subsystem page does not exist: docs/subsystems/missing.md', + 'packages/no-readme/README.md: package group has no group README declaring subsystem ownership', + ]) + }) + + it('rejects blank, orphaned, and stale exemptions', () => { + const root = fixture() + write(root, 'packages/linked/README.md', '[types](../../docs/subsystems/linked.md)\n') + write(root, 'docs/subsystems/linked.md', '# Linked\n') + write(root, 'packages/blank/README.md', '# Blank\n') + + expect(auditSubsystemPages(root, { + blank: ' ', + linked: 'No page.', + orphan: 'Removed group.', + }).violations).toEqual([ + 'exemption blank: missing justification for omitting a subsystem page', + 'exemption orphan: no matching package group; remove the stale entry', + 'packages/linked/README.md: links a subsystem page but remains exempt; remove the stale exemption', + ]) + }) +}) diff --git a/scripts/verify-subsystem-pages.ts b/scripts/verify-subsystem-pages.ts new file mode 100644 index 0000000000..92e1ad09c6 --- /dev/null +++ b/scripts/verify-subsystem-pages.ts @@ -0,0 +1,145 @@ +/** + * Doc-sync gate for package-group subsystem references. Every package group + * either links at least one existing `docs/subsystems/` page from its English + * group README or carries an explicit, justified exemption below. + */ + +import { existsSync, globSync, readFileSync } from 'node:fs' +import { resolve, sep } from 'node:path' +import { parseMarkdown, visitMarkdown } from './markdown.ts' + +const root = resolve(import.meta.dirname, '..') + +/** + * Package groups that do not own a standalone subsystem reference. Reasons + * are reviewable policy: a new group cannot silently inherit an exemption. + */ +export const GROUPS_WITHOUT_SUBSYSTEM_PAGE: Readonly> = { + acp: 'Protocol transport entry point; the server package README owns its interoperability contract.', + api: 'Remote transport and BFF assembly; Typert and the package READMEs own the underlying contracts.', + boot: 'Shared application-bin boot library rather than a runtime subsystem.', + bundle: 'Composition patch carriers whose mounted packages own all runtime contracts.', + e2b: 'Provider implementations of the filesystem and subprocess subsystems, not a new capability contract.', + examples: 'Non-product demonstration compositions whose mounted packages own all runtime contracts.', + experimental: 'Empty staging group; promoted packages move to their product-role group before release.', + feedback: 'One command producer and inline log-event payload; its package README and persistence catalog own the complete contract.', + hooks: 'External hook-protocol bridges over existing interception points, not a new Harness service.', + identity: 'Shared anonymous correlation values rather than authenticated account or authorization behavior.', + mcp: 'Integration adapter that contributes external tools through the existing tool registry.', + sdk: 'Out-of-process protocol and client packages whose package READMEs own the SDK contracts.', + util: 'Low-level primitives whose business semantics remain with their consuming subsystems.', +} + +/** Result of auditing package-group subsystem documentation. */ +export interface SubsystemPageAudit { + /** Package groups discovered from group READMEs or child package manifests. */ + readonly groups: number + /** Groups carrying at least one direct subsystem-page link. */ + readonly linked: number + /** Groups covered by an explicit no-page policy. */ + readonly exempt: number + /** Actionable contract violations. */ + readonly violations: readonly string[] +} + +/** Normalize one filesystem glob result to repository slash form. */ +function normalize(path: string): string { + return path.split(sep).join('/') +} + +/** Extract the package-group segment from a repository-relative path. */ +function groupOf(path: string): string { + const group = path.split('/')[1] + if (group === undefined || group.length === 0) throw new Error(`invalid package path: ${path}`) + return group +} + +/** Return canonical subsystem-page targets linked by one group README. */ +function subsystemLinks(source: string): string[] { + const links = new Set() + visitMarkdown(parseMarkdown(source), (node) => { + if (node.type !== 'link') return + const match = /^\.\.\/\.\.\/docs\/subsystems\/([^/#?]+\.md)(?:#[^?#]*)?$/.exec(node.url) + const page = match?.[1] + if (page !== undefined && page !== 'README.md' && !page.endsWith('.zh.md')) links.add(`docs/subsystems/${page}`) + }) + return [...links].sort() +} + +/** + * Audit package-group subsystem ownership for one repository tree. + * @param scanRoot - repository root containing `packages/` and `docs/`. + * @param exemptions - groups intentionally carrying no subsystem-page link. + * @returns counts plus every actionable violation. + */ +export function auditSubsystemPages( + scanRoot: string = root, + exemptions: Readonly> = GROUPS_WITHOUT_SUBSYSTEM_PAGE, +): SubsystemPageAudit { + const readmes = globSync('packages/*/README.md', { cwd: scanRoot }).map(normalize).sort() + const manifests = globSync('packages/*/*/package.json', { cwd: scanRoot }).map(normalize).sort() + const groups = new Set([...readmes, ...manifests].map(groupOf)) + const violations: string[] = [] + let linked = 0 + let exempt = 0 + + for (const [group, reason] of Object.entries(exemptions)) { + if (!groups.has(group)) { + violations.push(`exemption ${group}: no matching package group; remove the stale entry`) + } + if (reason.trim().length === 0) { + violations.push(`exemption ${group}: missing justification for omitting a subsystem page`) + } + } + + for (const group of [...groups].sort()) { + const readme = `packages/${group}/README.md` + const readmePath = resolve(scanRoot, readme) + if (!existsSync(readmePath)) { + violations.push(`${readme}: package group has no group README declaring subsystem ownership`) + continue + } + + const links = subsystemLinks(readFileSync(readmePath, 'utf8')) + const isExempt = Object.hasOwn(exemptions, group) + if (links.length === 0) { + if (isExempt) { + exempt += 1 + } else { + violations.push( + `${readme}: no reader-visible direct docs/subsystems/*.md link; add the owning page and link,` + + ' or add a justified GROUPS_WITHOUT_SUBSYSTEM_PAGE entry', + ) + } + continue + } + + linked += 1 + if (isExempt) { + violations.push(`${readme}: links a subsystem page but remains exempt; remove the stale exemption`) + } + for (const page of links) { + if (!existsSync(resolve(scanRoot, page))) { + violations.push(`${readme}: linked subsystem page does not exist: ${page}`) + } + } + } + + return { groups: groups.size, linked, exempt, violations } +} + +/** Run the repository audit as a standalone doc-sync gate. */ +function main(): void { + const audit = auditSubsystemPages() + if (audit.violations.length > 0) { + console.error('verify-subsystem-pages: package-group documentation violations found:') + for (const violation of audit.violations) console.error(` ${violation}`) + process.exit(1) + } + console.log( + `verify-subsystem-pages: ${String(audit.groups)} group(s) checked` + + ` (${String(audit.linked)} linked, ${String(audit.exempt)} explicitly exempt), all conform.`, + ) +} + +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) main()