@attalabs/vinaya 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -3
- package/aeg-root/contracts/archivist-tranche-archivist.md +2 -2
- package/aeg-root/contracts/brief-developer.md +1 -0
- package/aeg-root/contracts/developer-reviewer.md +3 -3
- package/aeg-root/contracts/reviewer-archivist.md +3 -3
- package/aeg-root/enforcement.md +26 -20
- package/aeg-root/process.md +8 -4
- package/aeg-root/roles/archivist.md +2 -2
- package/aeg-root/roles/brief-author.md +9 -3
- package/aeg-root/roles/developer.md +63 -31
- package/aeg-root/roles/planner.md +1 -1
- package/aeg-root/roles/principal.md +14 -0
- package/aeg-root/roles/reviewer.md +32 -15
- package/aeg-root/roles/security.md +15 -5
- package/aeg-root/skills/aeg/SKILL.md +9 -5
- package/aeg-root/skills/aeg-roles/SKILL.md +2 -2
- package/aeg-root/skills/brief-authoring/SKILL.md +39 -27
- package/aeg-root/state-machine.md +9 -11
- package/aeg-root/templates/brief-template.md +6 -6
- package/aeg-root/templates/pr-report-template.md +10 -7
- package/aeg-root/tranche-model.md +6 -2
- package/dist/checks/bin/check-body-bare-digits.js +559 -140
- package/dist/checks/bin/check-branch-topology.js +630 -145
- package/dist/checks/bin/check-brief-shape.js +600 -143
- package/dist/checks/bin/check-changeset-coverage.js +942 -162
- package/dist/checks/bin/check-closes-n.js +630 -145
- package/dist/checks/bin/check-coherence.js +641 -148
- package/dist/checks/bin/check-dead-branch-push.js +559 -140
- package/dist/checks/bin/check-dispatch-readiness.js +639 -146
- package/dist/checks/bin/check-doc-coverage-push.js +696 -219
- package/dist/checks/bin/check-doc-coverage.js +696 -219
- package/dist/checks/bin/check-doctrine-no-procedures.js +4800 -0
- package/dist/checks/bin/check-doctrine-portability.js +691 -147
- package/dist/checks/bin/check-evidence-fresh.js +1048 -222
- package/dist/checks/bin/check-exec-bits.js +4937 -0
- package/dist/checks/bin/check-first-push-dispatch.js +630 -145
- package/dist/checks/bin/check-issue-assignment.js +630 -145
- package/dist/checks/bin/check-main-branch-refusal.js +565 -141
- package/dist/checks/bin/check-no-disk-state.js +559 -140
- package/dist/checks/bin/check-pr-report-density.js +4467 -0
- package/dist/checks/bin/check-quoted-command.js +656 -164
- package/dist/checks/bin/check-reader-resolvable-prose.js +652 -163
- package/dist/checks/bin/check-registry-gates.js +602 -145
- package/dist/checks/bin/check-retired-vocabulary.js +652 -163
- package/dist/checks/bin/check-review-gate.js +677 -142
- package/dist/checks/bin/check-single-plan-pr.js +559 -140
- package/dist/checks/bin/check-test-plan.js +560 -141
- package/dist/checks/bin/check-token-collection-wired.js +559 -140
- package/dist/checks/bin/check-token-report.js +559 -140
- package/dist/checks/bin/check-workspace-escape.js +992 -146
- package/dist/index.js +8884 -5627
- package/package.json +1 -1
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/actions.ts +173 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/anchored-region.ts +118 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/archive-task.ts +206 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/baseline-capture.ts +65 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/blast-radius-domains.ts +198 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/branch-topology-gate.ts +85 -0
- 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 +912 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/claude-code-transcript.ts +460 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/coherence-checks.ts +806 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/consumer-enumeration.ts +75 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-audit.ts +55 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-guard.ts +77 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-section7.ts +66 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-tranche.ts +94 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/diagram-model.ts +372 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/direct-main-push.ts +39 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dispatch-gate.ts +268 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doc-owners.ts +314 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/build-doc-nav.ts +38 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/docs-coherence.ts +136 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/index.ts +29 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/legacy-anchors.ts +48 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/nav-helpers.ts +17 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/node-route.ts +134 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/parse-doc.ts +43 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/published-prose.ts +306 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/surfaced-manifest.ts +89 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/types.ts +33 -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 +293 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-source.ts +23 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/ensure-label.ts +28 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/file-classify.ts +51 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/first-push-dispatch-gate.ts +80 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/gate-audience.ts +171 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/index.ts +323 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-assignment.ts +102 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-validation.ts +746 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/leftover-detection.ts +68 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/local-anchor-coverage.ts +245 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/main-branch-refusal.ts +64 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/manifest-validity.ts +81 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/markdown-table.ts +76 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/metering-io-guard.ts +119 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/milestone-validation.ts +240 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/no-disk-state.ts +50 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-ledger.ts +77 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-registry.ts +105 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-token-report.ts +177 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-tranche.ts +183 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-report-density.ts +116 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-tier.ts +81 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/premise-check.ts +160 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/quoted-command.ts +275 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/reader-resolvable-prose.ts +320 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-checks.ts +255 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-parse.ts +164 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-scaffold.ts +216 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/report-tokens.ts +261 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/retired-vocabulary.ts +163 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-gate.ts +378 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-status.ts +200 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/single-plan-pr.ts +73 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-machine-model.ts +269 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-source.ts +14 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/status-block.ts +8 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/sum-ledger.ts +26 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/symbol-collisions.ts +78 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-gate.ts +117 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-section.ts +65 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/types.ts +174 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/verdict-extraction.ts +199 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/vocabulary-citation.ts +114 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/waiver-label.ts +52 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/workspace-escape.ts +172 -0
- 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]__069-_41._.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/{37hnluhzj9bei.js → 0zebsmmk1bvnb.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{267o-tsrnuwnv.js → 1uogpj5w2n5ju.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1hufpuody6vjv.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/{aeg-core → vinaya/studio-standalone/_node_modules/@attalabs/vinaya/studio-standalone/_node_modules/@attalabs/aeg-core}/bin/verify-coherence.ts +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → QY2GhiI47-765D9DHGy1I}/_buildManifest.js +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → QY2GhiI47-765D9DHGy1I}/_clientMiddlewareManifest.js +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → QY2GhiI47-765D9DHGy1I}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Required pre-merge review gate (aeg-review-gate-v1 task 1, #474). Blocks a
|
|
3
|
+
* PR from merging unless a clean code-reviewer `APPROVE` verdict
|
|
4
|
+
* AND a clean security-review `PASS` verdict both exist on the PR — the same
|
|
5
|
+
* `extractCodeReviewVerdict`/`extractSecurityReviewVerdict` detection
|
|
6
|
+
* (`verdict-extraction.ts`) the post-merge Archivist automation already runs,
|
|
7
|
+
* now gated pre-merge and blocking instead of post-merge and advisory-only.
|
|
8
|
+
*
|
|
9
|
+
* Verdict comments are ONLY counted when their author is on the same
|
|
10
|
+
* `PRINCIPAL_ALLOWLIST` the waiver actor-check trusts (security finding,
|
|
11
|
+
* PR #806): body-shape alone is never sufficient on a public repo. Unverified
|
|
12
|
+
* comments are ignored, not fatal.
|
|
13
|
+
*
|
|
14
|
+
* A verified `vinaya/waiver:review` label (the exact actor-verification pattern,
|
|
15
|
+
* `isWaiverLabelActorVerified` reused directly and parameterized by label —
|
|
16
|
+
* see `waiver-label.ts`) lets a principal explicitly skip the requirement for
|
|
17
|
+
* one PR. Label presence alone is never sufficient — only an actor-verified
|
|
18
|
+
* label waives the gate, mirroring exactly.
|
|
19
|
+
*
|
|
20
|
+
* Reviewed-commit binding (#73, a duplicate of #71 closes this one). A clean
|
|
21
|
+
* verdict is no longer sufficient on its own — it must also cover the PR's
|
|
22
|
+
* CURRENT head. Design record (the four questions this fix had to answer,
|
|
23
|
+
* argued in full in the closing PR's body):
|
|
24
|
+
*
|
|
25
|
+
* 1. Commit sha, not tree hash. `headSha` is `gh pr view --json headRefOid`
|
|
26
|
+
* verbatim — one API call, no second fetch of a commit object's tree.
|
|
27
|
+
* Matches the Issue's own proposed fix shape and the hand-typed
|
|
28
|
+
* convention reviewers were already using ("Judged head: <sha>", never a
|
|
29
|
+
* tree hash). Cost accepted: a rebase or an empty amend that leaves the
|
|
30
|
+
* tree byte-identical still invalidates every verdict — no free pass for
|
|
31
|
+
* "the code didn't really change." That is the explicit trade for a
|
|
32
|
+
* single mental model ("a verdict covers an exact commit") over a cheaper
|
|
33
|
+
* but subtler one (two different code states could share a tree).
|
|
34
|
+
* 2. Every push that changes `headRefOid` invalidates unconditionally — a
|
|
35
|
+
* rebase with an identical tree, a merge commit resolving a conflict
|
|
36
|
+
* elsewhere, and a genuine content-changing amend all produce a new sha
|
|
37
|
+
* and therefore a new required verdict. Only a push that does NOT change
|
|
38
|
+
* `headRefOid` at all (a body-only PR-description edit; a force-push that
|
|
39
|
+
* reproduces the exact same commit object) leaves an existing binding
|
|
40
|
+
* intact, because there is nothing for it to have gone stale against.
|
|
41
|
+
* 3. `headSha` is REQUIRED on `ReviewGateInput`, not optional. An optional
|
|
42
|
+
* field that silently skips the binding check when absent is fail-open —
|
|
43
|
+
* exactly the defect class this task exists to close. Every caller
|
|
44
|
+
* (`bin/verify-review-gate.ts`, `apps/cli/src/checks/bin/check-review-gate.ts`)
|
|
45
|
+
* must supply it; the type system enforces that, not a runtime default.
|
|
46
|
+
* 4. Fail-closed for the transition. The moment this merges, every verdict
|
|
47
|
+
* already posted on an open PR is unbound (it carries no `Judged head:`
|
|
48
|
+
* line) and the gate stops honouring it — expensive, and correct: an
|
|
49
|
+
* unbound verdict is exactly the property this task closes. The sanctioned
|
|
50
|
+
* escape for a PR already far along in review is the existing
|
|
51
|
+
* `vinaya/waiver:review` actor-verified label, applied by a principal, the
|
|
52
|
+
* same mechanism that already exists for any other one-off skip.
|
|
53
|
+
*
|
|
54
|
+
* Pure — no `fs`, no `fetch`, no `process.env`. The CLI shim
|
|
55
|
+
* (`bin/verify-review-gate.ts`) resolves the PR's comments/labels/label-actor/
|
|
56
|
+
* head sha via `gh` and calls `checkReviewGate`.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import { isPrincipal, isWaiverLabelActorVerified, PRINCIPAL_ALLOWLIST, WAIVER_LABEL_REVIEW } from './waiver-label'
|
|
60
|
+
import { extractCodeReviewVerdict, extractSecurityReviewVerdict } from './verdict-extraction'
|
|
61
|
+
|
|
62
|
+
export type ReviewGateVerdict = 'pass' | 'fail'
|
|
63
|
+
|
|
64
|
+
export type ReviewGateResult = {
|
|
65
|
+
verdict: ReviewGateVerdict
|
|
66
|
+
reason: string
|
|
67
|
+
waived: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type ReviewGateComment = {
|
|
71
|
+
body: string
|
|
72
|
+
/** The comment author's GitHub login, or `null` when the caller could not resolve one. */
|
|
73
|
+
author: string | null
|
|
74
|
+
}
|
|
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
|
+
|
|
83
|
+
export type ReviewGateInput = {
|
|
84
|
+
/** Every comment on the PR, with its author. */
|
|
85
|
+
comments: ReviewGateComment[]
|
|
86
|
+
/** Every label currently applied to the PR. */
|
|
87
|
+
labels: string[]
|
|
88
|
+
/** Actor of the most recent `vinaya/waiver:review` labeling timeline event, or `null` when none exists. */
|
|
89
|
+
waiverLabelActor: string | null
|
|
90
|
+
/**
|
|
91
|
+
* The PR's current head commit sha (`gh pr view --json headRefOid`),
|
|
92
|
+
* resolved from GitHub — never from local git, an env var, or the PR's own
|
|
93
|
+
* checkout, all three of which a `pull_request`-triggered workflow's
|
|
94
|
+
* PR-editable YAML could steer (#73). REQUIRED, not optional: an omitted
|
|
95
|
+
* head would have to mean either skip-the-binding-check (fail-open, the
|
|
96
|
+
* exact defect this field exists to close) or unconditional-fail, and a
|
|
97
|
+
* required field makes that choice a compile error instead of a runtime
|
|
98
|
+
* default. Every verdict must cover this value to count as clean.
|
|
99
|
+
*/
|
|
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[]
|
|
110
|
+
/**
|
|
111
|
+
* Overrides `PRINCIPAL_ALLOWLIST` for this evaluation when provided — an
|
|
112
|
+
* adopter repo's own `vinaya.config.json` `principals` field, resolved by
|
|
113
|
+
* the CLI bin before calling in (never read from here; this stays pure).
|
|
114
|
+
* Defaults to `PRINCIPAL_ALLOWLIST` when omitted, so every existing caller
|
|
115
|
+
* (this repo's own `bin/verify-review-gate.ts` included) is unaffected.
|
|
116
|
+
* `PRINCIPAL_ALLOWLIST` hardcoding this repo's own principal made the gate
|
|
117
|
+
* structurally unpassable on any adopter repo — found live on a real
|
|
118
|
+
* client repo's first dispatched task, the reviewer/security verdicts it
|
|
119
|
+
* already had counted for nobody.
|
|
120
|
+
*/
|
|
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
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Branch names are contributor-controlled metadata, so none can exempt an
|
|
156
|
+
* authority check. The former `plan/*` exemption assumed those branches
|
|
157
|
+
* contained topology docs only; a contributor could put code on a branch
|
|
158
|
+
* with that prefix and make the adapter exit before it fetched the PR. Keep
|
|
159
|
+
* this exported predicate for API compatibility, but fail closed for every
|
|
160
|
+
* branch. A future plan-only exemption would need server-derived changed-file
|
|
161
|
+
* validation, not a name prefix.
|
|
162
|
+
*/
|
|
163
|
+
export function isReviewGateExemptBranch(_branch: string): boolean {
|
|
164
|
+
return false
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* True when `extraction.headSha` covers `headSha` — an exact match, or
|
|
169
|
+
* `headSha` starting with `extraction.headSha` (the abbreviated-sha case:
|
|
170
|
+
* a verdict may bind against a 7-char prefix, and `headSha` itself is always
|
|
171
|
+
* the full 40-char form GitHub's API returns). `false` when the extraction
|
|
172
|
+
* carries no `headSha` at all (no `Judged head:` line was found) — an
|
|
173
|
+
* unbound verdict never counts as covering anything.
|
|
174
|
+
*/
|
|
175
|
+
function isBoundToHead(extraction: { headSha: string | null }, headSha: string): boolean {
|
|
176
|
+
if (!extraction.headSha) return false
|
|
177
|
+
return headSha.toLowerCase().startsWith(extraction.headSha.toLowerCase())
|
|
178
|
+
}
|
|
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
|
+
|
|
211
|
+
/**
|
|
212
|
+
* `pass` when either (a) `vinaya/waiver:review` is present and actor-verified against
|
|
213
|
+
* `PRINCIPAL_ALLOWLIST`, or (b) both verdicts are clean AND bound — code-reviewer
|
|
214
|
+
* `APPROVE` (not `REQUEST_CHANGES`, not missing, not unclear) covering the PR's
|
|
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.
|
|
225
|
+
*/
|
|
226
|
+
export function checkReviewGate(input: ReviewGateInput): ReviewGateResult {
|
|
227
|
+
const principalAllowlist = input.principalAllowlist ?? PRINCIPAL_ALLOWLIST
|
|
228
|
+
const waived = isWaiverLabelActorVerified({
|
|
229
|
+
label: WAIVER_LABEL_REVIEW,
|
|
230
|
+
labels: input.labels,
|
|
231
|
+
labelActor: input.waiverLabelActor,
|
|
232
|
+
principalAllowlist
|
|
233
|
+
})
|
|
234
|
+
if (waived) {
|
|
235
|
+
return {
|
|
236
|
+
verdict: 'pass',
|
|
237
|
+
reason: `\`${WAIVER_LABEL_REVIEW}\` label is actor-verified — review requirement waived for this PR.`,
|
|
238
|
+
waived: true
|
|
239
|
+
}
|
|
240
|
+
}
|
|
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
|
+
|
|
257
|
+
// Verdict-AUTHOR verification (security finding on PR #806): on a public
|
|
258
|
+
// repo any GitHub account can post a `VERDICT: APPROVE`-shaped comment, and
|
|
259
|
+
// most-recent-clear-hit-wins extraction would let a forged later APPROVE
|
|
260
|
+
// override a real earlier REQUEST CHANGES. Only comments whose author is on
|
|
261
|
+
// the same `PRINCIPAL_ALLOWLIST` the waiver's actor check already trusts
|
|
262
|
+
// participate in verdict extraction; everything else — unknown authors and
|
|
263
|
+
// unresolvable (`null`) ones alike — is IGNORED, never fatal, so a drive-by
|
|
264
|
+
// comment cannot brick evaluation, only fail to count. Dispatched reviewer
|
|
265
|
+
// agents post under the principal's own `gh` identity, so the legitimate
|
|
266
|
+
// flow is unchanged.
|
|
267
|
+
const verified = input.comments.filter((c) => isPrincipal(c.author, principalAllowlist))
|
|
268
|
+
// Count only VERDICT-shaped ignored comments — deployment bots and ordinary
|
|
269
|
+
// chat are also non-allowlisted, and counting them would imply forgery
|
|
270
|
+
// where there is only noise (review finding, PR #806).
|
|
271
|
+
const ignoredCount = input.comments.filter(
|
|
272
|
+
(c) => !isPrincipal(c.author, principalAllowlist) && c.body.includes('VERDICT')
|
|
273
|
+
).length
|
|
274
|
+
const verifiedBodies = verified.map((c) => c.body)
|
|
275
|
+
|
|
276
|
+
const codeReview = extractCodeReviewVerdict(verifiedBodies)
|
|
277
|
+
const security = extractSecurityReviewVerdict(verifiedBodies)
|
|
278
|
+
const codeReviewClean = codeReview.value === 'APPROVE'
|
|
279
|
+
const securityClean = security.value === 'PASS'
|
|
280
|
+
const codeReviewBound = isBoundToPatch(codeReview, input.headSha, input.patchIdOf)
|
|
281
|
+
const securityBound = isBoundToPatch(security, input.headSha, input.patchIdOf)
|
|
282
|
+
|
|
283
|
+
if (codeReviewClean && codeReviewBound && securityClean && securityBound && mechanicalChecksClean) {
|
|
284
|
+
return {
|
|
285
|
+
verdict: 'pass',
|
|
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.`,
|
|
287
|
+
waived: false
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const problems: string[] = []
|
|
292
|
+
if (!codeReviewClean) {
|
|
293
|
+
problems.push(`code-reviewer verdict is not a clean APPROVE (found: ${codeReview.value})`)
|
|
294
|
+
} else if (!codeReviewBound) {
|
|
295
|
+
problems.push(
|
|
296
|
+
`the newest code-review verdict covers ${codeReview.headSha ?? 'no recorded commit'}, head is ${input.headSha}`
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
if (!securityClean) {
|
|
300
|
+
problems.push(`security-review verdict is not a clean PASS (found: ${security.value})`)
|
|
301
|
+
} else if (!securityBound) {
|
|
302
|
+
problems.push(
|
|
303
|
+
`the newest security-review verdict covers ${security.headSha ?? 'no recorded commit'}, head is ${input.headSha}`
|
|
304
|
+
)
|
|
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
|
+
}
|
|
316
|
+
const ignoredNote =
|
|
317
|
+
ignoredCount > 0
|
|
318
|
+
? ` ${ignoredCount} verdict-shaped comment(s) from authors outside the principal allowlist were ignored.`
|
|
319
|
+
: ''
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
verdict: 'fail',
|
|
323
|
+
reason: `${problems.join('; ')}. A principal can apply an actor-verified \`${WAIVER_LABEL_REVIEW}\` label to skip this requirement, or post the missing/clean verdict comment(s).${ignoredNote}`,
|
|
324
|
+
waived: false
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Exported so a caller can cheaply pre-check the branch BEFORE paying for
|
|
330
|
+
* whatever it takes to resolve `expectedAuthor` — resolving that value is a
|
|
331
|
+
* network round-trip (`resolveReleaseActor(loadTrustAnchorConfig())`) that
|
|
332
|
+
* must not run on every ordinary PR just because it is one of three
|
|
333
|
+
* arguments to `isChangesetsReleasePr`. Found live (code review, PR #169):
|
|
334
|
+
* evaluating it inline as a function argument runs it unconditionally,
|
|
335
|
+
* regardless of branch, since JS evaluates arguments eagerly.
|
|
336
|
+
*/
|
|
337
|
+
export const CHANGESET_RELEASE_BRANCH = 'changeset-release/main'
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The stock Changesets flow's default identity — a release PR opened by
|
|
341
|
+
* `changesets/action` using the ambient `GITHUB_TOKEN` shows this as its
|
|
342
|
+
* author. Only a SANE DEFAULT for adopters who haven't configured
|
|
343
|
+
* `releaseActor` — never assume it matches any specific repo's real setup.
|
|
344
|
+
* A repo that opens release PRs with a custom PAT (this repo's own
|
|
345
|
+
* `RELEASE_TOKEN`, `.github/workflows/release.yml`) has a DIFFERENT real PR
|
|
346
|
+
* author (the token's owner) and must set `releaseActor` in its
|
|
347
|
+
* `vinaya.config.json`.
|
|
348
|
+
*/
|
|
349
|
+
export const DEFAULT_RELEASE_ACTOR = 'github-actions[bot]'
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* True only for the Changesets release PR. `branch` and `author` must both
|
|
353
|
+
* match — `branch` alone is not a trust boundary, an attacker can push a
|
|
354
|
+
* branch literally called `changeset-release/main`. `expectedAuthor` is
|
|
355
|
+
* caller-resolved, never hardcoded here — this ships in the published
|
|
356
|
+
* `@attalabs/vinaya` package, and hardcoding any one identity would be
|
|
357
|
+
* correct for at most one adopter's release-token setup. Callers resolve it
|
|
358
|
+
* via `resolveReleaseActor(loadTrustAnchorConfig())`, falling back to
|
|
359
|
+
* `DEFAULT_RELEASE_ACTOR`.
|
|
360
|
+
*
|
|
361
|
+
* This predicate is pure and trust-agnostic about ITS OWN inputs — the
|
|
362
|
+
* caller is entirely responsible for where `branch`/`author` come from.
|
|
363
|
+
* `check-body-bare-digits.ts` is the one caller, and only reachable in
|
|
364
|
+
* production from `vinaya-body-checks.yml`'s `pull_request_target` job:
|
|
365
|
+
* `branch`/`author` there come from a live `gh pr view <PR_NUMBER>` fetch,
|
|
366
|
+
* with `PR_NUMBER` itself sourced from `github.event.pull_request.number` —
|
|
367
|
+
* an expression evaluated from the workflow file on the DEFAULT BRANCH, a
|
|
368
|
+
* pull request cannot edit that file to substitute a different literal
|
|
369
|
+
* (the same `pull_request_target` boundary `vinaya-review.yml` already
|
|
370
|
+
* uses). Found live (round 5, PR #165): the identical exemption on a plain
|
|
371
|
+
* `pull_request` trigger let an attacker redirect `PR_NUMBER`/`BRANCH` to
|
|
372
|
+
* any already-approved PR by the configured release actor — verified no
|
|
373
|
+
* env-var or git-state signal inside that trigger type is a safe anchor,
|
|
374
|
+
* which is why this predicate never runs there again.
|
|
375
|
+
*/
|
|
376
|
+
export function isChangesetsReleasePr(branch: string, author: string | null, expectedAuthor: string): boolean {
|
|
377
|
+
return branch === CHANGESET_RELEASE_BRANCH && author === expectedAuthor
|
|
378
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-plan-PR guard predicate (task 19 / #336). Extracted from
|
|
3
|
+
* `bin/open-pr.ts` (aeg-governance-hardening task 24, #364, Part 1) so the
|
|
4
|
+
* exact same implementation can be consumed by both the ring-0 local wrapper
|
|
5
|
+
* (`open-pr.ts`, prevention) and a ring-1 CI check (detection, for PRs opened
|
|
6
|
+
* via the web UI that bypass the wrapper entirely) — one implementation per
|
|
7
|
+
* fact (§11 constraint), never a second copy of this predicate.
|
|
8
|
+
*
|
|
9
|
+
* Pure — no `fs`, no `gh`/`git` I/O. Callers gather the facts (this branch's
|
|
10
|
+
* touched files, every other open PR's touched files) and pass them in.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parses a tranche slug from a touched file path, when that path is an
|
|
15
|
+
* active (non-`completed/`) tranche topology file. Returns `null` for
|
|
16
|
+
* everything else — including `README.md` and `*.tokens.md`, neither of
|
|
17
|
+
* which is a topology file the single-plan-PR guard (below) cares about.
|
|
18
|
+
*/
|
|
19
|
+
export function trancheSlugFromTopologyPath(path: string): string | null {
|
|
20
|
+
const m = path.match(/^aeg-root\/tranches\/([^/]+)\.md$/)
|
|
21
|
+
if (!m) return null
|
|
22
|
+
const slug = m[1] as string
|
|
23
|
+
if (slug === 'README' || slug.endsWith('.tokens')) return null
|
|
24
|
+
return slug
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type OpenPrFiles = { number: number; files: string[] }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Single-plan-PR guard (task 19 / #336): refuses a plan-branch diff
|
|
31
|
+
* that touches a tranche's topology file when another OPEN PR's diff
|
|
32
|
+
* already touches that SAME tranche's topology file. Ends the plan-PR
|
|
33
|
+
* race that produced two concurrent plan PRs for `aeg-governance-hardening`
|
|
34
|
+
* itself (#352/#354) — each cut from `origin/main` unaware of the other's
|
|
35
|
+
* newly-cut Issue.
|
|
36
|
+
*
|
|
37
|
+
* `branchFiles` is this branch's diff vs `origin/main` (or vs the PR's
|
|
38
|
+
* base); `otherOpenPrs` is every other currently-open PR's touched files
|
|
39
|
+
* (the caller excludes this PR's own number when editing). An ordinary
|
|
40
|
+
* Dormant where plans are forge objects: with a tranche held as a
|
|
41
|
+
* Milestone plus labeled Issues, no plan diff touches a topology file and
|
|
42
|
+
* this predicate has nothing to compare. It stays because it is still the
|
|
43
|
+
* right guard for a repo that keeps plans as files — a dormant check that
|
|
44
|
+
* says so is honest; one that reads as live protection is not.
|
|
45
|
+
*
|
|
46
|
+
* task-branch PR touches no topology file at all, so `branchFiles` yields
|
|
47
|
+
* no slugs and this passes trivially without even needing `otherOpenPrs`.
|
|
48
|
+
*/
|
|
49
|
+
export function checkSinglePlanPr(
|
|
50
|
+
branchFiles: string[],
|
|
51
|
+
otherOpenPrs: OpenPrFiles[]
|
|
52
|
+
): { ok: boolean; message?: string } {
|
|
53
|
+
const touchedSlugs = new Set(branchFiles.map(trancheSlugFromTopologyPath).filter((s): s is string => s !== null))
|
|
54
|
+
if (touchedSlugs.size === 0) return { ok: true }
|
|
55
|
+
|
|
56
|
+
for (const pr of otherOpenPrs) {
|
|
57
|
+
const otherSlugs = new Set(pr.files.map(trancheSlugFromTopologyPath).filter((s): s is string => s !== null))
|
|
58
|
+
for (const slug of touchedSlugs) {
|
|
59
|
+
if (otherSlugs.has(slug)) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
message: `single-plan-pr: another open PR (#${pr.number}) already touches tranche "${slug}"'s topology file. Only one open plan PR per tranche is allowed at a time — wait for #${pr.number} to merge or close, or coordinate with its author.`
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { ok: true }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True when any of `files` touches an active tranche's topology file — i.e. this diff is a plan-PR diff. */
|
|
71
|
+
export function touchesAnyTopology(files: string[]): boolean {
|
|
72
|
+
return files.some((f) => trancheSlugFromTopologyPath(f) !== null)
|
|
73
|
+
}
|