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.
Files changed (65) hide show
  1. package/CHANGELOG.md +628 -0
  2. package/README.md +536 -42
  3. package/package.json +2 -2
  4. package/spec/GATE-SEMANTICS.md +166 -2
  5. package/spec/README.md +29 -10
  6. package/spec/lib/validate.mjs +140 -8
  7. package/spec/schemas/baseline.schema.json +13 -5
  8. package/spec/schemas/brief.schema.json +12 -12
  9. package/spec/schemas/calibration.schema.json +5 -5
  10. package/spec/schemas/claims.schema.json +122 -25
  11. package/spec/schemas/common.schema.json +131 -29
  12. package/spec/schemas/evidence.schema.json +476 -69
  13. package/spec/schemas/gate.schema.json +12 -12
  14. package/spec/schemas/ignore.schema.json +4 -4
  15. package/spec/schemas/replay.schema.json +85 -0
  16. package/spec/schemas/status.schema.json +258 -56
  17. package/src/admit/admit.mjs +17 -0
  18. package/src/baseline/baseline.mjs +31 -2
  19. package/src/brief/brief.mjs +71 -7
  20. package/src/claims/removed.mjs +161 -0
  21. package/src/cli.mjs +68 -10
  22. package/src/commands/admit.mjs +116 -0
  23. package/src/commands/baseline.mjs +17 -3
  24. package/src/commands/brief.mjs +13 -7
  25. package/src/commands/claims.mjs +55 -6
  26. package/src/commands/init.mjs +19 -6
  27. package/src/commands/mcp.mjs +12 -0
  28. package/src/commands/probe.mjs +35 -7
  29. package/src/commands/replay.mjs +63 -0
  30. package/src/commands/status.mjs +3 -1
  31. package/src/gate/changed.mjs +9 -2
  32. package/src/git.mjs +5 -0
  33. package/src/init/init.mjs +182 -29
  34. package/src/init/templates/SKILL.md +3 -3
  35. package/src/mcp/server.mjs +104 -0
  36. package/src/mcp/tools.mjs +114 -0
  37. package/src/probe/attribution.mjs +123 -0
  38. package/src/probe/classify.mjs +18 -2
  39. package/src/probe/contention.mjs +60 -0
  40. package/src/probe/cost.mjs +145 -0
  41. package/src/probe/discover.mjs +20 -3
  42. package/src/probe/independence.mjs +59 -0
  43. package/src/probe/inject.mjs +41 -9
  44. package/src/probe/mocks.mjs +122 -0
  45. package/src/probe/probe.mjs +331 -40
  46. package/src/probe/progress.mjs +87 -0
  47. package/src/probe/pyimports.mjs +287 -0
  48. package/src/probe/rank.mjs +75 -15
  49. package/src/probe/runners/index.mjs +79 -8
  50. package/src/probe/runners/jest.mjs +15 -3
  51. package/src/probe/runners/playwright.mjs +142 -0
  52. package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
  53. package/src/probe/runners/python/_testguard_report.py +141 -0
  54. package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
  55. package/src/probe/runners/python.mjs +316 -0
  56. package/src/probe/runners/shared.mjs +80 -14
  57. package/src/probe/runners/vitest.mjs +16 -3
  58. package/src/render.mjs +2 -2
  59. package/src/replay/label.mjs +64 -0
  60. package/src/replay/replay.mjs +310 -0
  61. package/src/scaffold/producers.mjs +217 -6
  62. package/src/scaffold/producers.python.mjs +209 -0
  63. package/src/scaffold/scaffold.mjs +90 -30
  64. package/src/status/status.mjs +47 -6
  65. package/src/util/glob.mjs +7 -1
@@ -0,0 +1,310 @@
1
+ import { existsSync, rmSync, realpathSync } from 'node:fs';
2
+ import { basename, dirname, join, relative, resolve } from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { git, GitError, repoRoot, headSha } from '../git.mjs';
5
+ import { createScratch, PreconditionError } from '../probe/worktree.mjs';
6
+ import { selectRunner, RUNNERS } from '../probe/runners/index.mjs';
7
+ import { parseCommandTemplate } from '../probe/runners/shared.mjs';
8
+ import { fileImports } from '../probe/rank.mjs';
9
+ import { IS_PY_TEST, pyFileImports } from '../probe/pyimports.mjs';
10
+ import { labelDiff } from './label.mjs';
11
+
12
+ const TEST_RE = /(^|\/)(__tests__|tests?)\//;
13
+ const IS_TEST = (f) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(f) || IS_PY_TEST.test(f) || TEST_RE.test(f);
14
+ const SOURCE_EXT = /\.([cm]?[jt]sx?|py)$/;
15
+
16
+ /**
17
+ * Fix commits in a range: those that change source AND a test together.
18
+ *
19
+ * That pairing is the signal a human confirmed a defect — someone changed
20
+ * behaviour and shipped a test for it — which is what makes bug replay ground
21
+ * truth rather than a synthetic mutant of arguable relevance.
22
+ */
23
+ export function findFixCommits({ dir, range, projectDir }) {
24
+ // In a monorepo a fix commit routinely touches several packages. Only the
25
+ // files under THIS project can be reverted or run here, so scope to them
26
+ // and drop a commit that has no source-and-test pair inside the project —
27
+ // otherwise most of the corpus comes back `revert-did-not-apply`, which
28
+ // looks like a tooling failure and hides the real verdicts.
29
+ const scope = projectDir && projectDir !== dir ? relative(dir, projectDir).split('\\').join('/') : '';
30
+ const inScope = (f) => !scope || f === scope || f.startsWith(`${scope}/`);
31
+ let log;
32
+ try {
33
+ log = git(['log', '--no-merges', '--format=%H%x1f%s', range], dir);
34
+ } catch (e) {
35
+ if (e instanceof GitError) throw new PreconditionError(`cannot read the range ${range}: ${e.message.split('\n')[0]}`);
36
+ throw e;
37
+ }
38
+ const out = [];
39
+ for (const line of log.split('\n').filter(Boolean)) {
40
+ const [commit, subject] = line.split('\x1f');
41
+ const files = git(['show', '--name-only', '--format=', '--diff-filter=d', commit], dir).split('\n').filter(Boolean);
42
+ const source = files.filter((f) => inScope(f) && SOURCE_EXT.test(f) && !IS_TEST(f));
43
+ const tests = files.filter((f) => inScope(f) && IS_TEST(f));
44
+ if (source.length === 0 || tests.length === 0) continue;
45
+ out.push({ commit, subject, source, tests });
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * De-duplicate by patch-id. A dual-branch topology carries the same fix under
52
+ * two or three shas; counting it twice corrupts the corpus the calibration is
53
+ * computed from, and it was a real defect in the first harness.
54
+ */
55
+ export function dedupeByPatch({ dir, commits }) {
56
+ const seen = new Map();
57
+ const unique = [];
58
+ for (const c of commits) {
59
+ // `git patch-id` reads the patch on stdin, which git() does not do.
60
+ const r = spawnSync('git', ['patch-id', '--stable'], { cwd: dir, encoding: 'utf8', input: (() => { try { return git(['show', c.commit], dir); } catch { return ''; } })() });
61
+ const patchId = r.status === 0 ? (r.stdout.trim().split(' ')[0] || null) : null;
62
+ const id = patchId || c.commit;
63
+ if (seen.has(id)) continue;
64
+ seen.set(id, c.commit);
65
+ unique.push({ ...c, patchId: id });
66
+ }
67
+ return { unique, duplicates: commits.length - unique.length };
68
+ }
69
+
70
+ /**
71
+ * Would the suite as it stood have caught this bug?
72
+ *
73
+ * For each fix: check out the fix, revert ONLY its source files to the
74
+ * parent, delete the test files the fix shipped, and run what remains. A
75
+ * fix's own test proves nothing about what the suite knew before it.
76
+ *
77
+ * Runs in a scratch worktree; the caller's tree is never touched.
78
+ */
79
+ export async function replay({
80
+ projectDir,
81
+ range,
82
+ confirmRuns = 3,
83
+ budgetMs = 120_000,
84
+ runnerCommand,
85
+ runnerName = 'auto',
86
+ nodeModules,
87
+ scratchBase,
88
+ limit,
89
+ toolVersion = '0.0.0',
90
+ onProgress = () => {},
91
+ onStage = () => {},
92
+ }) {
93
+ projectDir = realpathSync(resolve(projectDir));
94
+ const root = repoRoot(projectDir);
95
+ if (!headSha(root)) throw new PreconditionError('repository has no commits; there is nothing to replay');
96
+
97
+ const found = findFixCommits({ dir: root, range, projectDir });
98
+ const { unique, duplicates } = dedupeByPatch({ dir: root, commits: found });
99
+ const selected = limit ? unique.slice(0, limit) : unique;
100
+ if (selected.length === 0) {
101
+ throw new PreconditionError(`no fix commits in ${range}: a fix commit changes source and a test together. Widen the range, or this history has none.`);
102
+ }
103
+
104
+ const startedAt = new Date().toISOString();
105
+ const records = [];
106
+ let runnerUsed = RUNNERS[runnerName === 'auto' ? 'vitest' : runnerName];
107
+ let runnerVersion;
108
+
109
+ for (const [i, fix] of selected.entries()) {
110
+ onStage({ commit: fix.commit, i: i + 1, n: selected.length, stage: 'prepare' });
111
+ const iso = createScratch({ repoRoot: root, projectDir, ref: fix.commit, scratchBase, nodeModules });
112
+ try {
113
+ const commandTemplate = runnerCommand ? parseCommandTemplate(runnerCommand) : undefined;
114
+ if (!commandTemplate) {
115
+ const sel = await selectRunner({ projectDir: iso.projectDir, name: runnerName });
116
+ if (sel.error) throw new PreconditionError(`test runner is not resolvable in the scratch worktree (${sel.error}). Pass --node-modules <path>.`);
117
+ runnerUsed = sel.runner;
118
+ runnerVersion = sel.version;
119
+ }
120
+ const record = await replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, commandTemplate, runner: runnerUsed, onStage, index: i + 1, total: selected.length });
121
+ records.push(record);
122
+ onProgress(record);
123
+ } finally {
124
+ iso.cleanup();
125
+ }
126
+ }
127
+
128
+ return {
129
+ schemaVersion: 1,
130
+ tool: { name: 'testguard', version: toolVersion },
131
+ run: {
132
+ id: `replay-${startedAt.replace(/[-:.]/g, '').slice(0, 15)}`,
133
+ startedAt,
134
+ finishedAt: new Date().toISOString(),
135
+ range,
136
+ runner: { name: runnerUsed.name, ...(runnerVersion ? { version: runnerVersion } : {}) },
137
+ confirmRuns,
138
+ candidates: found.length,
139
+ duplicates,
140
+ },
141
+ records,
142
+ };
143
+ }
144
+
145
+ async function replayOne({ fix, iso, root, projectDir, confirmRuns, budgetMs, commandTemplate, runner, onStage, index, total }) {
146
+ // git reports paths from the REPOSITORY root; the runner and every file
147
+ // operation here work inside the scratch worktree, relative to the PROJECT
148
+ // directory. Map once, and refuse anything that would land outside the
149
+ // worktree: this function deletes files, and a wrong mapping silently
150
+ // deleted them from the real repository.
151
+ const relProject = relative(root, projectDir).split('\\').join('/');
152
+ const rel = (repoPath) => (relProject && repoPath.startsWith(`${relProject}/`) ? repoPath.slice(relProject.length + 1) : repoPath);
153
+ const isoRoot = realpathSync(iso.projectDir);
154
+ const inside = (p) => {
155
+ const abs = realpathSync(dirname(p)) + '/' + basename(p);
156
+ if (!abs.startsWith(isoRoot + '/')) throw new PreconditionError(`refusing to touch ${p}: outside the scratch worktree ${isoRoot}`);
157
+ return abs;
158
+ };
159
+ const base = { commit: fix.commit, patchId: fix.patchId, subject: fix.subject, source: fix.source, removedTests: fix.tests };
160
+
161
+ // The label comes from the fix's SOURCE diff only: a test diff describes
162
+ // what was asserted, not what was broken.
163
+ let faultClass;
164
+ try {
165
+ const diff = git(['show', '--format=', '--unified=0', fix.commit, '--', ...fix.source], root);
166
+ faultClass = labelDiff(diff).faultClass;
167
+ } catch {
168
+ faultClass = 'other';
169
+ }
170
+
171
+ // Revert only the source, to the fix's parent. A fix routinely ADDS a file
172
+ // as well as changing others, and a file that did not exist at the parent
173
+ // cannot be checked out of it — reverting such a file means removing it.
174
+ // Getting this wrong turns half a real corpus into `revert-did-not-apply`,
175
+ // which reads as a tooling failure and hides every real verdict behind it.
176
+ const existedBefore = [];
177
+ const addedByTheFix = [];
178
+ for (const f of fix.source) {
179
+ try {
180
+ git(['cat-file', '-e', `${fix.commit}^:${f}`], iso.projectDir);
181
+ existedBefore.push(f);
182
+ } catch {
183
+ addedByTheFix.push(f);
184
+ }
185
+ }
186
+ // Every source file is new: this commit ADDS behaviour rather than
187
+ // correcting it. There is no earlier version to have been blind to, so it
188
+ // is not a bug the suite could have caught, and it must not enter a
189
+ // calibration as though it were.
190
+ if (existedBefore.length === 0) {
191
+ return { ...base, faultClass, verdict: 'unverifiable', reason: 'no-prior-version', runs: [{ outcome: 'error', durationMs: 0 }] };
192
+ }
193
+ try {
194
+ if (existedBefore.length) git(['checkout', `${fix.commit}^`, '--', ...existedBefore.map(rel)], iso.projectDir);
195
+ for (const f of addedByTheFix) {
196
+ const abs = join(iso.projectDir, rel(f));
197
+ if (existsSync(abs)) rmSync(inside(abs), { force: true });
198
+ }
199
+ } catch (e) {
200
+ return { ...base, faultClass, verdict: 'unverifiable', reason: 'revert-did-not-apply', runs: [{ outcome: 'error', durationMs: 0 }] };
201
+ }
202
+
203
+ // A fix's own test proves nothing about what the suite knew before it.
204
+ for (const t of fix.tests) {
205
+ const inWorktree = join(iso.projectDir, rel(t));
206
+ if (existsSync(inWorktree)) rmSync(inside(inWorktree), { force: true });
207
+ }
208
+
209
+ // What remains that could possibly notice: the test files importing any
210
+ // reverted source file. `nocover` means nothing does — worse than blind,
211
+ // and a coverage report shows it as a red line you can ignore.
212
+ const all = runner.tests(iso.projectDir).filter((t) => !fix.tests.some((ft) => rel(ft) === t));
213
+ const related = all.filter((t) => fix.source.some((sf) => fileImports(iso.projectDir, join(iso.projectDir, t), rel(sf))));
214
+ if (related.length === 0) {
215
+ return { ...base, faultClass, verdict: 'nocover', reason: 'no-test-imports-the-reverted-source', ranTests: 0, runs: [{ outcome: 'error', durationMs: 0 }] };
216
+ }
217
+
218
+ const runs = [];
219
+ for (let i = 0; i < confirmRuns; i++) {
220
+ onStage({ commit: fix.commit, i: index, n: total, stage: `run ${i + 1}/${confirmRuns}` });
221
+ const res = await runner.run({ projectDir: iso.projectDir, files: related, budgetMs, commandTemplate });
222
+ runs.push(res.run);
223
+ // A suite that cannot load says nothing about the bug.
224
+ if (res.run.outcome === 'error') return { ...base, faultClass, verdict: 'unverifiable', reason: 'suite-failed-to-load', ranTests: related.length, runs };
225
+ }
226
+
227
+ return { ...base, faultClass, ...classifyReplay(runs), ranTests: related.length, runs };
228
+ }
229
+
230
+ /**
231
+ * Runs in, verdict out. Pure, so every branch is unit-testable — the same
232
+ * discipline as the probe's `classify()`, for the same reason.
233
+ *
234
+ * The ordering matters. A flaky failure reads as "the suite detected it", so
235
+ * flakiness biases this metric OPTIMISTICALLY and hides exactly the blind
236
+ * spots the replay exists to find: mixed runs are never `caught`.
237
+ */
238
+ export function classifyReplay(runs) {
239
+ const isCatch = (r) => r.outcome === 'fail' && (r.assertionFailures ?? 0) > 0;
240
+ if (runs.length === 0) return { verdict: 'unverifiable', reason: 'no-runs' };
241
+ if (runs.some((r) => r.outcome === 'error')) return { verdict: 'unverifiable', reason: 'suite-failed-to-load' };
242
+ if (runs.every(isCatch)) return { verdict: 'caught' };
243
+ if (runs.every((r) => r.outcome === 'pass')) return { verdict: 'blind' };
244
+ if (runs.some((r) => r.outcome === 'timeout')) return { verdict: 'unverifiable', reason: 'timed-out' };
245
+ // A failure that is not an assertion failure is not detection either.
246
+ if (runs.every((r) => r.outcome === 'fail') && !runs.every(isCatch)) return { verdict: 'unverifiable', reason: 'failed-without-an-assertion' };
247
+ return { verdict: 'flaky', reason: 'runs-disagreed' };
248
+ }
249
+
250
+ /** Wilson score interval: the reason a label carries n rather than a vibe. */
251
+ export function wilson(positives, n, z = 1.96) {
252
+ if (n === 0) return { p: 0, ci: [0, 1] };
253
+ const p = positives / n;
254
+ const d = 1 + (z * z) / n;
255
+ const centre = (p + (z * z) / (2 * n)) / d;
256
+ const half = (z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n))) / d;
257
+ const round = (x) => Number(Math.min(1, Math.max(0, x)).toFixed(4));
258
+ return { p: Number(p.toFixed(4)), ci: [round(centre - half), round(centre + half)] };
259
+ }
260
+
261
+ /**
262
+ * Calibration from a replay document: per fault class, the share of real
263
+ * escaped bugs of that shape the suite did NOT catch.
264
+ *
265
+ * Read it as "when a fault of this class survives, how often does that
266
+ * correspond to a bug that really escaped" — the number that turns an
267
+ * uninterpretable mutation score into a statement with a sample size.
268
+ * `caught` and `blind` are the only outcomes that carry information;
269
+ * `nocover`, `flaky` and `unverifiable` are excluded from both sides.
270
+ */
271
+ export function calibrationFrom(replayDoc, { confidence = 0.95, computedAt = new Date().toISOString(), toolVersion } = {}) {
272
+ const buckets = {};
273
+ for (const r of replayDoc.records) {
274
+ if (!['caught', 'blind'].includes(r.verdict)) continue;
275
+ const key = r.faultClass ?? 'other';
276
+ buckets[key] ??= { n: 0, positives: 0 };
277
+ buckets[key].n += 1;
278
+ if (r.verdict === 'blind') buckets[key].positives += 1;
279
+ }
280
+ for (const [k, b] of Object.entries(buckets)) buckets[k] = { ...b, ...wilson(b.positives, b.n) };
281
+ return {
282
+ schemaVersion: 1,
283
+ tool: { name: 'testguard', version: toolVersion ?? replayDoc.tool.version },
284
+ computedAt,
285
+ method: 'wilson',
286
+ confidence,
287
+ bucketBy: 'faultClass',
288
+ source: { kind: 'bug-replay', ref: replayDoc.run.range },
289
+ buckets,
290
+ };
291
+ }
292
+
293
+ export function renderReplay(doc) {
294
+ const by = {};
295
+ for (const r of doc.records) by[r.verdict] = (by[r.verdict] ?? 0) + 1;
296
+ const lines = [];
297
+ for (const r of doc.records) {
298
+ if (r.verdict === 'caught') continue;
299
+ lines.push(`${r.verdict.toUpperCase().padEnd(13)} ${r.commit.slice(0, 9)} ${(r.faultClass ?? 'other').padEnd(20)} ${r.ranTests ?? 0} test${r.ranTests === 1 ? '' : 's'} ran ${r.subject ?? ''}${r.reason ? ` [${r.reason}]` : ''}`);
300
+ }
301
+ const order = ['blind', 'nocover', 'flaky', 'unverifiable', 'caught'];
302
+ lines.push('');
303
+ lines.push(`${doc.records.length} fix commit${doc.records.length === 1 ? '' : 's'} replayed from ${doc.run.candidates} candidate${doc.run.candidates === 1 ? '' : 's'}` +
304
+ (doc.run.duplicates ? ` (${doc.run.duplicates} dropped as the same patch)` : '') + ': ' +
305
+ order.filter((v) => by[v]).map((v) => `${by[v]} ${v}`).join(', ') + '.');
306
+ const measurable = (by.caught ?? 0) + (by.blind ?? 0);
307
+ 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)}%.`);
308
+ 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.');
309
+ return lines.join('\n');
310
+ }
@@ -1,10 +1,25 @@
1
1
  /**
2
2
  * Mechanical fault producers. Deterministic, line-oriented, no AST, no LLM.
3
3
  *
4
- * Two independent field reports found that ~80% of hand-written faults are
5
- * one of these shapes. Each producer looks at one line (plus a little
6
- * context) and proposes a fault whose `find` is the exact line, so the anchor
7
- * hits by construction. A human keeps or drops every proposal.
4
+ * Field reports found that most hand-written faults are one of a few shapes.
5
+ * Each producer looks at one line (plus a little context) and proposes a
6
+ * fault whose `find` is the exact line, so the anchor hits by construction.
7
+ * A human keeps or drops every proposal.
8
+ *
9
+ * Shapes:
10
+ * condition-forced `if (<guard>) {` → `if (false) {`
11
+ * statement-deleted a single-line guard or a state change removed
12
+ * return-altered `return <check>;` → `return true;`
13
+ * literal-changed a security flag / window / cost literal weakened
14
+ * call-removed a bare verify/validate/check call removed
15
+ * field-dropped a field removed from a persisted or transmitted object,
16
+ * an allow-list, a schema, or a spread merge (issue #14)
17
+ * argument-swapped a call kept, its parameter-derived argument swapped for
18
+ * a trivial value (issue #22)
19
+ * element-removed a one-line JSX element (self-closing or paired) removed
20
+ * (issue #15)
21
+ * handler-dropped an on<Event>={…} prop removed from a JSX element
22
+ * (issue #15)
8
23
  */
9
24
 
10
25
  const COMMENT = /^\s*(\/\/|\*|\/\*)/;
@@ -26,6 +41,36 @@ const FUNCTION_HEAD = [
26
41
  ];
27
42
  const NOT_A_FUNCTION = new Set(['if', 'for', 'while', 'switch', 'catch', 'else', 'return', 'function', 'constructor']);
28
43
 
44
+ // ── field-dropped ──
45
+ // An object literal counts as a payload when its opening line says so: it is
46
+ // returned, built by an arrow, assigned to a payload-ish name, passed to a
47
+ // persistence/transport call, or is a validation schema. Type-level braces
48
+ // (`interface`, `type X = {`) are never payloads: dropping a key there is a
49
+ // compile error, not a behaviour.
50
+ const PAYLOAD_OPENER = /(\breturn\s*\{\s*$|=>\s*\(?\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)\w*\s*[:=]\s*\{\s*$|\.(?:save|update|put|patch|post|send|write|insert|upsert|create|set|publish|emit|dispatch|persist|store|log|fetch|request|audit|record|track|notify)\w*\s*\(\s*(?:[^,{]*,\s*)*\{\s*$|\b(?:z|yup|joi|v|t)\.(?:object|shape|strictObject|looseObject)\s*\(\s*\{\s*$|\.(?:object|shape|extend|merge|pick|omit)\s*\(\s*\{\s*$|\bobject\s*\(\s*\{\s*$)/i;
51
+ const TYPE_OPENER = /\b(?:interface|type|enum|declare)\b/;
52
+ const ALLOWLIST_OPENER = /\b\w*(?:fields|keys|allowed|writable|columns|attributes|props|whitelist|allowlist|editable|mutable|updatable|permitted|selectable|sortable|filterable|required)\w*\b[^[]*\[\s*$/i;
53
+ const PROP_LINE = /^\s*(?:[\w$]+|'[^']*'|"[^"]*"|\[[^\]]+\])\s*:\s*(?!\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[\w$]+\s*=>))(.+?)(,?)\s*$/;
54
+ const SHORTHAND_LINE = /^\s*[\w$]+\s*,\s*$/;
55
+ const METHOD_LINE = /^\s*(?:async\s+)?(?:get\s+|set\s+)?[\w$]+\s*\([^)]*\)\s*\{\s*$/;
56
+ const SPREAD_LINE = /^\s*\.\.\.[\w$.()]+\s*,?\s*$/;
57
+ const STRING_ENTRY = /^\s*(['"])[^'"]+\1\s*,?\s*$/;
58
+ const INLINE_SPREAD_MERGE = /\{\s*\.\.\.([\w$.()]+)\s*,\s*(?=[^}]*\S)/;
59
+
60
+ // ── element-removed / handler-dropped (JSX) ──
61
+ // One-line elements only, so the removal is syntactically safe by construction.
62
+ const JSX_SELF_CLOSING = /^\s*<[A-Za-z][\w.-]*\b[^<>]*\/>\s*$/;
63
+ const JSX_PAIRED_LINE = /^\s*<([A-Za-z][\w.-]*)\b[^<>]*>[^<>]*<\/\1>\s*$/;
64
+ const JSX_HANDLER_PROP_LINE = /^\s*on[A-Z]\w*=\{(?:[^{}]|\{[^{}]*\})*\}\s*$/;
65
+ const JSX_INLINE_HANDLER = /\s+on[A-Z]\w*=\{(?:[^{}]|\{[^{}]*\})*\}/;
66
+
67
+ // ── argument-swapped ──
68
+ // A call whose first argument is derived from a parameter or a request-like
69
+ // value. `callee(` must not be a keyword, a constructor, an import, or a test
70
+ // helper; the argument must not be a literal.
71
+ const CALL_WITH_ARG = /(?<![\w$.])(?<!\bnew\s+)(?!(?:if|for|while|switch|catch|return|function|typeof|await|new|require|import|expect|describe|it|test|vi|jest|console|logger)\b)([\w$]+(?:\.[\w$]+)*)\(\s*((?:[\w$]+(?:\?\.|\.)[\w$?.]+)|(?:[\w$]+\([^()]*\))|(?:[\w$]+))\s*(?=,|\))/g;
72
+ const REQUEST_LIKE = /^(?:req|request|res|ctx|context|event|input|args|params|options|opts|props|state|session|user|payload|body|query|headers|msg|message|job|task)$/;
73
+
29
74
  /** Name of the function whose head is on this line, or null. */
30
75
  export function functionHead(line) {
31
76
  for (const re of FUNCTION_HEAD) {
@@ -35,17 +80,177 @@ export function functionHead(line) {
35
80
  return null;
36
81
  }
37
82
 
83
+ /**
84
+ * Parameter names declared on a function-head line: plain, defaulted,
85
+ * typed, rest, and the leaves of one level of destructuring. Best effort.
86
+ */
87
+ export function functionParams(line) {
88
+ const open = line.indexOf('(');
89
+ const arrow = line.indexOf('=>');
90
+ if (open === -1 || (arrow !== -1 && arrow < open)) {
91
+ // `const f = x => …` style: a bare identifier before `=>`
92
+ const bare = /=\s*(?:async\s+)?([\w$]+)\s*=>/.exec(line);
93
+ return bare ? [bare[1]] : [];
94
+ }
95
+ let depth = 0;
96
+ let close = -1;
97
+ for (let i = open; i < line.length; i++) {
98
+ if (line[i] === '(') depth++;
99
+ else if (line[i] === ')' && --depth === 0) { close = i; break; }
100
+ }
101
+ if (close === -1) return [];
102
+ const inner = line.slice(open + 1, close);
103
+ const names = [];
104
+ for (const raw of inner.split(/,(?![^{[]*[}\]])/)) {
105
+ let p = raw.trim();
106
+ if (!p) continue;
107
+ p = p.replace(/^\.\.\./, '').replace(/\s*=[\s\S]*$/, '').replace(/\s*:[^,{}]*$/, '').replace(/\?$/, '');
108
+ const destructured = /^[{[]([\s\S]*)[}\]]$/.exec(p);
109
+ if (destructured) {
110
+ for (const part of destructured[1].split(',')) {
111
+ const leaf = part.trim().replace(/^\.\.\./, '').split(/\s*[:=]\s*/).pop().trim();
112
+ if (/^[\w$]+$/.test(leaf)) names.push(leaf);
113
+ }
114
+ } else if (/^[\w$]+$/.test(p)) {
115
+ names.push(p);
116
+ }
117
+ }
118
+ return names;
119
+ }
120
+
38
121
  function isGuard(cond, lines, i) {
39
122
  if (/^\s*!/.test(cond)) return true;
40
123
  const tail = lines.slice(i, i + 4).join('\n');
41
124
  return GUARD_BODY.test(tail);
42
125
  }
43
126
 
44
- /** Proposals for one line. Each: { faultClass, description, replace } where find is the line itself. */
45
- export function proposalsForLine(lines, i) {
127
+ const openerCache = new WeakMap();
128
+
129
+ /**
130
+ * For every line, the innermost bracket left open before that line starts:
131
+ * `{ line, char }` or null. One forward pass with a stack; strings and
132
+ * comments are not parsed (deterministic heuristic, like everything here).
133
+ */
134
+ export function openerIndex(lines) {
135
+ if (openerCache.has(lines)) return openerCache.get(lines);
136
+ const out = new Array(lines.length);
137
+ const stack = [];
138
+ for (let i = 0; i < lines.length; i++) {
139
+ out[i] = stack.length ? stack[stack.length - 1] : null;
140
+ const line = lines[i];
141
+ if (COMMENT.test(line)) continue;
142
+ for (const ch of line) {
143
+ if (ch === '{' || ch === '[' || ch === '(') stack.push({ line: i, char: ch });
144
+ else if (ch === '}' || ch === ']' || ch === ')') stack.pop();
145
+ }
146
+ }
147
+ openerCache.set(lines, out);
148
+ return out;
149
+ }
150
+
151
+ const balanced = (line) => {
152
+ const n = (re) => (line.match(re) ?? []).length;
153
+ return n(/\{/g) === n(/\}/g) && n(/\[/g) === n(/\]/g) && n(/\(/g) === n(/\)/g);
154
+ };
155
+
156
+ /** A line that can be removed on its own: balanced, and either comma-terminated or followed by the closer. */
157
+ function removableAlone(lines, i) {
158
+ const line = lines[i];
159
+ if (!balanced(line)) return false;
160
+ if (/,\s*$/.test(line)) return true;
161
+ for (let j = i + 1; j < lines.length; j++) {
162
+ if (!lines[j].trim()) continue;
163
+ return /^\s*[}\])]/.test(lines[j]);
164
+ }
165
+ return false;
166
+ }
167
+
168
+ function fieldDropProposals(lines, i) {
169
+ const line = lines[i];
170
+ const out = [];
171
+ const opener = openerIndex(lines)[i];
172
+ const openerLine = opener ? lines[opener.line] : '';
173
+ const inPayload = opener?.char === '{' && PAYLOAD_OPENER.test(openerLine) && !TYPE_OPENER.test(openerLine);
174
+ const inAllowList = opener?.char === '[' && ALLOWLIST_OPENER.test(openerLine);
175
+
176
+ if (inPayload && !METHOD_LINE.test(line) && removableAlone(lines, i)) {
177
+ const schema = /\b(?:z|yup|joi|v|t)\.|\.(?:object|shape|extend|merge|pick|omit)\s*\(|\bobject\s*\(/i.test(openerLine);
178
+ if (SPREAD_LINE.test(line)) {
179
+ out.push({ faultClass: 'field-dropped', description: `Base object dropped from the merge: \`${line.trim()}\` no longer contributes its fields.`, replace: '' });
180
+ } else if (PROP_LINE.test(line) || SHORTHAND_LINE.test(line)) {
181
+ const name = line.trim().replace(/[,:].*$/, '').replace(/^['"[]|['"\]]$/g, '');
182
+ out.push({ faultClass: 'field-dropped', description: schema ? `Field dropped from the schema: \`${name}\` is no longer validated or serialised.` : `Field dropped: \`${name}\` is no longer written.`, replace: '' });
183
+ }
184
+ } else if (inAllowList && STRING_ENTRY.test(line) && removableAlone(lines, i)) {
185
+ out.push({ faultClass: 'field-dropped', description: `Field dropped from the allow-list: ${line.trim().replace(/,\s*$/, '')} is no longer permitted.`, replace: '' });
186
+ }
187
+
188
+ const merge = INLINE_SPREAD_MERGE.exec(line);
189
+ if (merge) {
190
+ out.push({ faultClass: 'field-dropped', description: `Base object dropped from the inline merge: \`...${merge[1]}\` no longer contributes its fields.`, replace: line.replace(merge[0], '{ ') });
191
+ }
192
+ return out;
193
+ }
194
+
195
+ function jsxProposals(lines, i) {
196
+ const line = lines[i];
197
+ const out = [];
198
+ if (JSX_SELF_CLOSING.test(line) || JSX_PAIRED_LINE.test(line)) {
199
+ const tag = /<([A-Za-z][\w.-]*)/.exec(line)[1];
200
+ out.push({ faultClass: 'element-removed', description: `Element removed: \`<${tag}>\` is no longer rendered.`, replace: '' });
201
+ }
202
+ if (JSX_HANDLER_PROP_LINE.test(line)) {
203
+ const name = /on[A-Z]\w*/.exec(line)[0];
204
+ out.push({ faultClass: 'handler-dropped', description: `Handler dropped: \`${name}\` is no longer wired.`, replace: '' });
205
+ } else {
206
+ const m = JSX_INLINE_HANDLER.exec(line);
207
+ if (m && /<[A-Za-z]/.test(line)) {
208
+ const name = /on[A-Z]\w*/.exec(m[0])[0];
209
+ out.push({ faultClass: 'handler-dropped', description: `Handler dropped: \`${name}\` is no longer wired.`, replace: line.replace(m[0], '') });
210
+ }
211
+ }
212
+ return out;
213
+ }
214
+
215
+ function isDerived(arg, params) {
216
+ const idents = arg.match(/[\w$]+/g) ?? [];
217
+ if (idents.some((id) => params.has(id))) return true;
218
+ const head = /^([\w$]+)/.exec(arg)?.[1] ?? '';
219
+ return REQUEST_LIKE.test(head) || arg === 'this' || /^this[.?]/.test(arg);
220
+ }
221
+
222
+ function argumentSwapProposals(lines, i, params) {
223
+ const line = lines[i];
224
+ const out = [];
225
+ const head = functionHead(line);
226
+ const seen = new Set();
227
+ for (const m of line.matchAll(CALL_WITH_ARG)) {
228
+ const [whole, callee, arg] = m;
229
+ if (head && callee.split('.').pop() === head) continue; // the head of a declaration, not a call
230
+ if (/^(?:'|"|`|\d|true$|false$|null$|undefined$)/.test(arg)) continue;
231
+ if (!isDerived(arg, params)) continue;
232
+ if (seen.has(whole)) continue;
233
+ seen.add(whole);
234
+ const swap = (value) => line.replace(whole, `${callee}(${value}`);
235
+ out.push({ faultClass: 'argument-swapped', description: `Argument swapped: \`${callee}(${arg}, …)\` is called with \`undefined\` instead of the derived value.`, replace: swap('undefined') });
236
+ if (/\(/.test(arg)) out.push({ faultClass: 'argument-swapped', description: `Argument swapped: \`${callee}(${arg}, …)\` is called with an empty object instead of the derived value.`, replace: swap('{}') });
237
+ }
238
+ return out;
239
+ }
240
+
241
+ /**
242
+ * Proposals for one line. Each: { faultClass, description, replace } where
243
+ * find is the line itself. `ctx.params` is the enclosing function's parameter
244
+ * set (for argument-swapped); `ctx.fieldDrops = false` disables the
245
+ * data/UI shapes — field-dropped, element-removed, handler-dropped — for
246
+ * test files, fixtures and migrations, where an object or element is data,
247
+ * not the product's output.
248
+ */
249
+ export function proposalsForLine(lines, i, ctx = {}) {
46
250
  const line = lines[i];
47
251
  const out = [];
48
252
  if (!line.trim() || COMMENT.test(line)) return out;
253
+ const params = ctx.params ?? new Set();
49
254
 
50
255
  const ifm = IF_LINE.exec(line);
51
256
  if (ifm && isGuard(ifm[2], lines, i)) {
@@ -81,5 +286,11 @@ export function proposalsForLine(lines, i) {
81
286
  } else if ((m = WINDOW_NUM.exec(line))) {
82
287
  out.push({ faultClass: 'literal-changed', description: `Window widened ×1000: \`${m[1]}\` ${m[3]} becomes ${Number(m[3]) * 1000}.`, replace: line.replace(m[0], `${m[1]}${m[2] === ':' ? ': ' : ' = '}${Number(m[3]) * 1000}`) });
83
288
  }
289
+
290
+ if (ctx.fieldDrops !== false) {
291
+ out.push(...fieldDropProposals(lines, i));
292
+ out.push(...jsxProposals(lines, i));
293
+ }
294
+ out.push(...argumentSwapProposals(lines, i, params));
84
295
  return out;
85
296
  }