veriskit 0.2.0 → 0.3.0

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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-07-10
4
+
5
+ ### Added
6
+ - `veris scan` — import-graph map + untested areas, built from the project's own TypeScript (with a dep-free scanner fallback); writes `.veris/graph.json`.
7
+ - `veris plan` — prioritized recommendations (high-impact untested files, weak verification, risky changes). Analysis only — no code generation.
8
+
9
+ ### Changed
10
+ - `veris affected` / `veris watch` are now graph-based: the unit run is narrowed to only the test files that transitively import your changes, with a conservative full-suite fallback (config/global change, unresolved file, or untested change) so an affected test is never skipped. No new runtime dependencies.
11
+
3
12
  ## 0.2.0 — 2026-07-09
4
13
 
5
14
  ### Added
package/README.md CHANGED
@@ -95,6 +95,78 @@ want partial results to pass CI can opt in explicitly with
95
95
  | failed | `1` | at least one check failed |
96
96
  | partial | `2` (`0` with `--partial-ok`) | no failures, but something was skipped |
97
97
 
98
+ ## Project intelligence
99
+
100
+ `veris scan` and `veris plan` map your codebase's import graph and turn it
101
+ into recommendations — what to test, where verification is weak, and (with
102
+ `--base`) which of your changes are risky. Both are **read-only analysis**;
103
+ neither generates or writes any code.
104
+
105
+ ### `veris scan`
106
+
107
+ ```bash
108
+ veris scan
109
+ ```
110
+
111
+ `scan` discovers every source and test file in the project, builds an import
112
+ graph between them, and reports the source files with the most dependents
113
+ that no test transitively reaches ("untested, by impact"). It writes the
114
+ graph to `.veris/graph.json` — a derived cache, rebuilt on every run and not
115
+ meant to be committed.
116
+
117
+ **`scan` always states which resolver built the graph**, because the two
118
+ have very different accuracy:
119
+
120
+ - **`typescript`** — used when the project has a `tsconfig.json` and its own
121
+ `typescript` package exposes the classic compiler API
122
+ (`preProcessFile`/`resolveModuleName`/`readConfigFile`/…). Veris loads
123
+ *your project's own* TypeScript at run time — **no new dependency is added
124
+ for this** — and resolves imports the way `tsc` would: `tsconfig` path
125
+ aliases, extension-mapped specifiers, index resolution, and so on.
126
+ - **`scanner`** — the fallback when there's no TypeScript, no
127
+ `tsconfig.json`, or the installed `typescript` package is a 7.x
128
+ native/Go-ported build that doesn't expose the classic compiler API veris
129
+ needs. The scanner is a dependency-free, **relative-imports-only** reader:
130
+ it follows `./foo` and `../bar/baz` but does not understand `tsconfig`
131
+ path aliases or computed (non-string-literal) dynamic imports. On a
132
+ project that relies on aliases, this can miss real edges and undercount a
133
+ file's blast radius.
134
+ `scan` says plainly when this fallback is active — it is never presented
135
+ as equivalent to the TypeScript-accurate graph.
136
+
137
+ ### `veris plan`
138
+
139
+ ```bash
140
+ veris plan # recommendations from the current graph
141
+ veris plan --base main # also factor in changes vs another ref
142
+ ```
143
+
144
+ `plan` reads the same graph and turns it into prioritized recommendations:
145
+
146
+ - the highest-impact untested files to test first (most dependents, no test
147
+ reaches them);
148
+ - gaps in your verification setup (e.g. no lint or type-check configured);
149
+ - with `--base <ref>`, which files that changed since that ref are "risky" —
150
+ high blast radius and either untested or actually changed.
151
+
152
+ **`plan` only recommends — it never generates or writes any code.** Test
153
+ generation is a separate, later goal (v0.8), not something v0.3 does.
154
+
155
+ ### What's still deferred
156
+
157
+ - **No framework route/endpoint detection.** The graph understands imports
158
+ only; it doesn't know a file is an Express route, a Next.js page, or an
159
+ API handler, so it can't flag "this endpoint has no test" the way it flags
160
+ "this module has no test." Planned as a v0.3.x follow-up, not v0.3.0.
161
+ - **No test generation.** `plan` recommends what to test; it does not write
162
+ test files. That's v0.8.
163
+ - **Single tsconfig, single root.** Monorepos with multiple `tsconfig.json`
164
+ files aren't modeled yet — resolution runs against the root project only.
165
+ - **Plain JS / TS 7.x-native projects degrade to the scanner.** There's no
166
+ new dependency added to compensate — the classic TypeScript compiler API
167
+ is what makes the accurate resolver possible, and projects without it get
168
+ the honestly-labeled, relative-imports-only fallback described above.
169
+
98
170
  ## Developer loop
99
171
 
100
172
  For fast local iteration, veris can scope checks to what you changed instead
@@ -120,12 +192,34 @@ changed file to the check categories it plausibly touches:
120
192
  | docs/assets (`.md`, images, `LICENSE`) | nothing |
121
193
  | anything else unrecognized | every available check (safe default) |
122
194
 
123
- This is **coarse there is no import graph yet**. `affected` maps changed
124
- *files* to check *categories*, not to the specific modules or tests that
125
- actually import them; a real dependency-aware "run exactly the tests this
126
- file affects" is planned for v0.3. Today it will sometimes run more than the
127
- minimal ideal set (a config change reruns everything), but it never silently
128
- skips work it can't prove is safe to skip.
195
+ The table above decides *which check categories* run that part is still a
196
+ coarse, file-extension-based mapping. **What changed in v0.3 is what happens
197
+ inside the `unit` category.** Instead of running every unit test in the
198
+ project, `affected` builds the same import graph that
199
+ [`veris scan`/`veris plan`](#project-intelligence) use and narrows the unit
200
+ run to only the test files that **transitively import** your changed files.
201
+
202
+ That narrowing is deliberately conservative — it **falls back to running the
203
+ full test suite** rather than ever risk hiding an affected test:
204
+
205
+ - any changed file matches a config/global pattern (`tsconfig*.json`,
206
+ `package.json`, a `*.config.*` or `*.setup.*` file, biome/eslint config, …);
207
+ - a changed file isn't a node in the import graph at all (e.g. it's under an
208
+ ignored directory like `node_modules` or `.veris`, or it's not a
209
+ recognized code extension);
210
+ - no test file transitively reaches any of the changed files (an untested
211
+ change).
212
+
213
+ The output says when a run was narrowed and why it wasn't:
214
+
215
+ ```text
216
+ unit narrowed to 3 of 41 test file(s) via typescript graph
217
+ unit ran in full — global/config change (package.json)
218
+ ```
219
+
220
+ Today it will still sometimes run more than the minimal ideal set (a config
221
+ change reruns everything, an unresolved file falls back to full), but it
222
+ never silently skips work it can't prove is safe to skip.
129
223
 
130
224
  **The verdict is honestly scoped.** An `affected` run never prints a bare
131
225
  "Verified" — the terminal output and report say "Affected checks passed" (or
@@ -151,6 +245,11 @@ isn't supported, or on filesystems (containers, some network mounts) where
151
245
  native change events are unreliable, pass `--poll` to fall back to an
152
246
  interval-based scan that diffs file mtimes instead.
153
247
 
248
+ Every tick after the baseline uses the same graph-based `unit` narrowing (and
249
+ conservative full-suite fallback) described above for `affected` — the graph
250
+ is rebuilt fresh each tick, so it always reflects the file you just saved,
251
+ not a stale snapshot.
252
+
154
253
  Each tick reprints the full check board. A capability that wasn't affected by
155
254
  the latest change keeps showing its last real result, marked `⟳ cached` — a
156
255
  cached **failure stays a failure** (✗); it is never hidden or silently
@@ -169,7 +268,9 @@ Every `veris verify` run writes:
169
268
 
170
269
  `.veris/config.json` and `.veris/.gitignore` are meant to be committed;
171
270
  `.veris/runs/`, `.veris/reports/`, and `.veris/cache/` are gitignored by
172
- `veris init`.
271
+ `veris init`. `.veris/graph.json` (written by [`veris scan`](#project-intelligence))
272
+ is a separate derived cache, rebuilt on every scan — treat it the same way
273
+ and don't commit it.
173
274
 
174
275
  ## Design
175
276
 
package/dist/index.js CHANGED
@@ -231,7 +231,7 @@ var init_store = __esm({
231
231
  // src/util/exec.ts
232
232
  import { spawn } from "child_process";
233
233
  function exec(cmd, args, opts = {}) {
234
- return new Promise((resolve) => {
234
+ return new Promise((resolve2) => {
235
235
  const start = performance.now();
236
236
  const child = spawn(cmd, args, {
237
237
  cwd: opts.cwd,
@@ -249,7 +249,7 @@ function exec(cmd, args, opts = {}) {
249
249
  child.stderr.on("data", (d) => stderr += d.toString());
250
250
  child.on("close", (code) => {
251
251
  if (timer) clearTimeout(timer);
252
- resolve({
252
+ resolve2({
253
253
  code: code ?? 1,
254
254
  stdout,
255
255
  stderr,
@@ -259,7 +259,7 @@ function exec(cmd, args, opts = {}) {
259
259
  });
260
260
  child.on("error", () => {
261
261
  if (timer) clearTimeout(timer);
262
- resolve({
262
+ resolve2({
263
263
  code: 127,
264
264
  stdout,
265
265
  stderr: stderr || `failed to spawn ${cmd}`,
@@ -375,13 +375,13 @@ var init_jest = __esm({
375
375
  init_base();
376
376
  jestRunner = {
377
377
  id: "jest",
378
- toCheck(project, _cap) {
378
+ toCheck(project, _cap, opts) {
379
379
  return {
380
380
  id: "unit",
381
381
  title: "Unit tests",
382
382
  runner: "jest",
383
383
  cmd: localBin(project.root, "jest"),
384
- args: ["--ci"]
384
+ args: ["--ci", ...opts?.targetFiles ?? []]
385
385
  };
386
386
  },
387
387
  run(check, ctx) {
@@ -459,13 +459,14 @@ var init_vitest = __esm({
459
459
  init_base();
460
460
  vitestRunner = {
461
461
  id: "vitest",
462
- toCheck(project, _cap) {
462
+ toCheck(project, _cap, opts) {
463
+ const files = opts?.targetFiles ?? [];
463
464
  return {
464
465
  id: "unit",
465
466
  title: "Unit tests",
466
467
  runner: "vitest",
467
468
  cmd: localBin(project.root, "vitest"),
468
- args: ["run", "--reporter=json"]
469
+ args: ["run", "--reporter=json", ...files]
469
470
  };
470
471
  },
471
472
  run(check, ctx) {
@@ -544,7 +545,7 @@ var init_verdict = __esm({
544
545
  });
545
546
 
546
547
  // src/core/orchestrator.ts
547
- async function runChecks(project, ids, root) {
548
+ async function runChecks(project, ids, root, opts = {}) {
548
549
  const known = new Set(project.capabilities.map((c) => c.id));
549
550
  const unknown = ids.filter((id2) => !known.has(id2));
550
551
  if (unknown.length > 0) {
@@ -565,7 +566,9 @@ async function runChecks(project, ids, root) {
565
566
  summary
566
567
  };
567
568
  }
568
- const check = runner.toCheck(project, cap);
569
+ const check = runner.toCheck(project, cap, {
570
+ targetFiles: opts.targetFiles?.[capId]
571
+ });
569
572
  return runner.run(check, ctx);
570
573
  });
571
574
  const results = await Promise.all(tasks);
@@ -708,7 +711,7 @@ var init_init = __esm({
708
711
  "use strict";
709
712
  init_detect();
710
713
  init_fs_safe();
711
- GITIGNORE = ["runs/", "reports/", "cache/", ""].join("\n");
714
+ GITIGNORE = ["runs/", "reports/", "cache/", "graph.json", ""].join("\n");
712
715
  }
713
716
  });
714
717
 
@@ -940,6 +943,338 @@ var init_changes = __esm({
940
943
  }
941
944
  });
942
945
 
946
+ // src/project-graph/discover.ts
947
+ import { readdirSync as readdirSync2, statSync } from "fs";
948
+ import { join as join7, relative as relative2, sep } from "path";
949
+ function toPosix(p) {
950
+ return sep === "/" ? p : p.split(sep).join("/");
951
+ }
952
+ function classify(rel) {
953
+ if (TEST_RE2.test(rel)) return "test";
954
+ if (CONFIG_RE2.test(rel)) return "config";
955
+ return "source";
956
+ }
957
+ function discoverFiles(root) {
958
+ const out = [];
959
+ const walk = (dir) => {
960
+ let entries;
961
+ try {
962
+ entries = readdirSync2(dir);
963
+ } catch {
964
+ return;
965
+ }
966
+ for (const name of entries) {
967
+ const abs = join7(dir, name);
968
+ const rel = toPosix(relative2(root, abs));
969
+ if (IGNORE.test(rel)) continue;
970
+ let st;
971
+ try {
972
+ st = statSync(abs);
973
+ } catch {
974
+ continue;
975
+ }
976
+ if (st.isDirectory()) {
977
+ walk(abs);
978
+ } else if (CODE_RE.test(rel)) {
979
+ out.push({ file: rel, kind: classify(rel) });
980
+ }
981
+ }
982
+ };
983
+ walk(root);
984
+ return out.sort((a, b) => a.file.localeCompare(b.file));
985
+ }
986
+ var IGNORE, CODE_RE, TEST_RE2, CONFIG_RE2;
987
+ var init_discover = __esm({
988
+ "src/project-graph/discover.ts"() {
989
+ "use strict";
990
+ IGNORE = /(^|\/)(\.git|\.claude|\.veris|\.agentloop|\.agentflight|node_modules|dist|coverage|build|fixtures|__fixtures__)(\/|$)/;
991
+ CODE_RE = /\.[cm]?[jt]sx?$/;
992
+ TEST_RE2 = /(\.(test|spec)\.[cm]?[jt]sx?$)|(^|\/)(test|tests|__tests__)\//;
993
+ CONFIG_RE2 = /(^|\/)([^/]+\.config\.[cm]?[jt]sx?)$/;
994
+ }
995
+ });
996
+
997
+ // src/project-graph/scanner-resolver.ts
998
+ import { existsSync as existsSync3, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
999
+ import { dirname, join as join8, relative as relative3, resolve } from "path";
1000
+ function extractSpecifiers(text) {
1001
+ const specs = [];
1002
+ SPEC_RE.lastIndex = 0;
1003
+ let m;
1004
+ m = SPEC_RE.exec(text);
1005
+ while (m !== null) {
1006
+ const s = m[1] ?? m[2] ?? m[3] ?? m[4];
1007
+ if (s) specs.push(s);
1008
+ m = SPEC_RE.exec(text);
1009
+ }
1010
+ return specs;
1011
+ }
1012
+ function isFile(p) {
1013
+ try {
1014
+ return statSync2(p).isFile();
1015
+ } catch {
1016
+ return false;
1017
+ }
1018
+ }
1019
+ function resolveRelative(fromDir, spec) {
1020
+ const base = resolve(fromDir, spec);
1021
+ const candidates = [base];
1022
+ for (const ext of EXTS) candidates.push(base + ext);
1023
+ const extMatch = base.match(/\.[cm]?[jt]sx?$/);
1024
+ if (extMatch) {
1025
+ const noExt = base.slice(0, -extMatch[0].length);
1026
+ for (const ext of EXTS) candidates.push(noExt + ext);
1027
+ }
1028
+ for (const ext of EXTS) candidates.push(join8(base, `index${ext}`));
1029
+ for (const c of candidates) {
1030
+ if (existsSync3(c) && isFile(c)) return c;
1031
+ }
1032
+ return null;
1033
+ }
1034
+ function scannerImports(root, file) {
1035
+ let text;
1036
+ try {
1037
+ text = readFileSync3(join8(root, file), "utf8");
1038
+ } catch {
1039
+ return [];
1040
+ }
1041
+ const dir = dirname(join8(root, file));
1042
+ const out = /* @__PURE__ */ new Set();
1043
+ for (const spec of extractSpecifiers(text)) {
1044
+ if (!spec.startsWith(".")) continue;
1045
+ const resolved = resolveRelative(dir, spec);
1046
+ if (resolved) out.add(toPosix(relative3(root, resolved)));
1047
+ }
1048
+ return [...out];
1049
+ }
1050
+ var EXTS, SPEC_RE;
1051
+ var init_scanner_resolver = __esm({
1052
+ "src/project-graph/scanner-resolver.ts"() {
1053
+ "use strict";
1054
+ init_discover();
1055
+ EXTS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
1056
+ SPEC_RE = /(?:import|export)\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]|(?:^|[^.\w])import\s*['"]([^'"]+)['"]|\bimport\(\s*['"]([^'"]+)['"]\s*\)|\brequire\(\s*['"]([^'"]+)['"]\s*\)/g;
1057
+ }
1058
+ });
1059
+
1060
+ // src/project-graph/ts-resolver.ts
1061
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
1062
+ import { createRequire } from "module";
1063
+ import { join as join9, relative as relative4, sep as sep2 } from "path";
1064
+ function hasClassicApi(mod) {
1065
+ const m = mod;
1066
+ return !!m && typeof m.preProcessFile === "function" && typeof m.resolveModuleName === "function" && typeof m.readConfigFile === "function" && typeof m.parseJsonConfigFileContent === "function" && typeof m.sys === "object" && m.sys !== null;
1067
+ }
1068
+ function loadTypeScript(root) {
1069
+ try {
1070
+ const require2 = createRequire(join9(root, "__veris__.js"));
1071
+ const tsPath = require2.resolve("typescript");
1072
+ const mod = require2(tsPath);
1073
+ return hasClassicApi(mod) ? mod : null;
1074
+ } catch {
1075
+ return null;
1076
+ }
1077
+ }
1078
+ function loadCompilerOptions(tsmod, root) {
1079
+ const configPath = join9(root, "tsconfig.json");
1080
+ const read = tsmod.readConfigFile(configPath, tsmod.sys.readFile);
1081
+ if (read.error || !read.config) return {};
1082
+ const parsed = tsmod.parseJsonConfigFileContent(read.config, tsmod.sys, root);
1083
+ return parsed.options;
1084
+ }
1085
+ function tsImports(tsmod, root, options, file) {
1086
+ let text;
1087
+ try {
1088
+ text = readFileSync4(join9(root, file), "utf8");
1089
+ } catch {
1090
+ return [];
1091
+ }
1092
+ const abs = join9(root, file);
1093
+ const out = /* @__PURE__ */ new Set();
1094
+ try {
1095
+ const pre = tsmod.preProcessFile(text, true, true);
1096
+ for (const imp of pre.importedFiles) {
1097
+ const res = tsmod.resolveModuleName(
1098
+ imp.fileName,
1099
+ abs,
1100
+ options,
1101
+ tsmod.sys
1102
+ );
1103
+ const resolved = res.resolvedModule?.resolvedFileName;
1104
+ if (resolved && !resolved.includes("node_modules") && resolved.startsWith(root + sep2)) {
1105
+ out.add(toPosix(relative4(root, resolved)));
1106
+ }
1107
+ }
1108
+ } catch {
1109
+ }
1110
+ return [...out];
1111
+ }
1112
+ function selectResolver(root) {
1113
+ const tsmod = existsSync4(join9(root, "tsconfig.json")) ? loadTypeScript(root) : null;
1114
+ if (tsmod) {
1115
+ try {
1116
+ const options = loadCompilerOptions(tsmod, root);
1117
+ return {
1118
+ resolver: "typescript",
1119
+ importsOf: (file) => tsImports(tsmod, root, options, file)
1120
+ };
1121
+ } catch {
1122
+ }
1123
+ }
1124
+ return {
1125
+ resolver: "scanner",
1126
+ importsOf: (file) => scannerImports(root, file)
1127
+ };
1128
+ }
1129
+ var init_ts_resolver = __esm({
1130
+ "src/project-graph/ts-resolver.ts"() {
1131
+ "use strict";
1132
+ init_discover();
1133
+ init_scanner_resolver();
1134
+ }
1135
+ });
1136
+
1137
+ // src/project-graph/graph.ts
1138
+ var graph_exports = {};
1139
+ __export(graph_exports, {
1140
+ buildGraph: () => buildGraph
1141
+ });
1142
+ async function buildGraph(project) {
1143
+ const root = project.root;
1144
+ const files = discoverFiles(root);
1145
+ const known = new Set(files.map((f) => f.file));
1146
+ const { resolver, importsOf } = selectResolver(root);
1147
+ const nodes = {};
1148
+ for (const f of files) {
1149
+ nodes[f.file] = { file: f.file, kind: f.kind, imports: [], importedBy: [] };
1150
+ }
1151
+ for (const f of files) {
1152
+ const imps = importsOf(f.file).filter((i) => known.has(i) && i !== f.file);
1153
+ nodes[f.file].imports = imps;
1154
+ for (const i of imps) nodes[i]?.importedBy.push(f.file);
1155
+ }
1156
+ return {
1157
+ root,
1158
+ resolver,
1159
+ nodes,
1160
+ sourceFiles: files.filter((f) => f.kind === "source").map((f) => f.file),
1161
+ testFiles: files.filter((f) => f.kind === "test").map((f) => f.file)
1162
+ };
1163
+ }
1164
+ var init_graph = __esm({
1165
+ "src/project-graph/graph.ts"() {
1166
+ "use strict";
1167
+ init_discover();
1168
+ init_ts_resolver();
1169
+ }
1170
+ });
1171
+
1172
+ // src/project-graph/analyze.ts
1173
+ function transitiveDependents(graph, file) {
1174
+ const seen = /* @__PURE__ */ new Set();
1175
+ const stack = [...graph.nodes[file]?.importedBy ?? []];
1176
+ while (stack.length) {
1177
+ const f = stack.pop();
1178
+ if (!f || seen.has(f)) continue;
1179
+ seen.add(f);
1180
+ for (const d of graph.nodes[f]?.importedBy ?? []) stack.push(d);
1181
+ }
1182
+ return seen;
1183
+ }
1184
+ function reachableFromTests(graph) {
1185
+ const seen = /* @__PURE__ */ new Set();
1186
+ const stack = [...graph.testFiles];
1187
+ while (stack.length) {
1188
+ const f = stack.pop();
1189
+ if (!f || seen.has(f)) continue;
1190
+ seen.add(f);
1191
+ for (const i of graph.nodes[f]?.imports ?? []) stack.push(i);
1192
+ }
1193
+ return seen;
1194
+ }
1195
+ function analyze(graph, changed = []) {
1196
+ const blastRadius = {};
1197
+ for (const file of Object.keys(graph.nodes)) {
1198
+ blastRadius[file] = transitiveDependents(graph, file).size;
1199
+ }
1200
+ const reached = reachableFromTests(graph);
1201
+ const byBlast = (a, b) => (blastRadius[b] ?? 0) - (blastRadius[a] ?? 0);
1202
+ const untested = graph.sourceFiles.filter((f) => !reached.has(f)).sort(byBlast);
1203
+ const changedSet = new Set(changed);
1204
+ const untestedSet = new Set(untested);
1205
+ const risky = graph.sourceFiles.filter(
1206
+ (f) => (blastRadius[f] ?? 0) > 0 && (untestedSet.has(f) || changedSet.has(f))
1207
+ ).sort(byBlast);
1208
+ return { untested, blastRadius, risky };
1209
+ }
1210
+ var init_analyze = __esm({
1211
+ "src/project-graph/analyze.ts"() {
1212
+ "use strict";
1213
+ }
1214
+ });
1215
+
1216
+ // src/affected/select.ts
1217
+ var select_exports = {};
1218
+ __export(select_exports, {
1219
+ selectAffectedTests: () => selectAffectedTests
1220
+ });
1221
+ function selectAffectedTests(graph, changed) {
1222
+ if (graph.resolver !== "typescript") {
1223
+ return {
1224
+ mode: "full",
1225
+ testFiles: [],
1226
+ reason: "scanner graph can miss aliased/subpath imports \u2014 running the full suite"
1227
+ };
1228
+ }
1229
+ for (const f of changed) {
1230
+ if (GLOBAL_RE.test(f)) {
1231
+ return {
1232
+ mode: "full",
1233
+ testFiles: [],
1234
+ reason: `global/config change (${f})`
1235
+ };
1236
+ }
1237
+ if (!(f in graph.nodes)) {
1238
+ return {
1239
+ mode: "full",
1240
+ testFiles: [],
1241
+ reason: `unresolved changed file (${f})`
1242
+ };
1243
+ }
1244
+ }
1245
+ const tests = /* @__PURE__ */ new Set();
1246
+ for (const f of changed) {
1247
+ if (graph.nodes[f]?.kind === "test") tests.add(f);
1248
+ for (const dep of transitiveDependents(graph, f)) {
1249
+ if (graph.nodes[dep]?.kind === "test") tests.add(dep);
1250
+ }
1251
+ }
1252
+ if (tests.size === 0) {
1253
+ return {
1254
+ mode: "full",
1255
+ testFiles: [],
1256
+ reason: "no tests reach the changed files"
1257
+ };
1258
+ }
1259
+ const selected = [...tests].sort();
1260
+ if (selected.some((t) => t.startsWith("-"))) {
1261
+ return {
1262
+ mode: "full",
1263
+ testFiles: [],
1264
+ reason: "a reaching test path starts with '-' (unsafe as a CLI argument)"
1265
+ };
1266
+ }
1267
+ return { mode: "graph", testFiles: selected, reason: "" };
1268
+ }
1269
+ var GLOBAL_RE;
1270
+ var init_select = __esm({
1271
+ "src/affected/select.ts"() {
1272
+ "use strict";
1273
+ init_analyze();
1274
+ GLOBAL_RE = /(^|\/)(tsconfig[^/]*\.json|package\.json|biome\.jsonc?|\.eslintrc[^/]*|eslint\.config\.[^/]+|veris\.config\.[^/]+|[^/]+\.config\.[cm]?[jt]sx?|[^/]+\.setup\.[cm]?[jt]sx?)$/;
1275
+ }
1276
+ });
1277
+
943
1278
  // src/cli/commands/affected.ts
944
1279
  var affected_exports = {};
945
1280
  __export(affected_exports, {
@@ -956,7 +1291,25 @@ async function runAffected(root, opts = {}) {
956
1291
  );
957
1292
  return 0;
958
1293
  }
959
- const run = await runChecks(project, plan.checks, root);
1294
+ let targetFiles;
1295
+ let narrowedNote = "";
1296
+ const unitRunner = project.capabilities.find((c) => c.id === "unit")?.runner;
1297
+ const canNarrow = unitRunner === "vitest" || unitRunner === "jest";
1298
+ if (plan.checks.includes("unit") && canNarrow) {
1299
+ const { buildGraph: buildGraph2 } = await Promise.resolve().then(() => (init_graph(), graph_exports));
1300
+ const { selectAffectedTests: selectAffectedTests2 } = await Promise.resolve().then(() => (init_select(), select_exports));
1301
+ const graph = await buildGraph2(project);
1302
+ const sel = selectAffectedTests2(graph, files);
1303
+ if (sel.mode === "graph") {
1304
+ targetFiles = { unit: sel.testFiles };
1305
+ narrowedNote = `unit narrowed to ${sel.testFiles.length} of ${graph.testFiles.length} test file(s) via ${graph.resolver} graph`;
1306
+ } else {
1307
+ narrowedNote = `unit ran in full \u2014 ${sel.reason}`;
1308
+ }
1309
+ } else if (plan.checks.includes("unit") && unitRunner) {
1310
+ narrowedNote = `unit ran in full \u2014 the ${unitRunner} runner does not support file filtering`;
1311
+ }
1312
+ const run = await runChecks(project, plan.checks, root, { targetFiles });
960
1313
  run.scope = { kind: "affected", changedCount: files.length };
961
1314
  const affected = new Set(plan.checks);
962
1315
  for (const cap of project.capabilities) {
@@ -974,6 +1327,8 @@ async function runAffected(root, opts = {}) {
974
1327
  const runDir = await createRunDir(root, run.id);
975
1328
  await writeMetadata(runDir, run);
976
1329
  process.stdout.write(`${renderRun(run)}
1330
+ `);
1331
+ if (narrowedNote) process.stdout.write(`${narrowedNote}
977
1332
  `);
978
1333
  return verdictExitCode(run.verdict, opts);
979
1334
  }
@@ -994,10 +1349,10 @@ var init_affected = __esm({
994
1349
  // src/watch/watcher.ts
995
1350
  import {
996
1351
  watch as fsWatch,
997
- readdirSync as readdirSync2,
998
- statSync
1352
+ readdirSync as readdirSync3,
1353
+ statSync as statSync3
999
1354
  } from "fs";
1000
- import { basename, join as join7, relative as relative2 } from "path";
1355
+ import { basename, join as join10, relative as relative5 } from "path";
1001
1356
  function watch(root, opts, onBatch) {
1002
1357
  const debounceMs = opts.debounceMs ?? 150;
1003
1358
  const rootBase = basename(root);
@@ -1010,7 +1365,7 @@ function watch(root, opts, onBatch) {
1010
1365
  if (batch.length) onBatch(batch);
1011
1366
  };
1012
1367
  const schedule = (rel) => {
1013
- if (!rel || rel === rootBase || IGNORE.test(rel)) return;
1368
+ if (!rel || rel === rootBase || IGNORE2.test(rel)) return;
1014
1369
  pending.add(rel);
1015
1370
  if (timer) clearTimeout(timer);
1016
1371
  timer = setTimeout(flush, debounceMs);
@@ -1051,17 +1406,17 @@ function scanMtimes(root) {
1051
1406
  const walk = (dir) => {
1052
1407
  let entries;
1053
1408
  try {
1054
- entries = readdirSync2(dir);
1409
+ entries = readdirSync3(dir);
1055
1410
  } catch {
1056
1411
  return;
1057
1412
  }
1058
1413
  for (const name of entries) {
1059
- const abs = join7(dir, name);
1060
- const rel = relative2(root, abs);
1061
- if (IGNORE.test(rel)) continue;
1414
+ const abs = join10(dir, name);
1415
+ const rel = relative5(root, abs);
1416
+ if (IGNORE2.test(rel)) continue;
1062
1417
  let st;
1063
1418
  try {
1064
- st = statSync(abs);
1419
+ st = statSync3(abs);
1065
1420
  } catch {
1066
1421
  continue;
1067
1422
  }
@@ -1072,11 +1427,11 @@ function scanMtimes(root) {
1072
1427
  walk(root);
1073
1428
  return out;
1074
1429
  }
1075
- var IGNORE;
1430
+ var IGNORE2;
1076
1431
  var init_watcher = __esm({
1077
1432
  "src/watch/watcher.ts"() {
1078
1433
  "use strict";
1079
- IGNORE = /(^|\/)(\.git|\.veris|\.agentloop|\.agentflight|node_modules|dist)(\/|$)/;
1434
+ IGNORE2 = /(^|\/)(\.git|\.veris|\.agentloop|\.agentflight|node_modules|dist)(\/|$)/;
1080
1435
  }
1081
1436
  });
1082
1437
 
@@ -1122,9 +1477,29 @@ async function runWatch(root, opts = {}) {
1122
1477
  const project = await detectProject(root);
1123
1478
  const { files } = await changedFiles(root);
1124
1479
  const checks = initial ? availableIds(project) : affectedChecks(files, project).checks;
1480
+ let targetFiles;
1481
+ let narrowedNote = "";
1482
+ const unitRunner = project.capabilities.find(
1483
+ (c) => c.id === "unit"
1484
+ )?.runner;
1485
+ const canNarrow = unitRunner === "vitest" || unitRunner === "jest";
1486
+ if (!initial && checks.includes("unit") && canNarrow) {
1487
+ const { buildGraph: buildGraph2 } = await Promise.resolve().then(() => (init_graph(), graph_exports));
1488
+ const { selectAffectedTests: selectAffectedTests2 } = await Promise.resolve().then(() => (init_select(), select_exports));
1489
+ const graph = await buildGraph2(project);
1490
+ const sel = selectAffectedTests2(graph, files);
1491
+ if (sel.mode === "graph") {
1492
+ targetFiles = { unit: sel.testFiles };
1493
+ narrowedNote = `unit narrowed to ${sel.testFiles.length} of ${graph.testFiles.length} test file(s) via ${graph.resolver} graph`;
1494
+ } else {
1495
+ narrowedNote = `unit ran in full \u2014 ${sel.reason}`;
1496
+ }
1497
+ } else if (!initial && checks.includes("unit") && unitRunner) {
1498
+ narrowedNote = `unit ran in full \u2014 the ${unitRunner} runner does not support file filtering`;
1499
+ }
1125
1500
  let fresh = [];
1126
1501
  if (checks.length) {
1127
- const r = await runChecks(project, checks, root);
1502
+ const r = await runChecks(project, checks, root, { targetFiles });
1128
1503
  fresh = r.results;
1129
1504
  for (const result of fresh) cache.set(result.checkId, { ...result });
1130
1505
  }
@@ -1149,6 +1524,8 @@ async function runWatch(root, opts = {}) {
1149
1524
  scope: { kind: "watch", changedCount: files.length }
1150
1525
  };
1151
1526
  process.stdout.write(`${renderRun(run)}
1527
+ `);
1528
+ if (narrowedNote) process.stdout.write(`${narrowedNote}
1152
1529
  `);
1153
1530
  } finally {
1154
1531
  running = false;
@@ -1156,7 +1533,7 @@ async function runWatch(root, opts = {}) {
1156
1533
  };
1157
1534
  process.stdout.write("veris watch \u2014 press Ctrl-C to stop\n");
1158
1535
  await tick(true);
1159
- return await new Promise((resolve) => {
1536
+ return await new Promise((resolve2) => {
1160
1537
  let stop;
1161
1538
  try {
1162
1539
  stop = watch(root, { poll: opts.poll }, () => {
@@ -1167,13 +1544,13 @@ async function runWatch(root, opts = {}) {
1167
1544
  `veris: ${err instanceof Error ? err.message : String(err)}
1168
1545
  `
1169
1546
  );
1170
- resolve(1);
1547
+ resolve2(1);
1171
1548
  return;
1172
1549
  }
1173
1550
  process.on("SIGINT", () => {
1174
1551
  stop();
1175
1552
  process.stdout.write("\nStopped.\n");
1176
- resolve(0);
1553
+ resolve2(0);
1177
1554
  });
1178
1555
  });
1179
1556
  }
@@ -1190,6 +1567,147 @@ var init_watch = __esm({
1190
1567
  }
1191
1568
  });
1192
1569
 
1570
+ // src/cli/commands/scan.ts
1571
+ var scan_exports = {};
1572
+ __export(scan_exports, {
1573
+ renderScan: () => renderScan,
1574
+ runScan: () => runScan
1575
+ });
1576
+ import { writeFile as writeFile3 } from "fs/promises";
1577
+ import { join as join11 } from "path";
1578
+ import pc3 from "picocolors";
1579
+ function renderScan(graph, analysis) {
1580
+ const plain = isPlain();
1581
+ const bold = (s) => plain ? s : pc3.bold(s);
1582
+ const dim = (s) => plain ? s : pc3.dim(s);
1583
+ const warn = (s) => plain ? s : pc3.yellow(s);
1584
+ const lines = [];
1585
+ lines.push(bold("Veris \u2014 scan"));
1586
+ lines.push("");
1587
+ lines.push(
1588
+ `Resolver ${graph.resolver}${graph.resolver === "scanner" ? dim(" (no TypeScript found \u2014 relative imports only)") : ""}`
1589
+ );
1590
+ lines.push(
1591
+ `Modules ${Object.keys(graph.nodes).length} \xB7 Source ${graph.sourceFiles.length} \xB7 Tests ${graph.testFiles.length}`
1592
+ );
1593
+ lines.push("");
1594
+ lines.push("Untested (top by impact)");
1595
+ const top = analysis.untested.slice(0, 10);
1596
+ if (top.length === 0)
1597
+ lines.push(dim(" none \u2014 every source file is reached by a test"));
1598
+ for (const f of top) {
1599
+ lines.push(
1600
+ ` ${warn("!")} ${f} ${dim(`${analysis.blastRadius[f] ?? 0} dependents`)}`
1601
+ );
1602
+ }
1603
+ return lines.join("\n");
1604
+ }
1605
+ async function runScan(root) {
1606
+ const project = await detectProject(root);
1607
+ const graph = await buildGraph(project);
1608
+ const analysis = analyze(graph);
1609
+ const dir = join11(root, ".veris");
1610
+ await ensureDir(dir);
1611
+ await writeFile3(
1612
+ join11(dir, "graph.json"),
1613
+ JSON.stringify({ resolver: graph.resolver, nodes: graph.nodes }, null, 2),
1614
+ "utf8"
1615
+ );
1616
+ process.stdout.write(`${renderScan(graph, analysis)}
1617
+ `);
1618
+ process.stdout.write(`
1619
+ Graph ${join11(".veris", "graph.json")}
1620
+ `);
1621
+ return 0;
1622
+ }
1623
+ var init_scan = __esm({
1624
+ "src/cli/commands/scan.ts"() {
1625
+ "use strict";
1626
+ init_detect();
1627
+ init_analyze();
1628
+ init_graph();
1629
+ init_fs_safe();
1630
+ init_tty();
1631
+ }
1632
+ });
1633
+
1634
+ // src/cli/commands/plan.ts
1635
+ var plan_exports = {};
1636
+ __export(plan_exports, {
1637
+ renderPlan: () => renderPlan,
1638
+ runPlan: () => runPlan
1639
+ });
1640
+ import pc4 from "picocolors";
1641
+ function renderPlan(project, graph, analysis, changed) {
1642
+ const plain = isPlain();
1643
+ const bold = (s) => plain ? s : pc4.bold(s);
1644
+ const dim = (s) => plain ? s : pc4.dim(s);
1645
+ const warn = (s) => plain ? s : pc4.yellow(s);
1646
+ const lines = [];
1647
+ lines.push(bold("Veris \u2014 plan"));
1648
+ lines.push(dim(`(graph via ${graph.resolver})`));
1649
+ lines.push("");
1650
+ lines.push("Test these first (high impact, untested)");
1651
+ const top = analysis.untested.slice(0, 5);
1652
+ if (top.length === 0)
1653
+ lines.push(
1654
+ dim(
1655
+ " none \u2014 untested files have no dependents or all files are covered"
1656
+ )
1657
+ );
1658
+ top.forEach((f, i) => {
1659
+ lines.push(
1660
+ ` ${i + 1}. ${f} ${dim(`(${analysis.blastRadius[f] ?? 0} dependents, no test reaches it)`)}`
1661
+ );
1662
+ });
1663
+ lines.push("");
1664
+ lines.push("Verification setup");
1665
+ const weak = project.capabilities.filter(
1666
+ (c) => !c.available && c.id !== "browser"
1667
+ );
1668
+ if (weak.length === 0)
1669
+ lines.push(dim(" \u2713 types, lint, and unit are all configured"));
1670
+ for (const c of weak)
1671
+ lines.push(
1672
+ ` ${warn("\u26A0")} ${c.id} capability unavailable \u2014 ${c.reason ?? "not configured"}`
1673
+ );
1674
+ if (changed.length) {
1675
+ lines.push("");
1676
+ lines.push(`Risky changes (${changed.length} changed file(s))`);
1677
+ if (analysis.risky.length === 0) lines.push(dim(" none flagged"));
1678
+ for (const f of analysis.risky.slice(0, 8)) {
1679
+ const untested = analysis.untested.includes(f) ? ", untested" : "";
1680
+ lines.push(
1681
+ ` ${warn("!")} ${f} ${dim(`${analysis.blastRadius[f] ?? 0} dependents${untested}`)}`
1682
+ );
1683
+ }
1684
+ }
1685
+ lines.push("");
1686
+ lines.push(
1687
+ dim("Next: `veris affected` runs only the tests that reach your changes.")
1688
+ );
1689
+ return lines.join("\n");
1690
+ }
1691
+ async function runPlan(root, opts = {}) {
1692
+ const project = await detectProject(root);
1693
+ const graph = await buildGraph(project);
1694
+ const { files } = await changedFiles(root, { base: opts.base });
1695
+ const analysis = analyze(graph, files);
1696
+ process.stdout.write(`${renderPlan(project, graph, analysis, files)}
1697
+ `);
1698
+ return 0;
1699
+ }
1700
+ var init_plan = __esm({
1701
+ "src/cli/commands/plan.ts"() {
1702
+ "use strict";
1703
+ init_detect();
1704
+ init_changes();
1705
+ init_analyze();
1706
+ init_graph();
1707
+ init_tty();
1708
+ }
1709
+ });
1710
+
1193
1711
  // src/cli/index.ts
1194
1712
  import { realpathSync } from "fs";
1195
1713
  import { argv } from "process";
@@ -1242,6 +1760,17 @@ function buildCli() {
1242
1760
  const { runWatch: runWatch2 } = await Promise.resolve().then(() => (init_watch(), watch_exports));
1243
1761
  process.exitCode = await runWatch2(process.cwd(), { poll: opts.poll });
1244
1762
  });
1763
+ cli.command("scan", "Map the import graph and untested areas (read-only)").action(async () => {
1764
+ const { runScan: runScan2 } = await Promise.resolve().then(() => (init_scan(), scan_exports));
1765
+ process.exitCode = await runScan2(process.cwd());
1766
+ });
1767
+ cli.command(
1768
+ "plan",
1769
+ "Recommend what to test, from the import graph (read-only)"
1770
+ ).option("--base <ref>", "Also factor in changes vs a git ref").action(async (opts) => {
1771
+ const { runPlan: runPlan2 } = await Promise.resolve().then(() => (init_plan(), plan_exports));
1772
+ process.exitCode = await runPlan2(process.cwd(), { base: opts.base });
1773
+ });
1245
1774
  return { raw: cli, version: VERSION };
1246
1775
  }
1247
1776
  async function main(argv2) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "veriskit",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "The fastest way to prove your software works — a zero-config verification CLI.",
5
5
  "scripts": {
6
6
  "test": "vitest run",
@@ -34,7 +34,7 @@
34
34
  "@biomejs/biome": "^2.5.3",
35
35
  "@types/node": "^26.1.1",
36
36
  "tsup": "^8.5.1",
37
- "typescript": "^7.0.2",
37
+ "typescript": "^6.0.3",
38
38
  "vitest": "^4.1.10"
39
39
  },
40
40
  "files": [