Merge remote-tracking branch 'origin/master' into xjt/generated-docs-zh-translation-apply
# Conflicts: # packages/boot/app-boot/README.i18n.yaml # packages/boot/app-boot/README.zh.md
This commit is contained in:
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md
|
||||
2026-06-24-web-capability-seam.md: b705236690859961ed69b307dbb59ebefcbd65ac
|
||||
2026-06-24-web-capability-seam.zh.md: e3dc836004bf785c6811e4c4014e105266dbaade
|
||||
2026-06-24-web-capability-seam.zh.md: 15a16b12119f69cec632e219585600d261fda54f
|
||||
@@ -322,6 +322,8 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他
|
||||
|
||||
**大量 web 内容可能损害上下文质量。** 提供方强制执行字节/字符上限并报告 `truncated`;`tool-web` 格式化有界的模型输出,附带清晰的继续或后续引导。
|
||||
|
||||
<a id="deferred-work"></a>
|
||||
|
||||
## 推迟工作
|
||||
|
||||
- `web_fetch` 的 SSRF/私有网络防护:阻断私有、回环、链路本地、多播及其他非公开目的地,使 `web_fetch` 不再是 SSRF 原语。正确实现不仅仅是 URL 字符串检查——需要先 DNS 解析再连接到已验证的 IP(防御 DNS rebinding/TOCTOU)、跨重定向的每跳重新验证,以及 IPv6 边缘处理(私有范围、IPv4 映射地址)。所调研的参考实现均未做 IP 级阻断(OpenCode 做前缀检查后直接 fetch;Claude Code 依赖集中式主机名黑名单加「私有 URL 会失败」的提示词),因此没有可复制的实现,且这是 harness 唯一的 SSRF 防线——值得一次专门的设计/spike。在其落地之前,`web_fetch` 只能在无法触达敏感内部目标的部署中启用。
|
||||
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-08-09-cordis-event-walk-backstop.md
|
||||
2026-08-09-cordis-event-walk-backstop.md: 1d833858f9471c98f54b3793edd12e4710b8c0fc
|
||||
2026-08-09-cordis-event-walk-backstop.zh.md: 0cb0f3bd738b6cee703cee71f1c24741554df87a
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: An independent Events backstop closes the cordis-surface exhaustiveness gap
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-09-cordis-event-walk-backstop.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`gen-cordis-catalog` renders every service and event the Typert host-face projection discovers, and fail-closed page maps (`SERVICE_PAGE`, `EVENT_SCOPE_PAGE`) guarantee each discovered key or scope lands on exactly one `docs/subsystems/` page ([per-subsystem regions decision](../process/2026-07-28-per-subsystem-cordis-surface-regions.md) owns the page-region mechanism). Discovery itself was only backstopped for services: an independent AST scan read every `declare module 'cordis'` Context merge and demanded each declared key be rendered or carry a named `SERVICE_WALK_EXEMPTIONS` reason.
|
||||
|
||||
Events had no such backstop. The projection walks only files reachable from host-face package exports, so an `interface Events` merge in client-face code — or in any file the host analyzer cannot reach — vanished with no trace: 12 declared events (`slash/input-*`, `theme/change`, `locale/change`, and the client runtime's `*/changed` invalidation signals) were documented nowhere generated and nothing would ever notice a thirteenth. The services scan also globbed only `packages/*/*/src/*.ts`, so 13 client-face Context keys declared in nested files (`src/client/**`) were invisible to the very scan meant to prevent silent vanishing.
|
||||
|
||||
## Decision
|
||||
|
||||
Events get the exact mirror of the services backstop, and both scans read the full package source tree.
|
||||
|
||||
`scripts/cordis-walk.ts` gains `eventNameList` (every member name of an `interface Events` merge, read from method and property members alike so a shape the projector would reject still enters the scan); the scan yields every `declare module 'cordis'` block in a file (the Typert analyzer reads them all, so stopping at the first would hide a second block's face), and its quote-agnostic prefilter matches the `declare module` heads instead of the literal text `interface Context`, so an Events-only or double-quoted merge file is not skipped. The scan glob in `gen-cordis-catalog` deepens from `packages/*/*/src/*.ts` to `packages/*/*/src/**/*.{ts,tsx}` (two patterns). A third partition direction guards the scan itself: every rendered service key and event name must also be visible to the scan, so a scan regression (glob, prefilter, block walk) is a hard error rather than a silent backstop decay.
|
||||
|
||||
A new curated `EVENT_WALK_EXEMPTIONS` map names every declared event the projection cannot see, with the reason and the package README that owns its surface. Keys are full event names, not scopes: client-face events share scopes with rendered host events (`commands/changed` beside the host `commands/*` family), so a scope-level exemption would mask a host-face regression. The partition check is fail-closed in both directions, exactly like the service maps: an unexempted invisible event, an exemption for an event that renders, and an exemption no merge declares are all hard errors.
|
||||
|
||||
The partition judgment moved out of `computeOutputs` into the pure `walkPartitionProblems(input, maps)` so every acceptance path is provable by unit test without running the Typert projection; `computeOutputs` feeds it the rendered model plus the independent scan and keeps aggregating page-splice errors as before.
|
||||
|
||||
The audit that motivated this found the host face already complete: 48 rendered services + 10 walk exemptions covered all 58 host-visible Context keys, all 49 host events rendered, and every type name in every rendered signature is classified by the existing fail-closed `LINK_MAP`/`FOUNDATION_TYPE_NAMES`/`TYPE_LINK_EXEMPTIONS` check. The 25 findings (12 events, 13 keys) were all client-face; each now carries a named exemption pointing at its owning README, consistent with the existing `appShell`/`connection` precedent.
|
||||
|
||||
## Verification
|
||||
|
||||
`scripts/gen-cordis-catalog-partition.spec.ts` proves each acceptance path: the green partition, an invisible unexempted event (named with its declaring file), a stale rendered-event exemption, a stale never-declared exemption, the service mirror of each, unmapped rendered surface in both page maps, rendered surface the scan cannot see (the third direction), and the scan reaching nested Events-only merges, every block of a multi-block file, double-quoted heads, and `.tsx` sources. Deleting one live exemption from the real tree makes `gen-cordis-catalog` fail loud with the event's name and declaring file; restoring it returns the generator to a byte-identical no-op regeneration (85 artifacts, 0 written), which also proves the new exemptions exactly cover today's surface. `verify-cordis-catalog` in doc-sync executes the partition on every run.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Render the client face instead of exempting it.** Analyzing `faces: ['host', 'client']` and giving client services/events generated regions is the real fix for the underlying blind spot, but it changes what the subsystems catalog IS (host-tier reference) and requires page decisions for browser-only surfaces; the existing `TODO(cordis-catalog-interface-services)` already tracks widening the projection. The backstop is the guarantee; rendering is an upgrade behind it.
|
||||
- **Scope-level event exemptions.** Smaller map, but `commands/changed` (client) shares the `commands` scope with rendered host events, so exempting a scope would swallow a future host-face event silently — the exact failure mode this note removes.
|
||||
- **Deriving exhaustiveness from Typert instead of a raw AST scan.** The projection and the backstop must fail independently: a Typert reachability bug is precisely what the backstop exists to catch, so the scan deliberately stays a plain `ts.createSourceFile` walk with no shared machinery.
|
||||
- **Gating the transitive type closure of rendered signatures.** Measured before deciding: every type name reachable in rendered signatures is already classified, and deeper field-of-field types are owned by the pages' hand-curated `type-equiv` pastes and package READMEs; a closure gate would force page homes for internals without a reader-facing need.
|
||||
|
||||
## Consequences
|
||||
|
||||
A new cordis event — host or client, any file depth — must either render onto a subsystems page or name itself in `EVENT_WALK_EXEMPTIONS` with its documentation owner; deleting one must retire its exemption. The same now holds for Context keys declared anywhere under `src/`. The curated maps grew by 25 client-face entries whose reasons all point at package READMEs, keeping the subsystems catalog a host-tier reference. `walkPartitionProblems` is the single home of the partition judgment; future backstop dimensions (e.g. rendering the client face, schema surfaces) extend it and its spec rather than re-inlining checks into `computeOutputs`.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note(agent 决策记录):独立的 Events 兜底扫描补上 cordis 表面完备性缺口
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-09-cordis-event-walk-backstop.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`gen-cordis-catalog` 渲染 Typert host face 投影发现的每个服务与事件,fail-closed 的页面映射(`SERVICE_PAGE`、`EVENT_SCOPE_PAGE`)保证每个被发现的 key 或 scope 恰好落在一个 `docs/subsystems/` 页面上(页面区块机制归[按子系统区块决定](../process/2026-07-28-per-subsystem-cordis-surface-regions.md)所有)。但"发现"本身此前只对服务有兜底:一条独立的 AST 扫描读取每个 `declare module 'cordis'` Context merge,要求每个声明的 key 要么被渲染、要么在 `SERVICE_WALK_EXEMPTIONS` 中给出具名理由。
|
||||
|
||||
事件没有这样的兜底。投影只遍历从 host face 包导出可达的文件,因此 client face 代码——或 host 分析器无法触及的任何文件——里的 `interface Events` merge 会无声消失:12 个已声明事件(`slash/input-*`、`theme/change`、`locale/change`,以及 client runtime 的 `*/changed` 失效信号)不出现在任何生成文档中,而且再多一个也不会有任何机制察觉。服务扫描的 glob 也只有 `packages/*/*/src/*.ts`,于是声明在嵌套文件(`src/client/**`)中的 13 个 client face Context key 恰恰对这条为防止无声消失而存在的扫描不可见。
|
||||
|
||||
## Decision
|
||||
|
||||
事件获得与服务兜底完全对称的机制,且两条扫描都读取完整的包源码树。
|
||||
|
||||
`scripts/cordis-walk.ts` 新增 `eventNameList`(`interface Events` merge 的每个成员名,方法与属性成员一并读取,使投影器会拒绝的形状也进入扫描);扫描产出文件中每一个 `declare module 'cordis'` 块(Typert 分析器读取全部块,止步于第一个会藏起第二个块的表面),其对引号风格不敏感的预过滤匹配 `declare module` 头部而非字面文本 `interface Context`,从而不再跳过只含 Events 或使用双引号的 merge 文件。`gen-cordis-catalog` 的扫描 glob 从 `packages/*/*/src/*.ts` 加深为 `packages/*/*/src/**/*.{ts,tsx}`(两个 pattern)。分区新增第三个方向守住扫描自身:投影渲染的每个服务 key 与事件名也必须对扫描可见,使扫描回归(glob、预过滤、块遍历)成为硬错误而非兜底的无声退化。
|
||||
|
||||
新的人工维护映射 `EVENT_WALK_EXEMPTIONS` 为投影看不到的每个已声明事件命名,附理由与拥有其表面的包 README。键是完整事件名而非 scope:client face 事件与已渲染的 host 事件共享 scope(`commands/changed` 与 host 的 `commands/*` 家族并存),scope 级豁免会无声吞掉未来的 host face 回归。分区检查与服务映射一样双向 fail-closed:未豁免的不可见事件、已渲染事件的豁免、无任何 merge 声明的豁免,皆为硬错误。
|
||||
|
||||
分区判定从 `computeOutputs` 中提取为纯函数 `walkPartitionProblems(input, maps)`,使每条验收路径都能以单元测试证明而无需运行 Typert 投影;`computeOutputs` 向它馈送渲染模型加独立扫描结果,页面拼接错误的聚合方式保持不变。
|
||||
|
||||
促成本决定的审计发现 host face 本已完备:48 个渲染服务 + 10 条 walk 豁免覆盖全部 58 个 host 可见 Context key,49 个 host 事件全部渲染,且每个渲染签名中的每个类型名都已被既有的 fail-closed `LINK_MAP`/`FOUNDATION_TYPE_NAMES`/`TYPE_LINK_EXEMPTIONS` 检查分类。25 条发现(12 事件、13 key)全部在 client face;现在每条都带指向其所属 README 的具名豁免,与既有的 `appShell`/`connection` 先例一致。
|
||||
|
||||
## Verification
|
||||
|
||||
`scripts/gen-cordis-catalog-partition.spec.ts` 证明每条验收路径:绿色分区、不可见且未豁免的事件(报出声明文件)、已渲染事件的陈旧豁免、从未声明的陈旧豁免、服务侧的对称路径、两个页面映射中未映射的已渲染表面、扫描看不到的已渲染表面(第三方向),以及扫描触达嵌套的仅含 Events 的 merge、多块文件的每个块、双引号头部与 `.tsx` 源文件。在真实源码树上删除一条现役豁免会让 `gen-cordis-catalog` 以事件名与声明文件大声失败;恢复后生成器回到字节相同的 no-op 再生成(85 个 artifact,0 写入),这同时证明新豁免恰好覆盖当下表面。doc-sync 中的 `verify-cordis-catalog` 每次运行都会执行该分区检查。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **渲染 client face 而非豁免。** 以 `faces: ['host', 'client']` 分析并给 client 服务/事件生成区块才是对盲区的根治,但它改变子系统目录的定位(host 层参考),并要求为纯浏览器表面做页面归属决策;既有的 `TODO(cordis-catalog-interface-services)` 已跟踪拓宽投影。兜底是保证,渲染是其上的升级。
|
||||
- **scope 级事件豁免。** 映射更小,但 `commands/changed`(client)与已渲染的 host 事件共享 `commands` scope,豁免整个 scope 会无声吞掉未来的 host face 事件——正是本决定要消除的失败模式。
|
||||
- **用 Typert 推导完备性而非原始 AST 扫描。** 投影与兜底必须独立失败:Typert 的可达性 bug 恰是兜底要捕获的对象,因此扫描刻意保持为不共享机制的朴素 `ts.createSourceFile` 遍历。
|
||||
- **对渲染签名的传递类型闭包设门。** 决定前先测量:渲染签名中可达的每个类型名都已分类,更深的字段套字段类型由页面手工维护的 `type-equiv` 粘贴与包 README 拥有;闭包门会在没有读者需求的情况下强迫内部类型认领页面。
|
||||
|
||||
## Consequences
|
||||
|
||||
新的 cordis 事件——host 或 client、任意文件深度——必须渲染到某个子系统页面,或在 `EVENT_WALK_EXEMPTIONS` 中以其文档所有者具名;删除事件时必须一并退役其豁免。声明在 `src/` 下任意位置的 Context key 现在同样如此。人工维护映射增加了 25 条 client face 条目,理由全部指向包 README,使子系统目录保持 host 层参考的定位。`walkPartitionProblems` 是分区判定的唯一居所;未来的兜底维度(如渲染 client face、schema 表面)应扩展它及其 spec,而非把检查重新内联进 `computeOutputs`。
|
||||
@@ -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-06-18-markdown-cross-link-lint.md
|
||||
2026-06-18-markdown-cross-link-lint.md: b8b1337e9d758da6a4cc0bb46a6b37906357f877
|
||||
2026-06-18-markdown-cross-link-lint.zh.md: 9b627ebb17a0567424ca0caaeac8edd9b36917f2
|
||||
2026-06-18-markdown-cross-link-lint.md: 21c6884d3fd891794a11125a9aa51ac2bcb29059
|
||||
2026-06-18-markdown-cross-link-lint.zh.md: 444cf6eb97d95049d4e6b7bb5f44138051f6577a
|
||||
@@ -20,11 +20,11 @@ A fourth `doc-sync` gate, `verify-md-links` (`scripts/verify-md-links.ts`), mirr
|
||||
|
||||
Scope matches the other gates plus the AGENTS.md pair and the repo-authored agent-skill Markdown under `.agents/skills/` (those skill files cross-link into the docs tree, so this reorg rewrote links in them too): `README.md`, `docs/**/*.md`, `packages/*/README.md`, `AGENTS.md`, `packages/AGENTS.md`, `.agents/skills/**/*.md`, deduped by real path (the `CLAUDE.md` symlinks resolve onto the AGENTS.md files). It is wired into `doc-sync`, so relevant documentation changes and CI exercise the same broken-link check.
|
||||
|
||||
This gate checks *existence*, not anchor validity: a link to a real file with a `#wrong-heading` fragment still passes (the file resolves; the fragment is stripped).
|
||||
The gate now also checks `#fragment` anchors on Markdown targets — same-file anchors included — against heading slugs and explicit `<a id>`; the [fragment-anchor decision](2026-08-09-md-fragment-anchor-gate.md) owns that mechanism and the slug rules.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Anchor-level validity checking** — heavier and lower-value; file-level dead links are the failure that actually bit. The scope cut is deliberate: authors verify `#fragment` anchors themselves when linking to one.
|
||||
**Anchor-level validity checking** — deferred here as heavier and lower-value (file-level dead links were the failure that had actually bit), leaving authors to verify `#fragment` anchors themselves. That manual rule did not hold; the [fragment-anchor decision](2026-08-09-md-fragment-anchor-gate.md) later added the check.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ Status: implemented
|
||||
|
||||
检查范围与其他门禁一致,并额外包含 AGENTS.md 文件对以及 `.agents/skills/` 下仓库自有的 agent skill(技能)Markdown(这些 skill 文件会交叉链接到 docs 目录树,因此本次重组也改写了其中的链接):`README.md`、`docs/**/*.md`、`packages/*/README.md`、`AGENTS.md`、`packages/AGENTS.md`、`.agents/skills/**/*.md`。系统按真实路径去重(`CLAUDE.md` symlink 会解析到 AGENTS.md 文件)。该检查接入 `doc-sync`,因此相关文档变更与 CI 执行同一套断链检查。
|
||||
|
||||
本门禁检查的是*文件存在性*,而非锚点有效性:指向一个真实文件但带有 `#wrong-heading` 片段的链接仍会通过(文件路径可解析;片段被剥除)。
|
||||
本门禁现在也检查 Markdown 目标上的 `#fragment` 锚点——包括同文件锚点——对照标题 slug 与显式 `<a id>`;该机制与 slug 规则归 [fragment 锚点决定](2026-08-09-md-fragment-anchor-gate.md)所有。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**锚点级有效性检查**:更重且价值更低;实际造成问题的是文件级死链。这一范围裁剪是有意为之:作者在链接到某个锚点时自行验证 `#fragment`。
|
||||
**锚点级有效性检查**:当时以更重且价值更低为由推迟(实际咬过人的是文件级死链),把 `#fragment` 验证留给作者人工完成。该人工规则没有守住;[fragment 锚点决定](2026-08-09-md-fragment-anchor-gate.md)后来补上了这项检查。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
+2
-2
@@ -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-28-per-subsystem-cordis-surface-regions.md
|
||||
2026-07-28-per-subsystem-cordis-surface-regions.md: ef65bfc4c7dadd7dafe1a38f41656e6ecc61ea50
|
||||
2026-07-28-per-subsystem-cordis-surface-regions.zh.md: 1df18d7260800b89c95a9d4aeb0301adb0ee56f6
|
||||
2026-07-28-per-subsystem-cordis-surface-regions.md: f6d4494d4195ba27f2898eecb27af32b433af88b
|
||||
2026-07-28-per-subsystem-cordis-surface-regions.zh.md: ee2a3c9730e6259e9f53d1b722b04b99819af65b
|
||||
+1
-1
@@ -14,7 +14,7 @@ The [generated-catalog decision](../../archived/process/2026-06-20-generated-cor
|
||||
|
||||
`gen-cordis-catalog.ts` injects each subsystem's service and event reference INTO its own page, between `<!-- BEGIN GENERATED cordis-surface … -->` / `<!-- END GENERATED cordis-surface -->` markers, and the flat services/events catalogs are deleted. One page per subsystem now carries introduction, data structures, and the generated wiring surface.
|
||||
|
||||
- **Curated fail-loud partition.** `SERVICE_PAGE` maps every discovered `ctx.<key>` to exactly one page; `EVENT_SCOPE_PAGE` maps every event scope. The generator hard-errors in both directions — an unmapped discovered service/scope, and a mapped key/scope the walk no longer discovers — so the partition cannot drift from the source surface. An independent scan of EVERY `declare module 'cordis'` Context merge backstops the rendering walk's blind spot (it only sees a root `index.ts` with a same-named service class): a declared key the walk cannot render must carry a named `SERVICE_WALK_EXEMPTIONS` reason (today: the `ctx.agent` DX accessor, plus the interface-typed or non-index-declared lsp/apiProxy/appShell/tuiPrompt/tuiResumeHost), and stale exemptions hard-error; a `TODO(cordis-catalog-interface-services)` marks teaching the walk to render them.
|
||||
- **Curated fail-loud partition.** `SERVICE_PAGE` maps every discovered `ctx.<key>` to exactly one page; `EVENT_SCOPE_PAGE` maps every event scope. The generator hard-errors in both directions — an unmapped discovered service/scope, and a mapped key/scope the walk no longer discovers — so the partition cannot drift from the source surface. Independent AST scans of every `declare module 'cordis'` merge block under `packages/*/*/src/**` backstop the projection's blind spots for services AND events: a declared Context key or Events member the projection cannot render must carry a named `SERVICE_WALK_EXEMPTIONS`/`EVENT_WALK_EXEMPTIONS` reason, stale exemptions hard-error, and everything rendered must also be visible to the scan ([events-backstop decision](../architecture/2026-08-09-cordis-event-walk-backstop.md) owns the scan contract); a `TODO(cordis-catalog-interface-services)` marks teaching the projection to render the interface-typed entries.
|
||||
- **Byte-identical regions across the pair.** The generator writes the SAME English region bytes into `foo.md` and `foo.zh.md`, extending the existing rule that verbatim code fences match across a pair. `verify-translation-pairing` gained a dedicated region-identity check (`partitionGeneratedRegions` in `translation-pairing.ts` owns the marker grammar) that names a divergent or malformed region precisely; the whole-document structural signature still covers the region content a second time.
|
||||
- **Guarded pair auto-record.** A regeneration that changes region bytes would leave every touched pair out-of-sync, so the generator re-records a pair's `.i18n.yaml` itself — but ONLY when the write is region-confined: both sides' recorded blob hashes must match the pre-write bytes, and the region-STRIPPED content must be unchanged on both sides. Human-prose drift leaves the record stale so the pairing gate still forces the normal translation flow; a brand-new pair is never auto-recorded (the author's reviewed `--write` owns that). This keeps `.i18n.yaml` as plain `git hash-object` values — no stripped-hash semantics change.
|
||||
- **The inherited tier moved, not died.** The vendor `ctx` members and `internal/*`/loader/hmr/timer events render to `docs/cordis-api/inherited.md`, next to the relocated Cordis core API pages (`docs/cordis-catalog/core/` → `docs/cordis-api/`). Framework surface lives under a framework home; the harness pages stay repository-owned vocabulary.
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
`gen-cordis-catalog.ts` 把每个子系统的服务与事件参考注入到该子系统自己的页面内部,置于 `<!-- BEGIN GENERATED cordis-surface … -->` / `<!-- END GENERATED cordis-surface -->` 标记之间;平铺的 services/events 目录随之删除。现在每个子系统由一个页面同时承载介绍、数据结构和生成的接线表面。
|
||||
|
||||
- **人工维护、响亮失败的划分。** `SERVICE_PAGE` 把发现的每个 `ctx.<key>` 映射到恰好一个页面;`EVENT_SCOPE_PAGE` 映射每个事件作用域。生成器在两个方向上都会直接报错(既有被发现却未映射的服务或作用域,也有已映射但遍历不再发现的键或作用域),因此划分不可能相对源码表面发生漂移。一个独立扫描会读取每一处 `declare module 'cordis'` 的 Context 合并,为渲染遍历的盲区(它只看得到根 `index.ts` 中同名服务类)兜底:遍历渲染不了的已声明键必须在 `SERVICE_WALK_EXEMPTIONS` 中带着点名理由(目前是 `ctx.agent` 这一 DX 访问器字段,加上接口类型或不在 index 声明的 lsp/apiProxy/appShell/tuiPrompt/tuiResumeHost),陈旧豁免同样直接报错;教会遍历渲染它们的后续工作由 `TODO(cordis-catalog-interface-services)` 标记。
|
||||
- **人工维护、响亮失败的划分。** `SERVICE_PAGE` 把发现的每个 `ctx.<key>` 映射到恰好一个页面;`EVENT_SCOPE_PAGE` 映射每个事件作用域。生成器在两个方向上都会直接报错(既有被发现却未映射的服务或作用域,也有已映射但遍历不再发现的键或作用域),因此划分不可能相对源码表面发生漂移。独立的 AST 扫描读取 `packages/*/*/src/**` 下每一个 `declare module 'cordis'` merge 块,为投影在服务与事件两侧的盲区兜底:投影渲染不了的已声明 Context key 或 Events 成员必须在 `SERVICE_WALK_EXEMPTIONS`/`EVENT_WALK_EXEMPTIONS` 中带着点名理由,陈旧豁免直接报错,且投影渲染的一切也必须对扫描可见(扫描契约归[事件兜底决定](../architecture/2026-08-09-cordis-event-walk-backstop.md)所有);教会投影渲染接口类型条目的后续工作由 `TODO(cordis-catalog-interface-services)` 标记。
|
||||
- **区块在配对两侧按字节一致。** 生成器把同一份英文区块字节写入 `foo.md` 和 `foo.zh.md`,是对「围栏代码块在配对两侧逐字节一致」这一既有规则的延伸。`verify-translation-pairing` 新增了专门的区块一致性检查(标记语法归 `translation-pairing.ts` 中的 `partitionGeneratedRegions` 所有),能精确点名出现分歧或格式错误的区块;整篇文档的结构签名仍会把区块内容再覆盖一遍。
|
||||
- **带防护的配对自动记录。** 一次改变区块字节的重新生成会让每个被触及的配对失去同步,因此生成器会自行重新记录配对的 `.i18n.yaml`,但仅限本次写入完全限定在区块内的情况:两侧记录的 blob hash 必须与写入前的字节相符,且两侧剥离区块后的内容必须没有变化。人工行文若有漂移,记录就保持陈旧,配对门禁因此仍会强制走正常翻译流程;全新的配对绝不自动记录(那归作者经评审的 `--write` 所有)。这样 `.i18n.yaml` 保持为纯粹的 `git hash-object` 值:不引入任何「剥离后 hash」的语义变化。
|
||||
- **继承层搬了家,而非消亡。** vendor 的 `ctx` 成员与 `internal/*`/loader/hmr/timer 事件渲染到 `docs/cordis-api/inherited.md`,紧邻迁移后的 Cordis 核心 API 页面(`docs/cordis-catalog/core/` → `docs/cordis-api/`)。框架表面落在框架自己的归属之下;harness 页面仍是仓库自有的词汇。
|
||||
|
||||
@@ -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 .agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.md
|
||||
2026-08-09-md-fragment-anchor-gate.md: e02a917bedd9649a2326e3fb1f53072ac05c88a8
|
||||
2026-08-09-md-fragment-anchor-gate.zh.md: dcc0644ae064c161ef5232654993f0e6c872064c
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: verify-md-links validates fragment anchors, closing the last dead-link class
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-09-md-fragment-anchor-gate.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`verify-md-links` proved a relative link's target file exists but never looked at the `#fragment`, and the documentation standard compensated with a manual rule: grep anchors yourself before renaming a heading. A corpus sweep found 15 links whose fragments named no anchor in their target — three distinct decay modes: a heading reworded after the link was written (`#security-and-authority-are-explicit-non-goals` vs the note's current `Security and authority are non-goals`), a contract relocated to a different owning document (`tool-fs` linking the seam README for the no-timeout rule that now lives in the group README), and zh pair sides linking English slugs their Chinese headings never produce (`#deferred-work` against `## 推迟工作`). None of these fail any gate, and each silently strands the reader at the top of the target page.
|
||||
|
||||
## Decision
|
||||
|
||||
`verify-md-links` now resolves fragments too (superseding the deferred scope cut in the [cross-link decision](2026-06-18-markdown-cross-link-lint.md)). For every relative link whose target is a Markdown file — same-file `#anchor` links included, which the old checker skipped entirely — the fragment must name a real anchor in the target: a heading's GitHub slug or an explicit `<a id>` in real HTML flow (code samples and commented-out anchors register nothing). Slugs are computed from the RENDERED heading text via the repository's own `markdownHeadingLines`, so links, inline code, and emphasis inside a heading slug as GitHub renders them; underscores survive (`#showcase-web_fetch`); repeated slugs get GitHub's occupied-set `-1`, `-2`, … suffixes; matching is exact-case, since element ids are case-sensitive. Fragments onto non-Markdown targets (`file.ts#L10`) carry renderer-owned semantics and stay out of scope, as do external and root-absolute URLs. Anchor sets are collected lazily for any existing target (`anchorCache`), so links INTO archived notes and vendor documents are validated without making those files sources.
|
||||
|
||||
The slug function differs from `gen-cordis-catalog`'s region-anchor slugger (which drops underscores): the generator's headings are always reachable through its explicit `<a id>` anchors, so the two need not share one rule. Chinese pair sides follow the existing repository convention (`docs/glossary.zh.md`, `docs/cordis-primer.zh.md`): keep the English fragment in the link and place an explicit `<a id>` before the Chinese heading, so both language sides expose identical anchors.
|
||||
|
||||
The 15 broken fragments are fixed in the same change: stale slugs retargeted to the current headings, the relocated no-timeout contract now linked at its owning group README, and four zh documents given explicit anchors. `docs/AGENTS.md` and the `dsh-doc-standards` skill no longer prescribe the manual anchor grep for Markdown links; it survives only for anchors cited from TypeScript strings whose output never reaches gate-scanned Markdown (today's three all render into scanned pages, so the gate covers them through the committed output).
|
||||
|
||||
## Verification
|
||||
|
||||
`scripts/verify-md-links.spec.ts` proves the acceptance paths: rendered-text slugging (backticks, punctuation, a linked heading, kept underscores), occupied-set repeat suffixes, `<a id>` ignored inside fences/inline code/comments, a resolving mixed-link document, dead same-file and cross-file fragments, a case-variant fragment, and a missing target still reported as `target` rather than `anchor`. The gate runs over the full corpus in doc-sync (`verify-md-links`) and passes only after the 15 fixes — the corpus itself is the red-to-green evidence for each decay mode.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the manual-grep rule.** It demonstrably did not hold: the 15 fragments decayed under a gate-driven maintenance culture, because heading rewrites happen in PRs that never look at inbound links. A mechanical invariant belongs in an executed gate.
|
||||
- **Point zh links at Chinese-slug anchors.** GitHub slugs CJK headings fine, but the corpus convention is already explicit `<a id>` + English fragments (glossary, primer), which also survives renderers that strip non-ASCII; adopting a second convention would split the corpus.
|
||||
- **Share `githubSlug` with the typert generator.** A one-function import would couple a doc gate to a package build, and the two rules genuinely differ (the generator strips underscores; its anchors are explicit `<a id>`s the gate reads directly), so divergence is by design, not drift.
|
||||
- **Validate VitePress slugs as well.** The published site's dead-link check already runs in `website:build`; generated regions carry explicit anchors precisely so the two renderers agree, and hand headings that diverge would fail there.
|
||||
|
||||
## Consequences
|
||||
|
||||
Renaming a heading now breaks the build wherever a Markdown link cites its anchor, instead of stranding readers; authors fix the inbound links in the same change, exactly as they already must for file renames. Same-file anchors are no longer a blind spot, so zh pages must anchor any English fragment they use. The manual pre-rename grep survives only for anchors cited from TypeScript strings whose output never reaches gate-scanned Markdown.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note(agent 决策记录):verify-md-links 校验 fragment 锚点,关闭最后一类死链
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-09-md-fragment-anchor-gate.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`verify-md-links` 只证明相对链接的目标文件存在,从不检查 `#fragment`,文档标准以一条人工规则补偿:重命名标题前自己 grep 锚点。一次语料扫描发现 15 条链接的 fragment 在目标中没有对应锚点——三种衰变模式:链接写下后标题被改写(`#security-and-authority-are-explicit-non-goals` 对 note 现在的 `Security and authority are non-goals`)、契约搬迁到另一份属主文档(`tool-fs` 链到 seam README,而无超时规则现居 group README)、zh 侧链接其中文标题永远不会生成的英文 slug(`#deferred-work` 对 `## 推迟工作`)。这些都不触发任何 gate,且每条都把读者悄悄丢在目标页顶部。
|
||||
|
||||
## Decision
|
||||
|
||||
`verify-md-links` 现在也解析 fragment(取代[跨链接决定](2026-06-18-markdown-cross-link-lint.md)中被推迟的范围裁剪)。对每条目标为 Markdown 文件的相对链接——包括旧检查器完全跳过的同文件 `#anchor` 链接——fragment 必须命名目标中的真实锚点:标题的 GitHub slug,或真实 HTML 流中的显式 `<a id>`(代码示例与注释掉的锚点不注册任何东西)。slug 由仓库自有的 `markdownHeadingLines` 从**渲染后**的标题文本计算,因此标题内的链接、行内代码与强调都按 GitHub 的渲染结果 slug;下划线保留(`#showcase-web_fetch`);重复 slug 获得 GitHub 的占用集 `-1`、`-2`……后缀;匹配区分大小写,因为元素 id 本就区分大小写。指向非 Markdown 目标的 fragment(`file.ts#L10`)语义归渲染器所有,不在范围内;外部与根绝对 URL 同样不检查。锚点集合对任意存在的目标惰性收集(`anchorCache`),因此链入归档 note 与 vendor 文档的链接照常校验,而这些文件不会因此成为扫描源。
|
||||
|
||||
slug 函数与 `gen-cordis-catalog` 的区块锚点 slugger 不同(后者丢弃下划线):生成器的标题总能通过其显式 `<a id>` 锚点到达,两者无需共享一条规则。中文侧沿用既有语料惯例(`docs/glossary.zh.md`、`docs/cordis-primer.zh.md`):链接保留英文 fragment,在中文标题前放置显式 `<a id>`,使两个语言侧暴露相同的锚点。
|
||||
|
||||
15 条坏 fragment 在同一变更中修复:陈旧 slug 重定向到当前标题,搬迁的无超时契约改链其属主 group README,四份中文文档补上显式锚点。`docs/AGENTS.md` 与 `dsh-doc-standards` skill 不再为 Markdown 链接开人工 grep 锚点的处方;人工 grep 只对输出从不进入受检 Markdown 的 TypeScript 字符串锚点保留(当下三处全部渲染进受检页面,gate 经由提交的产物覆盖它们)。
|
||||
|
||||
## Verification
|
||||
|
||||
`scripts/verify-md-links.spec.ts` 证明各验收路径:渲染文本 slug 化(反引号、标点、含链接标题、保留下划线)、占用集重复后缀、围栏/行内代码/注释中的 `<a id>` 不注册、全部可解析的混合链接文档、死的同文件与跨文件 fragment、大小写变体 fragment、以及缺失目标仍报 `target` 而非 `anchor`。gate 在 doc-sync 中跑完整语料(`verify-md-links`),且只有在 15 条修复之后才通过——语料本身就是每种衰变模式由红转绿的证据。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **保留人工 grep 规则。** 它被证明守不住:15 条 fragment 在 gate 驱动的维护文化下仍然衰变,因为改写标题的 PR 从不会去看入链。可机械检查的不变式应进入被执行的 gate。
|
||||
- **让中文链接指向中文 slug 锚点。** GitHub 对 CJK 标题的 slug 没问题,但语料惯例已是显式 `<a id>` + 英文 fragment(glossary、primer),且它在剥离非 ASCII 的渲染器下也存活;引入第二种惯例会割裂语料。
|
||||
- **与 typert 生成器共享 `githubSlug`。** 为一个函数引入包构建耦合不值得,且两条规则确实不同(生成器剥离下划线;其锚点是 gate 直接读取的显式 `<a id>`),分歧是设计使然而非漂移。
|
||||
- **同时校验 VitePress slug。** 发布站点的死链检查已在 `website:build` 中运行;生成区块正是为两种渲染器一致而携带显式锚点,手写标题若有分歧会在那里失败。
|
||||
|
||||
## Consequences
|
||||
|
||||
重命名标题现在会在任何 Markdown 链接引用其锚点处使构建失败,而非把读者丢在页顶;作者须在同一变更中修复入链,与文件重命名的既有义务完全一致。同文件锚点不再是盲区,中文页面使用英文 fragment 时必须补锚点。人工的重命名前 grep 只对输出从不进入受检 Markdown 的 TypeScript 字符串锚点保留。
|
||||
@@ -29,7 +29,7 @@ Then check constraints that make placement expensive or wrong:
|
||||
|
||||
- Paired docs (`pnpm run verify-translation-pairing --list`) cost a zh counterpart update and a `--write` re-record on every edit — prefer an unpaired home for content that will churn.
|
||||
- Generated catalogs are never hand-edited; if the fact belongs there, change the generator's source.
|
||||
- Before renaming or moving any doc, grep for inbound references: `verify-md-links` catches Markdown links, `verify-doc-refs` catches `docs/*.md` citations in TypeScript comments, but nothing catches heading-anchor fragments — grep `#the-heading` across the repo yourself (one anchor is hardcoded in `scripts/gen-cordis-catalog.ts`).
|
||||
- Before renaming or moving any doc, grep for inbound references: `verify-md-links` catches Markdown link targets AND `#fragment` anchors onto Markdown files (heading slugs and explicit `<a id>`), and `verify-doc-refs` catches `docs/*.md` citations in TypeScript comments; anchors cited from TypeScript strings still need a manual grep when their output never reaches gate-scanned Markdown (today's three — `scripts/gen-doc-graphs.ts`, `scripts/gen-persistence-catalog.ts`, `packages/typert/generator/src/cordis-catalog.ts` — all render into scanned pages, so the gate catches them via the committed output).
|
||||
- A move is atomic: remove from the old home, add to the new home, and fix every inbound link in the same change.
|
||||
|
||||
## Audit the corpus
|
||||
|
||||
+1
-3
@@ -72,6 +72,4 @@ Hunt these in any doc; the [dsh-doc-standards](../.agents/skills/dsh-doc-standar
|
||||
|
||||
## Cross-reference with machine-checkable links, never free prose
|
||||
|
||||
Link repository references with relative Markdown paths, never bare filenames or Agent Note numbers. `verify-md-links` catches missing targets; the [cross-link Agent Note](../.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md) owns the rationale.
|
||||
|
||||
The gate checks file existence, not `#anchor` validity — verify anchors yourself when linking to one.
|
||||
Link repository references with relative Markdown paths, never bare filenames or Agent Note numbers. `verify-md-links` rejects missing targets and dead `#fragment` anchors ([rationale](../.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md)).
|
||||
@@ -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 docs/agent-lifecycle.md
|
||||
agent-lifecycle.md: 5bf2c4454ce2fac31dfd35218f2bc3c3318b18b6
|
||||
agent-lifecycle.zh.md: b7cf8a0e36ee08d52e71e5bc439bcf1815b0a467
|
||||
agent-lifecycle.md: 8690c792befee8a4ef1fdeed4befe0f1453cda74
|
||||
agent-lifecycle.zh.md: b310ead8bdf07e171f41b0d54cad31ac58a7fd08
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
# Agent Turn And Step Lifecycle
|
||||
|
||||
This sequence is the visual companion to [architecture.md](architecture.md#loop-lifecycle-session--turn--step). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.
|
||||
This sequence is the visual companion to [architecture.md](architecture.md#default-loop-lifecycle). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
[English](agent-lifecycle.md) | 中文
|
||||
|
||||
此时序图是 [architecture.md](architecture.md#loop-lifecycle-session--turn--step) 的配套图示。持久的回放事实保存在 `session/event` 中,实时控制与状态则保存在 `agent/*` 中。
|
||||
此时序图是 [architecture.md](architecture.md#default-loop-lifecycle) 的配套图示。持久的回放事实保存在 `session/event` 中,实时控制与状态则保存在 `agent/*` 中。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
|
||||
@@ -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 docs/subsystems/llm-streaming.md
|
||||
llm-streaming.md: c65416f449d1783f398cdd81b0d6987de83a6141
|
||||
llm-streaming.zh.md: ce79ecca53edf7213853df118489817451baa7b0
|
||||
llm-streaming.md: 1705c45697df98b51ba9f69c8c91b3a8a18dea76
|
||||
llm-streaming.zh.md: 591326582c96023df5a184e19e3e4fc812187fb4
|
||||
@@ -623,7 +623,7 @@ interface LlmCallConfigAdapterDefaults {
|
||||
|
||||
## The seam
|
||||
|
||||
`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity, an adapter-configured `defaultMaxTokens`, and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or provider-owned behavior, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. At the final adapter boundary, `resolveCallConfig()` materializes the output default only when `maxTokens` is absent and validates and materializes reasoning, so direct calls cannot bypass either configured behavior; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, retain detached context metadata from that exact lookup, and report which config fields the adapter defaulted. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. AgentLoop observes a request attempt once the outer waterfall returns a stream handle; that limited boundary does not prove a lazy terminal adapter was constructed or began provider I/O. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm).
|
||||
`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity, an adapter-configured `defaultMaxTokens`, and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or provider-owned behavior, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. At the final adapter boundary, `resolveCallConfig()` materializes the output default only when `maxTokens` is absent and validates and materializes reasoning, so direct calls cannot bypass either configured behavior; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, retain detached context metadata from that exact lookup, and report which config fields the adapter defaulted. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. AgentLoop observes a request attempt once the outer waterfall returns a stream handle; that limited boundary does not prove a lazy terminal adapter was constructed or began provider I/O. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#model-content).
|
||||
|
||||
```ts type-equiv
|
||||
/** One model call whose config and adapter registration were resolved together. */
|
||||
|
||||
@@ -631,7 +631,7 @@ interface LlmCallConfigAdapterDefaults {
|
||||
|
||||
## seam
|
||||
|
||||
`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、适配器配置的 `defaultMaxTokens`、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。在最终适配器边界,`resolveCallConfig()` 仅在 `maxTokens` 缺失时填入输出默认值,并校验和填入推理强度,因此直接调用也无法绕过任何一项已配置行为;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,保留来自同一次查询的分离上下文元数据,并报告适配器填入的配置字段。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。AgentLoop 在外层 waterfall 返回流句柄时观察到一次请求尝试;这个有限边界不能证明惰性终端适配器已构造完成或开始提供方 I/O。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。
|
||||
`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、适配器配置的 `defaultMaxTokens`、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。在最终适配器边界,`resolveCallConfig()` 仅在 `maxTokens` 缺失时填入输出默认值,并校验和填入推理强度,因此直接调用也无法绕过任何一项已配置行为;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,保留来自同一次查询的分离上下文元数据,并报告适配器填入的配置字段。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。AgentLoop 在外层 waterfall 返回流句柄时观察到一次请求尝试;这个有限边界不能证明惰性终端适配器已构造完成或开始提供方 I/O。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#model-content)。
|
||||
|
||||
```ts type-equiv
|
||||
/** One model call whose config and adapter registration were resolved together. */
|
||||
|
||||
@@ -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/core/scope/README.md
|
||||
README.md: 4f32573779a15e8c34b4936bfe75549dfc86d9f6
|
||||
README.zh.md: 16ec60a5489f909a46fd5f803dbf08490cd07988
|
||||
README.md: ecb442e39e40d5b97a07ccf8a71a190c4009ede8
|
||||
README.zh.md: f223d339129bcbef5c5f19ba6d0b453874944c4b
|
||||
@@ -23,7 +23,7 @@ The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime asse
|
||||
|
||||
## Design contract
|
||||
|
||||
The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals.
|
||||
The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals) for rationale and security non-goals.
|
||||
|
||||
Scope-aware services define a concrete `ScopeLayer` that aggregates their heterogeneous tables and domain helpers. `ScopedLayers.effect()` accepts one synchronous action returning one synchronous undo, installs that undo before optional notification, and reclaims an exact-scope layer only when the complete aggregate is empty. `notify` defaults to `true`; the supplied callback owns whether observer failures throw or are contained. `EntryValues` remains internal, the storage classes are imported from the package root rather than a `/store` subpath, and the shared storage does not define registry-specific filtering or iteration policy. See the [shared scoped-layer storage Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md).
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
## 设计契约
|
||||
|
||||
注册上下文同时决定可见性和所有权,防止注册在一个作用域中可见、却随另一个作用域 dispose(资源释放)。作用域用于路由受信任的同进程插件;它们不是沙箱或权限边界。原理与明确排除的安全目标见 [agent 作用域 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals)。
|
||||
注册上下文同时决定可见性和所有权,防止注册在一个作用域中可见、却随另一个作用域 dispose(资源释放)。作用域用于路由受信任的同进程插件;它们不是沙箱或权限边界。原理与明确排除的安全目标见 [agent 作用域 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
|
||||
|
||||
感知作用域的服务会定义具体 `ScopeLayer`,聚合各自不同的表与领域辅助函数。`ScopedLayers.effect()` 接受一个返回同步撤销函数的同步动作,在可选通知前安装该撤销函数,并且只有在完整聚合为空时才回收精确作用域层。`notify` 默认为 `true`;由所提供的回调决定观测方失败是向外抛出还是在内部处理。`EntryValues` 保持内部可见;存储类从包根而非 `/store` 子路径导入;共享存储不定义注册表专属的筛选或迭代策略。详见[共享作用域层存储 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)。
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md
|
||||
README.md: e98c45a8829a945500ef5282d84e1904b31c68bf
|
||||
README.zh.md: 5b9e2feaf82866a52cd8197ff5e800decdf3ee7e
|
||||
README.zh.md: bf659ea9961a9bb796c1ee1045ac7dad22bb997f
|
||||
@@ -21,6 +21,8 @@
|
||||
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void`:贡献提示词变量,在段文本中以 `{{name}}` 引用。带作用域变量会为该 agent 遮蔽同名全局变量。同层重复或无法引用的名称会抛出;`undefined` 表示「本次组装没有值」。随调用 fiber 一并 dispose。
|
||||
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 seam 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,并返回其权威结果。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。当已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。
|
||||
|
||||
<a id="live-events"></a>
|
||||
|
||||
### 实时事件
|
||||
|
||||
`system-prompt/assemble` 是权威来源;替换条目的监听器必须保留任何活动 Code Mode 或结构化输出协议。筛选需要在呈现、查找与执行之间保持一致时,应使用 [`ToolRegistry.restrict()`](../tools/README.md)。注册表变更通知不经过筛选。[system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) 的生成区块拥有签名与分发契约。
|
||||
|
||||
@@ -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/core/tools/README.md
|
||||
README.md: f3f25f908a4ba8016d38f7777fe72691dba4afb1
|
||||
README.zh.md: 796ee8e1aa8de88f979d825df5e0b4f39ab7bcf1
|
||||
README.md: 97aa97c6ca7675ff5b891e62224a4d1d7780ef42
|
||||
README.zh.md: f402722c81e1c7be9f45202a7bc79bfc85a41512
|
||||
@@ -18,7 +18,7 @@ tools:
|
||||
### Public API
|
||||
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber.
|
||||
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
|
||||
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
|
||||
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
|
||||
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)).
|
||||
- `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
|
||||
|
||||
@@ -18,7 +18,7 @@ tools:
|
||||
### 公开 API
|
||||
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose(资源释放)。
|
||||
- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals)。
|
||||
- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
|
||||
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。
|
||||
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。
|
||||
- `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall(瀑布式事件)监听器无法将守卫的拒绝重新变为允许。随调用 fiber dispose。
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md
|
||||
README.md: 6051c07628c0214f9396d12554719c5614610826
|
||||
README.zh.md: c6f3901af925d4bd31d2dcfbf5f5f65de62dac81
|
||||
README.zh.md: 23f48080136b5d212bef34d931a74773c5da4fd3
|
||||
@@ -49,6 +49,8 @@ OPENAI_API_KEY: sk-…
|
||||
|
||||
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。
|
||||
|
||||
<a id="security-boundary"></a>
|
||||
|
||||
## 安全边界
|
||||
|
||||
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境——这与用户的普通环境层 `$DSH_HOME/.env` 不同(见 [app-boot 的 Harness home 各层](../../boot/app-boot/README.md#profiles))——因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。
|
||||
|
||||
@@ -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/fs/tool-fs/README.md
|
||||
README.md: 28880860dc6c89745eb21fbf732d04f596f9b07f
|
||||
README.zh.md: 8af0aec51e71681211bbd5a4f582be8b8b0271b8
|
||||
README.md: 6f96970d8194c0992f9b955b95aec092185054b2
|
||||
README.zh.md: 2de8b02cd47f07af5a04a85694aec05214772a7f
|
||||
@@ -150,4 +150,4 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
- **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling [`dsh-tool-fs-search`](../tool-fs-search/) package supplies bash-backed `glob` and `grep` rather than extending the filesystem seam.
|
||||
- **`read` handles UTF-8 text files only** — binary-safe reads and PDF/image/multimodal content are deferred; a directory target is `FS_NOT_REGULAR_FILE`.
|
||||
- **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only ([provider rationale](../fs/README.md#no-io-deadline)).
|
||||
- **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only ([provider rationale](../README.md#no-timeouts-on-file-io)).
|
||||
@@ -150,4 +150,4 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
|
||||
|
||||
- **未交付面向模型的目录列表工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 [`dsh-tool-fs-search`](../tool-fs-search/) 包则提供基于 bash 的 `glob` 与 `grep`,而不是扩展文件系统 seam。
|
||||
- **`read` 只处理 UTF-8 文本文件**:二进制安全读取和 PDF/图像/多模态内容均延期处理;目录目标为 `FS_NOT_REGULAR_FILE`。
|
||||
- **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见[提供方理由](../fs/README.md#no-io-deadline))。
|
||||
- **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见[提供方理由](../README.md#no-timeouts-on-file-io))。
|
||||
+54
-18
@@ -9,41 +9,54 @@ import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
|
||||
/** Cheap textual prefilter for a cordis module merge, quote-style agnostic
|
||||
* (the AST match below reads `stmt.name.text` and never sees the quotes). */
|
||||
const MERGE_HEAD = /declare module ['"](?:cordis|\.\/context\.ts)['"]/
|
||||
|
||||
/**
|
||||
* Parse every file matching `pattern` (repo-relative, sorted, `/`-normalized)
|
||||
* that textually mentions `interface Context`, yielding each file's cordis
|
||||
* module-merge body. Files without a merge are skipped.
|
||||
* @param scanRoot - Repository root the pattern is resolved against.
|
||||
* @param pattern - Glob selecting the TypeScript files to scan.
|
||||
* @returns One entry per file with a cordis module merge, in path order.
|
||||
* Parse every file matching `patterns` (repo-relative, sorted, `/`-normalized)
|
||||
* that textually contains a cordis module merge, yielding one entry per merge
|
||||
* BLOCK — a file may legally hold several `declare module 'cordis'` blocks
|
||||
* (the Typert analyzer reads them all), so the exhaustiveness scan must too.
|
||||
* Files without a merge are skipped.
|
||||
* @param scanRoot - Repository root the patterns are resolved against.
|
||||
* @param patterns - Glob(s) selecting the TypeScript files to scan.
|
||||
* @returns One entry per cordis module block, in path then source order.
|
||||
*/
|
||||
export function contextMergeFiles(
|
||||
scanRoot: string,
|
||||
pattern: string,
|
||||
patterns: string | readonly string[],
|
||||
): { rel: string; sf: ts.SourceFile; text: string; body: ts.ModuleBlock }[] {
|
||||
const out: { rel: string; sf: ts.SourceFile; text: string; body: ts.ModuleBlock }[] = []
|
||||
for (const rel of globSync(pattern, { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
|
||||
const rels = [...new Set(globSync(patterns as string | string[], { cwd: scanRoot }).map(s => s.split(sep).join('/')))].sort()
|
||||
for (const rel of rels) {
|
||||
const abs = resolve(scanRoot, rel)
|
||||
const text = readFileSync(abs, 'utf8')
|
||||
if (!text.includes('interface Context')) continue
|
||||
if (!MERGE_HEAD.test(text)) continue
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
out.push({ rel, sf, text, body })
|
||||
for (const body of cordisModuleBodies(sf)) out.push({ rel, sf, text, body })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The body of the cordis module merge in `sf`: `declare module 'cordis'`
|
||||
* (harness packages) or `declare module './context.ts'` (vendor core), or
|
||||
* null when the file has neither. */
|
||||
export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
||||
/** Every cordis module-merge body in `sf`: `declare module 'cordis'` (harness
|
||||
* packages) or `declare module './context.ts'` (vendor core), in source order.
|
||||
* Module-local: consumers walk blocks through {@link contextMergeFiles}. */
|
||||
function cordisModuleBodies(sf: ts.SourceFile): ts.ModuleBlock[] {
|
||||
const bodies: ts.ModuleBlock[] = []
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue
|
||||
if (stmt.name.text !== 'cordis' && stmt.name.text !== './context.ts') continue
|
||||
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
|
||||
if (stmt.body && ts.isModuleBlock(stmt.body)) bodies.push(stmt.body)
|
||||
}
|
||||
return null
|
||||
return bodies
|
||||
}
|
||||
|
||||
/** The FIRST cordis module-merge body in `sf`, or null without one — for the
|
||||
* vendor core-API renderer whose input files carry exactly one merge; the
|
||||
* exhaustiveness scan uses {@link cordisModuleBodies} to read them all. */
|
||||
export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
||||
return cordisModuleBodies(sf)[0] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,3 +77,26 @@ export function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<stri
|
||||
}
|
||||
return keyToType
|
||||
}
|
||||
|
||||
/**
|
||||
* Every event name a `declare module 'cordis'` Events merge declares in one
|
||||
* module body. Names are the literal member keys (`'agent/created'`), read
|
||||
* from method and property members alike so a declaration shape the projector
|
||||
* would reject still enters the exhaustiveness scan.
|
||||
* @param body - The cordis module augmentation block.
|
||||
* @param sf - Owning source file (for computed-name text extraction).
|
||||
* @returns Declared event names, in declaration order.
|
||||
*/
|
||||
export function eventNameList(body: ts.ModuleBlock, sf: ts.SourceFile): string[] {
|
||||
const names: string[] = []
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!member.name) continue
|
||||
names.push(ts.isStringLiteral(member.name) || ts.isIdentifier(member.name)
|
||||
? member.name.text
|
||||
: member.name.getText(sf))
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Acceptance-path coverage for the cordis-surface partition backstops
|
||||
* (`walkPartitionProblems` + the AST scan helpers): a declared Context key or
|
||||
* Events member the rendering projection cannot see must carry a named walk
|
||||
* exemption, an exemption must stay live in both directions, and the scan
|
||||
* itself must reach nested (`src/**`) and Events-only merge files.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import ts from 'typescript'
|
||||
import { contextKeyMap, contextMergeFiles, eventNameList } from './cordis-walk.ts'
|
||||
import { walkPartitionProblems } from './gen-cordis-catalog.ts'
|
||||
import type { WalkPartitionInput, WalkPartitionMaps } from './gen-cordis-catalog.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A consistent baseline the red cases mutate one facet at a time. */
|
||||
function baseline(): { input: WalkPartitionInput; maps: WalkPartitionMaps } {
|
||||
return {
|
||||
input: {
|
||||
renderedKeys: new Map([['llm', 'packages/llm/llm/src/index.ts:10']]),
|
||||
renderedScopes: new Set(['llm']),
|
||||
renderedEventNames: new Set(['llm/request']),
|
||||
declaredKeys: new Map([
|
||||
['llm', 'packages/llm/llm/src/index.ts'],
|
||||
['theme', 'packages/client/ui-theme/src/client/index.ts'],
|
||||
]),
|
||||
declaredEvents: new Map([
|
||||
['llm/request', 'packages/llm/llm/src/index.ts'],
|
||||
['theme/change', 'packages/client/ui-theme/src/client/index.ts'],
|
||||
]),
|
||||
},
|
||||
maps: {
|
||||
servicePage: { llm: 'llm-streaming.md' },
|
||||
serviceWalkExemptions: { theme: 'client-side — packages/client/ui-theme/README.md owns the surface' },
|
||||
eventScopePage: { llm: 'llm-streaming.md' },
|
||||
eventWalkExemptions: { 'theme/change': 'client-face — packages/client/ui-theme/README.md owns the surface' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('walkPartitionProblems', () => {
|
||||
it('accepts a partition where every declared key and event is rendered or exempted', () => {
|
||||
const { input, maps } = baseline()
|
||||
expect(walkPartitionProblems(input, maps)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a declared event that is neither rendered nor exempted, naming its file', () => {
|
||||
const { input, maps } = baseline()
|
||||
const problems = walkPartitionProblems(input, { ...maps, eventWalkExemptions: {} })
|
||||
expect(problems).toEqual([
|
||||
expect.stringContaining("event 'theme/change' (packages/client/ui-theme/src/client/index.ts) is declared in an Events merge but invisible"),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an event exemption whose event the projection renders', () => {
|
||||
const { input, maps } = baseline()
|
||||
// A projection that renders theme/change necessarily renders the theme
|
||||
// scope too; the fixture models that and maps the scope so the only
|
||||
// violation is the stale exemption.
|
||||
const rendered = {
|
||||
...input,
|
||||
renderedScopes: new Set(['llm', 'theme']),
|
||||
renderedEventNames: new Set(['llm/request', 'theme/change']),
|
||||
}
|
||||
const mapped = { ...maps, eventScopePage: { llm: 'llm-streaming.md', theme: 'client-modules.md' } }
|
||||
expect(walkPartitionProblems(rendered, mapped)).toEqual([
|
||||
expect.stringContaining("event 'theme/change' is rendered by the projection but still listed in EVENT_WALK_EXEMPTIONS"),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects rendered surface the independent scan cannot see, naming the scan as the defect', () => {
|
||||
const { input, maps } = baseline()
|
||||
const blind = {
|
||||
...input,
|
||||
declaredKeys: new Map([['theme', 'packages/client/ui-theme/src/client/index.ts']]),
|
||||
declaredEvents: new Map([['theme/change', 'packages/client/ui-theme/src/client/index.ts']]),
|
||||
}
|
||||
expect(walkPartitionProblems(blind, maps)).toEqual([
|
||||
expect.stringContaining('ctx.llm is rendered by the projection but the independent scan finds no Context merge declaring it'),
|
||||
expect.stringContaining("event 'llm/request' is rendered by the projection but the independent scan finds no Events merge declaring it"),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an event exemption no Events merge declares', () => {
|
||||
const { input, maps } = baseline()
|
||||
const stale = { ...maps, eventWalkExemptions: { ...maps.eventWalkExemptions, 'gone/away': 'nothing owns this' } }
|
||||
expect(walkPartitionProblems(input, stale)).toEqual([
|
||||
expect.stringContaining("EVENT_WALK_EXEMPTIONS names 'gone/away' but no Events merge declares it"),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a declared Context key that is neither rendered nor exempted', () => {
|
||||
const { input, maps } = baseline()
|
||||
const problems = walkPartitionProblems(input, { ...maps, serviceWalkExemptions: {} })
|
||||
expect(problems).toEqual([
|
||||
expect.stringContaining('ctx.theme (packages/client/ui-theme/src/client/index.ts) is declared in a Context merge but invisible'),
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an unmapped rendered service with its source pointer, and stale page maps both ways', () => {
|
||||
const { input, maps } = baseline()
|
||||
const problems = walkPartitionProblems(input, {
|
||||
...maps,
|
||||
servicePage: { ghost: 'core.md' },
|
||||
eventScopePage: { specter: 'core.md' },
|
||||
})
|
||||
expect(problems).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('service ctx.llm (packages/llm/llm/src/index.ts:10) has no SERVICE_PAGE entry'),
|
||||
expect.stringContaining("event scope 'llm/*' has no EVENT_SCOPE_PAGE entry"),
|
||||
expect.stringContaining("SERVICE_PAGE maps 'ctx.ghost' but the projection discovers no such service"),
|
||||
expect.stringContaining("EVENT_SCOPE_PAGE maps 'specter/*' but the projection discovers no such scope"),
|
||||
]))
|
||||
expect(problems).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cordis-walk scan reach', () => {
|
||||
it('finds Context keys and Events names in nested Events-only merge files', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cordis-walk-'))
|
||||
roots.push(root)
|
||||
const dir = join(root, 'packages/client/ui-x/src/client')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'index.ts'), [
|
||||
"declare module 'cordis' {",
|
||||
' interface Events {',
|
||||
" 'x/changed'(): void",
|
||||
' }',
|
||||
'}',
|
||||
'export {}',
|
||||
'',
|
||||
].join('\n'))
|
||||
const merges = contextMergeFiles(root, 'packages/*/*/src/**/*.ts')
|
||||
expect(merges.map(m => m.rel)).toEqual(['packages/client/ui-x/src/client/index.ts'])
|
||||
const only = merges[0]
|
||||
if (!only) throw new Error('scan returned no merge')
|
||||
expect(eventNameList(only.body, only.sf)).toEqual(['x/changed'])
|
||||
expect([...contextKeyMap(only.body, only.sf).keys()]).toEqual([])
|
||||
})
|
||||
|
||||
it('yields every merge block of a multi-block file, double-quoted heads, and .tsx sources', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cordis-walk-'))
|
||||
roots.push(root)
|
||||
const dir = join(root, 'packages/client/ui-x/src')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
// The Typert analyzer reads every cordis module block in a file; the
|
||||
// backstop must not stop at the first one, skip the double-quoted legal
|
||||
// form, or ignore .tsx sources.
|
||||
writeFileSync(join(dir, 'split.ts'), [
|
||||
"declare module 'cordis' {",
|
||||
' interface Context {',
|
||||
' first: FirstService',
|
||||
' }',
|
||||
'}',
|
||||
'declare module "cordis" {',
|
||||
' interface Events {',
|
||||
" 'second/changed'(): void",
|
||||
' }',
|
||||
'}',
|
||||
'export {}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'view.tsx'), [
|
||||
"declare module 'cordis' {",
|
||||
' interface Context {',
|
||||
' fromTsx: TsxService',
|
||||
' }',
|
||||
'}',
|
||||
'export {}',
|
||||
'',
|
||||
].join('\n'))
|
||||
const merges = contextMergeFiles(root, ['packages/*/*/src/**/*.ts', 'packages/*/*/src/**/*.tsx'])
|
||||
expect(merges.map(m => m.rel)).toEqual([
|
||||
'packages/client/ui-x/src/split.ts',
|
||||
'packages/client/ui-x/src/split.ts',
|
||||
'packages/client/ui-x/src/view.tsx',
|
||||
])
|
||||
const keys = merges.flatMap(m => [...contextKeyMap(m.body, m.sf).keys()])
|
||||
const events = merges.flatMap(m => eventNameList(m.body, m.sf))
|
||||
expect(keys).toEqual(['first', 'fromTsx'])
|
||||
expect(events).toEqual(['second/changed'])
|
||||
})
|
||||
|
||||
it('reads string-literal and identifier member names from an Events merge', () => {
|
||||
const sf = ts.createSourceFile('x.ts', [
|
||||
"declare module 'cordis' {",
|
||||
' interface Events {',
|
||||
" 'scope/list'(items: string[]): void",
|
||||
' plain(): void',
|
||||
' }',
|
||||
' interface Context {',
|
||||
' thing: ThingService',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'), ts.ScriptTarget.Latest, true)
|
||||
const body = sf.statements[0] && ts.isModuleDeclaration(sf.statements[0]) && sf.statements[0].body
|
||||
&& ts.isModuleBlock(sf.statements[0].body)
|
||||
? sf.statements[0].body
|
||||
: null
|
||||
if (!body) throw new Error('fixture did not parse to a module block')
|
||||
expect(eventNameList(body, sf)).toEqual(['scope/list', 'plain'])
|
||||
expect([...contextKeyMap(body, sf)]).toEqual([['thing', 'ThingService']])
|
||||
})
|
||||
})
|
||||
+160
-41
@@ -21,7 +21,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-typert-generator'
|
||||
import type { CordisCatalogPolicy } from '@deepseek-ai/dsh-typert-generator'
|
||||
import { renderCordisCoreApiPages } from './cordis-core-api.ts'
|
||||
import { contextKeyMap, contextMergeFiles } from './cordis-walk.ts'
|
||||
import { contextKeyMap, contextMergeFiles, eventNameList } from './cordis-walk.ts'
|
||||
import {
|
||||
blobHash,
|
||||
parsePairMeta,
|
||||
@@ -97,10 +97,11 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
* Context keys declared in `interface Context` merges that the rendering
|
||||
* projection cannot see, each with the reason and its documentation owner.
|
||||
* The scan that enforces this list reads EVERY `declare module 'cordis'`
|
||||
* Context merge under `packages/x/x/src/*.ts` — not only root `index.ts`
|
||||
* files with a same-named service class — so a new service can never silently
|
||||
* join this blind spot: it either enters {@link SERVICE_PAGE} or names itself
|
||||
* here.
|
||||
* Context merge under `packages/x/x/src/**` — any depth, not only root
|
||||
* `index.ts` files with a same-named service class — so a new service can
|
||||
* never silently join this blind spot: it either enters {@link SERVICE_PAGE}
|
||||
* or names itself here. Client-face keys (the projection analyzes the host
|
||||
* face only) name the package README that owns their surface.
|
||||
* TODO(cordis-catalog-interface-services): the interface-typed and
|
||||
* non-index-declared entries would all render once the projection resolves a
|
||||
* Context key through its declaring file's imports to the class declaration.
|
||||
@@ -116,14 +117,27 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
apiProxy: 'interface-typed (ApiProxy) with the class in api-proxy.ts, not index.ts — packages/host/apiproxy/README.md owns the surface',
|
||||
appShell: 'client-side interface-typed browser service — packages/client/web/README.md owns the surface',
|
||||
connection: 'client-side interface-typed browser service — packages/client/connection/README.md owns the surface',
|
||||
chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the surface',
|
||||
command: 'client-side interface-typed browser service — packages/client/ui-command/README.md owns the surface',
|
||||
conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the surface',
|
||||
layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the surface',
|
||||
locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the surface',
|
||||
models: 'client-side interface-typed browser service — packages/client/ui-model/README.md owns the surface',
|
||||
modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the surface',
|
||||
remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the surface',
|
||||
sessionHistory: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
|
||||
slash: 'client-side interface-typed browser service — packages/client/ui-slash/README.md owns the surface',
|
||||
slots: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
|
||||
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the surface',
|
||||
workspaces: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
|
||||
}
|
||||
|
||||
/**
|
||||
* The owning subsystems page for every harness event scope (the segment
|
||||
* before the first `/`). Fail-closed exactly like {@link SERVICE_PAGE}.
|
||||
* `slash` lives with the human-command surface: the client slash-input
|
||||
* protocol parses toward command invocation and `dsh-ui-slash` owns the
|
||||
* declarations, but commands.md owns the cross-package command story.
|
||||
* before the first `/`) the projection renders. Fail-closed exactly like
|
||||
* {@link SERVICE_PAGE}. Client-face events (`slash/*`, `theme/change`, …) are
|
||||
* invisible to the host-face projection and therefore never reach this map;
|
||||
* {@link EVENT_WALK_EXEMPTIONS} names each one with its documentation owner.
|
||||
*/
|
||||
export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
'agent': 'core.md',
|
||||
@@ -145,6 +159,32 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
'workflow': 'workflow.md',
|
||||
}
|
||||
|
||||
/**
|
||||
* Event names declared in `interface Events` merges that the rendering
|
||||
* projection cannot see, each with the reason and its documentation owner.
|
||||
* The mirror of {@link SERVICE_WALK_EXEMPTIONS} for events: an independent
|
||||
* scan reads EVERY `declare module 'cordis'` Events merge under
|
||||
* `packages/x/x/src/**`, so a declared event either renders onto a subsystems
|
||||
* page (via {@link EVENT_SCOPE_PAGE}) or names itself here — never vanishes
|
||||
* silently. Keys are full event names, not scopes: client-face events share
|
||||
* scopes with rendered host events (`commands/changed` beside `commands/*`),
|
||||
* so a scope-level exemption would mask a host-face regression.
|
||||
*/
|
||||
export const EVENT_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
'commands/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'connection/reset': 'client-face transport signal — packages/client/runtime/README.md owns the surface',
|
||||
'credentials/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the surface',
|
||||
'models/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'settings/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slash/input-insert-reference': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slash/input-insert-text': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slots/changed': 'client-face slot invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'theme/change': 'client-face theme switch signal — packages/client/ui-theme/README.md owns the surface',
|
||||
}
|
||||
|
||||
/**
|
||||
* One primary subsystems page per project type used by a generated
|
||||
* signature. This stays curated because union names intentionally do not
|
||||
@@ -506,56 +546,135 @@ export function spliceRegion(content: string, region: string): string {
|
||||
return [...lines.slice(0, begin), ...region.split('\n'), ...lines.slice(end + 1)].join('\n')
|
||||
}
|
||||
|
||||
/** The declared-vs-rendered inputs {@link walkPartitionProblems} judges. */
|
||||
export interface WalkPartitionInput {
|
||||
/** Service key → source pointer, as the rendering projection produced them. */
|
||||
readonly renderedKeys: ReadonlyMap<string, string>
|
||||
/** Event scopes the rendering projection produced. */
|
||||
readonly renderedScopes: ReadonlySet<string>
|
||||
/** Event names the rendering projection produced. */
|
||||
readonly renderedEventNames: ReadonlySet<string>
|
||||
/** Context key → first declaring file, from the independent AST scan. */
|
||||
readonly declaredKeys: ReadonlyMap<string, string>
|
||||
/** Event name → first declaring file, from the independent AST scan. */
|
||||
readonly declaredEvents: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
/** The curated partition maps {@link walkPartitionProblems} enforces. */
|
||||
export interface WalkPartitionMaps {
|
||||
readonly servicePage: Readonly<Record<string, string>>
|
||||
readonly serviceWalkExemptions: Readonly<Record<string, string>>
|
||||
readonly eventScopePage: Readonly<Record<string, string>>
|
||||
readonly eventWalkExemptions: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge the rendered surface and the independent AST scan against the curated
|
||||
* partition maps, fail-closed in both directions for services AND events: a
|
||||
* rendered key/scope must be mapped to a page, a mapped key/scope must still
|
||||
* render, and — the backstop — a DECLARED key/event the projection cannot see
|
||||
* must carry a named walk exemption (a rendered one must not). A third
|
||||
* direction guards the scan itself: everything rendered must also be declared
|
||||
* to the scan, so a scan blind spot cannot decay silently. Pure so the
|
||||
* acceptance paths are provable without running the projection.
|
||||
* @param input - rendered surface plus the declared-key/event scans.
|
||||
* @param maps - the curated page maps and walk exemptions.
|
||||
* @returns one message per violation, empty when the partition holds.
|
||||
*/
|
||||
export function walkPartitionProblems(input: WalkPartitionInput, maps: WalkPartitionMaps): string[] {
|
||||
const problems: string[] = []
|
||||
for (const [key, source] of input.renderedKeys) {
|
||||
if (!Object.hasOwn(maps.servicePage, key)) problems.push(`service ctx.${key} (${source}) has no SERVICE_PAGE entry; every service maps to exactly one subsystems page.`)
|
||||
}
|
||||
for (const scope of [...input.renderedScopes].sort()) {
|
||||
if (!Object.hasOwn(maps.eventScopePage, scope)) problems.push(`event scope '${scope}/*' has no EVENT_SCOPE_PAGE entry; every event scope maps to exactly one subsystems page.`)
|
||||
}
|
||||
for (const key of Object.keys(maps.servicePage)) {
|
||||
if (!input.renderedKeys.has(key)) problems.push(`SERVICE_PAGE maps 'ctx.${key}' but the projection discovers no such service; remove the stale entry.`)
|
||||
}
|
||||
for (const scope of Object.keys(maps.eventScopePage)) {
|
||||
if (!input.renderedScopes.has(scope)) problems.push(`EVENT_SCOPE_PAGE maps '${scope}/*' but the projection discovers no such scope; remove the stale entry.`)
|
||||
}
|
||||
// The rendering projection only sees a Context key it can resolve to a
|
||||
// documented service class. The independent scan reads EVERY Context merge
|
||||
// so a key the projection cannot render must either be rendered (mapped) or
|
||||
// carry a named SERVICE_WALK_EXEMPTIONS reason — never vanish silently.
|
||||
for (const [key, rel] of input.declaredKeys) {
|
||||
const rendered = input.renderedKeys.has(key)
|
||||
const exempt = Object.hasOwn(maps.serviceWalkExemptions, key)
|
||||
if (!rendered && !exempt) {
|
||||
problems.push(`ctx.${key} (${rel}) is declared in a Context merge but invisible to the rendering projection; map it in SERVICE_PAGE (after making it renderable) or name it in SERVICE_WALK_EXEMPTIONS with its documentation owner.`)
|
||||
}
|
||||
if (rendered && exempt) problems.push(`ctx.${key} is rendered by the projection but still listed in SERVICE_WALK_EXEMPTIONS; remove the stale exemption.`)
|
||||
}
|
||||
for (const key of Object.keys(maps.serviceWalkExemptions)) {
|
||||
if (!input.declaredKeys.has(key)) problems.push(`SERVICE_WALK_EXEMPTIONS names 'ctx.${key}' but no Context merge declares it; remove the stale exemption.`)
|
||||
}
|
||||
// The event mirror of the service backstop: the projection walks only files
|
||||
// reachable from host-face package exports, so a client-face or unreachable
|
||||
// Events merge would otherwise vanish without a trace.
|
||||
for (const [name, rel] of input.declaredEvents) {
|
||||
const rendered = input.renderedEventNames.has(name)
|
||||
const exempt = Object.hasOwn(maps.eventWalkExemptions, name)
|
||||
if (!rendered && !exempt) {
|
||||
problems.push(`event '${name}' (${rel}) is declared in an Events merge but invisible to the rendering projection; make it renderable (mapped via EVENT_SCOPE_PAGE) or name it in EVENT_WALK_EXEMPTIONS with its documentation owner.`)
|
||||
}
|
||||
if (rendered && exempt) problems.push(`event '${name}' is rendered by the projection but still listed in EVENT_WALK_EXEMPTIONS; remove the stale exemption.`)
|
||||
}
|
||||
for (const name of Object.keys(maps.eventWalkExemptions)) {
|
||||
if (!input.declaredEvents.has(name)) problems.push(`EVENT_WALK_EXEMPTIONS names '${name}' but no Events merge declares it; remove the stale exemption.`)
|
||||
}
|
||||
// Self-check the scan itself: everything the projection renders is declared
|
||||
// in a Context/Events merge the scan must also reach, so a rendered key or
|
||||
// event the scan cannot see means the SCAN regressed (glob, prefilter, or
|
||||
// block walk) — a partial blind spot that exemption staleness alone would
|
||||
// never surface.
|
||||
for (const key of input.renderedKeys.keys()) {
|
||||
if (!input.declaredKeys.has(key)) problems.push(`ctx.${key} is rendered by the projection but the independent scan finds no Context merge declaring it; the scan has a blind spot (glob, prefilter, or module-block walk) — fix the scan, not the maps.`)
|
||||
}
|
||||
for (const name of input.renderedEventNames) {
|
||||
if (!input.declaredEvents.has(name)) problems.push(`event '${name}' is rendered by the projection but the independent scan finds no Events merge declaring it; the scan has a blind spot (glob, prefilter, or module-block walk) — fix the scan, not the maps.`)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute every generated artifact: the inherited-tier page, the model-facing
|
||||
* runtime API module, plus, per mapped subsystems page, the pair's two updated
|
||||
* documents with the injected region. Fail-loud partition checks live here: an
|
||||
* unmapped service/event scope, a mapping whose page file does not exist, a
|
||||
* curated entry whose key/scope the projection no longer discovers, and a
|
||||
* mapped page missing its markers are all aggregated errors.
|
||||
* curated entry whose key/scope the projection no longer discovers, a declared
|
||||
* Context key or Events member the projection cannot see without a named walk
|
||||
* exemption, and a mapped page missing its markers are all aggregated errors.
|
||||
* @returns `[repo-relative path, exact content]` for every generated artifact.
|
||||
*/
|
||||
export function computeOutputs(): [string, string][] {
|
||||
const { projector, model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY)
|
||||
const services = [...model.services]
|
||||
const events = [...model.events]
|
||||
const problems: string[] = []
|
||||
|
||||
const discoveredKeys = new Set(services.map(s => s.key))
|
||||
const discoveredScopes = new Set(events.map(e => e.scope))
|
||||
for (const s of services) {
|
||||
if (!Object.hasOwn(SERVICE_PAGE, s.key)) problems.push(`service ctx.${s.key} (${s.source}) has no SERVICE_PAGE entry; every service maps to exactly one subsystems page.`)
|
||||
}
|
||||
for (const scope of discoveredScopes) {
|
||||
if (!Object.hasOwn(EVENT_SCOPE_PAGE, scope)) problems.push(`event scope '${scope}/*' has no EVENT_SCOPE_PAGE entry; every event scope maps to exactly one subsystems page.`)
|
||||
}
|
||||
for (const key of Object.keys(SERVICE_PAGE)) {
|
||||
if (!discoveredKeys.has(key)) problems.push(`SERVICE_PAGE maps 'ctx.${key}' but the projection discovers no such service; remove the stale entry.`)
|
||||
}
|
||||
for (const scope of Object.keys(EVENT_SCOPE_PAGE)) {
|
||||
if (!discoveredScopes.has(scope)) problems.push(`EVENT_SCOPE_PAGE maps '${scope}/*' but the projection discovers no such scope; remove the stale entry.`)
|
||||
}
|
||||
// The rendering projection only sees a Context key it can resolve to a
|
||||
// documented service class. This independent scan reads EVERY Context merge
|
||||
// so a key the projection cannot render must either be rendered (mapped) or
|
||||
// carry a named SERVICE_WALK_EXEMPTIONS reason — never vanish silently.
|
||||
const declaredKeys = new Map<string, string>()
|
||||
for (const { rel, sf, body } of contextMergeFiles(root, 'packages/*/*/src/*.ts')) {
|
||||
const declaredEvents = new Map<string, string>()
|
||||
for (const { rel, sf, body } of contextMergeFiles(root, ['packages/*/*/src/**/*.ts', 'packages/*/*/src/**/*.tsx'])) {
|
||||
for (const key of contextKeyMap(body, sf).keys()) {
|
||||
if (!declaredKeys.has(key)) declaredKeys.set(key, rel)
|
||||
}
|
||||
}
|
||||
for (const [key, rel] of declaredKeys) {
|
||||
const rendered = discoveredKeys.has(key)
|
||||
const exempt = Object.hasOwn(SERVICE_WALK_EXEMPTIONS, key)
|
||||
if (!rendered && !exempt) {
|
||||
problems.push(`ctx.${key} (${rel}) is declared in a Context merge but invisible to the rendering projection; map it in SERVICE_PAGE (after making it renderable) or name it in SERVICE_WALK_EXEMPTIONS with its documentation owner.`)
|
||||
for (const name of eventNameList(body, sf)) {
|
||||
if (!declaredEvents.has(name)) declaredEvents.set(name, rel)
|
||||
}
|
||||
if (rendered && exempt) problems.push(`ctx.${key} is rendered by the projection but still listed in SERVICE_WALK_EXEMPTIONS; remove the stale exemption.`)
|
||||
}
|
||||
for (const key of Object.keys(SERVICE_WALK_EXEMPTIONS)) {
|
||||
if (!declaredKeys.has(key)) problems.push(`SERVICE_WALK_EXEMPTIONS names 'ctx.${key}' but no Context merge declares it; remove the stale exemption.`)
|
||||
}
|
||||
const problems = walkPartitionProblems({
|
||||
renderedKeys: new Map(services.map(s => [s.key, s.source])),
|
||||
renderedScopes: new Set(events.map(e => e.scope)),
|
||||
renderedEventNames: new Set(events.map(e => e.name)),
|
||||
declaredKeys,
|
||||
declaredEvents,
|
||||
}, {
|
||||
servicePage: SERVICE_PAGE,
|
||||
serviceWalkExemptions: SERVICE_WALK_EXEMPTIONS,
|
||||
eventScopePage: EVENT_SCOPE_PAGE,
|
||||
eventWalkExemptions: EVENT_WALK_EXEMPTIONS,
|
||||
})
|
||||
if (problems.length > 0) throw new Error(`gen-cordis-catalog: ${problems.length} partition violation(s):\n${problems.map(p => ` ${p}`).join('\n')}`)
|
||||
|
||||
const pages = [...new Set([...Object.values(SERVICE_PAGE), ...Object.values(EVENT_SCOPE_PAGE)])].sort()
|
||||
|
||||
@@ -1154,7 +1154,7 @@ function renderLifecycle(): string {
|
||||
const maintenance = 'curated Mermaid sequence; exact event signatures live in the generated Cordis catalog'
|
||||
return [
|
||||
...generatedHeader('Agent Turn And Step Lifecycle'),
|
||||
'This sequence is the visual companion to [architecture.md](architecture.md#loop-lifecycle-session--turn--step). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.',
|
||||
'This sequence is the visual companion to [architecture.md](architecture.md#default-loop-lifecycle). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.',
|
||||
'',
|
||||
'```mermaid',
|
||||
'sequenceDiagram',
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Acceptance-path coverage for fragment validation in `verify-md-links`: a
|
||||
* `#fragment` onto a Markdown target — same-file anchors included — must name
|
||||
* a real heading slug or explicit `<a id>`, while non-Markdown fragments and
|
||||
* external targets stay out of scope.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { anchorCache, documentAnchors, findViolations, githubSlug } from './verify-md-links.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function layout(files: Record<string, string>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'md-links-'))
|
||||
roots.push(root)
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
mkdirSync(join(root, rel, '..'), { recursive: true })
|
||||
writeFileSync(join(root, rel), content)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
function violationsIn(root: string, rel: string): { url: string; reason: string }[] {
|
||||
return findViolations(join(root, rel), anchorCache(), root).map(({ url, reason }) => ({ url, reason }))
|
||||
}
|
||||
|
||||
describe('documentAnchors', () => {
|
||||
it('slugs rendered heading text, suffixes repeats, and reads explicit <a id> anchors', () => {
|
||||
const anchors = documentAnchors([
|
||||
'# My Doc',
|
||||
'## Live `events` — mode!',
|
||||
'## Repeat',
|
||||
'## Repeat',
|
||||
'<a id="hand-anchor"></a>',
|
||||
'',
|
||||
].join('\n'))
|
||||
expect(anchors).toEqual(new Set(['my-doc', 'live-events--mode', 'repeat', 'repeat-1', 'hand-anchor']))
|
||||
expect(githubSlug('Security and authority are non-goals')).toBe('security-and-authority-are-non-goals')
|
||||
})
|
||||
|
||||
it('keeps underscores the way GitHub does', () => {
|
||||
expect(githubSlug('Showcase: web_fetch')).toBe('showcase-web_fetch')
|
||||
expect(documentAnchors('## Showcase: web_fetch\n')).toEqual(new Set(['showcase-web_fetch']))
|
||||
})
|
||||
|
||||
it('slugs a heading containing a link from its rendered text', () => {
|
||||
expect(documentAnchors('## [Install](setup.md)\n')).toEqual(new Set(['install']))
|
||||
})
|
||||
|
||||
it('bumps repeat suffixes past occupied slugs, matching GitHub', () => {
|
||||
const anchors = documentAnchors(['## Repeat', '## Repeat-1', '## Repeat', ''].join('\n'))
|
||||
expect(anchors).toEqual(new Set(['repeat', 'repeat-1', 'repeat-2']))
|
||||
})
|
||||
|
||||
it('ignores <a id> inside code fences, inline code, and HTML comments', () => {
|
||||
const anchors = documentAnchors([
|
||||
'# Doc',
|
||||
'```md',
|
||||
'<a id="fenced"></a>',
|
||||
'```',
|
||||
'Inline `<a id="inline"></a>` sample.',
|
||||
'<!-- <a id="commented"></a> -->',
|
||||
'<a id="real"></a>',
|
||||
'',
|
||||
].join('\n'))
|
||||
expect(anchors).toEqual(new Set(['doc', 'real']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('findViolations fragments', () => {
|
||||
it('accepts resolving same-file and cross-file fragments, non-md fragments, and externals', () => {
|
||||
const root = layout({
|
||||
'a.md': '# A\n\n## Deferred work\n\n[self](#deferred-work) [b](b.md#part-two) [code](x.ts#L10) [ext](https://x.example/#frag)\n',
|
||||
'b.md': '# B\n\n## Part two\n',
|
||||
'x.ts': 'export {}\n',
|
||||
})
|
||||
expect(violationsIn(root, 'a.md')).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a same-file fragment that names no heading or <a id>', () => {
|
||||
const root = layout({ 'a.md': '# A\n\n[gone](#deferred-work)\n' })
|
||||
expect(violationsIn(root, 'a.md')).toEqual([{ url: '#deferred-work', reason: 'anchor' }])
|
||||
})
|
||||
|
||||
it('rejects a case-variant fragment: element ids are case-sensitive', () => {
|
||||
const root = layout({ 'a.md': '# A\n\n## Default Loop\n\n[case](#Default-Loop)\n' })
|
||||
expect(violationsIn(root, 'a.md')).toEqual([{ url: '#Default-Loop', reason: 'anchor' }])
|
||||
})
|
||||
|
||||
it('rejects a cross-file fragment missing from the target document', () => {
|
||||
const root = layout({
|
||||
'a.md': '# A\n\n[stale](b.md#old-heading)\n',
|
||||
'b.md': '# B\n\n## New heading\n',
|
||||
})
|
||||
expect(violationsIn(root, 'a.md')).toEqual([{ url: 'b.md#old-heading', reason: 'anchor' }])
|
||||
})
|
||||
|
||||
it('still rejects a missing target file, reported as target not anchor', () => {
|
||||
const root = layout({ 'a.md': '# A\n\n[ghost](missing.md#anything)\n' })
|
||||
expect(violationsIn(root, 'a.md')).toEqual([{ url: 'missing.md#anything', reason: 'target' }])
|
||||
})
|
||||
})
|
||||
+134
-30
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* Verify that relative Markdown links, images, and definitions resolve. URL,
|
||||
* root-absolute, and in-page targets are excluded; query strings and fragments
|
||||
* do not affect resolution against the source file. The checker never rewrites,
|
||||
* and symlinked instruction files are deduped.
|
||||
* Verify that relative Markdown links, images, and definitions resolve — the
|
||||
* target file must exist AND a `#fragment` onto a Markdown target (including
|
||||
* a same-file `#anchor`) must name a real heading slug or explicit `<a id>`.
|
||||
* URL and root-absolute targets are excluded; query strings do not affect
|
||||
* resolution against the source file. The checker never rewrites, and
|
||||
* symlinked instruction files are deduped.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { dirname, relative, resolve } from 'node:path'
|
||||
import type { Nodes } from 'mdast'
|
||||
import { parseMarkdown, visitMarkdown } from './markdown.ts'
|
||||
import { markdownHeadingLines, parseMarkdown, visitMarkdown } from './markdown.ts'
|
||||
import { isArchivedAgentNotePath, uniqueRepoFiles } from './repo-files.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
@@ -28,21 +30,22 @@ const PATTERNS = [
|
||||
'skills/**/*.md',
|
||||
]
|
||||
|
||||
/** A broken relative link: a target path that does not resolve to a file. */
|
||||
/** A broken relative link: a missing target path or a missing anchor on it. */
|
||||
interface Violation {
|
||||
file: string
|
||||
/** 1-based line where the link/image/definition node starts. */
|
||||
line: number
|
||||
url: string
|
||||
/** What failed: the target file or the fragment onto it. */
|
||||
reason: 'target' | 'anchor'
|
||||
}
|
||||
|
||||
/**
|
||||
* True for targets this gate must NOT check: scheme-qualified URLs (`https:`,
|
||||
* `mailto:`, …), protocol-relative (`//host`), root-absolute (`/path`), and
|
||||
* pure in-page anchors (`#frag`). Everything else is a relative path we own.
|
||||
* `mailto:`, …), protocol-relative (`//host`), and root-absolute (`/path`).
|
||||
* Pure in-page anchors (`#frag`) ARE checked, against the source file itself.
|
||||
*/
|
||||
function isExternalOrAnchor(url: string): boolean {
|
||||
if (url.startsWith('#')) return true
|
||||
function isExternal(url: string): boolean {
|
||||
if (url.startsWith('//')) return true
|
||||
if (url.startsWith('/')) return true
|
||||
// A scheme like `https:` / `mailto:` — a colon before any slash, dot, or hash.
|
||||
@@ -69,22 +72,119 @@ function pathPart(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Find every broken relative cross-link in one Markdown file via its AST. */
|
||||
function findViolations(absPath: string): Violation[] {
|
||||
const file = relative(root, absPath)
|
||||
/** The percent-decoded `#fragment` of a link target, or null when it has none. */
|
||||
function fragmentPart(url: string): string | null {
|
||||
const hash = url.indexOf('#')
|
||||
if (hash === -1) return null
|
||||
const raw = url.slice(hash + 1).replace(/\?.*$/, '')
|
||||
try {
|
||||
return decodeURIComponent(raw)
|
||||
} catch {
|
||||
// Same stance as pathPart: a malformed escape names no anchor anyone
|
||||
// meant, so the raw text flows into the lookup and is reported missing.
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub's heading-slug algorithm (lowercase; drop everything but letters,
|
||||
* numbers, underscores, spaces, hyphens; spaces become hyphens). Underscores
|
||||
* survive (`## Showcase: web_fetch` → `#showcase-web_fetch`), unlike
|
||||
* `gen-cordis-catalog`'s region-anchor slugs — the generator's headings are
|
||||
* always reachable through its explicit `<a id>` anchors, so the two need not
|
||||
* share one rule.
|
||||
* @param heading - the RENDERED heading text (Markdown syntax already gone).
|
||||
* @returns the anchor GitHub assigns the first occurrence of the heading.
|
||||
*/
|
||||
export function githubSlug(heading: string): string {
|
||||
return heading.toLowerCase().replace(/[^\p{L}\p{N}_ -]/gu, '').replaceAll(' ', '-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Every anchor one Markdown document exposes: each heading's GitHub slug —
|
||||
* computed from the RENDERED heading text, so links, images, inline code, and
|
||||
* emphasis inside a heading slug the way GitHub renders them — plus every
|
||||
* explicit `<a id="…">` that appears in real HTML flow (a fenced or inline
|
||||
* code sample and a commented-out anchor register nothing). Repeated slugs
|
||||
* get GitHub's occupied-set `-1`, `-2`, … suffixes: each collision bumps the
|
||||
* ORIGINAL slug's counter until a free name is found, so `Repeat`, `Repeat-1`,
|
||||
* `Repeat` yields `repeat`, `repeat-1`, `repeat-2`. Matching is exact —
|
||||
* element ids are case-sensitive.
|
||||
* @param source - the document's full Markdown text.
|
||||
* @returns the set of valid fragments for links into this document.
|
||||
*/
|
||||
export function documentAnchors(source: string): Set<string> {
|
||||
const anchors = new Set<string>()
|
||||
const occurrences = new Map<string, number>()
|
||||
for (const heading of markdownHeadingLines(source)) {
|
||||
const base = githubSlug(heading.text)
|
||||
let result = base
|
||||
let bump = occurrences.get(base) ?? 0
|
||||
while (anchors.has(result)) {
|
||||
bump += 1
|
||||
result = `${base}-${bump}`
|
||||
}
|
||||
occurrences.set(base, bump)
|
||||
anchors.add(result)
|
||||
}
|
||||
visitMarkdown(parseMarkdown(source), (node: Nodes): void => {
|
||||
if (node.type !== 'html') return
|
||||
const html = node.value.replace(/<!--[\s\S]*?-->/g, '')
|
||||
for (const match of html.matchAll(/<a id="([^"]+)"/g)) anchors.add(match[1] ?? '')
|
||||
})
|
||||
return anchors
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily collect and cache the anchor set of any existing Markdown file —
|
||||
* shared across all scanned sources so a target parses once.
|
||||
* @returns the memoized absolute-path → anchor-set lookup.
|
||||
*/
|
||||
export function anchorCache(): (absPath: string) => Set<string> {
|
||||
const cache = new Map<string, Set<string>>()
|
||||
return (absPath) => {
|
||||
const hit = cache.get(absPath)
|
||||
if (hit) return hit
|
||||
const anchors = documentAnchors(readFileSync(absPath, 'utf8'))
|
||||
cache.set(absPath, anchors)
|
||||
return anchors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every broken relative cross-link in one Markdown file via its AST: a
|
||||
* relative target that does not exist, or a fragment onto a Markdown file
|
||||
* (same-file `#anchor` links included) that names no heading slug or explicit
|
||||
* `<a id>` there. Fragments onto non-Markdown targets (`file.ts#L10`) carry
|
||||
* renderer-owned semantics and are not judged.
|
||||
* @param absPath - absolute path of the Markdown source to scan.
|
||||
* @param anchorsOf - anchor lookup shared across files for cross-link checks.
|
||||
* @param scanRoot - repository root violations are reported relative to.
|
||||
* @returns one entry per broken link, in document order.
|
||||
*/
|
||||
export function findViolations(
|
||||
absPath: string,
|
||||
anchorsOf: (abs: string) => Set<string>,
|
||||
scanRoot: string = root,
|
||||
): Violation[] {
|
||||
const file = relative(scanRoot, absPath)
|
||||
const dir = dirname(absPath)
|
||||
const source = readFileSync(absPath, 'utf8')
|
||||
const tree = parseMarkdown(source)
|
||||
const out: Violation[] = []
|
||||
|
||||
const check = (url: string, node: Nodes): void => {
|
||||
if (isExternalOrAnchor(url)) return
|
||||
if (isExternal(url)) return
|
||||
const target = pathPart(url)
|
||||
// A bare `#anchor` reduced to empty path is a same-file anchor — skip.
|
||||
if (target === '') return
|
||||
const resolved = resolve(dir, target)
|
||||
const resolved = target === '' ? absPath : resolve(dir, target)
|
||||
if (!existsSync(resolved)) {
|
||||
out.push({ file, line: node.position?.start.line ?? 0, url })
|
||||
out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'target' })
|
||||
return
|
||||
}
|
||||
const fragment = fragmentPart(url)
|
||||
if (fragment === null || !resolved.endsWith('.md')) return
|
||||
if (!anchorsOf(resolved).has(fragment)) {
|
||||
out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'anchor' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,18 +196,22 @@ function findViolations(absPath: string): Violation[] {
|
||||
return out
|
||||
}
|
||||
|
||||
// Archived notes remain valid link targets, but their historical outbound links are frozen.
|
||||
const files = uniqueRepoFiles(root, PATTERNS, isArchivedAgentNotePath)
|
||||
const all = files.flatMap(file => findViolations(file.abs))
|
||||
const checked = files.length
|
||||
// Run only when invoked as a script, not when imported by the spec.
|
||||
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
|
||||
// Archived notes remain valid link targets, but their historical outbound links are frozen.
|
||||
const files = uniqueRepoFiles(root, PATTERNS, isArchivedAgentNotePath)
|
||||
const anchorsOf = anchorCache()
|
||||
const all = files.flatMap(file => findViolations(file.abs, anchorsOf))
|
||||
const checked = files.length
|
||||
|
||||
if (all.length === 0) {
|
||||
console.log(`verify-md-links: ${checked} file(s) checked, all relative cross-links resolve.`)
|
||||
process.exit(0)
|
||||
}
|
||||
if (all.length === 0) {
|
||||
console.log(`verify-md-links: ${checked} file(s) checked, all relative cross-links and fragments resolve.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-md-links: broken relative cross-links found (target does not exist):')
|
||||
for (const v of all) {
|
||||
console.error(` ${v.file}:${v.line} ${v.url}`)
|
||||
console.error('verify-md-links: broken relative cross-links found:')
|
||||
for (const v of all) {
|
||||
console.error(` ${v.file}:${v.line} ${v.url} (${v.reason === 'target' ? 'target does not exist' : 'no such anchor in target'})`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(1)
|
||||
Reference in New Issue
Block a user