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.
- 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 +59 -26
- package/dist/lib/agents/agency-agent/subagents/code.js +193 -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/runtime/agencyLlm.d.ts +7 -0
- package/dist/lib/runtime/agencyLlm.js +2 -0
- package/dist/lib/stdlib/threads.js +22 -2
- package/dist/lib/stdlib/version.d.ts +1 -1
- package/dist/lib/stdlib/version.js +1 -1
- package/package.json +1 -1
- package/stdlib/capabilities.agency +1 -1
- package/stdlib/capabilities.js +1 -1
- package/stdlib/data/wikidata.agency +269 -0
- package/stdlib/data/wikidata.js +2975 -0
- package/stdlib/thread.agency +12 -2
- package/stdlib/thread.js +32 -9
package/dist/lib/cli/commands.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `agency.json`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
27
|
-
import {
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 {
|
|
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.
|
|
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("
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
// keys
|
|
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":
|
|
83
|
+
"main.agency": IMPORTS_HELPER,
|
|
105
84
|
"main.test.json": TEST_JSON,
|
|
106
|
-
"agency.json": '{"verbose":
|
|
85
|
+
"agency.json": '{"verbose": false, "observability": true}',
|
|
107
86
|
},
|
|
108
87
|
b: {
|
|
109
|
-
"main.agency":
|
|
88
|
+
"main.agency": IMPORTS_HELPER,
|
|
110
89
|
"main.test.json": TEST_JSON,
|
|
111
|
-
"agency.json": '{"observability":
|
|
90
|
+
"agency.json": '{"observability": true, "verbose": false}',
|
|
112
91
|
},
|
|
113
92
|
});
|
|
114
|
-
|
|
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({
|