@telorun/analyzer 0.61.0 → 0.62.1

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 (73) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +130 -9
  3. package/dist/builtins.d.ts.map +1 -1
  4. package/dist/builtins.js +69 -12
  5. package/dist/cel-bindings.d.ts +0 -6
  6. package/dist/cel-bindings.d.ts.map +1 -1
  7. package/dist/cel-bindings.js +3 -28
  8. package/dist/definition-registry.d.ts +17 -0
  9. package/dist/definition-registry.d.ts.map +1 -1
  10. package/dist/definition-registry.js +31 -2
  11. package/dist/identifier-name.d.ts +114 -0
  12. package/dist/identifier-name.d.ts.map +1 -0
  13. package/dist/identifier-name.js +183 -0
  14. package/dist/index.d.ts +10 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +8 -0
  17. package/dist/manifest-schemas.d.ts +81 -0
  18. package/dist/manifest-schemas.d.ts.map +1 -1
  19. package/dist/manifest-schemas.js +208 -6
  20. package/dist/release/payload-digest.d.ts +7 -3
  21. package/dist/release/payload-digest.d.ts.map +1 -1
  22. package/dist/release/payload-digest.js +7 -3
  23. package/dist/requires-block.d.ts +125 -0
  24. package/dist/requires-block.d.ts.map +1 -0
  25. package/dist/requires-block.js +182 -0
  26. package/dist/schema-keywords.d.ts +68 -0
  27. package/dist/schema-keywords.d.ts.map +1 -0
  28. package/dist/schema-keywords.js +324 -0
  29. package/dist/schema-region.d.ts +12 -1
  30. package/dist/schema-region.d.ts.map +1 -1
  31. package/dist/schema-region.js +12 -1
  32. package/dist/telo-version.d.ts +3 -0
  33. package/dist/telo-version.d.ts.map +1 -0
  34. package/dist/telo-version.js +8 -0
  35. package/dist/types.d.ts +31 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/validate-identifier-names.d.ts +31 -0
  38. package/dist/validate-identifier-names.d.ts.map +1 -0
  39. package/dist/validate-identifier-names.js +144 -0
  40. package/dist/validate-observed-state.d.ts +9 -2
  41. package/dist/validate-observed-state.d.ts.map +1 -1
  42. package/dist/validate-observed-state.js +9 -2
  43. package/dist/validate-references.d.ts.map +1 -1
  44. package/dist/validate-references.js +5 -26
  45. package/dist/validate-requires.d.ts +49 -0
  46. package/dist/validate-requires.d.ts.map +1 -0
  47. package/dist/validate-requires.js +99 -0
  48. package/dist/value-type-keyword.d.ts +1 -1
  49. package/dist/value-type-keyword.d.ts.map +1 -1
  50. package/dist/value-type-keyword.js +1 -0
  51. package/dist/version-range.d.ts +88 -0
  52. package/dist/version-range.d.ts.map +1 -0
  53. package/dist/version-range.js +173 -0
  54. package/package.json +2 -2
  55. package/src/analyzer.ts +146 -10
  56. package/src/builtins.ts +73 -12
  57. package/src/cel-bindings.ts +3 -28
  58. package/src/definition-registry.ts +30 -2
  59. package/src/identifier-name.ts +228 -0
  60. package/src/index.ts +34 -0
  61. package/src/manifest-schemas.ts +223 -4
  62. package/src/release/payload-digest.ts +7 -3
  63. package/src/requires-block.ts +253 -0
  64. package/src/schema-keywords.ts +359 -0
  65. package/src/schema-region.ts +12 -1
  66. package/src/telo-version.ts +9 -0
  67. package/src/types.ts +32 -0
  68. package/src/validate-identifier-names.ts +173 -0
  69. package/src/validate-observed-state.ts +9 -2
  70. package/src/validate-references.ts +5 -26
  71. package/src/validate-requires.ts +129 -0
  72. package/src/value-type-keyword.ts +1 -0
  73. package/src/version-range.ts +238 -0
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Naming rules for every author-written Telo identifier — resource instances,
3
+ * kinds, modules, import aliases, step names, config declarations and CEL
4
+ * bindings. The single reader, so no surface re-derives the vocabulary; the
5
+ * `ref-slot.ts` / `zone-slot.ts` precedent.
6
+ *
7
+ * **The rules exist because Telo has no lexer.** A name is a YAML scalar, so
8
+ * nothing rejects its shape where it is declared, and every consequence
9
+ * surfaces later at a CEL site that references it — or not at all. Probed
10
+ * against the engine the runtime actually uses (`@marcbachmann/cel-js`):
11
+ *
12
+ * resources.my-server.url → EVALUATES, as `resources.my - server.url`
13
+ * resources.in → ParseError: Expected IDENTIFIER, got IN
14
+ * resources.2fa → ParseError
15
+ * resources.for → parses fine
16
+ *
17
+ * The first line is the one that decides the design. When a bare name is in
18
+ * scope — which `x-telo-bindings-from` deliberately makes possible — a
19
+ * hyphenated resource name yields a wrong number with no diagnostic anywhere.
20
+ * That is a swallowed error in the reference grammar, not a style preference.
21
+ * The last line is why the reserved set here is the whole keyword list rather
22
+ * than the subset today's parser happens to reject in field position: which
23
+ * keywords tokenize there is a property of a dependency, and a name that
24
+ * breaks on a parser upgrade was never safe.
25
+ *
26
+ * **Three tiers, because they fail in three different ways.**
27
+ *
28
+ * 1. `INVALID_NAME` (error, every surface) — not a CEL-safe identifier, or a
29
+ * CEL keyword. Below this line the name is unreferenceable or silently
30
+ * mis-referenced. This subsumes the old dot-only rule, which was the
31
+ * strictest special case of it: `!ref` splits on the first dot.
32
+ *
33
+ * 2. `INVALID_TYPE_NAME` (error) — a type-level name not starting uppercase.
34
+ * An error rather than a warning because half the reference grammar already
35
+ * rejects the alternative: `EXTENDS_ALIAS_PATTERN` in the kernel's manifest
36
+ * schema hard-rejects `extends: foo.Bar`, while nothing rejects the
37
+ * `metadata.name: foo` that produced it. A lowercase kind is a kind nobody
38
+ * can extend, so this only moves an existing failure to where it is
39
+ * fixable.
40
+ *
41
+ * 3. `NAME_CASE_CONVENTION` (warning) — a value-level name not starting
42
+ * lowercase. Warn-only, Rust's `non_snake_case` posture: a name is
43
+ * occasionally dictated from outside, and Telo has no `#[allow]`, so a hard
44
+ * error would leave no escape.
45
+ *
46
+ * **The convention: case encodes what the name denotes.** PascalCase names a
47
+ * *type* — something writable in `kind:` / `extends:` / an `x-telo-ref` or type
48
+ * slot. camelCase names a *value* — something holding data at runtime, read
49
+ * through `resources.` / `steps.` / `variables.` in CEL. That is what
50
+ * distinguishes `kind: Console.WriteLine` from `!ref Console.writeLine`, two
51
+ * character-identical grammars otherwise, and the collision is not
52
+ * hypothetical: it is the sanctioned singleton pattern (declare
53
+ * `kind: Self.WriteLine`, export the instance, withhold the kind).
54
+ *
55
+ * **Only the first character is checked.** A full camelCase pattern would
56
+ * relitigate `httpApi` vs `httpAPI` and `OAuthClient` vs `OauthClient` with no
57
+ * benefit — the first character is what carries the type/value signal, and a
58
+ * stricter rule would fight legitimate names containing acronyms or digits
59
+ * while owning a judgement nobody asked this pass to make. An
60
+ * entirely-acronym type name (`SQL`, `AI`) passes unchanged.
61
+ *
62
+ * **No `DiagnosticFix`.** A fix is a whole-value replacement for ONE node,
63
+ * and a rename is only correct when every reference moves with it — offering
64
+ * one here would rewrite `metadata.name` and break every `!ref` and CEL read
65
+ * of it. Renaming belongs to a refactor that owns the reference graph.
66
+ *
67
+ * Browser-safe: pure string predicates, no I/O, no Node built-ins.
68
+ */
69
+
70
+ import { DiagnosticSeverity } from "./types.js";
71
+
72
+ /**
73
+ * CEL keywords. A name matching one is unreachable — `true` lexes as a
74
+ * literal, `in` as an operator — so it is reserved everywhere a name becomes a
75
+ * CEL identifier, which is every surface this file governs.
76
+ */
77
+ export const CEL_RESERVED_WORDS: readonly string[] = [
78
+ "as",
79
+ "break",
80
+ "const",
81
+ "continue",
82
+ "else",
83
+ "false",
84
+ "for",
85
+ "function",
86
+ "if",
87
+ "import",
88
+ "in",
89
+ "let",
90
+ "loop",
91
+ "namespace",
92
+ "null",
93
+ "package",
94
+ "return",
95
+ "true",
96
+ "var",
97
+ "void",
98
+ "while",
99
+ ];
100
+
101
+ const RESERVED = new Set(CEL_RESERVED_WORDS);
102
+
103
+ /** What a CEL identifier may be — and therefore what a name that will be read
104
+ * through `resources.` / `steps.` / `variables.` must be. */
105
+ const CEL_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
106
+
107
+ /**
108
+ * Which half of the convention a name falls under.
109
+ *
110
+ * `type` — a name writable in a `kind:` / `extends:` / type slot: a module, a
111
+ * kind, an import alias, or a resource whose capability is `Telo.Type` (a
112
+ * named shape has no runtime instance, so its name denotes a type even though
113
+ * it is declared as a resource).
114
+ *
115
+ * `value` — a name read through a CEL scope: a resource instance, a step, a
116
+ * `variables` / `secrets` / `ports` declaration, a CEL binding.
117
+ */
118
+ export type NameLevel = "type" | "value";
119
+
120
+ /** Document kinds whose `metadata.name` is type-level. Everything else
121
+ * declaring a name is a resource instance, whose level is decided by its
122
+ * kind's capability (see {@link NameLevel}). */
123
+ export const TYPE_LEVEL_DOC_KINDS: ReadonlySet<string> = new Set([
124
+ "Telo.Application",
125
+ "Telo.Library",
126
+ "Telo.Definition",
127
+ "Telo.Abstract",
128
+ "Telo.Import",
129
+ ]);
130
+
131
+ export interface NameViolation {
132
+ /** Which tier failed. Callers use it to suppress a tier another check
133
+ * already reports better at that surface (the bindings site owns
134
+ * `reserved`, where it can also say what is being shadowed). */
135
+ tier: "grammar" | "reserved" | "case";
136
+ code: "INVALID_NAME" | "INVALID_TYPE_NAME" | "NAME_CASE_CONVENTION";
137
+ severity: DiagnosticSeverity;
138
+ /** Complete sentence(s), subject included — so every surface words the same
139
+ * rule the same way. */
140
+ message: string;
141
+ }
142
+
143
+ /**
144
+ * Check one author-written name.
145
+ *
146
+ * `surface` is the noun phrase naming what was declared ("resource name",
147
+ * "step name", "import alias"), used as the message's subject.
148
+ *
149
+ * Returns the first violation only: the tiers are ordered by how badly the
150
+ * name is broken, and telling an author their unparseable name is also
151
+ * miscased buries the part that matters.
152
+ */
153
+ export function checkName(
154
+ name: string,
155
+ level: NameLevel,
156
+ surface: string,
157
+ ): NameViolation | undefined {
158
+ const subject = `${surface} '${name}'`;
159
+
160
+ if (!CEL_IDENTIFIER_RE.test(name)) {
161
+ return {
162
+ tier: "grammar",
163
+ code: "INVALID_NAME",
164
+ severity: DiagnosticSeverity.Error,
165
+ message: `${subject} must match /^[A-Za-z_][A-Za-z0-9_]*$/ — ${grammarReason(name, level)}`,
166
+ };
167
+ }
168
+
169
+ if (RESERVED.has(name)) {
170
+ return {
171
+ tier: "reserved",
172
+ code: "INVALID_NAME",
173
+ severity: DiagnosticSeverity.Error,
174
+ message: `${subject} is a CEL keyword, so no expression can reference it. Rename it.`,
175
+ };
176
+ }
177
+
178
+ const first = name[0]!;
179
+ if (level === "type" && !(first >= "A" && first <= "Z")) {
180
+ return {
181
+ tier: "case",
182
+ code: "INVALID_TYPE_NAME",
183
+ severity: DiagnosticSeverity.Error,
184
+ message:
185
+ `${subject} must start with an uppercase letter — it names a type, and the ` +
186
+ `alias-qualified grammar of 'kind:' / 'extends:' accepts only PascalCase.`,
187
+ };
188
+ }
189
+
190
+ if (level === "value" && !(first >= "a" && first <= "z")) {
191
+ return {
192
+ tier: "case",
193
+ code: "NAME_CASE_CONVENTION",
194
+ severity: DiagnosticSeverity.Warning,
195
+ message:
196
+ `${subject} should start with a lowercase letter — camelCase names a value (read ` +
197
+ `through a CEL scope), PascalCase names a type.`,
198
+ };
199
+ }
200
+
201
+ return undefined;
202
+ }
203
+
204
+ /**
205
+ * Why the character set is what it is — which differs by level, and saying so
206
+ * accurately matters more than one shared sentence. A value-level name becomes
207
+ * a CEL identifier. A type-level name never does: it is a kind prefix or
208
+ * suffix, so what constrains it is the alias-qualified `<Alias>.<Kind>` grammar
209
+ * (`EXTENDS_ALIAS_PATTERN` in the kernel's manifest schema), which accepts the
210
+ * same characters for its own reasons.
211
+ */
212
+ function grammarReason(name: string, level: NameLevel): string {
213
+ if (level === "type") {
214
+ return name.includes(".")
215
+ ? `a '.' separates the two halves of every '<Alias>.<Kind>' reference, so a dotted name cannot appear in either.`
216
+ : `that is the character set the alias-qualified '<Alias>.<Kind>' grammar accepts on both sides.`;
217
+ }
218
+ if (name.includes("-")) {
219
+ return (
220
+ `it becomes a CEL identifier and CEL reads '-' as subtraction, so where a bare name is in ` +
221
+ `scope this evaluates as arithmetic instead of failing.`
222
+ );
223
+ }
224
+ if (name.includes(".")) {
225
+ return `in a '!ref' the first '.' separates the import alias from the name.`;
226
+ }
227
+ return `it becomes a CEL identifier, so as written it cannot be referenced.`;
228
+ }
package/src/index.ts CHANGED
@@ -33,6 +33,12 @@ export {
33
33
  resolveBindingOrder,
34
34
  } from "./cel-bindings.js";
35
35
  export type { BindingSites } from "./cel-bindings.js";
36
+ export {
37
+ CEL_RESERVED_WORDS,
38
+ TYPE_LEVEL_DOC_KINDS,
39
+ checkName,
40
+ } from "./identifier-name.js";
41
+ export type { NameLevel, NameViolation } from "./identifier-name.js";
36
42
  export {
37
43
  applyObservedStateNode,
38
44
  buildObservedStateIndex,
@@ -116,6 +122,31 @@ export {
116
122
  renderFixReplacement,
117
123
  } from "./yaml-source-edit.js";
118
124
  export type { QuoteStyle, TextEdit } from "./yaml-source-edit.js";
125
+ export { KNOWN_HOST_AXES, evaluateRequires, readRequires } from "./requires-block.js";
126
+ export type {
127
+ HostAxis,
128
+ HostVersions,
129
+ ReadRequiresResult,
130
+ RequiresBlock,
131
+ RequiresIssue,
132
+ RequiresVerdict,
133
+ } from "./requires-block.js";
134
+ export { TELO_SURFACE_VERSION } from "./telo-version.js";
135
+ export { validateRequires } from "./validate-requires.js";
136
+ export type { ValidateRequiresOptions } from "./validate-requires.js";
137
+ export {
138
+ isUnsatisfiable,
139
+ lowerBound,
140
+ parseVersionRange,
141
+ rangeAccepts,
142
+ upperBound,
143
+ } from "./version-range.js";
144
+ export type {
145
+ ComparatorOperator,
146
+ VersionComparator,
147
+ VersionRange,
148
+ VersionRangeResult,
149
+ } from "./version-range.js";
119
150
  export {
120
151
  hasProvidesZone,
121
152
  hasRequiresZone,
@@ -280,6 +311,9 @@ export type {
280
311
  Range
281
312
  } from "./types.js";
282
313
  export * from "./manifest-schemas.js";
314
+ // The JSON Schema vocabulary a manifest may write, as data — the source both the
315
+ // validating fragments and the IDE's completion are built from.
316
+ export * from "./schema-keywords.js";
283
317
  // The release model — module identity, fragments, the ledger, the edge graph and
284
318
  // version planning. Pure data in, plan out, so the editor and the CLI release
285
319
  // from one model.
@@ -28,6 +28,9 @@
28
28
  * and `$ref`-ing it from module schemas. Browser-safe: no Node built-ins.
29
29
  */
30
30
 
31
+ import { jsonSchemaKeywords } from "./schema-keywords.js";
32
+ import { SCHEMA_REGION_KEYS } from "./schema-region.js";
33
+
31
34
  export const MANIFEST_SCHEMA_URI = "telo://manifest";
32
35
 
33
36
  /** `$ref` to a fragment in this set, as a module schema writes it. */
@@ -247,6 +250,94 @@ export const InvokeStepSchema = {
247
250
  },
248
251
  };
249
252
 
253
+ /**
254
+ * A JSON Schema an author writes as a manifest VALUE — an `inputType:`, an
255
+ * `outputType:`, a `status:` block, an API route's `request.schema`.
256
+ *
257
+ * Declared so the surfaces that read a slot's schema learn what lives there. A
258
+ * slot spelled `type: object` told them "some object": completion offered
259
+ * nothing from the first key down, hover had nothing to show, and a misspelled
260
+ * keyword travelled to a runtime failure that named the wrong field.
261
+ *
262
+ * OPEN, deliberately (`additionalProperties: true`, `type` admitting the
263
+ * boolean form a nested `additionalProperties: false` takes). The keyword set
264
+ * below is draft-07's — the dialect AJV actually runs — and everything outside
265
+ * it, every `x-telo-*` annotation included, passes through untouched. Closing
266
+ * this would reject the next annotation the moment a module invented one, for a
267
+ * check nobody asked for; what it buys as it stands is the value of a keyword
268
+ * an author DID write (`required: "name"`, `type: 5`) being wrong at
269
+ * `telo check` rather than at dispatch.
270
+ *
271
+ * RECURSIVE, which is why it is not expanded in place like the fragments above
272
+ * it: a schema's properties hold schemas. `expandManifestFragments` rewrites a
273
+ * reference to this to the document-local `#/$defs/JsonSchema7` and hoists one
274
+ * copy to the enclosing schema's root, so the pointer resolves inside whatever
275
+ * AJV compiles, and the editor's local-only `$ref` resolver keeps working.
276
+ */
277
+ export const JsonSchema7Schema = {
278
+ title: "JSON Schema",
279
+ description: "The shape of a value, as JSON Schema (draft-07).",
280
+ type: ["object", "boolean"],
281
+ properties: jsonSchemaKeywords(hoistedDefKey("JsonSchema7")),
282
+ additionalProperties: true,
283
+ };
284
+
285
+ /**
286
+ * The schema a KIND declares for its own configuration — a `Telo.Definition`'s
287
+ * `schema:` block.
288
+ *
289
+ * The same body as {@link JsonSchema7Schema}, under its own name because the
290
+ * name is the discriminator: a kind's schema is where the `x-telo-*` vocabulary
291
+ * belongs (`x-telo-eval`, `x-telo-ref`, `x-telo-scope`, …) and a plain data
292
+ * schema is where it does not. Completion reads the `x-telo-fragment` stamp to
293
+ * decide which vocabulary to offer, exactly as a retry-budget consumer reads
294
+ * which of `RetryPolicy` / `RetryAttempts` a slot pointed at.
295
+ *
296
+ * The annotations are NOT properties here — see `schema-keywords.ts` for why a
297
+ * literal `x-telo-*` key inside a hoisted `properties` map would read to the
298
+ * annotation walkers as a slot the author never wrote.
299
+ */
300
+ export const KindSchemaSchema = {
301
+ title: "Kind schema",
302
+ description:
303
+ "The configuration a resource of this kind accepts, as JSON Schema plus the `x-telo-*` annotations that say how each field behaves.",
304
+ type: ["object", "boolean"],
305
+ properties: jsonSchemaKeywords(hoistedDefKey("KindSchema")),
306
+ additionalProperties: true,
307
+ };
308
+
309
+ /** The fragments that describe author-written JSON Schema, whichever vocabulary
310
+ * they admit. They are also exactly the RECURSIVE ones — a schema is the only
311
+ * shape in this set that contains itself — so a reference to one is localized
312
+ * and hoisted rather than expanded in place ({@link localizeRecursiveFragment});
313
+ * should a self-containing fragment that is not a schema ever land, the two
314
+ * ideas split and this set stays the one about schemas. */
315
+ const SCHEMA_FRAGMENTS = new Set(["JsonSchema7", "KindSchema"]);
316
+
317
+ /** True when a slot's `x-telo-fragment` stamp says it holds author-written JSON
318
+ * Schema — a kind's `schema:`, a `status:` block, a `Telo.JsonSchema`'s own
319
+ * `schema`. The one accessor every consumer asks, so no surface re-spells the
320
+ * set. */
321
+ export function isSchemaFragment(name: string | undefined): boolean {
322
+ return name !== undefined && SCHEMA_FRAGMENTS.has(name);
323
+ }
324
+
325
+ /**
326
+ * The `$defs` key a hoisted fragment is written under, and the one its own
327
+ * self-reference points at.
328
+ *
329
+ * NAMESPACED so a collision is unrepresentable rather than diagnosable: `$defs`
330
+ * is the author's namespace, and a kind declaring its own `$defs: { KindSchema:
331
+ * … }` beside a slot pointing at the fragment would otherwise have its shape
332
+ * silently validate every such slot — a wrong-but-plausible validation, which is
333
+ * worse than a loud failure and impossible to see. With a reserved key the two
334
+ * coexist, and "already present" can only mean a previous hoist of the same
335
+ * fragment, which is what makes {@link hoistFragmentDef}'s skip provably safe.
336
+ */
337
+ function hoistedDefKey(name: string): string {
338
+ return `telo:${name}`;
339
+ }
340
+
250
341
  /** Recursively freeze, so the fragment set cannot be edited through any of the
251
342
  * references handed out. `fragmentFor` clones precisely because downstream
252
343
  * passes rewrite schemas in place — `resolveSchemaRefKinds` rewrites the very
@@ -271,6 +362,8 @@ export const ManifestRootSchema = {
271
362
  RetryPolicy: RetryPolicySchema,
272
363
  RetryAttempts: RetryAttemptsSchema,
273
364
  InvokeStep: InvokeStepSchema,
365
+ JsonSchema7: JsonSchema7Schema,
366
+ KindSchema: KindSchemaSchema,
274
367
  },
275
368
  };
276
369
 
@@ -285,6 +378,16 @@ export function manifestFragment(name: string): Record<string, unknown> {
285
378
  if (!fragment || typeof fragment !== "object") {
286
379
  throw new Error(`Unknown manifest fragment '${name}'`);
287
380
  }
381
+ if (SCHEMA_FRAGMENTS.has(name)) {
382
+ // A recursive fragment has no expanded form — that is the whole reason it is
383
+ // localized instead. Embedding one would hand the consumer a body whose
384
+ // `#/$defs` pointers resolve against nothing.
385
+ throw new Error(
386
+ `Manifest fragment '${name}' is recursive and cannot be embedded — ` +
387
+ `point a slot at it with $ref: "${manifestFragmentRef(name)}" and pass the ` +
388
+ `enclosing schema through withSchemaFragments().`,
389
+ );
390
+ }
288
391
  const copy = structuredClone(fragment) as Record<string, unknown>;
289
392
  expandManifestFragments(copy);
290
393
  copy[X_TELO_FRAGMENT] = name;
@@ -313,27 +416,143 @@ const FRAGMENT_PREFIX = `${MANIFEST_SCHEMA_URI}#/$defs/`;
313
416
  * loaded manifest see the same expanded shape.
314
417
  */
315
418
  export function expandManifestFragments(node: unknown, seen = new Set<object>()): void {
419
+ if (!node || typeof node !== "object") return;
420
+ walkFragments(node, seen, { hoistTarget: node as Record<string, unknown>, depth: 0 });
421
+ }
422
+
423
+ /** Where a hoisted recursive fragment's `$defs` entry goes, and how deep the
424
+ * walk is — the two facts {@link walkFragments} carries down. */
425
+ interface ExpandContext {
426
+ /** The nearest enclosing node AJV will compile as a schema: a document's
427
+ * top-level schema-valued key, else the node expansion started from. */
428
+ readonly hoistTarget: Record<string, unknown>;
429
+ readonly depth: number;
430
+ }
431
+
432
+ /**
433
+ * Descend one key. A TOP-LEVEL schema-valued key (`schema:`, `inputType:`,
434
+ * `status:`, …) opens a new hoist target, because that node is what a validator
435
+ * compiles: a `#/$defs/…` pointer written below it resolves against IT, not
436
+ * against the document. Depth-anchored for the reason migrations anchor `under`
437
+ * at top-level keys — a slot DESCRIBING a field named `schema` is not itself a
438
+ * schema region, and treating it as one would scatter `$defs` blocks into the
439
+ * middle of a kind's property map.
440
+ */
441
+ function childContext(ctx: ExpandContext, key: string, value: unknown): ExpandContext {
442
+ if (ctx.depth === 0 && SCHEMA_REGION_KEYS.includes(key) && isPlainObject(value)) {
443
+ return { hoistTarget: value, depth: 1 };
444
+ }
445
+ return { hoistTarget: ctx.hoistTarget, depth: ctx.depth + 1 };
446
+ }
447
+
448
+ function walkFragments(node: unknown, seen: Set<object>, ctx: ExpandContext): void {
316
449
  if (!node || typeof node !== "object") return;
317
450
  if (seen.has(node as object)) return;
318
451
  seen.add(node as object);
319
452
 
320
453
  if (Array.isArray(node)) {
321
454
  for (let i = 0; i < node.length; i++) {
322
- const fragment = fragmentFor(node[i]);
323
- if (fragment) node[i] = fragment;
324
- else expandManifestFragments(node[i], seen);
455
+ const localized = localizeRecursiveFragment(node[i], ctx);
456
+ if (localized) node[i] = localized;
457
+ else {
458
+ const fragment = fragmentFor(node[i]);
459
+ if (fragment) node[i] = fragment;
460
+ else walkFragments(node[i], seen, { ...ctx, depth: ctx.depth + 1 });
461
+ }
325
462
  }
326
463
  return;
327
464
  }
328
465
 
329
466
  const obj = node as Record<string, unknown>;
330
467
  for (const [key, value] of Object.entries(obj)) {
468
+ const localized = localizeRecursiveFragment(value, ctx);
469
+ if (localized) {
470
+ obj[key] = localized;
471
+ continue;
472
+ }
331
473
  const fragment = fragmentFor(value);
332
474
  if (fragment) obj[key] = fragment;
333
- else expandManifestFragments(value, seen);
475
+ else walkFragments(value, seen, childContext(ctx, key, value));
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Rewrite a reference to a RECURSIVE fragment to the document-local pointer,
481
+ * hoisting one copy of the fragment into the enclosing schema's `$defs`.
482
+ *
483
+ * Localized rather than inlined because the shape contains itself: inlining
484
+ * cannot terminate, which is what the "closed, non-recursive set" caveat above
485
+ * says. Localized rather than left pointing at `telo://manifest` because two
486
+ * consumers cannot follow a foreign URI — the editor's resolver throws on any
487
+ * `$ref` that does not start with `#/`, and that throw takes the whole canvas
488
+ * down, exactly as gating fragment expansion once did.
489
+ *
490
+ * Siblings written beside the `$ref` are kept, but draft-07 makes `$ref`
491
+ * exclusive, so they reach the human surfaces (completion, hover, the editor)
492
+ * and not AJV. A slot adds a `title` / `description` that way; a slot narrowing
493
+ * the shape would be silently ignored and should declare its own schema.
494
+ */
495
+ function localizeRecursiveFragment(
496
+ value: unknown,
497
+ ctx: ExpandContext,
498
+ ): Record<string, unknown> | undefined {
499
+ if (!isPlainObject(value)) return undefined;
500
+ const ref = value.$ref;
501
+ if (typeof ref !== "string" || !ref.startsWith(FRAGMENT_PREFIX)) return undefined;
502
+ const name = ref.slice(FRAGMENT_PREFIX.length);
503
+ if (!SCHEMA_FRAGMENTS.has(name)) return undefined;
504
+
505
+ hoistFragmentDef(ctx.hoistTarget, name);
506
+ return { ...value, $ref: `#/$defs/${hoistedDefKey(name)}`, [X_TELO_FRAGMENT]: name };
507
+ }
508
+
509
+ /** Add `name` (and anything it references in turn) to `target.$defs`, cloned —
510
+ * the fragment set is frozen, and later passes rewrite schemas in place. */
511
+ function hoistFragmentDef(target: Record<string, unknown>, name: string): void {
512
+ const defs = (target.$defs ??= {}) as Record<string, unknown>;
513
+ const pending = [name];
514
+ while (pending.length > 0) {
515
+ const next = pending.pop() as string;
516
+ const key = hoistedDefKey(next);
517
+ if (defs[key]) continue;
518
+ const fragment = (ManifestRootSchema.$defs as Record<string, unknown>)[next];
519
+ if (!fragment || typeof fragment !== "object") continue;
520
+ // Stamped on the hoisted body, not only on the slot that pointed at it: a
521
+ // schema nests, so the node a consumer resolves two levels down is the
522
+ // fragment reached through its own self-reference, with no slot in sight.
523
+ // That node is exactly where an annotation like `x-telo-eval` is written,
524
+ // so it is where the stamp has to be readable.
525
+ defs[key] = { ...(structuredClone(fragment) as object), [X_TELO_FRAGMENT]: next };
526
+ for (const nested of SCHEMA_FRAGMENTS) {
527
+ if (nested !== next && referencesLocalDef(defs[key], nested)) pending.push(nested);
528
+ }
334
529
  }
335
530
  }
336
531
 
532
+ /** Does a hoisted fragment point at another one? Cheap and structural: the set
533
+ * is small and a missed edge would leave an unresolvable pointer, which AJV
534
+ * reports loudly rather than silently skipping. */
535
+ function referencesLocalDef(node: unknown, name: string): boolean {
536
+ if (!node || typeof node !== "object") return false;
537
+ if (Array.isArray(node)) return node.some((item) => referencesLocalDef(item, name));
538
+ const obj = node as Record<string, unknown>;
539
+ if (obj.$ref === `#/$defs/${hoistedDefKey(name)}`) return true;
540
+ return Object.values(obj).some((value) => referencesLocalDef(value, name));
541
+ }
542
+
543
+ /**
544
+ * Expand fragments in a schema that never passes through the loader —
545
+ * `builtins.ts` is not a manifest, so its slots have to arrive already
546
+ * localized, with the hoisted `$defs` at the root of the schema AJV compiles.
547
+ *
548
+ * Returns the same object, expanded in place, for use as a declaration
549
+ * initializer.
550
+ */
551
+ export function withSchemaFragments<T extends Record<string, unknown>>(schema: T): T {
552
+ expandManifestFragments(schema);
553
+ return schema;
554
+ }
555
+
337
556
  /**
338
557
  * The fragment a node references, expanded and merged with whatever the node
339
558
  * declared beside the `$ref`, or undefined when it references none.
@@ -29,9 +29,13 @@ export const MANIFEST_LAYER = "manifest";
29
29
  * Exactly one today, and for a structural reason rather than an omission: the
30
30
  * published `layers:` index lives *inside* `telo.yaml`, so it cannot carry that
31
31
  * file's own digest — the entry would have to be computed over bytes containing
32
- * it. Nor can the digest be recovered by hashing what the registry serves, since
33
- * the transport injects the index at push time and the published text is
34
- * therefore not the text the payload builder produced.
32
+ * it. `readPublishedDigests` reads that index, so it has no answer for this key.
33
+ *
34
+ * It is no longer *unknowable*, though, only absent from the index: the payload
35
+ * builder now writes the index, so its manifest is the published text and this
36
+ * digest is what hashing the served `telo.yaml` yields. Reconciling it would
37
+ * mean fetching the manifest per module rather than reading one index — worth
38
+ * doing, and a separate change from the one that made it possible.
35
39
  *
36
40
  * It stays in the ledger regardless, because it is the only thing that sees a
37
41
  * **manifest-only change**: a schema edit, a new kind, a description, a