agency-lang 0.7.2 → 0.7.3

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,113 @@
1
+ /**
2
+ * The single owner of compile-pipeline caching and orchestration.
3
+ *
4
+ * Before this module, "how does compilation caching work" required reading
5
+ * four places: parseCache.ts (parse memo), commands.ts (three module
6
+ * globals + compileMany + ensureCompiledClosure), precompile.ts (config
7
+ * grouping + cross-config assert), and the directory branch of compile().
8
+ * A BuildSession now owns all of that state and logic; commands.ts keeps
9
+ * thin delegates for its existing exports.
10
+ *
11
+ * Contract (spec "Boundary constraints"): buildCompiledClosure remains a
12
+ * pure exported function — compileSource (lib/compiler/compile.ts) calls
13
+ * it directly for in-memory sandbox compiles and must not be affected.
14
+ *
15
+ * PR 2 adds `freshness`/the manifest here. Until then every compile is a
16
+ * full compile (the historical behavior).
17
+ */
18
+ import { AgencyConfig } from "../config.js";
19
+ import { SymbolTable } from "../symbolTable.js";
20
+ import { type ImportStrategy } from "../importStrategy.js";
21
+ export type CompileOptions = {
22
+ ts?: boolean;
23
+ symbolTable?: SymbolTable;
24
+ importStrategy?: ImportStrategy;
25
+ /** Suppress the per-file `input → output (in Nms)` progress line. */
26
+ quiet?: boolean;
27
+ /** Test-harness only. Honors `import test { … }`. Never set outside the
28
+ * test runner / analysis paths — kept off AgencyConfig so agent source
29
+ * cannot enable it. */
30
+ allowTestImports?: boolean;
31
+ };
32
+ /**
33
+ * A set of entry files sharing one effective config. The canonical config
34
+ * key is NOT part of this type: it is the session's own integrity
35
+ * mechanism (the cross-config assert compares it, and PR 2 makes it a
36
+ * manifest field), so the session derives it — a caller-supplied key that
37
+ * canonicalized differently would silently weaken the assert.
38
+ */
39
+ export type CompileGroup = {
40
+ /** Base-config marker or the local-`agency.json` dir, for error messages. */
41
+ label: string;
42
+ config: AgencyConfig;
43
+ /** Absolute `.agency` entry paths. */
44
+ files: string[];
45
+ };
46
+ export declare function createBuildSession(): BuildSession;
47
+ export declare class BuildSession {
48
+ private compiledFiles;
49
+ private currentClosure;
50
+ /** Compile a file or directory entry: recursive imports, per-session
51
+ * dedupe. Returns the output path (null for directories). */
52
+ compile(config: AgencyConfig, inputFile: string, outputFile?: string, options?: CompileOptions): string | null;
53
+ /** Compile a set of entry files under ONE union closure, like the
54
+ * directory branch of `compile()`. Closure errors THROW
55
+ * (`CompileClosureError`) so programmatic callers can attach context;
56
+ * parse/typecheck failures inside per-file compiles keep their exit
57
+ * behavior. */
58
+ compileMany(config: AgencyConfig, files: string[], options?: {
59
+ quiet?: boolean;
60
+ allowTestImports?: boolean;
61
+ }): void;
62
+ /** Compile config-heterogeneous groups (one union closure each), after
63
+ * asserting no module is reachable from groups whose configs differ —
64
+ * compiled output is config-dependent and a sibling `.js` is a single
65
+ * slot per module, so a shared module would be last-writer-wins.
66
+ * Throws `CompileClosureError` naming the module and group labels. */
67
+ compileGroups(groups: CompileGroup[], options?: {
68
+ quiet?: boolean;
69
+ allowTestImports?: boolean;
70
+ }): void;
71
+ /** Drop all cached state (watch-mode rebuild boundary). */
72
+ reset(): void;
73
+ private setClosure;
74
+ /**
75
+ * Internal variant of compileMany keeping the prebuilt-closure handoff
76
+ * (`options.closure`) used by compileGroups; the public method omits it —
77
+ * the closure MUST cover every file, a cache-coherence contract no
78
+ * external caller should have to carry.
79
+ */
80
+ private compileManyImpl;
81
+ /**
82
+ * Build the import-closure analysis once per compile session — at the
83
+ * outermost call, before any recursive per-file compile runs. The
84
+ * recursive children reuse the cached closure to get per-module init
85
+ * plans without re-parsing.
86
+ *
87
+ * "Outermost call" = no `options.symbolTable` (passed by recursive
88
+ * children). When the outermost call's entry file changes (e.g. the
89
+ * `agency test` runner iterates several .test.json fixtures in one
90
+ * process), the cached closure no longer covers the new entry's
91
+ * imports so we rebuild and drop the stale `compiledFiles` set.
92
+ * Without that drop, downstream codegen would look up plans for
93
+ * modules that aren't in the closure and emit an empty init plan.
94
+ *
95
+ * Stdlib files compile under their own entry (e.g., when a user runs
96
+ * `agency compile std/...`) but most user code reaches them via
97
+ * `import "std::..."`, which the closure walker intentionally skips.
98
+ * Avoid building a closure rooted at a stdlib file — its imports are
99
+ * structured differently and we don't need the analysis there.
100
+ */
101
+ private ensureCompiledClosure;
102
+ private compileDirectory;
103
+ private compileEntry;
104
+ }
105
+ export declare function readFile(inputFile: string): string;
106
+ export declare function findCrossConfigConflicts(groups: {
107
+ label: string;
108
+ configKey: string;
109
+ modules: string[];
110
+ }[]): {
111
+ module: string;
112
+ labels: string[];
113
+ }[];
@@ -0,0 +1,360 @@
1
+ import { generateTypeScript } from "../index.js";
2
+ import { initPlanForModule } from "../backends/typescriptGenerator.js";
3
+ import { resolveImports } from "../preprocessors/importResolver.js";
4
+ import { resolveReExports } from "../preprocessors/resolveReExports.js";
5
+ import { liftCallbackBlocks } from "../preprocessors/liftCallbacks.js";
6
+ import { buildCompilationUnit } from "../compilationUnit.js";
7
+ import { SymbolTable } from "../symbolTable.js";
8
+ import { formatErrors, typeCheck } from "../typeChecker/index.js";
9
+ import { buildCompiledClosure, CompileClosureError, } from "../compiler/compileClosure.js";
10
+ import { transformSync } from "esbuild";
11
+ import * as fs from "fs";
12
+ import * as path from "path";
13
+ import { getStdlibDir, isNonTemplatedStdlib, isPkgImport, isStdlibImport, resolveAgencyImportPath, } from "../importPaths.js";
14
+ import { CompileStrategy } from "../importStrategy.js";
15
+ import { parseAgencyFileCached } from "../parseCache.js";
16
+ import { findRecursively, getImports } from "../cli/util.js";
17
+ export function createBuildSession() {
18
+ return new BuildSession();
19
+ }
20
+ export class BuildSession {
21
+ compiledFiles = new Set();
22
+ // Cached `CompiledClosure` for this session. Built once at the outermost
23
+ // compile call and reused by every per-file emit.
24
+ currentClosure = null;
25
+ /** Compile a file or directory entry: recursive imports, per-session
26
+ * dedupe. Returns the output path (null for directories). */
27
+ compile(config, inputFile, outputFile, options) {
28
+ return this.compileEntry(config, inputFile, outputFile, options);
29
+ }
30
+ /** Compile a set of entry files under ONE union closure, like the
31
+ * directory branch of `compile()`. Closure errors THROW
32
+ * (`CompileClosureError`) so programmatic callers can attach context;
33
+ * parse/typecheck failures inside per-file compiles keep their exit
34
+ * behavior. */
35
+ compileMany(config, files, options) {
36
+ this.compileManyImpl(config, files, options);
37
+ }
38
+ /** Compile config-heterogeneous groups (one union closure each), after
39
+ * asserting no module is reachable from groups whose configs differ —
40
+ * compiled output is config-dependent and a sibling `.js` is a single
41
+ * slot per module, so a shared module would be last-writer-wins.
42
+ * Throws `CompileClosureError` naming the module and group labels. */
43
+ compileGroups(groups, options) {
44
+ const withClosures = groups.map((group) => ({
45
+ group,
46
+ configKey: deriveConfigKey(group.config),
47
+ closure: buildCompiledClosure(group.files, group.config),
48
+ }));
49
+ const conflicts = findCrossConfigConflicts(withClosures.map(({ group, configKey, closure }) => ({
50
+ label: group.label,
51
+ configKey,
52
+ modules: Object.keys(closure.programs),
53
+ })));
54
+ if (conflicts.length > 0) {
55
+ const lines = conflicts.map((c) => ` ${c.module}\n reachable from: ${c.labels.join(", ")}`);
56
+ throw new CompileClosureError("Test sources with differing configs share modules. A module's " +
57
+ "compiled .js is a single slot, so this would be last-writer-wins. " +
58
+ "Move the shared module or align the configs:\n" +
59
+ lines.join("\n"));
60
+ }
61
+ for (const { group, closure } of withClosures) {
62
+ this.compileManyImpl(group.config, group.files, {
63
+ closure,
64
+ quiet: options?.quiet,
65
+ allowTestImports: options?.allowTestImports,
66
+ });
67
+ }
68
+ }
69
+ /** Drop all cached state (watch-mode rebuild boundary). */
70
+ reset() {
71
+ this.setClosure(null);
72
+ }
73
+ // `compiledFiles` entries are only meaningful under the current closure,
74
+ // so replacing the closure MUST clear the set. Previously that pairing
75
+ // was enforced by hand at three separate call sites; this makes it
76
+ // structural (and protects the additional writers PR 2 introduces).
77
+ setClosure(closure) {
78
+ this.currentClosure = closure;
79
+ this.compiledFiles.clear();
80
+ }
81
+ /**
82
+ * Internal variant of compileMany keeping the prebuilt-closure handoff
83
+ * (`options.closure`) used by compileGroups; the public method omits it —
84
+ * the closure MUST cover every file, a cache-coherence contract no
85
+ * external caller should have to carry.
86
+ */
87
+ compileManyImpl(config, files, options) {
88
+ const absFiles = files.map((f) => path.resolve(f));
89
+ if (absFiles.length === 0)
90
+ return;
91
+ this.setClosure(options?.closure ?? buildCompiledClosure(absFiles, config));
92
+ for (const file of absFiles) {
93
+ this.compileEntry(config, file, undefined, {
94
+ quiet: options?.quiet,
95
+ allowTestImports: options?.allowTestImports,
96
+ });
97
+ }
98
+ }
99
+ /**
100
+ * Build the import-closure analysis once per compile session — at the
101
+ * outermost call, before any recursive per-file compile runs. The
102
+ * recursive children reuse the cached closure to get per-module init
103
+ * plans without re-parsing.
104
+ *
105
+ * "Outermost call" = no `options.symbolTable` (passed by recursive
106
+ * children). When the outermost call's entry file changes (e.g. the
107
+ * `agency test` runner iterates several .test.json fixtures in one
108
+ * process), the cached closure no longer covers the new entry's
109
+ * imports so we rebuild and drop the stale `compiledFiles` set.
110
+ * Without that drop, downstream codegen would look up plans for
111
+ * modules that aren't in the closure and emit an empty init plan.
112
+ *
113
+ * Stdlib files compile under their own entry (e.g., when a user runs
114
+ * `agency compile std/...`) but most user code reaches them via
115
+ * `import "std::..."`, which the closure walker intentionally skips.
116
+ * Avoid building a closure rooted at a stdlib file — its imports are
117
+ * structured differently and we don't need the analysis there.
118
+ */
119
+ ensureCompiledClosure(absoluteInputFile, config, hasSymbolTable, verbose) {
120
+ const isOutermostCall = !hasSymbolTable;
121
+ const isStdlibEntry = absoluteInputFile.startsWith(getStdlibDir() + path.sep);
122
+ const closureCoversEntry = this.currentClosure?.programs[absoluteInputFile] !== undefined;
123
+ if (!isOutermostCall || isStdlibEntry || closureCoversEntry)
124
+ return;
125
+ this.setClosure(null);
126
+ try {
127
+ const ccStartTime = performance.now();
128
+ this.setClosure(buildCompiledClosure(absoluteInputFile, config));
129
+ const ccEndTime = performance.now();
130
+ logTime({ label: "Built compile closure", start: ccStartTime, end: ccEndTime, verbose });
131
+ }
132
+ catch (e) {
133
+ if (e instanceof CompileClosureError) {
134
+ console.error(e.message);
135
+ process.exit(1);
136
+ }
137
+ throw e;
138
+ }
139
+ }
140
+ // A directory is many entry points — every .agency file under it. To
141
+ // avoid recompiling shared dependencies once per entry, build ONE
142
+ // import closure covering all of them up front and reuse it for every
143
+ // file. Without this, each sibling entry the previous closure didn't
144
+ // cover would clear the per-session cache (see ensureCompiledClosure)
145
+ // and recompile shared deps once per entry. Skipped for:
146
+ // - recursive child calls (a symbolTable was threaded in) — those
147
+ // aren't real top-level directory compiles; and
148
+ // - stdlib dirs, which intentionally compile without a closure (the
149
+ // same carve-out ensureCompiledClosure makes for stdlib entries).
150
+ compileDirectory(config, inputFile, options) {
151
+ const files = [...findRecursively(inputFile)].map((f) => f.path);
152
+ const absDir = path.resolve(inputFile);
153
+ const isStdlibDir = absDir === getStdlibDir() || absDir.startsWith(getStdlibDir() + path.sep);
154
+ if (!options?.symbolTable && !isStdlibDir && files.length > 0) {
155
+ try {
156
+ // The fresh union closure supersedes anything cached for a prior
157
+ // entry (setClosure drops the per-file set so codegen reruns under
158
+ // the new closure).
159
+ this.setClosure(buildCompiledClosure(files.map((f) => path.resolve(f)), config));
160
+ }
161
+ catch (e) {
162
+ if (e instanceof CompileClosureError) {
163
+ console.error(e.message);
164
+ process.exit(1);
165
+ }
166
+ throw e;
167
+ }
168
+ }
169
+ for (const file of files) {
170
+ this.compileEntry(config, file, undefined, options);
171
+ }
172
+ return null;
173
+ }
174
+ compileEntry(config, inputFile, _outputFile, options) {
175
+ if (!fs.existsSync(inputFile)) {
176
+ console.error(`Error: Input file '${inputFile}' not found`);
177
+ process.exit(1);
178
+ }
179
+ const stats = fs.statSync(inputFile);
180
+ const verbose = config.verbose ?? false;
181
+ if (stats.isDirectory()) {
182
+ return this.compileDirectory(config, inputFile, options);
183
+ }
184
+ const compileStartTime = performance.now();
185
+ const absoluteInputFile = path.resolve(inputFile);
186
+ this.ensureCompiledClosure(absoluteInputFile, config, !!options?.symbolTable, verbose);
187
+ const ext = options?.ts ? ".ts" : ".js";
188
+ // Anchor the replacement to the extension so that an absolute path
189
+ // containing ".agency" as a substring in a parent directory (e.g.
190
+ // "/Users/me/dev/worksy.agency-init/src/agent.agency") does not get
191
+ // the first match clobbered. See issue #48.
192
+ let outputFile = _outputFile || inputFile.replace(/\.agency$/, ext);
193
+ if (config.outDir && !_outputFile) {
194
+ const outputDir = path.resolve(config.outDir);
195
+ if (!fs.existsSync(outputDir)) {
196
+ fs.mkdirSync(outputDir, { recursive: true });
197
+ }
198
+ outputFile = path.join(outputDir, outputFile);
199
+ }
200
+ if (this.compiledFiles.has(absoluteInputFile)) {
201
+ return outputFile;
202
+ }
203
+ this.compiledFiles.add(absoluteInputFile);
204
+ const contents = readFile(inputFile);
205
+ const applyTemplate = !isNonTemplatedStdlib(absoluteInputFile);
206
+ const parsedProgram = parseFileOrExit(absoluteInputFile, config, applyTemplate, contents);
207
+ const symbolTableStartTime = performance.now();
208
+ const symbolTable = options?.symbolTable ?? SymbolTable.build(absoluteInputFile, config);
209
+ const symbolTableEndTime = performance.now();
210
+ logTime({ label: `Built symbol table for ${absoluteInputFile}`, start: symbolTableStartTime, end: symbolTableEndTime, verbose });
211
+ const compilationUnitStartTime = performance.now();
212
+ const reExportedProgram = resolveReExports(parsedProgram, symbolTable, absoluteInputFile);
213
+ const resolvedProgram = resolveImports(reExportedProgram, symbolTable, absoluteInputFile, {
214
+ allowTestImports: options?.allowTestImports ?? false,
215
+ });
216
+ // Lift `callback("onX") { ... }` block bodies to top-level defs.
217
+ // Must run BEFORE buildCompilationUnit and typecheck so the lifted defs
218
+ // appear in functionDefinitions and get their bodies typechecked.
219
+ const liftedProgram = liftCallbackBlocks(resolvedProgram);
220
+ const info = buildCompilationUnit(liftedProgram, symbolTable, absoluteInputFile, contents);
221
+ const compilationUnitEndTime = performance.now();
222
+ logTime({ label: `Built compilation unit for ${absoluteInputFile}`, start: compilationUnitStartTime, end: compilationUnitEndTime, verbose });
223
+ runTypecheck(liftedProgram, config, info, absoluteInputFile, verbose);
224
+ const imports = getImports(resolvedProgram);
225
+ for (const importPath of imports) {
226
+ if (isStdlibImport(importPath) || isPkgImport(importPath))
227
+ continue;
228
+ const absPath = resolveAgencyImportPath(importPath, absoluteInputFile);
229
+ this.compileEntry(config, absPath, undefined, { ...options, symbolTable });
230
+ }
231
+ // Rewrite import paths in the AST using the import strategy
232
+ const strategy = options?.importStrategy ?? new CompileStrategy({ targetExt: ".js" });
233
+ const nonAgencyImports = [];
234
+ liftedProgram.nodes.forEach((node) => {
235
+ if (node.type !== "importStatement")
236
+ return;
237
+ if (isStdlibImport(node.modulePath) || isPkgImport(node.modulePath))
238
+ return;
239
+ node.modulePath = strategy.rewriteImport(node.modulePath, absoluteInputFile);
240
+ if (!node.modulePath.endsWith(".agency")) {
241
+ nonAgencyImports.push(node.modulePath);
242
+ }
243
+ });
244
+ try {
245
+ strategy.prepareDependencies(nonAgencyImports, absoluteInputFile);
246
+ }
247
+ catch (error) {
248
+ console.error(error instanceof Error ? error.message : String(error));
249
+ process.exit(1);
250
+ }
251
+ const moduleId = path.relative(process.cwd(), absoluteInputFile);
252
+ const absoluteOutputFile = path.resolve(outputFile);
253
+ // Per-module init plan view — derived from the cached closure if we
254
+ // built one. Modules not in the closure (e.g., out-of-tree stdlib
255
+ // compiles) fall through to the legacy path with no plan.
256
+ const initPlan = this.currentClosure
257
+ ? initPlanForModule(this.currentClosure, absoluteInputFile)
258
+ : undefined;
259
+ const codegenStartTime = performance.now();
260
+ const generatedCode = generateTypeScript(liftedProgram, config, info, moduleId, absoluteOutputFile, initPlan);
261
+ const codegenEndTime = performance.now();
262
+ logTime({ label: `Generated code for ${absoluteInputFile}`, start: codegenStartTime, end: codegenEndTime, verbose });
263
+ if (options?.ts) {
264
+ fs.writeFileSync(outputFile, "// @ts-nocheck\n" + generatedCode, "utf-8");
265
+ }
266
+ else {
267
+ const esbuildStartTime = performance.now();
268
+ const result = transformSync(generatedCode, {
269
+ loader: "ts",
270
+ format: "esm",
271
+ supported: { "top-level-await": true },
272
+ });
273
+ fs.writeFileSync(outputFile, result.code, "utf-8");
274
+ const esbuildEndTime = performance.now();
275
+ logTime({ label: `Transformed code for ${absoluteInputFile} with esbuild`, start: esbuildStartTime, end: esbuildEndTime, verbose });
276
+ }
277
+ const compileEndTime = performance.now();
278
+ const timeTaken = `${(compileEndTime - compileStartTime).toFixed(2)}ms`;
279
+ if (!options?.quiet) {
280
+ console.log(`${inputFile} → ${outputFile} (in ${timeTaken})`);
281
+ }
282
+ return outputFile;
283
+ }
284
+ }
285
+ export function readFile(inputFile) {
286
+ if (!fs.existsSync(inputFile)) {
287
+ console.error(`Error: Input file '${inputFile}' not found`);
288
+ process.exit(1);
289
+ }
290
+ return fs.readFileSync(inputFile, "utf-8");
291
+ }
292
+ // Canonical config identity. JSON.stringify is order-stable here because
293
+ // every config passes through AgencyConfigSchema.safeParse (loadConfigSafe
294
+ // returns result.data), and zod rebuilds objects in SCHEMA shape order —
295
+ // guarded by the key-order test in lib/cli/precompile.test.ts.
296
+ function deriveConfigKey(config) {
297
+ return JSON.stringify(config);
298
+ }
299
+ export function findCrossConfigConflicts(groups) {
300
+ // Null-prototype: keyed by absolute module paths.
301
+ const touchedBy = Object.create(null);
302
+ for (const group of groups) {
303
+ for (const module of group.modules) {
304
+ (touchedBy[module] ??= []).push({
305
+ configKey: group.configKey,
306
+ label: group.label,
307
+ });
308
+ }
309
+ }
310
+ const conflicts = [];
311
+ for (const [module, touches] of Object.entries(touchedBy)) {
312
+ const distinctKeys = touches
313
+ .map((t) => t.configKey)
314
+ .filter((key, i, all) => all.indexOf(key) === i);
315
+ if (distinctKeys.length > 1) {
316
+ conflicts.push({ module, labels: touches.map((t) => t.label) });
317
+ }
318
+ }
319
+ return conflicts;
320
+ }
321
+ // Cached-parse counterpart of `parse()`: same exit-on-failure contract the
322
+ // CLI pipeline expects, but reads through the process-wide parse cache.
323
+ function parseFileOrExit(absPath, config, applyTemplate, contents) {
324
+ const parseResult = parseAgencyFileCached(absPath, config, applyTemplate);
325
+ if (!parseResult.success) {
326
+ if (parseResult.message) {
327
+ console.error(`Failed to parse Agency program: ${parseResult.message}`);
328
+ }
329
+ else {
330
+ console.error("Failed to parse Agency program.", contents.slice(0, 400));
331
+ }
332
+ process.exit(1);
333
+ }
334
+ return parseResult.result;
335
+ }
336
+ function runTypecheck(liftedProgram, config, info, absoluteInputFile, verbose) {
337
+ const tc = config.typechecker;
338
+ if (!tc?.enabled && !tc?.strict)
339
+ return;
340
+ const tcStartTime = performance.now();
341
+ const { errors } = typeCheck(liftedProgram, config, info);
342
+ const tcEndTime = performance.now();
343
+ logTime({ label: `Type checked ${absoluteInputFile}`, start: tcStartTime, end: tcEndTime, verbose });
344
+ if (errors.length === 0)
345
+ return;
346
+ if (tc?.strict) {
347
+ console.error(formatErrors(errors));
348
+ const hasFatal = errors.some((e) => (e.severity ?? "error") === "error");
349
+ if (hasFatal)
350
+ process.exit(1);
351
+ }
352
+ else {
353
+ console.warn(formatErrors(errors, "warning"));
354
+ }
355
+ }
356
+ function logTime({ label, start, end, verbose }) {
357
+ if (verbose) {
358
+ console.log(`${label} in ${(end - start).toFixed(2)}ms`);
359
+ }
360
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,195 @@
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { createBuildSession, findCrossConfigConflicts } from "./buildSession.js";
6
+ import { CompileClosureError } from "./compileClosure.js";
7
+ import { compile, resetCompilationCache } from "../cli/commands.js";
8
+ const TRIVIAL = 'node main() {\n return "ok"\n}\n';
9
+ const HELPER = 'export def helper(): string {\n return "shared"\n}\n';
10
+ const IMPORTS_HELPER = 'import { helper } from "./helper.agency"\n\nnode main() {\n return helper()\n}\n';
11
+ function writeTempDir(files) {
12
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agency-buildsession-"));
13
+ for (const [name, contents] of Object.entries(files)) {
14
+ fs.writeFileSync(path.join(dir, name), contents);
15
+ }
16
+ return dir;
17
+ }
18
+ // Back-date a file so any re-emit measurably moves its mtime, independent
19
+ // of filesystem timestamp granularity vs compile duration.
20
+ const BACKDATE = new Date("2020-01-01T00:00:00Z");
21
+ function backdate(file) {
22
+ fs.utimesSync(file, BACKDATE, BACKDATE);
23
+ return fs.statSync(file).mtimeMs;
24
+ }
25
+ // Count progress lines ("<input> → <output> (in Nms)") for a source file.
26
+ function emitCount(spy, sourceName) {
27
+ return spy.mock.calls.filter((args) => typeof args[0] === "string" && args[0].includes(`${sourceName} → `)).length;
28
+ }
29
+ afterEach(() => {
30
+ vi.restoreAllMocks();
31
+ });
32
+ describe("createBuildSession", () => {
33
+ test("compiles a single file and returns the output path", () => {
34
+ const dir = writeTempDir({ "main.agency": TRIVIAL });
35
+ const session = createBuildSession();
36
+ const out = session.compile({}, path.join(dir, "main.agency"), undefined, { quiet: true });
37
+ expect(out).toBe(path.join(dir, "main.js"));
38
+ expect(fs.existsSync(out)).toBe(true);
39
+ });
40
+ test("dedupes within a session: second compile of the same entry is a no-op", () => {
41
+ const dir = writeTempDir({ "main.agency": TRIVIAL });
42
+ const session = createBuildSession();
43
+ const entry = path.join(dir, "main.agency");
44
+ session.compile({}, entry, undefined, { quiet: true });
45
+ const stamped = backdate(path.join(dir, "main.js"));
46
+ session.compile({}, entry, undefined, { quiet: true });
47
+ expect(fs.statSync(path.join(dir, "main.js")).mtimeMs).toBe(stamped);
48
+ });
49
+ test("sessions are isolated: a fresh session recompiles", () => {
50
+ const dir = writeTempDir({ "main.agency": TRIVIAL });
51
+ const entry = path.join(dir, "main.agency");
52
+ createBuildSession().compile({}, entry, undefined, { quiet: true });
53
+ const stamped = backdate(path.join(dir, "main.js"));
54
+ createBuildSession().compile({}, entry, undefined, { quiet: true });
55
+ expect(fs.statSync(path.join(dir, "main.js")).mtimeMs).toBeGreaterThan(stamped);
56
+ });
57
+ test("reset() drops the dedupe state", () => {
58
+ const dir = writeTempDir({ "main.agency": TRIVIAL });
59
+ const entry = path.join(dir, "main.agency");
60
+ const session = createBuildSession();
61
+ session.compile({}, entry, undefined, { quiet: true });
62
+ const stamped = backdate(path.join(dir, "main.js"));
63
+ session.reset();
64
+ session.compile({}, entry, undefined, { quiet: true });
65
+ expect(fs.statSync(path.join(dir, "main.js")).mtimeMs).toBeGreaterThan(stamped);
66
+ });
67
+ test("compileMany compiles the shared import exactly once (union closure)", () => {
68
+ const dir = writeTempDir({
69
+ "helper.agency": HELPER,
70
+ "a.agency": IMPORTS_HELPER,
71
+ "b.agency": IMPORTS_HELPER,
72
+ });
73
+ const spy = vi.spyOn(console, "log");
74
+ createBuildSession().compileMany({}, [
75
+ path.join(dir, "a.agency"),
76
+ path.join(dir, "b.agency"),
77
+ ]);
78
+ for (const out of ["a.js", "b.js", "helper.js"]) {
79
+ expect(fs.existsSync(path.join(dir, out))).toBe(true);
80
+ }
81
+ // The property under test: helper compiles ONCE. A closure-per-entry
82
+ // regression compiles it twice and still leaves all files existing.
83
+ expect(emitCount(spy, "helper.agency")).toBe(1);
84
+ });
85
+ test("directory entries compile every member with one union closure", () => {
86
+ const dir = writeTempDir({
87
+ "helper.agency": HELPER,
88
+ "a.agency": IMPORTS_HELPER,
89
+ "b.agency": IMPORTS_HELPER,
90
+ });
91
+ const spy = vi.spyOn(console, "log");
92
+ const result = createBuildSession().compile({}, dir);
93
+ expect(result).toBeNull();
94
+ for (const out of ["a.js", "b.js", "helper.js"]) {
95
+ expect(fs.existsSync(path.join(dir, out))).toBe(true);
96
+ }
97
+ expect(emitCount(spy, "helper.agency")).toBe(1);
98
+ });
99
+ test("compileMany throws CompileClosureError for programmatic callers", () => {
100
+ const dir = writeTempDir({
101
+ "main.agency": 'import { gone } from "./missing.agency"\n\nnode main() {\n return gone()\n}\n',
102
+ });
103
+ expect(() => createBuildSession().compileMany({}, [path.join(dir, "main.agency")], { quiet: true })).toThrow(CompileClosureError);
104
+ });
105
+ test("allowTestImports gates import test compilation", () => {
106
+ const dir = writeTempDir({
107
+ "lib.agency": 'def secret(): string {\n return "s"\n}\n',
108
+ "main.agency": 'import test { secret } from "./lib.agency"\n\nnode main() {\n return secret()\n}\n',
109
+ });
110
+ const files = [path.join(dir, "main.agency")];
111
+ expect(() => createBuildSession().compileMany({}, files, { quiet: true })).toThrow(/only allowed under the test harness/);
112
+ createBuildSession().compileMany({}, files, { quiet: true, allowTestImports: true });
113
+ expect(fs.existsSync(path.join(dir, "main.js"))).toBe(true);
114
+ });
115
+ test("outDir routes output into the configured directory", () => {
116
+ // The outDir branch joins outDir with the INPUT-derived path, so it is
117
+ // designed for CWD-relative inputs (`agency compile main.agency` with
118
+ // an outDir config). Exercise it the way the CLI does: chdir into the
119
+ // project dir and pass a bare relative path.
120
+ const dir = writeTempDir({ "main.agency": TRIVIAL });
121
+ const outDir = path.join(dir, "built");
122
+ const previousCwd = process.cwd();
123
+ try {
124
+ process.chdir(dir);
125
+ const out = createBuildSession().compile({ outDir }, "main.agency", undefined, {
126
+ quiet: true,
127
+ });
128
+ expect(out).toBe(path.join(outDir, "main.js"));
129
+ expect(fs.existsSync(out)).toBe(true);
130
+ }
131
+ finally {
132
+ process.chdir(previousCwd);
133
+ }
134
+ });
135
+ });
136
+ describe("findCrossConfigConflicts", () => {
137
+ test("no conflict when groups with the same config share a module", () => {
138
+ const conflicts = findCrossConfigConflicts([
139
+ { label: "a", configKey: "{}", modules: ["/shared.agency"] },
140
+ { label: "b", configKey: "{}", modules: ["/shared.agency"] },
141
+ ]);
142
+ expect(conflicts).toEqual([]);
143
+ });
144
+ test("conflict when groups with differing configs share a module", () => {
145
+ const conflicts = findCrossConfigConflicts([
146
+ { label: "a", configKey: "{}", modules: ["/shared.agency", "/a.agency"] },
147
+ { label: "b", configKey: '{"verbose":true}', modules: ["/shared.agency"] },
148
+ ]);
149
+ expect(conflicts).toHaveLength(1);
150
+ expect(conflicts[0].module).toBe("/shared.agency");
151
+ expect(conflicts[0].labels.sort()).toEqual(["a", "b"]);
152
+ });
153
+ test("no conflict when differing-config groups touch disjoint modules", () => {
154
+ const conflicts = findCrossConfigConflicts([
155
+ { label: "a", configKey: "{}", modules: ["/a.agency"] },
156
+ { label: "b", configKey: '{"verbose":true}', modules: ["/b.agency"] },
157
+ ]);
158
+ expect(conflicts).toEqual([]);
159
+ });
160
+ });
161
+ describe("compileGroups", () => {
162
+ test("asserts loudly when differing configs share a module", () => {
163
+ const dir = writeTempDir({
164
+ "helper.agency": HELPER,
165
+ "a.agency": IMPORTS_HELPER,
166
+ "b.agency": IMPORTS_HELPER,
167
+ });
168
+ const groups = [
169
+ { label: "a", config: {}, files: [path.join(dir, "a.agency")] },
170
+ { label: "b", config: { verbose: false }, files: [path.join(dir, "b.agency")] },
171
+ ];
172
+ expect(() => createBuildSession().compileGroups(groups, { quiet: true })).toThrow(/helper\.agency/);
173
+ });
174
+ test("compiles compatible groups", () => {
175
+ const dir = writeTempDir({ "main.agency": TRIVIAL });
176
+ const groups = [
177
+ { label: "<base config>", config: {}, files: [path.join(dir, "main.agency")] },
178
+ ];
179
+ createBuildSession().compileGroups(groups, { quiet: true });
180
+ expect(fs.existsSync(path.join(dir, "main.js"))).toBe(true);
181
+ });
182
+ });
183
+ describe("commands.ts delegates", () => {
184
+ test("module-level compile + resetCompilationCache re-emits", () => {
185
+ const dir = writeTempDir({ "main.agency": TRIVIAL });
186
+ const entry = path.join(dir, "main.agency");
187
+ compile({}, entry, undefined, { quiet: true });
188
+ const stamped = backdate(path.join(dir, "main.js"));
189
+ compile({}, entry, undefined, { quiet: true });
190
+ expect(fs.statSync(path.join(dir, "main.js")).mtimeMs).toBe(stamped); // deduped
191
+ resetCompilationCache();
192
+ compile({}, entry, undefined, { quiet: true });
193
+ expect(fs.statSync(path.join(dir, "main.js")).mtimeMs).toBeGreaterThan(stamped);
194
+ });
195
+ });
@@ -1 +1 @@
1
- export declare const VERSION = "0.7.2";
1
+ export declare const VERSION = "0.7.3";