verifika 0.0.0-stage → 0.0.1
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 +30 -0
- package/LICENSE +21 -0
- package/README.md +150 -2
- package/dist/ci.js +34 -0
- package/dist/cli.js +25 -0
- package/dist/config.js +294 -0
- package/dist/errors.js +19 -0
- package/dist/format.js +10 -0
- package/dist/git.js +150 -0
- package/dist/glob.js +11 -0
- package/dist/init.js +75 -0
- package/dist/logs.js +133 -0
- package/dist/main.js +295 -0
- package/dist/planner.js +169 -0
- package/dist/preview.js +147 -0
- package/dist/process.js +362 -0
- package/dist/reporters/agent.js +31 -0
- package/dist/reporters/human.js +115 -0
- package/dist/reporters/json.js +7 -0
- package/dist/reporters/plan.js +49 -0
- package/dist/reporters/quiet.js +15 -0
- package/dist/reporters/reporter.js +43 -0
- package/dist/runner.js +206 -0
- package/dist/runs.js +192 -0
- package/dist/scheduler.js +87 -0
- package/dist/types.js +1 -0
- package/package.json +70 -4
- package/schemas/verifika.schema.json +139 -0
- package/skills/verifika/SKILL.md +92 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
## [0.0.1] - 2026-10-12
|
|
6
|
+
|
|
7
|
+
First public release.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `vfk`: a shorthand executable for `verifika`, installed by the same package.
|
|
12
|
+
- `verifika` / `verifika run`: runs configured checks concurrently with dependency ordering, per-check timeouts, optional fail-fast, process-tree cleanup and signal handling.
|
|
13
|
+
- Safety defaults: checks run sequentially unless `defaults.concurrency` or `-j` opts into parallelism; CI always runs the full suite unless `--changed`/`--changed-since` is passed explicitly (environment variables cannot switch CI mode off); a check passes only if its process tree was confirmed cleaned up; concurrent Verifika invocations in one directory keep their run records and logs apart.
|
|
14
|
+
- Hybrid selection: selective runs from git changes locally (`changes.base`, `--changed`, `--changed-since <ref>` with merge-base semantics), full runs in CI and with `--all`, explicit `--check`. Global invalidation (`invalidateAll`), `paths`/`exclude` globs, `always` checks and `dependsOn`.
|
|
15
|
+
- Conservative fallbacks: full runs when git cannot determine changes implicitly; exit 2 when an explicit comparison is impossible.
|
|
16
|
+
- Output modes: `human` (live progress), `agent` (one line on success, bounded failure preview), `json` (versioned result, `schemaVersion: 1`) and `quiet`.
|
|
17
|
+
- `verifika plan` to show selection decisions and reasons without running checks.
|
|
18
|
+
- `verifika logs` with `--failed`, `--tail` and `--run`; logs stored in a self-ignoring `.verifika/` directory with retention (`defaults.keepRuns`).
|
|
19
|
+
- `verifika init` to create a starter config from `package.json` scripts.
|
|
20
|
+
- JSON schema for `verifika.config.json` with editor autocomplete.
|
|
21
|
+
- Agent skill (`skills/verifika/SKILL.md`) for Claude Code, Codex, OpenCode and other agents.
|
|
22
|
+
- Published to npm as `verifika`; the package tarball is also attached to the GitHub release.
|
|
23
|
+
|
|
24
|
+
### Known limitations
|
|
25
|
+
|
|
26
|
+
- No per-check lock: checks that write shared outputs need `dependsOn` or `concurrency: 1` (see the configuration reference).
|
|
27
|
+
- 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`.
|
|
28
|
+
- On Windows, confirming cleanup queries the process table with PowerShell after each check, which costs roughly 0.5–3 s per check.
|
|
29
|
+
|
|
30
|
+
[0.0.1]: https://github.com/alexportelli12/verifika/releases/tag/v0.0.1
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alex Portelli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,151 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Verifika
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/alexportelli12/verifika/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/alexportelli12/verifika/releases/latest)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Run the checks your changes need, concurrently, with minimal output.
|
|
8
|
+
|
|
9
|
+
Verifika is a small CLI for repository verification. You declare your checks (lint, typecheck, tests, build, ...) in `verifika.config.json`. Locally, Verifika runs only the checks affected by your changes; in CI it runs everything. Successful runs print one line, failures print exactly what failed and how to see the full logs. It works the same for developers, CI pipelines and AI coding agents (Claude Code, Codex, OpenCode, and others).
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
$ verifika
|
|
13
|
+
PASS 3/3 (4.1s) selective: 2 files changed since HEAD (a1b2c3d); 2 skipped
|
|
14
|
+
|
|
15
|
+
$ verifika --all
|
|
16
|
+
FAIL 4/5 (12s) full run (--all)
|
|
17
|
+
typecheck: exit 2
|
|
18
|
+
src/cart.ts(14,7): error TS2322: Type 'string' is not assignable to type 'number'.
|
|
19
|
+
Run: verifika logs typecheck
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
_Verifika_ is Maltese for "verification".
|
|
23
|
+
|
|
24
|
+
## Why
|
|
25
|
+
|
|
26
|
+
- **Deterministic.** Check selection comes from your config, file globs, dependencies and git. No heuristics, no LLM. When the change scope is unclear, Verifika runs everything or fails loudly; it never quietly skips a check.
|
|
27
|
+
- **Token-efficient.** Agents get one line on success and a bounded failure preview on failure. Full logs stay on disk until asked for.
|
|
28
|
+
- **Fast.** Selective runs skip unaffected checks, and independent checks can run in parallel once you opt in (`defaults.concurrency`). One small runtime dependency.
|
|
29
|
+
- **Reliable.** Timeouts, fail-fast, process cleanup, correct exit codes and signal handling, tested on Linux, macOS and Windows.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
Requires Node.js 22.13 or newer.
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
npm install --save-dev verifika
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The package installs two commands: `verifika` and its shorthand `vfk`. They are the same program, so use whichever you prefer:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
npx verifika plan # same as: npx vfk plan
|
|
43
|
+
npx vfk --all # same as: npx verifika --all
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The docs use `verifika` throughout. To install it globally instead, run `npm install --global verifika`; to try it without installing, run `npx verifika --help`.
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
Generate a starter config from your `package.json` scripts:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npx verifika init
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or write `verifika.config.json` yourself:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"$schema": "./node_modules/verifika/schemas/verifika.schema.json",
|
|
61
|
+
"version": 1,
|
|
62
|
+
"changes": {
|
|
63
|
+
"invalidateAll": ["package.json", "package-lock.json", "tsconfig*.json"]
|
|
64
|
+
},
|
|
65
|
+
"checks": {
|
|
66
|
+
"format": { "command": "npm run format:check" },
|
|
67
|
+
"lint": { "command": "npm run lint", "paths": ["src/**/*.{ts,html}"] },
|
|
68
|
+
"typecheck": { "command": "npm run typecheck", "paths": ["src/**/*.ts"] },
|
|
69
|
+
"test": { "command": "npm test", "paths": ["src/**", "test/**"] },
|
|
70
|
+
"build": { "command": "npm run build", "paths": ["src/**"], "dependsOn": ["typecheck"] }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
If your repository checks the formatting of every file (for example `prettier --check .`), format the new `verifika.config.json` too.
|
|
76
|
+
|
|
77
|
+
Then:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
npx verifika plan # which checks would run for your changes, and why
|
|
81
|
+
npx verifika # run them
|
|
82
|
+
npx verifika --all # run everything (do this before you push)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Add a script so everyone runs the same full gate:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{ "scripts": { "verify": "verifika --all" } }
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## How checks are selected
|
|
92
|
+
|
|
93
|
+
| Situation | What runs |
|
|
94
|
+
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
95
|
+
| `verifika` locally | Checks whose `paths` match files changed since `changes.base` (default `HEAD`: your uncommitted and untracked work) |
|
|
96
|
+
| `verifika` in CI (`CI` or a known CI variable) | Everything; only `--changed`/`--changed-since` on the command line can narrow it |
|
|
97
|
+
| `verifika --all` | Everything |
|
|
98
|
+
| `verifika --check lint` | `lint` and its dependencies |
|
|
99
|
+
| `verifika --changed-since origin/main` | Checks matching files changed since `merge-base(origin/main, HEAD)`, plus uncommitted and untracked files |
|
|
100
|
+
| A changed file matches `invalidateAll` | Everything |
|
|
101
|
+
| Git unavailable, no commits, unknown base | Everything, with the reason printed (or an error, when you asked for `--changed`/`--changed-since` explicitly) |
|
|
102
|
+
|
|
103
|
+
A check without `paths` runs whenever any file changed; `"always": true` runs it even when nothing changed. Selecting a check also selects its `dependsOn` checks. `verifika plan` shows every decision.
|
|
104
|
+
|
|
105
|
+
## Output modes
|
|
106
|
+
|
|
107
|
+
The default is `human` on an interactive terminal and `agent` otherwise. Override with `--mode` or `VERIFIKA_MODE`.
|
|
108
|
+
|
|
109
|
+
| Mode | Success | Failure |
|
|
110
|
+
| ------- | ----------------------------- | --------------------------------------------------- |
|
|
111
|
+
| `human` | Live progress, summary | Failing checks, previews, logs hint |
|
|
112
|
+
| `agent` | One line: `PASS 4/4 (4.3s) …` | One line per problem, bounded preview, logs command |
|
|
113
|
+
| `json` | Versioned result document | Same document, `"status": "failed"` |
|
|
114
|
+
| `quiet` | Nothing | One line on stderr |
|
|
115
|
+
|
|
116
|
+
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).
|
|
117
|
+
|
|
118
|
+
## Logs
|
|
119
|
+
|
|
120
|
+
Every run stores each check's combined stdout/stderr under `.verifika/runs/<run-id>/`. The directory ignores itself in git, and only the last 10 runs are kept (`defaults.keepRuns`).
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
verifika logs # latest run: statuses and log paths
|
|
124
|
+
verifika logs typecheck # full log of one check
|
|
125
|
+
verifika logs --failed # logs of every failed check
|
|
126
|
+
verifika logs test --tail 50 # last 50 lines
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Logs contain whatever your commands print, which can include secrets. They never leave your machine; keep `.verifika/` out of anything you publish.
|
|
130
|
+
|
|
131
|
+
## Agent integration
|
|
132
|
+
|
|
133
|
+
Verifika ships an [agent skill](skills/verifika/SKILL.md) that teaches coding agents when to run selective versus full verification, how to read results, and how not to claim success too early. See [docs/agents.md](docs/agents.md) to install it for Claude Code, Codex or OpenCode.
|
|
134
|
+
|
|
135
|
+
## Documentation
|
|
136
|
+
|
|
137
|
+
- [Configuration reference](docs/configuration.md)
|
|
138
|
+
- [CLI reference](docs/cli.md)
|
|
139
|
+
- [Agent integration](docs/agents.md)
|
|
140
|
+
- [Architecture](docs/architecture.md)
|
|
141
|
+
- [Validation and benchmarks](docs/benchmarks.md)
|
|
142
|
+
- [Example configs](examples/)
|
|
143
|
+
- [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md) · [Security](SECURITY.md)
|
|
144
|
+
|
|
145
|
+
## Trust model
|
|
146
|
+
|
|
147
|
+
`verifika.config.json` is trusted code: its commands run through your shell with your permissions, exactly like `package.json` scripts. Verifika never puts file names, git refs or other external input into those commands. Review config changes as carefully as script changes.
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
[MIT](LICENSE)
|
package/dist/ci.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Variables set by common CI providers. */
|
|
2
|
+
export const CI_VARIABLES = [
|
|
3
|
+
"GITHUB_ACTIONS",
|
|
4
|
+
"GITLAB_CI",
|
|
5
|
+
"BUILDKITE",
|
|
6
|
+
"CIRCLECI",
|
|
7
|
+
"TRAVIS",
|
|
8
|
+
"JENKINS_URL",
|
|
9
|
+
"TF_BUILD",
|
|
10
|
+
"TEAMCITY_VERSION",
|
|
11
|
+
"BITBUCKET_BUILD_NUMBER",
|
|
12
|
+
"CODEBUILD_BUILD_ID",
|
|
13
|
+
"DRONE",
|
|
14
|
+
"APPVEYOR",
|
|
15
|
+
"SEMAPHORE",
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* Decides whether this is a CI run, where verification defaults to the full
|
|
19
|
+
* suite. Environment variables can only turn CI mode on: a truthy `CI` or any
|
|
20
|
+
* provider variable means CI, and nothing (including `CI=false`, which some
|
|
21
|
+
* build setups export for unrelated reasons) can switch a detected provider
|
|
22
|
+
* off. Selective runs in CI require an explicit `--changed` or `--changed-since`.
|
|
23
|
+
*/
|
|
24
|
+
export function detectCi(env) {
|
|
25
|
+
if (isEnabled(env.CI))
|
|
26
|
+
return { ci: true, source: "CI" };
|
|
27
|
+
const provider = CI_VARIABLES.find((name) => isEnabled(env[name]));
|
|
28
|
+
return provider ? { ci: true, source: provider } : { ci: false, source: "none" };
|
|
29
|
+
}
|
|
30
|
+
function isEnabled(value) {
|
|
31
|
+
if (value === undefined)
|
|
32
|
+
return false;
|
|
33
|
+
return !["", "0", "false", "no", "off"].includes(value.trim().toLowerCase());
|
|
34
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ExitCode } from "./errors.js";
|
|
3
|
+
import { main } from "./main.js";
|
|
4
|
+
const write = (stream) => (text) => {
|
|
5
|
+
stream.write(text);
|
|
6
|
+
};
|
|
7
|
+
// A closed pipe (e.g. `verifika logs lint | head`) is not an error worth a stack trace.
|
|
8
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
9
|
+
stream.on("error", (error) => {
|
|
10
|
+
if (error.code !== "EPIPE")
|
|
11
|
+
throw error;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
main(process.argv.slice(2), {
|
|
15
|
+
cwd: process.cwd(),
|
|
16
|
+
env: process.env,
|
|
17
|
+
isTTY: Boolean(process.stdout.isTTY),
|
|
18
|
+
out: write(process.stdout),
|
|
19
|
+
err: write(process.stderr),
|
|
20
|
+
}).then((code) => {
|
|
21
|
+
process.exitCode = code;
|
|
22
|
+
}, (error) => {
|
|
23
|
+
process.stderr.write(`verifika: internal error: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
|
|
24
|
+
process.exitCode = ExitCode.Internal;
|
|
25
|
+
});
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { VerifikaError } from "./errors.js";
|
|
4
|
+
export const CONFIG_FILE = "verifika.config.json";
|
|
5
|
+
export const CHECK_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 600_000;
|
|
7
|
+
/** Largest delay setTimeout accepts (about 24.8 days); larger values would fire immediately. */
|
|
8
|
+
export const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
9
|
+
/** Device names Windows reserves in every directory; they cannot be log file names. */
|
|
10
|
+
const RESERVED_NAMES = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
|
11
|
+
const DEFAULT_KEEP_RUNS = 10;
|
|
12
|
+
/**
|
|
13
|
+
* Checks run one at a time unless the config (or `-j`) opts into parallelism:
|
|
14
|
+
* only the project knows which of its scripts share build outputs, and running
|
|
15
|
+
* those concurrently produces real but misleading failures.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_CONCURRENCY = 1;
|
|
18
|
+
/** Walks up from `cwd` to find verifika.config.json, or uses the explicit path. */
|
|
19
|
+
export function findConfigFile(cwd, explicit) {
|
|
20
|
+
if (explicit) {
|
|
21
|
+
const file = path.resolve(cwd, explicit);
|
|
22
|
+
if (!isFile(file))
|
|
23
|
+
throw new VerifikaError(`config file not found: ${explicit}`);
|
|
24
|
+
return file;
|
|
25
|
+
}
|
|
26
|
+
let directory = path.resolve(cwd);
|
|
27
|
+
for (;;) {
|
|
28
|
+
const candidate = path.join(directory, CONFIG_FILE);
|
|
29
|
+
if (isFile(candidate))
|
|
30
|
+
return candidate;
|
|
31
|
+
const parent = path.dirname(directory);
|
|
32
|
+
if (parent === directory) {
|
|
33
|
+
throw new VerifikaError(`no ${CONFIG_FILE} found in ${cwd} or any parent directory`, "Create one with: verifika init");
|
|
34
|
+
}
|
|
35
|
+
directory = parent;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function isFile(file) {
|
|
39
|
+
try {
|
|
40
|
+
return statSync(file).isFile();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function loadConfig(file) {
|
|
47
|
+
let text;
|
|
48
|
+
try {
|
|
49
|
+
text = readFileSync(file, "utf8");
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
throw new VerifikaError(`cannot read ${file}: ${error.message}`);
|
|
53
|
+
}
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = JSON.parse(text.replace(/^\uFEFF/, ""));
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw new VerifikaError(`${path.basename(file)} is not valid JSON: ${error.message}`);
|
|
60
|
+
}
|
|
61
|
+
return parseConfig(raw, file);
|
|
62
|
+
}
|
|
63
|
+
/** Validates raw JSON and applies defaults. Reports every problem at once. */
|
|
64
|
+
export function parseConfig(raw, file) {
|
|
65
|
+
const errors = [];
|
|
66
|
+
const v = new Validator(errors);
|
|
67
|
+
if (!v.object(raw, "config"))
|
|
68
|
+
throw configError(file, errors);
|
|
69
|
+
v.keys(raw, "config", ["$schema", "version", "defaults", "changes", "checks"]);
|
|
70
|
+
v.string(raw.$schema, "$schema");
|
|
71
|
+
if (raw.version !== 1) {
|
|
72
|
+
errors.push(raw.version === undefined
|
|
73
|
+
? "version: required; set it to 1"
|
|
74
|
+
: `version: unsupported value ${JSON.stringify(raw.version)}; this release supports 1`);
|
|
75
|
+
}
|
|
76
|
+
const defaults = raw.defaults ?? {};
|
|
77
|
+
let concurrency = DEFAULT_CONCURRENCY;
|
|
78
|
+
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
79
|
+
let failFast = false;
|
|
80
|
+
let keepRuns = DEFAULT_KEEP_RUNS;
|
|
81
|
+
if (v.object(defaults, "defaults")) {
|
|
82
|
+
v.keys(defaults, "defaults", ["concurrency", "timeoutMs", "failFast", "keepRuns"]);
|
|
83
|
+
concurrency = v.integer(defaults.concurrency, "defaults.concurrency", 1, 64) ?? concurrency;
|
|
84
|
+
timeoutMs = v.integer(defaults.timeoutMs, "defaults.timeoutMs", 1, MAX_TIMEOUT_MS) ?? timeoutMs;
|
|
85
|
+
failFast = v.boolean(defaults.failFast, "defaults.failFast") ?? failFast;
|
|
86
|
+
keepRuns = v.integer(defaults.keepRuns, "defaults.keepRuns", 1, 1000) ?? keepRuns;
|
|
87
|
+
}
|
|
88
|
+
const changes = raw.changes ?? {};
|
|
89
|
+
let base = "HEAD";
|
|
90
|
+
let invalidateAll = [];
|
|
91
|
+
if (v.object(changes, "changes")) {
|
|
92
|
+
v.keys(changes, "changes", ["base", "invalidateAll"]);
|
|
93
|
+
base = v.string(changes.base, "changes.base") ?? base;
|
|
94
|
+
invalidateAll = v.globs(changes.invalidateAll, "changes.invalidateAll", true) ?? [];
|
|
95
|
+
}
|
|
96
|
+
// A config edit can change what every check means, so it always invalidates everything.
|
|
97
|
+
if (!invalidateAll.includes(CONFIG_FILE))
|
|
98
|
+
invalidateAll = [...invalidateAll, CONFIG_FILE];
|
|
99
|
+
const checks = [];
|
|
100
|
+
if (raw.checks === undefined) {
|
|
101
|
+
errors.push("checks: required; declare at least one check");
|
|
102
|
+
}
|
|
103
|
+
else if (v.object(raw.checks, "checks")) {
|
|
104
|
+
const entries = Object.entries(raw.checks);
|
|
105
|
+
if (entries.length === 0)
|
|
106
|
+
errors.push("checks: declare at least one check");
|
|
107
|
+
for (const [name, value] of entries) {
|
|
108
|
+
const at = `checks.${name}`;
|
|
109
|
+
if (!CHECK_NAME_PATTERN.test(name)) {
|
|
110
|
+
errors.push(`${at}: invalid name; use letters, digits, ".", "_" or "-", starting with a letter or digit`);
|
|
111
|
+
}
|
|
112
|
+
if (RESERVED_NAMES.test(name)) {
|
|
113
|
+
errors.push(`${at}: "${name}" is a reserved device name on Windows; choose another name`);
|
|
114
|
+
}
|
|
115
|
+
const clash = entries.find(([other]) => other !== name && other.toLowerCase() === name.toLowerCase());
|
|
116
|
+
if (clash && clash[0] < name) {
|
|
117
|
+
errors.push(`${at}: differs from "${clash[0]}" only by case; log files would collide`);
|
|
118
|
+
}
|
|
119
|
+
if (!v.object(value, at))
|
|
120
|
+
continue;
|
|
121
|
+
v.keys(value, at, ["command", "paths", "exclude", "always", "dependsOn", "timeoutMs"]);
|
|
122
|
+
const command = v.string(value.command, `${at}.command`, true);
|
|
123
|
+
const paths = v.globs(value.paths, `${at}.paths`, false);
|
|
124
|
+
const exclude = v.globs(value.exclude, `${at}.exclude`, true) ?? [];
|
|
125
|
+
const always = v.boolean(value.always, `${at}.always`) ?? false;
|
|
126
|
+
const dependsOn = v.stringArray(value.dependsOn, `${at}.dependsOn`) ?? [];
|
|
127
|
+
const checkTimeout = v.integer(value.timeoutMs, `${at}.timeoutMs`, 1, MAX_TIMEOUT_MS);
|
|
128
|
+
if (always && paths) {
|
|
129
|
+
errors.push(`${at}: "always" and "paths" cannot be combined; an always-run check ignores paths`);
|
|
130
|
+
}
|
|
131
|
+
if (always && exclude.length > 0) {
|
|
132
|
+
errors.push(`${at}: "always" and "exclude" cannot be combined`);
|
|
133
|
+
}
|
|
134
|
+
checks.push({
|
|
135
|
+
name,
|
|
136
|
+
command: command ?? "",
|
|
137
|
+
paths,
|
|
138
|
+
exclude,
|
|
139
|
+
always,
|
|
140
|
+
dependsOn,
|
|
141
|
+
timeoutMs: checkTimeout ?? timeoutMs,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
validateDependencies(checks, errors);
|
|
145
|
+
}
|
|
146
|
+
if (errors.length > 0)
|
|
147
|
+
throw configError(file, errors);
|
|
148
|
+
return {
|
|
149
|
+
file,
|
|
150
|
+
root: path.dirname(file),
|
|
151
|
+
concurrency,
|
|
152
|
+
timeoutMs,
|
|
153
|
+
failFast,
|
|
154
|
+
keepRuns,
|
|
155
|
+
base,
|
|
156
|
+
invalidateAll,
|
|
157
|
+
checks,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function validateDependencies(checks, errors) {
|
|
161
|
+
const byName = new Map(checks.map((check) => [check.name, check]));
|
|
162
|
+
for (const check of checks) {
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
for (const dependency of check.dependsOn) {
|
|
165
|
+
if (dependency === check.name) {
|
|
166
|
+
errors.push(`checks.${check.name}.dependsOn: a check cannot depend on itself`);
|
|
167
|
+
}
|
|
168
|
+
else if (!byName.has(dependency)) {
|
|
169
|
+
errors.push(`checks.${check.name}.dependsOn: unknown check "${dependency}"`);
|
|
170
|
+
}
|
|
171
|
+
else if (seen.has(dependency)) {
|
|
172
|
+
errors.push(`checks.${check.name}.dependsOn: "${dependency}" is listed twice`);
|
|
173
|
+
}
|
|
174
|
+
seen.add(dependency);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const cycle = findCycle(checks, byName);
|
|
178
|
+
if (cycle)
|
|
179
|
+
errors.push(`checks: dependency cycle ${cycle.join(" -> ")}`);
|
|
180
|
+
}
|
|
181
|
+
function findCycle(checks, byName) {
|
|
182
|
+
const state = new Map();
|
|
183
|
+
const stack = [];
|
|
184
|
+
const visit = (name) => {
|
|
185
|
+
if (state.get(name) === "done")
|
|
186
|
+
return null;
|
|
187
|
+
if (state.get(name) === "visiting")
|
|
188
|
+
return [...stack.slice(stack.indexOf(name)), name];
|
|
189
|
+
state.set(name, "visiting");
|
|
190
|
+
stack.push(name);
|
|
191
|
+
for (const dependency of byName.get(name)?.dependsOn ?? []) {
|
|
192
|
+
if (dependency === name || !byName.has(dependency))
|
|
193
|
+
continue;
|
|
194
|
+
const cycle = visit(dependency);
|
|
195
|
+
if (cycle)
|
|
196
|
+
return cycle;
|
|
197
|
+
}
|
|
198
|
+
stack.pop();
|
|
199
|
+
state.set(name, "done");
|
|
200
|
+
return null;
|
|
201
|
+
};
|
|
202
|
+
for (const check of checks) {
|
|
203
|
+
const cycle = visit(check.name);
|
|
204
|
+
if (cycle)
|
|
205
|
+
return cycle;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
function configError(file, errors) {
|
|
210
|
+
return new VerifikaError(`invalid ${path.basename(file)}:\n${errors.map((error) => ` - ${error}`).join("\n")}`);
|
|
211
|
+
}
|
|
212
|
+
/** Small collecting validator; each method returns `undefined` for absent or invalid values. */
|
|
213
|
+
class Validator {
|
|
214
|
+
errors;
|
|
215
|
+
constructor(errors) {
|
|
216
|
+
this.errors = errors;
|
|
217
|
+
}
|
|
218
|
+
object(value, at) {
|
|
219
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value))
|
|
220
|
+
return true;
|
|
221
|
+
this.errors.push(`${at}: expected an object`);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
keys(value, at, allowed) {
|
|
225
|
+
for (const key of Object.keys(value)) {
|
|
226
|
+
if (!allowed.includes(key)) {
|
|
227
|
+
this.errors.push(`${at}: unknown property "${key}" (allowed: ${allowed.join(", ")})`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
string(value, at, required = false) {
|
|
232
|
+
if (value === undefined) {
|
|
233
|
+
if (required)
|
|
234
|
+
this.errors.push(`${at}: required`);
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
if (typeof value === "string" && value.trim() !== "")
|
|
238
|
+
return value;
|
|
239
|
+
this.errors.push(`${at}: expected a non-empty string`);
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
boolean(value, at) {
|
|
243
|
+
if (value === undefined || typeof value === "boolean")
|
|
244
|
+
return value;
|
|
245
|
+
this.errors.push(`${at}: expected true or false`);
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
integer(value, at, min, max) {
|
|
249
|
+
if (value === undefined)
|
|
250
|
+
return undefined;
|
|
251
|
+
if (typeof value === "number" &&
|
|
252
|
+
Number.isInteger(value) &&
|
|
253
|
+
value >= min &&
|
|
254
|
+
(max === undefined || value <= max)) {
|
|
255
|
+
return value;
|
|
256
|
+
}
|
|
257
|
+
const range = max === undefined ? `>= ${min}` : `between ${min} and ${max}`;
|
|
258
|
+
this.errors.push(`${at}: expected an integer ${range}`);
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
stringArray(value, at) {
|
|
262
|
+
if (value === undefined)
|
|
263
|
+
return undefined;
|
|
264
|
+
if (Array.isArray(value) &&
|
|
265
|
+
value.every((item) => typeof item === "string" && item.trim() !== "")) {
|
|
266
|
+
return value;
|
|
267
|
+
}
|
|
268
|
+
this.errors.push(`${at}: expected an array of non-empty strings`);
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
/** Glob lists must be portable: forward slashes, relative to the config directory. */
|
|
272
|
+
globs(value, at, allowEmpty) {
|
|
273
|
+
const list = this.stringArray(value, at);
|
|
274
|
+
if (list === undefined)
|
|
275
|
+
return undefined;
|
|
276
|
+
if (list.length === 0 && !allowEmpty) {
|
|
277
|
+
this.errors.push(`${at}: must not be empty; omit it to match every file, or use "always": true`);
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
const normalized = [];
|
|
281
|
+
for (const pattern of list) {
|
|
282
|
+
if (pattern.includes("\\")) {
|
|
283
|
+
this.errors.push(`${at}: "${pattern}" contains a backslash; use forward slashes on every platform`);
|
|
284
|
+
}
|
|
285
|
+
else if (pattern.startsWith("/") || /^[A-Za-z]:/.test(pattern)) {
|
|
286
|
+
this.errors.push(`${at}: "${pattern}" is absolute; patterns are relative to the config directory`);
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
normalized.push(pattern.replace(/^(\.\/)+/, ""));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return normalized;
|
|
293
|
+
}
|
|
294
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Exit codes are part of the public CLI contract; see docs/cli.md. */
|
|
2
|
+
export const ExitCode = {
|
|
3
|
+
Passed: 0,
|
|
4
|
+
Failed: 1,
|
|
5
|
+
Usage: 2,
|
|
6
|
+
Internal: 70,
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* A problem with how Verifika was invoked or configured. These never represent
|
|
10
|
+
* check results, so they always exit with the usage code rather than a pass.
|
|
11
|
+
*/
|
|
12
|
+
export class VerifikaError extends Error {
|
|
13
|
+
hint;
|
|
14
|
+
constructor(message, hint) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "VerifikaError";
|
|
17
|
+
this.hint = hint;
|
|
18
|
+
}
|
|
19
|
+
}
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Compact duration: `0.4s`, `12s`, `3m05s`. */
|
|
2
|
+
export function formatSeconds(ms) {
|
|
3
|
+
const seconds = ms / 1000;
|
|
4
|
+
if (seconds < 10)
|
|
5
|
+
return `${seconds.toFixed(1)}s`;
|
|
6
|
+
if (seconds < 120)
|
|
7
|
+
return `${Math.round(seconds)}s`;
|
|
8
|
+
const minutes = Math.floor(seconds / 60);
|
|
9
|
+
return `${minutes}m${String(Math.floor(seconds % 60)).padStart(2, "0")}s`;
|
|
10
|
+
}
|