agency-lang 0.7.2 → 0.7.4

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.
@@ -1,25 +1,14 @@
1
1
  import { generateAgency } from "../backends/agencyGenerator.js";
2
2
  import { loadConfigSafe } from "../config.js";
3
- import { generateTypeScript } from "../index.js";
4
- import { initPlanForModule } from "../backends/typescriptGenerator.js";
5
- import { resolveImports } from "../preprocessors/importResolver.js";
6
- import { resolveReExports } from "../preprocessors/resolveReExports.js";
7
- import { liftCallbackBlocks } from "../preprocessors/liftCallbacks.js";
8
- import { buildCompilationUnit } from "../compilationUnit.js";
9
- import { SymbolTable } from "../symbolTable.js";
10
- import { formatErrors, typeCheck } from "../typeChecker/index.js";
11
- import { buildCompiledClosure, CompileClosureError, } from "../compiler/compileClosure.js";
12
3
  import { spawn } from "child_process";
13
- import { transformSync } from "esbuild";
14
4
  import * as fs from "fs";
15
5
  import { createRequire } from "module";
16
6
  import * as path from "path";
17
- import { getStdlibDir, isNonTemplatedStdlib, isPkgImport, isStdlibImport, resolveAgencyImportPath, } from "../importPaths.js";
18
- import { CompileStrategy, RunStrategy, } from "../importStrategy.js";
7
+ import { RunStrategy } from "../importStrategy.js";
19
8
  import { parseAgency, replaceBlankLines } from "../parser.js";
20
- import { parseAgencyFileCached } from "../parseCache.js";
21
9
  import { fileURLToPath, pathToFileURL } from "url";
22
- import { findRecursively, getImports } from "./util.js";
10
+ import { findRecursively } from "./util.js";
11
+ import { createBuildSession, readFile, } from "../compiler/buildSession.js";
23
12
  // Returns the file:// URL of the ESM loader-register shim shipped with the
24
13
  // agency-lang package. Passing this to `node --import=<url>` causes Node to
25
14
  // fall back to agency-lang's own node_modules when resolving bare specifiers,
@@ -122,271 +111,47 @@ export function parse(contents, config, applyTemplate = true, lower = true) {
122
111
  }
123
112
  return parseResult.result;
124
113
  }
125
- export function readFile(inputFile) {
126
- if (!fs.existsSync(inputFile)) {
127
- console.error(`Error: Input file '${inputFile}' not found`);
128
- process.exit(1);
129
- }
130
- return fs.readFileSync(inputFile, "utf-8");
114
+ export { readFile };
115
+ // The default session backing the legacy module-level entry points
116
+ // (compile/compileMany/resetCompilationCache). Deliberately the ONLY
117
+ // compile-pipeline state left in this file: CLI processes are
118
+ // single-session by nature, and watch mode resets it between rebuilds.
119
+ // All pipeline logic lives in lib/compiler/buildSession.ts.
120
+ //
121
+ // Created LAZILY, not at module top level: `agency pack` bundles compiled
122
+ // programs whose import chain reaches this module for small helpers
123
+ // (readFile, loadConfig). An eager createBuildSession() call is a
124
+ // top-level side effect that defeats esbuild tree-shaking and drags the
125
+ // entire codegen subtree into every packed artifact (~16k extra lines,
126
+ // caught by pack.test.ts).
127
+ let defaultSession = null;
128
+ function getDefaultSession() {
129
+ return (defaultSession ??= createBuildSession());
131
130
  }
132
- const compiledFiles = new Set();
133
- // Cached `CompiledClosure` for the current compile session. Built once
134
- // at the outermost `compile()` call (when no per-file recursion is in
135
- // progress) and reused by every per-file emit. Cleared by
136
- // `resetCompilationCache()`.
137
- let currentClosure = null;
138
131
  export function resetCompilationCache() {
139
- compiledFiles.clear();
140
- currentClosure = null;
132
+ defaultSession = null;
141
133
  }
142
134
  /**
143
135
  * Compile a set of entry files under ONE union closure, like the
144
136
  * directory branch of `compile()` does. Callers with many entry points
145
137
  * (the test runner's precompile pass) use this instead of per-file
146
- * `compile()` calls, which would rebuild the closure once per entry via
147
- * `ensureCompiledClosure`'s covers-check.
138
+ * `compile()` calls, which would rebuild the closure once per entry.
148
139
  *
149
140
  * Unlike the CLI directory branch, closure errors THROW
150
141
  * (`CompileClosureError`) instead of exiting, so programmatic callers
151
142
  * can attach context. Parse/typecheck failures inside per-file
152
143
  * `compile()` keep their existing exit behavior.
153
- *
154
- * `options.closure` lets a caller that already built the union closure
155
- * (e.g. to run analyses over `closure.programs`) hand it in rather than
156
- * paying for a rebuild. It MUST cover every file in `files`, otherwise
157
- * `ensureCompiledClosure` clears the session cache mid-loop and the
158
- * one-compile-per-module guarantee is lost.
159
144
  */
160
145
  export function compileMany(config, files, options) {
161
- const absFiles = files.map((f) => path.resolve(f));
162
- if (absFiles.length === 0)
163
- return;
164
- currentClosure = options?.closure ?? buildCompiledClosure(absFiles, config);
165
- compiledFiles.clear();
166
- for (const file of absFiles) {
167
- compile(config, file, undefined, {
168
- quiet: options?.quiet,
169
- allowTestImports: options?.allowTestImports,
170
- });
171
- }
146
+ getDefaultSession().compileMany(config, files, options);
172
147
  }
173
148
  /**
174
- * Build the import-closure analysis once per compile session at the
175
- * outermost call, before any recursive per-file compile() runs. The
176
- * recursive children reuse the cached closure to get per-module init
177
- * plans without re-parsing.
178
- *
179
- * "Outermost call" = no `options.symbolTable` (passed by recursive
180
- * children). When the outermost call's entry file changes (e.g. the
181
- * `agency test` runner iterates several .test.json fixtures in one
182
- * process), the cached closure no longer covers the new entry's
183
- * imports so we rebuild and drop the stale `compiledFiles` set.
184
- * Without that drop, downstream codegen would look up plans for
185
- * modules that aren't in the closure and emit an empty init plan.
186
- *
187
- * Stdlib files compile under their own entry (e.g., when a user runs
188
- * `agency compile std/...`) but most user code reaches them via
189
- * `import "std::..."`, which the closure walker intentionally skips.
190
- * Avoid building a closure rooted at a stdlib file — its imports are
191
- * structured differently and we don't need the analysis there.
149
+ * Compile an .agency file (or directory of them) to JavaScript. Thin
150
+ * delegate over the default BuildSession all pipeline logic and caching
151
+ * state live in lib/compiler/buildSession.ts.
192
152
  */
193
- function ensureCompiledClosure(absoluteInputFile, config, hasSymbolTable, verbose) {
194
- const isOutermostCall = !hasSymbolTable;
195
- const isStdlibEntry = absoluteInputFile.startsWith(getStdlibDir() + path.sep);
196
- const closureCoversEntry = currentClosure?.programs[absoluteInputFile] !== undefined;
197
- if (!isOutermostCall || isStdlibEntry || closureCoversEntry)
198
- return;
199
- currentClosure = null;
200
- compiledFiles.clear();
201
- try {
202
- const ccStartTime = performance.now();
203
- currentClosure = buildCompiledClosure(absoluteInputFile, config);
204
- const ccEndTime = performance.now();
205
- logTime({ label: "Built compile closure", start: ccStartTime, end: ccEndTime, verbose });
206
- }
207
- catch (e) {
208
- if (e instanceof CompileClosureError) {
209
- console.error(e.message);
210
- process.exit(1);
211
- }
212
- throw e;
213
- }
214
- }
215
- function runTypecheck(liftedProgram, config, info, absoluteInputFile, verbose) {
216
- const tc = config.typechecker;
217
- if (!tc?.enabled && !tc?.strict)
218
- return;
219
- const tcStartTime = performance.now();
220
- const { errors } = typeCheck(liftedProgram, config, info);
221
- const tcEndTime = performance.now();
222
- logTime({ label: `Type checked ${absoluteInputFile}`, start: tcStartTime, end: tcEndTime, verbose });
223
- if (errors.length === 0)
224
- return;
225
- if (tc?.strict) {
226
- console.error(formatErrors(errors));
227
- const hasFatal = errors.some((e) => (e.severity ?? "error") === "error");
228
- if (hasFatal)
229
- process.exit(1);
230
- }
231
- else {
232
- console.warn(formatErrors(errors, "warning"));
233
- }
234
- }
235
- // Cached-parse counterpart of `parse()`: same exit-on-failure contract the
236
- // CLI pipeline expects, but reads through the process-wide parse cache.
237
- function parseFileOrExit(absPath, config, applyTemplate, contents) {
238
- const parseResult = parseAgencyFileCached(absPath, config, applyTemplate);
239
- if (!parseResult.success) {
240
- if (parseResult.message) {
241
- console.error(`Failed to parse Agency program: ${parseResult.message}`);
242
- }
243
- else {
244
- console.error("Failed to parse Agency program.", contents.slice(0, 400));
245
- }
246
- process.exit(1);
247
- }
248
- return parseResult.result;
249
- }
250
153
  export function compile(config, inputFile, _outputFile, options) {
251
- if (!fs.existsSync(inputFile)) {
252
- console.error(`Error: Input file '${inputFile}' not found`);
253
- process.exit(1);
254
- }
255
- const stats = fs.statSync(inputFile);
256
- const verbose = config.verbose ?? false;
257
- if (stats.isDirectory()) {
258
- const files = [...findRecursively(inputFile)].map((f) => f.path);
259
- // A directory is many entry points — every .agency file under it. To
260
- // avoid recompiling shared dependencies once per entry, build ONE
261
- // import closure covering all of them up front and reuse it for every
262
- // file. Without this, each sibling entry the previous closure didn't
263
- // cover would clear the per-session cache (see ensureCompiledClosure)
264
- // and recompile shared deps once per entry. Skipped for:
265
- // - recursive child calls (a symbolTable was threaded in) — those
266
- // aren't real top-level directory compiles; and
267
- // - stdlib dirs, which intentionally compile without a closure (the
268
- // same carve-out ensureCompiledClosure makes for stdlib entries).
269
- const absDir = path.resolve(inputFile);
270
- const isStdlibDir = absDir === getStdlibDir() || absDir.startsWith(getStdlibDir() + path.sep);
271
- if (!options?.symbolTable && !isStdlibDir && files.length > 0) {
272
- try {
273
- currentClosure = buildCompiledClosure(files.map((f) => path.resolve(f)), config);
274
- // The fresh union closure supersedes anything cached for a prior
275
- // entry; drop the per-file set so codegen reruns under it.
276
- compiledFiles.clear();
277
- }
278
- catch (e) {
279
- if (e instanceof CompileClosureError) {
280
- console.error(e.message);
281
- process.exit(1);
282
- }
283
- throw e;
284
- }
285
- }
286
- for (const file of files) {
287
- compile(config, file, undefined, options);
288
- }
289
- return null;
290
- }
291
- const compileStartTime = performance.now();
292
- const absoluteInputFile = path.resolve(inputFile);
293
- ensureCompiledClosure(absoluteInputFile, config, !!options?.symbolTable, verbose);
294
- const ext = options?.ts ? ".ts" : ".js";
295
- // Anchor the replacement to the extension so that an absolute path
296
- // containing ".agency" as a substring in a parent directory (e.g.
297
- // "/Users/me/dev/worksy.agency-init/src/agent.agency") does not get
298
- // the first match clobbered. See issue #48.
299
- let outputFile = _outputFile || inputFile.replace(/\.agency$/, ext);
300
- if (config.outDir && !_outputFile) {
301
- const outputDir = path.resolve(config.outDir);
302
- if (!fs.existsSync(outputDir)) {
303
- fs.mkdirSync(outputDir, { recursive: true });
304
- }
305
- outputFile = path.join(outputDir, outputFile);
306
- }
307
- if (compiledFiles.has(absoluteInputFile)) {
308
- return outputFile;
309
- }
310
- compiledFiles.add(absoluteInputFile);
311
- const contents = readFile(inputFile);
312
- const applyTemplate = !isNonTemplatedStdlib(absoluteInputFile);
313
- const parsedProgram = parseFileOrExit(absoluteInputFile, config, applyTemplate, contents);
314
- const symbolTableStartTime = performance.now();
315
- const symbolTable = options?.symbolTable ?? SymbolTable.build(absoluteInputFile, config);
316
- const symbolTableEndTime = performance.now();
317
- logTime({ label: `Built symbol table for ${absoluteInputFile}`, start: symbolTableStartTime, end: symbolTableEndTime, verbose });
318
- const compilationUnitStartTime = performance.now();
319
- const reExportedProgram = resolveReExports(parsedProgram, symbolTable, absoluteInputFile);
320
- const resolvedProgram = resolveImports(reExportedProgram, symbolTable, absoluteInputFile, {
321
- allowTestImports: options?.allowTestImports ?? false,
322
- });
323
- // Lift `callback("onX") { ... }` block bodies to top-level defs.
324
- // Must run BEFORE buildCompilationUnit and typecheck so the lifted defs
325
- // appear in functionDefinitions and get their bodies typechecked.
326
- const liftedProgram = liftCallbackBlocks(resolvedProgram);
327
- const info = buildCompilationUnit(liftedProgram, symbolTable, absoluteInputFile, contents);
328
- const compilationUnitEndTime = performance.now();
329
- logTime({ label: `Built compilation unit for ${absoluteInputFile}`, start: compilationUnitStartTime, end: compilationUnitEndTime, verbose });
330
- runTypecheck(liftedProgram, config, info, absoluteInputFile, verbose);
331
- const imports = getImports(resolvedProgram);
332
- for (const importPath of imports) {
333
- if (isStdlibImport(importPath) || isPkgImport(importPath))
334
- continue;
335
- const absPath = resolveAgencyImportPath(importPath, absoluteInputFile);
336
- compile(config, absPath, undefined, { ...options, symbolTable });
337
- }
338
- // Rewrite import paths in the AST using the import strategy
339
- const strategy = options?.importStrategy ?? new CompileStrategy({ targetExt: ".js" });
340
- const nonAgencyImports = [];
341
- liftedProgram.nodes.forEach((node) => {
342
- if (node.type !== "importStatement")
343
- return;
344
- if (isStdlibImport(node.modulePath) || isPkgImport(node.modulePath))
345
- return;
346
- node.modulePath = strategy.rewriteImport(node.modulePath, absoluteInputFile);
347
- if (!node.modulePath.endsWith(".agency")) {
348
- nonAgencyImports.push(node.modulePath);
349
- }
350
- });
351
- try {
352
- strategy.prepareDependencies(nonAgencyImports, absoluteInputFile);
353
- }
354
- catch (error) {
355
- console.error(error instanceof Error ? error.message : String(error));
356
- process.exit(1);
357
- }
358
- const moduleId = path.relative(process.cwd(), absoluteInputFile);
359
- const absoluteOutputFile = path.resolve(outputFile);
360
- // Per-module init plan view — derived from the cached closure if we
361
- // built one. Modules not in the closure (e.g., out-of-tree stdlib
362
- // compiles) fall through to the legacy path with no plan.
363
- const initPlan = currentClosure
364
- ? initPlanForModule(currentClosure, absoluteInputFile)
365
- : undefined;
366
- const codegenStartTime = performance.now();
367
- const generatedCode = generateTypeScript(liftedProgram, config, info, moduleId, absoluteOutputFile, initPlan);
368
- const codegenEndTime = performance.now();
369
- logTime({ label: `Generated code for ${absoluteInputFile}`, start: codegenStartTime, end: codegenEndTime, verbose });
370
- if (options?.ts) {
371
- fs.writeFileSync(outputFile, "// @ts-nocheck\n" + generatedCode, "utf-8");
372
- }
373
- else {
374
- const esbuildStartTime = performance.now();
375
- const result = transformSync(generatedCode, {
376
- loader: "ts",
377
- format: "esm",
378
- supported: { "top-level-await": true },
379
- });
380
- fs.writeFileSync(outputFile, result.code, "utf-8");
381
- const esbuildEndTime = performance.now();
382
- logTime({ label: `Transformed code for ${absoluteInputFile} with esbuild`, start: esbuildStartTime, end: esbuildEndTime, verbose });
383
- }
384
- const compileEndTime = performance.now();
385
- const timeTaken = `${(compileEndTime - compileStartTime).toFixed(2)}ms`;
386
- if (!options?.quiet) {
387
- console.log(`${inputFile} → ${outputFile} (in ${timeTaken})`);
388
- }
389
- return outputFile;
154
+ return getDefaultSession().compile(config, inputFile, _outputFile, options);
390
155
  }
391
156
  export async function format(contents, config = {}) {
392
157
  // Format path opts out of pattern lowering so the formatter sees the original
@@ -443,8 +208,3 @@ export function run(config, inputFile, outputFile, resumeFile) {
443
208
  }
444
209
  });
445
210
  }
446
- function logTime({ label, start, end, verbose }) {
447
- if (verbose) {
448
- console.log(`${label} in ${(end - start).toFixed(2)}ms`);
449
- }
450
- }
@@ -1,22 +1,7 @@
1
1
  import { AgencyConfig } from "../config.js";
2
- export type PrecompileGroup = {
3
- /** Base-config marker or the local-`agency.json` dir, for error messages. */
4
- label: string;
5
- config: AgencyConfig;
6
- /** Canonical serialization of `config`; groups conflict only when keys differ. */
7
- configKey: string;
8
- /** Absolute `.agency` entry paths. */
9
- files: string[];
10
- };
11
- export declare function groupTestSources(baseConfig: AgencyConfig, testJsonFiles: string[]): PrecompileGroup[];
12
- export declare function findCrossConfigConflicts(groups: {
13
- label: string;
14
- configKey: string;
15
- modules: string[];
16
- }[]): {
17
- module: string;
18
- labels: string[];
19
- }[];
2
+ import { type CompileGroup } from "../compiler/buildSession.js";
3
+ export type { CompileGroup as PrecompileGroup };
4
+ export declare function groupTestSources(baseConfig: AgencyConfig, testJsonFiles: string[]): CompileGroup[];
20
5
  export declare function precompileTestSources(baseConfig: AgencyConfig, testJsonFiles: string[], options?: {
21
6
  quiet?: boolean;
22
7
  }): void;
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Precompile pass for the Agency test runner.
2
+ * Precompile pass for the Agency test runner — a thin adapter over
3
+ * BuildSession.compileGroups.
3
4
  *
4
5
  * Historically each test CASE recompiled its `.agency` source (and the
5
6
  * source's whole import tree) via `executeNodeAsync` → `compile()` — with
@@ -8,23 +9,16 @@
8
9
  * unique source exactly once, up front; the runner then executes test cases
9
10
  * with `preferCompiled: true`, which reuses the sibling `.js`.
10
11
  *
11
- * Config grouping: compiled output is config-dependent (the generator bakes
12
- * config defaults into emitted code), and a test dir may carry a local
13
- * `agency.json` that `runTestFile` merges over the base config. Sources are
14
- * therefore grouped by merged config one union-closure compile for all
15
- * base-config files, plus one per local-config dir.
16
- *
17
- * Cross-config invariant: a sibling `.js` is a single slot per module, so a
18
- * module reachable from two groups whose configs differ would be
19
- * last-writer-wins. That is asserted here and fails loudly (no test dir
20
- * does this today). A graceful fallback (per-case compiles for conflicting
21
- * files) was rejected: interleaved recompiles rewrite shared siblings
22
- * mid-run, which is exactly the race this pass removes.
12
+ * What lives HERE is only what is test-runner-specific: mapping `.test.json`
13
+ * files to sibling sources, honoring file-level skip/skipOnCI, and merging a
14
+ * dir-local `agency.json` over the base config. The config grouping
15
+ * contract, the cross-config single-slot assert, and the per-group compile
16
+ * loop live in lib/compiler/buildSession.ts.
23
17
  */
24
18
  import fs from "fs";
25
19
  import path from "path";
26
- import { loadConfig, compileMany } from "./commands.js";
27
- import { buildCompiledClosure, CompileClosureError, } from "../compiler/compileClosure.js";
20
+ import { loadConfig } from "./commands.js";
21
+ import { createBuildSession, } from "../compiler/buildSession.js";
28
22
  const BASE_GROUP_LABEL = "<base config>";
29
23
  // File-level skip mirror of runTestFile's check: a `skip: true` (or
30
24
  // `skipOnCI: true` under CI) .test.json never runs, so its source must not
@@ -62,12 +56,6 @@ export function groupTestSources(baseConfig, testJsonFiles) {
62
56
  const group = (groups[label] ??= {
63
57
  label,
64
58
  config,
65
- // JSON.stringify is order-stable here: every config passes through
66
- // AgencyConfigSchema.safeParse (loadConfigSafe), and zod rebuilds the
67
- // object in SCHEMA shape order, not input order — so semantically
68
- // identical configs always serialize identically. Guarded by the
69
- // "configKey is key-order independent" test.
70
- configKey: JSON.stringify(config),
71
59
  files: [],
72
60
  });
73
61
  if (!group.files.includes(sourceFile))
@@ -75,51 +63,15 @@ export function groupTestSources(baseConfig, testJsonFiles) {
75
63
  }
76
64
  return Object.values(groups);
77
65
  }
78
- export function findCrossConfigConflicts(groups) {
79
- // Null-prototype: keyed by absolute module paths.
80
- const touchedBy = Object.create(null);
81
- for (const group of groups) {
82
- for (const module of group.modules) {
83
- (touchedBy[module] ??= []).push({
84
- configKey: group.configKey,
85
- label: group.label,
86
- });
87
- }
88
- }
89
- const conflicts = [];
90
- for (const [module, touches] of Object.entries(touchedBy)) {
91
- const distinctKeys = touches
92
- .map((t) => t.configKey)
93
- .filter((key, i, all) => all.indexOf(key) === i);
94
- if (distinctKeys.length > 1) {
95
- conflicts.push({ module, labels: touches.map((t) => t.label) });
96
- }
97
- }
98
- return conflicts;
99
- }
100
66
  export function precompileTestSources(baseConfig, testJsonFiles, options) {
101
67
  const groups = groupTestSources(baseConfig, testJsonFiles);
102
- const withClosures = groups.map((group) => ({
103
- group,
104
- closure: buildCompiledClosure(group.files, group.config),
105
- }));
106
- const conflicts = findCrossConfigConflicts(withClosures.map(({ group, closure }) => ({
107
- label: group.label,
108
- configKey: group.configKey,
109
- modules: Object.keys(closure.programs),
110
- })));
111
- if (conflicts.length > 0) {
112
- const lines = conflicts.map((c) => ` ${c.module}\n reachable from: ${c.labels.join(", ")}`);
113
- throw new CompileClosureError("Test sources with differing configs share modules. A module's " +
114
- "compiled .js is a single slot, so this would be last-writer-wins. " +
115
- "Move the shared module or align the configs:\n" +
116
- lines.join("\n"));
117
- }
118
- for (const { group, closure } of withClosures) {
119
- compileMany(group.config, group.files, {
120
- closure,
121
- quiet: options?.quiet,
122
- allowTestImports: true,
123
- });
124
- }
68
+ // Fresh session per precompile — a declared delta from the old shared
69
+ // module globals: the default session no longer inherits precompile's
70
+ // last-group closure/dedupe state. Safe because test cases run with
71
+ // `preferCompiled: true` and never re-enter compile(); the old inherited
72
+ // state was itself a latent staleness hazard.
73
+ createBuildSession().compileGroups(groups, {
74
+ quiet: options?.quiet,
75
+ allowTestImports: true,
76
+ });
125
77
  }
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
2
2
  import fs from "fs";
3
3
  import os from "os";
4
4
  import path from "path";
5
- import { findCrossConfigConflicts, groupTestSources, precompileTestSources, } from "./precompile.js";
5
+ import { groupTestSources, precompileTestSources } from "./precompile.js";
6
6
  const TRIVIAL = 'node main() {\n return "ok"\n}\n';
7
7
  const HELPER = 'export def helper(): string {\n return "shared"\n}\n';
8
8
  const IMPORTS_HELPER = 'import { helper } from "../shared/helper.agency"\n\nnode main() {\n return helper()\n}\n';
@@ -19,31 +19,6 @@ function writeTree(spec) {
19
19
  return root;
20
20
  }
21
21
  const TEST_JSON = JSON.stringify({ tests: [] });
22
- describe("findCrossConfigConflicts", () => {
23
- test("no conflict when groups with the same config share a module", () => {
24
- const conflicts = findCrossConfigConflicts([
25
- { label: "a", configKey: "{}", modules: ["/shared.agency"] },
26
- { label: "b", configKey: "{}", modules: ["/shared.agency"] },
27
- ]);
28
- expect(conflicts).toEqual([]);
29
- });
30
- test("conflict when groups with differing configs share a module", () => {
31
- const conflicts = findCrossConfigConflicts([
32
- { label: "a", configKey: "{}", modules: ["/shared.agency", "/a.agency"] },
33
- { label: "b", configKey: '{"verbose":true}', modules: ["/shared.agency"] },
34
- ]);
35
- expect(conflicts).toHaveLength(1);
36
- expect(conflicts[0].module).toBe("/shared.agency");
37
- expect(conflicts[0].labels.sort()).toEqual(["a", "b"]);
38
- });
39
- test("no conflict when differing-config groups touch disjoint modules", () => {
40
- const conflicts = findCrossConfigConflicts([
41
- { label: "a", configKey: "{}", modules: ["/a.agency"] },
42
- { label: "b", configKey: '{"verbose":true}', modules: ["/b.agency"] },
43
- ]);
44
- expect(conflicts).toEqual([]);
45
- });
46
- });
47
22
  describe("groupTestSources", () => {
48
23
  test("files without a local agency.json land in one base group", () => {
49
24
  const root = writeTree({
@@ -78,7 +53,7 @@ describe("groupTestSources", () => {
78
53
  expect(custom.config.verbose).toBe(true);
79
54
  expect(custom.config.observability).toBe(true);
80
55
  const base = groups.find((g) => !g.label.includes("custom"));
81
- expect(base.configKey).not.toBe(custom.configKey);
56
+ expect(base.config).not.toEqual(custom.config);
82
57
  });
83
58
  test("file-level skipped test files are excluded", () => {
84
59
  const root = writeTree({
@@ -95,28 +70,30 @@ describe("groupTestSources", () => {
95
70
  expect(groups).toHaveLength(1);
96
71
  expect(groups[0].files).toEqual([path.join(root, "live/main.agency")]);
97
72
  });
98
- test("configKey is key-order independent", () => {
99
- // Same config content, different key order in the two agency.json
100
- // files. JSON.stringify would produce different strings; the group
101
- // keys must still match so the dirs are config-compatible.
73
+ test("key-order-different but equal local configs are compatible (zod-normalized keys)", () => {
74
+ // Two dirs whose agency.json files have the same content in different
75
+ // key order, sharing an imported module. The session derives config
76
+ // keys via JSON.stringify, which is only order-stable because
77
+ // loadConfig routes through zod (schema shape order). If that
78
+ // normalization ever breaks, the keys diverge and the shared module
79
+ // trips the cross-config assert — this test is the tripwire.
102
80
  const root = writeTree({
81
+ shared: { "helper.agency": HELPER },
103
82
  a: {
104
- "main.agency": TRIVIAL,
83
+ "main.agency": IMPORTS_HELPER,
105
84
  "main.test.json": TEST_JSON,
106
- "agency.json": '{"verbose": true, "observability": false}',
85
+ "agency.json": '{"verbose": false, "observability": true}',
107
86
  },
108
87
  b: {
109
- "main.agency": TRIVIAL,
88
+ "main.agency": IMPORTS_HELPER,
110
89
  "main.test.json": TEST_JSON,
111
- "agency.json": '{"observability": false, "verbose": true}',
90
+ "agency.json": '{"observability": true, "verbose": false}',
112
91
  },
113
92
  });
114
- const groups = groupTestSources({}, [
93
+ expect(() => precompileTestSources({}, [
115
94
  path.join(root, "a/main.test.json"),
116
95
  path.join(root, "b/main.test.json"),
117
- ]);
118
- expect(groups).toHaveLength(2);
119
- expect(groups[0].configKey).toBe(groups[1].configKey);
96
+ ], { quiet: true })).not.toThrow();
120
97
  });
121
98
  test("test files without a sibling .agency are excluded", () => {
122
99
  const root = writeTree({