@applesnort/crosscheck 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -19,7 +19,38 @@ or your own wrapper. crosscheck owns prompt construction, routing, fan-out, dedu
19
19
  and output; you own the model. `--dry-run` prints the roster and prompts without
20
20
  spawning anything.
21
21
 
22
- No dependencies, no install step, 153 tests.
22
+ No dependencies, no install step, 183 tests.
23
+
24
+ ## Configuration
25
+
26
+ Retyping `--exec` on every run pushes people toward shell aliases nobody else on
27
+ the team can see. Commit a `.crosscheckrc.json` instead:
28
+
29
+ ```json
30
+ {
31
+ "exec": "claude -p",
32
+ "concurrency": 2,
33
+ "skip": ["ux"],
34
+ "sarif": "panel.sarif"
35
+ }
36
+ ```
37
+
38
+ Then the whole command is:
39
+
40
+ ```bash
41
+ npx @applesnort/crosscheck run src/
42
+ ```
43
+
44
+ The nearest config is used, searching upward from the working directory and
45
+ stopping at a repo root, so running from a subdirectory still picks up the
46
+ project's settings. The loaded path is printed on every run — a run silently
47
+ reshaped by a forgotten file is the kind of thing this tool refuses everywhere
48
+ else. Command-line flags override the file, `--config <file>` points elsewhere,
49
+ and an unrecognised key is an error rather than a silent no-op, because a
50
+ misspelled `exec` that quietly does nothing is worse than a crash.
51
+
52
+ Accepted keys: `exec`, `lenses`, `concurrency`, `only`, `skip`, `mixed`, `out`,
53
+ `sarif`, `baseline`, `overlap`. Keys beginning `//` are treated as comments.
23
54
 
24
55
  > **v0.x — the API is unstable.** The CLI commands and the `lib/` exports may
25
56
  > change shape before 1.0. Pin an exact version if you depend on it.
@@ -168,6 +199,7 @@ lib/
168
199
  lib/
169
200
  prompt.mjs lens prompt construction
170
201
  run.mjs roster planning and bounded fan-out
202
+ config.mjs .crosscheckrc.json discovery and validation
171
203
  bin/crosscheck.mjs CLI: run | report | sarif | baseline | overlap | calibrate
172
204
  fixtures/calibration/ planted defects, ground truth, and the calibration record
173
205
  fixtures/deception/ 20 modules that look safe and are not, or the reverse
@@ -211,7 +243,7 @@ band.** A parallel fan-out that renders inline floods the session you are workin
211
243
  in and has to be killed to recover it.
212
244
 
213
245
  ```bash
214
- npm test # 153 tests, no dependencies
246
+ npm test # 183 tests, no dependencies
215
247
  ```
216
248
 
217
249
  ## Adding a lens
@@ -28,6 +28,12 @@
28
28
  //
29
29
  // Options: --overlap <file> independence data from `overlap` (report/sarif)
30
30
  // --lenses <dir> lens directory (routing + SARIF rule metadata)
31
+ // --config <file> config file (default: nearest .crosscheckrc.json,
32
+ // searching upward and stopping at a repo root)
33
+ //
34
+ // Settings may live in .crosscheckrc.json so a team shares one panel definition
35
+ // instead of a shell alias nobody else can see. Command-line flags win over it.
36
+ // {"exec": "claude -p", "concurrency": 2, "skip": ["ux"]}
31
37
  //
32
38
  // `run` dispatches the lenses itself. crosscheck never talks to a model: --exec
33
39
  // names a command that receives one lens prompt on stdin and returns findings on
@@ -43,6 +49,7 @@ import {
43
49
  } from 'node:fs';
44
50
  import { join, relative, resolve } from 'node:path';
45
51
  import { formatScore, score } from '../lib/calibrate.mjs';
52
+ import { findConfig, mergeConfig, validateConfig } from '../lib/config.mjs';
46
53
  import { parseFrontmatter } from '../lib/lenses.mjs';
47
54
  import {
48
55
  countsBySeverity, lensOverlap, mergeFindings, panelVerdict
@@ -175,7 +182,11 @@ function collectFiles(targets) {
175
182
  }
176
183
  return;
177
184
  }
178
- out.push(relative(process.cwd(), path) || path);
185
+ // Relative when the target is under cwd, absolute when it is not: a
186
+ // ../../../ chain is harder to read than the full path, and the model has
187
+ // to resolve whatever we print.
188
+ const rel = relative(process.cwd(), path);
189
+ out.push(!rel || rel.startsWith('..') ? path : rel);
179
190
  };
180
191
  for (const target of targets) {
181
192
  if (!existsSync(target)) {
@@ -274,27 +285,68 @@ function write(options, text) {
274
285
  }
275
286
  }
276
287
 
277
- async function runCommand(options, positional) {
288
+ // Load the nearest config file, stopping at a repo root so a stray file in a
289
+ // parent directory cannot silently reshape the run. The path is always
290
+ // reported: a run configured by a file the user forgot about is the sort of
291
+ // invisible behaviour this tool rejects everywhere else.
292
+ function loadConfig(explicitPath) {
293
+ const path = explicitPath ?? findConfig(process.cwd(), {
294
+ exists: p => existsSync(p),
295
+ isRoot: dir => existsSync(join(dir, '.git'))
296
+ });
297
+ if (!path) {
298
+ return { config: {}, path: null };
299
+ }
300
+ if (!existsSync(path)) {
301
+ fail(`no such config file: ${path}`);
302
+ }
303
+ let raw;
304
+ try {
305
+ raw = JSON.parse(readFileSync(path, 'utf8'));
306
+ } catch (error) {
307
+ fail(`${path} is not valid JSON: ${error.message}`);
308
+ }
309
+ const { config, problems } = validateConfig(raw, path);
310
+ if (problems.length) {
311
+ for (const problem of problems) {
312
+ process.stderr.write(`crosscheck: ${problem}\n`);
313
+ }
314
+ fail('fix the config file, or pass --config to point elsewhere');
315
+ }
316
+ return { config, path };
317
+ }
318
+
319
+ async function runCommand(cliOptions, positional) {
320
+ const { config, path: configPath } = loadConfig(cliOptions.config);
321
+ const options = mergeConfig(config, cliOptions);
322
+ if (configPath) {
323
+ process.stderr.write(`crosscheck: config ${configPath}\n`);
324
+ }
278
325
  if (positional.length === 0) {
279
326
  fail('run needs at least one path to audit');
280
327
  }
328
+ // Resolve the target first: a bad path is the more fundamental error, and
329
+ // reporting a missing flag instead sends the user after the wrong problem.
330
+ const files = collectFiles(positional);
281
331
  if (!options.exec && !options['dry-run']) {
282
332
  fail("run needs --exec '<command>' (or --dry-run to see the prompts)");
283
333
  }
284
- const files = collectFiles(positional);
285
334
  const lensDir = resolveLensDir(options.lenses);
286
335
  const lenses = loadLenses(lensDir);
287
- const overrides = {
288
- only: options.only?.split(',').map(s => s.trim()).filter(Boolean),
289
- skip: options.skip?.split(',').map(s => s.trim()).filter(Boolean)
290
- };
291
- const { roster, skipped } = planRun(lenses, files, overrides);
336
+ // mergeConfig has already normalised these to arrays from either source.
337
+ const overrides = { only: options.only, skip: options.skip };
338
+ const { roster, skipped, unmatched } = planRun(lenses, files, overrides);
292
339
 
293
340
  process.stderr.write(
294
341
  `crosscheck: ${files.length} file(s), lenses from ${lensDir}\n` +
295
342
  ` roster: ${roster.map(l => l.name).join(', ') || '(none)'}\n` +
296
343
  (skipped.length
297
344
  ? skipped.map(s => ` skipped: ${s.lens} — ${s.reason}`).join('\n') + '\n'
345
+ : '') +
346
+ (unmatched.length
347
+ ? ` UNREVIEWED: ${unmatched.length} file(s) matched no lens in the ` +
348
+ `roster — ${unmatched.slice(0, 5).join(', ')}` +
349
+ (unmatched.length > 5 ? `, +${unmatched.length - 5} more` : '') + '\n'
298
350
  : ''));
299
351
 
300
352
  if (roster.length === 0) {
package/lib/config.mjs ADDED
@@ -0,0 +1,136 @@
1
+ /*!
2
+ * Copyright (c) 2026 Joel Mangin. MIT License.
3
+ */
4
+ // Project configuration.
5
+ //
6
+ // Retyping --exec on every invocation is friction that pushes people toward
7
+ // shell aliases, which are invisible to everyone else on the team. A committed
8
+ // config file makes the panel reproducible: the same command produces the same
9
+ // roster and the same model for whoever runs it.
10
+ //
11
+ // Precedence is defaults < config file < command line, and the loaded path is
12
+ // always reported — a run shaped by a file the user forgot about is exactly the
13
+ // kind of silent behaviour this tool refuses everywhere else.
14
+
15
+ export const CONFIG_FILENAMES = [
16
+ '.crosscheckrc.json',
17
+ '.crosscheckrc',
18
+ 'crosscheck.config.json'
19
+ ];
20
+
21
+ // Keys a config file may set. Anything else is a typo worth reporting rather
22
+ // than ignoring: a misspelled `exec` that silently does nothing is worse than
23
+ // an error.
24
+ export const CONFIG_KEYS = new Set([
25
+ 'exec', 'lenses', 'concurrency', 'only', 'skip', 'mixed',
26
+ 'out', 'sarif', 'baseline', 'overlap'
27
+ ]);
28
+
29
+ const LIST_KEYS = new Set(['only', 'skip']);
30
+
31
+ export function validateConfig(raw, source = 'config') {
32
+ if (raw == null) {
33
+ return { config: {}, problems: [] };
34
+ }
35
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
36
+ return {
37
+ config: {},
38
+ problems: [`${source}: expected a JSON object at the top level`]
39
+ };
40
+ }
41
+ const config = {};
42
+ const problems = [];
43
+ for (const [key, value] of Object.entries(raw)) {
44
+ if (key.startsWith('//') || key === '$schema') {
45
+ continue;
46
+ }
47
+ if (!CONFIG_KEYS.has(key)) {
48
+ problems.push(
49
+ `${source}: unknown key "${key}" — expected one of ` +
50
+ [...CONFIG_KEYS].sort().join(', '));
51
+ continue;
52
+ }
53
+ if (LIST_KEYS.has(key)) {
54
+ if (Array.isArray(value)) {
55
+ config[key] = value.map(String);
56
+ } else if (typeof value === 'string') {
57
+ config[key] = value.split(',').map(s => s.trim()).filter(Boolean);
58
+ } else {
59
+ problems.push(`${source}: "${key}" must be a list or a comma string`);
60
+ }
61
+ continue;
62
+ }
63
+ if (key === 'concurrency') {
64
+ const n = Number(value);
65
+ if (!Number.isInteger(n) || n < 1) {
66
+ problems.push(`${source}: "concurrency" must be a positive integer`);
67
+ continue;
68
+ }
69
+ config[key] = n;
70
+ continue;
71
+ }
72
+ if (key === 'mixed') {
73
+ if (typeof value !== 'boolean') {
74
+ problems.push(`${source}: "mixed" must be true or false`);
75
+ continue;
76
+ }
77
+ config[key] = value;
78
+ continue;
79
+ }
80
+ if (typeof value !== 'string' || value === '') {
81
+ problems.push(`${source}: "${key}" must be a non-empty string`);
82
+ continue;
83
+ }
84
+ config[key] = value;
85
+ }
86
+ return { config, problems };
87
+ }
88
+
89
+ // Command-line flags always win. `only`/`skip` arrive as comma strings from the
90
+ // CLI and as lists from a file, so both are normalised to lists here.
91
+ export function mergeConfig(fileConfig = {}, cliOptions = {}) {
92
+ const merged = { ...fileConfig };
93
+ for (const [key, value] of Object.entries(cliOptions)) {
94
+ if (value === undefined) {
95
+ continue;
96
+ }
97
+ merged[key] = LIST_KEYS.has(key) && typeof value === 'string'
98
+ ? value.split(',').map(s => s.trim()).filter(Boolean)
99
+ : value;
100
+ }
101
+ for (const key of LIST_KEYS) {
102
+ if (typeof merged[key] === 'string') {
103
+ merged[key] = merged[key].split(',').map(s => s.trim()).filter(Boolean);
104
+ }
105
+ }
106
+ return merged;
107
+ }
108
+
109
+ // Walk from `startDir` toward the filesystem root looking for a config file, so
110
+ // running from a subdirectory of a project still picks up its settings.
111
+ // `readFile` and `exists` are injected to keep this testable without a disk.
112
+ export function findConfig(startDir, { exists, isRoot = null } = {}) {
113
+ if (typeof exists !== 'function') {
114
+ throw new Error('findConfig requires an exists() probe');
115
+ }
116
+ let dir = startDir;
117
+ const seen = new Set();
118
+ while (dir && !seen.has(dir)) {
119
+ seen.add(dir);
120
+ for (const name of CONFIG_FILENAMES) {
121
+ const candidate = dir.endsWith('/') ? `${dir}${name}` : `${dir}/${name}`;
122
+ if (exists(candidate)) {
123
+ return candidate;
124
+ }
125
+ }
126
+ if (isRoot?.(dir)) {
127
+ return null;
128
+ }
129
+ const parent = dir.replace(/\/[^/]*\/?$/, '');
130
+ if (parent === dir || parent === '') {
131
+ return null;
132
+ }
133
+ dir = parent;
134
+ }
135
+ return null;
136
+ }
package/lib/prompt.mjs CHANGED
@@ -11,6 +11,13 @@
11
11
  export const CONTRACT_LINE =
12
12
  'file:line — SEVERITY — issue — fix';
13
13
 
14
+ // The frontmatter is routing metadata — globs, cites, owns — consumed by the
15
+ // router before dispatch. Sending it to the model costs tokens on every call and
16
+ // tells it nothing it needs, so the body is what gets inlined.
17
+ export function stripFrontmatter(text) {
18
+ return String(text ?? '').replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim();
19
+ }
20
+
14
21
  // A lens that reports on everything is useless, and a lens that quietly reviews
15
22
  // outside its remit corrupts the consensus signal — so both halves of its scope
16
23
  // are restated in the prompt, not just the part it owns.
@@ -33,10 +40,15 @@ export function buildLensPrompt(lens, files, options = {}) {
33
40
  parts.push(`You are running the **${lens.name}** audit lens.`);
34
41
 
35
42
  if (definition) {
43
+ const body = stripFrontmatter(definition);
44
+ if (!body) {
45
+ throw new Error(
46
+ `lens ${lens.name} has frontmatter but no body to adopt`);
47
+ }
36
48
  parts.push(
37
49
  'Adopt this lens completely — its method, its framing, its severity ' +
38
50
  'scale, and its output contract:\n\n' +
39
- '--- BEGIN LENS DEFINITION ---\n' + definition.trim() +
51
+ '--- BEGIN LENS DEFINITION ---\n' + body +
40
52
  '\n--- END LENS DEFINITION ---');
41
53
  } else if (definitionPath) {
42
54
  parts.push(
package/lib/run.mjs CHANGED
@@ -28,6 +28,11 @@ export function planRun(lenses, files, overrides = {}) {
28
28
  }
29
29
  const routed = routeRoster(lenses, files);
30
30
  const { roster, skipped } = applyOverrides(routed, overrides);
31
+ // A file that no rostered lens will read is a hole in the coverage. Reporting
32
+ // the file count without it lets a run look complete when part of the target
33
+ // was never examined.
34
+ const covered = new Set(roster.flatMap(l => l.files));
35
+ const unmatched = files.filter(f => !covered.has(f));
31
36
  if (overrides.only?.length) {
32
37
  const known = new Set((lenses ?? []).map(l => l.name));
33
38
  for (const name of overrides.only) {
@@ -36,7 +41,7 @@ export function planRun(lenses, files, overrides = {}) {
36
41
  }
37
42
  }
38
43
  }
39
- return { roster, skipped };
44
+ return { roster, skipped, unmatched };
40
45
  }
41
46
 
42
47
  export function promptsFor(roster, options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applesnort/crosscheck",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Run independent review lenses in parallel and merge their findings into one deduped, consensus-ranked report \u2014 with SARIF output.",
5
5
  "license": "MIT",
6
6
  "author": "Joel Mangin",
@@ -20,7 +20,8 @@
20
20
  "./lenses": "./lib/lenses.mjs",
21
21
  "./calibrate": "./lib/calibrate.mjs",
22
22
  "./run": "./lib/run.mjs",
23
- "./prompt": "./lib/prompt.mjs"
23
+ "./prompt": "./lib/prompt.mjs",
24
+ "./config": "./lib/config.mjs"
24
25
  },
25
26
  "files": [
26
27
  "bin/",