@webpieces/pr-gate 0.4.665 → 0.4.667

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":"build-affected.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/build-affected.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { loadAndValidate, CliExitError, HomeConfigService, DEFAULT_BUILD_COMMAND } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { BuildGateLog } from './build-gate-log';\n\n// Single source of truth for RUNNING the build gate. `wp-start-upsert-pr` runs NO build; stage ②\n// (`wp-review-upsert-pr`) runs it authoritatively before any reviewer is spawned, and stage ③\n// (`wp-finish-upsert-pr`) re-runs it only when HEAD has moved since. nx `affected` only rebuilds\n// changed projects. The fallback command itself lives in @webpieces/rules-config as\n// DEFAULT_BUILD_COMMAND — whole-repo-build-guard prints the same string, and one definition is what\n// keeps the refusal message naming the build that actually runs.\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * The caller-supplied framing for the build gate (label, re-run command, failure headline, stage id). Kept\n * as a parameter object so runBuildGate stays agnostic of who invokes it. A class (not an object literal)\n * 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 // WHICH stage's gate this is — REVIEW_STAGE or FINISH_STAGE. Required, with no default: it is part of\n // the captured log's filename, and a default would silently make two stages share one log file.\n stage: string;\n\n constructor(label: string, rerunCommand: string, failureHeadline: string, stage: string) {\n this.label = label;\n this.rerunCommand = rerunCommand;\n this.failureHeadline = failureHeadline;\n this.stage = stage;\n }\n}\n\n/** Runs the authoritative nx-affected build gate for wp-review-upsert-pr and wp-finish-upsert-pr. */\n@injectable(bindingScopeValues.Singleton)\nexport class BuildAffected {\n constructor(\n private readonly homeConfig: HomeConfigService,\n private readonly buildLog: BuildGateLog,\n ) {}\n\n /**\n * EXPERIMENTAL, and OFF unless the OPTIONAL machine-local `~/.webpieces/config.json` turns it on.\n *\n * That file does not exist for essentially anyone, and its absence is not an error, a warning or a\n * behaviour change of any kind — `HomeConfigService.load` returns all-defaults silently. False here is\n * therefore the state every consumer is in, and false means runBuildGate executes exactly the code it\n * executed before this feature existed.\n */\n isCaptureEnabled(): boolean {\n return this.homeConfig.load().buildGateLogCapture;\n }\n\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 // '' means NOT capturing, which is the case for every user who has not created the OPTIONAL\n // `~/.webpieces/config.json`. Everything below then runs exactly the code it ran before this\n // feature existed — same spawn, same message, no extra file, no extra line of output.\n const logPath = this.isCaptureEnabled() ? this.buildLog.pathFor(repoRoot, opts.stage) : '';\n const buildCode = logPath === ''\n ? this.runConfiguredBuildGate(repoRoot)\n : this.buildLog.run(repoRoot, buildCommand, logPath);\n if (buildCode !== 0) throw new CliExitError(buildCode, this.failureText(opts, buildCommand, logPath));\n process.stdout.write('\\n✅ Build passed.\\n');\n }\n\n /**\n * On failure, WITHOUT capture: the pre-existing text, which tells the AI to re-run the build itself.\n * WITH capture: a deliberately tiny pointer at the log the gate already wrote — the whole point of the\n * feature is that the agent reads one file instead of rebuilding the repo and eating the transcript.\n */\n private failureText(opts: BuildGateOptions, buildCommand: string, logPath: string): string {\n if (logPath !== '') return this.buildLog.failureMessage(buildCommand, logPath);\n return `\\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"]}
1
+ {"version":3,"file":"build-affected.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/build-affected.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { loadAndValidate, CliExitError, HomeConfigService, DEFAULT_BUILD_COMMAND } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\nimport { BuildGateLog } from './build-gate-log';\n\n// Single source of truth for RUNNING the build gate. `wp-start-upsert-pr` runs NO build; stage ②\n// (`wp-review-upsert-pr`) runs it authoritatively before any reviewer is spawned, and stage ③\n// (`wp-finish-upsert-pr`) re-runs it only when HEAD has moved since. nx `affected` only rebuilds\n// changed projects. The fallback command itself lives in @webpieces/rules-config as\n// DEFAULT_BUILD_COMMAND — whole-repo-build-guard prints the same string, and one definition is what\n// keeps the refusal message naming the build that actually runs.\n\nconst SEP = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\\n';\n\n/**\n * The caller-supplied framing for the build gate (label, re-run command, failure headline, stage id). Kept\n * as a parameter object so runBuildGate stays agnostic of who invokes it. A class (not an object literal)\n * 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 // WHICH stage's gate this is — REVIEW_STAGE, FINISH_STAGE or BUILD_STAGE. Required, with no default:\n // it decides the captured log's filename, and a default would silently make two stages share one file.\n stage: string;\n // Capture regardless of the EXPERIMENTAL `~/.webpieces/config.json` opt-in. True for `wp-build`, whose\n // entire contract IS the log file — its console output is a heartbeat and a pointer at that file, so a\n // wp-build that did not capture would have nothing to point at. The PR-flow stages pass false and stay\n // on the opt-in until the experiment lands for them too.\n alwaysCapture: boolean;\n\n constructor(label: string, rerunCommand: string, failureHeadline: string, stage: string, alwaysCapture: boolean) {\n this.label = label;\n this.rerunCommand = rerunCommand;\n this.failureHeadline = failureHeadline;\n this.stage = stage;\n this.alwaysCapture = alwaysCapture;\n }\n}\n\n/** Runs the authoritative nx-affected build gate for wp-review-upsert-pr and wp-finish-upsert-pr. */\n@injectable(bindingScopeValues.Singleton)\nexport class BuildAffected {\n constructor(\n private readonly homeConfig: HomeConfigService,\n private readonly buildLog: BuildGateLog,\n ) {}\n\n /**\n * EXPERIMENTAL, and OFF unless the OPTIONAL machine-local `~/.webpieces/config.json` turns it on.\n *\n * That file does not exist for essentially anyone, and its absence is not an error, a warning or a\n * behaviour change of any kind — `HomeConfigService.load` returns all-defaults silently. False here is\n * therefore the state every consumer is in, and false means runBuildGate executes exactly the code it\n * executed before this feature existed.\n */\n isCaptureEnabled(): boolean {\n return this.homeConfig.load().buildGateLogCapture;\n }\n\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 async runBuildGate(repoRoot: string, opts: BuildGateOptions): Promise<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 // '' means NOT capturing: a PR-flow stage on a machine that has not created the OPTIONAL\n // `~/.webpieces/config.json`. Everything below then runs exactly the code it ran before capture\n // existed — same spawn, streamed to the terminal, same message, no extra file.\n const logPath = opts.alwaysCapture || this.isCaptureEnabled() ? this.buildLog.pathFor(repoRoot, opts.stage) : '';\n const buildCode = logPath === ''\n ? this.runConfiguredBuildGate(repoRoot)\n : await this.buildLog.run(repoRoot, buildCommand, logPath);\n if (buildCode !== 0) throw new CliExitError(buildCode, this.failureText(opts, buildCommand, logPath));\n process.stdout.write(logPath === '' ? '\\n✅ Build passed.\\n' : this.buildLog.successMessage(logPath));\n }\n\n /**\n * On failure, WITHOUT capture: the pre-existing text, which tells the AI to re-run the build itself.\n * WITH capture: a deliberately tiny pointer at the log the gate already wrote — the whole point of the\n * feature is that the agent reads one file instead of rebuilding the repo and eating the transcript.\n */\n private failureText(opts: BuildGateOptions, buildCommand: string, logPath: string): string {\n if (logPath !== '') return this.buildLog.failureMessage(buildCommand, logPath);\n return `\\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"]}
@@ -1,64 +1,105 @@
1
+ /** How often the heartbeat reports the log's size. Hardcoded: a knob here would be a knob the PR gate's
2
+ * own build never receives, and the two must stay the same command. */
3
+ export declare const HEARTBEAT_MS = 10000;
4
+ /** How many trailing log lines the failure message echoes, so the immediate cause is visible without a
5
+ * second command. Small on purpose — the FULL log is one grep away and the message must not become the
6
+ * transcript it exists to replace. */
7
+ export declare const FAILURE_TAIL_LINES = 20;
1
8
  /**
2
- * Which stage's gate is being captured. The value is part of the log FILENAME, so review and finish never
3
- * write the same file even at the same commit on the same branch.
9
+ * Which stage's gate is being captured. The value decides the log FILENAME.
4
10
  */
5
11
  export declare const REVIEW_STAGE = "review";
6
12
  export declare const FINISH_STAGE = "finish";
7
13
  export declare const BUILD_STAGE = "build";
14
+ /** The one fixed log name — see BuildGateLog.fileNameFor for why only `wp-build` gets one. */
15
+ export declare const BUILD_LOG_NAME = "build.log";
8
16
  /**
9
- * Captures the build gate's full output to `.webpieces/logs/`, and renders the small pointer the AI is
10
- * handed instead of a rebuild instruction.
17
+ * The heartbeat's state: the line count reported on the PREVIOUS tick, so a tick that has not moved can
18
+ * say so. Stateful per RUN, which is why it is constructed per run rather than injected.
11
19
  *
12
- * ─── Why the filename cannot collide ───────────────────────────────────────────────────────────────────
13
- * The path is `dotWebpieces.logsFile(repoRoot, 'build-gate-<stage>-<branch>-<shortSha>.log')`, and each
14
- * component rules out one class of concurrent writer:
15
- * • the DIRECTORY is `dotWebpieces.local()`-scoped — `<primary>/.webpieces/worktrees/<git worktree
16
- * name>/logs/` in a linked worktree. Every worktree therefore has its own log directory already, which
17
- * is what makes "N agents in N worktrees" safe by construction rather than by naming.
18
- * • `<stage>` separates review from finish, the two gates that CAN run against one commit.
19
- * • `<branch>` — a branch can be checked out in at most one worktree (git enforces it), so within one
20
- * log directory the branch is effectively constant; it is in the name so a human reading the directory
21
- * can tell whose log is whose, and so switching branches never appends to a stale file.
22
- * • `<shortSha>` — a re-run at a NEW commit gets a new file, so the log a failure message points at is
23
- * always the build for the code that failed. A re-run at the SAME commit deliberately TRUNCATES: it is
24
- * the same build of the same tree, and the fresh one is the one worth reading.
25
- * The residual case is two agents running the same stage, at the same commit, in the SAME worktree,
26
- * simultaneously. That is already unsupported both would be driving one git index and one merge state —
27
- * and it is the only case this scheme does not separate.
20
+ * `still` is the load-bearing word. A build that is linking, or waiting on a cold nx cache, produces no
21
+ * output for minutes; without `still` the caller sees the same number twice and cannot tell a stalled
22
+ * BUILD from a stalled REPORTER.
23
+ */
24
+ export declare class BuildLogHeartbeat {
25
+ private readonly logPath;
26
+ private readonly displayPath;
27
+ private previous;
28
+ constructor(logPath: string, displayPath: string);
29
+ /** One heartbeat line `<path> size <n> lines`, plus ` still` when <n> has not moved. */
30
+ tick(): string;
31
+ private lineCount;
32
+ }
33
+ /**
34
+ * Captures the build gate's full output to a file, reports progress while it runs, and renders the
35
+ * pointer the caller is handed instead of a rebuild instruction.
36
+ *
37
+ * ─── Two naming schemes, one rule each ─────────────────────────────────────────────────────────────────
38
+ * • `wp-build` (BUILD_STAGE) writes ONE fixed path, `.webpieces/build.log`. It is fixed because a HUMAN
39
+ * OR AN AGENT TYPES IT — `grep -n error .webpieces/build.log` has to be writable from memory, and a
40
+ * name carrying a branch and a sha is not. History comes from the rotation below instead.
41
+ * • stage ② and stage ③ write `logs/build-gate-<stage>-<branch>-<shortSha>.log`, because those two
42
+ * gates CAN run against one commit and a failure message from one must not be pointing at a file the
43
+ * other overwrote. Nobody types those names; the failure message prints them.
44
+ *
45
+ * ─── Rotation, everywhere ──────────────────────────────────────────────────────────────────────────────
46
+ * Every run moves an existing log to `<log>.bak` before writing, so the last TWO runs are always on disk.
47
+ * One rule for every stage: no branch, and the previous run of a re-run at the same commit survives
48
+ * instead of being truncated away.
49
+ *
50
+ * ─── Concurrency ───────────────────────────────────────────────────────────────────────────────────────
51
+ * The DIRECTORY is `dotWebpieces.local()`-scoped — `<primary>/.webpieces/worktrees/<git worktree name>/`
52
+ * in a linked worktree — so "N agents in N worktrees" is safe by construction rather than by naming. The
53
+ * residual case is two builds in the SAME worktree at once, which is already unsupported: both would be
54
+ * driving one git index.
28
55
  */
29
56
  export declare class BuildGateLog {
30
- /** Absolute path of the log for `stage` at the current HEAD, creating the log directory. */
57
+ /** Absolute path of the log for `stage` at the current HEAD, creating its directory. */
31
58
  pathFor(repoRoot: string, stage: string): string;
32
59
  /** The same path WITHOUT creating anything, and '' when no such log exists. Used by finish's skip path. */
33
60
  existingLogFor(repoRoot: string, stage: string): string;
34
- /** `build-gate-<stage>-<branch>-<shortSha>.log` see the class docstring for why this cannot collide. */
61
+ /** `build.log` for `wp-build`; `build-gate-<stage>-<branch>-<shortSha>.log` for the PR-flow stages. */
35
62
  fileNameFor(repoRoot: string, stage: string): string;
63
+ /** Where the PREVIOUS run of `logPath` is kept — always `<logPath>.bak`. */
64
+ backupPathFor(logPath: string): string;
65
+ /**
66
+ * Move an existing log aside to `<log>.bak`, overwriting any previous backup. A missing log is the
67
+ * normal first-run state and is not an error.
68
+ */
69
+ rotate(logPath: string): void;
70
+ /**
71
+ * Run `buildCommand` with its stdout AND stderr redirected in full to `logPath`, printing a heartbeat
72
+ * to the console every HEARTBEAT_MS so the caller can see it is alive. Returns the BUILD's exit code.
73
+ * Nothing is truncated and nothing is streamed.
74
+ */
75
+ run(repoRoot: string, buildCommand: string, logPath: string): Promise<number>;
36
76
  /**
37
- * Run `buildCommand` with its combined stdout+stderr streaming to the terminal AND appended in full to
38
- * `logPath`. Returns the BUILD's exit code (not tee's). Nothing is truncated.
77
+ * The success summary: the caller is told WHERE the full output is, not handed the output.
39
78
  */
40
- run(repoRoot: string, buildCommand: string, logPath: string): number;
79
+ successMessage(logPath: string): string;
41
80
  /**
42
- * The ENTIRE message the AI gets on a captured failure. Deliberately tiny: the whole point is that the
43
- * agent reads ONE file rather than carrying a build transcript in its context.
81
+ * The ENTIRE message the caller gets on a failed build. It names the log, echoes the last
82
+ * FAILURE_TAIL_LINES lines so the immediate cause needs no second command, and forbids the rebuild
83
+ * this whole file exists to prevent.
44
84
  *
45
85
  * The last sentence is not filler. If the log holds no visible failure then something upstream is wrong
46
- * (a runner that died without printing, a truncated pipe), and the worst possible response is an agent
47
- * guessing or rebuilding — so it is told to surface the contradiction to the human and stop.
86
+ * (a runner that died without printing, a truncated redirect), and the worst possible response is an
87
+ * agent guessing or rebuilding — so it is told to surface the contradiction to the human and stop.
48
88
  */
49
89
  failureMessage(buildCommand: string, logPath: string): string;
90
+ private logPointer;
50
91
  /**
51
- * `{ ( <build> ) ; echo $? > <status> ; } 2>&1 | tee <log>` — written across LINES so a build command
52
- * ending in a `#` comment cannot swallow what follows it.
92
+ * The log's last FAILURE_TAIL_LINES lines, or a plain statement of why there are none.
53
93
  *
54
- * The inner `( )` is load-bearing, not decoration. The left side of a pipeline is already a subshell,
55
- * so a build command containing a plain `exit 7` (or any script that calls `exit`) would terminate that
56
- * subshell OUTRIGHT `echo $?` never runs, the side file never appears, and the only status left is
57
- * tee's 0. The nested subshell absorbs the `exit`, so `$?` is the build's code every time.
94
+ * A read that fails is REPORTED, never allowed to throw: this renders the message for a build that has
95
+ * ALREADY failed, so an I/O error escaping here would replace the real failure with the renderer's own
96
+ * — the caller would lose the build error and be handed a filesystem error instead. The full log is
97
+ * still named on the line above, so nothing is hidden by degrading to one line.
58
98
  */
59
- private wrap;
60
- private readStatus;
61
- private quote;
99
+ private tail;
100
+ private awaitExit;
101
+ private displayPath;
102
+ private resolvePath;
62
103
  private slug;
63
104
  private git;
64
105
  }
@@ -1,138 +1,245 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.BuildGateLog = exports.BUILD_STAGE = exports.FINISH_STAGE = exports.REVIEW_STAGE = void 0;
3
+ exports.BuildGateLog = exports.BuildLogHeartbeat = exports.BUILD_LOG_NAME = exports.BUILD_STAGE = exports.FINISH_STAGE = exports.REVIEW_STAGE = exports.FAILURE_TAIL_LINES = exports.HEARTBEAT_MS = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const child_process_1 = require("child_process");
6
6
  const fs = tslib_1.__importStar(require("fs"));
7
7
  const path = tslib_1.__importStar(require("path"));
8
8
  const rules_config_1 = require("@webpieces/rules-config");
9
9
  const inversify_1 = require("inversify");
10
- // EXPERIMENTAL (opt-in via `~/.webpieces/config.json` → experimental.buildGateLogCapture). Nothing in this
11
- // file runs for a user who has not created that OPTIONAL file — see `HomeConfigService`
12
- // (`@webpieces/rules-config`), which returns all-defaults silently when it is absent.
13
- //
14
- // EVERY key in that file is optional, so the smallest document enabling THIS feature is just:
15
- // { "experimental": { "buildGateLogCapture": true } }
16
- // Optional is not a convenience: the file is MACHINE-GLOBAL and the repos on one machine pin different
17
- // webpieces releases, so a REQUIRED key there has no satisfiable value — omitting it fails the new
18
- // release, adding it fails every older one. `HomeConfigService` owns that rule.
19
- //
20
10
  // ─── Why ───────────────────────────────────────────────────────────────────────────────────────────────
21
- // The build gate already builds everything. When it fails, the pre-existing message says "run THIS exact
22
- // command to reproduce", and an AI agent obeys it so the repo is built a SECOND time purely to see the
23
- // errors that scrolled past the first time. This captures the first build's output instead.
11
+ // The build gate already builds everything. When the output only ever went to the CONSOLE, an agent that
12
+ // wanted a different slice of it re-ran the WHOLE BUILD to get it: one measured session spent 23.9 minutes
13
+ // across nine `nx affected` runs, five of them with NO code change in between — `| tail -50`, then
14
+ // `> /tmp/file`, then `| grep`, then `| sed -n '1100,1230p'`. ~19 minutes spent re-reading a log.
15
+ //
16
+ // So the build's output is not streamed; it is REDIRECTED, in full, to a file whose path the caller is
17
+ // handed on completion. Reading a different slice is then a `grep` of a FILE, not a second build.
24
18
  //
25
- // ─── Why a shell `tee` and not spawnSync's pipes ───────────────────────────────────────────────────────
26
- // spawnSync BUFFERS a piped stream and hands it over only after the child exits, so the human would watch
27
- // a silent terminal for the length of a full build. The output has to be split at the OS level, which is
28
- // what `tee` is. The gate keeps `stdio: 'inherit'`, so the terminal is byte-identical to today.
19
+ // ─── Why a redirect and not `tee` ──────────────────────────────────────────────────────────────────────
20
+ // An earlier cut of this used `cmd 2>&1 | tee log`, to keep the terminal byte-identical. That is what
21
+ // makes the transcript expensive in the first place an AI caller carries every line of it in context.
22
+ // The redirect keeps the console to a handful of lines (a heartbeat, then a pointer at the file), which is
23
+ // the entire productivity claim. Losing `tee` also deletes the `$?`-into-a-side-file dance it needed: in a
24
+ // pipeline the shell reports TEE's status, which is 0 whether the build passed or failed, so the status
25
+ // had to be smuggled out through a side file. With no pipe, the child's own exit code IS the answer.
29
26
  //
30
- // ─── Why an exit-status side file and not `pipefail` ───────────────────────────────────────────────────
31
- // In a pipeline the shell reports the LAST command's status, i.e. tee's, which is 0 whether the build
32
- // passed or failed. `set -o pipefail` and `${PIPESTATUS[0]}` are bash/zsh, and spawnSync's `shell: true`
33
- // gives `/bin/sh` dash on Debian. Writing `$?` to a side file inside the group is plain POSIX and works
34
- // under every one of them. The redirect keeps it out of the pipe, so it never lands in the log.
35
- const STATUS_SUFFIX = '.status';
27
+ // ─── Why async (`spawn`, not `spawnSync`) ──────────────────────────────────────────────────────────────
28
+ // `spawnSync` blocks the event loop for the length of the build, so NOTHING can print while it runs — and
29
+ // a silent terminal for 3–7 minutes is indistinguishable from a hang. The heartbeat is the reason this is
30
+ // async, and it is why `run` returns a Promise and `BuildAffected.runBuildGate` is async with it.
31
+ /** How often the heartbeat reports the log's size. Hardcoded: a knob here would be a knob the PR gate's
32
+ * own build never receives, and the two must stay the same command. */
33
+ exports.HEARTBEAT_MS = 10_000;
34
+ /** How many trailing log lines the failure message echoes, so the immediate cause is visible without a
35
+ * second command. Small on purpose — the FULL log is one grep away and the message must not become the
36
+ * transcript it exists to replace. */
37
+ exports.FAILURE_TAIL_LINES = 20;
36
38
  /**
37
- * Which stage's gate is being captured. The value is part of the log FILENAME, so review and finish never
38
- * write the same file even at the same commit on the same branch.
39
+ * Which stage's gate is being captured. The value decides the log FILENAME.
39
40
  */
40
41
  exports.REVIEW_STAGE = 'review';
41
42
  exports.FINISH_STAGE = 'finish';
42
43
  // `wp-build`, which is not a stage of the PR flow but runs the SAME gate (BuildAffected.runBuildGate).
43
- // Its own id so a developer's inner-loop build never overwrites the log stage ② or ③ is holding.
44
44
  exports.BUILD_STAGE = 'build';
45
+ /** The one fixed log name — see BuildGateLog.fileNameFor for why only `wp-build` gets one. */
46
+ exports.BUILD_LOG_NAME = 'build.log';
47
+ const BACKUP_SUFFIX = '.bak';
45
48
  /**
46
- * Captures the build gate's full output to `.webpieces/logs/`, and renders the small pointer the AI is
47
- * handed instead of a rebuild instruction.
49
+ * The heartbeat's state: the line count reported on the PREVIOUS tick, so a tick that has not moved can
50
+ * say so. Stateful per RUN, which is why it is constructed per run rather than injected.
51
+ *
52
+ * `still` is the load-bearing word. A build that is linking, or waiting on a cold nx cache, produces no
53
+ * output for minutes; without `still` the caller sees the same number twice and cannot tell a stalled
54
+ * BUILD from a stalled REPORTER.
55
+ */
56
+ class BuildLogHeartbeat {
57
+ logPath;
58
+ displayPath;
59
+ previous = null;
60
+ constructor(logPath, displayPath) {
61
+ this.logPath = logPath;
62
+ this.displayPath = displayPath;
63
+ }
64
+ /** One heartbeat line — `<path> size <n> lines`, plus ` still` when <n> has not moved. */
65
+ tick() {
66
+ const count = this.lineCount();
67
+ const still = this.previous !== null && count === this.previous ? ' still' : '';
68
+ this.previous = count;
69
+ return `${this.displayPath} size ${count} lines${still}`;
70
+ }
71
+ // Lines currently in the log. A log that does not exist yet is zero lines, not an error: the build may
72
+ // simply not have written its first byte, and a heartbeat may never be the reason a build stops.
73
+ lineCount() {
74
+ if (!fs.existsSync(this.logPath))
75
+ return 0;
76
+ const body = fs.readFileSync(this.logPath, 'utf8');
77
+ if (body === '')
78
+ return 0;
79
+ return body.split('\n').length - (body.endsWith('\n') ? 1 : 0);
80
+ }
81
+ }
82
+ exports.BuildLogHeartbeat = BuildLogHeartbeat;
83
+ /**
84
+ * Captures the build gate's full output to a file, reports progress while it runs, and renders the
85
+ * pointer the caller is handed instead of a rebuild instruction.
86
+ *
87
+ * ─── Two naming schemes, one rule each ─────────────────────────────────────────────────────────────────
88
+ * • `wp-build` (BUILD_STAGE) writes ONE fixed path, `.webpieces/build.log`. It is fixed because a HUMAN
89
+ * OR AN AGENT TYPES IT — `grep -n error .webpieces/build.log` has to be writable from memory, and a
90
+ * name carrying a branch and a sha is not. History comes from the rotation below instead.
91
+ * • stage ② and stage ③ write `logs/build-gate-<stage>-<branch>-<shortSha>.log`, because those two
92
+ * gates CAN run against one commit and a failure message from one must not be pointing at a file the
93
+ * other overwrote. Nobody types those names; the failure message prints them.
94
+ *
95
+ * ─── Rotation, everywhere ──────────────────────────────────────────────────────────────────────────────
96
+ * Every run moves an existing log to `<log>.bak` before writing, so the last TWO runs are always on disk.
97
+ * One rule for every stage: no branch, and the previous run of a re-run at the same commit survives
98
+ * instead of being truncated away.
48
99
  *
49
- * ─── Why the filename cannot collide ───────────────────────────────────────────────────────────────────
50
- * The path is `dotWebpieces.logsFile(repoRoot, 'build-gate-<stage>-<branch>-<shortSha>.log')`, and each
51
- * component rules out one class of concurrent writer:
52
- * the DIRECTORY is `dotWebpieces.local()`-scoped `<primary>/.webpieces/worktrees/<git worktree
53
- * name>/logs/` in a linked worktree. Every worktree therefore has its own log directory already, which
54
- * is what makes "N agents in N worktrees" safe by construction rather than by naming.
55
- * • `<stage>` separates review from finish, the two gates that CAN run against one commit.
56
- * • `<branch>` — a branch can be checked out in at most one worktree (git enforces it), so within one
57
- * log directory the branch is effectively constant; it is in the name so a human reading the directory
58
- * can tell whose log is whose, and so switching branches never appends to a stale file.
59
- * • `<shortSha>` — a re-run at a NEW commit gets a new file, so the log a failure message points at is
60
- * always the build for the code that failed. A re-run at the SAME commit deliberately TRUNCATES: it is
61
- * the same build of the same tree, and the fresh one is the one worth reading.
62
- * The residual case is two agents running the same stage, at the same commit, in the SAME worktree,
63
- * simultaneously. That is already unsupported — both would be driving one git index and one merge state —
64
- * and it is the only case this scheme does not separate.
100
+ * ─── Concurrency ───────────────────────────────────────────────────────────────────────────────────────
101
+ * The DIRECTORY is `dotWebpieces.local()`-scoped `<primary>/.webpieces/worktrees/<git worktree name>/`
102
+ * in a linked worktree so "N agents in N worktrees" is safe by construction rather than by naming. The
103
+ * residual case is two builds in the SAME worktree at once, which is already unsupported: both would be
104
+ * driving one git index.
65
105
  */
66
106
  let BuildGateLog = class BuildGateLog {
67
- /** Absolute path of the log for `stage` at the current HEAD, creating the log directory. */
107
+ /** Absolute path of the log for `stage` at the current HEAD, creating its directory. */
68
108
  pathFor(repoRoot, stage) {
69
- const file = rules_config_1.dotWebpieces.logsFile(repoRoot, this.fileNameFor(repoRoot, stage));
109
+ const file = this.resolvePath(repoRoot, stage);
70
110
  fs.mkdirSync(path.dirname(file), { recursive: true });
71
111
  return file;
72
112
  }
73
113
  /** The same path WITHOUT creating anything, and '' when no such log exists. Used by finish's skip path. */
74
114
  existingLogFor(repoRoot, stage) {
75
- const file = rules_config_1.dotWebpieces.logsFile(repoRoot, this.fileNameFor(repoRoot, stage));
115
+ const file = this.resolvePath(repoRoot, stage);
76
116
  return fs.existsSync(file) ? file : '';
77
117
  }
78
- /** `build-gate-<stage>-<branch>-<shortSha>.log` see the class docstring for why this cannot collide. */
118
+ /** `build.log` for `wp-build`; `build-gate-<stage>-<branch>-<shortSha>.log` for the PR-flow stages. */
79
119
  fileNameFor(repoRoot, stage) {
120
+ if (stage === exports.BUILD_STAGE)
121
+ return exports.BUILD_LOG_NAME;
80
122
  const branch = this.slug(this.git(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD']));
81
123
  const sha = this.slug(this.git(repoRoot, ['rev-parse', '--short', 'HEAD']));
82
124
  return `build-gate-${this.slug(stage)}-${branch === '' ? 'nobranch' : branch}-${sha === '' ? 'nosha' : sha}.log`;
83
125
  }
126
+ /** Where the PREVIOUS run of `logPath` is kept — always `<logPath>.bak`. */
127
+ backupPathFor(logPath) {
128
+ return `${logPath}${BACKUP_SUFFIX}`;
129
+ }
130
+ /**
131
+ * Move an existing log aside to `<log>.bak`, overwriting any previous backup. A missing log is the
132
+ * normal first-run state and is not an error.
133
+ */
134
+ rotate(logPath) {
135
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
136
+ if (!fs.existsSync(logPath))
137
+ return;
138
+ fs.rmSync(this.backupPathFor(logPath), { force: true });
139
+ fs.renameSync(logPath, this.backupPathFor(logPath));
140
+ }
84
141
  /**
85
- * Run `buildCommand` with its combined stdout+stderr streaming to the terminal AND appended in full to
86
- * `logPath`. Returns the BUILD's exit code (not tee's). Nothing is truncated.
142
+ * Run `buildCommand` with its stdout AND stderr redirected in full to `logPath`, printing a heartbeat
143
+ * to the console every HEARTBEAT_MS so the caller can see it is alive. Returns the BUILD's exit code.
144
+ * Nothing is truncated and nothing is streamed.
87
145
  */
88
- run(repoRoot, buildCommand, logPath) {
89
- const statusFile = `${logPath}${STATUS_SUFFIX}`;
90
- fs.rmSync(statusFile, { force: true });
91
- const result = (0, child_process_1.spawnSync)(this.wrap(buildCommand, logPath, statusFile), { stdio: 'inherit', cwd: repoRoot, shell: true });
92
- const fromFile = this.readStatus(statusFile);
93
- fs.rmSync(statusFile, { force: true });
94
- if (fromFile !== null)
95
- return fromFile;
96
- // The side file never appeared ⇒ the shell died before the echo. The PIPELINE's status is tee's,
97
- // which is 0, and reporting 0 there would call a dead build green — so fail CLOSED instead.
98
- return result.status !== null && result.status !== 0 ? result.status : 1;
146
+ async run(repoRoot, buildCommand, logPath) {
147
+ this.rotate(logPath);
148
+ const fd = fs.openSync(logPath, 'w');
149
+ const heartbeat = new BuildLogHeartbeat(logPath, this.displayPath(repoRoot, logPath));
150
+ const timer = setInterval(() => { process.stdout.write(`${heartbeat.tick()}\n`); }, exports.HEARTBEAT_MS);
151
+ // webpieces-disable no-unmanaged-exceptions -- chokepoint: the timer and the fd MUST be released
152
+ // whatever the child does, and the exit code is returned rather than thrown so runBuildGate owns
153
+ // the one CliExitError.
154
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
155
+ try {
156
+ return await this.awaitExit((0, child_process_1.spawn)(buildCommand, { cwd: repoRoot, shell: true, stdio: ['ignore', fd, fd] }), fd);
157
+ }
158
+ finally {
159
+ clearInterval(timer);
160
+ fs.closeSync(fd);
161
+ }
99
162
  }
100
163
  /**
101
- * The ENTIRE message the AI gets on a captured failure. Deliberately tiny: the whole point is that the
102
- * agent reads ONE file rather than carrying a build transcript in its context.
164
+ * The success summary: the caller is told WHERE the full output is, not handed the output.
165
+ */
166
+ successMessage(logPath) {
167
+ return `\nBuild success\n${this.logPointer(logPath)}`;
168
+ }
169
+ /**
170
+ * The ENTIRE message the caller gets on a failed build. It names the log, echoes the last
171
+ * FAILURE_TAIL_LINES lines so the immediate cause needs no second command, and forbids the rebuild
172
+ * this whole file exists to prevent.
103
173
  *
104
174
  * The last sentence is not filler. If the log holds no visible failure then something upstream is wrong
105
- * (a runner that died without printing, a truncated pipe), and the worst possible response is an agent
106
- * guessing or rebuilding — so it is told to surface the contradiction to the human and stop.
175
+ * (a runner that died without printing, a truncated redirect), and the worst possible response is an
176
+ * agent guessing or rebuilding — so it is told to surface the contradiction to the human and stop.
107
177
  */
108
178
  failureMessage(buildCommand, logPath) {
109
- return `\n❌ The CI build failed. We ran\n\n` +
110
- ` ${buildCommand} > ${logPath}\n\n` +
111
- `and it failed, so read that file for the failures. Do NOT re-run the build to see them.\n` +
179
+ return `\nBuild Failed: ${buildCommand}\n${this.logPointer(logPath)}\n` +
180
+ `Last ${exports.FAILURE_TAIL_LINES} lines of that log:\n${this.tail(logPath)}\n` +
181
+ `Read that FILE for the failures. Do NOT re-run the build to see them.\n` +
112
182
  `If you do not see failures in that log, report that to the user and stop.\n`;
113
183
  }
184
+ // The two lines that name the log, identical on success and failure so there is one thing to recognise.
185
+ // The backup line says what is TRUE RIGHT NOW: on the very first build in a tree there is no `.bak`
186
+ // yet, and pointing a reader at a file that does not exist is the small lie that costs a wasted `cat`.
187
+ logPointer(logPath) {
188
+ const name = path.basename(logPath);
189
+ const backedUp = fs.existsSync(this.backupPathFor(logPath))
190
+ ? `(${name} is backed up to ${name}${BACKUP_SUFFIX} every run so you have the last 2 builds of logs)`
191
+ : `(the previous ${name} is kept as ${name}${BACKUP_SUFFIX} on every run — this is the first, so there is none yet)`;
192
+ return `FullLog : ${logPath}\n${backedUp}\n`;
193
+ }
114
194
  /**
115
- * `{ ( <build> ) ; echo $? > <status> ; } 2>&1 | tee <log>` — written across LINES so a build command
116
- * ending in a `#` comment cannot swallow what follows it.
195
+ * The log's last FAILURE_TAIL_LINES lines, or a plain statement of why there are none.
117
196
  *
118
- * The inner `( )` is load-bearing, not decoration. The left side of a pipeline is already a subshell,
119
- * so a build command containing a plain `exit 7` (or any script that calls `exit`) would terminate that
120
- * subshell OUTRIGHT `echo $?` never runs, the side file never appears, and the only status left is
121
- * tee's 0. The nested subshell absorbs the `exit`, so `$?` is the build's code every time.
197
+ * A read that fails is REPORTED, never allowed to throw: this renders the message for a build that has
198
+ * ALREADY failed, so an I/O error escaping here would replace the real failure with the renderer's own
199
+ * — the caller would lose the build error and be handed a filesystem error instead. The full log is
200
+ * still named on the line above, so nothing is hidden by degrading to one line.
122
201
  */
123
- wrap(buildCommand, logPath, statusFile) {
124
- return `{\n(\n${buildCommand}\n)\necho $? > ${this.quote(statusFile)}\n} 2>&1 | tee ${this.quote(logPath)}`;
125
- }
126
- // The build's own exit code, or null when the side file never appeared (the shell died before the echo).
127
- readStatus(statusFile) {
128
- if (!fs.existsSync(statusFile))
129
- return null;
130
- const parsed = Number.parseInt(fs.readFileSync(statusFile, 'utf8').trim(), 10);
131
- return Number.isNaN(parsed) ? null : parsed;
132
- }
133
- // POSIX single-quoting: everything is literal inside '…', and a literal ' is spelled '\''.
134
- quote(value) {
135
- return `'${value.split("'").join(`'\\''`)}'`;
202
+ tail(logPath) {
203
+ if (!fs.existsSync(logPath))
204
+ return ` (no log file at ${logPath})\n`;
205
+ // webpieces-disable no-unmanaged-exceptions -- chokepoint: see above, the failure renderer may not
206
+ // replace the build's failure with its own.
207
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
208
+ try {
209
+ const lines = fs.readFileSync(logPath, 'utf8').split('\n').filter((l) => l !== '');
210
+ if (lines.length === 0)
211
+ return ' (the log is empty)\n';
212
+ return lines.slice(-exports.FAILURE_TAIL_LINES).map((l) => ` ${l}\n`).join('');
213
+ }
214
+ catch (err) {
215
+ const error = (0, rules_config_1.toError)(err);
216
+ return ` (could not read ${logPath}: ${error.message})\n`;
217
+ }
218
+ }
219
+ // Resolve, and wait for, the child's exit code. A spawn that never starts (a shell that is missing, a
220
+ // cwd that vanished) fails CLOSED to 1 — calling a build that never ran green is the one outcome that
221
+ // must be impossible — and the reason is APPENDED TO THE LOG, so the failure message's pointer still
222
+ // leads to it rather than to an empty file.
223
+ awaitExit(child, fd) {
224
+ return new Promise((resolve) => {
225
+ child.on('error', (err) => {
226
+ fs.writeSync(fd, `\nThe build command could not be started: ${err.message}\n`);
227
+ resolve(1);
228
+ });
229
+ child.on('close', (code) => { resolve(code ?? 1); });
230
+ });
231
+ }
232
+ // The path as the heartbeat shows it: relative to the repo when it sits inside it (a linked worktree's
233
+ // state lives under the PRIMARY clone, so it often does not), absolute otherwise.
234
+ displayPath(repoRoot, logPath) {
235
+ const relative = path.relative(repoRoot, logPath);
236
+ return relative === '' || relative.startsWith('..') || path.isAbsolute(relative) ? logPath : relative;
237
+ }
238
+ resolvePath(repoRoot, stage) {
239
+ const name = this.fileNameFor(repoRoot, stage);
240
+ // `wp-build`'s log sits at the ROOT of the state dir, not under `logs/`, because it is the one log
241
+ // path a person types from memory. Everything else keeps the per-commit names in `logs/`.
242
+ return stage === exports.BUILD_STAGE ? rules_config_1.dotWebpieces.localFile(repoRoot, name) : rules_config_1.dotWebpieces.logsFile(repoRoot, name);
136
243
  }
137
244
  // Anything that is not a filename-safe character becomes '-', so `dean/feat` cannot create directories.
138
245
  slug(value) {