@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,275 @@
1
+ /**
2
+ * Quoted-command staleness (Issue #128). Pure — no `fs`, no `git`/`gh`.
3
+ *
4
+ * **The defect this closes.** A doc quotes a command, config line, or file
5
+ * content verbatim, in backticks, as a statement of present fact — "what
6
+ * runs today: `X`". When a diff changes the thing being quoted, the
7
+ * quotation goes stale and nothing notices: the doc changed nothing, the
8
+ * code is correct, every existing gate passes. Measured, live: a CI
9
+ * invocation was pinned to a version in one PR; `aeg-root/enforcement.md`
10
+ * quoted the pre-pin form verbatim as "what runs today"; all 17 registered
11
+ * checks passed, `doctor` reported healthy, security passed. A code-review
12
+ * agent found it after roughly half an hour of independent re-derivation.
13
+ * The fix was one line — the detection took a review round.
14
+ *
15
+ * **Marker-based, not inferred — the Principal's explicit decision
16
+ * (2026-08-30).** The Issue leaves the claim-vs-instruction distinction
17
+ * open and calls it "the real work"; it is now settled: a doc opts a span
18
+ * in with a citation marker naming the file it quotes. An adopter-facing
19
+ * `npx @attalabs/vinaya init` in a README is instruction, correctly
20
+ * unpinned — inferring over command-looking spans would flag it, which is
21
+ * the exact false-positive that gets a gate disabled. This module
22
+ * implements NO inference and NO heuristic fallback for an unmarked span;
23
+ * only annotated quotes are ever evaluated. Coverage grows only as docs
24
+ * adopt the marker — that cost is understood and accepted.
25
+ *
26
+ * **The marker grammar.** Two-line (or inline) HTML-comment pair, the same
27
+ * invisible-on-render, code-blind idiom `anchored-region.ts` uses for the
28
+ * six PR/Issue-body gate-read fields — reused for its masking discipline
29
+ * (`maskCode`, imported below) rather than re-implemented, though this is a
30
+ * DIFFERENT field kind from `anchored-region.ts`'s closed `AnchorField`
31
+ * union: `anchoredRegionBounds` parses one of six fixed field names inside a
32
+ * forge body; this marker instead carries a free-form cited-file path
33
+ * inline in its own START tag, inside an arbitrary doc file, so it cannot
34
+ * reuse that function directly without widening a closed, PR-body-specific
35
+ * type for a doc-authoring concern outside this task's surface. The shape:
36
+ *
37
+ * <!-- AEG:QUOTES-FILE:START:<repo-relative-path> -->
38
+ * `<quoted text>`
39
+ * <!-- AEG:QUOTES-FILE:END -->
40
+ *
41
+ * Both markers may sit on their own lines or inline within a prose
42
+ * sentence (the real annotation this task ships wraps an inline backtick
43
+ * span mid-paragraph). Recognition rules mirror `anchored-region.ts`
44
+ * exactly: markers inside a fenced/indented code block or inline code span
45
+ * do not count (an authoring example showing the marker syntax itself,
46
+ * fenced, is never mistaken for a real one); a `START` with no following
47
+ * `END` is not an anchor at all; multiple pairs in one file are scanned
48
+ * left to right, each `START` paired with the next `END` after it.
49
+ *
50
+ * **The quoted text.** The content between the markers, trimmed, with one
51
+ * layer of wrapping stripped: a single-backtick inline span, a fenced block
52
+ * (backtick or tilde), or neither (bare text) — whichever the author used.
53
+ * That literal string is the predicate's subject: it either occurs verbatim
54
+ * in the cited file's content, or it does not. No normalization beyond
55
+ * that — a doc that means to tolerate whitespace/formatting drift is not
56
+ * this check's problem to solve.
57
+ *
58
+ * **The predicate.** Decidable, per the Issue: a quoted command either
59
+ * appears in the file it claims to quote, or it does not. `findCitedQuotes`
60
+ * discovers every marked span in the governed-doc corpus (the same
61
+ * `classifyProseFile` scoping `reader-resolvable-prose.ts` sweeps —
62
+ * `ships`/`reader-facing`, never `internal` — reused rather than a second
63
+ * notion of "governed doc"); `evaluateCitedQuotes` then checks each against
64
+ * the cited file's actual content, which the caller supplies (the bin reads
65
+ * it — this module never touches disk). A finding names both sides: what
66
+ * the doc claims (the quoted text) and which file it cited that no longer
67
+ * (or never did) contain it verbatim.
68
+ */
69
+
70
+ import { maskCode } from '@attalabs/aeg-forge-state/strip-code'
71
+ import { classifyProseFile, type ProseFileClass } from './reader-resolvable-prose'
72
+
73
+ // `maskCode` (index-preserving) is imported directly from the `./strip-code`
74
+ // subpath — the same narrow front door `anchored-region.ts` uses, per that
75
+ // package's own barrel comment ("reachable through the `./strip-code`
76
+ // subpath for the two callers that need them"). Not through
77
+ // `anchored-region.ts`'s re-export: that file's own `anchoredRegionBounds`
78
+ // is scoped to its closed seven-field `AnchorField` grammar, which this
79
+ // marker (a free-form cited-file path, not one of those seven names) cannot
80
+ // use without widening a PR-body-specific type for a doc-authoring concern
81
+ // outside this task's surface. Reusing `maskCode` itself — rather than
82
+ // writing a second masker — is the actual "do not write a second anchor
83
+ // parser" discipline this module honors.
84
+
85
+ export type QuotedCommandSourceFile = { path: string; content: string }
86
+
87
+ /** One marked span: the doc that carries it, where, what it claims, and which file it cites. */
88
+ export type CitedQuote = {
89
+ file: string
90
+ line: number
91
+ quotedText: string
92
+ citedFile: string
93
+ }
94
+
95
+ export type QuotedCommandFinding = {
96
+ file: string
97
+ line: number
98
+ citedFile: string
99
+ quotedText: string
100
+ message: string
101
+ }
102
+
103
+ /** `aeg-root/**` by default — same default `reader-resolvable-prose.ts` uses, so a caller that doesn't override either stays consistent. */
104
+ const QUOTED_COMMAND_SHIPS_PREFIX = 'aeg-root/'
105
+
106
+ /** The two classes this check sweeps for markers — `internal` never is, matching `reader-resolvable-prose.ts`'s own (identically-valued, differently-named to avoid `symbol-collisions.test.ts`) `SWEPT_CLASSES`. */
107
+ const QUOTED_COMMAND_SWEPT_CLASSES: ReadonlySet<ProseFileClass> = new Set(['ships', 'reader-facing'])
108
+
109
+ const START_PATTERN = /<!--\s*AEG:QUOTES-FILE:START:(\S+?)\s*-->/g
110
+ const END_PATTERN = /<!--\s*AEG:QUOTES-FILE:END\s*-->/
111
+
112
+ /**
113
+ * A marker's `citedFile` must be a plain repo-root-relative path — never
114
+ * absolute, never carrying a `..` traversal segment. Security finding
115
+ * (this check's own PR, round 2): an unvalidated `citedFile` turns this
116
+ * check into a file-content oracle any doc author can drive — a crafted
117
+ * marker naming `../../../../etc/hosts` (or any path outside the repo the
118
+ * check process can reach) gets its content read and compared against
119
+ * attacker-chosen `quotedText`, and the three distinguishable outcomes
120
+ * (silent pass on an exact match, a "no longer contains" finding on a
121
+ * miss, a "could not be read" finding when the target is absent) form a
122
+ * working binary-search oracle over that file's real content — reproduced
123
+ * live, three ways, including a working read of `/etc/hosts`. A path
124
+ * failing this check is not a valid citation at all: the marker is treated
125
+ * exactly like an unterminated START/END pair (silently not an anchor),
126
+ * never reaching the file-read stage, so there is no signal difference
127
+ * between "malformed marker" and "no marker" for an attacker to probe.
128
+ */
129
+ export function isValidCitedFilePath(path: string): boolean {
130
+ if (path.length === 0) return false
131
+ if (path.startsWith('/') || path.startsWith('\\')) return false
132
+ if (/^[A-Za-z]:[\\/]/.test(path)) return false
133
+ return !path.split(/[\\/]+/).includes('..')
134
+ }
135
+
136
+ function quotedCommandLineAt(content: string, index: number): number {
137
+ let line = 1
138
+ for (let i = 0; i < index; i++) {
139
+ if (content.charCodeAt(i) === 10) line++
140
+ }
141
+ return line
142
+ }
143
+
144
+ /**
145
+ * Strips exactly one layer of wrapping from the marker's inner text: a
146
+ * single-backtick inline span, a fenced block (backtick or tilde, with or
147
+ * without an info string), or neither. Returns the trimmed bare text in
148
+ * every case — this is deliberately not CommonMark-general, only the two
149
+ * shapes an author actually writes a quoted command in.
150
+ */
151
+ function extractQuotedText(raw: string): string {
152
+ const trimmed = raw.trim()
153
+ const fenced = /^(?:`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n?(?:`{3,}|~{3,})$/.exec(trimmed)
154
+ if (fenced) return (fenced[1] ?? '').trim()
155
+ const inline = /^`([^`]+)`$/.exec(trimmed)
156
+ if (inline) return (inline[1] ?? '').trim()
157
+ return trimmed
158
+ }
159
+
160
+ /**
161
+ * Scans one file's content for `AEG:QUOTES-FILE` marker pairs. Searches
162
+ * position-only over `stripCode`'s masked-but-length-altering output is
163
+ * unsafe (indices would no longer map back) — so this uses `maskCode`
164
+ * (index-preserving, same primitive `anchored-region.ts` uses) to find
165
+ * marker positions code-blind, then slices the real inner text from the
166
+ * original `content` at those same indices, exactly as `anchoredRegionBounds`
167
+ * does for its own seven fields.
168
+ */
169
+ function findQuotesInFile(path: string, content: string): CitedQuote[] {
170
+ const masked = maskCode(content)
171
+ const quotes: CitedQuote[] = []
172
+
173
+ START_PATTERN.lastIndex = 0
174
+ let start: RegExpExecArray | null = START_PATTERN.exec(masked)
175
+ while (start !== null) {
176
+ const citedFile = start[1] as string
177
+ const innerStart = start.index + start[0].length
178
+ const end = END_PATTERN.exec(masked.slice(innerStart))
179
+ if (end === null) {
180
+ // A START with no following END is not an anchor at all (mirrors
181
+ // anchored-region.ts's identical rule) — resume scanning right after
182
+ // this unterminated START rather than treating the rest of the file
183
+ // as consumed by it.
184
+ START_PATTERN.lastIndex = innerStart
185
+ start = START_PATTERN.exec(masked)
186
+ continue
187
+ }
188
+ const innerEnd = innerStart + end.index
189
+ if (isValidCitedFilePath(citedFile)) {
190
+ const quotedText = extractQuotedText(content.slice(innerStart, innerEnd))
191
+ if (quotedText.length > 0) {
192
+ quotes.push({ file: path, line: quotedCommandLineAt(content, innerStart), quotedText, citedFile })
193
+ }
194
+ }
195
+ START_PATTERN.lastIndex = innerEnd + end[0].length
196
+ start = START_PATTERN.exec(masked)
197
+ }
198
+ return quotes
199
+ }
200
+
201
+ /**
202
+ * Every marked span across the governed-doc corpus — `files` scoped to the
203
+ * same `ships`/`reader-facing` classes `reader-resolvable-prose.ts` sweeps
204
+ * (never `internal`), via the identical `classifyProseFile` call, not a
205
+ * second notion of "governed doc". Zero I/O: `files` is read by the caller.
206
+ */
207
+ export function findCitedQuotes(
208
+ files: readonly QuotedCommandSourceFile[],
209
+ readerFacingPrefix: string,
210
+ readerFacingSuffix: string,
211
+ shipsPrefix: string = QUOTED_COMMAND_SHIPS_PREFIX
212
+ ): CitedQuote[] {
213
+ const quotes: CitedQuote[] = []
214
+ for (const file of files) {
215
+ const cls = classifyProseFile(file.path, readerFacingPrefix, readerFacingSuffix, shipsPrefix)
216
+ if (!cls || !QUOTED_COMMAND_SWEPT_CLASSES.has(cls)) continue
217
+ quotes.push(...findQuotesInFile(file.path, file.content))
218
+ }
219
+ return quotes
220
+ }
221
+
222
+ /**
223
+ * The predicate itself: does each cited quote still appear verbatim in the
224
+ * file it claims to quote? `citedFileContents` is supplied by the caller —
225
+ * this function does no I/O and does not care whether a cited path lies
226
+ * inside or outside the governed-doc corpus (a workflow YAML a doc quotes
227
+ * is neither `ships` nor `reader-facing`, and must still be checkable). A
228
+ * cited path absent from `citedFileContents` is itself a finding (the file
229
+ * could not be read/found), not a silent pass — collapsing "unreadable"
230
+ * into "verified" would be the same fail-open class other checks in this
231
+ * package document as a real, reproduced incident.
232
+ */
233
+ export function evaluateCitedQuotes(
234
+ citedQuotes: readonly CitedQuote[],
235
+ citedFileContents: ReadonlyMap<string, string>
236
+ ): QuotedCommandFinding[] {
237
+ const findings: QuotedCommandFinding[] = []
238
+ for (const quote of citedQuotes) {
239
+ const content = citedFileContents.get(quote.citedFile)
240
+ if (content === undefined) {
241
+ findings.push({
242
+ file: quote.file,
243
+ line: quote.line,
244
+ citedFile: quote.citedFile,
245
+ quotedText: quote.quotedText,
246
+ message: `${quote.file}:${quote.line} quotes "${quote.quotedText}" as citing \`${quote.citedFile}\`, but that file could not be read`
247
+ })
248
+ continue
249
+ }
250
+ if (!content.includes(quote.quotedText)) {
251
+ findings.push({
252
+ file: quote.file,
253
+ line: quote.line,
254
+ citedFile: quote.citedFile,
255
+ quotedText: quote.quotedText,
256
+ message: `${quote.file}:${quote.line} quotes "${quote.quotedText}" as citing \`${quote.citedFile}\`, but \`${quote.citedFile}\` no longer contains that text verbatim`
257
+ })
258
+ }
259
+ }
260
+ return findings
261
+ }
262
+
263
+ /** Runs both phases in one call: discover markers, then evaluate them against the supplied cited-file contents. */
264
+ export function checkQuotedCommandStaleness(
265
+ files: readonly QuotedCommandSourceFile[],
266
+ citedFileContents: ReadonlyMap<string, string>,
267
+ readerFacingPrefix: string,
268
+ readerFacingSuffix: string,
269
+ shipsPrefix: string = QUOTED_COMMAND_SHIPS_PREFIX
270
+ ): QuotedCommandFinding[] {
271
+ return evaluateCitedQuotes(
272
+ findCitedQuotes(files, readerFacingPrefix, readerFacingSuffix, shipsPrefix),
273
+ citedFileContents
274
+ )
275
+ }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Reader-resolvable prose — classes 1 and 2 of the three-class analysis in
3
+ * Issue #694: the two mechanizable classes of "words a reader cannot
4
+ * resolve." Class 3 (register/slop — padding adjectives, first person,
5
+ * narrating the work episode) is NOT here; it stays with the review role,
6
+ * per the measurement below.
7
+ *
8
+ * **The measurement that disqualifies a phrase blacklist.** A phrase
9
+ * blacklist tested against the real corpus (all of `aeg-root` plus 1,157
10
+ * TypeScript files) scored 14 hits, 14 false positives, 0 true positives —
11
+ * "a review was *requested*"; "could *this change* leak a secret"; "*I have*
12
+ * X, Y, Z" inside a quoted example. The fix is not tuning the word list; it
13
+ * is asking a different question:
14
+ *
15
+ * - Class 1 (references): not "does this phrase appear" but "does this
16
+ * doctrine cite a forge number or tranche slug the reader has no forge to
17
+ * resolve." Reuses the same two pattern shapes proven in
18
+ * `retired-vocabulary.test.ts` (`FORGE_NUMBER_PATTERN`,
19
+ * `TRANCHE_SLUG_VN_PATTERN`, and the legacy-slug list derived — not
20
+ * guessed — from `aeg-root/tranches/completed/*.md` filenames), scoped to
21
+ * the reader-facing surfaces instead of banned repo-wide (the same digit
22
+ * shape is this product's own live `Closes #N`/PR-number grammar
23
+ * elsewhere, so a repo-wide ban would flag the mechanism, not the
24
+ * citation habit).
25
+ * - Class 2 (vocabulary): not "is this word forbidden" but "does this
26
+ * reader-facing file use a coined term while neither defining it inline
27
+ * nor linking a definition." The term list is derived from
28
+ * `aeg-root/glossary.md`'s own entry headings — never hard-coded — so it
29
+ * cannot drift the first time an entry is added.
30
+ *
31
+ * Zero I/O: every input (file paths + contents, the glossary's term list, the
32
+ * legacy-slug list) is read by the adapter and passed in. POSIX-ERE-safe
33
+ * patterns only — no `\d`/`\w`/`\s`/lookaheads, and no `\b` relied on after a
34
+ * non-word character (the exact regression class `retired-vocabulary.test.ts`
35
+ * already paid for) — even though these patterns run through native
36
+ * `RegExp`, not `grep -E`, keeping them portable to either.
37
+ */
38
+
39
+ export type ProseFileClass = 'ships' | 'reader-facing' | 'internal'
40
+
41
+ export type ProseSourceFile = { path: string; content: string }
42
+
43
+ export type ProseFinding = {
44
+ file: string
45
+ line: number
46
+ message: string
47
+ }
48
+
49
+ /**
50
+ * `aeg-root/**` by default — what this repo's own package carries and
51
+ * `/docs` publishes. Every caller below takes `shipsPrefix` as its LAST,
52
+ * defaulted parameter so an adopter whose installed doctrine tree lives
53
+ * somewhere else (task 7, Issue #56 — de-hardcoding `doctrineRoot`) can
54
+ * override it without breaking any existing positional call.
55
+ */
56
+ const SHIPS_PREFIX = 'aeg-root/'
57
+
58
+ /** `<shipsPrefix>tranches/completed/` — history, never rewritten. Same exemption `retired-vocabulary.test.ts` already carries for the identical reason: an archived tranche legitimately cites the forge numbers and slugs it closed. */
59
+ function shipsArchivePrefix(shipsPrefix: string): string {
60
+ return `${shipsPrefix}tranches/completed/`
61
+ }
62
+
63
+ /** A per-product specs file (`apps/<product>/specs/**`) — this reader has this forge; references are legitimate here. */
64
+ function isSpecFile(path: string): boolean {
65
+ return path.startsWith('apps/') && path.includes('/specs/') && path.endsWith('.md')
66
+ }
67
+
68
+ /** Any `CLAUDE.md`, at any depth — same reasoning as specs: this reader has this forge. */
69
+ function isClaudeMdFile(path: string): boolean {
70
+ return path === 'CLAUDE.md' || path.endsWith('/CLAUDE.md')
71
+ }
72
+
73
+ /**
74
+ * The file-class map, encoded as data (not a rule buried in control flow).
75
+ * Returns `null` for anything outside all three classes — out of this
76
+ * check's scope entirely, not merely exempt.
77
+ *
78
+ * `readerFacingPrefix`/`readerFacingSuffix` scope the "reader-facing" class
79
+ * to a consumer's own reader-facing surface — this package stays generic and
80
+ * has no attalabs-specific path baked in. The caller-supplied shape mirrors
81
+ * the vinaya adopter's product site: a page's own body is where
82
+ * reader-visible prose lives (JSX text, step copy); sibling component
83
+ * directories include non-textual/decorative surfaces (e.g. canvas
84
+ * illustrations using fictional PR numbers as flavor text) that are not
85
+ * prose in the sense this check means, hence a suffix-scoped match rather
86
+ * than a bare prefix.
87
+ */
88
+ export function classifyProseFile(
89
+ path: string,
90
+ readerFacingPrefix: string,
91
+ readerFacingSuffix: string,
92
+ shipsPrefix: string = SHIPS_PREFIX
93
+ ): ProseFileClass | null {
94
+ if (path.startsWith(shipsPrefix)) {
95
+ return path.startsWith(shipsArchivePrefix(shipsPrefix)) ? 'internal' : 'ships'
96
+ }
97
+ if (path.startsWith(readerFacingPrefix) && path.endsWith(readerFacingSuffix)) {
98
+ return 'reader-facing'
99
+ }
100
+ if (isSpecFile(path) || isClaudeMdFile(path)) return 'internal'
101
+ return null
102
+ }
103
+
104
+ /** The two classes this check actually sweeps — `internal` never is. */
105
+ const SWEPT_CLASSES: ReadonlySet<ProseFileClass> = new Set(['ships', 'reader-facing'])
106
+
107
+ /**
108
+ * Strips the parts of a file that are never reader prose, per file kind — a
109
+ * fenced/inline code example in a markdown doc, or a source comment in a
110
+ * `.tsx` page (a page's own developer-facing comments, e.g. "same reasoning
111
+ * as `/roadmap`", are not what a site visitor reads). Line count is
112
+ * preserved (replacements keep their newlines) so reported line numbers stay
113
+ * accurate against the original file.
114
+ *
115
+ * The `//` line-comment cut requires the marker not be immediately preceded
116
+ * by `:` — without that, `https://vinaya.dev` on a reader-facing line reads
117
+ * as a comment starting at its own `//`, silently blanking every word of
118
+ * real prose that follows it on the line. No boundary check is needed on
119
+ * the far side: unlike `//`, the character before it is never itself part
120
+ * of the marker, so a plain capturing group (not a lookbehind) is enough.
121
+ */
122
+ export function stripNonProse(path: string, content: string): string {
123
+ if (path.endsWith('.md')) {
124
+ return content
125
+ .replace(/```.*?```/gs, (m) => m.replace(/[^\n]/g, ''))
126
+ .replace(/`[^`\n]*`/g, (m) => m.replace(/[^\n]/g, ''))
127
+ }
128
+ if (path.endsWith('.tsx') || path.endsWith('.ts')) {
129
+ return content
130
+ .replace(/\/\*.*?\*\//gs, (m) => m.replace(/[^\n]/g, ''))
131
+ .replace(/(^|[^:])\/\/[^\n]*/g, (m) => m.replace(/[^\n]/g, ''))
132
+ }
133
+ return content
134
+ }
135
+
136
+ function lineAt(content: string, index: number): number {
137
+ let line = 1
138
+ for (let i = 0; i < index; i++) {
139
+ if (content.charCodeAt(i) === 10) line++
140
+ }
141
+ return line
142
+ }
143
+
144
+ /** `#294`-shaped — a PR/Issue number cited bare, resolvable only inside this repo's own tracker. */
145
+ export const FORGE_NUMBER_PATTERN = /#[0-9]{2,4}/g
146
+
147
+ /** `aeg-forge-state-v1`-shaped — an internal tranche slug ending `-vN`. */
148
+ export const TRANCHE_SLUG_VN_PATTERN = /[a-z][a-z-]+-v[0-9]/g
149
+
150
+ /**
151
+ * Builds the legacy-slug pattern from the real archived-tranche filenames
152
+ * the adapter reads (mirrors `retired-vocabulary.test.ts`'s `legacySlugs()` —
153
+ * derived from `aeg-root/tranches/completed/*.md`, never a shape guess).
154
+ * Returns `null` when there is nothing left for it to catch (every archived
155
+ * slug now ends `-vN`), matching that file's "no pattern with zero positive
156
+ * samples" rule.
157
+ *
158
+ * Boundary-checked like `containsWholeWord` below (character class, not
159
+ * `\b`) so a real slug cannot match as a bare substring inside a longer,
160
+ * unrelated token. `retired-vocabulary.test.ts`'s own copy of this pattern
161
+ * has no such boundary; this one adds it rather than copying the gap.
162
+ * Group 1 is the leading boundary char (or start-of-string), group 2 is the
163
+ * slug itself, group 3 the trailing boundary char (or end-of-string) — the
164
+ * caller reports group 2, not the whole match, so the boundary chars never
165
+ * leak into a finding's message.
166
+ */
167
+ export function legacySlugPattern(legacySlugs: readonly string[]): RegExp | null {
168
+ if (legacySlugs.length === 0) return null
169
+ const alternation = legacySlugs.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
170
+ return new RegExp(`(^|[^a-zA-Z0-9\\n-])(${alternation})([^a-zA-Z0-9\\n-]|$)`, 'g')
171
+ }
172
+
173
+ /**
174
+ * Class 1 — unresolvable references. Runs the reference patterns over every
175
+ * swept file (`ships`/`reader-facing`), skipping code-fenced/commented-out
176
+ * non-prose first.
177
+ */
178
+ export function checkUnresolvableReferences(
179
+ files: readonly ProseSourceFile[],
180
+ readerFacingPrefix: string,
181
+ readerFacingSuffix: string,
182
+ legacySlugs: readonly string[] = [],
183
+ shipsPrefix: string = SHIPS_PREFIX
184
+ ): ProseFinding[] {
185
+ const findings: ProseFinding[] = []
186
+ const legacyPattern = legacySlugPattern(legacySlugs)
187
+ // `group` names the capture holding the actual cited text — the plain
188
+ // patterns have none (report the whole match), the boundary-checked
189
+ // legacy-slug pattern reports its group 2 so the boundary chars around it
190
+ // never leak into the finding's message.
191
+ const patterns: { pattern: RegExp; what: string; group?: number }[] = [
192
+ { pattern: FORGE_NUMBER_PATTERN, what: 'a forge number' },
193
+ { pattern: TRANCHE_SLUG_VN_PATTERN, what: 'an internal tranche slug' },
194
+ ...(legacyPattern ? [{ pattern: legacyPattern, what: 'an internal tranche slug', group: 2 }] : [])
195
+ ]
196
+
197
+ for (const file of files) {
198
+ const cls = classifyProseFile(file.path, readerFacingPrefix, readerFacingSuffix, shipsPrefix)
199
+ if (!cls || !SWEPT_CLASSES.has(cls)) continue
200
+ const scrubbed = stripNonProse(file.path, file.content)
201
+ for (const { pattern, what, group } of patterns) {
202
+ pattern.lastIndex = 0
203
+ let match: RegExpExecArray | null = pattern.exec(scrubbed)
204
+ while (match !== null) {
205
+ const cited = group !== undefined ? (match[group] ?? match[0]) : match[0]
206
+ findings.push({
207
+ file: file.path,
208
+ line: lineAt(scrubbed, match.index),
209
+ message: `references ${what} ("${cited}") a reader outside this repo's tracker cannot resolve`
210
+ })
211
+ match = pattern.exec(scrubbed)
212
+ }
213
+ }
214
+ }
215
+ return findings
216
+ }
217
+
218
+ /** Escapes a glossary term for use inside a hand-built, boundary-checked pattern. */
219
+ function escapeTerm(term: string): string {
220
+ return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
221
+ }
222
+
223
+ /**
224
+ * Whole-word (or whole-phrase), case-insensitive occurrence of `term` in
225
+ * `text` — boundaries checked by character class rather than `\b`, per the
226
+ * "do not rely on `\b` after a non-word character" lesson. Matches the exact
227
+ * glossary heading only (no inflections): "briefly" must never match
228
+ * "brief" the way a prefix match would — that exact shape (`let's` matching
229
+ * `bullet's`) is what the disqualifying measurement caught.
230
+ */
231
+ function containsWholeWord(text: string, term: string): boolean {
232
+ const escaped = escapeTerm(term)
233
+ const re = new RegExp(`(^|[^a-zA-Z])${escaped}([^a-zA-Z]|$)`, 'i')
234
+ return re.test(text)
235
+ }
236
+
237
+ function findWholeWordLine(content: string, term: string): number | null {
238
+ const lines = content.split('\n')
239
+ let lineNo = 0
240
+ for (const line of lines) {
241
+ lineNo++
242
+ if (containsWholeWord(line, term)) return lineNo
243
+ }
244
+ return null
245
+ }
246
+
247
+ /**
248
+ * A file "defines" a term inline when it carries the same
249
+ * `Term — definition` shape the glossary itself uses (an em-dash right after
250
+ * the term), or links the glossary page/file directly.
251
+ */
252
+ function definesOrLinksGlossary(content: string, term: string): boolean {
253
+ const escaped = escapeTerm(term)
254
+ const definesInline = new RegExp(`(^|[^a-zA-Z])${escaped}([^a-zA-Z]|$)[^\\n]{0,3}—`, 'i').test(content)
255
+ if (definesInline) return true
256
+ return /docs\/glossary|glossary\.md/i.test(content)
257
+ }
258
+
259
+ /**
260
+ * Class 2 — undefined coined vocabulary. Not "is this word forbidden" — it
261
+ * is "does this reader-facing file use a coined term while neither defining
262
+ * it inline nor linking a definition." `glossaryTerms` is read by the
263
+ * adapter from `aeg-root/glossary.md`'s own entry headings — never
264
+ * hard-coded here, so the check cannot silently rot the first time an entry
265
+ * is added.
266
+ */
267
+ export function checkUndefinedVocabulary(
268
+ files: readonly ProseSourceFile[],
269
+ glossaryTerms: readonly string[],
270
+ readerFacingPrefix: string,
271
+ readerFacingSuffix: string,
272
+ shipsPrefix: string = SHIPS_PREFIX
273
+ ): ProseFinding[] {
274
+ const findings: ProseFinding[] = []
275
+
276
+ for (const file of files) {
277
+ const cls = classifyProseFile(file.path, readerFacingPrefix, readerFacingSuffix, shipsPrefix)
278
+ if (!cls || !SWEPT_CLASSES.has(cls)) continue
279
+ const scrubbed = stripNonProse(file.path, file.content)
280
+
281
+ for (const term of glossaryTerms) {
282
+ const line = findWholeWordLine(scrubbed, term)
283
+ if (line === null) continue
284
+ if (definesOrLinksGlossary(scrubbed, term)) continue
285
+ findings.push({
286
+ file: file.path,
287
+ line,
288
+ message: `uses coined term "${term}" without defining it inline or linking the glossary`
289
+ })
290
+ }
291
+ }
292
+ return findings
293
+ }
294
+
295
+ /** Runs both mechanizable classes over the given files in one pass. */
296
+ export function checkReaderResolvableProse(
297
+ files: readonly ProseSourceFile[],
298
+ glossaryTerms: readonly string[],
299
+ readerFacingPrefix: string,
300
+ readerFacingSuffix: string,
301
+ legacySlugs: readonly string[] = [],
302
+ shipsPrefix: string = SHIPS_PREFIX
303
+ ): ProseFinding[] {
304
+ return [
305
+ ...checkUnresolvableReferences(files, readerFacingPrefix, readerFacingSuffix, legacySlugs, shipsPrefix),
306
+ ...checkUndefinedVocabulary(files, glossaryTerms, readerFacingPrefix, readerFacingSuffix, shipsPrefix)
307
+ ]
308
+ }
309
+
310
+ /** Parses `aeg-root/glossary.md`'s `**Term** — definition` entry headings into a bare term list. */
311
+ export function parseGlossaryTerms(glossaryContent: string): string[] {
312
+ const terms: string[] = []
313
+ const re = /^\*\*(.+?)\*\* —/gm
314
+ let match: RegExpExecArray | null = re.exec(glossaryContent)
315
+ while (match !== null) {
316
+ if (match[1] !== undefined) terms.push(match[1])
317
+ match = re.exec(glossaryContent)
318
+ }
319
+ return terms
320
+ }