@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 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 | overlap | calibrate
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 a lens
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
@@ -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
- // Lens directory: an explicit --lenses, else ./lenses, else the copy shipped
136
- // with the package. Resolved loudly so a typo does not silently run zero lenses.
137
- function resolveLensDir(dir) {
138
- const candidates = dir
139
- ? [resolve(dir)]
140
- : [resolve('lenses'), new URL('../lenses/', import.meta.url).pathname];
141
- for (const candidate of candidates) {
142
- if (existsSync(candidate)) {
143
- 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);
144
151
  }
145
152
  }
146
- fail(`no lens directory found (looked in ${candidates.join(', ')})`);
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 lensDir = resolveLensDir(options.lenses);
335
- const lenses = loadLenses(lensDir);
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 ${lensDir}\n` +
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: loadLensMeta(lensDir)
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.2.2",
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",