@telorun/analyzer 0.53.0 → 0.55.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 (76) hide show
  1. package/dist/analysis-registry.d.ts +20 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +36 -3
  4. package/dist/analyzer.d.ts +3 -2
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/analyzer.js +188 -26
  7. package/dist/builtins.d.ts.map +1 -1
  8. package/dist/builtins.js +32 -12
  9. package/dist/call-graph.d.ts +189 -0
  10. package/dist/call-graph.d.ts.map +1 -0
  11. package/dist/call-graph.js +617 -0
  12. package/dist/dependency-graph.d.ts +17 -7
  13. package/dist/dependency-graph.d.ts.map +1 -1
  14. package/dist/dependency-graph.js +36 -65
  15. package/dist/flatten-for-analyzer.d.ts +8 -0
  16. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  17. package/dist/flatten-for-analyzer.js +32 -0
  18. package/dist/index.d.ts +14 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +7 -1
  21. package/dist/manifest-navigation.d.ts +32 -0
  22. package/dist/manifest-navigation.d.ts.map +1 -0
  23. package/dist/manifest-navigation.js +91 -0
  24. package/dist/manifest-visitor.js +1 -1
  25. package/dist/ref-slot.d.ts +125 -0
  26. package/dist/ref-slot.d.ts.map +1 -0
  27. package/dist/ref-slot.js +226 -0
  28. package/dist/reference-field-map.d.ts +15 -1
  29. package/dist/reference-field-map.d.ts.map +1 -1
  30. package/dist/reference-field-map.js +29 -35
  31. package/dist/resolve-schema-ref-kinds.d.ts +4 -0
  32. package/dist/resolve-schema-ref-kinds.d.ts.map +1 -1
  33. package/dist/resolve-schema-ref-kinds.js +31 -8
  34. package/dist/resolve-zone-requirements.d.ts +110 -0
  35. package/dist/resolve-zone-requirements.d.ts.map +1 -0
  36. package/dist/resolve-zone-requirements.js +541 -0
  37. package/dist/types.d.ts +8 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/validate-observed-state.d.ts +14 -13
  40. package/dist/validate-observed-state.d.ts.map +1 -1
  41. package/dist/validate-observed-state.js +21 -88
  42. package/dist/validate-ref-slots.d.ts +48 -0
  43. package/dist/validate-ref-slots.d.ts.map +1 -0
  44. package/dist/validate-ref-slots.js +219 -0
  45. package/dist/validate-references.d.ts.map +1 -1
  46. package/dist/validate-references.js +8 -1
  47. package/dist/validate-zone-slots.d.ts +39 -0
  48. package/dist/validate-zone-slots.d.ts.map +1 -0
  49. package/dist/validate-zone-slots.js +114 -0
  50. package/dist/zone-module-documents.d.ts +27 -0
  51. package/dist/zone-module-documents.d.ts.map +1 -0
  52. package/dist/zone-module-documents.js +1 -0
  53. package/dist/zone-slot.d.ts +61 -0
  54. package/dist/zone-slot.d.ts.map +1 -0
  55. package/dist/zone-slot.js +91 -0
  56. package/package.json +3 -3
  57. package/src/analysis-registry.ts +36 -2
  58. package/src/analyzer.ts +206 -24
  59. package/src/builtins.ts +32 -12
  60. package/src/call-graph.ts +827 -0
  61. package/src/dependency-graph.ts +34 -68
  62. package/src/flatten-for-analyzer.ts +32 -0
  63. package/src/index.ts +47 -0
  64. package/src/manifest-navigation.ts +91 -0
  65. package/src/manifest-visitor.ts +1 -1
  66. package/src/ref-slot.ts +273 -0
  67. package/src/reference-field-map.ts +39 -36
  68. package/src/resolve-schema-ref-kinds.ts +34 -7
  69. package/src/resolve-zone-requirements.ts +781 -0
  70. package/src/types.ts +8 -0
  71. package/src/validate-observed-state.ts +26 -92
  72. package/src/validate-ref-slots.ts +293 -0
  73. package/src/validate-references.ts +8 -1
  74. package/src/validate-zone-slots.ts +175 -0
  75. package/src/zone-module-documents.ts +27 -0
  76. package/src/zone-slot.ts +116 -0
@@ -1,9 +1,7 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
- import { isRefSentinel } from "@telorun/templating";
3
2
  import type { AliasResolver } from "./alias-resolver.js";
3
+ import { buildCallGraph, projectToPairs, type ResourceGraphNode } from "./call-graph.js";
4
4
  import type { DefinitionRegistry } from "./definition-registry.js";
5
- import { visitManifest } from "./manifest-visitor.js";
6
- import { DEPENDENCY_GRAPH_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
7
5
 
8
6
  export interface ResourceNode {
9
7
  kind: string;
@@ -22,16 +20,26 @@ export interface DependencyGraph {
22
20
  const nodeKey = (kind: string, name: string) => `${kind}\0${name}`;
23
21
 
24
22
  /**
25
- * Builds a directed acyclic graph (DAG) of runtime resource dependencies and
23
+ * Builds a directed acyclic graph (DAG) of boot-time resource dependencies and
26
24
  * returns either a topological initialization order or the cycle path.
27
25
  *
28
- * Edges represent boot-time dependencies only:
29
- * - x-telo-ref fields that fall within a scope visibility path are excluded
30
- * (scoped resources are initialized on demand at runtime, not at boot).
31
- * - x-telo-scope fields themselves are excluded from the graph.
26
+ * A projection of the typed reference graph, not a second walk of the manifest.
27
+ * What this consumer keeps of the full graph:
32
28
  *
33
- * The registry is queried for each resource's field map by kind — callers do
34
- * not pre-compute or pass field maps separately.
29
+ * - **Injection sites only.** A site the reference field map reaches is a
30
+ * Phase-5 injection site, so its target must be constructed first — including
31
+ * `Telo.Application`'s inline `targets[].invoke`, which is step-declared but
32
+ * injected. A step slot behind a local `$ref` and a value-tree-discovered ref
33
+ * resolve at dispatch, so their targets need only exist by the time the step
34
+ * runs. All can be `use: call` — the difference is the site, never the node
35
+ * kind or the use.
36
+ * - **Every use but `schema`.** A `Telo.Type` slot names a shape; no runtime
37
+ * instance is constructed, so there is nothing to order against.
38
+ * - **No edge into the source's own scope.** A scoped resource is created when
39
+ * the scope opens, not at boot.
40
+ * - **Pairs, not parallel edges.** This is the one consumer for which two slots
41
+ * naming the same target genuinely mean the same thing, so it collapses the
42
+ * multigraph itself instead of the graph erasing the distinction for everyone.
35
43
  */
36
44
  export function buildDependencyGraph(
37
45
  resources: ResourceManifest[],
@@ -39,67 +47,25 @@ export function buildDependencyGraph(
39
47
  aliases?: AliasResolver,
40
48
  aliasesByModule?: Map<string, AliasResolver>,
41
49
  ): DependencyGraph {
42
- // --- Build node set + name index ---
50
+ const graph = buildCallGraph(resources, registry, { aliases, aliasesByModule });
51
+
43
52
  const nodes = new Map<string, ResourceNode>();
44
- // Sentinel lookup (`!ref <name>`) needs to resolve a bare name to its
45
- // declared kind. Names are unique within a manifest scope, so a flat
46
- // map suffices and lets the sentinel branch below avoid a full
47
- // O(N) scan of the node set on every reference.
48
- const nodesByName = new Map<string, ResourceNode>();
49
- for (const r of resources) {
50
- if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
51
- const node = { kind: r.kind, name: r.metadata.name as string };
52
- nodes.set(nodeKey(node.kind, node.name), node);
53
- nodesByName.set(node.name, node);
53
+ for (const node of graph.nodes.values()) {
54
+ if (node.type !== "resource") continue;
55
+ const resource = node as ResourceGraphNode;
56
+ // A scope-declared resource is created when its scope opens, never at boot.
57
+ if (resource.scoped) continue;
58
+ nodes.set(resource.id, { kind: resource.kind, name: resource.name });
54
59
  }
55
60
 
56
- // --- Build adjacency: from → deps (from depends on dep) ---
57
- const deps = new Map<string, Set<string>>();
58
- for (const key of nodes.keys()) deps.set(key, new Set());
59
-
60
- // Names of resources declared inside the *current* resource's scope fields —
61
- // initialized on-demand at runtime, not at boot, so edges pointing to them
62
- // are excluded. Scoping is per-source-resource: an edge A → B is dropped only
63
- // when B is declared inside A's own scope (the visitor's ScopeBoundary fires
64
- // before that resource's RefSites, so this is set before any edge is added).
65
- let scopedNames = new Set<string>();
66
-
67
- // Expanded map so refs nested behind x-telo-schema-from contribute edges to
68
- // the DAG. Without these, a parent (e.g. Http.Server) can init before its
69
- // extracted encoder and Phase 5 injection fires against a not-yet-created
70
- // dependency.
71
- visitManifest(
72
- resources,
73
- registry,
74
- {
75
- onScope: (e) => {
76
- scopedNames = e.enclosedNames;
77
- },
78
- onRef: (e) => {
79
- const sourceKey = nodeKey(e.source.kind, e.source.metadata!.name as string);
80
- const val = e.value;
81
-
82
- // `!ref <name>` sentinel — look up the target's kind from the name
83
- // (resources are unique by name) so the edge carries the concrete kind,
84
- // matching the {kind, name} edge shape below.
85
- if (isRefSentinel(val)) {
86
- const refName = val.source;
87
- if (scopedNames.has(refName)) return;
88
- const node = nodesByName.get(refName);
89
- if (node) deps.get(sourceKey)!.add(nodeKey(node.kind, node.name));
90
- return;
91
- }
92
-
93
- if (typeof val !== "object") return;
94
- const ref = val as Record<string, unknown>;
95
- if (!ref.kind || !ref.name) return;
96
- if (scopedNames.has(ref.name as string)) return;
97
- const targetKey = nodeKey(ref.kind as string, ref.name as string);
98
- if (nodes.has(targetKey)) deps.get(sourceKey)!.add(targetKey);
99
- },
100
- },
101
- { aliases, aliasesByModule, skipKinds: SYSTEM_KINDS, expand: true },
102
- );
61
+ const deps = projectToPairs(graph, {
62
+ keepUse: (use) => use.length === 0 || use.some((u) => u !== "schema"),
63
+ });
64
+ for (const edge of graph.edges) {
65
+ if (!edge.scoped || !edge.to) continue;
66
+ deps.get(edge.from)?.delete(edge.to);
67
+ }
68
+ for (const key of nodes.keys()) if (!deps.has(key)) deps.set(key, new Set());
103
69
 
104
70
  // --- Kahn's topological sort ---
105
71
  // in-degree[X] = number of X's dependencies (size of deps[X])
@@ -2,6 +2,7 @@ import type { ResourceManifest } from "@telorun/sdk";
2
2
  import type { LoadedGraph, LoadedModule } from "./loaded-types.js";
3
3
  import type { LoadedFile } from "./loaded-types.js";
4
4
  import { isModuleKind } from "./module-kinds.js";
5
+ import type { ZoneModuleDocuments } from "./zone-module-documents.js";
5
6
 
6
7
  /** One parsed `exports.resources` / `exports.kinds` entry. `name` is the exported
7
8
  * instance name or kind suffix (the part after the dot, or the whole entry); `alias`
@@ -380,6 +381,37 @@ function forwardReExports(graph: LoadedGraph, result: ResourceManifest[]): void
380
381
  stampExportedKinds(imports, declaredKinds);
381
382
  }
382
383
 
384
+ /** Collect every imported library's FULL document set for the zone stage's
385
+ * per-library export derivation (`AnalysisOptions.moduleDocuments`) — the
386
+ * flat list above deliberately drops library internals, and the zone
387
+ * projection needs the internal dispatch chain to derive an export's open
388
+ * requirements. Only libraries that actually export instances are included:
389
+ * a library exporting nothing has no export contract to derive. */
390
+ export function collectZoneModuleDocuments(graph: LoadedGraph): ZoneModuleDocuments[] {
391
+ const out: ZoneModuleDocuments[] = [];
392
+ for (const [source, mod] of graph.modules) {
393
+ if (source === graph.rootSource) continue;
394
+ const libDoc = mod.owner.manifests.find((m) => m && isModuleKind(m.kind)) as
395
+ | (ResourceManifest & { exports?: { resources?: unknown[] } })
396
+ | undefined;
397
+ const moduleName = libDoc?.metadata?.name as string | undefined;
398
+ if (!libDoc || !moduleName) continue;
399
+ const exportedNames: string[] = [];
400
+ for (const entry of libDoc.exports?.resources ?? []) {
401
+ if (typeof entry !== "string") continue;
402
+ exportedNames.push(parseExportEntry(entry).name);
403
+ }
404
+ if (exportedNames.length === 0) continue;
405
+ out.push({
406
+ module: moduleName,
407
+ sourceId: mod.owner.source,
408
+ manifests: collectModuleManifests(mod),
409
+ exportedNames,
410
+ });
411
+ }
412
+ return out;
413
+ }
414
+
383
415
  /** Project a LoadedModule (owner + partials) to a flat ResourceManifest[]
384
416
  * with `metadata.module` stamped on non-module docs. The kernel's runtime
385
417
  * entry load uses this to convert a `Loader.loadModule` result into the
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export type {
11
11
  ParseError,
12
12
  } from "./loaded-types.js";
13
13
  export {
14
+ collectZoneModuleDocuments,
14
15
  flattenForAnalyzer,
15
16
  flattenLoadedModule,
16
17
  forwardReExportManifests,
@@ -73,8 +74,54 @@ export {
73
74
  RedactionPathError,
74
75
  } from "./redaction-path.js";
75
76
  export type { RedactionSegment } from "./redaction-path.js";
77
+ export { buildCallGraph, projectToPairs, resourceId } from "./call-graph.js";
78
+ export type {
79
+ BuildCallGraphOptions,
80
+ CallGraph,
81
+ CallGraphEdge,
82
+ CallGraphNode,
83
+ ResourceGraphNode,
84
+ StepGraphNode,
85
+ } from "./call-graph.js";
76
86
  export { buildReferenceFieldMap, isRefEntry, isScopeEntry } from "./reference-field-map.js";
77
87
  export type { ReferenceFieldMap, RefFieldEntry } from "./reference-field-map.js";
88
+ export {
89
+ hasDeclaredUse,
90
+ isRefSlot,
91
+ isRefUse,
92
+ possibleUses,
93
+ readRefSlot,
94
+ REF_USES,
95
+ refSlotAnnotation,
96
+ rewriteRefSlotKinds,
97
+ transfersControl,
98
+ } from "./ref-slot.js";
99
+ export type { RefSlot, RefUse, RefUseCases } from "./ref-slot.js";
100
+ export {
101
+ hasProvidesZone,
102
+ hasRequiresZone,
103
+ readProvidesZone,
104
+ readRequiresZone,
105
+ rewriteRequiresZoneKind,
106
+ } from "./zone-slot.js";
107
+ export type { ProvidesZoneSlot, RequiresZoneSlot } from "./zone-slot.js";
108
+ export {
109
+ deriveLibraryExportRequirements,
110
+ projectZoneRequirements,
111
+ runZoneAnalysis,
112
+ zoneDocumentsSignature,
113
+ } from "./resolve-zone-requirements.js";
114
+ export type {
115
+ ZoneExportCache,
116
+ ZoneExportCacheEntry,
117
+ ZoneExportRequirements,
118
+ ZoneRequirementSpec,
119
+ } from "./resolve-zone-requirements.js";
120
+ export type { ZoneModuleDocuments } from "./zone-module-documents.js";
121
+ export { validateZoneSlotDeclarations } from "./validate-zone-slots.js";
122
+ export type { ZoneSlotIssue } from "./validate-zone-slots.js";
123
+ export { validateDynamicSelectors, validateRefSlotDeclarations } from "./validate-ref-slots.js";
124
+ export type { RefSlotIssue } from "./validate-ref-slots.js";
78
125
  export { visitManifest } from "./manifest-visitor.js";
79
126
  export type {
80
127
  CelSiteEvent,
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Navigating a manifest value and a definition schema by path — the primitives
3
+ * the call graph and the zone projection both need.
4
+ *
5
+ * They live here rather than in either consumer because they encode *rules*,
6
+ * not conveniences: which object a JSON Pointer anchors at, and when a path
7
+ * refuses to resolve. Two copies of a rule drift, and the drift is invisible —
8
+ * a second `enclosingOf` that returned the resource root where the first
9
+ * returned `undefined` would silently anchor a correlation pointer at the wrong
10
+ * object rather than failing.
11
+ *
12
+ * Browser-safe: no Node built-ins.
13
+ */
14
+
15
+ /**
16
+ * The object ENCLOSING a concrete site — its path with the last segment
17
+ * dropped. `routes[2].handler` → the value at `routes[2]`.
18
+ *
19
+ * A slot that IS an array element (`targets[0]`) has no enclosing object: its
20
+ * siblings are other elements, and a pointer must not cross an array boundary —
21
+ * returning the root here would silently do exactly that. The bracket test runs
22
+ * BEFORE the no-dot early return, so a top-level array path refuses too.
23
+ */
24
+ export function enclosingOf(root: unknown, concretePath: string): unknown {
25
+ const lastDot = concretePath.lastIndexOf(".");
26
+ const lastSegment = concretePath.slice(lastDot + 1);
27
+ if (lastSegment.includes("[")) return undefined;
28
+ if (lastDot < 0) return root;
29
+ return navigateConcrete(root, concretePath.slice(0, lastDot));
30
+ }
31
+
32
+ /** Navigate a concrete dotted path (`routes[2].handler`, `content.a/b.encoder`). */
33
+ export function navigateConcrete(root: unknown, path: string): unknown {
34
+ let current: unknown = root;
35
+ for (const rawSegment of path.split(".")) {
36
+ if (current == null || typeof current !== "object") return undefined;
37
+ let segment = rawSegment;
38
+ const indices: number[] = [];
39
+ const bracket = segment.indexOf("[");
40
+ if (bracket >= 0) {
41
+ for (const m of segment.slice(bracket).matchAll(/\[(\d+)\]/g)) {
42
+ indices.push(Number(m[1]));
43
+ }
44
+ segment = segment.slice(0, bracket);
45
+ }
46
+ if (segment) current = (current as Record<string, unknown>)[segment];
47
+ for (const index of indices) {
48
+ if (!Array.isArray(current)) return undefined;
49
+ current = current[index];
50
+ }
51
+ }
52
+ return current;
53
+ }
54
+
55
+ /** Resolve a local `#/$defs/<name>` ref against the root schema; any other
56
+ * schema (including one with no `$ref`) is returned unchanged. */
57
+ export function resolveLocalRef(
58
+ schema: Record<string, any> | undefined,
59
+ root: Record<string, any>,
60
+ ): Record<string, any> | undefined {
61
+ if (!schema) return undefined;
62
+ const ref = schema.$ref;
63
+ if (typeof ref === "string" && ref.startsWith("#/$defs/")) {
64
+ const resolved = root.$defs?.[ref.slice("#/$defs/".length)];
65
+ if (resolved && typeof resolved === "object") return resolved as Record<string, any>;
66
+ }
67
+ return schema;
68
+ }
69
+
70
+ /** Property schemas of a possibly variant-bearing object schema — `properties`
71
+ * plus every `oneOf` / `anyOf` / `allOf` branch's. */
72
+ export function propertySchemas(
73
+ schema: Record<string, any>,
74
+ ): Array<[string, Record<string, any>]> {
75
+ const out: Array<[string, Record<string, any>]> = [];
76
+ if (schema.properties && typeof schema.properties === "object") {
77
+ for (const [k, v] of Object.entries(schema.properties)) out.push([k, v as Record<string, any>]);
78
+ }
79
+ for (const key of ["oneOf", "anyOf", "allOf"] as const) {
80
+ const variants = schema[key];
81
+ if (!Array.isArray(variants)) continue;
82
+ for (const variant of variants) {
83
+ if (variant?.properties && typeof variant.properties === "object") {
84
+ for (const [k, v] of Object.entries(variant.properties)) {
85
+ out.push([k, v as Record<string, any>]);
86
+ }
87
+ }
88
+ }
89
+ }
90
+ return out;
91
+ }
@@ -146,7 +146,7 @@ export interface VisitOptions {
146
146
 
147
147
  /** Synthetic entry for a value-tree-discovered ref — these carry no declared
148
148
  * x-telo-ref constraint. */
149
- const NESTED_REF_ENTRY: RefFieldEntry = { refs: [], isArray: false };
149
+ const NESTED_REF_ENTRY: RefFieldEntry = { refs: [], uses: [], isArray: false };
150
150
 
151
151
  /** Scans a value tree for ref-shaped values, emitting each with its concrete
152
152
  * path. Recognizes `!ref <name>` sentinels and named `{kind, name}` reference
@@ -0,0 +1,273 @@
1
+ /**
2
+ * The single reader of the `x-telo-ref` annotation.
3
+ *
4
+ * A reference slot is *recognised* here and nowhere else. Four surfaces ask what
5
+ * a slot accepts — the analyzer's reference checks, the kernel's Phase-5
6
+ * injection, the GUI editor's reference picker, and `ide-support`'s completions
7
+ * / hover / go-to-definition — and before this module each recognised the
8
+ * annotation by pattern-matching its shape (`typeof node["x-telo-ref"] ===
9
+ * "string"`, plus a hand-rolled `anyOf` peel in three of them, and no peel at all
10
+ * in hover). That is why the shape could not change without silently turning
11
+ * every ref slot in the GUI into a free-text field.
12
+ *
13
+ * Browser-safe by construction: no Node built-ins, so the editor and
14
+ * `ide-support` import it directly and the kernel re-imports it rather than
15
+ * carrying a second reader — the split already used for the invocation-contract
16
+ * resolver, the eval-path matcher and the redaction path parser.
17
+ *
18
+ * Two annotation shapes are accepted during the migration:
19
+ *
20
+ * x-telo-ref: Telo.Invocable # bare string, no declared use
21
+ * x-telo-ref: # structured
22
+ * kind: [Telo.Invocable, Telo.Runnable]
23
+ * use: call
24
+ * inputs: /inputs
25
+ */
26
+
27
+ /** How control reaches a slot's target, relative to the declaring resource's own
28
+ * invocation. The one primitive fact every consumer derives its own semantics
29
+ * from — see `plans/typed-reference-graph.md`. */
30
+ export type RefUse =
31
+ /** Names a shape; no runtime instance exists. No edge of any kind. */
32
+ | "schema"
33
+ /** Held and read; control never transfers. Init-order edge only. */
34
+ | "dependency"
35
+ /** Control transfers during my invocation and returns to me. */
36
+ | "call"
37
+ /** Control transfers during my invocation through the kernel's detach
38
+ * primitive; I do not await it. */
39
+ | "detached"
40
+ /** I register the target; control reaches it after my invocation, driven by a
41
+ * request or a timer, and the runtime guarantees a fresh ambient context. */
42
+ | "trigger.inbound"
43
+ /** I register the target; control reaches it when someone drains a value I
44
+ * returned, so no guarantee holds either way. */
45
+ | "trigger.consumer";
46
+
47
+ export const REF_USES: readonly RefUse[] = [
48
+ "schema",
49
+ "dependency",
50
+ "call",
51
+ "detached",
52
+ "trigger.inbound",
53
+ "trigger.consumer",
54
+ ];
55
+
56
+ export function isRefUse(value: unknown): value is RefUse {
57
+ return typeof value === "string" && (REF_USES as readonly string[]).includes(value);
58
+ }
59
+
60
+ /** True for a use that transfers control to the target at some point. Everything
61
+ * but `schema` and `dependency`. */
62
+ export function transfersControl(use: RefUse): boolean {
63
+ return use !== "schema" && use !== "dependency";
64
+ }
65
+
66
+ /** A slot whose use is selected by a sibling config field. The selector must be
67
+ * statically resolvable — a literal or a schema default — because a call graph
68
+ * known only at runtime is not statically analyzable. */
69
+ export interface RefUseCases {
70
+ /** JSON Pointer relative to the object ENCLOSING the annotated slot: the
71
+ * resource root for a resource-level slot, the array item for a slot inside
72
+ * one. No pointer can reach across an array boundary. */
73
+ by: string;
74
+ /** Selector value (stringified as written in YAML) → the uses that hold. */
75
+ cases: Record<string, RefUse[]>;
76
+ }
77
+
78
+ /** A reference slot's declaration, normalized from every accepted shape. */
79
+ export interface RefSlot {
80
+ /** Accepted kinds, unioned across a `kind:` list and across `anyOf` branches.
81
+ * Canonical `<module>.<Kind>` keys once `resolveSchemaRefKinds` has run;
82
+ * alias-qualified as authored before that, or the legacy `<ns>/<mod>#<Kind>`
83
+ * identity form for an already-published module. */
84
+ kinds: string[];
85
+ /** The relations the declaring resource has with the target. A set, because a
86
+ * slot may dispatch its target more than one way within a single invocation
87
+ * (`Cache.View` calls inline on a miss and detached on a background
88
+ * revalidation). Empty when the slot declares no use — the bare-string form,
89
+ * or a slot that defers to {@link RefSlot.useCases}. */
90
+ uses: RefUse[];
91
+ /** Present when the use is chosen by configuration rather than fixed. */
92
+ useCases?: RefUseCases;
93
+ /** JSON Pointer (same anchoring as {@link RefUseCases.by}) naming the field
94
+ * that carries this call's arguments. Replaces
95
+ * `x-telo-topology-role: inputs`. */
96
+ inputs?: string;
97
+ /** `x-telo-inline: true` on the slot or any `anyOf` branch — accepts an inline
98
+ * `{kind, ...config}` definition, not only a `!ref`. */
99
+ inline: boolean;
100
+ }
101
+
102
+ /** Every use a slot can take, flattening a case map. The conservative reading
103
+ * for a consumer that does not resolve the selector. */
104
+ export function possibleUses(slot: RefSlot): RefUse[] {
105
+ if (!slot.useCases) return slot.uses;
106
+ const out = new Set<RefUse>(slot.uses);
107
+ for (const uses of Object.values(slot.useCases.cases)) {
108
+ for (const use of uses) out.add(use);
109
+ }
110
+ return [...out];
111
+ }
112
+
113
+ /** True when the slot states what the declaring resource does with the target.
114
+ * False only for the legacy bare-string form. */
115
+ export function hasDeclaredUse(slot: RefSlot): boolean {
116
+ return slot.uses.length > 0 || slot.useCases !== undefined;
117
+ }
118
+
119
+ function normalizeUses(raw: unknown): RefUse[] {
120
+ if (isRefUse(raw)) return [raw];
121
+ if (Array.isArray(raw)) return raw.filter(isRefUse);
122
+ return [];
123
+ }
124
+
125
+ function readUseCases(raw: unknown): RefUseCases | undefined {
126
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
127
+ const obj = raw as Record<string, unknown>;
128
+ if (typeof obj.by !== "string") return undefined;
129
+ const rawCases = obj.cases;
130
+ if (!rawCases || typeof rawCases !== "object" || Array.isArray(rawCases)) return undefined;
131
+ const cases: Record<string, RefUse[]> = {};
132
+ for (const [key, value] of Object.entries(rawCases as Record<string, unknown>)) {
133
+ cases[key] = normalizeUses(value);
134
+ }
135
+ return { by: obj.by, cases };
136
+ }
137
+
138
+ /** Kind names declared by one annotation value, in either shape. */
139
+ function readKinds(annotation: unknown): string[] {
140
+ if (typeof annotation === "string") return annotation ? [annotation] : [];
141
+ if (!annotation || typeof annotation !== "object" || Array.isArray(annotation)) return [];
142
+ const kind = (annotation as Record<string, unknown>).kind;
143
+ if (typeof kind === "string") return kind ? [kind] : [];
144
+ if (Array.isArray(kind)) return kind.filter((k): k is string => typeof k === "string" && !!k);
145
+ return [];
146
+ }
147
+
148
+ /** The nodes carrying an `x-telo-ref` for this slot: the node itself plus any
149
+ * `anyOf` / `oneOf` branch. The multi-kind shape the `kind:` list replaces.
150
+ *
151
+ * `oneOf` is peeled because the editor's picker always did and the analyzer
152
+ * never did — unifying on the wider reading is the point of a single accessor.
153
+ * The no-op claim is scoped to THIS repo: no schema here puts an `x-telo-ref`
154
+ * under `oneOf`, so for the standard library this cannot add a field-map entry
155
+ * (and hence a Phase-5 injection site) that did not exist before. An
156
+ * already-published third-party module that does use that spelling gains an
157
+ * injection site on upgrade — behavior its author most plausibly intended (the
158
+ * editor already treated the slot as a reference), but a change of behavior
159
+ * nonetheless, and part of this release's contract. */
160
+ function annotationNodes(node: Record<string, any> | undefined): Record<string, any>[] {
161
+ if (!node || typeof node !== "object") return [];
162
+ const out: Record<string, any>[] = [];
163
+ if (node["x-telo-ref"] !== undefined) out.push(node);
164
+ for (const key of ["anyOf", "oneOf"] as const) {
165
+ const branches = node[key];
166
+ if (!Array.isArray(branches)) continue;
167
+ for (const branch of branches) {
168
+ if (branch && typeof branch === "object" && branch["x-telo-ref"] !== undefined) {
169
+ out.push(branch);
170
+ }
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * Reads a schema node as a reference slot, or `undefined` when it declares none.
178
+ *
179
+ * Unions the accepted kinds across the `kind:` list and across `anyOf` branches,
180
+ * which is the model the analyzer has always had internally — `RefFieldEntry`
181
+ * has carried a flat `refs: string[]` since before the structured form existed.
182
+ * A `use` declared on more than one branch is taken once; a *disagreement*
183
+ * between branches is left visible in the returned set rather than silently
184
+ * resolved here, so `validate-ref-slots.ts` reports it against the authored
185
+ * node (`X_TELO_REF_USE_CONFLICT`) — the same split that keeps this reader
186
+ * lenient about unrecognized `use` tokens while that pass rejects them.
187
+ */
188
+ export function readRefSlot(node: Record<string, any> | undefined): RefSlot | undefined {
189
+ const nodes = annotationNodes(node);
190
+ if (nodes.length === 0) return undefined;
191
+
192
+ const kinds: string[] = [];
193
+ const uses = new Set<RefUse>();
194
+ let useCases: RefUseCases | undefined;
195
+ let inputs: string | undefined;
196
+
197
+ for (const carrier of nodes) {
198
+ const annotation = carrier["x-telo-ref"];
199
+ for (const kind of readKinds(annotation)) {
200
+ if (!kinds.includes(kind)) kinds.push(kind);
201
+ }
202
+ if (!annotation || typeof annotation !== "object" || Array.isArray(annotation)) continue;
203
+ const obj = annotation as Record<string, unknown>;
204
+ for (const use of normalizeUses(obj.use)) uses.add(use);
205
+ useCases ??= readUseCases(obj.use);
206
+ if (typeof obj.inputs === "string") inputs ??= obj.inputs;
207
+ }
208
+
209
+ const slot: RefSlot = {
210
+ kinds,
211
+ uses: [...uses],
212
+ inline: node?.["x-telo-inline"] === true || nodes.some((n) => n["x-telo-inline"] === true),
213
+ };
214
+ if (useCases) slot.useCases = useCases;
215
+ if (inputs !== undefined) slot.inputs = inputs;
216
+ return slot;
217
+ }
218
+
219
+ /** True when the node declares a reference slot in any accepted shape. The
220
+ * recognition test every surface used to spell for itself. */
221
+ export function isRefSlot(node: Record<string, any> | undefined): boolean {
222
+ return annotationNodes(node).length > 0;
223
+ }
224
+
225
+ /**
226
+ * Re-emits a slot as a canonical structured annotation value.
227
+ *
228
+ * For a consumer that has to *synthesize* a schema node and keep its reference
229
+ * slot intact — `ide-support` merges `anyOf` branches into one node for
230
+ * completion, and the merged node would otherwise carry no constraint at all,
231
+ * because the branches that held it are gone.
232
+ */
233
+ export function refSlotAnnotation(slot: RefSlot): Record<string, unknown> {
234
+ const annotation: Record<string, unknown> = {
235
+ kind: slot.kinds.length === 1 ? slot.kinds[0] : slot.kinds,
236
+ };
237
+ if (slot.useCases) annotation.use = slot.useCases;
238
+ else if (slot.uses.length === 1) annotation.use = slot.uses[0];
239
+ else if (slot.uses.length > 1) annotation.use = slot.uses;
240
+ if (slot.inputs !== undefined) annotation.inputs = slot.inputs;
241
+ return annotation;
242
+ }
243
+
244
+ /**
245
+ * Rewrites every kind name an annotation declares, in place, in whichever shape
246
+ * it is written. `map` returns the replacement, or `undefined` to leave the name
247
+ * untouched (which is what keeps `resolveSchemaRefKinds` idempotent and lets it
248
+ * report an unresolvable constraint while quoting what the author wrote).
249
+ *
250
+ * Lives here rather than in the caller so that adding an annotation shape is a
251
+ * one-file change on the write side as well as the read side.
252
+ */
253
+ export function rewriteRefSlotKinds(
254
+ annotationHolder: Record<string, any>,
255
+ map: (kind: string) => string | undefined,
256
+ ): void {
257
+ const annotation = annotationHolder["x-telo-ref"];
258
+ if (typeof annotation === "string") {
259
+ const next = map(annotation);
260
+ if (next !== undefined) annotationHolder["x-telo-ref"] = next;
261
+ return;
262
+ }
263
+ if (!annotation || typeof annotation !== "object" || Array.isArray(annotation)) return;
264
+ const obj = annotation as Record<string, unknown>;
265
+ if (typeof obj.kind === "string") {
266
+ const next = map(obj.kind);
267
+ if (next !== undefined) obj.kind = next;
268
+ return;
269
+ }
270
+ if (Array.isArray(obj.kind)) {
271
+ obj.kind = obj.kind.map((k) => (typeof k === "string" ? (map(k) ?? k) : k));
272
+ }
273
+ }