@webpieces/pr-gate 0.4.612 → 0.4.614
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/scripts/workflow/build-gate-log.js +5 -0
- package/src/scripts/workflow/build-gate-log.js.map +1 -1
- package/src/scripts/workflow/diff-basis.d.ts +1 -1
- package/src/scripts/workflow/diff-basis.js +1 -1
- package/src/scripts/workflow/diff-basis.js.map +1 -1
- package/src/scripts/workflow/diff-materializer.d.ts +1 -1
- package/src/scripts/workflow/diff-materializer.js +1 -1
- package/src/scripts/workflow/diff-materializer.js.map +1 -1
- package/src/scripts/workflow/repo-config-testkit.d.ts +5 -0
- package/src/scripts/workflow/repo-config-testkit.js +19 -0
- package/src/scripts/workflow/repo-config-testkit.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/pr-gate",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.614",
|
|
4
4
|
"description": "Gated PR system: 3-point squash-merge, merge validation gate, and red/yellow/green PR dashboard. Standalone scripts, no Nx dependency required.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"directory": "packages/tooling/pr-gate"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@webpieces/rules-config": "0.4.
|
|
18
|
+
"@webpieces/rules-config": "0.4.614",
|
|
19
19
|
"@inversifyjs/binding-decorators": "1.1.5",
|
|
20
20
|
"inversify": "7.10.4",
|
|
21
21
|
"reflect-metadata": "0.2.2"
|
|
@@ -11,6 +11,11 @@ const inversify_1 = require("inversify");
|
|
|
11
11
|
// file runs for a user who has not created that OPTIONAL file — see `HomeConfigService`
|
|
12
12
|
// (`@webpieces/rules-config`), which returns all-defaults silently when it is absent.
|
|
13
13
|
//
|
|
14
|
+
// That file has a second, REQUIRED key once it exists — `experimental.whole-repo-build-guard` — so the
|
|
15
|
+
// smallest document enabling THIS feature is:
|
|
16
|
+
// { "experimental": { "whole-repo-build-guard": false, "buildGateLogCapture": true } }
|
|
17
|
+
// `HomeConfigService` owns that rule and prints the same edit when the key is missing.
|
|
18
|
+
//
|
|
14
19
|
// ─── Why ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
15
20
|
// The build gate already builds everything. When it fails, the pre-existing message says "run THIS exact
|
|
16
21
|
// command to reproduce", and an AI agent obeys it — so the repo is built a SECOND time purely to see the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-gate-log.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/build-gate-log.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { dotWebpieces } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\n// EXPERIMENTAL (opt-in via `~/.webpieces/config.json` → experimental.buildGateLogCapture). Nothing in this\n// file runs for a user who has not created that OPTIONAL file — see `HomeConfigService`\n// (`@webpieces/rules-config`), which returns all-defaults silently when it is absent.\n//\n// ─── Why ───────────────────────────────────────────────────────────────────────────────────────────────\n// The build gate already builds everything. When it fails, the pre-existing message says \"run THIS exact\n// command to reproduce\", and an AI agent obeys it — so the repo is built a SECOND time purely to see the\n// errors that scrolled past the first time. This captures the first build's output instead.\n//\n// ─── Why a shell `tee` and not spawnSync's pipes ───────────────────────────────────────────────────────\n// spawnSync BUFFERS a piped stream and hands it over only after the child exits, so the human would watch\n// a silent terminal for the length of a full build. The output has to be split at the OS level, which is\n// what `tee` is. The gate keeps `stdio: 'inherit'`, so the terminal is byte-identical to today.\n//\n// ─── Why an exit-status side file and not `pipefail` ───────────────────────────────────────────────────\n// In a pipeline the shell reports the LAST command's status, i.e. tee's, which is 0 whether the build\n// passed or failed. `set -o pipefail` and `${PIPESTATUS[0]}` are bash/zsh, and spawnSync's `shell: true`\n// gives `/bin/sh` — dash on Debian. Writing `$?` to a side file inside the group is plain POSIX and works\n// under every one of them. The redirect keeps it out of the pipe, so it never lands in the log.\nconst STATUS_SUFFIX = '.status';\n\n/**\n * Which stage's gate is being captured. The value is part of the log FILENAME, so review and finish never\n * write the same file even at the same commit on the same branch.\n */\nexport const REVIEW_STAGE = 'review';\nexport const FINISH_STAGE = 'finish';\n\n/**\n * Captures the build gate's full output to `.webpieces/logs/`, and renders the small pointer the AI is\n * handed instead of a rebuild instruction.\n *\n * ─── Why the filename cannot collide ───────────────────────────────────────────────────────────────────\n * The path is `dotWebpieces.logsFile(repoRoot, 'build-gate-<stage>-<branch>-<shortSha>.log')`, and each\n * component rules out one class of concurrent writer:\n * • the DIRECTORY is `dotWebpieces.local()`-scoped — `<primary>/.webpieces/worktrees/<git worktree\n * name>/logs/` in a linked worktree. Every worktree therefore has its own log directory already, which\n * is what makes \"N agents in N worktrees\" safe by construction rather than by naming.\n * • `<stage>` separates review from finish, the two gates that CAN run against one commit.\n * • `<branch>` — a branch can be checked out in at most one worktree (git enforces it), so within one\n * log directory the branch is effectively constant; it is in the name so a human reading the directory\n * can tell whose log is whose, and so switching branches never appends to a stale file.\n * • `<shortSha>` — a re-run at a NEW commit gets a new file, so the log a failure message points at is\n * always the build for the code that failed. A re-run at the SAME commit deliberately TRUNCATES: it is\n * the same build of the same tree, and the fresh one is the one worth reading.\n * The residual case is two agents running the same stage, at the same commit, in the SAME worktree,\n * simultaneously. That is already unsupported — both would be driving one git index and one merge state —\n * and it is the only case this scheme does not separate.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class BuildGateLog {\n /** Absolute path of the log for `stage` at the current HEAD, creating the log directory. */\n pathFor(repoRoot: string, stage: string): string {\n const file = dotWebpieces.logsFile(repoRoot, this.fileNameFor(repoRoot, stage));\n fs.mkdirSync(path.dirname(file), { recursive: true });\n return file;\n }\n\n /** The same path WITHOUT creating anything, and '' when no such log exists. Used by finish's skip path. */\n existingLogFor(repoRoot: string, stage: string): string {\n const file = dotWebpieces.logsFile(repoRoot, this.fileNameFor(repoRoot, stage));\n return fs.existsSync(file) ? file : '';\n }\n\n /** `build-gate-<stage>-<branch>-<shortSha>.log` — see the class docstring for why this cannot collide. */\n fileNameFor(repoRoot: string, stage: string): string {\n const branch = this.slug(this.git(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD']));\n const sha = this.slug(this.git(repoRoot, ['rev-parse', '--short', 'HEAD']));\n return `build-gate-${this.slug(stage)}-${branch === '' ? 'nobranch' : branch}-${sha === '' ? 'nosha' : sha}.log`;\n }\n\n /**\n * Run `buildCommand` with its combined stdout+stderr streaming to the terminal AND appended in full to\n * `logPath`. Returns the BUILD's exit code (not tee's). Nothing is truncated.\n */\n run(repoRoot: string, buildCommand: string, logPath: string): number {\n const statusFile = `${logPath}${STATUS_SUFFIX}`;\n fs.rmSync(statusFile, { force: true });\n const result = spawnSync(this.wrap(buildCommand, logPath, statusFile),\n { stdio: 'inherit', cwd: repoRoot, shell: true });\n const fromFile = this.readStatus(statusFile);\n fs.rmSync(statusFile, { force: true });\n if (fromFile !== null) return fromFile;\n // The side file never appeared ⇒ the shell died before the echo. The PIPELINE's status is tee's,\n // which is 0, and reporting 0 there would call a dead build green — so fail CLOSED instead.\n return result.status !== null && result.status !== 0 ? result.status : 1;\n }\n\n /**\n * The ENTIRE message the AI gets on a captured failure. Deliberately tiny: the whole point is that the\n * agent reads ONE file rather than carrying a build transcript in its context.\n *\n * The last sentence is not filler. If the log holds no visible failure then something upstream is wrong\n * (a runner that died without printing, a truncated pipe), and the worst possible response is an agent\n * guessing or rebuilding — so it is told to surface the contradiction to the human and stop.\n */\n failureMessage(buildCommand: string, logPath: string): string {\n return `\\n❌ The CI build failed. We ran\\n\\n` +\n ` ${buildCommand} > ${logPath}\\n\\n` +\n `and it failed, so read that file for the failures. Do NOT re-run the build to see them.\\n` +\n `If you do not see failures in that log, report that to the user and stop.\\n`;\n }\n\n /**\n * `{ ( <build> ) ; echo $? > <status> ; } 2>&1 | tee <log>` — written across LINES so a build command\n * ending in a `#` comment cannot swallow what follows it.\n *\n * The inner `( … )` is load-bearing, not decoration. The left side of a pipeline is already a subshell,\n * so a build command containing a plain `exit 7` (or any script that calls `exit`) would terminate that\n * subshell OUTRIGHT — `echo $?` never runs, the side file never appears, and the only status left is\n * tee's 0. The nested subshell absorbs the `exit`, so `$?` is the build's code every time.\n */\n private wrap(buildCommand: string, logPath: string, statusFile: string): string {\n return `{\\n(\\n${buildCommand}\\n)\\necho $? > ${this.quote(statusFile)}\\n} 2>&1 | tee ${this.quote(logPath)}`;\n }\n\n // The build's own exit code, or null when the side file never appeared (the shell died before the echo).\n private readStatus(statusFile: string): number | null {\n if (!fs.existsSync(statusFile)) return null;\n const parsed = Number.parseInt(fs.readFileSync(statusFile, 'utf8').trim(), 10);\n return Number.isNaN(parsed) ? null : parsed;\n }\n\n // POSIX single-quoting: everything is literal inside '…', and a literal ' is spelled '\\''.\n private quote(value: string): string {\n return `'${value.split(\"'\").join(`'\\\\''`)}'`;\n }\n\n // Anything that is not a filename-safe character becomes '-', so `dean/feat` cannot create directories.\n private slug(value: string): string {\n return value.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');\n }\n\n // One local git read. Fails SOFT to '' — a missing branch/sha degrades the FILENAME, and degrading a\n // filename may never be the reason a build gate does not run.\n private git(repoRoot: string, args: string[]): string {\n const result = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8' });\n if (result.status !== 0) return '';\n return (result.stdout ?? '').trim();\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"build-gate-log.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/build-gate-log.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { dotWebpieces } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\n// EXPERIMENTAL (opt-in via `~/.webpieces/config.json` → experimental.buildGateLogCapture). Nothing in this\n// file runs for a user who has not created that OPTIONAL file — see `HomeConfigService`\n// (`@webpieces/rules-config`), which returns all-defaults silently when it is absent.\n//\n// That file has a second, REQUIRED key once it exists — `experimental.whole-repo-build-guard` — so the\n// smallest document enabling THIS feature is:\n// { \"experimental\": { \"whole-repo-build-guard\": false, \"buildGateLogCapture\": true } }\n// `HomeConfigService` owns that rule and prints the same edit when the key is missing.\n//\n// ─── Why ───────────────────────────────────────────────────────────────────────────────────────────────\n// The build gate already builds everything. When it fails, the pre-existing message says \"run THIS exact\n// command to reproduce\", and an AI agent obeys it — so the repo is built a SECOND time purely to see the\n// errors that scrolled past the first time. This captures the first build's output instead.\n//\n// ─── Why a shell `tee` and not spawnSync's pipes ───────────────────────────────────────────────────────\n// spawnSync BUFFERS a piped stream and hands it over only after the child exits, so the human would watch\n// a silent terminal for the length of a full build. The output has to be split at the OS level, which is\n// what `tee` is. The gate keeps `stdio: 'inherit'`, so the terminal is byte-identical to today.\n//\n// ─── Why an exit-status side file and not `pipefail` ───────────────────────────────────────────────────\n// In a pipeline the shell reports the LAST command's status, i.e. tee's, which is 0 whether the build\n// passed or failed. `set -o pipefail` and `${PIPESTATUS[0]}` are bash/zsh, and spawnSync's `shell: true`\n// gives `/bin/sh` — dash on Debian. Writing `$?` to a side file inside the group is plain POSIX and works\n// under every one of them. The redirect keeps it out of the pipe, so it never lands in the log.\nconst STATUS_SUFFIX = '.status';\n\n/**\n * Which stage's gate is being captured. The value is part of the log FILENAME, so review and finish never\n * write the same file even at the same commit on the same branch.\n */\nexport const REVIEW_STAGE = 'review';\nexport const FINISH_STAGE = 'finish';\n\n/**\n * Captures the build gate's full output to `.webpieces/logs/`, and renders the small pointer the AI is\n * handed instead of a rebuild instruction.\n *\n * ─── Why the filename cannot collide ───────────────────────────────────────────────────────────────────\n * The path is `dotWebpieces.logsFile(repoRoot, 'build-gate-<stage>-<branch>-<shortSha>.log')`, and each\n * component rules out one class of concurrent writer:\n * • the DIRECTORY is `dotWebpieces.local()`-scoped — `<primary>/.webpieces/worktrees/<git worktree\n * name>/logs/` in a linked worktree. Every worktree therefore has its own log directory already, which\n * is what makes \"N agents in N worktrees\" safe by construction rather than by naming.\n * • `<stage>` separates review from finish, the two gates that CAN run against one commit.\n * • `<branch>` — a branch can be checked out in at most one worktree (git enforces it), so within one\n * log directory the branch is effectively constant; it is in the name so a human reading the directory\n * can tell whose log is whose, and so switching branches never appends to a stale file.\n * • `<shortSha>` — a re-run at a NEW commit gets a new file, so the log a failure message points at is\n * always the build for the code that failed. A re-run at the SAME commit deliberately TRUNCATES: it is\n * the same build of the same tree, and the fresh one is the one worth reading.\n * The residual case is two agents running the same stage, at the same commit, in the SAME worktree,\n * simultaneously. That is already unsupported — both would be driving one git index and one merge state —\n * and it is the only case this scheme does not separate.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class BuildGateLog {\n /** Absolute path of the log for `stage` at the current HEAD, creating the log directory. */\n pathFor(repoRoot: string, stage: string): string {\n const file = dotWebpieces.logsFile(repoRoot, this.fileNameFor(repoRoot, stage));\n fs.mkdirSync(path.dirname(file), { recursive: true });\n return file;\n }\n\n /** The same path WITHOUT creating anything, and '' when no such log exists. Used by finish's skip path. */\n existingLogFor(repoRoot: string, stage: string): string {\n const file = dotWebpieces.logsFile(repoRoot, this.fileNameFor(repoRoot, stage));\n return fs.existsSync(file) ? file : '';\n }\n\n /** `build-gate-<stage>-<branch>-<shortSha>.log` — see the class docstring for why this cannot collide. */\n fileNameFor(repoRoot: string, stage: string): string {\n const branch = this.slug(this.git(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD']));\n const sha = this.slug(this.git(repoRoot, ['rev-parse', '--short', 'HEAD']));\n return `build-gate-${this.slug(stage)}-${branch === '' ? 'nobranch' : branch}-${sha === '' ? 'nosha' : sha}.log`;\n }\n\n /**\n * Run `buildCommand` with its combined stdout+stderr streaming to the terminal AND appended in full to\n * `logPath`. Returns the BUILD's exit code (not tee's). Nothing is truncated.\n */\n run(repoRoot: string, buildCommand: string, logPath: string): number {\n const statusFile = `${logPath}${STATUS_SUFFIX}`;\n fs.rmSync(statusFile, { force: true });\n const result = spawnSync(this.wrap(buildCommand, logPath, statusFile),\n { stdio: 'inherit', cwd: repoRoot, shell: true });\n const fromFile = this.readStatus(statusFile);\n fs.rmSync(statusFile, { force: true });\n if (fromFile !== null) return fromFile;\n // The side file never appeared ⇒ the shell died before the echo. The PIPELINE's status is tee's,\n // which is 0, and reporting 0 there would call a dead build green — so fail CLOSED instead.\n return result.status !== null && result.status !== 0 ? result.status : 1;\n }\n\n /**\n * The ENTIRE message the AI gets on a captured failure. Deliberately tiny: the whole point is that the\n * agent reads ONE file rather than carrying a build transcript in its context.\n *\n * The last sentence is not filler. If the log holds no visible failure then something upstream is wrong\n * (a runner that died without printing, a truncated pipe), and the worst possible response is an agent\n * guessing or rebuilding — so it is told to surface the contradiction to the human and stop.\n */\n failureMessage(buildCommand: string, logPath: string): string {\n return `\\n❌ The CI build failed. We ran\\n\\n` +\n ` ${buildCommand} > ${logPath}\\n\\n` +\n `and it failed, so read that file for the failures. Do NOT re-run the build to see them.\\n` +\n `If you do not see failures in that log, report that to the user and stop.\\n`;\n }\n\n /**\n * `{ ( <build> ) ; echo $? > <status> ; } 2>&1 | tee <log>` — written across LINES so a build command\n * ending in a `#` comment cannot swallow what follows it.\n *\n * The inner `( … )` is load-bearing, not decoration. The left side of a pipeline is already a subshell,\n * so a build command containing a plain `exit 7` (or any script that calls `exit`) would terminate that\n * subshell OUTRIGHT — `echo $?` never runs, the side file never appears, and the only status left is\n * tee's 0. The nested subshell absorbs the `exit`, so `$?` is the build's code every time.\n */\n private wrap(buildCommand: string, logPath: string, statusFile: string): string {\n return `{\\n(\\n${buildCommand}\\n)\\necho $? > ${this.quote(statusFile)}\\n} 2>&1 | tee ${this.quote(logPath)}`;\n }\n\n // The build's own exit code, or null when the side file never appeared (the shell died before the echo).\n private readStatus(statusFile: string): number | null {\n if (!fs.existsSync(statusFile)) return null;\n const parsed = Number.parseInt(fs.readFileSync(statusFile, 'utf8').trim(), 10);\n return Number.isNaN(parsed) ? null : parsed;\n }\n\n // POSIX single-quoting: everything is literal inside '…', and a literal ' is spelled '\\''.\n private quote(value: string): string {\n return `'${value.split(\"'\").join(`'\\\\''`)}'`;\n }\n\n // Anything that is not a filename-safe character becomes '-', so `dean/feat` cannot create directories.\n private slug(value: string): string {\n return value.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');\n }\n\n // One local git read. Fails SOFT to '' — a missing branch/sha degrades the FILENAME, and degrading a\n // filename may never be the reason a build gate does not run.\n private git(repoRoot: string, args: string[]): string {\n const result = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8' });\n if (result.status !== 0) return '';\n return (result.stdout ?? '').trim();\n }\n}\n"]}
|
|
@@ -8,7 +8,7 @@ import { GitStatusParser } from './git-status';
|
|
|
8
8
|
* files — while `ChecklistInstructionsService` printed `git diff <base> HEAD -- <file>` to every
|
|
9
9
|
* reviewer. On a dirty tree those are different ranges, and the printed command returns NOTHING: a reviewer
|
|
10
10
|
* is handed a file list and a command that shows it nothing. That is not hypothetical — a reviewer subagent
|
|
11
|
-
* on
|
|
11
|
+
* on consumer-monorepo2 ran the printed command, got empty output, and had to guess its way to `git diff HEAD`.
|
|
12
12
|
*
|
|
13
13
|
* So the command is DERIVED from the same basis the file set is, and no caller writes one by hand.
|
|
14
14
|
*
|
|
@@ -14,7 +14,7 @@ const git_status_1 = require("./git-status");
|
|
|
14
14
|
* files — while `ChecklistInstructionsService` printed `git diff <base> HEAD -- <file>` to every
|
|
15
15
|
* reviewer. On a dirty tree those are different ranges, and the printed command returns NOTHING: a reviewer
|
|
16
16
|
* is handed a file list and a command that shows it nothing. That is not hypothetical — a reviewer subagent
|
|
17
|
-
* on
|
|
17
|
+
* on consumer-monorepo2 ran the printed command, got empty output, and had to guess its way to `git diff HEAD`.
|
|
18
18
|
*
|
|
19
19
|
* So the command is DERIVED from the same basis the file set is, and no caller writes one by hand.
|
|
20
20
|
*
|
|
@@ -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';\nimport { GitStatusEntry, GitStatusParser } from './git-status';\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(\n private readonly forkPoint: ForkPoint,\n private readonly statusParser: GitStatusParser,\n ) {}\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 * Parsed by {@link GitStatusParser} off the RAW (untrimmed) output. This used to hand-roll\n * `line.slice(3).trim()` over `gitOut(...)`, which trims — and on a porcelain line the leading space\n * IS the index column, so trimming shifted the FIRST line left by one and `slice(3)` then cut a\n * character off the front of its path. It also left git's quoting on, so `\"a b.txt\"` was reported\n * with the quotes still attached and matched no checklist glob.\n */\n private dirtyPaths(repoRoot: string): string[] {\n const out = this.rawGitOut(repoRoot, ['status', '--porcelain', '--untracked-files=all']);\n return this.statusParser.parse(out).map((entry: GitStatusEntry): string => entry.path);\n }\n\n private gitOut(repoRoot: string, args: string[]): string {\n return this.rawGitOut(repoRoot, args).trim();\n }\n\n // Untrimmed — mandatory for `git status --porcelain`, where leading whitespace carries meaning.\n private rawGitOut(repoRoot: string, args: string[]): string {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n return result.status === 0 ? (result.stdout ?? '') : '';\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';\nimport { GitStatusEntry, GitStatusParser } from './git-status';\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 consumer-monorepo2 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(\n private readonly forkPoint: ForkPoint,\n private readonly statusParser: GitStatusParser,\n ) {}\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 * Parsed by {@link GitStatusParser} off the RAW (untrimmed) output. This used to hand-roll\n * `line.slice(3).trim()` over `gitOut(...)`, which trims — and on a porcelain line the leading space\n * IS the index column, so trimming shifted the FIRST line left by one and `slice(3)` then cut a\n * character off the front of its path. It also left git's quoting on, so `\"a b.txt\"` was reported\n * with the quotes still attached and matched no checklist glob.\n */\n private dirtyPaths(repoRoot: string): string[] {\n const out = this.rawGitOut(repoRoot, ['status', '--porcelain', '--untracked-files=all']);\n return this.statusParser.parse(out).map((entry: GitStatusEntry): string => entry.path);\n }\n\n private gitOut(repoRoot: string, args: string[]): string {\n return this.rawGitOut(repoRoot, args).trim();\n }\n\n // Untrimmed — mandatory for `git status --porcelain`, where leading whitespace carries meaning.\n private rawGitOut(repoRoot: string, args: string[]): string {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n return result.status === 0 ? (result.stdout ?? '') : '';\n }\n}\n"]}
|
|
@@ -39,7 +39,7 @@ export declare class DiffManifest {
|
|
|
39
39
|
/**
|
|
40
40
|
* Extracts this branch's diff ONCE, to disk, so a reviewer subagent reads it instead of reconstructing it.
|
|
41
41
|
*
|
|
42
|
-
* The motivating measurement: a reviewer on
|
|
42
|
+
* The motivating measurement: a reviewer on consumer-monorepo2 spent 14 of its 26 tool calls on context
|
|
43
43
|
* archaeology, and the very first `git diff` it was told to run returned nothing (see {@link DiffBasis}).
|
|
44
44
|
* Handing it a file it can simply open removes both failure modes — the empty range and the shell-out.
|
|
45
45
|
*
|
|
@@ -81,7 +81,7 @@ exports.DiffManifest = DiffManifest;
|
|
|
81
81
|
/**
|
|
82
82
|
* Extracts this branch's diff ONCE, to disk, so a reviewer subagent reads it instead of reconstructing it.
|
|
83
83
|
*
|
|
84
|
-
* The motivating measurement: a reviewer on
|
|
84
|
+
* The motivating measurement: a reviewer on consumer-monorepo2 spent 14 of its 26 tool calls on context
|
|
85
85
|
* archaeology, and the very first `git diff` it was told to run returned nothing (see {@link DiffBasis}).
|
|
86
86
|
* Handing it a file it can simply open removes both failure modes — the empty range and the shell-out.
|
|
87
87
|
*
|
|
@@ -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/**\n * One materialized file's diff. Data-only (per CLAUDE.md).\n *\n * Both paths are recorded TWICE, relative and absolute, deliberately. Relative is the stable identity that\n * survives the repo being cloned somewhere else; absolute is the only form a reviewer subagent can actually\n * open, because its working directory is not guaranteed to be the repo root. Recording only the relative\n * form is what turned \"read this file\" back into a search — the exact cost this whole feature removes.\n */\nexport class DiffManifestEntry {\n file: string; // repo-relative source path\n fileAbs: string; // ABSOLUTE working-tree path; '' for a deleted file, which has no after-state\n diffFile: string; // repo-relative path of the .diff under diff/files/\n diffAbs: string; // ABSOLUTE path of the same .diff\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(repoRoot: string, file: string, diffFile: string, status: string, bytes: number, truncated = false) {\n this.file = file;\n // Not `gitTrackedSourceFileAfterChanges`: `status` already says what happened, and a compound name\n // would lie for 'U' (untracked) and for 'D' (no after-state). The path field answers only WHERE.\n this.fileAbs = status === 'D' ? '' : path.join(repoRoot, file);\n this.diffFile = diffFile;\n this.diffAbs = path.resolve(repoRoot, 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 // Size of the combined view, measured once HERE so the instructions renderer and any future consumer\n // read one number instead of each running its own `wc -l`. A reviewer needs the LINE count specifically:\n // the Read tool truncates at ~2000 lines, silently, which is how a fraction of a change gets reviewed.\n allDiffLines: number;\n allDiffBytes: number;\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 this.allDiffLines = 0;\n this.allDiffBytes = 0;\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(repoRoot, 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(repoRoot, 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 HEADER as well as in the\n * manifest.\n *\n * The header used to be a footer, and that was a live correctness bug. `ALL_DIFF_MAX_BYTES` is 2 MB —\n * roughly 30–50k lines — while the Read tool truncates at ~2000. So on the exact PR where files were\n * dropped, a reviewer Reads the first ~5% and the notice telling it files are missing sits in the 95% it\n * never reaches: the in-band signal went invisible precisely when it mattered. A reviewer reads a diff\n * from the start, so the top is the only position that survives a truncated read.\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 const out = this.allDiffHeader(manifest) + parts.join('\\n');\n manifest.allDiffBytes = out.length;\n manifest.allDiffLines = out === '' ? 0 : out.split('\\n').length;\n fs.writeFileSync(path.join(dir, 'ALL.diff'), out);\n }\n\n /**\n * The \"this combined view is NOT the whole change\" notice, at the TOP (see {@link writeAllDiff}). Emitted\n * only when something really is missing — a header on every diff trains a reader to skip the header.\n */\n private allDiffHeader(manifest: DiffManifest): string {\n const truncated = manifest.entries.filter((e: DiffManifestEntry): boolean => e.truncated);\n if (manifest.omittedFromAllDiff.length === 0 && truncated.length === 0) return '';\n const lines = ['=== ⚠️ THIS COMBINED VIEW IS INCOMPLETE — read this notice before the diff below ==='];\n if (manifest.omittedFromAllDiff.length > 0) {\n lines.push(`${manifest.omittedFromAllDiff.length} file(s) are OMITTED here (this view hit the ` +\n `${ALL_DIFF_MAX_BYTES}-byte cap). Each still has its OWN complete diff`, 'under files/ — open those, and see manifest.json:');\n for (const f of manifest.omittedFromAllDiff) lines.push(` ${f}`);\n }\n if (truncated.length > 0) {\n lines.push(`${truncated.length} file(s) below are TRUNCATED at ${FILE_DIFF_MAX_BYTES} bytes; each says so inline where it cuts off:`);\n for (const e of truncated) lines.push(` ${e.file}`);\n }\n lines.push('=== end of notice ===', '');\n return lines.join('\\n') + '\\n';\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/**\n * One materialized file's diff. Data-only (per CLAUDE.md).\n *\n * Both paths are recorded TWICE, relative and absolute, deliberately. Relative is the stable identity that\n * survives the repo being cloned somewhere else; absolute is the only form a reviewer subagent can actually\n * open, because its working directory is not guaranteed to be the repo root. Recording only the relative\n * form is what turned \"read this file\" back into a search — the exact cost this whole feature removes.\n */\nexport class DiffManifestEntry {\n file: string; // repo-relative source path\n fileAbs: string; // ABSOLUTE working-tree path; '' for a deleted file, which has no after-state\n diffFile: string; // repo-relative path of the .diff under diff/files/\n diffAbs: string; // ABSOLUTE path of the same .diff\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(repoRoot: string, file: string, diffFile: string, status: string, bytes: number, truncated = false) {\n this.file = file;\n // Not `gitTrackedSourceFileAfterChanges`: `status` already says what happened, and a compound name\n // would lie for 'U' (untracked) and for 'D' (no after-state). The path field answers only WHERE.\n this.fileAbs = status === 'D' ? '' : path.join(repoRoot, file);\n this.diffFile = diffFile;\n this.diffAbs = path.resolve(repoRoot, 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 // Size of the combined view, measured once HERE so the instructions renderer and any future consumer\n // read one number instead of each running its own `wc -l`. A reviewer needs the LINE count specifically:\n // the Read tool truncates at ~2000 lines, silently, which is how a fraction of a change gets reviewed.\n allDiffLines: number;\n allDiffBytes: number;\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 this.allDiffLines = 0;\n this.allDiffBytes = 0;\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 consumer-monorepo2 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(repoRoot, 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(repoRoot, 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 HEADER as well as in the\n * manifest.\n *\n * The header used to be a footer, and that was a live correctness bug. `ALL_DIFF_MAX_BYTES` is 2 MB —\n * roughly 30–50k lines — while the Read tool truncates at ~2000. So on the exact PR where files were\n * dropped, a reviewer Reads the first ~5% and the notice telling it files are missing sits in the 95% it\n * never reaches: the in-band signal went invisible precisely when it mattered. A reviewer reads a diff\n * from the start, so the top is the only position that survives a truncated read.\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 const out = this.allDiffHeader(manifest) + parts.join('\\n');\n manifest.allDiffBytes = out.length;\n manifest.allDiffLines = out === '' ? 0 : out.split('\\n').length;\n fs.writeFileSync(path.join(dir, 'ALL.diff'), out);\n }\n\n /**\n * The \"this combined view is NOT the whole change\" notice, at the TOP (see {@link writeAllDiff}). Emitted\n * only when something really is missing — a header on every diff trains a reader to skip the header.\n */\n private allDiffHeader(manifest: DiffManifest): string {\n const truncated = manifest.entries.filter((e: DiffManifestEntry): boolean => e.truncated);\n if (manifest.omittedFromAllDiff.length === 0 && truncated.length === 0) return '';\n const lines = ['=== ⚠️ THIS COMBINED VIEW IS INCOMPLETE — read this notice before the diff below ==='];\n if (manifest.omittedFromAllDiff.length > 0) {\n lines.push(`${manifest.omittedFromAllDiff.length} file(s) are OMITTED here (this view hit the ` +\n `${ALL_DIFF_MAX_BYTES}-byte cap). Each still has its OWN complete diff`, 'under files/ — open those, and see manifest.json:');\n for (const f of manifest.omittedFromAllDiff) lines.push(` ${f}`);\n }\n if (truncated.length > 0) {\n lines.push(`${truncated.length} file(s) below are TRUNCATED at ${FILE_DIFF_MAX_BYTES} bytes; each says so inline where it cuts off:`);\n for (const e of truncated) lines.push(` ${e.file}`);\n }\n lines.push('=== end of notice ===', '');\n return lines.join('\\n') + '\\n';\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"]}
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* source"): a rule added in this working tree cannot get a config entry until its release ships,
|
|
14
14
|
* while the LOCAL validator a spec runs already demands one. Without this, adding any rule turns
|
|
15
15
|
* these specs red for exactly one release — a failure that says nothing about the code under test.
|
|
16
|
+
* 3. Every RETIRED rule entry is dropped — the same one-release lag pointing the other way. When a rule
|
|
17
|
+
* is retired in this working tree, the repo's config must KEEP its entry until the release carrying
|
|
18
|
+
* the retirement ships (deleting it early would fail the published validator and block every Bash
|
|
19
|
+
* call), while the LOCAL validator already REJECTS it. Same trade as (2), same fix.
|
|
16
20
|
*
|
|
17
21
|
* A testkit, not product code: nothing in a published bin imports it (same role as ai-hook-rules'
|
|
18
22
|
* `shim-testkit.ts`).
|
|
@@ -20,6 +24,7 @@
|
|
|
20
24
|
export declare class RepoConfigFixture {
|
|
21
25
|
/** The repo's config as a mutable object, checklists dropped and missing rules seeded. */
|
|
22
26
|
load(): Record<string, unknown>;
|
|
27
|
+
private dropRetiredRules;
|
|
23
28
|
/** Write `config` (usually a `load()` result a spec has edited) as the config of `dir`. */
|
|
24
29
|
writeTo(dir: string, config: Record<string, unknown>): void;
|
|
25
30
|
private seedMissingRules;
|
|
@@ -20,6 +20,10 @@ const rules_config_1 = require("@webpieces/rules-config");
|
|
|
20
20
|
* source"): a rule added in this working tree cannot get a config entry until its release ships,
|
|
21
21
|
* while the LOCAL validator a spec runs already demands one. Without this, adding any rule turns
|
|
22
22
|
* these specs red for exactly one release — a failure that says nothing about the code under test.
|
|
23
|
+
* 3. Every RETIRED rule entry is dropped — the same one-release lag pointing the other way. When a rule
|
|
24
|
+
* is retired in this working tree, the repo's config must KEEP its entry until the release carrying
|
|
25
|
+
* the retirement ships (deleting it early would fail the published validator and block every Bash
|
|
26
|
+
* call), while the LOCAL validator already REJECTS it. Same trade as (2), same fix.
|
|
23
27
|
*
|
|
24
28
|
* A testkit, not product code: nothing in a published bin imports it (same role as ai-hook-rules'
|
|
25
29
|
* `shim-testkit.ts`).
|
|
@@ -35,9 +39,24 @@ class RepoConfigFixture {
|
|
|
35
39
|
const commands = config['commands'];
|
|
36
40
|
delete commands['pr-gate']['checklists'];
|
|
37
41
|
delete commands['pr-gate']['checklistsWhy'];
|
|
42
|
+
this.dropRetiredRules(config);
|
|
38
43
|
this.seedMissingRules(config);
|
|
39
44
|
return config;
|
|
40
45
|
}
|
|
46
|
+
// Drop every rule/guard name this build knows to be RETIRED, from whichever section holds it.
|
|
47
|
+
// webpieces-disable no-any-unknown -- see load()
|
|
48
|
+
dropRetiredRules(config) {
|
|
49
|
+
for (const section of ['rules', 'hookGuards']) {
|
|
50
|
+
// webpieces-disable no-any-unknown -- narrowing one rule section
|
|
51
|
+
const entries = config[section];
|
|
52
|
+
if (entries === undefined)
|
|
53
|
+
continue;
|
|
54
|
+
for (const name of Object.keys(entries)) {
|
|
55
|
+
if ((0, rules_config_1.retiredRuleFor)(name) !== null)
|
|
56
|
+
delete entries[name];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
41
60
|
/** Write `config` (usually a `load()` result a spec has edited) as the config of `dir`. */
|
|
42
61
|
// webpieces-disable no-any-unknown -- see load()
|
|
43
62
|
writeTo(dir, config) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"repo-config-testkit.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/repo-config-testkit.ts"],"names":[],"mappings":";;;;AAAA,+CAAyB;AACzB,mDAA6B;AAE7B,
|
|
1
|
+
{"version":3,"file":"repo-config-testkit.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/repo-config-testkit.ts"],"names":[],"mappings":";;;;AAAA,+CAAyB;AACzB,mDAA6B;AAE7B,0DAAyG;AAEzG;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAa,iBAAiB;IAC1B,0FAA0F;IAC1F,mGAAmG;IACnG,IAAI;QACA,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,uBAAuB,CAAC,CAAC;QACjG,mFAAmF;QACnF,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,MAAM,EAAE,MAAM,CAAC,CAA4B,CAAC;QACtF,mEAAmE;QACnE,MAAM,QAAQ,GAAG,MAAM,CAAC,UAAU,CAA4C,CAAC;QAC/E,OAAO,QAAQ,CAAC,SAAS,CAAC,CAAC,YAAY,CAAC,CAAC;QACzC,OAAO,QAAQ,CAAC,SAAS,CAAC,CAAC,eAAe,CAAC,CAAC;QAC5C,IAAI,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAC;QAC9B,IAAI,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAC;QAC9B,OAAO,MAAM,CAAC;IAClB,CAAC;IAED,8FAA8F;IAC9F,iDAAiD;IACzC,gBAAgB,CAAC,MAA+B;QACpD,KAAK,MAAM,OAAO,IAAI,CAAC,OAAO,EAAE,YAAY,CAAC,EAAE,CAAC;YAC5C,iEAAiE;YACjE,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAwC,CAAC;YACvE,IAAI,OAAO,KAAK,SAAS;gBAAE,SAAS;YACpC,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;gBACtC,IAAI,IAAA,6BAAc,EAAC,IAAI,CAAC,KAAK,IAAI;oBAAE,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC;YAC5D,CAAC;QACL,CAAC;IACL,CAAC;IAED,2FAA2F;IAC3F,iDAAiD;IACjD,OAAO,CAAC,GAAW,EAAE,MAA+B;QAChD,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,uBAAuB,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IACtG,CAAC;IAED,iDAAiD;IACzC,gBAAgB,CAAC,MAA+B;QACpD,sEAAsE;QACtE,MAAM,KAAK,GAAG,MAAM,CAAC,OAAO,CAA4B,CAAC;QACzD,sEAAsE;QACtE,MAAM,MAAM,GAAG,MAAM,CAAC,YAAY,CAA4B,CAAC;QAC/D,KAAK,MAAM,IAAI,IAAI,IAAA,2BAAY,GAAE,EAAE,CAAC;YAChC,MAAM,OAAO,GAAG,IAAA,6BAAc,EAAC,IAAI,CAAC,KAAK,YAAY,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC;YACvE,IAAI,CAAC,CAAC,IAAI,IAAI,OAAO,CAAC;gBAAE,OAAO,CAAC,IAAI,CAAC,GAAG,IAAA,+BAAgB,EAAC,IAAI,CAAC,CAAC;QACnE,CAAC;IACL,CAAC;CACJ;AA9CD,8CA8CC","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { allRuleNames, retiredRuleFor, sectionForRule, seedEntryForRule } from '@webpieces/rules-config';\n\n/**\n * THIS repo's own `webpieces.config.json`, prepared for use inside a temp clone.\n *\n * Specs that exercise a command end-to-end need a config the REAL validator accepts, and using the\n * repo's own file (rather than a hand-rolled minimal one) is what keeps them honest — a stub drifts out\n * from under the validator on the next config change. Two adjustments are needed to make that file\n * usable outside this checkout, and both are the same for every such spec, so they live here once:\n *\n * 1. `checklists` is dropped. Its `doc` paths are validated REPO-RELATIVE and point at\n * `.claude/review/*.md`, which exist here and not in a temp clone.\n * 2. Every locally-known rule with no entry is SEEDED. The repo's config is validated by the PUBLISHED\n * validator and therefore lags local source by one release (CLAUDE.md, \"Published vs local\n * source\"): a rule added in this working tree cannot get a config entry until its release ships,\n * while the LOCAL validator a spec runs already demands one. Without this, adding any rule turns\n * these specs red for exactly one release — a failure that says nothing about the code under test.\n * 3. Every RETIRED rule entry is dropped — the same one-release lag pointing the other way. When a rule\n * is retired in this working tree, the repo's config must KEEP its entry until the release carrying\n * the retirement ships (deleting it early would fail the published validator and block every Bash\n * call), while the LOCAL validator already REJECTS it. Same trade as (2), same fix.\n *\n * A testkit, not product code: nothing in a published bin imports it (same role as ai-hook-rules'\n * `shim-testkit.ts`).\n */\nexport class RepoConfigFixture {\n /** The repo's config as a mutable object, checklists dropped and missing rules seeded. */\n // webpieces-disable no-any-unknown -- the repo's own config document, opaque until a spec edits it\n load(): Record<string, unknown> {\n const source = path.join(__dirname, '..', '..', '..', '..', '..', '..', 'webpieces.config.json');\n // webpieces-disable no-any-unknown -- parsed JSON, narrowed by the accessors below\n const config = JSON.parse(fs.readFileSync(source, 'utf8')) as Record<string, unknown>;\n // webpieces-disable no-any-unknown -- narrowing one nested section\n const commands = config['commands'] as Record<string, Record<string, unknown>>;\n delete commands['pr-gate']['checklists'];\n delete commands['pr-gate']['checklistsWhy'];\n this.dropRetiredRules(config);\n this.seedMissingRules(config);\n return config;\n }\n\n // Drop every rule/guard name this build knows to be RETIRED, from whichever section holds it.\n // webpieces-disable no-any-unknown -- see load()\n private dropRetiredRules(config: Record<string, unknown>): void {\n for (const section of ['rules', 'hookGuards']) {\n // webpieces-disable no-any-unknown -- narrowing one rule section\n const entries = config[section] as Record<string, unknown> | undefined;\n if (entries === undefined) continue;\n for (const name of Object.keys(entries)) {\n if (retiredRuleFor(name) !== null) delete entries[name];\n }\n }\n }\n\n /** Write `config` (usually a `load()` result a spec has edited) as the config of `dir`. */\n // webpieces-disable no-any-unknown -- see load()\n writeTo(dir: string, config: Record<string, unknown>): void {\n fs.writeFileSync(path.join(dir, 'webpieces.config.json'), JSON.stringify(config, null, 4) + '\\n');\n }\n\n // webpieces-disable no-any-unknown -- see load()\n private seedMissingRules(config: Record<string, unknown>): void {\n // webpieces-disable no-any-unknown -- narrowing the two rule sections\n const rules = config['rules'] as Record<string, unknown>;\n // webpieces-disable no-any-unknown -- narrowing the two rule sections\n const guards = config['hookGuards'] as Record<string, unknown>;\n for (const name of allRuleNames()) {\n const section = sectionForRule(name) === 'hookGuards' ? guards : rules;\n if (!(name in section)) section[name] = seedEntryForRule(name);\n }\n }\n}\n"]}
|