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/main.js
CHANGED
|
@@ -3,28 +3,32 @@ import path from "node:path";
|
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
4
|
import { findConfigFile, loadConfig } from "./config.js";
|
|
5
5
|
import { ExitCode, VerifikaError } from "./errors.js";
|
|
6
|
-
import { initConfig } from "./init.js";
|
|
6
|
+
import { addVerifyScripts, initConfig, VERIFY_SCRIPTS } from "./init.js";
|
|
7
7
|
import { showLogs } from "./logs.js";
|
|
8
|
+
import { pagerAvailable, pagerCommand } from "./pager.js";
|
|
8
9
|
import { plan } from "./planner.js";
|
|
9
10
|
import { killAllNow } from "./process.js";
|
|
10
11
|
import { agentReporter } from "./reporters/agent.js";
|
|
12
|
+
import { detectCi } from "./ci.js";
|
|
11
13
|
import { colors, humanReporter, plain } from "./reporters/human.js";
|
|
12
14
|
import { jsonReporter } from "./reporters/json.js";
|
|
15
|
+
import { liveReporter } from "./reporters/live.js";
|
|
13
16
|
import { planJson, renderPlan } from "./reporters/plan.js";
|
|
14
17
|
import { quietReporter } from "./reporters/quiet.js";
|
|
15
18
|
import { OUTPUT_MODES } from "./reporters/reporter.js";
|
|
16
19
|
import { runVerification } from "./runner.js";
|
|
20
|
+
import { estimateSkipped } from "./runs.js";
|
|
17
21
|
const COMMANDS = ["run", "plan", "logs", "init", "help"];
|
|
18
22
|
const DEFAULT_PREVIEW_LINES = 10;
|
|
19
|
-
export const HELP = `
|
|
23
|
+
export const HELP = `vfk - run the checks your changes need, with minimal output
|
|
24
|
+
(vfk is short for verifika; the two commands are identical)
|
|
20
25
|
|
|
21
26
|
Usage:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
vfk is a shorthand for verifika: vfk plan, vfk --all, ...
|
|
27
|
+
vfk [run] [options] Verify (selective locally, full in CI)
|
|
28
|
+
vfk plan [options] Show which checks would run and why
|
|
29
|
+
vfk logs [check...] Show logs from the latest run
|
|
30
|
+
vfk init [--force] Create verifika.config.json from package.json scripts
|
|
31
|
+
and add "verify"/"verify:ci" scripts if absent
|
|
28
32
|
|
|
29
33
|
Selection (run, plan):
|
|
30
34
|
--all Run every check
|
|
@@ -39,11 +43,15 @@ Execution (run):
|
|
|
39
43
|
--fail-fast Stop everything after the first failure
|
|
40
44
|
--no-fail-fast Run every selected check even after a failure
|
|
41
45
|
--preview <lines> Failure preview lines per check (default ${DEFAULT_PREVIEW_LINES}, 0 = off)
|
|
46
|
+
--no-live Print progress line by line instead of a live
|
|
47
|
+
status block (or set VERIFIKA_LIVE=0)
|
|
42
48
|
|
|
43
49
|
Logs:
|
|
44
50
|
--failed Print logs of every failed check
|
|
45
51
|
--tail <lines> Print only the last lines of each log
|
|
46
52
|
--run <id> Read a specific run instead of the latest
|
|
53
|
+
--no-pager Print logs directly instead of through $PAGER
|
|
54
|
+
(paging only happens on a terminal)
|
|
47
55
|
|
|
48
56
|
Output:
|
|
49
57
|
--mode <mode> human | agent | json | quiet
|
|
@@ -78,6 +86,7 @@ const OPTIONS = {
|
|
|
78
86
|
"fail-fast": { type: "boolean" },
|
|
79
87
|
"no-fail-fast": { type: "boolean" },
|
|
80
88
|
preview: { type: "string" },
|
|
89
|
+
"no-live": { type: "boolean" },
|
|
81
90
|
},
|
|
82
91
|
plan: { ...SHARED, ...SELECTION },
|
|
83
92
|
logs: {
|
|
@@ -85,6 +94,7 @@ const OPTIONS = {
|
|
|
85
94
|
failed: { type: "boolean" },
|
|
86
95
|
tail: { type: "string" },
|
|
87
96
|
run: { type: "string" },
|
|
97
|
+
"no-pager": { type: "boolean" },
|
|
88
98
|
},
|
|
89
99
|
init: { ...SHARED, force: { type: "boolean" } },
|
|
90
100
|
help: SHARED,
|
|
@@ -124,6 +134,9 @@ export async function main(argv, io) {
|
|
|
124
134
|
...(values.run !== undefined ? { runId: values.run } : {}),
|
|
125
135
|
...(values.tail !== undefined ? { tail: integerOption(values.tail, "--tail", 1) } : {}),
|
|
126
136
|
stripAnsi: !io.isTTY,
|
|
137
|
+
env: io.env,
|
|
138
|
+
// Agents in a pseudo-terminal must never get a pager waiting for a keypress.
|
|
139
|
+
...(mode === "human" ? logsDisplay(io, values["no-pager"] === true) : {}),
|
|
127
140
|
}, io);
|
|
128
141
|
}
|
|
129
142
|
const request = {
|
|
@@ -147,8 +160,10 @@ export async function main(argv, io) {
|
|
|
147
160
|
if (command === "plan") {
|
|
148
161
|
if (mode === "json")
|
|
149
162
|
io.out(planJson(selection, config));
|
|
150
|
-
else if (mode
|
|
151
|
-
io.out(renderPlan(selection,
|
|
163
|
+
else if (mode === "human")
|
|
164
|
+
io.out(renderPlan(selection, 50, humanStyle(io)));
|
|
165
|
+
else if (mode === "agent")
|
|
166
|
+
io.out(renderPlan(selection, 20));
|
|
152
167
|
return ExitCode.Passed;
|
|
153
168
|
}
|
|
154
169
|
if (values["fail-fast"] && values["no-fail-fast"]) {
|
|
@@ -161,18 +176,31 @@ export async function main(argv, io) {
|
|
|
161
176
|
const previewLines = values.preview !== undefined
|
|
162
177
|
? integerOption(values.preview, "--preview", 0)
|
|
163
178
|
: DEFAULT_PREVIEW_LINES;
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
179
|
+
const skipped = selection.checks.filter((check) => !check.selected).map((check) => check.name);
|
|
180
|
+
const reporter = reporterFor(mode, io, {
|
|
181
|
+
live: values["no-live"] !== true,
|
|
182
|
+
// Only people see the estimate; other modes skip reading earlier runs.
|
|
183
|
+
...(mode === "human" && skipped.length > 0
|
|
184
|
+
? { skipEstimate: await estimateSkipped(config.root, skipped) }
|
|
185
|
+
: {}),
|
|
186
|
+
});
|
|
187
|
+
try {
|
|
188
|
+
const result = await withInterruptHandling(() => reporter.onInterrupt?.(), (signal) => runVerification({
|
|
189
|
+
config,
|
|
190
|
+
selection,
|
|
191
|
+
concurrency,
|
|
192
|
+
failFast,
|
|
193
|
+
previewLines,
|
|
194
|
+
signal,
|
|
195
|
+
reporter,
|
|
196
|
+
version: version(),
|
|
197
|
+
env: io.env,
|
|
198
|
+
}));
|
|
199
|
+
return result.exitCode;
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
reporter.dispose?.();
|
|
203
|
+
}
|
|
176
204
|
}
|
|
177
205
|
catch (error) {
|
|
178
206
|
if (error instanceof VerifikaError) {
|
|
@@ -192,7 +220,19 @@ async function runInit(io, values) {
|
|
|
192
220
|
const root = values.config ? path.dirname(path.resolve(io.cwd, values.config)) : io.cwd;
|
|
193
221
|
const { file, checks } = await initConfig(root, Boolean(values.force));
|
|
194
222
|
io.out(`Created ${path.relative(io.cwd, file) || file} with checks: ${checks.join(", ")}\n`);
|
|
195
|
-
|
|
223
|
+
const scripts = await addVerifyScripts(root);
|
|
224
|
+
if (scripts.added.length > 0) {
|
|
225
|
+
const added = scripts.added.map((name) => `"${name}": "${VERIFY_SCRIPTS[name]}"`);
|
|
226
|
+
io.out(`Added package.json scripts: ${added.join(", ")}\n`);
|
|
227
|
+
}
|
|
228
|
+
for (const { name, command } of scripts.kept) {
|
|
229
|
+
if (command === VERIFY_SCRIPTS[name]) {
|
|
230
|
+
io.out(`package.json already has "${name}": "${command}"\n`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
io.out(`Kept the existing "${name}" script (${command}); to use Verifika, change it to "${VERIFY_SCRIPTS[name]}" once the checks cover everything it runs\n`);
|
|
234
|
+
}
|
|
235
|
+
io.out('Review the commands, add "paths" to checks that only some files affect, format the file if your repository checks formatting, then run: vfk plan\n');
|
|
196
236
|
return ExitCode.Passed;
|
|
197
237
|
}
|
|
198
238
|
function parse(args, command) {
|
|
@@ -208,7 +248,7 @@ function parse(args, command) {
|
|
|
208
248
|
}
|
|
209
249
|
catch (error) {
|
|
210
250
|
const message = error.message.replace(/\. To specify a positional argument.*$/s, "");
|
|
211
|
-
throw new VerifikaError(message, `Run "
|
|
251
|
+
throw new VerifikaError(message, `Run "vfk --help" for usage.`);
|
|
212
252
|
}
|
|
213
253
|
}
|
|
214
254
|
function integerOption(value, flag, min) {
|
|
@@ -241,13 +281,22 @@ function guessMode(argv, io) {
|
|
|
241
281
|
? "human"
|
|
242
282
|
: "agent";
|
|
243
283
|
}
|
|
244
|
-
function reporterFor(mode, io) {
|
|
284
|
+
function reporterFor(mode, io, options) {
|
|
245
285
|
switch (mode) {
|
|
246
286
|
case "human": {
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
287
|
+
const style = humanStyle(io);
|
|
288
|
+
// The live block redraws in place, so it needs a real, capable terminal that
|
|
289
|
+
// nobody is logging; everywhere else the line reporter is the fallback.
|
|
290
|
+
const live = options.live &&
|
|
291
|
+
io.terminal !== undefined &&
|
|
292
|
+
io.isTTY &&
|
|
293
|
+
!detectCi(io.env).ci &&
|
|
294
|
+
io.env.TERM !== "dumb" &&
|
|
295
|
+
!isOff(io.env.VERIFIKA_LIVE);
|
|
296
|
+
const estimate = options.skipEstimate ? { skipEstimate: options.skipEstimate } : {};
|
|
297
|
+
if (live && io.terminal)
|
|
298
|
+
return liveReporter(io.terminal, style, estimate);
|
|
299
|
+
return humanReporter(io, style, { heartbeat: io.isTTY, ...estimate });
|
|
251
300
|
}
|
|
252
301
|
case "agent":
|
|
253
302
|
return agentReporter(io);
|
|
@@ -257,6 +306,28 @@ function reporterFor(mode, io) {
|
|
|
257
306
|
return quietReporter(io);
|
|
258
307
|
}
|
|
259
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* On a terminal, `vfk logs` speaks to a person: local relative times, clickable
|
|
311
|
+
* log paths (not in CI, whose log viewers show escape codes), and a pager.
|
|
312
|
+
*/
|
|
313
|
+
function logsDisplay(io, noPager) {
|
|
314
|
+
if (!io.isTTY)
|
|
315
|
+
return {};
|
|
316
|
+
const capable = !detectCi(io.env).ci && io.env.TERM !== "dumb";
|
|
317
|
+
const pager = pagerCommand(io.env);
|
|
318
|
+
return {
|
|
319
|
+
display: { now: new Date(), hyperlinks: capable && !isOff(io.env.VERIFIKA_HYPERLINKS) },
|
|
320
|
+
...(noPager || !pagerAvailable(pager, io.env) ? {} : { pager }),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/** Colour on a terminal unless NO_COLOR is set; FORCE_COLOR enables it elsewhere. */
|
|
324
|
+
function humanStyle(io) {
|
|
325
|
+
const color = io.isTTY && !io.env.NO_COLOR ? true : Boolean(io.env.FORCE_COLOR && io.env.FORCE_COLOR !== "0");
|
|
326
|
+
return color ? colors : plain;
|
|
327
|
+
}
|
|
328
|
+
function isOff(value) {
|
|
329
|
+
return value !== undefined && ["0", "false", "no", "off"].includes(value.trim().toLowerCase());
|
|
330
|
+
}
|
|
260
331
|
function reportError(error, mode, io) {
|
|
261
332
|
if (mode === "json") {
|
|
262
333
|
const document = {
|
|
@@ -274,14 +345,16 @@ function reportError(error, mode, io) {
|
|
|
274
345
|
* First SIGINT/SIGTERM/SIGHUP stops scheduling and terminates running checks
|
|
275
346
|
* gracefully; a second one kills them immediately.
|
|
276
347
|
*/
|
|
277
|
-
async function withInterruptHandling(operation) {
|
|
348
|
+
async function withInterruptHandling(onFirstSignal, operation) {
|
|
278
349
|
const controller = new AbortController();
|
|
279
350
|
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
280
351
|
const onSignal = (signal) => {
|
|
281
352
|
if (controller.signal.aborted)
|
|
282
353
|
killAllNow();
|
|
283
|
-
else
|
|
354
|
+
else {
|
|
355
|
+
onFirstSignal();
|
|
284
356
|
controller.abort(signal);
|
|
357
|
+
}
|
|
285
358
|
};
|
|
286
359
|
for (const signal of signals)
|
|
287
360
|
process.on(signal, onSignal);
|
package/dist/pager.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { accessSync, constants } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/** Used when `PAGER` is unset: quit if one screen, keep colours, leave output on screen. */
|
|
5
|
+
export const DEFAULT_PAGER = "less -R -F -X";
|
|
6
|
+
/** The pager command line from `PAGER`, or the default. */
|
|
7
|
+
export function pagerCommand(env) {
|
|
8
|
+
const configured = env.PAGER?.trim();
|
|
9
|
+
return configured ? configured : DEFAULT_PAGER;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Whether the pager's program exists, so a missing one (e.g. `less` on Windows)
|
|
13
|
+
* falls back to plain output instead of a shell error. Checks the first word of
|
|
14
|
+
* the command (which may be quoted): a path, or a name looked up on PATH (with
|
|
15
|
+
* PATHEXT on Windows).
|
|
16
|
+
*/
|
|
17
|
+
export function pagerAvailable(command, env) {
|
|
18
|
+
const match = /^\s*(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(command);
|
|
19
|
+
const program = match?.[1] ?? match?.[2] ?? match?.[3] ?? "";
|
|
20
|
+
if (program === "")
|
|
21
|
+
return false;
|
|
22
|
+
const isWindows = process.platform === "win32";
|
|
23
|
+
const extensions = isWindows
|
|
24
|
+
? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)]
|
|
25
|
+
: [""];
|
|
26
|
+
const candidates = program.includes("/") || program.includes("\\")
|
|
27
|
+
? [program]
|
|
28
|
+
: (env.PATH ?? env.Path ?? "")
|
|
29
|
+
.split(path.delimiter)
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.map((directory) => path.join(directory, program));
|
|
32
|
+
return candidates.some((candidate) => extensions.some((extension) => {
|
|
33
|
+
try {
|
|
34
|
+
accessSync(candidate + extension, isWindows ? constants.F_OK : constants.X_OK);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Pipes everything `produce` writes into the pager, which takes over the
|
|
44
|
+
* terminal until the user quits it. Quitting early is not an error: further
|
|
45
|
+
* output is dropped. Ctrl-C belongs to the pager meanwhile, so Verifika ignores
|
|
46
|
+
* SIGINT until the pager exits (as git does).
|
|
47
|
+
*/
|
|
48
|
+
export async function withPager(command, env, produce) {
|
|
49
|
+
// Like git: colours and quit-if-one-screen for a plain `less`, unless the user configured LESS.
|
|
50
|
+
const child = spawn(command, {
|
|
51
|
+
shell: true,
|
|
52
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
53
|
+
env: { LESS: "FRX", ...env },
|
|
54
|
+
});
|
|
55
|
+
const ignoreInterrupt = () => undefined;
|
|
56
|
+
process.on("SIGINT", ignoreInterrupt);
|
|
57
|
+
const exited = new Promise((resolve) => {
|
|
58
|
+
child.once("close", () => resolve());
|
|
59
|
+
child.once("error", () => resolve());
|
|
60
|
+
});
|
|
61
|
+
let closed = false;
|
|
62
|
+
const stdin = child.stdin;
|
|
63
|
+
stdin.on("error", () => {
|
|
64
|
+
// EPIPE: the user quit the pager before reading everything.
|
|
65
|
+
closed = true;
|
|
66
|
+
});
|
|
67
|
+
child.once("close", () => {
|
|
68
|
+
closed = true;
|
|
69
|
+
});
|
|
70
|
+
try {
|
|
71
|
+
await produce((text) => {
|
|
72
|
+
if (closed)
|
|
73
|
+
return;
|
|
74
|
+
if (!stdin.write(text)) {
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
const done = () => {
|
|
77
|
+
stdin.off("drain", done);
|
|
78
|
+
child.off("close", done);
|
|
79
|
+
resolve();
|
|
80
|
+
};
|
|
81
|
+
stdin.once("drain", done);
|
|
82
|
+
child.once("close", done);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
stdin.end();
|
|
89
|
+
await exited;
|
|
90
|
+
process.off("SIGINT", ignoreInterrupt);
|
|
91
|
+
}
|
|
92
|
+
}
|
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
|
|
@@ -13,16 +13,13 @@ export function renderAgent(result) {
|
|
|
13
13
|
`${verdict(result)} ${summary.passed}/${summary.selected} (${duration(result.durationMs)}) ${selectionLabel(result.selection, summary.skipped)}`,
|
|
14
14
|
];
|
|
15
15
|
if (summary.selected === 0)
|
|
16
|
-
lines.push("Nothing to verify for these changes. Full run:
|
|
17
|
-
|
|
16
|
+
lines.push("Nothing to verify for these changes. Full run: vfk --all");
|
|
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)
|