@telorun/kernel 0.70.0 → 0.73.0
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/application-env.js +2 -2
- package/dist/application-env.js.map +1 -1
- package/dist/controllers/module/import-controller.d.ts.map +1 -1
- package/dist/controllers/module/import-controller.js +16 -1
- package/dist/controllers/module/import-controller.js.map +1 -1
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +8 -1
- package/dist/evaluation-context.js.map +1 -1
- package/dist/invocation-contract-binding.js +2 -2
- package/dist/invocation-contract-binding.js.map +1 -1
- package/dist/kernel.d.ts +4 -0
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +26 -3
- package/dist/kernel.js.map +1 -1
- package/dist/manifest-schemas.js +2 -2
- package/dist/manifest-schemas.js.map +1 -1
- package/dist/module-file-resolution.d.ts +22 -0
- package/dist/module-file-resolution.d.ts.map +1 -0
- package/dist/module-file-resolution.js +51 -0
- package/dist/module-file-resolution.js.map +1 -0
- package/dist/observed-state.js +2 -2
- package/dist/observed-state.js.map +1 -1
- package/dist/resolve-include-sentinels.d.ts +37 -0
- package/dist/resolve-include-sentinels.d.ts.map +1 -0
- package/dist/resolve-include-sentinels.js +175 -0
- package/dist/resolve-include-sentinels.js.map +1 -0
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +4 -39
- package/dist/resource-context.js.map +1 -1
- package/dist/runtime-seam.d.ts.map +1 -1
- package/dist/runtime-seam.js +33 -4
- package/dist/runtime-seam.js.map +1 -1
- package/dist/schema-validator.d.ts.map +1 -1
- package/dist/schema-validator.js +20 -35
- package/dist/schema-validator.js.map +1 -1
- package/package.json +4 -4
- package/src/application-env.ts +2 -2
- package/src/controllers/module/import-controller.ts +15 -1
- package/src/evaluation-context.ts +8 -1
- package/src/invocation-contract-binding.ts +2 -2
- package/src/kernel.ts +35 -3
- package/src/manifest-schemas.ts +2 -2
- package/src/module-file-resolution.ts +64 -0
- package/src/observed-state.ts +2 -2
- package/src/resolve-include-sentinels.ts +219 -0
- package/src/resource-context.ts +4 -39
- package/src/runtime-seam.ts +40 -3
- package/src/schema-validator.ts +19 -35
package/src/kernel.ts
CHANGED
|
@@ -52,6 +52,7 @@ import { nodeCelHandlers } from "./cel-handlers.js";
|
|
|
52
52
|
import { parseRef, seedInvokeSource } from "./invoke-dispatch.js";
|
|
53
53
|
import { stripCompiledValues } from "./schema-compiled-values.js";
|
|
54
54
|
import { injectAtPath } from "./dependency-injection.js";
|
|
55
|
+
import { resolveIncludeSentinels, type IncludeCache } from "./resolve-include-sentinels.js";
|
|
55
56
|
import {
|
|
56
57
|
computeAnalysisSignature,
|
|
57
58
|
readAnalysisStamp,
|
|
@@ -137,6 +138,10 @@ export class Kernel implements IKernel {
|
|
|
137
138
|
private idleResolvers: Array<() => void> = [];
|
|
138
139
|
private _exitCode = 0;
|
|
139
140
|
private readonly sharedSchemaValidator = new SchemaValidator();
|
|
141
|
+
/** `!include-*` reads, keyed by resolved URI, so two resources embedding the
|
|
142
|
+
* same asset read it once. Kernel-scoped: the values are retained by the
|
|
143
|
+
* resources holding them anyway, so this deduplicates rather than retains. */
|
|
144
|
+
private readonly includeCache: IncludeCache = new Map();
|
|
140
145
|
private rootContext!: ModuleContext;
|
|
141
146
|
private staticManifests: ResourceManifest[] = [];
|
|
142
147
|
private _entryUrl?: string;
|
|
@@ -266,7 +271,7 @@ export class Kernel implements IKernel {
|
|
|
266
271
|
}
|
|
267
272
|
|
|
268
273
|
async loadManifests(url: string): Promise<ResourceManifest[]> {
|
|
269
|
-
const graph = await this.loader.loadGraph(url, { desugarImports: true });
|
|
274
|
+
const graph = await this.loader.loadGraph(url, { desugarImports: true, migrate: true });
|
|
270
275
|
if (graph.errors.length > 0) throw graph.errors[0].error;
|
|
271
276
|
return flattenForAnalyzer(graph);
|
|
272
277
|
}
|
|
@@ -442,7 +447,7 @@ export class Kernel implements IKernel {
|
|
|
442
447
|
// `desugarImports` expands each module's inline `imports:` map into synthetic
|
|
443
448
|
// Telo.Import manifests before discovery walks the graph, so inline imports
|
|
444
449
|
// resolve identically to authored Telo.Import docs.
|
|
445
|
-
const analysisGraph = await this.loader.loadGraph(sourceUrl, { desugarImports: true });
|
|
450
|
+
const analysisGraph = await this.loader.loadGraph(sourceUrl, { desugarImports: true, migrate: true });
|
|
446
451
|
if (analysisGraph.errors.length > 0) {
|
|
447
452
|
throw analysisGraph.errors[0].error;
|
|
448
453
|
}
|
|
@@ -486,6 +491,20 @@ export class Kernel implements IKernel {
|
|
|
486
491
|
this.logging.kernelLogger().warn(d.message, { "telo.diagnostic.code": d.code });
|
|
487
492
|
}
|
|
488
493
|
}
|
|
494
|
+
// A migration rewrites the entry module's own manifest silently otherwise:
|
|
495
|
+
// `telo check`, VS Code and the editor all report the deprecation, and
|
|
496
|
+
// `telo run` — the command an author actually uses — would be the only
|
|
497
|
+
// surface that does not. Already scoped to the entry's own files by the
|
|
498
|
+
// loader, so a published dependency's spelling never appears here.
|
|
499
|
+
for (const d of analysisGraph.migrationDiagnostics) {
|
|
500
|
+
const filePath = (d.data as { filePath?: string } | undefined)?.filePath;
|
|
501
|
+
this.logging
|
|
502
|
+
.kernelLogger()
|
|
503
|
+
.warn(
|
|
504
|
+
filePath ? `${filePath}: ${d.message}` : d.message,
|
|
505
|
+
d.code === undefined ? undefined : { "telo.diagnostic.code": d.code },
|
|
506
|
+
);
|
|
507
|
+
}
|
|
489
508
|
const staticManifests = flattenForAnalyzer(analysisGraph);
|
|
490
509
|
this.staticManifests = staticManifests;
|
|
491
510
|
|
|
@@ -618,7 +637,7 @@ export class Kernel implements IKernel {
|
|
|
618
637
|
// routes them to the import-controller, which actually loads and runs them.
|
|
619
638
|
// Without it (analysis-only desugar) inline imports would pass validation
|
|
620
639
|
// and then never execute.
|
|
621
|
-
const lm = await this.loader.loadModule(sourceUrl, { compile: true, desugarImports: true });
|
|
640
|
+
const lm = await this.loader.loadModule(sourceUrl, { compile: true, desugarImports: true, migrate: true });
|
|
622
641
|
const allManifests = flattenLoadedModule(lm);
|
|
623
642
|
|
|
624
643
|
// Phase 2: normalize inline resources — extract inline values from x-telo-ref slots
|
|
@@ -1285,6 +1304,19 @@ export class Kernel implements IKernel {
|
|
|
1285
1304
|
const compile = [...parentEval.compile, ...ownEval.compile];
|
|
1286
1305
|
const runtime = [...parentEval.runtime, ...ownEval.runtime];
|
|
1287
1306
|
|
|
1307
|
+
// Embedded files are read here, at the single instance-production site, and
|
|
1308
|
+
// not at manifest load: `telo.yaml` is its own artifact layer so that reading
|
|
1309
|
+
// a manifest cannot pull the payload, and an app loads every imported
|
|
1310
|
+
// library's manifest. Before schema validation, so the schema sees the
|
|
1311
|
+
// resolved value — bytes arrive at an `x-telo-binary` slot as the
|
|
1312
|
+
// `Uint8Array` that annotation demands.
|
|
1313
|
+
await resolveIncludeSentinels(
|
|
1314
|
+
resource,
|
|
1315
|
+
this.findModuleContext(evalContext).source,
|
|
1316
|
+
this,
|
|
1317
|
+
this.includeCache,
|
|
1318
|
+
);
|
|
1319
|
+
|
|
1288
1320
|
// Schema validation runs before CEL evaluation so it sees the original manifest
|
|
1289
1321
|
// shape. CompiledValue wrappers (from load-time precompilation) are stripped,
|
|
1290
1322
|
// restoring the pre-CEL string view that the schema expects.
|
package/src/manifest-schemas.ts
CHANGED
|
@@ -2,7 +2,7 @@ import AjvModule from "ajv";
|
|
|
2
2
|
import addFormats from "ajv-formats";
|
|
3
3
|
// One definition of the `status:` block's shape, shared with `telo check` — the
|
|
4
4
|
// `required:` restriction is reported by the analyzer, which can name the fix.
|
|
5
|
-
import {
|
|
5
|
+
import { OBSERVED_STATE_SCHEMA, registerTeloKeywords } from "@telorun/analyzer";
|
|
6
6
|
const Ajv = AjvModule.default ?? AjvModule;
|
|
7
7
|
|
|
8
8
|
// Re-export the shared ResourceRef fragment from the templating package
|
|
@@ -177,7 +177,7 @@ export const ResourceAbstractSchema = {
|
|
|
177
177
|
};
|
|
178
178
|
|
|
179
179
|
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
180
|
-
ajv
|
|
180
|
+
registerTeloKeywords(ajv);
|
|
181
181
|
addFormats.default(ajv);
|
|
182
182
|
|
|
183
183
|
// Lazy-compile validator: the AJV codegen cost (≈10–15 ms for these
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import { pathToFileURL } from "url";
|
|
3
|
+
import { RuntimeError } from "@telorun/sdk";
|
|
4
|
+
import type { ModuleArtifact } from "./bundle/module-artifact.js";
|
|
5
|
+
|
|
6
|
+
/** The slice of the kernel this module needs: a module's artifact, when it has
|
|
7
|
+
* one. A module already on disk has none — that is normal, not an error. */
|
|
8
|
+
export interface ModuleArtifactLookup {
|
|
9
|
+
getModuleArtifact(source: string | undefined): ModuleArtifact | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a module-relative reference against the declaring module's own
|
|
14
|
+
* directory, materializing the layers that could carry it on first use.
|
|
15
|
+
*
|
|
16
|
+
* A URI, not a filesystem path: the SDK is cross-runtime, and a path is only
|
|
17
|
+
* what *this* kernel happens to return for a module whose files are local. An
|
|
18
|
+
* already-absolute URI (one with a scheme) passes through untouched; a bare
|
|
19
|
+
* absolute filesystem path is returned as a `file://` URI rather than being
|
|
20
|
+
* rebased onto the module directory.
|
|
21
|
+
*
|
|
22
|
+
* Shared by `ctx.resolveModuleFile` and by `!include-*` resolution, so a file
|
|
23
|
+
* reached by a controller and a file embedded by a tag are located by one rule
|
|
24
|
+
* — including which layers get materialized on the way.
|
|
25
|
+
*/
|
|
26
|
+
export async function resolveModuleFileUri(
|
|
27
|
+
relative: string,
|
|
28
|
+
source: string,
|
|
29
|
+
lookup: ModuleArtifactLookup,
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
// An absolute URI names its own location; a bare absolute path is already
|
|
32
|
+
// resolved and must not be rebased onto the module directory.
|
|
33
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(relative)) return relative;
|
|
34
|
+
if (path.isAbsolute(relative)) return pathToFileURL(relative).href;
|
|
35
|
+
|
|
36
|
+
const artifact = lookup.getModuleArtifact(source);
|
|
37
|
+
if (artifact) {
|
|
38
|
+
// Both the `assets` layer and `common` — the sink rule puts a file the
|
|
39
|
+
// author did not claim via `assets:` into `common`, and a module that ships
|
|
40
|
+
// static files with no bundled controller has no other route to its payload.
|
|
41
|
+
// Fetching only assets would leave such a module resolving into an empty
|
|
42
|
+
// directory.
|
|
43
|
+
await artifact.materializeModuleFiles();
|
|
44
|
+
return new URL(relative, pathToFileURL(path.join(artifact.directory, "/")).href).href;
|
|
45
|
+
}
|
|
46
|
+
// No artifact means no payload to fetch. That is normal for a module already
|
|
47
|
+
// on disk (development) or one that ships no files — but for a module reached
|
|
48
|
+
// over a non-local scheme it means the artifact carries no layer index, i.e. it
|
|
49
|
+
// predates layers. Raise the actionable error here rather than leaving each
|
|
50
|
+
// caller to invent its own message from a URI it cannot open.
|
|
51
|
+
if (!source.startsWith("file://") && !path.isAbsolute(source)) {
|
|
52
|
+
throw new RuntimeError(
|
|
53
|
+
"ERR_MODULE_FILES_UNAVAILABLE",
|
|
54
|
+
`Cannot resolve '${relative}' against module '${source}': the module's artifact ` +
|
|
55
|
+
`carries no layer index, so its files cannot be located. It was published by an ` +
|
|
56
|
+
`older Telo that wrote a single-blob artifact — republish the module, or import it ` +
|
|
57
|
+
`from a local path during development.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
// Local module: resolve against the manifest URL, the same rule `include:`
|
|
61
|
+
// and sibling imports follow.
|
|
62
|
+
const base = source.startsWith("file://") ? source : pathToFileURL(source).href;
|
|
63
|
+
return new URL(relative, base).href;
|
|
64
|
+
}
|
package/src/observed-state.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { registerTeloKeywords } from "@telorun/analyzer";
|
|
2
2
|
import AjvModule from "ajv";
|
|
3
3
|
import { detachSnapshotValue, OBSERVED_STATE_KEY, RuntimeError } from "@telorun/sdk";
|
|
4
4
|
|
|
@@ -58,7 +58,7 @@ function mark(target: Record<string, unknown>, info: ObservedStateInfo): void {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
61
|
-
ajv
|
|
61
|
+
registerTeloKeywords(ajv);
|
|
62
62
|
// Compiling a status schema costs ~ms and a resource may report repeatedly, so
|
|
63
63
|
// keep the validator keyed on the schema object it came from. The kind's folded
|
|
64
64
|
// `status:` is stamped once at registration, so this hits.
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { readFile, stat } from "fs/promises";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { RuntimeError, type ResourceManifest } from "@telorun/sdk";
|
|
4
|
+
import {
|
|
5
|
+
INCLUDE_BYTES_ENGINE,
|
|
6
|
+
isIncludeSentinel,
|
|
7
|
+
isTaggedSentinel,
|
|
8
|
+
normalizeIncludePath,
|
|
9
|
+
type TaggedSentinel,
|
|
10
|
+
} from "@telorun/templating";
|
|
11
|
+
import { resolveModuleFileUri, type ModuleArtifactLookup } from "./module-file-resolution.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ceiling on one embedded file.
|
|
15
|
+
*
|
|
16
|
+
* A resolved embed is an ordinary manifest value, retained for as long as the
|
|
17
|
+
* resource holding it, so there is no point at which a large one is released.
|
|
18
|
+
* Streaming a payload is a different primitive with a different lifetime, which
|
|
19
|
+
* is what the error points at.
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_INCLUDE_BYTES = 32 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
/** Reads keyed by resolved URI. Two resources embedding the same font read it
|
|
24
|
+
* once — the values are retained by those resources anyway, so the cache adds
|
|
25
|
+
* deduplication rather than retention. */
|
|
26
|
+
export type IncludeCache = Map<string, Uint8Array>;
|
|
27
|
+
|
|
28
|
+
/** Manifest objects already walked. Keyed by identity and weakly held, so this
|
|
29
|
+
* is a gate on repeated work rather than a lifetime extension. */
|
|
30
|
+
const resolved = new WeakSet<object>();
|
|
31
|
+
|
|
32
|
+
async function readIncluded(
|
|
33
|
+
uri: string,
|
|
34
|
+
displayPath: string,
|
|
35
|
+
cache: IncludeCache,
|
|
36
|
+
): Promise<Uint8Array> {
|
|
37
|
+
const cached = cache.get(uri);
|
|
38
|
+
if (cached) return cached;
|
|
39
|
+
|
|
40
|
+
if (!uri.startsWith("file:")) {
|
|
41
|
+
throw new RuntimeError(
|
|
42
|
+
"ERR_INCLUDE_UNREADABLE",
|
|
43
|
+
`Cannot embed '${displayPath}': it resolved to '${uri}', which this runtime cannot read ` +
|
|
44
|
+
`as a file. An embedded file must ship inside the module's own artifact.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const filePath = fileURLToPath(uri);
|
|
48
|
+
|
|
49
|
+
// Size is checked before reading, so an oversized file is reported rather
|
|
50
|
+
// than loaded to discover it was too big.
|
|
51
|
+
let size: number;
|
|
52
|
+
try {
|
|
53
|
+
const info = await stat(filePath);
|
|
54
|
+
if (!info.isFile()) {
|
|
55
|
+
throw new RuntimeError(
|
|
56
|
+
"ERR_INCLUDE_UNREADABLE",
|
|
57
|
+
`Cannot embed '${displayPath}': '${filePath}' is not a file.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
size = info.size;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error instanceof RuntimeError) throw error;
|
|
63
|
+
throw new RuntimeError(
|
|
64
|
+
"ERR_INCLUDE_FILE_NOT_FOUND",
|
|
65
|
+
`Cannot embed '${displayPath}': no such file at '${filePath}'. The path is relative to ` +
|
|
66
|
+
`the module root — the directory holding telo.yaml — not to the file the tag was ` +
|
|
67
|
+
`written in.`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (size > MAX_INCLUDE_BYTES) {
|
|
71
|
+
// Megabytes, not raw bytes: the limit is a round number chosen for a human,
|
|
72
|
+
// and this message is aimed at one.
|
|
73
|
+
const mb = (n: number) => `${Math.round((n / (1024 * 1024)) * 10) / 10} MB`;
|
|
74
|
+
throw new RuntimeError(
|
|
75
|
+
"ERR_INCLUDE_FILE_TOO_LARGE",
|
|
76
|
+
`Cannot embed '${displayPath}': it is ${mb(size)}, over the ${mb(MAX_INCLUDE_BYTES)} ` +
|
|
77
|
+
`limit for a file embedded into a manifest value. Read it at runtime with Fs.File ` +
|
|
78
|
+
`instead, which does not retain it for the life of the resource.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const bytes = new Uint8Array(await readFile(filePath));
|
|
83
|
+
cache.set(uri, bytes);
|
|
84
|
+
return bytes;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function resolveSentinel(
|
|
88
|
+
sentinel: TaggedSentinel,
|
|
89
|
+
moduleSource: string,
|
|
90
|
+
lookup: ModuleArtifactLookup,
|
|
91
|
+
cache: IncludeCache,
|
|
92
|
+
): Promise<string | Uint8Array> {
|
|
93
|
+
// Re-checked here rather than trusted from `telo check`: the kernel does not
|
|
94
|
+
// require that check to have run, and confinement is the one property whose
|
|
95
|
+
// absence is a security question rather than a broken build.
|
|
96
|
+
const { path: relative, diagnostic } = normalizeIncludePath(sentinel.source);
|
|
97
|
+
if (!relative) {
|
|
98
|
+
throw new RuntimeError(
|
|
99
|
+
"ERR_INCLUDE_PATH_INVALID",
|
|
100
|
+
`Invalid \`!${sentinel.engine}\` path: ${diagnostic?.message ?? "not a module-relative path."}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const uri = await resolveModuleFileUri(relative, moduleSource, lookup);
|
|
105
|
+
const bytes = await readIncluded(uri, relative, cache);
|
|
106
|
+
if (sentinel.engine === INCLUDE_BYTES_ENGINE) return bytes;
|
|
107
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Replace every `!include-text` / `!include-bytes` sentinel in a resource's
|
|
112
|
+
* config with the file's contents, in place.
|
|
113
|
+
*
|
|
114
|
+
* Called at resource creation — the kernel's single instance-production site —
|
|
115
|
+
* and NOT during manifest load. The artifact spec gives `telo.yaml` a layer of
|
|
116
|
+
* its own precisely so that reading a manifest cannot pull the whole artifact;
|
|
117
|
+
* resolving at load would defeat that just as thoroughly, because loading an app
|
|
118
|
+
* loads every imported library's manifest and would fetch every library's assets
|
|
119
|
+
* layer whether or not anything used it. Resolving here bounds the cost to
|
|
120
|
+
* modules whose resources actually instantiate, and a `with:`-scoped resource
|
|
121
|
+
* pays only when its scope runs.
|
|
122
|
+
*
|
|
123
|
+
* It runs BEFORE the resource's schema validation, so a resolved value is what
|
|
124
|
+
* the schema sees: bytes reach an `x-telo-binary` slot as the `Uint8Array` that
|
|
125
|
+
* annotation demands, and an unresolved marker never reaches a controller.
|
|
126
|
+
*
|
|
127
|
+
* In place, like `resolveRefSentinels` — which also makes it idempotent, so a
|
|
128
|
+
* scoped resource created once per scope run reads its files once.
|
|
129
|
+
*/
|
|
130
|
+
export async function resolveIncludeSentinels(
|
|
131
|
+
resource: ResourceManifest,
|
|
132
|
+
moduleSource: string,
|
|
133
|
+
lookup: ModuleArtifactLookup,
|
|
134
|
+
cache: IncludeCache,
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
// A resource that defers across init passes reaches `create()` more than once,
|
|
137
|
+
// and a `with:`-scoped one is created per scope run — the walk is idempotent
|
|
138
|
+
// either way, so repeating it is pure cost on the init loop. One walk per
|
|
139
|
+
// manifest object is enough: resolution rewrites it in place.
|
|
140
|
+
if (resolved.has(resource)) return;
|
|
141
|
+
|
|
142
|
+
const pending: Array<Promise<void>> = [];
|
|
143
|
+
|
|
144
|
+
/** A nested resource DECLARATION — an inline `{ kind, … }`, whether it sits in
|
|
145
|
+
* a `with:` scope or in a step's `invoke:`. Its embeds belong to it, not to
|
|
146
|
+
* the resource that encloses it, and it reaches `create()` in its own right.
|
|
147
|
+
* Phase-5 injection draws the same line for `!ref`s inside a scope. */
|
|
148
|
+
const isNestedDeclaration = (value: unknown): boolean =>
|
|
149
|
+
value !== null &&
|
|
150
|
+
typeof value === "object" &&
|
|
151
|
+
!Array.isArray(value) &&
|
|
152
|
+
typeof (value as { kind?: unknown }).kind === "string";
|
|
153
|
+
|
|
154
|
+
const take = (item: unknown, assign: (resolved: string | Uint8Array) => void): void => {
|
|
155
|
+
if (isIncludeSentinel(item)) {
|
|
156
|
+
pending.push(resolveSentinel(item, moduleSource, lookup, cache).then(assign));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Another engine's sentinel is opaque; a nested declaration is resolved when
|
|
160
|
+
// that resource is created, which is what keeps a scoped resource's files
|
|
161
|
+
// unread until its scope actually runs.
|
|
162
|
+
if (!isTaggedSentinel(item) && !isNestedDeclaration(item)) walk(item);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const walk = (value: unknown): void => {
|
|
166
|
+
if (value === null || typeof value !== "object") return;
|
|
167
|
+
// A compiled CEL node is opaque and carries no sentinels of its own.
|
|
168
|
+
if ((value as { __compiled?: unknown }).__compiled) return;
|
|
169
|
+
// Only PLAIN containers are descended into. A template kind expands
|
|
170
|
+
// `${{ self.connection }}` to a LIVE ResourceInstance, whose object graph
|
|
171
|
+
// reaches back into contexts and the kernel and contains cycles — walking it
|
|
172
|
+
// overflows the stack, and nothing in it could be a manifest value anyway.
|
|
173
|
+
// Same rule `compileWalker` and `precompileDoc` follow.
|
|
174
|
+
if (!Array.isArray(value)) {
|
|
175
|
+
const proto = Object.getPrototypeOf(value);
|
|
176
|
+
if (proto !== Object.prototype && proto !== null) return;
|
|
177
|
+
}
|
|
178
|
+
if (Array.isArray(value)) {
|
|
179
|
+
for (let i = 0; i < value.length; i++) {
|
|
180
|
+
const index = i;
|
|
181
|
+
take(value[index], (resolved) => {
|
|
182
|
+
value[index] = resolved;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const obj = value as Record<string, unknown>;
|
|
188
|
+
for (const key of Object.keys(obj)) {
|
|
189
|
+
take(obj[key], (resolved) => {
|
|
190
|
+
obj[key] = resolved;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// The resource being created is itself a `{ kind, … }` declaration, so the walk
|
|
196
|
+
// starts INSIDE it rather than at it — only the ones nested below are deferred.
|
|
197
|
+
walk(resource as Record<string, unknown>);
|
|
198
|
+
// Reads run concurrently and every failure surfaces: one unreadable file must
|
|
199
|
+
// not be hidden by another failing first.
|
|
200
|
+
const settled = await Promise.allSettled(pending);
|
|
201
|
+
const failures = settled.flatMap((r) => (r.status === "rejected" ? [r.reason] : []));
|
|
202
|
+
resolved.add(resource);
|
|
203
|
+
if (failures.length === 0) return;
|
|
204
|
+
// The FIRST failure is rethrown, carrying its own code and its own cause.
|
|
205
|
+
// Wrapping several into one generic error made the reported code depend on how
|
|
206
|
+
// many files happened to fail — two missing files became ERR_INCLUDE_UNREADABLE
|
|
207
|
+
// rather than ERR_INCLUDE_FILE_NOT_FOUND — so a caller matching on the code was
|
|
208
|
+
// misled by an artefact of the batch, and the individual errors were flattened
|
|
209
|
+
// into a string nothing could branch on. The rest are attached as `causes`, and
|
|
210
|
+
// named in the message so none is hidden.
|
|
211
|
+
const [first, ...rest] = failures as Error[];
|
|
212
|
+
if (rest.length > 0 && first instanceof RuntimeError) {
|
|
213
|
+
(first as RuntimeError & { causes?: unknown[] }).causes = rest;
|
|
214
|
+
first.message += ` (${rest.length} more embed${rest.length === 1 ? "" : "s"} also failed: ${rest
|
|
215
|
+
.map((f) => f.message)
|
|
216
|
+
.join("; ")})`;
|
|
217
|
+
}
|
|
218
|
+
throw first;
|
|
219
|
+
}
|
package/src/resource-context.ts
CHANGED
|
@@ -28,13 +28,14 @@ import {
|
|
|
28
28
|
type TypeRule,
|
|
29
29
|
type ZoneEntry,
|
|
30
30
|
} from "@telorun/sdk";
|
|
31
|
-
import {
|
|
31
|
+
import { registerTeloKeywords } from "@telorun/analyzer";
|
|
32
32
|
import { isRefSentinel } from "@telorun/templating";
|
|
33
33
|
import { ZoneContext } from "./zone-context.js";
|
|
34
34
|
import * as path from "path";
|
|
35
35
|
import { pathToFileURL } from "url";
|
|
36
36
|
import { withBigIntsAsNumbers } from "./bigint-schema-view.js";
|
|
37
37
|
import type { ModuleArtifact } from "./bundle/module-artifact.js";
|
|
38
|
+
import { resolveModuleFileUri } from "./module-file-resolution.js";
|
|
38
39
|
import { hostEnv } from "./host-env.js";
|
|
39
40
|
import type { LoggingHost } from "./logging/logging-host.js";
|
|
40
41
|
import type { ScopeConfig } from "./logging/scope-config.js";
|
|
@@ -316,10 +317,7 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
316
317
|
removeAdditional: true,
|
|
317
318
|
});
|
|
318
319
|
addFormats.default(ajv);
|
|
319
|
-
|
|
320
|
-
ajv.addKeyword(kw);
|
|
321
|
-
}
|
|
322
|
-
ajv.addKeyword(binaryKeyword());
|
|
320
|
+
registerTeloKeywords(ajv);
|
|
323
321
|
const validate = ajv.compile(
|
|
324
322
|
"type" in schema && typeof schema.type === "string"
|
|
325
323
|
? schema
|
|
@@ -794,40 +792,7 @@ export class ResourceContextImpl implements ResourceContext {
|
|
|
794
792
|
* rebased onto the module directory.
|
|
795
793
|
*/
|
|
796
794
|
async resolveModuleFile(relative: string): Promise<string> {
|
|
797
|
-
|
|
798
|
-
// resolved and must not be rebased onto the module directory.
|
|
799
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(relative)) return relative;
|
|
800
|
-
if (path.isAbsolute(relative)) return pathToFileURL(relative).href;
|
|
801
|
-
|
|
802
|
-
const source = this.moduleContext.source;
|
|
803
|
-
const artifact = this.kernel.getModuleArtifact(source);
|
|
804
|
-
if (artifact) {
|
|
805
|
-
// Both the `assets` layer and `common` — the sink rule puts a file the
|
|
806
|
-
// author did not claim via `assets:` into `common`, and a module that ships
|
|
807
|
-
// static files with no bundled controller has no other route to its payload.
|
|
808
|
-
// Fetching only assets would leave such a module resolving into an empty
|
|
809
|
-
// directory.
|
|
810
|
-
await artifact.materializeModuleFiles();
|
|
811
|
-
return new URL(relative, pathToFileURL(path.join(artifact.directory, "/")).href).href;
|
|
812
|
-
}
|
|
813
|
-
// No artifact means no payload to fetch. That is normal for a module already
|
|
814
|
-
// on disk (development) or one that ships no files — but for a module reached
|
|
815
|
-
// over a non-local scheme it means the artifact carries no layer index, i.e. it
|
|
816
|
-
// predates layers. Raise the actionable error here rather than leaving each
|
|
817
|
-
// caller to invent its own message from a URI it cannot open.
|
|
818
|
-
if (!source.startsWith("file://") && !path.isAbsolute(source)) {
|
|
819
|
-
throw new RuntimeError(
|
|
820
|
-
"ERR_MODULE_FILES_UNAVAILABLE",
|
|
821
|
-
`Cannot resolve '${relative}' against module '${source}': the module's artifact ` +
|
|
822
|
-
`carries no layer index, so its files cannot be located. It was published by an ` +
|
|
823
|
-
`older Telo that wrote a single-blob artifact — republish the module, or import it ` +
|
|
824
|
-
`from a local path during development.`,
|
|
825
|
-
);
|
|
826
|
-
}
|
|
827
|
-
// Local module: resolve against the manifest URL, the same rule `include:`
|
|
828
|
-
// and sibling imports follow.
|
|
829
|
-
const base = source.startsWith("file://") ? source : pathToFileURL(source).href;
|
|
830
|
-
return new URL(relative, base).href;
|
|
795
|
+
return resolveModuleFileUri(relative, this.moduleContext.source, this.kernel);
|
|
831
796
|
}
|
|
832
797
|
|
|
833
798
|
on(event: string, handler: (payload?: any) => void | Promise<void>): void {
|
package/src/runtime-seam.ts
CHANGED
|
@@ -2,8 +2,12 @@ import {
|
|
|
2
2
|
Loader,
|
|
3
3
|
StaticAnalyzer,
|
|
4
4
|
collectZoneModuleDocuments,
|
|
5
|
+
diagnosticFix,
|
|
5
6
|
flattenForAnalyzer,
|
|
7
|
+
remapMigratedPaths,
|
|
6
8
|
type AnalysisDiagnostic,
|
|
9
|
+
type DiagnosticData,
|
|
10
|
+
type LoadedGraph,
|
|
7
11
|
type ManifestSource,
|
|
8
12
|
type ZoneModuleDocuments,
|
|
9
13
|
} from "@telorun/analyzer";
|
|
@@ -116,6 +120,12 @@ const SEVERITY_NAMES: Record<number, CheckDiagnosticSeverity> = {
|
|
|
116
120
|
* read by kernels that speak no LSP. An unlabelled severity is an error: the
|
|
117
121
|
* analyzer's own default, and the safe reading for a caller gating on it. */
|
|
118
122
|
function toCheckDiagnostic(diagnostic: AnalysisDiagnostic): CheckDiagnostic {
|
|
123
|
+
// The repair is read through the analyzer's accessor rather than by casting
|
|
124
|
+
// `data`, so the stamp's shape stays owned by one module. `resource` / `path`
|
|
125
|
+
// ride along because a repair replaces the value AT `path`; forwarding the
|
|
126
|
+
// fix without its anchor gives a consumer something it cannot apply.
|
|
127
|
+
const fix = diagnosticFix(diagnostic);
|
|
128
|
+
const stamp = diagnostic.data as DiagnosticData | undefined;
|
|
119
129
|
return {
|
|
120
130
|
code: String(diagnostic.code ?? ""),
|
|
121
131
|
message: diagnostic.message,
|
|
@@ -123,6 +133,9 @@ function toCheckDiagnostic(diagnostic: AnalysisDiagnostic): CheckDiagnostic {
|
|
|
123
133
|
source: diagnostic.source,
|
|
124
134
|
line: diagnostic.range?.start?.line,
|
|
125
135
|
column: diagnostic.range?.start?.character,
|
|
136
|
+
...(stamp?.resource ? { resource: `${stamp.resource.kind}/${stamp.resource.name}` } : {}),
|
|
137
|
+
...(stamp?.path ? { path: stamp.path } : {}),
|
|
138
|
+
...(fix ? { fix: { replacement: fix.replacement } } : {}),
|
|
126
139
|
};
|
|
127
140
|
}
|
|
128
141
|
|
|
@@ -213,14 +226,26 @@ export class KernelRuntimeSeam implements RuntimeSeam {
|
|
|
213
226
|
// `analyze()`'s — carried out of the try so the checks below can see them.
|
|
214
227
|
let parseDiagnostics: AnalysisDiagnostic[] = [];
|
|
215
228
|
let versionDiagnostics: AnalysisDiagnostic[] = [];
|
|
229
|
+
let migrationDiagnostics: AnalysisDiagnostic[] = [];
|
|
216
230
|
let moduleDocuments: ZoneModuleDocuments[] = [];
|
|
231
|
+
// Carried out of the try for the same reason the diagnostics are: analysis
|
|
232
|
+
// runs over the MIGRATED tree while every path a caller resolves points at
|
|
233
|
+
// the raw file, so the driver's provenance record has to be in hand below.
|
|
234
|
+
let loadedGraph: LoadedGraph | undefined;
|
|
217
235
|
try {
|
|
236
|
+
// `migrate` unconditionally: this seam answers "does this manifest load
|
|
237
|
+
// and check", and the runtime it stands in for reads a legacy spelling
|
|
238
|
+
// through the same rewrite. A raw view is a round-trip editor's need, not
|
|
239
|
+
// a supervisor's.
|
|
218
240
|
const graph = await loader.loadGraph(source, {
|
|
219
241
|
desugarImports: options?.desugarImports ?? true,
|
|
242
|
+
migrate: true,
|
|
220
243
|
});
|
|
221
244
|
if (graph.errors.length > 0) throw graph.errors[0].error;
|
|
245
|
+
loadedGraph = graph;
|
|
222
246
|
parseDiagnostics = graph.parseDiagnostics;
|
|
223
247
|
versionDiagnostics = graph.versionDiagnostics;
|
|
248
|
+
migrationDiagnostics = graph.migrationDiagnostics;
|
|
224
249
|
manifests = flattenForAnalyzer(graph);
|
|
225
250
|
// The zone stage derives each imported library's export contracts from
|
|
226
251
|
// its own full documents, which the flattened list drops.
|
|
@@ -243,17 +268,29 @@ export class KernelRuntimeSeam implements RuntimeSeam {
|
|
|
243
268
|
// treating a parse failure as fatal before analysis.
|
|
244
269
|
if (parseDiagnostics.length > 0) {
|
|
245
270
|
return {
|
|
246
|
-
diagnostics: [...parseDiagnostics, ...versionDiagnostics].map(
|
|
271
|
+
diagnostics: [...parseDiagnostics, ...migrationDiagnostics, ...versionDiagnostics].map(
|
|
272
|
+
toCheckDiagnostic,
|
|
273
|
+
),
|
|
247
274
|
};
|
|
248
275
|
}
|
|
249
276
|
|
|
250
277
|
// `analyze()` never sees version skew, so without merging these a major
|
|
251
278
|
// mismatch — which `load()` refuses to boot on — would check clean.
|
|
252
|
-
|
|
279
|
+
//
|
|
280
|
+
// The remap is the same call `assembleGraphDiagnostics` makes for the CLI
|
|
281
|
+
// and VS Code, and it is not optional here: a caller acts on `path` (a
|
|
282
|
+
// module's `Assert.Manifest` matches on it), so this seam reporting the
|
|
283
|
+
// MIGRATED spelling while every other surface reports the author's would
|
|
284
|
+
// make one manifest mean two things depending on who asked. A no-op when
|
|
285
|
+
// nothing was migrated.
|
|
286
|
+
const analysis = new StaticAnalyzer({ celHandlers: nodeCelHandlers }).analyze(manifests, {
|
|
253
287
|
moduleDocuments,
|
|
254
288
|
});
|
|
289
|
+
const diagnostics = remapMigratedPaths(loadedGraph, analysis);
|
|
255
290
|
return {
|
|
256
|
-
diagnostics: [...versionDiagnostics, ...diagnostics].map(
|
|
291
|
+
diagnostics: [...migrationDiagnostics, ...versionDiagnostics, ...diagnostics].map(
|
|
292
|
+
toCheckDiagnostic,
|
|
293
|
+
),
|
|
257
294
|
};
|
|
258
295
|
}
|
|
259
296
|
}
|
package/src/schema-validator.ts
CHANGED
|
@@ -7,7 +7,8 @@ import { createHash } from "node:crypto";
|
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
8
|
import { createRequire } from "node:module";
|
|
9
9
|
import * as path from "node:path";
|
|
10
|
-
import {
|
|
10
|
+
import { registerTeloKeywords } from "@telorun/analyzer";
|
|
11
|
+
import { X_TELO_TYPE } from "@telorun/sdk";
|
|
11
12
|
import { mergeFilledDefaults, withBigIntsAsNumbers } from "./bigint-schema-view.js";
|
|
12
13
|
import { formatAjvErrors } from "./manifest-schemas.js";
|
|
13
14
|
|
|
@@ -25,7 +26,6 @@ import {
|
|
|
25
26
|
EXACT_TEMPLATE_REGEX,
|
|
26
27
|
isTaggedSentinel,
|
|
27
28
|
ManifestRootSchema,
|
|
28
|
-
normalizeRefSlots,
|
|
29
29
|
} from "@telorun/templating";
|
|
30
30
|
|
|
31
31
|
const Ajv = AjvModule.default ?? AjvModule;
|
|
@@ -165,13 +165,19 @@ const NAME_KEYED_SCHEMA_KEYWORDS = new Set([
|
|
|
165
165
|
const DATA_VALUE_KEYWORDS = new Set(["const", "default", "enum", "examples"]);
|
|
166
166
|
|
|
167
167
|
/** Annotations that DO emit validation code, and so must survive the strip and stay
|
|
168
|
-
* in the cache key. `x-telo-
|
|
168
|
+
* in the cache key. `x-telo-type` is the only one: bytes have no JSON Schema type,
|
|
169
169
|
* so the keyword is the only thing standing between a byte slot and "accepts any
|
|
170
170
|
* object". Stripping it would silently reduce the slot to an empty schema — the
|
|
171
171
|
* precise regression the annotation was introduced to close — and, because the key
|
|
172
172
|
* is meant to describe the compiled validator, a keyword that changes the validator
|
|
173
|
-
* belongs in it.
|
|
174
|
-
|
|
173
|
+
* belongs in it.
|
|
174
|
+
*
|
|
175
|
+
* Its names need no canonicalization to be safe in a key, unlike `x-telo-ref`'s:
|
|
176
|
+
* the vocabulary is closed and `Telo.`-qualified, so an author writes the
|
|
177
|
+
* canonical name or none, and a named SHAPE reaches the annotation as a `$ref`
|
|
178
|
+
* the loader already resolved. There is nothing here that the analyzer's baked
|
|
179
|
+
* view and the runtime could spell differently. */
|
|
180
|
+
const VALIDATING_ANNOTATIONS = new Set([X_TELO_TYPE]);
|
|
175
181
|
|
|
176
182
|
/** Deep-clone `schema` without its `x-telo-*` annotations — applied, like
|
|
177
183
|
* {@link collapseSentinelsToSource}, before both AJV compilation and cache
|
|
@@ -193,9 +199,7 @@ const VALIDATING_ANNOTATIONS = new Set([X_TELO_BINARY]);
|
|
|
193
199
|
*
|
|
194
200
|
* Stripping is what makes the key describe the compiled validator and nothing
|
|
195
201
|
* else, so the two views converge without either side having to agree on an
|
|
196
|
-
* annotation's spelling.
|
|
197
|
-
* reads `x-telo-ref` to drop a legacy scalar `type` at a ref slot, which does
|
|
198
|
-
* change validation, and it has already done so by the time this runs. */
|
|
202
|
+
* annotation's spelling. */
|
|
199
203
|
function stripTeloAnnotations(value: unknown, nameKeyed = false): unknown {
|
|
200
204
|
// An array's items are schema nodes (`allOf`, tuple `items`), never names.
|
|
201
205
|
if (Array.isArray(value)) return value.map((item) => stripTeloAnnotations(item));
|
|
@@ -258,27 +262,12 @@ export class SchemaValidator {
|
|
|
258
262
|
code: { source: true },
|
|
259
263
|
});
|
|
260
264
|
addFormats.default(this.ajv);
|
|
261
|
-
for
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
"x-telo-context-ref-from",
|
|
268
|
-
"x-telo-schema-from",
|
|
269
|
-
"x-telo-topology-role",
|
|
270
|
-
"x-telo-step-context",
|
|
271
|
-
"x-telo-widget",
|
|
272
|
-
"x-telo-type",
|
|
273
|
-
"x-telo-inline",
|
|
274
|
-
]) {
|
|
275
|
-
this.ajv.addKeyword(kw);
|
|
276
|
-
}
|
|
277
|
-
// Not a no-op like the rest: bytes have no JSON Schema type, so this keyword IS
|
|
278
|
-
// the check. Defined as codegen in the analyzer so it inlines into the
|
|
279
|
-
// standalone validators compiled and cached below, rather than needing the
|
|
280
|
-
// implementation present at load.
|
|
281
|
-
this.ajv.addKeyword(binaryKeyword());
|
|
265
|
+
// One registration site for every Telo keyword: the annotations as no-ops
|
|
266
|
+
// and `x-telo-type` as the one that actually checks. `x-telo-type` is defined
|
|
267
|
+
// as codegen in the analyzer so it inlines into the standalone validators
|
|
268
|
+
// compiled and cached below, rather than needing the implementation present
|
|
269
|
+
// at load.
|
|
270
|
+
registerTeloKeywords(this.ajv);
|
|
282
271
|
// Register the shared manifest root so module schemas can
|
|
283
272
|
// `$ref: "telo://manifest#/$defs/ResourceRef"` without each manifest
|
|
284
273
|
// bundling its own copy. Mirrors the analyzer's createAjv().
|
|
@@ -361,12 +350,7 @@ export class SchemaValidator {
|
|
|
361
350
|
}
|
|
362
351
|
: normalized;
|
|
363
352
|
|
|
364
|
-
|
|
365
|
-
// its `x-telo-ref` slots. Schema validation runs in create() before Phase 5
|
|
366
|
-
// injection, so a ref slot holds the resolved `{kind, name, alias?}` object
|
|
367
|
-
// (or an unresolved sentinel) — both objects the stale `type: "string"`
|
|
368
|
-
// would otherwise reject.
|
|
369
|
-
const injected = normalizeRefSlots(withImplicit) as typeof withImplicit;
|
|
353
|
+
const injected = withImplicit;
|
|
370
354
|
|
|
371
355
|
// Canonicalize CEL/template carriers (an inline `${{ }}` left in a
|
|
372
356
|
// `description`, a `!cel` tag, …) to their bare source text so AJV can
|