testguard-cli 0.5.0 → 0.7.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 +628 -0
- package/README.md +536 -42
- package/package.json +2 -2
- package/spec/GATE-SEMANTICS.md +166 -2
- package/spec/README.md +29 -10
- package/spec/lib/validate.mjs +140 -8
- package/spec/schemas/baseline.schema.json +13 -5
- package/spec/schemas/brief.schema.json +12 -12
- package/spec/schemas/calibration.schema.json +5 -5
- package/spec/schemas/claims.schema.json +122 -25
- package/spec/schemas/common.schema.json +131 -29
- package/spec/schemas/evidence.schema.json +476 -69
- package/spec/schemas/gate.schema.json +12 -12
- package/spec/schemas/ignore.schema.json +4 -4
- package/spec/schemas/replay.schema.json +85 -0
- package/spec/schemas/status.schema.json +258 -56
- package/src/admit/admit.mjs +17 -0
- package/src/baseline/baseline.mjs +31 -2
- package/src/brief/brief.mjs +71 -7
- package/src/claims/removed.mjs +161 -0
- package/src/cli.mjs +68 -10
- package/src/commands/admit.mjs +116 -0
- package/src/commands/baseline.mjs +17 -3
- package/src/commands/brief.mjs +13 -7
- package/src/commands/claims.mjs +55 -6
- package/src/commands/init.mjs +19 -6
- package/src/commands/mcp.mjs +12 -0
- package/src/commands/probe.mjs +35 -7
- package/src/commands/replay.mjs +63 -0
- package/src/commands/status.mjs +3 -1
- package/src/gate/changed.mjs +9 -2
- package/src/git.mjs +5 -0
- package/src/init/init.mjs +182 -29
- package/src/init/templates/SKILL.md +3 -3
- package/src/mcp/server.mjs +104 -0
- package/src/mcp/tools.mjs +114 -0
- package/src/probe/attribution.mjs +123 -0
- package/src/probe/classify.mjs +18 -2
- package/src/probe/contention.mjs +60 -0
- package/src/probe/cost.mjs +145 -0
- package/src/probe/discover.mjs +20 -3
- package/src/probe/independence.mjs +59 -0
- package/src/probe/inject.mjs +41 -9
- package/src/probe/mocks.mjs +122 -0
- package/src/probe/probe.mjs +331 -40
- package/src/probe/progress.mjs +87 -0
- package/src/probe/pyimports.mjs +287 -0
- package/src/probe/rank.mjs +75 -15
- package/src/probe/runners/index.mjs +79 -8
- package/src/probe/runners/jest.mjs +15 -3
- package/src/probe/runners/playwright.mjs +142 -0
- package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
- package/src/probe/runners/python/_testguard_report.py +141 -0
- package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
- package/src/probe/runners/python.mjs +316 -0
- package/src/probe/runners/shared.mjs +80 -14
- package/src/probe/runners/vitest.mjs +16 -3
- package/src/render.mjs +2 -2
- package/src/replay/label.mjs +64 -0
- package/src/replay/replay.mjs +310 -0
- package/src/scaffold/producers.mjs +217 -6
- package/src/scaffold/producers.python.mjs +209 -0
- package/src/scaffold/scaffold.mjs +90 -30
- package/src/status/status.mjs +47 -6
- package/src/util/glob.mjs +7 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mechanical fault producers for Python. Deterministic, line-oriented, no AST,
|
|
3
|
+
* no LLM — the same contract as `producers.mjs`, expressed in the syntax the
|
|
4
|
+
* file is actually written in.
|
|
5
|
+
*
|
|
6
|
+
* The fault classes are the ones the spec already closes over; none is new.
|
|
7
|
+
* The two JSX shapes (`element-removed`, `handler-dropped`) have no Python
|
|
8
|
+
* meaning and are absent rather than faked.
|
|
9
|
+
*
|
|
10
|
+
* One deliberate difference from JavaScript: a statement is removed by
|
|
11
|
+
* replacing it with `pass`, not by deleting the line. A block whose only
|
|
12
|
+
* statement is deleted is an IndentationError in Python, and a fault that
|
|
13
|
+
* cannot compile is a `fault-invalid` verdict — a wasted probe run that says
|
|
14
|
+
* nothing about the tests. `pass` removes the behaviour and always parses.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const COMMENT = /^\s*#/;
|
|
18
|
+
|
|
19
|
+
// `if <cond>:` and `elif <cond>:`, with an optional same-line body.
|
|
20
|
+
const IF_LINE = /^(\s*)(el)?if\s+(.+?)\s*:\s*(\S.*)?$/;
|
|
21
|
+
const GUARD_BODY = /\braise\b|\breturn\b|\babort\b|\bsys\.exit\b|\bFalse\b|\bNone\b|\bHTTPException\b|\b4\d\d\b/;
|
|
22
|
+
const RETURN_CHECK = /^(\s*)return\s+(.+?)\s*$/;
|
|
23
|
+
const CHECK_EXPR = /(==|!=|\bin\b|\bis\b|\bnot\b|\band\b|\bor\b|\.startswith\(|\.endswith\(|\.match\(|\.search\(|\.fullmatch\(|isinstance\(|\.issubset\(|>=|<=|>|<)/;
|
|
24
|
+
const CHECK_CALL = /^\s*(?:await\s+)?(?:[\w.]+\.)?(verify|validate|assert|check|require|ensure|authoriz|authentic|rate_limit|throttle|enforce|guard|sanitiz|escape|audit)\w*\s*\(.*\)\s*$/i;
|
|
25
|
+
const MUTATION = /^\s*(?!(?:return|if|elif|else|for|while|with|try|except|finally|raise|assert|import|from|def|class|yield|pass|global|nonlocal)\b)[\w.\[\]'"]+\s*(=|\+=|-=|\|=|&=)\s*(?!=)\S.*$/;
|
|
26
|
+
|
|
27
|
+
const FLAG_TRUE = /\b(verify|secure|httponly|check_hostname|require_tls|validate_certs|strict|signed|use_tls|ssl)\s*=\s*True\b/i;
|
|
28
|
+
const SAME_SITE = /\b(samesite)\s*=\s*(['"])(strict|lax)\2/i;
|
|
29
|
+
const COST_NUM = /\b(\w*(?:cost|rounds|iterations|work_factor)\w*)\s*=\s*(\d+)\b/i;
|
|
30
|
+
const WINDOW_NUM = /\b(\w*(?:ttl|expir|tolerance|window|max_age|maxage|timeout|limit|attempts|length|min_length|clock_skew|skew|leeway)\w*)\s*=\s*(\d+)\b/i;
|
|
31
|
+
|
|
32
|
+
const DEF_LINE = /^(\s*)(?:async\s+)?def\s+([\w]+)\s*\(/;
|
|
33
|
+
// A definition is not a call site and not a statement: `def f(x)` must never
|
|
34
|
+
// be read as `f(x)` with a swappable argument.
|
|
35
|
+
const DEFINITION = /^\s*(?:async\s+)?(?:def|class)\b/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Does this line leave a bracket open, or continue onto the next?
|
|
39
|
+
*
|
|
40
|
+
* `COLOURS = {` is an assignment by every regex here, but replacing it with
|
|
41
|
+
* `pass` orphans the lines below it and the file stops parsing. Python has no
|
|
42
|
+
* statement terminator to lean on the way the JavaScript producers lean on
|
|
43
|
+
* `;`, so the balance is counted instead. A fault that cannot compile is a
|
|
44
|
+
* `fault-invalid` verdict: a probe run spent saying nothing about the tests.
|
|
45
|
+
*/
|
|
46
|
+
function isIncomplete(line) {
|
|
47
|
+
const code = line.replace(/#.*$/, '');
|
|
48
|
+
if (/\\\s*$/.test(code)) return true;
|
|
49
|
+
let depth = 0;
|
|
50
|
+
let quote = null;
|
|
51
|
+
for (let i = 0; i < code.length; i++) {
|
|
52
|
+
const ch = code[i];
|
|
53
|
+
if (quote) {
|
|
54
|
+
if (ch === '\\') i++;
|
|
55
|
+
else if (ch === quote) quote = null;
|
|
56
|
+
} else if (ch === '"' || ch === "'") quote = ch;
|
|
57
|
+
else if (ch === '(' || ch === '[' || ch === '{') depth++;
|
|
58
|
+
else if (ch === ')' || ch === ']' || ch === '}') depth--;
|
|
59
|
+
}
|
|
60
|
+
return depth > 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The function a `def` line opens, or null. */
|
|
64
|
+
export function functionHead(line) {
|
|
65
|
+
const m = DEF_LINE.exec(line);
|
|
66
|
+
return m ? m[2] : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The indentation of a line, in characters. Blank lines have none. */
|
|
70
|
+
export const indentOf = (line) => (line.trim() ? line.length - line.trimStart().length : null);
|
|
71
|
+
|
|
72
|
+
/** Parameter names of a `def` line, without `self`/`cls`, defaults or annotations. */
|
|
73
|
+
export function functionParams(line) {
|
|
74
|
+
const m = /^\s*(?:async\s+)?def\s+\w+\s*\(([^)]*)\)/.exec(line);
|
|
75
|
+
if (!m) return [];
|
|
76
|
+
return m[1]
|
|
77
|
+
.split(',')
|
|
78
|
+
.map((p) => p.trim().replace(/^[*]+/, '').split(/[:=]/)[0].trim())
|
|
79
|
+
.filter((p) => /^\w+$/.test(p) && p !== 'self' && p !== 'cls');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const isComment = (line) => COMMENT.test(line);
|
|
83
|
+
|
|
84
|
+
/** Does this `if` guard something — does its body reject, raise or return? */
|
|
85
|
+
function isGuard(sameLineBody, lines, i) {
|
|
86
|
+
if (sameLineBody) return GUARD_BODY.test(sameLineBody);
|
|
87
|
+
const next = lines[i + 1] ?? '';
|
|
88
|
+
return GUARD_BODY.test(next);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── field-dropped ─────────────────────────────────────────────────────────
|
|
92
|
+
// A dict literal counts as a payload when its opening line says so: it is
|
|
93
|
+
// returned, assigned to a payload-ish name, or passed to a persistence or
|
|
94
|
+
// transport call. A TypedDict or a class body is never a payload — dropping a
|
|
95
|
+
// key there changes a type, not a behaviour.
|
|
96
|
+
const PAYLOAD_OPENER = /(\breturn\s*\{\s*$|\b(?:body|payload|input|item|params|data|record|row|attrs|attributes|values|fields|patch|update|updates|changes|doc|document|entity|dto|request|response|options|args|props|config|settings|message|event|entry|audit|claims|headers|context)\w*\s*=\s*\{\s*$|\.(?:save|update|put|patch|post|send|write|insert|upsert|create|set|publish|emit|dispatch|persist|store|log|audit|record|track|notify|write_audit)\w*\s*\(\s*(?:[^,{]*,\s*)*\{\s*$)/i;
|
|
97
|
+
const DICT_ENTRY = /^(\s*)(['"])([\w.-]+)\2\s*:\s*(.+?),\s*$/;
|
|
98
|
+
const ALLOWLIST_OPENER = /\b\w*(?:fields|keys|allowed|writable|columns|attributes|props|allowlist|whitelist|editable|mutable|updatable|permitted|required|scopes|roles)\w*\s*=\s*[\[({]\s*$/i;
|
|
99
|
+
const STRING_ENTRY = /^\s*(['"])[^'"]+\1\s*,\s*$/;
|
|
100
|
+
|
|
101
|
+
function fieldDropProposals(lines, i) {
|
|
102
|
+
const line = lines[i];
|
|
103
|
+
const entry = DICT_ENTRY.exec(line);
|
|
104
|
+
const isStringEntry = STRING_ENTRY.test(line);
|
|
105
|
+
if (!entry && !isStringEntry) return [];
|
|
106
|
+
const lineIndent = indentOf(line);
|
|
107
|
+
for (let j = i - 1; j >= 0 && j >= i - 40; j--) {
|
|
108
|
+
const opener = lines[j];
|
|
109
|
+
if (!opener.trim() || COMMENT.test(opener)) continue;
|
|
110
|
+
const openerIndent = indentOf(opener);
|
|
111
|
+
// The first line above that is less indented opens the literal this entry
|
|
112
|
+
// belongs to. Anything at the same indentation is a sibling entry.
|
|
113
|
+
if (openerIndent !== null && lineIndent !== null && openerIndent < lineIndent) {
|
|
114
|
+
if (entry && PAYLOAD_OPENER.test(opener)) {
|
|
115
|
+
return [{ faultClass: 'field-dropped', description: `Field dropped: \`${entry[3]}\` no longer appears in the object built at line ${j + 1}.`, replace: '' }];
|
|
116
|
+
}
|
|
117
|
+
if (isStringEntry && ALLOWLIST_OPENER.test(opener)) {
|
|
118
|
+
return [{ faultClass: 'field-dropped', description: `Entry removed from the list at line ${j + 1}: \`${line.trim().replace(/,$/, '')}\`.`, replace: '' }];
|
|
119
|
+
}
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── argument-swapped ──────────────────────────────────────────────────────
|
|
127
|
+
// A call is kept and one parameter-derived argument is swapped for a trivial
|
|
128
|
+
// value, so the call still happens and still proves nothing.
|
|
129
|
+
const CALL_WITH_ARGS = /\b([\w.]+)\s*\(([^()]*)\)/g;
|
|
130
|
+
|
|
131
|
+
function argumentSwapProposals(lines, i, params) {
|
|
132
|
+
const line = lines[i];
|
|
133
|
+
if (!params.size || COMMENT.test(line)) return [];
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const m of line.matchAll(CALL_WITH_ARGS)) {
|
|
136
|
+
const args = m[2];
|
|
137
|
+
if (!args.trim()) continue;
|
|
138
|
+
const parts = args.split(',').map((a) => a.trim());
|
|
139
|
+
for (let n = 0; n < parts.length; n++) {
|
|
140
|
+
const arg = parts[n];
|
|
141
|
+
const named = /^(\w+)\s*=\s*(\w+)$/.exec(arg);
|
|
142
|
+
const bare = /^\w+$/.test(arg) ? arg : null;
|
|
143
|
+
const value = named ? named[2] : bare;
|
|
144
|
+
if (!value || !params.has(value)) continue;
|
|
145
|
+
const trivial = named ? `${named[1]}=None` : 'None';
|
|
146
|
+
const swapped = [...parts.slice(0, n), trivial, ...parts.slice(n + 1)].join(', ');
|
|
147
|
+
out.push({
|
|
148
|
+
faultClass: 'argument-swapped',
|
|
149
|
+
description: `Call kept, argument neutralised: \`${m[1]}(…)\` receives ${trivial} in place of \`${arg}\`.`,
|
|
150
|
+
replace: line.replace(m[0], `${m[1]}(${swapped})`),
|
|
151
|
+
});
|
|
152
|
+
break; // one proposal per call is enough for a human to judge
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Every fault this line supports. `ctx.params` is the enclosing function's
|
|
160
|
+
* parameter names; `ctx.fieldDrops` disables payload shapes in files where a
|
|
161
|
+
* dict literal is data rather than something the product sends.
|
|
162
|
+
*/
|
|
163
|
+
export function proposalsForLine(lines, i, ctx = {}) {
|
|
164
|
+
const out = [];
|
|
165
|
+
const line = lines[i];
|
|
166
|
+
if (!line.trim() || COMMENT.test(line) || DEFINITION.test(line)) return out;
|
|
167
|
+
const params = ctx.params ?? new Set();
|
|
168
|
+
const indent = ' '.repeat(indentOf(line) ?? 0);
|
|
169
|
+
|
|
170
|
+
const ifm = IF_LINE.exec(line);
|
|
171
|
+
if (ifm && isGuard(ifm[4], lines, i)) {
|
|
172
|
+
if (ifm[4]) {
|
|
173
|
+
// `if not allowed: raise …` — the whole guard is this one line.
|
|
174
|
+
out.push({ faultClass: 'statement-deleted', description: `Guard removed: \`${line.trim()}\` no longer runs.`, replace: `${indent}pass` });
|
|
175
|
+
} else {
|
|
176
|
+
out.push({ faultClass: 'condition-forced', description: `Guard never triggers: \`${ifm[2] ?? ''}if ${ifm[3].trim()}\` becomes \`${ifm[2] ?? ''}if False\`.`, replace: `${ifm[1]}${ifm[2] ?? ''}if False:` });
|
|
177
|
+
}
|
|
178
|
+
return out; // an `if` line is not also a mutation, a return or a call
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const rm = RETURN_CHECK.exec(line);
|
|
182
|
+
if (rm && CHECK_EXPR.test(rm[2]) && !/^(await|yield)\b/.test(rm[2])) {
|
|
183
|
+
out.push({ faultClass: 'return-altered', description: `Check always passes: \`return ${rm[2]}\` becomes \`return True\`.`, replace: `${rm[1]}return True` });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const complete = !isIncomplete(line);
|
|
187
|
+
if (complete && CHECK_CALL.test(line)) {
|
|
188
|
+
out.push({ faultClass: 'call-removed', description: `Check call removed: \`${line.trim()}\` no longer runs.`, replace: `${indent}pass` });
|
|
189
|
+
} else if (complete && MUTATION.test(line)) {
|
|
190
|
+
out.push({ faultClass: 'statement-deleted', description: `State change removed: \`${line.trim()}\` no longer runs.`, replace: `${indent}pass` });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let m;
|
|
194
|
+
if ((m = FLAG_TRUE.exec(line))) {
|
|
195
|
+
out.push({ faultClass: 'literal-changed', description: `Security flag flipped: \`${m[1]}=True\` becomes \`${m[1]}=False\`.`, replace: line.replace(m[0], `${m[1]}=False`) });
|
|
196
|
+
}
|
|
197
|
+
if ((m = SAME_SITE.exec(line))) {
|
|
198
|
+
out.push({ faultClass: 'literal-changed', description: `samesite weakened: \`${m[3]}\` becomes \`none\`.`, replace: line.replace(m[0], `${m[1]}=${m[2]}none${m[2]}`) });
|
|
199
|
+
}
|
|
200
|
+
if ((m = COST_NUM.exec(line))) {
|
|
201
|
+
out.push({ faultClass: 'literal-changed', description: `Work factor collapsed: \`${m[1]}\` ${m[2]} becomes 1.`, replace: line.replace(m[0], `${m[1]}=1`) });
|
|
202
|
+
} else if ((m = WINDOW_NUM.exec(line))) {
|
|
203
|
+
out.push({ faultClass: 'literal-changed', description: `Window widened ×1000: \`${m[1]}\` ${m[2]} becomes ${Number(m[2]) * 1000}.`, replace: line.replace(m[0], `${m[1]}=${Number(m[2]) * 1000}`) });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (ctx.fieldDrops !== false) out.push(...fieldDropProposals(lines, i));
|
|
207
|
+
out.push(...argumentSwapProposals(lines, i, params));
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
@@ -1,14 +1,100 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { basename, extname, join } from 'node:path';
|
|
3
|
-
import { proposalsForLine, functionHead } from './producers.mjs';
|
|
3
|
+
import { proposalsForLine, functionHead, functionParams } from './producers.mjs';
|
|
4
|
+
import * as py from './producers.python.mjs';
|
|
4
5
|
import { locate } from '../probe/inject.mjs';
|
|
5
6
|
import { discoverDefenders } from '../probe/discover.mjs';
|
|
6
7
|
import { validate } from '../../spec/lib/validate.mjs';
|
|
7
8
|
|
|
8
9
|
const ANNOTATION = /@claim\s+([A-Za-z0-9]+(?:[._]?[A-Za-z0-9]+)*-[A-Za-z0-9._-]*[A-Za-z0-9])\b/;
|
|
10
|
+
// field-dropped is never proposed inside tests, fixtures or migrations: an
|
|
11
|
+
// object literal there is data, not a payload the product writes.
|
|
12
|
+
const NO_FIELD_DROPS = /\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)(__tests__|__fixtures__|__mocks__|fixtures|migrations)\//;
|
|
13
|
+
// Python names its tests by file rather than by a dotted suffix, so the JS
|
|
14
|
+
// rule would not recognise one. Kept separate so adding Python cannot quietly
|
|
15
|
+
// change which proposals a JavaScript file gets.
|
|
16
|
+
const NO_FIELD_DROPS_PY = /(^|\/)(test[^/]*\.py|[^/]*_test\.py|conftest\.py)$|(^|\/)(tests?|__fixtures__|fixtures|migrations)\//;
|
|
9
17
|
|
|
10
18
|
const idPart = (s) => s.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toUpperCase();
|
|
11
19
|
|
|
20
|
+
const isPython = (file) => file.endsWith('.py');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* JavaScript line scan: enclosing function tracked by brace depth, `@claim`
|
|
24
|
+
* annotations read from `//` and `/* *\u002f` comments.
|
|
25
|
+
*/
|
|
26
|
+
function scanJs({ source, lines, fieldDrops, keep, anchor }) {
|
|
27
|
+
const proposals = [];
|
|
28
|
+
let offset = 0;
|
|
29
|
+
let fn = null;
|
|
30
|
+
let fnDepth = 0;
|
|
31
|
+
let params = new Set();
|
|
32
|
+
let depth = 0;
|
|
33
|
+
let pendingAnnotation = null;
|
|
34
|
+
lines.forEach((line, i) => {
|
|
35
|
+
const ann = ANNOTATION.exec(line);
|
|
36
|
+
if (ann && /^\s*(\/\/|\*|\/\*)/.test(line)) pendingAnnotation = ann[1];
|
|
37
|
+
|
|
38
|
+
const head = functionHead(line);
|
|
39
|
+
if (head) {
|
|
40
|
+
fn = head;
|
|
41
|
+
fnDepth = depth;
|
|
42
|
+
params = new Set(functionParams(line));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const p of proposalsForLine(lines, i, { params, fieldDrops })) {
|
|
46
|
+
const { hits, occurrence } = anchor(line, offset);
|
|
47
|
+
const fault = { ...p, find: line, replace: p.replace, expectHits: hits, occurrence, line: i + 1, fn, annotation: pendingAnnotation };
|
|
48
|
+
if (keep(fault)) proposals.push(fault); // never propose an anchor that would be unverifiable
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
depth += (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length;
|
|
52
|
+
if (fn && depth <= fnDepth && !head) { fn = null; params = new Set(); }
|
|
53
|
+
if (pendingAnnotation && !ann && !head && line.trim() && !/^\s*(\/\/|\*|\/\*)/.test(line)) pendingAnnotation = null;
|
|
54
|
+
offset += line.length + 1;
|
|
55
|
+
});
|
|
56
|
+
return proposals;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Python line scan. Scope is indentation, not braces, and it must be read
|
|
61
|
+
* BEFORE the line is attributed: a line dedented out of a function belongs to
|
|
62
|
+
* whatever follows, not to the function that just ended. `@claim` annotations
|
|
63
|
+
* are read from `#` comments.
|
|
64
|
+
*/
|
|
65
|
+
function scanPython({ source, lines, fieldDrops, keep, anchor }) {
|
|
66
|
+
const proposals = [];
|
|
67
|
+
let offset = 0;
|
|
68
|
+
let fn = null;
|
|
69
|
+
let fnIndent = 0;
|
|
70
|
+
let params = new Set();
|
|
71
|
+
let pendingAnnotation = null;
|
|
72
|
+
lines.forEach((line, i) => {
|
|
73
|
+
const indent = py.indentOf(line);
|
|
74
|
+
if (fn !== null && indent !== null && indent <= fnIndent) { fn = null; params = new Set(); }
|
|
75
|
+
|
|
76
|
+
const ann = ANNOTATION.exec(line);
|
|
77
|
+
if (ann && py.isComment(line)) pendingAnnotation = ann[1];
|
|
78
|
+
|
|
79
|
+
const head = py.functionHead(line);
|
|
80
|
+
if (head) {
|
|
81
|
+
fn = head;
|
|
82
|
+
fnIndent = indent ?? 0;
|
|
83
|
+
params = new Set(py.functionParams(line));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const p of py.proposalsForLine(lines, i, { params, fieldDrops })) {
|
|
87
|
+
const { hits, occurrence } = anchor(line, offset);
|
|
88
|
+
const fault = { ...p, find: line, replace: p.replace, expectHits: hits, occurrence, line: i + 1, fn, annotation: pendingAnnotation };
|
|
89
|
+
if (keep(fault)) proposals.push(fault);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (pendingAnnotation && !ann && !head && line.trim() && !py.isComment(line)) pendingAnnotation = null;
|
|
93
|
+
offset += line.length + 1;
|
|
94
|
+
});
|
|
95
|
+
return proposals;
|
|
96
|
+
}
|
|
97
|
+
|
|
12
98
|
/** How many times `find` occurs, and which occurrence the line at `offset` is. */
|
|
13
99
|
function anchorFor(source, find, offset) {
|
|
14
100
|
let hits = 0;
|
|
@@ -40,35 +126,9 @@ export function scaffoldFile({ projectDir, file, claimId, existingClaims, toolVe
|
|
|
40
126
|
return groups.get(key);
|
|
41
127
|
};
|
|
42
128
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
let depth = 0;
|
|
47
|
-
let pendingAnnotation = null;
|
|
48
|
-
const proposals = [];
|
|
49
|
-
lines.forEach((line, i) => {
|
|
50
|
-
const ann = ANNOTATION.exec(line);
|
|
51
|
-
if (ann && /^\s*(\/\/|\*|\/\*)/.test(line)) pendingAnnotation = ann[1];
|
|
52
|
-
|
|
53
|
-
const head = functionHead(line);
|
|
54
|
-
if (head) {
|
|
55
|
-
fn = head;
|
|
56
|
-
fnDepth = depth;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
for (const p of proposalsForLine(lines, i)) {
|
|
60
|
-
const find = line;
|
|
61
|
-
const { hits, occurrence } = anchorFor(source, find, offset);
|
|
62
|
-
const fault = { ...p, find, replace: p.replace, expectHits: hits, occurrence, line: i + 1, fn, annotation: pendingAnnotation };
|
|
63
|
-
if (locate(source, fault).status !== 'ok') continue; // never propose an anchor that would be unverifiable
|
|
64
|
-
proposals.push(fault);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
depth += (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length;
|
|
68
|
-
if (fn && depth <= fnDepth && !head) fn = null;
|
|
69
|
-
if (pendingAnnotation && !ann && !head && line.trim() && !/^\s*(\/\/|\*|\/\*)/.test(line)) pendingAnnotation = null;
|
|
70
|
-
offset += line.length + 1;
|
|
71
|
-
});
|
|
129
|
+
const fieldDrops = !(isPython(file) ? NO_FIELD_DROPS_PY : NO_FIELD_DROPS).test(file);
|
|
130
|
+
const scan = isPython(file) ? scanPython : scanJs;
|
|
131
|
+
const proposals = scan({ source, lines, fieldDrops, keep: (fault) => locate(source, fault).status === 'ok', anchor: (find, offset) => anchorFor(source, find, offset) });
|
|
72
132
|
|
|
73
133
|
const existing = new Map((existingClaims?.claims ?? []).map((c) => [c.id, c]));
|
|
74
134
|
const usedIds = new Set();
|
package/src/status/status.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { resolveDefenders } from '../probe/runners/shared.mjs';
|
|
|
8
8
|
import { discoverDefenders } from '../probe/discover.mjs';
|
|
9
9
|
import { sortForReport } from '../render.mjs';
|
|
10
10
|
import { computeChangedGate, defaultIgnorePath } from '../gate/changed.mjs';
|
|
11
|
+
import { headSha, isAncestor } from '../git.mjs';
|
|
11
12
|
|
|
12
13
|
export const faultContentHash = (fault) => sha256(`${fault.find}\n${fault.replace}`);
|
|
13
14
|
|
|
@@ -23,7 +24,13 @@ const P = (projectDir) => ({
|
|
|
23
24
|
* Reads the claims file, evidence, baseline and the working tree; never
|
|
24
25
|
* trusts a cached verdict whose inputs have changed.
|
|
25
26
|
*/
|
|
26
|
-
|
|
27
|
+
/**
|
|
28
|
+
* `evidence` overrides the project's own evidence file: a document taken on
|
|
29
|
+
* ANOTHER commit (CI's, fetched as an artifact) is legitimate input. Staleness
|
|
30
|
+
* is computed from the recorded input hashes exactly as for a local run, so a
|
|
31
|
+
* foreign document is trusted only for the faults whose inputs still match.
|
|
32
|
+
*/
|
|
33
|
+
export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt = new Date().toISOString(), paths = P(projectDir), max = 20, changedRef, includeDirty = false, evidence: evidenceOverride }) {
|
|
27
34
|
const rel = (p) => relative(projectDir, p) || '.';
|
|
28
35
|
const doc = {
|
|
29
36
|
schemaVersion: 1,
|
|
@@ -36,6 +43,7 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
36
43
|
stale: [],
|
|
37
44
|
changedFaults: [],
|
|
38
45
|
findings: [],
|
|
46
|
+
notes: [],
|
|
39
47
|
paths: {},
|
|
40
48
|
};
|
|
41
49
|
// ── Claim coverage of the change, when a reference is known ──
|
|
@@ -79,7 +87,8 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
79
87
|
return doc;
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
const
|
|
90
|
+
const evidenceFile = evidenceOverride ?? paths.evidence;
|
|
91
|
+
const hasEvidence = existsSync(evidenceFile);
|
|
83
92
|
const hasProvisional = existsSync(paths.provisional);
|
|
84
93
|
if (hasProvisional) doc.paths.provisionalEvidence = rel(paths.provisional);
|
|
85
94
|
if (!hasEvidence) {
|
|
@@ -93,11 +102,29 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
93
102
|
}
|
|
94
103
|
return doc;
|
|
95
104
|
}
|
|
96
|
-
doc.paths.evidence = rel(
|
|
97
|
-
|
|
105
|
+
doc.paths.evidence = rel(evidenceFile);
|
|
106
|
+
doc.evidenceSource = evidenceOverride ? 'provided' : 'local';
|
|
107
|
+
const evidence = readSpecDoc('evidence', evidenceFile);
|
|
108
|
+
// A verdict is about a commit. When the evidence describes a different one,
|
|
109
|
+
// say both: "looks right and is not" is the failure mode this prevents.
|
|
110
|
+
doc.evidenceHead = evidence.run.repo.snapshot ?? evidence.run.repo.head;
|
|
111
|
+
const localHead = (() => { try { return headSha(projectDir); } catch { return null; } })();
|
|
112
|
+
if (localHead) doc.head = localHead;
|
|
113
|
+
|
|
98
114
|
const baseline = existsSync(paths.baseline) ? readSpecDoc('baseline', paths.baseline) : undefined;
|
|
99
115
|
if (baseline) doc.paths.baseline = rel(paths.baseline);
|
|
100
116
|
|
|
117
|
+
// ── Baseline provenance: informational, never a state. A baseline frozen
|
|
118
|
+
// from a snapshot or a dirty tree names the PARENT of the commit that carries
|
|
119
|
+
// its tests; once HEAD moved on, say so and name the way to re-stamp. ──
|
|
120
|
+
if (baseline?.head) {
|
|
121
|
+
const head = headSha(projectDir);
|
|
122
|
+
if (head && head !== baseline.head) {
|
|
123
|
+
if (baseline.snapshot || baseline.dirty) doc.notes.push(`baseline was frozen from a ${baseline.snapshot ? 'working-tree snapshot' : 'dirty tree'} at ${baseline.head.slice(0, 7)}; HEAD is ${head.slice(0, 7)} — it predates the commit that carries its tests. After a clean probe of HEAD reproduces the same fingerprints: testguard baseline --restamp`);
|
|
124
|
+
else if (!isAncestor(projectDir, baseline.head, head)) doc.notes.push(`baseline head ${baseline.head.slice(0, 7)} is not an ancestor of HEAD ${head.slice(0, 7)} (rewritten or foreign history); re-probe and re-baseline if the fingerprints changed`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
101
128
|
// ── Staleness: does the evidence still describe this claims file and this tree? ──
|
|
102
129
|
const probed = new Map(evidence.records.map((r) => [`${r.claim.id}/${r.subject.id}`, r]));
|
|
103
130
|
for (const key of faultIndex.keys()) if (!probed.has(key)) doc.stale.push(`fault ${key} has never been probed`);
|
|
@@ -124,6 +151,14 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
124
151
|
const counts = {};
|
|
125
152
|
for (const r of evidence.records) counts[r.verdict] = (counts[r.verdict] ?? 0) + 1;
|
|
126
153
|
doc.counts.byVerdict = counts;
|
|
154
|
+
// L3 independence and observed flakiness, as numbers rather than adjectives.
|
|
155
|
+
const kills = evidence.records.filter((r) => r.verdict === 'killed');
|
|
156
|
+
if (kills.length) doc.counts.killedCoAuthored = kills.filter((r) => r.detail.independence?.class === 'co-authored').length;
|
|
157
|
+
const flaky = evidence.records.filter((r) => r.detail.flakeRate?.failures > 0);
|
|
158
|
+
if (flaky.length) {
|
|
159
|
+
doc.counts.flakyDefenderSets = new Set(flaky.map((r) => r.defenders.resolved.join('\n'))).size;
|
|
160
|
+
doc.counts.worstFlakeRate = Math.max(...flaky.map((r) => r.detail.flakeRate.failures / r.detail.flakeRate.runs));
|
|
161
|
+
}
|
|
127
162
|
doc.counts.new = g.new.length + g.belowFloor.length;
|
|
128
163
|
doc.counts.baselined = g.baselined.length;
|
|
129
164
|
const ranked = sortForReport([...g.new, ...g.belowFloor, ...g.baselined]);
|
|
@@ -170,7 +205,7 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
170
205
|
action: fixClaim ? 'probe' : 'write-test',
|
|
171
206
|
command: fixClaim
|
|
172
207
|
? `edit ${top.claimId}/${top.subjectId} in testguard.claims.json, then: testguard probe --claim ${top.claimId} --include-dirty`
|
|
173
|
-
: `write a test in ${where} that fails on ${top.claimId}/${top.subjectId} and passes on HEAD, then: testguard
|
|
208
|
+
: `write a test in ${where} that fails on ${top.claimId}/${top.subjectId} and passes on HEAD, then: testguard admit ${defenders[0] ?? '<that test file>'} --claim ${top.claimId}`,
|
|
174
209
|
why: whyByVerdict[top.verdict] ?? `${top.claimId}/${top.subjectId} is ${top.verdict}.`,
|
|
175
210
|
target: { claimId: top.claimId, subjectId: top.subjectId, ...(top.file ? { file: top.file } : {}), verdict: top.verdict },
|
|
176
211
|
};
|
|
@@ -187,7 +222,12 @@ export function computeStatus({ projectDir, toolVersion = '0.0.0', generatedAt =
|
|
|
187
222
|
}
|
|
188
223
|
|
|
189
224
|
export function renderStatus(doc) {
|
|
190
|
-
const lines = [
|
|
225
|
+
const lines = [];
|
|
226
|
+
if (doc.evidenceSource === 'provided') {
|
|
227
|
+
lines.push(`evidence: ${doc.paths.evidence} (provided)` + (doc.evidenceHead ? ` describes ${doc.evidenceHead.slice(0, 7)}` : '') + (doc.head && doc.evidenceHead && doc.head !== doc.evidenceHead ? `; this tree is ${doc.head.slice(0, 7)}` : ''));
|
|
228
|
+
}
|
|
229
|
+
lines.push(`state: ${doc.state}${doc.provisional ? ' (provisional)' : ''} — ${doc.counts.claims} claims / ${doc.counts.faults} faults` + (doc.counts.byVerdict ? `; ${Object.entries(doc.counts.byVerdict).map(([k, v]) => `${v} ${k}`).join(', ')}; ${doc.counts.new ?? 0} new, ${doc.counts.baselined ?? 0} baselined` : '')
|
|
230
|
+
);
|
|
191
231
|
if (doc.changes) {
|
|
192
232
|
const c = doc.changes;
|
|
193
233
|
lines.push(`changes: ${c.changed} file${c.changed === 1 ? '' : 's'} since ${c.ref}; ${c.evaluated} evaluated, ${c.excluded} excluded, ${c.uncovered.length} unclaimed`);
|
|
@@ -197,6 +237,7 @@ export function renderStatus(doc) {
|
|
|
197
237
|
}
|
|
198
238
|
for (const c of doc.changedFaults) lines.push(`CHANGED ${c.claimId}/${c.subjectId} edited since it ${c.previousVerdict} (${c.file})`);
|
|
199
239
|
for (const s of doc.stale.slice(0, 8)) lines.push(`stale ${s}`);
|
|
240
|
+
for (const n of doc.notes ?? []) lines.push(`note ${n}`);
|
|
200
241
|
lines.push(`next: [${doc.next.action}] ${doc.next.command}`);
|
|
201
242
|
lines.push(`why: ${doc.next.why}`);
|
|
202
243
|
return lines.join('\n');
|
package/src/util/glob.mjs
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { readdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// Dependency and tool directories. The Python entries are not cosmetic: a
|
|
5
|
+
// virtualenv inside the project holds thousands of `test_*.py` files belonging
|
|
6
|
+
// to installed packages, and without skipping it every one of them would be
|
|
7
|
+
// collected as a test file of the project under probe.
|
|
8
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.testguard',
|
|
9
|
+
'__pycache__', '.venv', 'venv', 'site-packages', '.tox', '.nox',
|
|
10
|
+
'.pytest_cache', '.mypy_cache', '.ruff_cache', '.eggs']);
|
|
5
11
|
|
|
6
12
|
/** Translate a minimal glob (`**`, `*`, `?`) into an anchored RegExp over posix paths. */
|
|
7
13
|
export function globToRegExp(glob) {
|