@webpieces/pr-gate 0.4.511 → 0.4.513

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 +1 @@
1
- {"version":3,"file":"start-upsert-pr-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/start-upsert-pr-command.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\nimport { loadAndValidate, writeTemplate, writeTemplateIfMissing, CliExitError, RepoRootFinder, ChecklistReviewContext } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { AiBranchName } from '../workflow/git-readAiBranchName';\nimport { BranchNaming } from '../workflow/branch-naming';\nimport { DiffBasisResolver } from '../workflow/diff-basis';\nimport { GitExec } from '../workflow/git-exec';\nimport { PrContextWriter } from '../workflow/pr-context-writer';\nimport { RunUpdate } from '../workflow/run-update';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n// STAGE ① of the AI-first PR flow: the deterministic setup — update from main / drive the 3-point merge —\n// then hand the AI ONE next command, `wp-review-upsert-pr`.\n//\n// It runs NO build gate, and that is still deliberate, but the reason has changed. It is not that the build\n// belongs only at the end: it is that at THIS point a conflicted 3-point merge may still be unresolved, so\n// there is nothing worth building yet. The gate moved FORWARD, not away — stage ② finalizes the merge and\n// then builds, so reviewers never judge a branch that does not compile, and wp-finish-upsert-pr skips the\n// rebuild when HEAD has not moved since. `pr-gate.buildCommand` is therefore no longer a finish-only knob.\n//\n// This command NEVER creates/updates a PR and NEVER pushes: all `gh` posting and the ONE push live in\n// finish, behind review.json + the checklists + the build gate.\n@injectable(bindingScopeValues.Singleton)\nexport class StartUpsertPrCommand {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly aiBranchName: AiBranchName,\n private readonly branchNaming: BranchNaming,\n private readonly gitExec: GitExec,\n private readonly runUpdate: RunUpdate,\n private readonly diffBasisResolver: DiffBasisResolver,\n private readonly prContextWriter: PrContextWriter,\n ) {}\n\n async run(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n // Refresh the AI-facing workflow doc so it's present + current for any failure message to cite.\n writeTemplate(repoRoot, 'webpieces.git-workflow.md');\n // When this repo has opted into server-side enforcement (a committed gateSalt), scaffold the CI\n // workflow into the gitignored instruct-ai dir (never .github directly — that would dirty the tree\n // before the clean-tree check) and tell the human to copy + require it. IfMissing so it is written\n // once and never clobbers a customized copy.\n this.scaffoldCiWorkflow(repoRoot);\n\n // Precondition: a fully-committed tree. This flow squash-updates the branch and builds it — the\n // tooling must not commit your work for you, and building a dirty working tree would let an\n // uncommitted change build green over a different commit than the one that ships. Fail if dirty.\n this.gitExec.assertCleanTree(repoRoot);\n\n // Nothing here pushes. This command reviews; wp-finish-upsert-pr pushes ONCE, after review.json,\n // every BLOCK checklist, and the authoritative build gate — so no unreviewed commit reaches the\n // remote, and there is no early `synchronize` firing against a PR body with a stale gate token.\n await this.updateBranchFromMain(repoRoot);\n\n // No build gate here — a conflicted merge may still be unresolved, so there is nothing worth\n // building. Stage ② finalizes the merge FIRST, then builds; see the class comment.\n this.handOffToReview(repoRoot);\n }\n\n /**\n * Hand the AI its ONE next step: `wp-review-upsert-pr`.\n *\n * This prints exactly one command on purpose. It used to print three (run the checklist, write\n * review.json, then finish), and a three-item list is a list with a step to skip. Stage ② now owns all\n * of it — it validates the 3-point merge, builds, materializes the diff, briefs the reviewers, AND\n * prints the review.json schema — so there is nothing here to enumerate. Notably review.json is NOT\n * requested here any more: asking for a written review before the branch is known to compile invites\n * one that describes code that does not build.\n */\n private handOffToReview(repoRoot: string): void {\n // Persist the review-format + process instructions where any failure message can cite them.\n writeTemplate(repoRoot, 'webpieces.review-checklists.md');\n // Persist the PR diff context (base sha + the full changed-file set) so it exists even if the AI\n // stops here. Stage ② rewrites it with the materialized-diff dir once it has one.\n const context = this.prContextWriter.ensure(\n repoRoot, this.aiBranchName.getFeatureName(), this.diffBasisResolver.resolve(repoRoot));\n process.stdout.write('\\n' + SEP + '② Review the PR, then finish\\n' + SEP + '\\n');\n process.stdout.write(\n `Branch is updated (nothing pushed yet — finish does the one push, behind the build gate).\\n` +\n `${this.contextLines(context)}\\n` +\n `▶ NEXT run: pnpm wp-review-upsert-pr\\n` +\n ` It validates the 3-point merge, runs the build gate, extracts this branch's diff for the\\n` +\n ` reviewers, and prints what to spawn plus the review.json schema. Everything else waits on it —\\n` +\n ` wp-finish-upsert-pr refuses to open a PR until it has run.\\n\\n`,\n );\n }\n\n // The diff facts, stated where the instruction to use them is — not in a separate block above it. The\n // command comes from the resolved basis; hand-writing `<base> HEAD` here is empty on a dirty tree.\n private contextLines(context: ChecklistReviewContext): string {\n if (context.baseSha.trim() === '') return '';\n const cmd = context.fileDiffCommand.replace(' -- <file>', '');\n return (\n `${cmd === '' ? '' : `Your diff: ${cmd}\\n`}` +\n `Full changed-file set + base/head sha: ${context.prContextPath}\\n`\n );\n }\n\n // Scaffold the server-side CI check when (and only when) this repo set a gateSalt. Written to the\n // gitignored instruct-ai dir so it never dirties the tree; the human copies it to .github/workflows\n // and marks it required (webpieces can't set branch protection). No-op for repos with no gateSalt.\n private scaffoldCiWorkflow(repoRoot: string): void {\n if (loadAndValidate(repoRoot).prGate.gateSalt.trim() === '') return;\n writeTemplateIfMissing(repoRoot, 'webpieces-pr-gate.yml');\n process.stdout.write(\n `\\nℹ️ Server-side gate enforcement is ON (gateSalt set). If you have not already:\\n` +\n ` • copy .webpieces/instruct-ai/webpieces-pr-gate.yml → .github/workflows/ and commit it\\n` +\n ` • mark the \"webpieces-pr-gate\" check REQUIRED in branch protection (repo admin only)\\n`,\n );\n }\n\n // Bring the branch up to date with main via the shared 3-point engine (in-process). On conflict the\n // merge process doc it writes names `wp-review-upsert-pr` as the finish command — that is the command\n // that now validates and commits a conflict resolution, so it is what the doc must send the AI to.\n private async updateBranchFromMain(repoRoot: string): Promise<void> {\n process.stdout.write('\\n' + SEP + '① Updating branch from main\\n' + SEP + '\\n');\n // pushRemote=false — finish owns the single push (see MergeEndOptions).\n const outcome = await this.runUpdate.runUpdateFromMain(repoRoot, 'wp-start-upsert-pr', 'wp-review-upsert-pr', false);\n if (outcome === 'conflict' || outcome === 'unvalidatedResume') {\n throw new CliExitError(2,\n '\\n⏸️ Conflicts — resolve them, then run pnpm wp-review-upsert-pr (it validates the merge, builds it,\\n' +\n ' and only then briefs the reviewers).',\n );\n }\n }\n}\n"]}
1
+ {"version":3,"file":"start-upsert-pr-command.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/commands/start-upsert-pr-command.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\nimport { writeTemplate, CliExitError, RepoRootFinder, ChecklistReviewContext } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { AiBranchName } from '../workflow/git-readAiBranchName';\nimport { BranchNaming } from '../workflow/branch-naming';\nimport { DiffBasisResolver } from '../workflow/diff-basis';\nimport { GitExec } from '../workflow/git-exec';\nimport { PrContextWriter } from '../workflow/pr-context-writer';\nimport { RunUpdate } from '../workflow/run-update';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n// STAGE ① of the AI-first PR flow: the deterministic setup — update from main / drive the 3-point merge —\n// then hand the AI ONE next command, `wp-review-upsert-pr`.\n//\n// It runs NO build gate, and that is still deliberate, but the reason has changed. It is not that the build\n// belongs only at the end: it is that at THIS point a conflicted 3-point merge may still be unresolved, so\n// there is nothing worth building yet. The gate moved FORWARD, not away — stage ② finalizes the merge and\n// then builds, so reviewers never judge a branch that does not compile, and wp-finish-upsert-pr skips the\n// rebuild when HEAD has not moved since. `pr-gate.buildCommand` is therefore no longer a finish-only knob.\n//\n// This command NEVER creates/updates a PR and NEVER pushes: all `gh` posting and the ONE push live in\n// finish, behind review.json + the checklists + the build gate.\n@injectable(bindingScopeValues.Singleton)\nexport class StartUpsertPrCommand {\n constructor(\n private readonly repoRootFinder: RepoRootFinder,\n private readonly aiBranchName: AiBranchName,\n private readonly branchNaming: BranchNaming,\n private readonly gitExec: GitExec,\n private readonly runUpdate: RunUpdate,\n private readonly diffBasisResolver: DiffBasisResolver,\n private readonly prContextWriter: PrContextWriter,\n ) {}\n\n async run(): Promise<void> {\n const repoRoot = this.repoRootFinder.resolveRepoRoot(process.cwd());\n // Refresh the AI-facing workflow doc so it's present + current for any failure message to cite.\n writeTemplate(repoRoot, 'webpieces.git-workflow.md');\n // NOTE: the server-side CI gate is NOT scaffolded here any more — `wp-install-ai-hooks` owns it.\n // This printed copy-to-.github + branch-protection instructions on EVERY run, at an agent doing\n // feature work that could not act on them: marking a check required needs a repo admin. Setup\n // belongs in the setup command; see templates/webpieces.ci-gate-setup.md.\n\n // Precondition: a fully-committed tree. This flow squash-updates the branch and builds it — the\n // tooling must not commit your work for you, and building a dirty working tree would let an\n // uncommitted change build green over a different commit than the one that ships. Fail if dirty.\n this.gitExec.assertCleanTree(repoRoot);\n\n // Nothing here pushes. This command reviews; wp-finish-upsert-pr pushes ONCE, after review.json,\n // every BLOCK checklist, and the authoritative build gate — so no unreviewed commit reaches the\n // remote, and there is no early `synchronize` firing against a PR body with a stale gate token.\n await this.updateBranchFromMain(repoRoot);\n\n // No build gate here — a conflicted merge may still be unresolved, so there is nothing worth\n // building. Stage ② finalizes the merge FIRST, then builds; see the class comment.\n this.handOffToReview(repoRoot);\n }\n\n /**\n * Hand the AI its ONE next step: `wp-review-upsert-pr`.\n *\n * This prints exactly one command on purpose. It used to print three (run the checklist, write\n * review.json, then finish), and a three-item list is a list with a step to skip. Stage ② now owns all\n * of it — it validates the 3-point merge, builds, materializes the diff, briefs the reviewers, AND\n * prints the review.json schema — so there is nothing here to enumerate. Notably review.json is NOT\n * requested here any more: asking for a written review before the branch is known to compile invites\n * one that describes code that does not build.\n */\n private handOffToReview(repoRoot: string): void {\n // Persist the review-format + process instructions where any failure message can cite them.\n writeTemplate(repoRoot, 'webpieces.review-checklists.md');\n // Persist the PR diff context (base sha + the full changed-file set) so it exists even if the AI\n // stops here. Stage ② rewrites it with the materialized-diff dir once it has one.\n const context = this.prContextWriter.ensure(\n repoRoot, this.aiBranchName.getFeatureName(), this.diffBasisResolver.resolve(repoRoot), 'stage1-start');\n process.stdout.write('\\n' + SEP + '② Review the PR, then finish\\n' + SEP + '\\n');\n process.stdout.write(\n `Branch is updated (nothing pushed yet — finish does the one push, behind the build gate).\\n` +\n `${this.contextLines(context)}\\n` +\n `▶ NEXT run: pnpm wp-review-upsert-pr\\n` +\n ` It validates the 3-point merge, runs the build gate, extracts this branch's diff for the\\n` +\n ` reviewers, and prints what to spawn plus the review.json schema. Everything else waits on it —\\n` +\n ` wp-finish-upsert-pr refuses to open a PR until it has run.\\n\\n`,\n );\n }\n\n // The diff facts, stated where the instruction to use them is — not in a separate block above it. The\n // command comes from the resolved basis; hand-writing `<base> HEAD` here is empty on a dirty tree.\n private contextLines(context: ChecklistReviewContext): string {\n if (context.baseSha.trim() === '') return '';\n const cmd = context.fileDiffCommand.replace(' -- <file>', '');\n return (\n `${cmd === '' ? '' : `Your diff: ${cmd}\\n`}` +\n `Full changed-file set + base/head sha: ${context.prContextPath}\\n`\n );\n }\n\n // Bring the branch up to date with main via the shared 3-point engine (in-process). On conflict the\n // merge process doc it writes names `wp-review-upsert-pr` as the finish command — that is the command\n // that now validates and commits a conflict resolution, so it is what the doc must send the AI to.\n private async updateBranchFromMain(repoRoot: string): Promise<void> {\n process.stdout.write('\\n' + SEP + '① Updating branch from main\\n' + SEP + '\\n');\n // pushRemote=false — finish owns the single push (see MergeEndOptions).\n const outcome = await this.runUpdate.runUpdateFromMain(repoRoot, 'wp-start-upsert-pr', 'wp-review-upsert-pr', false);\n if (outcome === 'conflict' || outcome === 'unvalidatedResume') {\n throw new CliExitError(2,\n '\\n⏸️ Conflicts — resolve them, then run pnpm wp-review-upsert-pr (it validates the merge, builds it,\\n' +\n ' and only then briefs the reviewers).',\n );\n }\n }\n}\n"]}
@@ -17,7 +17,12 @@ export declare class BuildAffected {
17
17
  * PrGateConfig.buildCommand, or the default affected-ci command when none is set.
18
18
  */
19
19
  resolveBuildCommand(repoRoot: string): string;
20
- /** Run the build gate. Returns the process exit code (0 = pass). */
20
+ /**
21
+ * Run the build gate. Returns the process exit code (0 = pass).
22
+ *
23
+ * Prints NOTHING itself — `runBuildGate` announces the command in one line. This used to print its own
24
+ * `▶ Build gate: <cmd>` banner on top of that, so the command appeared twice in a row.
25
+ */
21
26
  runBuildAffected(repoRoot: string, buildCommand?: string): number;
22
27
  /** Run the build gate using the project's configured command (PrGateConfig.buildCommand). */
23
28
  runConfiguredBuildGate(repoRoot: string): number;
@@ -39,10 +39,14 @@ let BuildAffected = class BuildAffected {
39
39
  const configured = (0, rules_config_1.loadAndValidate)(repoRoot).prGate.buildCommand;
40
40
  return configured !== undefined && configured.trim() !== '' ? configured : exports.DEFAULT_BUILD_COMMAND;
41
41
  }
42
- /** Run the build gate. Returns the process exit code (0 = pass). */
42
+ /**
43
+ * Run the build gate. Returns the process exit code (0 = pass).
44
+ *
45
+ * Prints NOTHING itself — `runBuildGate` announces the command in one line. This used to print its own
46
+ * `▶ Build gate: <cmd>` banner on top of that, so the command appeared twice in a row.
47
+ */
43
48
  runBuildAffected(repoRoot, buildCommand) {
44
49
  const cmd = buildCommand !== undefined && buildCommand.trim() !== '' ? buildCommand : exports.DEFAULT_BUILD_COMMAND;
45
- process.stdout.write(`\n▶ Build gate: ${cmd}\n\n`);
46
50
  const result = (0, child_process_1.spawnSync)(cmd, { stdio: 'inherit', cwd: repoRoot, shell: true });
47
51
  return result.status ?? 1;
48
52
  }
@@ -57,9 +61,10 @@ let BuildAffected = class BuildAffected {
57
61
  */
58
62
  runBuildGate(repoRoot, opts) {
59
63
  const buildCommand = this.resolveBuildCommand(repoRoot);
60
- process.stdout.write('\n' + SEP + opts.label + '\n' + SEP + '\n');
61
- process.stdout.write(`Running the build gate. To get it passing, run the SAME command yourself and fix everything it reports:\n\n` +
62
- ` ${buildCommand}\n\n`);
64
+ // TWO lines on the happy path — the command, then the result. The old framing spent a banner and a
65
+ // paragraph explaining how to reproduce a build that was about to pass anyway; that explanation is
66
+ // only useful when the build FAILS, so it now lives solely on the failure path below.
67
+ process.stdout.write(`\n${opts.label}: ${buildCommand}\n`);
63
68
  const buildCode = this.runConfiguredBuildGate(repoRoot);
64
69
  if (buildCode !== 0) {
65
70
  throw new rules_config_1.CliExitError(buildCode, `\n❌ ${opts.failureHeadline}\n\n` +
@@ -1 +1 @@
1
- {"version":3,"file":"build-affected.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/build-affected.ts"],"names":[],"mappings":";;;;AAAA,iDAA0C;AAC1C,0DAAwE;AACxE,yCAA2D;AAE3D,kGAAkG;AAClG,yGAAyG;AACzG,uEAAuE;AACvE,gGAAgG;AAChG,wGAAwG;AACxG,oGAAoG;AACvF,QAAA,qBAAqB,GAAG,wEAAwE,CAAC;AAE9G,MAAM,GAAG,GAAG,0DAA0D,CAAC;AAEvE;;;;GAIG;AACH,MAAa,gBAAgB;IACzB,KAAK,CAAS,CAAY,sCAAsC;IAChE,YAAY,CAAS,CAAK,gDAAgD;IAC1E,eAAe,CAAS,CAAE,gCAAgC;IAE1D,YAAY,KAAa,EAAE,YAAoB,EAAE,eAAuB;QACpE,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,YAAY,GAAG,YAAY,CAAC;QACjC,IAAI,CAAC,eAAe,GAAG,eAAe,CAAC;IAC3C,CAAC;CACJ;AAVD,4CAUC;AAED,2GAA2G;AAEpG,IAAM,aAAa,GAAnB,MAAM,aAAa;IACtB;;;OAGG;IACH,mBAAmB,CAAC,QAAgB;QAChC,MAAM,UAAU,GAAG,IAAA,8BAAe,EAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,YAAY,CAAC;QACjE,OAAO,UAAU,KAAK,SAAS,IAAI,UAAU,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,6BAAqB,CAAC;IACrG,CAAC;IAED,oEAAoE;IACpE,gBAAgB,CAAC,QAAgB,EAAE,YAAqB;QACpD,MAAM,GAAG,GAAG,YAAY,KAAK,SAAS,IAAI,YAAY,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,6BAAqB,CAAC;QAC5G,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,mBAAmB,GAAG,MAAM,CAAC,CAAC;QACnD,MAAM,MAAM,GAAG,IAAA,yBAAS,EAAC,GAAG,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QAChF,OAAO,MAAM,CAAC,MAAM,IAAI,CAAC,CAAC;IAC9B,CAAC;IAED,6FAA6F;IAC7F,sBAAsB,CAAC,QAAgB;QACnC,OAAO,IAAI,CAAC,gBAAgB,CAAC,QAAQ,EAAE,IAAA,8BAAe,EAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;IAC1F,CAAC;IAED;;;;OAIG;IACH,YAAY,CAAC,QAAgB,EAAE,IAAsB;QACjD,MAAM,YAAY,GAAG,IAAI,CAAC,mBAAmB,CAAC,QAAQ,CAAC,CAAC;QACxD,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,GAAG,GAAG,GAAG,IAAI,CAAC,KAAK,GAAG,IAAI,GAAG,GAAG,GAAG,IAAI,CAAC,CAAC;QAClE,OAAO,CAAC,MAAM,CAAC,KAAK,CAChB,6GAA6G;YAC7G,OAAO,YAAY,MAAM,CAC5B,CAAC;QACF,MAAM,SAAS,GAAG,IAAI,CAAC,sBAAsB,CAAC,QAAQ,CAAC,CAAC;QACxD,IAAI,SAAS,KAAK,CAAC,EAAE,CAAC;YAClB,MAAM,IAAI,2BAAY,CAAC,SAAS,EAC5B,OAAO,IAAI,CAAC,eAAe,MAAM;gBACjC,uEAAuE,IAAI,CAAC,YAAY,OAAO;gBAC/F,OAAO,YAAY,IAAI,CAC1B,CAAC;QACN,CAAC;QACD,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC;IAChD,CAAC;CACJ,CAAA;AA7CY,sCAAa;wBAAb,aAAa;IADzB,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;GAC5B,aAAa,CA6CzB","sourcesContent":["import { spawnSync } from 'child_process';\nimport { loadAndValidate, CliExitError } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\n// Single source of truth for the build gate. Only `wp-finish-upsert-pr` runs it (authoritatively,\n// before the one push) — `wp-start-upsert-pr` deliberately runs NO build gate, so `pr-gate.buildCommand`\n// is a finish-only knob. nx `affected` only rebuilds changed projects.\n// `--base=$(git merge-base origin/main HEAD)` (the fork point) instead of `--base=origin/main`:\n// origin/main rebuilds projects touched by OTHER people's merged PRs. The fork point scopes affected to\n// only YOUR branch's changes. The `$(...)` resolves because runBuildAffected runs with shell: true.\nexport const DEFAULT_BUILD_COMMAND = 'pnpm nx affected --target=ci --base=$(git merge-base origin/main HEAD)';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * The caller-supplied framing for the build gate (label, re-run command, failure headline). Kept as a\n * parameter object so runBuildGate stays agnostic of who invokes it; today the sole caller is\n * wp-finish-upsert-pr. A class (not an object literal) per the codebase's data-structure convention.\n */\nexport class BuildGateOptions {\n label: string; // section header shown above the gate\n rerunCommand: string; // command the AI re-runs after fixing the build\n failureHeadline: string; // first line printed on failure\n\n constructor(label: string, rerunCommand: string, failureHeadline: string) {\n this.label = label;\n this.rerunCommand = rerunCommand;\n this.failureHeadline = failureHeadline;\n }\n}\n\n/** Runs the authoritative nx-affected build gate for wp-finish-upsert-pr (the only PR-flow build gate). */\n@injectable(bindingScopeValues.Singleton)\nexport class BuildAffected {\n /**\n * Resolve the exact build command this gate will run: the project's configured\n * PrGateConfig.buildCommand, or the default affected-ci command when none is set.\n */\n resolveBuildCommand(repoRoot: string): string {\n const configured = loadAndValidate(repoRoot).prGate.buildCommand;\n return configured !== undefined && configured.trim() !== '' ? configured : DEFAULT_BUILD_COMMAND;\n }\n\n /** Run the build gate. Returns the process exit code (0 = pass). */\n runBuildAffected(repoRoot: string, buildCommand?: string): number {\n const cmd = buildCommand !== undefined && buildCommand.trim() !== '' ? buildCommand : DEFAULT_BUILD_COMMAND;\n process.stdout.write(`\\n▶ Build gate: ${cmd}\\n\\n`);\n const result = spawnSync(cmd, { stdio: 'inherit', cwd: repoRoot, shell: true });\n return result.status ?? 1;\n }\n\n /** Run the build gate using the project's configured command (PrGateConfig.buildCommand). */\n runConfiguredBuildGate(repoRoot: string): number {\n return this.runBuildAffected(repoRoot, loadAndValidate(repoRoot).prGate.buildCommand);\n }\n\n /**\n * Run the configured build gate with consistent framing, throwing CliExitError(buildCode) on\n * failure so the bin's main()/runMain owns the exit. Single source of truth: wp-start-upsert-pr and\n * wp-finish-upsert-pr both call THIS (only the BuildGateOptions differ).\n */\n runBuildGate(repoRoot: string, opts: BuildGateOptions): void {\n const buildCommand = this.resolveBuildCommand(repoRoot);\n process.stdout.write('\\n' + SEP + opts.label + '\\n' + SEP + '\\n');\n process.stdout.write(\n `Running the build gate. To get it passing, run the SAME command yourself and fix everything it reports:\\n\\n` +\n ` ${buildCommand}\\n\\n`,\n );\n const buildCode = this.runConfiguredBuildGate(repoRoot);\n if (buildCode !== 0) {\n throw new CliExitError(buildCode,\n `\\n❌ ${opts.failureHeadline}\\n\\n` +\n `Run THIS exact command to reproduce and fix all errors, then re-run ${opts.rerunCommand}:\\n\\n` +\n ` ${buildCommand}\\n`,\n );\n }\n process.stdout.write('\\n✅ Build passed.\\n');\n }\n}\n"]}
1
+ {"version":3,"file":"build-affected.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/build-affected.ts"],"names":[],"mappings":";;;;AAAA,iDAA0C;AAC1C,0DAAwE;AACxE,yCAA2D;AAE3D,kGAAkG;AAClG,yGAAyG;AACzG,uEAAuE;AACvE,gGAAgG;AAChG,wGAAwG;AACxG,oGAAoG;AACvF,QAAA,qBAAqB,GAAG,wEAAwE,CAAC;AAE9G,MAAM,GAAG,GAAG,0DAA0D,CAAC;AAEvE;;;;GAIG;AACH,MAAa,gBAAgB;IACzB,KAAK,CAAS,CAAY,sCAAsC;IAChE,YAAY,CAAS,CAAK,gDAAgD;IAC1E,eAAe,CAAS,CAAE,gCAAgC;IAE1D,YAAY,KAAa,EAAE,YAAoB,EAAE,eAAuB;QACpE,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,YAAY,GAAG,YAAY,CAAC;QACjC,IAAI,CAAC,eAAe,GAAG,eAAe,CAAC;IAC3C,CAAC;CACJ;AAVD,4CAUC;AAED,2GAA2G;AAEpG,IAAM,aAAa,GAAnB,MAAM,aAAa;IACtB;;;OAGG;IACH,mBAAmB,CAAC,QAAgB;QAChC,MAAM,UAAU,GAAG,IAAA,8BAAe,EAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,YAAY,CAAC;QACjE,OAAO,UAAU,KAAK,SAAS,IAAI,UAAU,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,6BAAqB,CAAC;IACrG,CAAC;IAED;;;;;OAKG;IACH,gBAAgB,CAAC,QAAgB,EAAE,YAAqB;QACpD,MAAM,GAAG,GAAG,YAAY,KAAK,SAAS,IAAI,YAAY,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,6BAAqB,CAAC;QAC5G,MAAM,MAAM,GAAG,IAAA,yBAAS,EAAC,GAAG,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QAChF,OAAO,MAAM,CAAC,MAAM,IAAI,CAAC,CAAC;IAC9B,CAAC;IAED,6FAA6F;IAC7F,sBAAsB,CAAC,QAAgB;QACnC,OAAO,IAAI,CAAC,gBAAgB,CAAC,QAAQ,EAAE,IAAA,8BAAe,EAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;IAC1F,CAAC;IAED;;;;OAIG;IACH,YAAY,CAAC,QAAgB,EAAE,IAAsB;QACjD,MAAM,YAAY,GAAG,IAAI,CAAC,mBAAmB,CAAC,QAAQ,CAAC,CAAC;QACxD,mGAAmG;QACnG,mGAAmG;QACnG,sFAAsF;QACtF,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,IAAI,CAAC,KAAK,KAAK,YAAY,IAAI,CAAC,CAAC;QAC3D,MAAM,SAAS,GAAG,IAAI,CAAC,sBAAsB,CAAC,QAAQ,CAAC,CAAC;QACxD,IAAI,SAAS,KAAK,CAAC,EAAE,CAAC;YAClB,MAAM,IAAI,2BAAY,CAAC,SAAS,EAC5B,OAAO,IAAI,CAAC,eAAe,MAAM;gBACjC,uEAAuE,IAAI,CAAC,YAAY,OAAO;gBAC/F,OAAO,YAAY,IAAI,CAC1B,CAAC;QACN,CAAC;QACD,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC;IAChD,CAAC;CACJ,CAAA;AAhDY,sCAAa;wBAAb,aAAa;IADzB,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;GAC5B,aAAa,CAgDzB","sourcesContent":["import { spawnSync } from 'child_process';\nimport { loadAndValidate, CliExitError } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\n// Single source of truth for the build gate. Only `wp-finish-upsert-pr` runs it (authoritatively,\n// before the one push) — `wp-start-upsert-pr` deliberately runs NO build gate, so `pr-gate.buildCommand`\n// is a finish-only knob. nx `affected` only rebuilds changed projects.\n// `--base=$(git merge-base origin/main HEAD)` (the fork point) instead of `--base=origin/main`:\n// origin/main rebuilds projects touched by OTHER people's merged PRs. The fork point scopes affected to\n// only YOUR branch's changes. The `$(...)` resolves because runBuildAffected runs with shell: true.\nexport const DEFAULT_BUILD_COMMAND = 'pnpm nx affected --target=ci --base=$(git merge-base origin/main HEAD)';\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * The caller-supplied framing for the build gate (label, re-run command, failure headline). Kept as a\n * parameter object so runBuildGate stays agnostic of who invokes it; today the sole caller is\n * wp-finish-upsert-pr. A class (not an object literal) per the codebase's data-structure convention.\n */\nexport class BuildGateOptions {\n label: string; // section header shown above the gate\n rerunCommand: string; // command the AI re-runs after fixing the build\n failureHeadline: string; // first line printed on failure\n\n constructor(label: string, rerunCommand: string, failureHeadline: string) {\n this.label = label;\n this.rerunCommand = rerunCommand;\n this.failureHeadline = failureHeadline;\n }\n}\n\n/** Runs the authoritative nx-affected build gate for wp-finish-upsert-pr (the only PR-flow build gate). */\n@injectable(bindingScopeValues.Singleton)\nexport class BuildAffected {\n /**\n * Resolve the exact build command this gate will run: the project's configured\n * PrGateConfig.buildCommand, or the default affected-ci command when none is set.\n */\n resolveBuildCommand(repoRoot: string): string {\n const configured = loadAndValidate(repoRoot).prGate.buildCommand;\n return configured !== undefined && configured.trim() !== '' ? configured : DEFAULT_BUILD_COMMAND;\n }\n\n /**\n * Run the build gate. Returns the process exit code (0 = pass).\n *\n * Prints NOTHING itself — `runBuildGate` announces the command in one line. This used to print its own\n * `▶ Build gate: <cmd>` banner on top of that, so the command appeared twice in a row.\n */\n runBuildAffected(repoRoot: string, buildCommand?: string): number {\n const cmd = buildCommand !== undefined && buildCommand.trim() !== '' ? buildCommand : DEFAULT_BUILD_COMMAND;\n const result = spawnSync(cmd, { stdio: 'inherit', cwd: repoRoot, shell: true });\n return result.status ?? 1;\n }\n\n /** Run the build gate using the project's configured command (PrGateConfig.buildCommand). */\n runConfiguredBuildGate(repoRoot: string): number {\n return this.runBuildAffected(repoRoot, loadAndValidate(repoRoot).prGate.buildCommand);\n }\n\n /**\n * Run the configured build gate with consistent framing, throwing CliExitError(buildCode) on\n * failure so the bin's main()/runMain owns the exit. Single source of truth: wp-start-upsert-pr and\n * wp-finish-upsert-pr both call THIS (only the BuildGateOptions differ).\n */\n runBuildGate(repoRoot: string, opts: BuildGateOptions): void {\n const buildCommand = this.resolveBuildCommand(repoRoot);\n // TWO lines on the happy path — the command, then the result. The old framing spent a banner and a\n // paragraph explaining how to reproduce a build that was about to pass anyway; that explanation is\n // only useful when the build FAILS, so it now lives solely on the failure path below.\n process.stdout.write(`\\n${opts.label}: ${buildCommand}\\n`);\n const buildCode = this.runConfiguredBuildGate(repoRoot);\n if (buildCode !== 0) {\n throw new CliExitError(buildCode,\n `\\n❌ ${opts.failureHeadline}\\n\\n` +\n `Run THIS exact command to reproduce and fix all errors, then re-run ${opts.rerunCommand}:\\n\\n` +\n ` ${buildCommand}\\n`,\n );\n }\n process.stdout.write('\\n✅ Build passed.\\n');\n }\n}\n"]}
@@ -11,7 +11,21 @@ export declare class ChecklistScanOptions {
11
11
  * still owes review (what `wp-finish-upsert-pr` BLOCKS on).
12
12
  */
13
13
  filterAlreadyReviewed: boolean;
14
- constructor(filterAlreadyReviewed?: boolean);
14
+ /**
15
+ * Which per-stage snapshot this scan should persist ('stage3-finish', …), or '' for "do not write
16
+ * pr-context.json here".
17
+ *
18
+ * '' exists for stage ②, which cannot write yet: the context records `diffDir`, and the diff has not
19
+ * been materialized at scan time. Letting the scan write anyway meant stage ② wrote the file twice,
20
+ * ~60 lines apart, the first time with an empty diffDir. Now it writes once, after materializing.
21
+ *
22
+ * The DEFAULT is deliberately non-empty, so writing is what you get by NOT thinking about it. A
23
+ * reviewer block that lost its diff command because nobody had written pr-context.json is a bug this
24
+ * codebase has already shipped once (see PrContextWriter's docstring); opting out must be an explicit,
25
+ * visible act by a caller that takes responsibility for writing it later — not an omission.
26
+ */
27
+ contextStage: string;
28
+ constructor(filterAlreadyReviewed?: boolean, contextStage?: string);
15
29
  }
16
30
  /**
17
31
  * The answer to "what review does this branch owe?", in the X → N → Z terms the commands report:
@@ -16,8 +16,23 @@ class ChecklistScanOptions {
16
16
  * still owes review (what `wp-finish-upsert-pr` BLOCKS on).
17
17
  */
18
18
  filterAlreadyReviewed;
19
- constructor(filterAlreadyReviewed = false) {
19
+ /**
20
+ * Which per-stage snapshot this scan should persist ('stage3-finish', …), or '' for "do not write
21
+ * pr-context.json here".
22
+ *
23
+ * '' exists for stage ②, which cannot write yet: the context records `diffDir`, and the diff has not
24
+ * been materialized at scan time. Letting the scan write anyway meant stage ② wrote the file twice,
25
+ * ~60 lines apart, the first time with an empty diffDir. Now it writes once, after materializing.
26
+ *
27
+ * The DEFAULT is deliberately non-empty, so writing is what you get by NOT thinking about it. A
28
+ * reviewer block that lost its diff command because nobody had written pr-context.json is a bug this
29
+ * codebase has already shipped once (see PrContextWriter's docstring); opting out must be an explicit,
30
+ * visible act by a caller that takes responsibility for writing it later — not an omission.
31
+ */
32
+ contextStage;
33
+ constructor(filterAlreadyReviewed = false, contextStage = 'stage-scan') {
20
34
  this.filterAlreadyReviewed = filterAlreadyReviewed;
35
+ this.contextStage = contextStage;
21
36
  }
22
37
  }
23
38
  exports.ChecklistScanOptions = ChecklistScanOptions;
@@ -126,7 +141,9 @@ let ChecklistScanner = class ChecklistScanner {
126
141
  const stillOwed = this.reviewJsonService.pendingChecklists(applicable, results);
127
142
  const owedIds = new Set(stillOwed.map((r) => r.id));
128
143
  const reviewed = applicable.filter((r) => !owedIds.has(r.id));
129
- return new ChecklistScan(defined, applicable, reviewed, opts.filterAlreadyReviewed ? stillOwed : applicable, this.prContextWriter.ensure(repoRoot, featureName, basis), reviewPath, base, roster, this.reviewJsonService.checklistFormatErrors(applicable, results), basis, changedFiles);
144
+ return new ChecklistScan(defined, applicable, reviewed, opts.filterAlreadyReviewed ? stillOwed : applicable, opts.contextStage === ''
145
+ ? 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);
130
147
  }
131
148
  /**
132
149
  * 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 constructor(filterAlreadyReviewed = false) {\n this.filterAlreadyReviewed = filterAlreadyReviewed;\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 this.prContextWriter.ensure(repoRoot, featureName, basis),\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, 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"]}
@@ -16,11 +16,26 @@ import { ForkPoint } from './git-findForkPoint';
16
16
  export declare class DiffBasis {
17
17
  base: string;
18
18
  headSha: string;
19
+ /**
20
+ * The SAME three hash points the 3-point merge records, under the SAME names
21
+ * (`merge-info/<branch>/updatemain-hashes.json` → hashForkPoint / hashFeatureHead / hashMainHead).
22
+ *
23
+ * Two reasons this matters. First, vocabulary: the merge half of the system said "hashForkPoint"
24
+ * while the review half said "base" for the identical sha, so nothing could be grepped across both.
25
+ * Second, and the real gap: the review side recorded only TWO of the three. Without `hashMainHead`
26
+ * nothing downstream can answer "did main move while this branch was under review?" — which is
27
+ * exactly what you want to know when a review looks stale or a merge is about to surprise you.
28
+ *
29
+ * `hashMainHead` is '' when origin/main cannot be resolved offline; it is a REPORT, never a gate.
30
+ */
31
+ hashForkPoint: string;
32
+ hashFeatureHead: string;
33
+ hashMainHead: string;
19
34
  dirty: boolean;
20
35
  dirtyFiles: string[];
21
36
  diffCommand: string;
22
37
  fileDiffCommand: string;
23
- constructor(base?: string, headSha?: string, dirty?: boolean, dirtyFiles?: string[], diffCommand?: string, fileDiffCommand?: string);
38
+ constructor(base?: string, headSha?: string, dirty?: boolean, dirtyFiles?: string[], diffCommand?: string, fileDiffCommand?: string, hashMainHead?: string);
24
39
  get unresolved(): boolean;
25
40
  }
26
41
  /**
@@ -21,10 +21,27 @@ const git_findForkPoint_1 = require("./git-findForkPoint");
21
21
  */
22
22
  class DiffBasis {
23
23
  // The 3-point fork point of main. '' when neither origin/main nor main resolves.
24
+ // Alias of {@link hashForkPoint} — see the hash-point trio below.
24
25
  base;
25
26
  // The REAL sha, never the literal string 'HEAD'. A recorded 'HEAD' is worthless the moment a commit
26
27
  // lands on top of it, and the stage receipt has to compare shas to know the tree moved under a review.
28
+ // Alias of {@link hashFeatureHead}.
27
29
  headSha;
30
+ /**
31
+ * The SAME three hash points the 3-point merge records, under the SAME names
32
+ * (`merge-info/<branch>/updatemain-hashes.json` → hashForkPoint / hashFeatureHead / hashMainHead).
33
+ *
34
+ * Two reasons this matters. First, vocabulary: the merge half of the system said "hashForkPoint"
35
+ * while the review half said "base" for the identical sha, so nothing could be grepped across both.
36
+ * Second, and the real gap: the review side recorded only TWO of the three. Without `hashMainHead`
37
+ * nothing downstream can answer "did main move while this branch was under review?" — which is
38
+ * exactly what you want to know when a review looks stale or a merge is about to surprise you.
39
+ *
40
+ * `hashMainHead` is '' when origin/main cannot be resolved offline; it is a REPORT, never a gate.
41
+ */
42
+ hashForkPoint;
43
+ hashFeatureHead;
44
+ hashMainHead;
28
45
  // True when the diff includes staged/unstaged/untracked work, i.e. the range is base→working tree.
29
46
  dirty;
30
47
  // Exactly WHICH paths make it dirty, so a message can name them instead of asserting dirtiness.
@@ -34,13 +51,18 @@ class DiffBasis {
34
51
  // The same command with a `-- <file>` tail — what every reviewer instruction prints.
35
52
  fileDiffCommand;
36
53
  // eslint-disable-next-line @typescript-eslint/max-params
37
- constructor(base = '', headSha = '', dirty = false, dirtyFiles = [], diffCommand = '', fileDiffCommand = '') {
54
+ constructor(base = '', headSha = '', dirty = false, dirtyFiles = [], diffCommand = '', fileDiffCommand = '', hashMainHead = '') {
38
55
  this.base = base;
39
56
  this.headSha = headSha;
40
57
  this.dirty = dirty;
41
58
  this.dirtyFiles = dirtyFiles;
42
59
  this.diffCommand = diffCommand;
43
60
  this.fileDiffCommand = fileDiffCommand;
61
+ // The trio is DERIVED from base/headSha rather than passed separately, so the two names for one
62
+ // sha cannot drift apart into disagreeing values.
63
+ this.hashForkPoint = base;
64
+ this.hashFeatureHead = headSha;
65
+ this.hashMainHead = hashMainHead;
44
66
  }
45
67
  // True when there is no usable base — callers must SAY so rather than print a command that cannot work.
46
68
  get unresolved() {
@@ -72,12 +94,16 @@ let DiffBasisResolver = class DiffBasisResolver {
72
94
  resolve(repoRoot) {
73
95
  const base = this.forkPoint.resolveForkPoint(repoRoot);
74
96
  const headSha = this.gitOut(repoRoot, ['rev-parse', 'HEAD']);
97
+ // Point C. Deliberately does NOT fetch — this must work offline, like resolveForkPoint. So it is
98
+ // main as this clone last saw it, which is the honest answer to "has main moved under me?" that a
99
+ // local command can give. '' when origin/main is unknown (no remote yet).
100
+ const mainHead = this.gitOut(repoRoot, ['rev-parse', 'origin/main']);
75
101
  const dirtyFiles = this.dirtyPaths(repoRoot);
76
102
  const dirty = dirtyFiles.length > 0;
77
103
  if (base === '')
78
- return new DiffBasis('', headSha, dirty, dirtyFiles);
104
+ return new DiffBasis('', headSha, dirty, dirtyFiles, '', '', mainHead);
79
105
  const whole = dirty ? `git diff ${base}` : `git diff ${base} ${headSha}`;
80
- return new DiffBasis(base, headSha, dirty, dirtyFiles, whole, `${whole} -- <file>`);
106
+ return new DiffBasis(base, headSha, dirty, dirtyFiles, whole, `${whole} -- <file>`, mainHead);
81
107
  }
82
108
  /**
83
109
  * Every path that makes the tree dirty: tracked modifications/staged changes PLUS untracked files.
@@ -1 +1 @@
1
- {"version":3,"file":"diff-basis.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/diff-basis.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { ForkPoint } from './git-findForkPoint';\n\n/**\n * The ONE resolved answer to \"what exactly is this branch's diff, and what command reproduces it?\"\n *\n * It exists because the two halves of the review flow used to disagree. `ChecklistScanner` computes the\n * changed-FILE set by calling `getChangedFiles` with NO head — base→WORKING TREE, unioning in untracked\n * files — while `ChecklistInstructionsService` printed `git diff <base> HEAD -- <file>` to every\n * reviewer. On a dirty tree those are different ranges, and the printed command returns NOTHING: a reviewer\n * is handed a file list and a command that shows it nothing. That is not hypothetical — a reviewer subagent\n * on monorepo-nx2 ran the printed command, got empty output, and had to guess its way to `git diff HEAD`.\n *\n * So the command is DERIVED from the same basis the file set is, and no caller writes one by hand.\n *\n * Data-only (per CLAUDE.md).\n */\nexport class DiffBasis {\n // The 3-point fork point of main. '' when neither origin/main nor main resolves.\n base: string;\n // The REAL sha, never the literal string 'HEAD'. A recorded 'HEAD' is worthless the moment a commit\n // lands on top of it, and the stage receipt has to compare shas to know the tree moved under a review.\n headSha: string;\n // True when the diff includes staged/unstaged/untracked work, i.e. the range is base→working tree.\n dirty: boolean;\n // Exactly WHICH paths make it dirty, so a message can name them instead of asserting dirtiness.\n dirtyFiles: string[];\n // The command that reproduces the WHOLE diff. Correct for both clean and dirty; see DiffBasisResolver.\n diffCommand: string;\n // The same command with a `-- <file>` tail — what every reviewer instruction prints.\n fileDiffCommand: string;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(base = '', headSha = '', dirty = false, dirtyFiles: string[] = [], diffCommand = '', fileDiffCommand = '') {\n this.base = base;\n this.headSha = headSha;\n this.dirty = dirty;\n this.dirtyFiles = dirtyFiles;\n this.diffCommand = diffCommand;\n this.fileDiffCommand = fileDiffCommand;\n }\n\n // True when there is no usable base — callers must SAY so rather than print a command that cannot work.\n get unresolved(): boolean {\n return this.base.trim() === '';\n }\n}\n\n/**\n * Resolves {@link DiffBasis} for a repo. Injects {@link ForkPoint} so the base is the SAME fork-point\n * computation the checklist matching uses — never `DiffScope.resolveBase`, which overlays NX_BASE/NX_HEAD\n * from the environment and would make review coverage depend on an env var.\n *\n * `@injectable(bindingScopeValues.Singleton)` so it is injected by type and drawn in the DI design.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class DiffBasisResolver {\n constructor(private readonly forkPoint: ForkPoint) {}\n\n /**\n * Resolve the basis. The command shape is the whole point:\n *\n * clean tree → `git diff <base> <headSha>` (commit-to-commit; reproducible later, unlike 'HEAD')\n * dirty tree → `git diff <base>` (NO head — this is what includes the working tree)\n *\n * Omitting the head on a dirty tree is not a shortcut, it is the only form that shows uncommitted work,\n * and supplying `HEAD` there is precisely the bug this class exists to make unrepresentable.\n */\n resolve(repoRoot: string): DiffBasis {\n const base = this.forkPoint.resolveForkPoint(repoRoot);\n const headSha = this.gitOut(repoRoot, ['rev-parse', 'HEAD']);\n const dirtyFiles = this.dirtyPaths(repoRoot);\n const dirty = dirtyFiles.length > 0;\n if (base === '') return new DiffBasis('', headSha, dirty, dirtyFiles);\n const whole = dirty ? `git diff ${base}` : `git diff ${base} ${headSha}`;\n return new DiffBasis(base, headSha, dirty, dirtyFiles, whole, `${whole} -- <file>`);\n }\n\n /**\n * Every path that makes the tree dirty: tracked modifications/staged changes PLUS untracked files.\n * Untracked must be included — `git status --porcelain` reports them as `??`, and they are exactly the\n * files a brand-new migration or terraform rule arrives as, which is when a checklist most wants to fire.\n */\n private dirtyPaths(repoRoot: string): string[] {\n const out = this.gitOut(repoRoot, ['status', '--porcelain', '--untracked-files=all']);\n if (out === '') return [];\n const paths: string[] = [];\n for (const line of out.split('\\n')) {\n // Porcelain v1: 2 status chars, a space, then the path. A rename reads `R old -> new`; the new\n // name is the one that exists on disk, so it is the one a reviewer can open.\n const raw = line.slice(3).trim();\n if (raw === '') continue;\n const arrow = raw.indexOf(' -> ');\n paths.push(arrow === -1 ? raw : raw.slice(arrow + 4));\n }\n return paths;\n }\n\n private gitOut(repoRoot: string, args: string[]): string {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n return result.status === 0 ? (result.stdout ?? '').trim() : '';\n }\n}\n"]}
1
+ {"version":3,"file":"diff-basis.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/diff-basis.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { ForkPoint } from './git-findForkPoint';\n\n/**\n * The ONE resolved answer to \"what exactly is this branch's diff, and what command reproduces it?\"\n *\n * It exists because the two halves of the review flow used to disagree. `ChecklistScanner` computes the\n * changed-FILE set by calling `getChangedFiles` with NO head — base→WORKING TREE, unioning in untracked\n * files — while `ChecklistInstructionsService` printed `git diff <base> HEAD -- <file>` to every\n * reviewer. On a dirty tree those are different ranges, and the printed command returns NOTHING: a reviewer\n * is handed a file list and a command that shows it nothing. That is not hypothetical — a reviewer subagent\n * on monorepo-nx2 ran the printed command, got empty output, and had to guess its way to `git diff HEAD`.\n *\n * So the command is DERIVED from the same basis the file set is, and no caller writes one by hand.\n *\n * Data-only (per CLAUDE.md).\n */\nexport class DiffBasis {\n // The 3-point fork point of main. '' when neither origin/main nor main resolves.\n // Alias of {@link hashForkPoint} — see the hash-point trio below.\n base: string;\n // The REAL sha, never the literal string 'HEAD'. A recorded 'HEAD' is worthless the moment a commit\n // lands on top of it, and the stage receipt has to compare shas to know the tree moved under a review.\n // Alias of {@link hashFeatureHead}.\n headSha: string;\n /**\n * The SAME three hash points the 3-point merge records, under the SAME names\n * (`merge-info/<branch>/updatemain-hashes.json` → hashForkPoint / hashFeatureHead / hashMainHead).\n *\n * Two reasons this matters. First, vocabulary: the merge half of the system said \"hashForkPoint\"\n * while the review half said \"base\" for the identical sha, so nothing could be grepped across both.\n * Second, and the real gap: the review side recorded only TWO of the three. Without `hashMainHead`\n * nothing downstream can answer \"did main move while this branch was under review?\" — which is\n * exactly what you want to know when a review looks stale or a merge is about to surprise you.\n *\n * `hashMainHead` is '' when origin/main cannot be resolved offline; it is a REPORT, never a gate.\n */\n hashForkPoint: string;\n hashFeatureHead: string;\n hashMainHead: string;\n // True when the diff includes staged/unstaged/untracked work, i.e. the range is base→working tree.\n dirty: boolean;\n // Exactly WHICH paths make it dirty, so a message can name them instead of asserting dirtiness.\n dirtyFiles: string[];\n // The command that reproduces the WHOLE diff. Correct for both clean and dirty; see DiffBasisResolver.\n diffCommand: string;\n // The same command with a `-- <file>` tail — what every reviewer instruction prints.\n fileDiffCommand: string;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(base = '', headSha = '', dirty = false, dirtyFiles: string[] = [], diffCommand = '', fileDiffCommand = '', hashMainHead = '') {\n this.base = base;\n this.headSha = headSha;\n this.dirty = dirty;\n this.dirtyFiles = dirtyFiles;\n this.diffCommand = diffCommand;\n this.fileDiffCommand = fileDiffCommand;\n // The trio is DERIVED from base/headSha rather than passed separately, so the two names for one\n // sha cannot drift apart into disagreeing values.\n this.hashForkPoint = base;\n this.hashFeatureHead = headSha;\n this.hashMainHead = hashMainHead;\n }\n\n // True when there is no usable base — callers must SAY so rather than print a command that cannot work.\n get unresolved(): boolean {\n return this.base.trim() === '';\n }\n}\n\n/**\n * Resolves {@link DiffBasis} for a repo. Injects {@link ForkPoint} so the base is the SAME fork-point\n * computation the checklist matching uses — never `DiffScope.resolveBase`, which overlays NX_BASE/NX_HEAD\n * from the environment and would make review coverage depend on an env var.\n *\n * `@injectable(bindingScopeValues.Singleton)` so it is injected by type and drawn in the DI design.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class DiffBasisResolver {\n constructor(private readonly forkPoint: ForkPoint) {}\n\n /**\n * Resolve the basis. The command shape is the whole point:\n *\n * clean tree → `git diff <base> <headSha>` (commit-to-commit; reproducible later, unlike 'HEAD')\n * dirty tree → `git diff <base>` (NO head — this is what includes the working tree)\n *\n * Omitting the head on a dirty tree is not a shortcut, it is the only form that shows uncommitted work,\n * and supplying `HEAD` there is precisely the bug this class exists to make unrepresentable.\n */\n resolve(repoRoot: string): DiffBasis {\n const base = this.forkPoint.resolveForkPoint(repoRoot);\n const headSha = this.gitOut(repoRoot, ['rev-parse', 'HEAD']);\n // Point C. Deliberately does NOT fetch — this must work offline, like resolveForkPoint. So it is\n // main as this clone last saw it, which is the honest answer to \"has main moved under me?\" that a\n // local command can give. '' when origin/main is unknown (no remote yet).\n const mainHead = this.gitOut(repoRoot, ['rev-parse', 'origin/main']);\n const dirtyFiles = this.dirtyPaths(repoRoot);\n const dirty = dirtyFiles.length > 0;\n if (base === '') return new DiffBasis('', headSha, dirty, dirtyFiles, '', '', mainHead);\n const whole = dirty ? `git diff ${base}` : `git diff ${base} ${headSha}`;\n return new DiffBasis(base, headSha, dirty, dirtyFiles, whole, `${whole} -- <file>`, mainHead);\n }\n\n /**\n * Every path that makes the tree dirty: tracked modifications/staged changes PLUS untracked files.\n * Untracked must be included — `git status --porcelain` reports them as `??`, and they are exactly the\n * files a brand-new migration or terraform rule arrives as, which is when a checklist most wants to fire.\n */\n private dirtyPaths(repoRoot: string): string[] {\n const out = this.gitOut(repoRoot, ['status', '--porcelain', '--untracked-files=all']);\n if (out === '') return [];\n const paths: string[] = [];\n for (const line of out.split('\\n')) {\n // Porcelain v1: 2 status chars, a space, then the path. A rename reads `R old -> new`; the new\n // name is the one that exists on disk, so it is the one a reviewer can open.\n const raw = line.slice(3).trim();\n if (raw === '') continue;\n const arrow = raw.indexOf(' -> ');\n paths.push(arrow === -1 ? raw : raw.slice(arrow + 4));\n }\n return paths;\n }\n\n private gitOut(repoRoot: string, args: string[]): string {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n return result.status === 0 ? (result.stdout ?? '').trim() : '';\n }\n}\n"]}
@@ -15,12 +15,15 @@ export declare class DiffManifestEntry {
15
15
  export declare class DiffManifest {
16
16
  base: string;
17
17
  head: string;
18
+ hashForkPoint: string;
19
+ hashFeatureHead: string;
20
+ hashMainHead: string;
18
21
  dirty: boolean;
19
22
  diffCommand: string;
20
23
  entries: DiffManifestEntry[];
21
24
  excluded: string[];
22
25
  omittedFromAllDiff: string[];
23
- constructor(base: string, head: string, dirty: boolean, diffCommand: string, entries?: DiffManifestEntry[], excluded?: string[], omittedFromAllDiff?: string[]);
26
+ constructor(base: string, head: string, dirty: boolean, diffCommand: string, entries?: DiffManifestEntry[], excluded?: string[], omittedFromAllDiff?: string[], hashMainHead?: string);
24
27
  }
25
28
  /**
26
29
  * Extracts this branch's diff ONCE, to disk, so a reviewer subagent reads it instead of reconstructing it.
@@ -30,17 +30,26 @@ class DiffManifestEntry {
30
30
  exports.DiffManifestEntry = DiffManifestEntry;
31
31
  /** The authoritative map from a mangled `.diff` name back to its real path, plus what was dropped. */
32
32
  class DiffManifest {
33
+ // Points A and B. Also exposed as hashForkPoint/hashFeatureHead below, which is the vocabulary the
34
+ // 3-point merge already uses — one grep should find the same sha on both halves of the system.
33
35
  base;
34
36
  head;
37
+ hashForkPoint;
38
+ hashFeatureHead;
39
+ hashMainHead; // point C: main as this clone last saw it. '' when unresolvable.
35
40
  dirty;
36
41
  diffCommand;
37
42
  entries;
38
43
  excluded; // matched pr-gate.reviewDiffExclude — present, stubbed, NOT materialized
39
44
  omittedFromAllDiff; // too big for ALL.diff; their per-file .diff still exists
40
45
  // eslint-disable-next-line @typescript-eslint/max-params
41
- constructor(base, head, dirty, diffCommand, entries = [], excluded = [], omittedFromAllDiff = []) {
46
+ constructor(base, head, dirty, diffCommand, entries = [], excluded = [], omittedFromAllDiff = [], hashMainHead = '') {
42
47
  this.base = base;
43
48
  this.head = head;
49
+ // Derived, never passed separately, so the two names for one sha cannot drift into disagreement.
50
+ this.hashForkPoint = base;
51
+ this.hashFeatureHead = head;
52
+ this.hashMainHead = hashMainHead;
44
53
  this.dirty = dirty;
45
54
  this.diffCommand = diffCommand;
46
55
  this.entries = entries;
@@ -83,7 +92,7 @@ let DiffMaterializer = class DiffMaterializer {
83
92
  const filesDir = path.join(dir, 'files');
84
93
  fs.rmSync(dir, { recursive: true, force: true }); // a stale diff read as current is worse than none
85
94
  fs.mkdirSync(filesDir, { recursive: true });
86
- const manifest = new DiffManifest(basis.base, basis.headSha, basis.dirty, basis.diffCommand);
95
+ const manifest = new DiffManifest(basis.base, basis.headSha, basis.dirty, basis.diffCommand, [], [], [], basis.hashMainHead);
87
96
  if (basis.unresolved)
88
97
  return this.writeManifest(dir, manifest);
89
98
  const byFile = this.captureByFile(repoRoot, basis);
@@ -1 +1 @@
1
- {"version":3,"file":"diff-materializer.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/diff-materializer.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { isPathExcluded, ReviewJsonService, toError } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { DiffBasis } from './diff-basis';\n\n// Per-file cap. Over it, the file's diff is truncated WITH a footer naming the command that gets the rest.\nexport const FILE_DIFF_MAX_BYTES = 256 * 1024;\n// Cap for the combined ALL.diff. Over it, whole files are omitted — again, named, never silently.\nexport const ALL_DIFF_MAX_BYTES = 2 * 1024 * 1024;\n\n/** One materialized file's diff. Data-only (per CLAUDE.md). */\nexport class DiffManifestEntry {\n file: string; // repo-relative source path\n diffFile: string; // repo-relative path of the .diff under diff/files/\n status: string; // 'M' | 'A' | 'D' | 'U' (untracked) | 'X' (excluded by config)\n bytes: number;\n truncated: boolean;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(file: string, diffFile: string, status: string, bytes: number, truncated = false) {\n this.file = file;\n this.diffFile = diffFile;\n this.status = status;\n this.bytes = bytes;\n this.truncated = truncated;\n }\n}\n\n/** The authoritative map from a mangled `.diff` name back to its real path, plus what was dropped. */\nexport class DiffManifest {\n base: string;\n head: string;\n dirty: boolean;\n diffCommand: string;\n entries: DiffManifestEntry[];\n excluded: string[]; // matched pr-gate.reviewDiffExclude — present, stubbed, NOT materialized\n omittedFromAllDiff: string[]; // too big for ALL.diff; their per-file .diff still exists\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n base: string, head: string, dirty: boolean, diffCommand: string,\n entries: DiffManifestEntry[] = [], excluded: string[] = [], omittedFromAllDiff: string[] = [],\n ) {\n this.base = base;\n this.head = head;\n this.dirty = dirty;\n this.diffCommand = diffCommand;\n this.entries = entries;\n this.excluded = excluded;\n this.omittedFromAllDiff = omittedFromAllDiff;\n }\n}\n\n/**\n * Extracts this branch's diff ONCE, to disk, so a reviewer subagent reads it instead of reconstructing it.\n *\n * The motivating measurement: a reviewer on monorepo-nx2 spent 14 of its 26 tool calls on context\n * archaeology, and the very first `git diff` it was told to run returned nothing (see {@link DiffBasis}).\n * Handing it a file it can simply open removes both failure modes — the empty range and the shell-out.\n *\n * Cost discipline: **two git invocations total, regardless of file count.** One `git diff` for everything,\n * split on `diff --git` headers, plus one `ls-files --others` for untracked files. A per-file `git diff`\n * loop would be N spawns for the same bytes.\n *\n * `@injectable(bindingScopeValues.Singleton)` so it is injected by type and drawn in the DI design.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class DiffMaterializer {\n constructor(private readonly reviewJsonService: ReviewJsonService) {}\n\n /** The dir this writes into: `.webpieces/pr-review/<feature>/diff`. */\n diffDirFor(repoRoot: string, featureName: string): string {\n return path.join(this.reviewJsonService.prDirFor(repoRoot, featureName), 'diff');\n }\n\n /**\n * Write `diff/ALL.diff`, `diff/files/*.diff` and `diff/manifest.json`. Returns the manifest.\n *\n * `changedFiles` is the set the CHECKLIST MATCHING used — passed in rather than recomputed, so the diff\n * a reviewer reads covers exactly the files its checklist matched on. Recomputing here is how the two\n * would drift apart again.\n */\n materialize(repoRoot: string, featureName: string, basis: DiffBasis, changedFiles: readonly string[], excludeGlobs: readonly string[]): DiffManifest {\n const dir = this.diffDirFor(repoRoot, featureName);\n const filesDir = path.join(dir, 'files');\n fs.rmSync(dir, { recursive: true, force: true }); // a stale diff read as current is worse than none\n fs.mkdirSync(filesDir, { recursive: true });\n const manifest = new DiffManifest(basis.base, basis.headSha, basis.dirty, basis.diffCommand);\n if (basis.unresolved) return this.writeManifest(dir, manifest);\n const byFile = this.captureByFile(repoRoot, basis);\n const used = new Set<string>();\n for (const file of [...changedFiles].sort()) {\n this.writeOne(repoRoot, filesDir, file, byFile, used, excludeGlobs, manifest);\n }\n this.writeAllDiff(dir, filesDir, manifest);\n return this.writeManifest(dir, manifest);\n }\n\n /**\n * ONE `git diff` for the whole branch, split into per-file patches keyed by repo-relative path.\n * Untracked files never appear in `git diff`, so they are synthesized as all-added patches — the same\n * treatment DiffScope.getFileDiff already gives them, kept consistent so a reviewer sees one format.\n */\n private captureByFile(repoRoot: string, basis: DiffBasis): Map<string, string> {\n const args = basis.dirty ? ['diff', basis.base] : ['diff', basis.base, basis.headSha];\n const out = this.gitOut(repoRoot, args);\n const byFile = new Map<string, string>();\n // Split BEFORE each `diff --git` header while keeping the header on its chunk.\n for (const chunk of out.split(/\\n(?=diff --git )/)) {\n if (!chunk.startsWith('diff --git ')) continue;\n const file = this.pathFromHeader(chunk);\n if (file !== '') byFile.set(file, chunk.endsWith('\\n') ? chunk : `${chunk}\\n`);\n }\n for (const file of this.untrackedFiles(repoRoot, basis)) {\n if (!byFile.has(file)) byFile.set(file, this.syntheticAddedDiff(repoRoot, file));\n }\n return byFile;\n }\n\n /**\n * The b-side path out of a `diff --git a/x b/x` header. The b-side is the right one: for a rename it is\n * the name that exists on disk, which is the file a reviewer can actually open.\n */\n private pathFromHeader(chunk: string): string {\n const header = chunk.slice(0, chunk.indexOf('\\n') === -1 ? chunk.length : chunk.indexOf('\\n'));\n const match = header.match(/^diff --git a\\/(.+) b\\/(.+)$/);\n return match ? match[2] : '';\n }\n\n private untrackedFiles(repoRoot: string, basis: DiffBasis): string[] {\n if (!basis.dirty) return [];\n const out = this.gitOut(repoRoot, ['ls-files', '--others', '--exclude-standard']);\n return out === '' ? [] : out.split('\\n').filter((f: string): boolean => f.trim() !== '');\n }\n\n private syntheticAddedDiff(repoRoot: string, file: string): string {\n const abs = path.join(repoRoot, file);\n if (!fs.existsSync(abs)) return '';\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: an unreadable/binary untracked file yields a note, never a crash\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const body = fs.readFileSync(abs, 'utf8').split('\\n').map((l: string): string => `+${l}`).join('\\n');\n return `diff --git a/${file} b/${file}\\nnew file (untracked)\\n--- /dev/null\\n+++ b/${file}\\n${body}\\n`;\n } catch (err: unknown) {\n const error = toError(err);\n void error; // a binary/unreadable untracked file gets a NOTE, never a crash\n return `diff --git a/${file} b/${file}\\nnew file (untracked, unreadable as text)\\n`;\n }\n }\n\n /** Write ONE file's diff, recording it (or its exclusion) in the manifest. Never drops a file silently. */\n // eslint-disable-next-line @typescript-eslint/max-params\n private writeOne(\n repoRoot: string, filesDir: string, file: string, byFile: Map<string, string>,\n used: Set<string>, excludeGlobs: readonly string[], manifest: DiffManifest,\n ): void {\n const name = this.safeName(file, used);\n const abs = path.join(filesDir, name);\n const rel = path.relative(repoRoot, abs);\n // Excluded files stay in the manifest and keep a stub. Dropping them entirely would read as \"this\n // file did not change\"; they are noise to READ, not facts to hide — and they still match checklists.\n if (excludeGlobs.length > 0 && isPathExcluded(file, [...excludeGlobs])) {\n const stub = `[excluded from materialization by pr-gate.reviewDiffExclude]\\nIf you need it: git diff ${manifest.base} -- ${file}\\n`;\n fs.writeFileSync(abs, stub);\n manifest.excluded.push(file);\n manifest.entries.push(new DiffManifestEntry(file, rel, 'X', stub.length));\n return;\n }\n const raw = byFile.get(file) ?? '';\n const status = this.statusOf(file, raw, repoRoot);\n const body = this.capped(raw, file, manifest.base);\n fs.writeFileSync(abs, body);\n manifest.entries.push(new DiffManifestEntry(file, rel, status, body.length, body !== raw));\n }\n\n private statusOf(file: string, raw: string, repoRoot: string): string {\n if (raw.includes('\\nnew file (untracked)')) return 'U';\n if (raw.includes('\\nnew file mode ')) return 'A';\n if (raw.includes('\\ndeleted file mode ')) return 'D';\n return fs.existsSync(path.join(repoRoot, file)) ? 'M' : 'D';\n }\n\n /**\n * Truncate to the cap WITH a footer. Never silently: a truncated diff that looks whole is how a reviewer\n * reports \"reviewed\" on a tenth of a change — the same trap `formatFileList` states its dropped count for.\n */\n private capped(raw: string, file: string, base: string): string {\n if (raw.length <= FILE_DIFF_MAX_BYTES) return raw;\n return `${raw.slice(0, FILE_DIFF_MAX_BYTES)}\\n` +\n `\\n… TRUNCATED at ${FILE_DIFF_MAX_BYTES} bytes (full diff is ${raw.length} bytes).\\n` +\n ` Read the rest with: git diff ${base} -- ${file}\\n`;\n }\n\n /**\n * `ALL.diff` — the single Read that answers \"what changed on this branch?\". Files are appended smallest\n * first so a cap drops the fewest of them, and anything dropped is named in a footer AND in the manifest.\n */\n private writeAllDiff(dir: string, filesDir: string, manifest: DiffManifest): void {\n const ordered = [...manifest.entries].sort((a: DiffManifestEntry, b: DiffManifestEntry): number => a.bytes - b.bytes);\n const parts: string[] = [];\n let total = 0;\n for (const entry of ordered) {\n const body = fs.readFileSync(path.join(filesDir, path.basename(entry.diffFile)), 'utf8');\n if (total + body.length > ALL_DIFF_MAX_BYTES) {\n manifest.omittedFromAllDiff.push(entry.file);\n continue;\n }\n parts.push(body);\n total += body.length;\n }\n let out = parts.join('\\n');\n if (manifest.omittedFromAllDiff.length > 0) {\n out += `\\n\\n… ${manifest.omittedFromAllDiff.length} file(s) OMITTED from this combined view (it hit the ` +\n `${ALL_DIFF_MAX_BYTES}-byte cap). Each still has its own diff under files/ — see manifest.json:\\n` +\n manifest.omittedFromAllDiff.map((f: string): string => ` ${f}`).join('\\n') + '\\n';\n }\n fs.writeFileSync(path.join(dir, 'ALL.diff'), out);\n }\n\n /**\n * `a/b/c.ts` → `a__b__c.ts.diff`, reusing the SAME `/`→`__` convention MergeState.perFileContextDir\n * already uses for 3-point merge context. One mangling scheme in the repo, not two. A real `__` in a\n * path can collide, so collisions get a numeric suffix and manifest.json stays authoritative.\n */\n private safeName(file: string, used: Set<string>): string {\n const flat = `${file.replace(/\\//g, '__')}.diff`;\n if (!used.has(flat)) {\n used.add(flat);\n return flat;\n }\n for (let n = 2; ; n++) {\n const candidate = `${file.replace(/\\//g, '__')}-${n}.diff`;\n if (!used.has(candidate)) {\n used.add(candidate);\n return candidate;\n }\n }\n }\n\n private writeManifest(dir: string, manifest: DiffManifest): DiffManifest {\n fs.mkdirSync(dir, { recursive: true });\n fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\\n');\n return manifest;\n }\n\n private gitOut(repoRoot: string, args: string[]): string {\n // maxBuffer: a monorepo diff routinely exceeds node's 1MB default, and the failure mode is a\n // truncated capture that looks like a complete one.\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });\n return result.status === 0 ? (result.stdout ?? '') : '';\n }\n}\n"]}
1
+ {"version":3,"file":"diff-materializer.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/diff-materializer.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { isPathExcluded, ReviewJsonService, toError } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { DiffBasis } from './diff-basis';\n\n// Per-file cap. Over it, the file's diff is truncated WITH a footer naming the command that gets the rest.\nexport const FILE_DIFF_MAX_BYTES = 256 * 1024;\n// Cap for the combined ALL.diff. Over it, whole files are omitted — again, named, never silently.\nexport const ALL_DIFF_MAX_BYTES = 2 * 1024 * 1024;\n\n/** One materialized file's diff. Data-only (per CLAUDE.md). */\nexport class DiffManifestEntry {\n file: string; // repo-relative source path\n diffFile: string; // repo-relative path of the .diff under diff/files/\n status: string; // 'M' | 'A' | 'D' | 'U' (untracked) | 'X' (excluded by config)\n bytes: number;\n truncated: boolean;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(file: string, diffFile: string, status: string, bytes: number, truncated = false) {\n this.file = file;\n this.diffFile = diffFile;\n this.status = status;\n this.bytes = bytes;\n this.truncated = truncated;\n }\n}\n\n/** The authoritative map from a mangled `.diff` name back to its real path, plus what was dropped. */\nexport class DiffManifest {\n // Points A and B. Also exposed as hashForkPoint/hashFeatureHead below, which is the vocabulary the\n // 3-point merge already uses — one grep should find the same sha on both halves of the system.\n base: string;\n head: string;\n hashForkPoint: string;\n hashFeatureHead: string;\n hashMainHead: string; // point C: main as this clone last saw it. '' when unresolvable.\n dirty: boolean;\n diffCommand: string;\n entries: DiffManifestEntry[];\n excluded: string[]; // matched pr-gate.reviewDiffExclude — present, stubbed, NOT materialized\n omittedFromAllDiff: string[]; // too big for ALL.diff; their per-file .diff still exists\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n base: string, head: string, dirty: boolean, diffCommand: string,\n entries: DiffManifestEntry[] = [], excluded: string[] = [], omittedFromAllDiff: string[] = [],\n hashMainHead = '',\n ) {\n this.base = base;\n this.head = head;\n // Derived, never passed separately, so the two names for one sha cannot drift into disagreement.\n this.hashForkPoint = base;\n this.hashFeatureHead = head;\n this.hashMainHead = hashMainHead;\n this.dirty = dirty;\n this.diffCommand = diffCommand;\n this.entries = entries;\n this.excluded = excluded;\n this.omittedFromAllDiff = omittedFromAllDiff;\n }\n}\n\n/**\n * Extracts this branch's diff ONCE, to disk, so a reviewer subagent reads it instead of reconstructing it.\n *\n * The motivating measurement: a reviewer on monorepo-nx2 spent 14 of its 26 tool calls on context\n * archaeology, and the very first `git diff` it was told to run returned nothing (see {@link DiffBasis}).\n * Handing it a file it can simply open removes both failure modes — the empty range and the shell-out.\n *\n * Cost discipline: **two git invocations total, regardless of file count.** One `git diff` for everything,\n * split on `diff --git` headers, plus one `ls-files --others` for untracked files. A per-file `git diff`\n * loop would be N spawns for the same bytes.\n *\n * `@injectable(bindingScopeValues.Singleton)` so it is injected by type and drawn in the DI design.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class DiffMaterializer {\n constructor(private readonly reviewJsonService: ReviewJsonService) {}\n\n /** The dir this writes into: `.webpieces/pr-review/<feature>/diff`. */\n diffDirFor(repoRoot: string, featureName: string): string {\n return path.join(this.reviewJsonService.prDirFor(repoRoot, featureName), 'diff');\n }\n\n /**\n * Write `diff/ALL.diff`, `diff/files/*.diff` and `diff/manifest.json`. Returns the manifest.\n *\n * `changedFiles` is the set the CHECKLIST MATCHING used — passed in rather than recomputed, so the diff\n * a reviewer reads covers exactly the files its checklist matched on. Recomputing here is how the two\n * would drift apart again.\n */\n materialize(repoRoot: string, featureName: string, basis: DiffBasis, changedFiles: readonly string[], excludeGlobs: readonly string[]): DiffManifest {\n const dir = this.diffDirFor(repoRoot, featureName);\n const filesDir = path.join(dir, 'files');\n fs.rmSync(dir, { recursive: true, force: true }); // a stale diff read as current is worse than none\n fs.mkdirSync(filesDir, { recursive: true });\n const manifest = new DiffManifest(\n basis.base, basis.headSha, basis.dirty, basis.diffCommand, [], [], [], basis.hashMainHead);\n if (basis.unresolved) return this.writeManifest(dir, manifest);\n const byFile = this.captureByFile(repoRoot, basis);\n const used = new Set<string>();\n for (const file of [...changedFiles].sort()) {\n this.writeOne(repoRoot, filesDir, file, byFile, used, excludeGlobs, manifest);\n }\n this.writeAllDiff(dir, filesDir, manifest);\n return this.writeManifest(dir, manifest);\n }\n\n /**\n * ONE `git diff` for the whole branch, split into per-file patches keyed by repo-relative path.\n * Untracked files never appear in `git diff`, so they are synthesized as all-added patches — the same\n * treatment DiffScope.getFileDiff already gives them, kept consistent so a reviewer sees one format.\n */\n private captureByFile(repoRoot: string, basis: DiffBasis): Map<string, string> {\n const args = basis.dirty ? ['diff', basis.base] : ['diff', basis.base, basis.headSha];\n const out = this.gitOut(repoRoot, args);\n const byFile = new Map<string, string>();\n // Split BEFORE each `diff --git` header while keeping the header on its chunk.\n for (const chunk of out.split(/\\n(?=diff --git )/)) {\n if (!chunk.startsWith('diff --git ')) continue;\n const file = this.pathFromHeader(chunk);\n if (file !== '') byFile.set(file, chunk.endsWith('\\n') ? chunk : `${chunk}\\n`);\n }\n for (const file of this.untrackedFiles(repoRoot, basis)) {\n if (!byFile.has(file)) byFile.set(file, this.syntheticAddedDiff(repoRoot, file));\n }\n return byFile;\n }\n\n /**\n * The b-side path out of a `diff --git a/x b/x` header. The b-side is the right one: for a rename it is\n * the name that exists on disk, which is the file a reviewer can actually open.\n */\n private pathFromHeader(chunk: string): string {\n const header = chunk.slice(0, chunk.indexOf('\\n') === -1 ? chunk.length : chunk.indexOf('\\n'));\n const match = header.match(/^diff --git a\\/(.+) b\\/(.+)$/);\n return match ? match[2] : '';\n }\n\n private untrackedFiles(repoRoot: string, basis: DiffBasis): string[] {\n if (!basis.dirty) return [];\n const out = this.gitOut(repoRoot, ['ls-files', '--others', '--exclude-standard']);\n return out === '' ? [] : out.split('\\n').filter((f: string): boolean => f.trim() !== '');\n }\n\n private syntheticAddedDiff(repoRoot: string, file: string): string {\n const abs = path.join(repoRoot, file);\n if (!fs.existsSync(abs)) return '';\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: an unreadable/binary untracked file yields a note, never a crash\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const body = fs.readFileSync(abs, 'utf8').split('\\n').map((l: string): string => `+${l}`).join('\\n');\n return `diff --git a/${file} b/${file}\\nnew file (untracked)\\n--- /dev/null\\n+++ b/${file}\\n${body}\\n`;\n } catch (err: unknown) {\n const error = toError(err);\n void error; // a binary/unreadable untracked file gets a NOTE, never a crash\n return `diff --git a/${file} b/${file}\\nnew file (untracked, unreadable as text)\\n`;\n }\n }\n\n /** Write ONE file's diff, recording it (or its exclusion) in the manifest. Never drops a file silently. */\n // eslint-disable-next-line @typescript-eslint/max-params\n private writeOne(\n repoRoot: string, filesDir: string, file: string, byFile: Map<string, string>,\n used: Set<string>, excludeGlobs: readonly string[], manifest: DiffManifest,\n ): void {\n const name = this.safeName(file, used);\n const abs = path.join(filesDir, name);\n const rel = path.relative(repoRoot, abs);\n // Excluded files stay in the manifest and keep a stub. Dropping them entirely would read as \"this\n // file did not change\"; they are noise to READ, not facts to hide — and they still match checklists.\n if (excludeGlobs.length > 0 && isPathExcluded(file, [...excludeGlobs])) {\n const stub = `[excluded from materialization by pr-gate.reviewDiffExclude]\\nIf you need it: git diff ${manifest.base} -- ${file}\\n`;\n fs.writeFileSync(abs, stub);\n manifest.excluded.push(file);\n manifest.entries.push(new DiffManifestEntry(file, rel, 'X', stub.length));\n return;\n }\n const raw = byFile.get(file) ?? '';\n const status = this.statusOf(file, raw, repoRoot);\n const body = this.capped(raw, file, manifest.base);\n fs.writeFileSync(abs, body);\n manifest.entries.push(new DiffManifestEntry(file, rel, status, body.length, body !== raw));\n }\n\n private statusOf(file: string, raw: string, repoRoot: string): string {\n if (raw.includes('\\nnew file (untracked)')) return 'U';\n if (raw.includes('\\nnew file mode ')) return 'A';\n if (raw.includes('\\ndeleted file mode ')) return 'D';\n return fs.existsSync(path.join(repoRoot, file)) ? 'M' : 'D';\n }\n\n /**\n * Truncate to the cap WITH a footer. Never silently: a truncated diff that looks whole is how a reviewer\n * reports \"reviewed\" on a tenth of a change — the same trap `formatFileList` states its dropped count for.\n */\n private capped(raw: string, file: string, base: string): string {\n if (raw.length <= FILE_DIFF_MAX_BYTES) return raw;\n return `${raw.slice(0, FILE_DIFF_MAX_BYTES)}\\n` +\n `\\n… TRUNCATED at ${FILE_DIFF_MAX_BYTES} bytes (full diff is ${raw.length} bytes).\\n` +\n ` Read the rest with: git diff ${base} -- ${file}\\n`;\n }\n\n /**\n * `ALL.diff` — the single Read that answers \"what changed on this branch?\". Files are appended smallest\n * first so a cap drops the fewest of them, and anything dropped is named in a footer AND in the manifest.\n */\n private writeAllDiff(dir: string, filesDir: string, manifest: DiffManifest): void {\n const ordered = [...manifest.entries].sort((a: DiffManifestEntry, b: DiffManifestEntry): number => a.bytes - b.bytes);\n const parts: string[] = [];\n let total = 0;\n for (const entry of ordered) {\n const body = fs.readFileSync(path.join(filesDir, path.basename(entry.diffFile)), 'utf8');\n if (total + body.length > ALL_DIFF_MAX_BYTES) {\n manifest.omittedFromAllDiff.push(entry.file);\n continue;\n }\n parts.push(body);\n total += body.length;\n }\n let out = parts.join('\\n');\n if (manifest.omittedFromAllDiff.length > 0) {\n out += `\\n\\n… ${manifest.omittedFromAllDiff.length} file(s) OMITTED from this combined view (it hit the ` +\n `${ALL_DIFF_MAX_BYTES}-byte cap). Each still has its own diff under files/ — see manifest.json:\\n` +\n manifest.omittedFromAllDiff.map((f: string): string => ` ${f}`).join('\\n') + '\\n';\n }\n fs.writeFileSync(path.join(dir, 'ALL.diff'), out);\n }\n\n /**\n * `a/b/c.ts` → `a__b__c.ts.diff`, reusing the SAME `/`→`__` convention MergeState.perFileContextDir\n * already uses for 3-point merge context. One mangling scheme in the repo, not two. A real `__` in a\n * path can collide, so collisions get a numeric suffix and manifest.json stays authoritative.\n */\n private safeName(file: string, used: Set<string>): string {\n const flat = `${file.replace(/\\//g, '__')}.diff`;\n if (!used.has(flat)) {\n used.add(flat);\n return flat;\n }\n for (let n = 2; ; n++) {\n const candidate = `${file.replace(/\\//g, '__')}-${n}.diff`;\n if (!used.has(candidate)) {\n used.add(candidate);\n return candidate;\n }\n }\n }\n\n private writeManifest(dir: string, manifest: DiffManifest): DiffManifest {\n fs.mkdirSync(dir, { recursive: true });\n fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\\n');\n return manifest;\n }\n\n private gitOut(repoRoot: string, args: string[]): string {\n // maxBuffer: a monorepo diff routinely exceeds node's 1MB default, and the failure mode is a\n // truncated capture that looks like a complete one.\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });\n return result.status === 0 ? (result.stdout ?? '') : '';\n }\n}\n"]}
@@ -31,5 +31,8 @@ export declare class PrContextWriter {
31
31
  * *.sql / Dockerfile / .env* file a checklist most wants to key on). `includeDeletions` is on for the
32
32
  * same reason: a DELETED migration is exactly what a checklist wants to see.
33
33
  */
34
- ensure(repoRoot: string, featureName: string, basis: DiffBasis, diffDir?: string): ChecklistReviewContext;
34
+ ensure(repoRoot: string, featureName: string, basis: DiffBasis, stage: string, changedFiles?: readonly string[], diffDir?: string): ChecklistReviewContext;
35
+ /** The context WITHOUT persisting it — for a caller that must write later (after materializing). */
36
+ contextFor(repoRoot: string, featureName: string, basis: DiffBasis): ChecklistReviewContext;
37
+ private changedFiles;
35
38
  }