@telorun/kernel 1.1.0 → 1.3.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 (37) hide show
  1. package/LICENSE +2 -2
  2. package/dist/controllers/module/import-controller.d.ts +3 -2
  3. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  4. package/dist/controllers/module/import-controller.js +23 -25
  5. package/dist/controllers/module/import-controller.js.map +1 -1
  6. package/dist/internal-context.d.ts +25 -0
  7. package/dist/internal-context.d.ts.map +1 -0
  8. package/dist/internal-context.js +2 -0
  9. package/dist/internal-context.js.map +1 -0
  10. package/dist/kernel.d.ts +21 -1
  11. package/dist/kernel.d.ts.map +1 -1
  12. package/dist/kernel.js +90 -2
  13. package/dist/kernel.js.map +1 -1
  14. package/dist/manifest-schemas.d.ts +7 -23
  15. package/dist/manifest-schemas.d.ts.map +1 -1
  16. package/dist/manifest-schemas.js +18 -8
  17. package/dist/manifest-schemas.js.map +1 -1
  18. package/dist/manifest-sources/analysis-stamp.d.ts +25 -0
  19. package/dist/manifest-sources/analysis-stamp.d.ts.map +1 -0
  20. package/dist/manifest-sources/analysis-stamp.js +151 -0
  21. package/dist/manifest-sources/analysis-stamp.js.map +1 -0
  22. package/dist/resource-context.d.ts +2 -0
  23. package/dist/resource-context.d.ts.map +1 -1
  24. package/dist/resource-context.js +28 -0
  25. package/dist/resource-context.js.map +1 -1
  26. package/dist/schema-validator.d.ts +28 -0
  27. package/dist/schema-validator.d.ts.map +1 -1
  28. package/dist/schema-validator.js +161 -1
  29. package/dist/schema-validator.js.map +1 -1
  30. package/package.json +5 -3
  31. package/src/controllers/module/import-controller.ts +33 -36
  32. package/src/internal-context.ts +25 -0
  33. package/src/kernel.ts +106 -2
  34. package/src/manifest-schemas.ts +31 -11
  35. package/src/manifest-sources/analysis-stamp.ts +169 -0
  36. package/src/resource-context.ts +34 -0
  37. package/src/schema-validator.ts +178 -2
@@ -1,26 +1,14 @@
1
1
  import { DiagnosticSeverity, StaticAnalyzer } from "@telorun/analyzer";
2
- import type { ResourceContext, ResourceInstance } from "@telorun/sdk";
2
+ import type { ResourceInstance } from "@telorun/sdk";
3
3
  import { RuntimeError } from "@telorun/sdk";
4
+ import type { BuiltinControllerContext } from "../../internal-context.js";
4
5
  import { ModuleContext } from "../../module-context.js";
5
6
  import { isDefaultPolicy, normalizeRuntime } from "../../runtime-registry.js";
6
7
 
7
- const importAnalysisCache = new Map<
8
- string,
9
- { signature: string; errors: string[] }
10
- >();
11
-
12
- // Only resolve relative/absolute-path sources against the importer's URL. Registry refs
13
- // (std/foo@1.2.3) and absolute URLs (https://, file://) must pass through unchanged so the
14
- // loader's source chain can dispatch them — otherwise `new URL("std/foo@1", "file:///srv/telo.yaml")`
15
- // turns a registry ref into a bogus file path and LocalFileSource ENOENTs on it.
16
- function resolveImportSource(source: string, baseSource: string): string {
17
- if (source.startsWith(".") || source.startsWith("/")) {
18
- return new URL(source, baseSource).toString();
19
- }
20
- return source;
21
- }
22
-
23
- export async function create(resource: any, ctx: ResourceContext): Promise<ResourceInstance> {
8
+ export async function create(
9
+ resource: any,
10
+ ctx: BuiltinControllerContext,
11
+ ): Promise<ResourceInstance> {
24
12
  const alias = resource.metadata.name as string;
25
13
 
26
14
  const moduleSource: string = resource.module ?? resource.source;
@@ -36,27 +24,36 @@ export async function create(resource: any, ctx: ResourceContext): Promise<Resou
36
24
  // Validate the imported module and all its transitive imports before loading for runtime.
37
25
  // loadManifests() follows Telo.Import chains so definitions from sub-imports are present,
38
26
  // preventing false UNDEFINED_KIND errors for kinds that come from the module's own imports.
39
- const resolvedUrl = resolveImportSource(moduleSource, base);
40
- const analysisManifests = await ctx.loadManifests(resolvedUrl);
41
- const signature = JSON.stringify(analysisManifests);
42
- const cached = importAnalysisCache.get(resolvedUrl);
43
- let errors: string[];
44
-
45
- if (cached && cached.signature === signature) {
46
- errors = cached.errors;
47
- } else {
27
+ //
28
+ // Route URL resolution through the kernel/loader's own helper rather than
29
+ // a hand-rolled `new URL(...).toString()`. For LocalFileSource the
30
+ // outputs match; for any custom `ManifestSource` with a non-trivial
31
+ // `resolveRelative`, only this path produces the canonical URL the
32
+ // loader keyed its caches under — without which fast paths like
33
+ // `isImportValidatedAtLoad` silently miss.
34
+ const resolvedUrl = ctx.resolveImportUrl(base, moduleSource);
35
+
36
+ // Fast path: when the kernel's load-time `analyzeErrors` already covered
37
+ // this import's subtree (the common case — every Telo.Import declared in
38
+ // the entry graph is walked by `loadGraph` and validated by
39
+ // `kernel.load`), skip the redundant per-import StaticAnalyzer pass.
40
+ // Falls through to the full analysis for URLs that arrived
41
+ // programmatically after `load()` (e.g. dynamically constructed imports
42
+ // in tests). Two Telo.Imports with the same source but distinct
43
+ // metadata.name would each re-run analysis here — a memoisation hook
44
+ // can be reintroduced if that turns into a measurable cost.
45
+ if (!ctx.isImportValidatedAtLoad(resolvedUrl)) {
46
+ const analysisManifests = await ctx.loadManifests(resolvedUrl);
48
47
  const diagnostics = new StaticAnalyzer().analyze(analysisManifests);
49
- errors = diagnostics
48
+ const errors = diagnostics
50
49
  .filter((d) => d.severity === DiagnosticSeverity.Error)
51
50
  .map((d) => d.message);
52
- importAnalysisCache.set(resolvedUrl, { signature, errors });
53
- }
54
-
55
- if (errors.length > 0) {
56
- throw new RuntimeError(
57
- "ERR_MANIFEST_VALIDATION_FAILED",
58
- errors.join("\n"),
59
- );
51
+ if (errors.length > 0) {
52
+ throw new RuntimeError(
53
+ "ERR_MANIFEST_VALIDATION_FAILED",
54
+ errors.join("\n"),
55
+ );
56
+ }
60
57
  }
61
58
 
62
59
  // Load target module manifests for runtime. Inject variables/secrets as compile context so
@@ -0,0 +1,25 @@
1
+ import type { ResourceContext } from "@telorun/sdk";
2
+
3
+ /**
4
+ * Context interface used by built-in kernel controllers (Telo.Application /
5
+ * Telo.Library / Telo.Import / Telo.Definition / Telo.Abstract) that need
6
+ * privileged access to load-time graph identity. These methods are
7
+ * intentionally *not* on the public `ResourceContext` exposed to module
8
+ * authors — they couple the caller to the kernel's load-time view of the
9
+ * world, and the import-controller is the only consumer today.
10
+ *
11
+ * `ResourceContextImpl` in this package implements both interfaces, so a
12
+ * controller authored against this type still works under the generic
13
+ * `controller.create(resource, ctx)` dispatch — the kernel just types it
14
+ * locally as `BuiltinControllerContext` instead of `ResourceContext`.
15
+ */
16
+ export interface BuiltinControllerContext extends ResourceContext {
17
+ /** True when `url` resolved (via the loader's URL → canonical-source
18
+ * map) to a module that was part of the entry graph successfully
19
+ * analyzed during `Kernel.load()`. */
20
+ isImportValidatedAtLoad(url: string): boolean;
21
+ /** Resolve `importSource` against `fromSource` through the loader's
22
+ * source-chain `resolveRelative`. Identical to what `loadGraph` used
23
+ * internally — so the produced URL agrees with the loader's caches. */
24
+ resolveImportUrl(fromSource: string, importSource: string): string;
25
+ }
package/src/kernel.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  isModuleKind,
6
6
  Loader,
7
7
  StaticAnalyzer,
8
+ type LoadedGraph,
8
9
  type ManifestSource,
9
10
  } from "@telorun/analyzer";
10
11
  import {
@@ -30,6 +31,12 @@ import { EventStream } from "./event-stream.js";
30
31
  import { EventBus } from "./events.js";
31
32
  import { ModuleContext } from "./module-context.js";
32
33
  import { ResourceContextImpl } from "./resource-context.js";
34
+ import {
35
+ computeAnalysisSignature,
36
+ readAnalysisStamp,
37
+ writeAnalysisStamp,
38
+ } from "./manifest-sources/analysis-stamp.js";
39
+ import { resolveEntryDir } from "./manifest-sources/local-manifest-cache-source.js";
33
40
  import { resolveApplicationEnv } from "./application-env.js";
34
41
  import { policyFingerprint } from "./runtime-registry.js";
35
42
  import { SchemaValidator } from "./schema-validator.js";
@@ -118,6 +125,7 @@ export class Kernel implements IKernel {
118
125
  private rootContext!: ModuleContext;
119
126
  private staticManifests: ResourceManifest[] = [];
120
127
  private _entryUrl?: string;
128
+ private _loadedGraph?: LoadedGraph;
121
129
  // Lifecycle state — guards boot/runTargets/teardown/invoke transitions.
122
130
  // teardown() is the only idempotent method; everything else throws on misuse.
123
131
  private _bootCalled = false;
@@ -186,6 +194,37 @@ export class Kernel implements IKernel {
186
194
  return this.registry;
187
195
  }
188
196
 
197
+ /** The full LoadedGraph captured during `load()`. Used by the CLI to
198
+ * feed `writeManifestCache` so a successful `telo run` populates
199
+ * `<entry-dir>/.telo/manifests/` for subsequent runs — the same on-disk
200
+ * layout `telo install` writes. Undefined before `load()` has been
201
+ * called or if it threw before the graph was captured. */
202
+ getLoadedGraph(): LoadedGraph | undefined {
203
+ return this._loadedGraph;
204
+ }
205
+
206
+ /** True when `url` resolves (via the loader's URL → canonical-source map)
207
+ * to a module that was already part of the entry graph successfully
208
+ * analyzed during `load()`. The import-controller uses it to skip its
209
+ * per-import `analyze()` pass when the kernel's load-time validation
210
+ * already covered the same subtree. */
211
+ isImportValidatedAtLoad(url: string): boolean {
212
+ if (!this._loadedGraph) return false;
213
+ const canonical = this.loader.canonicalize(url);
214
+ if (!canonical) return false;
215
+ return this._loadedGraph.modules.has(canonical);
216
+ }
217
+
218
+ /** Resolve a `Telo.Import.source` against the importing file's URL
219
+ * through the same source-chain `resolveRelative` the loader used at
220
+ * graph-walk time. The import-controller routes through here so its
221
+ * `resolveImportSource` no longer second-guesses the loader for
222
+ * custom `ManifestSource`s — `isImportValidatedAtLoad` etc. only hit
223
+ * when both sides agree on the canonical URL. */
224
+ resolveImportUrl(fromSource: string, importSource: string): string {
225
+ return this.loader.resolveImportUrl(fromSource, importSource);
226
+ }
227
+
189
228
  /**
190
229
  * Load built-in Runtime definitions (e.g., Telo.Application, Telo.Library).
191
230
  * Also declares all known module namespaces upfront so that resources can be
@@ -226,6 +265,16 @@ export class Kernel implements IKernel {
226
265
  async load(url: string): Promise<void> {
227
266
  const sourceUrl = await this.loader.resolveEntryPoint(url);
228
267
  this._entryUrl = sourceUrl;
268
+ // Point the shared schema validator at the entry-anchored cache so
269
+ // compiled AJV validators are persisted (standalone CJS) under
270
+ // `<entry-dir>/.telo/manifests/__validators/`. Memory- or HTTP-rooted
271
+ // entries skip the cache; their schema compiles stay in-process only.
272
+ const validatorCacheDir = resolveEntryDir(sourceUrl);
273
+ this.sharedSchemaValidator.setCacheDir(
274
+ validatorCacheDir
275
+ ? `${validatorCacheDir}/.telo/manifests/__validators`
276
+ : undefined,
277
+ );
229
278
  this.rootContext = new ModuleContext(
230
279
  sourceUrl,
231
280
  {},
@@ -254,6 +303,7 @@ export class Kernel implements IKernel {
254
303
  if (analysisGraph.errors.length > 0) {
255
304
  throw analysisGraph.errors[0].error;
256
305
  }
306
+ this._loadedGraph = analysisGraph;
257
307
  const staticManifests = flattenForAnalyzer(analysisGraph);
258
308
  this.staticManifests = staticManifests;
259
309
 
@@ -276,7 +326,22 @@ export class Kernel implements IKernel {
276
326
  }
277
327
  }
278
328
 
279
- const errors = this.analyzer.analyzeErrors(staticManifests, {}, this.registry);
329
+ // Hash-keyed analysis cache: when the entry's full LoadedGraph matches
330
+ // a previously-stamped successful run (same file bytes, same stamp
331
+ // protocol version), skip the per-resource validation walk inside
332
+ // `analyzeErrors`. Registration of identities / aliases / definitions
333
+ // and inline-resource normalisation still runs — only the diagnostic
334
+ // passes are elided. Memory- / HTTP-rooted entries have no
335
+ // local stamp store and always re-validate.
336
+ const entryDir = resolveEntryDir(sourceUrl);
337
+ const analysisSignature = computeAnalysisSignature(analysisGraph);
338
+ const stamp = entryDir ? await readAnalysisStamp(entryDir) : undefined;
339
+ const skipValidation = stamp?.signature === analysisSignature;
340
+ const errors = this.analyzer.analyzeErrors(
341
+ staticManifests,
342
+ { skipValidation },
343
+ this.registry,
344
+ );
280
345
  if (errors.length > 0) {
281
346
  throw new RuntimeError(
282
347
  "ERR_MANIFEST_VALIDATION_FAILED",
@@ -291,6 +356,19 @@ export class Kernel implements IKernel {
291
356
  })),
292
357
  );
293
358
  }
359
+ if (entryDir && !skipValidation) {
360
+ // Best-effort: stamp the verdict so subsequent loads hit the fast
361
+ // path. A read-only filesystem (baked Docker image) reports the
362
+ // failure on stderr and keeps running — the lookup above will
363
+ // simply miss next time.
364
+ try {
365
+ await writeAnalysisStamp(entryDir, analysisSignature);
366
+ } catch (err) {
367
+ this.stderr.write(
368
+ `[telo:kernel] analysis stamp write failed: ${err instanceof Error ? err.message : String(err)}\n`,
369
+ );
370
+ }
371
+ }
294
372
 
295
373
  // Load runtime configuration — root module gets access to host env.
296
374
  // Imports are loaded separately via the import-controller; this load is
@@ -313,7 +391,26 @@ export class Kernel implements IKernel {
313
391
  // mapping per field; the kernel populates the root scope from
314
392
  // `process.env` after the manifest loop so imports can read
315
393
  // `${{ variables.X }}` during their own init.
316
- this.rootContext.setTargets(manifest.targets ?? []);
394
+ //
395
+ // Targets normalize down to bare names regardless of source surface.
396
+ // The analyzer's `resolveRefSentinels` pass already substituted any
397
+ // `!ref <name>` to `{kind, name}`; bare-string forms pass through.
398
+ // Anything else (e.g. an unresolved sentinel because the analyzer
399
+ // couldn't see it, or a malformed manifest) is a hard error —
400
+ // silently dropping the entry would leave the user staring at a
401
+ // "no targets ran" outcome with no signal where it went wrong.
402
+ const rawTargets = (manifest.targets ?? []) as unknown[];
403
+ const targetNames = rawTargets.map((t, index) => {
404
+ if (typeof t === "string") return t;
405
+ if (t && typeof t === "object" && typeof (t as { name?: unknown }).name === "string") {
406
+ return (t as { name: string }).name;
407
+ }
408
+ throw new RuntimeError(
409
+ "ERR_INVALID_VALUE",
410
+ `Telo.Application '${(manifest.metadata as { name?: string } | undefined)?.name ?? "(unnamed)"}' targets[${index}] could not be normalized to a resource name. Got: ${JSON.stringify(t)}`,
411
+ );
412
+ });
413
+ this.rootContext.setTargets(targetNames);
317
414
  if (manifest.kind === "Telo.Application") {
318
415
  rootApplicationManifest = manifest;
319
416
  }
@@ -440,6 +537,13 @@ export class Kernel implements IKernel {
440
537
  if (this.rootContext) {
441
538
  await this.rootContext.teardownResources();
442
539
  }
540
+ // Drop the load-time graph so a teardown'd kernel doesn't pin every
541
+ // manifest file's text in memory (LoadedFile retains the parsed
542
+ // documents + the original YAML bytes). Reusing the kernel after
543
+ // teardown is a hard error elsewhere, so this is purely a memory
544
+ // hygiene step.
545
+ this._loadedGraph = undefined;
546
+ this.staticManifests = [];
443
547
  await this.eventBus.emit("Kernel.Stopped", { exitCode: this._exitCode });
444
548
  }
445
549
 
@@ -1,15 +1,16 @@
1
- import { Type } from "@sinclair/typebox";
2
1
  import AjvModule from "ajv";
3
2
  import addFormats from "ajv-formats";
4
3
  const Ajv = AjvModule.default ?? AjvModule;
5
4
 
6
- export const RuntimeResourceSchema = Type.Object(
7
- {
8
- kind: Type.String(),
9
- metadata: Type.Object({ name: Type.String() }, { additionalProperties: true }),
10
- },
11
- { additionalProperties: true },
12
- );
5
+ // Re-export the shared ResourceRef fragment from the templating package
6
+ // so consumers that pull it from the kernel manifest-schemas surface keep
7
+ // working. The canonical home is `@telorun/templating` because the
8
+ // fragment describes the parsed shape of the `!ref` tag's TaggedSentinel.
9
+ export {
10
+ MANIFEST_SCHEMA_URI,
11
+ ManifestRootSchema,
12
+ ResourceRefSchema,
13
+ } from "@telorun/templating";
13
14
 
14
15
  const metadataSchema = {
15
16
  type: "object",
@@ -149,9 +150,28 @@ export const ResourceAbstractSchema = {
149
150
  const ajv = new Ajv({ allErrors: true, strict: false });
150
151
  addFormats.default(ajv);
151
152
 
152
- export const validateRuntimeResource = ajv.compile(RuntimeResourceSchema);
153
- export const validateResourceDefinition = ajv.compile(ResourceDefinitionSchema);
154
- export const validateResourceAbstract = ajv.compile(ResourceAbstractSchema);
153
+ // Lazy-compile validator: the AJV codegen cost (≈10–15 ms for these
154
+ // schemas) is only paid when a definition / abstract actually needs
155
+ // validating. A hello-world that loads no Telo.Definition or
156
+ // Telo.Abstract documents never triggers either compile; apps that
157
+ // do see them only pay once per process.
158
+ interface LazyValidator {
159
+ (data: unknown): boolean | Promise<unknown>;
160
+ errors?: any[] | null;
161
+ }
162
+ function lazyValidator(schema: object): LazyValidator {
163
+ let compiled: ReturnType<typeof ajv.compile> | undefined;
164
+ const fn: LazyValidator = (data: unknown) => {
165
+ if (!compiled) compiled = ajv.compile(schema);
166
+ const ok = compiled(data);
167
+ fn.errors = compiled.errors as any[] | null | undefined;
168
+ return ok;
169
+ };
170
+ return fn;
171
+ }
172
+
173
+ export const validateResourceDefinition = lazyValidator(ResourceDefinitionSchema);
174
+ export const validateResourceAbstract = lazyValidator(ResourceAbstractSchema);
155
175
 
156
176
  export function formatAjvErrors(errors: any[] | null | undefined): string {
157
177
  if (!errors || errors.length === 0) return "Unknown schema error";
@@ -0,0 +1,169 @@
1
+ import type { LoadedGraph } from "@telorun/analyzer";
2
+ import { createHash } from "crypto";
3
+ import { readFileSync } from "fs";
4
+ import * as fs from "fs/promises";
5
+ import { createRequire } from "module";
6
+ import * as path from "path";
7
+ import { fileURLToPath } from "url";
8
+
9
+ /**
10
+ * Hash-keyed analysis cache: a tiny JSON sidecar in `.telo/manifests/`
11
+ * recording that an exact set of manifest bytes — under specific
12
+ * `@telorun/kernel` and `@telorun/analyzer` package versions — passed
13
+ * `analyzer.analyzeErrors`. The next `kernel.load` reads the sidecar
14
+ * and, if signatures match, skips the per-resource validation walk.
15
+ *
16
+ * Lives next to the manifest cache (`LocalManifestCacheSource`) but is
17
+ * independent of it — splitting both for grep-ability and because the
18
+ * concerns (URL → file content vs. content → analyzer verdict) are
19
+ * orthogonal.
20
+ */
21
+
22
+ const CACHE_SUBDIR = ".telo/manifests";
23
+
24
+ /** File-format version of the analysis stamp envelope. Only bumped when
25
+ * the on-disk *layout* changes (new fields, restructured payload). The
26
+ * *semantic* invalidation — "did the analyzer's logic change?" — is
27
+ * handled by baking the resolved `@telorun/analyzer` / `@telorun/kernel`
28
+ * package versions into the signature itself, so any pnpm/npm install
29
+ * that bumps either package automatically invalidates every stamp on
30
+ * disk. A hand-maintained integer for that purpose would silently mask
31
+ * newly-stricter validation until the next manifest edit. */
32
+ const ANALYSIS_STAMP_FORMAT_VERSION = 1;
33
+ const ANALYSIS_STAMP_FILE = `${CACHE_SUBDIR}/.validated.json`;
34
+
35
+ const localRequire = createRequire(import.meta.url);
36
+
37
+ /** Read the kernel's own `package.json` — `createRequire` can't resolve
38
+ * `@telorun/kernel/package.json` from inside the kernel package itself
39
+ * (the self-reference loops in some node_modules layouts). The file
40
+ * sits two levels up from `dist/manifest-sources/`. */
41
+ function readKernelVersion(): string {
42
+ try {
43
+ const url = new URL("../../package.json", import.meta.url);
44
+ const pkg = JSON.parse(readFileSync(fileURLToPath(url), "utf-8"));
45
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
46
+ } catch {
47
+ return "unknown";
48
+ }
49
+ }
50
+
51
+ function readDepVersion(spec: string): string {
52
+ // Fast path: direct `require("<pkg>/package.json")`. Fails (with
53
+ // ERR_PACKAGE_PATH_NOT_EXPORTED) when the dependency declares a strict
54
+ // `exports` map without listing `./package.json` — common for packages
55
+ // that consider package.json an implementation detail. Don't return
56
+ // "unknown" in that case; fall back to resolving the package's main
57
+ // entry and walking the filesystem up to its package.json.
58
+ const pkgJsonSpec = spec.endsWith("/package.json")
59
+ ? spec
60
+ : `${spec}/package.json`;
61
+ try {
62
+ const pkg = localRequire(pkgJsonSpec);
63
+ if (typeof pkg.version === "string") return pkg.version;
64
+ } catch {
65
+ // fall through to filesystem walk
66
+ }
67
+ try {
68
+ const mainSpec = spec.endsWith("/package.json") ? spec.slice(0, -13) : spec;
69
+ const entry = localRequire.resolve(mainSpec);
70
+ let dir = path.dirname(entry);
71
+ while (dir !== path.dirname(dir)) {
72
+ const candidate = path.join(dir, "package.json");
73
+ try {
74
+ const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
75
+ // Guard against scoped-package interior package.json files (some
76
+ // packages stamp one in dist/) — match by name when the spec
77
+ // names a package.
78
+ const expectedName = mainSpec
79
+ .split("/")
80
+ .slice(0, mainSpec.startsWith("@") ? 2 : 1)
81
+ .join("/");
82
+ if (typeof pkg.name === "string" && pkg.name === expectedName) {
83
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
84
+ }
85
+ } catch {
86
+ // not at the package root yet — keep walking
87
+ }
88
+ dir = path.dirname(dir);
89
+ }
90
+ } catch {
91
+ // resolution failed — package not installed at all
92
+ }
93
+ return "unknown";
94
+ }
95
+
96
+ const KERNEL_VERSION = readKernelVersion();
97
+ const ANALYZER_VERSION = readDepVersion("@telorun/analyzer");
98
+
99
+ export interface AnalysisStamp {
100
+ version: number;
101
+ signature: string;
102
+ }
103
+
104
+ /** Hash every owner + partial file in `graph` together with the resolved
105
+ * `@telorun/kernel` and `@telorun/analyzer` versions into one content
106
+ * signature. Two loads of the same manifest set under the same package
107
+ * versions produce the same signature; any edit to any reachable file —
108
+ * or any pnpm/npm install that bumps the kernel or analyzer — flips it.
109
+ * This is what the kernel uses to decide whether the previous analyzer
110
+ * run's verdict still applies. */
111
+ export function computeAnalysisSignature(graph: LoadedGraph): string {
112
+ const entries: Array<[string, string]> = [];
113
+ for (const [, mod] of graph.modules) {
114
+ for (const file of [mod.owner, ...mod.partials]) {
115
+ const digest = createHash("sha256").update(file.text).digest("hex");
116
+ entries.push([file.source, digest]);
117
+ }
118
+ }
119
+ entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
120
+ return createHash("sha256")
121
+ .update(
122
+ JSON.stringify({
123
+ kernel: KERNEL_VERSION,
124
+ analyzer: ANALYZER_VERSION,
125
+ files: entries,
126
+ }),
127
+ )
128
+ .digest("hex");
129
+ }
130
+
131
+ /** Read the stamped analysis verdict for the entry at `entryDir`, or
132
+ * `undefined` when missing / unreadable / format-mismatched. The
133
+ * `version` field is the on-disk *format* version; semantic
134
+ * invalidation flows through the signature (which embeds package
135
+ * versions). A future format change bumps `version` so older kernels
136
+ * reading a newer stamp (or vice versa) discard rather than misparse. */
137
+ export async function readAnalysisStamp(
138
+ entryDir: string,
139
+ ): Promise<AnalysisStamp | undefined> {
140
+ try {
141
+ const text = await fs.readFile(path.join(entryDir, ANALYSIS_STAMP_FILE), "utf-8");
142
+ const parsed = JSON.parse(text) as Partial<AnalysisStamp>;
143
+ if (
144
+ parsed?.version === ANALYSIS_STAMP_FORMAT_VERSION &&
145
+ typeof parsed?.signature === "string"
146
+ ) {
147
+ return parsed as AnalysisStamp;
148
+ }
149
+ } catch {
150
+ // missing / unreadable / unparseable — treat as cache miss
151
+ }
152
+ return undefined;
153
+ }
154
+
155
+ /** Persist the analysis verdict so the next `kernel.load` can skip the
156
+ * per-resource validation walk when the manifest set is unchanged.
157
+ * Idempotent; safe to call after every successful load. */
158
+ export async function writeAnalysisStamp(
159
+ entryDir: string,
160
+ signature: string,
161
+ ): Promise<void> {
162
+ const stamp: AnalysisStamp = {
163
+ version: ANALYSIS_STAMP_FORMAT_VERSION,
164
+ signature,
165
+ };
166
+ const target = path.join(entryDir, ANALYSIS_STAMP_FILE);
167
+ await fs.mkdir(path.dirname(target), { recursive: true });
168
+ await fs.writeFile(target, JSON.stringify(stamp), "utf-8");
169
+ }
@@ -13,6 +13,7 @@ import {
13
13
  type ParsedArgs,
14
14
  type TypeRule,
15
15
  } from "@telorun/sdk";
16
+ import { isRefSentinel } from "@telorun/templating";
16
17
  import AjvModule from "ajv";
17
18
  import addFormats from "ajv-formats";
18
19
  import { EvaluationContext } from "./evaluation-context.js";
@@ -176,6 +177,14 @@ export class ResourceContextImpl implements ResourceContext {
176
177
  return this.kernel.loadManifests(url);
177
178
  }
178
179
 
180
+ isImportValidatedAtLoad(url: string): boolean {
181
+ return this.kernel.isImportValidatedAtLoad(url);
182
+ }
183
+
184
+ resolveImportUrl(fromSource: string, importSource: string): string {
185
+ return this.kernel.resolveImportUrl(fromSource, importSource);
186
+ }
187
+
179
188
  /**
180
189
  * Resolves a resource into a normalized {kind, name} reference.
181
190
  * If the resource contains a definition (kind + properties), registers it as a manifest.
@@ -194,6 +203,31 @@ export class ResourceContextImpl implements ResourceContext {
194
203
  );
195
204
  }
196
205
 
206
+ // Stopgap: `!ref <name>` sentinels can reach the controller directly
207
+ // when the slot is hidden behind a local `$ref: "#/$defs/..."` — the
208
+ // analyzer's field-map walker descends `oneOf`/`anyOf` variant
209
+ // properties but intentionally early-returns on `$ref` (see
210
+ // `analyzer/nodejs/src/reference-field-map.ts`). Enabling the `$ref`
211
+ // descent regresses the kernel's `<Kind>.<Name>.Invoked` event
212
+ // emission for kinds (notably `Run.Sequence`) whose controllers
213
+ // call `instance.invoke()` directly on Phase-5-injected instances;
214
+ // the walker fix needs to land together with routing those callers
215
+ // through `EvaluationContext.invokeResolved`. Until then, the Node
216
+ // kernel resolves the sentinel here. Polyglot controllers don't get
217
+ // this rescue — schemas exercising those hidden slots must use the
218
+ // legacy string or `{kind, name}` forms for now.
219
+ if (isRefSentinel(resource)) {
220
+ const refName = resource.source;
221
+ const entry = this.moduleContext.resourceInstances.get(refName);
222
+ if (!entry) {
223
+ throw new RuntimeError(
224
+ "ERR_RESOURCE_NOT_FOUND",
225
+ `[${this.metadata.name}] !ref '${refName}' did not resolve to a registered resource.`,
226
+ );
227
+ }
228
+ return { kind: entry.resource.kind as string, name: refName };
229
+ }
230
+
197
231
  if (!resource.kind) {
198
232
  throw new RuntimeError(
199
233
  "ERR_INVALID_VALUE",