@telorun/analyzer 0.72.0 → 0.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +76 -20
  3. package/dist/artifact-axes.d.ts +14 -0
  4. package/dist/artifact-axes.d.ts.map +1 -0
  5. package/dist/artifact-axes.js +12 -0
  6. package/dist/artifact-layer-index.d.ts +2 -2
  7. package/dist/artifact-layer-index.d.ts.map +1 -1
  8. package/dist/artifact-layer-index.js +9 -4
  9. package/dist/artifact-selector.d.ts +47 -21
  10. package/dist/artifact-selector.d.ts.map +1 -1
  11. package/dist/artifact-selector.js +85 -16
  12. package/dist/builtins.d.ts.map +1 -1
  13. package/dist/builtins.js +144 -113
  14. package/dist/call-graph.d.ts +12 -1
  15. package/dist/call-graph.d.ts.map +1 -1
  16. package/dist/call-graph.js +22 -0
  17. package/dist/catch-scope.js +1 -1
  18. package/dist/cel-scope-query.d.ts.map +1 -1
  19. package/dist/cel-scope-query.js +32 -11
  20. package/dist/cel-scope.d.ts.map +1 -1
  21. package/dist/cel-scope.js +1 -0
  22. package/dist/eval-paths.d.ts +36 -5
  23. package/dist/eval-paths.d.ts.map +1 -1
  24. package/dist/eval-paths.js +47 -6
  25. package/dist/extends-resolution.d.ts +12 -0
  26. package/dist/extends-resolution.d.ts.map +1 -1
  27. package/dist/extends-resolution.js +23 -0
  28. package/dist/forwarded-declaration.d.ts +26 -0
  29. package/dist/forwarded-declaration.d.ts.map +1 -0
  30. package/dist/forwarded-declaration.js +31 -0
  31. package/dist/index.d.ts +10 -4
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +5 -2
  34. package/dist/kernel-globals.d.ts.map +1 -1
  35. package/dist/kernel-globals.js +21 -5
  36. package/dist/module-alias-scope.d.ts +11 -0
  37. package/dist/module-alias-scope.d.ts.map +1 -1
  38. package/dist/module-alias-scope.js +10 -0
  39. package/dist/module-file-claims.d.ts +17 -1
  40. package/dist/module-file-claims.d.ts.map +1 -1
  41. package/dist/module-file-claims.js +38 -3
  42. package/dist/module-named-files.d.ts +72 -0
  43. package/dist/module-named-files.d.ts.map +1 -0
  44. package/dist/module-named-files.js +115 -0
  45. package/dist/native-entries.d.ts +83 -0
  46. package/dist/native-entries.d.ts.map +1 -0
  47. package/dist/native-entries.js +169 -0
  48. package/dist/nearest-name.d.ts +22 -0
  49. package/dist/nearest-name.d.ts.map +1 -0
  50. package/dist/nearest-name.js +43 -0
  51. package/dist/normalize-inline-resources.d.ts +41 -13
  52. package/dist/normalize-inline-resources.d.ts.map +1 -1
  53. package/dist/normalize-inline-resources.js +236 -54
  54. package/dist/reference-field-map.d.ts +59 -1
  55. package/dist/reference-field-map.d.ts.map +1 -1
  56. package/dist/reference-field-map.js +200 -34
  57. package/dist/release/destinations.d.ts +59 -0
  58. package/dist/release/destinations.d.ts.map +1 -0
  59. package/dist/release/destinations.js +75 -0
  60. package/dist/release/index.d.ts +6 -2
  61. package/dist/release/index.d.ts.map +1 -1
  62. package/dist/release/index.js +3 -1
  63. package/dist/release/ledger.d.ts +20 -11
  64. package/dist/release/ledger.d.ts.map +1 -1
  65. package/dist/release/ledger.js +43 -14
  66. package/dist/release/payload-digest.d.ts +2 -1
  67. package/dist/release/payload-digest.d.ts.map +1 -1
  68. package/dist/release/payload-digest.js +2 -1
  69. package/dist/release/release-plan.d.ts +3 -2
  70. package/dist/release/release-plan.d.ts.map +1 -1
  71. package/dist/release/release-plan.js +12 -19
  72. package/dist/release/workspace-config.d.ts +114 -34
  73. package/dist/release/workspace-config.d.ts.map +1 -1
  74. package/dist/release/workspace-config.js +322 -38
  75. package/dist/release/workspace-schema.d.ts +41 -0
  76. package/dist/release/workspace-schema.d.ts.map +1 -0
  77. package/dist/release/workspace-schema.js +77 -0
  78. package/dist/resolve-ref-sentinels.d.ts.map +1 -1
  79. package/dist/resolve-ref-sentinels.js +8 -5
  80. package/dist/resolve-throws-union.d.ts +25 -4
  81. package/dist/resolve-throws-union.d.ts.map +1 -1
  82. package/dist/resolve-throws-union.js +62 -32
  83. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  84. package/dist/resolve-zone-requirements.js +2 -1
  85. package/dist/rewrite-synthetic-origins.d.ts +6 -1
  86. package/dist/rewrite-synthetic-origins.d.ts.map +1 -1
  87. package/dist/rewrite-synthetic-origins.js +107 -13
  88. package/dist/schema-compat.d.ts.map +1 -1
  89. package/dist/schema-compat.js +18 -5
  90. package/dist/schema-error-report.d.ts +7 -0
  91. package/dist/schema-error-report.d.ts.map +1 -1
  92. package/dist/schema-error-report.js +9 -4
  93. package/dist/schema-walk.d.ts +47 -13
  94. package/dist/schema-walk.d.ts.map +1 -1
  95. package/dist/schema-walk.js +150 -30
  96. package/dist/scope-declarations.d.ts +59 -0
  97. package/dist/scope-declarations.d.ts.map +1 -0
  98. package/dist/scope-declarations.js +45 -0
  99. package/dist/source-entries.d.ts +119 -0
  100. package/dist/source-entries.d.ts.map +1 -0
  101. package/dist/source-entries.js +349 -0
  102. package/dist/step-bodies.d.ts +24 -0
  103. package/dist/step-bodies.d.ts.map +1 -0
  104. package/dist/step-bodies.js +28 -0
  105. package/dist/telo-version.d.ts +1 -1
  106. package/dist/telo-version.js +1 -1
  107. package/dist/template-body.d.ts +10 -0
  108. package/dist/template-body.d.ts.map +1 -1
  109. package/dist/template-body.js +25 -0
  110. package/dist/validate-cel-context.d.ts +23 -8
  111. package/dist/validate-cel-context.d.ts.map +1 -1
  112. package/dist/validate-cel-context.js +51 -18
  113. package/dist/validate-exports.d.ts +28 -0
  114. package/dist/validate-exports.d.ts.map +1 -0
  115. package/dist/validate-exports.js +146 -0
  116. package/dist/validate-extends.d.ts.map +1 -1
  117. package/dist/validate-extends.js +37 -1
  118. package/dist/validate-module-artifact.d.ts +4 -2
  119. package/dist/validate-module-artifact.d.ts.map +1 -1
  120. package/dist/validate-module-artifact.js +43 -4
  121. package/dist/validate-native-entries.d.ts +15 -0
  122. package/dist/validate-native-entries.d.ts.map +1 -0
  123. package/dist/validate-native-entries.js +103 -0
  124. package/dist/validate-observed-state.d.ts +7 -1
  125. package/dist/validate-observed-state.d.ts.map +1 -1
  126. package/dist/validate-observed-state.js +24 -6
  127. package/dist/validate-provider-coherence.d.ts +4 -8
  128. package/dist/validate-provider-coherence.d.ts.map +1 -1
  129. package/dist/validate-provider-coherence.js +12 -123
  130. package/dist/validate-reference-forms.d.ts.map +1 -1
  131. package/dist/validate-reference-forms.js +2 -2
  132. package/dist/validate-references.d.ts.map +1 -1
  133. package/dist/validate-references.js +152 -65
  134. package/dist/validate-scope-reach.d.ts +31 -0
  135. package/dist/validate-scope-reach.d.ts.map +1 -0
  136. package/dist/validate-scope-reach.js +180 -0
  137. package/dist/validate-source-entries.d.ts +16 -0
  138. package/dist/validate-source-entries.d.ts.map +1 -0
  139. package/dist/validate-source-entries.js +90 -0
  140. package/dist/validate-template-body.d.ts +36 -0
  141. package/dist/validate-template-body.d.ts.map +1 -0
  142. package/dist/validate-template-body.js +246 -0
  143. package/dist/validate-throws-coverage.d.ts.map +1 -1
  144. package/dist/validate-throws-coverage.js +21 -39
  145. package/dist/value-type-keyword.d.ts +22 -5
  146. package/dist/value-type-keyword.d.ts.map +1 -1
  147. package/dist/value-type-keyword.js +16 -7
  148. package/package.json +4 -3
  149. package/src/analyzer.ts +97 -20
  150. package/src/artifact-axes.ts +24 -0
  151. package/src/artifact-layer-index.ts +9 -6
  152. package/src/artifact-selector.ts +110 -35
  153. package/src/builtins.ts +160 -113
  154. package/src/call-graph.ts +30 -0
  155. package/src/catch-scope.ts +1 -1
  156. package/src/cel-scope-query.ts +28 -9
  157. package/src/cel-scope.ts +1 -0
  158. package/src/eval-paths.ts +53 -6
  159. package/src/extends-resolution.ts +24 -0
  160. package/src/forwarded-declaration.ts +32 -0
  161. package/src/index.ts +37 -1
  162. package/src/kernel-globals.ts +28 -5
  163. package/src/migrations/CLAUDE.md +35 -0
  164. package/src/module-alias-scope.ts +17 -0
  165. package/src/module-file-claims.ts +63 -4
  166. package/src/module-named-files.ts +162 -0
  167. package/src/native-entries.ts +227 -0
  168. package/src/nearest-name.ts +47 -0
  169. package/src/normalize-inline-resources.ts +326 -70
  170. package/src/reference-field-map.ts +257 -52
  171. package/src/release/CLAUDE.md +32 -0
  172. package/src/release/destinations.ts +105 -0
  173. package/src/release/index.ts +33 -2
  174. package/src/release/ledger.ts +51 -21
  175. package/src/release/payload-digest.ts +2 -1
  176. package/src/release/release-plan.ts +16 -26
  177. package/src/release/workspace-config.ts +483 -47
  178. package/src/release/workspace-schema.ts +99 -0
  179. package/src/resolve-ref-sentinels.ts +7 -6
  180. package/src/resolve-throws-union.ts +87 -32
  181. package/src/resolve-zone-requirements.ts +2 -1
  182. package/src/rewrite-synthetic-origins.ts +107 -16
  183. package/src/schema-compat.ts +18 -4
  184. package/src/schema-error-report.ts +16 -4
  185. package/src/schema-walk.ts +162 -34
  186. package/src/scope-declarations.ts +98 -0
  187. package/src/source-entries.ts +502 -0
  188. package/src/step-bodies.ts +55 -0
  189. package/src/telo-version.ts +1 -1
  190. package/src/template-body.ts +25 -0
  191. package/src/validate-cel-context.ts +60 -18
  192. package/src/validate-exports.ts +185 -0
  193. package/src/validate-extends.ts +43 -0
  194. package/src/validate-module-artifact.ts +46 -3
  195. package/src/validate-native-entries.ts +147 -0
  196. package/src/validate-observed-state.ts +36 -7
  197. package/src/validate-provider-coherence.ts +17 -127
  198. package/src/validate-reference-forms.ts +2 -3
  199. package/src/validate-references.ts +151 -66
  200. package/src/validate-scope-reach.ts +213 -0
  201. package/src/validate-source-entries.ts +120 -0
  202. package/src/validate-template-body.ts +274 -0
  203. package/src/validate-throws-coverage.ts +24 -31
  204. package/src/value-type-keyword.ts +35 -9
  205. package/dist/validate-template-dispatch.d.ts +0 -27
  206. package/dist/validate-template-dispatch.d.ts.map +0 -1
  207. package/dist/validate-template-dispatch.js +0 -95
  208. package/src/validate-template-dispatch.ts +0 -99
@@ -1,4 +1,6 @@
1
+ import { resolveLocalRef } from "./manifest-navigation.js";
1
2
  import { type RefSlot, type RefUse, type RefUseCases, readRefSlot } from "./ref-slot.js";
3
+ import { readStepSlot, type StepSlot } from "./step-slot.js";
2
4
 
3
5
  export { readRefSlot, isRefSlot, hasDeclaredUse } from "./ref-slot.js";
4
6
  export type { RefSlot, RefUse, RefUseCases } from "./ref-slot.js";
@@ -38,6 +40,53 @@ export interface RefFieldEntry {
38
40
  * an unconditional opt-in would rewrite the value into a valid shape before
39
41
  * the schema ever saw it. */
40
42
  inline?: boolean;
43
+ /** See {@link RefSlot.throwsThrough}. */
44
+ throwsThrough?: boolean;
45
+ }
46
+
47
+ /** The slot an entry records, as `readRefSlot` read it. */
48
+ export function refSlotOfEntry(entry: RefFieldEntry): RefSlot {
49
+ const slot: RefSlot = {
50
+ kinds: entry.refs,
51
+ uses: entry.uses,
52
+ inline: entry.inline === true,
53
+ valueBranches: entry.valueBranches ?? [],
54
+ };
55
+ if (entry.useCases) slot.useCases = entry.useCases;
56
+ if (entry.inputs !== undefined) slot.inputs = entry.inputs;
57
+ if (entry.throwsThrough) slot.throwsThrough = true;
58
+ return slot;
59
+ }
60
+
61
+ /** Everything a driven-slot map records at one field path. */
62
+ export interface DrivenPath {
63
+ /** Every reference slot declared here, by any branch. Kept apart rather than
64
+ * merged into one slot and read as a union: a branch that does not apply to a
65
+ * resource can only ADD what counts — more coverage demanded, never less — and
66
+ * one merged slot could not hold two case-map selectors or a branch that
67
+ * declares no use. */
68
+ refs: RefFieldEntry[];
69
+ /** Every step body declared here, by any branch. */
70
+ steps: StepSlot[];
71
+ /** Back-edges of a recursive schema: the schema here is also the one entered
72
+ * at each of these paths, so everything below them applies again below this
73
+ * one, as deep as a resource's data goes. Recorded BESIDE `refs`, never in
74
+ * place of them — a `!ref` here resolves as a reference, an inline object
75
+ * recurses. */
76
+ recurse: string[];
77
+ }
78
+
79
+ /** See {@link buildDrivenSlotMap}. */
80
+ export interface DrivenSlots {
81
+ paths: Map<string, DrivenPath>;
82
+ /** Per `{}` field path, the keys JSON Schema does NOT apply that
83
+ * `additionalProperties` to: those the same schema declares under
84
+ * `properties`, plus the resource envelope at the root. Where several schemas
85
+ * meet at one `{}` path, only keys every one of them declares are skipped. */
86
+ declaredKeys: Map<string, Set<string>>;
87
+ /** True when some path holds a step or reference slot. A map of back-edges
88
+ * alone drives nothing, so a walk over it is skipped. */
89
+ drives: boolean;
41
90
  }
42
91
 
43
92
  /** An entry for a field that declares an execution scope (x-telo-scope). */
@@ -207,9 +256,10 @@ export function resolveFieldValues(obj: unknown, path: string): unknown[] {
207
256
  */
208
257
  export function buildReferenceFieldMap(schema: Record<string, any>): ReferenceFieldMap {
209
258
  const map: ReferenceFieldMap = new Map();
259
+ const sink = injectionSink(map);
210
260
  if (schema.properties) {
211
261
  for (const [key, propSchema] of Object.entries(schema.properties)) {
212
- traverseNode(propSchema as Record<string, any>, key, map, schema);
262
+ traverseNode(propSchema as Record<string, any>, key, sink, schema);
213
263
  }
214
264
  }
215
265
  return map;
@@ -231,40 +281,207 @@ export function buildFieldMapAtPath(
231
281
  pathPrefix: string,
232
282
  ): ReferenceFieldMap {
233
283
  const map: ReferenceFieldMap = new Map();
234
- traverseNode(schema, pathPrefix, map, schema);
284
+ traverseNode(schema, pathPrefix, injectionSink(map), schema);
235
285
  return map;
236
286
  }
237
287
 
288
+ const drivenSlotMaps = new WeakMap<object, DrivenSlots>();
289
+
290
+ /** The resource envelope: present on every resource document, and never
291
+ * configuration a kind's root `additionalProperties` describes. */
292
+ const RESOURCE_ENVELOPE_KEYS = ["kind", "metadata"];
293
+
294
+ /**
295
+ * Every slot through which a resource of this schema DRIVES another — the
296
+ * reference field map's own traversal, plus what a dispatch analysis needs and
297
+ * the injection surface must not have:
298
+ *
299
+ * - local `$ref` is followed, resolved against the root. The injection map
300
+ * stops there so a step's `invoke` never becomes an injection site; this map
301
+ * stops AT a step body instead, which removes that reason. A reference back
302
+ * to a node already on the descent is recorded as a back-edge rather than
303
+ * unrolled, so a recursive shape stays finite here and is followed as deep as
304
+ * the data goes by `forEachDrivenSlot`;
305
+ * - a step body is recorded and not descended — the step traversal owns
306
+ * everything below it;
307
+ * - the root's own variant branches and `additionalProperties` are walked, not
308
+ * only its `properties`;
309
+ * - a path keeps EVERY slot any branch declares there, where the injection map
310
+ * keeps the last one written (see {@link DrivenPath.refs});
311
+ * - each `{}` path records the keys its `additionalProperties` does not cover.
312
+ *
313
+ * Shared by every throws question (a kind's `inherit` union, a scope list's
314
+ * denominator, catch-scope enclosure, and whether `inherit` is legal at all),
315
+ * so none can reach a slot another cannot. Memoized per schema object.
316
+ */
317
+ export function buildDrivenSlotMap(schema: Record<string, any>): DrivenSlots {
318
+ const cached = drivenSlotMaps.get(schema);
319
+ if (cached) return cached;
320
+ const slots: DrivenSlots = { paths: new Map(), declaredKeys: new Map(), drives: false };
321
+ traverseNode(schema, "", drivenSink(slots), schema);
322
+ drivenSlotMaps.set(schema, slots);
323
+ return slots;
324
+ }
325
+
326
+ const joinPath = (path: string, key: string): string => (path ? `${path}.${key}` : key);
327
+
328
+ /** Where a traversal records what it finds. */
329
+ interface FieldMapSink {
330
+ readonly driven: boolean;
331
+ ref(path: string, entry: RefFieldEntry, node: Record<string, any>): void;
332
+ stop(path: string, entry: ScopeFieldEntry | SchemaFromFieldEntry): void;
333
+ step(path: string, step: StepSlot, node: Record<string, any>): void;
334
+ recurse(path: string, to: string): void;
335
+ mapValue(mapPath: string, declared: string[]): void;
336
+ }
337
+
338
+ /** The injection map: one entry per path, the last one written. Step bodies,
339
+ * back-edges and map-value keys are the driven map's alone. */
340
+ function injectionSink(map: ReferenceFieldMap): FieldMapSink {
341
+ return {
342
+ driven: false,
343
+ ref: (path, entry) => {
344
+ map.set(path, entry);
345
+ },
346
+ stop: (path, entry) => {
347
+ map.set(path, entry);
348
+ },
349
+ step: () => {},
350
+ recurse: () => {},
351
+ mapValue: () => {},
352
+ };
353
+ }
354
+
355
+ function drivenSink(slots: DrivenSlots): FieldMapSink {
356
+ const recorded = new Map<string, Set<object>>();
357
+ const at = (path: string): DrivenPath => {
358
+ let entry = slots.paths.get(path);
359
+ if (!entry) {
360
+ entry = { refs: [], steps: [], recurse: [] };
361
+ slots.paths.set(path, entry);
362
+ }
363
+ return entry;
364
+ };
365
+ // One schema node reached twice at one path (two branches `$ref`-ing one
366
+ // definition) is one slot.
367
+ const firstTime = (path: string, node: object): boolean => {
368
+ let nodes = recorded.get(path);
369
+ if (!nodes) recorded.set(path, (nodes = new Set()));
370
+ if (nodes.has(node)) return false;
371
+ nodes.add(node);
372
+ return true;
373
+ };
374
+ return {
375
+ driven: true,
376
+ ref: (path, entry, node) => {
377
+ if (!firstTime(path, node)) return;
378
+ at(path).refs.push(entry);
379
+ slots.drives = true;
380
+ },
381
+ stop: () => {},
382
+ step: (path, step, node) => {
383
+ if (!firstTime(path, node)) return;
384
+ at(path).steps.push(step);
385
+ slots.drives = true;
386
+ },
387
+ recurse: (path, to) => {
388
+ const entry = at(path);
389
+ if (!entry.recurse.includes(to)) entry.recurse.push(to);
390
+ },
391
+ mapValue: (mapPath, declared) => {
392
+ const previous = slots.declaredKeys.get(mapPath);
393
+ slots.declaredKeys.set(
394
+ mapPath,
395
+ new Set(previous ? declared.filter((key) => previous.has(key)) : declared),
396
+ );
397
+ },
398
+ };
399
+ }
400
+
401
+ /** Follow a local `$ref` in driven mode. `onStack` maps each node on the
402
+ * current descent to the path it was entered at: re-entering one records a
403
+ * back-edge to that path, and a cycle that made no structural progress (same
404
+ * path) records nothing, since its body is already recorded there. */
405
+ function followLocalRef(
406
+ node: Record<string, any>,
407
+ path: string,
408
+ sink: FieldMapSink,
409
+ root: Record<string, any> | undefined,
410
+ onStack: Map<Record<string, any>, string>,
411
+ ): void {
412
+ if (!root) return;
413
+ const target = resolveLocalRef(node, root);
414
+ if (!target || target === node) return;
415
+ const entered = onStack.get(target);
416
+ if (entered !== undefined) {
417
+ if (entered !== path) sink.recurse(path, entered);
418
+ return;
419
+ }
420
+ onStack.set(target, path);
421
+ try {
422
+ traverseNode(target, path, sink, root, onStack);
423
+ } finally {
424
+ onStack.delete(target);
425
+ }
426
+ }
427
+
428
+ /** `owner.additionalProperties`, the schema of every value in an open-keyed
429
+ * object. JSON Schema applies it only to keys `owner` does not declare, which
430
+ * the driven map records so a walk skips them. */
431
+ function traverseMapValue(
432
+ owner: Record<string, any>,
433
+ path: string,
434
+ sink: FieldMapSink,
435
+ root: Record<string, any> | undefined,
436
+ onStack: Map<Record<string, any>, string>,
437
+ ): void {
438
+ const valueSchema = owner.additionalProperties;
439
+ if (!valueSchema || typeof valueSchema !== "object" || Array.isArray(valueSchema)) return;
440
+ const mapPath = joinPath(path, "{}");
441
+ const declared =
442
+ owner.properties && typeof owner.properties === "object" ? Object.keys(owner.properties) : [];
443
+ sink.mapValue(mapPath, path === "" ? [...declared, ...RESOURCE_ENVELOPE_KEYS] : declared);
444
+ traverseNode(valueSchema as Record<string, any>, mapPath, sink, root, onStack);
445
+ }
446
+
238
447
  function traverseNode(
239
448
  node: Record<string, any>,
240
449
  path: string,
241
- map: ReferenceFieldMap,
450
+ sink: FieldMapSink,
242
451
  root?: Record<string, any>,
243
- visitedRefs: Set<string> = new Set(),
452
+ onStack: Map<Record<string, any>, string> = new Map(),
244
453
  ): void {
245
- // Local `$ref` is intentionally NOT followed. This map is the kernel's
454
+ const driven = sink.driven;
455
+ // Local `$ref` is intentionally NOT followed here. This map is the kernel's
246
456
  // Phase-5 injection surface: descending into shared `$defs` (notably
247
457
  // `Run.Sequence`'s `step` definition) would make every step's `invoke` an
248
458
  // injection site, and step slots resolve at dispatch — injecting there is
249
459
  // unwanted regardless of tracing (the original dispatcher-bypass blocker
250
460
  // has since shipped via the `REF_IDENTITY` stamp). Static analysis is NOT
251
461
  // limited by this stop: the call graph (`call-graph.ts`) reads step slots
252
- // from the item schema itself and scans the value tree, so it sees what
253
- // this map deliberately hides. `visitedRefs` stays as a parameter so the
254
- // recursive calls below thread the right signature.
255
- if (typeof node?.$ref === "string") return;
462
+ // from the item schema itself and scans the value tree, and the driven-slot
463
+ // mode (`buildDrivenSlotMap`) follows the reference.
464
+ if (typeof node?.$ref === "string" && !driven) return;
256
465
  // Scope slot — record and stop; do not recurse into scope contents
257
466
  if ("x-telo-scope" in node) {
258
- map.set(path, { scope: node["x-telo-scope"] });
467
+ sink.stop(path, { scope: node["x-telo-scope"] });
259
468
  return;
260
469
  }
261
470
 
262
471
  // Schema-from slot — record and stop; no further traversal needed
263
472
  if ("x-telo-schema-from" in node) {
264
- map.set(path, { schemaFrom: node["x-telo-schema-from"] });
473
+ sink.stop(path, { schemaFrom: node["x-telo-schema-from"] });
265
474
  return;
266
475
  }
267
476
 
477
+ if (driven) {
478
+ const step = readStepSlot(node);
479
+ if (step) {
480
+ sink.step(path, step, node);
481
+ return;
482
+ }
483
+ }
484
+
268
485
  // Reference slot (direct, via a `kind:` list, or via anyOf)
269
486
  const slot = readRefSlot(node);
270
487
  if (slot && slot.kinds.length > 0) {
@@ -278,7 +495,8 @@ function traverseNode(
278
495
  if (slot.valueBranches.length > 0) entry.valueBranches = slot.valueBranches;
279
496
  if (node["x-telo-context"]) entry.context = node["x-telo-context"] as Record<string, any>;
280
497
  if (slot.inline) entry.inline = true;
281
- map.set(path, entry);
498
+ if (slot.throwsThrough) entry.throwsThrough = true;
499
+ sink.ref(path, entry, node);
282
500
  // A node can mix item-level ref branches (a bare string / `{kind, name}`)
283
501
  // with object branches that carry their OWN nested refs — e.g. Application
284
502
  // `targets`: a bare ref vs inline `{ invoke }` vs gated `{ ref }`. Descend
@@ -290,22 +508,26 @@ function traverseNode(
290
508
  if (!Array.isArray(variants)) continue;
291
509
  for (const variant of variants) {
292
510
  if (!variant || typeof variant !== "object") continue;
293
- traverseVariant(variant as Record<string, any>, path, map, root, visitedRefs);
511
+ traverseVariant(variant as Record<string, any>, path, sink, root, onStack);
294
512
  }
295
513
  }
296
514
  return;
297
515
  }
298
- if (typeof node?.$ref === "string") return;
516
+ // Reached only in driven mode — the injection map stopped at the top.
517
+ if (typeof node?.$ref === "string") {
518
+ followLocalRef(node, path, sink, root, onStack);
519
+ return;
520
+ }
299
521
 
300
522
  // Array — recurse into items
301
523
  if (node.type === "array" && node.items) {
302
- traverseNode(node.items as Record<string, any>, path + "[]", map, root, visitedRefs);
524
+ traverseNode(node.items as Record<string, any>, path + "[]", sink, root, onStack);
303
525
  }
304
526
 
305
527
  // Object — recurse into properties
306
528
  if (node.properties) {
307
529
  for (const [key, propSchema] of Object.entries(node.properties)) {
308
- traverseNode(propSchema as Record<string, any>, `${path}.${key}`, map, root, visitedRefs);
530
+ traverseNode(propSchema as Record<string, any>, joinPath(path, key), sink, root, onStack);
309
531
  }
310
532
  }
311
533
 
@@ -315,67 +537,50 @@ function traverseNode(
315
537
  // slots inside the branch. Walking each branch surfaces those slots into
316
538
  // the field map so downstream passes (ref validation, sentinel
317
539
  // resolution, dependency graph) cover them without a runtime fallback.
318
- // The same field path may be added by multiple branches; the later
319
- // assignment wins, which is fine — branches with the same field path
320
- // share the same ref/context configuration (any divergence is already
321
- // a schema bug).
540
+ // The same field path may be added by multiple branches. The injection map
541
+ // keeps the later assignment, which is fine for injection — branches with
542
+ // the same field path share the same ref/context configuration. The driven
543
+ // map keeps every one, because which branch applies decides what a
544
+ // dispatch can throw.
322
545
  for (const variantKey of ["oneOf", "anyOf", "allOf"] as const) {
323
546
  const variants = node[variantKey];
324
547
  if (!Array.isArray(variants)) continue;
325
548
  for (const variant of variants) {
326
549
  if (!variant || typeof variant !== "object") continue;
327
- traverseVariant(variant as Record<string, any>, path, map, root, visitedRefs);
550
+ traverseVariant(variant as Record<string, any>, path, sink, root, onStack);
328
551
  }
329
552
  }
330
553
 
331
554
  // Map — `additionalProperties: { ... }` describes every value in an
332
555
  // open-keyed object. Encoder refs nested inside `content[mime]` map
333
556
  // entries reach Phase 5 through this branch.
334
- if (
335
- node.additionalProperties &&
336
- typeof node.additionalProperties === "object" &&
337
- !Array.isArray(node.additionalProperties)
338
- ) {
339
- traverseNode(
340
- node.additionalProperties as Record<string, any>,
341
- `${path}.{}`,
342
- map,
343
- root,
344
- visitedRefs,
345
- );
346
- }
557
+ traverseMapValue(node, path, sink, root, onStack);
347
558
  }
348
559
 
349
560
  /** Walk a single variant of a `oneOf` / `anyOf` / `allOf` branch. Only
350
561
  * the properties / items / map slots are followed — collectRefs at the
351
562
  * variant root is handled by the parent's `collectRefs(node)` already
352
- * (anyOf of x-telo-ref branches is the canonical multi-ref shape). */
563
+ * (anyOf of x-telo-ref branches is the canonical multi-ref shape). In
564
+ * driven mode a `$ref` branch is followed as a node in its own right, since
565
+ * `readRefSlot` does not look through one. */
353
566
  function traverseVariant(
354
567
  variant: Record<string, any>,
355
568
  path: string,
356
- map: ReferenceFieldMap,
569
+ sink: FieldMapSink,
357
570
  root?: Record<string, any>,
358
- visitedRefs: Set<string> = new Set(),
571
+ onStack: Map<Record<string, any>, string> = new Map(),
359
572
  ): void {
573
+ if (sink.driven && typeof variant.$ref === "string") {
574
+ followLocalRef(variant, path, sink, root, onStack);
575
+ return;
576
+ }
360
577
  if (variant.properties) {
361
578
  for (const [key, propSchema] of Object.entries(variant.properties)) {
362
- traverseNode(propSchema as Record<string, any>, `${path}.${key}`, map, root, visitedRefs);
579
+ traverseNode(propSchema as Record<string, any>, joinPath(path, key), sink, root, onStack);
363
580
  }
364
581
  }
365
582
  if (variant.type === "array" && variant.items) {
366
- traverseNode(variant.items as Record<string, any>, path + "[]", map, root, visitedRefs);
367
- }
368
- if (
369
- variant.additionalProperties &&
370
- typeof variant.additionalProperties === "object" &&
371
- !Array.isArray(variant.additionalProperties)
372
- ) {
373
- traverseNode(
374
- variant.additionalProperties as Record<string, any>,
375
- `${path}.{}`,
376
- map,
377
- root,
378
- visitedRefs,
379
- );
583
+ traverseNode(variant.items as Record<string, any>, path + "[]", sink, root, onStack);
380
584
  }
585
+ traverseMapValue(variant, path, sink, root, onStack);
381
586
  }
@@ -0,0 +1,32 @@
1
+ # Release model — guide
2
+
3
+ Loaded when working under `analyzer/nodejs/src/release/`. The release RULES every change follows (changesets for published packages, `telo release add` fragments for modules, pre-1.0 minors) are in the root `CLAUDE.md`; publishing (payload builder, pins, the publication split) is in `cli/nodejs/CLAUDE.md`. Guide: `docs/extend/releasing-modules.md`.
4
+
5
+ **The release system exists because a module's artifact EMBEDS its dependencies** — esbuild inlines a sibling library's source into the controller bundle, and publish pins each relative import to a hash of the sibling's manifest — so bumping the dependents is a correctness requirement, not a courtesy. No general-purpose changelog tool can see that — a per-project ledger has no dependency graph at all, and changesets' stops at the npm boundary, where a module's inlined sibling is invisible.
6
+
7
+ **Three questions, three mechanisms.** The **payload digest** (exact, from the bytes; `computeFilesIntegrity` over the layers publish builds, or over an image module's `.dockerignore`-defined file set) decides *whether* a module bumps, so it fires for an inlined sibling, a shared-library fix and a lockfile-only transitive bump alike. The **edge graph** decides *at what level*: edges come from the controller build's own **metafile** (a declared-deps graph cannot see `--external`, so `@telorun/sdk` — declared by 54 modules, inlined by none — would bump the whole stdlib on every SDK change) plus in-repo relative `imports:`; a dependent mirrors its dependency's level, joined as the maximum over paths. An **import edge bumps unconditionally** (publishing rewrites the sibling's version into the manifest, so the layer provably changes) while an **inline edge only explains drift that already showed up**. A digest that moved with nothing to attribute it to takes a **patch** and is reported as *unattributed*, never silently. The path-scoped **changed-files rule** survives but is demoted to deciding only *whether a changelog line is requested* — it used to decide the version, which is why its guesswork (`docs/` vs `tests/` vs `nodejs/`, `bundlesAController`) had to be sound and was not; now a false positive costs one sentence.
8
+
9
+ **The ledger is a cache; the registry is the authority.** `.changes/ledger.yaml` records per module its version, its per-layer integrity **as published**, and the **registry base** those digests were taken against — per ENTRY, since a workspace may publish its subtrees to different bases (canonicalization writes the destination into every relative import, so digests against another base are digests of different bytes). A top-level `registry:` is a legacy form the reader accepts and never writes, applied to every entry: the credential-free PR gate reads whatever ledger is committed on the branch. That is the load-bearing property: **the PR gate and the publish gate compute the same number**, and the PR gate needs no merge base and no credentials, so a fork runs the identical computation. A *missing* entry is not drift — nothing is published, the right reading for a new module. `telo publish` still reads the registry, and `telo release verify [--write]` reconciles.
10
+
11
+ **`telo release check` does NOT fail because a payload moved.** Under a toolchain bump every digest moves; demanding sixty fragments for that is a tax on nobody's behalf, and those ship as unattributed patches. It fails when no consistent plan can be formed: a fragment naming an unknown module, a major-inducing kind (`Changed`/`Removed`), a manifest version disagreeing with its ledger entry, or a registry-base mismatch. It *warns* (`CHANGELOG_ENTRY_REQUESTED`) when a module's own files changed with no fragment naming it.
12
+
13
+ ## The workspace marker
14
+
15
+ **The workspace anchor.** `telo-workspace.yaml` at the repo root. **Its location is the anchor** for every module key, ledger entry, fragment path and the `.telo` cache; **every FIELD lives in a block scoped to what it governs**, which is the file's one rule. `release:` carries `registry:` (the publish base), `ignore:` (module-relative gitignore-style paths whose changes ask for no changelog fragment) and `modules:` (the subtrees that may hold modules — not derivable, since hundreds of manifests elsewhere carry a `metadata.version`). `env:` carries `roots:` (how far up `telo run` walks collecting env files) and `files:` (which filenames, later winning within one directory). Reader + shape as data: `analyzer/nodejs/src/release/workspace-{config,schema}.ts`; guide `docs/guides/workspaces.md`.
16
+
17
+ **`modules:` moved under `release:` and no second spelling is kept** — top-level is a recognized-and-moved key naming the move. It is a release INVENTORY, not an inventory of manifests, and leaving it at the top level made it read as a fact about the tree: the runner seeded `modules: ["*"]` into every session workspace purely because an empty list was a parse error, shipping release scope a session never reads. **The file and every block in it are optional**, so a marker whose whole content is comments is valid — which is what the runner seeds now.
18
+
19
+ **The reader is lenient and the strict half is diagnostics; which are FATAL is the caller's policy.** `readWorkspaceConfig` returns config + diagnostics anchored at key paths (`WORKSPACE_MODULES_MOVED`, `WORKSPACE_UNKNOWN_KEY`, `WORKSPACE_INVALID_VALUE`, plus the repo-shaped `WORKSPACE_ENTRY_MATCHES_NOTHING` / `_SHADOWED` / `WORKSPACE_MARKER_SHADOWED` the editor adds). `telo release` refuses on any error; **`telo run` consumes `env:` alone** — a typo under `release.modules` is printed and the run proceeds, while one INSIDE `env:` fails the run, because degrading to the marker-wide bound would WIDEN the walk and a block that opted into a boundary and got nothing is a defect, not a default. An unknown top-level BLOCK is a warning (so the next block ships without breaking today's runs); a near-miss of a known one is an error. Gitignore matching is **injected** (`PatternMatch`), because the grammar is `@telorun/glob`'s and the analyzer is browser-safe; `lastMatchIndex` is what makes a decision attributable to the entry that made it.
20
+
21
+ **Settings cascade key-wise and each value REPLACES whole**: built-in → `release:`'s own key → the matching entry's. A `modules:` entry is a bare pattern string or `{path, registry?, ignore?}`, evaluated **last-match-wins** — the rule the list already had — so a later, more specific entry overrides one module and a `!` entry is an exclusion that supplies nothing. A list is never unioned across levels, so the set in force is always exactly one authored list.
22
+
23
+ **A destination is the resolved base plus the module's own directory name, and both ways that can be inconsistent are checked at plan time, before any payload is built**: `DESTINATION_COLLISION` (two modules on one ref — the payload builder is keyed by manifest, so it catches one module claimed twice and never the reverse, while the ledger keys by module and both entries would record digests for one artifact) and `IMPORT_DESTINATION_CONFLICT` (a relative import whose derived ref is not where the target publishes; equal registries are necessary and not sufficient, since two modules under one base at different depths derive differently). **The registry cascade is entry → block → `--registry` → `TELO_OCI_REGISTRY` → the base that module's own ledger entry recorded** — the ledger is LAST, a change: a workspace that authors its destination has said where it publishes, and a cache of a past answer must not outrank it. A disagreement is a per-module `LEDGER_REGISTRY_MISMATCH` in a plan that is still produced, rather than an abort. **`telo release order` emits `{key, destination}`** and the module publisher reads the destination from it: deriving `$TELO_OCI_REGISTRY/<directory name>` for itself was the last fact that script still answered, and a multi-destination workspace would have planned several bases and pushed them all to one.
24
+
25
+ Modules are **discovered, not registered**: a module is a directory under a named subtree holding a `telo.yaml` whose **module doc** carries a `metadata.version`, and its key is its workspace-relative path (never a bare name — two subtrees can share one). Selection and attribution are ONE decision, since the entry that decides whether a directory is a module is the entry whose settings it resolves. Changed-file attribution is to the **nearest** enclosing module, matching how build inputs are attributed. A `Dockerfile` beside the manifest makes it an **image module**; that decides only which file set is digested. An image module's base image is deliberately outside its digest — the every-push `:latest` + `:sha-<short>` policy already carries a kernel change to the deployed app.
26
+
27
+ The model splits on the browser-safety line: `analyzer/nodejs/src/release/` holds module identity, fragment parsing, the ledger, the edge graph, propagation and version planning (pure data in, plan out, so the editor can answer "what does changing this library bump?"); `cli/nodejs/src/release/` holds evidence collection (driving the kernel's builder, workspace and git reads, file writing) and the commands. Version rewriting reuses `yaml-source-edit.ts`, so a bump is a one-line diff rather than a re-serialized file.
28
+
29
+ ## Where to look
30
+
31
+ - "will this change bump anything, and why?" → `analyzer/nodejs/src/release/` (`release-plan.ts` the planner, `payload-digest.ts` the digest keys, `ledger.ts`, `fragment.ts`, `version-stamp.ts`), `cli/nodejs/src/release/` (`workspace.ts` discovery, `evidence.ts` collection, `targets.ts` the registry cascade + the import graph), `cli/nodejs/src/bundle/module-payload.ts` (the one payload builder publish and release share), `docs/extend/releasing-modules.md`
32
+ - "what may the workspace marker say, and who reads which half?" → `analyzer/nodejs/src/release/workspace-schema.ts` (the shape, as data), `workspace-config.ts` (the lenient reader + the cascade), `destinations.ts` (the two plan-time checks), `cli/nodejs/src/env-files.ts` (the `env:` half and the run path's policy), `packages/ide-support/src/workspace/` (diagnostics + completion), `docs/guides/workspaces.md`
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Where each module publishes, and the two ways a set of destinations can be
3
+ * inconsistent.
4
+ *
5
+ * A destination is the module's resolved registry base plus its own directory
6
+ * name. That rule held silently for as long as every module sat at one depth
7
+ * under one base with a unique directory name; per-subtree bases remove that
8
+ * coincidence, so both failures it was hiding become reachable and are checked
9
+ * here — **at plan time, before any payload is built**, because the payload
10
+ * builder's own refusal speaks about a manifest published to two places rather
11
+ * than about the workspace file that said so.
12
+ *
13
+ * Pure data in, diagnostics out: the destinations are already computed for
14
+ * `telo release order`, so one derivation feeds that payload and both checks.
15
+ */
16
+
17
+ import type { ModuleKey } from "./fragment.js";
18
+ import type { ReleaseDiagnostic } from "./release-plan.js";
19
+
20
+ export interface ModuleDestination {
21
+ readonly key: ModuleKey;
22
+ /** `<registry>/<the module's own directory name>`. */
23
+ readonly destination: string;
24
+ }
25
+
26
+ /**
27
+ * A relative import between two workspace modules, with both answers about
28
+ * where the target publishes.
29
+ *
30
+ * `derived` is what the importer's own destination yields when the import path
31
+ * is applied to it — the transport's rule, and what ref canonicalization writes
32
+ * into the published manifest. `assigned` is what discovery independently gives
33
+ * that module.
34
+ */
35
+ export interface ImportDestination {
36
+ readonly from: ModuleKey;
37
+ readonly to: ModuleKey;
38
+ readonly derived: string;
39
+ readonly assigned: string;
40
+ }
41
+
42
+ /**
43
+ * Two modules must not resolve to one ref.
44
+ *
45
+ * Nothing else catches it: the payload builder is keyed by manifest, so it sees
46
+ * one module claimed by two destinations and never two modules claiming one,
47
+ * while the ledger keys by module — both entries would record digests for a
48
+ * single published artifact and reconciliation could never settle.
49
+ */
50
+ export function checkDestinationCollisions(
51
+ destinations: readonly ModuleDestination[],
52
+ ): ReleaseDiagnostic[] {
53
+ const byDestination = new Map<string, ModuleKey[]>();
54
+ for (const { key, destination } of destinations) {
55
+ const sharing = byDestination.get(destination);
56
+ if (sharing) sharing.push(key);
57
+ else byDestination.set(destination, [key]);
58
+ }
59
+
60
+ const diagnostics: ReleaseDiagnostic[] = [];
61
+ for (const [destination, keys] of byDestination) {
62
+ if (keys.length < 2) continue;
63
+ diagnostics.push({
64
+ severity: "error",
65
+ code: "DESTINATION_COLLISION",
66
+ message:
67
+ `${keys.sort().join(" and ")} both publish to '${destination}'. A module's ref is its ` +
68
+ `registry base plus its own directory name, so they would overwrite one artifact and ` +
69
+ `their ledger entries could never reconcile. Rename one directory, or give one of the ` +
70
+ `subtrees its own 'registry:' in telo-workspace.yaml.`,
71
+ });
72
+ }
73
+ return diagnostics;
74
+ }
75
+
76
+ /**
77
+ * A relative import is valid only where the importer's derived ref for its
78
+ * target equals the destination that module is independently assigned.
79
+ *
80
+ * Equal registries are necessary and not sufficient — two modules under one base
81
+ * at different directory depths derive differently too. Making the builder take
82
+ * the assigned destination instead is not the fix: it would put a ref in the
83
+ * artifact that the transport's resolution rule does not produce, so the
84
+ * manifest would say one thing and every consumer resolving relatively would
85
+ * compute another.
86
+ */
87
+ export function checkImportDestinations(
88
+ edges: readonly ImportDestination[],
89
+ ): ReleaseDiagnostic[] {
90
+ const diagnostics: ReleaseDiagnostic[] = [];
91
+ for (const edge of edges) {
92
+ if (edge.derived === edge.assigned) continue;
93
+ diagnostics.push({
94
+ severity: "error",
95
+ code: "IMPORT_DESTINATION_CONFLICT",
96
+ message:
97
+ `${edge.from} imports ${edge.to} by relative path, which canonicalizes to ` +
98
+ `'${edge.derived}' — but ${edge.to} publishes to '${edge.assigned}'. Publishing rewrites ` +
99
+ `the import to the ref its own path yields, so the artifact would name a module nobody ` +
100
+ `pushes. Give the two the same registry base and the same directory depth, or make it a ` +
101
+ `pinned remote import, which is what a dependency across a publish boundary is.`,
102
+ });
103
+ }
104
+ return diagnostics;
105
+ }
@@ -63,5 +63,36 @@ export {
63
63
  stampPackageVersion,
64
64
  } from "./version-stamp.js";
65
65
 
66
- export { WORKSPACE_FILENAME, WorkspaceConfigError, parseWorkspaceConfig } from "./workspace-config.js";
67
- export type { WorkspaceConfig } from "./workspace-config.js";
66
+ export { checkDestinationCollisions, checkImportDestinations } from "./destinations.js";
67
+ export type { ImportDestination, ModuleDestination } from "./destinations.js";
68
+
69
+ export {
70
+ DEFAULT_ENV_FILES,
71
+ DEFAULT_RELEASE_IGNORE,
72
+ WORKSPACE_FILENAME,
73
+ WorkspaceConfigError,
74
+ diagnosticsFor,
75
+ hasError,
76
+ matchesPatterns,
77
+ readWorkspaceConfig,
78
+ requireReleaseSettings,
79
+ settingsForModule,
80
+ } from "./workspace-config.js";
81
+ export type {
82
+ EnvSettings,
83
+ ModuleEntry,
84
+ ModuleSettings,
85
+ PatternMatch,
86
+ ReleaseSettings,
87
+ WorkspaceConfig,
88
+ WorkspaceDiagnostic,
89
+ WorkspaceDiagnosticCode,
90
+ WorkspaceRead,
91
+ } from "./workspace-config.js";
92
+
93
+ export {
94
+ MODULE_ENTRY_KEYS,
95
+ WORKSPACE_BLOCKS,
96
+ WORKSPACE_SCHEMA,
97
+ } from "./workspace-schema.js";
98
+ export type { WorkspaceBlockSchema, WorkspaceKeySchema } from "./workspace-schema.js";