@attalabs/vinaya 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +14 -3
  2. package/aeg-root/contracts/archivist-tranche-archivist.md +2 -2
  3. package/aeg-root/contracts/brief-developer.md +1 -0
  4. package/aeg-root/contracts/developer-reviewer.md +3 -3
  5. package/aeg-root/contracts/reviewer-archivist.md +3 -3
  6. package/aeg-root/enforcement.md +26 -20
  7. package/aeg-root/process.md +8 -4
  8. package/aeg-root/roles/archivist.md +2 -2
  9. package/aeg-root/roles/brief-author.md +9 -3
  10. package/aeg-root/roles/developer.md +63 -31
  11. package/aeg-root/roles/planner.md +1 -1
  12. package/aeg-root/roles/principal.md +14 -0
  13. package/aeg-root/roles/reviewer.md +32 -15
  14. package/aeg-root/roles/security.md +15 -5
  15. package/aeg-root/skills/aeg/SKILL.md +9 -5
  16. package/aeg-root/skills/aeg-roles/SKILL.md +2 -2
  17. package/aeg-root/skills/brief-authoring/SKILL.md +39 -27
  18. package/aeg-root/state-machine.md +9 -11
  19. package/aeg-root/templates/brief-template.md +6 -6
  20. package/aeg-root/templates/pr-report-template.md +10 -7
  21. package/aeg-root/tranche-model.md +6 -2
  22. package/dist/checks/bin/check-body-bare-digits.js +559 -140
  23. package/dist/checks/bin/check-branch-topology.js +630 -145
  24. package/dist/checks/bin/check-brief-shape.js +600 -143
  25. package/dist/checks/bin/check-changeset-coverage.js +942 -162
  26. package/dist/checks/bin/check-closes-n.js +630 -145
  27. package/dist/checks/bin/check-coherence.js +641 -148
  28. package/dist/checks/bin/check-dead-branch-push.js +559 -140
  29. package/dist/checks/bin/check-dispatch-readiness.js +639 -146
  30. package/dist/checks/bin/check-doc-coverage-push.js +696 -219
  31. package/dist/checks/bin/check-doc-coverage.js +696 -219
  32. package/dist/checks/bin/check-doctrine-no-procedures.js +4800 -0
  33. package/dist/checks/bin/check-doctrine-portability.js +691 -147
  34. package/dist/checks/bin/check-evidence-fresh.js +1048 -222
  35. package/dist/checks/bin/check-exec-bits.js +4937 -0
  36. package/dist/checks/bin/check-first-push-dispatch.js +630 -145
  37. package/dist/checks/bin/check-issue-assignment.js +630 -145
  38. package/dist/checks/bin/check-main-branch-refusal.js +565 -141
  39. package/dist/checks/bin/check-no-disk-state.js +559 -140
  40. package/dist/checks/bin/check-pr-report-density.js +4467 -0
  41. package/dist/checks/bin/check-quoted-command.js +656 -164
  42. package/dist/checks/bin/check-reader-resolvable-prose.js +652 -163
  43. package/dist/checks/bin/check-registry-gates.js +602 -145
  44. package/dist/checks/bin/check-retired-vocabulary.js +652 -163
  45. package/dist/checks/bin/check-review-gate.js +677 -142
  46. package/dist/checks/bin/check-single-plan-pr.js +559 -140
  47. package/dist/checks/bin/check-test-plan.js +560 -141
  48. package/dist/checks/bin/check-token-collection-wired.js +559 -140
  49. package/dist/checks/bin/check-token-report.js +559 -140
  50. package/dist/checks/bin/check-workspace-escape.js +992 -146
  51. package/dist/index.js +8884 -5627
  52. package/package.json +1 -1
  53. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/actions.ts +173 -0
  54. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/anchored-region.ts +118 -0
  55. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/archive-task.ts +206 -0
  56. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/baseline-capture.ts +65 -0
  57. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/blast-radius-domains.ts +198 -0
  58. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/branch-topology-gate.ts +85 -0
  59. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-render.ts +462 -0
  60. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-validation.ts +912 -0
  61. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/claude-code-transcript.ts +460 -0
  62. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/coherence-checks.ts +806 -0
  63. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/consumer-enumeration.ts +75 -0
  64. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-audit.ts +55 -0
  65. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-guard.ts +77 -0
  66. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-section7.ts +66 -0
  67. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-tranche.ts +94 -0
  68. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/diagram-model.ts +372 -0
  69. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/direct-main-push.ts +39 -0
  70. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dispatch-gate.ts +268 -0
  71. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doc-owners.ts +314 -0
  72. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/build-doc-nav.ts +38 -0
  73. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/docs-coherence.ts +136 -0
  74. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/index.ts +29 -0
  75. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/legacy-anchors.ts +48 -0
  76. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/nav-helpers.ts +17 -0
  77. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/node-route.ts +134 -0
  78. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/parse-doc.ts +43 -0
  79. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/published-prose.ts +306 -0
  80. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/surfaced-manifest.ts +89 -0
  81. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/types.ts +33 -0
  82. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-no-procedures.ts +94 -0
  83. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-portability.ts +293 -0
  84. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-source.ts +23 -0
  85. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/ensure-label.ts +28 -0
  86. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/file-classify.ts +51 -0
  87. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/first-push-dispatch-gate.ts +80 -0
  88. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/gate-audience.ts +171 -0
  89. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/index.ts +323 -0
  90. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-assignment.ts +102 -0
  91. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-validation.ts +746 -0
  92. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/leftover-detection.ts +68 -0
  93. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/local-anchor-coverage.ts +245 -0
  94. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/main-branch-refusal.ts +64 -0
  95. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/manifest-validity.ts +81 -0
  96. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/markdown-table.ts +76 -0
  97. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/metering-io-guard.ts +119 -0
  98. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/milestone-validation.ts +240 -0
  99. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/no-disk-state.ts +50 -0
  100. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-ledger.ts +77 -0
  101. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-registry.ts +105 -0
  102. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-token-report.ts +177 -0
  103. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-tranche.ts +183 -0
  104. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-report-density.ts +116 -0
  105. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-tier.ts +81 -0
  106. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/premise-check.ts +160 -0
  107. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/quoted-command.ts +275 -0
  108. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/reader-resolvable-prose.ts +320 -0
  109. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-checks.ts +255 -0
  110. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-parse.ts +164 -0
  111. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-scaffold.ts +216 -0
  112. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/report-tokens.ts +261 -0
  113. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/retired-vocabulary.ts +163 -0
  114. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-gate.ts +378 -0
  115. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-status.ts +200 -0
  116. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/single-plan-pr.ts +73 -0
  117. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-machine-model.ts +269 -0
  118. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-source.ts +14 -0
  119. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/status-block.ts +8 -0
  120. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/sum-ledger.ts +26 -0
  121. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/symbol-collisions.ts +78 -0
  122. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-gate.ts +117 -0
  123. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-section.ts +65 -0
  124. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/types.ts +174 -0
  125. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/verdict-extraction.ts +199 -0
  126. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/vocabulary-citation.ts +114 -0
  127. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/waiver-label.ts +52 -0
  128. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/workspace-escape.ts +172 -0
  129. package/studio-standalone/apps/vinaya-studio/web/.next/BUILD_ID +1 -1
  130. package/studio-standalone/apps/vinaya-studio/web/.next/build-manifest.json +3 -3
  131. package/studio-standalone/apps/vinaya-studio/web/.next/prerender-manifest.json +3 -3
  132. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.html +1 -1
  133. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.rsc +1 -1
  134. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  135. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  136. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  137. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  138. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  139. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page/server-reference-manifest.json +2 -2
  140. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  141. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  142. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/api/coherence/route.js.nft.json +1 -1
  143. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page/server-reference-manifest.json +2 -2
  144. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page.js.nft.json +1 -1
  145. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page_client-reference-manifest.js +1 -1
  146. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page/server-reference-manifest.json +2 -2
  147. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page.js.nft.json +1 -1
  148. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page_client-reference-manifest.js +1 -1
  149. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page/server-reference-manifest.json +2 -2
  150. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page.js.nft.json +1 -1
  151. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page_client-reference-manifest.js +1 -1
  152. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page/server-reference-manifest.json +2 -2
  153. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page.js.nft.json +1 -1
  154. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page_client-reference-manifest.js +1 -1
  155. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page/server-reference-manifest.json +2 -2
  156. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page.js.nft.json +1 -1
  157. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page_client-reference-manifest.js +1 -1
  158. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page/server-reference-manifest.json +2 -2
  159. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page.js.nft.json +1 -1
  160. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page_client-reference-manifest.js +1 -1
  161. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page/server-reference-manifest.json +2 -2
  162. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page.js.nft.json +1 -1
  163. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page_client-reference-manifest.js +1 -1
  164. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page/server-reference-manifest.json +2 -2
  165. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page.js.nft.json +1 -1
  166. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page_client-reference-manifest.js +1 -1
  167. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/[root-of-the-server]__02a-3g8._.js +1 -1
  168. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/1q96_modules_@clerk_nextjs_dist_esm_app-router_client_keyless-creator-reader_0lom2js.js +1 -1
  169. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0053k9k._.js +1 -1
  170. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0112h-k._.js +1 -1
  171. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0o771t1._.js +1 -1
  172. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0puovz5._.js +1 -1
  173. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1hs0dcu._.js +1 -1
  174. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{[root-of-the-server]__069-_41._.js → [root-of-the-server]__1pndh3_._.js} +1 -1
  175. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1wc4-ip._.js +1 -1
  176. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_03x_w6q._.js +1 -1
  177. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0gvm3og._.js +1 -1
  178. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0lwxg63._.js +1 -1
  179. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1fqw88f._.js +2 -2
  180. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1n0cnq-._.js +1 -1
  181. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/apps_vinaya-studio_web_src_app_studio_projects_[name]_tranches_[slug]_05ufo8i._.js +3 -3
  182. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
  183. package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
  184. package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
  185. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
  186. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
  187. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{37hnluhzj9bei.js → 0zebsmmk1bvnb.js} +1 -1
  188. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{267o-tsrnuwnv.js → 1uogpj5w2n5ju.js} +1 -1
  189. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1hufpuody6vjv.js → 3m1kgax7j2vgs.js} +1 -1
  190. package/studio-standalone/apps/vinaya-studio/web/package.json +2 -2
  191. /package/studio-standalone/_node_modules/@attalabs/vinaya/studio-standalone/_node_modules/@attalabs/{aeg-core → vinaya/studio-standalone/_node_modules/@attalabs/vinaya/studio-standalone/_node_modules/@attalabs/aeg-core}/bin/verify-coherence.ts +0 -0
  192. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → QY2GhiI47-765D9DHGy1I}/_buildManifest.js +0 -0
  193. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → QY2GhiI47-765D9DHGy1I}/_clientMiddlewareManifest.js +0 -0
  194. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → QY2GhiI47-765D9DHGy1I}/_ssgManifest.js +0 -0
@@ -0,0 +1,314 @@
1
+ /**
2
+ * The `.vinaya/doc-owners` manifest parser and the C5 code→doc
3
+ * coverage evaluator (state-machine.md Section 15). Pure — I/O (reading the
4
+ * manifest off disk, checking pointer existence) is injected by the caller.
5
+ */
6
+
7
+ import { isCodeFile } from './file-classify'
8
+ import { WAIVER_LABEL } from './waiver-label'
9
+
10
+ export const DOC_OWNERS_PATH = '.vinaya/doc-owners'
11
+
12
+ export type DocOwnersBinding = { glob: string; pointer: string; lineNum: number }
13
+
14
+ /**
15
+ * What a caller found where the manifest should be.
16
+ *
17
+ * `absent` — a repo that never configured doc ownership. Legitimately dormant.
18
+ * `empty` — a manifest that exists but has nothing in it. This is the shape a
19
+ * misresolved repo root produces, and reporting it as success is how a broken
20
+ * derivation passes for a real one. It is a refusal, not a dormancy.
21
+ * `present` — parse it.
22
+ *
23
+ * Split out of `verify-dispatch --surfaces` so the three-way decision is
24
+ * testable: the CLI chdirs to the repo root before reading, so the branch is
25
+ * unreachable from a test as long as it lives inside the command.
26
+ */
27
+ export type DocOwnersManifestState = 'absent' | 'empty' | 'present'
28
+
29
+ export function classifyDocOwnersManifest(content: string | null): DocOwnersManifestState {
30
+ if (content === null) return 'absent'
31
+ return content.trim() === '' ? 'empty' : 'present'
32
+ }
33
+
34
+ export type C5Result = { errors: string[]; notes: string[] }
35
+
36
+ /**
37
+ * Parse the doc-owners file. Each non-blank, non-comment line is
38
+ * `<code-glob> <doc-pointer>`
39
+ *
40
+ * Comments start with `#`. Pointer is in-repo path, path#anchor, or URL.
41
+ * Returns the parsed bindings + any malformed-line errors.
42
+ */
43
+ export function parseDocOwners(content: string): { bindings: DocOwnersBinding[]; errors: string[] } {
44
+ const bindings: DocOwnersBinding[] = []
45
+ const errors: string[] = []
46
+ const lines = content.split('\n')
47
+ for (let i = 0; i < lines.length; i++) {
48
+ const raw = lines[i] ?? ''
49
+ const stripped = raw.replace(/#.*$/, '').trim()
50
+ if (!stripped) continue
51
+ const parts = stripped.split(/\s+/)
52
+ if (parts.length < 2) {
53
+ errors.push(
54
+ `C5 doc-owners-parse: ${DOC_OWNERS_PATH}:${i + 1} — malformed binding (expected "<glob> <pointer>", got "${raw.trim()}").`
55
+ )
56
+ continue
57
+ }
58
+ const [glob, ...pointerParts] = parts
59
+ bindings.push({ glob: glob ?? '', pointer: pointerParts.join(' '), lineNum: i + 1 })
60
+ }
61
+ return { bindings, errors }
62
+ }
63
+
64
+ /**
65
+ * Translate a doc-owners glob to a RegExp. Deliberately simple — only `*` and
66
+ * `**` are special; every other character is literal so Next.js dynamic-route
67
+ * segments like `[username]` match without escaping.
68
+ */
69
+ export function globToRegex(pat: string): RegExp {
70
+ let re = '^'
71
+ let i = 0
72
+ while (i < pat.length) {
73
+ const c = pat[i]
74
+ if (c === '*') {
75
+ if (pat[i + 1] === '*') {
76
+ re += '.*'
77
+ i += 2
78
+ } else {
79
+ re += '[^/]*'
80
+ i += 1
81
+ }
82
+ } else if (/[.+^$|(){}[\]\\?]/.test(c as string)) {
83
+ re += `\\${c}`
84
+ i += 1
85
+ } else {
86
+ re += c
87
+ i += 1
88
+ }
89
+ }
90
+ re += '$'
91
+ return new RegExp(re)
92
+ }
93
+
94
+ export function isUrlPointer(p: string): boolean {
95
+ return /^https?:\/\//.test(p)
96
+ }
97
+
98
+ export function pointerToPath(p: string): string {
99
+ const idx = p.indexOf('#')
100
+ return idx === -1 ? p : p.slice(0, idx)
101
+ }
102
+
103
+ export type DocAck = { surface: string; note: string }
104
+ export type DocNeutral = { surface: string; note: string }
105
+
106
+ // Separator between <pointer> and <note>. Tolerates em-dash (—),
107
+ // en-dash (–), or a plain ASCII hyphen-minus (-) with REQUIRED surrounding
108
+ // whitespace. Required whitespace around `-` disambiguates the separator
109
+ // from hyphens that legitimately appear inside pointers (e.g. `aeg-root`,
110
+ // `.claude/skills/ui-components/SKILL.md`). Non-greedy `(.+?)` for the
111
+ // pointer plus this anchored separator means the pointer naturally stops
112
+ // at the first valid separator without literally excluding `-` from
113
+ // the pointer character set.
114
+ const SEPARATOR = /(?:[ \t]*[—–][ \t]*|[ \t]+-[ \t]+)/.source
115
+
116
+ /** Shared by `Doc-ack:` and `Doc-neutral:` — both are `<field>: <pointer> <sep> <note>` PR-body lines. */
117
+ function readPointerNoteField(body: string, field: string): { surface: string; note: string }[] {
118
+ const out: { surface: string; note: string }[] = []
119
+ const re = new RegExp(`^[ \\t]*${field}[ \\t]*:[ \\t]*(.+?)${SEPARATOR}(.+?)[ \\t]*$`, 'gim')
120
+ for (const m of body.matchAll(re)) {
121
+ out.push({ surface: (m[1] ?? '').trim(), note: (m[2] ?? '').trim() })
122
+ }
123
+ return out
124
+ }
125
+
126
+ export function readDocAcks(body: string): DocAck[] {
127
+ return readPointerNoteField(body, 'Doc-ack')
128
+ }
129
+
130
+ /**
131
+ * `Doc-neutral: <pointer> — <note>` — declares that the doc a fired binding
132
+ * would otherwise require is not owed because the matched code change is
133
+ * mechanically neutral (comment/whitespace-only). Mirrors `Doc-ack:`'s
134
+ * grammar deliberately, so it reads as the same PR-body-field family.
135
+ *
136
+ * The declaration alone is never sufficient — see `isMechanicallyNeutralDiff`
137
+ * below, which `evaluateC5` requires as corroborating evidence before this
138
+ * declaration satisfies a fired binding. An unevidenced declaration is a
139
+ * distinct failure (`C5 doc-neutral-unverified`), not a silent pass — that
140
+ * asymmetry is what keeps this from being a self-serve exemption.
141
+ */
142
+ export function readDocNeutrals(body: string): DocNeutral[] {
143
+ return readPointerNoteField(body, 'Doc-neutral')
144
+ }
145
+
146
+ /**
147
+ * Full-line comment markers, scoped per source-language file extension. A
148
+ * marker is listed for a language ONLY if no legitimate code construct in
149
+ * that language can start a trimmed line with it — that constraint is what
150
+ * keeps this fail-closed rather than a bare per-line prefix table (review
151
+ * finding, task 4 code review): a flat asterisk/hash/double-dash/block-
152
+ * comment-delimiter table misclassifies real code as comment — a TS/JS
153
+ * generator method (`*gen() {`), a TS/JS private class field
154
+ * (`#count = 0`), SQL's `--` also being C-style pre-decrement (`--i;`), and
155
+ * a block-comment-close delimiter hiding trailing code on the same line are
156
+ * all real collisions, not hypothetical ones. `//` is the only marker kept
157
+ * for TS/JS/JSX because no such language construct starts a trimmed line
158
+ * with `//`. An unrecognized extension gets no safe marker at all, so
159
+ * `isMechanicallyNeutralDiff` can never classify it neutral — fail-closed
160
+ * under uncertainty, not an attempt at full per-language comment/string-
161
+ * region parsing (out of this task's bounded "comment/whitespace-only"
162
+ * scope).
163
+ */
164
+ const LANGUAGE_COMMENT_PREFIXES: ReadonlyArray<{ test: RegExp; prefixes: readonly string[] }> = [
165
+ { test: /\.(ts|tsx|js|jsx|mjs|cjs)$/, prefixes: ['//'] },
166
+ { test: /(^|\/)(pre-push|pre-commit|pre-merge-commit)$/, prefixes: ['#'] },
167
+ { test: /\.(sh|bash)$/, prefixes: ['#'] },
168
+ { test: /\.ya?ml$/, prefixes: ['#'] },
169
+ { test: /\.py$/, prefixes: ['#'] }
170
+ ]
171
+
172
+ function commentPrefixesForPath(path: string): readonly string[] {
173
+ for (const { test, prefixes } of LANGUAGE_COMMENT_PREFIXES) {
174
+ if (test.test(path)) return prefixes
175
+ }
176
+ return []
177
+ }
178
+
179
+ function isNeutralDiffContentLine(line: string, prefixes: readonly string[]): boolean {
180
+ const t = line.trim()
181
+ if (t === '') return true
182
+ // A shebang is comment-shaped but changes what interpreter runs the
183
+ // file — real behavior, never neutral, even though it starts with `#`.
184
+ if (t.startsWith('#!')) return false
185
+ return prefixes.some((p) => t.startsWith(p))
186
+ }
187
+
188
+ /**
189
+ * The evidence half of the neutral-edit path: given a unified diff for one
190
+ * file and that file's path (for language-scoped comment-marker selection),
191
+ * true only if every added/removed line is comment-only or whitespace-only.
192
+ * Context lines and the `+++`/`---`/`@@` headers are not evidence either
193
+ * way. A diff with zero +/- content lines is not "a change" at all, so it
194
+ * returns false rather than vacuously true — there must be at least one
195
+ * actual edited line for a neutrality claim to mean anything. An
196
+ * unrecognized file extension (`commentPrefixesForPath` returns `[]`)
197
+ * always returns false — no fired binding on an unrecognized language can
198
+ * take the Doc-neutral path.
199
+ */
200
+ export function isMechanicallyNeutralDiff(diffText: string, path: string): boolean {
201
+ const prefixes = commentPrefixesForPath(path)
202
+ let sawChange = false
203
+ for (const raw of diffText.split('\n')) {
204
+ if (raw.startsWith('+++ ') || raw.startsWith('--- ') || raw.startsWith('@@')) continue
205
+ if (raw.startsWith('+') || raw.startsWith('-')) {
206
+ if (!isNeutralDiffContentLine(raw.slice(1), prefixes)) return false
207
+ sawChange = true
208
+ }
209
+ }
210
+ return sawChange
211
+ }
212
+
213
+ /**
214
+ * Pure evaluator for the C5 doc-coverage check. The runtime wrapper reads
215
+ * `.vinaya/doc-owners` from disk and `PR_BODY` from env; this
216
+ * function takes both as inputs and an injectable file-exists for unit tests.
217
+ *
218
+ * Dormancy: a null `docOwnersContent` (absent file) OR no glob matching any
219
+ * changed code file produces an empty result — no errors, no notes.
220
+ *
221
+ * `waiverActive` is a single PR-wide boolean, not a per-binding
222
+ * pointer/reason lookup — it's the caller-resolved result of
223
+ * `isWaiverLabelActorVerified`, itself a mechanized read of a forge fact.
224
+ * There is no agent-emittable `Doc-waiver:` string anymore; a waiver is
225
+ * either verified true PR-wide, or not.
226
+ *
227
+ * `getDiff` is optional and injectable (unit tests; also absent whenever a
228
+ * caller has no diff-content source) — it resolves the unified diff for one
229
+ * matched code file, feeding the `Doc-neutral:` evidence check below. Its
230
+ * absence never produces a pass: a declared-but-unevidenced neutral claim
231
+ * fails the same as if it evidence didn't check out, preserving silence =
232
+ * failure (state-machine.md Section 15).
233
+ */
234
+ export function evaluateC5(
235
+ changed: string[],
236
+ docOwnersContent: string | null,
237
+ prBody: string,
238
+ fileExists: (p: string) => boolean,
239
+ waiverActive: boolean,
240
+ getDiff?: (p: string) => string | null
241
+ ): C5Result {
242
+ const out: C5Result = { errors: [], notes: [] }
243
+
244
+ if (docOwnersContent === null) return out
245
+
246
+ const { bindings, errors: parseErrors } = parseDocOwners(docOwnersContent)
247
+ for (const e of parseErrors) out.errors.push(e)
248
+ if (bindings.length === 0) return out
249
+
250
+ const codeFiles = changed.filter(isCodeFile)
251
+ const fired: { binding: DocOwnersBinding; matchedFiles: string[] }[] = []
252
+ for (const b of bindings) {
253
+ const re = globToRegex(b.glob)
254
+ const matchedFiles = codeFiles.filter((f) => re.test(f))
255
+ if (matchedFiles.length > 0) fired.push({ binding: b, matchedFiles })
256
+ }
257
+ if (fired.length === 0) return out
258
+
259
+ const acks = readDocAcks(prBody)
260
+ const neutrals = readDocNeutrals(prBody)
261
+
262
+ for (const { binding: b, matchedFiles } of fired) {
263
+ if (waiverActive) {
264
+ out.notes.push(`C5 doc-waiver active for ${b.pointer} (binding ${DOC_OWNERS_PATH}:${b.lineNum})`)
265
+ continue
266
+ }
267
+
268
+ if (isUrlPointer(b.pointer)) {
269
+ const acked = acks.some((a) => a.surface === b.pointer)
270
+ if (!acked) {
271
+ out.errors.push(
272
+ `C5 doc-coverage: code change matched ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\` → ${b.pointer}). External pointer requires \`Doc-ack: ${b.pointer} — <note>\` in the PR body, or an actor-verified \`${WAIVER_LABEL}\` label to skip.`
273
+ )
274
+ }
275
+ continue
276
+ }
277
+
278
+ const pointerPath = pointerToPath(b.pointer)
279
+ if (!fileExists(pointerPath)) {
280
+ out.errors.push(
281
+ `C5 doc-owners-dangling: ${DOC_OWNERS_PATH}:${b.lineNum} points to ${b.pointer}, which does not exist on disk. Fix the binding or add the doc.`
282
+ )
283
+ continue
284
+ }
285
+
286
+ if (changed.includes(pointerPath)) continue
287
+
288
+ const declared = neutrals.find((n) => n.surface === b.pointer)
289
+ if (declared) {
290
+ const evidenced =
291
+ getDiff !== undefined &&
292
+ matchedFiles.every((f) => {
293
+ const diff = getDiff(f)
294
+ return diff !== null && isMechanicallyNeutralDiff(diff, f)
295
+ })
296
+ if (evidenced) {
297
+ out.notes.push(
298
+ `C5 doc-neutral: ${b.pointer} not required for ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\`) — declared neutral ("${declared.note}"), diff of ${matchedFiles.join(', ')} confirmed comment/whitespace-only.`
299
+ )
300
+ continue
301
+ }
302
+ out.errors.push(
303
+ `C5 doc-neutral-unverified: ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\` → ${b.pointer}) declared \`Doc-neutral: ${b.pointer} — ${declared.note}\`, but the diff of ${matchedFiles.join(', ')} contains changes beyond comments/whitespace. Update ${pointerPath}, or have a principal apply the \`${WAIVER_LABEL}\` label.`
304
+ )
305
+ continue
306
+ }
307
+
308
+ out.errors.push(
309
+ `C5 doc-coverage: code change matched ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\` → ${b.pointer}), but ${pointerPath} is not in the PR diff. Update it, declare \`Doc-neutral: ${b.pointer} — <why>\` if the change is genuinely mechanically-neutral, or have a principal apply the \`${WAIVER_LABEL}\` label.`
310
+ )
311
+ }
312
+
313
+ return out
314
+ }
@@ -0,0 +1,38 @@
1
+ import type { Doc, DocNav, DocSection } from './types'
2
+
3
+ export type BuildDocNavOptions = {
4
+ sectionOrder?: string[]
5
+ }
6
+
7
+ function sectionIdOf(label: string): string {
8
+ return label
9
+ .toLowerCase()
10
+ .replace(/[^a-z0-9]+/g, '-')
11
+ .replace(/^-+|-+$/g, '')
12
+ }
13
+
14
+ export function buildDocNav(docs: Doc[], opts: BuildDocNavOptions = {}): DocNav {
15
+ const bySection = new Map<string, Doc[]>()
16
+ for (const doc of docs) {
17
+ const list = bySection.get(doc.section) ?? []
18
+ list.push(doc)
19
+ bySection.set(doc.section, list)
20
+ }
21
+
22
+ const ordered: string[] = []
23
+ if (opts.sectionOrder) {
24
+ for (const label of opts.sectionOrder) if (bySection.has(label)) ordered.push(label)
25
+ }
26
+ for (const label of bySection.keys()) if (!ordered.includes(label)) ordered.push(label)
27
+
28
+ const sections: DocSection[] = ordered.map((label) => {
29
+ const items = (bySection.get(label) ?? []).slice().sort((a, b) => {
30
+ if (a.order !== b.order) return a.order - b.order
31
+ return a.title.localeCompare(b.title)
32
+ })
33
+ return { id: sectionIdOf(label), label, docs: items }
34
+ })
35
+
36
+ const flat = sections.flatMap((s) => s.docs)
37
+ return { sections, flat }
38
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * C6 — the docs-coherence check (state-machine.md Section 15c). Pure: given
3
+ * every parsed doc entry from `aeg-root/` and the model-backed surfaced-path
4
+ * allowlist (`modelBackedDocPaths`, passed in because deriving
5
+ * it requires reading doctrine, which this pure module never does), asserts:
6
+ * (a) every surfaced doc is reachable in the doc-nav tree the docs engine
7
+ * (`buildDocNav`) would build for the surfaced set;
8
+ * (b) no surfaced doc's `parent:` reference points at a doc that doesn't
9
+ * exist, or exists but isn't itself surfaced;
10
+ * (c) every relative `.md` link between two surfaced docs resolves to
11
+ * another surfaced doc.
12
+ *
13
+ * (a) and (b) mirror the real parent/child resolution
14
+ * `apps/vinaya/web/src/lib/docs/nest-doc-children.ts` performs when building
15
+ * Vinaya's live `/docs` nav (a Portal route since `vinaya-pages-v1` task 8;
16
+ * that file is read-only reference here, never imported): a doc whose
17
+ * `parent:` frontmatter points at
18
+ * a nonexistent (or excluded) slug is silently dropped from that nav's flat
19
+ * list — reachable neither at the top level nor as anyone's child. That
20
+ * silent-drop is the exact defect this check exists to catch.
21
+ */
22
+
23
+ import { deriveTitle, type ParsedDoc } from './parse-doc'
24
+ import { isSurfacedDoc } from './surfaced-manifest'
25
+ import type { Doc, DocFrontmatter } from './types'
26
+
27
+ export type DocsCoherenceEntry = {
28
+ /** Path relative to `aeg-root/`, e.g. `roles/developer.md`. */
29
+ relPath: string
30
+ frontmatter: DocFrontmatter
31
+ body: string
32
+ firstH1?: string
33
+ }
34
+
35
+ export type DocsCoherenceResult = { errors: string[]; notes: string[] }
36
+
37
+ function slugOf(relPath: string): string {
38
+ return relPath.replace(/\.md$/, '')
39
+ }
40
+
41
+ function relPathOf(slug: string): string {
42
+ return `${slug}.md`
43
+ }
44
+
45
+ function defaultSectionFor(relPath: string): string {
46
+ const segments = relPath.split('/')
47
+ return segments.length === 1 ? 'Overview' : (segments[0] ?? 'Overview')
48
+ }
49
+
50
+ function toDoc(entry: DocsCoherenceEntry): Doc {
51
+ const slug = slugOf(entry.relPath)
52
+ const parsed: ParsedDoc = { frontmatter: entry.frontmatter, body: entry.body, firstH1: entry.firstH1 }
53
+ return {
54
+ slug,
55
+ title: deriveTitle(parsed, slug),
56
+ sidebarTitle: entry.frontmatter.sidebarTitle,
57
+ description: entry.frontmatter.description,
58
+ section: entry.frontmatter.section ?? defaultSectionFor(entry.relPath),
59
+ order: entry.frontmatter.order ?? 0,
60
+ href: `/docs/${slug}`,
61
+ filePath: `aeg-root/${entry.relPath}`,
62
+ parentSlug: entry.frontmatter.parent
63
+ }
64
+ }
65
+
66
+ function isReachable(doc: Doc, bySlug: Map<string, Doc>, seen: Set<string> = new Set()): boolean {
67
+ if (!doc.parentSlug) return true
68
+ if (seen.has(doc.slug)) return false // cycle guard
69
+ const parent = bySlug.get(doc.parentSlug)
70
+ if (!parent) return false
71
+ seen.add(doc.slug)
72
+ return isReachable(parent, bySlug, seen)
73
+ }
74
+
75
+ const MD_LINK_PATTERN = /\]\(([^)]+)\)/g
76
+
77
+ /** Resolves `target` relative to the directory of `fromRelPath`. Returns null if it escapes `aeg-root/`. */
78
+ function resolveRelativeMdLink(fromRelPath: string, target: string): string | null {
79
+ const fromDir = fromRelPath.includes('/') ? fromRelPath.slice(0, fromRelPath.lastIndexOf('/')) : ''
80
+ const combined = target.startsWith('/') ? target.slice(1) : fromDir ? `${fromDir}/${target}` : target
81
+
82
+ const stack: string[] = []
83
+ for (const part of combined.split('/')) {
84
+ if (part === '' || part === '.') continue
85
+ if (part === '..') {
86
+ if (stack.length === 0) return null
87
+ stack.pop()
88
+ } else {
89
+ stack.push(part)
90
+ }
91
+ }
92
+ return stack.join('/')
93
+ }
94
+
95
+ export function evaluateDocsCoherence(
96
+ entries: DocsCoherenceEntry[],
97
+ surfacedPaths: ReadonlySet<string> = new Set()
98
+ ): DocsCoherenceResult {
99
+ const errors: string[] = []
100
+ const notes: string[] = []
101
+
102
+ const surfacedEntries = entries.filter((e) => isSurfacedDoc(e.relPath, e.frontmatter, surfacedPaths))
103
+ const surfacedRelPaths = new Set(surfacedEntries.map((e) => e.relPath))
104
+ const docs = surfacedEntries.map(toDoc)
105
+ const bySlug = new Map(docs.map((d) => [d.slug, d]))
106
+
107
+ for (const doc of docs) {
108
+ if (!isReachable(doc, bySlug)) {
109
+ errors.push(`C6: surfaced doc "${relPathOf(doc.slug)}" is not reachable in the doc nav`)
110
+ }
111
+ if (doc.parentSlug && !bySlug.has(doc.parentSlug)) {
112
+ errors.push(
113
+ `C6: nav entry "${relPathOf(doc.slug)}" points at a non-existent/excluded doc "${relPathOf(doc.parentSlug)}"`
114
+ )
115
+ }
116
+ }
117
+
118
+ for (const entry of surfacedEntries) {
119
+ for (const match of entry.body.matchAll(MD_LINK_PATTERN)) {
120
+ const raw = (match[1] ?? '').trim()
121
+ const target = (raw.split('#')[0] ?? '').trim()
122
+ if (!target) continue // pure same-doc anchor, e.g. `(#some-heading)`
123
+ if (/^https?:\/\//.test(target) || target.startsWith('mailto:')) continue
124
+ if (!target.endsWith('.md')) continue
125
+
126
+ const resolved = resolveRelativeMdLink(entry.relPath, target)
127
+ if (resolved === null) continue // escapes aeg-root/ — out of scope for this check
128
+
129
+ if (!surfacedRelPaths.has(resolved)) {
130
+ errors.push(`C6: link "${target}" in "${entry.relPath}" resolves to no surfaced doc`)
131
+ }
132
+ }
133
+ }
134
+
135
+ return { errors, notes }
136
+ }
@@ -0,0 +1,29 @@
1
+ export type { Doc, DocFrontmatter, DocNav, DocSection } from './types'
2
+ export { parseDocFrontmatter, deriveTitle, stripLeadingH1 } from './parse-doc'
3
+ export type { ParsedDoc } from './parse-doc'
4
+ export { buildDocNav } from './build-doc-nav'
5
+ export type { BuildDocNavOptions } from './build-doc-nav'
6
+ export { findDoc, getNextDoc, getPrevDoc } from './nav-helpers'
7
+ export { isSurfacedDoc, surfacedDocs, modelBackedDocPaths } from './surfaced-manifest'
8
+ export type { SurfacedManifestEntry } from './surfaced-manifest'
9
+ export { nodeDocRoute, nodeDocHref } from './node-route'
10
+ export type { NodeDocRoute } from './node-route'
11
+ export { legacyAnchorSlugs } from './legacy-anchors'
12
+ export { evaluateDocsCoherence } from './docs-coherence'
13
+ export type { DocsCoherenceEntry, DocsCoherenceResult } from './docs-coherence'
14
+ export {
15
+ ALLOWED_MECHANICS,
16
+ CONTRACT_BLOCKS,
17
+ countWords,
18
+ enforcementPublishedText,
19
+ evaluatePublishedProse,
20
+ extractShortVersion,
21
+ publishedDoctrineBody,
22
+ readabilityErrors,
23
+ REFERENCE_HEADING,
24
+ ROLE_BLOCKS,
25
+ SHORT_VERSION_HEADING,
26
+ SHORT_VERSION_MAX_WORDS,
27
+ SHORT_VERSION_MIN_WORDS
28
+ } from './published-prose'
29
+ export type { PublishedProseEntry, PublishedProseResult } from './published-prose'
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The old-slug → new-slug alias derivation for `/docs` gate/check anchors.
3
+ * `node-route.ts`'s `nodeSlug()` computes an anchor from a node's display
4
+ * form (G-code stripped, over-length names cut at a clause boundary), which
5
+ * for a handful of nodes now differs from the slug `diagram-model.ts`
6
+ * originally stamped into the node id. A reader who bookmarked, or a page
7
+ * that still links to, the old `#g1-implementation-exists`-style fragment
8
+ * must keep landing on the right section — this is the input to that alias.
9
+ *
10
+ * Derived, not hand-listed: the failure mode this replaces is exactly the
11
+ * one `node-route.ts`'s own header comment warns against for a second slug
12
+ * source. Zero I/O, additive export — takes an already-derived node, returns
13
+ * data, never reads a file (aeg-core purity, #372/#382/#506).
14
+ */
15
+
16
+ import type { DiagramNode } from '../diagram-model'
17
+ import { nodeDocRoute } from './node-route'
18
+
19
+ /**
20
+ * A doctrine cell's own text renamed outright (not a G-code strip, not an
21
+ * over-length cut) is the one case this file cannot derive. `node.id` is
22
+ * `slugify(row.action)` computed fresh from whatever `enforcement.md` says
23
+ * RIGHT NOW — by the time a rename lands, the old text is gone from the
24
+ * doctrine snapshot entirely, so there is nothing left in `node.id`/
25
+ * `node.label` for a derivation to diff against. This is not a second
26
+ * routing authority — `nodeSlug()`/`nodeDocRoute()` still compute every
27
+ * live anchor unconditionally; this map only remembers what one specific
28
+ * anchor used to be before an editor rewrote the cell it came from, and it
29
+ * grows by exactly one entry, once, at the moment of a rename. Keyed by the
30
+ * node id the rename produced. */
31
+ const RENAMED_CELL_ALIASES: Record<string, string> = {
32
+ 'check:coherence-check': 'coherence-oracle'
33
+ }
34
+
35
+ /**
36
+ * The anchor slugs this node used to publish and must continue to answer
37
+ * to. `[]` when the node's canonical slug is unchanged — most nodes, since
38
+ * `nodeSlug()`'s cleanup is a no-op on an already-clean display form.
39
+ */
40
+ export function legacyAnchorSlugs(node: DiagramNode): string[] {
41
+ const rawSlug = node.id.slice(node.kind.length + 1)
42
+ const canonicalSlug = nodeDocRoute(node)?.slug
43
+ const aliases = new Set<string>()
44
+ if (canonicalSlug && canonicalSlug !== rawSlug) aliases.add(rawSlug)
45
+ const renamed = RENAMED_CELL_ALIASES[node.id]
46
+ if (renamed) aliases.add(renamed)
47
+ return [...aliases]
48
+ }
@@ -0,0 +1,17 @@
1
+ import type { Doc, DocNav } from './types'
2
+
3
+ export function findDoc(nav: DocNav, slug: string): Doc | undefined {
4
+ return nav.flat.find((d) => d.slug === slug)
5
+ }
6
+
7
+ export function getNextDoc(nav: DocNav, slug: string): Doc | undefined {
8
+ const idx = nav.flat.findIndex((d) => d.slug === slug)
9
+ if (idx === -1 || idx === nav.flat.length - 1) return undefined
10
+ return nav.flat[idx + 1]
11
+ }
12
+
13
+ export function getPrevDoc(nav: DocNav, slug: string): Doc | undefined {
14
+ const idx = nav.flat.findIndex((d) => d.slug === slug)
15
+ if (idx <= 0) return undefined
16
+ return nav.flat[idx - 1]
17
+ }