@genroc/eval-node 0.0.0-edge.d1df786 → 0.0.0-edge.d46a8ebf
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/LICENSE +202 -0
- package/README.md +58 -34
- package/dist/eval.js +27 -14
- package/dist/import.js +133 -126
- package/dist/realm.js +63 -80
- package/dist/worker.js +52 -64
- package/eval.ts +31 -20
- package/import.ts +219 -148
- package/package.json +1 -1
- package/realm.ts +58 -82
- package/worker.ts +59 -67
package/import.ts
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The code-phase resolver: manifest on stdin, `{"code": [...]}` on stdout,
|
|
3
|
-
// with
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
// over the same project.
|
|
9
|
-
|
|
10
|
-
import { spawn } from "node:child_process";
|
|
11
|
-
import { access, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
// The code-phase resolver: manifest on stdin, `{"code": [...]}` on stdout, diagnostic on stderr
|
|
3
|
+
// with a non-zero exit (specs/source-resolution.md). Two modes share one `tsc` pass: "types"
|
|
4
|
+
// writes declarations only, "build" typechecks and bundles.
|
|
5
|
+
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { access, writeFile } from "node:fs/promises";
|
|
12
8
|
import { builtinModules } from "node:module";
|
|
13
|
-
import { dirname, join,
|
|
14
|
-
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
10
|
|
|
16
11
|
import commonjs from "@rollup/plugin-commonjs";
|
|
17
12
|
import json from "@rollup/plugin-json";
|
|
@@ -22,20 +17,37 @@ import ts from "typescript";
|
|
|
22
17
|
type Schema = Record<string, any>;
|
|
23
18
|
|
|
24
19
|
type Site = {
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
// Which namespace the directive sits in: `process`, `task` or `action`.
|
|
21
|
+
level: "process" | "task" | "action";
|
|
27
22
|
task?: string;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
23
|
+
// What the site IS, at action level: the action's type, and the process a child call names.
|
|
24
|
+
action?: string;
|
|
25
|
+
child?: string;
|
|
26
|
+
// The slot as a path of keys and indices, so nothing has to unescape a JSON Pointer.
|
|
27
|
+
pointer: (string | number)[];
|
|
28
|
+
// Everything after `$<resolver>:`, verbatim. genctl does not read it as a path — that is
|
|
29
|
+
// this resolver's reading of it.
|
|
30
|
+
argument: string;
|
|
31
|
+
// What .genroc asked genctl to type, keyed by the name it chose. This resolver reads Input
|
|
32
|
+
// and Output; which ADDRESS each came from is the config's business, not ours.
|
|
33
|
+
types?: Record<string, Schema>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type ManifestProcess = {
|
|
37
|
+
name: string;
|
|
38
|
+
// The definition's own location, split because a relative argument is relative to the
|
|
39
|
+
// DIRECTORY — joining is ours to do, and this is the base.
|
|
40
|
+
dir: string;
|
|
41
|
+
file: string;
|
|
42
|
+
sites: Site[];
|
|
43
|
+
// Only what this process's fragments reach — a `$ref` in `types` points here.
|
|
44
|
+
$defs?: Record<string, Schema>;
|
|
32
45
|
};
|
|
33
46
|
|
|
34
47
|
type Manifest = {
|
|
35
48
|
mode: "types" | "build";
|
|
36
49
|
root: string;
|
|
37
|
-
|
|
38
|
-
sites: Site[];
|
|
50
|
+
processes: ManifestProcess[];
|
|
39
51
|
};
|
|
40
52
|
|
|
41
53
|
function die(message: string): never {
|
|
@@ -52,12 +64,6 @@ async function exists(path: string): Promise<boolean> {
|
|
|
52
64
|
}
|
|
53
65
|
}
|
|
54
66
|
|
|
55
|
-
/** Creates the parent directory, which `.genroc-cache/` relies on: nothing else makes it. */
|
|
56
|
-
async function write(path: string, content: string): Promise<void> {
|
|
57
|
-
await mkdir(dirname(path), { recursive: true });
|
|
58
|
-
await writeFile(path, content);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
67
|
// ── JSON Schema → TypeScript ───────────────────────────────────────────────────
|
|
62
68
|
|
|
63
69
|
/** Only genroc's keyword set is handled; anything else its strict decoder would have
|
|
@@ -72,13 +78,16 @@ function tsType(s: Schema | undefined, used: Set<string>): string {
|
|
|
72
78
|
if (Array.isArray(s.enum)) {
|
|
73
79
|
return s.enum.map((v: unknown) => JSON.stringify(v)).join(" | ") || "never";
|
|
74
80
|
}
|
|
75
|
-
if (Array.isArray(s.anyOf))
|
|
76
|
-
|
|
81
|
+
if (Array.isArray(s.anyOf))
|
|
82
|
+
return union(s.anyOf.map((a: Schema) => tsType(a, used)));
|
|
83
|
+
if (Array.isArray(s.oneOf))
|
|
84
|
+
return union(s.oneOf.map((a: Schema) => tsType(a, used)));
|
|
77
85
|
if (Array.isArray(s.allOf)) {
|
|
78
86
|
return s.allOf.map((a: Schema) => tsType(a, used)).join(" & ") || "unknown";
|
|
79
87
|
}
|
|
80
88
|
|
|
81
|
-
const types: string[] =
|
|
89
|
+
const types: string[] =
|
|
90
|
+
s.type === undefined ? [] : Array.isArray(s.type) ? s.type : [s.type];
|
|
82
91
|
if (types.length === 0) {
|
|
83
92
|
// The top type: `{}` means unknown, not "an empty object". specs/unknown-type.md.
|
|
84
93
|
return s.properties ? objectType(s, used) : "unknown";
|
|
@@ -111,16 +120,26 @@ function objectType(s: Schema, used: Set<string>): string {
|
|
|
111
120
|
const required = new Set<string>(s.required ?? []);
|
|
112
121
|
const lines: string[] = [];
|
|
113
122
|
for (const [key, sub] of Object.entries(props)) {
|
|
114
|
-
const doc =
|
|
115
|
-
|
|
123
|
+
const doc =
|
|
124
|
+
typeof sub.description === "string"
|
|
125
|
+
? ` /** ${sub.description} */\n`
|
|
126
|
+
: "";
|
|
127
|
+
lines.push(
|
|
128
|
+
`${doc} ${propKey(key)}${required.has(key) ? "" : "?"}: ${nest(tsType(sub, used))};`,
|
|
129
|
+
);
|
|
116
130
|
}
|
|
117
131
|
if (s.additionalProperties && typeof s.additionalProperties === "object") {
|
|
118
|
-
lines.push(
|
|
132
|
+
lines.push(
|
|
133
|
+
` [key: string]: ${nest(tsType(s.additionalProperties, used))};`,
|
|
134
|
+
);
|
|
119
135
|
}
|
|
120
136
|
if (lines.length === 0) return "Record<string, unknown>";
|
|
121
137
|
return `{\n${lines.join("\n")}\n}`;
|
|
122
138
|
}
|
|
123
139
|
|
|
140
|
+
/** Every line but the first, which is already placed by the property that opens it. */
|
|
141
|
+
const nest = (t: string) => t.replace(/\n/g, "\n ");
|
|
142
|
+
|
|
124
143
|
function union(parts: string[]): string {
|
|
125
144
|
const seen = [...new Set(parts)];
|
|
126
145
|
return seen.length === 0 ? "unknown" : seen.join(" | ");
|
|
@@ -128,32 +147,17 @@ function union(parts: string[]): string {
|
|
|
128
147
|
|
|
129
148
|
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
130
149
|
const propKey = (k: string) => (IDENT.test(k) ? k : JSON.stringify(k));
|
|
131
|
-
const identifier = (n: string) =>
|
|
132
|
-
|
|
133
|
-
function deref(s: Schema | undefined, defs: Record<string, Schema>): Schema | undefined {
|
|
134
|
-
let cur = s;
|
|
135
|
-
for (let i = 0; cur && typeof cur.$ref === "string" && i < 16; i++) {
|
|
136
|
-
cur = defs[cur.$ref.replace(/^#\/\$defs\//, "")];
|
|
137
|
-
}
|
|
138
|
-
return cur;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** The manifest's `input` is the type of the whole ACTION input — for /eval that is
|
|
142
|
-
* `{code, input, timeout_ms, …}`, and only its `input` field is bound as the script's
|
|
143
|
-
* parameter. genroc cannot know that; this file owns the evaluator's wire contract, so
|
|
144
|
-
* the navigation belongs here rather than in genctl. */
|
|
145
|
-
function scriptInput(site: Site, defs: Record<string, Schema>): Schema | undefined {
|
|
146
|
-
const action = deref(site.input, defs);
|
|
147
|
-
const bound = action?.properties?.input;
|
|
148
|
-
return bound ?? site.input;
|
|
149
|
-
}
|
|
150
|
+
const identifier = (n: string) =>
|
|
151
|
+
IDENT.test(n) ? n : `Def_${n.replace(/[^A-Za-z0-9_$]/g, "_")}`;
|
|
150
152
|
|
|
151
153
|
/** Emits one named type per reachable $def rather than inlining: a task output may
|
|
152
154
|
* reference itself (specs/recursive-type-inference.md) and inlining would not terminate. */
|
|
153
|
-
function declarations(
|
|
155
|
+
function declarations(at: Located): string {
|
|
156
|
+
const { site, where } = at;
|
|
157
|
+
const defs = where.$defs ?? {};
|
|
154
158
|
const used = new Set<string>();
|
|
155
|
-
const input = tsType(
|
|
156
|
-
const output = tsType(site.
|
|
159
|
+
const input = tsType(site.types?.Input, used);
|
|
160
|
+
const output = tsType(site.types?.Output, used);
|
|
157
161
|
|
|
158
162
|
const emitted: string[] = [];
|
|
159
163
|
const done = new Set<string>();
|
|
@@ -167,7 +171,7 @@ function declarations(site: Site, defs: Record<string, Schema>): string {
|
|
|
167
171
|
|
|
168
172
|
return [
|
|
169
173
|
"// Generated by genroc. Do not edit - regenerate with `genctl types`.",
|
|
170
|
-
`// ${
|
|
174
|
+
`// ${where.name} (${address(site.pointer)})`,
|
|
171
175
|
"",
|
|
172
176
|
...emitted,
|
|
173
177
|
emitted.length ? "" : "",
|
|
@@ -186,10 +190,12 @@ function typesPathFor(scriptPath: string): string {
|
|
|
186
190
|
|
|
187
191
|
// ── typecheck ──────────────────────────────────────────────────────────────────
|
|
188
192
|
|
|
189
|
-
/** The
|
|
190
|
-
*
|
|
191
|
-
|
|
192
|
-
|
|
193
|
+
/** The tsconfig the author's editor reads, so editor and apply agree. Stops at the project
|
|
194
|
+
* root. */
|
|
195
|
+
async function nearestTsconfig(
|
|
196
|
+
from: string,
|
|
197
|
+
root: string,
|
|
198
|
+
): Promise<string | null> {
|
|
193
199
|
for (let dir = from; ; dir = dirname(dir)) {
|
|
194
200
|
const candidate = join(dir, "tsconfig.json");
|
|
195
201
|
if (await exists(candidate)) return candidate;
|
|
@@ -197,26 +203,21 @@ async function nearestTsconfig(from: string, root: string): Promise<string | nul
|
|
|
197
203
|
}
|
|
198
204
|
}
|
|
199
205
|
|
|
200
|
-
async function typecheck(
|
|
201
|
-
//
|
|
202
|
-
// coexist with it. The suffix is what keeps the scratch area out of its way.
|
|
203
|
-
const dir = join(root, ".genroc-cache");
|
|
204
|
-
await write(join(dir, ".gitignore"), "*\n");
|
|
205
|
-
|
|
206
|
-
// One tsc per distinct base config: `extends` takes a single base, so merging two would
|
|
206
|
+
async function typecheck(sites: Located[]): Promise<void> {
|
|
207
|
+
// One program per distinct base config: `extends` takes a single base, so merging two would
|
|
207
208
|
// check each script under the other author's options.
|
|
208
|
-
const groups = new Map<string,
|
|
209
|
-
for (const
|
|
210
|
-
const base = (await nearestTsconfig(dirname(
|
|
209
|
+
const groups = new Map<string, Located[]>();
|
|
210
|
+
for (const at of sites) {
|
|
211
|
+
const base = (await nearestTsconfig(dirname(at.file), root)) ?? "";
|
|
211
212
|
const group = groups.get(base);
|
|
212
|
-
if (group) group.push(
|
|
213
|
-
else groups.set(base, [
|
|
213
|
+
if (group) group.push(at);
|
|
214
|
+
else groups.set(base, [at]);
|
|
214
215
|
}
|
|
215
216
|
|
|
216
|
-
|
|
217
|
+
const diagnostics: ts.Diagnostic[] = [];
|
|
217
218
|
for (const [base, group] of groups) {
|
|
218
219
|
const config: Record<string, unknown> = {
|
|
219
|
-
...(base ? { extends:
|
|
220
|
+
...(base ? { extends: base } : {}),
|
|
220
221
|
compilerOptions: {
|
|
221
222
|
noEmit: true,
|
|
222
223
|
strict: true,
|
|
@@ -227,40 +228,45 @@ async function typecheck(root: string, sites: Site[]): Promise<void> {
|
|
|
227
228
|
// `lib` DESCRIBES the realm and is written after `extends` so a base cannot widen it:
|
|
228
229
|
// a worker thread has no document, whatever an author's config claims.
|
|
229
230
|
lib: ["esnext", "webworker"],
|
|
230
|
-
// `types` is the author's
|
|
231
|
-
//
|
|
232
|
-
// base config there is nothing to opt in with, so the default stays none.
|
|
231
|
+
// `types` is the author's opt-in to node globals, which the realm has. With no base
|
|
232
|
+
// config there is nothing to opt in with, so it stays none.
|
|
233
233
|
...(base ? {} : { types: [] }),
|
|
234
234
|
},
|
|
235
|
-
files: group.flatMap((s) => [
|
|
235
|
+
files: group.flatMap((s) => [s.file, typesPathFor(s.file)]),
|
|
236
236
|
// `files` overrides the base's, but a base `include` survives beside it and would
|
|
237
237
|
// drag the author's whole tree in, to be checked under the worker lib.
|
|
238
238
|
include: [],
|
|
239
239
|
};
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
240
|
+
// Never written; anchors `@types` lookup at the root. Not `tsconfig.json`: that is often the
|
|
241
|
+
// base it extends, and tsc refuses a circular extends.
|
|
242
|
+
const parsed = ts.parseJsonConfigFileContent(
|
|
243
|
+
config,
|
|
244
|
+
ts.sys,
|
|
245
|
+
root,
|
|
246
|
+
undefined,
|
|
247
|
+
join(root, "tsconfig.genroc.json"),
|
|
248
|
+
);
|
|
249
|
+
const program = ts.createProgram({
|
|
250
|
+
rootNames: parsed.fileNames,
|
|
251
|
+
options: parsed.options,
|
|
252
|
+
projectReferences: parsed.projectReferences,
|
|
253
|
+
configFileParsingDiagnostics: parsed.errors,
|
|
254
|
+
});
|
|
255
|
+
diagnostics.push(...parsed.errors, ...ts.getPreEmitDiagnostics(program));
|
|
243
256
|
}
|
|
244
|
-
}
|
|
245
257
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
proc.on("close", (c) => resolve(c ?? 1));
|
|
259
|
-
});
|
|
260
|
-
if (code !== 0) {
|
|
261
|
-
// tsc reports on stdout; the exit code IS the type check, so this is the diagnostic
|
|
262
|
-
// genctl surfaces and the reason a failed import never produces a string.
|
|
263
|
-
die([out, err].filter(Boolean).join("\n").trimEnd());
|
|
258
|
+
if (diagnostics.length > 0) {
|
|
259
|
+
// The diagnostics are the whole type check: this is what genctl surfaces, and the reason a
|
|
260
|
+
// failed import never produces a string.
|
|
261
|
+
die(
|
|
262
|
+
ts
|
|
263
|
+
.formatDiagnostics(diagnostics, {
|
|
264
|
+
getCanonicalFileName: (f) => f,
|
|
265
|
+
getCurrentDirectory: () => root,
|
|
266
|
+
getNewLine: () => "\n",
|
|
267
|
+
})
|
|
268
|
+
.trimEnd(),
|
|
269
|
+
);
|
|
264
270
|
}
|
|
265
271
|
}
|
|
266
272
|
|
|
@@ -285,68 +291,82 @@ const transpile: Plugin = {
|
|
|
285
291
|
},
|
|
286
292
|
};
|
|
287
293
|
|
|
288
|
-
const BUILTIN = new Set([
|
|
294
|
+
const BUILTIN = new Set([
|
|
295
|
+
...builtinModules,
|
|
296
|
+
...builtinModules.map((m) => `node:${m}`),
|
|
297
|
+
]);
|
|
289
298
|
|
|
290
|
-
/** Resolves
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
* A package resolving to a `.d.ts` is declined — that is a type, not the implementation —
|
|
294
|
-
* which leaves node_modules to nodeResolve. */
|
|
299
|
+
/** Resolves through TYPESCRIPT under the typecheck's config, so a `paths` alias that compiles
|
|
300
|
+
* also bundles. A `.d.ts` resolution is declined (a type, not code), leaving node_modules to
|
|
301
|
+
* nodeResolve. */
|
|
295
302
|
function tsResolve(configPath: string | null): Plugin {
|
|
296
303
|
let options: ts.CompilerOptions = {};
|
|
297
304
|
if (configPath) {
|
|
298
305
|
const read = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
299
|
-
options = ts.parseJsonConfigFileContent(
|
|
306
|
+
options = ts.parseJsonConfigFileContent(
|
|
307
|
+
read.config ?? {},
|
|
308
|
+
ts.sys,
|
|
309
|
+
dirname(configPath),
|
|
310
|
+
).options;
|
|
300
311
|
}
|
|
301
312
|
return {
|
|
302
313
|
name: "genroc-ts-resolve",
|
|
303
314
|
resolveId(source, importer) {
|
|
304
315
|
if (!importer || BUILTIN.has(source)) return null;
|
|
305
|
-
const { resolvedModule } = ts.resolveModuleName(
|
|
306
|
-
|
|
307
|
-
|
|
316
|
+
const { resolvedModule } = ts.resolveModuleName(
|
|
317
|
+
source,
|
|
318
|
+
importer,
|
|
319
|
+
options,
|
|
320
|
+
ts.sys,
|
|
321
|
+
);
|
|
322
|
+
if (!resolvedModule || resolvedModule.isExternalLibraryImport)
|
|
323
|
+
return null;
|
|
324
|
+
return resolvedModule.resolvedFileName.endsWith(".d.ts")
|
|
325
|
+
? null
|
|
326
|
+
: resolvedModule.resolvedFileName;
|
|
308
327
|
},
|
|
309
328
|
};
|
|
310
329
|
}
|
|
311
330
|
|
|
312
|
-
/**
|
|
313
|
-
*
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
// Builtins are
|
|
317
|
-
//
|
|
318
|
-
// of a module that will not be there, which bundles clean and fails at runtime.
|
|
331
|
+
/** A self-contained ES module whose default export is the author's own, unwrapped. Bundling here
|
|
332
|
+
* means a definition version pins its code forever. */
|
|
333
|
+
async function bundle(at: Located): Promise<string> {
|
|
334
|
+
const site = at.site;
|
|
335
|
+
// Builtins are externalised; anything else unresolved is a REFUSAL, since rollup's default
|
|
336
|
+
// leaves an import that bundles clean and fails at runtime.
|
|
319
337
|
const built = await rollup({
|
|
320
|
-
input:
|
|
338
|
+
input: at.file,
|
|
321
339
|
external: (id) => BUILTIN.has(id),
|
|
322
340
|
plugins: [
|
|
323
|
-
tsResolve(await nearestTsconfig(dirname(
|
|
341
|
+
tsResolve(await nearestTsconfig(dirname(at.file), root)),
|
|
324
342
|
nodeResolve({ extensions: [".ts", ".tsx", ".mjs", ".js", ".json"] }),
|
|
325
343
|
commonjs(),
|
|
326
|
-
//
|
|
327
|
-
// did natively; without it rollup hands the JSON to the JS parser.
|
|
344
|
+
// Inlines `.json` imports; without it rollup hands JSON to the JS parser.
|
|
328
345
|
json(),
|
|
329
346
|
transpile,
|
|
330
347
|
],
|
|
331
348
|
onwarn(warning) {
|
|
332
349
|
if (warning.code === "UNRESOLVED_IMPORT") {
|
|
333
|
-
die(
|
|
350
|
+
die(
|
|
351
|
+
`${at.file}: cannot resolve ${warning.exporter ?? "an import"} — is it installed?`,
|
|
352
|
+
);
|
|
334
353
|
}
|
|
335
354
|
},
|
|
336
|
-
}).catch((e: unknown) =>
|
|
355
|
+
}).catch((e: unknown) =>
|
|
356
|
+
die(`${at.file}: ${e instanceof Error ? e.message : String(e)}`),
|
|
357
|
+
);
|
|
337
358
|
|
|
338
|
-
const { output } = await built.generate({
|
|
359
|
+
const { output } = await built.generate({
|
|
360
|
+
format: "es",
|
|
361
|
+
inlineDynamicImports: true,
|
|
362
|
+
});
|
|
339
363
|
await built.close();
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
` throw new Error(${JSON.stringify(`${site.path} has no default export function`)});`,
|
|
347
|
-
"}",
|
|
348
|
-
"return await __genroc_main(input);",
|
|
349
|
-
].join("\n");
|
|
364
|
+
// Refused here rather than in the realm: the evaluator can only report it against a running
|
|
365
|
+
// instance, and the file it names is on this machine.
|
|
366
|
+
if (!output[0].exports.includes("default")) {
|
|
367
|
+
die(`${at.file}: a script must \`export default\` the function to run`);
|
|
368
|
+
}
|
|
369
|
+
return output[0].code;
|
|
350
370
|
}
|
|
351
371
|
|
|
352
372
|
// ── main ───────────────────────────────────────────────────────────────────────
|
|
@@ -359,40 +379,91 @@ const stdin: string = await new Promise((resolve, reject) => {
|
|
|
359
379
|
process.stdin.on("error", reject);
|
|
360
380
|
});
|
|
361
381
|
const manifest = JSON.parse(stdin) as Manifest;
|
|
362
|
-
|
|
382
|
+
// genctl runs a resolver with the project root as its cwd, so the manifest need not say so.
|
|
383
|
+
const root = process.cwd();
|
|
384
|
+
if (!manifest || !Array.isArray(manifest.processes))
|
|
385
|
+
die("stdin is not a genroc resolver manifest");
|
|
386
|
+
|
|
387
|
+
/** Sites in manifest order, the order `code` must answer in. The argument is joined to the
|
|
388
|
+
* definition's directory here: only this resolver knows it is a path. */
|
|
389
|
+
type Located = { site: Site; where: ManifestProcess; file: string };
|
|
390
|
+
|
|
391
|
+
/** A pointer as the address it is, so a generated comment or an error can be pasted into
|
|
392
|
+
* `genctl schema`. A key no identifier can spell is bracketed, as the grammar spells it. */
|
|
393
|
+
function address(pointer: (string | number)[]): string {
|
|
394
|
+
return pointer
|
|
395
|
+
.map((seg) =>
|
|
396
|
+
typeof seg === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(seg)
|
|
397
|
+
? `.${seg}`
|
|
398
|
+
: `[${JSON.stringify(seg)}]`,
|
|
399
|
+
)
|
|
400
|
+
.join("")
|
|
401
|
+
.replace(/^\./, "");
|
|
402
|
+
}
|
|
403
|
+
const located: Located[] = manifest.processes.flatMap((where) =>
|
|
404
|
+
where.sites.map((site) => ({
|
|
405
|
+
site,
|
|
406
|
+
where,
|
|
407
|
+
file: resolve(where.dir, site.argument),
|
|
408
|
+
})),
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
// The `code`-field contract is this resolver's, not genctl's, so it checks the directive landed
|
|
412
|
+
// there: in a child call (the scaffold's shape) or an external task's input.
|
|
413
|
+
for (const at of located) {
|
|
414
|
+
// The action's kind is a field; where in it the directive sits is the pointer.
|
|
415
|
+
const kind = at.site.level === "action" ? (at.site.action ?? "") : "";
|
|
416
|
+
const slot = at.site.pointer.slice(-2).join(".");
|
|
417
|
+
if ((kind !== "child" && kind !== "external") || slot !== "input.code") {
|
|
418
|
+
die(
|
|
419
|
+
`${join(at.where.dir, at.where.file)}: ${address(at.site.pointer)}: an evaluated script ` +
|
|
420
|
+
"belongs in the `code` field of a child or external task's input, and this is " +
|
|
421
|
+
`${kind ? `a ${kind} task's ` : ""}\`${slot}\`.`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// genctl does not know the argument is a file, so this resolver reports a missing one.
|
|
427
|
+
for (const at of located) {
|
|
428
|
+
if (!existsSync(at.file)) {
|
|
429
|
+
die(
|
|
430
|
+
`${join(at.where.dir, at.where.file)}: ${address(at.site.pointer)}: ` +
|
|
431
|
+
`"$import: ${at.site.argument}" names no file (looked at ${at.file})`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
363
435
|
|
|
364
436
|
// One script at two sites with different input types is a refusal, not a union: the union
|
|
365
437
|
// is sound and would typecheck a body that is wrong at one of the sites.
|
|
366
|
-
const byPath = new Map<string,
|
|
367
|
-
for (const
|
|
368
|
-
const seen = byPath.get(
|
|
369
|
-
const defsOf = (x: Site) => (manifest.schemas[x.process]?.$defs ?? {}) as Record<string, Schema>;
|
|
438
|
+
const byPath = new Map<string, Located>();
|
|
439
|
+
for (const at of located) {
|
|
440
|
+
const seen = byPath.get(at.file);
|
|
370
441
|
if (
|
|
371
442
|
seen &&
|
|
372
|
-
JSON.stringify(
|
|
373
|
-
JSON.stringify(scriptInput(site, defsOf(site)))
|
|
443
|
+
JSON.stringify(seen.site.types) !== JSON.stringify(at.site.types)
|
|
374
444
|
) {
|
|
375
445
|
die(
|
|
376
|
-
`${
|
|
446
|
+
`${at.file} is imported at ${address(seen.site.pointer)} and ` +
|
|
447
|
+
`${address(at.site.pointer)} with different input types.\n` +
|
|
377
448
|
"Split it into two scripts, or make the two call sites pass the same shape.",
|
|
378
449
|
);
|
|
379
450
|
}
|
|
380
|
-
byPath.set(
|
|
451
|
+
byPath.set(at.file, at);
|
|
381
452
|
}
|
|
382
453
|
|
|
383
|
-
for (const
|
|
384
|
-
|
|
385
|
-
await write(typesPathFor(site.path), declarations(site, defs));
|
|
454
|
+
for (const at of byPath.values()) {
|
|
455
|
+
await writeFile(typesPathFor(at.file), declarations(at));
|
|
386
456
|
}
|
|
387
457
|
|
|
388
458
|
if (manifest.mode === "types") {
|
|
459
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
389
460
|
process.exit(0);
|
|
390
461
|
}
|
|
391
462
|
|
|
392
|
-
await typecheck(
|
|
463
|
+
await typecheck([...byPath.values()]);
|
|
393
464
|
|
|
394
465
|
const code: string[] = [];
|
|
395
|
-
for (const
|
|
396
|
-
code.push(await bundle(
|
|
466
|
+
for (const at of located) {
|
|
467
|
+
code.push(await bundle(at));
|
|
397
468
|
}
|
|
398
469
|
process.stdout.write(JSON.stringify({ code }));
|