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/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 = `verifika - run the checks your changes need, with minimal output
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
- verifika [run] [options] Verify (selective locally, full in CI)
23
- verifika plan [options] Show which checks would run and why
24
- verifika logs [check...] Show logs from the latest run
25
- verifika init [--force] Create verifika.config.json from package.json scripts
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 !== "quiet")
151
- io.out(renderPlan(selection, mode === "human" ? 50 : 20));
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 result = await withInterruptHandling((signal) => runVerification({
165
- config,
166
- selection,
167
- concurrency,
168
- failFast,
169
- previewLines,
170
- signal,
171
- reporter: reporterFor(mode, io),
172
- version: version(),
173
- env: io.env,
174
- }));
175
- return result.exitCode;
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
- io.out('Review the commands, add "paths" to checks that only some files affect, format the file if your repository checks formatting, then run: verifika plan\n');
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 "verifika --help" for usage.`);
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 color = io.isTTY && !io.env.NO_COLOR
248
- ? true
249
- : Boolean(io.env.FORCE_COLOR && io.env.FORCE_COLOR !== "0");
250
- return humanReporter(io, color ? colors : plain, { heartbeat: io.isTTY });
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
- /** Adds dependencies of selected checks, then emits decisions in config order. */
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, then exception lines
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
- headerShown = false;
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 definite = !isJson && DEFINITE_FAILURE.test(line);
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 (definite || (this.continueDefinite && indented && !/^\s*at\s/.test(line))) {
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.headerShown = false;
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.headerShown && list.length + 2 <= this.limit) {
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.headerShown = true;
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 [child.stdout, child.stderr]) {
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) => capture.write(stream, decoder.write(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;
@@ -1,4 +1,4 @@
1
- import { describeProblem, duration, isFailureStatus, logsHint, MAX_PREVIEWED_FAILURES, problems, selectionLabel, verdict, } from "./reporter.js";
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: verifika --all");
17
- let previewed = 0;
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
- lines.push(`${check.name}: ${describeProblem(check)}`);
20
- if (isFailureStatus(check.status) &&
21
- check.preview.length > 0 &&
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)