@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,255 @@
1
+ /**
2
+ * registry-checks.ts — G1–G6, the deterministic coherence checks that make
3
+ * `aeg-root/enforcement.md`'s three ring tables (parsed by
4
+ * `registry-parse.ts`) load-bearing instead of decorative.
5
+ *
6
+ * Pure — no `fs`, no `git`/`gh` I/O, no `fetch`. All forge/filesystem facts
7
+ * are injected by the caller (`bin/verify-registry.ts` /
8
+ * `apps/cli/src/checks/bin/check-registry-gates.ts`, the I/O shims),
9
+ * mirroring `coherence-checks.ts`'s shape exactly (plain executables,
10
+ * deterministic pass/fail, no config conditionals).
11
+ *
12
+ * Rollout policy: G2 ships report-only — it can only ever report `'info'`,
13
+ * never `'fail'`, so it never affects CI's exit code (the accepted shape for
14
+ * G2's inverse sweep, unchanged by task 8). G1 was report-only through its
15
+ * own rollout window; task 8 flips it to blocking now that the orphan
16
+ * backlog it existed to surface is clean (0 findings against the real
17
+ * `enforcement.md` at the time of the flip). G3–G6 are blocking (`'fail'` on
18
+ * any violation).
19
+ */
20
+
21
+ import { GATE_AUDIENCE, isShipped } from './gate-audience'
22
+ import type { GateRow } from './registry-parse'
23
+
24
+ export type RegistryCheckStatus = 'pass' | 'fail' | 'info'
25
+
26
+ export type RegistryFinding = {
27
+ row?: string
28
+ path?: string
29
+ reason: string
30
+ }
31
+
32
+ export type RegistryCheckResult = {
33
+ check: 'G1' | 'G2' | 'G3' | 'G4' | 'G5' | 'G6'
34
+ status: RegistryCheckStatus
35
+ findings: RegistryFinding[]
36
+ }
37
+
38
+ /**
39
+ * G1 — every row's non-empty `implementation` resolves on disk. Blocking as
40
+ * of task 8 (re-graded from report-only: eleven permanent `info` findings on
41
+ * every run had become indistinguishable from silence, which is how the gap
42
+ * this tranche closes stayed invisible; the report-only window had already
43
+ * cleared the backlog it existed to surface).
44
+ */
45
+ export function checkG1(rows: GateRow[], existsFn: (path: string) => boolean): RegistryCheckResult {
46
+ const findings: RegistryFinding[] = []
47
+ for (const row of rows) {
48
+ if (row.implementation === '') continue
49
+ if (!existsFn(row.implementation)) {
50
+ findings.push({
51
+ row: row.action,
52
+ path: row.implementation,
53
+ reason: `${row.ring} row "${row.action}" names implementation "${row.implementation}", which does not exist on disk`
54
+ })
55
+ }
56
+ }
57
+ return { check: 'G1', status: findings.length > 0 ? 'fail' : 'pass', findings }
58
+ }
59
+
60
+ /** The literal placeholder marker a registry-scaffold stub row's
61
+ * non-mechanical cells carry (`registry-scaffold.ts`'s `PLACEHOLDER`,
62
+ * duplicated here rather than imported to keep this module's zero-import
63
+ * shape — the two are asserted equal by `registry-checks.test.ts`). A stub
64
+ * that filled `implementation` alone would silence G2 outright (the whole
65
+ * defect Issue #104 exists to close): this marker is what keeps a row G2
66
+ * still flags loud until a human replaces it with the real "why". */
67
+ const SCAFFOLD_PLACEHOLDER = '[undocumented — fill in why]'
68
+
69
+ /**
70
+ * G2 — every candidate hook/CLI file is named by SOME row's `implementation`,
71
+ * AND no row still carries the scaffold's placeholder marker un-replaced.
72
+ * `candidateFiles` is already-globbed by the caller (`.husky/*`,
73
+ * `.claude/hooks/*.sh`, `packages/aeg-core/bin/*.ts`, excluding `.husky/_`).
74
+ * Report-only this tranche — same as G1, never `'fail'`.
75
+ *
76
+ * The second half (placeholder scan) is what keeps a registry-scaffold stub
77
+ * row visible: `implementation` presence alone would make a stub read as
78
+ * "documented" to the ORPHAN half above, which is precisely the trap the
79
+ * scaffold must not fall into. Scanned over `summary`/`description`/`spec`
80
+ * — every cell a stub can carry the marker in — never `implementation`,
81
+ * `action`, `category`, or `audience`, which the scaffold fills mechanically
82
+ * and are never expected to carry it.
83
+ */
84
+ export function checkG2(rows: GateRow[], candidateFiles: string[]): RegistryCheckResult {
85
+ const implementations = new Set(rows.map((r) => r.implementation).filter((p) => p !== ''))
86
+ const findings: RegistryFinding[] = []
87
+ for (const path of candidateFiles) {
88
+ if (!implementations.has(path)) {
89
+ findings.push({
90
+ path,
91
+ reason: `"${path}" is not named as the implementation of any row in enforcement.md's ring tables`
92
+ })
93
+ }
94
+ }
95
+ for (const row of rows) {
96
+ const carriesPlaceholder = [row.summary, row.description, row.spec].some((cell) => cell === SCAFFOLD_PLACEHOLDER)
97
+ if (carriesPlaceholder) {
98
+ findings.push({
99
+ row: row.action,
100
+ path: row.implementation,
101
+ reason: `${row.ring} row "${row.action}" still carries the scaffold placeholder marker — the why is owed`
102
+ })
103
+ }
104
+ }
105
+ return { check: 'G2', status: findings.length > 0 ? 'info' : 'pass', findings }
106
+ }
107
+
108
+ /**
109
+ * G3 — every file that makes a GitHub-crossing call is named by SOME Ring-0
110
+ * row's `implementation` — "no seventh way into GitHub". `crossingFiles` is
111
+ * already-detected by the caller via a source-grep. Blocking.
112
+ */
113
+ export function checkG3(ring0Rows: GateRow[], crossingFiles: string[]): RegistryCheckResult {
114
+ const ring0Implementations = new Set(ring0Rows.map((r) => r.implementation))
115
+ const findings: RegistryFinding[] = []
116
+ for (const path of crossingFiles) {
117
+ if (!ring0Implementations.has(path)) {
118
+ findings.push({
119
+ path,
120
+ reason: `"${path}" makes a GitHub-crossing call but is not named by any Ring-0 row's implementation — a seventh, unlisted way into GitHub`
121
+ })
122
+ }
123
+ }
124
+ return { check: 'G3', status: findings.length > 0 ? 'fail' : 'pass', findings }
125
+ }
126
+
127
+ /**
128
+ * G4 — every `#NNN` cited in enforcement.md's body resolves in the forge.
129
+ * `resolveFn` wraps `gh issue view`/`gh pr view` (caller injects). Blocking.
130
+ * Broad reading (brief): every `#`-prefixed 3-or-more-digit number
131
+ * occurring anywhere in the body is checked, not just ones near keywords
132
+ * like "incident" — an incomplete keyword net would create a
133
+ * false-negative gap. The current repo's real citations are all 3-4
134
+ * digits; the lower bound of 3 exists only to exclude unrelated short
135
+ * numerals (ring/tier numbers) that are never written with a `#` prefix
136
+ * anyway — there is deliberately no upper bound, so a longer fabricated
137
+ * number is still caught.
138
+ */
139
+ export function checkG4(content: string, resolveFn: (n: number) => boolean): RegistryCheckResult {
140
+ const cited = new Set<number>()
141
+ for (const hit of content.matchAll(/#(\d{3,})\b/g)) {
142
+ cited.add(Number(hit[1]))
143
+ }
144
+ const findings: RegistryFinding[] = []
145
+ for (const n of cited) {
146
+ if (!resolveFn(n)) {
147
+ findings.push({
148
+ reason: `#${n} is cited in enforcement.md but does not resolve to a real Issue or PR in the forge`
149
+ })
150
+ }
151
+ }
152
+ return { check: 'G4', status: findings.length > 0 ? 'fail' : 'pass', findings }
153
+ }
154
+
155
+ /**
156
+ * G5 — every contract's producer/consumer is a real role_id; every role's
157
+ * `performs`/`refuses_when` is present and non-empty. Blocking.
158
+ *
159
+ * "The action exists" is satisfied by presence/well-formedness of the
160
+ * role's own frontmatter — there is no second, independent registry of
161
+ * valid actions to cross-reference `performs` entries against.
162
+ */
163
+ export function checkG5(
164
+ roles: Array<{ file: string; role_id: string; performs: string[]; refuses_when: string }>,
165
+ contracts: Array<{ file: string; producer: string; consumer: string }>
166
+ ): RegistryCheckResult {
167
+ const roleIds = new Set(roles.map((r) => r.role_id))
168
+ const findings: RegistryFinding[] = []
169
+
170
+ for (const contract of contracts) {
171
+ if (!roleIds.has(contract.producer)) {
172
+ findings.push({
173
+ path: contract.file,
174
+ reason: `contract "${contract.file}" names producer "${contract.producer}", which is not a real role_id`
175
+ })
176
+ }
177
+ if (!roleIds.has(contract.consumer)) {
178
+ findings.push({
179
+ path: contract.file,
180
+ reason: `contract "${contract.file}" names consumer "${contract.consumer}", which is not a real role_id`
181
+ })
182
+ }
183
+ }
184
+
185
+ for (const role of roles) {
186
+ if (role.performs.length === 0) {
187
+ findings.push({ path: role.file, reason: `role "${role.role_id}" has an empty performs array` })
188
+ }
189
+ if (role.refuses_when.trim() === '') {
190
+ findings.push({ path: role.file, reason: `role "${role.role_id}" has an empty refuses_when` })
191
+ }
192
+ }
193
+
194
+ return { check: 'G5', status: findings.length > 0 ? 'fail' : 'pass', findings }
195
+ }
196
+
197
+ const SHIPPED_BIN_PATH_PREFIX = 'apps/cli/src/checks/bin/check-'
198
+ const AEG_CORE_BIN_PATH_PREFIX = 'packages/aeg-core/bin/'
199
+
200
+ function basenameNoExt(path: string): string {
201
+ const base = path.slice(path.lastIndexOf('/') + 1)
202
+ return base.replace(/\.ts$/, '')
203
+ }
204
+
205
+ /**
206
+ * Every `coreCheckRegistry()` name a `product`-audience row's implementation
207
+ * could resolve to. Two shapes: a path directly under the shipped bin
208
+ * directory names its own check 1:1 (`apps/cli/src/checks/bin/check-doc-
209
+ * coverage-push.ts` -> `doc-coverage-push`); a path under
210
+ * `packages/aeg-core/bin/` resolves through `GATE_AUDIENCE`, the same map
211
+ * `shipped-bin-audience.test.ts` (`apps/cli`) already asserts agrees with
212
+ * the registry — reused here rather than re-derived, so doctrine and code
213
+ * cannot silently disagree about what a bin ships as. Neither shape matches
214
+ * (e.g. a `.github/workflows/*.yml` path, `checks/runner.ts`, a forge-write
215
+ * command) resolves to no candidate at all — correct, since those are not
216
+ * `coreCheckRegistry()` checks by construction.
217
+ */
218
+ function claimedCheckNames(implementation: string): string[] {
219
+ if (implementation.startsWith(SHIPPED_BIN_PATH_PREFIX) && implementation.endsWith('.ts')) {
220
+ return [implementation.slice(SHIPPED_BIN_PATH_PREFIX.length, -'.ts'.length)]
221
+ }
222
+ if (implementation.startsWith(AEG_CORE_BIN_PATH_PREFIX)) {
223
+ const audience = GATE_AUDIENCE[basenameNoExt(implementation)]
224
+ if (audience && isShipped(audience)) {
225
+ return Array.isArray(audience.shippedAs) ? audience.shippedAs : [audience.shippedAs]
226
+ }
227
+ }
228
+ return []
229
+ }
230
+
231
+ /**
232
+ * G6 — every row doctrine marks `product` must actually resolve to a
233
+ * `coreCheckRegistry()` entry ("claimed checks must ship" — the parity gate
234
+ * this task exists for). `registeredCheckNames` is caller-injected
235
+ * (`coreCheckRegistry().map(s => s.name)`, from `apps/cli`, which
236
+ * `aeg-core` cannot import without closing a dependency cycle — same
237
+ * reasoning `gate-audience.ts` documents for `GATE_AUDIENCE` itself).
238
+ * Blocking. A row left at the default `repo-own` audience makes no shipped
239
+ * claim and is never checked here.
240
+ */
241
+ export function checkG6(rows: GateRow[], registeredCheckNames: Set<string>): RegistryCheckResult {
242
+ const findings: RegistryFinding[] = []
243
+ for (const row of rows) {
244
+ if (row.audience !== 'product') continue
245
+ const candidates = claimedCheckNames(row.implementation)
246
+ if (!candidates.some((name) => registeredCheckNames.has(name))) {
247
+ findings.push({
248
+ row: row.action,
249
+ path: row.implementation,
250
+ reason: `${row.ring} row "${row.action}" is marked \`product\` but its implementation "${row.implementation}" does not resolve to a coreCheckRegistry() entry`
251
+ })
252
+ }
253
+ }
254
+ return { check: 'G6', status: findings.length > 0 ? 'fail' : 'pass', findings }
255
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * registry-parse.ts — pure, no-I/O parser for `aeg-root/enforcement.md`'s
3
+ * three ring markdown tables (Ring 0/1/2). enforcement.md's tables ARE the
4
+ * gate registry (no live file duplicates forge state); this
5
+ * parses them rather than maintaining a second copy.
6
+ *
7
+ * The generic markdown-table extraction lives in `./markdown-table` — the one
8
+ * aeg-core-owned parser every doctrine consumer shares. This module
9
+ * imports `findTable`/`findHeadingLine` from there rather than re-deriving
10
+ * them; it adds only the enforcement-specific normalization on top (the
11
+ * ring-heading list, `stripBackticks`, and the last-two-columns rule).
12
+ *
13
+ * Each ring's table has a different header wording (Ring 0: Action/Gate/...;
14
+ * Ring 1: CI check/Re-verifies; Ring 2: Mechanism/Runs/Catches). The FIRST
15
+ * column is always the row's identifying label; `implementation` and
16
+ * `description` are resolved BY HEADER NAME, since those two are spelled
17
+ * identically in all three tables. Positional reads are used only where no
18
+ * shared header name exists.
19
+ *
20
+ * The one exception is `Description`, which IS looked up by header name: it is
21
+ * the only column spelled identically in all three tables, and the only one a
22
+ * table can legitimately lack. Position cannot express that difference (a
23
+ * 7-column table means two different shapes depending on the ring), so the
24
+ * name is the only honest key. See the lookup in `parseEnforcementRegistry`.
25
+ */
26
+
27
+ import { findHeadingLine, findTable } from './markdown-table'
28
+
29
+ export type GateRing = 'ring0' | 'ring1' | 'ring2'
30
+
31
+ export type GateRow = {
32
+ ring: GateRing
33
+ action: string
34
+ summary: string
35
+ category: 'ci' | 'hook' | 'event'
36
+ /** One plain sentence: what this gate does, for a reader. Present in all
37
+ * three ring tables and resolved by header name (see the lookup below).
38
+ *
39
+ * The counterpart to `spec`, and not interchangeable with it — `spec` is
40
+ * written to ENFORCE: 17 of the 31 real rows cite an Issue number, a
41
+ * task number or a file path, and the longest runs 2708 chars. That is
42
+ * correct for a gate and unreadable on a page. This column is the same fact
43
+ * addressed to a person, and is what a row's `summary` question gets
44
+ * answered by. The same bar `roles/*.md`/`contracts/*.md` hold via
45
+ * `description:` frontmatter and `ACTIONS` holds via its `description`
46
+ * field, so a reader gets one register whatever they click.
47
+ *
48
+ * Optional on the type only so a malformed table degrades instead of
49
+ * throwing — every real row carries one, asserted against the live file. */
50
+ description?: string
51
+ /** The substantive middle column — "What must be true..." (ring0),
52
+ * "Re-verifies" (ring1), "Catches" (ring2) — always the column
53
+ * immediately before `implementation`. Each ring names it differently,
54
+ * but structurally it's always the same slot: the normative spec, written
55
+ * for enforcement. Distinct from `summary`'s rhetorical question and from
56
+ * `description`'s plain-language answer to it. Undefined when a table has
57
+ * no such column. */
58
+ spec?: string
59
+ implementation: string
60
+ /**
61
+ * `product` — this row's implementation is a check registered in
62
+ * `coreCheckRegistry()` (`apps/cli/src/checks/registry.ts`), so it ships to
63
+ * every adopter through `vinaya check`. `repo-own` — everything else: a
64
+ * mechanism specific to how this repository enforces itself on top of the
65
+ * product (a hand-written CI job, the check runner/resolver, a forge-write
66
+ * command, or a check this repo runs on its own doctrine but has not
67
+ * registered). Read from an `Audience` column, by header name like
68
+ * `description`. A table carrying no such column, or a cell holding
69
+ * anything other than the literal `product`, resolves to `repo-own` — the
70
+ * safe default, since it makes no shipped claim for G6 to verify.
71
+ */
72
+ audience: 'product' | 'repo-own'
73
+ line: number
74
+ }
75
+
76
+ const RING_HEADINGS: Array<{ ring: GateRing; pattern: RegExp }> = [
77
+ { ring: 'ring0', pattern: /^##\s+Ring 0\b/ },
78
+ { ring: 'ring1', pattern: /^##\s+Ring 1\b/ },
79
+ { ring: 'ring2', pattern: /^##\s+Ring 2\b/ }
80
+ ]
81
+
82
+ function stripBackticks(cell: string): string {
83
+ const trimmed = cell.trim()
84
+ if (trimmed.length >= 2 && trimmed.startsWith('`') && trimmed.endsWith('`')) {
85
+ return trimmed.slice(1, -1)
86
+ }
87
+ return trimmed
88
+ }
89
+
90
+ /** Strips markdown `**bold**` markers from a gate/check row's own name.
91
+ * `action` is the one cell this parser has never sanitized — most rows are
92
+ * plain text, but a real row can legitimately bold its name for doctrine-
93
+ * prose emphasis, and that literal `**...**` was leaking straight through
94
+ * to `DiagramNode.label` (visible asterisks in the how-it-works UI; every
95
+ * label already renders bold via CSS where it matters, so the markdown
96
+ * marker carries zero information downstream). Root-cause fix, not a
97
+ * per-row doctrine patch — the next accidentally-bolded name is covered
98
+ * too, not just the ones caught so far. */
99
+ function stripBold(cell: string): string {
100
+ return cell.trim().replace(/\*\*/g, '')
101
+ }
102
+
103
+ /**
104
+ * Parses the three ring tables out of `enforcement.md`'s raw content into a
105
+ * flat, normalized `GateRow[]`. Each row's `action` is its table's first
106
+ * column; `implementation` is resolved by header name, falling back to the
107
+ * last column.
108
+ */
109
+ export function parseEnforcementRegistry(content: string): GateRow[] {
110
+ const lines = content.split('\n')
111
+ const result: GateRow[] = []
112
+
113
+ for (const { ring, pattern } of RING_HEADINGS) {
114
+ const headingLine = findHeadingLine(lines, pattern)
115
+ if (headingLine === null) continue
116
+ const table = findTable(lines, headingLine + 1)
117
+ if (!table) continue
118
+ // `description`/`Audience` are found BY HEADER NAME, never by position —
119
+ // the two columns here that can be. Every other column is positional out
120
+ // of necessity: the three ring tables name their first column
121
+ // differently ("Action"/"CI check"/"Mechanism") and their middle columns
122
+ // differently again ("Gate" + "What must be true" / "Re-verifies" /
123
+ // "Runs" + "Catches"), so only the ends are reliable. "Description" and
124
+ // "Audience" are spelled the same in all three, which makes a name
125
+ // lookup possible — and a name lookup is what keeps a table WITHOUT the
126
+ // column from having some other column silently read as its value. By
127
+ // index that is undetectable: a 7-column table means "has Description"
128
+ // in one ring and "has Gate" in another, and the parser cannot tell
129
+ // which. -1 here simply means the table doesn't have that column.
130
+ const descriptionIndex = table.headers.findIndex((h) => h.trim().toLowerCase() === 'description')
131
+ const implementationIndex = table.headers.findIndex((h) => h.trim().toLowerCase() === 'implementation')
132
+ const audienceIndex = table.headers.findIndex((h) => h.trim().toLowerCase() === 'audience')
133
+ for (const row of table.rows) {
134
+ const cells = row.cells
135
+ if (cells.length < 3) continue
136
+ const action = stripBold(cells[0] ?? '')
137
+ const summary = stripBackticks(cells[1] ?? '')
138
+ const category = stripBackticks(cells[2] ?? '') as GateRow['category']
139
+ const implementation = stripBackticks(
140
+ (implementationIndex === -1 ? cells[cells.length - 1] : cells[implementationIndex]) ?? ''
141
+ )
142
+ const description =
143
+ descriptionIndex === -1 ? undefined : stripBackticks(cells[descriptionIndex] ?? '') || undefined
144
+ // `spec` is the ring-specific column immediately before `implementation`
145
+ // — normally the last cell, but `Audience`, when present, now sits
146
+ // between them. `specIndex` follows whichever of the two comes first:
147
+ // one before `Audience` if the table carries one, one before the last
148
+ // cell (`implementation`'s positional fallback) if it doesn't — so a
149
+ // table predating this column (a fixture, an un-upgraded adopter copy)
150
+ // still resolves `spec` exactly as it did before `Audience` existed.
151
+ const specIndex = (audienceIndex === -1 ? cells.length - 1 : audienceIndex) - 1
152
+ const spec = specIndex >= 3 ? stripBackticks(cells[specIndex] ?? '') || undefined : undefined
153
+ const audience: GateRow['audience'] =
154
+ audienceIndex === -1
155
+ ? 'repo-own'
156
+ : stripBackticks(cells[audienceIndex] ?? '') === 'product'
157
+ ? 'product'
158
+ : 'repo-own'
159
+ result.push({ ring, action, summary, category, description, spec, implementation, audience, line: row.line })
160
+ }
161
+ }
162
+
163
+ return result
164
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * registry-scaffold.ts — pure insertion logic that turns a G2 "orphan
3
+ * hook/CLI" finding into a stub row, instead of leaving it a drift warning a
4
+ * human has to notice and fix by hand.
5
+ *
6
+ * Purity contract identical to `registry-checks.ts`'s: no fs/git/gh here.
7
+ * The caller (`bin/verify-registry.ts`) supplies the parsed rows, the
8
+ * already-globbed candidate files, and the raw `enforcement.md` content to
9
+ * rewrite; this module only ever reads `GATE_AUDIENCE` (a same-package,
10
+ * aeg-core-local data table — see its own doc comment for why aeg-core
11
+ * cannot instead import `apps/cli`'s `CORE_CHECK_RING` directly).
12
+ *
13
+ * Three derivation classes, per Issue #104's Principal decision (extended
14
+ * by Issue #307 for the second registry-backed location):
15
+ * - a `packages/aeg-core/bin/*.ts` candidate that IS a registered check
16
+ * (its basename resolves in `GATE_AUDIENCE` to a `ShippedGate`) gets a
17
+ * stub in the ring `GATE_AUDIENCE` names for it;
18
+ * - an `apps/cli/src/checks/bin/*.ts` candidate that IS a registered check
19
+ * (its `check-`-stripped basename resolves in `CLI_CHECK_RING`) gets a
20
+ * stub in the ring `CLI_CHECK_RING` names for it — most core check bins
21
+ * live here, not under `packages/aeg-core/bin/` (Issue #307: 22 of 27 at
22
+ * the time it was filed);
23
+ * - a `.husky/*` or `.claude/hooks/*.sh` candidate is a managed hook by
24
+ * construction — ring 0, no registry lookup needed.
25
+ * Anything else (a bin with no registry entry — `internal`, `NON_GATE_BINS`,
26
+ * or simply undeclared) gets NO stub: ring is genuinely underivable there,
27
+ * and guessing is exactly what this task's Stop-and-escalate condition
28
+ * forbids. It stays a plain G2 finding, same as today.
29
+ *
30
+ * Never touches an existing row: stubs are computed only for candidates
31
+ * whose path is absent from every parsed row's `implementation` — the
32
+ * identical predicate `checkG2` itself uses — so a candidate already
33
+ * documented (including every row `#67` wrote by hand) is left alone, and
34
+ * running the scaffold twice inserts nothing the second time (the first
35
+ * run's own stubs are now present, so the same predicate excludes them).
36
+ *
37
+ * Only mechanical facts are filled: the check name / candidate path
38
+ * (`Action`/`CI check`/`Mechanism`), `Category` (fixed per ring — matches
39
+ * every existing row: ring 0 is `hook`, ring 1 is `ci`, ring 2 is `event`),
40
+ * `Audience` (`product` for a registered check, `repo-own` for a bare hook
41
+ * script), and `implementation` (the path itself). A ring-0 stub also gets a
42
+ * mechanical `Gate` cell naming the check/its invocation — never the
43
+ * substantive "what must be true"/"re-verifies"/"catches" reasoning, which
44
+ * (along with `Summary`/`Description`) is the placeholder marker, verbatim,
45
+ * on every stub this module ever produces.
46
+ */
47
+
48
+ import { CLI_CHECK_RING, GATE_AUDIENCE, isShipped } from './gate-audience'
49
+ import { findHeadingLine, findTable } from './markdown-table'
50
+ import type { GateRing, GateRow } from './registry-parse'
51
+
52
+ /** The literal marker a stub's every non-mechanical cell carries. Never
53
+ * synthesized reasoning — `checkG2`'s placeholder scan (registry-checks.ts)
54
+ * looks for this exact string. */
55
+ export const PLACEHOLDER = '[undocumented — fill in why]'
56
+
57
+ const HUSKY_PREFIX = '.husky/'
58
+ const CLAUDE_HOOKS_PREFIX = '.claude/hooks/'
59
+ const AEG_CORE_BIN_PREFIX = 'packages/aeg-core/bin/'
60
+ const CLI_BIN_PREFIX = 'apps/cli/src/checks/bin/'
61
+
62
+ const RING_CATEGORY: Record<GateRing, string> = { ring0: 'hook', ring1: 'ci', ring2: 'event' }
63
+
64
+ const RING_HEADING_PATTERN: Record<GateRing, RegExp> = {
65
+ ring0: /^##\s+Ring 0\b/,
66
+ ring1: /^##\s+Ring 1\b/,
67
+ ring2: /^##\s+Ring 2\b/
68
+ }
69
+
70
+ export type ScaffoldStub = {
71
+ ring: GateRing
72
+ path: string
73
+ checkName?: string
74
+ /** Ordered cell values, matching that ring table's own column count/order. */
75
+ cells: string[]
76
+ }
77
+
78
+ export type ScaffoldSkip = { path: string; reason: string }
79
+
80
+ export type ScaffoldPlan = {
81
+ stubs: ScaffoldStub[]
82
+ skipped: ScaffoldSkip[]
83
+ }
84
+
85
+ function ringFromNumber(n: 0 | 1 | 2): GateRing {
86
+ if (n === 0) return 'ring0'
87
+ if (n === 1) return 'ring1'
88
+ return 'ring2'
89
+ }
90
+
91
+ /** Classifies one G2-orphan candidate path. Returns null when no ring is
92
+ * derivable — the candidate must remain a plain G2 finding, never guessed. */
93
+ function classify(path: string): { ring: GateRing; checkName?: string } | null {
94
+ if (path.startsWith(HUSKY_PREFIX) || path.startsWith(CLAUDE_HOOKS_PREFIX)) {
95
+ return { ring: 'ring0' }
96
+ }
97
+ if (path.startsWith(AEG_CORE_BIN_PREFIX) && path.endsWith('.ts')) {
98
+ const base = path.slice(AEG_CORE_BIN_PREFIX.length, -'.ts'.length)
99
+ const audience = GATE_AUDIENCE[base]
100
+ if (audience && isShipped(audience)) {
101
+ const checkName = Array.isArray(audience.shippedAs) ? audience.shippedAs[0] : audience.shippedAs
102
+ return { ring: ringFromNumber(audience.ring), checkName }
103
+ }
104
+ return null
105
+ }
106
+ if (path.startsWith(CLI_BIN_PREFIX) && path.endsWith('.ts')) {
107
+ const base = path.slice(CLI_BIN_PREFIX.length, -'.ts'.length)
108
+ if (!base.startsWith('check-')) return null
109
+ const checkName = base.slice('check-'.length)
110
+ const ring = CLI_CHECK_RING[checkName]
111
+ if (ring === undefined) return null
112
+ return { ring: ringFromNumber(ring), checkName }
113
+ }
114
+ return null
115
+ }
116
+
117
+ function gateCell(path: string, checkName: string | undefined): string {
118
+ if (checkName) return `\`${checkName}\` check (\`vinaya check ${checkName}\`)`
119
+ return `managed hook script (\`${path}\`)`
120
+ }
121
+
122
+ function buildCells(ring: GateRing, path: string, checkName: string | undefined): string[] {
123
+ const action = checkName ?? path
124
+ const category = RING_CATEGORY[ring]
125
+ const audience = checkName ? 'product' : 'repo-own'
126
+ const implementation = `\`${path}\``
127
+ if (ring === 'ring0') {
128
+ return [
129
+ action,
130
+ PLACEHOLDER,
131
+ category,
132
+ PLACEHOLDER,
133
+ gateCell(path, checkName),
134
+ PLACEHOLDER,
135
+ audience,
136
+ implementation
137
+ ]
138
+ }
139
+ if (ring === 'ring1') {
140
+ return [action, PLACEHOLDER, category, PLACEHOLDER, PLACEHOLDER, audience, implementation]
141
+ }
142
+ return [action, PLACEHOLDER, category, PLACEHOLDER, PLACEHOLDER, PLACEHOLDER, audience, implementation]
143
+ }
144
+
145
+ /** Computes which G2-orphan candidates get a stub row, and where. Pure: no
146
+ * I/O, deterministic over its inputs. Idempotent by construction — a
147
+ * candidate already present in `rows` (including one from a prior scaffold
148
+ * run) is excluded before classification ever runs. */
149
+ export function computeScaffoldPlan(rows: GateRow[], candidateFiles: string[]): ScaffoldPlan {
150
+ const implementations = new Set(rows.map((r) => r.implementation).filter((p) => p !== ''))
151
+ const stubs: ScaffoldStub[] = []
152
+ const skipped: ScaffoldSkip[] = []
153
+ for (const path of candidateFiles) {
154
+ if (implementations.has(path)) continue
155
+ const classified = classify(path)
156
+ if (!classified) {
157
+ skipped.push({ path, reason: 'no derivable ring — remains a plain G2 finding' })
158
+ continue
159
+ }
160
+ stubs.push({
161
+ ring: classified.ring,
162
+ path,
163
+ checkName: classified.checkName,
164
+ cells: buildCells(classified.ring, path, classified.checkName)
165
+ })
166
+ }
167
+ return { stubs, skipped }
168
+ }
169
+
170
+ function formatRow(cells: string[]): string {
171
+ return `| ${cells.join(' | ')} |`
172
+ }
173
+
174
+ /**
175
+ * Rewrites `content` to append the plan's stub rows to their ring tables.
176
+ * Only ever APPENDS new lines after a ring table's last existing row —
177
+ * never edits, reorders, or removes an existing line, so a hand-written row
178
+ * (including every row task `#67` wrote) is untouched byte-for-byte.
179
+ *
180
+ * Rings are processed from the bottom of the file upward (ring 2 first,
181
+ * then ring 1, then ring 0) so an earlier insertion's line numbers, computed
182
+ * once up front from the ORIGINAL `rows`, stay valid throughout — a later
183
+ * ring's insertion never shifts an earlier ring's already-resolved splice
184
+ * point.
185
+ *
186
+ * Throws if a ring with stubs to insert has no existing table/rows to
187
+ * anchor on — the caller (`bin/verify-registry.ts`) must not write anything
188
+ * to disk when this throws; computing this in memory before any write is
189
+ * what keeps the round-trip guard restore-free (nothing is ever written
190
+ * badly in the first place).
191
+ */
192
+ export function applyScaffoldPlan(content: string, plan: ScaffoldPlan): string {
193
+ if (plan.stubs.length === 0) return content
194
+ const lines = content.split('\n')
195
+
196
+ const byRing: Record<GateRing, ScaffoldStub[]> = { ring0: [], ring1: [], ring2: [] }
197
+ for (const stub of plan.stubs) byRing[stub.ring].push(stub)
198
+
199
+ for (const ring of ['ring2', 'ring1', 'ring0'] as const) {
200
+ const stubs = byRing[ring]
201
+ if (stubs.length === 0) continue
202
+ const headingLine = findHeadingLine(lines, RING_HEADING_PATTERN[ring])
203
+ if (headingLine === null) {
204
+ throw new Error(`registry-scaffold: no "${ring}" heading found in enforcement.md`)
205
+ }
206
+ const table = findTable(lines, headingLine + 1)
207
+ if (!table || table.rows.length === 0) {
208
+ throw new Error(`registry-scaffold: ${ring} table has no existing rows to insert after`)
209
+ }
210
+ const lastRowLine = table.rows[table.rows.length - 1]?.line ?? headingLine
211
+ const newLines = stubs.map((s) => formatRow(s.cells))
212
+ lines.splice(lastRowLine, 0, ...newLines)
213
+ }
214
+
215
+ return lines.join('\n')
216
+ }