@telorun/templating 0.7.0 → 0.8.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
@@ -58,8 +58,8 @@ imports:
58
58
  Http: std/http-server@0.9.0
59
59
  Sql: std/sql@0.8.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:
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.8.0",
4
4
  "description": "Telo Templating - Engine registry and shared CEL core for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
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;