@abloh/core 0.1.3 → 1.0.1

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.
@@ -0,0 +1,714 @@
1
+ import ts from 'typescript';
2
+ export { default as ts } from 'typescript';
3
+
4
+ /**
5
+ * Shared TypeScript-AST utility (Extensions 2 + 5). Regex cannot safely reason about TSX,
6
+ * template literals, nested braces, comments, or modern syntax, so both the error-handler scanner
7
+ * (Extension 5) and the tautological-assertion scanner (Extension 2) parse with the real compiler.
8
+ *
9
+ * Parsing is best-effort and NEVER throws: `createSourceFile` recovers from syntax errors, so we
10
+ * surface a `parseOk` flag from the parser's own diagnostics. Callers MUST degrade to a
11
+ * `partial`/`unavailable` result on `parseOk === false` rather than reporting a clean empty scan —
12
+ * a file we could not fully parse must not read as "no findings".
13
+ */
14
+
15
+ interface ParsedSource {
16
+ sourceFile: ts.SourceFile;
17
+ /** false when the parser recorded syntax errors — treat the scan of this file as partial */
18
+ parseOk: boolean;
19
+ }
20
+ /** Parse TS/TSX/JS/JSX source into a SourceFile with parent pointers set. Never throws. */
21
+ declare function parseSource(fileName: string, source: string): ParsedSource;
22
+ /** 1-based source line of a character position. */
23
+ declare function lineOf(sourceFile: ts.SourceFile, pos: number): number;
24
+ /**
25
+ * True when the text span of `node` contains a real COMMENT token (single- or multi-line) whose
26
+ * text matches `pattern`. Uses the scanner (not a regex over source) so a `TODO` inside a string
27
+ * literal or identifier is never mistaken for a comment.
28
+ */
29
+ declare function commentInNodeMatches(node: ts.Node, sourceFile: ts.SourceFile, source: string, pattern: RegExp): boolean;
30
+
31
+ interface MutationRecipe {
32
+ /** workdir-relative file the edit targets */
33
+ file: string;
34
+ /** UTF-16 code-unit offsets of the span to replace, half-open [startOffset, endOffset) */
35
+ startOffset: number;
36
+ endOffset: number;
37
+ /** the exact source slice at [startOffset,endOffset) when the recipe was built (drift guard) */
38
+ original: string;
39
+ /** the replacement text */
40
+ replacement: string;
41
+ /** sha256 of the whole file source when the recipe was built (drift guard) */
42
+ sourceDigest: string;
43
+ /** stable content-hash id — independent of LLM/report order (dedup + selection key) */
44
+ id: string;
45
+ /** realistic-mutant category, e.g. "missing-await" (presentational + composition metadata) */
46
+ category?: string;
47
+ }
48
+ declare function sha256(text: string): string;
49
+ /**
50
+ * Line/column → UTF-16 offset. `line` is 1-based; `column` is 1-based (the mutation-testing report
51
+ * schema convention). `\n` is the line separator, so a `\r` in a CRLF file stays as the last code
52
+ * unit of its line and is counted by the column offset — the recipe operates on the exact bytes,
53
+ * CRLF included. Out-of-range coordinates clamp to the source length.
54
+ */
55
+ declare function offsetOf(source: string, line: number, column: number): number;
56
+ /**
57
+ * Resolve a 1-based (line, column) to an offset that provably lies ON that line, or null.
58
+ *
59
+ * `offsetOf` clamps only against the whole file, so a column past the end of its line silently walks
60
+ * into LATER lines. Scope validation checks a proposal's line numbers, so that clamping is a scope
61
+ * escape: a proposal declaring a changed line can land on unchanged code. Out-of-range coordinates
62
+ * are therefore REJECTED here rather than clamped.
63
+ */
64
+ declare function strictOffsetOf(source: string, line: number, column: number): number | null;
65
+ /** Build a recipe's stable content-hash id from its source-derived fields (order-independent). */
66
+ declare function recipeId(file: string, startOffset: number, endOffset: number, original: string, replacement: string): string;
67
+ /**
68
+ * Build a recipe from 1-based line/column span coordinates. The BOM (if present) is part of the
69
+ * source and counted in offsets, so digests + slices stay exact.
70
+ */
71
+ declare function recipeFromSpan(file: string, source: string, span: {
72
+ startLine: number;
73
+ startColumn: number;
74
+ endLine: number;
75
+ endColumn: number;
76
+ }, replacement: string, category?: string): MutationRecipe;
77
+ /**
78
+ * Span → recipe, or null when the coordinates do not lie within their declared lines. Callers
79
+ * handling UNTRUSTED coordinates (model proposals) must use this and reject the null rather than
80
+ * let an out-of-line column clamp into unchanged code — see {@link strictOffsetOf}.
81
+ */
82
+ declare function tryRecipeFromSpan(file: string, source: string, span: {
83
+ startLine: number;
84
+ startColumn: number;
85
+ endLine: number;
86
+ endColumn: number;
87
+ }, replacement: string, category?: string): MutationRecipe | null;
88
+ /**
89
+ * Build a recipe directly from UTF-16 offsets (half-open [startOffset, endOffset)). Used where a
90
+ * span is already known in offset form (e.g. AST node bounds) so no line/column round-trip is needed.
91
+ */
92
+ declare function recipeFromOffsets(file: string, source: string, startOffset: number, endOffset: number, replacement: string, category?: string): MutationRecipe;
93
+ type ApplyResult = {
94
+ ok: true;
95
+ result: string;
96
+ } | {
97
+ ok: false;
98
+ reason: "source-drift" | "slice-mismatch" | "bad-span";
99
+ };
100
+ /**
101
+ * Materialize a recipe against `source`, returning the mutated text. Fails closed on any drift:
102
+ * a changed file (digest mismatch), a moved span (original-slice mismatch), or an inverted span.
103
+ */
104
+ declare function applyRecipe(source: string, recipe: MutationRecipe): ApplyResult;
105
+ /**
106
+ * Make a file path safe to use as a Stryker `mutate` entry that carries a line range.
107
+ *
108
+ * Stryker validates a ranged entry with `new Minimatch(entry).hasMagic()` and REFUSES it when the
109
+ * pattern looks like a glob — "Cannot combine a glob expression with a mutation range". A Next.js
110
+ * App Router directory is literally named `[workspaceId]`, so every changed file under a dynamic
111
+ * route made the whole mutation run exit 1 before a single mutant was generated. It is not a rare
112
+ * shape: it took one real repository to hit it across 50+ files at once.
113
+ *
114
+ * Backslash-escaping the magic characters clears `hasMagic()` while still matching the literal
115
+ * path, which is the behaviour Minimatch documents and what the fix is verified against.
116
+ */
117
+ declare function escapeMutatePath(path: string): string;
118
+
119
+ /**
120
+ * Normalized result contract `attest-results/v2` (build plan §4.2.1).
121
+ *
122
+ * Two-layer rule: the producer's own report, in whichever dialect it writes, is preserved verbatim
123
+ * as evidence; THIS schema is what every downstream consumer reads. `@abloh/measure` maps a raw
124
+ * report to this one and the mutation engine's seam produces it directly; a future producer
125
+ * implements the same mapping. We never extend or mutate someone else's schema.
126
+ */
127
+
128
+ type HandlerAntiPatternKind = "empty-catch" | "catch-all-abort" | "todo-in-handler";
129
+ interface HandlerFinding {
130
+ file: string;
131
+ startLine: number;
132
+ endLine: number;
133
+ kind: HandlerAntiPatternKind;
134
+ }
135
+
136
+ interface HandlerSpan {
137
+ file: string;
138
+ startLine: number;
139
+ endLine: number;
140
+ }
141
+ interface FileHandlerScan {
142
+ antiPatterns: HandlerFinding[];
143
+ /** every changed catch-handler span (mutant classification keys off these later) */
144
+ changedHandlers: HandlerSpan[];
145
+ /** false when the file did not parse cleanly — caller must treat the scan as partial */
146
+ parseOk: boolean;
147
+ }
148
+ /** Scan one file's source for changed-handler anti-patterns. `changedLines` are 1-based. */
149
+ declare function scanFileHandlers(fileName: string, source: string, changedLines: ReadonlySet<number>): FileHandlerScan;
150
+ /**
151
+ * Materialize deterministic FORCED handler mutations (Extension 5 reserve). For each changed catch
152
+ * clause we neuter its FIRST executable statement (`stmt` → `;`), a real handler-behavior change
153
+ * that the carrier smuggles through Stryker so the handler is always probed even when the sampler
154
+ * would not have reached it. No LLM — purely structural, so it runs at every tier. Empty handlers
155
+ * and handlers whose first statement is not carriable are skipped (nothing to force). Deterministic
156
+ * ordering by (file, offset); a file that does not parse yields no recipes.
157
+ */
158
+ declare function forcedHandlerRecipes(fileName: string, source: string, changedLines: ReadonlySet<number>): MutationRecipe[];
159
+ interface HandlerScanInput {
160
+ file: string;
161
+ source: string;
162
+ /** 1-based changed lines for this file (from diff-scope inspectionScopes) */
163
+ changedLines: ReadonlySet<number>;
164
+ }
165
+ interface ErrorHandlerScan {
166
+ antiPatterns: HandlerFinding[];
167
+ changedHandlers: HandlerSpan[];
168
+ quality: "complete" | "partial";
169
+ }
170
+ /** Scan several files; a single unparseable file downgrades overall `quality` to `partial`. */
171
+ declare function scanErrorHandlers(inputs: readonly HandlerScanInput[]): ErrorHandlerScan;
172
+
173
+ /**
174
+ * Tautological-assertion scanner (Extension 2). Conservative and high-confidence: it flags a test
175
+ * ONLY when every assertion it makes is a recognized self-confirming form (`assert.equal(x, x)`,
176
+ * `expect(x).toBe(x)`, `assert(true)`, …) AND no unrecognized assertion shape appears — so a test
177
+ * doing real work is never mislabeled. Assertion SOURCE is never emitted; only the canonical test
178
+ * identity (`<file>::<fullName>`) is returned, and the CLI reduces even that to a digest at egress.
179
+ *
180
+ * Uses the shared TS-AST utility (a regex cannot tell a `TODO` in a comment from one in a string,
181
+ * nor `toBe(x)` on `x` from `toBe(y)`). A file that fails to parse downgrades quality to `partial`.
182
+ */
183
+
184
+ interface TautologyScanInput {
185
+ /** test file path (report key) */
186
+ file: string;
187
+ source: string;
188
+ }
189
+ interface TautologyResult {
190
+ /** canonical `<file>::<fullName>` of tests whose every recognized assertion is tautological */
191
+ tautologicalTests: string[];
192
+ quality: "complete" | "partial";
193
+ }
194
+ /** Exported so corpus mining derives fullNames by the SAME rule the runner matches on. */
195
+ declare const TEST_FNS: Set<string>;
196
+ declare const DESCRIBE_FNS: Set<string>;
197
+ /**
198
+ * The base test/describe identifier for a call, following modifier chains, or null.
199
+ *
200
+ * `it("…")` is an identifier call, but `it.only("…")`, `test.each(table)("…")` and
201
+ * `describe.concurrent("…")` are property-access (and, for `each`, call) chains. Recognizing only
202
+ * the bare identifiers meant modified tests were never scanned at all — while the scan still
203
+ * reported `complete` quality, which overstates coverage of the file.
204
+ */
205
+ /** Is this a parameterized declaration (`test.each(...)`) whose real test names we cannot know? */
206
+ declare function isParameterized(expr: ts.Expression): boolean;
207
+ declare function testCallName(expr: ts.Expression): string | null;
208
+ declare function stringArg(call: ts.CallExpression): string | null;
209
+ /**
210
+ * Scan test files for fully-tautological tests. One unparseable file → `quality: "partial"`.
211
+ *
212
+ * SCANNING NOTHING IS NOT SCANNING CLEANLY. `quality` began as `"complete"` and was only ever
213
+ * downgraded inside the loop, so an empty input list — a runner whose report carries no test
214
+ * sources, coverage switched off, a command runner — returned `{ tautologicalTests: [], quality:
215
+ * "complete" }`. That is a positive claim: we read your suite and found no test whose every
216
+ * assertion is a tautology. We had read nothing. An empty input is reported `partial`, which is the
217
+ * existing word for "this scan did not cover everything".
218
+ */
219
+ declare function scanTautologies(inputs: readonly TautologyScanInput[], workDir?: string): TautologyResult;
220
+
221
+ /** How the mutated code can be reached from outside its module. */
222
+ type Reachability =
223
+ /** the function holding the mutation is itself exported */
224
+ "exported-directly"
225
+ /** private, but an exported symbol calls it (see `entryPoints`) */
226
+ | "reachable-via"
227
+ /** private, and nothing exported in this file reaches it — the decline signal */
228
+ | "unreachable"
229
+ /** we could not tell; callers must NOT decline on this */
230
+ | "unknown";
231
+ interface ParameterInfo {
232
+ name: string;
233
+ optional: boolean;
234
+ /** the type as WRITTEN (`Packet`, `{ a: number }`, `string[]`), or null when untyped */
235
+ type: string | null;
236
+ rest: boolean;
237
+ }
238
+ interface EntryPoint {
239
+ /** exported symbol name, or "default" */
240
+ name: string;
241
+ kind: "function" | "method" | "variable" | "default";
242
+ /** rendered from the syntax, e.g. `tableBlock(packet: Packet, view: View): Block` */
243
+ signature: string;
244
+ parameters: ParameterInfo[];
245
+ returnType: string | null;
246
+ isAsync: boolean;
247
+ /** ["tableBlock", "buildFlatTable"] — first is the export, last holds the mutation */
248
+ path: string[];
249
+ /** the source line of each call along the path (one fewer than `path`) */
250
+ callSites: string[];
251
+ /** distinct type names named in the signature — what `collectTypeContext` resolves */
252
+ typeNames: string[];
253
+ }
254
+ interface EnclosingFunction {
255
+ /** `buildFlatTable`, `Class.method`, or null when anonymous */
256
+ name: string | null;
257
+ /** the FULL declaration text including leading JSDoc — not a character window */
258
+ text: string;
259
+ startLine: number;
260
+ endLine: number;
261
+ exported: boolean;
262
+ /**
263
+ * Type names in THIS function's own signature.
264
+ *
265
+ * Separate from the entry point's, and both are needed. A private worker is usually called with
266
+ * values the entry point derives rather than with its own arguments — `tableBlock(packet, view)`
267
+ * calls `buildFlatTable(columns: Column[], …)`, and `Column` appears in no entry signature. On a
268
+ * real run the model therefore never saw `Column`, passed plain strings where `{ key }` objects
269
+ * were required, and the test failed identically on real and mutated code.
270
+ */
271
+ typeNames: string[];
272
+ }
273
+ interface ExportSurface {
274
+ /** sorted export names — byte-identical to the CLI's previous listModuleExports */
275
+ named: string[];
276
+ hasDefault: boolean;
277
+ /** name → declaration, so a signature can be read. `named` never depends on this. */
278
+ declarations: ReadonlyMap<string, ts.Declaration>;
279
+ }
280
+ interface ReachabilityAnalysis {
281
+ /** "unavailable" ⇔ the file did not parse, or the offsets landed in no node */
282
+ quality: "complete" | "unavailable";
283
+ reachability: Reachability;
284
+ enclosing: EnclosingFunction | null;
285
+ /** shortest paths first, then lexicographic — deterministic across runs */
286
+ entryPoints: EntryPoint[];
287
+ exportSurface: ExportSurface;
288
+ /** module specifiers this file imports that name a known external service */
289
+ serviceImports: string[];
290
+ }
291
+ /**
292
+ * Every node containing [start, end], smallest span first. Lifted from carrier.ts, which had it
293
+ * privately; that file now imports it so the two can never drift.
294
+ */
295
+ declare function enclosingNodes(sf: ts.SourceFile, start: number, end: number): ts.Node[];
296
+ /**
297
+ * A module's export surface.
298
+ *
299
+ * `named` and `hasDefault` are byte-identical to the CLI's previous `listModuleExports`, which is
300
+ * load-bearing rather than tidy: the invariant engine hashes that list into its cache key, so a
301
+ * change of even one name silently invalidates every cached proposal. `declarations` is additive —
302
+ * the old function returned strings and discarded the node, which made it impossible to read a
303
+ * signature back off an export.
304
+ */
305
+ declare function moduleExportSurface(sourceFile: ts.SourceFile): ExportSurface;
306
+ interface AnalyzeInput {
307
+ fileName: string;
308
+ source: string;
309
+ /** byte offsets of the mutated span */
310
+ startOffset: number;
311
+ endOffset: number;
312
+ maxEntryPoints?: number;
313
+ maxDepth?: number;
314
+ }
315
+ /**
316
+ * Analyze how (and whether) a test can reach the mutated span. Never throws; see the module header
317
+ * for the degradation contract that callers must honor.
318
+ */
319
+ declare function analyzeReachability(input: AnalyzeInput): ReachabilityAnalysis;
320
+ interface TypeContextInput {
321
+ fileName: string;
322
+ source: string;
323
+ names: readonly string[];
324
+ /** injected so this stays pure and unit-testable; the CLI passes a sandbox-scoped reader */
325
+ readFile: (absPath: string) => string | null;
326
+ maxChars?: number;
327
+ }
328
+ /**
329
+ * The TEXT of the type declarations a signature names — the difference between a model knowing a
330
+ * `Packet` exists and being able to construct one.
331
+ *
332
+ * Local declarations first, then ONE hop through this file's own relative imports. One hop, not
333
+ * transitive: it bounds both the cost and the prompt, and a type two modules away is rarely the
334
+ * one the entry point takes. Anything unresolved is reported by name so the prompt can tell the
335
+ * model to build a minimal object and cast it, rather than leaving it to guess in silence.
336
+ */
337
+ declare function collectTypeContext(input: TypeContextInput): {
338
+ text: string;
339
+ resolved: string[];
340
+ unresolved: string[];
341
+ };
342
+
343
+ /**
344
+ * How several survivors in one place are presented as ONE finding.
345
+ *
346
+ * Declared here, in core, because it is a config value the engine reads — the engine depends on
347
+ * core, never the reverse, so the closed set has to live on this side of that edge.
348
+ *
349
+ * - `line` — group by source line. Wrong in both directions: two operands of one `if` on
350
+ * separate lines render as two gaps, while two unrelated statements sharing a
351
+ * line render as one. Retained ONLY because the hosted control plane never
352
+ * receives source and so cannot do better.
353
+ * - `structural` — group by the smallest enclosing STATEMENT, with containment capped so a
354
+ * whole-function survivor cannot absorb everything inside it. A fact about the
355
+ * syntax tree, so no merge can be wrong. Fixes both of line's errors. THE DEFAULT.
356
+ *
357
+ * TWO STRATEGIES WERE REMOVED, both on measurement rather than taste:
358
+ *
359
+ * - `data-flow` merged clusters sharing a resolved binding inside one function. On 189 real
360
+ * survivors across 17 files it produced **2 merges beyond structural**, in 2 files. A setting
361
+ * that changes nothing is worse than an absent one, because someone eventually enables it and
362
+ * wonders why their output is identical. The scope resolver it was built on REMAINS — see
363
+ * `core/src/source-analysis/scope.ts`; the `wrong-variable` mutator depends on it to avoid substituting
364
+ * a name that is not in scope, and nothing else can tell two same-named variables apart.
365
+ * - `semantic` asked a model to merge. gpt-5.6-terra at `xhigh` also managed **2 merges** on the
366
+ * same corpus — a mechanical analyser and the best available model independently agreeing there is
367
+ * nothing to merge beyond the syntax tree.
368
+ *
369
+ * Every strategy keeps each mutant individually in the artifact and displays each cluster's member
370
+ * count, so no signed number depends on this setting.
371
+ */
372
+ declare const CLUSTER_STRATEGIES: readonly ["line", "structural"];
373
+ type ClusterStrategy = (typeof CLUSTER_STRATEGIES)[number];
374
+
375
+ /** One mutant, as the clusterer needs it. A subset of `GapFinding` — no triage, no status. */
376
+ interface ClusterInput {
377
+ mutantId: string;
378
+ file: string;
379
+ startLine: number;
380
+ endLine: number;
381
+ mutator: string;
382
+ /** 1-based, optional — absent on historical artifacts */
383
+ startColumn?: number;
384
+ endColumn?: number;
385
+ /** the exact source slice this mutant replaced; used to VERIFY the span, never to render */
386
+ originalText?: string;
387
+ }
388
+ interface Cluster {
389
+ /** stable id, derived from the first member — matches the previous `mutant:<id>` convention */
390
+ id: string;
391
+ file: string;
392
+ startLine: number;
393
+ endLine: number;
394
+ /** every mutant in this cluster. `members.length` is the count a surface must display. */
395
+ members: ClusterInput[];
396
+ mutators: string[];
397
+ /**
398
+ * The strategy that actually decided THIS cluster. It can be `line` even when a different
399
+ * strategy was requested — see {@link ClusterResult.degraded}. Recording it per cluster is the
400
+ * point: a run must never present a fallback as if it were the thing that was asked for.
401
+ */
402
+ by: ClusterStrategy;
403
+ }
404
+ interface ClusterResult {
405
+ clusters: Cluster[];
406
+ /** the strategy that was requested */
407
+ requested: ClusterStrategy;
408
+ /**
409
+ * Mutants that fell back to line grouping because their span could not be located.
410
+ *
411
+ * Reported, never swallowed. A stage that silently degrades and renders its own failure as a fact
412
+ * about the customer's code is the defect class this repo spent a week removing; a clusterer that
413
+ * quietly grouped by line while claiming to group structurally would be another instance of it.
414
+ */
415
+ degraded: {
416
+ mutantId: string;
417
+ reason: string;
418
+ }[];
419
+ }
420
+ /** Today's rule, kept verbatim so the hosted path can call it instead of re-implementing it. */
421
+ declare function lineKey(m: {
422
+ file: string;
423
+ startLine: number;
424
+ endLine: number;
425
+ }): string;
426
+ /**
427
+ * Group mutants into clusters.
428
+ *
429
+ * `sources` maps a workdir-relative file path to its current source. A file that is missing from
430
+ * the map is not an error — its mutants fall back to line grouping and are reported in `degraded`.
431
+ */
432
+ declare function clusterMutants(mutants: readonly ClusterInput[], sources: ReadonlyMap<string, string>, strategy: ClusterStrategy): ClusterResult;
433
+
434
+ /**
435
+ * Lexical scope resolution — which DECLARATION does this identifier refer to.
436
+ *
437
+ * Everything else in this engine answers that question by comparing `Identifier.text` against a
438
+ * flat, file-global namespace. `buildCallGraph` keys functions by bare name and the first
439
+ * declaration wins, so two same-named functions collapse into one node. `localNode` looks only at
440
+ * top-level statements. `referencedElsewhere` counts any identifier whose text happens to match.
441
+ * `ts.isBlock` appears nowhere in the repo. That is fine for the heuristics those functions feed —
442
+ * they widen a search or add a call edge, and a spurious match costs recall, not correctness.
443
+ *
444
+ * It is NOT fine for finding clustering, where the question is "do these two mutants touch the same
445
+ * data". There, matching on spelling merges
446
+ *
447
+ * const total = a + b; // mutant 1
448
+ * { const total = c + d; } // mutant 2 — a DIFFERENT variable
449
+ *
450
+ * into one finding, and one of the two mutants stops being visible as its own gap. A merge that
451
+ * hides a gap is the one failure this product cannot ship: everywhere else in the pipeline we spent
452
+ * the week removing places where our own silence was rendered as a fact about the customer.
453
+ *
454
+ * So this module resolves properly. SYNTAX ONLY, like the rest of the engine — no `ts.Program`, no
455
+ * `TypeChecker`, no customer tsconfig, no module resolution. A scope chain built from the syntax
456
+ * tree answers shadowing exactly; what it cannot answer, it declines by returning null.
457
+ *
458
+ * The bias is deliberate and one-directional: an identifier we cannot resolve merges with NOTHING.
459
+ * Unresolved is never treated as "matches everything", so every failure of this module costs the
460
+ * author an extra finding, never a missing one.
461
+ */
462
+
463
+ /** How a name was introduced. `var` and `function` hoist to the function scope; the rest do not. */
464
+ type BindingKind = "const" | "let" | "var" | "param" | "function" | "class" | "import" | "catch";
465
+ interface Binding {
466
+ name: string;
467
+ /**
468
+ * The declaration node — this is the IDENTITY. Two identifiers refer to the same thing when they
469
+ * resolve to the same node, never when they merely spell the same. Destructuring gives each bound
470
+ * name its own `BindingElement`, so `const { a, b } = x` yields two distinct identities.
471
+ */
472
+ decl: ts.Declaration;
473
+ kind: BindingKind;
474
+ }
475
+ interface Scope {
476
+ parent: Scope | null;
477
+ /** var/function declarations hoist past block scopes to the nearest scope with this set */
478
+ isFunctionScope: boolean;
479
+ bindings: Map<string, Binding>;
480
+ }
481
+ /** The scope table for one file: every scope-opening node mapped to its bindings. */
482
+ interface ScopeTable {
483
+ byNode: Map<ts.Node, Scope>;
484
+ sourceFile: ts.SourceFile;
485
+ /**
486
+ * Memo for {@link isReassigned}, which walks the whole file per query. Clustering asks about the
487
+ * same handful of bindings once per mutant, so without this a file with 200 survivors re-walks
488
+ * its own AST hundreds of times.
489
+ */
490
+ reassigned: Map<ts.Declaration, boolean>;
491
+ }
492
+ /**
493
+ * Build the scope chain for a file.
494
+ *
495
+ * One pass. Declarations are routed to the scope that actually owns them: a `var` or function
496
+ * declaration walks out to the nearest function scope, everything else lands in the innermost
497
+ * scope. A function's NAME belongs to the enclosing scope while its PARAMETERS belong to its own —
498
+ * getting that backwards is what makes a parameter appear to shadow nothing.
499
+ */
500
+ declare function buildScopes(sourceFile: ts.SourceFile): ScopeTable;
501
+ /**
502
+ * Resolve an identifier to the declaration it names, or null.
503
+ *
504
+ * Null means "we could not tell", and every caller must treat it as "matches nothing" rather than
505
+ * "matches anything" — see the module header. Null is returned for globals, for names imported via
506
+ * paths we do not follow, and for identifiers in a naming rather than referencing position.
507
+ */
508
+ declare function resolveBinding(table: ScopeTable, id: ts.Identifier): ts.Declaration | null;
509
+ /**
510
+ * Is this binding ever REASSIGNED anywhere in the file?
511
+ *
512
+ * This is what makes alias-following sound. `const b = a` says `b` and `a` are the same value —
513
+ * but only while `a` cannot change under it:
514
+ *
515
+ * let a = 1; const b = a; a = 2; // b is a SNAPSHOT, not an alias
516
+ *
517
+ * Merging findings on `b` with findings on `a` there would be a genuine over-merge, the one
518
+ * direction that can hide a gap. So the alias chain refuses to follow whenever either end is
519
+ * reassigned.
520
+ *
521
+ * Assignment to a PROPERTY (`obj.x = 1`) is not reassignment of `obj` — the binding still names the
522
+ * same object — so only a bare identifier on the left counts.
523
+ */
524
+ declare function isReassigned(table: ScopeTable, decl: ts.Declaration): boolean;
525
+ /**
526
+ * Resolve an identifier, then follow `const b = a` alias chains to the ORIGINAL binding.
527
+ *
528
+ * Without this, `b.total` and `a.total` cluster separately even though they are the same data —
529
+ * an under-merge, harmless but avoidable. With it they cluster together, and the reassignment gate
530
+ * above keeps the merge sound.
531
+ *
532
+ * ONLY a bare identifier initializer is followed. `const b = a.x` is deliberately NOT followed:
533
+ * `b` and `a` are different data (the second is a whole object, the first one of its properties),
534
+ * so resolving `b` to `a`'s declaration would merge two findings that are not the same gap. That
535
+ * case is an under-merge and stays one. Calls, `await` and `new` are never followed for the same
536
+ * reason — their result is a new value, not the named one.
537
+ */
538
+ declare function resolveThroughAliases(table: ScopeTable, id: ts.Identifier): ts.Declaration | null;
539
+ /**
540
+ * Every name in scope at a position, as a set.
541
+ *
542
+ * Used by the `wrong-variable` mutator, which today collects every identifier in the FILE and
543
+ * substitutes one, reasoning that "an invented name would be a compile error". A real name that is
544
+ * not in scope at the mutation site is equally a compile error — those mutants consume a run slot
545
+ * and come back `build-error`, which is wasted budget, not evidence.
546
+ */
547
+ declare function namesInScopeAt(table: ScopeTable, node: ts.Node): Set<string>;
548
+
549
+ declare const SAMPLING_ALGORITHM = "line-first-rr-v1";
550
+ /**
551
+ * One enumerated (not yet run) mutant. Lines are 1-BASED here (matching FileScope, git diffs, and
552
+ * the human-facing config strings); columns are 0-based babel columns, end exclusive.
553
+ *
554
+ * CONVENTION MAP (each verified in the respective package's source, not assumed):
555
+ * - config string "file.js:5:4-6:4": 1-based lines, 0-based columns (core project-reader.js
556
+ * parses with parseInt(line) - 1 and passes columns through).
557
+ * - FileDescription.mutate: 0-based lines (instrumenter's toBabelLineNumber ADDS 1 for babel).
558
+ * - api Mutant.location (what instrument() returns): 0-based lines, 0-based columns.
559
+ * This module converts at its boundaries so callers only ever see 1-based lines.
560
+ */
561
+ interface EnumeratedMutant {
562
+ /** workdir-relative file (posix separators) */
563
+ file: string;
564
+ mutatorName: string;
565
+ startLine: number;
566
+ startColumn: number;
567
+ endLine: number;
568
+ endColumn: number;
569
+ /**
570
+ * The mutated text this mutant substitutes.
571
+ *
572
+ * Load-bearing for IDENTITY, not decoration: file + span + operator is NOT unique — one operator
573
+ * routinely emits several mutants at the same span (measured on a real 315-mutant file: 57 of
574
+ * them shared a span+operator with another, so keying without the replacement collapsed 315
575
+ * distinct mutants into 258 and made the executed-set accounting undercount). Optional because a
576
+ * mutant may legitimately carry no replacement text.
577
+ */
578
+ replacement?: string;
579
+ }
580
+ interface SamplePlan {
581
+ algorithm: typeof SAMPLING_ALGORITHM;
582
+ seed: string;
583
+ cap: number;
584
+ /** every mutant the changed ranges allow (the honest denominator for disclosure) */
585
+ eligible: number;
586
+ /** the picked subset — the mutants this plan deliberately selected */
587
+ sampled: EnumeratedMutant[];
588
+ /**
589
+ * Every mutant that will ACTUALLY RUN: the picks plus their containment closure.
590
+ *
591
+ * A Stryker `mutate` entry is a RANGE, so selecting a mutant also selects every mutant nested
592
+ * inside its span (an arrow-function pick drags in each mutant in its body). Before this was
593
+ * charged at plan time a cap of 500 executed 717 mutants and the run summary contradicted
594
+ * itself. The budget is charged against this set, so `executed.length <= cap` holds.
595
+ */
596
+ executed: EnumeratedMutant[];
597
+ /** changed lines bearing at least one eligible mutant */
598
+ linesEligible: number;
599
+ /** lines with at least one EXECUTED mutant — breadth, the disclosed metric */
600
+ linesProbed: number;
601
+ /**
602
+ * Picks skipped because their containment closure alone exceeded the remaining budget. Disclosed
603
+ * rather than silently dropped: a large pick that never fits means those lines were probed only
604
+ * by smaller mutants, or not at all.
605
+ */
606
+ skippedOversizeClosures: number;
607
+ }
608
+ /**
609
+ * Stable identity of an enumerated mutant: file + exact span + operator + REPLACEMENT.
610
+ * The replacement is part of the identity because one operator emits several mutants at the same
611
+ * span (see {@link EnumeratedMutant.replacement}); dropping it silently merges distinct mutants.
612
+ */
613
+ declare const mutantKey: (m: EnumeratedMutant) => string;
614
+ /**
615
+ * Line-first round-robin sample of `cap` mutants. Line order and the within-line order are both
616
+ * seeded-deterministic; round k takes each line's (k+1)-th mutant while budget remains, so every
617
+ * mutant-bearing line is probed once before any line is probed twice.
618
+ */
619
+ declare function lineFirstSample(mutants: readonly EnumeratedMutant[], cap: number, seed: string): SamplePlan;
620
+ /**
621
+ * Express the picks as Stryker `mutate` entries, column-precise, one per sampled mutant
622
+ * ("src/a.js:5:4-5:12"). Stryker's range columns are the instrumenter's own convention — the
623
+ * locations came from the same package that will re-place the mutants, so a pick selects exactly
624
+ * the mutant it enumerated (asserted by the round-trip unit test).
625
+ */
626
+ declare function sampleToMutateTargets(plan: SamplePlan, subdir?: string | null): string[];
627
+
628
+ /** Categories this baseline covers — the same six the model is asked for. */
629
+ declare const DETERMINISTIC_CATEGORIES: readonly ["missing-await", "exception-swallow", "argument-order", "off-by-one", "wrong-variable", "wrong-constant"];
630
+ interface DeterministicOptions {
631
+ /** only emit recipes whose span intersects these 1-based lines (the diff scope) */
632
+ changedLines?: ReadonlySet<number>;
633
+ /** cap per category, so one dense file cannot crowd out every other rule */
634
+ maxPerCategory?: number;
635
+ }
636
+ /**
637
+ * "Similar identifier", made executable rather than left as prose. Two names are confusable when
638
+ * they are close in edit distance but NOT the same name — the mistake being modelled is reaching for
639
+ * the wrong one of two names that look alike (`startIndex` / `startIdx`, `res` / `req`).
640
+ * Very short names are excluded: at length 2 every name is within distance 2 of every other, which
641
+ * would make the rule fire everywhere and mean nothing.
642
+ */
643
+ declare function isConfusableIdentifier(a: string, b: string): boolean;
644
+ /**
645
+ * "Plausible constant", made executable. A replacement is plausible when it is the kind of value
646
+ * someone would actually type by mistake: an adjacent number, a unit confusion, an off-by-a-power,
647
+ * or the opposite boolean. Arbitrary values are not — replacing 7 with 913 tests nothing a real
648
+ * mistake would produce.
649
+ */
650
+ declare function plausibleConstants(literal: string): string[];
651
+ /**
652
+ * Generate the deterministic baseline's recipes for one file.
653
+ *
654
+ * Returns recipes in the same shape the validated LLM proposals take, so both arms can be run
655
+ * through one pipeline and compared on identical terms.
656
+ */
657
+ declare function deterministicRecipes(file: string, source: string, opts?: DeterministicOptions): MutationRecipe[];
658
+ /** Per-category tally, so a run can report which rules actually had opportunity in a corpus. */
659
+ declare function categoryCounts(recipes: readonly MutationRecipe[]): Record<string, number>;
660
+
661
+ declare const PRODUCTION_OPERATOR_VERSION = "production-operators-v2";
662
+ declare const PRODUCTION_OPERATOR_CATEGORIES: readonly ["statement-deletion", "return-deletion", "control-flow-deletion", "argument-omission", "argument-order", "argument-replacement", "identifier-replacement", "property-substitution", "assignment-operator", "assignment-rhs", "missing-await", "nullish-fallback", "optional-chain-removal", "call-chain-omission", "parameter-default-removal", "class-field-initializer-removal"];
663
+ interface ProductionOperatorOptions {
664
+ changedLines?: ReadonlySet<number>;
665
+ maxPerCategory?: number;
666
+ /** Include the frozen six-family control inventory. Default true. */
667
+ includeControl?: boolean;
668
+ }
669
+ declare function productionRecipes(file: string, source: string, options?: ProductionOperatorOptions): MutationRecipe[];
670
+ declare function productionCategoryCounts(recipes: readonly MutationRecipe[]): Record<string, number>;
671
+
672
+ /** One operator, and the transformation it performs on source text. */
673
+ interface ClassicOperatorEntry {
674
+ /** the operator's identity in the pass that runs it */
675
+ id: string;
676
+ /** what it rewrites - a statement about the EDIT, checkable against a proposal */
677
+ rewrite: string;
678
+ }
679
+ /**
680
+ * Stryker's built-in mutators, as this product runs them.
681
+ *
682
+ * THE WHOLE SET, because the classic pass writes no `mutator` block and Stryker's default is all of
683
+ * them. Each sentence describes the edit at the level a reader can apply: "replaces a string literal
684
+ * with an empty string" is checkable; "string mutations" is not.
685
+ */
686
+ declare const STRYKER_BUILTIN_OPERATORS: readonly ClassicOperatorEntry[];
687
+ /**
688
+ * The operators the deterministic pass runs: the frozen control inventory plus the production set.
689
+ *
690
+ * ONE LIST BECAUSE ONE PASS RUNS THEM. `productionRecipes` composes both by default
691
+ * (`includeControl`), so a customer's run either has all of these or none of them, and splitting
692
+ * them here would offer a distinction the prompt could not act on.
693
+ */
694
+ declare const DETERMINISTIC_PASS_OPERATORS: readonly ClassicOperatorEntry[];
695
+ /**
696
+ * The operator set a run actually ran, for the planting prompt's exclusion block.
697
+ *
698
+ * `deterministic` IS THE POLICY FLAG, NOT AN OPINION. `classicMutation.deterministicMutants.enabled`
699
+ * decides whether the deterministic pass runs at all, and a run that did not run it must not tell
700
+ * the model those rewrites are already covered - the model would then decline to write a bug nobody
701
+ * else is writing. The Stryker built-ins are unconditional because the classic pass is.
702
+ */
703
+ declare function classicOperatorInventory(input: {
704
+ deterministic: boolean;
705
+ }): ClassicOperatorEntry[];
706
+ /**
707
+ * The category names the deterministic pass can emit, as its own modules declare them.
708
+ *
709
+ * Exported so the drift test can compare against this inventory without importing two constants and
710
+ * re-deriving the union at the assertion site.
711
+ */
712
+ declare const DETERMINISTIC_PASS_CATEGORIES: readonly string[];
713
+
714
+ export { type ApplyResult, type Binding, type BindingKind, type ClassicOperatorEntry, type Cluster, type ClusterInput, type ClusterResult, DESCRIBE_FNS, DETERMINISTIC_CATEGORIES, DETERMINISTIC_PASS_CATEGORIES, DETERMINISTIC_PASS_OPERATORS, type DeterministicOptions, type EnclosingFunction, type EntryPoint, type EnumeratedMutant, type ErrorHandlerScan, type ExportSurface, type FileHandlerScan, type HandlerScanInput, type HandlerSpan, type MutationRecipe, PRODUCTION_OPERATOR_CATEGORIES, PRODUCTION_OPERATOR_VERSION, type ParameterInfo, type ParsedSource, type ProductionOperatorOptions, type Reachability, type ReachabilityAnalysis, SAMPLING_ALGORITHM, STRYKER_BUILTIN_OPERATORS, type SamplePlan, type ScopeTable, TEST_FNS, type TautologyResult, type TautologyScanInput, analyzeReachability, applyRecipe, buildScopes, categoryCounts, classicOperatorInventory, clusterMutants, collectTypeContext, commentInNodeMatches, deterministicRecipes, enclosingNodes, escapeMutatePath, forcedHandlerRecipes, isConfusableIdentifier, isParameterized, isReassigned, lineFirstSample, lineKey, lineOf, moduleExportSurface, mutantKey, namesInScopeAt, offsetOf, parseSource, plausibleConstants, productionCategoryCounts, productionRecipes, recipeFromOffsets, recipeFromSpan, recipeId, sha256 as recipeSha256, resolveBinding, resolveThroughAliases, sampleToMutateTargets, scanErrorHandlers, scanFileHandlers, scanTautologies, strictOffsetOf, stringArg, testCallName, tryRecipeFromSpan };