@telorun/kernel 0.69.0 → 0.72.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 (69) hide show
  1. package/dist/bigint-json.d.ts +45 -0
  2. package/dist/bigint-json.d.ts.map +1 -0
  3. package/dist/bigint-json.js +65 -0
  4. package/dist/bigint-json.js.map +1 -0
  5. package/dist/bigint-schema-view.d.ts +34 -0
  6. package/dist/bigint-schema-view.d.ts.map +1 -0
  7. package/dist/bigint-schema-view.js +85 -0
  8. package/dist/bigint-schema-view.js.map +1 -0
  9. package/dist/cel-handlers.d.ts.map +1 -1
  10. package/dist/cel-handlers.js +6 -8
  11. package/dist/cel-handlers.js.map +1 -1
  12. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  13. package/dist/controllers/module/import-controller.js +15 -1
  14. package/dist/controllers/module/import-controller.js.map +1 -1
  15. package/dist/evaluation-context.d.ts.map +1 -1
  16. package/dist/evaluation-context.js +8 -1
  17. package/dist/evaluation-context.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/invocation-contract-binding.d.ts +0 -6
  23. package/dist/invocation-contract-binding.d.ts.map +1 -1
  24. package/dist/invocation-contract-binding.js +9 -64
  25. package/dist/invocation-contract-binding.js.map +1 -1
  26. package/dist/kernel.d.ts +4 -0
  27. package/dist/kernel.d.ts.map +1 -1
  28. package/dist/kernel.js +22 -3
  29. package/dist/kernel.js.map +1 -1
  30. package/dist/logging/encode-json.d.ts.map +1 -1
  31. package/dist/logging/encode-json.js +13 -9
  32. package/dist/logging/encode-json.js.map +1 -1
  33. package/dist/logging/encode-pretty.d.ts.map +1 -1
  34. package/dist/logging/encode-pretty.js +13 -4
  35. package/dist/logging/encode-pretty.js.map +1 -1
  36. package/dist/module-file-resolution.d.ts +22 -0
  37. package/dist/module-file-resolution.d.ts.map +1 -0
  38. package/dist/module-file-resolution.js +51 -0
  39. package/dist/module-file-resolution.js.map +1 -0
  40. package/dist/resolve-include-sentinels.d.ts +37 -0
  41. package/dist/resolve-include-sentinels.d.ts.map +1 -0
  42. package/dist/resolve-include-sentinels.js +175 -0
  43. package/dist/resolve-include-sentinels.js.map +1 -0
  44. package/dist/resource-context.d.ts +10 -1
  45. package/dist/resource-context.d.ts.map +1 -1
  46. package/dist/resource-context.js +42 -45
  47. package/dist/resource-context.js.map +1 -1
  48. package/dist/runtime-seam.d.ts.map +1 -1
  49. package/dist/runtime-seam.js +10 -1
  50. package/dist/runtime-seam.js.map +1 -1
  51. package/dist/schema-validator.d.ts.map +1 -1
  52. package/dist/schema-validator.js +23 -12
  53. package/dist/schema-validator.js.map +1 -1
  54. package/package.json +4 -4
  55. package/src/bigint-json.ts +69 -0
  56. package/src/bigint-schema-view.ts +76 -0
  57. package/src/cel-handlers.ts +6 -9
  58. package/src/controllers/module/import-controller.ts +14 -1
  59. package/src/evaluation-context.ts +8 -1
  60. package/src/index.ts +1 -0
  61. package/src/invocation-contract-binding.ts +9 -55
  62. package/src/kernel.ts +29 -0
  63. package/src/logging/encode-json.ts +14 -9
  64. package/src/logging/encode-pretty.ts +12 -3
  65. package/src/module-file-resolution.ts +64 -0
  66. package/src/resolve-include-sentinels.ts +219 -0
  67. package/src/resource-context.ts +46 -45
  68. package/src/runtime-seam.ts +11 -0
  69. package/src/schema-validator.ts +23 -12
@@ -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
+ }
@@ -33,7 +33,9 @@ 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
+ import { withBigIntsAsNumbers } from "./bigint-schema-view.js";
36
37
  import type { ModuleArtifact } from "./bundle/module-artifact.js";
38
+ import { resolveModuleFileUri } from "./module-file-resolution.js";
37
39
  import { hostEnv } from "./host-env.js";
38
40
  import type { LoggingHost } from "./logging/logging-host.js";
39
41
  import type { ScopeConfig } from "./logging/scope-config.js";
@@ -74,6 +76,12 @@ export class ResourceContextImpl implements ResourceContext {
74
76
  * eagerly for every context would allocate per resource for nothing. */
75
77
  #log: Logger | undefined;
76
78
 
79
+ /** The resolved kind. It cannot come from `metadata`, which is the resource's
80
+ * metadata BLOCK — `kind` is its sibling, not its member, so reading
81
+ * `metadata.kind` yielded `undefined` and every controller record went out
82
+ * with no resource identity at all (§7.3). */
83
+ #resolvedKind: string | undefined;
84
+
77
85
  /**
78
86
  * The resource's structured logger, stamped with its identity, module, and
79
87
  * import-alias scope so a record identifies *which instance* emitted it — the
@@ -82,8 +90,8 @@ export class ResourceContextImpl implements ResourceContext {
82
90
  */
83
91
  get log(): Logger {
84
92
  if (!this.#log) {
85
- const kind = this.metadata?.kind as string | undefined;
86
- const name = this.metadata?.metadata?.name as string | undefined;
93
+ const kind = this.#resolvedKind;
94
+ const name = this.metadata?.name as string | undefined;
87
95
  const resource =
88
96
  kind && name
89
97
  ? { kind, name, id: `${this.ownerPrefix}${kind}.${name}` }
@@ -150,7 +158,17 @@ export class ResourceContextImpl implements ResourceContext {
150
158
  * dispatch fails.
151
159
  */
152
160
  private readonly owningContext: IEvaluationContext = moduleContext,
161
+ /**
162
+ * The resolved kind, known long before `create()` runs. Passed here rather
163
+ * than waited for at `bindResourceIdentity` so `ctx.log` carries resource
164
+ * identity from the moment the context exists: a controller that captures
165
+ * `ctx.log` in its constructor — the natural thing to do when the logger is
166
+ * handed to a helper — would otherwise hold an identity-less logger for the
167
+ * resource's whole life, and nothing would report that it had.
168
+ */
169
+ resolvedKind?: string,
153
170
  ) {
171
+ this.#resolvedKind = resolvedKind;
154
172
  // `ctx.env` is the sanctioned host-env channel for controllers — always the
155
173
  // real environment (kernel passes its snapshot), never the locked Proxy.
156
174
  this.env = env ?? hostEnv();
@@ -313,7 +331,11 @@ export class ResourceContextImpl implements ResourceContext {
313
331
  additionalProperties: false,
314
332
  },
315
333
  );
316
- const isValid = validate(stripCompiledValues(value));
334
+ // A BigInt-normalized view: AJV reads `integer` as `typeof == "number"`, so a
335
+ // CEL integer (int64) would be rejected at a slot it satisfies. This validator
336
+ // runs without `useDefaults` and already checks a derived value, so there is
337
+ // nothing to merge back. See `bigint-schema-view.ts`.
338
+ const isValid = validate(withBigIntsAsNumbers(stripCompiledValues(value)));
317
339
  if (!isValid) {
318
340
  throw new RuntimeError(
319
341
  "ERR_INVALID_VALUE",
@@ -343,6 +365,10 @@ export class ResourceContextImpl implements ResourceContext {
343
365
  manifest: Record<string, unknown>,
344
366
  ): void {
345
367
  this.#self = handle;
368
+ // The kind is NOT restated here. It is set at construction — the single
369
+ // production site always has it — and `#log` is memoized on first access, so
370
+ // a late assignment could not reach a logger a controller already holds. A
371
+ // fallback here would read as a guarantee it cannot provide.
346
372
  this.#zones = new ZoneContext({
347
373
  resourceName: (this.metadata?.name as string) ?? "<unnamed>",
348
374
  resolvedKind,
@@ -420,10 +446,16 @@ export class ResourceContextImpl implements ResourceContext {
420
446
  // settlement is already observed here.
421
447
  const tracked = this.owningContext
422
448
  .runDetached(fn) // bare scope-detach primitive
423
- .catch(async (err: unknown) => {
424
- const detail =
425
- err instanceof Error ? { name: err.name, message: err.message } : { message: String(err) };
426
- await this.emitEvent("background.task.error", { resource: this.metadata.name, error: detail });
449
+ .catch((err: unknown) => {
450
+ // A detached task has no caller to throw to, so this record is the only
451
+ // report. It replaces the bus event rather than joining it: `eventName`
452
+ // IS the bridge to the event bus (§4), and a record already reaches every
453
+ // sink including the debug wire, so emitting both would ship two copies
454
+ // of one fact with two payload shapes to keep in step.
455
+ this.log.error("Detached task failed", undefined, {
456
+ error: err,
457
+ eventName: "background.task.error",
458
+ });
427
459
  })
428
460
  .finally(() => {
429
461
  this.pendingDetached.delete(tracked);
@@ -448,10 +480,12 @@ export class ResourceContextImpl implements ResourceContext {
448
480
  await Promise.race([Promise.allSettled([...this.pendingDetached]), timeout]);
449
481
  if (timer) clearTimeout(timer);
450
482
  if (this.pendingDetached.size > 0) {
451
- await this.emitEvent("background.task.abandoned", {
452
- resource: this.metadata.name,
453
- count: this.pendingDetached.size,
454
- });
483
+ this.log.warn(
484
+ `Abandoned ${this.pendingDetached.size} background task(s) after waiting ` +
485
+ `${DETACHED_DRAIN_TIMEOUT_MS}ms for them to drain`,
486
+ { "telo.detached.abandoned": this.pendingDetached.size },
487
+ { eventName: "background.task.abandoned" },
488
+ );
455
489
  }
456
490
  }
457
491
 
@@ -761,40 +795,7 @@ export class ResourceContextImpl implements ResourceContext {
761
795
  * rebased onto the module directory.
762
796
  */
763
797
  async resolveModuleFile(relative: string): Promise<string> {
764
- // An absolute URI names its own location; a bare absolute path is already
765
- // resolved and must not be rebased onto the module directory.
766
- if (/^[a-z][a-z0-9+.-]*:/i.test(relative)) return relative;
767
- if (path.isAbsolute(relative)) return pathToFileURL(relative).href;
768
-
769
- const source = this.moduleContext.source;
770
- const artifact = this.kernel.getModuleArtifact(source);
771
- if (artifact) {
772
- // Both the `assets` layer and `common` — the sink rule puts a file the
773
- // author did not claim via `assets:` into `common`, and a module that ships
774
- // static files with no bundled controller has no other route to its payload.
775
- // Fetching only assets would leave such a module resolving into an empty
776
- // directory.
777
- await artifact.materializeModuleFiles();
778
- return new URL(relative, pathToFileURL(path.join(artifact.directory, "/")).href).href;
779
- }
780
- // No artifact means no payload to fetch. That is normal for a module already
781
- // on disk (development) or one that ships no files — but for a module reached
782
- // over a non-local scheme it means the artifact carries no layer index, i.e. it
783
- // predates layers. Raise the actionable error here rather than leaving each
784
- // caller to invent its own message from a URI it cannot open.
785
- if (!source.startsWith("file://") && !path.isAbsolute(source)) {
786
- throw new RuntimeError(
787
- "ERR_MODULE_FILES_UNAVAILABLE",
788
- `Cannot resolve '${relative}' against module '${source}': the module's artifact ` +
789
- `carries no layer index, so its files cannot be located. It was published by an ` +
790
- `older Telo that wrote a single-blob artifact — republish the module, or import it ` +
791
- `from a local path during development.`,
792
- );
793
- }
794
- // Local module: resolve against the manifest URL, the same rule `include:`
795
- // and sibling imports follow.
796
- const base = source.startsWith("file://") ? source : pathToFileURL(source).href;
797
- return new URL(relative, base).href;
798
+ return resolveModuleFileUri(relative, this.moduleContext.source, this.kernel);
798
799
  }
799
800
 
800
801
  on(event: string, handler: (payload?: any) => void | Promise<void>): void {
@@ -2,8 +2,10 @@ import {
2
2
  Loader,
3
3
  StaticAnalyzer,
4
4
  collectZoneModuleDocuments,
5
+ diagnosticFix,
5
6
  flattenForAnalyzer,
6
7
  type AnalysisDiagnostic,
8
+ type DiagnosticData,
7
9
  type ManifestSource,
8
10
  type ZoneModuleDocuments,
9
11
  } from "@telorun/analyzer";
@@ -116,6 +118,12 @@ const SEVERITY_NAMES: Record<number, CheckDiagnosticSeverity> = {
116
118
  * read by kernels that speak no LSP. An unlabelled severity is an error: the
117
119
  * analyzer's own default, and the safe reading for a caller gating on it. */
118
120
  function toCheckDiagnostic(diagnostic: AnalysisDiagnostic): CheckDiagnostic {
121
+ // The repair is read through the analyzer's accessor rather than by casting
122
+ // `data`, so the stamp's shape stays owned by one module. `resource` / `path`
123
+ // ride along because a repair replaces the value AT `path`; forwarding the
124
+ // fix without its anchor gives a consumer something it cannot apply.
125
+ const fix = diagnosticFix(diagnostic);
126
+ const stamp = diagnostic.data as DiagnosticData | undefined;
119
127
  return {
120
128
  code: String(diagnostic.code ?? ""),
121
129
  message: diagnostic.message,
@@ -123,6 +131,9 @@ function toCheckDiagnostic(diagnostic: AnalysisDiagnostic): CheckDiagnostic {
123
131
  source: diagnostic.source,
124
132
  line: diagnostic.range?.start?.line,
125
133
  column: diagnostic.range?.start?.character,
134
+ ...(stamp?.resource ? { resource: `${stamp.resource.kind}/${stamp.resource.name}` } : {}),
135
+ ...(stamp?.path ? { path: stamp.path } : {}),
136
+ ...(fix ? { fix: { replacement: fix.replacement } } : {}),
126
137
  };
127
138
  }
128
139
 
@@ -8,17 +8,15 @@ import * as fs from "node:fs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as path from "node:path";
10
10
  import { binaryKeyword, X_TELO_BINARY } from "@telorun/analyzer";
11
+ import { mergeFilledDefaults, withBigIntsAsNumbers } from "./bigint-schema-view.js";
11
12
  import { formatAjvErrors } from "./manifest-schemas.js";
12
13
 
13
- /** Render a value for an error message without ever throwing.
14
- *
15
- * `JSON.stringify` refuses BigInt, and CEL evaluates an integer literal to one —
16
- * so serializing the offending data threw from inside the message template and
17
- * the thrown stringify error REPLACED the validation failure. The author was
18
- * told "cannot serialize BigInt" instead of which field was wrong. */
14
+ /** Render a value for an error message without ever throwing — the offending
15
+ * data may be cyclic, and a throw here would REPLACE the validation failure
16
+ * with an unrelated error naming no field. */
19
17
  function describeValue(data: unknown): string {
20
18
  try {
21
- return JSON.stringify(data, (_k, v) => (typeof v === "bigint" ? `${v}` : v)) ?? String(data);
19
+ return JSON.stringify(data) ?? String(data);
22
20
  } catch {
23
21
  return String(data);
24
22
  }
@@ -399,19 +397,32 @@ export class SchemaValidator {
399
397
  const validate = this.compileAjvOrLoadCached(sanitized, hash, persist);
400
398
  if (persist) this.persistedHashes.add(hash);
401
399
 
400
+ // AJV's type check is `typeof data == "number"`, so a CEL integer — a BigInt —
401
+ // is rejected at an `integer` slot no matter what the author writes. Check a
402
+ // normalized VIEW instead and merge the `useDefaults` fills back, so the value
403
+ // that reaches the controller keeps its 64-bit range. `withBigIntsAsNumbers`
404
+ // returns the same reference when there was nothing to normalize, which is what
405
+ // keeps the BigInt-free path byte-identical to a plain `validate(data)`.
406
+ const check = (data: any): boolean => {
407
+ const view = withBigIntsAsNumbers(data);
408
+ const ok = validate(view);
409
+ if (ok && view !== data) mergeFilledDefaults(data, view);
410
+ return ok;
411
+ };
412
+
402
413
  const validator = {
403
414
  validate: (data: any) => {
404
- const isValid = validate(data);
405
- if (!isValid) {
415
+ if (!check(data)) {
416
+ // Reports `data`, not the normalized view: the view renders a wide
417
+ // integer through a double, so the digits it prints for the offending
418
+ // value would not be the ones the author wrote.
406
419
  throw new RuntimeError(
407
420
  "ERR_RESOURCE_SCHEMA_VALIDATION_FAILED",
408
421
  `Invalid value passed: ${describeValue(data)}. Error: ${formatAjvErrors(validate.errors)}`,
409
422
  );
410
423
  }
411
424
  },
412
- isValid: (data: any) => {
413
- return validate(data);
414
- },
425
+ isValid: (data: any) => check(data),
415
426
  };
416
427
 
417
428
  this.hashCache.set(hash, validator);