@attalabs/vinaya 0.22.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 (188) hide show
  1. package/README.md +13 -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 +552 -139
  23. package/dist/checks/bin/check-branch-topology.js +603 -144
  24. package/dist/checks/bin/check-brief-shape.js +593 -142
  25. package/dist/checks/bin/check-changeset-coverage.js +935 -161
  26. package/dist/checks/bin/check-closes-n.js +603 -144
  27. package/dist/checks/bin/check-coherence.js +614 -147
  28. package/dist/checks/bin/check-dead-branch-push.js +552 -139
  29. package/dist/checks/bin/check-dispatch-readiness.js +612 -145
  30. package/dist/checks/bin/check-doc-coverage-push.js +689 -218
  31. package/dist/checks/bin/check-doc-coverage.js +689 -218
  32. package/dist/checks/bin/check-doctrine-no-procedures.js +4794 -0
  33. package/dist/checks/bin/check-doctrine-portability.js +684 -146
  34. package/dist/checks/bin/check-evidence-fresh.js +994 -174
  35. package/dist/checks/bin/check-exec-bits.js +4931 -0
  36. package/dist/checks/bin/check-first-push-dispatch.js +603 -144
  37. package/dist/checks/bin/check-issue-assignment.js +603 -144
  38. package/dist/checks/bin/check-main-branch-refusal.js +552 -139
  39. package/dist/checks/bin/check-no-disk-state.js +552 -139
  40. package/dist/checks/bin/check-pr-report-density.js +4461 -0
  41. package/dist/checks/bin/check-quoted-command.js +621 -155
  42. package/dist/checks/bin/check-reader-resolvable-prose.js +617 -154
  43. package/dist/checks/bin/check-registry-gates.js +595 -144
  44. package/dist/checks/bin/check-retired-vocabulary.js +617 -154
  45. package/dist/checks/bin/check-review-gate.js +670 -141
  46. package/dist/checks/bin/check-single-plan-pr.js +552 -139
  47. package/dist/checks/bin/check-test-plan.js +553 -140
  48. package/dist/checks/bin/check-token-collection-wired.js +552 -139
  49. package/dist/checks/bin/check-token-report.js +552 -139
  50. package/dist/checks/bin/check-workspace-escape.js +985 -145
  51. package/dist/index.js +8500 -5518
  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]__0o771t1._.js +1 -1
  167. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0puovz5._.js +1 -1
  168. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1hs0dcu._.js +1 -1
  169. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{[root-of-the-server]__069-_41._.js → [root-of-the-server]__1mcitst._.js} +1 -1
  170. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1wc4-ip._.js +1 -1
  171. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_03x_w6q._.js +1 -1
  172. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0gvm3og._.js +1 -1
  173. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0lwxg63._.js +1 -1
  174. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1fqw88f._.js +1 -1
  175. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1n0cnq-._.js +1 -1
  176. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
  177. package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
  178. package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
  179. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
  180. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
  181. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{37hnluhzj9bei.js → 0jer_drkl1e4r.js} +4 -4
  182. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{267o-tsrnuwnv.js → 20x-t0a0v129z.js} +1 -1
  183. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1hufpuody6vjv.js → 2ou_81ahqw31e.js} +1 -1
  184. package/studio-standalone/apps/vinaya-studio/web/package.json +2 -2
  185. /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
  186. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_buildManifest.js +0 -0
  187. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_clientMiddlewareManifest.js +0 -0
  188. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_ssgManifest.js +0 -0
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Direct-main-push detection (aeg-governance-hardening task 24, #364,
3
+ * Part 3 — item 1). Pure — no `fs`, no `gh`/`git` shell-outs. The CLI shim
4
+ * (`bin/check-direct-main-push.ts`) makes the one forge call (the
5
+ * commits→pulls association API: `GET /repos/{owner}/{repo}/commits/{sha}/pulls`,
6
+ * which GitHub documents as returning "the merged Pull Request that
7
+ * introduced the commit to the repository" for a commit on the default
8
+ * branch) and passes the result in here.
9
+ *
10
+ * Detection only, never prevention here: prevention lives at
11
+ * `.husky/pre-push` (ring 0) and the repository ruleset (ring 1). This
12
+ * predicate only decides whether the pushed commit is
13
+ * legitimate (associated with a merged PR) — the CLI shim opens an
14
+ * incident Issue and fails the run loudly on a violation; it never reverts
15
+ * or mutates anything (Principal decision, brief §11).
16
+ */
17
+
18
+ export type DirectMainPushFact = {
19
+ sha: string
20
+ /** PR numbers the commits→pulls association API reports as MERGED for this commit. Empty when the commit has no associated merged PR — i.e. it was pushed directly. */
21
+ associatedMergedPrNumbers: number[]
22
+ }
23
+
24
+ export type DirectMainPushResult =
25
+ | { verdict: 'legitimate'; mergedPrNumber: number }
26
+ | { verdict: 'direct-push'; sha: string }
27
+
28
+ /**
29
+ * `legitimate` — at least one merged PR is associated with this commit (the
30
+ * normal case: a squash-merge or merge commit that landed via a PR).
31
+ * `direct-push` — no associated merged PR at all. This is the violation:
32
+ * either a genuine direct push, or (equivalently suspicious) a commit the
33
+ * association API cannot explain.
34
+ */
35
+ export function checkDirectMainPush(fact: DirectMainPushFact): DirectMainPushResult {
36
+ const [first] = fact.associatedMergedPrNumbers
37
+ if (first !== undefined) return { verdict: 'legitimate', mergedPrNumber: first }
38
+ return { verdict: 'direct-push', sha: fact.sha }
39
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Dispatch-readiness composition (aeg-governance-hardening task 11, #324).
3
+ * Pure — no `fs`, no `fetch`. Composes the forge/rationale/provenance/
4
+ * archival facts the CLI shim (`bin/verify-dispatch.ts`) gathers (reusing
5
+ * `parseTranche`, `hasProvenance`, `taskRefFromBranch`,
6
+ * `checkIssueRationale`, `fetchProvenance` — never re-implementing any of
7
+ * them) into one `{ ready, blockers }` verdict, one blocker string per
8
+ * failing predicate.
9
+ *
10
+ * Every predicate here mechanizes a prose precondition that `roles/
11
+ * developer.md`'s entry gate and `contracts/brief-developer.md` currently
12
+ * state as something the Developer must re-derive by hand — the exact gap
13
+ * this task exists to close: four Developer agents independently re-derived
14
+ * (and stopped on) the same archival fact from scratch, at real token cost,
15
+ * hours after it first became true (aeg-governance-hardening's 2026-07-02/03
16
+ * live-fire wave).
17
+ *
18
+ * Message style mirrors `coherence-checks.ts`'s A1/A2/T2/etc. family: one
19
+ * line per failure, naming the exact task/Issue/PR involved.
20
+ */
21
+
22
+ import type { Task } from './types'
23
+ import { isPrincipal, PRINCIPAL_ALLOWLIST } from './waiver-label'
24
+
25
+ export type DispatchIssueFact = { number: number; state: 'open' | 'closed' } | null
26
+
27
+ export type DispatchEdgeFact = {
28
+ /** The edge's target task id, as written in the topology's depends-on/conflicts-with cell. */
29
+ id: string
30
+ /** The target task's Issue number, if known — for message readability only. */
31
+ issue: number | null
32
+ }
33
+
34
+ export type DispatchDependsOnFact = DispatchEdgeFact & {
35
+ merged: boolean
36
+ /**
37
+ * `false` when the resolver could not find any tranche/task/Issue matching
38
+ * this edge at all — a fact distinct from `merged: false`, which means the
39
+ * edge resolved to a real target that just hasn't merged yet. Conflating
40
+ * the two produced a false "not merged yet" claim for #193's
41
+ * `vinaya-milestone-model-v1 2` edge, which had genuinely already merged
42
+ * (#196) — the message this distinction exists to correct. Absent or
43
+ * `true` for every edge the resolver actually matched to a target,
44
+ * including one whose target lookup itself then failed (an outage), which
45
+ * stays under the existing conservative `merged: false` default rather
46
+ * than this one.
47
+ */
48
+ resolved?: boolean
49
+ /**
50
+ * Hand-close recognition facts (task `vinaya-engine-v1` 21, #99) — a
51
+ * second, narrower path alongside `merged` for a dependency Issue closed
52
+ * directly by a recognized Principal, with a stated `COMPLETED` reason,
53
+ * rather than via a merged PR. All three are `null`/absent when the edge
54
+ * is unresolvable or the target issue is open/has no close event — in
55
+ * which case this path never fires and only `merged` matters, same as
56
+ * before this task.
57
+ */
58
+ issueState?: 'open' | 'closed' | null
59
+ stateReason?: 'completed' | 'not_planned' | null
60
+ closedByActor?: string | null
61
+ }
62
+
63
+ /**
64
+ * True when a dependency Issue was closed directly by a recognized Principal
65
+ * with a stated `COMPLETED` reason — the second, narrower "done" path this
66
+ * task adds alongside `merged`. Deliberately conjunctive and centralized
67
+ * here (not duplicated per caller): the trap this task exists to avoid is
68
+ * "any closed Issue with a comment counts," so every condition below is a
69
+ * real forge fact, not a prose claim —
70
+ * - `issueState === 'closed'`: the Issue is actually closed.
71
+ * - `stateReason === 'completed'`: GitHub's own close reason says resolved,
72
+ * not `not_planned` (abandoned — the opposite of resolved).
73
+ * - `closedByActor` is the GitHub login that performed the CLOSED_EVENT
74
+ * (not a claim in a comment body) and is a member of the recognized
75
+ * Principal allowlist.
76
+ */
77
+ function isHandClosedByRecognizedPrincipal(
78
+ dep: Pick<DispatchDependsOnFact, 'issueState' | 'stateReason' | 'closedByActor'>,
79
+ principalAllowlist: string[]
80
+ ): boolean {
81
+ return (
82
+ dep.issueState === 'closed' &&
83
+ dep.stateReason === 'completed' &&
84
+ isPrincipal(dep.closedByActor ?? null, principalAllowlist)
85
+ )
86
+ }
87
+
88
+ export type DispatchConflictsWithFact = DispatchEdgeFact & { openOrInFlight: boolean }
89
+
90
+ /**
91
+ * Removed the predicate that consumed this fact
92
+ * (`checkDispatchReadiness` no longer blocks on it) — automated the
93
+ * signal (per-task provenance posting) this predicate existed to protect,
94
+ * making the row-adjacency gate itself the stale part, not the automation.
95
+ * Kept, dormant, rather than deleted: several callers (`bin/verify-dispatch.ts`,
96
+ * both Studio/Vinaya `map-dispatch-input.ts` mappers) still assemble this
97
+ * fact as harmless dead plumbing, and `DispatchGateInput.priorTask` below
98
+ * remains the exact shape needed to test the predicate's absence.
99
+ */
100
+ export type DispatchPriorTaskFact = {
101
+ id: string
102
+ issue: number | null
103
+ issueClosed: boolean
104
+ prMerged: boolean
105
+ hasProvenance: boolean
106
+ }
107
+
108
+ export type DispatchPriorTrancheFact = {
109
+ project: string
110
+ /** null when this project has no prior tranche at all — the gate passes trivially for it. */
111
+ priorTrancheSlug: string | null
112
+ archived: boolean
113
+ }
114
+
115
+ export type DispatchGateInput = {
116
+ trancheSlug: string
117
+ /** The topology row for this task, as parsed by `parseTranche`. */
118
+ task: Task
119
+ /** null when the task has no Issue (#TBD/blank) OR the Issue number doesn't resolve on the forge (phantom ref). */
120
+ issue: DispatchIssueFact
121
+ /** `checkIssueRationale` result for this task's Issue — irrelevant (treated as passing) when `issue` is null. */
122
+ issueRationalePass: boolean
123
+ dependsOn: DispatchDependsOnFact[]
124
+ conflictsWith: DispatchConflictsWithFact[]
125
+ /**
126
+ * The immediately-prior task in this same tranche's topology, or null
127
+ * when this is the first task. no longer read by
128
+ * `checkDispatchReadiness` — dormant field, kept for caller compatibility.
129
+ */
130
+ priorTask: DispatchPriorTaskFact | null
131
+ /** One entry per project named in `task.projects`. */
132
+ priorTrancheArchival: DispatchPriorTrancheFact[]
133
+ /**
134
+ * Overrides `PRINCIPAL_ALLOWLIST` for hand-close recognition when
135
+ * provided — an adopter repo's own `vinaya.config.json` `principals`
136
+ * field, resolved by the CLI bin before calling in (never read from here;
137
+ * this stays pure). Defaults to `PRINCIPAL_ALLOWLIST` when omitted, same
138
+ * pattern as `checkReviewGate`'s `principalAllowlist` (`review-gate.ts`) —
139
+ * hardcoding this repo's own principal made that gate unpassable on any
140
+ * adopter repo; the same mistake here would silently do the same to the
141
+ * hand-close path.
142
+ */
143
+ principalAllowlist?: string[]
144
+ }
145
+
146
+ export type DispatchResult = { ready: boolean; blockers: string[] }
147
+
148
+ export function checkDispatchReadiness(input: DispatchGateInput): DispatchResult {
149
+ const { trancheSlug, task } = input
150
+ const taskLabel = `task ${task.id} (tranche ${trancheSlug})`
151
+ const principalAllowlist = input.principalAllowlist ?? PRINCIPAL_ALLOWLIST
152
+ const blockers: string[] = []
153
+
154
+ // Issue-existence — the topology row itself has no Issue number.
155
+ if (task.issue === null) {
156
+ blockers.push(
157
+ `dispatch-gate issue-existence: ${taskLabel} has no Issue (#TBD or blank) in the topology — not dispatchable until the Planner cuts the Issue.`
158
+ )
159
+ } else if (input.issue === null) {
160
+ // Row names an Issue number, but it doesn't resolve on the forge — phantom ref (T1's fail class).
161
+ blockers.push(
162
+ `dispatch-gate issue-existence: ${taskLabel} names Issue #${task.issue}, but it does not resolve to a real GitHub Issue (phantom reference).`
163
+ )
164
+ }
165
+
166
+ // Planner-rationale completeness — only evaluable when the Issue itself resolved.
167
+ if (input.issue !== null && !input.issueRationalePass) {
168
+ blockers.push(
169
+ `dispatch-gate rationale: Issue #${input.issue.number} for ${taskLabel} fails the rationale gate (checkIssueRationale) — the Planner must complete the eight-field rationale before this task is dispatchable.`
170
+ )
171
+ }
172
+
173
+ // Depends-on merged — OR hand-closed by a recognized Principal (task
174
+ // `vinaya-engine-v1` 21, #99): a second, narrower path for a dependency
175
+ // Issue closed directly rather than via a merged PR.
176
+ for (const dep of input.dependsOn) {
177
+ if (dep.resolved === false) {
178
+ // Distinct from the "not merged yet" branch below (#196): this edge
179
+ // never resolved to any tranche/task/Issue at all, so a "not merged"
180
+ // claim would misattribute the failure to the forge rather than to
181
+ // the edge text. Still blocks — the conservative default is correct
182
+ // for a genuinely unresolvable edge — but says so honestly.
183
+ blockers.push(
184
+ `dispatch-gate depends-on: ${taskLabel} depends on "${dep.id}", which is UNRESOLVABLE — the resolver could not find a matching tranche/task/Issue for this edge (not a claim about merge status). Not dispatchable until the edge is corrected.`
185
+ )
186
+ continue
187
+ }
188
+ if (!dep.merged && !isHandClosedByRecognizedPrincipal(dep, principalAllowlist)) {
189
+ const issueStr = dep.issue !== null ? ` (#${dep.issue})` : ''
190
+ blockers.push(
191
+ `dispatch-gate depends-on: ${taskLabel} depends on ${dep.id}${issueStr}, whose PR is not merged yet — not dispatchable, it serializes behind it.`
192
+ )
193
+ }
194
+ }
195
+
196
+ // Conflicts-with not open/in-flight.
197
+ for (const c of input.conflictsWith) {
198
+ if (c.openOrInFlight) {
199
+ const issueStr = c.issue !== null ? ` (#${c.issue})` : ''
200
+ blockers.push(
201
+ `dispatch-gate conflicts-with: ${taskLabel} conflicts with ${c.id}${issueStr}, whose PR is open or in-flight — not dispatchable until it merges.`
202
+ )
203
+ }
204
+ }
205
+
206
+ // Prior-task archival / row-adjacency predicate REMOVED (2026-07-13).
207
+ // The retired predicate required every earlier table row's full archival (Issue
208
+ // closed, PR merged, provenance posted) regardless of whether that row was
209
+ // a declared dependency automated the provenance-posting signal
210
+ // this existed to protect, so the blanket row-order block outlived its
211
+ // justification. `input.priorTask` is still accepted (dormant) for caller
212
+ // compatibility; see the type's doc comment above.
213
+
214
+ // Prior-tranche archival, per project named in Project(s).
215
+ for (const proj of input.priorTrancheArchival) {
216
+ if (proj.priorTrancheSlug !== null && !proj.archived) {
217
+ blockers.push(
218
+ `dispatch-gate prior-tranche-archival: project \`${proj.project}\`'s previous tranche \`${proj.priorTrancheSlug}\` is not archived — the Tranche Archivist must run before new work on this product.`
219
+ )
220
+ }
221
+ }
222
+
223
+ return { ready: blockers.length === 0, blockers }
224
+ }
@@ -0,0 +1,314 @@
1
+ /**
2
+ * The `.vinaya/doc-owners` manifest parser and the C5 code→doc
3
+ * coverage evaluator (state-machine.md Section 15). Pure — I/O (reading the
4
+ * manifest off disk, checking pointer existence) is injected by the caller.
5
+ */
6
+
7
+ import { isCodeFile } from './file-classify'
8
+ import { WAIVER_LABEL } from './waiver-label'
9
+
10
+ export const DOC_OWNERS_PATH = '.vinaya/doc-owners'
11
+
12
+ export type DocOwnersBinding = { glob: string; pointer: string; lineNum: number }
13
+
14
+ /**
15
+ * What a caller found where the manifest should be.
16
+ *
17
+ * `absent` — a repo that never configured doc ownership. Legitimately dormant.
18
+ * `empty` — a manifest that exists but has nothing in it. This is the shape a
19
+ * misresolved repo root produces, and reporting it as success is how a broken
20
+ * derivation passes for a real one. It is a refusal, not a dormancy.
21
+ * `present` — parse it.
22
+ *
23
+ * Split out of `verify-dispatch --surfaces` so the three-way decision is
24
+ * testable: the CLI chdirs to the repo root before reading, so the branch is
25
+ * unreachable from a test as long as it lives inside the command.
26
+ */
27
+ export type DocOwnersManifestState = 'absent' | 'empty' | 'present'
28
+
29
+ export function classifyDocOwnersManifest(content: string | null): DocOwnersManifestState {
30
+ if (content === null) return 'absent'
31
+ return content.trim() === '' ? 'empty' : 'present'
32
+ }
33
+
34
+ export type C5Result = { errors: string[]; notes: string[] }
35
+
36
+ /**
37
+ * Parse the doc-owners file. Each non-blank, non-comment line is
38
+ * `<code-glob> <doc-pointer>`
39
+ *
40
+ * Comments start with `#`. Pointer is in-repo path, path#anchor, or URL.
41
+ * Returns the parsed bindings + any malformed-line errors.
42
+ */
43
+ export function parseDocOwners(content: string): { bindings: DocOwnersBinding[]; errors: string[] } {
44
+ const bindings: DocOwnersBinding[] = []
45
+ const errors: string[] = []
46
+ const lines = content.split('\n')
47
+ for (let i = 0; i < lines.length; i++) {
48
+ const raw = lines[i] ?? ''
49
+ const stripped = raw.replace(/#.*$/, '').trim()
50
+ if (!stripped) continue
51
+ const parts = stripped.split(/\s+/)
52
+ if (parts.length < 2) {
53
+ errors.push(
54
+ `C5 doc-owners-parse: ${DOC_OWNERS_PATH}:${i + 1} — malformed binding (expected "<glob> <pointer>", got "${raw.trim()}").`
55
+ )
56
+ continue
57
+ }
58
+ const [glob, ...pointerParts] = parts
59
+ bindings.push({ glob: glob ?? '', pointer: pointerParts.join(' '), lineNum: i + 1 })
60
+ }
61
+ return { bindings, errors }
62
+ }
63
+
64
+ /**
65
+ * Translate a doc-owners glob to a RegExp. Deliberately simple — only `*` and
66
+ * `**` are special; every other character is literal so Next.js dynamic-route
67
+ * segments like `[username]` match without escaping.
68
+ */
69
+ export function globToRegex(pat: string): RegExp {
70
+ let re = '^'
71
+ let i = 0
72
+ while (i < pat.length) {
73
+ const c = pat[i]
74
+ if (c === '*') {
75
+ if (pat[i + 1] === '*') {
76
+ re += '.*'
77
+ i += 2
78
+ } else {
79
+ re += '[^/]*'
80
+ i += 1
81
+ }
82
+ } else if (/[.+^$|(){}[\]\\?]/.test(c as string)) {
83
+ re += `\\${c}`
84
+ i += 1
85
+ } else {
86
+ re += c
87
+ i += 1
88
+ }
89
+ }
90
+ re += '$'
91
+ return new RegExp(re)
92
+ }
93
+
94
+ export function isUrlPointer(p: string): boolean {
95
+ return /^https?:\/\//.test(p)
96
+ }
97
+
98
+ export function pointerToPath(p: string): string {
99
+ const idx = p.indexOf('#')
100
+ return idx === -1 ? p : p.slice(0, idx)
101
+ }
102
+
103
+ export type DocAck = { surface: string; note: string }
104
+ export type DocNeutral = { surface: string; note: string }
105
+
106
+ // Separator between <pointer> and <note>. Tolerates em-dash (—),
107
+ // en-dash (–), or a plain ASCII hyphen-minus (-) with REQUIRED surrounding
108
+ // whitespace. Required whitespace around `-` disambiguates the separator
109
+ // from hyphens that legitimately appear inside pointers (e.g. `aeg-root`,
110
+ // `.claude/skills/ui-components/SKILL.md`). Non-greedy `(.+?)` for the
111
+ // pointer plus this anchored separator means the pointer naturally stops
112
+ // at the first valid separator without literally excluding `-` from
113
+ // the pointer character set.
114
+ const SEPARATOR = /(?:[ \t]*[—–][ \t]*|[ \t]+-[ \t]+)/.source
115
+
116
+ /** Shared by `Doc-ack:` and `Doc-neutral:` — both are `<field>: <pointer> <sep> <note>` PR-body lines. */
117
+ function readPointerNoteField(body: string, field: string): { surface: string; note: string }[] {
118
+ const out: { surface: string; note: string }[] = []
119
+ const re = new RegExp(`^[ \\t]*${field}[ \\t]*:[ \\t]*(.+?)${SEPARATOR}(.+?)[ \\t]*$`, 'gim')
120
+ for (const m of body.matchAll(re)) {
121
+ out.push({ surface: (m[1] ?? '').trim(), note: (m[2] ?? '').trim() })
122
+ }
123
+ return out
124
+ }
125
+
126
+ export function readDocAcks(body: string): DocAck[] {
127
+ return readPointerNoteField(body, 'Doc-ack')
128
+ }
129
+
130
+ /**
131
+ * `Doc-neutral: <pointer> — <note>` — declares that the doc a fired binding
132
+ * would otherwise require is not owed because the matched code change is
133
+ * mechanically neutral (comment/whitespace-only). Mirrors `Doc-ack:`'s
134
+ * grammar deliberately, so it reads as the same PR-body-field family.
135
+ *
136
+ * The declaration alone is never sufficient — see `isMechanicallyNeutralDiff`
137
+ * below, which `evaluateC5` requires as corroborating evidence before this
138
+ * declaration satisfies a fired binding. An unevidenced declaration is a
139
+ * distinct failure (`C5 doc-neutral-unverified`), not a silent pass — that
140
+ * asymmetry is what keeps this from being a self-serve exemption.
141
+ */
142
+ export function readDocNeutrals(body: string): DocNeutral[] {
143
+ return readPointerNoteField(body, 'Doc-neutral')
144
+ }
145
+
146
+ /**
147
+ * Full-line comment markers, scoped per source-language file extension. A
148
+ * marker is listed for a language ONLY if no legitimate code construct in
149
+ * that language can start a trimmed line with it — that constraint is what
150
+ * keeps this fail-closed rather than a bare per-line prefix table (review
151
+ * finding, task 4 code review): a flat asterisk/hash/double-dash/block-
152
+ * comment-delimiter table misclassifies real code as comment — a TS/JS
153
+ * generator method (`*gen() {`), a TS/JS private class field
154
+ * (`#count = 0`), SQL's `--` also being C-style pre-decrement (`--i;`), and
155
+ * a block-comment-close delimiter hiding trailing code on the same line are
156
+ * all real collisions, not hypothetical ones. `//` is the only marker kept
157
+ * for TS/JS/JSX because no such language construct starts a trimmed line
158
+ * with `//`. An unrecognized extension gets no safe marker at all, so
159
+ * `isMechanicallyNeutralDiff` can never classify it neutral — fail-closed
160
+ * under uncertainty, not an attempt at full per-language comment/string-
161
+ * region parsing (out of this task's bounded "comment/whitespace-only"
162
+ * scope).
163
+ */
164
+ const LANGUAGE_COMMENT_PREFIXES: ReadonlyArray<{ test: RegExp; prefixes: readonly string[] }> = [
165
+ { test: /\.(ts|tsx|js|jsx|mjs|cjs)$/, prefixes: ['//'] },
166
+ { test: /(^|\/)(pre-push|pre-commit|pre-merge-commit)$/, prefixes: ['#'] },
167
+ { test: /\.(sh|bash)$/, prefixes: ['#'] },
168
+ { test: /\.ya?ml$/, prefixes: ['#'] },
169
+ { test: /\.py$/, prefixes: ['#'] }
170
+ ]
171
+
172
+ function commentPrefixesForPath(path: string): readonly string[] {
173
+ for (const { test, prefixes } of LANGUAGE_COMMENT_PREFIXES) {
174
+ if (test.test(path)) return prefixes
175
+ }
176
+ return []
177
+ }
178
+
179
+ function isNeutralDiffContentLine(line: string, prefixes: readonly string[]): boolean {
180
+ const t = line.trim()
181
+ if (t === '') return true
182
+ // A shebang is comment-shaped but changes what interpreter runs the
183
+ // file — real behavior, never neutral, even though it starts with `#`.
184
+ if (t.startsWith('#!')) return false
185
+ return prefixes.some((p) => t.startsWith(p))
186
+ }
187
+
188
+ /**
189
+ * The evidence half of the neutral-edit path: given a unified diff for one
190
+ * file and that file's path (for language-scoped comment-marker selection),
191
+ * true only if every added/removed line is comment-only or whitespace-only.
192
+ * Context lines and the `+++`/`---`/`@@` headers are not evidence either
193
+ * way. A diff with zero +/- content lines is not "a change" at all, so it
194
+ * returns false rather than vacuously true — there must be at least one
195
+ * actual edited line for a neutrality claim to mean anything. An
196
+ * unrecognized file extension (`commentPrefixesForPath` returns `[]`)
197
+ * always returns false — no fired binding on an unrecognized language can
198
+ * take the Doc-neutral path.
199
+ */
200
+ export function isMechanicallyNeutralDiff(diffText: string, path: string): boolean {
201
+ const prefixes = commentPrefixesForPath(path)
202
+ let sawChange = false
203
+ for (const raw of diffText.split('\n')) {
204
+ if (raw.startsWith('+++ ') || raw.startsWith('--- ') || raw.startsWith('@@')) continue
205
+ if (raw.startsWith('+') || raw.startsWith('-')) {
206
+ if (!isNeutralDiffContentLine(raw.slice(1), prefixes)) return false
207
+ sawChange = true
208
+ }
209
+ }
210
+ return sawChange
211
+ }
212
+
213
+ /**
214
+ * Pure evaluator for the C5 doc-coverage check. The runtime wrapper reads
215
+ * `.vinaya/doc-owners` from disk and `PR_BODY` from env; this
216
+ * function takes both as inputs and an injectable file-exists for unit tests.
217
+ *
218
+ * Dormancy: a null `docOwnersContent` (absent file) OR no glob matching any
219
+ * changed code file produces an empty result — no errors, no notes.
220
+ *
221
+ * `waiverActive` is a single PR-wide boolean, not a per-binding
222
+ * pointer/reason lookup — it's the caller-resolved result of
223
+ * `isWaiverLabelActorVerified`, itself a mechanized read of a forge fact.
224
+ * There is no agent-emittable `Doc-waiver:` string anymore; a waiver is
225
+ * either verified true PR-wide, or not.
226
+ *
227
+ * `getDiff` is optional and injectable (unit tests; also absent whenever a
228
+ * caller has no diff-content source) — it resolves the unified diff for one
229
+ * matched code file, feeding the `Doc-neutral:` evidence check below. Its
230
+ * absence never produces a pass: a declared-but-unevidenced neutral claim
231
+ * fails the same as if it evidence didn't check out, preserving silence =
232
+ * failure (state-machine.md Section 15).
233
+ */
234
+ export function evaluateC5(
235
+ changed: string[],
236
+ docOwnersContent: string | null,
237
+ prBody: string,
238
+ fileExists: (p: string) => boolean,
239
+ waiverActive: boolean,
240
+ getDiff?: (p: string) => string | null
241
+ ): C5Result {
242
+ const out: C5Result = { errors: [], notes: [] }
243
+
244
+ if (docOwnersContent === null) return out
245
+
246
+ const { bindings, errors: parseErrors } = parseDocOwners(docOwnersContent)
247
+ for (const e of parseErrors) out.errors.push(e)
248
+ if (bindings.length === 0) return out
249
+
250
+ const codeFiles = changed.filter(isCodeFile)
251
+ const fired: { binding: DocOwnersBinding; matchedFiles: string[] }[] = []
252
+ for (const b of bindings) {
253
+ const re = globToRegex(b.glob)
254
+ const matchedFiles = codeFiles.filter((f) => re.test(f))
255
+ if (matchedFiles.length > 0) fired.push({ binding: b, matchedFiles })
256
+ }
257
+ if (fired.length === 0) return out
258
+
259
+ const acks = readDocAcks(prBody)
260
+ const neutrals = readDocNeutrals(prBody)
261
+
262
+ for (const { binding: b, matchedFiles } of fired) {
263
+ if (waiverActive) {
264
+ out.notes.push(`C5 doc-waiver active for ${b.pointer} (binding ${DOC_OWNERS_PATH}:${b.lineNum})`)
265
+ continue
266
+ }
267
+
268
+ if (isUrlPointer(b.pointer)) {
269
+ const acked = acks.some((a) => a.surface === b.pointer)
270
+ if (!acked) {
271
+ out.errors.push(
272
+ `C5 doc-coverage: code change matched ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\` → ${b.pointer}). External pointer requires \`Doc-ack: ${b.pointer} — <note>\` in the PR body, or an actor-verified \`${WAIVER_LABEL}\` label to skip.`
273
+ )
274
+ }
275
+ continue
276
+ }
277
+
278
+ const pointerPath = pointerToPath(b.pointer)
279
+ if (!fileExists(pointerPath)) {
280
+ out.errors.push(
281
+ `C5 doc-owners-dangling: ${DOC_OWNERS_PATH}:${b.lineNum} points to ${b.pointer}, which does not exist on disk. Fix the binding or add the doc.`
282
+ )
283
+ continue
284
+ }
285
+
286
+ if (changed.includes(pointerPath)) continue
287
+
288
+ const declared = neutrals.find((n) => n.surface === b.pointer)
289
+ if (declared) {
290
+ const evidenced =
291
+ getDiff !== undefined &&
292
+ matchedFiles.every((f) => {
293
+ const diff = getDiff(f)
294
+ return diff !== null && isMechanicallyNeutralDiff(diff, f)
295
+ })
296
+ if (evidenced) {
297
+ out.notes.push(
298
+ `C5 doc-neutral: ${b.pointer} not required for ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\`) — declared neutral ("${declared.note}"), diff of ${matchedFiles.join(', ')} confirmed comment/whitespace-only.`
299
+ )
300
+ continue
301
+ }
302
+ out.errors.push(
303
+ `C5 doc-neutral-unverified: ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\` → ${b.pointer}) declared \`Doc-neutral: ${b.pointer} — ${declared.note}\`, but the diff of ${matchedFiles.join(', ')} contains changes beyond comments/whitespace. Update ${pointerPath}, or have a principal apply the \`${WAIVER_LABEL}\` label.`
304
+ )
305
+ continue
306
+ }
307
+
308
+ out.errors.push(
309
+ `C5 doc-coverage: code change matched ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\` → ${b.pointer}), but ${pointerPath} is not in the PR diff. Update it, declare \`Doc-neutral: ${b.pointer} — <why>\` if the change is genuinely mechanically-neutral, or have a principal apply the \`${WAIVER_LABEL}\` label.`
310
+ )
311
+ }
312
+
313
+ return out
314
+ }
@@ -0,0 +1,38 @@
1
+ import type { Doc, DocNav, DocSection } from './types'
2
+
3
+ export type BuildDocNavOptions = {
4
+ sectionOrder?: string[]
5
+ }
6
+
7
+ function sectionIdOf(label: string): string {
8
+ return label
9
+ .toLowerCase()
10
+ .replace(/[^a-z0-9]+/g, '-')
11
+ .replace(/^-+|-+$/g, '')
12
+ }
13
+
14
+ export function buildDocNav(docs: Doc[], opts: BuildDocNavOptions = {}): DocNav {
15
+ const bySection = new Map<string, Doc[]>()
16
+ for (const doc of docs) {
17
+ const list = bySection.get(doc.section) ?? []
18
+ list.push(doc)
19
+ bySection.set(doc.section, list)
20
+ }
21
+
22
+ const ordered: string[] = []
23
+ if (opts.sectionOrder) {
24
+ for (const label of opts.sectionOrder) if (bySection.has(label)) ordered.push(label)
25
+ }
26
+ for (const label of bySection.keys()) if (!ordered.includes(label)) ordered.push(label)
27
+
28
+ const sections: DocSection[] = ordered.map((label) => {
29
+ const items = (bySection.get(label) ?? []).slice().sort((a, b) => {
30
+ if (a.order !== b.order) return a.order - b.order
31
+ return a.title.localeCompare(b.title)
32
+ })
33
+ return { id: sectionIdOf(label), label, docs: items }
34
+ })
35
+
36
+ const flat = sections.flatMap((s) => s.docs)
37
+ return { sections, flat }
38
+ }