@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.
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.StageOutputLog = exports.FINISH_CONSOLE_LOG = exports.REVIEW_CONSOLE_LOG = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const fs = tslib_1.__importStar(require("fs"));
6
+ const inversify_1 = require("inversify");
7
+ const gate_log_file_1 = require("./gate-log-file");
8
+ // ─── Why ───────────────────────────────────────────────────────────────────────────────────────────────
9
+ // `wp-build` already solved this for the BUILD: its output goes to a FILE and the console gets a heartbeat
10
+ // plus a pointer. Stage ② and stage ③ had not been given the same treatment, so they printed everything —
11
+ // merge validation, the active-hatch report, the checklist scan, the diff extraction, the dashboard, the
12
+ // gh round trips — straight down the terminal.
13
+ //
14
+ // The consequence was measured, and it is not "a long transcript". An agent that expects a wall of output
15
+ // bounds it by reflex: `pnpm wp-review-upsert-pr 2>&1 | tail -50`. A PIPE WITHHOLDS EVERY BYTE UNTIL THE
16
+ // COMMAND EXITS, so the harness sees a command that has printed nothing for 600 seconds and kills it — a
17
+ // full build's worth of work thrown away, and then usually re-run the same way. In this repo's primary
18
+ // tree alone the call log holds 85 piped `wp-*` invocations, 42 of them on a command that runs a build
19
+ // (`wp-review-upsert-pr` 22, `wp-finish-upsert-pr` 17, `wp-build` 3).
20
+ //
21
+ // Shrinking the output is therefore the FIRST half of the fix and the guard against the pipe is the
22
+ // second, in that order. Blocking the pipe while the command still printed a thousand lines would only
23
+ // have traded a watchdog kill for a flooded context, and an agent would have routed around it with `>`.
24
+ //
25
+ // ─── Why it INTERCEPTS stdout instead of every writer calling it ───────────────────────────────────────
26
+ // There are ~160 `process.stdout.write` sites across this package, in classes shared by six other bins.
27
+ // Threading a logger through all of them would be a far larger diff with far more ways to miss one — and
28
+ // a MISSED one is the whole failure, because a single verbose writer that still reaches the console keeps
29
+ // the output big enough to be piped. Interception inverts the default: everything a stage prints is
30
+ // captured unless it says out loud that it must be seen NOW, and the "say it out loud" list is short
31
+ // enough to read (`say()` — the build heartbeat, the build result, the review instructions, the PR link).
32
+ //
33
+ // The interception is installed and removed by `withCapture`, in a `finally`, so a throw cannot leave a
34
+ // process with a patched stdout. Child processes that inherit fd 1 write straight past it, which is why
35
+ // the build gate redirects its child to a file of its own rather than relying on this.
36
+ //
37
+ // Also deliberately NOT intercepted: stderr. Failures must stay on the terminal — `runMain` renders every
38
+ // thrown CliExitError / RuleFailError there, and a refusal an agent cannot see is worse than a long one.
39
+ /** Stage ②'s console log. Named for the command, because that is what a reader is looking for. */
40
+ exports.REVIEW_CONSOLE_LOG = 'wp-review-upsert-pr.log';
41
+ /** Stage ③'s console log. */
42
+ exports.FINISH_CONSOLE_LOG = 'wp-finish-upsert-pr.log';
43
+ /**
44
+ * Captures a stage's verbose console output to a file, keeps the handful of lines the caller must act on
45
+ * immediately on the terminal, and hands back the one-line pointer at the file.
46
+ *
47
+ * Singleton, and stateful FOR THE DURATION OF ONE `withCapture` call: a bin runs exactly one stage.
48
+ * Re-entering it is a programming error, not a supported mode, and it throws rather than silently
49
+ * capturing into the inner file and restoring the outer one.
50
+ */
51
+ let StageOutputLog = class StageOutputLog {
52
+ files;
53
+ constructor(files) {
54
+ this.files = files;
55
+ }
56
+ // The open log, or '' when nothing is being captured. `say` works either way — outside a capture it
57
+ // is a plain write to the terminal, which is what every other bin wants.
58
+ logPath = '';
59
+ fd = -1;
60
+ original = null;
61
+ /**
62
+ * Run `body` with this process's stdout captured to `<logs>/<fileName>`, then restore stdout and
63
+ * print the pointer at the file.
64
+ *
65
+ * The pointer is printed in a `finally`, so it is there on the FAILURE path too — which is the path
66
+ * where a reader most needs it, and the one where an uncaptured stage would have scrolled the cause
67
+ * off the top of the terminal.
68
+ */
69
+ async withCapture(repoRoot, fileName, body) {
70
+ if (this.original !== null)
71
+ throw new Error(`StageOutputLog is already capturing to ${this.logPath}`);
72
+ this.logPath = this.files.logsPath(repoRoot, fileName);
73
+ this.files.rotate(this.logPath);
74
+ this.fd = fs.openSync(this.logPath, 'w');
75
+ // The REFERENCE, not a bound copy: `release` puts this exact function back, so a caller that
76
+ // installed its own stdout before us (a spec, a wrapping bin) gets ITS function back rather than
77
+ // a wrapper around it, and repeated captures cannot stack bound layers.
78
+ this.original = process.stdout.write;
79
+ process.stdout.write = this.intercept.bind(this);
80
+ // webpieces-disable no-unmanaged-exceptions -- chokepoint: stdout MUST be restored and the fd
81
+ // closed whatever `body` does; the throw is re-raised untouched.
82
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
83
+ try {
84
+ return await body();
85
+ }
86
+ finally {
87
+ this.release();
88
+ }
89
+ }
90
+ /**
91
+ * Print `text` to the TERMINAL as well as the log — for the few things a caller must act on before it
92
+ * can do anything else: the build heartbeat (a silent command is a killed command), the build result,
93
+ * the review instructions, the PR link, and anything naming a file to read.
94
+ *
95
+ * Outside a capture this is an ordinary write, so a class that calls it is not coupled to the stage.
96
+ */
97
+ say(text) {
98
+ this.appendToLog(text);
99
+ // `.call`, because `original` is the raw reference rather than a bound copy — see withCapture.
100
+ (this.original ?? process.stdout.write).call(process.stdout, text);
101
+ }
102
+ // The stdout replacement. Node calls this with (chunk), (chunk, encoding), (chunk, callback) or
103
+ // (chunk, encoding, callback); a callback that is never invoked can stall a writer, so it is always
104
+ // called. Returning true means "not backpressured", which is honest for a synchronous file write.
105
+ intercept(chunk, encodingOrCallback, callback) {
106
+ this.appendToLog(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
107
+ const done = typeof encodingOrCallback === 'function' ? encodingOrCallback : callback;
108
+ if (done !== undefined)
109
+ done();
110
+ return true;
111
+ }
112
+ // Restore stdout, close the log, and print the pointer at it. Idempotent: a second call after the
113
+ // `finally` has already run is a no-op rather than a double pointer.
114
+ release() {
115
+ if (this.original === null)
116
+ return;
117
+ const restore = this.original;
118
+ this.original = null;
119
+ process.stdout.write = restore;
120
+ fs.closeSync(this.fd);
121
+ this.fd = -1;
122
+ restore.call(process.stdout, `\n${this.files.pointer(this.logPath)}`);
123
+ this.logPath = '';
124
+ }
125
+ // Nothing is captured when no capture is open — `say` still reaches the terminal, which is the point.
126
+ appendToLog(text) {
127
+ if (this.fd === -1)
128
+ return;
129
+ fs.writeSync(this.fd, text);
130
+ }
131
+ };
132
+ exports.StageOutputLog = StageOutputLog;
133
+ exports.StageOutputLog = StageOutputLog = tslib_1.__decorate([
134
+ (0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
135
+ tslib_1.__metadata("design:paramtypes", [gate_log_file_1.GateLogFile])
136
+ ], StageOutputLog);
137
+ //# sourceMappingURL=stage-output-log.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stage-output-log.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/stage-output-log.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { GateLogFile } from './gate-log-file';\n\n// ─── Why ───────────────────────────────────────────────────────────────────────────────────────────────\n// `wp-build` already solved this for the BUILD: its output goes to a FILE and the console gets a heartbeat\n// plus a pointer. Stage ② and stage ③ had not been given the same treatment, so they printed everything —\n// merge validation, the active-hatch report, the checklist scan, the diff extraction, the dashboard, the\n// gh round trips — straight down the terminal.\n//\n// The consequence was measured, and it is not \"a long transcript\". An agent that expects a wall of output\n// bounds it by reflex: `pnpm wp-review-upsert-pr 2>&1 | tail -50`. A PIPE WITHHOLDS EVERY BYTE UNTIL THE\n// COMMAND EXITS, so the harness sees a command that has printed nothing for 600 seconds and kills it — a\n// full build's worth of work thrown away, and then usually re-run the same way. In this repo's primary\n// tree alone the call log holds 85 piped `wp-*` invocations, 42 of them on a command that runs a build\n// (`wp-review-upsert-pr` 22, `wp-finish-upsert-pr` 17, `wp-build` 3).\n//\n// Shrinking the output is therefore the FIRST half of the fix and the guard against the pipe is the\n// second, in that order. Blocking the pipe while the command still printed a thousand lines would only\n// have traded a watchdog kill for a flooded context, and an agent would have routed around it with `>`.\n//\n// ─── Why it INTERCEPTS stdout instead of every writer calling it ───────────────────────────────────────\n// There are ~160 `process.stdout.write` sites across this package, in classes shared by six other bins.\n// Threading a logger through all of them would be a far larger diff with far more ways to miss one — and\n// a MISSED one is the whole failure, because a single verbose writer that still reaches the console keeps\n// the output big enough to be piped. Interception inverts the default: everything a stage prints is\n// captured unless it says out loud that it must be seen NOW, and the \"say it out loud\" list is short\n// enough to read (`say()` — the build heartbeat, the build result, the review instructions, the PR link).\n//\n// The interception is installed and removed by `withCapture`, in a `finally`, so a throw cannot leave a\n// process with a patched stdout. Child processes that inherit fd 1 write straight past it, which is why\n// the build gate redirects its child to a file of its own rather than relying on this.\n//\n// Also deliberately NOT intercepted: stderr. Failures must stay on the terminal — `runMain` renders every\n// thrown CliExitError / RuleFailError there, and a refusal an agent cannot see is worse than a long one.\n\n/** Stage ②'s console log. Named for the command, because that is what a reader is looking for. */\nexport const REVIEW_CONSOLE_LOG = 'wp-review-upsert-pr.log';\n\n/** Stage ③'s console log. */\nexport const FINISH_CONSOLE_LOG = 'wp-finish-upsert-pr.log';\n\n/** The write function this class swaps out and restores — Node's own `process.stdout.write`. */\ntype StdoutWrite = typeof process.stdout.write;\n\n/** The optional completion callback Node's `write` accepts, in either of its two argument positions. */\ntype StdoutCallback = (err?: Error) => void;\n\n/**\n * Captures a stage's verbose console output to a file, keeps the handful of lines the caller must act on\n * immediately on the terminal, and hands back the one-line pointer at the file.\n *\n * Singleton, and stateful FOR THE DURATION OF ONE `withCapture` call: a bin runs exactly one stage.\n * Re-entering it is a programming error, not a supported mode, and it throws rather than silently\n * capturing into the inner file and restoring the outer one.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class StageOutputLog {\n constructor(private readonly files: GateLogFile) {}\n\n // The open log, or '' when nothing is being captured. `say` works either way — outside a capture it\n // is a plain write to the terminal, which is what every other bin wants.\n private logPath = '';\n private fd = -1;\n private original: StdoutWrite | null = null;\n\n /**\n * Run `body` with this process's stdout captured to `<logs>/<fileName>`, then restore stdout and\n * print the pointer at the file.\n *\n * The pointer is printed in a `finally`, so it is there on the FAILURE path too — which is the path\n * where a reader most needs it, and the one where an uncaptured stage would have scrolled the cause\n * off the top of the terminal.\n */\n async withCapture<T>(repoRoot: string, fileName: string, body: () => Promise<T>): Promise<T> {\n if (this.original !== null) throw new Error(`StageOutputLog is already capturing to ${this.logPath}`);\n this.logPath = this.files.logsPath(repoRoot, fileName);\n this.files.rotate(this.logPath);\n this.fd = fs.openSync(this.logPath, 'w');\n // The REFERENCE, not a bound copy: `release` puts this exact function back, so a caller that\n // installed its own stdout before us (a spec, a wrapping bin) gets ITS function back rather than\n // a wrapper around it, and repeated captures cannot stack bound layers.\n this.original = process.stdout.write;\n process.stdout.write = this.intercept.bind(this) as StdoutWrite;\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: stdout MUST be restored and the fd\n // closed whatever `body` does; the throw is re-raised untouched.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return await body();\n } finally {\n this.release();\n }\n }\n\n /**\n * Print `text` to the TERMINAL as well as the log — for the few things a caller must act on before it\n * can do anything else: the build heartbeat (a silent command is a killed command), the build result,\n * the review instructions, the PR link, and anything naming a file to read.\n *\n * Outside a capture this is an ordinary write, so a class that calls it is not coupled to the stage.\n */\n say(text: string): void {\n this.appendToLog(text);\n // `.call`, because `original` is the raw reference rather than a bound copy — see withCapture.\n (this.original ?? process.stdout.write).call(process.stdout, text);\n }\n\n // The stdout replacement. Node calls this with (chunk), (chunk, encoding), (chunk, callback) or\n // (chunk, encoding, callback); a callback that is never invoked can stall a writer, so it is always\n // called. Returning true means \"not backpressured\", which is honest for a synchronous file write.\n private intercept(\n chunk: string | Uint8Array, encodingOrCallback?: BufferEncoding | StdoutCallback, callback?: StdoutCallback,\n ): boolean {\n this.appendToLog(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));\n const done = typeof encodingOrCallback === 'function' ? encodingOrCallback : callback;\n if (done !== undefined) done();\n return true;\n }\n\n // Restore stdout, close the log, and print the pointer at it. Idempotent: a second call after the\n // `finally` has already run is a no-op rather than a double pointer.\n private release(): void {\n if (this.original === null) return;\n const restore = this.original;\n this.original = null;\n process.stdout.write = restore;\n fs.closeSync(this.fd);\n this.fd = -1;\n restore.call(process.stdout, `\\n${this.files.pointer(this.logPath)}`);\n this.logPath = '';\n }\n\n // Nothing is captured when no capture is open — `say` still reaches the terminal, which is the point.\n private appendToLog(text: string): void {\n if (this.fd === -1) return;\n fs.writeSync(this.fd, text);\n }\n}\n"]}