verifika 0.0.1 → 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 +35 -0
- package/README.md +102 -53
- package/dist/cli.js +4 -0
- package/dist/config.js +1 -1
- package/dist/init.js +39 -1
- package/dist/logs.js +58 -17
- package/dist/main.js +104 -31
- 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 +6 -9
- package/dist/reporters/human.js +71 -21
- package/dist/reporters/live.js +209 -0
- package/dist/reporters/plan.js +5 -3
- package/dist/reporters/reporter.js +25 -1
- package/dist/runner.js +14 -1
- package/dist/runs.js +40 -1
- package/package.json +3 -3
- package/skills/verifika/SKILL.md +29 -14
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: verifika --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;
|
|
@@ -16,12 +17,13 @@ export function renderPlan(selection, maxFiles) {
|
|
|
16
17
|
for (const file of files.slice(0, maxFiles))
|
|
17
18
|
lines.push(` ${STATUS_LETTER[file.status]} ${file.path}`);
|
|
18
19
|
if (files.length > maxFiles)
|
|
19
|
-
lines.push(` … ${files.length - maxFiles} more (
|
|
20
|
+
lines.push(` … ${files.length - maxFiles} more (vfk plan --mode json lists all)`);
|
|
20
21
|
}
|
|
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"
|
package/dist/runner.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { constants } from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { formatSeconds } from "./format.js";
|
|
4
|
+
import { LastLine } from "./preview.js";
|
|
4
5
|
import { execCheck } from "./process.js";
|
|
5
6
|
import { createRun, logPath, pruneRuns, readLatest, releaseRun, writeRunResult } from "./runs.js";
|
|
6
7
|
import { schedule } from "./scheduler.js";
|
|
@@ -47,6 +48,7 @@ export async function runVerification(options) {
|
|
|
47
48
|
selection,
|
|
48
49
|
selected: selected.map((check) => check.name),
|
|
49
50
|
concurrency: options.concurrency,
|
|
51
|
+
dependsOn: Object.fromEntries(selected.map((check) => [check.name, check.dependsOn])),
|
|
50
52
|
});
|
|
51
53
|
const env = { ...(options.env ?? process.env), VERIFIKA: "1", VERIFIKA_RUN_ID: runId };
|
|
52
54
|
let firstFailure = null;
|
|
@@ -54,6 +56,11 @@ export async function runVerification(options) {
|
|
|
54
56
|
const check = byName.get(task.name);
|
|
55
57
|
reporter.onCheckStart?.(check.name);
|
|
56
58
|
const log = logPath(runId, check.name);
|
|
59
|
+
const onCheckOutput = reporter.onCheckOutput?.bind(reporter);
|
|
60
|
+
const followers = onCheckOutput && {
|
|
61
|
+
stdout: new LastLine((line) => onCheckOutput(check.name, line)),
|
|
62
|
+
stderr: new LastLine((line) => onCheckOutput(check.name, line)),
|
|
63
|
+
};
|
|
57
64
|
const outcome = await execCheck({
|
|
58
65
|
command: check.command,
|
|
59
66
|
cwd: root,
|
|
@@ -62,6 +69,7 @@ export async function runVerification(options) {
|
|
|
62
69
|
logFile: path.join(root, log),
|
|
63
70
|
previewLines: options.previewLines,
|
|
64
71
|
signal,
|
|
72
|
+
...(followers ? { onOutput: (stream, text) => followers[stream].write(text) } : {}),
|
|
65
73
|
});
|
|
66
74
|
const checkResult = completedResult(results.get(check.name), check, outcome, log);
|
|
67
75
|
results.set(check.name, checkResult);
|
|
@@ -172,7 +180,12 @@ function completedResult(base, check, outcome, log) {
|
|
|
172
180
|
}
|
|
173
181
|
function notRunResult(base, outcome, interrupted, firstFailure) {
|
|
174
182
|
if (outcome.state === "blocked") {
|
|
175
|
-
return {
|
|
183
|
+
return {
|
|
184
|
+
...base,
|
|
185
|
+
status: "blocked",
|
|
186
|
+
reason: `dependency ${outcome.dependency} did not pass`,
|
|
187
|
+
blockedBy: outcome.dependency,
|
|
188
|
+
};
|
|
176
189
|
}
|
|
177
190
|
if (outcome.state !== "not_started")
|
|
178
191
|
return null;
|
package/dist/runs.js
CHANGED
|
@@ -155,10 +155,49 @@ function isProcessAlive(pid) {
|
|
|
155
155
|
return error.code === "EPERM";
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Estimates the time saved by skipping `names`: each check's duration in the most
|
|
160
|
+
* recent retained run where it ran to completion (passed or failed).
|
|
161
|
+
*/
|
|
162
|
+
export async function estimateSkipped(root, names) {
|
|
163
|
+
const found = new Map();
|
|
164
|
+
let runs = [];
|
|
165
|
+
try {
|
|
166
|
+
runs = (await readdir(path.join(root, STATE_DIR, RUNS_DIR)))
|
|
167
|
+
.filter((name) => RUN_ID_PATTERN.test(name))
|
|
168
|
+
.sort()
|
|
169
|
+
.reverse();
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// No runs yet: nothing is known.
|
|
173
|
+
}
|
|
174
|
+
const wanted = new Set(names);
|
|
175
|
+
for (const id of runs) {
|
|
176
|
+
if (found.size === wanted.size)
|
|
177
|
+
break;
|
|
178
|
+
let run;
|
|
179
|
+
try {
|
|
180
|
+
run = JSON.parse(await readFile(path.join(runDirectory(root, id), RESULT_FILE), "utf8"));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
for (const check of run.checks ?? []) {
|
|
186
|
+
if (wanted.has(check.name) &&
|
|
187
|
+
!found.has(check.name) &&
|
|
188
|
+
(check.status === "passed" || check.status === "failed") &&
|
|
189
|
+
typeof check.durationMs === "number") {
|
|
190
|
+
found.set(check.name, check.durationMs);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const knownMs = [...found.values()].reduce((total, ms) => total + ms, 0);
|
|
195
|
+
return { knownMs, unknown: wanted.size - found.size };
|
|
196
|
+
}
|
|
158
197
|
export async function readRun(root, runId) {
|
|
159
198
|
const id = runId ?? (await readLatest(root));
|
|
160
199
|
if (id === null) {
|
|
161
|
-
throw new VerifikaError("no verification runs recorded yet", "Run `
|
|
200
|
+
throw new VerifikaError("no verification runs recorded yet", "Run `vfk` first.");
|
|
162
201
|
}
|
|
163
202
|
if (!RUN_ID_PATTERN.test(id))
|
|
164
203
|
throw new VerifikaError(`invalid run id "${id}"`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verifika",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Deterministic, agent-friendly repository verification: run the checks your changes need, concurrently, with minimal output.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"verify",
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"test": "vitest run",
|
|
52
52
|
"test:pack": "node scripts/pack-smoke.mjs",
|
|
53
53
|
"check:skill": "node scripts/check-skill.mjs",
|
|
54
|
-
"
|
|
55
|
-
"verify": "node src/cli.ts --all",
|
|
54
|
+
"verify": "node src/cli.ts",
|
|
55
|
+
"verify:ci": "node src/cli.ts --all",
|
|
56
56
|
"prepack": "npm run build"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
package/skills/verifika/SKILL.md
CHANGED
|
@@ -10,37 +10,37 @@ Verifika runs a repository's configured checks (lint, typecheck, tests, build, .
|
|
|
10
10
|
|
|
11
11
|
## Is it set up here?
|
|
12
12
|
|
|
13
|
-
Look for `verifika.config.json` in the repository root (or the package directory in a monorepo). Its `checks` object lists every check name and command. Run the CLI the way the repository does: `npx
|
|
13
|
+
Look for `verifika.config.json` in the repository root (or the package directory in a monorepo). Its `checks` object lists every check name and command. Run the CLI the way the repository does: `npx vfk` when it is a devDependency, `vfk` when installed globally, or the repository's scripts. Projects set up with the init command have `npm run verify` (runs `vfk`: selective) and `npm run verify:ci` (runs `vfk --all`: the full gate). `npm run verify` is not the full gate. `vfk` is short for `verifika`; both commands are identical. The examples below write `vfk`.
|
|
14
14
|
|
|
15
15
|
## Workflow
|
|
16
16
|
|
|
17
17
|
1. **While implementing**, after a meaningful change:
|
|
18
18
|
|
|
19
19
|
```sh
|
|
20
|
-
|
|
20
|
+
vfk
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
Runs only the checks matching files changed since the configured `changes.base` (by default `HEAD`: your uncommitted and untracked changes). If the environment sets `CI` (some agent sandboxes do), plain `
|
|
23
|
+
Runs only the checks matching files changed since the configured `changes.base` (by default `HEAD`: your uncommitted and untracked changes). If the environment sets `CI` (some agent sandboxes do), plain `vfk` runs everything; use `vfk --changed` for a selective run there.
|
|
24
24
|
|
|
25
25
|
2. **To see what would run and why**, without running anything:
|
|
26
26
|
|
|
27
27
|
```sh
|
|
28
|
-
|
|
28
|
+
vfk plan
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
3. **To rerun one check** while fixing it (its dependencies run too):
|
|
32
32
|
|
|
33
33
|
```sh
|
|
34
|
-
|
|
34
|
+
vfk --check typecheck
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
4. **Before claiming the task is complete, committing, or opening a pull request**, always run the full suite:
|
|
37
|
+
4. **Before claiming the task is complete, committing, or opening a pull request**, always run the full suite (`npm run verify:ci` where that script exists):
|
|
38
38
|
|
|
39
39
|
```sh
|
|
40
|
-
|
|
40
|
+
vfk --all
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
5. **To verify a whole branch** rather than just uncommitted work: `
|
|
43
|
+
5. **To verify a whole branch** rather than just uncommitted work: `vfk --changed-since origin/main` (compares against the merge-base, plus uncommitted and untracked files). Useful for review; it does not replace `vfk --all` before delivery.
|
|
44
44
|
|
|
45
45
|
Wait for the command to finish. Long suites can take minutes; allow enough time and never report a result you have not seen.
|
|
46
46
|
|
|
@@ -59,7 +59,22 @@ On failure, one line per problem, a short preview, and the command for full logs
|
|
|
59
59
|
FAIL 3/4 (3.2s) full run (--all)
|
|
60
60
|
typecheck: exit 2
|
|
61
61
|
src/cart.ts(14,7): error TS2322: Type 'string' is not assignable to type 'number'.
|
|
62
|
-
Run:
|
|
62
|
+
Run: vfk logs typecheck
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Previews are shown for at most three failed checks. Any further failed check says where its output is instead:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
FAIL 1/5 (9.8s) full run (--all)
|
|
69
|
+
format: exit 1
|
|
70
|
+
[warn] src/cart.ts
|
|
71
|
+
lint: exit 1
|
|
72
|
+
/repo/src/cart.ts
|
|
73
|
+
12:9 error 'total' is assigned a value but never used no-unused-vars
|
|
74
|
+
typecheck: exit 2
|
|
75
|
+
src/cart.ts(14,7): error TS2322: Type 'string' is not assignable to type 'number'.
|
|
76
|
+
test: exit 1 (preview omitted: vfk logs test)
|
|
77
|
+
Run: vfk logs --failed
|
|
63
78
|
```
|
|
64
79
|
|
|
65
80
|
| Exit code | Meaning |
|
|
@@ -75,18 +90,18 @@ Check statuses: `passed`, `failed`, `timed_out`, `error` (could not start, or le
|
|
|
75
90
|
## When verification fails
|
|
76
91
|
|
|
77
92
|
1. Read the failing check names and previews first; they usually identify the problem.
|
|
78
|
-
2. If you need more, read only the failing logs: `
|
|
79
|
-
3. Fix the root cause in the code. Then rerun the failed check (`
|
|
93
|
+
2. If you need more, read only the failing logs: `vfk logs typecheck`, or `vfk logs --failed` for all of them. For long logs use `vfk logs typecheck --tail 80`.
|
|
94
|
+
3. Fix the root cause in the code. Then rerun the failed check (`vfk --check typecheck`), and finish with `vfk --all`.
|
|
80
95
|
4. `blocked` checks never ran; fix the dependency they name and rerun.
|
|
81
96
|
5. If a failure is unrelated to your change (for example it also fails on the base branch), report it with evidence instead of working around it.
|
|
82
97
|
|
|
83
98
|
## Rules
|
|
84
99
|
|
|
85
100
|
- Do not claim success unless you saw `PASS` and exit code 0 from a finished run. Say what ran: "selective verification passed (2 checks)" is not "all checks pass".
|
|
86
|
-
- A selective `PASS` only covers the checks it selected. Skipped checks were not verified. Run `
|
|
101
|
+
- A selective `PASS` only covers the checks it selected. Skipped checks were not verified. Run `vfk --all` (or `npm run verify:ci`) before delivery, every time; `npm run verify` is selective and does not count.
|
|
87
102
|
- `PASS 0/0` means nothing matched your changes and nothing was verified.
|
|
88
|
-
- Do not read logs of passing checks or use `
|
|
103
|
+
- Do not read logs of passing checks or use `vfk logs` after a `PASS`; there is nothing to diagnose.
|
|
89
104
|
- Do not edit `verifika.config.json`, remove checks, narrow `paths`, raise timeouts, or pass `--check` to dodge a failing check, unless the user asked for that change.
|
|
90
105
|
- Exit code 2 is not a code failure: read the message (bad flag, unknown check, invalid config, missing git ref) and fix the invocation, or report it.
|
|
91
106
|
- Do not raise concurrency (`-j`) beyond what the config sets: the project chose it, and checks that share build outputs fail spuriously when run together.
|
|
92
|
-
- Prefer the default agent output. Use `
|
|
107
|
+
- Prefer the default agent output. Use `vfk --mode json` only when a script needs structured results; avoid `vfk --mode human` in agent sessions.
|