@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 +34 -2
- package/bin/crosscheck.mjs +60 -8
- package/lib/config.mjs +136 -0
- package/lib/prompt.mjs +13 -1
- package/lib/run.mjs +6 -1
- 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,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 #
|
|
246
|
+
npm test # 183 tests, no dependencies
|
|
215
247
|
```
|
|
216
248
|
|
|
217
249
|
## Adding a lens
|
package/bin/crosscheck.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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' +
|
|
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.
|
|
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/",
|