verifika 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +26 -12
- package/dist/cli.js +4 -0
- package/dist/init.js +39 -1
- package/dist/logs.js +58 -17
- package/dist/main.js +96 -22
- package/dist/pager.js +92 -0
- package/dist/planner.js +20 -7
- package/dist/preview.js +66 -7
- package/dist/process.js +9 -2
- package/dist/reporters/agent.js +5 -8
- package/dist/reporters/human.js +71 -21
- package/dist/reporters/live.js +209 -0
- package/dist/reporters/plan.js +4 -2
- package/dist/reporters/reporter.js +25 -1
- package/dist/runner.js +14 -1
- package/dist/runs.js +39 -0
- package/package.json +3 -3
- package/skills/verifika/SKILL.md +19 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/). The CLI flags, exit codes, configuration format (`version: 1`) and JSON output (`schemaVersion: 1`) are the public API.
|
|
4
4
|
|
|
5
|
+
## [0.0.3] - 2026-10-13
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Live status block in `human` mode on an interactive terminal: progress and elapsed time, each running check with a spinner, its elapsed time and latest output line, and what queued checks wait on. Finished checks print above it as in line mode. Line output remains the fallback in CI, when piped, with `TERM=dumb`, and with the new `--no-live` flag or `VERIFIKA_LIVE=0`.
|
|
10
|
+
- `human` output: a `verifika · <selection> · <n> checks · <j> parallel` header, aligned durations, the command and a rerun hint (`vfk -c <check>`) on failure lines, a `○ not run: build failed` marker for checks blocked by a failed dependency, and an estimate of the time saved by skipped checks from their last recorded durations.
|
|
11
|
+
- In `human` mode, the first interrupt prints `stopping… (Ctrl-C again to force)`.
|
|
12
|
+
- `vfk logs` on a terminal: local and relative start time (`today 01:12, 3m ago`), clickable OSC 8 log paths (opt out with `VERIFIKA_HYPERLINKS=0`), and paging through `$PAGER` or `less -R -F -X` (`--no-pager` to opt out; never with `--tail`, when piped, or in `agent` mode).
|
|
13
|
+
- `vfk plan` in `human` mode colours `run` and dims skipped checks.
|
|
14
|
+
- `vfk init` adds `"verify": "vfk"` and `"verify:ci": "vfk --all"` to `package.json` when they are absent, keeping its indentation, line endings, key order and trailing newline; existing scripts are never overwritten.
|
|
15
|
+
- JSON (additive, `schemaVersion: 1`): `checks[].blockedBy` on blocked checks in run results, naming the dependency that did not pass; `checks[].requiredBy` in plan output on checks selected only as dependencies.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **`npm run verify` is now selective.** The recommended scripts are `"verify": "vfk"` for local work and `"verify:ci": "vfk --all"` for the full gate; use `npm run verify:ci` (or `vfk --all`) in CI and before delivery. The README setup prompt, docs and agent skill say so, and the prompt only replaces an existing `verify` script when the configured checks cover what it ran. If you followed the 0.0.2 setup, your `"verify": "vfk --all"` script still runs the full gate; to adopt the split, rename it to `verify:ci` and add `"verify": "vfk"` by hand (`vfk init` only adds scripts when it creates the config).
|
|
20
|
+
- A check pulled in by several dependents now names all of them (`required by test, pack`), in plan output and reasons.
|
|
21
|
+
- Failure previews leave out package-manager script banners (`> app@1.0.0 lint`, `> eslint .`, yarn's `$ …`).
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- ESLint failure previews showed only the `✖ N problems` summary; they now keep each `line:col error` line under its file heading, plus the summary.
|
|
26
|
+
- A failed check whose preview was hidden by the three-preview limit looked as if nothing was found; its line now says `(preview omitted: vfk logs <check>)`, in `human` and `agent` output.
|
|
27
|
+
|
|
5
28
|
## [0.0.2] - 2026-10-12
|
|
6
29
|
|
|
7
30
|
### Changed
|
|
@@ -37,5 +60,6 @@ First public release.
|
|
|
37
60
|
- A process that deliberately detaches from the check (on Linux and macOS: leaves the process group, for example via `setsid`; on Windows: breaks away from the job with an exited intermediate parent) is not tracked unless it keeps the check's output open, in which case the check is reported as `error`.
|
|
38
61
|
- On Windows, confirming cleanup queries the process table with PowerShell after each check, which costs roughly 0.5–3 s per check.
|
|
39
62
|
|
|
63
|
+
[0.0.3]: https://github.com/alexportelli12/verifika/compare/v0.0.2...v0.0.3
|
|
40
64
|
[0.0.2]: https://github.com/alexportelli12/verifika/releases/tag/v0.0.2
|
|
41
65
|
[0.0.1]: https://github.com/alexportelli12/verifika/releases/tag/v0.0.1
|
package/README.md
CHANGED
|
@@ -62,7 +62,14 @@ Set up Verifika (https://github.com/alexportelli12/verifika) in this repository.
|
|
|
62
62
|
runs on every change.
|
|
63
63
|
- Add `dependsOn` where one check needs another's output (for example e2e after build).
|
|
64
64
|
- Keep concurrency at 1 unless the checks clearly share no build outputs.
|
|
65
|
-
-
|
|
65
|
+
- Make sure package.json has two scripts: "verify": "vfk" (selective, for local
|
|
66
|
+
work) and "verify:ci": "vfk --all" (the full gate). `vfk init` adds them when
|
|
67
|
+
they are missing. If a "verify" or "verify:ci" script already exists, replace
|
|
68
|
+
it only when the configured checks cover everything it ran; otherwise leave it
|
|
69
|
+
and report the conflict. This is the one exception to the rule below about
|
|
70
|
+
existing scripts.
|
|
71
|
+
- If the CI workflow runs the gate some other way, report that switching it to
|
|
72
|
+
`npm run verify:ci` is recommended. Do not edit the workflow.
|
|
66
73
|
Reference: https://github.com/alexportelli12/verifika/blob/main/docs/configuration.md
|
|
67
74
|
3. Validate the config:
|
|
68
75
|
- Run `npx vfk --all`. If a check fails, decide whether the config is wrong (fix it)
|
|
@@ -74,9 +81,11 @@ Set up Verifika (https://github.com/alexportelli12/verifika) in this repository.
|
|
|
74
81
|
agent's skills directory (see
|
|
75
82
|
https://github.com/alexportelli12/verifika/blob/main/docs/agents.md).
|
|
76
83
|
|
|
77
|
-
Do not change source code or existing scripts to make checks pass
|
|
84
|
+
Do not change source code or existing scripts to make checks pass (except "verify"
|
|
85
|
+
and "verify:ci", as step 2 describes), and do not commit.
|
|
78
86
|
Finish with: the checks you configured and why, the --all result, the expected
|
|
79
|
-
selection for those two edits,
|
|
87
|
+
selection for those two edits, the scripts you added, replaced or left (with any
|
|
88
|
+
conflicts), any CI recommendation, and any existing failures you found.
|
|
80
89
|
```
|
|
81
90
|
|
|
82
91
|
Review the config it writes before committing it: the commands in it run on every verification.
|
|
@@ -85,7 +94,8 @@ Review the config it writes before committing it: the commands in it run on ever
|
|
|
85
94
|
|
|
86
95
|
```sh
|
|
87
96
|
npm install --save-dev verifika
|
|
88
|
-
npx vfk init # creates verifika.config.json from your package.json scripts
|
|
97
|
+
npx vfk init # creates verifika.config.json from your package.json scripts,
|
|
98
|
+
# and adds "verify" / "verify:ci" scripts if they are missing
|
|
89
99
|
```
|
|
90
100
|
|
|
91
101
|
`init` only maps conventional script names (`lint`, `test`, `build`, ...) and leaves every check unfiltered, so open the file and give each check `paths`:
|
|
@@ -115,12 +125,14 @@ npx vfk # run them
|
|
|
115
125
|
npx vfk --all # run everything (do this before you push)
|
|
116
126
|
```
|
|
117
127
|
|
|
118
|
-
|
|
128
|
+
`init` adds two scripts unless package.json already has scripts with those names (it never overwrites them):
|
|
119
129
|
|
|
120
130
|
```json
|
|
121
|
-
{ "scripts": { "verify": "vfk --all" } }
|
|
131
|
+
{ "scripts": { "verify": "vfk", "verify:ci": "vfk --all" } }
|
|
122
132
|
```
|
|
123
133
|
|
|
134
|
+
`npm run verify` runs the checks your changes affect, for quick feedback while you work. `npm run verify:ci` runs every check: it is the full gate, so use it in CI and before you push or open a pull request.
|
|
135
|
+
|
|
124
136
|
Run logs go to `.verifika/`, which ignores itself in git, so you do not need to edit `.gitignore`. To install globally instead, run `npm install --global verifika`.
|
|
125
137
|
|
|
126
138
|
## How checks are selected
|
|
@@ -141,12 +153,14 @@ A check without `paths` runs whenever any file changed; `"always": true` runs it
|
|
|
141
153
|
|
|
142
154
|
The default is `human` on an interactive terminal and `agent` otherwise. Override with `--mode` or `VERIFIKA_MODE`.
|
|
143
155
|
|
|
144
|
-
| Mode | Success | Failure
|
|
145
|
-
| ------- | ----------------------------- |
|
|
146
|
-
| `human` | Live
|
|
147
|
-
| `agent` | One line: `PASS 4/4 (4.3s) …` | One line per problem, bounded preview, logs command
|
|
148
|
-
| `json` | Versioned result document | Same document, `"status": "failed"`
|
|
149
|
-
| `quiet` | Nothing | One line on stderr
|
|
156
|
+
| Mode | Success | Failure |
|
|
157
|
+
| ------- | ----------------------------- | --------------------------------------------------------------- |
|
|
158
|
+
| `human` | Live status block, summary | Failing checks with command and rerun hint, previews, logs hint |
|
|
159
|
+
| `agent` | One line: `PASS 4/4 (4.3s) …` | One line per problem, bounded preview, logs command |
|
|
160
|
+
| `json` | Versioned result document | Same document, `"status": "failed"` |
|
|
161
|
+
| `quiet` | Nothing | One line on stderr |
|
|
162
|
+
|
|
163
|
+
In `human` mode on an interactive terminal, a live status block shows progress, each running check with its latest output line, and what the queued checks wait on; finished checks print above it. In CI, when piped, with `TERM=dumb`, or with `--no-live` / `VERIFIKA_LIVE=0`, `human` mode prints plain lines instead (one per check start and end, plus a note every 30 seconds for long checks), which read well in logs. `NO_COLOR` turns colour off in both.
|
|
150
164
|
|
|
151
165
|
Exit codes: `0` passed (or nothing to verify), `1` verification failed, `2` usage or configuration error, `128+n` interrupted by a signal (`130` SIGINT, `143` SIGTERM).
|
|
152
166
|
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { ExitCode } from "./errors.js";
|
|
3
3
|
import { main } from "./main.js";
|
|
4
|
+
import { systemTerminal } from "./reporters/live.js";
|
|
4
5
|
const write = (stream) => (text) => {
|
|
5
6
|
stream.write(text);
|
|
6
7
|
};
|
|
@@ -17,6 +18,9 @@ main(process.argv.slice(2), {
|
|
|
17
18
|
isTTY: Boolean(process.stdout.isTTY),
|
|
18
19
|
out: write(process.stdout),
|
|
19
20
|
err: write(process.stderr),
|
|
21
|
+
...(process.stdout.isTTY
|
|
22
|
+
? { terminal: systemTerminal(write(process.stdout), write(process.stderr), process.stdout) }
|
|
23
|
+
: {}),
|
|
20
24
|
}).then((code) => {
|
|
21
25
|
process.exitCode = code;
|
|
22
26
|
}, (error) => {
|
package/dist/init.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { CONFIG_FILE } from "./config.js";
|
|
5
5
|
import { VerifikaError } from "./errors.js";
|
|
@@ -14,6 +14,44 @@ const CANDIDATES = [
|
|
|
14
14
|
["build", ["build"]],
|
|
15
15
|
];
|
|
16
16
|
const NPM_PLACEHOLDER_TEST = /no test specified/;
|
|
17
|
+
/** npm scripts `vfk init` adds: selective verification while working, the full gate for CI. */
|
|
18
|
+
export const VERIFY_SCRIPTS = {
|
|
19
|
+
verify: "vfk",
|
|
20
|
+
"verify:ci": "vfk --all",
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Adds the `verify` and `verify:ci` scripts to package.json where they are
|
|
24
|
+
* absent; existing scripts are never overwritten. Keeps the file's indentation,
|
|
25
|
+
* line endings, key order and trailing newline.
|
|
26
|
+
*/
|
|
27
|
+
export async function addVerifyScripts(root) {
|
|
28
|
+
const file = path.join(root, "package.json");
|
|
29
|
+
const raw = await readFile(file, "utf8");
|
|
30
|
+
const bom = raw.startsWith("\uFEFF") ? "\uFEFF" : "";
|
|
31
|
+
const text = raw.slice(bom.length);
|
|
32
|
+
const manifest = JSON.parse(text);
|
|
33
|
+
const scripts = (manifest.scripts ??= {});
|
|
34
|
+
const result = { added: [], kept: [] };
|
|
35
|
+
for (const [name, command] of Object.entries(VERIFY_SCRIPTS)) {
|
|
36
|
+
const existing = scripts[name];
|
|
37
|
+
if (existing === undefined) {
|
|
38
|
+
scripts[name] = command;
|
|
39
|
+
result.added.push(name);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
result.kept.push({ name, command: existing });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (result.added.length === 0)
|
|
46
|
+
return result;
|
|
47
|
+
// A one-line manifest stays on one line.
|
|
48
|
+
const indent = text.trim().includes("\n") ? (/^[ \t]+(?=")/m.exec(text)?.[0] ?? " ") : "";
|
|
49
|
+
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
|
50
|
+
const trailing = /\r?\n$/.test(text) ? newline : "";
|
|
51
|
+
const json = JSON.stringify(manifest, null, indent).replace(/\n/g, newline);
|
|
52
|
+
await writeFile(file, `${bom}${json}${trailing}`);
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
17
55
|
/**
|
|
18
56
|
* Writes a starter verifika.config.json from the package.json scripts in `root`.
|
|
19
57
|
* Checks are created without path filters (they run on any change); narrowing
|
package/dist/logs.js
CHANGED
|
@@ -2,7 +2,9 @@ import { createReadStream } from "node:fs";
|
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
3
|
import { open } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
5
6
|
import { VerifikaError } from "./errors.js";
|
|
7
|
+
import { withPager } from "./pager.js";
|
|
6
8
|
import { duration, isFailureStatus, selectionLabel } from "./reporters/reporter.js";
|
|
7
9
|
import { plain, symbol } from "./reporters/human.js";
|
|
8
10
|
import { stripAnsi } from "./preview.js";
|
|
@@ -15,7 +17,7 @@ import { readRun } from "./runs.js";
|
|
|
15
17
|
export async function showLogs(request, output) {
|
|
16
18
|
const run = await readRun(request.root, request.runId);
|
|
17
19
|
if (request.checks.length === 0 && !request.failed) {
|
|
18
|
-
output.out(renderRunSummary(run));
|
|
20
|
+
output.out(renderRunSummary(run, request.display));
|
|
19
21
|
return 0;
|
|
20
22
|
}
|
|
21
23
|
let targets;
|
|
@@ -35,20 +37,29 @@ export async function showLogs(request, output) {
|
|
|
35
37
|
targets = request.checks.map((name) => byName.get(name));
|
|
36
38
|
}
|
|
37
39
|
const headers = request.failed || targets.length > 1;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
const print = async (write) => {
|
|
41
|
+
for (const check of targets) {
|
|
42
|
+
if (headers)
|
|
43
|
+
await write(`── ${check.name}: ${check.status} (${check.reason}) ──\n`);
|
|
44
|
+
if (!check.log) {
|
|
45
|
+
await write(`${check.name} has no log: ${check.status} (${check.reason})\n`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
await printLog(path.join(request.root, check.log), request, write);
|
|
44
49
|
}
|
|
45
|
-
|
|
50
|
+
};
|
|
51
|
+
if (request.pager !== undefined && request.tail === undefined) {
|
|
52
|
+
await withPager(request.pager, request.env ?? process.env, print);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
await print((text) => output.out(text));
|
|
46
56
|
}
|
|
47
57
|
return 0;
|
|
48
58
|
}
|
|
49
|
-
export function renderRunSummary(run) {
|
|
59
|
+
export function renderRunSummary(run, display) {
|
|
60
|
+
const started = display ? describeTime(new Date(run.startedAt), display.now) : run.startedAt;
|
|
50
61
|
const lines = [
|
|
51
|
-
`Run ${run.runId} · ${run.status} · ${duration(run.durationMs)} · ${
|
|
62
|
+
`Run ${run.runId} · ${run.status} · ${duration(run.durationMs)} · ${started}`,
|
|
52
63
|
selectionLabel(run.selection, run.summary.skipped),
|
|
53
64
|
];
|
|
54
65
|
if (run.status === "running")
|
|
@@ -60,8 +71,9 @@ export function renderRunSummary(run) {
|
|
|
60
71
|
parts.push(duration(check.durationMs).padStart(6));
|
|
61
72
|
if (check.status !== "passed")
|
|
62
73
|
parts.push(check.reason);
|
|
63
|
-
if (check.log)
|
|
64
|
-
parts.push(check.log);
|
|
74
|
+
if (check.log) {
|
|
75
|
+
parts.push(display?.hyperlinks ? hyperlink(check.log, path.join(run.root, check.log)) : check.log);
|
|
76
|
+
}
|
|
65
77
|
lines.push(parts.join(" "));
|
|
66
78
|
}
|
|
67
79
|
// One line for everything not selected; large configs would otherwise list dozens.
|
|
@@ -72,12 +84,41 @@ export function renderRunSummary(run) {
|
|
|
72
84
|
}
|
|
73
85
|
return `${lines.join("\n")}\n`;
|
|
74
86
|
}
|
|
75
|
-
|
|
87
|
+
/**
|
|
88
|
+
* `today 01:12, 3m ago`; `yesterday 23:40, 2h ago`; otherwise the local date.
|
|
89
|
+
* Local time, because that is what the user's clock shows.
|
|
90
|
+
*/
|
|
91
|
+
export function describeTime(time, now) {
|
|
92
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
93
|
+
const clock = `${pad(time.getHours())}:${pad(time.getMinutes())}`;
|
|
94
|
+
const day = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
95
|
+
const days = Math.round((day(now) - day(time)) / 86_400_000);
|
|
96
|
+
const date = days === 0
|
|
97
|
+
? "today"
|
|
98
|
+
: days === 1
|
|
99
|
+
? "yesterday"
|
|
100
|
+
: `${time.getFullYear()}-${pad(time.getMonth() + 1)}-${pad(time.getDate())}`;
|
|
101
|
+
return `${date} ${clock}, ${ago(now.getTime() - time.getTime())}`;
|
|
102
|
+
}
|
|
103
|
+
function ago(ms) {
|
|
104
|
+
if (ms < 60_000)
|
|
105
|
+
return ms < 1_000 ? "just now" : `${Math.floor(ms / 1000)}s ago`;
|
|
106
|
+
if (ms < 3_600_000)
|
|
107
|
+
return `${Math.floor(ms / 60_000)}m ago`;
|
|
108
|
+
if (ms < 86_400_000)
|
|
109
|
+
return `${Math.floor(ms / 3_600_000)}h ago`;
|
|
110
|
+
return `${Math.floor(ms / 86_400_000)}d ago`;
|
|
111
|
+
}
|
|
112
|
+
/** An OSC 8 terminal hyperlink to a local file; terminals without support show just the text. */
|
|
113
|
+
export function hyperlink(text, file) {
|
|
114
|
+
return `\u001b]8;;${pathToFileURL(file).href}\u001b\\${text}\u001b]8;;\u001b\\`;
|
|
115
|
+
}
|
|
116
|
+
async function printLog(file, { tail, stripAnsi: strip = false }, write) {
|
|
76
117
|
try {
|
|
77
118
|
if (tail !== undefined) {
|
|
78
119
|
const text = await readTail(file, tail);
|
|
79
120
|
const shown = strip ? stripAnsi(text) : text;
|
|
80
|
-
|
|
121
|
+
await write(shown.endsWith("\n") || shown === "" ? shown : `${shown}\n`);
|
|
81
122
|
return;
|
|
82
123
|
}
|
|
83
124
|
if (strip) {
|
|
@@ -87,16 +128,16 @@ async function printLog(file, { tail, stripAnsi: strip = false }, output) {
|
|
|
87
128
|
crlfDelay: Infinity,
|
|
88
129
|
});
|
|
89
130
|
for await (const line of lines)
|
|
90
|
-
|
|
131
|
+
await write(`${stripAnsi(line)}\n`);
|
|
91
132
|
return;
|
|
92
133
|
}
|
|
93
134
|
let last = "";
|
|
94
135
|
for await (const chunk of createReadStream(file, { encoding: "utf8" })) {
|
|
95
|
-
|
|
136
|
+
await write(chunk);
|
|
96
137
|
last = chunk;
|
|
97
138
|
}
|
|
98
139
|
if (last !== "" && !last.endsWith("\n"))
|
|
99
|
-
|
|
140
|
+
await write("\n");
|
|
100
141
|
}
|
|
101
142
|
catch (error) {
|
|
102
143
|
throw new VerifikaError(`cannot read log ${file}: ${error.message}`);
|
package/dist/main.js
CHANGED
|
@@ -3,17 +3,21 @@ 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
23
|
export const HELP = `vfk - run the checks your changes need, with minimal output
|
|
@@ -24,6 +28,7 @@ Usage:
|
|
|
24
28
|
vfk plan [options] Show which checks would run and why
|
|
25
29
|
vfk logs [check...] Show logs from the latest run
|
|
26
30
|
vfk init [--force] Create verifika.config.json from package.json scripts
|
|
31
|
+
and add "verify"/"verify:ci" scripts if absent
|
|
27
32
|
|
|
28
33
|
Selection (run, plan):
|
|
29
34
|
--all Run every check
|
|
@@ -38,11 +43,15 @@ Execution (run):
|
|
|
38
43
|
--fail-fast Stop everything after the first failure
|
|
39
44
|
--no-fail-fast Run every selected check even after a failure
|
|
40
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)
|
|
41
48
|
|
|
42
49
|
Logs:
|
|
43
50
|
--failed Print logs of every failed check
|
|
44
51
|
--tail <lines> Print only the last lines of each log
|
|
45
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)
|
|
46
55
|
|
|
47
56
|
Output:
|
|
48
57
|
--mode <mode> human | agent | json | quiet
|
|
@@ -77,6 +86,7 @@ const OPTIONS = {
|
|
|
77
86
|
"fail-fast": { type: "boolean" },
|
|
78
87
|
"no-fail-fast": { type: "boolean" },
|
|
79
88
|
preview: { type: "string" },
|
|
89
|
+
"no-live": { type: "boolean" },
|
|
80
90
|
},
|
|
81
91
|
plan: { ...SHARED, ...SELECTION },
|
|
82
92
|
logs: {
|
|
@@ -84,6 +94,7 @@ const OPTIONS = {
|
|
|
84
94
|
failed: { type: "boolean" },
|
|
85
95
|
tail: { type: "string" },
|
|
86
96
|
run: { type: "string" },
|
|
97
|
+
"no-pager": { type: "boolean" },
|
|
87
98
|
},
|
|
88
99
|
init: { ...SHARED, force: { type: "boolean" } },
|
|
89
100
|
help: SHARED,
|
|
@@ -123,6 +134,9 @@ export async function main(argv, io) {
|
|
|
123
134
|
...(values.run !== undefined ? { runId: values.run } : {}),
|
|
124
135
|
...(values.tail !== undefined ? { tail: integerOption(values.tail, "--tail", 1) } : {}),
|
|
125
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) : {}),
|
|
126
140
|
}, io);
|
|
127
141
|
}
|
|
128
142
|
const request = {
|
|
@@ -146,8 +160,10 @@ export async function main(argv, io) {
|
|
|
146
160
|
if (command === "plan") {
|
|
147
161
|
if (mode === "json")
|
|
148
162
|
io.out(planJson(selection, config));
|
|
149
|
-
else if (mode
|
|
150
|
-
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));
|
|
151
167
|
return ExitCode.Passed;
|
|
152
168
|
}
|
|
153
169
|
if (values["fail-fast"] && values["no-fail-fast"]) {
|
|
@@ -160,18 +176,31 @@ export async function main(argv, io) {
|
|
|
160
176
|
const previewLines = values.preview !== undefined
|
|
161
177
|
? integerOption(values.preview, "--preview", 0)
|
|
162
178
|
: DEFAULT_PREVIEW_LINES;
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
+
}
|
|
175
204
|
}
|
|
176
205
|
catch (error) {
|
|
177
206
|
if (error instanceof VerifikaError) {
|
|
@@ -191,6 +220,18 @@ async function runInit(io, values) {
|
|
|
191
220
|
const root = values.config ? path.dirname(path.resolve(io.cwd, values.config)) : io.cwd;
|
|
192
221
|
const { file, checks } = await initConfig(root, Boolean(values.force));
|
|
193
222
|
io.out(`Created ${path.relative(io.cwd, file) || file} with checks: ${checks.join(", ")}\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
|
+
}
|
|
194
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');
|
|
195
236
|
return ExitCode.Passed;
|
|
196
237
|
}
|
|
@@ -240,13 +281,22 @@ function guessMode(argv, io) {
|
|
|
240
281
|
? "human"
|
|
241
282
|
: "agent";
|
|
242
283
|
}
|
|
243
|
-
function reporterFor(mode, io) {
|
|
284
|
+
function reporterFor(mode, io, options) {
|
|
244
285
|
switch (mode) {
|
|
245
286
|
case "human": {
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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 });
|
|
250
300
|
}
|
|
251
301
|
case "agent":
|
|
252
302
|
return agentReporter(io);
|
|
@@ -256,6 +306,28 @@ function reporterFor(mode, io) {
|
|
|
256
306
|
return quietReporter(io);
|
|
257
307
|
}
|
|
258
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
|
+
}
|
|
259
331
|
function reportError(error, mode, io) {
|
|
260
332
|
if (mode === "json") {
|
|
261
333
|
const document = {
|
|
@@ -273,14 +345,16 @@ function reportError(error, mode, io) {
|
|
|
273
345
|
* First SIGINT/SIGTERM/SIGHUP stops scheduling and terminates running checks
|
|
274
346
|
* gracefully; a second one kills them immediately.
|
|
275
347
|
*/
|
|
276
|
-
async function withInterruptHandling(operation) {
|
|
348
|
+
async function withInterruptHandling(onFirstSignal, operation) {
|
|
277
349
|
const controller = new AbortController();
|
|
278
350
|
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
279
351
|
const onSignal = (signal) => {
|
|
280
352
|
if (controller.signal.aborted)
|
|
281
353
|
killAllNow();
|
|
282
|
-
else
|
|
354
|
+
else {
|
|
355
|
+
onFirstSignal();
|
|
283
356
|
controller.abort(signal);
|
|
357
|
+
}
|
|
284
358
|
};
|
|
285
359
|
for (const signal of signals)
|
|
286
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
|
+
}
|