testguard-cli 0.6.0 → 0.8.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +489 -0
  2. package/README.md +287 -34
  3. package/package.json +2 -2
  4. package/spec/GATE-SEMANTICS.md +210 -8
  5. package/spec/README.md +32 -11
  6. package/spec/lib/validate.mjs +125 -14
  7. package/spec/lib/wilson.mjs +112 -0
  8. package/spec/schemas/baseline.schema.json +7 -7
  9. package/spec/schemas/brief.schema.json +12 -12
  10. package/spec/schemas/calibration.schema.json +96 -26
  11. package/spec/schemas/claims.schema.json +129 -25
  12. package/spec/schemas/common.schema.json +127 -29
  13. package/spec/schemas/evidence.schema.json +175 -73
  14. package/spec/schemas/gate.schema.json +12 -12
  15. package/spec/schemas/ignore.schema.json +4 -4
  16. package/spec/schemas/replay.schema.json +10 -10
  17. package/spec/schemas/status.schema.json +25 -25
  18. package/src/cli.mjs +20 -5
  19. package/src/commands/claims.mjs +22 -2
  20. package/src/commands/probe.mjs +24 -4
  21. package/src/commands/replay.mjs +1 -1
  22. package/src/gate/changed.mjs +9 -2
  23. package/src/probe/attribution.mjs +123 -0
  24. package/src/probe/classify.mjs +18 -2
  25. package/src/probe/cost.mjs +205 -0
  26. package/src/probe/discover.mjs +14 -6
  27. package/src/probe/inject.mjs +41 -9
  28. package/src/probe/mocks.mjs +2 -0
  29. package/src/probe/probe.mjs +235 -52
  30. package/src/probe/progress.mjs +87 -0
  31. package/src/probe/pyimports.mjs +287 -0
  32. package/src/probe/rank.mjs +2 -0
  33. package/src/probe/runners/index.mjs +38 -11
  34. package/src/probe/runners/jest.mjs +13 -1
  35. package/src/probe/runners/playwright.mjs +9 -0
  36. package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
  37. package/src/probe/runners/python/_testguard_report.py +141 -0
  38. package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
  39. package/src/probe/runners/python.mjs +316 -0
  40. package/src/probe/runners/shared.mjs +10 -1
  41. package/src/probe/runners/vitest.mjs +13 -1
  42. package/src/replay/replay.mjs +50 -22
  43. package/src/scaffold/producers.python.mjs +209 -0
  44. package/src/scaffold/scaffold.mjs +86 -32
  45. package/src/util/glob.mjs +7 -1
@@ -6,11 +6,13 @@ import { createScratch, PreconditionError } from '../probe/worktree.mjs';
6
6
  import { selectRunner, RUNNERS } from '../probe/runners/index.mjs';
7
7
  import { parseCommandTemplate } from '../probe/runners/shared.mjs';
8
8
  import { fileImports } from '../probe/rank.mjs';
9
+ import { IS_PY_TEST, pyFileImports } from '../probe/pyimports.mjs';
9
10
  import { labelDiff } from './label.mjs';
11
+ import { wilsonInterval, roundTo } from '../../spec/lib/wilson.mjs';
10
12
 
11
13
  const TEST_RE = /(^|\/)(__tests__|tests?)\//;
12
- const IS_TEST = (f) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(f) || TEST_RE.test(f);
13
- const SOURCE_EXT = /\.[cm]?[jt]sx?$/;
14
+ const IS_TEST = (f) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(f) || IS_PY_TEST.test(f) || TEST_RE.test(f);
15
+ const SOURCE_EXT = /\.([cm]?[jt]sx?|py)$/;
14
16
 
15
17
  /**
16
18
  * Fix commits in a range: those that change source AND a test together.
@@ -246,35 +248,50 @@ export function classifyReplay(runs) {
246
248
  return { verdict: 'flaky', reason: 'runs-disagreed' };
247
249
  }
248
250
 
249
- /** Wilson score interval: the reason a label carries n rather than a vibe. */
251
+ /**
252
+ * Wilson score interval at 4 dp — the reason a label carries n rather than a
253
+ * vibe. Computed by the spec's single implementation, which the validator
254
+ * recomputes with: an emitter and a validator with separate arithmetic can
255
+ * drift, and did (see `spec/lib/wilson.mjs`).
256
+ */
250
257
  export function wilson(positives, n, z = 1.96) {
251
- if (n === 0) return { p: 0, ci: [0, 1] };
252
- const p = positives / n;
253
- const d = 1 + (z * z) / n;
254
- const centre = (p + (z * z) / (2 * n)) / d;
255
- const half = (z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n))) / d;
256
- const round = (x) => Number(Math.min(1, Math.max(0, x)).toFixed(4));
257
- return { p: Number(p.toFixed(4)), ci: [round(centre - half), round(centre + half)] };
258
+ if (n === 0) return { p: null, ci: [0, 1] };
259
+ const [lo, hi] = wilsonInterval(positives, n, z);
260
+ return { p: roundTo(positives / n, 4), ci: [roundTo(lo, 4), roundTo(hi, 4)] };
258
261
  }
259
262
 
263
+ /**
264
+ * Replay verdicts that are measurements of the suite. `caught` is a hit.
265
+ * `blind` is a miss. `nocover` is also a miss — the worst kind, no test even
266
+ * imported the broken file — and it counts as one: excluding it would score a
267
+ * project with no tests for a subsystem BETTER than one with weak tests,
268
+ * because its worst outcomes would leave the denominator before the ratio is
269
+ * taken. Stryker and PIT count no-coverage mutants against the headline score
270
+ * for the same reason. `flaky` and `unverifiable` are failed measurements
271
+ * and enter neither side.
272
+ */
273
+ export const MEASURED_VERDICTS = Object.freeze(['caught', 'blind', 'nocover']);
274
+
260
275
  /**
261
276
  * Calibration from a replay document: per fault class, the share of real
262
- * escaped bugs of that shape the suite did NOT catch.
277
+ * escaped bugs of that shape the suite failed to catch.
263
278
  *
264
- * Read it as "when a fault of this class survives, how often does that
265
- * correspond to a bug that really escaped" — the number that turns an
266
- * uninterpretable mutation score into a statement with a sample size.
267
- * `caught` and `blind` are the only outcomes that carry information;
268
- * `nocover`, `flaky` and `unverifiable` are excluded from both sides.
279
+ * `p` is P(the suite misses it | a bug of this class escapes) — a miss rate,
280
+ * so a HIGH number is bad. It is the number that turns an uninterpretable
281
+ * mutation score into a statement with a sample size. `positives` counts the
282
+ * misses; see MEASURED_VERDICTS for which verdicts are measurements at all
283
+ * and why `nocover` is a miss rather than an exclusion.
269
284
  */
270
285
  export function calibrationFrom(replayDoc, { confidence = 0.95, computedAt = new Date().toISOString(), toolVersion } = {}) {
271
286
  const buckets = {};
272
287
  for (const r of replayDoc.records) {
273
- if (!['caught', 'blind'].includes(r.verdict)) continue;
288
+ if (!MEASURED_VERDICTS.includes(r.verdict)) continue;
274
289
  const key = r.faultClass ?? 'other';
275
- buckets[key] ??= { n: 0, positives: 0 };
290
+ buckets[key] ??= { n: 0, positives: 0, breakdown: {} };
276
291
  buckets[key].n += 1;
277
- if (r.verdict === 'blind') buckets[key].positives += 1;
292
+ buckets[key].breakdown[r.verdict] = (buckets[key].breakdown[r.verdict] ?? 0) + 1;
293
+ // Anything measured that was not caught is a miss — blind and nocover alike.
294
+ if (r.verdict !== 'caught') buckets[key].positives += 1;
278
295
  }
279
296
  for (const [k, b] of Object.entries(buckets)) buckets[k] = { ...b, ...wilson(b.positives, b.n) };
280
297
  return {
@@ -283,8 +300,18 @@ export function calibrationFrom(replayDoc, { confidence = 0.95, computedAt = new
283
300
  computedAt,
284
301
  method: 'wilson',
285
302
  confidence,
303
+ // What p is the probability of. Without it the document is unattributed
304
+ // and a consumer may quote nothing from it — see GATE-SEMANTICS.
305
+ measures: 'escape-missed',
286
306
  bucketBy: 'faultClass',
287
- source: { kind: 'bug-replay', ref: replayDoc.run.range },
307
+ source: {
308
+ kind: 'bug-replay',
309
+ ref: replayDoc.run.range,
310
+ // The ground truth is this repository's own history, which `ref` names,
311
+ // so there is no external corpus to list. The caveat is the sentence a
312
+ // consumer shows beside any number it quotes from here.
313
+ caveat: 'measured on this repository\'s own fix history: every replayed bug is one that escaped, so a first run is expected to be high — the number to move is this one, over time',
314
+ },
288
315
  buckets,
289
316
  };
290
317
  }
@@ -302,8 +329,9 @@ export function renderReplay(doc) {
302
329
  lines.push(`${doc.records.length} fix commit${doc.records.length === 1 ? '' : 's'} replayed from ${doc.run.candidates} candidate${doc.run.candidates === 1 ? '' : 's'}` +
303
330
  (doc.run.duplicates ? ` (${doc.run.duplicates} dropped as the same patch)` : '') + ': ' +
304
331
  order.filter((v) => by[v]).map((v) => `${by[v]} ${v}`).join(', ') + '.');
305
- const measurable = (by.caught ?? 0) + (by.blind ?? 0);
306
- if (measurable) lines.push(`${by.blind ?? 0} of ${measurable} measurable bugs were invisible to the suite — the escaped-bug replay rate is ${(((by.blind ?? 0) / measurable) * 100).toFixed(0)}%.`);
332
+ const measurable = MEASURED_VERDICTS.reduce((s, v) => s + (by[v] ?? 0), 0);
333
+ const missed = measurable - (by.caught ?? 0);
334
+ if (measurable) lines.push(`${missed} of ${measurable} measurable bugs were invisible to the suite (${by.blind ?? 0} blind, ${by.nocover ?? 0} with no test at all) — the escaped-bug miss rate is ${((missed / measurable) * 100).toFixed(0)}%.`);
307
335
  lines.push('A bug that reached production is by construction one the suite missed, so a high rate is expected on a first run. The number to move is this one, over time.');
308
336
  return lines.join('\n');
309
337
  }
@@ -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,6 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { basename, extname, join } from 'node:path';
3
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';
@@ -9,9 +10,91 @@ const ANNOTATION = /@claim\s+([A-Za-z0-9]+(?:[._]?[A-Za-z0-9]+)*-[A-Za-z0-9._-]*
9
10
  // field-dropped is never proposed inside tests, fixtures or migrations: an
10
11
  // object literal there is data, not a payload the product writes.
11
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)\//;
12
17
 
13
18
  const idPart = (s) => s.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toUpperCase();
14
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
+
15
98
  /** How many times `find` occurs, and which occurrence the line at `offset` is. */
16
99
  function anchorFor(source, find, offset) {
17
100
  let hits = 0;
@@ -43,38 +126,9 @@ export function scaffoldFile({ projectDir, file, claimId, existingClaims, toolVe
43
126
  return groups.get(key);
44
127
  };
45
128
 
46
- let offset = 0;
47
- let fn = null;
48
- let fnDepth = 0;
49
- let params = new Set();
50
- const fieldDrops = !NO_FIELD_DROPS.test(file);
51
- let depth = 0;
52
- let pendingAnnotation = null;
53
- const proposals = [];
54
- lines.forEach((line, i) => {
55
- const ann = ANNOTATION.exec(line);
56
- if (ann && /^\s*(\/\/|\*|\/\*)/.test(line)) pendingAnnotation = ann[1];
57
-
58
- const head = functionHead(line);
59
- if (head) {
60
- fn = head;
61
- fnDepth = depth;
62
- params = new Set(functionParams(line));
63
- }
64
-
65
- for (const p of proposalsForLine(lines, i, { params, fieldDrops })) {
66
- const find = line;
67
- const { hits, occurrence } = anchorFor(source, find, offset);
68
- const fault = { ...p, find, replace: p.replace, expectHits: hits, occurrence, line: i + 1, fn, annotation: pendingAnnotation };
69
- if (locate(source, fault).status !== 'ok') continue; // never propose an anchor that would be unverifiable
70
- proposals.push(fault);
71
- }
72
-
73
- depth += (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length;
74
- if (fn && depth <= fnDepth && !head) { fn = null; params = new Set(); }
75
- if (pendingAnnotation && !ann && !head && line.trim() && !/^\s*(\/\/|\*|\/\*)/.test(line)) pendingAnnotation = null;
76
- offset += line.length + 1;
77
- });
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) });
78
132
 
79
133
  const existing = new Map((existingClaims?.claims ?? []).map((c) => [c.id, c]));
80
134
  const usedIds = new Set();
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
- const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.testguard']);
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) {