@applesnort/crosscheck 0.2.1 → 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/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,7 +199,9 @@ lib/
168
199
  lib/
169
200
  prompt.mjs lens prompt construction
170
201
  run.mjs roster planning and bounded fan-out
171
- bin/crosscheck.mjs CLI: run | report | sarif | baseline | overlap | calibrate
202
+ config.mjs .crosscheckrc.json discovery and validation
203
+ bin/crosscheck.mjs CLI: run | lenses | report | sarif | baseline |
204
+ overlap | calibrate
172
205
  fixtures/calibration/ planted defects, ground truth, and the calibration record
173
206
  fixtures/deception/ 20 modules that look safe and are not, or the reverse
174
207
  PROVENANCE.md where all of this came from
@@ -211,10 +244,83 @@ band.** A parallel fan-out that renders inline floods the session you are workin
211
244
  in and has to be killed to recover it.
212
245
 
213
246
  ```bash
214
- npm test # 153 tests, no dependencies
247
+ npm test # 183 tests, no dependencies
248
+ ```
249
+
250
+ ## Adding your own lenses
251
+
252
+ Lens sources layer, in increasing precedence:
253
+
254
+ 1. the lenses packaged with crosscheck
255
+ 2. `./lenses` or `./.crosscheck/lenses` in your project, if present
256
+ 3. anything named by `--lenses dir,dir` or the config's `lenses` key
257
+
258
+ A later source **adds** to the earlier ones. A lens whose `name` matches an
259
+ earlier one **overrides** it, and the override is printed — so customising the
260
+ stock `check` costs one file rather than forking all five and losing upstream
261
+ changes. `--no-builtin` drops the packaged set entirely.
262
+
263
+ ```bash
264
+ mkdir -p .crosscheck/lenses
265
+ $EDITOR .crosscheck/lenses/chaos.md
266
+ npx @applesnort/crosscheck lenses # what resolved, and from where
267
+ ```
268
+
269
+ A lens is a markdown file: five frontmatter keys, then the prompt.
270
+
271
+ ```markdown
272
+ ---
273
+ name: chaos
274
+ summary: adversarial user trying to break the flow
275
+ when: [**/*.{js,jsx,tsx,vue}]
276
+ owns: states reachable by misuse — double-submit, back button, hostile input
277
+ not-owns: correctness, security categories, architecture
278
+ ---
279
+
280
+ # Lens: chaos
281
+
282
+ You are trying to break this, not review it. ...
283
+
284
+ Findings only: `file:line — SEVERITY — issue — fix`. SEVERITY is BLOCK, FIX, or
285
+ CONSIDER. If nothing here can be broken, reply exactly `NO FINDINGS`.
215
286
  ```
216
287
 
217
- ## Adding a lens
288
+ `when` routes it — a lens whose globs match nothing in the target is skipped, with
289
+ the reason printed. `not-owns` is required: a lens that never declines dilutes the
290
+ signal everything else depends on. The body should end by restating the output
291
+ contract, since that is what the parser expects back.
292
+
293
+ `crosscheck lenses` prints the resolved set with each lens's origin and globs,
294
+ which is the fastest way to see why something did or did not run.
295
+
296
+ ### Local lenses stay local
297
+
298
+ Nothing in `./lenses` or `./.crosscheck/lenses` is packaged, uploaded, or shared
299
+ by crosscheck. Those files are read off your disk at dispatch time and go nowhere
300
+ else. That has two practical consequences worth knowing before you write any.
301
+
302
+ **A local lens can be as specific as you like.** The packaged lenses are
303
+ deliberately generic, which also makes them shallow: they cannot know your
304
+ storage conventions, your framework's failure modes, the mistake your team makes
305
+ every quarter, or the review standard one colleague applies better than anyone
306
+ else. A lens that encodes any of that will outperform a generic one on your
307
+ codebase, and it belongs in your repo rather than upstream. The useful ones
308
+ usually aren't portable.
309
+
310
+ **Distribution is where obligations start, and you are not distributing.** If you
311
+ adapt a lens from a published persona set, a standards document, or a colleague's
312
+ review checklist, keeping it local puts you in the same position as any private
313
+ note-taking. Those materials often carry licences requiring attribution — the UK
314
+ government's accessibility personas are published under the Open Government
315
+ Licence, for one — and that condition attaches when you *publish* a derivative,
316
+ not when you run one. If a local lens later becomes something you want to share,
317
+ that is the moment to check what it derives from and credit it.
318
+
319
+ The corollary: a lens holding a named individual's review preferences, or a
320
+ description of an unreleased product, is a local lens permanently. Publishing it
321
+ shares something about a person or a project, not just a prompt.
322
+
323
+ ## Writing a good lens
218
324
 
219
325
  A lens earns its place by finding what the others miss. Give it a remit narrow
220
326
  enough that it declines most changes, state what it does *not* own, and make it name
@@ -20,6 +20,7 @@
20
20
  // crosscheck run <path...> --exec '<command>' [--lenses dir] [--only a,b]
21
21
  // [--skip x,y] [--concurrency N] [--out run.json]
22
22
  // [--sarif f] [--baseline b] [--mixed] [--dry-run]
23
+ // crosscheck lenses [--lenses dir,dir] [--no-builtin]
23
24
  // crosscheck report [--in run.json] [--baseline b.json]
24
25
  // crosscheck sarif [--in run.json] [--baseline b.json] [--out x.sarif]
25
26
  // crosscheck baseline [--in run.json] --out baseline.json
@@ -28,6 +29,12 @@
28
29
  //
29
30
  // Options: --overlap <file> independence data from `overlap` (report/sarif)
30
31
  // --lenses <dir> lens directory (routing + SARIF rule metadata)
32
+ // --config <file> config file (default: nearest .crosscheckrc.json,
33
+ // searching upward and stopping at a repo root)
34
+ //
35
+ // Settings may live in .crosscheckrc.json so a team shares one panel definition
36
+ // instead of a shell alias nobody else can see. Command-line flags win over it.
37
+ // {"exec": "claude -p", "concurrency": 2, "skip": ["ux"]}
31
38
  //
32
39
  // `run` dispatches the lenses itself. crosscheck never talks to a model: --exec
33
40
  // names a command that receives one lens prompt on stdin and returns findings on
@@ -43,7 +50,8 @@ import {
43
50
  } from 'node:fs';
44
51
  import { join, relative, resolve } from 'node:path';
45
52
  import { formatScore, score } from '../lib/calibrate.mjs';
46
- import { parseFrontmatter } from '../lib/lenses.mjs';
53
+ import { findConfig, mergeConfig, validateConfig } from '../lib/config.mjs';
54
+ import { parseFrontmatter, resolveLensSet } from '../lib/lenses.mjs';
47
55
  import {
48
56
  countsBySeverity, lensOverlap, mergeFindings, panelVerdict
49
57
  } from '../lib/merge.mjs';
@@ -58,7 +66,7 @@ function fail(message) {
58
66
  process.exit(2);
59
67
  }
60
68
 
61
- const BOOLEAN_FLAGS = new Set(['dry-run', 'mixed']);
69
+ const BOOLEAN_FLAGS = new Set(['dry-run', 'mixed', 'no-builtin']);
62
70
 
63
71
  function parseArgs(argv) {
64
72
  const [command, ...rest] = argv;
@@ -125,18 +133,37 @@ function loadLensMeta(dir) {
125
133
  return meta;
126
134
  }
127
135
 
128
- // Lens directory: an explicit --lenses, else ./lenses, else the copy shipped
129
- // with the package. Resolved loudly so a typo does not silently run zero lenses.
130
- function resolveLensDir(dir) {
131
- const candidates = dir
132
- ? [resolve(dir)]
133
- : [resolve('lenses'), new URL('../lenses/', import.meta.url).pathname];
134
- for (const candidate of candidates) {
135
- if (existsSync(candidate)) {
136
- return candidate;
136
+ const BUILTIN_LENS_DIR = new URL('../lenses/', import.meta.url).pathname;
137
+
138
+ // Lens sources, in increasing precedence: the packaged lenses, then ./lenses or
139
+ // .crosscheck/lenses if present, then anything named by --lenses. Layering
140
+ // rather than replacing means adding one lens costs one file instead of forking
141
+ // all of them and losing upstream changes.
142
+ function lensSources(option, { includeBuiltin = true } = {}) {
143
+ const dirs = [];
144
+ if (includeBuiltin) {
145
+ dirs.push(BUILTIN_LENS_DIR);
146
+ }
147
+ for (const local of ['lenses', '.crosscheck/lenses']) {
148
+ const path = resolve(local);
149
+ if (existsSync(path) && path !== resolve(BUILTIN_LENS_DIR)) {
150
+ dirs.push(path);
151
+ }
152
+ }
153
+ const explicit = Array.isArray(option)
154
+ ? option
155
+ : (option ? String(option).split(',').map(d => d.trim()).filter(Boolean) : []);
156
+ for (const dir of explicit) {
157
+ const path = resolve(dir);
158
+ if (!existsSync(path)) {
159
+ fail(`no such lens directory: ${dir}`);
137
160
  }
161
+ dirs.push(path);
138
162
  }
139
- fail(`no lens directory found (looked in ${candidates.join(', ')})`);
163
+ if (dirs.length === 0) {
164
+ fail('no lens directories to load (--no-builtin with no --lenses?)');
165
+ }
166
+ return dirs.map(dir => ({ origin: dir, lenses: loadLenses(dir) }));
140
167
  }
141
168
 
142
169
  function loadLenses(dir) {
@@ -278,7 +305,43 @@ function write(options, text) {
278
305
  }
279
306
  }
280
307
 
281
- async function runCommand(options, positional) {
308
+ // Load the nearest config file, stopping at a repo root so a stray file in a
309
+ // parent directory cannot silently reshape the run. The path is always
310
+ // reported: a run configured by a file the user forgot about is the sort of
311
+ // invisible behaviour this tool rejects everywhere else.
312
+ function loadConfig(explicitPath) {
313
+ const path = explicitPath ?? findConfig(process.cwd(), {
314
+ exists: p => existsSync(p),
315
+ isRoot: dir => existsSync(join(dir, '.git'))
316
+ });
317
+ if (!path) {
318
+ return { config: {}, path: null };
319
+ }
320
+ if (!existsSync(path)) {
321
+ fail(`no such config file: ${path}`);
322
+ }
323
+ let raw;
324
+ try {
325
+ raw = JSON.parse(readFileSync(path, 'utf8'));
326
+ } catch (error) {
327
+ fail(`${path} is not valid JSON: ${error.message}`);
328
+ }
329
+ const { config, problems } = validateConfig(raw, path);
330
+ if (problems.length) {
331
+ for (const problem of problems) {
332
+ process.stderr.write(`crosscheck: ${problem}\n`);
333
+ }
334
+ fail('fix the config file, or pass --config to point elsewhere');
335
+ }
336
+ return { config, path };
337
+ }
338
+
339
+ async function runCommand(cliOptions, positional) {
340
+ const { config, path: configPath } = loadConfig(cliOptions.config);
341
+ const options = mergeConfig(config, cliOptions);
342
+ if (configPath) {
343
+ process.stderr.write(`crosscheck: config ${configPath}\n`);
344
+ }
282
345
  if (positional.length === 0) {
283
346
  fail('run needs at least one path to audit');
284
347
  }
@@ -288,16 +351,21 @@ async function runCommand(options, positional) {
288
351
  if (!options.exec && !options['dry-run']) {
289
352
  fail("run needs --exec '<command>' (or --dry-run to see the prompts)");
290
353
  }
291
- const lensDir = resolveLensDir(options.lenses);
292
- const lenses = loadLenses(lensDir);
293
- const overrides = {
294
- only: options.only?.split(',').map(s => s.trim()).filter(Boolean),
295
- skip: options.skip?.split(',').map(s => s.trim()).filter(Boolean)
296
- };
354
+ const sources = lensSources(options.lenses,
355
+ { includeBuiltin: !options['no-builtin'] });
356
+ const { lenses, shadowed } = resolveLensSet(sources);
357
+ const lensDir = sources.at(-1).origin;
358
+ for (const s of shadowed) {
359
+ process.stderr.write(
360
+ `crosscheck: lens "${s.name}" from ${s.winner} overrides ${s.shadowedFrom}\n`);
361
+ }
362
+ // mergeConfig has already normalised these to arrays from either source.
363
+ const overrides = { only: options.only, skip: options.skip };
297
364
  const { roster, skipped, unmatched } = planRun(lenses, files, overrides);
298
365
 
299
366
  process.stderr.write(
300
- `crosscheck: ${files.length} file(s), lenses from ${lensDir}\n` +
367
+ `crosscheck: ${files.length} file(s), ${lenses.length} lens(es) from ` +
368
+ `${sources.length} source(s)\n` +
301
369
  ` roster: ${roster.map(l => l.name).join(', ') || '(none)'}\n` +
302
370
  (skipped.length
303
371
  ? skipped.map(s => ` skipped: ${s.lens} — ${s.reason}`).join('\n') + '\n'
@@ -362,7 +430,7 @@ async function runCommand(options, positional) {
362
430
 
363
431
  if (options.sarif) {
364
432
  writeFileSync(options.sarif, toSarifJson(merged, {
365
- lensMeta: loadLensMeta(lensDir)
433
+ lensMeta: Object.fromEntries(lenses.map(l => [l.name, l]))
366
434
  }));
367
435
  process.stderr.write(`crosscheck: wrote ${options.sarif}\n`);
368
436
  }
@@ -389,6 +457,22 @@ async function main() {
389
457
  return;
390
458
  }
391
459
 
460
+ if (command === 'lenses') {
461
+ const sources = lensSources(options.lenses,
462
+ { includeBuiltin: !options['no-builtin'] });
463
+ const { lenses, shadowed } = resolveLensSet(sources);
464
+ const out = [`${lenses.length} lens(es) from ${sources.length} source(s):`];
465
+ for (const lens of lenses) {
466
+ out.push(` ${lens.name.padEnd(16)} ${lens.origin}`);
467
+ out.push(` when: ${(lens.when ?? []).join(', ')}`);
468
+ }
469
+ for (const s of shadowed) {
470
+ out.push(` override: "${s.name}" from ${s.winner} shadows ${s.shadowedFrom}`);
471
+ }
472
+ process.stdout.write(out.join('\n') + '\n');
473
+ return;
474
+ }
475
+
392
476
  if (command === 'report') {
393
477
  write(options, report(buildMerged(options)));
394
478
  return;
package/lib/lenses.mjs CHANGED
@@ -198,3 +198,38 @@ export function applyOverrides(routed, { only, skip } = {}) {
198
198
  }
199
199
  return { roster, skipped };
200
200
  }
201
+
202
+ // Layered lens resolution.
203
+ //
204
+ // A single lens directory has to be either yours or the packaged one, which
205
+ // forces anyone adding a lens to fork all of them and lose upstream changes.
206
+ // Sources are layered instead, in increasing precedence: a later source with the
207
+ // same lens `name` shadows an earlier one, so overriding one lens costs one file
208
+ // rather than a fork.
209
+ //
210
+ // sources: [{origin, lenses: [{name, ...}]}] — origin is a label for reporting,
211
+ // usually the directory the lenses were read from.
212
+ export function resolveLensSet(sources) {
213
+ const byName = new Map();
214
+ const shadowed = [];
215
+ for (const source of sources ?? []) {
216
+ for (const lens of source.lenses ?? []) {
217
+ if (!lens?.name) {
218
+ continue;
219
+ }
220
+ const previous = byName.get(lens.name);
221
+ if (previous) {
222
+ shadowed.push({
223
+ name: lens.name,
224
+ winner: source.origin,
225
+ shadowedFrom: previous.origin
226
+ });
227
+ }
228
+ byName.set(lens.name, { ...lens, origin: source.origin });
229
+ }
230
+ }
231
+ return {
232
+ lenses: [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)),
233
+ shadowed
234
+ };
235
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applesnort/crosscheck",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
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/",