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.
- package/dist/lib/agents/agency-agent/agent.agency +7 -1
- package/dist/lib/agents/agency-agent/agent.js +31 -15
- package/dist/lib/agents/agency-agent/subagents/code.agency +55 -26
- package/dist/lib/agents/agency-agent/subagents/code.js +189 -29
- package/dist/lib/cli/commands.d.ts +9 -24
- package/dist/lib/cli/commands.js +26 -266
- package/dist/lib/cli/precompile.d.ts +3 -18
- package/dist/lib/cli/precompile.js +18 -66
- package/dist/lib/cli/precompile.test.js +16 -39
- package/dist/lib/compiler/buildSession.d.ts +113 -0
- package/dist/lib/compiler/buildSession.js +360 -0
- package/dist/lib/compiler/buildSession.test.d.ts +1 -0
- package/dist/lib/compiler/buildSession.test.js +195 -0
- package/dist/lib/stdlib/version.d.ts +1 -1
- package/dist/lib/stdlib/version.js +1 -1
- package/package.json +1 -1
|
@@ -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.
|
|
1
|
+
export declare const VERSION = "0.7.3";
|