testguard-cli 0.12.0 → 0.14.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/CHANGELOG.md +144 -0
- package/README.md +95 -11
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +8 -0
- package/spec/lib/validate.mjs +50 -3
- package/spec/schemas/concerns.schema.json +41 -0
- package/spec/schemas/evidence.schema.json +35 -2
- package/spec/schemas/sweep.schema.json +61 -0
- package/src/cli.mjs +7 -1
- package/src/commands/concerns.mjs +32 -0
- package/src/commands/sweep.mjs +52 -23
- package/src/probe/probe.mjs +8 -1
- package/src/probe/pyimports.mjs +9 -2
- package/src/scaffold/producers.python.mjs +54 -2
- package/src/supply/concerns.mjs +162 -0
- package/src/supply/feedback.mjs +146 -0
- package/src/supply/persistence.mjs +73 -0
- package/src/supply/select.mjs +39 -6
- package/src/sweep/sweep.mjs +114 -18
- package/src/util/glob.mjs +5 -1
package/src/cli.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { replayCommand } from './commands/replay.mjs';
|
|
|
19
19
|
import { mcpCommand } from './commands/mcp.mjs';
|
|
20
20
|
import { admitCommand } from './commands/admit.mjs';
|
|
21
21
|
import { sweepCommand } from './commands/sweep.mjs';
|
|
22
|
+
import { concernsCommand } from './commands/concerns.mjs';
|
|
22
23
|
|
|
23
24
|
const VERSION = JSON.parse(readFileSync(join(fileURLToPath(import.meta.url), '..', '..', 'package.json'), 'utf8')).version;
|
|
24
25
|
const ISSUES = 'https://github.com/raccioly/testguard/issues';
|
|
@@ -34,6 +35,7 @@ export const USAGE = `testguard ${VERSION} — proves a test suite defends the c
|
|
|
34
35
|
testguard gate [dir] fail when a changed source file carries no claim and no excusing ignore entry
|
|
35
36
|
testguard scaffold <file> propose faults mechanically for one source file, as a draft claims document
|
|
36
37
|
testguard sweep [dir] propose faults for the changed source files that carry no claim, probe a bounded selection, and report what nothing noticed
|
|
38
|
+
testguard concerns [dir] the one-sentence scopes a sweep can be aimed by: where to look, and which shapes of break are relevant
|
|
37
39
|
testguard mcp serve the read-only status/brief/claims/evidence tools over MCP on stdio, so the loop works in any agent harness
|
|
38
40
|
testguard replay [dir] would this suite have caught the bugs that already escaped? Replays real fix commits and calibrates fault classes against them
|
|
39
41
|
testguard admit <test> --claim <ID> the two-gate rule as one verb: is this test green on HEAD and does it fail on every fault of the claim?
|
|
@@ -77,6 +79,8 @@ probe
|
|
|
77
79
|
|
|
78
80
|
scaffold --claim <ID> (put every proposal under this claim; copies it if it exists) --out <path> --json
|
|
79
81
|
sweep --changed <ref> (required unless --save-paths; CI bases and a safe local default are detected) --cap <n>
|
|
82
|
+
--concern <ID> aim the sweep by one of this project's concerns: its targets and its fault classes.
|
|
83
|
+
testguard concerns lists them. --save-paths is sugar for the built-in SAVE-PERSISTS
|
|
80
84
|
--save-paths sweep every file that WRITES TO STORAGE instead of the diff, and report the surface as the
|
|
81
85
|
denominator: "120 writes across 38 files, swept 12". A clean sweep over an unstated
|
|
82
86
|
denominator is a sample of unknown size, not a guarantee that saving works
|
|
@@ -126,7 +130,7 @@ brief --evidence <path> --baseline <path> --max <n> --text (print only;
|
|
|
126
130
|
exit codes: 0 nothing new to prove · 1 unproven claims (or claim drift, or unclaimed changes) · 2 precondition failed · 3 usage
|
|
127
131
|
`;
|
|
128
132
|
|
|
129
|
-
const COMMANDS = { mcp: mcpCommand, replay: replayCommand, probe: probeCommand, claims: claimsCommand, baseline: baselineCommand, brief: briefCommand, scaffold: scaffoldCommand, status: statusCommand, init: initCommand, gate: gateCommand, admit: admitCommand, sweep: sweepCommand };
|
|
133
|
+
const COMMANDS = { mcp: mcpCommand, replay: replayCommand, probe: probeCommand, claims: claimsCommand, baseline: baselineCommand, brief: briefCommand, scaffold: scaffoldCommand, status: statusCommand, init: initCommand, gate: gateCommand, admit: admitCommand, sweep: sweepCommand, concerns: concernsCommand };
|
|
130
134
|
|
|
131
135
|
export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n'), err: (s) => process.stderr.write(s + '\n') }) {
|
|
132
136
|
let parsed;
|
|
@@ -154,6 +158,8 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
|
|
|
154
158
|
exclude: { type: 'string', multiple: true },
|
|
155
159
|
cap: { type: 'string' },
|
|
156
160
|
'save-paths': { type: 'boolean', default: false },
|
|
161
|
+
concern: { type: 'string' },
|
|
162
|
+
concerns: { type: 'string' },
|
|
157
163
|
strict: { type: 'boolean', default: false },
|
|
158
164
|
explain: { type: 'boolean', default: false },
|
|
159
165
|
ignore: { type: 'string' },
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { loadConcerns, renderConcerns } from '../supply/concerns.mjs';
|
|
3
|
+
import { validate } from '../../spec/lib/validate.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `testguard concerns [dir]`: the one-sentence scopes a sweep can be aimed by.
|
|
7
|
+
*
|
|
8
|
+
* A concern says WHERE to look and WHICH shapes of break are relevant. It is
|
|
9
|
+
* never a promise about the code — only a claim is that — so this command
|
|
10
|
+
* validates and lists, and has no exit code that means "something is wrong
|
|
11
|
+
* with your project". Exit 2 is a malformed concerns file, which is wrong with
|
|
12
|
+
* the FILE.
|
|
13
|
+
*/
|
|
14
|
+
export async function concernsCommand({ projectDir, values, version }, io) {
|
|
15
|
+
const path = values.concerns ? resolve(values.concerns) : undefined;
|
|
16
|
+
const loaded = loadConcerns(projectDir, { path });
|
|
17
|
+
|
|
18
|
+
if (loaded.source) {
|
|
19
|
+
const result = validate('concerns', { schemaVersion: 1, concerns: loaded.raw });
|
|
20
|
+
if (!result.ok) {
|
|
21
|
+
io.err(`${loaded.source} does not conform to the spec:`);
|
|
22
|
+
for (const e of result.errors) io.err(` ${e.path}: ${e.message}`);
|
|
23
|
+
return 2;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (values.json) {
|
|
27
|
+
io.out(JSON.stringify({ schemaVersion: 1, tool: { name: 'testguard', version }, source: loaded.source, shadowed: loaded.shadowed, concerns: loaded.concerns }, null, 2));
|
|
28
|
+
} else {
|
|
29
|
+
io.out(renderConcerns(loaded));
|
|
30
|
+
}
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
package/src/commands/sweep.mjs
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { join, resolve } from 'node:path';
|
|
2
|
-
import { sweep, renderSweep } from '../sweep/sweep.mjs';
|
|
2
|
+
import { sweep, renderSweep, ConcernError } from '../sweep/sweep.mjs';
|
|
3
3
|
import { resolveChangedRef } from '../gate/changed.mjs';
|
|
4
4
|
import { writeSpecDoc } from '../evidence/writer.mjs';
|
|
5
5
|
|
|
6
6
|
export const sweepPath = (projectDir) => join(projectDir, '.testguard', 'sweep.json');
|
|
7
|
+
/**
|
|
8
|
+
* A sweep's own evidence, beside the canonical one and never replacing it.
|
|
9
|
+
* The next sweep reads it to learn which fault classes are productive HERE,
|
|
10
|
+
* which is the feedback half of the selection Google reports taking from 15%
|
|
11
|
+
* to 89% productive. Machine-proposed faults, so it is the closest observation
|
|
12
|
+
* of the distribution the ranker actually orders.
|
|
13
|
+
*/
|
|
14
|
+
export const sweepEvidencePath = (projectDir) => join(projectDir, '.testguard', 'sweep-evidence.json');
|
|
7
15
|
|
|
8
16
|
/**
|
|
9
17
|
* `testguard sweep [dir] --changed <ref>`: propose faults for the changed
|
|
@@ -21,11 +29,14 @@ export const sweepPath = (projectDir) => join(projectDir, '.testguard', 'sweep.j
|
|
|
21
29
|
*/
|
|
22
30
|
export async function sweepCommand({ projectDir, values, version }, io) {
|
|
23
31
|
const mode = values['save-paths'] ? 'save-paths' : 'changed';
|
|
32
|
+
// A concern that aims at the write surface or at a glob needs no diff, the
|
|
33
|
+
// same as --save-paths. Only a concern that defers to the gate does.
|
|
34
|
+
const aimed = Boolean(values.concern) || mode === 'save-paths';
|
|
24
35
|
// `--save-paths` scans the whole write surface, so it needs no diff. The gate
|
|
25
36
|
// is still computed underneath (the document reports `changed`), and HEAD is
|
|
26
37
|
// a reference every repository has.
|
|
27
38
|
const resolved = resolveChangedRef({ explicit: values.changed, projectDir })
|
|
28
|
-
?? (
|
|
39
|
+
?? (aimed ? { ref: 'HEAD', from: values.concern ? `concern ${values.concern}` : 'save-paths', required: false } : null);
|
|
29
40
|
if (!resolved) {
|
|
30
41
|
io.err('sweep needs a reference to measure the change against: --changed <ref> (e.g. origin/main), or set TESTGUARD_CHANGED_REF. CI bases and a safe local remote default or differently named upstream are detected automatically. Or sweep the write surface instead with --save-paths.');
|
|
31
42
|
return 3;
|
|
@@ -46,34 +57,52 @@ export async function sweepCommand({ projectDir, values, version }, io) {
|
|
|
46
57
|
}
|
|
47
58
|
if (mode === 'changed' && !resolved.required && !values.json && !values.quiet) io.err(`sweep: comparing against ${resolved.ref} (${resolved.from})`);
|
|
48
59
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
let doc;
|
|
61
|
+
try {
|
|
62
|
+
doc = await sweep({
|
|
63
|
+
projectDir,
|
|
64
|
+
ref: resolved.ref,
|
|
65
|
+
mode,
|
|
66
|
+
concern: values.concern,
|
|
67
|
+
concernsPath: values.concerns ? resolve(values.concerns) : undefined,
|
|
68
|
+
includeDirty: values['include-dirty'],
|
|
69
|
+
exclude: values.exclude ?? [],
|
|
70
|
+
cap,
|
|
71
|
+
confirmRuns,
|
|
72
|
+
budgetMs,
|
|
73
|
+
runnerCommand: values['runner-cmd'],
|
|
74
|
+
runnerName: values.runner,
|
|
75
|
+
nodeModules: values['node-modules'] ? resolve(values['node-modules']) : process.env.TESTGUARD_NODE_MODULES,
|
|
76
|
+
toolVersion: version,
|
|
77
|
+
claimsPath: values.claims ? resolve(values.claims) : undefined,
|
|
78
|
+
ignorePath: values.ignore ? resolve(values.ignore) : undefined,
|
|
79
|
+
onStage: !values.quiet && !values.json && process.stderr.isTTY
|
|
65
80
|
? ({ claimId, faultId, stage, i, n }) => process.stderr.write(`\r\x1b[K … ${claimId}/${faultId} ${stage} ${i}/${n}`)
|
|
66
81
|
: undefined,
|
|
67
|
-
|
|
68
|
-
|
|
82
|
+
onWarn: (m) => { if (!values.quiet) io.err(`warning: ${m}`); },
|
|
83
|
+
});
|
|
84
|
+
} catch (e) {
|
|
85
|
+
// A concern named on the command line that this project does not declare
|
|
86
|
+
// is a usage error, not a precondition failure: nothing about the
|
|
87
|
+
// repository is wrong.
|
|
88
|
+
if (e instanceof ConcernError) {
|
|
89
|
+
io.err(e.message);
|
|
90
|
+
return 3;
|
|
91
|
+
}
|
|
92
|
+
throw e;
|
|
93
|
+
}
|
|
69
94
|
if (process.stderr.isTTY && !values.quiet && !values.json) process.stderr.write('\r\x1b[K');
|
|
70
95
|
|
|
96
|
+
const { evidence, ...document } = doc;
|
|
71
97
|
const outPath = values.out ? resolve(values.out) : sweepPath(projectDir);
|
|
72
|
-
writeSpecDoc('sweep', outPath,
|
|
98
|
+
writeSpecDoc('sweep', outPath, document);
|
|
99
|
+
// Written only when something was actually probed: an empty document would
|
|
100
|
+
// teach the next run that every class is unproductive.
|
|
101
|
+
if (evidence?.records?.length) writeSpecDoc('evidence', sweepEvidencePath(projectDir), evidence);
|
|
73
102
|
if (values.json) {
|
|
74
|
-
io.out(JSON.stringify(
|
|
103
|
+
io.out(JSON.stringify(document, null, 2));
|
|
75
104
|
} else {
|
|
76
|
-
io.out(renderSweep(
|
|
105
|
+
io.out(renderSweep(document, { limit: Number(values.max) || 20 }));
|
|
77
106
|
if (!values.quiet) io.out(`\nsweep: ${outPath}`);
|
|
78
107
|
}
|
|
79
108
|
return doc.exitCode;
|
package/src/probe/probe.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { classifyIndependence } from './independence.mjs';
|
|
|
16
16
|
import { escalationStart, foldEscalationRun, escalationResult, flakeRate, killersFromRuns, subjectOf, isReusable } from './attribution.mjs';
|
|
17
17
|
import { hashFile, sha256 } from '../util/hash.mjs';
|
|
18
18
|
import { fingerprint } from '../../spec/lib/fingerprint.mjs';
|
|
19
|
+
import { persistenceSignalsFor } from '../supply/persistence.mjs';
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
/** Is `child` the same file as `root`, or under it? Both must already be real paths. */
|
|
@@ -485,13 +486,19 @@ async function probeOne({ claim, fault, defenders, discovered, allTests, iso, is
|
|
|
485
486
|
detail.independence = classifyIndependence({ dir: historyDir, ref: historyRef, targetFile: toRepoPath(fault.file), defenders: (killers.length ? killers : defenders).map(toRepoPath) });
|
|
486
487
|
}
|
|
487
488
|
|
|
489
|
+
// Why a survivor on a write path was missed: each defender that mocks the
|
|
490
|
+
// persistence layer, with its assertion mix. Descriptive, never predictive —
|
|
491
|
+
// it is attached to a verdict already reached and selects nothing. The sweep
|
|
492
|
+
// document carried this alone until claimspec v1 admitted it to the evidence.
|
|
493
|
+
const allSignals = [...signals, ...persistenceSignalsFor({ verdict, fault, defenders, projectDir: iso.projectDir })];
|
|
494
|
+
|
|
488
495
|
return {
|
|
489
496
|
fingerprint: fingerprint({ claimId: claim.id, subjectId: fault.id, file: fault.file, verdict }),
|
|
490
497
|
claim: { id: claim.id, statement: claim.statement, severity: claim.severity, source: claim.source, producedBy: claim.producedBy },
|
|
491
498
|
subject,
|
|
492
499
|
verdict,
|
|
493
500
|
detail,
|
|
494
|
-
defenders: { requested: claim.defendedBy ?? [], resolved: defenders, nocover: defenders.length === 0, ...(discovered ? { discovered: true } : {}), ...(byRunner ? { byRunner } : {}), ...(mocking.length ? { mocking } : {}), ...(
|
|
501
|
+
defenders: { requested: claim.defendedBy ?? [], resolved: defenders, nocover: defenders.length === 0, ...(discovered ? { discovered: true } : {}), ...(byRunner ? { byRunner } : {}), ...(mocking.length ? { mocking } : {}), ...(allSignals.length ? { signals: allSignals } : {}) },
|
|
495
502
|
inputs,
|
|
496
503
|
rank: rank({ severity: claim.severity, sourceKind: claim.source.kind, blast, independence: detail.independence?.class }),
|
|
497
504
|
};
|
package/src/probe/pyimports.mjs
CHANGED
|
@@ -49,11 +49,18 @@ export function pyModuleName(projectDir, rel) {
|
|
|
49
49
|
* `from x import (a, b)` collapsed onto one line so the names are visible.
|
|
50
50
|
*/
|
|
51
51
|
function logicalLines(source) {
|
|
52
|
-
|
|
52
|
+
// A trailing comment is stripped from every physical line BEFORE anything
|
|
53
|
+
// is joined or split. An import statement carries no string literal, so a
|
|
54
|
+
// `#` on one always starts a comment — and `from pkg import cli, demo # noqa: E402`
|
|
55
|
+
// is the idiom of every src-layout test that inserts into sys.path before
|
|
56
|
+
// importing. Measured on a real suite: the comment was kept inside the LAST
|
|
57
|
+
// name, which then failed the identifier test, so `demo` was never seen and
|
|
58
|
+
// its tests were reported as no cover. 72 import lines in one suite.
|
|
59
|
+
const raw = source.split('\n').map((l) => l.replace(/#.*$/, ''));
|
|
53
60
|
const out = [];
|
|
54
61
|
for (let i = 0; i < raw.length; i++) {
|
|
55
62
|
let line = raw[i];
|
|
56
|
-
if (
|
|
63
|
+
if (!line.trim()) continue;
|
|
57
64
|
while (/\\\s*$/.test(line) && i + 1 < raw.length) line = line.replace(/\\\s*$/, ' ') + raw[++i];
|
|
58
65
|
if (/^\s*from\s/.test(line) && line.includes('(') && !line.includes(')')) {
|
|
59
66
|
// Bounded: a malformed file must not make this loop to the end of a large source.
|
|
@@ -60,6 +60,54 @@ function isIncomplete(line) {
|
|
|
60
60
|
return depth > 0;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The bracket depth at the START of every line, for the whole file.
|
|
65
|
+
*
|
|
66
|
+
* `isIncomplete()` answers "does this line leave a bracket open"; this answers
|
|
67
|
+
* the other half, "does this line sit inside one opened above it". Measured on
|
|
68
|
+
* a real Python CLI: 77 of 600 proposals were a `statement-deleted` on a
|
|
69
|
+
* continuation line — `help="...")` inside an `add_argument(` — and none of
|
|
70
|
+
* them compiled. The tokenizer's exact depth agreed on 74 of the 77 and
|
|
71
|
+
* flagged no valid proposal: a dict entry or a call argument is inside a
|
|
72
|
+
* bracket by definition and keeps its own producer.
|
|
73
|
+
*
|
|
74
|
+
* Line-oriented, no AST: triple-quoted strings are tracked across lines so a
|
|
75
|
+
* docstring's parentheses cannot leave the rest of the file "inside" forever,
|
|
76
|
+
* and single-line strings and comments are skipped. Memoised per `lines`
|
|
77
|
+
* array because the scaffold calls the producer once per line.
|
|
78
|
+
*/
|
|
79
|
+
const DEPTHS = new WeakMap();
|
|
80
|
+
export function depthBefore(lines) {
|
|
81
|
+
if (DEPTHS.has(lines)) return DEPTHS.get(lines);
|
|
82
|
+
const out = new Array(lines.length);
|
|
83
|
+
let depth = 0;
|
|
84
|
+
let triple = null;
|
|
85
|
+
for (let i = 0; i < lines.length; i++) {
|
|
86
|
+
out[i] = depth;
|
|
87
|
+
const line = lines[i];
|
|
88
|
+
for (let j = 0; j < line.length;) {
|
|
89
|
+
if (triple) {
|
|
90
|
+
if (line.startsWith(triple, j)) { triple = null; j += 3; } else j += line[j] === '\\' ? 2 : 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const ch = line[j];
|
|
94
|
+
if (ch === '#') break;
|
|
95
|
+
if (line.startsWith('"""', j) || line.startsWith("'''", j)) { triple = line.slice(j, j + 3); j += 3; continue; }
|
|
96
|
+
if (ch === '"' || ch === "'") {
|
|
97
|
+
let k = j + 1;
|
|
98
|
+
while (k < line.length && line[k] !== ch) k += line[k] === '\\' ? 2 : 1;
|
|
99
|
+
j = k + 1; // an unterminated string runs to the end of the line
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (ch === '(' || ch === '[' || ch === '{') depth++;
|
|
103
|
+
else if (ch === ')' || ch === ']' || ch === '}') depth = Math.max(0, depth - 1);
|
|
104
|
+
j++;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
DEPTHS.set(lines, out);
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
63
111
|
/** The function a `def` line opens, or null. */
|
|
64
112
|
export function functionHead(line) {
|
|
65
113
|
const m = DEF_LINE.exec(line);
|
|
@@ -166,9 +214,13 @@ export function proposalsForLine(lines, i, ctx = {}) {
|
|
|
166
214
|
if (!line.trim() || COMMENT.test(line) || DEFINITION.test(line)) return out;
|
|
167
215
|
const params = ctx.params ?? new Set();
|
|
168
216
|
const indent = ' '.repeat(indentOf(line) ?? 0);
|
|
217
|
+
// A line inside a bracket opened above it is an argument or an entry, never
|
|
218
|
+
// a statement: `pass` there does not parse. The shapes that keep their own
|
|
219
|
+
// producers on such a line (a dropped field, a swapped argument) are below.
|
|
220
|
+
const inside = depthBefore(lines)[i] > 0;
|
|
169
221
|
|
|
170
222
|
const ifm = IF_LINE.exec(line);
|
|
171
|
-
if (ifm && isGuard(ifm[4], lines, i)) {
|
|
223
|
+
if (!inside && ifm && isGuard(ifm[4], lines, i)) {
|
|
172
224
|
if (ifm[4]) {
|
|
173
225
|
// `if not allowed: raise …` — the whole guard is this one line.
|
|
174
226
|
out.push({ faultClass: 'statement-deleted', description: `Guard removed: \`${line.trim()}\` no longer runs.`, replace: `${indent}pass` });
|
|
@@ -183,7 +235,7 @@ export function proposalsForLine(lines, i, ctx = {}) {
|
|
|
183
235
|
out.push({ faultClass: 'return-altered', description: `Check always passes: \`return ${rm[2]}\` becomes \`return True\`.`, replace: `${rm[1]}return True` });
|
|
184
236
|
}
|
|
185
237
|
|
|
186
|
-
const complete = !isIncomplete(line);
|
|
238
|
+
const complete = !inside && !isIncomplete(line);
|
|
187
239
|
if (complete && CHECK_CALL.test(line)) {
|
|
188
240
|
out.push({ faultClass: 'call-removed', description: `Check call removed: \`${line.trim()}\` no longer runs.`, replace: `${indent}pass` });
|
|
189
241
|
} else if (complete && MUTATION.test(line)) {
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A concern: one sentence a human writes, that generates faults across a whole
|
|
3
|
+
* surface.
|
|
4
|
+
*
|
|
5
|
+
* The rung between "nothing written" and "a claim per behaviour". A claim names
|
|
6
|
+
* one promise and the faults that would break it; that is precise and it is why
|
|
7
|
+
* a repository with two hundred screens never finishes writing them. A concern
|
|
8
|
+
* names a KIND of promise — "every write persists the data it was given" — and
|
|
9
|
+
* says where to look for it and which shapes of break are relevant. One
|
|
10
|
+
* sentence, two hundred screens.
|
|
11
|
+
*
|
|
12
|
+
* The idea is Meta's. ACH has an engineer describe an area of concern in plain
|
|
13
|
+
* text, generates faults specific to it, and gates every step by execution
|
|
14
|
+
* ("Mutation-Guided LLM-based Test Generation at Meta", Foster et al., FSE
|
|
15
|
+
* 2025). What is taken here is the UNIT — the concern as the thing a human
|
|
16
|
+
* writes — not their generator.
|
|
17
|
+
*
|
|
18
|
+
* NO MODEL IS REQUIRED, and that is deliberate. A concern's useful core is a
|
|
19
|
+
* named scope plus a producer selection, both of which the mechanical
|
|
20
|
+
* producers already satisfy. An LLM can later propose faults a line-oriented
|
|
21
|
+
* producer cannot see, as an ADDITIONAL source for a concern that already
|
|
22
|
+
* works — rather than as the thing the rung depends on. That keeps every
|
|
23
|
+
* verifying command offline, which is the property this tool is least willing
|
|
24
|
+
* to trade.
|
|
25
|
+
*
|
|
26
|
+
* A concern is NOT a claim and never becomes one by itself. It says where to
|
|
27
|
+
* look; the probe says what it found; a human states the sentence worth
|
|
28
|
+
* defending. Nothing here is written to `testguard.claims.json`.
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import { saveSurface } from './savepath.mjs';
|
|
33
|
+
import { globToRegExp, walk } from '../util/glob.mjs';
|
|
34
|
+
|
|
35
|
+
export const defaultConcernsPath = (projectDir) => join(projectDir, 'testguard.concerns.json');
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The concerns every project gets without writing anything.
|
|
39
|
+
*
|
|
40
|
+
* Two, because two are the ones whose targets can be enumerated honestly. An
|
|
41
|
+
* authorization concern would need a heuristic for "which files check
|
|
42
|
+
* permissions", and a guess there produces exactly the non-actionable noise
|
|
43
|
+
* that gets a check switched off. A project that knows its own auth layer can
|
|
44
|
+
* say so with a `glob` target in three lines; this file will not guess for it.
|
|
45
|
+
*/
|
|
46
|
+
export const BUILTIN_CONCERNS = Object.freeze([
|
|
47
|
+
Object.freeze({
|
|
48
|
+
id: 'SAVE-PERSISTS',
|
|
49
|
+
statement: 'Every write to storage persists the data it was given.',
|
|
50
|
+
severity: 'high',
|
|
51
|
+
targets: { kind: 'write-sites' },
|
|
52
|
+
// The shapes that break a persisted payload. Measured on a real
|
|
53
|
+
// application: 12 of 12 surviving faults on the persistence path were a
|
|
54
|
+
// field dropped from the payload, so the list leads with that.
|
|
55
|
+
faultClasses: ['field-dropped', 'call-removed', 'statement-deleted', 'literal-changed'],
|
|
56
|
+
builtin: true,
|
|
57
|
+
}),
|
|
58
|
+
Object.freeze({
|
|
59
|
+
id: 'CHANGED-CODE',
|
|
60
|
+
statement: 'Code changed in this branch is defended by something.',
|
|
61
|
+
severity: 'medium',
|
|
62
|
+
targets: { kind: 'changed' },
|
|
63
|
+
// Everything: a diff has no shape to narrow by.
|
|
64
|
+
faultClasses: null,
|
|
65
|
+
builtin: true,
|
|
66
|
+
}),
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Load the project's concerns, with the built-ins behind them.
|
|
71
|
+
*
|
|
72
|
+
* A project concern with the same id as a built-in REPLACES it, so a team can
|
|
73
|
+
* retune `SAVE-PERSISTS` for an ORM this tool does not recognise without
|
|
74
|
+
* forking anything. Shadowing is recorded rather than silent: a concern that
|
|
75
|
+
* quietly replaced a default would be the kind of surprise that costs an
|
|
76
|
+
* afternoon.
|
|
77
|
+
*/
|
|
78
|
+
export function loadConcerns(projectDir, { path } = {}) {
|
|
79
|
+
const p = path ?? defaultConcernsPath(projectDir);
|
|
80
|
+
let project = [];
|
|
81
|
+
let source = null;
|
|
82
|
+
if (existsSync(p)) {
|
|
83
|
+
const doc = JSON.parse(readFileSync(p, 'utf8'));
|
|
84
|
+
project = doc.concerns ?? [];
|
|
85
|
+
source = p;
|
|
86
|
+
}
|
|
87
|
+
const overridden = new Set(project.map((c) => c.id));
|
|
88
|
+
const concerns = [
|
|
89
|
+
...project.map((c) => ({ ...c, builtin: false })),
|
|
90
|
+
...BUILTIN_CONCERNS.filter((c) => !overridden.has(c.id)),
|
|
91
|
+
];
|
|
92
|
+
return {
|
|
93
|
+
concerns,
|
|
94
|
+
// The project's own entries, exactly as written. Validation runs against
|
|
95
|
+
// these: the enriched objects above carry a `builtin` marker this tool
|
|
96
|
+
// added, and a schema that refuses unknown properties is right to reject
|
|
97
|
+
// them. Validating what we constructed instead of what the user wrote
|
|
98
|
+
// would report OUR bug as THEIR malformed file.
|
|
99
|
+
raw: project.map(({ builtin, ...c }) => c),
|
|
100
|
+
source,
|
|
101
|
+
shadowed: BUILTIN_CONCERNS.filter((c) => overridden.has(c.id)).map((c) => c.id),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One concern by id, or null. Ids are matched exactly: a near-miss is a typo, not an intention. */
|
|
106
|
+
export const concernById = (concerns, id) => concerns.find((c) => c.id === id) ?? null;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The files a concern applies to.
|
|
110
|
+
*
|
|
111
|
+
* `write-sites` asks the enumerator; `glob` matches the project's own paths;
|
|
112
|
+
* `changed` returns null, meaning "the caller's diff decides" — a concern does
|
|
113
|
+
* not get to re-derive what the gate already computed.
|
|
114
|
+
*/
|
|
115
|
+
export function targetsFor(projectDir, concern, { changedFiles } = {}) {
|
|
116
|
+
const t = concern.targets ?? { kind: 'changed' };
|
|
117
|
+
if (t.kind === 'write-sites') return saveSurface(projectDir).files.map((f) => f.file);
|
|
118
|
+
if (t.kind === 'glob') {
|
|
119
|
+
// Against the whole project, never the diff. A concern like "every admin
|
|
120
|
+
// route refuses an unauthorised caller" is about the admin routes, not
|
|
121
|
+
// about the ones that happened to change today — and pooling from the diff
|
|
122
|
+
// made such a concern report clean on a clean branch, which is the exact
|
|
123
|
+
// false reassurance a concern exists to prevent.
|
|
124
|
+
const res = (t.globs ?? []).map(globToRegExp);
|
|
125
|
+
return walk(projectDir).filter((f) => res.some((re) => re.test(f)));
|
|
126
|
+
}
|
|
127
|
+
return changedFiles ?? null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Keep only the faults whose class this concern cares about.
|
|
132
|
+
*
|
|
133
|
+
* `faultClasses: null` means every class — the honest default for a concern
|
|
134
|
+
* with no shape to narrow by. Narrowing is what makes one sentence useful
|
|
135
|
+
* across two hundred files: a persistence concern that also reported every
|
|
136
|
+
* altered return value would bury the payload findings it exists to surface.
|
|
137
|
+
*/
|
|
138
|
+
export function filterByConcern(candidates, concern) {
|
|
139
|
+
if (!concern?.faultClasses) return candidates;
|
|
140
|
+
const keep = new Set(concern.faultClasses);
|
|
141
|
+
return candidates.filter((c) => keep.has(c.fault.faultClass));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The concerns as text, for someone deciding which to sweep. */
|
|
145
|
+
export function renderConcerns({ concerns, source, shadowed }) {
|
|
146
|
+
const out = [];
|
|
147
|
+
out.push(source ? `${concerns.length} concern${concerns.length === 1 ? '' : 's'} (${source}, plus the built-ins)` : `${concerns.length} built-in concern${concerns.length === 1 ? '' : 's'} — none declared in this project.`);
|
|
148
|
+
out.push('');
|
|
149
|
+
for (const c of concerns) {
|
|
150
|
+
const shape = c.targets?.kind === 'glob' ? `glob ${(c.targets.globs ?? []).join(', ')}` : c.targets?.kind ?? 'changed';
|
|
151
|
+
out.push(` ${c.id}${c.builtin ? ' (built-in)' : ''}`);
|
|
152
|
+
out.push(` ${c.statement}`);
|
|
153
|
+
out.push(` targets: ${shape} · faults: ${c.faultClasses ? c.faultClasses.join(', ') : 'every class'}`);
|
|
154
|
+
out.push('');
|
|
155
|
+
}
|
|
156
|
+
if (shadowed.length) out.push(`This project replaces the built-in ${shadowed.join(', ')}.`);
|
|
157
|
+
if (!source) {
|
|
158
|
+
out.push('Declare your own in testguard.concerns.json. A concern is one sentence and a');
|
|
159
|
+
out.push('scope: it says where to look, never what is true. Only a claim does that.');
|
|
160
|
+
}
|
|
161
|
+
return out.join('\n');
|
|
162
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this project's own runs say about which faults are worth proposing.
|
|
3
|
+
*
|
|
4
|
+
* `select.mjs` ships a productivity prior measured on one real application.
|
|
5
|
+
* That is a reasonable starting point and a poor stopping point: the classes
|
|
6
|
+
* that survive a Next.js suite are not the classes that survive a Python one,
|
|
7
|
+
* and Google's mutation service got from a 15% productive rate to 89% on
|
|
8
|
+
* exactly this — feedback, accumulated over years, refining which candidates
|
|
9
|
+
* are worth a developer's attention ("Practical Mutation Testing at Scale",
|
|
10
|
+
* Petrović et al., 2021). The static table is the part of that idea this tool
|
|
11
|
+
* had not yet taken.
|
|
12
|
+
*
|
|
13
|
+
* THE FEEDBACK IS ALREADY ON DISK. Every evidence record carries a
|
|
14
|
+
* `faultClass` and a `verdict`, so "how often does a fault of this class
|
|
15
|
+
* survive here" is a tally, not a new thing to capture. There is no click to
|
|
16
|
+
* collect and no state to maintain: a project that has probed anything has
|
|
17
|
+
* already said what its own productive classes are.
|
|
18
|
+
*
|
|
19
|
+
* WHY THIS ESTIMATOR AND NOT A BETTER-SOUNDING ONE. The combination below is a
|
|
20
|
+
* Beta-Binomial posterior mean, and a posterior mean is what minimises Brier
|
|
21
|
+
* and log score in expectation — it is a STRICTLY PROPER estimate, which is
|
|
22
|
+
* the only kind that cannot be improved by reporting something other than an
|
|
23
|
+
* honest probability. Optimising the ordering for accuracy or F1 instead would
|
|
24
|
+
* maximise the number of survivors found while destroying the calibration of
|
|
25
|
+
* the number attached to each, which is the documented failure mode of naive
|
|
26
|
+
* optimisation and the reason Laya trains against proper scoring rules. If
|
|
27
|
+
* this file is ever changed to rank by anything but an honest probability,
|
|
28
|
+
* that is the invariant being broken.
|
|
29
|
+
*
|
|
30
|
+
* Pure apart from reading the documents it is given.
|
|
31
|
+
*/
|
|
32
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import { MEASURED_PRODUCTIVITY } from './select.mjs';
|
|
35
|
+
|
|
36
|
+
/** The evidence a project may have produced, in the order they are most relevant. */
|
|
37
|
+
export const EVIDENCE_PATHS = Object.freeze([
|
|
38
|
+
// A sweep probes MACHINE-proposed faults, the same distribution the ranker
|
|
39
|
+
// orders, so it is the closest observation available.
|
|
40
|
+
'.testguard/sweep-evidence.json',
|
|
41
|
+
// The canonical probe is human-authored faults: a real observation of the
|
|
42
|
+
// same classes, drawn slightly differently, and still worth counting.
|
|
43
|
+
'.testguard/evidence.json',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Survival counts per fault class from one evidence document.
|
|
48
|
+
*
|
|
49
|
+
* Only `killed` and `survived` are counted. `nocover`, `unverifiable`,
|
|
50
|
+
* `fault-invalid`, `timeout` and `flaky-defender` say nothing about whether a
|
|
51
|
+
* fault of that class would be NOTICED — they say the question could not be
|
|
52
|
+
* asked — and folding them in either direction would be inventing data.
|
|
53
|
+
*/
|
|
54
|
+
export function tallyEvidence(doc) {
|
|
55
|
+
const by = {};
|
|
56
|
+
for (const r of doc?.records ?? []) {
|
|
57
|
+
const cls = r.subject?.faultClass;
|
|
58
|
+
if (!cls) continue;
|
|
59
|
+
if (r.verdict !== 'killed' && r.verdict !== 'survived') continue;
|
|
60
|
+
const e = (by[cls] ??= { survived: 0, n: 0 });
|
|
61
|
+
e.n += 1;
|
|
62
|
+
if (r.verdict === 'survived') e.survived += 1;
|
|
63
|
+
}
|
|
64
|
+
return by;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Merge tallies from several documents. Counts add; nothing is averaged twice. */
|
|
68
|
+
export function mergeTallies(tallies) {
|
|
69
|
+
const by = {};
|
|
70
|
+
for (const t of tallies) {
|
|
71
|
+
for (const [cls, e] of Object.entries(t)) {
|
|
72
|
+
const m = (by[cls] ??= { survived: 0, n: 0 });
|
|
73
|
+
m.survived += e.survived;
|
|
74
|
+
m.n += e.n;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return by;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The productivity table to rank with: this project's own observations, with
|
|
82
|
+
* the shipped measurement as the prior they are shrunk toward.
|
|
83
|
+
*
|
|
84
|
+
* The shipped numbers become pseudo-counts rather than a competing estimate —
|
|
85
|
+
* `n` of them, so a class measured on twelve faults elsewhere carries the
|
|
86
|
+
* weight of twelve observations here and no more. A project with its own
|
|
87
|
+
* hundred records therefore overrides the shipping default; a project with
|
|
88
|
+
* three nudges it.
|
|
89
|
+
*
|
|
90
|
+
* `shipped` is injectable so the combination can be tested without depending
|
|
91
|
+
* on whatever the current measurement happens to be.
|
|
92
|
+
*/
|
|
93
|
+
export function combinedProductivity(local, shipped = MEASURED_PRODUCTIVITY) {
|
|
94
|
+
const out = {};
|
|
95
|
+
for (const cls of new Set([...Object.keys(shipped), ...Object.keys(local)])) {
|
|
96
|
+
const s = shipped[cls];
|
|
97
|
+
const l = local[cls];
|
|
98
|
+
const priorSurvived = s ? s.p * s.n : 0;
|
|
99
|
+
const priorN = s ? s.n : 0;
|
|
100
|
+
const n = priorN + (l?.n ?? 0);
|
|
101
|
+
if (n === 0) continue;
|
|
102
|
+
const survived = priorSurvived + (l?.survived ?? 0);
|
|
103
|
+
out[cls] = { p: survived / n, n, observed: l?.n ?? 0 };
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Read whichever evidence documents this project has. Missing or malformed ones are skipped, never guessed at. */
|
|
109
|
+
export function readEvidence(projectDir, paths = EVIDENCE_PATHS) {
|
|
110
|
+
const docs = [];
|
|
111
|
+
for (const rel of paths) {
|
|
112
|
+
const p = join(projectDir, rel);
|
|
113
|
+
if (!existsSync(p)) continue;
|
|
114
|
+
try {
|
|
115
|
+
docs.push({ path: rel, doc: JSON.parse(readFileSync(p, 'utf8')) });
|
|
116
|
+
} catch {
|
|
117
|
+
// A document this tool cannot parse is not evidence of anything. It is
|
|
118
|
+
// skipped rather than defaulted, because a ranking built on a guess
|
|
119
|
+
// about a corrupt file is worse than one built on the shipped prior.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return docs;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The learned table plus what it was learned from, so a report can say whether
|
|
127
|
+
* an ordering came from this project or from the shipping default. An ordering
|
|
128
|
+
* nobody can trace is a number nobody should trust.
|
|
129
|
+
*/
|
|
130
|
+
export function learnedProductivity(projectDir, { paths, shipped } = {}) {
|
|
131
|
+
const docs = readEvidence(projectDir, paths);
|
|
132
|
+
const local = mergeTallies(docs.map((d) => tallyEvidence(d.doc)));
|
|
133
|
+
const observed = Object.values(local).reduce((a, e) => a + e.n, 0);
|
|
134
|
+
return {
|
|
135
|
+
table: combinedProductivity(local, shipped),
|
|
136
|
+
sources: docs.map((d) => d.path),
|
|
137
|
+
observed,
|
|
138
|
+
classes: Object.keys(local).sort(),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One line for a report: where the ordering came from. */
|
|
143
|
+
export function renderLearned(learned) {
|
|
144
|
+
if (learned.observed === 0) return 'ordering: the shipped productivity prior — this project has no probed evidence yet.';
|
|
145
|
+
return `ordering: ${learned.observed} probed fault${learned.observed === 1 ? '' : 's'} of this project's own, across ${learned.classes.length} class${learned.classes.length === 1 ? '' : 'es'} (${learned.sources.join(', ')}), shrunk toward the shipped prior.`;
|
|
146
|
+
}
|