testguard-cli 0.1.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 +39 -0
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/cli/testguard.mjs +6 -0
- package/package.json +66 -0
- package/spec/GATE-SEMANTICS.md +89 -0
- package/spec/README.md +54 -0
- package/spec/lib/fingerprint.mjs +19 -0
- package/spec/lib/validate.mjs +119 -0
- package/spec/schemas/baseline.schema.json +21 -0
- package/spec/schemas/brief.schema.json +65 -0
- package/spec/schemas/calibration.schema.json +54 -0
- package/spec/schemas/claims.schema.json +103 -0
- package/spec/schemas/common.schema.json +94 -0
- package/spec/schemas/evidence.schema.json +206 -0
- package/spec/schemas/ignore.schema.json +35 -0
- package/src/baseline/baseline.mjs +34 -0
- package/src/brief/brief.mjs +87 -0
- package/src/claims/annotations.mjs +59 -0
- package/src/claims/load.mjs +23 -0
- package/src/cli.mjs +105 -0
- package/src/commands/baseline.mjs +21 -0
- package/src/commands/brief.mjs +27 -0
- package/src/commands/claims.mjs +27 -0
- package/src/commands/probe.mjs +53 -0
- package/src/evidence/writer.mjs +36 -0
- package/src/git.mjs +32 -0
- package/src/probe/classify.mjs +37 -0
- package/src/probe/inject.mjs +86 -0
- package/src/probe/probe.mjs +182 -0
- package/src/probe/rank.mjs +51 -0
- package/src/probe/runner-vitest.mjs +85 -0
- package/src/probe/worktree.mjs +64 -0
- package/src/render.mjs +28 -0
- package/src/util/glob.mjs +56 -0
- package/src/util/hash.mjs +5 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, extname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const SCAN_EXT = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx', '.py', '.go', '.rs', '.java', '.kt', '.md']);
|
|
5
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage']);
|
|
6
|
+
const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)(tests?|__tests__)\//;
|
|
7
|
+
// An annotation id must contain a hyphen (REDACT-001, TG-KILL-NEEDS-N). Prose
|
|
8
|
+
// such as "@claim annotations" is not an annotation.
|
|
9
|
+
const RE = /@claim\s+([A-Za-z0-9]+(?:[._]?[A-Za-z0-9]+)*-[A-Za-z0-9._-]*[A-Za-z0-9])\b/g;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Source files worth scanning: skips hidden directories (tooling), dependency
|
|
13
|
+
* and build output, test files (a claim asserted by a test is the authorship
|
|
14
|
+
* trap this tool exists for), and nested projects that carry their own
|
|
15
|
+
* claims file.
|
|
16
|
+
*/
|
|
17
|
+
function sourceFiles(root) {
|
|
18
|
+
const out = [];
|
|
19
|
+
const visit = (dir, rel) => {
|
|
20
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
21
|
+
const relPath = rel ? `${rel}/${e.name}` : e.name;
|
|
22
|
+
if (e.isDirectory()) {
|
|
23
|
+
if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
|
|
24
|
+
if (existsSync(join(dir, e.name, 'testguard.claims.json'))) continue;
|
|
25
|
+
visit(join(dir, e.name), relPath);
|
|
26
|
+
} else if (e.isFile() && SCAN_EXT.has(extname(e.name)) && !TEST_FILE.test(relPath)) {
|
|
27
|
+
out.push(relPath);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
visit(root, '');
|
|
32
|
+
return out.sort();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Every `@claim <ID>` annotation in source, with where it was found. */
|
|
36
|
+
export function scanAnnotations(projectDir) {
|
|
37
|
+
const found = [];
|
|
38
|
+
for (const rel of sourceFiles(projectDir)) {
|
|
39
|
+
readFileSync(join(projectDir, rel), 'utf8').split('\n').forEach((text, i) => {
|
|
40
|
+
for (const m of text.matchAll(RE)) found.push({ id: m[1], file: rel, line: i + 1 });
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return found;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Drift between what the code annotates and what the claims file declares.
|
|
48
|
+
* `undeclared`: annotated in code, absent from the file — a claim with no
|
|
49
|
+
* fault model. `stale`: declared as annotation-sourced, but no code carries it.
|
|
50
|
+
*/
|
|
51
|
+
export function reconcile(claims, annotations) {
|
|
52
|
+
const declared = new Map(claims.claims.map((c) => [c.id, c]));
|
|
53
|
+
const annotated = new Set(annotations.map((a) => a.id));
|
|
54
|
+
return {
|
|
55
|
+
undeclared: annotations.filter((a) => !declared.has(a.id)),
|
|
56
|
+
stale: claims.claims.filter((c) => c.source.kind === 'annotation' && !annotated.has(c.id)),
|
|
57
|
+
annotated: [...declared.keys()].filter((id) => annotated.has(id)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { validate } from '../../spec/lib/validate.mjs';
|
|
4
|
+
|
|
5
|
+
export class ClaimsError extends Error {}
|
|
6
|
+
|
|
7
|
+
export const defaultClaimsPath = (projectDir) => join(projectDir, 'testguard.claims.json');
|
|
8
|
+
|
|
9
|
+
/** Read and validate a claims file. Any defect is fatal: a claims file is code. */
|
|
10
|
+
export function loadClaims(path) {
|
|
11
|
+
let doc;
|
|
12
|
+
try {
|
|
13
|
+
doc = JSON.parse(readFileSync(path, 'utf8'));
|
|
14
|
+
} catch (e) {
|
|
15
|
+
throw new ClaimsError(`cannot read claims file ${path}: ${e.message}`);
|
|
16
|
+
}
|
|
17
|
+
const result = validate('claims', doc);
|
|
18
|
+
if (!result.ok) {
|
|
19
|
+
const lines = result.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n');
|
|
20
|
+
throw new ClaimsError(`claims file ${path} does not conform to the spec:\n${lines}`);
|
|
21
|
+
}
|
|
22
|
+
return doc;
|
|
23
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { ClaimsError } from './claims/load.mjs';
|
|
6
|
+
import { PreconditionError } from './probe/worktree.mjs';
|
|
7
|
+
import { GitError } from './git.mjs';
|
|
8
|
+
import { SpecDocError } from './evidence/writer.mjs';
|
|
9
|
+
import { probeCommand } from './commands/probe.mjs';
|
|
10
|
+
import { claimsCommand } from './commands/claims.mjs';
|
|
11
|
+
import { baselineCommand } from './commands/baseline.mjs';
|
|
12
|
+
import { briefCommand } from './commands/brief.mjs';
|
|
13
|
+
|
|
14
|
+
const VERSION = JSON.parse(readFileSync(join(fileURLToPath(import.meta.url), '..', '..', 'package.json'), 'utf8')).version;
|
|
15
|
+
|
|
16
|
+
const USAGE = `testguard ${VERSION} — proves a test suite defends the claims a project makes
|
|
17
|
+
|
|
18
|
+
testguard claims [dir] list the claims file and report drift against @claim annotations in code
|
|
19
|
+
testguard probe [dir] inject each claim's faults, run its defenders, report what survived
|
|
20
|
+
testguard baseline [dir] freeze today's unproven findings so only new ones gate
|
|
21
|
+
testguard brief [dir] emit the blind-spot block for an agent's session-start context
|
|
22
|
+
|
|
23
|
+
probe
|
|
24
|
+
--claims <path> claims file (default: <dir>/testguard.claims.json)
|
|
25
|
+
--confirm <n> runs per verdict (default: 3)
|
|
26
|
+
--budget <ms> wall clock per run (default: 120000)
|
|
27
|
+
--out <path> evidence file (default: <dir>/.testguard/evidence.json)
|
|
28
|
+
--baseline <path> baseline to gate against (default: <dir>/.testguard/baseline.json if present)
|
|
29
|
+
--severity <level> gate only at or above (default: low)
|
|
30
|
+
--ref <commit> probe this commit in the scratch worktree (default: HEAD)
|
|
31
|
+
--in-place mutate the working tree instead of a scratch worktree
|
|
32
|
+
--no-escalate do not re-run survivors against the whole suite
|
|
33
|
+
--no-reuse re-probe claims whose inputs have not changed
|
|
34
|
+
--quiet summary only
|
|
35
|
+
|
|
36
|
+
claims --json
|
|
37
|
+
baseline --evidence <path> --out <path>
|
|
38
|
+
brief --evidence <path> --baseline <path> --max <n> --text (print only; safe for hooks)
|
|
39
|
+
|
|
40
|
+
exit codes: 0 nothing new to prove · 1 unproven claims (or claim drift) · 2 precondition failed · 3 usage
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
const COMMANDS = { probe: probeCommand, claims: claimsCommand, baseline: baselineCommand, brief: briefCommand };
|
|
44
|
+
|
|
45
|
+
export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n'), err: (s) => process.stderr.write(s + '\n') }) {
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = parseArgs({
|
|
49
|
+
args: argv,
|
|
50
|
+
allowPositionals: true,
|
|
51
|
+
options: {
|
|
52
|
+
claims: { type: 'string' },
|
|
53
|
+
confirm: { type: 'string', default: '3' },
|
|
54
|
+
budget: { type: 'string', default: '120000' },
|
|
55
|
+
out: { type: 'string' },
|
|
56
|
+
evidence: { type: 'string' },
|
|
57
|
+
baseline: { type: 'string' },
|
|
58
|
+
severity: { type: 'string', default: 'low' },
|
|
59
|
+
max: { type: 'string', default: '20' },
|
|
60
|
+
ref: { type: 'string', default: 'HEAD' },
|
|
61
|
+
'in-place': { type: 'boolean', default: false },
|
|
62
|
+
'no-escalate': { type: 'boolean', default: false },
|
|
63
|
+
'no-reuse': { type: 'boolean', default: false },
|
|
64
|
+
quiet: { type: 'boolean', default: false },
|
|
65
|
+
json: { type: 'boolean', default: false },
|
|
66
|
+
text: { type: 'boolean', default: false },
|
|
67
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
68
|
+
version: { type: 'boolean', short: 'v', default: false },
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
} catch (e) {
|
|
72
|
+
io.err(e.message);
|
|
73
|
+
io.err(USAGE);
|
|
74
|
+
return 3;
|
|
75
|
+
}
|
|
76
|
+
const { values, positionals } = parsed;
|
|
77
|
+
if (values.version) {
|
|
78
|
+
io.out(VERSION);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
const [command, dirArg] = positionals;
|
|
82
|
+
if (values.help || !command) {
|
|
83
|
+
io.out(USAGE);
|
|
84
|
+
return command ? 0 : 3;
|
|
85
|
+
}
|
|
86
|
+
const handler = COMMANDS[command];
|
|
87
|
+
if (!handler) {
|
|
88
|
+
io.err(`unknown command: ${command}`);
|
|
89
|
+
io.err(USAGE);
|
|
90
|
+
return 3;
|
|
91
|
+
}
|
|
92
|
+
if (!['critical', 'high', 'medium', 'low'].includes(values.severity)) {
|
|
93
|
+
io.err('--severity must be one of critical, high, medium, low');
|
|
94
|
+
return 3;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
return await handler({ projectDir: resolve(dirArg ?? '.'), values, version: VERSION }, io);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
if (e instanceof ClaimsError || e instanceof PreconditionError || e instanceof GitError || e instanceof SpecDocError) {
|
|
100
|
+
io.err(`error: ${e.message}`);
|
|
101
|
+
return 2;
|
|
102
|
+
}
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { readSpecDoc, writeSpecDoc } from '../evidence/writer.mjs';
|
|
4
|
+
import { buildBaseline } from '../baseline/baseline.mjs';
|
|
5
|
+
import { evidencePath, baselinePath } from './probe.mjs';
|
|
6
|
+
|
|
7
|
+
export async function baselineCommand({ projectDir, values }, io) {
|
|
8
|
+
const evPath = values.evidence ? resolve(values.evidence) : evidencePath(projectDir);
|
|
9
|
+
if (!existsSync(evPath)) {
|
|
10
|
+
io.err(`no evidence at ${evPath}; run \`testguard probe\` first`);
|
|
11
|
+
return 2;
|
|
12
|
+
}
|
|
13
|
+
const evidence = readSpecDoc('evidence', evPath);
|
|
14
|
+
const baseline = buildBaseline(evidence);
|
|
15
|
+
const outPath = values.out ? resolve(values.out) : baselinePath(projectDir);
|
|
16
|
+
writeSpecDoc('baseline', outPath, baseline);
|
|
17
|
+
const n = Object.values(baseline.fingerprints).reduce((a, b) => a + b, 0);
|
|
18
|
+
io.out(`baseline: ${n} unproven finding${n === 1 ? '' : 's'} frozen at ${baseline.head.slice(0, 12)} → ${outPath}`);
|
|
19
|
+
io.out('Commit this file. From now on only new findings gate.');
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { readSpecDoc, writeSpecDoc } from '../evidence/writer.mjs';
|
|
4
|
+
import { buildBrief } from '../brief/brief.mjs';
|
|
5
|
+
import { evidencePath, baselinePath } from './probe.mjs';
|
|
6
|
+
|
|
7
|
+
export async function briefCommand({ projectDir, values }, io) {
|
|
8
|
+
const evPath = values.evidence ? resolve(values.evidence) : evidencePath(projectDir);
|
|
9
|
+
if (!existsSync(evPath)) {
|
|
10
|
+
// A missing brief must never break an agent's session start.
|
|
11
|
+
if (values.text) return 0;
|
|
12
|
+
io.err(`no evidence at ${evPath}; run \`testguard probe\` first`);
|
|
13
|
+
return 2;
|
|
14
|
+
}
|
|
15
|
+
const evidence = readSpecDoc('evidence', evPath);
|
|
16
|
+
const basePath = values.baseline ? resolve(values.baseline) : baselinePath(projectDir);
|
|
17
|
+
const baseline = existsSync(basePath) ? readSpecDoc('baseline', basePath) : undefined;
|
|
18
|
+
const max = Number(values.max);
|
|
19
|
+
if (!Number.isInteger(max) || max < 1 || max > 50) {
|
|
20
|
+
io.err('--max must be an integer from 1 to 50');
|
|
21
|
+
return 3;
|
|
22
|
+
}
|
|
23
|
+
const brief = buildBrief(evidence, baseline, { max });
|
|
24
|
+
if (!values.text) writeSpecDoc('brief', values.out ? resolve(values.out) : join(projectDir, '.testguard', 'brief.json'), brief);
|
|
25
|
+
io.out(brief.text.trimEnd());
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
|
|
3
|
+
import { scanAnnotations, reconcile } from '../claims/annotations.mjs';
|
|
4
|
+
import { resolveDefenders } from '../probe/runner-vitest.mjs';
|
|
5
|
+
|
|
6
|
+
export async function claimsCommand({ projectDir, values }, io) {
|
|
7
|
+
const path = values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir);
|
|
8
|
+
const claims = loadClaims(path);
|
|
9
|
+
const annotations = scanAnnotations(projectDir);
|
|
10
|
+
const drift = reconcile(claims, annotations);
|
|
11
|
+
|
|
12
|
+
if (values.json) {
|
|
13
|
+
io.out(JSON.stringify({ path, claims, annotations, drift }, null, 2));
|
|
14
|
+
} else {
|
|
15
|
+
io.out(`${claims.claims.length} claims in ${path}`);
|
|
16
|
+
io.out('');
|
|
17
|
+
for (const c of claims.claims) {
|
|
18
|
+
const defenders = resolveDefenders(projectDir, c.defendedBy);
|
|
19
|
+
const cover = defenders.length ? `${defenders.length} defender${defenders.length === 1 ? '' : 's'}` : 'NO DEFENDER';
|
|
20
|
+
io.out(`${c.id.padEnd(14)} ${c.severity.padEnd(8)} ${c.source.kind.padEnd(10)} ${String(c.faults.length).padStart(2)} fault${c.faults.length === 1 ? ' ' : 's'} ${cover.padEnd(12)} ${c.statement}`);
|
|
21
|
+
}
|
|
22
|
+
if (drift.undeclared.length || drift.stale.length) io.out('');
|
|
23
|
+
for (const a of drift.undeclared) io.out(`UNDECLARED @claim ${a.id} at ${a.file}:${a.line} has no entry in the claims file — a claim with no fault model`);
|
|
24
|
+
for (const c of drift.stale) io.out(`STALE ${c.id} is annotation-sourced but no source file carries @claim ${c.id}`);
|
|
25
|
+
}
|
|
26
|
+
return drift.undeclared.length || drift.stale.length ? 1 : 0;
|
|
27
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
|
|
4
|
+
import { probe } from '../probe/probe.mjs';
|
|
5
|
+
import { writeSpecDoc, readSpecDoc } from '../evidence/writer.mjs';
|
|
6
|
+
import { gate } from '../baseline/baseline.mjs';
|
|
7
|
+
import { renderRecord, renderSummary, sortForReport } from '../render.mjs';
|
|
8
|
+
|
|
9
|
+
export const evidencePath = (projectDir) => join(projectDir, '.testguard', 'evidence.json');
|
|
10
|
+
export const baselinePath = (projectDir) => join(projectDir, '.testguard', 'baseline.json');
|
|
11
|
+
|
|
12
|
+
export async function probeCommand({ projectDir, values, version }, io) {
|
|
13
|
+
const confirmRuns = Number(values.confirm);
|
|
14
|
+
const budgetMs = Number(values.budget);
|
|
15
|
+
if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
|
|
16
|
+
io.err('--confirm must be a positive integer and --budget at least 1000');
|
|
17
|
+
return 3;
|
|
18
|
+
}
|
|
19
|
+
const claims = loadClaims(values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir));
|
|
20
|
+
if (claims.claims.length === 0) {
|
|
21
|
+
io.err('claims file declares no claims; nothing to verify');
|
|
22
|
+
return 2;
|
|
23
|
+
}
|
|
24
|
+
const outPath = values.out ? resolve(values.out) : evidencePath(projectDir);
|
|
25
|
+
const previous = !values['no-reuse'] && existsSync(outPath) ? readSpecDoc('evidence', outPath) : undefined;
|
|
26
|
+
const basePath = values.baseline ? resolve(values.baseline) : baselinePath(projectDir);
|
|
27
|
+
const baseline = existsSync(basePath) ? readSpecDoc('baseline', basePath) : undefined;
|
|
28
|
+
|
|
29
|
+
const evidence = await probe({
|
|
30
|
+
projectDir,
|
|
31
|
+
claims,
|
|
32
|
+
previous,
|
|
33
|
+
confirmRuns,
|
|
34
|
+
budgetMs,
|
|
35
|
+
mode: values['in-place'] ? 'in-place' : 'worktree',
|
|
36
|
+
ref: values.ref,
|
|
37
|
+
escalate: !values['no-escalate'],
|
|
38
|
+
toolVersion: version,
|
|
39
|
+
onProgress: values.quiet ? undefined : (r) => io.out(renderRecord(r) + (r.reusedFrom ? ' (reused)' : '')),
|
|
40
|
+
});
|
|
41
|
+
writeSpecDoc('evidence', outPath, evidence);
|
|
42
|
+
|
|
43
|
+
const g = gate(evidence.records, baseline, { severityFloor: values.severity });
|
|
44
|
+
if (!values.quiet) {
|
|
45
|
+
io.out('');
|
|
46
|
+
const tag = (r) => (g.new.includes(r) ? '[NEW] ' : g.baselined.includes(r) ? '[baseline] ' : '[below floor] ');
|
|
47
|
+
for (const r of sortForReport(evidence.records).filter((x) => x.verdict !== 'killed')) io.out(' ' + tag(r) + renderRecord(r));
|
|
48
|
+
}
|
|
49
|
+
io.out('');
|
|
50
|
+
io.out(renderSummary(evidence.records) + (baseline ? ` ${g.new.length} new since baseline, ${g.baselined.length} baselined.` : ' No baseline.'));
|
|
51
|
+
io.out(`evidence: ${outPath}`);
|
|
52
|
+
return g.new.length > 0 ? 1 : 0;
|
|
53
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { validate } from '../../spec/lib/validate.mjs';
|
|
4
|
+
|
|
5
|
+
export class SpecDocError extends Error {}
|
|
6
|
+
|
|
7
|
+
function describe(kind, path, result) {
|
|
8
|
+
const lines = result.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n');
|
|
9
|
+
return `${kind} document ${path} does not conform to the spec:\n${lines}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Write a spec document — but only if it conforms. The tool is held to its
|
|
14
|
+
* own spec at the moment of output, not by a separate test someone might skip.
|
|
15
|
+
*/
|
|
16
|
+
export function writeSpecDoc(kind, path, doc) {
|
|
17
|
+
const result = validate(kind, doc);
|
|
18
|
+
if (!result.ok) throw new SpecDocError('refusing to write: ' + describe(kind, path, result));
|
|
19
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
20
|
+
writeFileSync(path, JSON.stringify(doc, null, 2) + '\n');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Read a spec document, refusing one that does not conform. */
|
|
24
|
+
export function readSpecDoc(kind, path) {
|
|
25
|
+
let doc;
|
|
26
|
+
try {
|
|
27
|
+
doc = JSON.parse(readFileSync(path, 'utf8'));
|
|
28
|
+
} catch (e) {
|
|
29
|
+
throw new SpecDocError(`cannot read ${kind} document ${path}: ${e.message}`);
|
|
30
|
+
}
|
|
31
|
+
const result = validate(kind, doc);
|
|
32
|
+
if (!result.ok) throw new SpecDocError(describe(kind, path, result));
|
|
33
|
+
return doc;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const writeEvidence = (path, doc) => writeSpecDoc('evidence', path, doc);
|
package/src/git.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { rmSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
export class GitError extends Error {}
|
|
5
|
+
|
|
6
|
+
export function git(args, cwd) {
|
|
7
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
8
|
+
if (r.status !== 0) throw new GitError(`git ${args.join(' ')}: ${(r.stderr || r.stdout).trim()}`);
|
|
9
|
+
return r.stdout.trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const repoRoot = (dir) => git(['rev-parse', '--show-toplevel'], dir);
|
|
13
|
+
|
|
14
|
+
/** Full sha of `ref` (default HEAD), or null when it does not resolve. */
|
|
15
|
+
export function headSha(dir, ref = 'HEAD') {
|
|
16
|
+
try {
|
|
17
|
+
return git(['rev-parse', '--verify', `${ref}^{commit}`], dir);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** True when any of `paths` (repo-relative; empty = whole tree) has uncommitted changes. */
|
|
24
|
+
export const isDirty = (dir, paths = []) => git(['status', '--porcelain', '--', ...paths], dir).length > 0;
|
|
25
|
+
|
|
26
|
+
export const addWorktree = (repo, dest, ref = 'HEAD') => git(['worktree', 'add', '--detach', dest, ref], repo);
|
|
27
|
+
|
|
28
|
+
export function removeWorktree(repo, dest) {
|
|
29
|
+
spawnSync('git', ['worktree', 'remove', '--force', dest], { cwd: repo });
|
|
30
|
+
rmSync(dest, { recursive: true, force: true });
|
|
31
|
+
spawnSync('git', ['worktree', 'prune'], { cwd: repo });
|
|
32
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The verdict function. Pure: everything it needs is passed in, so every
|
|
3
|
+
* branch is unit-testable without a runner.
|
|
4
|
+
*
|
|
5
|
+
* Order matters. Each check short-circuits the ones below it, and the order
|
|
6
|
+
* encodes GATE-SEMANTICS.md: nothing to run → nocover; nothing to apply →
|
|
7
|
+
* unverifiable; defenders not green → flaky-defender; then, and only then,
|
|
8
|
+
* does the fault's own result count.
|
|
9
|
+
*/
|
|
10
|
+
export function classify({ defenders, anchor, baselineRuns, probeRuns, confirmRuns }) {
|
|
11
|
+
if (defenders.length === 0) return { verdict: 'nocover' };
|
|
12
|
+
if (anchor && anchor.status !== 'ok') return { verdict: 'unverifiable', reason: anchor.status };
|
|
13
|
+
if (baselineRuns.length === 0 || baselineRuns.some((r) => r.outcome !== 'pass')) {
|
|
14
|
+
return { verdict: 'flaky-defender', reason: 'defenders-not-green' };
|
|
15
|
+
}
|
|
16
|
+
const loadError = probeRuns.find((r) => r.outcome === 'error');
|
|
17
|
+
if (loadError) {
|
|
18
|
+
const parseError = /syntax|parse/i.test(loadError.loadMessage ?? '');
|
|
19
|
+
return { verdict: 'fault-invalid', reason: parseError ? 'replacement-does-not-compile' : 'suite-failed-to-load' };
|
|
20
|
+
}
|
|
21
|
+
if (probeRuns.some((r) => r.outcome === 'timeout' || r.timeouts > 0)) return { verdict: 'timeout', reason: 'test-timed-out' };
|
|
22
|
+
|
|
23
|
+
const killedRuns = probeRuns.filter((r) => r.outcome === 'fail' && r.assertionFailures > 0);
|
|
24
|
+
const passedRuns = probeRuns.filter((r) => r.outcome === 'pass');
|
|
25
|
+
if (probeRuns.length === confirmRuns && killedRuns.length === confirmRuns) return { verdict: 'killed' };
|
|
26
|
+
if (probeRuns.length === confirmRuns && passedRuns.length === confirmRuns) return { verdict: 'survived' };
|
|
27
|
+
// Some runs killed, some passed: the defenders' response to this fault is
|
|
28
|
+
// nondeterministic. Reporting it as killed would be the optimistic bias
|
|
29
|
+
// the research warned about; reporting survived would be a lie the other way.
|
|
30
|
+
return { verdict: 'flaky-defender', reason: 'inconsistent-probe' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Decide whether another probe run is worth doing, or the verdict is already forced. */
|
|
34
|
+
export function shouldStopEarly(probeRuns) {
|
|
35
|
+
const last = probeRuns[probeRuns.length - 1];
|
|
36
|
+
return last.outcome === 'error' || last.outcome === 'timeout' || last.timeouts > 0;
|
|
37
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Every live mutation registers its restore here so a signal or crash can
|
|
5
|
+
// undo all of them before the process dies. Worktree mode makes this
|
|
6
|
+
// belt-and-braces; in-place mode depends on it.
|
|
7
|
+
const live = new Set();
|
|
8
|
+
let handlersInstalled = false;
|
|
9
|
+
|
|
10
|
+
function restoreAll() {
|
|
11
|
+
for (const restore of live) {
|
|
12
|
+
try {
|
|
13
|
+
restore();
|
|
14
|
+
} catch {}
|
|
15
|
+
}
|
|
16
|
+
live.clear();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function installHandlers() {
|
|
20
|
+
if (handlersInstalled) return;
|
|
21
|
+
handlersInstalled = true;
|
|
22
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
23
|
+
process.on(sig, () => {
|
|
24
|
+
restoreAll();
|
|
25
|
+
process.exit(130);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
process.on('uncaughtException', (err) => {
|
|
29
|
+
restoreAll();
|
|
30
|
+
throw err;
|
|
31
|
+
});
|
|
32
|
+
process.on('exit', restoreAll);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function countOccurrences(haystack, needle) {
|
|
36
|
+
let n = 0;
|
|
37
|
+
let i = 0;
|
|
38
|
+
while ((i = haystack.indexOf(needle, i)) !== -1) {
|
|
39
|
+
n++;
|
|
40
|
+
i += needle.length;
|
|
41
|
+
}
|
|
42
|
+
return n;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Does the anchor hit exactly as declared? */
|
|
46
|
+
export function locate(source, fault) {
|
|
47
|
+
const hits = countOccurrences(source, fault.find);
|
|
48
|
+
const expected = fault.expectHits ?? 1;
|
|
49
|
+
if (hits === 0) return { status: 'anchor-missing', hits, expected };
|
|
50
|
+
if (hits !== expected) return { status: 'anchor-ambiguous', hits, expected };
|
|
51
|
+
return { status: 'ok', hits, expected };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Replace the declared occurrence of `find`. Pure; assumes `locate` returned ok. */
|
|
55
|
+
export function mutate(source, fault) {
|
|
56
|
+
const nth = fault.occurrence ?? 1;
|
|
57
|
+
let idx = -1;
|
|
58
|
+
for (let k = 0; k < nth; k++) {
|
|
59
|
+
idx = source.indexOf(fault.find, idx + 1);
|
|
60
|
+
if (idx === -1) throw new RangeError(`occurrence ${nth} of anchor not found`);
|
|
61
|
+
}
|
|
62
|
+
return source.slice(0, idx) + fault.replace + source.slice(idx + fault.find.length);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Apply a fault to a file on disk. Returns a handle whose `restore()` puts the
|
|
67
|
+
* original back; restore is idempotent and also runs on process exit/signal.
|
|
68
|
+
*/
|
|
69
|
+
export function applyFault(projectDir, fault) {
|
|
70
|
+
installHandlers();
|
|
71
|
+
const path = join(projectDir, fault.file);
|
|
72
|
+
const original = readFileSync(path, 'utf8');
|
|
73
|
+
const anchor = locate(original, fault);
|
|
74
|
+
if (anchor.status !== 'ok') return { anchor, applied: false, restore: () => {} };
|
|
75
|
+
|
|
76
|
+
let restored = false;
|
|
77
|
+
const restore = () => {
|
|
78
|
+
if (restored) return;
|
|
79
|
+
restored = true;
|
|
80
|
+
live.delete(restore);
|
|
81
|
+
writeFileSync(path, original);
|
|
82
|
+
};
|
|
83
|
+
live.add(restore);
|
|
84
|
+
writeFileSync(path, mutate(original, fault));
|
|
85
|
+
return { anchor, applied: true, restore };
|
|
86
|
+
}
|