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/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
@@ -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
- let previewed = 0;
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)
@@ -1,4 +1,5 @@
1
- import { describeProblem, duration, isFailureStatus, logsHint, MAX_PREVIEWED_FAILURES, problems, selectionLabel, } from "./reporter.js";
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({ selection, selected }) {
30
- const skipped = selection.checks.length - selected.length;
31
- output.out(`${style.bold("verifika")} ${selectionLabel(selection, skipped)}\n`);
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
- const detail = check.status === "passed" ? "" : ` ${describeProblem(check)}`;
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
- let previewed = 0;
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)} ${check.name}: ${describeProblem(check)}`);
116
+ lines.push(`${symbol(check.status, style)} ${name} ${describeProblem(check)}`);
81
117
  continue;
82
118
  }
83
- lines.push(`${symbol(check.status, style)} ${style.bold(check.name)}: ${describeProblem(check)}`);
84
- if (check.preview.length > 0 && previewed < MAX_PREVIEWED_FAILURES) {
85
- previewed++;
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
+ }
@@ -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
- lines.push(` ${check.selected ? "run " : "skip"} ${check.name.padEnd(width)} ${check.reason}`);
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
- const command = withLogs.length === 1 ? `vfk logs ${withLogs[0]?.name}` : "vfk logs --failed";
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"