@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,134 @@
1
+ /**
2
+ * The single node → `/docs` route derivation. Nav construction
3
+ * (`load-aeg-docs.ts`), the "Read more" resolver (`read-more.ts`), and the
4
+ * harness map (`/docs` `page.tsx`) all resolve a `DiagramNode` to its docs
5
+ * location through THIS function — one source of truth, so a card's deep-link,
6
+ * a "Read more" anchor, and a ring/action page's heading `id` can never point
7
+ * at three different places for the same node. A parallel, hand-maintained
8
+ * slug list is the exact failure mode this replaces (the same discipline
9
+ * `surfaced-manifest.ts` holds for the file allowlist).
10
+ *
11
+ * Granularity follows content size. A role or contract is a whole
12
+ * `aeg-root/**.md` file, so it keeps its own page and needs no anchor. A gate
13
+ * or check is one row of `enforcement.md`, and an action is one entry of
14
+ * `ACTIONS`, so each is an `#`-anchored section inside a grouping page
15
+ * (`/docs/rings/ring-<n>` or `/docs/actions`).
16
+ *
17
+ * The anchor slug is recomputed here from the node's DISPLAY form, not read
18
+ * verbatim off the node id. `node.id`/`node.label` keep the full doctrine
19
+ * text — G-codes included — because the registry check (G1-G5,
20
+ * `registry-checks.ts`) matches on that exact id; a slug that a reader
21
+ * follows into a URL fragment has no such constraint, so it is cleaned at
22
+ * this one edge instead. Both the heading (`humanLabel`/`shortLabel`, in
23
+ * `apps/vinaya/web`) and this anchor apply the same two rules — strip a
24
+ * leading G-code, cut a pathologically long name at its first clause — so a
25
+ * heading and its anchor still agree, just no longer byte-identical to the
26
+ * doctrine id. A node whose display form needs no cleanup still gets the
27
+ * same slug it always did, since stripping/cutting are no-ops on it.
28
+ *
29
+ * Zero I/O, additive export — takes an already-derived node, returns data,
30
+ * never reads a file (aeg-core purity, #372/#382/#506).
31
+ */
32
+
33
+ import type { DiagramNode } from '../diagram-model'
34
+
35
+ export type NodeDocRoute = {
36
+ /** The page a node lives on. */
37
+ route: string
38
+ /** The heading anchor within that page, or `null` for a file-sized node
39
+ * (role/contract) that owns its whole page. */
40
+ slug: string | null
41
+ }
42
+
43
+ /** A pathologically long anchor is cut at its first clause boundary rather
44
+ * than published whole. 100 is chosen against the current doctrine, not a
45
+ * round guess: the longest anchor slug outside the one row this exists to
46
+ * fix is 84 characters (a ring-0 gate's compound name), so 100 leaves that
47
+ * row untouched while still catching genuinely oversized names — the one
48
+ * 127-character row this was written for included. */
49
+ const MAX_ANCHOR_SLUG_LENGTH = 100
50
+
51
+ /** Strips a leading `g<n>-` code off an already-slugified anchor. Operates on
52
+ * the slug form (not `node.label`) so a node whose id isn't `slugify(label)`
53
+ * to begin with — an `action`, whose id is the hand-authored `ACTIONS` entry
54
+ * id, not a re-derivation of its label — is never touched: no G-coded or
55
+ * over-length action exists today, so this function is a no-op for every one
56
+ * of them, exactly preserving their existing anchors. */
57
+ function stripGCodeSlug(slug: string): string {
58
+ return slug.replace(/^g\d+-/, '')
59
+ }
60
+
61
+ /** Strips the same `G<n> — ` doctrine code off raw label text, for the
62
+ * over-length fallback below. Same contract as `humanLabel` in
63
+ * `apps/vinaya/web`'s `display-label.ts` (duplicated rather than shared:
64
+ * aeg-core cannot import from the web app, and this rule is small enough
65
+ * that keeping two copies in sync by inspection is cheaper than a new
66
+ * cross-package export). */
67
+ function stripGCodeLabel(label: string): string {
68
+ return label.replace(/^G\d+\s*—\s*/, '')
69
+ }
70
+
71
+ /** Cuts at the first clause boundary, same separator set `shortLabel` uses
72
+ * in `display-label.ts` — kept in sync with that function's comment by
73
+ * inspection, for the same reason `stripGCodeLabel` is duplicated above. */
74
+ function firstClause(label: string): string {
75
+ return label.split(/ \(| \/ |—|–|: /)[0]?.trim() ?? label
76
+ }
77
+
78
+ /** Same cleanup `diagram-model.ts`'s own (private, un-exported) `slugify`
79
+ * applies when it stamps a node id — duplicated here rather than imported so
80
+ * this file never needs `diagram-model.ts` to export it. Reached only by the
81
+ * over-length fallback below, never by the common case. */
82
+ function slugifyText(text: string): string {
83
+ return text
84
+ .toLowerCase()
85
+ .replace(/`/g, '')
86
+ .replace(/[^a-z0-9]+/g, '-')
87
+ .replace(/^-+|-+$/g, '')
88
+ }
89
+
90
+ /** The anchor slug for a node's display form. Strips a G-code off the
91
+ * existing id-stamped slug first — a byte-preserving operation for every
92
+ * node that never had one. Only if the result is STILL too long to publish
93
+ * does this fall back to re-deriving a short slug from `node.label`'s first
94
+ * clause; that fallback is the one path that can produce a slug other than
95
+ * a prefix of the id-stamped one, and today only one row (a 127-character
96
+ * ring-0 gate name) reaches it. */
97
+ function nodeSlug(node: DiagramNode): string {
98
+ const rawSlug = node.id.slice(node.kind.length + 1)
99
+ const stripped = stripGCodeSlug(rawSlug)
100
+ if (stripped.length <= MAX_ANCHOR_SLUG_LENGTH) return stripped
101
+ return slugifyText(firstClause(stripGCodeLabel(node.label)))
102
+ }
103
+
104
+ /**
105
+ * Resolve a node to its `/docs` route + anchor slug. Returns `null` for a kind
106
+ * with no docs surface (there is none today — every kind resolves).
107
+ */
108
+ export function nodeDocRoute(node: DiagramNode): NodeDocRoute | null {
109
+ switch (node.kind) {
110
+ case 'role':
111
+ return { route: `/docs/roles/${node.label}`, slug: null }
112
+ case 'contract':
113
+ return { route: `/docs/contracts/${node.label}`, slug: null }
114
+ case 'gate':
115
+ case 'check':
116
+ if (node.ringIndex === undefined) return null
117
+ return { route: `/docs/rings/ring-${node.ringIndex}`, slug: nodeSlug(node) }
118
+ case 'action':
119
+ return { route: '/docs/actions', slug: nodeSlug(node) }
120
+ case 'ring':
121
+ if (node.ringIndex === undefined) return null
122
+ return { route: `/docs/rings/ring-${node.ringIndex}`, slug: null }
123
+ default:
124
+ return null
125
+ }
126
+ }
127
+
128
+ /** The full in-app href — `route` plus a `#slug` fragment when the node is an
129
+ * anchored section. The form the map and `read-more.ts` link to directly. */
130
+ export function nodeDocHref(node: DiagramNode): string | null {
131
+ const resolved = nodeDocRoute(node)
132
+ if (!resolved) return null
133
+ return resolved.slug ? `${resolved.route}#${resolved.slug}` : resolved.route
134
+ }
@@ -0,0 +1,43 @@
1
+ import matter from 'gray-matter'
2
+ import type { DocFrontmatter } from './types'
3
+
4
+ export type ParsedDoc = {
5
+ frontmatter: DocFrontmatter
6
+ body: string
7
+ firstH1?: string
8
+ }
9
+
10
+ const H1_PATTERN = /^\s*#\s+(.+?)\s*$/m
11
+
12
+ export function parseDocFrontmatter(raw: string): ParsedDoc {
13
+ const { data, content } = matter(raw)
14
+ const fm = data as Record<string, unknown>
15
+
16
+ const frontmatter: DocFrontmatter = {}
17
+ if (typeof fm.title === 'string') frontmatter.title = fm.title
18
+ if (typeof fm.sidebarTitle === 'string') {
19
+ frontmatter.sidebarTitle = fm.sidebarTitle
20
+ } else if (typeof fm.sidebar_title === 'string') {
21
+ frontmatter.sidebarTitle = fm.sidebar_title
22
+ }
23
+ if (typeof fm.description === 'string') frontmatter.description = fm.description
24
+ if (typeof fm.section === 'string') frontmatter.section = fm.section
25
+ if (typeof fm.order === 'number') frontmatter.order = fm.order
26
+ if (typeof fm.parent === 'string') frontmatter.parent = fm.parent
27
+ if (typeof fm.surfaced === 'boolean') frontmatter.surfaced = fm.surfaced
28
+
29
+ const h1Match = content.match(H1_PATTERN)
30
+ const firstH1 = h1Match?.[1]?.trim()
31
+
32
+ return { frontmatter, body: content, firstH1 }
33
+ }
34
+
35
+ export function deriveTitle(parsed: ParsedDoc, fallbackFromPath: string): string {
36
+ if (parsed.frontmatter.title) return parsed.frontmatter.title
37
+ if (parsed.firstH1) return parsed.firstH1
38
+ return fallbackFromPath
39
+ }
40
+
41
+ export function stripLeadingH1(body: string): string {
42
+ return body.replace(/^\s*#\s+.*\n+/, '')
43
+ }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * C7 — the published-prose check. Pure and zero-I/O, shaped like
3
+ * `docs-coherence.ts` (parsed entries plus the model-backed surfaced-path
4
+ * allowlist in, `{ errors, notes }` out).
5
+ *
6
+ * A role or contract doc is read by two audiences with opposite needs: an
7
+ * agent taking the role reads it end to end as its operating instruction, and
8
+ * a person landing on `/docs/roles/<id>` reads the same file cold. The answer
9
+ * is one short, binding section at the top of every role and contract — `##
10
+ * The short version` — which is what publishes; everything under `##
11
+ * Reference` explains it and stays in the repo. This check enforces two
12
+ * properties of that section, over the published set only:
13
+ *
14
+ * 1. **Structure** — every surfaced `roles/*.md` and `contracts/*.md` opens
15
+ * with a `## The short version` section carrying all four bold-led
16
+ * blocks, in order, inside the word band. The blocks answer the same four
17
+ * questions for a role and for a seam (what it owns/carries, when it
18
+ * refuses, what it never does, how it physically runs), which is why the
19
+ * lead-ins differ by kind but the count and order never do.
20
+ * 2. **Readability** — the text that actually reaches a reader carries no
21
+ * token a stranger cannot resolve from the page: no decision id, no
22
+ * section sign, no forge number, no retired public name, no tranche
23
+ * slug, no repo-internal path, and no label vocabulary (namespaced or
24
+ * retired alike — a published page should name neither, and flagging only
25
+ * the current grammar would leave every retired mention invisible).
26
+ *
27
+ * **What "published" means here is deliberately narrow.** For a role or
28
+ * contract it is the short version, not the file. For `enforcement.md` it is
29
+ * the introduction plus the four columns the page renders — the row's own
30
+ * name, `Summary`, `Category`, `Description`. The enforcing columns (`Gate`,
31
+ * `What must be true…`, `Re-verifies`, `Runs`, `Catches`, `implementation`,
32
+ * `lock`) are never scanned: they never reach a page, and flagging them would
33
+ * demand rewriting the gate registry to suit a website. Columns resolve by
34
+ * HEADER NAME, never by index — the three ring tables have different shapes,
35
+ * and `Summary`/`Category`/`Description` are the ones spelled identically in
36
+ * all three, the same property `registry-parse.ts` relies on for
37
+ * `Description`.
38
+ *
39
+ * **Protocol mechanics are published on purpose.** `.worktrees/task/<tranche>/<n>`
40
+ * and the `task/<tranche>/<n>` branch convention are the method an adopter
41
+ * must learn, not this repo's internals — they are masked out before scanning
42
+ * so a path rule can never eat them, and that exemption is tested rather than
43
+ * incidental.
44
+ */
45
+
46
+ import { findHeadingLine, findTable } from '../markdown-table'
47
+ import { isSurfacedDoc } from './surfaced-manifest'
48
+ import type { DocFrontmatter } from './types'
49
+
50
+ export const SHORT_VERSION_HEADING = 'The short version'
51
+ export const REFERENCE_HEADING = 'Reference'
52
+
53
+ /** Words. Under the floor means something was dropped; over the ceiling means
54
+ * the reference is being summarised instead of the rule being stated. */
55
+ export const SHORT_VERSION_MIN_WORDS = 150
56
+ export const SHORT_VERSION_MAX_WORDS = 450
57
+
58
+ /** The four blocks, in order, as a role answers them. */
59
+ export const ROLE_BLOCKS = ['You own', 'You refuse', 'You never', 'How it physically runs'] as const
60
+
61
+ /** The same four blocks, as a seam answers them. */
62
+ export const CONTRACT_BLOCKS = [
63
+ 'What crosses',
64
+ 'The hand-off is malformed',
65
+ 'What it does not carry',
66
+ 'How it physically runs'
67
+ ] as const
68
+
69
+ /**
70
+ * Protocol facts that must survive every readability rule. Masked out of the
71
+ * text before scanning, longest first, so a repo-internal-path rule can never
72
+ * claim the branch convention.
73
+ */
74
+ export const ALLOWED_MECHANICS: readonly string[] = [
75
+ '.worktrees/task/<tranche>/<n>',
76
+ 'task/<tranche>/<n>',
77
+ '.worktrees/'
78
+ ]
79
+
80
+ export type PublishedProseEntry = {
81
+ /** Path relative to `aeg-root/`, e.g. `roles/developer.md`. */
82
+ relPath: string
83
+ frontmatter: Pick<DocFrontmatter, 'surfaced' | 'title' | 'description'>
84
+ body: string
85
+ }
86
+
87
+ export type PublishedProseResult = { errors: string[]; notes: string[] }
88
+
89
+ type ProseRule = { id: string; what: string; pattern: RegExp }
90
+
91
+ /**
92
+ * Every rule is case-sensitive on purpose where the token itself is: forge
93
+ * labels are lower-case, so a lower-case-only match keeps a capitalized
94
+ * English word ("Project:", the PR-body field) from being read as the deleted
95
+ * `project:*` label family.
96
+ */
97
+ const PROSE_RULES: ProseRule[] = [
98
+ { id: 'decision-id', what: 'a decision id', pattern: /\bD-\d{3,}\b/ },
99
+ { id: 'section-ref', what: 'a section reference', pattern: /§/ },
100
+ { id: 'forge-number', what: 'a forge number', pattern: /#\d+/ },
101
+ { id: 'retired-name', what: 'the retired public name', pattern: /\bAEG\b/ },
102
+ { id: 'tranche-slug', what: 'a tranche slug', pattern: /\b[a-z][a-z0-9]*(?:-[a-z0-9]+)+-v\d+\b/ },
103
+ {
104
+ id: 'label-vocabulary',
105
+ what: 'forge label vocabulary',
106
+ // Every entry here is CUMULATIVE — a retired namespace stays banned after
107
+ // the objects carrying it are gone, because a published page naming it is
108
+ // just as unreadable to a stranger as one naming the current grammar (see
109
+ // the module header). `aeg:` is retired and still listed for that reason,
110
+ // and `iteration:` joins it: the rename to `tranche:` ADDS an alternative,
111
+ // it does not substitute one. Substituting is how a rename silently
112
+ // narrows this gate — the forge still carried `vinaya/iteration:*` labels
113
+ // when the rename landed, and a page naming one would have passed.
114
+ pattern: /(?:vinaya\/)?\b(?:tranche|iteration|tier|needs|waiver|project|aeg):(?![/\s]|$)/
115
+ },
116
+ { id: 'internal-path', what: 'a repo-internal path', pattern: /\b(?:packages|apps|aeg-root|tools|scripts)\// },
117
+ { id: 'internal-path', what: 'a repo-internal path', pattern: /\.(?:claude|husky|github)\// },
118
+ {
119
+ id: 'internal-path',
120
+ what: 'a repo-internal file',
121
+ pattern: /(?:^|[\s(`"'[])[\w.-]+(?:\/[\w.-]+)*\.(?:ts|tsx|js|jsx|mjs|cjs|sh|ya?ml|json|md)\b/
122
+ }
123
+ ]
124
+
125
+ /** The doc kinds this check governs, and the block set each answers with. */
126
+ function blocksFor(relPath: string): readonly string[] | null {
127
+ if (relPath.startsWith('roles/')) return ROLE_BLOCKS
128
+ if (relPath.startsWith('contracts/')) return CONTRACT_BLOCKS
129
+ return null
130
+ }
131
+
132
+ function sectionBody(body: string, heading: string): string | null {
133
+ const lines = body.split('\n')
134
+ const start = lines.findIndex((line) => line.trim() === `## ${heading}`)
135
+ if (start === -1) return null
136
+ const rest = lines.slice(start + 1)
137
+ const end = rest.findIndex((line) => /^##\s/.test(line))
138
+ return (end === -1 ? rest : rest.slice(0, end)).join('\n').trim()
139
+ }
140
+
141
+ /**
142
+ * The section a role/contract page publishes, or null when the doc carries
143
+ * none. The single extraction both the gate and Vinaya's `/docs` renderer
144
+ * read, so the page and the check can never disagree about what "published"
145
+ * means.
146
+ */
147
+ export function extractShortVersion(body: string): string | null {
148
+ return sectionBody(body, SHORT_VERSION_HEADING)
149
+ }
150
+
151
+ /**
152
+ * What a role/contract page renders, with a degradation ladder that never
153
+ * leaks the reference: the short version when present; otherwise the body
154
+ * above `## Reference`; otherwise the whole body (a doc with neither heading
155
+ * is not governed by C7's structure rule and has no reference to hide).
156
+ */
157
+ export function publishedDoctrineBody(body: string): string {
158
+ const short = extractShortVersion(body)
159
+ if (short !== null) return short
160
+ const lines = body.split('\n')
161
+ const referenceAt = lines.findIndex((line) => line.trim() === `## ${REFERENCE_HEADING}`)
162
+ return referenceAt === -1 ? body : lines.slice(0, referenceAt).join('\n').trim()
163
+ }
164
+
165
+ export function countWords(text: string): number {
166
+ return text.split(/\s+/).filter(Boolean).length
167
+ }
168
+
169
+ function maskAllowedMechanics(text: string): string {
170
+ let masked = text
171
+ for (const literal of ALLOWED_MECHANICS) {
172
+ masked = masked.split(literal).join(' ')
173
+ }
174
+ return masked
175
+ }
176
+
177
+ /**
178
+ * The four columns `enforcement.md` actually renders — the row's own name plus
179
+ * `Summary`, `Category`, `Description` — and the introduction above the first
180
+ * ring. Everything else in those tables is enforcing text that never reaches a
181
+ * page.
182
+ */
183
+ export function enforcementPublishedText(content: string): string[] {
184
+ const lines = content.split('\n')
185
+ const out: string[] = []
186
+
187
+ const firstRing = lines.findIndex((line) => /^##\s+Ring 0\b/.test(line))
188
+ out.push((firstRing === -1 ? lines : lines.slice(0, firstRing)).join('\n'))
189
+
190
+ const publishedHeaders = ['summary', 'category', 'description']
191
+ for (const ring of [0, 1, 2]) {
192
+ const headingLine = findHeadingLine(lines, new RegExp(`^##\\s+Ring ${ring}\\b`))
193
+ if (headingLine === null) continue
194
+ const table = findTable(lines, headingLine + 1)
195
+ if (!table) continue
196
+ const indices = publishedHeaders
197
+ .map((name) => table.headers.findIndex((h) => h.trim().toLowerCase() === name))
198
+ .filter((i) => i !== -1)
199
+ for (const row of table.rows) {
200
+ out.push(row.cells[0] ?? '')
201
+ for (const i of indices) out.push(row.cells[i] ?? '')
202
+ }
203
+ }
204
+
205
+ return out.filter((text) => text.trim().length > 0)
206
+ }
207
+
208
+ /**
209
+ * The readability rules, callable on any published text — not just a doc body.
210
+ *
211
+ * Exported because `/docs/actions` renders prose that lives in TypeScript
212
+ * (`ACTIONS` in `actions.ts`), not in a markdown file, so `evaluatePublishedProse`
213
+ * never sees it. A whole published page was unguarded on that technicality;
214
+ * `actions.test.ts` closes it by running this over every entry.
215
+ */
216
+ export function readabilityErrors(where: string, texts: string[]): string[] {
217
+ const errors: string[] = []
218
+ checkReadability(where, texts, errors)
219
+ return errors
220
+ }
221
+
222
+ function checkReadability(where: string, texts: string[], errors: string[]): void {
223
+ const seen = new Set<string>()
224
+ for (const raw of texts) {
225
+ const text = maskAllowedMechanics(raw)
226
+ for (const rule of PROSE_RULES) {
227
+ const match = text.match(rule.pattern)
228
+ if (!match) continue
229
+ const token = (match[0] ?? '').trim()
230
+ const key = `${rule.id}:${token}`
231
+ if (seen.has(key)) continue
232
+ seen.add(key)
233
+ errors.push(`C7: published text in "${where}" contains ${rule.what} a reader cannot resolve: "${token}"`)
234
+ }
235
+ }
236
+ }
237
+
238
+ function checkStructure(relPath: string, body: string, blocks: readonly string[], errors: string[]): void {
239
+ const short = extractShortVersion(body)
240
+ if (short === null) {
241
+ errors.push(
242
+ `C7: surfaced doc "${relPath}" has no "## ${SHORT_VERSION_HEADING}" section — the published page would have nothing binding to show a reader`
243
+ )
244
+ return
245
+ }
246
+
247
+ let cursor = 0
248
+ for (const block of blocks) {
249
+ const at = short.indexOf(`**${block}`, cursor)
250
+ if (at === -1) {
251
+ errors.push(`C7: "${relPath}" short version is missing the "${block}" block (all four blocks always appear)`)
252
+ continue
253
+ }
254
+ cursor = at + block.length
255
+ }
256
+
257
+ const words = countWords(short)
258
+ if (words < SHORT_VERSION_MIN_WORDS) {
259
+ errors.push(
260
+ `C7: "${relPath}" short version is ${words} words, under the ${SHORT_VERSION_MIN_WORDS}-word floor — something was dropped`
261
+ )
262
+ } else if (words > SHORT_VERSION_MAX_WORDS) {
263
+ errors.push(
264
+ `C7: "${relPath}" short version is ${words} words, over the ${SHORT_VERSION_MAX_WORDS}-word ceiling — state the rule, don't summarise the reference`
265
+ )
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Runs both checks over the published set only. `entries` may hold every doc
271
+ * under `aeg-root/`; `surfacedPaths` (the model-backed allowlist) is what
272
+ * narrows it, so this check can never publish-check a doc the site does not
273
+ * publish, nor miss one it does.
274
+ */
275
+ export function evaluatePublishedProse(
276
+ entries: PublishedProseEntry[],
277
+ surfacedPaths: ReadonlySet<string> = new Set()
278
+ ): PublishedProseResult {
279
+ const errors: string[] = []
280
+ const notes: string[] = []
281
+
282
+ const surfaced = entries.filter((e) => isSurfacedDoc(e.relPath, e.frontmatter, surfacedPaths))
283
+
284
+ for (const entry of surfaced) {
285
+ // Frontmatter renders too: `title` is the page heading and the sidebar
286
+ // entry, `description` is the page metadata. Checking only the body left
287
+ // both unguarded — a citation in a title reaches a reader exactly as
288
+ // surely as one in a paragraph.
289
+ checkReadability(entry.relPath, [entry.frontmatter.title ?? '', entry.frontmatter.description ?? ''], errors)
290
+
291
+ const blocks = blocksFor(entry.relPath)
292
+ if (blocks) {
293
+ checkStructure(entry.relPath, entry.body, blocks, errors)
294
+ const short = extractShortVersion(entry.body)
295
+ if (short !== null) checkReadability(entry.relPath, [short], errors)
296
+ continue
297
+ }
298
+ if (entry.relPath === 'enforcement.md') {
299
+ checkReadability(entry.relPath, enforcementPublishedText(entry.body), errors)
300
+ }
301
+ }
302
+
303
+ if (surfaced.length === 0) notes.push('C7: no surfaced docs to check.')
304
+
305
+ return { errors, notes }
306
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The canonical "surfaced doc" manifest for `aeg-root/`. Defines, as
3
+ * data, which docs a public AEG page shows. The rule is **model-backed**: a
4
+ * doc is surfaced if and only if a `DiagramModel` node points at it.
5
+ * The same `DiagramModel` that `/docs/harness` renders is the allowlist for
6
+ * `/docs/reference` — the two surfaces are two renderers of one model, and the doc that
7
+ * backs no reachable node stops being public, every build.
8
+ *
9
+ * The node→doc mapping (`modelBackedDocPaths`) is the same one `read-more.ts`
10
+ * uses to resolve a node's "Read more" target: gate/check nodes back
11
+ * `enforcement.md`; a role node backs `roles/<id>.md`; a contract node backs
12
+ * `contracts/<id>.md`. `action`/`ring` nodes back no `aeg-root/**` document
13
+ * (an action's source is `packages/aeg-core/src/actions.ts`, a ring is a
14
+ * summary label), so they add nothing to the set. Today that is 16 files —
15
+ * `enforcement.md` + the 9 `roles/*.md` + the 6 `contracts/*.md`.
16
+ *
17
+ * This is the single source of truth the C6 docs-coherence check
18
+ * (`docs-coherence.ts`) and Vinaya's `/docs` loader both consume. There is
19
+ * **no second, path-based exclusion rule** — a competing rule is the failure
20
+ * mode this manifest exists to prevent, and the model-backed set replaced the
21
+ * old path-exclusion rules outright rather than sitting beside them. The only
22
+ * escape hatch is the per-doc `surfaced` frontmatter override, which wins in
23
+ * both directions.
24
+ *
25
+ * Purity: this module imports only the `DiagramModel` **type** from the
26
+ * diagram layer — no runtime coupling, no I/O. The doctrine is read (and the
27
+ * model derived) by the caller, which passes the derived path set in; aeg-core
28
+ * stays zero-I/O (#372/#382/#506).
29
+ *
30
+ * Paths are relative to `aeg-root/` (e.g. `roles/developer.md`), matching
31
+ * `DocFrontmatter`'s existing convention.
32
+ */
33
+
34
+ import type { DiagramModel } from '../diagram-model'
35
+ import type { DocFrontmatter } from './types'
36
+
37
+ /**
38
+ * The set of `aeg-root/`-relative doc paths a `DiagramModel` points at — the
39
+ * allowlist. Mirrors `read-more.ts`'s `docRoute` resolution exactly, so
40
+ * `/docs/reference`'s surfaced set and `/docs/harness`'s "Read more" targets can never
41
+ * name different files: gate/check → `enforcement.md`, role → `roles/<id>.md`,
42
+ * contract → `contracts/<id>.md`. A node's `label` is its doctrine id
43
+ * (`role.roleId` / `contract.contractId`), and every `roles/*.md` /
44
+ * `contracts/*.md` file is named `<id>.md` — one convention backs both the
45
+ * GitHub path and the docs route.
46
+ */
47
+ export function modelBackedDocPaths(model: DiagramModel): Set<string> {
48
+ const paths = new Set<string>()
49
+ for (const node of model.nodes) {
50
+ if (node.kind === 'gate' || node.kind === 'check') paths.add('enforcement.md')
51
+ else if (node.kind === 'role') paths.add(`roles/${node.label}.md`)
52
+ else if (node.kind === 'contract') paths.add(`contracts/${node.label}.md`)
53
+ }
54
+ return paths
55
+ }
56
+
57
+ const NO_MODEL_BACKED_PATHS: ReadonlySet<string> = new Set()
58
+
59
+ /**
60
+ * Whether `relPath` (relative to `aeg-root/`) is a surfaced doc. A boolean
61
+ * `surfaced` frontmatter field always wins over the model — the escape hatch
62
+ * in both directions. Otherwise the doc is surfaced iff it is in
63
+ * `surfacedPaths` (the model-backed allowlist from `modelBackedDocPaths`).
64
+ *
65
+ * `surfacedPaths` is optional so a caller that has no model surfaces nothing
66
+ * by default rather than everything — the safe direction for a rule whose
67
+ * whole point is that a doc publishes only when a node points at it.
68
+ */
69
+ export function isSurfacedDoc(
70
+ relPath: string,
71
+ frontmatter: Pick<DocFrontmatter, 'surfaced'>,
72
+ surfacedPaths: ReadonlySet<string> = NO_MODEL_BACKED_PATHS
73
+ ): boolean {
74
+ if (typeof frontmatter.surfaced === 'boolean') return frontmatter.surfaced
75
+ return surfacedPaths.has(relPath)
76
+ }
77
+
78
+ export type SurfacedManifestEntry = {
79
+ relPath: string
80
+ frontmatter: Pick<DocFrontmatter, 'surfaced'>
81
+ }
82
+
83
+ /** Filters a list of parsed doc entries down to the surfaced subset. */
84
+ export function surfacedDocs(
85
+ entries: SurfacedManifestEntry[],
86
+ surfacedPaths: ReadonlySet<string> = NO_MODEL_BACKED_PATHS
87
+ ): string[] {
88
+ return entries.filter((e) => isSurfacedDoc(e.relPath, e.frontmatter, surfacedPaths)).map((e) => e.relPath)
89
+ }
@@ -0,0 +1,33 @@
1
+ export type DocFrontmatter = {
2
+ title?: string
3
+ sidebarTitle?: string
4
+ description?: string
5
+ section?: string
6
+ order?: number
7
+ parent?: string
8
+ surfaced?: boolean
9
+ }
10
+
11
+ export type Doc = {
12
+ slug: string
13
+ title: string
14
+ sidebarTitle?: string
15
+ description?: string
16
+ section: string
17
+ order: number
18
+ href: string
19
+ filePath: string
20
+ children?: Doc[]
21
+ parentSlug?: string
22
+ }
23
+
24
+ export type DocSection = {
25
+ id: string
26
+ label: string
27
+ docs: Doc[]
28
+ }
29
+
30
+ export type DocNav = {
31
+ sections: DocSection[]
32
+ flat: Doc[]
33
+ }