@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.
Files changed (48) hide show
  1. package/dist/application-env.js +2 -2
  2. package/dist/application-env.js.map +1 -1
  3. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  4. package/dist/controllers/module/import-controller.js +16 -1
  5. package/dist/controllers/module/import-controller.js.map +1 -1
  6. package/dist/evaluation-context.d.ts.map +1 -1
  7. package/dist/evaluation-context.js +8 -1
  8. package/dist/evaluation-context.js.map +1 -1
  9. package/dist/invocation-contract-binding.js +2 -2
  10. package/dist/invocation-contract-binding.js.map +1 -1
  11. package/dist/kernel.d.ts +4 -0
  12. package/dist/kernel.d.ts.map +1 -1
  13. package/dist/kernel.js +26 -3
  14. package/dist/kernel.js.map +1 -1
  15. package/dist/manifest-schemas.js +2 -2
  16. package/dist/manifest-schemas.js.map +1 -1
  17. package/dist/module-file-resolution.d.ts +22 -0
  18. package/dist/module-file-resolution.d.ts.map +1 -0
  19. package/dist/module-file-resolution.js +51 -0
  20. package/dist/module-file-resolution.js.map +1 -0
  21. package/dist/observed-state.js +2 -2
  22. package/dist/observed-state.js.map +1 -1
  23. package/dist/resolve-include-sentinels.d.ts +37 -0
  24. package/dist/resolve-include-sentinels.d.ts.map +1 -0
  25. package/dist/resolve-include-sentinels.js +175 -0
  26. package/dist/resolve-include-sentinels.js.map +1 -0
  27. package/dist/resource-context.d.ts.map +1 -1
  28. package/dist/resource-context.js +4 -39
  29. package/dist/resource-context.js.map +1 -1
  30. package/dist/runtime-seam.d.ts.map +1 -1
  31. package/dist/runtime-seam.js +33 -4
  32. package/dist/runtime-seam.js.map +1 -1
  33. package/dist/schema-validator.d.ts.map +1 -1
  34. package/dist/schema-validator.js +20 -35
  35. package/dist/schema-validator.js.map +1 -1
  36. package/package.json +4 -4
  37. package/src/application-env.ts +2 -2
  38. package/src/controllers/module/import-controller.ts +15 -1
  39. package/src/evaluation-context.ts +8 -1
  40. package/src/invocation-contract-binding.ts +2 -2
  41. package/src/kernel.ts +35 -3
  42. package/src/manifest-schemas.ts +2 -2
  43. package/src/module-file-resolution.ts +64 -0
  44. package/src/observed-state.ts +2 -2
  45. package/src/resolve-include-sentinels.ts +219 -0
  46. package/src/resource-context.ts +4 -39
  47. package/src/runtime-seam.ts +40 -3
  48. 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.
@@ -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 { binaryKeyword, OBSERVED_STATE_SCHEMA } from "@telorun/analyzer";
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.addKeyword(binaryKeyword());
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
+ }
@@ -1,4 +1,4 @@
1
- import { binaryKeyword } from "@telorun/analyzer";
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.addKeyword(binaryKeyword());
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
+ }
@@ -28,13 +28,14 @@ import {
28
28
  type TypeRule,
29
29
  type ZoneEntry,
30
30
  } from "@telorun/sdk";
31
- import { binaryKeyword } from "@telorun/analyzer";
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
- for (const kw of ["x-telo-ref", "x-telo-scope", "x-telo-context", "x-telo-schema-from"]) {
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
- // An absolute URI names its own location; a bare absolute path is already
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 {
@@ -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(toCheckDiagnostic),
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
- const diagnostics = new StaticAnalyzer({ celHandlers: nodeCelHandlers }).analyze(manifests, {
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(toCheckDiagnostic),
291
+ diagnostics: [...migrationDiagnostics, ...versionDiagnostics, ...diagnostics].map(
292
+ toCheckDiagnostic,
293
+ ),
257
294
  };
258
295
  }
259
296
  }
@@ -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 { binaryKeyword, X_TELO_BINARY } from "@telorun/analyzer";
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-binary` is the first: bytes have no JSON Schema type,
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
- const VALIDATING_ANNOTATIONS = new Set([X_TELO_BINARY]);
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. `normalizeRefSlots` runs FIRST and is unaffected: it
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 (const kw of [
262
- "x-telo-ref",
263
- "x-telo-eval",
264
- "x-telo-scope",
265
- "x-telo-context",
266
- "x-telo-context-from",
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
- // Drop the legacy scalar `type` an older published module may still pin on
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