@webpieces/pr-gate 0.4.692 → 0.4.693

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.
@@ -4,9 +4,9 @@ exports.BuildGateLog = exports.BuildLogHeartbeat = exports.BUILD_LOG_NAME = expo
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
- const path = tslib_1.__importStar(require("path"));
8
- const rules_config_1 = require("@webpieces/rules-config");
9
7
  const inversify_1 = require("inversify");
8
+ const gate_log_file_1 = require("./gate-log-file");
9
+ const stage_output_log_1 = require("./stage-output-log");
10
10
  // ─── Why ───────────────────────────────────────────────────────────────────────────────────────────────
11
11
  // The build gate already builds everything. When the output only ever went to the CONSOLE, an agent that
12
12
  // wanted a different slice of it re-ran the WHOLE BUILD to get it: one measured session spent 23.9 minutes
@@ -28,6 +28,10 @@ const inversify_1 = require("inversify");
28
28
  // `spawnSync` blocks the event loop for the length of the build, so NOTHING can print while it runs — and
29
29
  // a silent terminal for 3–7 minutes is indistinguishable from a hang. The heartbeat is the reason this is
30
30
  // async, and it is why `run` returns a Promise and `BuildAffected.runBuildGate` is async with it.
31
+ //
32
+ // It is also the reason the heartbeat goes through `StageOutputLog.say` rather than `process.stdout` —
33
+ // stage ② and stage ③ capture their own console output to a file, and a heartbeat captured into a file is
34
+ // a heartbeat nobody can see. The 600-second watchdog that kills a silent command does not read files.
31
35
  /** How often the heartbeat reports the log's size. Hardcoded: a knob here would be a knob the PR gate's
32
36
  * own build never receives, and the two must stay the same command. */
33
37
  exports.HEARTBEAT_MS = 10_000;
@@ -44,7 +48,6 @@ exports.FINISH_STAGE = 'finish';
44
48
  exports.BUILD_STAGE = 'build';
45
49
  /** The one fixed log name — see BuildGateLog.fileNameFor for why only `wp-build` gets one. */
46
50
  exports.BUILD_LOG_NAME = 'build.log';
47
- const BACKUP_SUFFIX = '.bak';
48
51
  /**
49
52
  * The heartbeat's state: the line count reported on the PREVIOUS tick, so a tick that has not moved can
50
53
  * say so. Stateful per RUN, which is why it is constructed per run rather than injected.
@@ -54,49 +57,39 @@ const BACKUP_SUFFIX = '.bak';
54
57
  * BUILD from a stalled REPORTER.
55
58
  */
56
59
  class BuildLogHeartbeat {
60
+ files;
57
61
  logPath;
58
62
  displayPath;
59
63
  previous = null;
60
- constructor(logPath, displayPath) {
64
+ constructor(files, logPath, displayPath) {
65
+ this.files = files;
61
66
  this.logPath = logPath;
62
67
  this.displayPath = displayPath;
63
68
  }
64
69
  /** One heartbeat line — `<path> size <n> lines`, plus ` still` when <n> has not moved. */
65
70
  tick() {
66
- const count = this.lineCount();
71
+ const count = this.files.lineCount(this.logPath);
67
72
  const still = this.previous !== null && count === this.previous ? ' still' : '';
68
73
  this.previous = count;
69
74
  return `${this.displayPath} size ${count} lines${still}`;
70
75
  }
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
76
  }
82
77
  exports.BuildLogHeartbeat = BuildLogHeartbeat;
83
78
  /**
84
79
  * Captures the build gate's full output to a file, reports progress while it runs, and renders the
85
80
  * pointer the caller is handed instead of a rebuild instruction.
86
81
  *
82
+ * WHERE the file goes, how the previous run is kept, and what the pointer reads like are `GateLogFile`'s
83
+ * — one mechanism, shared with the stage console log, so there is a single answer to "where is it?".
84
+ *
87
85
  * ─── Two naming schemes, one rule each ─────────────────────────────────────────────────────────────────
88
86
  * • `wp-build` (BUILD_STAGE) writes ONE fixed path, `.webpieces/build.log`. It is fixed because a HUMAN
89
87
  * 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.
88
+ * name carrying a branch and a sha is not. History comes from the rotation instead.
91
89
  * • stage ② and stage ③ write `logs/build-gate-<stage>-<branch>-<shortSha>.log`, because those two
92
90
  * gates CAN run against one commit and a failure message from one must not be pointing at a file the
93
91
  * other overwrote. Nobody types those names; the failure message prints them.
94
92
  *
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.
99
- *
100
93
  * ─── Concurrency ───────────────────────────────────────────────────────────────────────────────────────
101
94
  * The DIRECTORY is `dotWebpieces.local()`-scoped — `<primary>/.webpieces/worktrees/<git worktree name>/`
102
95
  * in a linked worktree — so "N agents in N worktrees" is safe by construction rather than by naming. The
@@ -104,13 +97,17 @@ exports.BuildLogHeartbeat = BuildLogHeartbeat;
104
97
  * driving one git index.
105
98
  */
106
99
  let BuildGateLog = class BuildGateLog {
100
+ files;
101
+ stageConsole;
102
+ constructor(files, stageConsole) {
103
+ this.files = files;
104
+ this.stageConsole = stageConsole;
105
+ }
107
106
  /** Absolute path of the log for `stage` at the current HEAD, creating its directory. */
108
107
  pathFor(repoRoot, stage) {
109
- const file = this.resolvePath(repoRoot, stage);
110
- fs.mkdirSync(path.dirname(file), { recursive: true });
111
- return file;
108
+ return this.resolvePath(repoRoot, stage);
112
109
  }
113
- /** The same path WITHOUT creating anything, and '' when no such log exists. Used by finish's skip path. */
110
+ /** The same path, and '' when no log has been WRITTEN there yet. Used by finish's skip path. */
114
111
  existingLogFor(repoRoot, stage) {
115
112
  const file = this.resolvePath(repoRoot, stage);
116
113
  return fs.existsSync(file) ? file : '';
@@ -123,31 +120,16 @@ let BuildGateLog = class BuildGateLog {
123
120
  const sha = this.slug(this.git(repoRoot, ['rev-parse', '--short', 'HEAD']));
124
121
  return `build-gate-${this.slug(stage)}-${branch === '' ? 'nobranch' : branch}-${sha === '' ? 'nosha' : sha}.log`;
125
122
  }
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
- }
141
123
  /**
142
124
  * 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.
125
+ * every HEARTBEAT_MS so the caller can see it is alive. Returns the BUILD's exit code. Nothing is
126
+ * truncated and nothing is streamed.
145
127
  */
146
128
  async run(repoRoot, buildCommand, logPath) {
147
- this.rotate(logPath);
129
+ this.files.rotate(logPath);
148
130
  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);
131
+ const heartbeat = new BuildLogHeartbeat(this.files, logPath, this.files.displayPath(repoRoot, logPath));
132
+ const timer = setInterval(() => { this.stageConsole.say(`${heartbeat.tick()}\n`); }, exports.HEARTBEAT_MS);
151
133
  // webpieces-disable no-unmanaged-exceptions -- chokepoint: the timer and the fd MUST be released
152
134
  // whatever the child does, and the exit code is returned rather than thrown so runBuildGate owns
153
135
  // the one CliExitError.
@@ -164,7 +146,7 @@ let BuildGateLog = class BuildGateLog {
164
146
  * The success summary: the caller is told WHERE the full output is, not handed the output.
165
147
  */
166
148
  successMessage(logPath) {
167
- return `\nBuild success\n${this.logPointer(logPath)}`;
149
+ return `\nBuild success\n${this.files.pointer(logPath)}`;
168
150
  }
169
151
  /**
170
152
  * The ENTIRE message the caller gets on a failed build. It names the log, echoes the last
@@ -176,46 +158,11 @@ let BuildGateLog = class BuildGateLog {
176
158
  * agent guessing or rebuilding — so it is told to surface the contradiction to the human and stop.
177
159
  */
178
160
  failureMessage(buildCommand, logPath) {
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` +
161
+ return `\nBuild Failed: ${buildCommand}\n${this.files.pointer(logPath)}\n` +
162
+ `Last ${exports.FAILURE_TAIL_LINES} lines of that log:\n${this.files.tail(logPath, exports.FAILURE_TAIL_LINES)}\n` +
181
163
  `Read that FILE for the failures. Do NOT re-run the build to see them.\n` +
182
164
  `If you do not see failures in that log, report that to the user and stop.\n`;
183
165
  }
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
- }
194
- /**
195
- * The log's last FAILURE_TAIL_LINES lines, or a plain statement of why there are none.
196
- *
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.
201
- */
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
166
  // Resolve, and wait for, the child's exit code. A spawn that never starts (a shell that is missing, a
220
167
  // cwd that vanished) fails CLOSED to 1 — calling a build that never ran green is the one outcome that
221
168
  // must be impossible — and the reason is APPENDED TO THE LOG, so the failure message's pointer still
@@ -229,17 +176,11 @@ let BuildGateLog = class BuildGateLog {
229
176
  child.on('close', (code) => { resolve(code ?? 1); });
230
177
  });
231
178
  }
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
179
  resolvePath(repoRoot, stage) {
239
180
  const name = this.fileNameFor(repoRoot, stage);
240
181
  // `wp-build`'s log sits at the ROOT of the state dir, not under `logs/`, because it is the one log
241
182
  // 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);
183
+ return stage === exports.BUILD_STAGE ? this.files.localPath(repoRoot, name) : this.files.logsPath(repoRoot, name);
243
184
  }
244
185
  // Anything that is not a filename-safe character becomes '-', so `dean/feat` cannot create directories.
245
186
  slug(value) {
@@ -256,6 +197,8 @@ let BuildGateLog = class BuildGateLog {
256
197
  };
257
198
  exports.BuildGateLog = BuildGateLog;
258
199
  exports.BuildGateLog = BuildGateLog = tslib_1.__decorate([
259
- (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
200
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
201
+ tslib_1.__metadata("design:paramtypes", [gate_log_file_1.GateLogFile,
202
+ stage_output_log_1.StageOutputLog])
260
203
  ], BuildGateLog);
261
204
  //# sourceMappingURL=build-gate-log.js.map
@@ -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 { spawn, spawnSync, ChildProcess } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { dotWebpieces, toError } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\n// ─── Why ───────────────────────────────────────────────────────────────────────────────────────────────\n// The build gate already builds everything. When the output only ever went to the CONSOLE, an agent that\n// wanted a different slice of it re-ran the WHOLE BUILD to get it: one measured session spent 23.9 minutes\n// across nine `nx affected` runs, five of them with NO code change in between — `| tail -50`, then\n// `> /tmp/file`, then `| grep`, then `| sed -n '1100,1230p'`. ~19 minutes spent re-reading a log.\n//\n// So the build's output is not streamed; it is REDIRECTED, in full, to a file whose path the caller is\n// handed on completion. Reading a different slice is then a `grep` of a FILE, not a second build.\n//\n// ─── Why a redirect and not `tee` ──────────────────────────────────────────────────────────────────────\n// An earlier cut of this used `cmd 2>&1 | tee log`, to keep the terminal byte-identical. That is what\n// makes the transcript expensive in the first place — an AI caller carries every line of it in context.\n// The redirect keeps the console to a handful of lines (a heartbeat, then a pointer at the file), which is\n// the entire productivity claim. Losing `tee` also deletes the `$?`-into-a-side-file dance it needed: in a\n// pipeline the shell reports TEE's status, which is 0 whether the build passed or failed, so the status\n// had to be smuggled out through a side file. With no pipe, the child's own exit code IS the answer.\n//\n// ─── Why async (`spawn`, not `spawnSync`) ──────────────────────────────────────────────────────────────\n// `spawnSync` blocks the event loop for the length of the build, so NOTHING can print while it runs — and\n// a silent terminal for 3–7 minutes is indistinguishable from a hang. The heartbeat is the reason this is\n// async, and it is why `run` returns a Promise and `BuildAffected.runBuildGate` is async with it.\n\n/** How often the heartbeat reports the log's size. Hardcoded: a knob here would be a knob the PR gate's\n * own build never receives, and the two must stay the same command. */\nexport const HEARTBEAT_MS = 10_000;\n\n/** How many trailing log lines the failure message echoes, so the immediate cause is visible without a\n * second command. Small on purpose — the FULL log is one grep away and the message must not become the\n * transcript it exists to replace. */\nexport const FAILURE_TAIL_LINES = 20;\n\n/**\n * Which stage's gate is being captured. The value decides the log FILENAME.\n */\nexport const REVIEW_STAGE = 'review';\nexport const FINISH_STAGE = 'finish';\n// `wp-build`, which is not a stage of the PR flow but runs the SAME gate (BuildAffected.runBuildGate).\nexport const BUILD_STAGE = 'build';\n\n/** The one fixed log name — see BuildGateLog.fileNameFor for why only `wp-build` gets one. */\nexport const BUILD_LOG_NAME = 'build.log';\nconst BACKUP_SUFFIX = '.bak';\n\n/**\n * The heartbeat's state: the line count reported on the PREVIOUS tick, so a tick that has not moved can\n * say so. Stateful per RUN, which is why it is constructed per run rather than injected.\n *\n * `still` is the load-bearing word. A build that is linking, or waiting on a cold nx cache, produces no\n * output for minutes; without `still` the caller sees the same number twice and cannot tell a stalled\n * BUILD from a stalled REPORTER.\n */\nexport class BuildLogHeartbeat {\n private previous: number | null = null;\n\n constructor(private readonly logPath: string, private readonly displayPath: string) {}\n\n /** One heartbeat line — `<path> size <n> lines`, plus ` still` when <n> has not moved. */\n tick(): string {\n const count = this.lineCount();\n const still = this.previous !== null && count === this.previous ? ' still' : '';\n this.previous = count;\n return `${this.displayPath} size ${count} lines${still}`;\n }\n\n // Lines currently in the log. A log that does not exist yet is zero lines, not an error: the build may\n // simply not have written its first byte, and a heartbeat may never be the reason a build stops.\n private lineCount(): number {\n if (!fs.existsSync(this.logPath)) return 0;\n const body = fs.readFileSync(this.logPath, 'utf8');\n if (body === '') return 0;\n return body.split('\\n').length - (body.endsWith('\\n') ? 1 : 0);\n }\n}\n\n/**\n * Captures the build gate's full output to a file, reports progress while it runs, and renders the\n * pointer the caller is handed instead of a rebuild instruction.\n *\n * ─── Two naming schemes, one rule each ─────────────────────────────────────────────────────────────────\n * • `wp-build` (BUILD_STAGE) writes ONE fixed path, `.webpieces/build.log`. It is fixed because a HUMAN\n * OR AN AGENT TYPES IT — `grep -n error .webpieces/build.log` has to be writable from memory, and a\n * name carrying a branch and a sha is not. History comes from the rotation below instead.\n * • stage ② and stage ③ write `logs/build-gate-<stage>-<branch>-<shortSha>.log`, because those two\n * gates CAN run against one commit and a failure message from one must not be pointing at a file the\n * other overwrote. Nobody types those names; the failure message prints them.\n *\n * ─── Rotation, everywhere ──────────────────────────────────────────────────────────────────────────────\n * Every run moves an existing log to `<log>.bak` before writing, so the last TWO runs are always on disk.\n * One rule for every stage: no branch, and the previous run of a re-run at the same commit survives\n * instead of being truncated away.\n *\n * ─── Concurrency ───────────────────────────────────────────────────────────────────────────────────────\n * The DIRECTORY is `dotWebpieces.local()`-scoped — `<primary>/.webpieces/worktrees/<git worktree name>/`\n * in a linked worktree — so \"N agents in N worktrees\" is safe by construction rather than by naming. The\n * residual case is two builds in the SAME worktree at once, which is already unsupported: both would be\n * driving one git index.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class BuildGateLog {\n /** Absolute path of the log for `stage` at the current HEAD, creating its directory. */\n pathFor(repoRoot: string, stage: string): string {\n const file = this.resolvePath(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 = this.resolvePath(repoRoot, stage);\n return fs.existsSync(file) ? file : '';\n }\n\n /** `build.log` for `wp-build`; `build-gate-<stage>-<branch>-<shortSha>.log` for the PR-flow stages. */\n fileNameFor(repoRoot: string, stage: string): string {\n if (stage === BUILD_STAGE) return BUILD_LOG_NAME;\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 /** Where the PREVIOUS run of `logPath` is kept — always `<logPath>.bak`. */\n backupPathFor(logPath: string): string {\n return `${logPath}${BACKUP_SUFFIX}`;\n }\n\n /**\n * Move an existing log aside to `<log>.bak`, overwriting any previous backup. A missing log is the\n * normal first-run state and is not an error.\n */\n rotate(logPath: string): void {\n fs.mkdirSync(path.dirname(logPath), { recursive: true });\n if (!fs.existsSync(logPath)) return;\n fs.rmSync(this.backupPathFor(logPath), { force: true });\n fs.renameSync(logPath, this.backupPathFor(logPath));\n }\n\n /**\n * Run `buildCommand` with its stdout AND stderr redirected in full to `logPath`, printing a heartbeat\n * to the console every HEARTBEAT_MS so the caller can see it is alive. Returns the BUILD's exit code.\n * Nothing is truncated and nothing is streamed.\n */\n async run(repoRoot: string, buildCommand: string, logPath: string): Promise<number> {\n this.rotate(logPath);\n const fd = fs.openSync(logPath, 'w');\n const heartbeat = new BuildLogHeartbeat(logPath, this.displayPath(repoRoot, logPath));\n const timer = setInterval((): void => { process.stdout.write(`${heartbeat.tick()}\\n`); }, HEARTBEAT_MS);\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: the timer and the fd MUST be released\n // whatever the child does, and the exit code is returned rather than thrown so runBuildGate owns\n // the one CliExitError.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return await this.awaitExit(spawn(buildCommand, { cwd: repoRoot, shell: true, stdio: ['ignore', fd, fd] }), fd);\n } finally {\n clearInterval(timer);\n fs.closeSync(fd);\n }\n }\n\n /**\n * The success summary: the caller is told WHERE the full output is, not handed the output.\n */\n successMessage(logPath: string): string {\n return `\\nBuild success\\n${this.logPointer(logPath)}`;\n }\n\n /**\n * The ENTIRE message the caller gets on a failed build. It names the log, echoes the last\n * FAILURE_TAIL_LINES lines so the immediate cause needs no second command, and forbids the rebuild\n * this whole file exists to prevent.\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 redirect), and the worst possible response is an\n * agent 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 `\\nBuild Failed: ${buildCommand}\\n${this.logPointer(logPath)}\\n` +\n `Last ${FAILURE_TAIL_LINES} lines of that log:\\n${this.tail(logPath)}\\n` +\n `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 // The two lines that name the log, identical on success and failure so there is one thing to recognise.\n // The backup line says what is TRUE RIGHT NOW: on the very first build in a tree there is no `.bak`\n // yet, and pointing a reader at a file that does not exist is the small lie that costs a wasted `cat`.\n private logPointer(logPath: string): string {\n const name = path.basename(logPath);\n const backedUp = fs.existsSync(this.backupPathFor(logPath))\n ? `(${name} is backed up to ${name}${BACKUP_SUFFIX} every run so you have the last 2 builds of logs)`\n : `(the previous ${name} is kept as ${name}${BACKUP_SUFFIX} on every run — this is the first, so there is none yet)`;\n return `FullLog : ${logPath}\\n${backedUp}\\n`;\n }\n\n /**\n * The log's last FAILURE_TAIL_LINES lines, or a plain statement of why there are none.\n *\n * A read that fails is REPORTED, never allowed to throw: this renders the message for a build that has\n * ALREADY failed, so an I/O error escaping here would replace the real failure with the renderer's own\n * — the caller would lose the build error and be handed a filesystem error instead. The full log is\n * still named on the line above, so nothing is hidden by degrading to one line.\n */\n private tail(logPath: string): string {\n if (!fs.existsSync(logPath)) return ` (no log file at ${logPath})\\n`;\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: see above, the failure renderer may not\n // replace the build's failure with its own.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const lines = fs.readFileSync(logPath, 'utf8').split('\\n').filter((l: string): boolean => l !== '');\n if (lines.length === 0) return ' (the log is empty)\\n';\n return lines.slice(-FAILURE_TAIL_LINES).map((l: string): string => ` ${l}\\n`).join('');\n } catch (err: unknown) {\n const error = toError(err);\n return ` (could not read ${logPath}: ${error.message})\\n`;\n }\n }\n\n // Resolve, and wait for, the child's exit code. A spawn that never starts (a shell that is missing, a\n // cwd that vanished) fails CLOSED to 1 — calling a build that never ran green is the one outcome that\n // must be impossible — and the reason is APPENDED TO THE LOG, so the failure message's pointer still\n // leads to it rather than to an empty file.\n private awaitExit(child: ChildProcess, fd: number): Promise<number> {\n return new Promise<number>((resolve: (code: number) => void): void => {\n child.on('error', (err: Error): void => {\n fs.writeSync(fd, `\\nThe build command could not be started: ${err.message}\\n`);\n resolve(1);\n });\n child.on('close', (code: number | null): void => { resolve(code ?? 1); });\n });\n }\n\n // The path as the heartbeat shows it: relative to the repo when it sits inside it (a linked worktree's\n // state lives under the PRIMARY clone, so it often does not), absolute otherwise.\n private displayPath(repoRoot: string, logPath: string): string {\n const relative = path.relative(repoRoot, logPath);\n return relative === '' || relative.startsWith('..') || path.isAbsolute(relative) ? logPath : relative;\n }\n\n private resolvePath(repoRoot: string, stage: string): string {\n const name = this.fileNameFor(repoRoot, stage);\n // `wp-build`'s log sits at the ROOT of the state dir, not under `logs/`, because it is the one log\n // path a person types from memory. Everything else keeps the per-commit names in `logs/`.\n return stage === BUILD_STAGE ? dotWebpieces.localFile(repoRoot, name) : dotWebpieces.logsFile(repoRoot, name);\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 { spawn, spawnSync, ChildProcess } from 'child_process';\nimport * as fs from 'fs';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { GateLogFile } from './gate-log-file';\nimport { StageOutputLog } from './stage-output-log';\n\n// ─── Why ───────────────────────────────────────────────────────────────────────────────────────────────\n// The build gate already builds everything. When the output only ever went to the CONSOLE, an agent that\n// wanted a different slice of it re-ran the WHOLE BUILD to get it: one measured session spent 23.9 minutes\n// across nine `nx affected` runs, five of them with NO code change in between — `| tail -50`, then\n// `> /tmp/file`, then `| grep`, then `| sed -n '1100,1230p'`. ~19 minutes spent re-reading a log.\n//\n// So the build's output is not streamed; it is REDIRECTED, in full, to a file whose path the caller is\n// handed on completion. Reading a different slice is then a `grep` of a FILE, not a second build.\n//\n// ─── Why a redirect and not `tee` ──────────────────────────────────────────────────────────────────────\n// An earlier cut of this used `cmd 2>&1 | tee log`, to keep the terminal byte-identical. That is what\n// makes the transcript expensive in the first place — an AI caller carries every line of it in context.\n// The redirect keeps the console to a handful of lines (a heartbeat, then a pointer at the file), which is\n// the entire productivity claim. Losing `tee` also deletes the `$?`-into-a-side-file dance it needed: in a\n// pipeline the shell reports TEE's status, which is 0 whether the build passed or failed, so the status\n// had to be smuggled out through a side file. With no pipe, the child's own exit code IS the answer.\n//\n// ─── Why async (`spawn`, not `spawnSync`) ──────────────────────────────────────────────────────────────\n// `spawnSync` blocks the event loop for the length of the build, so NOTHING can print while it runs — and\n// a silent terminal for 3–7 minutes is indistinguishable from a hang. The heartbeat is the reason this is\n// async, and it is why `run` returns a Promise and `BuildAffected.runBuildGate` is async with it.\n//\n// It is also the reason the heartbeat goes through `StageOutputLog.say` rather than `process.stdout` —\n// stage ② and stage ③ capture their own console output to a file, and a heartbeat captured into a file is\n// a heartbeat nobody can see. The 600-second watchdog that kills a silent command does not read files.\n\n/** How often the heartbeat reports the log's size. Hardcoded: a knob here would be a knob the PR gate's\n * own build never receives, and the two must stay the same command. */\nexport const HEARTBEAT_MS = 10_000;\n\n/** How many trailing log lines the failure message echoes, so the immediate cause is visible without a\n * second command. Small on purpose — the FULL log is one grep away and the message must not become the\n * transcript it exists to replace. */\nexport const FAILURE_TAIL_LINES = 20;\n\n/**\n * Which stage's gate is being captured. The value decides the log FILENAME.\n */\nexport const REVIEW_STAGE = 'review';\nexport const FINISH_STAGE = 'finish';\n// `wp-build`, which is not a stage of the PR flow but runs the SAME gate (BuildAffected.runBuildGate).\nexport const BUILD_STAGE = 'build';\n\n/** The one fixed log name — see BuildGateLog.fileNameFor for why only `wp-build` gets one. */\nexport const BUILD_LOG_NAME = 'build.log';\n\n/**\n * The heartbeat's state: the line count reported on the PREVIOUS tick, so a tick that has not moved can\n * say so. Stateful per RUN, which is why it is constructed per run rather than injected.\n *\n * `still` is the load-bearing word. A build that is linking, or waiting on a cold nx cache, produces no\n * output for minutes; without `still` the caller sees the same number twice and cannot tell a stalled\n * BUILD from a stalled REPORTER.\n */\nexport class BuildLogHeartbeat {\n private previous: number | null = null;\n\n constructor(\n private readonly files: GateLogFile,\n private readonly logPath: string,\n private readonly displayPath: string,\n ) {}\n\n /** One heartbeat line — `<path> size <n> lines`, plus ` still` when <n> has not moved. */\n tick(): string {\n const count = this.files.lineCount(this.logPath);\n const still = this.previous !== null && count === this.previous ? ' still' : '';\n this.previous = count;\n return `${this.displayPath} size ${count} lines${still}`;\n }\n}\n\n/**\n * Captures the build gate's full output to a file, reports progress while it runs, and renders the\n * pointer the caller is handed instead of a rebuild instruction.\n *\n * WHERE the file goes, how the previous run is kept, and what the pointer reads like are `GateLogFile`'s\n * — one mechanism, shared with the stage console log, so there is a single answer to \"where is it?\".\n *\n * ─── Two naming schemes, one rule each ─────────────────────────────────────────────────────────────────\n * • `wp-build` (BUILD_STAGE) writes ONE fixed path, `.webpieces/build.log`. It is fixed because a HUMAN\n * OR AN AGENT TYPES IT — `grep -n error .webpieces/build.log` has to be writable from memory, and a\n * name carrying a branch and a sha is not. History comes from the rotation instead.\n * • stage ② and stage ③ write `logs/build-gate-<stage>-<branch>-<shortSha>.log`, because those two\n * gates CAN run against one commit and a failure message from one must not be pointing at a file the\n * other overwrote. Nobody types those names; the failure message prints them.\n *\n * ─── Concurrency ───────────────────────────────────────────────────────────────────────────────────────\n * The DIRECTORY is `dotWebpieces.local()`-scoped — `<primary>/.webpieces/worktrees/<git worktree name>/`\n * in a linked worktree — so \"N agents in N worktrees\" is safe by construction rather than by naming. The\n * residual case is two builds in the SAME worktree at once, which is already unsupported: both would be\n * driving one git index.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class BuildGateLog {\n constructor(\n private readonly files: GateLogFile,\n private readonly stageConsole: StageOutputLog,\n ) {}\n\n /** Absolute path of the log for `stage` at the current HEAD, creating its directory. */\n pathFor(repoRoot: string, stage: string): string {\n return this.resolvePath(repoRoot, stage);\n }\n\n /** The same path, and '' when no log has been WRITTEN there yet. Used by finish's skip path. */\n existingLogFor(repoRoot: string, stage: string): string {\n const file = this.resolvePath(repoRoot, stage);\n return fs.existsSync(file) ? file : '';\n }\n\n /** `build.log` for `wp-build`; `build-gate-<stage>-<branch>-<shortSha>.log` for the PR-flow stages. */\n fileNameFor(repoRoot: string, stage: string): string {\n if (stage === BUILD_STAGE) return BUILD_LOG_NAME;\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 stdout AND stderr redirected in full to `logPath`, printing a heartbeat\n * every HEARTBEAT_MS so the caller can see it is alive. Returns the BUILD's exit code. Nothing is\n * truncated and nothing is streamed.\n */\n async run(repoRoot: string, buildCommand: string, logPath: string): Promise<number> {\n this.files.rotate(logPath);\n const fd = fs.openSync(logPath, 'w');\n const heartbeat = new BuildLogHeartbeat(this.files, logPath, this.files.displayPath(repoRoot, logPath));\n const timer = setInterval((): void => { this.stageConsole.say(`${heartbeat.tick()}\\n`); }, HEARTBEAT_MS);\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: the timer and the fd MUST be released\n // whatever the child does, and the exit code is returned rather than thrown so runBuildGate owns\n // the one CliExitError.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return await this.awaitExit(spawn(buildCommand, { cwd: repoRoot, shell: true, stdio: ['ignore', fd, fd] }), fd);\n } finally {\n clearInterval(timer);\n fs.closeSync(fd);\n }\n }\n\n /**\n * The success summary: the caller is told WHERE the full output is, not handed the output.\n */\n successMessage(logPath: string): string {\n return `\\nBuild success\\n${this.files.pointer(logPath)}`;\n }\n\n /**\n * The ENTIRE message the caller gets on a failed build. It names the log, echoes the last\n * FAILURE_TAIL_LINES lines so the immediate cause needs no second command, and forbids the rebuild\n * this whole file exists to prevent.\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 redirect), and the worst possible response is an\n * agent 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 `\\nBuild Failed: ${buildCommand}\\n${this.files.pointer(logPath)}\\n` +\n `Last ${FAILURE_TAIL_LINES} lines of that log:\\n${this.files.tail(logPath, FAILURE_TAIL_LINES)}\\n` +\n `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 // Resolve, and wait for, the child's exit code. A spawn that never starts (a shell that is missing, a\n // cwd that vanished) fails CLOSED to 1 — calling a build that never ran green is the one outcome that\n // must be impossible — and the reason is APPENDED TO THE LOG, so the failure message's pointer still\n // leads to it rather than to an empty file.\n private awaitExit(child: ChildProcess, fd: number): Promise<number> {\n return new Promise<number>((resolve: (code: number) => void): void => {\n child.on('error', (err: Error): void => {\n fs.writeSync(fd, `\\nThe build command could not be started: ${err.message}\\n`);\n resolve(1);\n });\n child.on('close', (code: number | null): void => { resolve(code ?? 1); });\n });\n }\n\n private resolvePath(repoRoot: string, stage: string): string {\n const name = this.fileNameFor(repoRoot, stage);\n // `wp-build`'s log sits at the ROOT of the state dir, not under `logs/`, because it is the one log\n // path a person types from memory. Everything else keeps the per-commit names in `logs/`.\n return stage === BUILD_STAGE ? this.files.localPath(repoRoot, name) : this.files.logsPath(repoRoot, name);\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"]}
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The file mechanics shared by every captured log in the PR gate: WHERE it goes, keeping the previous
3
+ * run, the pointer the caller prints instead of the output, and the failure tail.
4
+ *
5
+ * It knows nothing about builds, stages or console interception — its callers own all of that. Singleton
6
+ * because it is pure behaviour over paths; it holds no state.
7
+ */
8
+ export declare class GateLogFile {
9
+ /**
10
+ * `<local()>/logs/<name>` — the ONE log directory, per worktree namespace. Creates the directory.
11
+ * Use for anything a MESSAGE points at; nobody types these names.
12
+ */
13
+ logsPath(repoRoot: string, name: string): string;
14
+ /**
15
+ * `<local()>/<name>` — the ROOT of the state dir, for the one log a PERSON types from memory
16
+ * (`.webpieces/build.log`). Creates the directory. Reach for `logsPath` unless the path itself has
17
+ * to be memorable.
18
+ */
19
+ localPath(repoRoot: string, name: string): string;
20
+ /** Where the PREVIOUS run of `logPath` is kept — always `<logPath>.bak`. */
21
+ backupPathFor(logPath: string): string;
22
+ /**
23
+ * Move an existing log aside to `<log>.bak`, overwriting any previous backup, so the last TWO runs
24
+ * are always on disk. A missing log is the normal first-run state and is not an error.
25
+ *
26
+ * ONE generation, everywhere: a re-run at the same commit does not truncate away the run before it,
27
+ * and a reader never has to work out which of N dated files is the one they want.
28
+ */
29
+ rotate(logPath: string): void;
30
+ /**
31
+ * The two lines that name a log — identical on success and on failure, so there is exactly one shape
32
+ * to recognise.
33
+ *
34
+ * The backup line states what is TRUE RIGHT NOW: on the very first run in a tree there is no `.bak`
35
+ * yet, and pointing a reader at a file that does not exist is the small lie that costs a wasted `cat`.
36
+ */
37
+ pointer(logPath: string): string;
38
+ /**
39
+ * The path as a progress line should show it: relative to the repo when it sits inside it (a linked
40
+ * worktree's state lives under the PRIMARY clone, so it usually does not), absolute otherwise.
41
+ */
42
+ displayPath(repoRoot: string, logPath: string): string;
43
+ /**
44
+ * The log's last `lines` lines, indented, or a plain statement of why there are none.
45
+ *
46
+ * A read that fails is REPORTED, never allowed to throw: this renders a message for something that
47
+ * has ALREADY failed, so an I/O error escaping here would replace the real failure with the
48
+ * renderer's own — the caller would lose the actual error and be handed a filesystem error instead.
49
+ * The full log is still named on the line above, so nothing is hidden by degrading to one line.
50
+ */
51
+ tail(logPath: string, lines: number): string;
52
+ /** How many lines the log holds right now. A log that does not exist yet is zero lines, not an error. */
53
+ lineCount(logPath: string): number;
54
+ private ensureDir;
55
+ }
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GateLogFile = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const fs = tslib_1.__importStar(require("fs"));
6
+ const path = tslib_1.__importStar(require("path"));
7
+ const rules_config_1 = require("@webpieces/rules-config");
8
+ const inversify_1 = require("inversify");
9
+ // ─── Why this class exists ─────────────────────────────────────────────────────────────────────────────
10
+ // TWO things in this package write a big blob of output to a file and hand the caller a one-line pointer
11
+ // instead of the blob: the BUILD (BuildGateLog) and the STAGE CONSOLE (StageOutputLog). They are different
12
+ // subjects — one spawns a child and heartbeats, the other intercepts this process's own stdout — but the
13
+ // FILE half is identical: where the file lands, how the previous run is kept, and what the pointer at it
14
+ // reads like.
15
+ //
16
+ // That half lives here, once. Cloning it would have given the two logs two answers to "where is it?" and
17
+ // two rotation policies, and the whole value of the pointer is that a reader recognises it instantly and
18
+ // can `grep` it without first working out which subsystem wrote it.
19
+ //
20
+ // ─── WHERE the files land, and why that shape ──────────────────────────────────────────────────────────
21
+ // Through `dotWebpieces` — never `path.join(repoRoot, '.webpieces')`. In a LINKED WORKTREE `local()`
22
+ // resolves to `<primary>/.webpieces/worktrees/<git worktree name>/`, so:
23
+ // • N agents in N worktrees never write one another's log, by construction rather than by naming;
24
+ // • the history OUTLIVES the worktree — `wp-cleanup` reaps a tree's directory and the logs it wrote are
25
+ // still there under the primary clone, which is exactly when you want them;
26
+ // • it self-limits, because `.webpieces/worktrees/**` is already swept once a namespace goes stale.
27
+ // Nothing here needs a retention policy of its own.
28
+ /** The suffix of the ONE previous generation kept beside a log. */
29
+ const BACKUP_SUFFIX = '.bak';
30
+ /**
31
+ * The file mechanics shared by every captured log in the PR gate: WHERE it goes, keeping the previous
32
+ * run, the pointer the caller prints instead of the output, and the failure tail.
33
+ *
34
+ * It knows nothing about builds, stages or console interception — its callers own all of that. Singleton
35
+ * because it is pure behaviour over paths; it holds no state.
36
+ */
37
+ let GateLogFile = class GateLogFile {
38
+ /**
39
+ * `<local()>/logs/<name>` — the ONE log directory, per worktree namespace. Creates the directory.
40
+ * Use for anything a MESSAGE points at; nobody types these names.
41
+ */
42
+ logsPath(repoRoot, name) {
43
+ return this.ensureDir(rules_config_1.dotWebpieces.logsFile(repoRoot, name));
44
+ }
45
+ /**
46
+ * `<local()>/<name>` — the ROOT of the state dir, for the one log a PERSON types from memory
47
+ * (`.webpieces/build.log`). Creates the directory. Reach for `logsPath` unless the path itself has
48
+ * to be memorable.
49
+ */
50
+ localPath(repoRoot, name) {
51
+ return this.ensureDir(rules_config_1.dotWebpieces.localFile(repoRoot, name));
52
+ }
53
+ /** Where the PREVIOUS run of `logPath` is kept — always `<logPath>.bak`. */
54
+ backupPathFor(logPath) {
55
+ return `${logPath}${BACKUP_SUFFIX}`;
56
+ }
57
+ /**
58
+ * Move an existing log aside to `<log>.bak`, overwriting any previous backup, so the last TWO runs
59
+ * are always on disk. A missing log is the normal first-run state and is not an error.
60
+ *
61
+ * ONE generation, everywhere: a re-run at the same commit does not truncate away the run before it,
62
+ * and a reader never has to work out which of N dated files is the one they want.
63
+ */
64
+ rotate(logPath) {
65
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
66
+ if (!fs.existsSync(logPath))
67
+ return;
68
+ fs.rmSync(this.backupPathFor(logPath), { force: true });
69
+ fs.renameSync(logPath, this.backupPathFor(logPath));
70
+ }
71
+ /**
72
+ * The two lines that name a log — identical on success and on failure, so there is exactly one shape
73
+ * to recognise.
74
+ *
75
+ * The backup line states what is TRUE RIGHT NOW: on the very first run in a tree there is no `.bak`
76
+ * yet, and pointing a reader at a file that does not exist is the small lie that costs a wasted `cat`.
77
+ */
78
+ pointer(logPath) {
79
+ const name = path.basename(logPath);
80
+ const backedUp = fs.existsSync(this.backupPathFor(logPath))
81
+ ? `(${name} is backed up to ${name}${BACKUP_SUFFIX} every run so you have the last 2 runs of logs)`
82
+ : `(the previous ${name} is kept as ${name}${BACKUP_SUFFIX} on every run — this is the first, so there is none yet)`;
83
+ return `FullLog : ${logPath}\n${backedUp}\n`;
84
+ }
85
+ /**
86
+ * The path as a progress line should show it: relative to the repo when it sits inside it (a linked
87
+ * worktree's state lives under the PRIMARY clone, so it usually does not), absolute otherwise.
88
+ */
89
+ displayPath(repoRoot, logPath) {
90
+ const relative = path.relative(repoRoot, logPath);
91
+ return relative === '' || relative.startsWith('..') || path.isAbsolute(relative) ? logPath : relative;
92
+ }
93
+ /**
94
+ * The log's last `lines` lines, indented, or a plain statement of why there are none.
95
+ *
96
+ * A read that fails is REPORTED, never allowed to throw: this renders a message for something that
97
+ * has ALREADY failed, so an I/O error escaping here would replace the real failure with the
98
+ * renderer's own — the caller would lose the actual error and be handed a filesystem error instead.
99
+ * The full log is still named on the line above, so nothing is hidden by degrading to one line.
100
+ */
101
+ tail(logPath, lines) {
102
+ if (!fs.existsSync(logPath))
103
+ return ` (no log file at ${logPath})\n`;
104
+ // webpieces-disable no-unmanaged-exceptions -- chokepoint: see above, the failure renderer may not
105
+ // replace the caller's failure with its own.
106
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
107
+ try {
108
+ const all = fs.readFileSync(logPath, 'utf8').split('\n').filter((l) => l !== '');
109
+ if (all.length === 0)
110
+ return ' (the log is empty)\n';
111
+ return all.slice(-lines).map((l) => ` ${l}\n`).join('');
112
+ }
113
+ catch (err) {
114
+ const error = (0, rules_config_1.toError)(err);
115
+ return ` (could not read ${logPath}: ${error.message})\n`;
116
+ }
117
+ }
118
+ /** How many lines the log holds right now. A log that does not exist yet is zero lines, not an error. */
119
+ lineCount(logPath) {
120
+ if (!fs.existsSync(logPath))
121
+ return 0;
122
+ const body = fs.readFileSync(logPath, 'utf8');
123
+ if (body === '')
124
+ return 0;
125
+ return body.split('\n').length - (body.endsWith('\n') ? 1 : 0);
126
+ }
127
+ ensureDir(file) {
128
+ fs.mkdirSync(path.dirname(file), { recursive: true });
129
+ return file;
130
+ }
131
+ };
132
+ exports.GateLogFile = GateLogFile;
133
+ exports.GateLogFile = GateLogFile = tslib_1.__decorate([
134
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
135
+ ], GateLogFile);
136
+ //# sourceMappingURL=gate-log-file.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gate-log-file.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/gate-log-file.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport { dotWebpieces, toError } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\n// ─── Why this class exists ─────────────────────────────────────────────────────────────────────────────\n// TWO things in this package write a big blob of output to a file and hand the caller a one-line pointer\n// instead of the blob: the BUILD (BuildGateLog) and the STAGE CONSOLE (StageOutputLog). They are different\n// subjects — one spawns a child and heartbeats, the other intercepts this process's own stdout — but the\n// FILE half is identical: where the file lands, how the previous run is kept, and what the pointer at it\n// reads like.\n//\n// That half lives here, once. Cloning it would have given the two logs two answers to \"where is it?\" and\n// two rotation policies, and the whole value of the pointer is that a reader recognises it instantly and\n// can `grep` it without first working out which subsystem wrote it.\n//\n// ─── WHERE the files land, and why that shape ──────────────────────────────────────────────────────────\n// Through `dotWebpieces` — never `path.join(repoRoot, '.webpieces')`. In a LINKED WORKTREE `local()`\n// resolves to `<primary>/.webpieces/worktrees/<git worktree name>/`, so:\n// • N agents in N worktrees never write one another's log, by construction rather than by naming;\n// • the history OUTLIVES the worktree — `wp-cleanup` reaps a tree's directory and the logs it wrote are\n// still there under the primary clone, which is exactly when you want them;\n// • it self-limits, because `.webpieces/worktrees/**` is already swept once a namespace goes stale.\n// Nothing here needs a retention policy of its own.\n\n/** The suffix of the ONE previous generation kept beside a log. */\nconst BACKUP_SUFFIX = '.bak';\n\n/**\n * The file mechanics shared by every captured log in the PR gate: WHERE it goes, keeping the previous\n * run, the pointer the caller prints instead of the output, and the failure tail.\n *\n * It knows nothing about builds, stages or console interception — its callers own all of that. Singleton\n * because it is pure behaviour over paths; it holds no state.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class GateLogFile {\n /**\n * `<local()>/logs/<name>` — the ONE log directory, per worktree namespace. Creates the directory.\n * Use for anything a MESSAGE points at; nobody types these names.\n */\n logsPath(repoRoot: string, name: string): string {\n return this.ensureDir(dotWebpieces.logsFile(repoRoot, name));\n }\n\n /**\n * `<local()>/<name>` — the ROOT of the state dir, for the one log a PERSON types from memory\n * (`.webpieces/build.log`). Creates the directory. Reach for `logsPath` unless the path itself has\n * to be memorable.\n */\n localPath(repoRoot: string, name: string): string {\n return this.ensureDir(dotWebpieces.localFile(repoRoot, name));\n }\n\n /** Where the PREVIOUS run of `logPath` is kept — always `<logPath>.bak`. */\n backupPathFor(logPath: string): string {\n return `${logPath}${BACKUP_SUFFIX}`;\n }\n\n /**\n * Move an existing log aside to `<log>.bak`, overwriting any previous backup, so the last TWO runs\n * are always on disk. A missing log is the normal first-run state and is not an error.\n *\n * ONE generation, everywhere: a re-run at the same commit does not truncate away the run before it,\n * and a reader never has to work out which of N dated files is the one they want.\n */\n rotate(logPath: string): void {\n fs.mkdirSync(path.dirname(logPath), { recursive: true });\n if (!fs.existsSync(logPath)) return;\n fs.rmSync(this.backupPathFor(logPath), { force: true });\n fs.renameSync(logPath, this.backupPathFor(logPath));\n }\n\n /**\n * The two lines that name a log — identical on success and on failure, so there is exactly one shape\n * to recognise.\n *\n * The backup line states what is TRUE RIGHT NOW: on the very first run in a tree there is no `.bak`\n * yet, and pointing a reader at a file that does not exist is the small lie that costs a wasted `cat`.\n */\n pointer(logPath: string): string {\n const name = path.basename(logPath);\n const backedUp = fs.existsSync(this.backupPathFor(logPath))\n ? `(${name} is backed up to ${name}${BACKUP_SUFFIX} every run so you have the last 2 runs of logs)`\n : `(the previous ${name} is kept as ${name}${BACKUP_SUFFIX} on every run — this is the first, so there is none yet)`;\n return `FullLog : ${logPath}\\n${backedUp}\\n`;\n }\n\n /**\n * The path as a progress line should show it: relative to the repo when it sits inside it (a linked\n * worktree's state lives under the PRIMARY clone, so it usually does not), absolute otherwise.\n */\n displayPath(repoRoot: string, logPath: string): string {\n const relative = path.relative(repoRoot, logPath);\n return relative === '' || relative.startsWith('..') || path.isAbsolute(relative) ? logPath : relative;\n }\n\n /**\n * The log's last `lines` lines, indented, or a plain statement of why there are none.\n *\n * A read that fails is REPORTED, never allowed to throw: this renders a message for something that\n * has ALREADY failed, so an I/O error escaping here would replace the real failure with the\n * renderer's own — the caller would lose the actual error and be handed a filesystem error instead.\n * The full log is still named on the line above, so nothing is hidden by degrading to one line.\n */\n tail(logPath: string, lines: number): string {\n if (!fs.existsSync(logPath)) return ` (no log file at ${logPath})\\n`;\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: see above, the failure renderer may not\n // replace the caller's failure with its own.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const all = fs.readFileSync(logPath, 'utf8').split('\\n').filter((l: string): boolean => l !== '');\n if (all.length === 0) return ' (the log is empty)\\n';\n return all.slice(-lines).map((l: string): string => ` ${l}\\n`).join('');\n } catch (err: unknown) {\n const error = toError(err);\n return ` (could not read ${logPath}: ${error.message})\\n`;\n }\n }\n\n /** How many lines the log holds right now. A log that does not exist yet is zero lines, not an error. */\n lineCount(logPath: string): number {\n if (!fs.existsSync(logPath)) return 0;\n const body = fs.readFileSync(logPath, 'utf8');\n if (body === '') return 0;\n return body.split('\\n').length - (body.endsWith('\\n') ? 1 : 0);\n }\n\n private ensureDir(file: string): string {\n fs.mkdirSync(path.dirname(file), { recursive: true });\n return file;\n }\n}\n"]}
@@ -0,0 +1,40 @@
1
+ import { GateLogFile } from './gate-log-file';
2
+ /** Stage ②'s console log. Named for the command, because that is what a reader is looking for. */
3
+ export declare const REVIEW_CONSOLE_LOG = "wp-review-upsert-pr.log";
4
+ /** Stage ③'s console log. */
5
+ export declare const FINISH_CONSOLE_LOG = "wp-finish-upsert-pr.log";
6
+ /**
7
+ * Captures a stage's verbose console output to a file, keeps the handful of lines the caller must act on
8
+ * immediately on the terminal, and hands back the one-line pointer at the file.
9
+ *
10
+ * Singleton, and stateful FOR THE DURATION OF ONE `withCapture` call: a bin runs exactly one stage.
11
+ * Re-entering it is a programming error, not a supported mode, and it throws rather than silently
12
+ * capturing into the inner file and restoring the outer one.
13
+ */
14
+ export declare class StageOutputLog {
15
+ private readonly files;
16
+ constructor(files: GateLogFile);
17
+ private logPath;
18
+ private fd;
19
+ private original;
20
+ /**
21
+ * Run `body` with this process's stdout captured to `<logs>/<fileName>`, then restore stdout and
22
+ * print the pointer at the file.
23
+ *
24
+ * The pointer is printed in a `finally`, so it is there on the FAILURE path too — which is the path
25
+ * where a reader most needs it, and the one where an uncaptured stage would have scrolled the cause
26
+ * off the top of the terminal.
27
+ */
28
+ withCapture<T>(repoRoot: string, fileName: string, body: () => Promise<T>): Promise<T>;
29
+ /**
30
+ * Print `text` to the TERMINAL as well as the log — for the few things a caller must act on before it
31
+ * can do anything else: the build heartbeat (a silent command is a killed command), the build result,
32
+ * the review instructions, the PR link, and anything naming a file to read.
33
+ *
34
+ * Outside a capture this is an ordinary write, so a class that calls it is not coupled to the stage.
35
+ */
36
+ say(text: string): void;
37
+ private intercept;
38
+ private release;
39
+ private appendToLog;
40
+ }