@applesnort/crosscheck 0.2.0 → 0.2.1
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/bin/crosscheck.mjs +14 -3
- package/lib/config.mjs +136 -0
- package/lib/prompt.mjs +13 -1
- package/lib/run.mjs +6 -1
- package/package.json +1 -1
package/bin/crosscheck.mjs
CHANGED
|
@@ -175,7 +175,11 @@ function collectFiles(targets) {
|
|
|
175
175
|
}
|
|
176
176
|
return;
|
|
177
177
|
}
|
|
178
|
-
|
|
178
|
+
// Relative when the target is under cwd, absolute when it is not: a
|
|
179
|
+
// ../../../ chain is harder to read than the full path, and the model has
|
|
180
|
+
// to resolve whatever we print.
|
|
181
|
+
const rel = relative(process.cwd(), path);
|
|
182
|
+
out.push(!rel || rel.startsWith('..') ? path : rel);
|
|
179
183
|
};
|
|
180
184
|
for (const target of targets) {
|
|
181
185
|
if (!existsSync(target)) {
|
|
@@ -278,23 +282,30 @@ async function runCommand(options, positional) {
|
|
|
278
282
|
if (positional.length === 0) {
|
|
279
283
|
fail('run needs at least one path to audit');
|
|
280
284
|
}
|
|
285
|
+
// Resolve the target first: a bad path is the more fundamental error, and
|
|
286
|
+
// reporting a missing flag instead sends the user after the wrong problem.
|
|
287
|
+
const files = collectFiles(positional);
|
|
281
288
|
if (!options.exec && !options['dry-run']) {
|
|
282
289
|
fail("run needs --exec '<command>' (or --dry-run to see the prompts)");
|
|
283
290
|
}
|
|
284
|
-
const files = collectFiles(positional);
|
|
285
291
|
const lensDir = resolveLensDir(options.lenses);
|
|
286
292
|
const lenses = loadLenses(lensDir);
|
|
287
293
|
const overrides = {
|
|
288
294
|
only: options.only?.split(',').map(s => s.trim()).filter(Boolean),
|
|
289
295
|
skip: options.skip?.split(',').map(s => s.trim()).filter(Boolean)
|
|
290
296
|
};
|
|
291
|
-
const { roster, skipped } = planRun(lenses, files, overrides);
|
|
297
|
+
const { roster, skipped, unmatched } = planRun(lenses, files, overrides);
|
|
292
298
|
|
|
293
299
|
process.stderr.write(
|
|
294
300
|
`crosscheck: ${files.length} file(s), lenses from ${lensDir}\n` +
|
|
295
301
|
` roster: ${roster.map(l => l.name).join(', ') || '(none)'}\n` +
|
|
296
302
|
(skipped.length
|
|
297
303
|
? skipped.map(s => ` skipped: ${s.lens} — ${s.reason}`).join('\n') + '\n'
|
|
304
|
+
: '') +
|
|
305
|
+
(unmatched.length
|
|
306
|
+
? ` UNREVIEWED: ${unmatched.length} file(s) matched no lens in the ` +
|
|
307
|
+
`roster — ${unmatched.slice(0, 5).join(', ')}` +
|
|
308
|
+
(unmatched.length > 5 ? `, +${unmatched.length - 5} more` : '') + '\n'
|
|
298
309
|
: ''));
|
|
299
310
|
|
|
300
311
|
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.1",
|
|
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",
|