@codyswann/lisa 4.33.5 → 4.33.8
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/dist/cli/apply.d.ts.map +1 -1
- package/dist/cli/apply.js +8 -1
- package/dist/cli/apply.js.map +1 -1
- package/dist/core/apply-receipt.d.ts +18 -0
- package/dist/core/apply-receipt.d.ts.map +1 -1
- package/dist/core/apply-receipt.js +7 -5
- package/dist/core/apply-receipt.js.map +1 -1
- package/dist/core/config.d.ts +34 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +8 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/lisa.d.ts +75 -0
- package/dist/core/lisa.d.ts.map +1 -1
- package/dist/core/lisa.js +125 -6
- package/dist/core/lisa.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +22 -7
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/core/workflow-deletion-ownership.d.ts +69 -0
- package/dist/core/workflow-deletion-ownership.d.ts.map +1 -0
- package/dist/core/workflow-deletion-ownership.js +156 -0
- package/dist/core/workflow-deletion-ownership.js.map +1 -0
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-intake-explain/SKILL.md +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-repair-intake/SKILL.md +32 -20
- package/plugins/lisa/.codex-plugin/skills/lisa-ticket-triage/SKILL.md +1 -1
- package/plugins/lisa/rules/eager/blocker-containment.md +44 -0
- package/plugins/lisa/rules/reference/blocker-containment.md +92 -0
- package/plugins/lisa/skills/lisa-intake-explain/SKILL.md +1 -1
- package/plugins/lisa/skills/lisa-repair-intake/SKILL.md +32 -20
- package/plugins/lisa/skills/lisa-ticket-triage/SKILL.md +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-intake-explain/SKILL.md +1 -1
- package/plugins/lisa-agy/skills/lisa-repair-intake/SKILL.md +32 -20
- package/plugins/lisa-agy/skills/lisa-ticket-triage/SKILL.md +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/rules/eager/blocker-containment.md +44 -0
- package/plugins/lisa-copilot/rules/reference/blocker-containment.md +92 -0
- package/plugins/lisa-copilot/skills/lisa-intake-explain/SKILL.md +1 -1
- package/plugins/lisa-copilot/skills/lisa-repair-intake/SKILL.md +32 -20
- package/plugins/lisa-copilot/skills/lisa-ticket-triage/SKILL.md +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/rules/blocker-containment-reference.mdc +97 -0
- package/plugins/lisa-cursor/rules/blocker-containment.mdc +49 -0
- package/plugins/lisa-cursor/skills/lisa-intake-explain/SKILL.md +1 -1
- package/plugins/lisa-cursor/skills/lisa-repair-intake/SKILL.md +32 -20
- package/plugins/lisa-cursor/skills/lisa-ticket-triage/SKILL.md +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/rules/eager/blocker-containment.md +44 -0
- package/plugins/src/base/rules/reference/blocker-containment.md +92 -0
- package/plugins/src/base/skills/lisa-intake-explain/SKILL.md +1 -1
- package/plugins/src/base/skills/lisa-repair-intake/SKILL.md +32 -20
- package/plugins/src/base/skills/lisa-ticket-triage/SKILL.md +1 -1
- package/typescript/copy-overwrite/.github/GITHUB_ACTIONS.md +2 -2
- package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +414 -6
- package/typescript/create-only/.github/workflows/review-evidence.yml +78 -3
- package/typescript/deletions.json +8 -1
|
@@ -1005,28 +1005,40 @@ is no normalized `is blocked by` field. Read the bundle, then extract blockers p
|
|
|
1005
1005
|
- **Linear**: inspect the native issue **relations** from Linear MCP `get_issue` and select
|
|
1006
1006
|
blocker relations.
|
|
1007
1007
|
|
|
1008
|
-
Then classify each blocker
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1008
|
+
Then classify each blocker **by containment, per the `blocker-containment` rule** — cite that
|
|
1009
|
+
slug for the full contract; do not restate its resolution steps or its fail-closed table here.
|
|
1010
|
+
|
|
1011
|
+
The one question is whether the blocker's code is on the branch **this item** will be built from:
|
|
1012
|
+
resolve this item's base branch from its `## Target Backend Environment` through
|
|
1013
|
+
`.lisa.config.json` `deploy.branches` (`derived-branch-plan`, `config-resolution` — never by
|
|
1014
|
+
reading a rendered `Branch Plan`), resolve the blocker's merge commit through the vendor access
|
|
1015
|
+
layer, and test ancestry against the remote.
|
|
1016
|
+
|
|
1017
|
+
- **Contained** — every merged PR on the blocker is an ancestor of this item's base branch →
|
|
1018
|
+
**cleared**.
|
|
1019
|
+
- **Not contained**, or containment **not computable** for any reason the rule enumerates (no
|
|
1020
|
+
linked PR, PR not merged, blocker inaccessible, base branch underivable or absent from the
|
|
1021
|
+
remote, ancestry query errored) → **still blocking**, with the rule's reason key in the run
|
|
1022
|
+
record.
|
|
1023
|
+
- **Ships no code** — the blocker is closed as completed, positively declares
|
|
1024
|
+
`Target Backend Environment: None — no runtime behavior change`, and has no merged PR →
|
|
1025
|
+
**cleared** under the rule's carve-out. This is a positive determination, never an absence:
|
|
1026
|
+
"no PR found" is `no-pr` and stays blocking.
|
|
1027
|
+
- **Human override** — an explicit human statement on this item that the dependency is satisfied
|
|
1028
|
+
clears it and outranks a failed containment check. Name it in the run record.
|
|
1029
|
+
|
|
1030
|
+
**A lifecycle status name is never the test.** `done`, `on-stg`, `on-dev` and `code-review` are
|
|
1031
|
+
labels that correlate with containment in a single-trunk workflow and come apart from it in a
|
|
1032
|
+
promotion workflow, where `main` is the trailing branch — a blocker at `On Stg` is on `staging`
|
|
1033
|
+
and absent from `main`, and a blocker in `Code Review` is merged to nothing. Containment gets
|
|
1034
|
+
both the loose and the strict failure right at once: a blocker merged into the branch this item
|
|
1035
|
+
builds from clears **immediately**, with no wait for production, so a dependent is never stranded
|
|
1036
|
+
behind work already merged where it needs it.
|
|
1026
1037
|
|
|
1027
1038
|
Only re-dispatch when **every** parsed blocker is cleared. When in doubt, stay blocked — a
|
|
1028
|
-
false-negative (left blocked) is
|
|
1029
|
-
|
|
1039
|
+
false-negative (left blocked) costs one cycle and is visible; a false-positive is **invisible**,
|
|
1040
|
+
because the item moves to `ready`, which looks exactly like a repair that worked, and the failure
|
|
1041
|
+
surfaces days later as work begun before its dependency existed.
|
|
1030
1042
|
|
|
1031
1043
|
### Class B — validation / quality-gate self-block
|
|
1032
1044
|
|
|
@@ -46,7 +46,7 @@ If relevant code IS found, proceed to Phase 2.
|
|
|
46
46
|
|
|
47
47
|
From the context bundle, evaluate relationships before analyzing this ticket in isolation:
|
|
48
48
|
|
|
49
|
-
- **Open blockers (`is blocked by`)**:
|
|
49
|
+
- **Open blockers (`is blocked by`)**: clear-check each blocker **by containment, per the `blocker-containment` rule** — cite that slug for the full contract rather than restating it. A blocker is satisfied when its merged PRs are ancestors of the branch this ticket will be built from (resolved from this ticket's `## Target Backend Environment` through `.lisa.config.json` `deploy.branches`, never from a rendered `Branch Plan`), or when the rule's narrow no-code carve-out or human override applies. **`Done` is not the test** — a state name holds in a single-trunk workflow and comes apart in a promotion workflow, and requiring the production terminal strands every dependent behind work already merged where this ticket needs it. This is the same predicate the `DUPLICATE_ALREADY_FIXED` bullet below already requires ("present on the relevant base branch… never from a name/label match alone"), applied here too. For a blocker that is not contained, or where containment is not computable, raise an ambiguity naming the rule's reason key: "Blocker {KEY} is not on {BRANCH} ({REASON}) — work cannot meaningfully start." That is an automatic `BLOCKED` verdict unless the human confirms the blocker state is acceptable.
|
|
50
50
|
- **Epic siblings in progress**: if a sibling under the same epic is `In Progress` / `In Review` with a different assignee and overlapping scope, raise it as an edge case in Phase 4 ("Duplicate-work risk with {KEY}").
|
|
51
51
|
- **`duplicates` / `is duplicated by` links**:
|
|
52
52
|
- If this ticket is a duplicate of an open canonical ticket whose fix is not yet merged into the base branch, verdict is `BLOCKED` with the recommendation to close as duplicate manually rather than implement.
|
|
@@ -45,6 +45,8 @@ For full functionality, configure the following secrets:
|
|
|
45
45
|
|--------|---------|--------------|
|
|
46
46
|
| `EXPO_TOKEN` | EAS builds and updates | Mobile deployment |
|
|
47
47
|
| `SENTRY_AUTH_TOKEN` | Error tracking | Release monitoring |
|
|
48
|
+
| `SENTRY_ORG` | Sentry organization slug | Release monitoring |
|
|
49
|
+
| `SENTRY_PROJECT` | Sentry project slug | Release monitoring |
|
|
48
50
|
| `SONAR_TOKEN` | Code quality analysis | Security scanning |
|
|
49
51
|
| `SNYK_TOKEN` | Vulnerability scanning | Security scanning |
|
|
50
52
|
|
|
@@ -535,8 +537,6 @@ Variables are non-sensitive configuration values. Set them in **Settings** > **S
|
|
|
535
537
|
| `JIRA_BASE_URL` | Jira instance base URL (enables Jira triage workflow) | `https://company.atlassian.net` |
|
|
536
538
|
| `JIRA_USER_EMAIL` | Email associated with the Jira API token | `user@company.com` |
|
|
537
539
|
| `JIRA_PROJECT_KEY` | Jira project key for ticket queries | `PROJ` |
|
|
538
|
-
| `SENTRY_ORG` | Sentry organization slug | `my-company` |
|
|
539
|
-
| `SENTRY_PROJECT` | Sentry project slug | `frontend-app` |
|
|
540
540
|
|
|
541
541
|
## External Service Setup
|
|
542
542
|
|
|
@@ -487,6 +487,40 @@ export const SETTLE_INTERVAL_SECONDS = 15;
|
|
|
487
487
|
/** Enables the blocking review-evidence policy. */
|
|
488
488
|
const REQUIRE_REVIEW_EVIDENCE_FLAG = "--require-review-evidence";
|
|
489
489
|
|
|
490
|
+
/**
|
|
491
|
+
* Selects how many recently merged pull requests the waive-rate sample reads.
|
|
492
|
+
*
|
|
493
|
+
* Zero — the shipped default — disables the sample entirely, so no repository
|
|
494
|
+
* acquires a network read it did not ask for. The workflow that ships with Lisa
|
|
495
|
+
* passes a value, because the number is the point: `.github/workflows/
|
|
496
|
+
* review-evidence.yml` recorded "of the last 40 merged pull requests, 39 waive"
|
|
497
|
+
* in a COMMENT, where it changed nothing for eight months. A rate that an
|
|
498
|
+
* operator reads at merge time is a different artefact from a rate somebody
|
|
499
|
+
* once measured.
|
|
500
|
+
*/
|
|
501
|
+
const WAIVE_RATE_SAMPLE_FLAG = "--waive-rate-sample";
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The one call the waive-rate sample makes.
|
|
505
|
+
*
|
|
506
|
+
* Kept beside the flag rather than inline in the reader so the network shape is
|
|
507
|
+
* visible without reading a function: merged pull requests, newest first, each
|
|
508
|
+
* with its head commit's rollup contexts. `StatusContext` and `CheckRun` are
|
|
509
|
+
* both selected because a review bot may report as either and the description —
|
|
510
|
+
* `description` on one, `title` on the other — is the only field that separates
|
|
511
|
+
* a review from a waiver.
|
|
512
|
+
*/
|
|
513
|
+
const RECENT_MERGED_CHECKS_QUERY = [
|
|
514
|
+
"query($owner:String!,$name:String!,$limit:Int!){",
|
|
515
|
+
"repository(owner:$owner,name:$name){",
|
|
516
|
+
"pullRequests(states:MERGED,first:$limit,orderBy:{field:UPDATED_AT,direction:DESC}){",
|
|
517
|
+
"nodes{number commits(last:1){nodes{commit{statusCheckRollup{contexts(last:100){",
|
|
518
|
+
"nodes{__typename",
|
|
519
|
+
" ... on StatusContext{context state description}",
|
|
520
|
+
" ... on CheckRun{name conclusion title}",
|
|
521
|
+
"}}}}}}}}}}",
|
|
522
|
+
].join("");
|
|
523
|
+
|
|
490
524
|
/**
|
|
491
525
|
* The flag that used to select the live-ruleset drift mode, retired with it.
|
|
492
526
|
*
|
|
@@ -1284,13 +1318,18 @@ export function reviewGateState(reading, vocabulary = {}) {
|
|
|
1284
1318
|
* @param {object} declaration - The per-repo declaration
|
|
1285
1319
|
* @param {ReadonlyArray<{name: string, state: string, description?: string}>} checks - The checks
|
|
1286
1320
|
* @param {{headSha?: string}} [options] - `headSha` is cited in every finding
|
|
1287
|
-
* @returns {{violations: object[], states: Record<string, string>, checked: number}} Findings, per-check state, and how many were examined
|
|
1321
|
+
* @returns {{violations: object[], states: Record<string, string>, descriptions: Record<string, string>, checked: number}} Findings, per-check state, the description each verdict was read from, and how many were examined
|
|
1288
1322
|
*/
|
|
1289
1323
|
export function evaluateReviewGate(declaration, checks, options = {}) {
|
|
1290
1324
|
const declared = declaration.evidence_bearing_checks ?? {};
|
|
1291
1325
|
const at = citeHeadSha(options.headSha);
|
|
1292
1326
|
const violations = [];
|
|
1293
1327
|
const states = {};
|
|
1328
|
+
// Carried out with the states because the RENDERED verdict quotes it. A title
|
|
1329
|
+
// that said only "WAIVED" would tell an operator that something was waived
|
|
1330
|
+
// and not which vendor sentence did the waiving, which is the fact that says
|
|
1331
|
+
// whether to wait for the entitlement or merge on the waiver.
|
|
1332
|
+
const descriptions = {};
|
|
1294
1333
|
let checked = 0;
|
|
1295
1334
|
|
|
1296
1335
|
for (const [name, entry] of Object.entries(declared)) {
|
|
@@ -1308,6 +1347,13 @@ export function evaluateReviewGate(declaration, checks, options = {}) {
|
|
|
1308
1347
|
vocabulary
|
|
1309
1348
|
);
|
|
1310
1349
|
states[name] = verdict.state;
|
|
1350
|
+
// Assigned ONLY when the check reported. An absent check with a `""`
|
|
1351
|
+
// description entry renders as `CodeRabbit reported ""` — a sentence that
|
|
1352
|
+
// says it reported. MEASURED on this fix's own first commit, where the
|
|
1353
|
+
// published verdict read exactly that. Absence is not a quiet report, and
|
|
1354
|
+
// the whole file turns on the two being different facts.
|
|
1355
|
+
if (found !== undefined)
|
|
1356
|
+
descriptions[name] = String(found.description ?? "");
|
|
1311
1357
|
if (verdict.state === REVIEW_GATE_STATES.satisfied) continue;
|
|
1312
1358
|
violations.push({
|
|
1313
1359
|
kind:
|
|
@@ -1320,7 +1366,295 @@ export function evaluateReviewGate(declaration, checks, options = {}) {
|
|
|
1320
1366
|
});
|
|
1321
1367
|
}
|
|
1322
1368
|
|
|
1323
|
-
return { violations, states, checked };
|
|
1369
|
+
return { violations, states, descriptions, checked };
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* The check-run conclusion each review-gate verdict is published under.
|
|
1374
|
+
*
|
|
1375
|
+
* THIS IS THE WHOLE POINT OF #3639. Before this map existed the gate's three
|
|
1376
|
+
* verdicts all reached a merge decision through the same rendering: the job
|
|
1377
|
+
* exited 0 for `satisfied` AND for `waived`, so `gh pr checks` printed
|
|
1378
|
+
*
|
|
1379
|
+
* 🕵️ Did the required review checks do any work? pass
|
|
1380
|
+
*
|
|
1381
|
+
* for a pull request nothing had read, character for character what it prints
|
|
1382
|
+
* for one a reviewer completed. The distinction lived in the run log, and the
|
|
1383
|
+
* file's own header had already measured how little that is worth: of the last
|
|
1384
|
+
* 40 merged pull requests, 39 waived. A waiver that applies to 39 of 40 merges
|
|
1385
|
+
* is the default path, and the default path rendering as the success path
|
|
1386
|
+
* reproduces — on the guard's own green — the exact indistinguishability the
|
|
1387
|
+
* guard exists to remove.
|
|
1388
|
+
*
|
|
1389
|
+
* `neutral` is the mechanism because it is the one conclusion that is NOT a
|
|
1390
|
+
* pass and NOT a failure. `gh pr checks` buckets NEUTRAL as `skipping`, so the
|
|
1391
|
+
* row reads differently at a glance, while contributing neither a failure to
|
|
1392
|
+
* gh's exit code nor a satisfied context to branch protection. The waiver keeps
|
|
1393
|
+
* its rationale — a pull request author cannot fix a vendor's billing state,
|
|
1394
|
+
* and reddening every PR on it would be a worse gate — and loses only its
|
|
1395
|
+
* disguise.
|
|
1396
|
+
*/
|
|
1397
|
+
export const REVIEW_VERDICT_CONCLUSIONS = Object.freeze({
|
|
1398
|
+
satisfied: "success",
|
|
1399
|
+
waived: "neutral",
|
|
1400
|
+
unsatisfied: "failure",
|
|
1401
|
+
uninspected: "failure",
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
/** GitHub truncates a check-run output title past this many characters. */
|
|
1405
|
+
export const REVIEW_VERDICT_TITLE_LIMIT = 255;
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Flattens a title to one line and fits it in a check-run output title.
|
|
1409
|
+
*
|
|
1410
|
+
* Single-line is a correctness requirement, not tidiness: this string is
|
|
1411
|
+
* written to `$GITHUB_OUTPUT` as `key=value`, where a newline in the value ends
|
|
1412
|
+
* the assignment and the remainder is parsed as further output keys. A vendor
|
|
1413
|
+
* description is arbitrary text from outside this repository.
|
|
1414
|
+
*
|
|
1415
|
+
* @param {string} text - The composed title
|
|
1416
|
+
* @returns {string} One line, at most {@link REVIEW_VERDICT_TITLE_LIMIT} chars
|
|
1417
|
+
*/
|
|
1418
|
+
function fitTitle(text) {
|
|
1419
|
+
const flat = String(text).replace(/\s+/gu, " ").trim();
|
|
1420
|
+
return flat.length <= REVIEW_VERDICT_TITLE_LIMIT
|
|
1421
|
+
? flat
|
|
1422
|
+
: `${flat.slice(0, REVIEW_VERDICT_TITLE_LIMIT - 1)}…`;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* The waive-rate sentence appended to a verdict title, or nothing.
|
|
1427
|
+
*
|
|
1428
|
+
* @param {{waived: number, sampled: number}|undefined} rate - A sampled tally
|
|
1429
|
+
* @returns {string} The sentence, or an empty string when nothing was sampled
|
|
1430
|
+
*/
|
|
1431
|
+
function waiveRateSuffix(rate) {
|
|
1432
|
+
return rate === undefined || rate.sampled === 0
|
|
1433
|
+
? ""
|
|
1434
|
+
: ` · waived on ${rate.waived} of the last ${rate.sampled} merged pull requests`;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Renders one merge-time verdict for every declared evidence-bearing check.
|
|
1439
|
+
*
|
|
1440
|
+
* Worst-wins across the declared checks, in the order `unsatisfied` > `waived`
|
|
1441
|
+
* > `satisfied`. One waived check among four satisfied ones still means this
|
|
1442
|
+
* pull request has an unreviewed arm, and a verdict that averaged them would be
|
|
1443
|
+
* the summary that hides the finding.
|
|
1444
|
+
*
|
|
1445
|
+
* Pure, and deliberately separate from the publishing step, so the property
|
|
1446
|
+
* that matters — a waiver and a satisfaction never render the same — is
|
|
1447
|
+
* testable without a GitHub API.
|
|
1448
|
+
*
|
|
1449
|
+
* @param {{states?: Record<string, string>, descriptions?: Record<string, string>, refusal?: {kind: string}|null, waiveRate?: {waived: number, sampled: number}}} reading -
|
|
1450
|
+
* The gate's per-check states, the descriptions they were read from, any
|
|
1451
|
+
* refusal, and an optional sampled waive rate
|
|
1452
|
+
* @returns {{verdict: string, conclusion: string, title: string}} What to publish
|
|
1453
|
+
*/
|
|
1454
|
+
export function reviewGateVerdict(reading = {}) {
|
|
1455
|
+
const states = reading.states ?? {};
|
|
1456
|
+
const descriptions = reading.descriptions ?? {};
|
|
1457
|
+
const names = Object.keys(states);
|
|
1458
|
+
const suffix = waiveRateSuffix(reading.waiveRate);
|
|
1459
|
+
|
|
1460
|
+
if (reading.refusal || names.length === 0) {
|
|
1461
|
+
return {
|
|
1462
|
+
verdict: "uninspected",
|
|
1463
|
+
conclusion: REVIEW_VERDICT_CONCLUSIONS.uninspected,
|
|
1464
|
+
title: fitTitle(
|
|
1465
|
+
`NOT INSPECTED — no review evidence was read${reading.refusal ? ` (${reading.refusal.kind})` : ""}. NOBODY LOOKED is not the same fact as a clean review.`
|
|
1466
|
+
),
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* Names one check alongside the exact description its verdict was read from.
|
|
1472
|
+
*
|
|
1473
|
+
* A check with no entry never REPORTED, and says so. `reported ""` for a
|
|
1474
|
+
* check that posted nothing is the one phrasing this whole file exists to
|
|
1475
|
+
* refuse: it makes absence look like a quiet answer.
|
|
1476
|
+
*
|
|
1477
|
+
* @param {string} name - The declared check name
|
|
1478
|
+
* @returns {string} `<name> reported "<description>"`, or that it posted none
|
|
1479
|
+
*/
|
|
1480
|
+
const quote = name =>
|
|
1481
|
+
Object.hasOwn(descriptions, name)
|
|
1482
|
+
? `${name} reported ${JSON.stringify(descriptions[name])}`
|
|
1483
|
+
: `${name} posted NO review status at all`;
|
|
1484
|
+
|
|
1485
|
+
const unsatisfied = names.filter(
|
|
1486
|
+
name => states[name] === REVIEW_GATE_STATES.unsatisfied
|
|
1487
|
+
);
|
|
1488
|
+
if (unsatisfied.length > 0) {
|
|
1489
|
+
return {
|
|
1490
|
+
verdict: REVIEW_GATE_STATES.unsatisfied,
|
|
1491
|
+
conclusion: REVIEW_VERDICT_CONCLUSIONS.unsatisfied,
|
|
1492
|
+
title: fitTitle(
|
|
1493
|
+
`UNREVIEWED — review evidence unsatisfied: ${unsatisfied.map(quote).join("; ")}${suffix}`
|
|
1494
|
+
),
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
const waived = names.filter(
|
|
1499
|
+
name => states[name] === REVIEW_GATE_STATES.waived
|
|
1500
|
+
);
|
|
1501
|
+
if (waived.length > 0) {
|
|
1502
|
+
return {
|
|
1503
|
+
verdict: REVIEW_GATE_STATES.waived,
|
|
1504
|
+
conclusion: REVIEW_VERDICT_CONCLUSIONS.waived,
|
|
1505
|
+
title: fitTitle(
|
|
1506
|
+
`WAIVED — this pull request is UNREVIEWED and merging it is a decision taken on that basis: ${waived.map(quote).join("; ")}${suffix}`
|
|
1507
|
+
),
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
return {
|
|
1512
|
+
verdict: REVIEW_GATE_STATES.satisfied,
|
|
1513
|
+
conclusion: REVIEW_VERDICT_CONCLUSIONS.satisfied,
|
|
1514
|
+
title: fitTitle(`REVIEWED — ${names.map(quote).join("; ")}${suffix}`),
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Counts how a sample of pull requests ended up, worst-wins per pull request.
|
|
1520
|
+
*
|
|
1521
|
+
* A pull request whose declared checks produced no state at all is not counted
|
|
1522
|
+
* as reviewed OR waived — it is not counted. A denominator that quietly absorbs
|
|
1523
|
+
* unreadable pull requests reports a lower waive rate than the truth, which is
|
|
1524
|
+
* the direction that makes the number reassuring rather than useful.
|
|
1525
|
+
*
|
|
1526
|
+
* @param {ReadonlyArray<Record<string, string>>} samples - One states map per pull request
|
|
1527
|
+
* @returns {{sampled: number, waived: number, satisfied: number, unsatisfied: number}} The tally
|
|
1528
|
+
*/
|
|
1529
|
+
export function summarizeWaiveRate(samples) {
|
|
1530
|
+
const tally = { sampled: 0, waived: 0, satisfied: 0, unsatisfied: 0 };
|
|
1531
|
+
for (const states of samples) {
|
|
1532
|
+
const values = Object.values(states ?? {});
|
|
1533
|
+
if (values.length === 0) continue;
|
|
1534
|
+
tally.sampled += 1;
|
|
1535
|
+
if (values.includes(REVIEW_GATE_STATES.unsatisfied)) tally.unsatisfied += 1;
|
|
1536
|
+
else if (values.includes(REVIEW_GATE_STATES.waived)) tally.waived += 1;
|
|
1537
|
+
else tally.satisfied += 1;
|
|
1538
|
+
}
|
|
1539
|
+
return tally;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* Reads the declared evidence-bearing rows for recently merged pull requests.
|
|
1544
|
+
*
|
|
1545
|
+
* ONE GraphQL call, not one REST call per pull request. A per-PR loop is ~2N
|
|
1546
|
+
* network reads inside a job that is already spending five minutes waiting for
|
|
1547
|
+
* a review bot to settle, and a sampler that can time the job out would be
|
|
1548
|
+
* deleted rather than read.
|
|
1549
|
+
*
|
|
1550
|
+
* `orderBy: UPDATED_AT` rather than a merge timestamp, and the difference is
|
|
1551
|
+
* worth naming: this is "recently touched merged pull requests", a proxy for
|
|
1552
|
+
* "recently merged" that the single-call shape buys. The number it produces is
|
|
1553
|
+
* an order-of-magnitude reading of how often the waiver is the path — which is
|
|
1554
|
+
* what an operator needs — not an audit figure.
|
|
1555
|
+
*
|
|
1556
|
+
* @param {number} limit - How many merged pull requests to read
|
|
1557
|
+
* @param {string} [repo] - `OWNER/NAME`; defaults to the current repository
|
|
1558
|
+
* @returns {Array<Array<{name: string, state: string, description: string}>>} Rows per pull request
|
|
1559
|
+
* @throws {Error} When the slug cannot be resolved or `gh` cannot answer
|
|
1560
|
+
*/
|
|
1561
|
+
export function fetchRecentMergedChecks(limit, repo) {
|
|
1562
|
+
const slug = resolveRepoSlug(repo);
|
|
1563
|
+
if (slug === undefined) {
|
|
1564
|
+
throw new Error(
|
|
1565
|
+
"check-skipped-required-checks: the waive-rate sample needs an OWNER/NAME. Pass `--repo=OWNER/NAME`, or set GITHUB_REPOSITORY."
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1568
|
+
const [owner, name] = slug.split("/");
|
|
1569
|
+
const raw = boundedExecFileSync(
|
|
1570
|
+
"gh",
|
|
1571
|
+
[
|
|
1572
|
+
"api",
|
|
1573
|
+
"graphql",
|
|
1574
|
+
"-f",
|
|
1575
|
+
`query=${RECENT_MERGED_CHECKS_QUERY}`,
|
|
1576
|
+
"-F",
|
|
1577
|
+
`owner=${owner}`,
|
|
1578
|
+
"-F",
|
|
1579
|
+
`name=${name}`,
|
|
1580
|
+
"-F",
|
|
1581
|
+
`limit=${limit}`,
|
|
1582
|
+
],
|
|
1583
|
+
{ encoding: "utf8" }
|
|
1584
|
+
);
|
|
1585
|
+
const nodes = JSON.parse(raw)?.data?.repository?.pullRequests?.nodes ?? [];
|
|
1586
|
+
return nodes.map(node =>
|
|
1587
|
+
(
|
|
1588
|
+
node?.commits?.nodes?.[0]?.commit?.statusCheckRollup?.contexts?.nodes ??
|
|
1589
|
+
[]
|
|
1590
|
+
).map(context =>
|
|
1591
|
+
context?.__typename === "CheckRun"
|
|
1592
|
+
? normalizeCheckRow(context.name, context.conclusion, context.title)
|
|
1593
|
+
: normalizeCheckRow(
|
|
1594
|
+
context?.context,
|
|
1595
|
+
context?.state,
|
|
1596
|
+
context?.description
|
|
1597
|
+
)
|
|
1598
|
+
)
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* Samples how often the review gate waives, best-effort and never fatal.
|
|
1604
|
+
*
|
|
1605
|
+
* A failed sample must never change the verdict. The rate is CONTEXT for a
|
|
1606
|
+
* decision the gate has already made from this pull request's own evidence, so
|
|
1607
|
+
* an unreachable API costs an operator one sentence rather than a red build —
|
|
1608
|
+
* and the absent sentence is the honest rendering of "not measured", which is
|
|
1609
|
+
* what the caller prints in its place.
|
|
1610
|
+
*
|
|
1611
|
+
* @param {object} declaration - The per-repo declaration
|
|
1612
|
+
* @param {number} limit - How many merged pull requests to read; 0 disables
|
|
1613
|
+
* @param {string} [repo] - `OWNER/NAME`; defaults to the current repository
|
|
1614
|
+
* @param {(limit: number, repo?: string) => Array<Array<object>>} [fetch] - Injection seam
|
|
1615
|
+
* @returns {{sampled: number, waived: number, satisfied: number, unsatisfied: number}|undefined} The tally, or undefined when it could not be taken
|
|
1616
|
+
*/
|
|
1617
|
+
export function sampleWaiveRate(declaration, limit, repo, fetch) {
|
|
1618
|
+
if (!Number.isInteger(limit) || limit <= 0) return undefined;
|
|
1619
|
+
try {
|
|
1620
|
+
const perPullRequest = (fetch ?? fetchRecentMergedChecks)(limit, repo);
|
|
1621
|
+
return summarizeWaiveRate(
|
|
1622
|
+
perPullRequest.map(
|
|
1623
|
+
checks => evaluateReviewGate(declaration, checks).states
|
|
1624
|
+
)
|
|
1625
|
+
);
|
|
1626
|
+
} catch {
|
|
1627
|
+
return undefined;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/**
|
|
1632
|
+
* Publishes the verdict where a workflow step can read it.
|
|
1633
|
+
*
|
|
1634
|
+
* `$GITHUB_OUTPUT` rather than stdout because the consumer is the next step in
|
|
1635
|
+
* the job, which turns it into a check run — the surface a merge decision
|
|
1636
|
+
* actually consults. Best-effort for the same reason the step summary is:
|
|
1637
|
+
* losing the rendering must not invert the exit code the guard worked to earn.
|
|
1638
|
+
*
|
|
1639
|
+
* @param {{verdict: string, conclusion: string, title: string}} verdict - What to publish
|
|
1640
|
+
* @param {NodeJS.ProcessEnv} [env] - Environment, injectable for tests
|
|
1641
|
+
* @returns {boolean} True when the outputs were written
|
|
1642
|
+
*/
|
|
1643
|
+
export function writeVerdictOutputs(verdict, env = process.env) {
|
|
1644
|
+
const target = env.GITHUB_OUTPUT;
|
|
1645
|
+
if (target === undefined || target === "") return false;
|
|
1646
|
+
try {
|
|
1647
|
+
appendFileSync(
|
|
1648
|
+
target,
|
|
1649
|
+
`review_evidence_verdict=${verdict.verdict}\nreview_evidence_conclusion=${verdict.conclusion}\nreview_evidence_title=${verdict.title}\n`
|
|
1650
|
+
);
|
|
1651
|
+
return true;
|
|
1652
|
+
} catch (error) {
|
|
1653
|
+
console.error(
|
|
1654
|
+
`[skipped-required-checks] review verdict outputs not written: ${error.message}`
|
|
1655
|
+
);
|
|
1656
|
+
return false;
|
|
1657
|
+
}
|
|
1324
1658
|
}
|
|
1325
1659
|
|
|
1326
1660
|
/**
|
|
@@ -1917,6 +2251,26 @@ export function vacuityRefusal(input) {
|
|
|
1917
2251
|
return null;
|
|
1918
2252
|
}
|
|
1919
2253
|
|
|
2254
|
+
/**
|
|
2255
|
+
* Reads a `--name=<integer>` flag, falling back when absent or unreadable.
|
|
2256
|
+
*
|
|
2257
|
+
* Separate from the seconds reader because the fallback for an unreadable value
|
|
2258
|
+
* differs in kind: a bad timeout should still wait, while a bad sample size must
|
|
2259
|
+
* NOT silently become the shipped default and start making network calls a
|
|
2260
|
+
* caller did not ask for. Both clamp at zero.
|
|
2261
|
+
*
|
|
2262
|
+
* @param {ReadonlyArray<string>} argv - CLI arguments
|
|
2263
|
+
* @param {string} name - The flag, including its leading dashes
|
|
2264
|
+
* @param {number} fallback - Value to use when the flag is absent or unreadable
|
|
2265
|
+
* @returns {number} A non-negative integer
|
|
2266
|
+
*/
|
|
2267
|
+
function readIntegerFlag(argv, name, fallback) {
|
|
2268
|
+
const raw = readFlagValue(argv, name);
|
|
2269
|
+
if (raw === undefined) return fallback;
|
|
2270
|
+
const value = Number(raw);
|
|
2271
|
+
return Number.isInteger(value) && value >= 0 ? value : fallback;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
1920
2274
|
/**
|
|
1921
2275
|
* Reads a `--name=<seconds>` flag, falling back when absent or unreadable.
|
|
1922
2276
|
*
|
|
@@ -1968,8 +2322,19 @@ export function inspectVacuity(argv, declaration, options = {}) {
|
|
|
1968
2322
|
violations: [],
|
|
1969
2323
|
settled: false,
|
|
1970
2324
|
};
|
|
2325
|
+
/**
|
|
2326
|
+
* Attaches the published verdict to a refusal, which must not render as green.
|
|
2327
|
+
*
|
|
2328
|
+
* @param {{kind: string, reason: string}|null} refusal - Why nothing was read
|
|
2329
|
+
* @returns {object} The refusal result, verdict included
|
|
2330
|
+
*/
|
|
2331
|
+
const refused = refusal => ({
|
|
2332
|
+
...empty,
|
|
2333
|
+
refusal,
|
|
2334
|
+
verdict: reviewGateVerdict({ refusal }),
|
|
2335
|
+
});
|
|
1971
2336
|
if (pr === undefined) {
|
|
1972
|
-
return
|
|
2337
|
+
return refused(vacuityRefusal({ declaration, pr }));
|
|
1973
2338
|
}
|
|
1974
2339
|
|
|
1975
2340
|
const repo = readFlagValue(argv, "--repo");
|
|
@@ -1992,16 +2357,15 @@ export function inspectVacuity(argv, declaration, options = {}) {
|
|
|
1992
2357
|
headSha: options.headSha,
|
|
1993
2358
|
});
|
|
1994
2359
|
} catch (error) {
|
|
1995
|
-
return
|
|
2360
|
+
return refused(vacuityRefusal({ declaration, pr, error }));
|
|
1996
2361
|
}
|
|
1997
2362
|
|
|
1998
2363
|
const refusal = vacuityRefusal({ declaration, pr, checks: read.checks });
|
|
1999
2364
|
if (refusal !== null) {
|
|
2000
2365
|
return {
|
|
2001
|
-
...
|
|
2366
|
+
...refused(refusal),
|
|
2002
2367
|
headSha: read.headSha,
|
|
2003
2368
|
settled: read.settled,
|
|
2004
|
-
refusal,
|
|
2005
2369
|
};
|
|
2006
2370
|
}
|
|
2007
2371
|
const evaluated = evaluateVacuousChecks(declaration, read.checks, {
|
|
@@ -2015,6 +2379,20 @@ export function inspectVacuity(argv, declaration, options = {}) {
|
|
|
2015
2379
|
const gate = evaluateReviewGate(declaration, read.checks, {
|
|
2016
2380
|
headSha: read.headSha,
|
|
2017
2381
|
});
|
|
2382
|
+
// Sampled ONLY when this pull request is itself waived. The rate answers
|
|
2383
|
+
// "is this the exception or the rule?", a question that only arises once a
|
|
2384
|
+
// waiver is on the table, and every other run keeps the sampler's network
|
|
2385
|
+
// call — and its failure modes — out of the job entirely.
|
|
2386
|
+
const waiveRate = Object.values(gate.states).includes(
|
|
2387
|
+
REVIEW_GATE_STATES.waived
|
|
2388
|
+
)
|
|
2389
|
+
? sampleWaiveRate(
|
|
2390
|
+
declaration,
|
|
2391
|
+
readIntegerFlag(argv, WAIVE_RATE_SAMPLE_FLAG, 0),
|
|
2392
|
+
repo,
|
|
2393
|
+
options.sampleChecks
|
|
2394
|
+
)
|
|
2395
|
+
: undefined;
|
|
2018
2396
|
return {
|
|
2019
2397
|
pr,
|
|
2020
2398
|
prSource: source,
|
|
@@ -2022,6 +2400,13 @@ export function inspectVacuity(argv, declaration, options = {}) {
|
|
|
2022
2400
|
checked: evaluated.checked,
|
|
2023
2401
|
violations: [...evaluated.violations, ...gate.violations],
|
|
2024
2402
|
gateStates: gate.states,
|
|
2403
|
+
gateDescriptions: gate.descriptions,
|
|
2404
|
+
waiveRate,
|
|
2405
|
+
verdict: reviewGateVerdict({
|
|
2406
|
+
states: gate.states,
|
|
2407
|
+
descriptions: gate.descriptions,
|
|
2408
|
+
waiveRate,
|
|
2409
|
+
}),
|
|
2025
2410
|
settled: read.settled,
|
|
2026
2411
|
refusal: null,
|
|
2027
2412
|
};
|
|
@@ -2081,6 +2466,8 @@ export function runGuard(argv, options = {}) {
|
|
|
2081
2466
|
recipe: transcriptionRecipe(declaration),
|
|
2082
2467
|
pr: vacuity?.pr,
|
|
2083
2468
|
evidenceChecked: vacuity?.checked ?? 0,
|
|
2469
|
+
verdict: vacuity?.verdict,
|
|
2470
|
+
waiveRate: vacuity?.waiveRate,
|
|
2084
2471
|
vacuity,
|
|
2085
2472
|
};
|
|
2086
2473
|
}
|
|
@@ -2141,6 +2528,11 @@ function main(argv) {
|
|
|
2141
2528
|
: !warnOnly || ALWAYS_BLOCKING.includes(violation.kind);
|
|
2142
2529
|
};
|
|
2143
2530
|
const blocking = result.violations.filter(blocks);
|
|
2531
|
+
// Published BEFORE the report and before the exit code, because it is the only
|
|
2532
|
+
// artefact of this run that a merge decision reads. A verdict computed after a
|
|
2533
|
+
// `return` on some branch is a verdict that exists on the happy path only.
|
|
2534
|
+
const verdict = result.verdict;
|
|
2535
|
+
if (verdict !== undefined) writeVerdictOutputs(verdict);
|
|
2144
2536
|
const refusal = result.vacuity?.refusal ?? null;
|
|
2145
2537
|
const refusalBlocks =
|
|
2146
2538
|
refusal !== null && (!warnOnly || requireReviewEvidence);
|
|
@@ -2169,6 +2561,22 @@ function main(argv) {
|
|
|
2169
2561
|
|
|
2170
2562
|
const lines = ["## 🔒 Required checks that prove nothing", ""];
|
|
2171
2563
|
|
|
2564
|
+
// The verdict is the FIRST line of the report and not a footnote, and it is
|
|
2565
|
+
// the same string published to the check run, so the two surfaces cannot
|
|
2566
|
+
// drift into disagreeing about what this run concluded.
|
|
2567
|
+
if (verdict !== undefined) {
|
|
2568
|
+
lines.push(
|
|
2569
|
+
`**${verdict.title}**`,
|
|
2570
|
+
"",
|
|
2571
|
+
`Published as check-run conclusion \`${verdict.conclusion}\`. A \`neutral\` conclusion is how a WAIVED pull request is kept out of the shape a satisfied one prints — the waiver still blocks nothing, and it no longer looks like a review.`,
|
|
2572
|
+
"",
|
|
2573
|
+
result.waiveRate === undefined
|
|
2574
|
+
? "The waive rate over recently merged pull requests was NOT sampled on this run, so no rate is claimed here."
|
|
2575
|
+
: `Recently merged pull requests sampled: ${result.waiveRate.sampled} — ${result.waiveRate.satisfied} reviewed, ${result.waiveRate.waived} waived, ${result.waiveRate.unsatisfied} unsatisfied.`,
|
|
2576
|
+
""
|
|
2577
|
+
);
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2172
2580
|
// The refusal comes FIRST and replaces the verdict. Printing "✅ none
|
|
2173
2581
|
// silences a required check" from a snapshot nobody transcribed is the one
|
|
2174
2582
|
// outcome that is worse than never running: it is a confident wrong answer,
|