@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.
- package/README.md +1 -0
- package/dist/checks/bin/check-body-bare-digits.js +7 -1
- package/dist/checks/bin/check-branch-topology.js +27 -1
- package/dist/checks/bin/check-brief-shape.js +7 -1
- package/dist/checks/bin/check-changeset-coverage.js +7 -1
- package/dist/checks/bin/check-closes-n.js +27 -1
- package/dist/checks/bin/check-coherence.js +27 -1
- package/dist/checks/bin/check-dead-branch-push.js +7 -1
- package/dist/checks/bin/check-dispatch-readiness.js +27 -1
- package/dist/checks/bin/check-doc-coverage-push.js +7 -1
- package/dist/checks/bin/check-doc-coverage.js +7 -1
- package/dist/checks/bin/check-doctrine-no-procedures.js +7 -1
- package/dist/checks/bin/check-doctrine-portability.js +7 -1
- package/dist/checks/bin/check-evidence-fresh.js +7 -1
- package/dist/checks/bin/check-exec-bits.js +7 -1
- package/dist/checks/bin/check-first-push-dispatch.js +27 -1
- package/dist/checks/bin/check-issue-assignment.js +27 -1
- package/dist/checks/bin/check-main-branch-refusal.js +13 -2
- package/dist/checks/bin/check-no-disk-state.js +7 -1
- package/dist/checks/bin/check-pr-report-density.js +7 -1
- package/dist/checks/bin/check-quoted-command.js +38 -12
- package/dist/checks/bin/check-reader-resolvable-prose.js +38 -12
- package/dist/checks/bin/check-registry-gates.js +7 -1
- package/dist/checks/bin/check-retired-vocabulary.js +38 -12
- package/dist/checks/bin/check-review-gate.js +7 -1
- package/dist/checks/bin/check-single-plan-pr.js +7 -1
- package/dist/checks/bin/check-test-plan.js +7 -1
- package/dist/checks/bin/check-token-collection-wired.js +7 -1
- package/dist/checks/bin/check-token-report.js +7 -1
- package/dist/checks/bin/check-workspace-escape.js +7 -1
- package/dist/index.js +362 -87
- package/package.json +1 -1
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/anchored-region.ts +9 -4
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/blast-radius-domains.ts +7 -1
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-render.ts +462 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-validation.ts +405 -4
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/coherence-checks.ts +19 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/consumer-enumeration.ts +75 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dispatch-gate.ts +44 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-no-procedures.ts +94 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-portability.ts +128 -5
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/gate-audience.ts +3 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/index.ts +25 -3
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-token-report.ts +17 -5
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-report-density.ts +116 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/premise-check.ts +35 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/quoted-command.ts +3 -3
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-gate.ts +116 -7
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-status.ts +200 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-gate.ts +25 -12
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/verdict-extraction.ts +70 -11
- package/studio-standalone/apps/vinaya-studio/web/.next/BUILD_ID +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/build-manifest.json +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/prerender-manifest.json +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.html +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/api/coherence/route.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/[root-of-the-server]__02a-3g8._.js +1 -1
- 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
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0053k9k._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0112h-k._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0o771t1._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0puovz5._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1hs0dcu._.js +1 -1
- 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
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1wc4-ip._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_03x_w6q._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0gvm3og._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0lwxg63._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1fqw88f._.js +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1n0cnq-._.js +1 -1
- 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
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{0jer_drkl1e4r.js → 0zebsmmk1bvnb.js} +4 -4
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{20x-t0a0v129z.js → 1uogpj5w2n5ju.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{2ou_81ahqw31e.js → 3m1kgax7j2vgs.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/package.json +2 -2
- /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
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{5xy_GA7G5KRuE3HDumIiw → QY2GhiI47-765D9DHGy1I}/_buildManifest.js +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{5xy_GA7G5KRuE3HDumIiw → QY2GhiI47-765D9DHGy1I}/_clientMiddlewareManifest.js +0 -0
- /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
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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 =
|
|
182
|
-
const securityBound =
|
|
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
|
|
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
|
|
16
|
-
*
|
|
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 = {
|
|
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]\]
|
|
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
|
-
'
|
|
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
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
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:
|
|
112
|
-
headSha:
|
|
113
|
-
danglingNote:
|
|
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:
|
|
119
|
-
headSha:
|
|
120
|
-
danglingNote:
|
|
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
|
-
|
|
1
|
+
QY2GhiI47-765D9DHGy1I
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
"static/chunks/0cz1d0mv5g_q7.js"
|
|
8
8
|
],
|
|
9
9
|
"lowPriorityFiles": [
|
|
10
|
-
"static/
|
|
11
|
-
"static/
|
|
12
|
-
"static/
|
|
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": "
|
|
33
|
-
"previewModeSigningKey": "
|
|
34
|
-
"previewModeEncryptionKey": "
|
|
32
|
+
"previewModeId": "6c1eff09a35d132d0b34583f9da20f05",
|
|
33
|
+
"previewModeSigningKey": "7dc30b59adbc55dad328594e58967a6f497c97ccf2c930ccc1625d0228f961c2",
|
|
34
|
+
"previewModeEncryptionKey": "7eade789aac49d9d0df75df427d7d9f0d3183a0ca892d36db2139826cc4e6529"
|
|
35
35
|
}
|
|
36
36
|
}
|