@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 +110 -4
- package/bin/crosscheck.mjs +105 -21
- package/lib/lenses.mjs +35 -0
- package/package.json +3 -2
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,
|
|
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
|
-
|
|
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 #
|
|
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
|
-
|
|
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
|
package/bin/crosscheck.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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/",
|