Skip to content

Commit 5de722c

Browse files
author
Sean Roberts
committed
fix: improve fuzzy detection of configs
1 parent ae945d8 commit 5de722c

2 files changed

Lines changed: 183 additions & 7 deletions

File tree

src/config/loader.ts

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,24 @@ async function collectFromPath(absolutePath: string, scenarios: Scenario[]): Pro
203203
if (loaded) scenarios.push(...loaded);
204204
}
205205

206+
/**
207+
* Directory names skipped when walking the scenarios tree. These commonly
208+
* appear inside fixture codebases (e.g. `scenarios/fixtures/site/.netlify/`)
209+
* and never contain authored scenarios.
210+
*/
211+
const WALK_SKIP_DIRS = new Set(["node_modules"]);
212+
206213
async function walkDir(dir: string, rootDir: string, scenarios: Scenario[]): Promise<void> {
207214
const entries = await fs.readdir(dir, { withFileTypes: true });
208215

209216
for (const entry of entries) {
210217
const fullPath = path.join(dir, entry.name);
211218

212219
if (entry.isDirectory()) {
220+
// Skip dotfile directories (`.git`, `.netlify`, `.next`, …) and known
221+
// non-source dirs so we don't crawl into tool state or vendored code
222+
// when a scenario directory contains fixture codebases.
223+
if (entry.name.startsWith(".") || WALK_SKIP_DIRS.has(entry.name)) continue;
213224
await walkDir(fullPath, rootDir, scenarios);
214225
continue;
215226
}
@@ -220,8 +231,9 @@ async function walkDir(dir: string, rootDir: string, scenarios: Scenario[]): Pro
220231

221232
// Derive key from path relative to the walk root: scenarios/cms/create-post.ts → "cms/create-post"
222233
const baseKey = path.relative(rootDir, fullPath).replace(SCENARIO_EXT_RE, "").split(path.sep).join("/");
223-
// Walking a directory: silently skip module files that don't default-export a scenario object,
224-
// so user-authored helpers/utilities can live alongside scenarios without special handling.
234+
// Walking a directory: silently skip files that don't look like a scenario
235+
// (e.g. fixture JSON, helper TS modules) so authors can keep them alongside
236+
// real scenarios without special handling.
225237
const loaded = await loadScenarioFromPath(fullPath, baseKey, true);
226238
if (loaded) scenarios.push(...loaded);
227239
}
@@ -230,9 +242,11 @@ async function walkDir(dir: string, rootDir: string, scenarios: Scenario[]): Pro
230242
/**
231243
* Loads a single scenario from disk, dispatching by extension.
232244
*
233-
* @param silentSkip When true, JS/TS modules without a default object export return null
234-
* instead of throwing. Used when walking a directory so non-scenario
235-
* helper modules can coexist with scenario files.
245+
* @param silentSkip When true, files that don't look like a scenario return
246+
* null instead of throwing — JS/TS modules without a default
247+
* object export, JSON files with no scenario-identifying
248+
* fields. Used when walking a directory so fixture data and
249+
* helper modules can coexist with real scenario files.
236250
*/
237251
async function loadScenarioFromPath(
238252
filePath: string,
@@ -242,7 +256,7 @@ async function loadScenarioFromPath(
242256
const ext = path.extname(filePath).toLowerCase();
243257

244258
if (ext === ".json") {
245-
return loadJsonScenario(filePath, baseKey);
259+
return loadJsonScenario(filePath, baseKey, silentSkip);
246260
}
247261

248262
if (JS_EXTENSIONS.has(ext) || TS_EXTENSIONS.has(ext)) {
@@ -253,24 +267,44 @@ async function loadScenarioFromPath(
253267
throw new Error(`Unsupported scenario file extension "${ext}" at ${filePath}`);
254268
}
255269

256-
async function loadJsonScenario(filePath: string, baseKey: string): Promise<Scenario[]> {
270+
/**
271+
* Top-level fields that, if present in a JSON file, signal "this is intended to
272+
* be a scenario." `prompt` and `rubric` are AXIS-specific enough that no common
273+
* non-scenario JSON (package.json, tsconfig.json, lockfiles, framework state)
274+
* uses them. When walking a directory and neither appears, the JSON is some
275+
* other artifact and we skip it silently instead of treating it as a malformed
276+
* scenario. `name` is intentionally excluded — package.json has it.
277+
*/
278+
const SCENARIO_MARKER_FIELDS = ["prompt", "rubric"] as const;
279+
280+
async function loadJsonScenario(filePath: string, baseKey: string, silentSkip: boolean): Promise<Scenario[] | null> {
257281
const raw = await fs.readFile(filePath, "utf-8");
258282

259283
let parsed: unknown;
260284
try {
261285
parsed = JSON.parse(raw);
262286
} catch {
287+
if (silentSkip) return null;
263288
throw new Error(`Failed to parse JSON in scenario file ${filePath}`);
264289
}
265290

291+
if (silentSkip && !looksLikeScenario(parsed)) return null;
292+
266293
return finalizeScenarioObject(parsed, filePath, baseKey);
267294
}
268295

296+
function looksLikeScenario(parsed: unknown): boolean {
297+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false;
298+
const obj = parsed as Record<string, unknown>;
299+
return SCENARIO_MARKER_FIELDS.some((field) => field in obj);
300+
}
301+
269302
async function loadModuleScenario(filePath: string, baseKey: string, silentSkip: boolean): Promise<Scenario[] | null> {
270303
let mod: { default?: unknown };
271304
try {
272305
mod = await importModule(filePath);
273306
} catch (err) {
307+
if (silentSkip) return null;
274308
throw new Error(`Failed to load scenario module at ${filePath}: ${formatError(err)}`);
275309
}
276310

@@ -284,6 +318,12 @@ async function loadModuleScenario(filePath: string, baseKey: string, silentSkip:
284318
throw new Error(`Scenario module at ${filePath} must default-export an object (or function returning one)`);
285319
}
286320

321+
// Fixture codebases inside the scenarios tree may include framework configs
322+
// (next.config.mjs, vite.config.ts, …) that default-export an object. Skip
323+
// anything without scenario-marker fields when walking; surface a real error
324+
// only for files explicitly named on the command line / config.
325+
if (silentSkip && !looksLikeScenario(def)) return null;
326+
287327
return finalizeScenarioObject(def, filePath, baseKey);
288328
}
289329

test/unit/config/loader.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,142 @@ describe("discoverScenarios", () => {
185185
await expect(discoverScenarios(FIXTURES_DIR, "./nonexistent")).rejects.toThrow("Could not read scenarios path");
186186
});
187187

188+
describe("walk filtering", () => {
189+
it("silently skips JSON files that do not look like scenarios", async () => {
190+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "axis-walk-"));
191+
const scenariosDir = path.join(tmpDir, "scenarios");
192+
const fixtureDir = path.join(scenariosDir, "fixture-site");
193+
await fs.mkdir(fixtureDir, { recursive: true });
194+
195+
await fs.writeFile(
196+
path.join(scenariosDir, "real.json"),
197+
JSON.stringify({ name: "Real", prompt: "p", rubric: "r" }),
198+
);
199+
await fs.writeFile(
200+
path.join(fixtureDir, "package.json"),
201+
JSON.stringify({ name: "fixture-site", version: "1.0.0", dependencies: {} }),
202+
);
203+
await fs.writeFile(
204+
path.join(fixtureDir, "state.json"),
205+
JSON.stringify({ siteId: "abc", deployId: "xyz" }),
206+
);
207+
208+
const scenarios = await discoverScenarios(tmpDir, "./scenarios");
209+
expect(scenarios.map((s) => s.key)).toEqual(["real"]);
210+
211+
await fs.rm(tmpDir, { recursive: true });
212+
});
213+
214+
it("still validates JSON files that have any scenario-marker field", async () => {
215+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "axis-walk-"));
216+
const scenariosDir = path.join(tmpDir, "scenarios");
217+
await fs.mkdir(scenariosDir, { recursive: true });
218+
// Has `prompt` but no `name` — clearly intended as a scenario, must error.
219+
await fs.writeFile(
220+
path.join(scenariosDir, "broken.json"),
221+
JSON.stringify({ prompt: "do thing", rubric: "judged" }),
222+
);
223+
224+
await expect(discoverScenarios(tmpDir, "./scenarios")).rejects.toThrow(/missing required field "name"/);
225+
226+
await fs.rm(tmpDir, { recursive: true });
227+
});
228+
229+
it("silently skips invalid JSON files when walking", async () => {
230+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "axis-walk-"));
231+
const scenariosDir = path.join(tmpDir, "scenarios");
232+
await fs.mkdir(scenariosDir, { recursive: true });
233+
await fs.writeFile(
234+
path.join(scenariosDir, "real.json"),
235+
JSON.stringify({ name: "Real", prompt: "p", rubric: "r" }),
236+
);
237+
await fs.writeFile(path.join(scenariosDir, "garbage.json"), "this is not json");
238+
239+
const scenarios = await discoverScenarios(tmpDir, "./scenarios");
240+
expect(scenarios.map((s) => s.key)).toEqual(["real"]);
241+
242+
await fs.rm(tmpDir, { recursive: true });
243+
});
244+
245+
it("does not descend into hidden directories or node_modules", async () => {
246+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "axis-walk-"));
247+
const scenariosDir = path.join(tmpDir, "scenarios");
248+
const hidden = path.join(scenariosDir, ".netlify");
249+
const vendored = path.join(scenariosDir, "node_modules", "some-pkg");
250+
await fs.mkdir(hidden, { recursive: true });
251+
await fs.mkdir(vendored, { recursive: true });
252+
253+
await fs.writeFile(
254+
path.join(scenariosDir, "real.json"),
255+
JSON.stringify({ name: "Real", prompt: "p", rubric: "r" }),
256+
);
257+
// Files inside these dirs would otherwise be readdir'd and parsed.
258+
// Even if they happened to look like scenarios, we should not pick them up.
259+
await fs.writeFile(
260+
path.join(hidden, "state.json"),
261+
JSON.stringify({ name: "Should Not Load", prompt: "p", rubric: "r" }),
262+
);
263+
await fs.writeFile(
264+
path.join(vendored, "package.json"),
265+
JSON.stringify({ name: "Should Not Load", prompt: "p", rubric: "r" }),
266+
);
267+
268+
const scenarios = await discoverScenarios(tmpDir, "./scenarios");
269+
expect(scenarios.map((s) => s.key)).toEqual(["real"]);
270+
271+
await fs.rm(tmpDir, { recursive: true });
272+
});
273+
274+
it("treats explicit single-file JSON entries strictly even if they don't look like scenarios", async () => {
275+
// Pointing at a file directly is intent — surface validation errors instead of silently skipping.
276+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "axis-walk-"));
277+
await fs.writeFile(path.join(tmpDir, "not-a-scenario.json"), JSON.stringify({ siteId: "x" }));
278+
279+
await expect(discoverScenarios(tmpDir, "./not-a-scenario.json")).rejects.toThrow(/missing required field/);
280+
281+
await fs.rm(tmpDir, { recursive: true });
282+
});
283+
284+
it("silently skips ESM module files that default-export non-scenario configs", async () => {
285+
// Mimics next.config.mjs / vite.config.mjs living inside a fixture codebase.
286+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "axis-walk-"));
287+
const scenariosDir = path.join(tmpDir, "scenarios");
288+
const fixtureDir = path.join(scenariosDir, "nextjs-fixture");
289+
await fs.mkdir(fixtureDir, { recursive: true });
290+
291+
await fs.writeFile(
292+
path.join(scenariosDir, "real.json"),
293+
JSON.stringify({ name: "Real", prompt: "p", rubric: "r" }),
294+
);
295+
await fs.writeFile(
296+
path.join(fixtureDir, "next.config.mjs"),
297+
`export default { reactStrictMode: true, images: { remotePatterns: [] } };`,
298+
);
299+
300+
const scenarios = await discoverScenarios(tmpDir, "./scenarios");
301+
expect(scenarios.map((s) => s.key)).toEqual(["real"]);
302+
303+
await fs.rm(tmpDir, { recursive: true });
304+
});
305+
306+
it("silently skips module files that fail to import when walking", async () => {
307+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "axis-walk-"));
308+
const scenariosDir = path.join(tmpDir, "scenarios");
309+
await fs.mkdir(scenariosDir, { recursive: true });
310+
311+
await fs.writeFile(
312+
path.join(scenariosDir, "real.json"),
313+
JSON.stringify({ name: "Real", prompt: "p", rubric: "r" }),
314+
);
315+
await fs.writeFile(path.join(scenariosDir, "broken.mjs"), `import "./does-not-exist.js";`);
316+
317+
const scenarios = await discoverScenarios(tmpDir, "./scenarios");
318+
expect(scenarios.map((s) => s.key)).toEqual(["real"]);
319+
320+
await fs.rm(tmpDir, { recursive: true });
321+
});
322+
});
323+
188324
describe("variants", () => {
189325
let tmpDir: string;
190326

0 commit comments

Comments
 (0)