Skip to content

Commit 4f80d58

Browse files
committed
fix(observability-map): widen the corpus helper to every export form
mutations.ts entryBodies collected exported function declarations and exported const identifiers only, so it missed the object binding pattern (export const { action, loader } = createActionApiRoute(...)), the export clause (const { action } = builder(...); export { action }), and export const action = route.action. That is 36 of the tree's 427 entry points, all of them API routes: every whole-body corpus entry skipped them while the file count suggested otherwise. The scanner has read all four forms since early on, so this was the harness lagging it. No assertion could have noticed. A mutation that reaches fewer routes lowers the score rather than raising it, which is exactly how the suppress-every-check omission hid, so the answer is the same: assert the population. admin.tsx is the one exclusion, named rather than counted, because its handler is a concise arrow with no block for a block wrapper to wrap. wrap-body-in-rethrow goes from 391 files with 36 entry points missed to 426 files, 1020 sites, 1 missed. Widening changes no entry's verdict: the full corpus is 55 passed and 1 expected fail either way, and with the narrow population the only failure is the new population assertion itself. readTree now calls routeModuleFiles rather than keeping its own copy of the directory walk. isScannableFile had already replaced the file half of that copy; the directory half survived.
1 parent 30fdb02 commit 4f80d58

2 files changed

Lines changed: 178 additions & 36 deletions

File tree

internal-packages/observability-map/src/mutationCorpus.test.ts

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
1+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { dirname, join, resolve } from "node:path";
4-
import { isScannableFile, scanDirectory } from "./scan.js";
4+
import { routeModuleFiles, scanDirectory } from "./scan.js";
55
import { buildReport } from "./score.js";
66
import { ADDITIVE_IDS, MUTATIONS, type Mutation } from "./mutations.js";
77
import { CHECKS } from "./checks/index.js";
@@ -62,27 +62,20 @@ const KNOWN_GAPS = new Set<string>(["dead-classifying-try-with-call"]);
6262

6363
type SourceFile = { relativeName: string; source: string };
6464

65-
/** Route modules exactly as `scanDirectory` enumerates them: flat files, plus one `route.ts(x)` per
66-
* directory. Read once; every mutation rewrites this list rather than the tree on disk. */
65+
/**
66+
* Route modules exactly as `scanDirectory` enumerates them, because it is the same enumeration and
67+
* no longer a copy of it. `isScannableFile` had already replaced the file half of the copy; the
68+
* directory half survived, so "one `route.ts(x)` per immediate subdirectory" was still written
69+
* twice. A harness that reads a different tree from the scanner reports files and sites the scan
70+
* never saw, and those counts are what the thresholds below rest on.
71+
*
72+
* Read once; every mutation rewrites this list rather than the tree on disk.
73+
*/
6774
function readTree(dir: string): SourceFile[] {
68-
const files: SourceFile[] = [];
69-
const take = (absolutePath: string, relativeName: string) => {
70-
files.push({ relativeName, source: readFileSync(absolutePath, "utf8") });
71-
};
72-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
73-
if (entry.isDirectory()) {
74-
for (const child of readdirSync(join(dir, entry.name), { withFileTypes: true })) {
75-
if (!child.isFile() || (child.name !== "route.ts" && child.name !== "route.tsx")) continue;
76-
take(join(dir, entry.name, child.name), `${entry.name}/${child.name}`);
77-
}
78-
continue;
79-
}
80-
// `scanDirectory`'s own predicate rather than a copy of it. A copy that drifts lets a mutation
81-
// report files and sites the scanner never read, which is what the thresholds below are for.
82-
if (!entry.isFile() || !isScannableFile(entry.name)) continue;
83-
take(join(dir, entry.name), entry.name);
84-
}
85-
return files;
75+
return routeModuleFiles(dir).map((file) => ({
76+
relativeName: file.relativeName,
77+
source: readFileSync(file.absolutePath, "utf8"),
78+
}));
8679
}
8780

8881
function materialize(files: SourceFile[]): string {
@@ -318,6 +311,43 @@ describeCorpus("mutation corpus over the real route tree", { timeout: ENTRY_TIME
318311
baseline = measure(files);
319312
}, ENTRY_TIMEOUT_MS);
320313

314+
/**
315+
* The corpus's own population, against the scanner's.
316+
*
317+
* The whole-body entries wrap what `entryBodies` finds, and that helper read two of the four
318+
* export forms `scan.ts` reads. It missed `export const { action, loader } = builder(...)`,
319+
* `const { action } = builder(...); export { action };` and `export const action = route.action`,
320+
* which is 36 of the tree's entry points: the corpus was testing less than its entry count
321+
* implied, and no assertion could notice, because a mutation that reaches fewer routes lowers the
322+
* score rather than raising it. Same failure mode as the `suppress-every-check` omission above,
323+
* so the answer is the same: assert the population rather than wait for a verdict to move.
324+
*
325+
* `admin.tsx` is the one documented exclusion. Its handler is a concise arrow
326+
* (`async ({ user }) => typedjson({ user })`) with no block for a block wrapper to wrap, which is
327+
* a limit of the rewrite rather than a gap in the enumeration. It is named rather than counted so
328+
* a second one cannot appear silently.
329+
*/
330+
const CONCISE_ARROW_BODIES = new Set(["admin.tsx"]);
331+
332+
it("wraps a body in every non-delegating entry point the scanner finds", () => {
333+
const wrap = MUTATIONS.find((m) => m.id === "wrap-body-in-rethrow")!;
334+
const root = materialize(files);
335+
let entryPoints;
336+
try {
337+
({ entryPoints } = scanDirectory(root));
338+
} finally {
339+
rmSync(root, { recursive: true, force: true });
340+
}
341+
342+
const byName = new Map(files.map((f) => [f.relativeName, f.source]));
343+
const untouched = entryPoints
344+
.filter((ep) => !ep.delegating && !CONCISE_ARROW_BODIES.has(ep.fileName))
345+
.filter((ep) => wrap.apply(ep.fileName, byName.get(ep.fileName)!) === null)
346+
.map((ep) => ep.fileName);
347+
348+
expect(untouched).toEqual([]);
349+
});
350+
321351
it("has a baseline worth mutating", () => {
322352
expect(baseline).not.toBeNull();
323353
expect(baseline!.entryPoints).toBeGreaterThan(300);

internal-packages/observability-map/src/mutations.ts

Lines changed: 126 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -179,18 +179,44 @@ function rootCall(call: ts.CallExpression): ts.CallExpression {
179179
}
180180
}
181181

182-
function fromInitializer(expr: ts.Expression, out: EntryFunction[]): void {
182+
/**
183+
* The handler functions an export's initializer resolves to.
184+
*
185+
* `locals` is consulted for the two indirect spellings, both of which `scan.ts` resolves and
186+
* neither of which this reached: `export const action = route.action` beside
187+
* `const route = createActionApiRoute(...)`, which is 7 of the tree's API routes, and
188+
* `export const action = handleThing` naming a local. `seen` stops `const a = b; const b = a`.
189+
*/
190+
function fromInitializer(
191+
expr: ts.Expression,
192+
out: EntryFunction[],
193+
locals: LocalDeclarations,
194+
seen: Set<string> = new Set()
195+
): void {
183196
const target = unwrap(expr);
184197
if (isEntryFunction(target)) {
185198
out.push(target);
186199
return;
187200
}
188-
if (!ts.isCallExpression(target)) return;
189-
for (const arg of rootCall(target).arguments) {
190-
const unwrapped = unwrap(arg);
191-
if (isEntryFunction(unwrapped)) out.push(unwrapped);
192-
else if (ts.isObjectLiteralExpression(unwrapped)) collectNamedHandlers(unwrapped, out);
201+
if (ts.isCallExpression(target)) {
202+
for (const arg of rootCall(target).arguments) {
203+
const unwrapped = unwrap(arg);
204+
if (isEntryFunction(unwrapped)) out.push(unwrapped);
205+
else if (ts.isObjectLiteralExpression(unwrapped)) collectNamedHandlers(unwrapped, out);
206+
}
207+
return;
193208
}
209+
// `route.action`, and any longer chain, is resolved from whatever declared its root identifier.
210+
let root: ts.Expression = target;
211+
while (ts.isPropertyAccessExpression(root) || ts.isElementAccessExpression(root)) {
212+
root = unwrap(root.expression);
213+
}
214+
if (!ts.isIdentifier(root) || seen.has(root.text)) return;
215+
const declaration = locals.get(root.text);
216+
if (declaration === undefined) return;
217+
seen.add(root.text);
218+
if (ts.isFunctionDeclaration(declaration)) out.push(declaration);
219+
else fromInitializer(declaration, out, locals, seen);
194220
}
195221

196222
const ENTRY_NAMES = new Set(["loader", "action"]);
@@ -202,23 +228,109 @@ function isExported(node: ts.Node): boolean {
202228
);
203229
}
204230

205-
/** Block bodies of the exported `loader`/`action` handlers, the region a whole-body wrapper wraps. */
206-
function entryBodies(sf: ts.SourceFile): ts.Block[] {
207-
const functions: EntryFunction[] = [];
231+
/**
232+
* Top-level declarations by binding name, so a named export clause (`export { action }`) resolves
233+
* back to the initializer it came from. Object binding patterns are read element by element, which
234+
* is what makes `const { action, loader } = createActionApiRoute(...)` resolvable.
235+
*/
236+
type LocalDeclarations = Map<string, ts.Expression | ts.FunctionDeclaration>;
237+
238+
function localDeclarations(sf: ts.SourceFile): LocalDeclarations {
239+
const locals: LocalDeclarations = new Map();
208240
for (const statement of sf.statements) {
209-
if (!isExported(statement)) continue;
210241
if (ts.isFunctionDeclaration(statement) && statement.name) {
211-
if (ENTRY_NAMES.has(statement.name.text)) functions.push(statement);
242+
locals.set(statement.name.text, statement);
212243
continue;
213244
}
214245
if (!ts.isVariableStatement(statement)) continue;
215246
for (const decl of statement.declarationList.declarations) {
216-
if (!decl.initializer || !ts.isIdentifier(decl.name)) continue;
217-
if (ENTRY_NAMES.has(decl.name.text)) fromInitializer(decl.initializer, functions);
247+
if (!decl.initializer) continue;
248+
if (ts.isIdentifier(decl.name)) {
249+
locals.set(decl.name.text, decl.initializer);
250+
continue;
251+
}
252+
if (ts.isObjectBindingPattern(decl.name)) {
253+
for (const element of decl.name.elements) {
254+
if (ts.isIdentifier(element.name)) locals.set(element.name.text, decl.initializer);
255+
}
256+
}
218257
}
219258
}
259+
return locals;
260+
}
261+
262+
/**
263+
* Block bodies of the exported `loader`/`action` handlers, the region a whole-body wrapper wraps.
264+
*
265+
* Reads the same four export forms `scan.ts` reads: an exported function declaration, an exported
266+
* `const`, an exported object binding pattern, and a named export clause resolved back through a
267+
* local. It read only the first two, which is the shape of every API route in the tree
268+
* (`const { action, loader } = createActionApiRoute(...); export { action, loader };` and the
269+
* direct `export const { action } = ...`), so `wrapEveryBody` and the other whole-body entries
270+
* silently skipped 36 of the 427 entry points while reporting a file count that suggested
271+
* otherwise. `mutationCorpus.test.ts` pins the population now ("wraps a body in every
272+
* non-delegating entry point the scanner finds"), so the harness cannot lag the scanner here again
273+
* without going red.
274+
*
275+
* This is NOT a retreat from the deliberate independence `collectNamedHandlers` documents. That
276+
* independence is about disagreeing over where a HANDLER sits inside a builder's argument, which is
277+
* a judgement the corpus has to be able to make for itself. Which exports exist is not a judgement,
278+
* and the harness was simply behind.
279+
*/
280+
function entryBodies(sf: ts.SourceFile): ts.Block[] {
281+
const functions: EntryFunction[] = [];
282+
const locals = localDeclarations(sf);
283+
const fromLocal = (name: string) => {
284+
const decl = locals.get(name);
285+
if (decl === undefined) return;
286+
if (ts.isFunctionDeclaration(decl)) functions.push(decl);
287+
else fromInitializer(decl, functions, locals);
288+
};
289+
290+
for (const statement of sf.statements) {
291+
if (ts.isFunctionDeclaration(statement) && statement.name && isExported(statement)) {
292+
if (ENTRY_NAMES.has(statement.name.text)) functions.push(statement);
293+
continue;
294+
}
295+
296+
if (ts.isVariableStatement(statement) && isExported(statement)) {
297+
for (const decl of statement.declarationList.declarations) {
298+
if (!decl.initializer) continue;
299+
if (ts.isIdentifier(decl.name)) {
300+
if (ENTRY_NAMES.has(decl.name.text)) fromInitializer(decl.initializer, functions, locals);
301+
continue;
302+
}
303+
if (!ts.isObjectBindingPattern(decl.name)) continue;
304+
for (const element of decl.name.elements) {
305+
if (ts.isIdentifier(element.name) && ENTRY_NAMES.has(element.name.text)) {
306+
fromInitializer(decl.initializer, functions, locals);
307+
}
308+
}
309+
}
310+
continue;
311+
}
312+
313+
// A re-export (`export { loader } from "./x"`) has no local binding to resolve, and a namespace
314+
// clause cannot name a loader or an action.
315+
if (
316+
ts.isExportDeclaration(statement) &&
317+
statement.exportClause &&
318+
!statement.moduleSpecifier &&
319+
ts.isNamedExports(statement.exportClause)
320+
) {
321+
for (const element of statement.exportClause.elements) {
322+
if (!ENTRY_NAMES.has(element.name.text)) continue;
323+
fromLocal(element.propertyName?.text ?? element.name.text);
324+
}
325+
}
326+
}
327+
328+
// One handler can serve both exports, and both reach it by their own road: the loader and the
329+
// action of `const { loader, action } = createActionApiRoute({ handler })` resolve to the same
330+
// node. Wrapping it twice would splice the same text in twice at the same offset, because
331+
// `applyEdits` treats two zero-width inserts at one position as non-overlapping.
220332
const bodies: ts.Block[] = [];
221-
for (const fn of functions) {
333+
for (const fn of new Set(functions)) {
222334
if (fn.body && ts.isBlock(fn.body)) bodies.push(fn.body);
223335
}
224336
return bodies;

0 commit comments

Comments
 (0)