@webpieces/pr-gate 0.4.535 → 0.4.536

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.
@@ -1,4 +1,4 @@
1
- import { ChecklistDefinition, ChecklistReviewContext, DiffScope, RequiredChecklist, ReviewJsonService } from '@webpieces/rules-config';
1
+ import { ChecklistDefinition, ChecklistResult, ChecklistReviewContext, DiffScope, RequiredChecklist, ReviewJsonService } from '@webpieces/rules-config';
2
2
  import { AiBranchName } from './git-readAiBranchName';
3
3
  import { ChecklistDetector, ChecklistRoster } from './checklist-detector';
4
4
  import { DiffBasis, DiffBasisResolver } from './diff-basis';
@@ -51,7 +51,18 @@ export declare class ChecklistScan {
51
51
  */
52
52
  basis: DiffBasis;
53
53
  changedFiles: string[];
54
- constructor(defined: ChecklistDefinition[], applicable: RequiredChecklist[], reviewed: RequiredChecklist[], outstanding: RequiredChecklist[], context: ChecklistReviewContext, reviewPath: string, forkPoint: string, roster: ChecklistRoster, formatErrors: string[], basis?: DiffBasis, changedFiles?: string[]);
54
+ /**
55
+ * The verdict files the scan ALREADY read, carried out rather than dropped.
56
+ *
57
+ * `outstanding` answers "who still owes a verdict?" but not "why" — and the two reasons demand opposite
58
+ * actions from the reader: a reviewer that never ran must be SPAWNED, a reviewer that ran and REFUSED
59
+ * must not be (it will refuse again; the finding has to be fixed first). Telling them apart means
60
+ * resolving each checklist's verdict, and without the results here every caller either re-reads the same
61
+ * files off disk — a second read that can disagree with this one — or merges the two cases into one
62
+ * message, which is exactly the loop this field exists to break.
63
+ */
64
+ results: ChecklistResult[];
65
+ constructor(defined: ChecklistDefinition[], applicable: RequiredChecklist[], reviewed: RequiredChecklist[], outstanding: RequiredChecklist[], context: ChecklistReviewContext, reviewPath: string, forkPoint: string, roster: ChecklistRoster, formatErrors: string[], basis?: DiffBasis, changedFiles?: string[], results?: ChecklistResult[]);
55
66
  }
56
67
  /**
57
68
  * The ONE computation of which reviewer subagents a branch owes, shared by `wp-review-upsert-pr` (which lists) and
@@ -66,8 +66,22 @@ class ChecklistScan {
66
66
  */
67
67
  basis;
68
68
  changedFiles; // the full changed-file set the matching ran against
69
+ /**
70
+ * The verdict files the scan ALREADY read, carried out rather than dropped.
71
+ *
72
+ * `outstanding` answers "who still owes a verdict?" but not "why" — and the two reasons demand opposite
73
+ * actions from the reader: a reviewer that never ran must be SPAWNED, a reviewer that ran and REFUSED
74
+ * must not be (it will refuse again; the finding has to be fixed first). Telling them apart means
75
+ * resolving each checklist's verdict, and without the results here every caller either re-reads the same
76
+ * files off disk — a second read that can disagree with this one — or merges the two cases into one
77
+ * message, which is exactly the loop this field exists to break.
78
+ */
79
+ results;
69
80
  // eslint-disable-next-line @typescript-eslint/max-params
70
- constructor(defined, applicable, reviewed, outstanding, context, reviewPath, forkPoint, roster, formatErrors, basis = new diff_basis_1.DiffBasis(), changedFiles = []) {
81
+ constructor(defined, applicable, reviewed, outstanding, context, reviewPath, forkPoint, roster, formatErrors, basis = new diff_basis_1.DiffBasis(), changedFiles = [],
82
+ // Defaulted so a caller that only cares about the X/N/Z counts (and every existing test construction)
83
+ // stays a one-liner; the scanner itself always passes the real set.
84
+ results = []) {
71
85
  this.defined = defined;
72
86
  this.applicable = applicable;
73
87
  this.reviewed = reviewed;
@@ -79,6 +93,7 @@ class ChecklistScan {
79
93
  this.formatErrors = formatErrors;
80
94
  this.basis = basis;
81
95
  this.changedFiles = changedFiles;
96
+ this.results = results;
82
97
  }
83
98
  }
84
99
  exports.ChecklistScan = ChecklistScan;
@@ -143,7 +158,7 @@ let ChecklistScanner = class ChecklistScanner {
143
158
  const reviewed = applicable.filter((r) => !owedIds.has(r.id));
144
159
  return new ChecklistScan(defined, applicable, reviewed, opts.filterAlreadyReviewed ? stillOwed : applicable, opts.contextStage === ''
145
160
  ? this.prContextWriter.contextFor(repoRoot, featureName, basis)
146
- : this.prContextWriter.ensure(repoRoot, featureName, basis, opts.contextStage, changedFiles), reviewPath, base, roster, this.reviewJsonService.checklistFormatErrors(applicable, results), basis, changedFiles);
161
+ : this.prContextWriter.ensure(repoRoot, featureName, basis, opts.contextStage, changedFiles), reviewPath, base, roster, this.reviewJsonService.checklistFormatErrors(applicable, results), basis, changedFiles, results);
147
162
  }
148
163
  /**
149
164
  * Every file changed since the fork point, INCLUDING uncommitted and untracked ones. Two non-default
@@ -1 +1 @@
1
- {"version":3,"file":"checklist-scanner.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/checklist-scanner.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n ChangedFilesOptions, ChecklistDefinition, ChecklistReviewContext, DiffScope, RequiredChecklist,\n ReviewJsonService, reviewJsonPath,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { AiBranchName } from './git-readAiBranchName';\nimport { ChecklistDetector, ChecklistRoster } from './checklist-detector';\nimport { DiffBasis, DiffBasisResolver } from './diff-basis';\nimport { PrContextWriter } from './pr-context-writer';\n\n/** How a caller wants the scan filtered. Data-only (per CLAUDE.md). */\nexport class ChecklistScanOptions {\n /**\n * false — `outstanding` is every applicable checklist (what `wp-review-upsert-pr` LISTS).\n * true — `outstanding` drops the ones already carrying a passing/overridden verdict, leaving only what\n * still owes review (what `wp-finish-upsert-pr` BLOCKS on).\n */\n filterAlreadyReviewed: boolean;\n /**\n * Which per-stage snapshot this scan should persist ('stage3-finish', …), or '' for \"do not write\n * pr-context.json here\".\n *\n * '' exists for stage ②, which cannot write yet: the context records `diffDir`, and the diff has not\n * been materialized at scan time. Letting the scan write anyway meant stage ② wrote the file twice,\n * ~60 lines apart, the first time with an empty diffDir. Now it writes once, after materializing.\n *\n * The DEFAULT is deliberately non-empty, so writing is what you get by NOT thinking about it. A\n * reviewer block that lost its diff command because nobody had written pr-context.json is a bug this\n * codebase has already shipped once (see PrContextWriter's docstring); opting out must be an explicit,\n * visible act by a caller that takes responsibility for writing it later — not an omission.\n */\n contextStage: string;\n\n constructor(filterAlreadyReviewed = false, contextStage = 'stage-scan') {\n this.filterAlreadyReviewed = filterAlreadyReviewed;\n this.contextStage = contextStage;\n }\n}\n\n/**\n * The answer to \"what review does this branch owe?\", in the X → N → Z terms the commands report:\n * X = `defined` — every checklist in pr-gate.checklists\n * N = `applicable` — those whose patterns matched (or that have no patterns, so always run)\n * Z = `outstanding` — of N, those still owing a verdict (only when filterAlreadyReviewed)\n * Data-only.\n */\nexport class ChecklistScan {\n defined: ChecklistDefinition[]; // X\n applicable: RequiredChecklist[]; // N\n reviewed: RequiredChecklist[]; // N − Z: already have a passing/warned/overridden review-<id>.json\n outstanding: RequiredChecklist[]; // Z (== applicable when not filtering)\n context: ChecklistReviewContext; // fork-point sha + pr-context.json path\n reviewPath: string; // the branch's review.json; verdict files sit beside it\n forkPoint: string; // '' when no fork point resolved\n // ALL X, matched or not, with why — what the PR comment publishes as its roster. Skipped checklists are\n // absent from `applicable` by construction, and recovering them downstream would mean a second\n // changed-file computation with different semantics (see the class comment).\n roster: ChecklistRoster;\n // Verdict files that exist but cannot be read as a verdict (e.g. still using the removed `success`).\n // Carried on the SCAN because wp-finish-upsert-pr refuses on missing reviewers before it ever parses\n // review.json — a complaint raised only in there would never reach the AI.\n formatErrors: string[];\n /**\n * The basis the matching ACTUALLY ran against. Carried out so a caller that materializes the diff\n * (stage ②) reuses the identical range instead of resolving its own — two independent resolutions is\n * exactly how the changed-file set and the printed `git diff` command came to disagree.\n */\n basis: DiffBasis;\n changedFiles: string[]; // the full changed-file set the matching ran against\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n defined: ChecklistDefinition[],\n applicable: RequiredChecklist[],\n reviewed: RequiredChecklist[],\n outstanding: RequiredChecklist[],\n context: ChecklistReviewContext,\n reviewPath: string,\n forkPoint: string,\n roster: ChecklistRoster,\n formatErrors: string[],\n basis: DiffBasis = new DiffBasis(),\n changedFiles: string[] = [],\n ) {\n this.defined = defined;\n this.applicable = applicable;\n this.reviewed = reviewed;\n this.outstanding = outstanding;\n this.context = context;\n this.reviewPath = reviewPath;\n this.forkPoint = forkPoint;\n this.roster = roster;\n this.formatErrors = formatErrors;\n this.basis = basis;\n this.changedFiles = changedFiles;\n }\n}\n\n/**\n * The ONE computation of which reviewer subagents a branch owes, shared by `wp-review-upsert-pr` (which lists) and\n * `wp-finish-upsert-pr` (which blocks). They previously each assembled this from the same parts in slightly\n * different ways, and any divergence means the command that reports and the command that gates disagree.\n *\n * Two properties are deliberate and load-bearing:\n *\n * 1. **The base is the FORK POINT of main, computed directly** — never `DiffScope.resolveBase`, which\n * overlays `NX_BASE`/`NX_HEAD` from the environment. That made review coverage depend on an env var.\n * It now arrives via {@link DiffBasisResolver}, which injects ForkPoint; same sha, one resolution.\n * 2. **UNCOMMITTED work counts.** `getChangedFiles` is called with NO head, which is the branch of it that\n * diffs base → WORKING TREE and unions in untracked files. Passing a head would diff commit-to-commit and\n * silently miss staged, unstaged and untracked changes — so a checklist matching only uncommitted files\n * would never fire and its reviewer would never be listed.\n * 3. **The range and the command it prints are the SAME basis.** Property 2 used to be true of the file set\n * only: reviewers were handed `git diff <base> HEAD`, which on a dirty tree covers a different range and\n * prints nothing. Both now derive from one {@link DiffBasis}, carried on the scan so a materializing\n * caller cannot re-resolve and drift.\n *\n * `@injectable(bindingScopeValues.Singleton)` so it is injected by type and drawn in the DI design.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class ChecklistScanner {\n constructor(\n private readonly aiBranchName: AiBranchName,\n private readonly checklistDetector: ChecklistDetector,\n private readonly diffScope: DiffScope,\n private readonly diffBasisResolver: DiffBasisResolver,\n private readonly prContextWriter: PrContextWriter,\n private readonly reviewJsonService: ReviewJsonService,\n ) {}\n\n /**\n * `defined` is the caller's ALREADY-VALIDATED `prGate.checklists`. The scanner deliberately does not load\n * config itself: `loadAndValidate` is the one gate on the checklist set (it rejects a non-array\n * `checklists`, including the removed `{ doc }` manifest shape, and verifies every guidance doc and\n * reviewer-agent file exists), both callers already run it for other fields, and keeping the read out of\n * here leaves this a function of its inputs rather than of the filesystem.\n */\n scan(repoRoot: string, defined: ChecklistDefinition[], opts: ChecklistScanOptions): ChecklistScan {\n const featureName = this.aiBranchName.getFeatureName();\n const reviewPath = reviewJsonPath(repoRoot, featureName);\n // ONE basis for the file set, the reproduce command and any downstream materialization. The fork\n // point still comes from ForkPoint (never DiffScope.resolveBase) — DiffBasisResolver injects it.\n const basis = this.diffBasisResolver.resolve(repoRoot);\n const base = basis.base;\n // ONE changed-file computation feeds both the roster (all X) and the applicable set (N). `detect` is\n // pure and defined as the roster minus its empty entries, so the two cannot disagree about a match.\n const changedFiles = this.changedFiles(repoRoot, base);\n const roster = new ChecklistRoster(\n this.checklistDetector.roster(defined, changedFiles), changedFiles.length, base !== '');\n const applicable = this.checklistDetector.toRequired(this.checklistDetector.detect(defined, changedFiles));\n const results = this.reviewJsonService.loadChecklistResults(reviewPath, applicable);\n const stillOwed = this.reviewJsonService.pendingChecklists(applicable, results);\n const owedIds = new Set(stillOwed.map((r: RequiredChecklist): string => r.id));\n const reviewed = applicable.filter((r: RequiredChecklist): boolean => !owedIds.has(r.id));\n return new ChecklistScan(\n defined,\n applicable,\n reviewed,\n opts.filterAlreadyReviewed ? stillOwed : applicable,\n opts.contextStage === ''\n ? this.prContextWriter.contextFor(repoRoot, featureName, basis)\n : this.prContextWriter.ensure(repoRoot, featureName, basis, opts.contextStage, changedFiles),\n reviewPath,\n base,\n roster,\n this.reviewJsonService.checklistFormatErrors(applicable, results),\n basis,\n changedFiles,\n );\n }\n\n /**\n * Every file changed since the fork point, INCLUDING uncommitted and untracked ones. Two non-default\n * options, both load-bearing:\n *\n * `tsOnly:false` — the default drops every *.sql / Dockerfile / .env* file a checklist most wants\n * to key on, which would silently shrink the set a reviewer is pointed at.\n * `includeDeletions:true` — the default is `--diff-filter=d`, so a DELETED file is invisible. A PR that\n * deletes a migration, an auth check or a terraform rule changed exactly what a\n * checklist exists to catch, and under the default no checklist fires at all.\n */\n private changedFiles(repoRoot: string, base: string): string[] {\n if (base === '') return [];\n const opts = new ChangedFilesOptions();\n opts.tsOnly = false;\n opts.includeDeletions = true;\n // No head argument — see the class comment. This is what includes the working tree.\n return this.diffScope.getChangedFiles(repoRoot, base, undefined, opts);\n }\n}\n"]}
1
+ {"version":3,"file":"checklist-scanner.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/checklist-scanner.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n ChangedFilesOptions, ChecklistDefinition, ChecklistResult, ChecklistReviewContext, DiffScope,\n RequiredChecklist, ReviewJsonService, reviewJsonPath,\n} from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { AiBranchName } from './git-readAiBranchName';\nimport { ChecklistDetector, ChecklistRoster } from './checklist-detector';\nimport { DiffBasis, DiffBasisResolver } from './diff-basis';\nimport { PrContextWriter } from './pr-context-writer';\n\n/** How a caller wants the scan filtered. Data-only (per CLAUDE.md). */\nexport class ChecklistScanOptions {\n /**\n * false — `outstanding` is every applicable checklist (what `wp-review-upsert-pr` LISTS).\n * true — `outstanding` drops the ones already carrying a passing/overridden verdict, leaving only what\n * still owes review (what `wp-finish-upsert-pr` BLOCKS on).\n */\n filterAlreadyReviewed: boolean;\n /**\n * Which per-stage snapshot this scan should persist ('stage3-finish', …), or '' for \"do not write\n * pr-context.json here\".\n *\n * '' exists for stage ②, which cannot write yet: the context records `diffDir`, and the diff has not\n * been materialized at scan time. Letting the scan write anyway meant stage ② wrote the file twice,\n * ~60 lines apart, the first time with an empty diffDir. Now it writes once, after materializing.\n *\n * The DEFAULT is deliberately non-empty, so writing is what you get by NOT thinking about it. A\n * reviewer block that lost its diff command because nobody had written pr-context.json is a bug this\n * codebase has already shipped once (see PrContextWriter's docstring); opting out must be an explicit,\n * visible act by a caller that takes responsibility for writing it later — not an omission.\n */\n contextStage: string;\n\n constructor(filterAlreadyReviewed = false, contextStage = 'stage-scan') {\n this.filterAlreadyReviewed = filterAlreadyReviewed;\n this.contextStage = contextStage;\n }\n}\n\n/**\n * The answer to \"what review does this branch owe?\", in the X → N → Z terms the commands report:\n * X = `defined` — every checklist in pr-gate.checklists\n * N = `applicable` — those whose patterns matched (or that have no patterns, so always run)\n * Z = `outstanding` — of N, those still owing a verdict (only when filterAlreadyReviewed)\n * Data-only.\n */\nexport class ChecklistScan {\n defined: ChecklistDefinition[]; // X\n applicable: RequiredChecklist[]; // N\n reviewed: RequiredChecklist[]; // N − Z: already have a passing/warned/overridden review-<id>.json\n outstanding: RequiredChecklist[]; // Z (== applicable when not filtering)\n context: ChecklistReviewContext; // fork-point sha + pr-context.json path\n reviewPath: string; // the branch's review.json; verdict files sit beside it\n forkPoint: string; // '' when no fork point resolved\n // ALL X, matched or not, with why — what the PR comment publishes as its roster. Skipped checklists are\n // absent from `applicable` by construction, and recovering them downstream would mean a second\n // changed-file computation with different semantics (see the class comment).\n roster: ChecklistRoster;\n // Verdict files that exist but cannot be read as a verdict (e.g. still using the removed `success`).\n // Carried on the SCAN because wp-finish-upsert-pr refuses on missing reviewers before it ever parses\n // review.json — a complaint raised only in there would never reach the AI.\n formatErrors: string[];\n /**\n * The basis the matching ACTUALLY ran against. Carried out so a caller that materializes the diff\n * (stage ②) reuses the identical range instead of resolving its own — two independent resolutions is\n * exactly how the changed-file set and the printed `git diff` command came to disagree.\n */\n basis: DiffBasis;\n changedFiles: string[]; // the full changed-file set the matching ran against\n /**\n * The verdict files the scan ALREADY read, carried out rather than dropped.\n *\n * `outstanding` answers \"who still owes a verdict?\" but not \"why\" — and the two reasons demand opposite\n * actions from the reader: a reviewer that never ran must be SPAWNED, a reviewer that ran and REFUSED\n * must not be (it will refuse again; the finding has to be fixed first). Telling them apart means\n * resolving each checklist's verdict, and without the results here every caller either re-reads the same\n * files off disk — a second read that can disagree with this one — or merges the two cases into one\n * message, which is exactly the loop this field exists to break.\n */\n results: ChecklistResult[];\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n defined: ChecklistDefinition[],\n applicable: RequiredChecklist[],\n reviewed: RequiredChecklist[],\n outstanding: RequiredChecklist[],\n context: ChecklistReviewContext,\n reviewPath: string,\n forkPoint: string,\n roster: ChecklistRoster,\n formatErrors: string[],\n basis: DiffBasis = new DiffBasis(),\n changedFiles: string[] = [],\n // Defaulted so a caller that only cares about the X/N/Z counts (and every existing test construction)\n // stays a one-liner; the scanner itself always passes the real set.\n results: ChecklistResult[] = [],\n ) {\n this.defined = defined;\n this.applicable = applicable;\n this.reviewed = reviewed;\n this.outstanding = outstanding;\n this.context = context;\n this.reviewPath = reviewPath;\n this.forkPoint = forkPoint;\n this.roster = roster;\n this.formatErrors = formatErrors;\n this.basis = basis;\n this.changedFiles = changedFiles;\n this.results = results;\n }\n}\n\n/**\n * The ONE computation of which reviewer subagents a branch owes, shared by `wp-review-upsert-pr` (which lists) and\n * `wp-finish-upsert-pr` (which blocks). They previously each assembled this from the same parts in slightly\n * different ways, and any divergence means the command that reports and the command that gates disagree.\n *\n * Two properties are deliberate and load-bearing:\n *\n * 1. **The base is the FORK POINT of main, computed directly** — never `DiffScope.resolveBase`, which\n * overlays `NX_BASE`/`NX_HEAD` from the environment. That made review coverage depend on an env var.\n * It now arrives via {@link DiffBasisResolver}, which injects ForkPoint; same sha, one resolution.\n * 2. **UNCOMMITTED work counts.** `getChangedFiles` is called with NO head, which is the branch of it that\n * diffs base → WORKING TREE and unions in untracked files. Passing a head would diff commit-to-commit and\n * silently miss staged, unstaged and untracked changes — so a checklist matching only uncommitted files\n * would never fire and its reviewer would never be listed.\n * 3. **The range and the command it prints are the SAME basis.** Property 2 used to be true of the file set\n * only: reviewers were handed `git diff <base> HEAD`, which on a dirty tree covers a different range and\n * prints nothing. Both now derive from one {@link DiffBasis}, carried on the scan so a materializing\n * caller cannot re-resolve and drift.\n *\n * `@injectable(bindingScopeValues.Singleton)` so it is injected by type and drawn in the DI design.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class ChecklistScanner {\n constructor(\n private readonly aiBranchName: AiBranchName,\n private readonly checklistDetector: ChecklistDetector,\n private readonly diffScope: DiffScope,\n private readonly diffBasisResolver: DiffBasisResolver,\n private readonly prContextWriter: PrContextWriter,\n private readonly reviewJsonService: ReviewJsonService,\n ) {}\n\n /**\n * `defined` is the caller's ALREADY-VALIDATED `prGate.checklists`. The scanner deliberately does not load\n * config itself: `loadAndValidate` is the one gate on the checklist set (it rejects a non-array\n * `checklists`, including the removed `{ doc }` manifest shape, and verifies every guidance doc and\n * reviewer-agent file exists), both callers already run it for other fields, and keeping the read out of\n * here leaves this a function of its inputs rather than of the filesystem.\n */\n scan(repoRoot: string, defined: ChecklistDefinition[], opts: ChecklistScanOptions): ChecklistScan {\n const featureName = this.aiBranchName.getFeatureName();\n const reviewPath = reviewJsonPath(repoRoot, featureName);\n // ONE basis for the file set, the reproduce command and any downstream materialization. The fork\n // point still comes from ForkPoint (never DiffScope.resolveBase) — DiffBasisResolver injects it.\n const basis = this.diffBasisResolver.resolve(repoRoot);\n const base = basis.base;\n // ONE changed-file computation feeds both the roster (all X) and the applicable set (N). `detect` is\n // pure and defined as the roster minus its empty entries, so the two cannot disagree about a match.\n const changedFiles = this.changedFiles(repoRoot, base);\n const roster = new ChecklistRoster(\n this.checklistDetector.roster(defined, changedFiles), changedFiles.length, base !== '');\n const applicable = this.checklistDetector.toRequired(this.checklistDetector.detect(defined, changedFiles));\n const results = this.reviewJsonService.loadChecklistResults(reviewPath, applicable);\n const stillOwed = this.reviewJsonService.pendingChecklists(applicable, results);\n const owedIds = new Set(stillOwed.map((r: RequiredChecklist): string => r.id));\n const reviewed = applicable.filter((r: RequiredChecklist): boolean => !owedIds.has(r.id));\n return new ChecklistScan(\n defined,\n applicable,\n reviewed,\n opts.filterAlreadyReviewed ? stillOwed : applicable,\n opts.contextStage === ''\n ? this.prContextWriter.contextFor(repoRoot, featureName, basis)\n : this.prContextWriter.ensure(repoRoot, featureName, basis, opts.contextStage, changedFiles),\n reviewPath,\n base,\n roster,\n this.reviewJsonService.checklistFormatErrors(applicable, results),\n basis,\n changedFiles,\n results,\n );\n }\n\n /**\n * Every file changed since the fork point, INCLUDING uncommitted and untracked ones. Two non-default\n * options, both load-bearing:\n *\n * `tsOnly:false` — the default drops every *.sql / Dockerfile / .env* file a checklist most wants\n * to key on, which would silently shrink the set a reviewer is pointed at.\n * `includeDeletions:true` — the default is `--diff-filter=d`, so a DELETED file is invisible. A PR that\n * deletes a migration, an auth check or a terraform rule changed exactly what a\n * checklist exists to catch, and under the default no checklist fires at all.\n */\n private changedFiles(repoRoot: string, base: string): string[] {\n if (base === '') return [];\n const opts = new ChangedFilesOptions();\n opts.tsOnly = false;\n opts.includeDeletions = true;\n // No head argument — see the class comment. This is what includes the working tree.\n return this.diffScope.getChangedFiles(repoRoot, base, undefined, opts);\n }\n}\n"]}
@@ -1,5 +1,23 @@
1
1
  import { RequiredChecklist, ReviewerBriefing, ReviewerInstructionsService } from '@webpieces/rules-config';
2
2
  import { ChecklistNotice } from './checklist-notice';
3
+ /**
4
+ * One reviewer that ALREADY ANSWERED on this branch and refused, with the refusal rendered by
5
+ * `ReviewJsonService.refusalError` — the ONE wording, shared with `wp-finish-upsert-pr`. Data-only.
6
+ *
7
+ * Stage ② carries these because it had the same defect finish did: a refused checklist has no passing
8
+ * verdict, so it is "owed", so it got an ordinary spawn block identical to a reviewer that never ran. An
9
+ * agent obeys that block, the reviewer re-reads unchanged code, refuses again — the loop, one stage earlier.
10
+ *
11
+ * The message is rendered by the COMMAND rather than in here for one reason: stage ② must not archive
12
+ * anything. `refusalError` called with no archive path says "fix it, then re-run; or set an override in
13
+ * review-<id>.json", which is only true while that file is still live — and at stage ② it is. Retiring a
14
+ * verdict is finish's act, on the refusal it is actually acting on.
15
+ */
16
+ export declare class RefusedReviewer {
17
+ checklistId: string;
18
+ message: string;
19
+ constructor(checklistId: string, message: string);
20
+ }
3
21
  /**
4
22
  * Everything the closing block of `wp-review-upsert-pr` needs. Data-only, and a class rather than an
5
23
  * object literal per CLAUDE.md. The three identifying paths are constructor args; the rest are optional
@@ -14,6 +32,7 @@ export declare class ReviewReportInput {
14
32
  reviewed: RequiredChecklist[];
15
33
  formatErrors: string[];
16
34
  briefings: ReviewerBriefing[];
35
+ refused: RefusedReviewer[];
17
36
  constructor(repoRoot: string, featureName: string, reviewPath: string);
18
37
  }
19
38
  /**
@@ -77,6 +96,17 @@ export declare class ReviewReport {
77
96
  private spawnStep;
78
97
  private finishStep;
79
98
  private owedReviewers;
99
+ /**
100
+ * One reviewer's block. A reviewer that already REFUSED gets the SAME spawn coordinates but a different
101
+ * lead-in, because the action before spawning is different: its own words are printed, and the spawn is
102
+ * explicitly conditioned on having fixed the finding first.
103
+ *
104
+ * It keeps its spawn block rather than being dropped from the list, because the reviewer genuinely does
105
+ * still owe a fresh verdict — dropping it would leave nothing anywhere saying how to get one. What must
106
+ * not happen is a bare "spawn this" that reads identically to a reviewer that never ran, which is the
107
+ * loop this exists to break.
108
+ */
80
109
  private oneSpawnBlock;
110
+ private leadIn;
81
111
  private why;
82
112
  }
@@ -1,11 +1,33 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ReviewReport = exports.ReviewReportInput = void 0;
3
+ exports.ReviewReport = exports.ReviewReportInput = exports.RefusedReviewer = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const inversify_1 = require("inversify");
6
6
  const rules_config_1 = require("@webpieces/rules-config");
7
7
  const checklist_notice_1 = require("./checklist-notice");
8
8
  const SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n';
9
+ /**
10
+ * One reviewer that ALREADY ANSWERED on this branch and refused, with the refusal rendered by
11
+ * `ReviewJsonService.refusalError` — the ONE wording, shared with `wp-finish-upsert-pr`. Data-only.
12
+ *
13
+ * Stage ② carries these because it had the same defect finish did: a refused checklist has no passing
14
+ * verdict, so it is "owed", so it got an ordinary spawn block identical to a reviewer that never ran. An
15
+ * agent obeys that block, the reviewer re-reads unchanged code, refuses again — the loop, one stage earlier.
16
+ *
17
+ * The message is rendered by the COMMAND rather than in here for one reason: stage ② must not archive
18
+ * anything. `refusalError` called with no archive path says "fix it, then re-run; or set an override in
19
+ * review-<id>.json", which is only true while that file is still live — and at stage ② it is. Retiring a
20
+ * verdict is finish's act, on the refusal it is actually acting on.
21
+ */
22
+ class RefusedReviewer {
23
+ checklistId;
24
+ message;
25
+ constructor(checklistId, message) {
26
+ this.checklistId = checklistId;
27
+ this.message = message;
28
+ }
29
+ }
30
+ exports.RefusedReviewer = RefusedReviewer;
9
31
  /**
10
32
  * Everything the closing block of `wp-review-upsert-pr` needs. Data-only, and a class rather than an
11
33
  * object literal per CLAUDE.md. The three identifying paths are constructor args; the rest are optional
@@ -20,6 +42,7 @@ class ReviewReportInput {
20
42
  reviewed; // already have a passing verdict on this branch
21
43
  formatErrors; // verdict files that exist but cannot be read as verdicts
22
44
  briefings; // one per applicable checklist, already written to disk
45
+ refused; // of the owed ones, those that already ran and said no (see RefusedReviewer)
23
46
  constructor(repoRoot, featureName, reviewPath) {
24
47
  this.repoRoot = repoRoot;
25
48
  this.featureName = featureName;
@@ -29,6 +52,7 @@ class ReviewReportInput {
29
52
  this.reviewed = [];
30
53
  this.formatErrors = [];
31
54
  this.briefings = [];
55
+ this.refused = [];
32
56
  }
33
57
  }
34
58
  exports.ReviewReportInput = ReviewReportInput;
@@ -147,6 +171,11 @@ let ReviewReport = class ReviewReport {
147
171
  ' file on its behalf.',
148
172
  '',
149
173
  ];
174
+ // Said up front, not only beside the block: an agent that has decided to spawn everything listed here
175
+ // needs to know BEFORE it starts that one of these entries is not a spawn-shaped task.
176
+ if (input.refused.length > 0) {
177
+ lines.push(` ${input.refused.length} of them already ANSWERED and refused (marked ⛔ below). Do not spawn`, ' those against unchanged code — fix what they found first; the fix is the prerequisite.', '');
178
+ }
150
179
  for (const b of owed)
151
180
  lines.push(...this.oneSpawnBlock(input, b));
152
181
  lines.push('');
@@ -165,15 +194,39 @@ let ReviewReport = class ReviewReport {
165
194
  const reviewedIds = new Set(input.reviewed.map((r) => r.id));
166
195
  return input.briefings.filter((b) => !reviewedIds.has(b.checklistId));
167
196
  }
197
+ /**
198
+ * One reviewer's block. A reviewer that already REFUSED gets the SAME spawn coordinates but a different
199
+ * lead-in, because the action before spawning is different: its own words are printed, and the spawn is
200
+ * explicitly conditioned on having fixed the finding first.
201
+ *
202
+ * It keeps its spawn block rather than being dropped from the list, because the reviewer genuinely does
203
+ * still owe a fresh verdict — dropping it would leave nothing anywhere saying how to get one. What must
204
+ * not happen is a bare "spawn this" that reads identically to a reviewer that never ran, which is the
205
+ * loop this exists to break.
206
+ */
168
207
  oneSpawnBlock(input, b) {
208
+ const instructionsFile = this.reviewerInstructions.pathFor(input.repoRoot, input.featureName, b.subagent);
169
209
  return [
170
- ` ▶ ${b.subagent} — ${this.why(b)}`,
210
+ ...this.leadIn(input, b),
171
211
  ` subagent_type: ${b.subagent}`,
172
212
  ' prompt: Read your instructions file FIRST and follow it exactly:',
173
- ` ${this.reviewerInstructions.pathFor(input.repoRoot, input.featureName, b.subagent)}`,
213
+ ` ${instructionsFile}`,
174
214
  '',
175
215
  ];
176
216
  }
217
+ // The lines above the spawn coordinates: normally just why this reviewer is in scope; for one that
218
+ // already refused, its verdict verbatim plus the order the two actions must happen in.
219
+ leadIn(input, b) {
220
+ const refusal = input.refused.find((r) => r.checklistId === b.checklistId);
221
+ if (!refusal)
222
+ return [` ▶ ${b.subagent} — ${this.why(b)}`];
223
+ return [
224
+ ` ⛔ ${b.subagent} — ALREADY REVIEWED THIS BRANCH AND REFUSED. It will refuse again on unchanged code.`,
225
+ ` ${refusal.message}`,
226
+ ' FIX THE FINDING FIRST (or record a human-authored override). ONLY THEN spawn it again, to',
227
+ ' write a fresh verdict:',
228
+ ];
229
+ }
177
230
  // Why this one is in scope. A patternless checklist is NOT "matched" — it always runs, over the whole
178
231
  // diff, and saying so is what tells a repo its checklist is firing on docs-only PRs by design.
179
232
  why(b) {
@@ -1 +1 @@
1
- {"version":3,"file":"review-report.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/review-report.ts"],"names":[],"mappings":"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","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\nimport { reviewJsonSchemaHint, RequiredChecklist, ReviewerBriefing, ReviewerInstructionsService } from '@webpieces/rules-config';\nimport { ChecklistNotice } from './checklist-notice';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * Everything the closing block of `wp-review-upsert-pr` needs. Data-only, and a class rather than an\n * object literal per CLAUDE.md. The three identifying paths are constructor args; the rest are optional\n * facts about the scan that default to \"nothing\", so a repo with no checklists constructs it in one line.\n */\nexport class ReviewReportInput {\n repoRoot: string;\n featureName: string;\n reviewPath: string; // the branch's review.json — the file the AI must write next\n definedCount: number; // how many checklists pr-gate.checklists defines\n applicableCount: number; // how many of them apply to THIS diff (0 ⇒ the notice, not spawn blocks)\n reviewed: RequiredChecklist[]; // already have a passing verdict on this branch\n formatErrors: string[]; // verdict files that exist but cannot be read as verdicts\n briefings: ReviewerBriefing[]; // one per applicable checklist, already written to disk\n\n constructor(repoRoot: string, featureName: string, reviewPath: string) {\n this.repoRoot = repoRoot;\n this.featureName = featureName;\n this.reviewPath = reviewPath;\n this.definedCount = 0;\n this.applicableCount = 0;\n this.reviewed = [];\n this.formatErrors = [];\n this.briefings = [];\n }\n}\n\n/**\n * Renders the closing block of `wp-review-upsert-pr` — the checklist verdict, then EXACTLY ONE \"what to do\n * next\".\n *\n * Extracted from the command so it can be asserted on as a rendered string, because the ordering IS the\n * contract. The bug it was extracted to fix: the zero-checklist notice ended with \"Carry on and run: pnpm\n * wp-finish-upsert-pr\", and the block printed directly beneath it said to write review.json first and\n * finish afterwards. Two next-steps, in the wrong order, and an agent that follows instructions literally —\n * which is the entire reason this command prints them — took the first one and opened a PR with no review.\n * That is precisely the failure the three-stage flow exists to prevent (reported on PR #519).\n *\n * The invariants, enforced by review-report.spec.ts:\n * 1. `wp-finish-upsert-pr` is named as a thing to run EXACTLY ONCE in the whole block.\n * 2. The review.json instruction comes BEFORE it — and BEFORE the spawn blocks (see nextSteps).\n * 3. With zero checklists the all-clear precedes any configuration guidance.\n *\n * Pure string building, no I/O. `@injectable(bindingScopeValues.Singleton)` so it is injected by type.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class ReviewReport {\n constructor(\n private readonly checklistNotice: ChecklistNotice,\n private readonly reviewerInstructions: ReviewerInstructionsService,\n ) {}\n\n render(input: ReviewReportInput): string {\n return '\\n' + SEP + this.header(input) + SEP\n + this.scanVerdict(input)\n + this.nextSteps(input);\n }\n\n // Name what this block is actually about. A repo with reviewers owed is being told to SPAWN; a repo\n // with none is not, and promising subagents it does not have is the same kind of noise as explaining\n // checklist configuration to a repo that configured none. Keyed on what is actually OWED rather than on\n // the applicable count: every applicable checklist already having a verdict means nothing to spawn.\n private header(input: ReviewReportInput): string {\n if (this.owedReviewers(input).length === 0) return '② Review, then finish\\n';\n return '② Review, spawn subagent reviewers, then finish\\n';\n }\n\n /**\n * What the SCAN found: either \"nothing applies here\" or the already-reviewed / unreadable-verdict lines.\n * Verdicts, not instructions — every instruction lives in nextSteps() below, so no line up here can be\n * mistaken for the next action.\n */\n private scanVerdict(input: ReviewReportInput): string {\n if (input.applicableCount === 0) return '\\n' + this.checklistNotice.build(input.definedCount);\n const lines: string[] = [];\n for (const r of input.reviewed) {\n lines.push(` ✓ ${r.subagent} — already reviewed on this branch (reusing its review-${r.id}.json)`);\n }\n // A verdict file that EXISTS but is unreadable as a verdict is called out here. Without it this\n // reports the checklist as simply owed, and the AI re-runs a reviewer that already ran instead of\n // correcting the file sitting right there.\n for (const e of input.formatErrors) lines.push(` ⛔ ${e}`);\n if (this.owedReviewers(input).length === 0) {\n lines.push('', '✅ Every checklist that applies is already reviewed — nothing to spawn.');\n }\n if (lines.length === 0) return '';\n return '\\n' + lines.join('\\n') + '\\n';\n }\n\n /**\n * The ONE instruction block, and the last thing the stage prints. Numbered rather than prose-linked\n * (\"Then… Finally…\") so that skipping step 1 is visibly skipping a step, and worded so no earlier line\n * can be mistaken for the real next action.\n *\n * review.json is STEP 1 and the spawn blocks are STEP 2 — that ORDER is the contract, not a preference.\n * This block used to print the spawn blocks first and then say to write review.json \"WHILE any reviewer\n * subagents above are still running\", which does not merely permit spawning first, it instructs it.\n * Harmless for a reviewer that only reads the diff; wrong for one that judges the PR's stated INTENT —\n * its title, summary or risk level — because review.json is the only place that intent lives. Such a\n * reviewer either finds no file (a false RED and a wasted reviewer run) or, on a second run of this\n * stage on the same branch, finds the PREVIOUS run's file and validates a title that no longer exists —\n * a false GREEN, with nothing in the output saying which of the two happened. A consuming repo had to\n * write itself a rule telling its agents to DISOBEY this block to work around it.\n *\n * What the old ordering bought was overlap on a single local file write, not a subagent round-trip.\n *\n * The schema hint is rendered by ReviewJsonService — the single renderer — so the shape printed here\n * can never drift from the shape `wp-finish-upsert-pr` validates.\n */\n private nextSteps(input: ReviewReportInput): string {\n const owed = this.owedReviewers(input);\n const stepCount = owed.length === 0 ? 2 : 3;\n return '\\n' + SEP\n + `▶ NEXT — ${stepCount} steps, in this order. Step 1 is NOT optional:\\n` + SEP + '\\n'\n + this.writeReviewStep(input.reviewPath)\n + this.spawnStep(input, owed)\n + this.finishStep(stepCount);\n }\n\n private writeReviewStep(reviewPath: string): string {\n return (\n 'STEP 1 — review your own changes, then write the review file. Write it FIRST — BEFORE you spawn\\n' +\n ' anything below. finish REFUSES without it, and a reviewer subagent may READ it: a\\n' +\n ' checklist that judges the PR title, summary or risk level reads exactly this file, so\\n' +\n ' writing it afterwards races that reviewer into seeing nothing — or, on a re-run of this\\n' +\n ' stage, into judging the PREVIOUS run\\'s review of code that has since changed.\\n\\n' +\n reviewJsonSchemaHint(reviewPath) + '\\n\\n'\n );\n }\n\n /**\n * STEP 2 — one copy-paste block per owed reviewer, and nothing at all when none is owed. The prompt is\n * deliberately a POINTER and nothing else: the generated instructions file is the contract, so anything\n * restated here is a second copy that can go stale — which is exactly how a removed `success` field\n * outlived its own removal in print.\n */\n private spawnStep(input: ReviewReportInput, owed: readonly ReviewerBriefing[]): string {\n if (owed.length === 0) return '';\n const lines: string[] = [\n `STEP 2 — only once that file is written, spawn these ${owed.length} reviewer subagent(s) — a SEPARATE`,\n ' one each. You may NOT review your own work, and you may NOT write a reviewer\\'s verdict',\n ' file on its behalf.',\n '',\n ];\n for (const b of owed) lines.push(...this.oneSpawnBlock(input, b));\n lines.push('');\n return lines.join('\\n');\n }\n\n private finishStep(stepNumber: number): string {\n const precondition = stepNumber === 3\n ? 'once every reviewer above has written its verdict file'\n : 'once that file exists';\n return (\n `STEP ${stepNumber} — only ${precondition}, run: pnpm wp-finish-upsert-pr\\n` +\n ' (The build gate is already green for this commit — finish reuses it unless HEAD moves.)\\n\\n'\n );\n }\n\n // The briefings with no passing verdict yet — the ONE definition of \"owed\", shared by the header, the\n // scan verdict and the step numbering, so they cannot disagree about whether there is anything to spawn.\n private owedReviewers(input: ReviewReportInput): ReviewerBriefing[] {\n const reviewedIds = new Set(input.reviewed.map((r: RequiredChecklist): string => r.id));\n return input.briefings.filter((b: ReviewerBriefing): boolean => !reviewedIds.has(b.checklistId));\n }\n\n private oneSpawnBlock(input: ReviewReportInput, b: ReviewerBriefing): string[] {\n return [\n ` ▶ ${b.subagent} — ${this.why(b)}`,\n ` subagent_type: ${b.subagent}`,\n ' prompt: Read your instructions file FIRST and follow it exactly:',\n ` ${this.reviewerInstructions.pathFor(input.repoRoot, input.featureName, b.subagent)}`,\n '',\n ];\n }\n\n // Why this one is in scope. A patternless checklist is NOT \"matched\" — it always runs, over the whole\n // diff, and saying so is what tells a repo its checklist is firing on docs-only PRs by design.\n private why(b: ReviewerBriefing): string {\n if (b.matchedPatterns.length === 0) {\n return `ALWAYS RUNS (no \"patterns\" configured), whole diff in scope — ${b.myFiles.length} file(s)`;\n }\n return `${b.myFiles.length} file(s) matched ${b.matchedPatterns.map((p: string): string => `\"${p}\"`).join(', ')}`;\n }\n}\n"]}
1
+ {"version":3,"file":"review-report.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/review-report.ts"],"names":[],"mappings":";;;;AAAA,yCAA2D;AAC3D,0DAAiI;AACjI,yDAAqD;AAErD,MAAM,GAAG,GAAG,0DAA0D,CAAC;AAEvE;;;;;;;;;;;;GAYG;AACH,MAAa,eAAe;IACxB,WAAW,CAAS;IACpB,OAAO,CAAS;IAEhB,YAAY,WAAmB,EAAE,OAAe;QAC5C,IAAI,CAAC,WAAW,GAAG,WAAW,CAAC;QAC/B,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IAC3B,CAAC;CACJ;AARD,0CAQC;AAED;;;;GAIG;AACH,MAAa,iBAAiB;IAC1B,QAAQ,CAAS;IACjB,WAAW,CAAS;IACpB,UAAU,CAAS,CAAa,6DAA6D;IAC7F,YAAY,CAAS,CAAW,iDAAiD;IACjF,eAAe,CAAS,CAAQ,yEAAyE;IACzG,QAAQ,CAAsB,CAAE,gDAAgD;IAChF,YAAY,CAAW,CAAS,0DAA0D;IAC1F,SAAS,CAAqB,CAAE,wDAAwD;IACxF,OAAO,CAAoB,CAAK,6EAA6E;IAE7G,YAAY,QAAgB,EAAE,WAAmB,EAAE,UAAkB;QACjE,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,WAAW,GAAG,WAAW,CAAC;QAC/B,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC;QAC7B,IAAI,CAAC,YAAY,GAAG,CAAC,CAAC;QACtB,IAAI,CAAC,eAAe,GAAG,CAAC,CAAC;QACzB,IAAI,CAAC,QAAQ,GAAG,EAAE,CAAC;QACnB,IAAI,CAAC,YAAY,GAAG,EAAE,CAAC;QACvB,IAAI,CAAC,SAAS,GAAG,EAAE,CAAC;QACpB,IAAI,CAAC,OAAO,GAAG,EAAE,CAAC;IACtB,CAAC;CACJ;AAtBD,8CAsBC;AAED;;;;;;;;;;;;;;;;;GAiBG;AAEI,IAAM,YAAY,GAAlB,MAAM,YAAY;IAEA;IACA;IAFrB,YACqB,eAAgC,EAChC,oBAAiD;QADjD,oBAAe,GAAf,eAAe,CAAiB;QAChC,yBAAoB,GAApB,oBAAoB,CAA6B;IACnE,CAAC;IAEJ,MAAM,CAAC,KAAwB;QAC3B,OAAO,IAAI,GAAG,GAAG,GAAG,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,GAAG;cACtC,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC;cACvB,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;IAChC,CAAC;IAED,oGAAoG;IACpG,qGAAqG;IACrG,wGAAwG;IACxG,oGAAoG;IAC5F,MAAM,CAAC,KAAwB;QACnC,IAAI,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,yBAAyB,CAAC;QAC7E,OAAO,mDAAmD,CAAC;IAC/D,CAAC;IAED;;;;OAIG;IACK,WAAW,CAAC,KAAwB;QACxC,IAAI,KAAK,CAAC,eAAe,KAAK,CAAC;YAAE,OAAO,IAAI,GAAG,IAAI,CAAC,eAAe,CAAC,KAAK,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;QAC9F,MAAM,KAAK,GAAa,EAAE,CAAC;QAC3B,KAAK,MAAM,CAAC,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;YAC7B,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,QAAQ,0DAA0D,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;QACxG,CAAC;QACD,gGAAgG;QAChG,kGAAkG;QAClG,2CAA2C;QAC3C,KAAK,MAAM,CAAC,IAAI,KAAK,CAAC,YAAY;YAAE,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;QAC3D,IAAI,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACzC,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,wEAAwE,CAAC,CAAC;QAC7F,CAAC;QACD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,EAAE,CAAC;QAClC,OAAO,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC1C,CAAC;IAED;;;;;;;;;;;;;;;;;;;OAmBG;IACK,SAAS,CAAC,KAAwB;QACtC,MAAM,IAAI,GAAG,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QACvC,MAAM,SAAS,GAAG,IAAI,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAC5C,OAAO,IAAI,GAAG,GAAG;cACX,YAAY,SAAS,kDAAkD,GAAG,GAAG,GAAG,IAAI;cACpF,IAAI,CAAC,eAAe,CAAC,KAAK,CAAC,UAAU,CAAC;cACtC,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,CAAC;cAC3B,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;IACrC,CAAC;IAEO,eAAe,CAAC,UAAkB;QACtC,OAAO,CACH,mGAAmG;YACnG,8FAA8F;YAC9F,kGAAkG;YAClG,oGAAoG;YACpG,6FAA6F;YAC7F,IAAA,mCAAoB,EAAC,UAAU,CAAC,GAAG,MAAM,CAC5C,CAAC;IACN,CAAC;IAED;;;;;OAKG;IACK,SAAS,CAAC,KAAwB,EAAE,IAAiC;QACzE,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,EAAE,CAAC;QACjC,MAAM,KAAK,GAAa;YACpB,wDAAwD,IAAI,CAAC,MAAM,oCAAoC;YACvG,kGAAkG;YAClG,8BAA8B;YAC9B,EAAE;SACL,CAAC;QACF,sGAAsG;QACtG,uFAAuF;QACvF,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC3B,KAAK,CAAC,IAAI,CACN,YAAY,KAAK,CAAC,OAAO,CAAC,MAAM,sEAAsE,EACtG,iGAAiG,EACjG,EAAE,CAAC,CAAC;QACZ,CAAC;QACD,KAAK,MAAM,CAAC,IAAI,IAAI;YAAE,KAAK,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,aAAa,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC;QAClE,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACf,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC5B,CAAC;IAEO,UAAU,CAAC,UAAkB;QACjC,MAAM,YAAY,GAAG,UAAU,KAAK,CAAC;YACjC,CAAC,CAAC,wDAAwD;YAC1D,CAAC,CAAC,uBAAuB,CAAC;QAC9B,OAAO,CACH,QAAQ,UAAU,WAAW,YAAY,oCAAoC;YAC7E,sGAAsG,CACzG,CAAC;IACN,CAAC;IAED,sGAAsG;IACtG,yGAAyG;IACjG,aAAa,CAAC,KAAwB;QAC1C,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAoB,EAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QACxF,OAAO,KAAK,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAmB,EAAW,EAAE,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC;IACrG,CAAC;IAED;;;;;;;;;OASG;IACK,aAAa,CAAC,KAAwB,EAAE,CAAmB;QAC/D,MAAM,gBAAgB,GAAG,IAAI,CAAC,oBAAoB,CAAC,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC;QAC1G,OAAO;YACH,GAAG,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YACxB,wBAAwB,CAAC,CAAC,QAAQ,EAAE;YACpC,+EAA+E;YAC/E,wBAAwB,gBAAgB,EAAE;YAC1C,EAAE;SACL,CAAC;IACN,CAAC;IAED,mGAAmG;IACnG,uFAAuF;IAC/E,MAAM,CAAC,KAAwB,EAAE,CAAmB;QACxD,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAkB,EAAW,EAAE,CAAC,CAAC,CAAC,WAAW,KAAK,CAAC,CAAC,WAAW,CAAC,CAAC;QACrG,IAAI,CAAC,OAAO;YAAE,OAAO,CAAC,OAAO,CAAC,CAAC,QAAQ,MAAM,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QAC5D,OAAO;YACH,OAAO,CAAC,CAAC,QAAQ,sFAAsF;YACvG,SAAS,OAAO,CAAC,OAAO,EAAE;YAC1B,iGAAiG;YACjG,8BAA8B;SACjC,CAAC;IACN,CAAC;IAED,sGAAsG;IACtG,+FAA+F;IACvF,GAAG,CAAC,CAAmB;QAC3B,IAAI,CAAC,CAAC,eAAe,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACjC,OAAO,iEAAiE,CAAC,CAAC,OAAO,CAAC,MAAM,UAAU,CAAC;QACvG,CAAC;QACD,OAAO,GAAG,CAAC,CAAC,OAAO,CAAC,MAAM,oBAAoB,CAAC,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC,CAAS,EAAU,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;IACtH,CAAC;CACJ,CAAA;AA1KY,oCAAY;uBAAZ,YAAY;IADxB,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;6CAGC,kCAAe;QACV,0CAA2B;GAH7D,YAAY,CA0KxB","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\nimport { reviewJsonSchemaHint, RequiredChecklist, ReviewerBriefing, ReviewerInstructionsService } from '@webpieces/rules-config';\nimport { ChecklistNotice } from './checklist-notice';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * One reviewer that ALREADY ANSWERED on this branch and refused, with the refusal rendered by\n * `ReviewJsonService.refusalError` — the ONE wording, shared with `wp-finish-upsert-pr`. Data-only.\n *\n * Stage ② carries these because it had the same defect finish did: a refused checklist has no passing\n * verdict, so it is \"owed\", so it got an ordinary spawn block identical to a reviewer that never ran. An\n * agent obeys that block, the reviewer re-reads unchanged code, refuses again — the loop, one stage earlier.\n *\n * The message is rendered by the COMMAND rather than in here for one reason: stage ② must not archive\n * anything. `refusalError` called with no archive path says \"fix it, then re-run; or set an override in\n * review-<id>.json\", which is only true while that file is still live — and at stage ② it is. Retiring a\n * verdict is finish's act, on the refusal it is actually acting on.\n */\nexport class RefusedReviewer {\n checklistId: string;\n message: string;\n\n constructor(checklistId: string, message: string) {\n this.checklistId = checklistId;\n this.message = message;\n }\n}\n\n/**\n * Everything the closing block of `wp-review-upsert-pr` needs. Data-only, and a class rather than an\n * object literal per CLAUDE.md. The three identifying paths are constructor args; the rest are optional\n * facts about the scan that default to \"nothing\", so a repo with no checklists constructs it in one line.\n */\nexport class ReviewReportInput {\n repoRoot: string;\n featureName: string;\n reviewPath: string; // the branch's review.json — the file the AI must write next\n definedCount: number; // how many checklists pr-gate.checklists defines\n applicableCount: number; // how many of them apply to THIS diff (0 ⇒ the notice, not spawn blocks)\n reviewed: RequiredChecklist[]; // already have a passing verdict on this branch\n formatErrors: string[]; // verdict files that exist but cannot be read as verdicts\n briefings: ReviewerBriefing[]; // one per applicable checklist, already written to disk\n refused: RefusedReviewer[]; // of the owed ones, those that already ran and said no (see RefusedReviewer)\n\n constructor(repoRoot: string, featureName: string, reviewPath: string) {\n this.repoRoot = repoRoot;\n this.featureName = featureName;\n this.reviewPath = reviewPath;\n this.definedCount = 0;\n this.applicableCount = 0;\n this.reviewed = [];\n this.formatErrors = [];\n this.briefings = [];\n this.refused = [];\n }\n}\n\n/**\n * Renders the closing block of `wp-review-upsert-pr` — the checklist verdict, then EXACTLY ONE \"what to do\n * next\".\n *\n * Extracted from the command so it can be asserted on as a rendered string, because the ordering IS the\n * contract. The bug it was extracted to fix: the zero-checklist notice ended with \"Carry on and run: pnpm\n * wp-finish-upsert-pr\", and the block printed directly beneath it said to write review.json first and\n * finish afterwards. Two next-steps, in the wrong order, and an agent that follows instructions literally —\n * which is the entire reason this command prints them — took the first one and opened a PR with no review.\n * That is precisely the failure the three-stage flow exists to prevent (reported on PR #519).\n *\n * The invariants, enforced by review-report.spec.ts:\n * 1. `wp-finish-upsert-pr` is named as a thing to run EXACTLY ONCE in the whole block.\n * 2. The review.json instruction comes BEFORE it — and BEFORE the spawn blocks (see nextSteps).\n * 3. With zero checklists the all-clear precedes any configuration guidance.\n *\n * Pure string building, no I/O. `@injectable(bindingScopeValues.Singleton)` so it is injected by type.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class ReviewReport {\n constructor(\n private readonly checklistNotice: ChecklistNotice,\n private readonly reviewerInstructions: ReviewerInstructionsService,\n ) {}\n\n render(input: ReviewReportInput): string {\n return '\\n' + SEP + this.header(input) + SEP\n + this.scanVerdict(input)\n + this.nextSteps(input);\n }\n\n // Name what this block is actually about. A repo with reviewers owed is being told to SPAWN; a repo\n // with none is not, and promising subagents it does not have is the same kind of noise as explaining\n // checklist configuration to a repo that configured none. Keyed on what is actually OWED rather than on\n // the applicable count: every applicable checklist already having a verdict means nothing to spawn.\n private header(input: ReviewReportInput): string {\n if (this.owedReviewers(input).length === 0) return '② Review, then finish\\n';\n return '② Review, spawn subagent reviewers, then finish\\n';\n }\n\n /**\n * What the SCAN found: either \"nothing applies here\" or the already-reviewed / unreadable-verdict lines.\n * Verdicts, not instructions — every instruction lives in nextSteps() below, so no line up here can be\n * mistaken for the next action.\n */\n private scanVerdict(input: ReviewReportInput): string {\n if (input.applicableCount === 0) return '\\n' + this.checklistNotice.build(input.definedCount);\n const lines: string[] = [];\n for (const r of input.reviewed) {\n lines.push(` ✓ ${r.subagent} — already reviewed on this branch (reusing its review-${r.id}.json)`);\n }\n // A verdict file that EXISTS but is unreadable as a verdict is called out here. Without it this\n // reports the checklist as simply owed, and the AI re-runs a reviewer that already ran instead of\n // correcting the file sitting right there.\n for (const e of input.formatErrors) lines.push(` ⛔ ${e}`);\n if (this.owedReviewers(input).length === 0) {\n lines.push('', '✅ Every checklist that applies is already reviewed — nothing to spawn.');\n }\n if (lines.length === 0) return '';\n return '\\n' + lines.join('\\n') + '\\n';\n }\n\n /**\n * The ONE instruction block, and the last thing the stage prints. Numbered rather than prose-linked\n * (\"Then… Finally…\") so that skipping step 1 is visibly skipping a step, and worded so no earlier line\n * can be mistaken for the real next action.\n *\n * review.json is STEP 1 and the spawn blocks are STEP 2 — that ORDER is the contract, not a preference.\n * This block used to print the spawn blocks first and then say to write review.json \"WHILE any reviewer\n * subagents above are still running\", which does not merely permit spawning first, it instructs it.\n * Harmless for a reviewer that only reads the diff; wrong for one that judges the PR's stated INTENT —\n * its title, summary or risk level — because review.json is the only place that intent lives. Such a\n * reviewer either finds no file (a false RED and a wasted reviewer run) or, on a second run of this\n * stage on the same branch, finds the PREVIOUS run's file and validates a title that no longer exists —\n * a false GREEN, with nothing in the output saying which of the two happened. A consuming repo had to\n * write itself a rule telling its agents to DISOBEY this block to work around it.\n *\n * What the old ordering bought was overlap on a single local file write, not a subagent round-trip.\n *\n * The schema hint is rendered by ReviewJsonService — the single renderer — so the shape printed here\n * can never drift from the shape `wp-finish-upsert-pr` validates.\n */\n private nextSteps(input: ReviewReportInput): string {\n const owed = this.owedReviewers(input);\n const stepCount = owed.length === 0 ? 2 : 3;\n return '\\n' + SEP\n + `▶ NEXT — ${stepCount} steps, in this order. Step 1 is NOT optional:\\n` + SEP + '\\n'\n + this.writeReviewStep(input.reviewPath)\n + this.spawnStep(input, owed)\n + this.finishStep(stepCount);\n }\n\n private writeReviewStep(reviewPath: string): string {\n return (\n 'STEP 1 — review your own changes, then write the review file. Write it FIRST — BEFORE you spawn\\n' +\n ' anything below. finish REFUSES without it, and a reviewer subagent may READ it: a\\n' +\n ' checklist that judges the PR title, summary or risk level reads exactly this file, so\\n' +\n ' writing it afterwards races that reviewer into seeing nothing — or, on a re-run of this\\n' +\n ' stage, into judging the PREVIOUS run\\'s review of code that has since changed.\\n\\n' +\n reviewJsonSchemaHint(reviewPath) + '\\n\\n'\n );\n }\n\n /**\n * STEP 2 — one copy-paste block per owed reviewer, and nothing at all when none is owed. The prompt is\n * deliberately a POINTER and nothing else: the generated instructions file is the contract, so anything\n * restated here is a second copy that can go stale — which is exactly how a removed `success` field\n * outlived its own removal in print.\n */\n private spawnStep(input: ReviewReportInput, owed: readonly ReviewerBriefing[]): string {\n if (owed.length === 0) return '';\n const lines: string[] = [\n `STEP 2 — only once that file is written, spawn these ${owed.length} reviewer subagent(s) — a SEPARATE`,\n ' one each. You may NOT review your own work, and you may NOT write a reviewer\\'s verdict',\n ' file on its behalf.',\n '',\n ];\n // Said up front, not only beside the block: an agent that has decided to spawn everything listed here\n // needs to know BEFORE it starts that one of these entries is not a spawn-shaped task.\n if (input.refused.length > 0) {\n lines.push(\n ` ${input.refused.length} of them already ANSWERED and refused (marked ⛔ below). Do not spawn`,\n ' those against unchanged code — fix what they found first; the fix is the prerequisite.',\n '');\n }\n for (const b of owed) lines.push(...this.oneSpawnBlock(input, b));\n lines.push('');\n return lines.join('\\n');\n }\n\n private finishStep(stepNumber: number): string {\n const precondition = stepNumber === 3\n ? 'once every reviewer above has written its verdict file'\n : 'once that file exists';\n return (\n `STEP ${stepNumber} — only ${precondition}, run: pnpm wp-finish-upsert-pr\\n` +\n ' (The build gate is already green for this commit — finish reuses it unless HEAD moves.)\\n\\n'\n );\n }\n\n // The briefings with no passing verdict yet — the ONE definition of \"owed\", shared by the header, the\n // scan verdict and the step numbering, so they cannot disagree about whether there is anything to spawn.\n private owedReviewers(input: ReviewReportInput): ReviewerBriefing[] {\n const reviewedIds = new Set(input.reviewed.map((r: RequiredChecklist): string => r.id));\n return input.briefings.filter((b: ReviewerBriefing): boolean => !reviewedIds.has(b.checklistId));\n }\n\n /**\n * One reviewer's block. A reviewer that already REFUSED gets the SAME spawn coordinates but a different\n * lead-in, because the action before spawning is different: its own words are printed, and the spawn is\n * explicitly conditioned on having fixed the finding first.\n *\n * It keeps its spawn block rather than being dropped from the list, because the reviewer genuinely does\n * still owe a fresh verdict — dropping it would leave nothing anywhere saying how to get one. What must\n * not happen is a bare \"spawn this\" that reads identically to a reviewer that never ran, which is the\n * loop this exists to break.\n */\n private oneSpawnBlock(input: ReviewReportInput, b: ReviewerBriefing): string[] {\n const instructionsFile = this.reviewerInstructions.pathFor(input.repoRoot, input.featureName, b.subagent);\n return [\n ...this.leadIn(input, b),\n ` subagent_type: ${b.subagent}`,\n ' prompt: Read your instructions file FIRST and follow it exactly:',\n ` ${instructionsFile}`,\n '',\n ];\n }\n\n // The lines above the spawn coordinates: normally just why this reviewer is in scope; for one that\n // already refused, its verdict verbatim plus the order the two actions must happen in.\n private leadIn(input: ReviewReportInput, b: ReviewerBriefing): string[] {\n const refusal = input.refused.find((r: RefusedReviewer): boolean => r.checklistId === b.checklistId);\n if (!refusal) return [` ▶ ${b.subagent} — ${this.why(b)}`];\n return [\n ` ⛔ ${b.subagent} — ALREADY REVIEWED THIS BRANCH AND REFUSED. It will refuse again on unchanged code.`,\n ` ${refusal.message}`,\n ' FIX THE FINDING FIRST (or record a human-authored override). ONLY THEN spawn it again, to',\n ' write a fresh verdict:',\n ];\n }\n\n // Why this one is in scope. A patternless checklist is NOT \"matched\" — it always runs, over the whole\n // diff, and saying so is what tells a repo its checklist is firing on docs-only PRs by design.\n private why(b: ReviewerBriefing): string {\n if (b.matchedPatterns.length === 0) {\n return `ALWAYS RUNS (no \"patterns\" configured), whole diff in scope — ${b.myFiles.length} file(s)`;\n }\n return `${b.myFiles.length} file(s) matched ${b.matchedPatterns.map((p: string): string => `\"${p}\"`).join(', ')}`;\n }\n}\n"]}
@@ -0,0 +1,72 @@
1
+ import { ChecklistInstructionsService, ReviewJsonService } from '@webpieces/rules-config';
2
+ import { ChecklistScan } from './checklist-scanner';
3
+ /**
4
+ * The gate `wp-finish-upsert-pr` runs before it parses review.json: REFUSE the PR while any applicable
5
+ * checklist is not clear, and say — per checklist — which of three different things went wrong.
6
+ *
7
+ * It is its own class, not a private method on the command, for two reasons. It is the one piece of finish
8
+ * that both MUTATES the branch (it retires red verdicts) and produces the text an AI acts on, so it is the
9
+ * piece most worth asserting on directly; and the command it came out of is already the largest file in this
10
+ * package, against a hard `@webpieces/max-file-lines` limit.
11
+ *
12
+ * THE BUG THIS EXISTS TO FIX. The message used to be one bucket and one imperative — "You MUST run these N
13
+ * reviewer subagent(s)" — for three unrelated states. Handed to an agent, the literal, obedient response for
14
+ * a checklist that had ALREADY REFUSED was to spawn the reviewer again; it re-read the same unchanged code,
15
+ * refused again, and the loop cost a full subagent run per pass while the reviewer's actual finding was
16
+ * never printed at all. Three states, three actions, in this order:
17
+ *
18
+ * 1. UNREADABLE (`scan.formatErrors`) -> fix four characters of JSON. Not a reviewer problem.
19
+ * 2. REFUSED (CK_FAIL) -> fix the finding (or get a HUMAN override). Not a missing step.
20
+ * 3. NEVER RAN (the rest) -> spawn the subagent. The ONLY case where that is the right move.
21
+ *
22
+ * `@injectable(bindingScopeValues.Singleton)` so it is injected by type and drawn in the DI design.
23
+ */
24
+ export declare class ReviewerVerdictGate {
25
+ private readonly reviewJsonService;
26
+ private readonly instructions;
27
+ constructor(reviewJsonService: ReviewJsonService, instructions: ChecklistInstructionsService);
28
+ /**
29
+ * Refuse the PR while ANY applicable checklist still owes a passing verdict — naming exactly what each
30
+ * one needs. No-op for a repo with no applicable checklists.
31
+ *
32
+ * SIDE EFFECT, deliberately: every REFUSED checklist's verdict file is RETIRED to review-<id>.json.old on
33
+ * the way out (see {@link retireAndReport}). Only red verdicts are ever moved — a green, yellow or
34
+ * overridden one is reused across finish attempts and retiring it would force a needless subagent re-run.
35
+ */
36
+ assertEveryReviewerRan(scan: ChecklistScan): void;
37
+ /**
38
+ * Say plainly how many REFUSED and how many never ran.
39
+ *
40
+ * The old headline said all of them "have no passing verdict yet", which is technically true of a refusal
41
+ * and reads as though nobody had looked. It is the sentence that framed a decision as an omission.
42
+ */
43
+ private headline;
44
+ private unreadableSection;
45
+ /**
46
+ * The refusals — SECOND, above anything that says to spawn a subagent, because a spawn instruction is the
47
+ * one line an agent acts on first, and acting on it here IS the loop.
48
+ */
49
+ private refusedSection;
50
+ /**
51
+ * The reviewers that genuinely never ran — LAST, and listing ONLY these. When there are none this block
52
+ * is absent entirely, which is the whole point: the "You MUST run these N reviewer subagent(s)"
53
+ * imperative must not appear at all on a run whose only problem is a refusal.
54
+ */
55
+ private neverRanSection;
56
+ /**
57
+ * Retire ONE refused checklist's verdict and render the refusal that reports it.
58
+ *
59
+ * ORDER IS LOAD-BEARING. The verdict is resolved from the already-loaded results, so the finding is in
60
+ * hand before the file moves; the move then happens; and only then is the message rendered, because it
61
+ * names the archive path and must not claim a move that did not happen.
62
+ *
63
+ * The move is NON-FATAL. A finish that died after retiring a verdict would leave the AI with a red
64
+ * verdict gone from the live path and no message saying where it went or why the PR was refused — a
65
+ * silent gap, and the worst possible outcome for a feature whose entire purpose is a durable record. So a
66
+ * failed move warns and the refusal is reported without an archive path (the file is then still live,
67
+ * which the un-archived wording of `refusalError` describes correctly).
68
+ */
69
+ private retireAndReport;
70
+ private archiveOrWarn;
71
+ private footer;
72
+ }
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ReviewerVerdictGate = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const rules_config_1 = require("@webpieces/rules-config");
6
+ const inversify_1 = require("inversify");
7
+ /**
8
+ * The gate `wp-finish-upsert-pr` runs before it parses review.json: REFUSE the PR while any applicable
9
+ * checklist is not clear, and say — per checklist — which of three different things went wrong.
10
+ *
11
+ * It is its own class, not a private method on the command, for two reasons. It is the one piece of finish
12
+ * that both MUTATES the branch (it retires red verdicts) and produces the text an AI acts on, so it is the
13
+ * piece most worth asserting on directly; and the command it came out of is already the largest file in this
14
+ * package, against a hard `@webpieces/max-file-lines` limit.
15
+ *
16
+ * THE BUG THIS EXISTS TO FIX. The message used to be one bucket and one imperative — "You MUST run these N
17
+ * reviewer subagent(s)" — for three unrelated states. Handed to an agent, the literal, obedient response for
18
+ * a checklist that had ALREADY REFUSED was to spawn the reviewer again; it re-read the same unchanged code,
19
+ * refused again, and the loop cost a full subagent run per pass while the reviewer's actual finding was
20
+ * never printed at all. Three states, three actions, in this order:
21
+ *
22
+ * 1. UNREADABLE (`scan.formatErrors`) -> fix four characters of JSON. Not a reviewer problem.
23
+ * 2. REFUSED (CK_FAIL) -> fix the finding (or get a HUMAN override). Not a missing step.
24
+ * 3. NEVER RAN (the rest) -> spawn the subagent. The ONLY case where that is the right move.
25
+ *
26
+ * `@injectable(bindingScopeValues.Singleton)` so it is injected by type and drawn in the DI design.
27
+ */
28
+ let ReviewerVerdictGate = class ReviewerVerdictGate {
29
+ reviewJsonService;
30
+ instructions;
31
+ constructor(reviewJsonService, instructions) {
32
+ this.reviewJsonService = reviewJsonService;
33
+ this.instructions = instructions;
34
+ }
35
+ /**
36
+ * Refuse the PR while ANY applicable checklist still owes a passing verdict — naming exactly what each
37
+ * one needs. No-op for a repo with no applicable checklists.
38
+ *
39
+ * SIDE EFFECT, deliberately: every REFUSED checklist's verdict file is RETIRED to review-<id>.json.old on
40
+ * the way out (see {@link retireAndReport}). Only red verdicts are ever moved — a green, yellow or
41
+ * overridden one is reused across finish attempts and retiring it would force a needless subagent re-run.
42
+ */
43
+ assertEveryReviewerRan(scan) {
44
+ if (scan.outstanding.length === 0)
45
+ return;
46
+ // The split is computed from the verdicts the SCAN already loaded, never from a second read of disk:
47
+ // a re-read here could disagree with the set that produced `outstanding`, and a checklist that is
48
+ // "outstanding" per one read and "clear" per another belongs to neither section of the message.
49
+ const refused = this.reviewJsonService.refusedChecklists(scan.outstanding, scan.results);
50
+ const refusedIds = new Set(refused.map((r) => r.id));
51
+ const neverRan = scan.outstanding.filter((r) => !refusedIds.has(r.id));
52
+ // Rendered BEFORE the throw and, per checklist, with the verdict resolved before its file is moved —
53
+ // the message quotes the reviewer's own words, so losing them to the move would defeat the point.
54
+ const refusals = refused.map((req) => this.retireAndReport(scan, req));
55
+ throw new rules_config_1.InformAiError(this.headline(scan, refused, neverRan)
56
+ + this.unreadableSection(scan)
57
+ + this.refusedSection(refusals)
58
+ + this.neverRanSection(scan, neverRan)
59
+ + this.footer(refused.length > 0));
60
+ }
61
+ /**
62
+ * Say plainly how many REFUSED and how many never ran.
63
+ *
64
+ * The old headline said all of them "have no passing verdict yet", which is technically true of a refusal
65
+ * and reads as though nobody had looked. It is the sentence that framed a decision as an omission.
66
+ */
67
+ headline(scan, refused, neverRan) {
68
+ const lines = [
69
+ `⛔ NO PR — ${scan.outstanding.length} of ${scan.applicable.length} review checklist(s) that apply to this branch are not clear:`,
70
+ ];
71
+ if (refused.length > 0) {
72
+ lines.push(` • ${refused.length} REFUSED — a reviewer ran, judged this change, and said no: ${this.instructions.names(refused)}`);
73
+ }
74
+ if (neverRan.length > 0) {
75
+ lines.push(` • ${neverRan.length} never ran — no verdict has been written yet: ${this.instructions.names(neverRan)}`);
76
+ }
77
+ return lines.join('\n') + '\n\n';
78
+ }
79
+ // Unreadable verdict files come FIRST. A reviewer that wrote its verdict in the removed `success` format
80
+ // is otherwise indistinguishable from one that never ran, and the AI would go re-run a subagent instead
81
+ // of correcting four characters of JSON.
82
+ unreadableSection(scan) {
83
+ if (scan.formatErrors.length === 0)
84
+ return '';
85
+ return `${scan.formatErrors.length} verdict file(s) are in an UNREADABLE format:\n\n`
86
+ + scan.formatErrors.map((e) => ` • ${e}`).join('\n') + '\n\n';
87
+ }
88
+ /**
89
+ * The refusals — SECOND, above anything that says to spawn a subagent, because a spawn instruction is the
90
+ * one line an agent acts on first, and acting on it here IS the loop.
91
+ */
92
+ refusedSection(refusals) {
93
+ if (refusals.length === 0)
94
+ return '';
95
+ return '⛔ REFUSED — these reviewers ALREADY ANSWERED. Re-spawning one against unchanged code only buys\n'
96
+ + ' the same answer: fix what it found (or get a HUMAN to authorize an override), THEN review again.\n\n'
97
+ + refusals.map((r) => ` • ${r}`).join('\n\n') + '\n\n';
98
+ }
99
+ /**
100
+ * The reviewers that genuinely never ran — LAST, and listing ONLY these. When there are none this block
101
+ * is absent entirely, which is the whole point: the "You MUST run these N reviewer subagent(s)"
102
+ * imperative must not appear at all on a run whose only problem is a refusal.
103
+ */
104
+ neverRanSection(scan, neverRan) {
105
+ if (neverRan.length === 0)
106
+ return '';
107
+ return '❓ NO VERDICT YET — nothing has been written for these, so they must actually be run:\n\n'
108
+ + `${this.instructions.render(neverRan, scan.reviewPath, scan.context)}\n\n`;
109
+ }
110
+ /**
111
+ * Retire ONE refused checklist's verdict and render the refusal that reports it.
112
+ *
113
+ * ORDER IS LOAD-BEARING. The verdict is resolved from the already-loaded results, so the finding is in
114
+ * hand before the file moves; the move then happens; and only then is the message rendered, because it
115
+ * names the archive path and must not claim a move that did not happen.
116
+ *
117
+ * The move is NON-FATAL. A finish that died after retiring a verdict would leave the AI with a red
118
+ * verdict gone from the live path and no message saying where it went or why the PR was refused — a
119
+ * silent gap, and the worst possible outcome for a feature whose entire purpose is a durable record. So a
120
+ * failed move warns and the refusal is reported without an archive path (the file is then still live,
121
+ * which the un-archived wording of `refusalError` describes correctly).
122
+ */
123
+ retireAndReport(scan, req) {
124
+ const verdict = this.reviewJsonService.resolveVerdict(req, scan.results);
125
+ return this.reviewJsonService.refusalError(req, verdict, this.archiveOrWarn(scan.reviewPath, req.id));
126
+ }
127
+ // The archive path, or '' when there was nothing to move or the move failed (see retireAndReport).
128
+ archiveOrWarn(reviewPath, checklistId) {
129
+ // webpieces-disable no-unmanaged-exceptions -- chokepoint: a failed archive must never swallow the refusal it belongs to
130
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
131
+ try {
132
+ return this.reviewJsonService.archiveChecklistResult(reviewPath, checklistId);
133
+ }
134
+ catch (err) {
135
+ const error = (0, rules_config_1.toError)(err);
136
+ process.stderr.write(`⚠️ Could not retire the refused verdict for "${checklistId}" (non-fatal — the refusal is still `
137
+ + `reported below): ${error.message}\n`);
138
+ return '';
139
+ }
140
+ }
141
+ // Re-running stage 2 is normally optional. It stops being optional after a refusal, because the only way
142
+ // past one is to CHANGE the code — which is exactly the condition that makes the extracted diff and every
143
+ // reviewer briefing stale.
144
+ footer(anyRefused) {
145
+ if (anyRefused) {
146
+ return 'Then re-run: pnpm wp-review-upsert-pr (the code changed, so the extracted diff and the reviewer\n'
147
+ + 'briefings are stale), re-run the reviewer(s) above, and finally: pnpm wp-finish-upsert-pr';
148
+ }
149
+ return 'Then re-run: pnpm wp-finish-upsert-pr\n'
150
+ + '(Each reviewer\'s generated instructions file is already written — re-running pnpm wp-review-upsert-pr\n'
151
+ + ' is only needed if the code changed since it ran.)';
152
+ }
153
+ };
154
+ exports.ReviewerVerdictGate = ReviewerVerdictGate;
155
+ exports.ReviewerVerdictGate = ReviewerVerdictGate = tslib_1.__decorate([
156
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
157
+ tslib_1.__metadata("design:paramtypes", [rules_config_1.ReviewJsonService,
158
+ rules_config_1.ChecklistInstructionsService])
159
+ ], ReviewerVerdictGate);
160
+ //# sourceMappingURL=reviewer-verdict-gate.js.map