@attalabs/vinaya 0.21.0 → 0.23.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 (211) hide show
  1. package/README.md +15 -4
  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 +28 -21
  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 +2 -2
  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 +12 -7
  21. package/aeg-root/tranche-model.md +9 -5
  22. package/dist/checks/bin/check-body-bare-digits.js +667 -148
  23. package/dist/checks/bin/check-branch-topology.js +714 -149
  24. package/dist/checks/bin/check-brief-shape.js +704 -147
  25. package/dist/checks/bin/check-changeset-coverage.js +1046 -166
  26. package/dist/checks/bin/check-closes-n.js +714 -149
  27. package/dist/checks/bin/check-coherence.js +729 -156
  28. package/dist/checks/bin/check-dead-branch-push.js +663 -144
  29. package/dist/checks/bin/check-dispatch-readiness.js +728 -155
  30. package/dist/checks/bin/check-doc-coverage-push.js +838 -261
  31. package/dist/checks/bin/check-doc-coverage.js +838 -261
  32. package/dist/checks/bin/check-doctrine-no-procedures.js +4794 -0
  33. package/dist/checks/bin/check-doctrine-portability.js +799 -155
  34. package/dist/checks/bin/check-evidence-fresh.js +1132 -206
  35. package/dist/checks/bin/check-exec-bits.js +4931 -0
  36. package/dist/checks/bin/check-first-push-dispatch.js +716 -151
  37. package/dist/checks/bin/check-issue-assignment.js +714 -149
  38. package/dist/checks/bin/check-main-branch-refusal.js +663 -144
  39. package/dist/checks/bin/check-no-disk-state.js +663 -144
  40. package/dist/checks/bin/check-pr-report-density.js +4461 -0
  41. package/dist/checks/bin/check-quoted-command.js +741 -166
  42. package/dist/checks/bin/check-reader-resolvable-prose.js +768 -173
  43. package/dist/checks/bin/check-registry-gates.js +726 -154
  44. package/dist/checks/bin/check-retired-vocabulary.js +760 -165
  45. package/dist/checks/bin/check-review-gate.js +783 -148
  46. package/dist/checks/bin/check-single-plan-pr.js +663 -144
  47. package/dist/checks/bin/check-test-plan.js +664 -145
  48. package/dist/checks/bin/check-token-collection-wired.js +665 -146
  49. package/dist/checks/bin/check-token-report.js +4509 -0
  50. package/dist/checks/bin/check-workspace-escape.js +1129 -183
  51. package/dist/index.js +8458 -5403
  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 +113 -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 +192 -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-validation.ts +511 -0
  60. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/claude-code-transcript.ts +460 -0
  61. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/coherence-checks.ts +787 -0
  62. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-audit.ts +55 -0
  63. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-guard.ts +77 -0
  64. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-section7.ts +66 -0
  65. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-tranche.ts +94 -0
  66. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/diagram-model.ts +372 -0
  67. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/direct-main-push.ts +39 -0
  68. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dispatch-gate.ts +224 -0
  69. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doc-owners.ts +314 -0
  70. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/build-doc-nav.ts +38 -0
  71. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/docs-coherence.ts +136 -0
  72. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/index.ts +29 -0
  73. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/legacy-anchors.ts +48 -0
  74. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/nav-helpers.ts +17 -0
  75. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/node-route.ts +134 -0
  76. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/parse-doc.ts +43 -0
  77. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/published-prose.ts +306 -0
  78. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/surfaced-manifest.ts +89 -0
  79. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/types.ts +33 -0
  80. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-portability.ts +170 -0
  81. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-source.ts +23 -0
  82. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/ensure-label.ts +28 -0
  83. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/file-classify.ts +51 -0
  84. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/first-push-dispatch-gate.ts +80 -0
  85. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/gate-audience.ts +168 -0
  86. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/index.ts +301 -0
  87. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-assignment.ts +102 -0
  88. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-validation.ts +746 -0
  89. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/leftover-detection.ts +68 -0
  90. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/local-anchor-coverage.ts +245 -0
  91. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/main-branch-refusal.ts +64 -0
  92. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/manifest-validity.ts +81 -0
  93. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/markdown-table.ts +76 -0
  94. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/metering-io-guard.ts +119 -0
  95. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/milestone-validation.ts +240 -0
  96. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/no-disk-state.ts +50 -0
  97. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-ledger.ts +77 -0
  98. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-registry.ts +105 -0
  99. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-token-report.ts +165 -0
  100. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-tranche.ts +183 -0
  101. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-tier.ts +81 -0
  102. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/premise-check.ts +125 -0
  103. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/quoted-command.ts +275 -0
  104. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/reader-resolvable-prose.ts +320 -0
  105. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-checks.ts +255 -0
  106. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-parse.ts +164 -0
  107. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-scaffold.ts +216 -0
  108. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/report-tokens.ts +261 -0
  109. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/retired-vocabulary.ts +163 -0
  110. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-gate.ts +269 -0
  111. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/single-plan-pr.ts +73 -0
  112. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-machine-model.ts +269 -0
  113. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-source.ts +14 -0
  114. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/status-block.ts +8 -0
  115. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/sum-ledger.ts +26 -0
  116. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/symbol-collisions.ts +78 -0
  117. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-gate.ts +104 -0
  118. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-section.ts +65 -0
  119. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/types.ts +174 -0
  120. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/verdict-extraction.ts +140 -0
  121. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/vocabulary-citation.ts +114 -0
  122. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/waiver-label.ts +52 -0
  123. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/workspace-escape.ts +172 -0
  124. package/studio-standalone/apps/vinaya-studio/web/.next/BUILD_ID +1 -1
  125. package/studio-standalone/apps/vinaya-studio/web/.next/build-manifest.json +3 -3
  126. package/studio-standalone/apps/vinaya-studio/web/.next/prerender-manifest.json +3 -3
  127. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.html +1 -1
  128. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.rsc +1 -1
  129. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  130. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  131. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  132. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  133. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  134. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page/server-reference-manifest.json +2 -2
  135. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  136. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  137. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/api/coherence/route.js.nft.json +1 -1
  138. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page/server-reference-manifest.json +2 -2
  139. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page.js.nft.json +1 -1
  140. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page_client-reference-manifest.js +1 -1
  141. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page/server-reference-manifest.json +2 -2
  142. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page.js.nft.json +1 -1
  143. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page_client-reference-manifest.js +1 -1
  144. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page/server-reference-manifest.json +2 -2
  145. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page.js.nft.json +1 -1
  146. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page_client-reference-manifest.js +1 -1
  147. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page/server-reference-manifest.json +2 -2
  148. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page.js.nft.json +1 -1
  149. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page_client-reference-manifest.js +1 -1
  150. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page/server-reference-manifest.json +2 -2
  151. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page.js.nft.json +1 -1
  152. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page_client-reference-manifest.js +1 -1
  153. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page/server-reference-manifest.json +2 -2
  154. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page.js.nft.json +1 -1
  155. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page_client-reference-manifest.js +1 -1
  156. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page/server-reference-manifest.json +2 -2
  157. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page.js.nft.json +1 -1
  158. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page_client-reference-manifest.js +1 -1
  159. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page/server-reference-manifest.json +2 -2
  160. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page.js.nft.json +1 -1
  161. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page_client-reference-manifest.js +1 -1
  162. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/[root-of-the-server]__02a-3g8._.js +1 -1
  163. 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
  164. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0053k9k._.js +1 -1
  165. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0112h-k._.js +1 -1
  166. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__02c71kv._.js +1 -1
  167. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0o771t1._.js +1 -1
  168. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0puovz5._.js +1 -1
  169. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1hs0dcu._.js +1 -1
  170. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{[root-of-the-server]__0ozbaq0._.js → [root-of-the-server]__1mcitst._.js} +1 -1
  171. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1wc4-ip._.js +1 -1
  172. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_03x_w6q._.js +1 -1
  173. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0gvm3og._.js +1 -1
  174. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0lwxg63._.js +1 -1
  175. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0o54suy._.js +1 -1
  176. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1-6rzip._.js +1 -1
  177. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1fqw88f._.js +1 -1
  178. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1mrtj0f._.js +1 -1
  179. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1n0cnq-._.js +1 -1
  180. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/apps_vinaya-studio_web_src_app_studio_06it6s-._.js +1 -1
  181. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
  182. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/packages_ui_libraries_animate_components_index_ts_186fje1._.js +1 -1
  183. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/packages_ui_libraries_brutal_components_index_ts_0n_lcc4._.js +1 -1
  184. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/packages_ui_libraries_retro_components_index_ts_0wvpm_z._.js +1 -1
  185. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/packages_ui_libraries_retro_components_index_ts_1_cfi48._.js +1 -1
  186. package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
  187. package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
  188. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
  189. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
  190. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/00pi8vhg8w6gi.js +1 -0
  191. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{0r4i_ks9z02bw.js → 03q0q9n6-xah8.js} +1 -1
  192. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{2_k6j1o9q5nn4.js → 0jer_drkl1e4r.js} +4 -4
  193. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/0vc2u252q5q6o.js +1 -0
  194. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1k916ih3p4nsg.js → 13g16cu00g570.js} +1 -1
  195. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{2hmannd4bjhzy.js → 1sfxfunirkcr6.js} +1 -1
  196. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1t2kft7bxzuxn.js → 206ajg8f1a-_h.js} +1 -1
  197. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1hfhxjy577l5d.js → 20x-t0a0v129z.js} +1 -1
  198. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{25kbt5k-xb2a5.js → 279qq4twinc97.js} +1 -1
  199. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{112g08xpx3kro.js → 2ka8dg687auhj.js} +1 -1
  200. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/2ou_81ahqw31e.js +1 -0
  201. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{0up5x3jg-cwov.js → 2rn89uwtf2lwc.js} +1 -1
  202. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{3mqu_uhluy19i.css → 3qc67qhcvbg0o.css} +1 -1
  203. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1yqs2zgvwa4p1.js → 3zugx9esh2x51.js} +1 -1
  204. package/studio-standalone/apps/vinaya-studio/web/package.json +2 -2
  205. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/0_jnwbarv-cqk.js +0 -1
  206. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/1ybrihl5_aocy.js +0 -1
  207. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/3r5efmleneaco.js +0 -1
  208. /package/studio-standalone/_node_modules/@attalabs/vinaya/studio-standalone/_node_modules/@attalabs/{aeg-core → vinaya/studio-standalone/_node_modules/@attalabs/aeg-core}/bin/verify-coherence.ts +0 -0
  209. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{crNCHQ9uAgKOYC6DKkZma → 5xy_GA7G5KRuE3HDumIiw}/_buildManifest.js +0 -0
  210. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{crNCHQ9uAgKOYC6DKkZma → 5xy_GA7G5KRuE3HDumIiw}/_clientMiddlewareManifest.js +0 -0
  211. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{crNCHQ9uAgKOYC6DKkZma → 5xy_GA7G5KRuE3HDumIiw}/_ssgManifest.js +0 -0
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Leftover-worktree/branch classification (aeg-governance-hardening task 11,
3
+ * #324). Pure — no `fs`, no `git` shell-outs. The CLI shim
4
+ * (`bin/verify-dispatch.ts`) gathers the three facts (does the remote branch
5
+ * exist, does a local worktree exist, how many commits is the branch ahead
6
+ * of main) and passes them in.
7
+ *
8
+ * Exists to answer, deterministically, "is it safe to run Step 0's
9
+ * `git worktree add -b <branch> origin/main` for this task?" — Step 0 itself
10
+ * never creates a commit, so ANY commit already ahead of main on this branch
11
+ * is real prior work, never an artifact of re-running Step 0. Silently
12
+ * recreating a branch that already has commits ahead of main would discard
13
+ * that work — the failure this module exists to prevent.
14
+ */
15
+
16
+ export type LeftoverInput = {
17
+ /** A `task/<tranche>/<n>` branch already exists on the forge remote. */
18
+ branchExistsRemote: boolean
19
+ /** A local worktree for this task already exists on disk. */
20
+ worktreeExistsLocal: boolean
21
+ /** Commits on the task branch that are not yet on `origin/main`. */
22
+ commitsAheadOfMain: number
23
+ /**
24
+ * An OPEN pull request already exists for this task's branch, or `null`/
25
+ * omitted when unknown or none exists. Optional and additive — a caller
26
+ * that has no PR-number fact (e.g. `verify-dispatch.ts`'s existing call
27
+ * site, which only ever needed the three fields above) is unaffected.
28
+ */
29
+ openPrNumber?: number | null
30
+ }
31
+
32
+ export type LeftoverVerdict = 'clean' | 'resume' | 'stop'
33
+
34
+ export type LeftoverResult = { verdict: LeftoverVerdict; reason: string }
35
+
36
+ /**
37
+ * `clean` — no branch, no worktree, zero commits ahead: safe to run Step 0 fresh.
38
+ * `resume` — a branch or worktree already exists but carries zero commits
39
+ * ahead of main (e.g. a prior Step 0 ran but no work was
40
+ * committed yet): reuse the existing worktree rather than
41
+ * recreating it, but there is nothing to lose either way.
42
+ * `stop` — commits already ahead of main exist: real work is already on
43
+ * this branch. Never silently discard it — the Developer must
44
+ * resume in the existing worktree, not re-run Step 0.
45
+ */
46
+ export function classifyLeftover(input: LeftoverInput): LeftoverResult {
47
+ const { branchExistsRemote, worktreeExistsLocal, commitsAheadOfMain, openPrNumber } = input
48
+ const prSuffix = openPrNumber ? ` PR #${openPrNumber} is already open for this task.` : ''
49
+
50
+ if (commitsAheadOfMain > 0) {
51
+ return {
52
+ verdict: 'stop',
53
+ reason: `${commitsAheadOfMain} commit(s) already ahead of origin/main on this task branch — real work exists.${prSuffix} Do not re-run Step 0 (which would branch fresh from origin/main and orphan that work); resume in the existing worktree instead.`
54
+ }
55
+ }
56
+
57
+ if (branchExistsRemote || worktreeExistsLocal) {
58
+ return {
59
+ verdict: 'resume',
60
+ reason: `A ${branchExistsRemote ? 'remote branch' : ''}${branchExistsRemote && worktreeExistsLocal ? ' and a ' : ''}${worktreeExistsLocal ? 'local worktree' : ''} already exist for this task, with zero commits ahead of main — reuse it rather than recreating (\`git worktree add\` on an existing branch/dir will fail anyway).`
61
+ }
62
+ }
63
+
64
+ return {
65
+ verdict: 'clean',
66
+ reason: 'No branch, no worktree, no commits ahead of main — safe to run Step 0 fresh.'
67
+ }
68
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * local-anchor-coverage.ts — a generic, regex-parameterized "dangling
3
+ * anchor" checker.
4
+ *
5
+ * The pattern this generalizes: `reader-resolvable-prose.ts`'s Class 1
6
+ * (`checkUnresolvableReferences`) flags a reference a reader cannot resolve
7
+ * at all — the pattern (a forge number, a tranche slug) IS the finding, full
8
+ * stop. This module handles the one-step-further case Issue #730 names: a
9
+ * reference that is FINE as long as something else nearby resolves it — an
10
+ * ordinal task mention ("Task 2") is fine beside a citation shaped
11
+ * `(task N, #issue)`, and only a finding when that citation is missing from
12
+ * its scope. Same "pattern is an input, not a constant" discipline as
13
+ * `reader-resolvable-prose.ts`, carried one step further: the CITATION
14
+ * pattern and its co-occurrence SCOPE are inputs too. This file has no
15
+ * `/Task \d+/` literal, no citation-shape regex, no product name — that
16
+ * knowledge belongs to whoever configures an instance (see
17
+ * `scripts/vinaya-checks/task-anchor.ts` for attalabs' own).
18
+ *
19
+ * Zero I/O: mirrors `reader-resolvable-prose.ts`'s in-memory-content shape
20
+ * (`files: { path, content }[]`, adapter-read) rather than
21
+ * `vocabulary-citation.ts`'s `grepFn`/`matchFn` injection. Reason: co-occurrence
22
+ * scoping (§ below) needs the whole file's line structure to group lines into
23
+ * blocks — a per-line grep-hit model would force the adapter to pre-chunk
24
+ * paragraphs itself before calling in, which is exactly the structural work
25
+ * this primitive exists to own.
26
+ *
27
+ * `pattern`/`mustCoOccurWith` are real `RegExp` objects, not POSIX-ERE
28
+ * strings — unlike `vocabulary-citation.ts`, nothing here shells out to
29
+ * `grep -E`, so the `\d`/`\w`/`\s`-avoidance caution that applies there (GNU
30
+ * grep reads those as literal characters) does not apply to this file; native
31
+ * `RegExp` supports them. A consumer that itself uses `grep` for file
32
+ * discovery (as `task-anchor.ts` does not — it reads files directly) would
33
+ * still need that caution on its own end, independent of this primitive.
34
+ */
35
+
36
+ export type AnchorSourceFile = { path: string; content: string }
37
+
38
+ /**
39
+ * `'line'` — the citation must appear on the same line as the anchor.
40
+ * `'block'` — the citation must appear anywhere in the anchor's enclosing
41
+ * block (see `splitIntoBlocks` below): a markdown paragraph, or a list item
42
+ * together with its nested sub-items and wrapped continuation lines. A
43
+ * blank line, or a new top-level (non-indented) bullet/heading, starts a new
44
+ * block — so two adjacent list items are two different blocks, never one.
45
+ */
46
+ export type AnchorCoverageScope = 'line' | 'block'
47
+
48
+ export type LocalAnchorCoverageOptions = {
49
+ pattern: RegExp
50
+ mustCoOccurWith: RegExp
51
+ scope: AnchorCoverageScope
52
+ /** Non-vacuity self-test (optional): `pattern` must match this string. */
53
+ sample?: string
54
+ /** Non-vacuity self-test (optional): `mustCoOccurWith` must match this string. */
55
+ coOccurSample?: string
56
+ }
57
+
58
+ export type AnchorCoverageFinding = {
59
+ file: string
60
+ line: number
61
+ match: string
62
+ }
63
+
64
+ export type LocalAnchorCoverageResult = {
65
+ findings: AnchorCoverageFinding[]
66
+ /**
67
+ * Names of whichever supplied sample(s) failed to self-match —
68
+ * `'pattern'` and/or `'mustCoOccurWith'`. A config bug reported loud
69
+ * instead of silently returning clean, same discipline as
70
+ * `vocabulary-citation.ts`'s `vacuousPatterns`.
71
+ */
72
+ vacuous: ('pattern' | 'mustCoOccurWith')[]
73
+ }
74
+
75
+ /** Strips `g`/`y` flags so a caller-supplied global/sticky regex can't leak `lastIndex` state into a one-shot `.test()`. */
76
+ function testFresh(re: RegExp, text: string): boolean {
77
+ const flags = re.flags.replace(/[gy]/g, '')
78
+ return new RegExp(re.source, flags).test(text)
79
+ }
80
+
81
+ /** Ensures the regex used to iterate every match carries the `g` flag, without mutating the caller's object. */
82
+ function toGlobalIterator(re: RegExp): RegExp {
83
+ const flags = re.flags.includes('g') ? re.flags : `${re.flags}g`
84
+ return new RegExp(re.source, flags)
85
+ }
86
+
87
+ /** Total match count of `re` in `text` — the co-occurrence "supply" a scope offers. */
88
+ function countMatches(re: RegExp, text: string): number {
89
+ const iter = toGlobalIterator(re)
90
+ let count = 0
91
+ iter.lastIndex = 0
92
+ let m = iter.exec(text)
93
+ while (m !== null) {
94
+ count++
95
+ if (m.index === iter.lastIndex) iter.lastIndex++
96
+ m = iter.exec(text)
97
+ }
98
+ return count
99
+ }
100
+
101
+ function computeLineStarts(content: string): number[] {
102
+ const starts = [0]
103
+ for (let i = 0; i < content.length; i++) {
104
+ if (content.charCodeAt(i) === 10) starts.push(i + 1)
105
+ }
106
+ return starts
107
+ }
108
+
109
+ /** 1-based line number containing character offset `index`, via binary search over `lineStarts`. */
110
+ function lineNumberAt(lineStarts: number[], index: number): number {
111
+ let lo = 0
112
+ let hi = lineStarts.length - 1
113
+ while (lo < hi) {
114
+ const mid = (lo + hi + 1) >> 1
115
+ if ((lineStarts[mid] as number) <= index) lo = mid
116
+ else hi = mid - 1
117
+ }
118
+ return lo + 1
119
+ }
120
+
121
+ const BLANK_LINE = /^\s*$/
122
+ /** A top-level (non-indented) list marker — `-`/`*`/`+` or `N.` — starts a new block; an indented nested item does not. */
123
+ const TOP_LEVEL_BULLET = /^(?:[-*+]|[0-9]+\.)\s/
124
+ const HEADING_LINE = /^#{1,6}\s/
125
+
126
+ type Block = { startLine: number; endLine: number; text: string }
127
+
128
+ /**
129
+ * Groups a file's lines into scope blocks: a blank line ends the current
130
+ * block; a new top-level bullet or heading also ends it (and starts its
131
+ * own); every other line — including an indented/nested bullet, or a
132
+ * wrapped continuation line — extends the current block. This is what makes
133
+ * "same paragraph/bullet" well-defined rather than a guess: two sibling
134
+ * list items on adjacent lines with no blank line between them are still
135
+ * two separate blocks (each starts its own on hitting the next top-level
136
+ * marker), while a nested sub-bullet stays part of its parent's block (an
137
+ * elaboration of the same anchor, not a separate one).
138
+ */
139
+ function splitIntoBlocks(content: string): Block[] {
140
+ const lines = content.split('\n')
141
+ const blocks: Block[] = []
142
+ let current: string[] = []
143
+ let currentStartIdx = 0
144
+
145
+ const flush = (): void => {
146
+ if (current.length === 0) return
147
+ blocks.push({
148
+ startLine: currentStartIdx + 1,
149
+ endLine: currentStartIdx + current.length,
150
+ text: current.join('\n')
151
+ })
152
+ current = []
153
+ }
154
+
155
+ for (let i = 0; i < lines.length; i++) {
156
+ const line = lines[i] as string
157
+ if (BLANK_LINE.test(line)) {
158
+ flush()
159
+ continue
160
+ }
161
+ const isHeading = HEADING_LINE.test(line)
162
+ const startsNewBlock = TOP_LEVEL_BULLET.test(line) || isHeading
163
+ if (startsNewBlock && current.length > 0) flush()
164
+ if (current.length === 0) currentStartIdx = i
165
+ current.push(line)
166
+ // A heading is always a single-line block — it never absorbs the prose
167
+ // that follows it (that prose's own citation does not clear an anchor
168
+ // inside the heading's own text).
169
+ if (isHeading) flush()
170
+ }
171
+ flush()
172
+ return blocks
173
+ }
174
+
175
+ /**
176
+ * Flags every match of `pattern` that has no `mustCoOccurWith` match within
177
+ * its scope. Pure — takes file contents, returns findings; performs no I/O
178
+ * and knows nothing about what `pattern`/`mustCoOccurWith` mean.
179
+ *
180
+ * **Co-occurrence is counted, not merely detected.** A scope with two
181
+ * `pattern` matches and only one `mustCoOccurWith` match must flag one of
182
+ * them — checking "does a citation exist anywhere in this scope" lets a
183
+ * SINGLE real citation silently clear every other, unrelated anchor sharing
184
+ * the same block (found live in review: `"Task 2 did X, and Task 5 (task 5,
185
+ * #999) wrapped it up."` reported zero findings under a presence-only test,
186
+ * because "Task 5"'s citation cleared the unrelated "Task 2" anchor too).
187
+ * Each scope's citation matches are a fixed SUPPLY; each pattern match in
188
+ * that scope, in document order, consumes one — the first `N` matches (`N` =
189
+ * the scope's citation count) are covered, everything past that is a
190
+ * finding. This does not verify WHICH citation belongs to WHICH anchor
191
+ * (this primitive has no product-specific notion of "the same number") —
192
+ * only that supply meets demand, which is exactly what a purely structural,
193
+ * product-agnostic primitive can promise.
194
+ */
195
+ export function checkLocalAnchorCoverage(
196
+ files: readonly AnchorSourceFile[],
197
+ options: LocalAnchorCoverageOptions
198
+ ): LocalAnchorCoverageResult {
199
+ const vacuous: ('pattern' | 'mustCoOccurWith')[] = []
200
+ if (options.sample !== undefined && !testFresh(options.pattern, options.sample)) vacuous.push('pattern')
201
+ if (options.coOccurSample !== undefined && !testFresh(options.mustCoOccurWith, options.coOccurSample)) {
202
+ vacuous.push('mustCoOccurWith')
203
+ }
204
+
205
+ const findings: AnchorCoverageFinding[] = []
206
+ const iterPattern = toGlobalIterator(options.pattern)
207
+
208
+ for (const file of files) {
209
+ const lineStarts = computeLineStarts(file.content)
210
+ const lines = file.content.split('\n')
211
+ const blocks = options.scope === 'block' ? splitIntoBlocks(file.content) : null
212
+
213
+ // Scope key: the block object itself (`'block'` scope) or the line
214
+ // number (`'line'` scope) — matches sharing a key share one citation
215
+ // supply, consumed in document order.
216
+ const remainingSupply = new Map<Block | number, number>()
217
+ const supplyFor = (key: Block | number, scopeText: string): number => {
218
+ const cached = remainingSupply.get(key)
219
+ if (cached !== undefined) return cached
220
+ const supply = countMatches(options.mustCoOccurWith, scopeText)
221
+ remainingSupply.set(key, supply)
222
+ return supply
223
+ }
224
+
225
+ iterPattern.lastIndex = 0
226
+ let match: RegExpExecArray | null = iterPattern.exec(file.content)
227
+ while (match !== null) {
228
+ const line = lineNumberAt(lineStarts, match.index)
229
+ const block = blocks ? (blocks.find((b) => line >= b.startLine && line <= b.endLine) ?? null) : null
230
+ const scopeText = block ? block.text : (lines[line - 1] ?? '')
231
+ const key: Block | number = block ?? line
232
+ const supply = supplyFor(key, scopeText)
233
+ if (supply > 0) {
234
+ remainingSupply.set(key, supply - 1)
235
+ } else {
236
+ findings.push({ file: file.path, line, match: match[0] })
237
+ }
238
+ // Guard against a zero-width match looping forever.
239
+ if (match.index === iterPattern.lastIndex) iterPattern.lastIndex++
240
+ match = iterPattern.exec(file.content)
241
+ }
242
+ }
243
+
244
+ return { findings, vacuous }
245
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Ring-0 main-branch refusal (task 9, Issue #58). Mechanizes the
3
+ * worktree-plus-PR rule at ring 0 for adopters: refuses a commit or push
4
+ * whose current branch IS the repo's default branch — today enforced only
5
+ * by attalabs' hand-written husky shell (which `init` never generates) and
6
+ * detected post-merge by `audit --only=direct-push`. Pure — no `git`/`fs`
7
+ * I/O; the caller derives both branch facts and passes them in, same
8
+ * discipline as `no-disk-state.ts`.
9
+ *
10
+ * The discriminator that makes this safe: refusal keys on the SYMBOLIC
11
+ * current branch equaling the default branch. A detached HEAD (every CI
12
+ * checkout, or a deliberate `git checkout --detach`) has no symbolic branch
13
+ * name at all — `currentSymbolicBranch` is `null` — and always passes,
14
+ * never refused. Any branch other than the default passes too. Only a
15
+ * real, named local branch that literal-matches the default branch name
16
+ * refuses. A plain `vinaya check --all` run while parked on the default
17
+ * branch locally WILL refuse — that is the intended behavior (work belongs
18
+ * in worktrees), not a bug.
19
+ *
20
+ * `defaultBranch: null` means the caller could not derive the default
21
+ * branch (no resolvable `origin/HEAD`, no forge answer) — this predicate
22
+ * fails OPEN with a `warning` finding naming why, never a false refusal: a
23
+ * check whose job is refusing risky actions must not itself risk refusing
24
+ * a legitimate one it cannot actually evaluate.
25
+ */
26
+
27
+ export type MainBranchRefusalReason = 'on-default-branch' | 'default-branch-undetermined'
28
+
29
+ export type MainBranchRefusalFinding = {
30
+ reason: MainBranchRefusalReason
31
+ severity: 'error' | 'warning'
32
+ currentBranch: string
33
+ defaultBranch: string | null
34
+ }
35
+
36
+ export function checkMainBranchRefusal(facts: {
37
+ currentSymbolicBranch: string | null
38
+ defaultBranch: string | null
39
+ }): MainBranchRefusalFinding | null {
40
+ const { currentSymbolicBranch, defaultBranch } = facts
41
+
42
+ // Detached HEAD — no symbolic branch to compare. Never refuse.
43
+ if (currentSymbolicBranch === null) return null
44
+
45
+ if (defaultBranch === null) {
46
+ return {
47
+ reason: 'default-branch-undetermined',
48
+ severity: 'warning',
49
+ currentBranch: currentSymbolicBranch,
50
+ defaultBranch: null
51
+ }
52
+ }
53
+
54
+ if (currentSymbolicBranch === defaultBranch) {
55
+ return {
56
+ reason: 'on-default-branch',
57
+ severity: 'error',
58
+ currentBranch: currentSymbolicBranch,
59
+ defaultBranch
60
+ }
61
+ }
62
+
63
+ return null
64
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * doc-owners manifest validity checks (M1/M2/M3) plus the `# no-doc:` allow-list
3
+ * parser. Pure — file-existence is injected by the caller.
4
+ */
5
+
6
+ import { DOC_OWNERS_PATH, globToRegex, isUrlPointer, parseDocOwners, pointerToPath } from './doc-owners'
7
+
8
+ export type NoDocRule = { glob: string; reason: string }
9
+
10
+ /**
11
+ * Parse `# no-doc: <glob> — <reason>` allow-list lines from doc-owners content.
12
+ * These lines exempt a surface from the completeness scoreboard.
13
+ */
14
+ export function parseNoDocRules(content: string): NoDocRule[] {
15
+ const rules: NoDocRule[] = []
16
+ for (const line of content.split('\n')) {
17
+ const m = line.match(/^#\s+no-doc:\s+(\S+)(?:\s+[—–]\s+|\s+-\s+)(.+)$/)
18
+ if (m) rules.push({ glob: (m[1] ?? '').trim(), reason: (m[2] ?? '').trim() })
19
+ }
20
+ return rules
21
+ }
22
+
23
+ /**
24
+ * Validate the doc-owners manifest file (all bindings, not just fired ones).
25
+ * M1 (hard-fail): in-repo pointer does not exist on disk.
26
+ * M2 (advisory): glob fails to produce a valid regex (extremely unlikely with our simple grammar).
27
+ * M3 (hard-fail): same glob appears more than once.
28
+ */
29
+ export function checkManifestValidity(
30
+ content: string | null,
31
+ fileExists: (p: string) => boolean
32
+ ): { m1Errors: string[]; m2Notes: string[]; m3Errors: string[]; noDocRules: NoDocRule[] } {
33
+ if (content === null) return { m1Errors: [], m2Notes: [], m3Errors: [], noDocRules: [] }
34
+
35
+ const { bindings, errors: parseErrors } = parseDocOwners(content)
36
+ const m1Errors: string[] = [...parseErrors]
37
+ const m2Notes: string[] = []
38
+ const m3Errors: string[] = []
39
+ const noDocRules = parseNoDocRules(content)
40
+
41
+ // M2: glob syntax (advisory — our grammar is permissive so failures are very rare)
42
+ for (const b of bindings) {
43
+ if (!b.glob) {
44
+ m2Notes.push(`M2 manifest-bad-glob: ${DOC_OWNERS_PATH}:${b.lineNum} — empty glob.`)
45
+ continue
46
+ }
47
+ try {
48
+ globToRegex(b.glob)
49
+ } catch (e) {
50
+ m2Notes.push(`M2 manifest-bad-glob: ${DOC_OWNERS_PATH}:${b.lineNum} — glob "${b.glob}" failed: ${e}`)
51
+ }
52
+ }
53
+
54
+ // M3: duplicate globs
55
+ const globLines = new Map<string, number[]>()
56
+ for (const b of bindings) {
57
+ const list = globLines.get(b.glob) ?? []
58
+ list.push(b.lineNum)
59
+ globLines.set(b.glob, list)
60
+ }
61
+ for (const [glob, lines] of globLines) {
62
+ if (lines.length > 1) {
63
+ m3Errors.push(
64
+ `M3 manifest-duplicate-glob: ${DOC_OWNERS_PATH} — glob "${glob}" appears ${lines.length} times (lines ${lines.join(', ')}).`
65
+ )
66
+ }
67
+ }
68
+
69
+ // M1: every in-repo pointer exists on disk (checks ALL bindings, not just fired ones)
70
+ for (const b of bindings) {
71
+ if (isUrlPointer(b.pointer)) continue
72
+ const pointerPath = pointerToPath(b.pointer)
73
+ if (!fileExists(pointerPath)) {
74
+ m1Errors.push(
75
+ `M1 manifest-dangling: ${DOC_OWNERS_PATH}:${b.lineNum} — pointer ${b.pointer} does not exist on disk.`
76
+ )
77
+ }
78
+ }
79
+
80
+ return { m1Errors, m2Notes, m3Errors, noDocRules }
81
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Pure, no-I/O extraction of GitHub-flavored-markdown tables from raw text.
3
+ * The single, aeg-core-owned parser every doctrine consumer reads tables
4
+ * through — the Vinaya `/aeg` renderer, `registry-parse.ts`, and the
5
+ * DiagramModel derivation all sit on this one implementation, so no consumer
6
+ * can silently drift from the real file the moment a row's wording changes
7
+ * (one parser, N consumers).
8
+ */
9
+
10
+ export type TableRow = {
11
+ /** Raw cell text per column, in header order. */
12
+ cells: string[]
13
+ /** 1-indexed line number of this row in the source file. */
14
+ line: number
15
+ }
16
+
17
+ export type ParsedTable = {
18
+ headers: string[]
19
+ rows: TableRow[]
20
+ }
21
+
22
+ function splitRow(line: string): string[] {
23
+ const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '')
24
+ return trimmed.split('|').map((cell) => cell.trim())
25
+ }
26
+
27
+ const TABLE_ROW_PATTERN = /^\s*\|.*\|\s*$/
28
+ const SEPARATOR_ROW_PATTERN = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
29
+
30
+ /**
31
+ * Finds the FIRST markdown table appearing at or after `fromLine` (1-indexed,
32
+ * inclusive) in `lines`. Returns null if none is found before the next `## `
33
+ * heading (or end of file) when `stopAtHeading` is true.
34
+ */
35
+ export function findTable(lines: string[], fromLine: number, stopAtHeading = true): ParsedTable | null {
36
+ let i = fromLine - 1
37
+ while (i < lines.length) {
38
+ const line = lines[i] ?? ''
39
+ if (stopAtHeading && i > fromLine - 1 && /^##\s/.test(line)) return null
40
+ if (TABLE_ROW_PATTERN.test(line)) {
41
+ const headerLine = line
42
+ const sepLine = lines[i + 1] ?? ''
43
+ if (!SEPARATOR_ROW_PATTERN.test(sepLine)) {
44
+ i++
45
+ continue
46
+ }
47
+ const headers = splitRow(headerLine)
48
+ const rows: TableRow[] = []
49
+ let j = i + 2
50
+ while (j < lines.length && TABLE_ROW_PATTERN.test(lines[j] ?? '')) {
51
+ rows.push({ cells: splitRow(lines[j] ?? ''), line: j + 1 })
52
+ j++
53
+ }
54
+ return { headers, rows }
55
+ }
56
+ i++
57
+ }
58
+ return null
59
+ }
60
+
61
+ /** Finds the 1-indexed line of the first heading matching `pattern`, or null. */
62
+ export function findHeadingLine(lines: string[], pattern: RegExp): number | null {
63
+ for (let i = 0; i < lines.length; i++) {
64
+ if (pattern.test(lines[i] ?? '')) return i + 1
65
+ }
66
+ return null
67
+ }
68
+
69
+ /** Convenience: row cells as a keyed object using `headers` as keys. */
70
+ export function rowToRecord(headers: string[], row: TableRow): Record<string, string> {
71
+ const record: Record<string, string> = {}
72
+ headers.forEach((h, idx) => {
73
+ record[h] = row.cells[idx] ?? ''
74
+ })
75
+ return record
76
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Hardened I/O for `MeteringCapabilityDeps` (CWE-59, `#313`). Every real
3
+ * caller of `resolveMeteringCapability` fills `exists`/`readFile` with the
4
+ * factory here rather than hand-rolling `existsSync`/`readFileSync`, so the
5
+ * guard lives once at the shared seam instead of being re-derived (or
6
+ * missed) at each call site.
7
+ *
8
+ * `claude-code-transcript.ts` stays pure (no `fs`, no `process.env`) — this
9
+ * sibling module is `aeg-core`'s one deliberate exception, exported
10
+ * alongside it rather than living in `bin/`: `apps/cli` only ever sees this
11
+ * package's `src/index.ts` surface, and `bin/` is not part of it (see that
12
+ * file's own header on the `bin/` vs `src/` split).
13
+ *
14
+ * The pointer path this guards (`$TMPDIR/claude-transcript-<key>.txt`) is
15
+ * fully predictable and, on the usual CI/Linux default with `TMPDIR` unset,
16
+ * sits in a directory other local users can typically write to. The write
17
+ * side of this exact path was hardened already
18
+ * (`apps/cli/src/lib/claude-stop-hook-emitter.ts`, CWE-59: `wx` +
19
+ * `renameSync`, refusing to write through a symlink). This is the read
20
+ * side's turn, against the same threat model.
21
+ *
22
+ * Mechanism: open with `O_NOFOLLOW | O_NONBLOCK`, then `fstat` the
23
+ * resulting descriptor — never a separate `lstat`-then-`open` pair, which
24
+ * still races between the two calls (a symlink or FIFO planted in the gap
25
+ * defeats the earlier check). `O_NOFOLLOW` makes the kernel refuse an open
26
+ * through a symlink outright (`ELOOP`) rather than trusting a stat taken a
27
+ * moment earlier. `O_NONBLOCK` is what actually closes the FIFO hang:
28
+ * opening a FIFO for reading in the default blocking mode waits for a
29
+ * writer that may never come — the reviewer's finding, 20+ seconds, killed
30
+ * by their own alarm rather than by the process. With `O_NONBLOCK` the open
31
+ * returns immediately regardless of whether a writer exists, and `fstat`'s
32
+ * `isFile()` then refuses the descriptor before any read is attempted, so
33
+ * the read call this guards never has a FIFO to block on in the first
34
+ * place. Neither flag changes behavior for a genuine regular file.
35
+ * Confirmed against a live planted symlink and a live FIFO, on both Node
36
+ * and Bun, before writing this comment.
37
+ */
38
+
39
+ import { closeSync, constants as fsConstants, fstatSync, openSync, readFileSync } from 'node:fs'
40
+ import type { MeteringCapabilityDeps } from './claude-code-transcript'
41
+
42
+ const GUARD_OPEN_FLAGS = fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW
43
+
44
+ /**
45
+ * The one property both `exists` and `readFile` require of an already-open
46
+ * descriptor before its content is trusted: a regular file (never a FIFO,
47
+ * device, directory, or — moot once `O_NOFOLLOW` has run — a symlink),
48
+ * owned by this process's own user on a platform that reports one at all.
49
+ *
50
+ * Split out from the `openSync`/`fstatSync` pair so the foreign-owner
51
+ * refusal — not constructible in CI without a second real local user — stays
52
+ * unit-testable by handing this function a faked stat directly, rather than
53
+ * needing an actual cross-user fixture.
54
+ */
55
+ export function isTrustedMeteringStat(stat: { isFile(): boolean; uid: number }, ownUid: number | undefined): boolean {
56
+ if (!stat.isFile()) return false
57
+ if (typeof ownUid === 'number' && stat.uid !== ownUid) return false
58
+ return true
59
+ }
60
+
61
+ function ownUid(): number | undefined {
62
+ return typeof process.getuid === 'function' ? process.getuid() : undefined
63
+ }
64
+
65
+ function guardedOpen(path: string): number | undefined {
66
+ try {
67
+ return openSync(path, GUARD_OPEN_FLAGS)
68
+ } catch {
69
+ // Covers a missing path (`ENOENT`), a symlink (`ELOOP`, `O_NOFOLLOW`),
70
+ // and anything else the platform refuses to open under these flags —
71
+ // all degrade to "not usable", never a throw. An attacker must not be
72
+ // able to turn a probe into a crash either.
73
+ return undefined
74
+ }
75
+ }
76
+
77
+ function hardenedExists(path: string): boolean {
78
+ const fd = guardedOpen(path)
79
+ if (fd === undefined) return false
80
+ try {
81
+ return isTrustedMeteringStat(fstatSync(fd), ownUid())
82
+ } finally {
83
+ closeSync(fd)
84
+ }
85
+ }
86
+
87
+ function hardenedReadFile(path: string): string {
88
+ const fd = guardedOpen(path)
89
+ if (fd === undefined) {
90
+ throw new Error(`refusing to read ${path}: not openable as a plain, non-symlinked file`)
91
+ }
92
+ try {
93
+ if (!isTrustedMeteringStat(fstatSync(fd), ownUid())) {
94
+ throw new Error(`refusing to read ${path}: not a regular file owned by this user`)
95
+ }
96
+ // Reads the already-open, already-checked descriptor directly — never
97
+ // reopens by path, which would reintroduce the exact TOCTOU gap this
98
+ // module exists to close.
99
+ return readFileSync(fd, 'utf8')
100
+ } finally {
101
+ closeSync(fd)
102
+ }
103
+ }
104
+
105
+ /**
106
+ * The hardened `MeteringCapabilityDeps` every real caller of
107
+ * `resolveMeteringCapability` should build from, in place of hand-rolled
108
+ * `existsSync`/`readFileSync`. `env`/`cwd` carry no I/O hazard of their own;
109
+ * every current call site reads them the same way, so this reads them
110
+ * directly rather than taking them as parameters no caller would vary.
111
+ */
112
+ export function hardenedMeteringDeps(): MeteringCapabilityDeps {
113
+ return {
114
+ env: process.env,
115
+ cwd: process.cwd(),
116
+ exists: hardenedExists,
117
+ readFile: hardenedReadFile
118
+ }
119
+ }