verifika 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +26 -12
- package/dist/cli.js +4 -0
- package/dist/init.js +39 -1
- package/dist/logs.js +58 -17
- package/dist/main.js +96 -22
- package/dist/pager.js +92 -0
- package/dist/planner.js +20 -7
- package/dist/preview.js +66 -7
- package/dist/process.js +9 -2
- package/dist/reporters/agent.js +5 -8
- package/dist/reporters/human.js +71 -21
- package/dist/reporters/live.js +209 -0
- package/dist/reporters/plan.js +4 -2
- package/dist/reporters/reporter.js +25 -1
- package/dist/runner.js +14 -1
- package/dist/runs.js +39 -0
- package/package.json +3 -3
- package/skills/verifika/SKILL.md +19 -4
package/dist/planner.js
CHANGED
|
@@ -138,24 +138,37 @@ function decideByPaths(check, files) {
|
|
|
138
138
|
matchedFiles: matched.slice(0, MAX_MATCHED_FILES),
|
|
139
139
|
};
|
|
140
140
|
}
|
|
141
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* Adds dependencies of selected checks, recording every check that requires
|
|
143
|
+
* each one, then emits decisions in config order.
|
|
144
|
+
*/
|
|
142
145
|
function finalize(config, decisions, skipReason) {
|
|
143
146
|
const byName = new Map(config.checks.map((check) => [check.name, check]));
|
|
147
|
+
const requiredBy = new Map();
|
|
144
148
|
const pending = [...decisions.keys()];
|
|
145
149
|
while (pending.length > 0) {
|
|
146
150
|
const name = pending.shift();
|
|
147
151
|
for (const dependency of byName.get(name)?.dependsOn ?? []) {
|
|
152
|
+
// Selected on its own merits: its own reason says why.
|
|
153
|
+
if (decisions.has(dependency) && !requiredBy.has(dependency))
|
|
154
|
+
continue;
|
|
155
|
+
requiredBy.set(dependency, (requiredBy.get(dependency) ?? new Set()).add(name));
|
|
148
156
|
if (decisions.has(dependency))
|
|
149
157
|
continue;
|
|
150
|
-
decisions.set(dependency, {
|
|
151
|
-
name: dependency,
|
|
152
|
-
selected: true,
|
|
153
|
-
reason: `required by ${name}`,
|
|
154
|
-
matchedFiles: [],
|
|
155
|
-
});
|
|
158
|
+
decisions.set(dependency, { name: dependency, selected: true, reason: "", matchedFiles: [] });
|
|
156
159
|
pending.push(dependency);
|
|
157
160
|
}
|
|
158
161
|
}
|
|
162
|
+
for (const [dependency, dependents] of requiredBy) {
|
|
163
|
+
const names = config.checks.map((check) => check.name).filter((name) => dependents.has(name));
|
|
164
|
+
decisions.set(dependency, {
|
|
165
|
+
name: dependency,
|
|
166
|
+
selected: true,
|
|
167
|
+
reason: `required by ${names.join(", ")}`,
|
|
168
|
+
matchedFiles: [],
|
|
169
|
+
requiredBy: names,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
159
172
|
return config.checks.map((check) => decisions.get(check.name) ?? {
|
|
160
173
|
name: check.name,
|
|
161
174
|
selected: false,
|
package/dist/preview.js
CHANGED
|
@@ -6,6 +6,8 @@ const ANSI =
|
|
|
6
6
|
const REDRAW = /\u001b\[\d*A|\u001b\[2K/;
|
|
7
7
|
/** Markers test runners and compilers print for the failure itself. Case-sensitive on purpose. */
|
|
8
8
|
const DEFINITE_FAILURE = /\bFAILED\b|^\s*FAIL\b|\bAssertionError\b|^\s*Expected\b|\berror TS\d+|\[ERROR\]|^\s*[✗✖×]|\bTraceback\b|\bpanicked\b/;
|
|
9
|
+
/** ESLint's stylish format: ` 1:9 error 'x' is assigned a value but never used no-unused-vars`. */
|
|
10
|
+
const ESLINT_ERROR = /^\s+\d+:\d+\s+error\s/;
|
|
9
11
|
/** Exception lines; passing tests that exercise error paths print these too. */
|
|
10
12
|
const EXCEPTION = /\b\w*Error:/;
|
|
11
13
|
/** Lines that often relate to a failure but also appear in passing output. */
|
|
@@ -16,6 +18,13 @@ const PROGRESS = /\b\d+\s*(?:of|\/)\s*\d+\b|\d+(?:\.\d+)?%/;
|
|
|
16
18
|
const JSON_LINE = /^\s*[[{]"/;
|
|
17
19
|
/** Package-manager wrapper noise that repeats the exit code without saying what failed. */
|
|
18
20
|
const WRAPPER_NOISE = /^(?:npm (?:error|ERR!|warn|notice)|\s*ELIFECYCLE\b|error Command failed with exit code|info Visit https:\/\/yarnpkg)/;
|
|
21
|
+
/**
|
|
22
|
+
* First line of a package-manager script banner: npm and pnpm print `> pkg@1.2.3 script`
|
|
23
|
+
* (pnpm adds the directory), yarn 1 prints `yarn run v1.22.19`. The next line repeats the command.
|
|
24
|
+
*/
|
|
25
|
+
const SCRIPT_BANNER = /^(?:> \S*@\S+ \S|yarn run v\d)/;
|
|
26
|
+
/** The command line that follows a banner: `> eslint .` (npm, pnpm) or `$ eslint .` (yarn). */
|
|
27
|
+
const BANNER_COMMAND = /^[>$] \S/;
|
|
19
28
|
const MAX_LINE_CHARS = 200;
|
|
20
29
|
/** Partial lines are truncated beyond this, so one giant line cannot grow memory. */
|
|
21
30
|
const MAX_PENDING_CHARS = 4_096;
|
|
@@ -25,10 +34,12 @@ export function stripAnsi(text) {
|
|
|
25
34
|
/**
|
|
26
35
|
* Keeps a bounded excerpt of command output for failure previews. In order of
|
|
27
36
|
* preference it returns lines that clearly mark a failure (FAIL/FAILED,
|
|
28
|
-
* assertion and compiler errors) with the line after each,
|
|
37
|
+
* assertion and compiler errors) with the line after each, and ESLint errors
|
|
38
|
+
* under their file heading; then exception lines
|
|
29
39
|
* (`TypeError: ...`), then lines that merely mention errors, then the last lines
|
|
30
40
|
* of output.
|
|
31
41
|
*
|
|
42
|
+
* Package-manager script banners (`> pkg@1.2.3 lint` / `> eslint .`) are dropped.
|
|
32
43
|
* Progress output is collapsed: terminal redraws and progress counters rewritten
|
|
33
44
|
* on new lines replace the previous line. Memory use is O(limit) regardless of
|
|
34
45
|
* how much the command prints.
|
|
@@ -44,9 +55,12 @@ export class OutputCapture {
|
|
|
44
55
|
lastShape = null;
|
|
45
56
|
/** Most recent unindented, non-matching line, e.g. the file name ESLint prints above its errors. */
|
|
46
57
|
header = null;
|
|
47
|
-
|
|
58
|
+
/** Lists that already hold the current header, so each group shows it once. */
|
|
59
|
+
headerShownIn = new Set();
|
|
48
60
|
/** Whether the next indented line continues a failure marker (e.g. the message under a FAILED test). */
|
|
49
61
|
continueDefinite = false;
|
|
62
|
+
/** Whether the previous line opened a package-manager script banner. */
|
|
63
|
+
inBanner = false;
|
|
50
64
|
constructor(limit) {
|
|
51
65
|
this.limit = Math.max(0, limit);
|
|
52
66
|
}
|
|
@@ -78,6 +92,10 @@ export class OutputCapture {
|
|
|
78
92
|
const line = stripAnsi(content.slice(content.lastIndexOf("\r") + 1)).trimEnd();
|
|
79
93
|
if (line.trim() === "" || WRAPPER_NOISE.test(line))
|
|
80
94
|
return;
|
|
95
|
+
const banner = SCRIPT_BANNER.test(line) || (this.inBanner && BANNER_COMMAND.test(line));
|
|
96
|
+
this.inBanner = banner && SCRIPT_BANNER.test(line);
|
|
97
|
+
if (banner)
|
|
98
|
+
return;
|
|
81
99
|
const text = line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS - 1)}…` : line;
|
|
82
100
|
// Progress counters ("Executed 12 of 756", "40%") rewritten on new lines are one line;
|
|
83
101
|
// a final progress line often adds a suffix ("… (16.5 secs)").
|
|
@@ -93,10 +111,16 @@ export class OutputCapture {
|
|
|
93
111
|
const entry = { seq: this.seq, text, progressKey };
|
|
94
112
|
const indented = /^\s/.test(line);
|
|
95
113
|
const isJson = JSON_LINE.test(line);
|
|
96
|
-
const
|
|
114
|
+
const eslint = ESLINT_ERROR.test(line);
|
|
115
|
+
const definite = !isJson && (eslint || DEFINITE_FAILURE.test(line));
|
|
97
116
|
const exception = !isJson && !definite && EXCEPTION.test(line);
|
|
98
117
|
const weak = !isJson && !definite && !exception && WEAK_FAILURE.test(line);
|
|
99
|
-
if (
|
|
118
|
+
if (eslint) {
|
|
119
|
+
this.pushWithHeader(this.definite, entry, indented);
|
|
120
|
+
// The line after an ESLint error is the next problem, possibly a warning: not a continuation.
|
|
121
|
+
this.continueDefinite = false;
|
|
122
|
+
}
|
|
123
|
+
else if (definite || (this.continueDefinite && indented && !/^\s*at\s/.test(line))) {
|
|
100
124
|
push(this.definite, entry, this.limit, false);
|
|
101
125
|
this.continueDefinite = definite;
|
|
102
126
|
}
|
|
@@ -109,16 +133,16 @@ export class OutputCapture {
|
|
|
109
133
|
}
|
|
110
134
|
if (!indented && !definite && !exception && !weak && !isJson) {
|
|
111
135
|
this.header = entry;
|
|
112
|
-
this.
|
|
136
|
+
this.headerShownIn.clear();
|
|
113
137
|
}
|
|
114
138
|
push(this.tail, entry, this.limit, true);
|
|
115
139
|
}
|
|
116
140
|
/** An indented failure usually belongs to the heading above it; keep that context once. */
|
|
117
141
|
pushWithHeader(list, entry, indented) {
|
|
118
|
-
if (indented && this.header && !this.
|
|
142
|
+
if (indented && this.header && !this.headerShownIn.has(list) && list.length + 2 <= this.limit) {
|
|
119
143
|
push(list, this.header, this.limit, false);
|
|
120
144
|
}
|
|
121
|
-
this.
|
|
145
|
+
this.headerShownIn.add(list);
|
|
122
146
|
push(list, entry, this.limit, false);
|
|
123
147
|
}
|
|
124
148
|
replacePrevious() {
|
|
@@ -145,3 +169,38 @@ function push(list, entry, limit, rolling) {
|
|
|
145
169
|
list.push(entry);
|
|
146
170
|
}
|
|
147
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Follows a stream of output and reports its latest non-empty line, including a
|
|
174
|
+
* line still being written (progress bars redraw with `\r` and may never end
|
|
175
|
+
* one). Reports only changes, ANSI-stripped. Memory is bounded like OutputCapture.
|
|
176
|
+
*/
|
|
177
|
+
export class LastLine {
|
|
178
|
+
report;
|
|
179
|
+
pending = "";
|
|
180
|
+
last = "";
|
|
181
|
+
constructor(report) {
|
|
182
|
+
this.report = report;
|
|
183
|
+
}
|
|
184
|
+
write(text) {
|
|
185
|
+
const buffer = this.pending + text;
|
|
186
|
+
const lines = buffer.split("\n");
|
|
187
|
+
const rest = lines.pop() ?? "";
|
|
188
|
+
this.pending = rest.length > MAX_PENDING_CHARS ? rest.slice(-MAX_PENDING_CHARS) : rest;
|
|
189
|
+
for (const candidate of [rest, ...lines.reverse()]) {
|
|
190
|
+
const content = candidate.endsWith("\r") ? candidate.slice(0, -1) : candidate;
|
|
191
|
+
// Control characters (BEL, SO/SI, backspace) would act on the terminal at every redraw.
|
|
192
|
+
const line = stripAnsi(content.slice(content.lastIndexOf("\r") + 1))
|
|
193
|
+
.replace(/\t/g, " ")
|
|
194
|
+
// eslint-disable-next-line no-control-regex
|
|
195
|
+
.replace(/[\u0000-\u001f\u007f]/g, "")
|
|
196
|
+
.trim();
|
|
197
|
+
if (line === "")
|
|
198
|
+
continue;
|
|
199
|
+
if (line !== this.last) {
|
|
200
|
+
this.last = line;
|
|
201
|
+
this.report(line);
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
package/dist/process.js
CHANGED
|
@@ -62,12 +62,19 @@ export async function execCheck(options) {
|
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
active.add(child);
|
|
65
|
-
for (const stream of [
|
|
65
|
+
for (const [name, stream] of [
|
|
66
|
+
["stdout", child.stdout],
|
|
67
|
+
["stderr", child.stderr],
|
|
68
|
+
]) {
|
|
66
69
|
if (!stream)
|
|
67
70
|
continue;
|
|
68
71
|
const decoder = new StringDecoder("utf8");
|
|
69
72
|
stream.pipe(log, { end: false });
|
|
70
|
-
stream.on("data", (chunk) =>
|
|
73
|
+
stream.on("data", (chunk) => {
|
|
74
|
+
const text = decoder.write(chunk);
|
|
75
|
+
capture.write(stream, text);
|
|
76
|
+
options.onOutput?.(name, text);
|
|
77
|
+
});
|
|
71
78
|
stream.on("end", () => capture.write(stream, decoder.end() + "\n"));
|
|
72
79
|
}
|
|
73
80
|
let giveUp = () => undefined;
|
package/dist/reporters/agent.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describeProblem, duration,
|
|
1
|
+
import { describeProblem, duration, logsHint, previewDecider, problems, selectionLabel, verdict, } from "./reporter.js";
|
|
2
2
|
/**
|
|
3
3
|
* Minimal output for coding agents and other non-interactive callers: one line
|
|
4
4
|
* on success; on failure, one line per problem, a bounded preview, and the
|
|
@@ -14,15 +14,12 @@ export function renderAgent(result) {
|
|
|
14
14
|
];
|
|
15
15
|
if (summary.selected === 0)
|
|
16
16
|
lines.push("Nothing to verify for these changes. Full run: vfk --all");
|
|
17
|
-
|
|
17
|
+
const decide = previewDecider(result);
|
|
18
18
|
for (const check of problems(result)) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
previewed < MAX_PREVIEWED_FAILURES) {
|
|
23
|
-
previewed++;
|
|
19
|
+
const preview = decide(check);
|
|
20
|
+
lines.push(`${check.name}: ${describeProblem(check)}${preview.note}`);
|
|
21
|
+
if (preview.show)
|
|
24
22
|
lines.push(...check.preview.map((line) => ` ${line}`));
|
|
25
|
-
}
|
|
26
23
|
}
|
|
27
24
|
const hint = logsHint(result);
|
|
28
25
|
if (hint)
|
package/dist/reporters/human.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { formatSeconds } from "../format.js";
|
|
2
|
+
import { describeProblem, duration, isFailureStatus, logsHint, previewDecider, problems, selectionLabel, STOPPING_MESSAGE, } from "./reporter.js";
|
|
2
3
|
const wrap = (open, close) => (text) => `\u001b[${open}m${text}\u001b[${close}m`;
|
|
3
4
|
export const colors = {
|
|
4
5
|
green: wrap(32, 39),
|
|
@@ -25,20 +26,13 @@ const HEARTBEAT_MS = 30_000;
|
|
|
25
26
|
export function humanReporter(output, style, options = {}) {
|
|
26
27
|
const running = new Map();
|
|
27
28
|
let heartbeat;
|
|
29
|
+
let width = 0;
|
|
28
30
|
return {
|
|
29
|
-
onStart(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (selected.length === 0)
|
|
33
|
-
output.out(style.dim("Nothing to verify for these changes. Full run: vfk --all\n"));
|
|
31
|
+
onStart(info) {
|
|
32
|
+
output.out(renderStart(info, style, options.skipEstimate));
|
|
33
|
+
width = nameWidth(info.selected);
|
|
34
|
+
if (info.selected.length === 0)
|
|
34
35
|
return;
|
|
35
|
-
}
|
|
36
|
-
if (skipped > 0) {
|
|
37
|
-
const names = selection.checks
|
|
38
|
-
.filter((check) => !check.selected)
|
|
39
|
-
.map((check) => check.name);
|
|
40
|
-
output.out(style.dim(`skipped: ${names.join(", ")}\n`));
|
|
41
|
-
}
|
|
42
36
|
if (options.heartbeat) {
|
|
43
37
|
heartbeat = setInterval(() => {
|
|
44
38
|
if (running.size === 0)
|
|
@@ -58,33 +52,75 @@ export function humanReporter(output, style, options = {}) {
|
|
|
58
52
|
},
|
|
59
53
|
onCheckEnd(check) {
|
|
60
54
|
running.delete(check.name);
|
|
61
|
-
|
|
62
|
-
output.out(`${symbol(check.status, style)} ${check.name} ${style.dim(duration(check.durationMs))}${detail}\n`);
|
|
55
|
+
output.out(renderCheckEnd(check, style, width));
|
|
63
56
|
},
|
|
64
57
|
onFinish(result) {
|
|
65
58
|
if (heartbeat)
|
|
66
59
|
clearInterval(heartbeat);
|
|
67
60
|
output.out(renderSummary(result, style));
|
|
68
61
|
},
|
|
62
|
+
onInterrupt() {
|
|
63
|
+
output.err(style.yellow(STOPPING_MESSAGE));
|
|
64
|
+
},
|
|
69
65
|
};
|
|
70
66
|
}
|
|
67
|
+
export function nameWidth(names) {
|
|
68
|
+
return Math.max(0, ...names.map((name) => name.length));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The run header (`verifika · <selection> · <n> checks · <j> parallel`), then
|
|
72
|
+
* the skipped checks with the time skipping them saved.
|
|
73
|
+
*/
|
|
74
|
+
export function renderStart({ selection, selected, concurrency }, style, estimate) {
|
|
75
|
+
const header = [style.bold("verifika"), selectionLabel(selection, 0)];
|
|
76
|
+
header.push(`${selected.length} check${selected.length === 1 ? "" : "s"}`);
|
|
77
|
+
if (selected.length > 0)
|
|
78
|
+
header.push(`${concurrency} parallel`);
|
|
79
|
+
let text = `${header.join(" · ")}\n`;
|
|
80
|
+
const skipped = selection.checks.filter((check) => !check.selected).map((check) => check.name);
|
|
81
|
+
if (skipped.length > 0) {
|
|
82
|
+
text += style.dim(`${skipped.length} skipped${savedNote(skipped.length, estimate)}: ${skipped.join(", ")}\n`);
|
|
83
|
+
}
|
|
84
|
+
if (selected.length === 0) {
|
|
85
|
+
text += style.dim("Nothing to verify for these changes. Full run: vfk --all\n");
|
|
86
|
+
}
|
|
87
|
+
return text;
|
|
88
|
+
}
|
|
89
|
+
/** ` (~40s saved)`, ` (≥40s saved)` when some durations are unknown, or nothing when none are known. */
|
|
90
|
+
function savedNote(skipped, estimate) {
|
|
91
|
+
if (!estimate || estimate.unknown >= skipped)
|
|
92
|
+
return "";
|
|
93
|
+
return ` (${estimate.unknown > 0 ? "≥" : "~"}${formatSeconds(estimate.knownMs)} saved)`;
|
|
94
|
+
}
|
|
95
|
+
/** The permanent line printed when a check finishes; durations line up in a column. */
|
|
96
|
+
export function renderCheckEnd(check, style, width = 0) {
|
|
97
|
+
const time = style.dim(duration(check.durationMs).padStart(5));
|
|
98
|
+
const detail = check.status === "passed" ? "" : ` ${describeProblem(check)}`;
|
|
99
|
+
return `${symbol(check.status, style)} ${check.name.padEnd(width)} ${time}${detail}\n`;
|
|
100
|
+
}
|
|
71
101
|
export function renderSummary(result, style) {
|
|
72
102
|
const { summary } = result;
|
|
73
103
|
const lines = [];
|
|
74
104
|
const failing = problems(result);
|
|
75
|
-
|
|
105
|
+
const decide = previewDecider(result);
|
|
106
|
+
const width = nameWidth(failing.map((check) => check.name));
|
|
76
107
|
if (failing.length > 0)
|
|
77
108
|
lines.push("");
|
|
78
109
|
for (const check of failing) {
|
|
110
|
+
const name = check.name.padEnd(width);
|
|
111
|
+
if (check.status === "blocked") {
|
|
112
|
+
lines.push(style.dim(`${symbol(check.status, plain)} ${name} ${blockedReason(check, result)}`));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
79
115
|
if (!isFailureStatus(check.status)) {
|
|
80
|
-
lines.push(`${symbol(check.status, style)} ${
|
|
116
|
+
lines.push(`${symbol(check.status, style)} ${name} ${describeProblem(check)}`);
|
|
81
117
|
continue;
|
|
82
118
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
119
|
+
const preview = decide(check);
|
|
120
|
+
const rerun = `rerun: vfk -c ${check.name}`;
|
|
121
|
+
lines.push(`${symbol(check.status, style)} ${style.bold(name)} ${check.command} → ${describeProblem(check)}${style.dim(` · ${rerun}${preview.note}`)}`);
|
|
122
|
+
if (preview.show)
|
|
86
123
|
lines.push(...check.preview.map((line) => style.dim(` ${line}`)));
|
|
87
|
-
}
|
|
88
124
|
}
|
|
89
125
|
const time = duration(result.durationMs);
|
|
90
126
|
if (result.status === "passed") {
|
|
@@ -99,6 +135,18 @@ export function renderSummary(result, style) {
|
|
|
99
135
|
lines.push(style.dim(`Logs: ${hint}`));
|
|
100
136
|
return `${lines.join("\n")}\n`;
|
|
101
137
|
}
|
|
138
|
+
/** `not run: build failed`, naming what happened to the dependency that blocked the check. */
|
|
139
|
+
function blockedReason(check, result) {
|
|
140
|
+
const dependency = result.checks.find((other) => other.name === check.blockedBy);
|
|
141
|
+
if (!dependency)
|
|
142
|
+
return `not run: ${check.reason}`;
|
|
143
|
+
const outcome = dependency.status === "timed_out"
|
|
144
|
+
? "timed out"
|
|
145
|
+
: isFailureStatus(dependency.status)
|
|
146
|
+
? "failed"
|
|
147
|
+
: "did not run";
|
|
148
|
+
return `not run: ${dependency.name} ${outcome}`;
|
|
149
|
+
}
|
|
102
150
|
export function symbol(status, style) {
|
|
103
151
|
switch (status) {
|
|
104
152
|
case "passed":
|
|
@@ -109,6 +157,8 @@ export function symbol(status, style) {
|
|
|
109
157
|
return style.red("✗");
|
|
110
158
|
case "skipped":
|
|
111
159
|
return style.dim("–");
|
|
160
|
+
case "blocked":
|
|
161
|
+
return style.dim("○");
|
|
112
162
|
default:
|
|
113
163
|
return style.yellow("!");
|
|
114
164
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { formatSeconds } from "../format.js";
|
|
2
|
+
import { nameWidth, renderCheckEnd, renderStart, renderSummary, } from "./human.js";
|
|
3
|
+
import { STOPPING_MESSAGE } from "./reporter.js";
|
|
4
|
+
/** Redraw interval: about 10 frames a second, which also throttles output-driven updates. */
|
|
5
|
+
const FRAME_MS = 100;
|
|
6
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
7
|
+
const CSI = "\u001b[";
|
|
8
|
+
const HIDE_CURSOR = `${CSI}?25l`;
|
|
9
|
+
const SHOW_CURSOR = `${CSI}?25h`;
|
|
10
|
+
// Autowrap stays off while the block is drawn, so an overlong line (wide characters
|
|
11
|
+
// the width estimate missed) is clipped by the terminal instead of wrapping and
|
|
12
|
+
// breaking the cursor-up count of the next redraw.
|
|
13
|
+
const NO_WRAP = `${CSI}?7l`;
|
|
14
|
+
const WRAP = `${CSI}?7h`;
|
|
15
|
+
/**
|
|
16
|
+
* The redrawn part of the live display: a progress header, one line per running
|
|
17
|
+
* check (spinner, elapsed time, latest output) and the checks still waiting.
|
|
18
|
+
* Lines fit the width; the block never exceeds the height minus one row, so the
|
|
19
|
+
* cursor can always move back to its top.
|
|
20
|
+
*/
|
|
21
|
+
export function renderBlock(state, view, style) {
|
|
22
|
+
const max = Math.max(1, view.width - 1);
|
|
23
|
+
const elapsed = formatSeconds(view.now - state.startedAt);
|
|
24
|
+
const header = `[${state.finished.size}/${state.selected.length}] ${elapsed}${state.stopping ? " · stopping…" : ""}`;
|
|
25
|
+
const namePad = nameWidth(state.selected);
|
|
26
|
+
const frame = SPINNER[Math.floor(view.now / FRAME_MS) % SPINNER.length];
|
|
27
|
+
const running = state.selected
|
|
28
|
+
.filter((name) => state.running.has(name))
|
|
29
|
+
.map((name) => {
|
|
30
|
+
const { since, line } = state.running.get(name);
|
|
31
|
+
const time = formatSeconds(view.now - since).padStart(5);
|
|
32
|
+
const prefix = `${frame} ${name.padEnd(namePad)} ${time}`;
|
|
33
|
+
if (prefix.length + 2 >= max)
|
|
34
|
+
return fit(prefix, max);
|
|
35
|
+
const shown = fit(line, max - prefix.length - 2);
|
|
36
|
+
return `${frame} ${name.padEnd(namePad)} ${style.dim(time)}${shown ? ` ${style.dim(shown)}` : ""}`;
|
|
37
|
+
});
|
|
38
|
+
const groups = waitingGroups(state);
|
|
39
|
+
const waiting = groups.map(({ names, why }) => style.dim(fit(` ${names.join(", ")} ${why}`, max)));
|
|
40
|
+
// Keep the header, then as many running checks as fit, then waiting groups;
|
|
41
|
+
// collapse whatever does not fit into a count.
|
|
42
|
+
const room = Math.max(1, view.height - 1) - 1;
|
|
43
|
+
let shownRunning = running.length;
|
|
44
|
+
let shownWaiting = waiting.length;
|
|
45
|
+
const total = () => shownRunning +
|
|
46
|
+
(shownRunning < running.length ? 1 : 0) +
|
|
47
|
+
shownWaiting +
|
|
48
|
+
(shownWaiting < waiting.length ? 1 : 0);
|
|
49
|
+
while (total() > room && (shownWaiting > 0 || shownRunning > 0)) {
|
|
50
|
+
if (shownWaiting > 0)
|
|
51
|
+
shownWaiting--;
|
|
52
|
+
else
|
|
53
|
+
shownRunning--;
|
|
54
|
+
}
|
|
55
|
+
const lines = [style.bold(fit(header, max)), ...running.slice(0, shownRunning)];
|
|
56
|
+
if (shownRunning < running.length)
|
|
57
|
+
lines.push(style.dim(fit(` +${running.length - shownRunning} more running`, max)));
|
|
58
|
+
lines.push(...waiting.slice(0, shownWaiting));
|
|
59
|
+
if (shownWaiting < waiting.length) {
|
|
60
|
+
const hidden = groups
|
|
61
|
+
.slice(shownWaiting)
|
|
62
|
+
.reduce((count, group) => count + group.names.length, 0);
|
|
63
|
+
lines.push(style.dim(fit(` +${hidden} more waiting`, max)));
|
|
64
|
+
}
|
|
65
|
+
return lines.slice(0, Math.max(1, view.height - 1));
|
|
66
|
+
}
|
|
67
|
+
/** Checks not yet started, grouped by what they wait for, e.g. `test, pack waiting on build`. */
|
|
68
|
+
function waitingGroups(state) {
|
|
69
|
+
const selected = new Set(state.selected);
|
|
70
|
+
// A check whose dependency failed (directly or down a chain) will never start.
|
|
71
|
+
const blocked = new Set();
|
|
72
|
+
for (let changed = true; changed;) {
|
|
73
|
+
changed = false;
|
|
74
|
+
for (const name of state.selected) {
|
|
75
|
+
if (blocked.has(name) || state.finished.has(name) || state.running.has(name))
|
|
76
|
+
continue;
|
|
77
|
+
const deps = state.dependsOn[name] ?? [];
|
|
78
|
+
if (deps.some((dep) => state.finished.get(dep) === false || blocked.has(dep))) {
|
|
79
|
+
blocked.add(name);
|
|
80
|
+
changed = true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const groups = new Map();
|
|
85
|
+
for (const name of state.selected) {
|
|
86
|
+
if (state.running.has(name) || state.finished.has(name) || blocked.has(name))
|
|
87
|
+
continue;
|
|
88
|
+
const unmet = (state.dependsOn[name] ?? []).filter((dep) => selected.has(dep) && state.finished.get(dep) !== true);
|
|
89
|
+
const why = state.stopping
|
|
90
|
+
? "will not start"
|
|
91
|
+
: unmet.length > 0
|
|
92
|
+
? `waiting on ${unmet.join(", ")}`
|
|
93
|
+
: "queued";
|
|
94
|
+
groups.set(why, [...(groups.get(why) ?? []), name]);
|
|
95
|
+
}
|
|
96
|
+
return [...groups].map(([why, names]) => ({ names, why }));
|
|
97
|
+
}
|
|
98
|
+
/** Truncates to `width` characters (code points), marking the cut with an ellipsis. */
|
|
99
|
+
export function fit(text, width) {
|
|
100
|
+
const chars = Array.from(text);
|
|
101
|
+
if (chars.length <= width)
|
|
102
|
+
return text;
|
|
103
|
+
return width <= 0 ? "" : `${chars.slice(0, width - 1).join("")}…`;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Human output with a live status block. Finished checks print permanently above
|
|
107
|
+
* the block exactly as the line reporter prints them, so scrollback reads like
|
|
108
|
+
* line mode; the block below is redrawn in place (cursor up, erase down) and
|
|
109
|
+
* removed when the run ends. The cursor is hidden while live and restored on
|
|
110
|
+
* finish, on dispose (errors) and on process exit.
|
|
111
|
+
*/
|
|
112
|
+
export function liveReporter(terminal, style, options = {}) {
|
|
113
|
+
let state = null;
|
|
114
|
+
let drawn = 0;
|
|
115
|
+
let stopTicker;
|
|
116
|
+
let unregisterExit;
|
|
117
|
+
let width = 0;
|
|
118
|
+
const clear = () => (drawn > 0 ? `\r${CSI}${drawn}A${CSI}J` : "");
|
|
119
|
+
const draw = (above = "") => {
|
|
120
|
+
if (!state)
|
|
121
|
+
return;
|
|
122
|
+
const lines = renderBlock(state, { width: terminal.columns(), height: terminal.rows(), now: terminal.now() }, style);
|
|
123
|
+
terminal.write(`${clear()}${above}${NO_WRAP}${lines.map((line) => `${line}\n`).join("")}${WRAP}`);
|
|
124
|
+
drawn = lines.length;
|
|
125
|
+
};
|
|
126
|
+
const release = (after = "") => {
|
|
127
|
+
if (!state) {
|
|
128
|
+
if (after)
|
|
129
|
+
terminal.write(after);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
stopTicker?.();
|
|
133
|
+
unregisterExit?.();
|
|
134
|
+
terminal.write(`${clear()}${SHOW_CURSOR}${after}`);
|
|
135
|
+
state = null;
|
|
136
|
+
drawn = 0;
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
onStart(info) {
|
|
140
|
+
terminal.write(renderStart(info, style, options.skipEstimate));
|
|
141
|
+
width = nameWidth(info.selected);
|
|
142
|
+
if (info.selected.length === 0)
|
|
143
|
+
return;
|
|
144
|
+
state = {
|
|
145
|
+
selected: info.selected,
|
|
146
|
+
dependsOn: info.dependsOn,
|
|
147
|
+
running: new Map(),
|
|
148
|
+
finished: new Map(),
|
|
149
|
+
startedAt: terminal.now(),
|
|
150
|
+
stopping: false,
|
|
151
|
+
};
|
|
152
|
+
terminal.write(HIDE_CURSOR);
|
|
153
|
+
unregisterExit = terminal.onExit(() => terminal.write(`${SHOW_CURSOR}${WRAP}`));
|
|
154
|
+
stopTicker = terminal.every(FRAME_MS, () => draw());
|
|
155
|
+
draw();
|
|
156
|
+
},
|
|
157
|
+
onCheckStart(name) {
|
|
158
|
+
state?.running.set(name, { since: terminal.now(), line: "" });
|
|
159
|
+
draw();
|
|
160
|
+
},
|
|
161
|
+
onCheckOutput(name, line) {
|
|
162
|
+
// Picked up by the next frame; output alone never triggers a redraw.
|
|
163
|
+
const entry = state?.running.get(name);
|
|
164
|
+
if (entry)
|
|
165
|
+
entry.line = line;
|
|
166
|
+
},
|
|
167
|
+
onCheckEnd(check) {
|
|
168
|
+
state?.running.delete(check.name);
|
|
169
|
+
state?.finished.set(check.name, check.status === "passed");
|
|
170
|
+
draw(renderCheckEnd(check, style, width));
|
|
171
|
+
},
|
|
172
|
+
onFinish(result) {
|
|
173
|
+
release(renderSummary(result, style));
|
|
174
|
+
},
|
|
175
|
+
onInterrupt() {
|
|
176
|
+
// Clear the block first so the message lands above it, not inside it (or on top of
|
|
177
|
+
// the ^C the terminal echoed below it); the redrawn block then says it is stopping.
|
|
178
|
+
if (state) {
|
|
179
|
+
state.stopping = true;
|
|
180
|
+
terminal.write(clear());
|
|
181
|
+
drawn = 0;
|
|
182
|
+
}
|
|
183
|
+
terminal.writeError(style.yellow(STOPPING_MESSAGE));
|
|
184
|
+
draw();
|
|
185
|
+
},
|
|
186
|
+
dispose() {
|
|
187
|
+
release();
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/** The real terminal behind stdout. */
|
|
192
|
+
export function systemTerminal(write, writeError, stream) {
|
|
193
|
+
return {
|
|
194
|
+
write,
|
|
195
|
+
writeError,
|
|
196
|
+
columns: () => stream.columns || 80,
|
|
197
|
+
rows: () => stream.rows || 24,
|
|
198
|
+
now: () => performance.now(),
|
|
199
|
+
every(ms, frame) {
|
|
200
|
+
const timer = setInterval(frame, ms);
|
|
201
|
+
timer.unref();
|
|
202
|
+
return () => clearInterval(timer);
|
|
203
|
+
},
|
|
204
|
+
onExit(restore) {
|
|
205
|
+
process.once("exit", restore);
|
|
206
|
+
return () => process.off("exit", restore);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
package/dist/reporters/plan.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { plain } from "./human.js";
|
|
1
2
|
import { selectionLabel } from "./reporter.js";
|
|
2
3
|
const STATUS_LETTER = {
|
|
3
4
|
added: "A",
|
|
@@ -7,7 +8,7 @@ const STATUS_LETTER = {
|
|
|
7
8
|
changed: "~",
|
|
8
9
|
};
|
|
9
10
|
/** Text rendering of a plan. `maxFiles` bounds the changed-file list. */
|
|
10
|
-
export function renderPlan(selection, maxFiles) {
|
|
11
|
+
export function renderPlan(selection, maxFiles, style = plain) {
|
|
11
12
|
const skipped = selection.checks.filter((check) => !check.selected).length;
|
|
12
13
|
const lines = [`Plan: ${selectionLabel(selection, skipped)}`];
|
|
13
14
|
const files = selection.changes?.files;
|
|
@@ -21,7 +22,8 @@ export function renderPlan(selection, maxFiles) {
|
|
|
21
22
|
lines.push("Checks:");
|
|
22
23
|
const width = Math.max(...selection.checks.map((check) => check.name.length));
|
|
23
24
|
for (const check of selection.checks) {
|
|
24
|
-
|
|
25
|
+
const row = `${check.name.padEnd(width)} ${check.reason}`;
|
|
26
|
+
lines.push(check.selected ? ` ${style.green("run ")} ${row}` : style.dim(` skip ${row}`));
|
|
25
27
|
}
|
|
26
28
|
return `${lines.join("\n")}\n`;
|
|
27
29
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { formatSeconds } from "../format.js";
|
|
2
2
|
export const OUTPUT_MODES = ["human", "agent", "json", "quiet"];
|
|
3
|
+
/** Printed to stderr in human mode on the first interrupt. */
|
|
4
|
+
export const STOPPING_MESSAGE = "stopping… (Ctrl-C again to force)\n";
|
|
3
5
|
/** Previews are shown for at most this many failed checks, to keep output bounded. */
|
|
4
6
|
export const MAX_PREVIEWED_FAILURES = 3;
|
|
5
7
|
export function isFailureStatus(status) {
|
|
@@ -28,9 +30,31 @@ export function logsHint(result) {
|
|
|
28
30
|
const withLogs = problems(result).filter((check) => isFailureStatus(check.status) && check.log);
|
|
29
31
|
if (withLogs.length === 0)
|
|
30
32
|
return null;
|
|
31
|
-
|
|
33
|
+
return logsCommand(result, withLogs.length === 1 ? (withLogs[0]?.name ?? "") : "--failed");
|
|
34
|
+
}
|
|
35
|
+
/** `vfk logs <target>`, naming the run when a later run has become the latest. */
|
|
36
|
+
function logsCommand(result, target) {
|
|
37
|
+
const command = `vfk logs ${target}`;
|
|
32
38
|
return result.latest ? command : `${command} --run ${result.runId}`;
|
|
33
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Decides which failed checks get their preview printed: at most
|
|
42
|
+
* MAX_PREVIEWED_FAILURES, in config order. The rest get a note pointing at their
|
|
43
|
+
* log, so a hidden preview never looks like one that found nothing.
|
|
44
|
+
*/
|
|
45
|
+
export function previewDecider(result) {
|
|
46
|
+
let previewed = 0;
|
|
47
|
+
return (check) => {
|
|
48
|
+
if (!isFailureStatus(check.status) || check.preview.length === 0) {
|
|
49
|
+
return { show: false, note: "" };
|
|
50
|
+
}
|
|
51
|
+
if (previewed < MAX_PREVIEWED_FAILURES) {
|
|
52
|
+
previewed++;
|
|
53
|
+
return { show: true, note: "" };
|
|
54
|
+
}
|
|
55
|
+
return { show: false, note: ` (preview omitted: ${logsCommand(result, check.name)})` };
|
|
56
|
+
};
|
|
57
|
+
}
|
|
34
58
|
export function verdict(result) {
|
|
35
59
|
return result.status === "passed"
|
|
36
60
|
? "PASS"
|