@attalabs/vinaya 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +1 -0
  2. package/dist/checks/bin/check-body-bare-digits.js +7 -1
  3. package/dist/checks/bin/check-branch-topology.js +27 -1
  4. package/dist/checks/bin/check-brief-shape.js +7 -1
  5. package/dist/checks/bin/check-changeset-coverage.js +7 -1
  6. package/dist/checks/bin/check-closes-n.js +27 -1
  7. package/dist/checks/bin/check-coherence.js +27 -1
  8. package/dist/checks/bin/check-dead-branch-push.js +7 -1
  9. package/dist/checks/bin/check-dispatch-readiness.js +27 -1
  10. package/dist/checks/bin/check-doc-coverage-push.js +7 -1
  11. package/dist/checks/bin/check-doc-coverage.js +7 -1
  12. package/dist/checks/bin/check-doctrine-no-procedures.js +7 -1
  13. package/dist/checks/bin/check-doctrine-portability.js +7 -1
  14. package/dist/checks/bin/check-evidence-fresh.js +7 -1
  15. package/dist/checks/bin/check-exec-bits.js +7 -1
  16. package/dist/checks/bin/check-first-push-dispatch.js +27 -1
  17. package/dist/checks/bin/check-issue-assignment.js +27 -1
  18. package/dist/checks/bin/check-main-branch-refusal.js +13 -2
  19. package/dist/checks/bin/check-no-disk-state.js +7 -1
  20. package/dist/checks/bin/check-pr-report-density.js +7 -1
  21. package/dist/checks/bin/check-quoted-command.js +38 -12
  22. package/dist/checks/bin/check-reader-resolvable-prose.js +38 -12
  23. package/dist/checks/bin/check-registry-gates.js +7 -1
  24. package/dist/checks/bin/check-retired-vocabulary.js +38 -12
  25. package/dist/checks/bin/check-review-gate.js +7 -1
  26. package/dist/checks/bin/check-single-plan-pr.js +7 -1
  27. package/dist/checks/bin/check-test-plan.js +7 -1
  28. package/dist/checks/bin/check-token-collection-wired.js +7 -1
  29. package/dist/checks/bin/check-token-report.js +7 -1
  30. package/dist/checks/bin/check-workspace-escape.js +7 -1
  31. package/dist/index.js +362 -87
  32. package/package.json +1 -1
  33. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/anchored-region.ts +9 -4
  34. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/blast-radius-domains.ts +7 -1
  35. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-render.ts +462 -0
  36. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-validation.ts +405 -4
  37. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/coherence-checks.ts +19 -0
  38. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/consumer-enumeration.ts +75 -0
  39. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dispatch-gate.ts +44 -0
  40. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-no-procedures.ts +94 -0
  41. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-portability.ts +128 -5
  42. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/gate-audience.ts +3 -0
  43. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/index.ts +25 -3
  44. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-token-report.ts +17 -5
  45. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-report-density.ts +116 -0
  46. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/premise-check.ts +35 -0
  47. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/quoted-command.ts +3 -3
  48. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-gate.ts +116 -7
  49. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-status.ts +200 -0
  50. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-gate.ts +25 -12
  51. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/verdict-extraction.ts +70 -11
  52. package/studio-standalone/apps/vinaya-studio/web/.next/BUILD_ID +1 -1
  53. package/studio-standalone/apps/vinaya-studio/web/.next/build-manifest.json +3 -3
  54. package/studio-standalone/apps/vinaya-studio/web/.next/prerender-manifest.json +3 -3
  55. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.html +1 -1
  56. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.rsc +1 -1
  57. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  58. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  59. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  60. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  61. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  62. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page/server-reference-manifest.json +2 -2
  63. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  64. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  65. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/api/coherence/route.js.nft.json +1 -1
  66. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page/server-reference-manifest.json +2 -2
  67. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page.js.nft.json +1 -1
  68. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page_client-reference-manifest.js +1 -1
  69. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page/server-reference-manifest.json +2 -2
  70. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page.js.nft.json +1 -1
  71. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page_client-reference-manifest.js +1 -1
  72. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page/server-reference-manifest.json +2 -2
  73. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page.js.nft.json +1 -1
  74. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page_client-reference-manifest.js +1 -1
  75. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page/server-reference-manifest.json +2 -2
  76. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page.js.nft.json +1 -1
  77. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page_client-reference-manifest.js +1 -1
  78. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page/server-reference-manifest.json +2 -2
  79. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page.js.nft.json +1 -1
  80. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page_client-reference-manifest.js +1 -1
  81. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page/server-reference-manifest.json +2 -2
  82. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page.js.nft.json +1 -1
  83. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page_client-reference-manifest.js +1 -1
  84. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page/server-reference-manifest.json +2 -2
  85. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page.js.nft.json +1 -1
  86. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page_client-reference-manifest.js +1 -1
  87. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page/server-reference-manifest.json +2 -2
  88. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page.js.nft.json +1 -1
  89. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page_client-reference-manifest.js +1 -1
  90. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/[root-of-the-server]__02a-3g8._.js +1 -1
  91. 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
  92. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0053k9k._.js +1 -1
  93. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0112h-k._.js +1 -1
  94. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0o771t1._.js +1 -1
  95. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0puovz5._.js +1 -1
  96. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1hs0dcu._.js +1 -1
  97. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{[root-of-the-server]__1mcitst._.js → [root-of-the-server]__1pndh3_._.js} +1 -1
  98. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1wc4-ip._.js +1 -1
  99. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_03x_w6q._.js +1 -1
  100. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0gvm3og._.js +1 -1
  101. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0lwxg63._.js +1 -1
  102. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1fqw88f._.js +2 -2
  103. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1n0cnq-._.js +1 -1
  104. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/apps_vinaya-studio_web_src_app_studio_projects_[name]_tranches_[slug]_05ufo8i._.js +3 -3
  105. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
  106. package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
  107. package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
  108. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
  109. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
  110. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{0jer_drkl1e4r.js → 0zebsmmk1bvnb.js} +4 -4
  111. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{20x-t0a0v129z.js → 1uogpj5w2n5ju.js} +1 -1
  112. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{2ou_81ahqw31e.js → 3m1kgax7j2vgs.js} +1 -1
  113. package/studio-standalone/apps/vinaya-studio/web/package.json +2 -2
  114. /package/studio-standalone/_node_modules/@attalabs/vinaya/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
  115. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{5xy_GA7G5KRuE3HDumIiw → QY2GhiI47-765D9DHGy1I}/_buildManifest.js +0 -0
  116. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{5xy_GA7G5KRuE3HDumIiw → QY2GhiI47-765D9DHGy1I}/_clientMiddlewareManifest.js +0 -0
  117. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{5xy_GA7G5KRuE3HDumIiw → QY2GhiI47-765D9DHGy1I}/_ssgManifest.js +0 -0
@@ -73,6 +73,13 @@ export type ReviewGateComment = {
73
73
  author: string | null
74
74
  }
75
75
 
76
+ export type MechanicalCheckStatus = {
77
+ /** The check-run's display name, as GitHub reports it (`gh pr checks`' own `name` field). */
78
+ name: string
79
+ /** GitHub's own coalesced status vocabulary for this check-run — forwarded verbatim from `gh pr checks --json name,bucket`'s `bucket` field (e.g. "pass", "fail", "pending", "skipping", "cancel"). Not re-mapped to a smaller enum here — that would be a second copy of a vocabulary `gh` already owns. */
80
+ bucket: string
81
+ }
82
+
76
83
  export type ReviewGateInput = {
77
84
  /** Every comment on the PR, with its author. */
78
85
  comments: ReviewGateComment[]
@@ -91,6 +98,15 @@ export type ReviewGateInput = {
91
98
  * default. Every verdict must cover this value to count as clean.
92
99
  */
93
100
  headSha: string
101
+ /**
102
+ * Every check-run reported for the PR's current head, EXCLUDING this
103
+ * repo's own review-gate check-run (the caller filters that out before
104
+ * calling in — see check-review-gate.ts's own comment for why the
105
+ * exclusion must not live here). An empty array means no mechanical
106
+ * check-run has reported yet, which does NOT count as clean — there is
107
+ * no proof to point to, not an implicit pass.
108
+ */
109
+ mechanicalChecks: MechanicalCheckStatus[]
94
110
  /**
95
111
  * Overrides `PRINCIPAL_ALLOWLIST` for this evaluation when provided — an
96
112
  * adopter repo's own `vinaya.config.json` `principals` field, resolved by
@@ -103,6 +119,36 @@ export type ReviewGateInput = {
103
119
  * already had counted for nobody.
104
120
  */
105
121
  principalAllowlist?: string[]
122
+ /**
123
+ * The PATCH IDENTITY of a commit — `git diff <base>...<sha> | git patch-id
124
+ * --stable`, truncated — or `null` when git cannot answer for that sha
125
+ * (an unreachable commit after a force-push, a shallow clone, no git at
126
+ * all). Supplied by the caller because this module is pure; omitted
127
+ * entirely, the gate behaves exactly as before.
128
+ *
129
+ * Why a second binding at all: a verdict was judged, the branch merged
130
+ * `origin/main` to clear a stale base, and the verdict died — for a merge
131
+ * commit that changed not one line of the PR's own patch. Re-reviewing
132
+ * an identical patch is a round spent proving nothing. Patch identity is
133
+ * what a reviewer actually judged; the head sha is only its address.
134
+ *
135
+ * Two known limits, stated rather than papered over.
136
+ *
137
+ * A base that moved under an identical patch can carry a semantic conflict
138
+ * the earlier review could not have seen, and this binding will still
139
+ * hold. That is the same limit GitHub's own stale-review rule has, and CI
140
+ * at the new head — which this gate already requires green — is the guard
141
+ * for it.
142
+ *
143
+ * `git patch-id --stable` ignores whitespace, so a push that changes only
144
+ * whitespace produces the same patch identity and KEEPS the verdict. That
145
+ * is deliberate for reformatting, but it is not free: whitespace is
146
+ * semantic in some languages and some string literals, so a push that is
147
+ * whitespace-only to git can still change behaviour. Any change to
148
+ * non-whitespace content produces a different identity and correctly drops
149
+ * the verdict; only the whitespace-only case survives unreviewed.
150
+ */
151
+ patchIdOf?: (sha: string) => string | null
106
152
  }
107
153
 
108
154
  /**
@@ -131,13 +177,51 @@ function isBoundToHead(extraction: { headSha: string | null }, headSha: string):
131
177
  return headSha.toLowerCase().startsWith(extraction.headSha.toLowerCase())
132
178
  }
133
179
 
180
+ /**
181
+ * True when the judged head and the current head carry the SAME patch — the
182
+ * verdict was cast on this exact set of changes, whatever sha now addresses
183
+ * it. Composed BESIDE `isBoundToHead`, never in place of it: sha binding
184
+ * still counts on its own, and this only widens what else counts.
185
+ *
186
+ * Fails closed on every uncertainty. `null` on either side is "git could not
187
+ * answer", not "they match" — a force-push that makes the judged head
188
+ * unreachable resolves to `null` and the verdict correctly stops counting.
189
+ */
190
+ function isBoundByPatchIdentity(
191
+ extraction: { headSha: string | null },
192
+ headSha: string,
193
+ patchIdOf?: (sha: string) => string | null
194
+ ): boolean {
195
+ if (patchIdOf === undefined || !extraction.headSha) return false
196
+ const judged = patchIdOf(extraction.headSha)
197
+ const current = patchIdOf(headSha)
198
+ if (judged === null || current === null) return false
199
+ return judged === current
200
+ }
201
+
202
+ /** A verdict covers the current head when its sha binds it, or its patch identity does. */
203
+ function isBoundToPatch(
204
+ extraction: { headSha: string | null },
205
+ headSha: string,
206
+ patchIdOf?: (sha: string) => string | null
207
+ ): boolean {
208
+ return isBoundToHead(extraction, headSha) || isBoundByPatchIdentity(extraction, headSha, patchIdOf)
209
+ }
210
+
134
211
  /**
135
212
  * `pass` when either (a) `vinaya/waiver:review` is present and actor-verified against
136
213
  * `PRINCIPAL_ALLOWLIST`, or (b) both verdicts are clean AND bound — code-reviewer
137
214
  * `APPROVE` (not `REQUEST_CHANGES`, not missing, not unclear) covering the PR's
138
- * current `headSha`, and security-review `PASS` (not `FAIL`, not missing, not
139
- * unclear) covering it too. `fail` otherwise, naming exactly which verdict(s)
140
- * are not clean, not bound to the current head, or both.
215
+ * current `headSha` by that sha, or by an equal patch identity when
216
+ * `patchIdOf` is supplied and security-review `PASS` (not `FAIL`, not
217
+ * missing, not unclear) covering it too AND every reported mechanical check-run for that
218
+ * same head is green, a `skipping`/`neutral` entry (a job whose own `if:`
219
+ * was false for this event) filtered out first as absent rather than
220
+ * counted either way (`mechanicalChecks`, after that filter, non-empty and
221
+ * every remaining entry's `bucket` is `"pass"`). `fail` otherwise, naming
222
+ * exactly which verdict(s) are not clean, not bound to the current head,
223
+ * which mechanical check(s) are not green, or that none have reported at
224
+ * all.
141
225
  */
142
226
  export function checkReviewGate(input: ReviewGateInput): ReviewGateResult {
143
227
  const principalAllowlist = input.principalAllowlist ?? PRINCIPAL_ALLOWLIST
@@ -155,6 +239,21 @@ export function checkReviewGate(input: ReviewGateInput): ReviewGateResult {
155
239
  }
156
240
  }
157
241
 
242
+ // A `skipping` (GitHub `conclusion: "skipped"` or `"neutral"`) check-run is
243
+ // ABSENT, never a failure (Issue #402 O4): a job whose own `if:` is false
244
+ // for this event still reports a check-run — `vinaya-review.yml`'s
245
+ // `retrigger-on-ci-green` job reports `skipped` on every ordinary
246
+ // `pull_request_target` run — and counting that as "not green" blocked
247
+ // every PR (first seen on PR #401: "vinaya review gate (retrigger on CI
248
+ // green) (skipping)"). Filtered out before both the emptiness check and
249
+ // the clean-check, so a head reporting only skipped/neutral runs reads as
250
+ // "nothing has reported yet", not as a false pass.
251
+ const reportedMechanicalChecks = input.mechanicalChecks.filter(
252
+ (c) => c.bucket !== 'skipping' && c.bucket !== 'neutral'
253
+ )
254
+ const mechanicalChecksClean =
255
+ reportedMechanicalChecks.length > 0 && reportedMechanicalChecks.every((c) => c.bucket === 'pass')
256
+
158
257
  // Verdict-AUTHOR verification (security finding on PR #806): on a public
159
258
  // repo any GitHub account can post a `VERDICT: APPROVE`-shaped comment, and
160
259
  // most-recent-clear-hit-wins extraction would let a forged later APPROVE
@@ -178,13 +277,13 @@ export function checkReviewGate(input: ReviewGateInput): ReviewGateResult {
178
277
  const security = extractSecurityReviewVerdict(verifiedBodies)
179
278
  const codeReviewClean = codeReview.value === 'APPROVE'
180
279
  const securityClean = security.value === 'PASS'
181
- const codeReviewBound = isBoundToHead(codeReview, input.headSha)
182
- const securityBound = isBoundToHead(security, input.headSha)
280
+ const codeReviewBound = isBoundToPatch(codeReview, input.headSha, input.patchIdOf)
281
+ const securityBound = isBoundToPatch(security, input.headSha, input.patchIdOf)
183
282
 
184
- if (codeReviewClean && codeReviewBound && securityClean && securityBound) {
283
+ if (codeReviewClean && codeReviewBound && securityClean && securityBound && mechanicalChecksClean) {
185
284
  return {
186
285
  verdict: 'pass',
187
- reason: `code-reviewer verdict is a clean APPROVE and security-review verdict is a clean PASS, both covering head ${input.headSha}.`,
286
+ reason: `code-reviewer verdict is a clean APPROVE and security-review verdict is a clean PASS, both covering head ${input.headSha}, and every reported mechanical check is green.`,
188
287
  waived: false
189
288
  }
190
289
  }
@@ -204,6 +303,16 @@ export function checkReviewGate(input: ReviewGateInput): ReviewGateResult {
204
303
  `the newest security-review verdict covers ${security.headSha ?? 'no recorded commit'}, head is ${input.headSha}`
205
304
  )
206
305
  }
306
+ if (!mechanicalChecksClean) {
307
+ problems.push(
308
+ reportedMechanicalChecks.length === 0
309
+ ? 'no mechanical checks have reported for this head yet'
310
+ : `mechanical check(s) not green: ${reportedMechanicalChecks
311
+ .filter((c) => c.bucket !== 'pass')
312
+ .map((c) => `${c.name} (${c.bucket})`)
313
+ .join(', ')}`
314
+ )
315
+ }
207
316
  const ignoredNote =
208
317
  ignoredCount > 0
209
318
  ? ` ${ignoredCount} verdict-shaped comment(s) from authors outside the principal allowlist were ignored.`
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The review loop's own state, derived (review-convergence-v1 task 8, #381).
3
+ * Pure — no `fs`, no `fetch`, no `process.env`; the CLI shim
4
+ * (`apps/cli/src/commands/review-status.ts`) fetches the PR's comments, head
5
+ * and base through `gh` and hands them here.
6
+ *
7
+ * Why this exists: two PRs of this tranche took six and four review rounds,
8
+ * and every extra round traced to a sentence somebody wrote instead of a
9
+ * command somebody ran. "Is this loop still converging?" was one of those
10
+ * sentences. It is a function now: the four ways a loop stops converging are
11
+ * each a named `PAUSE` reason with the round that produced it, read off the
12
+ * PR's own comments.
13
+ *
14
+ * Every fact here is read through a parser that already exists. Verdict
15
+ * comments and their `Judged head:` binding come from `verdict-extraction.ts`
16
+ * — the same extractors `review-gate.ts` blocks merges with — so this command
17
+ * can never disagree with the gate about which comment cast a verdict or what
18
+ * head it judged. There is no second `Judged head:` regex in this file.
19
+ */
20
+
21
+ import { extractCodeReviewVerdict, extractSecurityReviewVerdict } from './verdict-extraction'
22
+ import { isPrincipal } from './waiver-label'
23
+
24
+ /**
25
+ * The Developer's per-round comment marker. `roles/developer.md`'s post-open
26
+ * sequence renders it into the one comment headed `Head: <sha>`; every gate
27
+ * that needs to know a Developer round happened reads THIS marker at a fixed
28
+ * position rather than scanning the comment's free text for a phrase.
29
+ */
30
+ const DEVELOPER_ROUND_MARKER = /<!--\s*aeg:developer:round-(\d+)\s*-->/i
31
+
32
+ /**
33
+ * A rendered finding line as `review-post.ts`'s `renderFindingsSection`
34
+ * writes it — `<n>. [SEVERITY] <location> — F<id> <class> <state>: <text>` —
35
+ * read for its id and its re-review state together. The id half deliberately
36
+ * mirrors that renderer's own output rather than inventing a second finding
37
+ * grammar; the state half is what round-over-round convergence is measured
38
+ * from, and no exported parser carries it.
39
+ */
40
+ const FINDING_LINE =
41
+ /^\d+\.\s+\[[A-Z]+\]\s+\S+\s+—\s+F(\d+)(?:\s+\S+)?(?:\s+(open|fix-claimed|reproduced|resolved))?:/gm
42
+
43
+ export type ReviewStatus =
44
+ | { state: 'CONTINUE' }
45
+ | { state: 'PAUSE'; reason: 'reappearance' | 'zero-deaths' | 'stale' | 'max-rounds'; id?: string; round: number }
46
+
47
+ export type ReviewStatusInput = {
48
+ comments: { body: string; author: string | null }[]
49
+ headSha: string
50
+ principalAllowlist: string[]
51
+ maxRounds: number
52
+ }
53
+
54
+ /**
55
+ * The round number a Developer round comment declares, or `null` when the
56
+ * body carries no marker at all. Fixed-position read: the marker is an HTML
57
+ * comment this repo's own doctrine renders, never a phrase inferred from the
58
+ * comment's prose.
59
+ */
60
+ export function parseDeveloperRoundMarker(body: string): number | null {
61
+ const m = body.match(DEVELOPER_ROUND_MARKER)
62
+ if (!m) return null
63
+ const n = Number(m[1])
64
+ return Number.isFinite(n) ? n : null
65
+ }
66
+
67
+ type VerdictComment = { judgedHead: string | null; ids: Map<string, string | null> }
68
+
69
+ function findingStates(body: string): Map<string, string | null> {
70
+ const out = new Map<string, string | null>()
71
+ for (const m of body.matchAll(FINDING_LINE)) {
72
+ const id = `F${m[1]}`
73
+ const state = m[2] ?? null
74
+ // A round that lists the same id twice keeps the first state it declared;
75
+ // a later id-less repeat must never erase a real `resolved`/`reproduced`.
76
+ if (!out.has(id) || out.get(id) === null) out.set(id, state)
77
+ }
78
+ return out
79
+ }
80
+
81
+ /**
82
+ * A comment counts as a verdict comment when either extractor parses it
83
+ * clean — the same "did this cast a verdict" test the merge gate applies,
84
+ * one body at a time so this function also learns WHICH body won.
85
+ */
86
+ function asVerdictComment(body: string): VerdictComment | null {
87
+ const code = extractCodeReviewVerdict([body])
88
+ const security = extractSecurityReviewVerdict([body])
89
+ if (code.danglingNote !== null && security.danglingNote !== null) return null
90
+ return { judgedHead: code.headSha ?? security.headSha, ids: findingStates(body) }
91
+ }
92
+
93
+ type Round = { judgedHead: string | null; ids: Map<string, string | null> }
94
+
95
+ /**
96
+ * One round per `Judged head:` value, in the order that head was first
97
+ * judged. A code-review verdict and a security verdict cast on the same head
98
+ * are one round, not two — which is why the grouping key is the judged head
99
+ * and not the comment count.
100
+ */
101
+ function groupRounds(verdicts: VerdictComment[]): Round[] {
102
+ const rounds: Round[] = []
103
+ for (const v of verdicts) {
104
+ const existing = rounds.find((r) => r.judgedHead === v.judgedHead)
105
+ const target = existing ?? { judgedHead: v.judgedHead, ids: new Map<string, string | null>() }
106
+ if (!existing) rounds.push(target)
107
+ for (const [id, state] of v.ids) {
108
+ if (!target.ids.has(id) || target.ids.get(id) === null) target.ids.set(id, state)
109
+ }
110
+ }
111
+ return rounds
112
+ }
113
+
114
+ /**
115
+ * `CONTINUE` means the loop is still converging; a `PAUSE` names the one
116
+ * reason it is not and the round that produced it. The four reasons are
117
+ * evaluated in the order they are declared on `ReviewStatus`, most specific
118
+ * first: a reappearance names an individual finding, zero-deaths names a
119
+ * round, staleness names the loop's own inactivity, and max-rounds is the
120
+ * count alone. The first that holds is reported — a paused loop needs one
121
+ * actionable reason, not a list.
122
+ */
123
+ export function deriveReviewStatus(input: ReviewStatusInput): ReviewStatus {
124
+ const verdicts: VerdictComment[] = []
125
+ let lastVerdictIndex = -1
126
+ input.comments.forEach((c, i) => {
127
+ if (!isPrincipal(c.author, input.principalAllowlist)) return
128
+ const v = asVerdictComment(c.body)
129
+ if (v === null) return
130
+ verdicts.push(v)
131
+ lastVerdictIndex = i
132
+ })
133
+ const rounds = groupRounds(verdicts)
134
+ if (rounds.length === 0) return { state: 'CONTINUE' }
135
+
136
+ // reappearance — an id this loop already called `resolved` comes back
137
+ // `reproduced` in the very next round. The loop is re-litigating, not
138
+ // converging.
139
+ for (let i = 1; i < rounds.length; i++) {
140
+ const prior = rounds[i - 1] as Round
141
+ const current = rounds[i] as Round
142
+ for (const [id, state] of current.ids) {
143
+ if (state === 'reproduced' && prior.ids.get(id) === 'resolved') {
144
+ return { state: 'PAUSE', reason: 'reappearance', id, round: i + 1 }
145
+ }
146
+ }
147
+ }
148
+
149
+ // zero-deaths — a round that kills nothing it inherited and still raises
150
+ // something new. Round one is excluded structurally: it inherits no ids, so
151
+ // "resolved none of them" is vacuously true there and would pause every
152
+ // loop at its first verdict.
153
+ for (let i = 1; i < rounds.length; i++) {
154
+ const current = rounds[i] as Round
155
+ const priorIds = new Set<string>()
156
+ for (let j = 0; j < i; j++) for (const id of (rounds[j] as Round).ids.keys()) priorIds.add(id)
157
+ if (priorIds.size === 0) continue
158
+ const killedOne = [...current.ids].some(([id, state]) => priorIds.has(id) && state === 'resolved')
159
+ const raisedNew = [...current.ids.keys()].some((id) => !priorIds.has(id))
160
+ if (!killedOne && raisedNew) return { state: 'PAUSE', reason: 'zero-deaths', round: i + 1 }
161
+ }
162
+
163
+ // stale — the newest verdict judged a head that is no longer the PR's, and
164
+ // no Developer round comment has landed since it. Nothing is moving: the
165
+ // reviewer is judging a superseded head and the Developer has not answered.
166
+ const newest = rounds[rounds.length - 1] as Round
167
+ const judged = newest.judgedHead
168
+ const boundToHead = judged !== null && (input.headSha.startsWith(judged) || judged.startsWith(input.headSha))
169
+ // Allowlist-filtered, exactly as the verdict scan above is: a round marker
170
+ // is an HTML comment any commenter can paste, and an unfiltered read would
171
+ // let a stranger's comment clear a `stale` pause the Developer never
172
+ // answered.
173
+ const developerAnswered = input.comments
174
+ .slice(lastVerdictIndex + 1)
175
+ .some((c) => isPrincipal(c.author, input.principalAllowlist) && parseDeveloperRoundMarker(c.body) !== null)
176
+ if (!boundToHead && !developerAnswered) {
177
+ return { state: 'PAUSE', reason: 'stale', round: rounds.length }
178
+ }
179
+
180
+ // max-rounds — the count alone. The loop may still be converging; it has
181
+ // simply run long enough that the Principal decides whether it continues.
182
+ if (rounds.length >= input.maxRounds) return { state: 'PAUSE', reason: 'max-rounds', round: rounds.length }
183
+
184
+ return { state: 'CONTINUE' }
185
+ }
186
+
187
+ /**
188
+ * The one-line rendering the CLI prints: `CONTINUE`, or `PAUSE: <reason>[ <id>]`
189
+ * — except `stale`, whose condition (the newest verdict's judged head is no
190
+ * longer the PR's, and no Developer round comment has answered it since) is
191
+ * exactly "a commit landed after the newest verdict, unacknowledged". Verdicts
192
+ * are the last forge event before merge (`roles/developer.md`), so that
193
+ * condition is rendered as the actionable fact it names rather than the bare
194
+ * reason word: `push after verdict — re-review required`.
195
+ */
196
+ export function renderReviewStatus(status: ReviewStatus): string {
197
+ if (status.state === 'CONTINUE') return 'CONTINUE'
198
+ if (status.reason === 'stale') return 'push after verdict — re-review required'
199
+ return status.id !== undefined ? `PAUSE: ${status.reason} ${status.id}` : `PAUSE: ${status.reason}`
200
+ }
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * Test Plan checkbox-state gate evaluator (aeg-governance-hardening
3
- * task 25, #365). Pure no `fs`, no `gh`/`git`. The CLI shim
3
+ * task 25, #365; narrowed to `[principal]` boxes only by task 12, #387 —
4
+ * the `[agent]` half stopped being a checkbox at all: it is a fenced command
5
+ * list `vinaya pr report` executes and writes into the `AEG:EVIDENCE` block,
6
+ * which IS the evidence, so there is nothing left for this gate to grade on
7
+ * that half). Pure — no `fs`, no `gh`/`git`. The CLI shim
4
8
  * (`bin/verify-test-plan.ts`) reads `PR_BODY`/`BRANCH` from env and prints
5
9
  * this function's messages.
6
10
  *
@@ -12,14 +16,20 @@
12
16
  * Test Plan is malformed, not exempt — the exact #377 live-fire gap).
13
17
  * - No section found, non-task branch (or no BRANCH) → `pass` (advisory).
14
18
  * - `Test Plan: unit-tests-only` sentinel → `pass`.
15
- * - Any unticked `- [ ]` checkbox → `fail`, naming every unticked line.
16
- * - All boxes ticked (or no checkbox items at all) → `pass`.
19
+ * - Any unticked `- [ ]` `[principal]` checkbox → `fail`, naming every
20
+ * unticked line. A `[agent]`-tagged checkbox (a pre-#387 body, still
21
+ * grandfathered by `brief-shape`'s own rollout) is never graded here —
22
+ * see the module doc comment above.
23
+ * - All `[principal]` boxes ticked (or none exist at all) → `pass`.
17
24
  */
18
25
 
19
26
  import { locateTestPlanSection } from './test-plan-section'
20
27
 
21
28
  export type TestPlanGateVerdict = 'pass' | 'fail'
22
- export type TestPlanGateResult = { verdict: TestPlanGateVerdict; messages: string[] }
29
+ export type TestPlanGateResult = {
30
+ verdict: TestPlanGateVerdict
31
+ messages: string[]
32
+ }
23
33
 
24
34
  const TASK_BRANCH_PATTERN = /^task\/[^/]+\/[^/]+$/
25
35
 
@@ -66,16 +76,20 @@ export function evaluateTestPlanGate(body: string, branch: string): TestPlanGate
66
76
  }
67
77
  }
68
78
 
79
+ // `[agent]` checkboxes are never graded here — see the module doc comment.
80
+ // A pre-#387 body may still carry them (grandfathered by `brief-shape`'s
81
+ // own rollout); this gate simply does not look at that tag at all any
82
+ // more, ticked or not.
69
83
  const checkboxLines = section
70
84
  .split('\n')
71
85
  .map((line) => line.trimStart())
72
- .filter((line) => /^[-*]\s+\[[ xX]\]/.test(line))
86
+ .filter((line) => /^[-*]\s+\[[ xX]\]\s*\*{0,2}\[principal\]\*{0,2}/i.test(line))
73
87
 
74
88
  if (checkboxLines.length === 0) {
75
89
  return {
76
90
  verdict: 'pass',
77
91
  messages: [
78
- 'Test Plan section found but has no checkbox items.',
92
+ 'Test Plan section found but has no `[principal]` checkbox items.',
79
93
  "PASS (advisory) — the section may be empty or expressed in prose; verification is the Principal's call."
80
94
  ]
81
95
  }
@@ -84,21 +98,20 @@ export function evaluateTestPlanGate(body: string, branch: string): TestPlanGate
84
98
  const unchecked = checkboxLines.filter((line) => /^[-*]\s+\[\s\]/.test(line))
85
99
  const checked = checkboxLines.filter((line) => /^[-*]\s+\[[xX]\]/.test(line))
86
100
 
87
- const messages = [`Test Plan items: ${checked.length} ticked, ${unchecked.length} unticked.`]
101
+ const messages = [`Test Plan [principal] items: ${checked.length} ticked, ${unchecked.length} unticked.`]
88
102
 
89
103
  if (unchecked.length > 0) {
90
- messages.push('', 'FAIL — the following Test Plan items are unticked:')
104
+ messages.push('', 'FAIL — the following [principal] Test Plan items are unticked:')
91
105
  for (const line of unchecked) messages.push(` ${line}`)
92
106
  messages.push(
93
107
  '',
94
- 'Per aeg-root/roles/developer.md (Verification), a PR is not mergeable while any Test Plan box is unticked.',
95
- '- [agent] items: the Developer-agent posts the actual command output as evidence and ticks the box.',
96
- '- [principal] items: the Principal runs the item in a real signed-in browser and ticks the box.',
108
+ 'Per aeg-root/roles/developer.md (Verification), a PR is not mergeable while any [principal] Test Plan box is unticked.',
109
+ 'The Principal runs the item in a real signed-in browser and ticks the box.',
97
110
  'Note: editing the PR body does not retrigger most workflows. If your PR body changes do not surface here, push an empty commit to re-run.'
98
111
  )
99
112
  return { verdict: 'fail', messages }
100
113
  }
101
114
 
102
- messages.push('All Test Plan items ticked — runtime verification complete.', 'PASS.')
115
+ messages.push('All [principal] Test Plan items ticked — runtime verification complete.', 'PASS.')
103
116
  return { verdict: 'pass', messages }
104
117
  }
@@ -25,6 +25,23 @@
25
25
  * code-reviewer.md`/`security-reviewer.md` require verbatim) — tightening
26
26
  * to it closes the gap without inventing a new convention.
27
27
  *
28
+ * The VALUE is windowed to the comment's first THREE lines only
29
+ * (review-convergence-v1 task 2, round 4, `#392`) — never a line anywhere
30
+ * else in the body. Every shape this package's own callers render puts the
31
+ * `VERDICT:`/`ESCALATE:` line first and `Judged head:` third; a
32
+ * caller-supplied field (findings, conformance prose, a summary) never
33
+ * renders before line 5. Restricting the read window closes a
34
+ * caller-controlled-text injection route without narrowing what any real
35
+ * render needs matched.
36
+ *
37
+ * The CANDIDATE SET stays whole-body (review-convergence-v1 task 2, round 5,
38
+ * `#392`) — round 4 windowed where a verdict's value is read, not which
39
+ * comments count as a candidate at all. A comment whose only VERDICT-shaped
40
+ * line sits outside its first three lines is still the most recent
41
+ * candidate if it is the most recent comment; it reads as DANGLING rather
42
+ * than being skipped, so it correctly shadows an earlier clean verdict
43
+ * instead of silently letting the earlier one win.
44
+ *
28
45
  * The anchor tolerates a leading markdown EMPHASIS run — one to three `*` or
29
46
  * `_`, immediately abutting the token — so `**VERDICT: APPROVE**` and
30
47
  * `_VERDICT: APPROVE_` match (PR #636: the reviewer subagent emitted the
@@ -87,8 +104,27 @@
87
104
 
88
105
  const HEAD_SHA_PATTERN = /^[ \t]*(?:\*{1,3}|_{1,3})?Judged head:\s*([0-9a-f]{7,40})(?![A-Za-z0-9])/im
89
106
 
107
+ /**
108
+ * Round-4 ruling on `#392`: both markers are read from a comment's first
109
+ * THREE lines only, never anywhere else in the body. Every shape this
110
+ * package itself renders (`review-post.ts`'s `renderCodeReviewComment`/
111
+ * `renderSecurityComment`/`renderEscalationComment`) puts `VERDICT:`/
112
+ * `ESCALATE:` on line 1 and `Judged head:` on line 3 — every caller-supplied
113
+ * field (findings, conformance prose, scope, a summary) renders strictly
114
+ * after that, starting at line 5 at the earliest. Restricting the window to
115
+ * lines 1–3 costs no real render anything: it is a strictly narrower read
116
+ * than "anywhere in the body," and it closes the class of defect that
117
+ * motivated this ruling — a caller-supplied field that smuggled a raw
118
+ * newline followed by a `VERDICT:`- or `Judged head:`-shaped line could
119
+ * previously inject a structural line from outside the command's own
120
+ * three-line skeleton; that line can now never be read as one.
121
+ */
122
+ function firstThreeLines(comment: string): string {
123
+ return comment.split('\n').slice(0, 3).join('\n')
124
+ }
125
+
90
126
  function extractHeadSha(comment: string): string | null {
91
- const m = comment.match(HEAD_SHA_PATTERN)
127
+ const m = firstThreeLines(comment).match(HEAD_SHA_PATTERN)
92
128
  return m ? (m[1] as string).toLowerCase() : null
93
129
  }
94
130
 
@@ -102,22 +138,45 @@ function extractHeadSha(comment: string): string | null {
102
138
  */
103
139
  export type VerdictExtraction = { value: string; headSha: string | null; danglingNote: string | null }
104
140
 
141
+ /**
142
+ * Round 5 (`#392`): the candidate set — which comments even ATTEMPTED a
143
+ * verdict — is a whole-body test, the pre-round-4 pattern. Only the VALUE is
144
+ * read from the first three lines (round 4, kept). Round 4's own change
145
+ * built `clearHits` from the windowed text directly, which made a comment
146
+ * whose marker sits below line 3 vanish from consideration entirely rather
147
+ * than counting as present-but-unclear — "most recent clear hit wins" then
148
+ * silently fell through to an OLDER comment, so a later blocking verdict
149
+ * that happened to render past line 3 let an earlier clean pass win. Here,
150
+ * the most recent CANDIDATE is picked first; only then is its value read
151
+ * from its own first three lines. A candidate whose window carries no value
152
+ * is DANGLING — never skipped in favour of an earlier one — so a later
153
+ * unclear comment still shadows an earlier clean verdict, the fail-closed
154
+ * direction every other branch of this function already takes.
155
+ */
105
156
  function extractVerdict(comments: string[], valuePattern: RegExp, missingLabel: string): VerdictExtraction {
106
- const clearHits = comments.filter((c) => valuePattern.test(c))
107
- if (clearHits.length > 0) {
108
- const latest = clearHits[clearHits.length - 1] as string
109
- const m = latest.match(valuePattern) as RegExpMatchArray
157
+ const candidates = comments.filter((c) => valuePattern.test(c))
158
+ if (candidates.length === 0) {
159
+ return {
160
+ value: `no ${missingLabel} pass was run before merge — DANGLING, see below`,
161
+ headSha: null,
162
+ danglingNote: `no ${missingLabel} verdict comment found on this PR`
163
+ }
164
+ }
165
+
166
+ const latest = candidates[candidates.length - 1] as string
167
+ const m = firstThreeLines(latest).match(valuePattern)
168
+ if (!m) {
110
169
  return {
111
- value: (m[1] as string).toUpperCase().replace(/[_-]/g, ' '),
112
- headSha: extractHeadSha(latest),
113
- danglingNote: null
170
+ value: `the most recent ${missingLabel} comment's VERDICT line is not within its first three lines — DANGLING, see below`,
171
+ headSha: null,
172
+ danglingNote: `the most recent ${missingLabel} verdict comment carries a VERDICT-shaped line outside the first-three-line read window`
114
173
  }
115
174
  }
116
175
 
117
176
  return {
118
- value: `no ${missingLabel} pass was run before merge — DANGLING, see below`,
119
- headSha: null,
120
- danglingNote: `no ${missingLabel} verdict comment found on this PR`
177
+ value: (m[1] as string).toUpperCase().replace(/[_-]/g, ' '),
178
+ headSha: extractHeadSha(latest),
179
+ danglingNote: null
121
180
  }
122
181
  }
123
182
 
@@ -1 +1 @@
1
- 5xy_GA7G5KRuE3HDumIiw
1
+ QY2GhiI47-765D9DHGy1I
@@ -7,9 +7,9 @@
7
7
  "static/chunks/0cz1d0mv5g_q7.js"
8
8
  ],
9
9
  "lowPriorityFiles": [
10
- "static/5xy_GA7G5KRuE3HDumIiw/_buildManifest.js",
11
- "static/5xy_GA7G5KRuE3HDumIiw/_ssgManifest.js",
12
- "static/5xy_GA7G5KRuE3HDumIiw/_clientMiddlewareManifest.js"
10
+ "static/QY2GhiI47-765D9DHGy1I/_buildManifest.js",
11
+ "static/QY2GhiI47-765D9DHGy1I/_ssgManifest.js",
12
+ "static/QY2GhiI47-765D9DHGy1I/_clientMiddlewareManifest.js"
13
13
  ],
14
14
  "rootMainFiles": [
15
15
  "static/chunks/1hklle-my0ly_.js",
@@ -29,8 +29,8 @@
29
29
  "dynamicRoutes": {},
30
30
  "notFoundRoutes": [],
31
31
  "preview": {
32
- "previewModeId": "a3b0a9b026a44ef8737f46f409d836a2",
33
- "previewModeSigningKey": "a8c09c70586d91fcf55df81d161a79e0e79d590d89a16b9133739bc9e0cfd060",
34
- "previewModeEncryptionKey": "3e382dfd9bc6989a28fe017f6149dae6e5c3997c3f038ffb5b560030f95dace8"
32
+ "previewModeId": "6c1eff09a35d132d0b34583f9da20f05",
33
+ "previewModeSigningKey": "7dc30b59adbc55dad328594e58967a6f497c97ccf2c930ccc1625d0228f961c2",
34
+ "previewModeEncryptionKey": "7eade789aac49d9d0df75df427d7d9f0d3183a0ca892d36db2139826cc4e6529"
35
35
  }
36
36
  }