@applesnort/crosscheck 0.2.2 → 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 +76 -2
- package/bin/crosscheck.mjs +59 -16
- package/lib/lenses.mjs +35 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -200,7 +200,8 @@ lib/
|
|
|
200
200
|
prompt.mjs lens prompt construction
|
|
201
201
|
run.mjs roster planning and bounded fan-out
|
|
202
202
|
config.mjs .crosscheckrc.json discovery and validation
|
|
203
|
-
bin/crosscheck.mjs CLI: run | report | sarif | baseline |
|
|
203
|
+
bin/crosscheck.mjs CLI: run | lenses | report | sarif | baseline |
|
|
204
|
+
overlap | calibrate
|
|
204
205
|
fixtures/calibration/ planted defects, ground truth, and the calibration record
|
|
205
206
|
fixtures/deception/ 20 modules that look safe and are not, or the reverse
|
|
206
207
|
PROVENANCE.md where all of this came from
|
|
@@ -246,7 +247,80 @@ in and has to be killed to recover it.
|
|
|
246
247
|
npm test # 183 tests, no dependencies
|
|
247
248
|
```
|
|
248
249
|
|
|
249
|
-
## Adding
|
|
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`.
|
|
286
|
+
```
|
|
287
|
+
|
|
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
|
|
250
324
|
|
|
251
325
|
A lens earns its place by finding what the others miss. Give it a remit narrow
|
|
252
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
|
|
@@ -50,7 +51,7 @@ import {
|
|
|
50
51
|
import { join, relative, resolve } from 'node:path';
|
|
51
52
|
import { formatScore, score } from '../lib/calibrate.mjs';
|
|
52
53
|
import { findConfig, mergeConfig, validateConfig } from '../lib/config.mjs';
|
|
53
|
-
import { parseFrontmatter } from '../lib/lenses.mjs';
|
|
54
|
+
import { parseFrontmatter, resolveLensSet } from '../lib/lenses.mjs';
|
|
54
55
|
import {
|
|
55
56
|
countsBySeverity, lensOverlap, mergeFindings, panelVerdict
|
|
56
57
|
} from '../lib/merge.mjs';
|
|
@@ -65,7 +66,7 @@ function fail(message) {
|
|
|
65
66
|
process.exit(2);
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
const BOOLEAN_FLAGS = new Set(['dry-run', 'mixed']);
|
|
69
|
+
const BOOLEAN_FLAGS = new Set(['dry-run', 'mixed', 'no-builtin']);
|
|
69
70
|
|
|
70
71
|
function parseArgs(argv) {
|
|
71
72
|
const [command, ...rest] = argv;
|
|
@@ -132,18 +133,37 @@ function loadLensMeta(dir) {
|
|
|
132
133
|
return meta;
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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);
|
|
144
151
|
}
|
|
145
152
|
}
|
|
146
|
-
|
|
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}`);
|
|
160
|
+
}
|
|
161
|
+
dirs.push(path);
|
|
162
|
+
}
|
|
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) }));
|
|
147
167
|
}
|
|
148
168
|
|
|
149
169
|
function loadLenses(dir) {
|
|
@@ -331,14 +351,21 @@ async function runCommand(cliOptions, positional) {
|
|
|
331
351
|
if (!options.exec && !options['dry-run']) {
|
|
332
352
|
fail("run needs --exec '<command>' (or --dry-run to see the prompts)");
|
|
333
353
|
}
|
|
334
|
-
const
|
|
335
|
-
|
|
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
|
+
}
|
|
336
362
|
// mergeConfig has already normalised these to arrays from either source.
|
|
337
363
|
const overrides = { only: options.only, skip: options.skip };
|
|
338
364
|
const { roster, skipped, unmatched } = planRun(lenses, files, overrides);
|
|
339
365
|
|
|
340
366
|
process.stderr.write(
|
|
341
|
-
`crosscheck: ${files.length} file(s), lenses from
|
|
367
|
+
`crosscheck: ${files.length} file(s), ${lenses.length} lens(es) from ` +
|
|
368
|
+
`${sources.length} source(s)\n` +
|
|
342
369
|
` roster: ${roster.map(l => l.name).join(', ') || '(none)'}\n` +
|
|
343
370
|
(skipped.length
|
|
344
371
|
? skipped.map(s => ` skipped: ${s.lens} — ${s.reason}`).join('\n') + '\n'
|
|
@@ -403,7 +430,7 @@ async function runCommand(cliOptions, positional) {
|
|
|
403
430
|
|
|
404
431
|
if (options.sarif) {
|
|
405
432
|
writeFileSync(options.sarif, toSarifJson(merged, {
|
|
406
|
-
lensMeta:
|
|
433
|
+
lensMeta: Object.fromEntries(lenses.map(l => [l.name, l]))
|
|
407
434
|
}));
|
|
408
435
|
process.stderr.write(`crosscheck: wrote ${options.sarif}\n`);
|
|
409
436
|
}
|
|
@@ -430,6 +457,22 @@ async function main() {
|
|
|
430
457
|
return;
|
|
431
458
|
}
|
|
432
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
|
+
|
|
433
476
|
if (command === 'report') {
|
|
434
477
|
write(options, report(buildMerged(options)));
|
|
435
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",
|