@telorun/templating 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,11 +55,11 @@ metadata:
55
55
  A complete feedback collection REST API — no code, pure YAML.
56
56
  Persists entries to SQLite and serves them over HTTP.
57
57
  imports:
58
- Http: std/http-server@0.9.0
59
- Sql: std/sql@0.8.0
58
+ Http: std/http-server@0.11.0
59
+ Sql: std/sql@0.9.0
60
60
  targets:
61
- - Migrations
62
- - Server
61
+ - !ref Migrations
62
+ - !ref Server
63
63
  ---
64
64
  # SQLite database — swap driver/host/database for PostgreSQL with zero YAML changes
65
65
  kind: Sql.Connection
@@ -72,9 +72,7 @@ file: ./tmp/feedback.db
72
72
  kind: Sql.Migrations
73
73
  metadata:
74
74
  name: Migrations
75
- connection:
76
- kind: Sql.Connection
77
- name: Db
75
+ connection: !ref Db
78
76
  ---
79
77
  kind: Sql.Migration
80
78
  metadata:
@@ -101,7 +99,7 @@ openapi:
101
99
  version: 1.0.0
102
100
  mounts:
103
101
  - path: /v1
104
- type: Http.Api.FeedbackRoutes
102
+ mount: !ref FeedbackRoutes
105
103
  ---
106
104
  kind: Http.Api
107
105
  metadata:
@@ -123,9 +121,7 @@ routes:
123
121
  required: [ text ]
124
122
  handler:
125
123
  kind: Sql.Exec
126
- connection:
127
- kind: Sql.Connection
128
- name: Db
124
+ connection: !ref Db
129
125
  inputs:
130
126
  sql: "INSERT INTO feedback (text, source, score) VALUES (?, ?, ?)"
131
127
  bindings:
@@ -146,9 +142,7 @@ routes:
146
142
  method: GET
147
143
  handler:
148
144
  kind: Sql.Select
149
- connection:
150
- kind: Sql.Connection
151
- name: Db
145
+ connection: !ref Db
152
146
  from: feedback
153
147
  columns: [ id, text, source, score, created_at ]
154
148
  orderBy:
@@ -172,9 +166,7 @@ routes:
172
166
  required: [ id ]
173
167
  handler:
174
168
  kind: Sql.Select
175
- connection:
176
- kind: Sql.Connection
177
- name: Db
169
+ connection: !ref Db
178
170
  from: feedback
179
171
  columns: [ id, text, source, score, created_at ]
180
172
  where:
@@ -1 +1 @@
1
- {"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../../src/cel/catalog.ts"],"names":[],"mappings":"AAEA;;;0DAG0D;AAC1D,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAC9B,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAC3B,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAC5B,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAC9B,IAAI,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IAClE,YAAY,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,YAAY,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,IAAI,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,MAAM,CAAC;CAClC;AAED,MAAM,MAAM,mBAAmB,GAC3B,YAAY,GACZ,MAAM,GACN,MAAM,GACN,QAAQ,GACR,MAAM,GACN,YAAY,GACZ,MAAM,GACN,UAAU,GACV,SAAS,GACT,MAAM,CAAC;AAEX;;;qBAGqB;AACrB,MAAM,WAAW,cAAc;IAC7B,+CAA+C;IAC/C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;0EACsE;IACtE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;yDAIqD;IACrD,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,QAAQ,CAAC,QAAQ,EAAE,mBAAmB,CAAC;IACvC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;wBACoB;IACpB,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC;2EACuE;IACvE,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,EAAE,WAAW,KAAK,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC;CACjE;AAED,wEAAwE;AACxE,MAAM,MAAM,eAAe,GAAG,IAAI,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC;AAyE5D,eAAO,MAAM,aAAa,EAAE,SAAS,cAAc,EAoZlD,CAAC;AAEF,oEAAoE;AACpE,wBAAgB,kBAAkB,IAAI,eAAe,EAAE,CAEtD"}
1
+ {"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../../src/cel/catalog.ts"],"names":[],"mappings":"AAEA;;;0DAG0D;AAC1D,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAC9B,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAC3B,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAC5B,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAC9B,IAAI,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IAClE,YAAY,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,YAAY,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,IAAI,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,MAAM,CAAC;CAClC;AAED,MAAM,MAAM,mBAAmB,GAC3B,YAAY,GACZ,MAAM,GACN,MAAM,GACN,QAAQ,GACR,MAAM,GACN,YAAY,GACZ,MAAM,GACN,UAAU,GACV,SAAS,GACT,MAAM,CAAC;AAEX;;;qBAGqB;AACrB,MAAM,WAAW,cAAc;IAC7B,+CAA+C;IAC/C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;0EACsE;IACtE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;yDAIqD;IACrD,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,QAAQ,CAAC,QAAQ,EAAE,mBAAmB,CAAC;IACvC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;wBACoB;IACpB,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC;2EACuE;IACvE,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,EAAE,WAAW,KAAK,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC;CACjE;AAED,wEAAwE;AACxE,MAAM,MAAM,eAAe,GAAG,IAAI,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC;AA8E5D,eAAO,MAAM,aAAa,EAAE,SAAS,cAAc,EAoelD,CAAC;AAEF,oEAAoE;AACpE,wBAAgB,kBAAkB,IAAI,eAAe,EAAE,CAEtD"}
@@ -14,6 +14,9 @@ const minMax = (list, isMin) => {
14
14
  }
15
15
  return best;
16
16
  };
17
+ /** Ensure a regex replaces every match: append the global flag unless the
18
+ * caller already passed it. */
19
+ const withGlobal = (flags) => flags && flags.includes("g") ? flags : `${flags ?? ""}g`;
17
20
  const sortList = (list) => [...list].sort((a, b) => {
18
21
  if (typeof a === "number" || typeof a === "bigint") {
19
22
  const d = num(a) - num(b);
@@ -130,6 +133,24 @@ export const CEL_FUNCTIONS = [
130
133
  hostBacked: false,
131
134
  build: () => (list) => sortList(list),
132
135
  },
136
+ {
137
+ name: "range",
138
+ signature: "range(int): list<int>",
139
+ category: "collection",
140
+ summary: "Integers [0, n-1] (empty for n <= 0); materializes indices for an unknown-length list.",
141
+ deterministic: true,
142
+ hostBacked: false,
143
+ build: () => (n) => Array.from({ length: Math.max(0, Math.trunc(num(n))) }, (_unused, i) => BigInt(i)),
144
+ },
145
+ {
146
+ name: "enumerate",
147
+ signature: "enumerate(list): list",
148
+ category: "collection",
149
+ summary: "Pair each element with its zero-based position as {index, value}.",
150
+ deterministic: true,
151
+ hostBacked: false,
152
+ build: () => (list) => list.map((value, i) => ({ index: BigInt(i), value })),
153
+ },
133
154
  // Strings
134
155
  {
135
156
  name: "lower",
@@ -176,6 +197,63 @@ export const CEL_FUNCTIONS = [
176
197
  hostBacked: false,
177
198
  build: () => (s, sep) => s.split(sep),
178
199
  },
200
+ {
201
+ name: "regexReplace",
202
+ signature: "regexReplace(string, string, string, string?): string",
203
+ category: "string",
204
+ summary: "Replace every regex match with a replacement ($1 backrefs); flags like 'i', 'm', 's'.",
205
+ deterministic: true,
206
+ hostBacked: false,
207
+ build: () => (s, pattern, replacement, flags) => s.replace(new RegExp(pattern, withGlobal(flags)), replacement),
208
+ },
209
+ {
210
+ name: "regexExtract",
211
+ signature: "regexExtract(string, string): string",
212
+ category: "string",
213
+ summary: "First whole match of a regex, or '' when there is none.",
214
+ deterministic: true,
215
+ hostBacked: false,
216
+ build: () => (s, pattern) => s.match(new RegExp(pattern))?.[0] ?? "",
217
+ },
218
+ {
219
+ name: "regexExtractAll",
220
+ signature: "regexExtractAll(string, string): list<string>",
221
+ category: "string",
222
+ summary: "Every whole match of a regex, in order.",
223
+ deterministic: true,
224
+ hostBacked: false,
225
+ build: () => (s, pattern) => [...s.matchAll(new RegExp(pattern, "g"))].map((m) => m[0]),
226
+ },
227
+ {
228
+ name: "regexGroups",
229
+ signature: "regexGroups(string, string): list<string>",
230
+ category: "string",
231
+ summary: "Capture groups of the first regex match (empty list when there is no match).",
232
+ deterministic: true,
233
+ hostBacked: false,
234
+ build: () => (s, pattern) => {
235
+ const m = s.match(new RegExp(pattern));
236
+ return m ? m.slice(1).map((g) => g ?? "") : [];
237
+ },
238
+ },
239
+ {
240
+ name: "trimPrefix",
241
+ signature: "trimPrefix(string, string): string",
242
+ category: "string",
243
+ summary: "Strip a leading prefix if present.",
244
+ deterministic: true,
245
+ hostBacked: false,
246
+ build: () => (s, prefix) => (s.startsWith(prefix) ? s.slice(prefix.length) : s),
247
+ },
248
+ {
249
+ name: "trimSuffix",
250
+ signature: "trimSuffix(string, string): string",
251
+ category: "string",
252
+ summary: "Strip a trailing suffix if present.",
253
+ deterministic: true,
254
+ hostBacked: false,
255
+ build: () => (s, suffix) => suffix && s.endsWith(suffix) ? s.slice(0, s.length - suffix.length) : s,
256
+ },
179
257
  // Math
180
258
  {
181
259
  name: "abs",
package/dist/index.d.ts CHANGED
@@ -12,5 +12,5 @@ export { builtinEngines, createDefaultRegistry, defaultRegistry } from "./builti
12
12
  export type { AnalyzeEnv, CompileEnv, EngineDiagnostic, TemplatingEngine, } from "./engine.js";
13
13
  export { isRefSentinel, isTaggedSentinel, makeTaggedSentinel, type TaggedSentinel } from "./sentinel.js";
14
14
  export { buildCustomTags, defaultCustomTags } from "./yaml-tags.js";
15
- export { MANIFEST_SCHEMA_URI, ManifestRootSchema, ResourceRefSchema, } from "./manifest-schemas.js";
15
+ export { MANIFEST_SCHEMA_URI, ManifestRootSchema, ResourceRefSchema, normalizeRefSlots, } from "./manifest-schemas.js";
16
16
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC7E,OAAO,EACL,kBAAkB,EAClB,aAAa,EACb,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,KAAK,mBAAmB,GACzB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,iBAAiB,EACjB,aAAa,EACb,eAAe,EACf,cAAc,EACd,oBAAoB,GACrB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,mBAAmB,EACnB,wBAAwB,EACxB,aAAa,EACb,0BAA0B,GAC3B,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAEnD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,SAAS,EAAE,kBAAkB,EAAE,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAExF,OAAO,EAAE,wBAAwB,EAAE,MAAM,eAAe,CAAC;AACzD,OAAO,EAAE,cAAc,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AACvF,YAAY,EACV,UAAU,EACV,UAAU,EACV,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AACzG,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACpE,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC7E,OAAO,EACL,kBAAkB,EAClB,aAAa,EACb,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,KAAK,mBAAmB,GACzB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,iBAAiB,EACjB,aAAa,EACb,eAAe,EACf,cAAc,EACd,oBAAoB,GACrB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,mBAAmB,EACnB,wBAAwB,EACxB,aAAa,EACb,0BAA0B,GAC3B,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAEnD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,SAAS,EAAE,kBAAkB,EAAE,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAExF,OAAO,EAAE,wBAAwB,EAAE,MAAM,eAAe,CAAC;AACzD,OAAO,EAAE,cAAc,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AACvF,YAAY,EACV,UAAU,EACV,UAAU,EACV,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AACzG,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACpE,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,iBAAiB,EACjB,iBAAiB,GAClB,MAAM,uBAAuB,CAAC"}
package/dist/index.js CHANGED
@@ -11,4 +11,4 @@ export { TemplatingEngineRegistry } from "./registry.js";
11
11
  export { builtinEngines, createDefaultRegistry, defaultRegistry } from "./builtins.js";
12
12
  export { isRefSentinel, isTaggedSentinel, makeTaggedSentinel } from "./sentinel.js";
13
13
  export { buildCustomTags, defaultCustomTags } from "./yaml-tags.js";
14
- export { MANIFEST_SCHEMA_URI, ManifestRootSchema, ResourceRefSchema, } from "./manifest-schemas.js";
14
+ export { MANIFEST_SCHEMA_URI, ManifestRootSchema, ResourceRefSchema, normalizeRefSlots, } from "./manifest-schemas.js";
@@ -5,35 +5,87 @@
5
5
  * layer where a single source of truth can sit. The kernel re-exports
6
6
  * these symbols from its own `manifest-schemas` surface for downstream
7
7
  * ergonomics. */
8
- /** Schema fragment for a resource reference produced by the `!ref` YAML
9
- * tag. After parsing, the value is a `TaggedSentinel` (engine "ref")
10
- * whose `source` is the bare resource name. Module schemas declare a
11
- * ref slot as `$ref: "telo://manifest#/$defs/ResourceRef"`; the
12
- * analyzer's reference walker is what looks the name up against the
13
- * slot's `x-telo-ref` constraint.
8
+ /** Schema fragment for a resource-reference slot. The only form a manifest
9
+ * author writes is the `!ref <name>` (or `!ref <Alias>.<name>`) YAML tag,
10
+ * which parses to a `TaggedSentinel` (engine "ref") whose `source` is the
11
+ * bare resource name. In practice module schemas mark a ref slot with a bare
12
+ * `x-telo-ref` annotation (plus, where the slot only ever holds a reference,
13
+ * `type: object` to reject a stray scalar); the analyzer's reference walker
14
+ * reads `x-telo-ref` to look the name up against that constraint, independent
15
+ * of this fragment. A slot opts into `$ref:
16
+ * "telo://manifest#/$defs/ResourceRef"` only when it wants this exact
17
+ * two-branch shape enforced at the AJV layer too — it is not required, and
18
+ * slots that also accept an inline value (e.g. `inputType` / `outputType`)
19
+ * deliberately do not use it (an inline JSON Schema has no `kind`).
14
20
  *
15
- * Migration note: while the legacy bare-name string and `{kind, name}`
16
- * object forms are still accepted by the analyzer/kernel walkers (in an
17
- * additive transitional state), those shapes are *not* part of this
18
- * schema. Slots that need to accept legacy forms during migration keep
19
- * their hand-rolled `oneOf` until the cutover lands. */
21
+ * Two `anyOf` branches because the value's shape depends on the phase at
22
+ * which it is validated:
23
+ *
24
+ * 1. The raw `!ref` sentinel what survives to AJV when a cross-module
25
+ * reference can't be resolved in standalone single-file analysis (the
26
+ * imported module isn't loaded). `substituteCelFields` deliberately
27
+ * keeps the sentinel so this branch matches.
28
+ * 2. A resolved reference object — `{kind, name, alias?}` substituted in
29
+ * place of a sentinel (or an inline definition `{kind, ...config}`
30
+ * reached through a local `$ref` that escapes extraction). Both the
31
+ * kernel and the analyzer validate ref slots *after* sentinel
32
+ * resolution, so this is the shape AJV usually sees.
33
+ *
34
+ * The object-form `{kind, name}` reference a user could once type directly
35
+ * is gone: a plain object at a ref slot is only ever an inline definition
36
+ * or the resolver's own substitution, never an author-written reference.
37
+ * That removal is enforced by the analyzer (it rejects an author-written
38
+ * `{kind, name}` before normalization), not by this schema — branch 2
39
+ * cannot distinguish an author's `{kind, name}` from the resolver's. */
20
40
  export declare const ResourceRefSchema: {
21
- type: string;
22
- required: string[];
23
- properties: {
24
- __tagged: {
25
- const: boolean;
26
- };
27
- engine: {
28
- const: string;
41
+ title: string;
42
+ anyOf: ({
43
+ type: string;
44
+ required: string[];
45
+ properties: {
46
+ __tagged: {
47
+ const: boolean;
48
+ };
49
+ engine: {
50
+ const: string;
51
+ };
52
+ source: {
53
+ type: string;
54
+ minLength: number;
55
+ };
56
+ kind?: undefined;
29
57
  };
30
- source: {
31
- type: string;
32
- minLength: number;
58
+ additionalProperties: boolean;
59
+ } | {
60
+ type: string;
61
+ required: string[];
62
+ properties: {
63
+ kind: {
64
+ type: string;
65
+ };
66
+ __tagged?: undefined;
67
+ engine?: undefined;
68
+ source?: undefined;
33
69
  };
34
- };
35
- additionalProperties: boolean;
70
+ additionalProperties: boolean;
71
+ })[];
36
72
  };
73
+ /** Deep-clone `schema`, dropping the stale scalar `type` constraint from every
74
+ * reference-slot node — one carrying an `x-telo-ref` string annotation.
75
+ *
76
+ * A reference slot's value is always a `!ref` sentinel or its resolved
77
+ * `{kind, name, alias?}` object (never a bare string, post-migration). Older
78
+ * published modules still pin `type: "string"` on these slots — the encoding
79
+ * references took when they were written as plain strings — which now rejects
80
+ * the resolved object. Removing only the scalar `type` lets the analyzer and
81
+ * kernel accept references uniformly across module versions during the
82
+ * migration away from `{kind, name}` / string references, without disturbing
83
+ * slots that legitimately accept an inline object (e.g. `inputType` /
84
+ * `outputType`, which take a Telo.Type reference *or* an inline JSON schema).
85
+ * The `x-telo-ref` constraint itself (which kind the reference must satisfy) is
86
+ * checked separately by the analyzer's reference walker, which reads the
87
+ * original schema — not this validation-only copy. */
88
+ export declare function normalizeRefSlots(schema: unknown): unknown;
37
89
  /** Stable URI under which the shared manifest root schema is registered
38
90
  * with module-side AJV instances. Module YAMLs reach the fragments via
39
91
  * `$ref: "telo://manifest#/$defs/<Name>"`. The URI is the contract;
@@ -49,21 +101,37 @@ export declare const ManifestRootSchema: {
49
101
  $id: string;
50
102
  $defs: {
51
103
  ResourceRef: {
52
- type: string;
53
- required: string[];
54
- properties: {
55
- __tagged: {
56
- const: boolean;
104
+ title: string;
105
+ anyOf: ({
106
+ type: string;
107
+ required: string[];
108
+ properties: {
109
+ __tagged: {
110
+ const: boolean;
111
+ };
112
+ engine: {
113
+ const: string;
114
+ };
115
+ source: {
116
+ type: string;
117
+ minLength: number;
118
+ };
119
+ kind?: undefined;
57
120
  };
58
- engine: {
59
- const: string;
121
+ additionalProperties: boolean;
122
+ } | {
123
+ type: string;
124
+ required: string[];
125
+ properties: {
126
+ kind: {
127
+ type: string;
128
+ };
129
+ __tagged?: undefined;
130
+ engine?: undefined;
131
+ source?: undefined;
60
132
  };
61
- source: {
62
- type: string;
63
- minLength: number;
64
- };
65
- };
66
- additionalProperties: boolean;
133
+ additionalProperties: boolean;
134
+ })[];
67
135
  };
68
136
  };
69
137
  };
@@ -1 +1 @@
1
- {"version":3,"file":"manifest-schemas.d.ts","sourceRoot":"","sources":["../src/manifest-schemas.ts"],"names":[],"mappings":"AAAA;;;;;;kBAMkB;AAElB;;;;;;;;;;;yDAWyD;AACzD,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;CAS7B,CAAC;AAEF;;;;;+BAK+B;AAC/B,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD;;;8BAG8B;AAC9B,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;CAK9B,CAAC"}
1
+ {"version":3,"file":"manifest-schemas.d.ts","sourceRoot":"","sources":["../src/manifest-schemas.ts"],"names":[],"mappings":"AAAA;;;;;;kBAMkB;AAElB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;yEA+ByE;AACzE,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoB7B,CAAC;AAqCF;;;;;;;;;;;;;;uDAcuD;AACvD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CA0C1D;AAED;;;;;+BAK+B;AAC/B,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD;;;8BAG8B;AAC9B,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAK9B,CAAC"}
@@ -5,28 +5,149 @@
5
5
  * layer where a single source of truth can sit. The kernel re-exports
6
6
  * these symbols from its own `manifest-schemas` surface for downstream
7
7
  * ergonomics. */
8
- /** Schema fragment for a resource reference produced by the `!ref` YAML
9
- * tag. After parsing, the value is a `TaggedSentinel` (engine "ref")
10
- * whose `source` is the bare resource name. Module schemas declare a
11
- * ref slot as `$ref: "telo://manifest#/$defs/ResourceRef"`; the
12
- * analyzer's reference walker is what looks the name up against the
13
- * slot's `x-telo-ref` constraint.
8
+ /** Schema fragment for a resource-reference slot. The only form a manifest
9
+ * author writes is the `!ref <name>` (or `!ref <Alias>.<name>`) YAML tag,
10
+ * which parses to a `TaggedSentinel` (engine "ref") whose `source` is the
11
+ * bare resource name. In practice module schemas mark a ref slot with a bare
12
+ * `x-telo-ref` annotation (plus, where the slot only ever holds a reference,
13
+ * `type: object` to reject a stray scalar); the analyzer's reference walker
14
+ * reads `x-telo-ref` to look the name up against that constraint, independent
15
+ * of this fragment. A slot opts into `$ref:
16
+ * "telo://manifest#/$defs/ResourceRef"` only when it wants this exact
17
+ * two-branch shape enforced at the AJV layer too — it is not required, and
18
+ * slots that also accept an inline value (e.g. `inputType` / `outputType`)
19
+ * deliberately do not use it (an inline JSON Schema has no `kind`).
14
20
  *
15
- * Migration note: while the legacy bare-name string and `{kind, name}`
16
- * object forms are still accepted by the analyzer/kernel walkers (in an
17
- * additive transitional state), those shapes are *not* part of this
18
- * schema. Slots that need to accept legacy forms during migration keep
19
- * their hand-rolled `oneOf` until the cutover lands. */
21
+ * Two `anyOf` branches because the value's shape depends on the phase at
22
+ * which it is validated:
23
+ *
24
+ * 1. The raw `!ref` sentinel what survives to AJV when a cross-module
25
+ * reference can't be resolved in standalone single-file analysis (the
26
+ * imported module isn't loaded). `substituteCelFields` deliberately
27
+ * keeps the sentinel so this branch matches.
28
+ * 2. A resolved reference object — `{kind, name, alias?}` substituted in
29
+ * place of a sentinel (or an inline definition `{kind, ...config}`
30
+ * reached through a local `$ref` that escapes extraction). Both the
31
+ * kernel and the analyzer validate ref slots *after* sentinel
32
+ * resolution, so this is the shape AJV usually sees.
33
+ *
34
+ * The object-form `{kind, name}` reference a user could once type directly
35
+ * is gone: a plain object at a ref slot is only ever an inline definition
36
+ * or the resolver's own substitution, never an author-written reference.
37
+ * That removal is enforced by the analyzer (it rejects an author-written
38
+ * `{kind, name}` before normalization), not by this schema — branch 2
39
+ * cannot distinguish an author's `{kind, name}` from the resolver's. */
20
40
  export const ResourceRefSchema = {
21
- type: "object",
22
- required: ["__tagged", "engine", "source"],
23
- properties: {
24
- __tagged: { const: true },
25
- engine: { const: "ref" },
26
- source: { type: "string", minLength: 1 },
27
- },
28
- additionalProperties: false,
41
+ title: "Resource reference",
42
+ anyOf: [
43
+ {
44
+ type: "object",
45
+ required: ["__tagged", "engine", "source"],
46
+ properties: {
47
+ __tagged: { const: true },
48
+ engine: { const: "ref" },
49
+ source: { type: "string", minLength: 1 },
50
+ },
51
+ additionalProperties: false,
52
+ },
53
+ {
54
+ type: "object",
55
+ required: ["kind"],
56
+ properties: { kind: { type: "string" } },
57
+ additionalProperties: true,
58
+ },
59
+ ],
29
60
  };
61
+ const REF_ANNOTATION = "x-telo-ref";
62
+ // The legacy base types a reference slot used to pin when references were
63
+ // written as plain strings. Post-migration a reference resolves to an object
64
+ // (the `{kind, name, alias?}` shape, or an unresolved `!ref` sentinel), so a
65
+ // scalar `type` on a ref slot is a stale constraint that would reject the
66
+ // resolved value. Object / array `type`s are left alone — they already admit
67
+ // the reference object (and any inline value a slot like `inputType` accepts).
68
+ const LEGACY_REF_SCALAR_TYPES = new Set(["string", "number", "integer", "boolean"]);
69
+ // JSON Schema keywords whose values are themselves subschemas. Split by shape so
70
+ // the ref-slot normalizer recurses only into schema positions — never into
71
+ // data-bearing keywords (`default`, `const`, `enum`, `examples`), where a stray
72
+ // `x-telo-ref` key would be data, not an annotation.
73
+ const SUBSCHEMA_SINGLE = [
74
+ "additionalProperties",
75
+ "additionalItems",
76
+ "contains",
77
+ "not",
78
+ "if",
79
+ "then",
80
+ "else",
81
+ "propertyNames",
82
+ "unevaluatedItems",
83
+ "unevaluatedProperties",
84
+ ];
85
+ const SUBSCHEMA_LIST = ["allOf", "anyOf", "oneOf", "prefixItems"];
86
+ const SUBSCHEMA_MAP = [
87
+ "properties",
88
+ "patternProperties",
89
+ "$defs",
90
+ "definitions",
91
+ "dependentSchemas",
92
+ ];
93
+ /** Deep-clone `schema`, dropping the stale scalar `type` constraint from every
94
+ * reference-slot node — one carrying an `x-telo-ref` string annotation.
95
+ *
96
+ * A reference slot's value is always a `!ref` sentinel or its resolved
97
+ * `{kind, name, alias?}` object (never a bare string, post-migration). Older
98
+ * published modules still pin `type: "string"` on these slots — the encoding
99
+ * references took when they were written as plain strings — which now rejects
100
+ * the resolved object. Removing only the scalar `type` lets the analyzer and
101
+ * kernel accept references uniformly across module versions during the
102
+ * migration away from `{kind, name}` / string references, without disturbing
103
+ * slots that legitimately accept an inline object (e.g. `inputType` /
104
+ * `outputType`, which take a Telo.Type reference *or* an inline JSON schema).
105
+ * The `x-telo-ref` constraint itself (which kind the reference must satisfy) is
106
+ * checked separately by the analyzer's reference walker, which reads the
107
+ * original schema — not this validation-only copy. */
108
+ export function normalizeRefSlots(schema) {
109
+ if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
110
+ return schema;
111
+ }
112
+ const node = schema;
113
+ const out = { ...node };
114
+ // Reference slot with a stale scalar `type` (legacy string-ref encoding):
115
+ // drop the constraint so the resolved reference object / sentinel validates.
116
+ if (typeof node[REF_ANNOTATION] === "string" &&
117
+ typeof node.type === "string" &&
118
+ LEGACY_REF_SCALAR_TYPES.has(node.type)) {
119
+ delete out.type;
120
+ }
121
+ for (const key of SUBSCHEMA_SINGLE) {
122
+ const value = node[key];
123
+ if (value && typeof value === "object" && !Array.isArray(value)) {
124
+ out[key] = normalizeRefSlots(value);
125
+ }
126
+ }
127
+ for (const key of SUBSCHEMA_LIST) {
128
+ const value = node[key];
129
+ if (Array.isArray(value))
130
+ out[key] = value.map(normalizeRefSlots);
131
+ }
132
+ // `items` is either a single subschema or a tuple of subschemas.
133
+ if (Array.isArray(node.items)) {
134
+ out.items = node.items.map(normalizeRefSlots);
135
+ }
136
+ else if (node.items && typeof node.items === "object") {
137
+ out.items = normalizeRefSlots(node.items);
138
+ }
139
+ for (const key of SUBSCHEMA_MAP) {
140
+ const value = node[key];
141
+ if (value && typeof value === "object" && !Array.isArray(value)) {
142
+ const mapped = {};
143
+ for (const [name, sub] of Object.entries(value)) {
144
+ mapped[name] = normalizeRefSlots(sub);
145
+ }
146
+ out[key] = mapped;
147
+ }
148
+ }
149
+ return out;
150
+ }
30
151
  /** Stable URI under which the shared manifest root schema is registered
31
152
  * with module-side AJV instances. Module YAMLs reach the fragments via
32
153
  * `$ref: "telo://manifest#/$defs/<Name>"`. The URI is the contract;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/templating",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Telo Templating - Engine registry and shared CEL core for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -44,7 +44,7 @@
44
44
  "@types/uuid": "^10.0.0",
45
45
  "typescript": "^5.0.0",
46
46
  "vitest": "^2.1.8",
47
- "@telorun/sdk": "0.23.0"
47
+ "@telorun/sdk": "0.26.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "@telorun/sdk": "*"
@@ -73,6 +73,11 @@ const minMax = (list: unknown[], isMin: boolean): unknown => {
73
73
  return best;
74
74
  };
75
75
 
76
+ /** Ensure a regex replaces every match: append the global flag unless the
77
+ * caller already passed it. */
78
+ const withGlobal = (flags?: string): string =>
79
+ flags && flags.includes("g") ? flags : `${flags ?? ""}g`;
80
+
76
81
  const sortList = (list: unknown[]): unknown[] =>
77
82
  [...list].sort((a, b) => {
78
83
  if (typeof a === "number" || typeof a === "bigint") {
@@ -195,6 +200,26 @@ export const CEL_FUNCTIONS: readonly CelFunctionDoc[] = [
195
200
  hostBacked: false,
196
201
  build: () => (list: unknown[]) => sortList(list),
197
202
  },
203
+ {
204
+ name: "range",
205
+ signature: "range(int): list<int>",
206
+ category: "collection",
207
+ summary: "Integers [0, n-1] (empty for n <= 0); materializes indices for an unknown-length list.",
208
+ deterministic: true,
209
+ hostBacked: false,
210
+ build: () => (n: unknown) =>
211
+ Array.from({ length: Math.max(0, Math.trunc(num(n))) }, (_unused, i) => BigInt(i)),
212
+ },
213
+ {
214
+ name: "enumerate",
215
+ signature: "enumerate(list): list",
216
+ category: "collection",
217
+ summary: "Pair each element with its zero-based position as {index, value}.",
218
+ deterministic: true,
219
+ hostBacked: false,
220
+ build: () => (list: unknown[]) =>
221
+ list.map((value, i) => ({ index: BigInt(i), value })),
222
+ },
198
223
  // Strings
199
224
  {
200
225
  name: "lower",
@@ -241,6 +266,66 @@ export const CEL_FUNCTIONS: readonly CelFunctionDoc[] = [
241
266
  hostBacked: false,
242
267
  build: () => (s: string, sep: string) => s.split(sep),
243
268
  },
269
+ {
270
+ name: "regexReplace",
271
+ signature: "regexReplace(string, string, string, string?): string",
272
+ category: "string",
273
+ summary: "Replace every regex match with a replacement ($1 backrefs); flags like 'i', 'm', 's'.",
274
+ deterministic: true,
275
+ hostBacked: false,
276
+ build: () => (s: string, pattern: string, replacement: string, flags?: string) =>
277
+ s.replace(new RegExp(pattern, withGlobal(flags)), replacement),
278
+ },
279
+ {
280
+ name: "regexExtract",
281
+ signature: "regexExtract(string, string): string",
282
+ category: "string",
283
+ summary: "First whole match of a regex, or '' when there is none.",
284
+ deterministic: true,
285
+ hostBacked: false,
286
+ build: () => (s: string, pattern: string) => s.match(new RegExp(pattern))?.[0] ?? "",
287
+ },
288
+ {
289
+ name: "regexExtractAll",
290
+ signature: "regexExtractAll(string, string): list<string>",
291
+ category: "string",
292
+ summary: "Every whole match of a regex, in order.",
293
+ deterministic: true,
294
+ hostBacked: false,
295
+ build: () => (s: string, pattern: string) =>
296
+ [...s.matchAll(new RegExp(pattern, "g"))].map((m) => m[0]),
297
+ },
298
+ {
299
+ name: "regexGroups",
300
+ signature: "regexGroups(string, string): list<string>",
301
+ category: "string",
302
+ summary: "Capture groups of the first regex match (empty list when there is no match).",
303
+ deterministic: true,
304
+ hostBacked: false,
305
+ build: () => (s: string, pattern: string) => {
306
+ const m = s.match(new RegExp(pattern));
307
+ return m ? m.slice(1).map((g) => g ?? "") : [];
308
+ },
309
+ },
310
+ {
311
+ name: "trimPrefix",
312
+ signature: "trimPrefix(string, string): string",
313
+ category: "string",
314
+ summary: "Strip a leading prefix if present.",
315
+ deterministic: true,
316
+ hostBacked: false,
317
+ build: () => (s: string, prefix: string) => (s.startsWith(prefix) ? s.slice(prefix.length) : s),
318
+ },
319
+ {
320
+ name: "trimSuffix",
321
+ signature: "trimSuffix(string, string): string",
322
+ category: "string",
323
+ summary: "Strip a trailing suffix if present.",
324
+ deterministic: true,
325
+ hostBacked: false,
326
+ build: () => (s: string, suffix: string) =>
327
+ suffix && s.endsWith(suffix) ? s.slice(0, s.length - suffix.length) : s,
328
+ },
244
329
  // Math
245
330
  {
246
331
  name: "abs",
package/src/index.ts CHANGED
@@ -41,4 +41,5 @@ export {
41
41
  MANIFEST_SCHEMA_URI,
42
42
  ManifestRootSchema,
43
43
  ResourceRefSchema,
44
+ normalizeRefSlots,
44
45
  } from "./manifest-schemas.js";
@@ -6,29 +6,154 @@
6
6
  * these symbols from its own `manifest-schemas` surface for downstream
7
7
  * ergonomics. */
8
8
 
9
- /** Schema fragment for a resource reference produced by the `!ref` YAML
10
- * tag. After parsing, the value is a `TaggedSentinel` (engine "ref")
11
- * whose `source` is the bare resource name. Module schemas declare a
12
- * ref slot as `$ref: "telo://manifest#/$defs/ResourceRef"`; the
13
- * analyzer's reference walker is what looks the name up against the
14
- * slot's `x-telo-ref` constraint.
9
+ /** Schema fragment for a resource-reference slot. The only form a manifest
10
+ * author writes is the `!ref <name>` (or `!ref <Alias>.<name>`) YAML tag,
11
+ * which parses to a `TaggedSentinel` (engine "ref") whose `source` is the
12
+ * bare resource name. In practice module schemas mark a ref slot with a bare
13
+ * `x-telo-ref` annotation (plus, where the slot only ever holds a reference,
14
+ * `type: object` to reject a stray scalar); the analyzer's reference walker
15
+ * reads `x-telo-ref` to look the name up against that constraint, independent
16
+ * of this fragment. A slot opts into `$ref:
17
+ * "telo://manifest#/$defs/ResourceRef"` only when it wants this exact
18
+ * two-branch shape enforced at the AJV layer too — it is not required, and
19
+ * slots that also accept an inline value (e.g. `inputType` / `outputType`)
20
+ * deliberately do not use it (an inline JSON Schema has no `kind`).
15
21
  *
16
- * Migration note: while the legacy bare-name string and `{kind, name}`
17
- * object forms are still accepted by the analyzer/kernel walkers (in an
18
- * additive transitional state), those shapes are *not* part of this
19
- * schema. Slots that need to accept legacy forms during migration keep
20
- * their hand-rolled `oneOf` until the cutover lands. */
22
+ * Two `anyOf` branches because the value's shape depends on the phase at
23
+ * which it is validated:
24
+ *
25
+ * 1. The raw `!ref` sentinel what survives to AJV when a cross-module
26
+ * reference can't be resolved in standalone single-file analysis (the
27
+ * imported module isn't loaded). `substituteCelFields` deliberately
28
+ * keeps the sentinel so this branch matches.
29
+ * 2. A resolved reference object — `{kind, name, alias?}` substituted in
30
+ * place of a sentinel (or an inline definition `{kind, ...config}`
31
+ * reached through a local `$ref` that escapes extraction). Both the
32
+ * kernel and the analyzer validate ref slots *after* sentinel
33
+ * resolution, so this is the shape AJV usually sees.
34
+ *
35
+ * The object-form `{kind, name}` reference a user could once type directly
36
+ * is gone: a plain object at a ref slot is only ever an inline definition
37
+ * or the resolver's own substitution, never an author-written reference.
38
+ * That removal is enforced by the analyzer (it rejects an author-written
39
+ * `{kind, name}` before normalization), not by this schema — branch 2
40
+ * cannot distinguish an author's `{kind, name}` from the resolver's. */
21
41
  export const ResourceRefSchema = {
22
- type: "object",
23
- required: ["__tagged", "engine", "source"],
24
- properties: {
25
- __tagged: { const: true },
26
- engine: { const: "ref" },
27
- source: { type: "string", minLength: 1 },
28
- },
29
- additionalProperties: false,
42
+ title: "Resource reference",
43
+ anyOf: [
44
+ {
45
+ type: "object",
46
+ required: ["__tagged", "engine", "source"],
47
+ properties: {
48
+ __tagged: { const: true },
49
+ engine: { const: "ref" },
50
+ source: { type: "string", minLength: 1 },
51
+ },
52
+ additionalProperties: false,
53
+ },
54
+ {
55
+ type: "object",
56
+ required: ["kind"],
57
+ properties: { kind: { type: "string" } },
58
+ additionalProperties: true,
59
+ },
60
+ ],
30
61
  };
31
62
 
63
+ const REF_ANNOTATION = "x-telo-ref";
64
+
65
+ // The legacy base types a reference slot used to pin when references were
66
+ // written as plain strings. Post-migration a reference resolves to an object
67
+ // (the `{kind, name, alias?}` shape, or an unresolved `!ref` sentinel), so a
68
+ // scalar `type` on a ref slot is a stale constraint that would reject the
69
+ // resolved value. Object / array `type`s are left alone — they already admit
70
+ // the reference object (and any inline value a slot like `inputType` accepts).
71
+ const LEGACY_REF_SCALAR_TYPES = new Set(["string", "number", "integer", "boolean"]);
72
+
73
+ // JSON Schema keywords whose values are themselves subschemas. Split by shape so
74
+ // the ref-slot normalizer recurses only into schema positions — never into
75
+ // data-bearing keywords (`default`, `const`, `enum`, `examples`), where a stray
76
+ // `x-telo-ref` key would be data, not an annotation.
77
+ const SUBSCHEMA_SINGLE = [
78
+ "additionalProperties",
79
+ "additionalItems",
80
+ "contains",
81
+ "not",
82
+ "if",
83
+ "then",
84
+ "else",
85
+ "propertyNames",
86
+ "unevaluatedItems",
87
+ "unevaluatedProperties",
88
+ ] as const;
89
+ const SUBSCHEMA_LIST = ["allOf", "anyOf", "oneOf", "prefixItems"] as const;
90
+ const SUBSCHEMA_MAP = [
91
+ "properties",
92
+ "patternProperties",
93
+ "$defs",
94
+ "definitions",
95
+ "dependentSchemas",
96
+ ] as const;
97
+
98
+ /** Deep-clone `schema`, dropping the stale scalar `type` constraint from every
99
+ * reference-slot node — one carrying an `x-telo-ref` string annotation.
100
+ *
101
+ * A reference slot's value is always a `!ref` sentinel or its resolved
102
+ * `{kind, name, alias?}` object (never a bare string, post-migration). Older
103
+ * published modules still pin `type: "string"` on these slots — the encoding
104
+ * references took when they were written as plain strings — which now rejects
105
+ * the resolved object. Removing only the scalar `type` lets the analyzer and
106
+ * kernel accept references uniformly across module versions during the
107
+ * migration away from `{kind, name}` / string references, without disturbing
108
+ * slots that legitimately accept an inline object (e.g. `inputType` /
109
+ * `outputType`, which take a Telo.Type reference *or* an inline JSON schema).
110
+ * The `x-telo-ref` constraint itself (which kind the reference must satisfy) is
111
+ * checked separately by the analyzer's reference walker, which reads the
112
+ * original schema — not this validation-only copy. */
113
+ export function normalizeRefSlots(schema: unknown): unknown {
114
+ if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
115
+ return schema;
116
+ }
117
+ const node = schema as Record<string, unknown>;
118
+ const out: Record<string, unknown> = { ...node };
119
+ // Reference slot with a stale scalar `type` (legacy string-ref encoding):
120
+ // drop the constraint so the resolved reference object / sentinel validates.
121
+ if (
122
+ typeof node[REF_ANNOTATION] === "string" &&
123
+ typeof node.type === "string" &&
124
+ LEGACY_REF_SCALAR_TYPES.has(node.type)
125
+ ) {
126
+ delete out.type;
127
+ }
128
+ for (const key of SUBSCHEMA_SINGLE) {
129
+ const value = node[key];
130
+ if (value && typeof value === "object" && !Array.isArray(value)) {
131
+ out[key] = normalizeRefSlots(value);
132
+ }
133
+ }
134
+ for (const key of SUBSCHEMA_LIST) {
135
+ const value = node[key];
136
+ if (Array.isArray(value)) out[key] = value.map(normalizeRefSlots);
137
+ }
138
+ // `items` is either a single subschema or a tuple of subschemas.
139
+ if (Array.isArray(node.items)) {
140
+ out.items = node.items.map(normalizeRefSlots);
141
+ } else if (node.items && typeof node.items === "object") {
142
+ out.items = normalizeRefSlots(node.items);
143
+ }
144
+ for (const key of SUBSCHEMA_MAP) {
145
+ const value = node[key];
146
+ if (value && typeof value === "object" && !Array.isArray(value)) {
147
+ const mapped: Record<string, unknown> = {};
148
+ for (const [name, sub] of Object.entries(value as Record<string, unknown>)) {
149
+ mapped[name] = normalizeRefSlots(sub);
150
+ }
151
+ out[key] = mapped;
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
32
157
  /** Stable URI under which the shared manifest root schema is registered
33
158
  * with module-side AJV instances. Module YAMLs reach the fragments via
34
159
  * `$ref: "telo://manifest#/$defs/<Name>"`. The URI is the contract;