@telorun/analyzer 0.67.0 → 0.69.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 (96) hide show
  1. package/dist/analysis-registry.d.ts +12 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +26 -0
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +23 -0
  6. package/dist/call-graph.d.ts +80 -1
  7. package/dist/call-graph.d.ts.map +1 -1
  8. package/dist/call-graph.js +145 -12
  9. package/dist/extends-resolution.d.ts +20 -0
  10. package/dist/extends-resolution.d.ts.map +1 -1
  11. package/dist/extends-resolution.js +29 -0
  12. package/dist/import-resolution-diagnostics.d.ts.map +1 -1
  13. package/dist/import-resolution-diagnostics.js +22 -8
  14. package/dist/index.d.ts +8 -7
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +6 -6
  17. package/dist/invocation-contract.d.ts +20 -0
  18. package/dist/invocation-contract.d.ts.map +1 -1
  19. package/dist/invocation-contract.js +74 -0
  20. package/dist/loaded-types.d.ts +4 -4
  21. package/dist/loaded-types.d.ts.map +1 -1
  22. package/dist/manifest-analysis.d.ts +19 -0
  23. package/dist/manifest-analysis.d.ts.map +1 -1
  24. package/dist/manifest-analysis.js +27 -0
  25. package/dist/manifest-loader.d.ts +3 -4
  26. package/dist/manifest-loader.d.ts.map +1 -1
  27. package/dist/manifest-loader.js +4 -5
  28. package/dist/manifest-schemas.d.ts +2 -0
  29. package/dist/manifest-schemas.d.ts.map +1 -1
  30. package/dist/manifest-schemas.js +4 -0
  31. package/dist/module-graph.d.ts +500 -0
  32. package/dist/module-graph.d.ts.map +1 -0
  33. package/dist/module-graph.js +1411 -0
  34. package/dist/reconcile-module-versions.d.ts.map +1 -1
  35. package/dist/reconcile-module-versions.js +10 -11
  36. package/dist/release/release-plan.d.ts +1 -1
  37. package/dist/resolve-zone-containment.d.ts +9 -1
  38. package/dist/resolve-zone-containment.d.ts.map +1 -1
  39. package/dist/resolve-zone-containment.js +34 -6
  40. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  41. package/dist/resolve-zone-requirements.js +4 -2
  42. package/dist/schema-keywords.d.ts +16 -1
  43. package/dist/schema-keywords.d.ts.map +1 -1
  44. package/dist/schema-keywords.js +20 -0
  45. package/dist/sources/default-sources.d.ts +6 -6
  46. package/dist/sources/default-sources.d.ts.map +1 -1
  47. package/dist/sources/default-sources.js +7 -8
  48. package/dist/sources/integrity.d.ts +3 -2
  49. package/dist/sources/integrity.d.ts.map +1 -1
  50. package/dist/sources/integrity.js +26 -3
  51. package/dist/sources/versioned-ref.d.ts +17 -12
  52. package/dist/sources/versioned-ref.d.ts.map +1 -1
  53. package/dist/sources/versioned-ref.js +22 -24
  54. package/dist/telo-version.d.ts +1 -1
  55. package/dist/telo-version.js +1 -1
  56. package/dist/validate-extends.d.ts.map +1 -1
  57. package/dist/validate-extends.js +42 -1
  58. package/dist/validate-sensitive-slots.d.ts +28 -0
  59. package/dist/validate-sensitive-slots.d.ts.map +1 -0
  60. package/dist/validate-sensitive-slots.js +75 -0
  61. package/dist/value-type-keyword.d.ts +1 -1
  62. package/dist/value-type-keyword.d.ts.map +1 -1
  63. package/dist/value-type-keyword.js +1 -0
  64. package/package.json +1 -1
  65. package/src/analysis-registry.ts +37 -0
  66. package/src/analyzer.ts +26 -0
  67. package/src/call-graph.ts +207 -14
  68. package/src/extends-resolution.ts +33 -0
  69. package/src/import-resolution-diagnostics.ts +24 -7
  70. package/src/index.ts +42 -5
  71. package/src/invocation-contract.ts +71 -0
  72. package/src/loaded-types.ts +4 -4
  73. package/src/manifest-analysis.ts +39 -0
  74. package/src/manifest-loader.ts +4 -5
  75. package/src/manifest-schemas.ts +4 -0
  76. package/src/module-graph.ts +2005 -0
  77. package/src/reconcile-module-versions.ts +10 -11
  78. package/src/release/release-plan.ts +1 -1
  79. package/src/resolve-zone-containment.ts +49 -9
  80. package/src/resolve-zone-requirements.ts +7 -2
  81. package/src/schema-keywords.ts +33 -1
  82. package/src/sources/default-sources.ts +7 -8
  83. package/src/sources/integrity.ts +28 -3
  84. package/src/sources/versioned-ref.ts +26 -28
  85. package/src/telo-version.ts +1 -1
  86. package/src/validate-extends.ts +51 -1
  87. package/src/validate-sensitive-slots.ts +109 -0
  88. package/src/value-type-keyword.ts +1 -0
  89. package/dist/sources/module-ref.d.ts +0 -21
  90. package/dist/sources/module-ref.d.ts.map +0 -1
  91. package/dist/sources/module-ref.js +0 -36
  92. package/dist/sources/registry-source.d.ts +0 -14
  93. package/dist/sources/registry-source.d.ts.map +0 -1
  94. package/dist/sources/registry-source.js +0 -45
  95. package/src/sources/module-ref.ts +0 -49
  96. package/src/sources/registry-source.ts +0 -52
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate-sensitive-slots.d.ts","sourceRoot":"","sources":["../src/validate-sensitive-slots.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGrD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,gCAAgC,GAAG,8BAA8B,CAAC;IACxE,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAOD,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,gBAAgB,GAAG,kBAAkB,EAAE,CAqDvF"}
@@ -0,0 +1,75 @@
1
+ import { SCHEMA_REGION_KEYS } from "./schema-region.js";
2
+ const ANNOTATION = "x-telo-sensitive";
3
+ /** The only regions the kernel reads the annotation from. */
4
+ const CONTRACT_KEYS = new Set(["inputType", "outputType"]);
5
+ export function validateSensitiveSlots(manifest) {
6
+ const issues = [];
7
+ const walk = (node, path, seen) => {
8
+ if (!node || typeof node !== "object")
9
+ return;
10
+ if (seen.has(node))
11
+ return;
12
+ seen.add(node);
13
+ if (Array.isArray(node)) {
14
+ node.forEach((child, i) => walk(child, [...path, i], seen));
15
+ return;
16
+ }
17
+ const record = node;
18
+ if (Object.hasOwn(record, ANNOTATION)) {
19
+ const dotted = path.join(".");
20
+ if (record[ANNOTATION] !== true) {
21
+ issues.push({
22
+ code: "SENSITIVE_ANNOTATION_INVALID",
23
+ manifest,
24
+ path: dotted,
25
+ message: `'${ANNOTATION}' must be \`true\`; got ${JSON.stringify(record[ANNOTATION])}. ` +
26
+ `It is a marker, not a level — a value other than \`true\` reads as "not sensitive".`,
27
+ });
28
+ }
29
+ else {
30
+ const region = enclosingSchemaRegion(path);
31
+ if (region === undefined || !CONTRACT_KEYS.has(region)) {
32
+ issues.push({
33
+ code: "SENSITIVE_ANNOTATION_MISPLACED",
34
+ manifest,
35
+ path: dotted,
36
+ message: `'${ANNOTATION}' is only read from a resource's declared contract ` +
37
+ `(\`inputType\` / \`outputType\`), and this node is ` +
38
+ (region === undefined
39
+ ? "not inside a schema at all"
40
+ : `inside \`${region}\``) +
41
+ `. The kernel will not redact it, so the value would still reach trace ` +
42
+ `payloads and the debug wire. Move the mark onto the contract property ` +
43
+ `that carries the value.`,
44
+ });
45
+ }
46
+ }
47
+ }
48
+ for (const [key, child] of Object.entries(record)) {
49
+ walk(child, [...path, key], seen);
50
+ }
51
+ };
52
+ walk(manifest, [], new Set());
53
+ return issues;
54
+ }
55
+ /**
56
+ * The OUTERMOST segment naming a schema-valued key, or `undefined` when the node
57
+ * is not inside a schema.
58
+ *
59
+ * Outermost, not nearest, because a contract is routinely written in the inline
60
+ * `{kind: Telo.JsonSchema, schema: …}` form — so the path to a marked property is
61
+ * `outputType.schema.properties.headers`, and the nearest region key is that
62
+ * wrapper's own `schema`. Reading it as a kind's configuration would report every
63
+ * correctly-marked contract as misplaced, which is how this check first behaved.
64
+ *
65
+ * It is the same reasoning `expandManifestFragments` uses when it keys on the
66
+ * top-level slot: the object a contract resolver is handed is the one under
67
+ * `inputType` / `outputType`, whatever nesting the authoring form adds beneath.
68
+ */
69
+ function enclosingSchemaRegion(path) {
70
+ for (const segment of path) {
71
+ if (typeof segment === "string" && SCHEMA_REGION_KEYS.includes(segment))
72
+ return segment;
73
+ }
74
+ return undefined;
75
+ }
@@ -28,7 +28,7 @@ import type { KeywordDefinition } from "ajv";
28
28
  *
29
29
  * `x-telo-type` is deliberately absent — it is the one that emits code.
30
30
  */
31
- export declare const ANNOTATION_KEYWORDS: readonly ["x-telo-bindings-from", "x-telo-catches-for", "x-telo-context", "x-telo-context-collection-from", "x-telo-context-element-from", "x-telo-context-from", "x-telo-context-from-ref-kind", "x-telo-context-from-root", "x-telo-context-ref-from", "x-telo-error-context", "x-telo-eval", "x-telo-inline", "x-telo-outcome-list", "x-telo-provides-zone", "x-telo-ref", "x-telo-requires-zone", "x-telo-resource-rules", "x-telo-schema-from", "x-telo-schema-map", "x-telo-schema-projection", "x-telo-schema-projection-from", "x-telo-scope", "x-telo-step-context", "x-telo-topology-role", "x-telo-value-schema-from", "x-telo-widget"];
31
+ export declare const ANNOTATION_KEYWORDS: readonly ["x-telo-bindings-from", "x-telo-catches-for", "x-telo-context", "x-telo-context-collection-from", "x-telo-context-element-from", "x-telo-context-from", "x-telo-context-from-ref-kind", "x-telo-context-from-root", "x-telo-context-ref-from", "x-telo-error-context", "x-telo-eval", "x-telo-inline", "x-telo-outcome-list", "x-telo-provides-zone", "x-telo-ref", "x-telo-requires-zone", "x-telo-resource-rules", "x-telo-schema-from", "x-telo-schema-map", "x-telo-schema-projection", "x-telo-schema-projection-from", "x-telo-scope", "x-telo-sensitive", "x-telo-step-context", "x-telo-topology-role", "x-telo-value-schema-from", "x-telo-widget"];
32
32
  /**
33
33
  * The `x-telo-type` keyword.
34
34
  *
@@ -1 +1 @@
1
- {"version":3,"file":"value-type-keyword.d.ts","sourceRoot":"","sources":["../src/value-type-keyword.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;+EAqB+E;AAG/E,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,KAAK,CAAC;AAiB7C;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,onBA2BtB,CAAC;AAEX;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,IAAI,iBAAiB,CA+BpD;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE;IACxC,UAAU,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,UAAU,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;CACzD,GAAG,IAAI,CAGP"}
1
+ {"version":3,"file":"value-type-keyword.d.ts","sourceRoot":"","sources":["../src/value-type-keyword.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;+EAqB+E;AAG/E,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,KAAK,CAAC;AAiB7C;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,woBA4BtB,CAAC;AAEX;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,IAAI,iBAAiB,CA+BpD;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE;IACxC,UAAU,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,UAAU,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;CACzD,GAAG,IAAI,CAGP"}
@@ -54,6 +54,7 @@ export const ANNOTATION_KEYWORDS = [
54
54
  "x-telo-schema-projection",
55
55
  "x-telo-schema-projection-from",
56
56
  "x-telo-scope",
57
+ "x-telo-sensitive",
57
58
  "x-telo-step-context",
58
59
  "x-telo-topology-role",
59
60
  "x-telo-value-schema-from",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.67.0",
3
+ "version": "0.69.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -1,12 +1,14 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import { AliasResolver } from "./alias-resolver.js";
3
3
  import { KERNEL_BUILTINS } from "./builtins.js";
4
+ import type { BuildModuleGraphOptions, ModuleGraphDeps } from "./module-graph.js";
4
5
  import { ManifestAnalysis } from "./manifest-analysis.js";
5
6
  import { DefinitionRegistry } from "./definition-registry.js";
6
7
  import { computeSuggestKind, computeValidUserFacingKinds } from "./kind-suggest.js";
7
8
  import { visitManifest as runVisitManifest, type ManifestVisitor } from "./manifest-visitor.js";
8
9
  import type { ContractDirection, DefResolver } from "./extends-resolution.js";
9
10
  import { resolveContract } from "./invocation-contract.js";
11
+ import { createResolveCtx, resolveThrowsUnion } from "./resolve-throws-union.js";
10
12
  import { isRefEntry, isScopeEntry } from "./reference-field-map.js";
11
13
  import { resolveSchemaTypeRefs as resolveSchemaTypeRefsIn } from "./resolve-schema-type-refs.js";
12
14
  import type { AnalysisContext } from "./types.js";
@@ -279,6 +281,41 @@ export class AnalysisRegistry {
279
281
  });
280
282
  }
281
283
 
284
+ /**
285
+ * What the projection needs that only this registry knows: how a kind
286
+ * resolves, what its reference slots are, which capabilities they target, and
287
+ * what a resource can raise.
288
+ *
289
+ * The GRAPH itself is built by `ManifestAnalysis.moduleGraph`, which is the
290
+ * pairing of a registry with a manifest set — a fifth
291
+ * `registry.x(manifests, …)` factory is the accretion naming that class
292
+ * stopped. This is the half the registry owns.
293
+ */
294
+ moduleGraphDeps(
295
+ manifests: ResourceManifest[],
296
+ options: BuildModuleGraphOptions = {},
297
+ ): ModuleGraphDeps {
298
+ // The error contract is resolved through the SAME resolver `telo check`
299
+ // uses, memoized across the whole graph — so what a box says it can raise
300
+ // is what a route's `catches:` is validated against.
301
+ const throwsCtx = createResolveCtx(
302
+ manifests,
303
+ this.defs,
304
+ this.aliases,
305
+ this.aliasesByModule,
306
+ new Set(options.entryModule ? [options.entryModule] : []),
307
+ );
308
+ return {
309
+ refFields: (resource) => this.refFieldsForResource(resource),
310
+ definition: (kind, module) => this.resolveDefinitionIn(kind, module),
311
+ aliasesForModule: (module) => this.aliases.aliasesFor(module),
312
+ throwsOf: (resource) => {
313
+ const union = resolveThrowsUnion(resource, throwsCtx);
314
+ return { codes: [...union.codes.keys()], unbounded: union.unbounded };
315
+ },
316
+ };
317
+ }
318
+
282
319
  /**
283
320
  * Returns the built-in kernel definitions. The underlying DefinitionRegistry already
284
321
  * seeds these on construction; this method exposes them so callers (e.g. the kernel's
package/src/analyzer.ts CHANGED
@@ -108,6 +108,10 @@ import {
108
108
  validateValueTypeSlots,
109
109
  type ValueTypeSlotIssue,
110
110
  } from "./validate-value-type-slots.js";
111
+ import {
112
+ validateSensitiveSlots,
113
+ type SensitiveSlotIssue,
114
+ } from "./validate-sensitive-slots.js";
111
115
  import { resolveSchemaTypeRefs } from "./resolve-schema-type-refs.js";
112
116
  import { validateSchemaTypeRefs } from "./validate-schema-type-refs.js";
113
117
  import { rewriteSyntheticOrigins } from "./rewrite-synthetic-origins.js";
@@ -979,10 +983,16 @@ export class StaticAnalyzer {
979
983
  // definition's `schema:` does. Same scoping as every other schema issue —
980
984
  // the entry's own modules, since a dependency is not the consumer's to fix.
981
985
  const valueTypeSlotIssues: ValueTypeSlotIssue[] = [];
986
+ // `x-telo-sensitive` rides the same walk and the same scoping. It is read by
987
+ // ONE consumer — the kernel, off a bound contract — so an occurrence outside
988
+ // `inputType` / `outputType` is inert; for a security control, inert-and-
989
+ // silent is the failure worth reporting.
990
+ const sensitiveSlotIssues: SensitiveSlotIssue[] = [];
982
991
  for (const m of manifests) {
983
992
  const declaringModule = (m.metadata as { module?: string } | undefined)?.module;
984
993
  if (!declaringModule || rootModules.has(declaringModule)) {
985
994
  valueTypeSlotIssues.push(...validateValueTypeSlots(m as unknown as ResourceManifest));
995
+ sensitiveSlotIssues.push(...validateSensitiveSlots(m as unknown as ResourceManifest));
986
996
  }
987
997
  }
988
998
  for (const m of manifests) {
@@ -1212,6 +1222,22 @@ export class StaticAnalyzer {
1212
1222
  },
1213
1223
  });
1214
1224
  }
1225
+ for (const issue of sensitiveSlotIssues) {
1226
+ diagnostics.push({
1227
+ severity: DiagnosticSeverity.Error,
1228
+ code: issue.code,
1229
+ source: SOURCE,
1230
+ message: issue.message,
1231
+ data: {
1232
+ resource: {
1233
+ kind: issue.manifest.kind,
1234
+ name: issue.manifest.metadata?.name as string,
1235
+ },
1236
+ filePath: (issue.manifest.metadata as { source?: string } | undefined)?.source,
1237
+ path: issue.path,
1238
+ },
1239
+ });
1240
+ }
1215
1241
  // Same split for the two zone annotations. Unreadable ones fail in
1216
1242
  // OPPOSITE directions — a dropped requirement is silently unenforced, a
1217
1243
  // dropped provision invents failures — so neither can be left to
package/src/call-graph.ts CHANGED
@@ -56,7 +56,12 @@ import {
56
56
  type RefUseCases,
57
57
  } from "./ref-slot.js";
58
58
  import { isStepSlot } from "./step-slot.js";
59
- import { isRefEntry, resolveFieldEntries, type RefFieldEntry } from "./reference-field-map.js";
59
+ import {
60
+ isInlineResource,
61
+ isRefEntry,
62
+ resolveFieldEntries,
63
+ type RefFieldEntry,
64
+ } from "./reference-field-map.js";
60
65
  import { DEPENDENCY_GRAPH_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
61
66
 
62
67
  export interface ResourceGraphNode {
@@ -92,6 +97,42 @@ export interface StepGraphNode {
92
97
  index: number;
93
98
  /** The step value as written. */
94
99
  step: Record<string, unknown>;
100
+ /**
101
+ * Which branch of the step grammar this step IS — the branch's own title
102
+ * (`invoke`, `if/then/else`, `while/do`, `switch/cases/default`,
103
+ * `try/catch/finally`, `throw`, `value`).
104
+ *
105
+ * Matched on the branch's REQUIRED keys, and IS those keys — sorted and
106
+ * joined (`if+then`, `invoke`, `do+while`). A machine identity, so a consumer
107
+ * may branch on it; it was the branch's `title`, which is prose an author may
108
+ * reword at any time, and a view was splitting it on `/` to recover a keyword.
109
+ * Absent for a step matching no branch — one just added, and still empty.
110
+ */
111
+ variant?: string;
112
+ /** The branch's `title` as its author wrote it, for a view to render verbatim.
113
+ * Never parsed: it is a label, and the moment anything reads its SHAPE it
114
+ * becomes a vocabulary the author cannot change. */
115
+ variantLabel?: string;
116
+ /**
117
+ * The expression that decides whether or how this step runs, as written: an
118
+ * `if:`, a `while:`, a `switch:`, or the `when:` guard on a dispatch.
119
+ *
120
+ * Found through `x-telo-topology-role`, never by keyword, so a third-party
121
+ * composer annotating its own predicate is read the same way.
122
+ */
123
+ predicate?: string;
124
+ /**
125
+ * Every reference slot this step's grammar declares — filled, empty, or
126
+ * holding a declaration written at the site.
127
+ *
128
+ * Recorded here because this is the one place a step's item schema and its
129
+ * value are both in hand: a step array's items sit behind a local `$ref`, so
130
+ * the reference field map deliberately never reaches them, and a consumer
131
+ * asking "what may this step dispatch to, and where is that written" has
132
+ * nowhere else to look. An EMPTY slot is listed for exactly that reason — it
133
+ * is a site an editor can offer to fill, and an edge says nothing about one.
134
+ */
135
+ refSlots?: { key: string; path: string; kinds: string[]; inline?: boolean }[];
95
136
  }
96
137
 
97
138
  export type CallGraphNode = ResourceGraphNode | StepGraphNode;
@@ -154,7 +195,15 @@ export interface CallGraph {
154
195
  /** Edges arriving at a resource node. */
155
196
  edgesTo(id: string): CallGraphEdge[];
156
197
  resource(kind: string, name: string): ResourceGraphNode | undefined;
157
- resourceByName(name: string): ResourceGraphNode | undefined;
198
+ /**
199
+ * The resource a bare name addresses, read from `fromModule`'s scope.
200
+ *
201
+ * A name is module-scoped, so the module is how two declarations of one name
202
+ * are told apart — see {@link resolveScopedName}. Omitting it answers for a
203
+ * name declared once and, where several modules declare it, declines rather
204
+ * than taking whichever came first.
205
+ */
206
+ resourceByName(name: string, fromModule?: string): ResourceGraphNode | undefined;
158
207
  /** Step nodes declared by a resource, in lexical order. */
159
208
  steps(resourceId: string): StepGraphNode[];
160
209
  /** Every edge whose `use` includes at least one control transfer, plus every
@@ -164,6 +213,55 @@ export interface CallGraph {
164
213
 
165
214
  export const resourceId = (kind: string, name: string): string => `${kind}\0${name}`;
166
215
 
216
+ /**
217
+ * The id a manifest's node is keyed by.
218
+ *
219
+ * **A resource name is MODULE-SCOPED**, so `(kind, name)` is unique inside one
220
+ * module and not across a flattened set: two libraries each exporting an
221
+ * `Http.Api` named `routes` are two declarations, and keying both as
222
+ * `http.Api\0routes` silently made them one — the second overwrote the first,
223
+ * taking its edges and its steps with it. The module is part of the identity
224
+ * wherever the loader stamped one; a manifest with no stamp keeps the bare form,
225
+ * so nothing that never crossed a module boundary moves.
226
+ */
227
+ export function nodeIdFor(manifest: ResourceManifest): string {
228
+ const kind = manifest.kind as string;
229
+ const name = manifest.metadata?.name as string;
230
+ const module = (manifest.metadata as { module?: string } | undefined)?.module;
231
+ return module ? `${module}\0${resourceId(kind, name)}` : resourceId(kind, name);
232
+ }
233
+
234
+ /** The module a manifest was declared in, when the loader stamped one. */
235
+ const declaringModule = (manifest: ResourceManifest): string | undefined =>
236
+ (manifest.metadata as { module?: string } | undefined)?.module;
237
+
238
+ /**
239
+ * A bare `<name>` read from a resource declared in `fromModule`.
240
+ *
241
+ * **Same module first**, because that is what a bare name MEANS: it is resolved
242
+ * in the scope it was written in, and a cross-module reference is written with
243
+ * an alias. A name declared in exactly one module resolves to it whatever the
244
+ * reader's scope — which is what keeps an unstamped manifest set (every
245
+ * fixture, every single-module load) behaving as it did. A name declared in
246
+ * SEVERAL other modules and none of the reader's resolves to NOTHING: guessing
247
+ * between them attributes a reference to a resource the author never named, and
248
+ * some other pass reports the dangling name honestly.
249
+ *
250
+ * Exported because more than one pass resolves a bare name — the call graph's
251
+ * own sites, and the projection's inline declarations and CEL state reads. Two
252
+ * spellings of this rule is how a fix in one leaves a first-wins lookup in the
253
+ * other, which is exactly the collision it exists to prevent.
254
+ */
255
+ export function resolveScopedName<T>(
256
+ candidates: readonly T[] | undefined,
257
+ moduleOf: (item: T) => string | undefined,
258
+ fromModule: string | undefined,
259
+ ): T | undefined {
260
+ if (!candidates || candidates.length === 0) return undefined;
261
+ if (candidates.length === 1) return candidates[0];
262
+ return candidates.find((candidate) => moduleOf(candidate) === fromModule);
263
+ }
264
+
167
265
  /**
168
266
  * Does control reach this edge's target?
169
267
  *
@@ -323,6 +421,65 @@ interface StepWalkContext {
323
421
  stepEdgesByPath: Map<string, CallGraphEdge>;
324
422
  }
325
423
 
424
+ /**
425
+ * What a step IS, and what decides whether it runs.
426
+ *
427
+ * Both come off the schema rather than off a keyword list: the variant is the
428
+ * `oneOf` branch whose REQUIRED keys the step carries, and the predicate is the
429
+ * field annotated as one. A composer that declares a step body of its own is
430
+ * therefore described in its own words, and nothing here knows that `while`
431
+ * exists.
432
+ *
433
+ * A step matching several branches takes the FIRST — the grammar is a `oneOf`,
434
+ * so more than one match is a manifest the checker rejects, and guessing among
435
+ * them would be a second opinion on a question the checker already answers.
436
+ */
437
+ function classifyStep(
438
+ step: Record<string, unknown>,
439
+ itemSchema: Record<string, any> | undefined,
440
+ rootSchema: Record<string, any>,
441
+ ): { variant?: string; variantLabel?: string; predicate?: string } {
442
+ if (!itemSchema) return {};
443
+ const branches = Array.isArray(itemSchema.oneOf) ? itemSchema.oneOf : [];
444
+ let variant: string | undefined;
445
+ let variantLabel: string | undefined;
446
+ for (const raw of branches) {
447
+ const branch = resolveLocalRef(raw, rootSchema);
448
+ const required = Array.isArray(branch?.required) ? (branch.required as string[]) : [];
449
+ if (required.length === 0 || !required.every((key) => step[key] !== undefined)) continue;
450
+ // The keys are what the branch IS; the title is what it is called.
451
+ variant = [...required].sort().join("+");
452
+ if (typeof branch?.title === "string") variantLabel = branch.title;
453
+ break;
454
+ }
455
+
456
+ let predicate: string | undefined;
457
+ for (const [key, propSchema] of propertySchemas(itemSchema)) {
458
+ const role = propSchema?.["x-telo-topology-role"];
459
+ if (role !== "predicate" && role !== "discriminator") continue;
460
+ const written = expressionSource(step[key]);
461
+ if (written !== undefined) {
462
+ predicate = written;
463
+ break;
464
+ }
465
+ }
466
+ return {
467
+ ...(variant ? { variant } : {}),
468
+ ...(variantLabel ? { variantLabel } : {}),
469
+ ...(predicate ? { predicate } : {}),
470
+ };
471
+ }
472
+
473
+ /** A predicate as the author wrote it. A CEL value reaches here as a tagged
474
+ * sentinel or as an already-compiled node, and the SOURCE is the only part of
475
+ * either worth showing. */
476
+ function expressionSource(value: unknown): string | undefined {
477
+ if (typeof value === "string") return value;
478
+ if (value === null || typeof value !== "object") return undefined;
479
+ const record = value as { source?: unknown; __compiled?: unknown };
480
+ return typeof record.source === "string" ? record.source : undefined;
481
+ }
482
+
326
483
  /**
327
484
  * Emit the edges a single step's own ref slots declare.
328
485
  *
@@ -338,7 +495,21 @@ function emitStepEdges(node: StepGraphNode, ctx: StepWalkContext): void {
338
495
  for (const [key, propSchema] of propertySchemas(ctx.itemSchema)) {
339
496
  const slot = readRefSlot(propSchema);
340
497
  if (!slot || slot.kinds.length === 0) continue;
341
- const targetName = refTargetName(node.step[key]);
498
+ const written = node.step[key];
499
+ const inline =
500
+ written !== null &&
501
+ typeof written === "object" &&
502
+ !Array.isArray(written) &&
503
+ isInlineResource(written as Record<string, unknown>);
504
+ // Every declared slot is recorded, whatever is in it: an empty one is a
505
+ // site an editor can fill, and a declaration written at the site is a real
506
+ // dispatch that emits no edge — which is what made an inline step read
507
+ // exactly like one that dispatches nothing.
508
+ node.refSlots = [
509
+ ...(node.refSlots ?? []),
510
+ { key, path: `${node.path}.${key}`, kinds: slot.kinds, ...(inline ? { inline: true } : {}) },
511
+ ];
512
+ const targetName = refTargetName(written);
342
513
  if (targetName === undefined) continue;
343
514
  const entry: RefFieldEntry = {
344
515
  refs: slot.kinds,
@@ -441,6 +612,10 @@ function walkSteps(
441
612
  step: value,
442
613
  };
443
614
  if (typeof value.name === "string") node.name = value.name;
615
+ const { variant, variantLabel, predicate } = classifyStep(value, ctx.itemSchema, ctx.rootSchema);
616
+ if (variant) node.variant = variant;
617
+ if (variantLabel) node.variantLabel = variantLabel;
618
+ if (predicate) node.predicate = predicate;
444
619
  if (parent) node.parent = parent;
445
620
  ctx.nodes.set(id, node);
446
621
  ctx.order.push(node);
@@ -498,7 +673,9 @@ export function buildCallGraph(
498
673
  ): CallGraph {
499
674
  const nodes = new Map<string, CallGraphNode>();
500
675
  const edges: CallGraphEdge[] = [];
501
- const byName = new Map<string, ResourceGraphNode>();
676
+ /** Every declaration of a bare name, in declaration order — a name may be
677
+ * declared once per module, so this is a list rather than one node. */
678
+ const byName = new Map<string, ResourceGraphNode[]>();
502
679
  const stepsByOwner = new Map<string, StepGraphNode[]>();
503
680
  const stepEdgesByPath = new Map<string, CallGraphEdge>();
504
681
 
@@ -527,15 +704,22 @@ export function buildCallGraph(
527
704
  if (!name || !manifest.kind || SYSTEM_KINDS.has(manifest.kind)) continue;
528
705
  const node: ResourceGraphNode = {
529
706
  type: "resource",
530
- id: resourceId(manifest.kind, name as string),
707
+ id: nodeIdFor(manifest),
531
708
  kind: manifest.kind,
532
709
  name: name as string,
533
710
  manifest,
534
711
  };
712
+ if (nodes.has(node.id)) continue;
535
713
  nodes.set(node.id, node);
536
- byName.set(node.name, node);
714
+ byName.set(node.name, [...(byName.get(node.name) ?? []), node]);
537
715
  }
538
716
 
717
+ const resolveByName = (
718
+ name: string,
719
+ fromModule: string | undefined,
720
+ ): ResourceGraphNode | undefined =>
721
+ resolveScopedName(byName.get(name), (node) => declaringModule(node.manifest), fromModule);
722
+
539
723
  // --- step nodes ---
540
724
  const collectStepsFor = (
541
725
  node: ResourceGraphNode,
@@ -565,7 +749,7 @@ export function buildCallGraph(
565
749
  };
566
750
 
567
751
  for (const node of [...nodes.values()] as ResourceGraphNode[]) {
568
- collectStepsFor(node, (name) => byName.get(name));
752
+ collectStepsFor(node, (name) => resolveByName(name, declaringModule(node.manifest)));
569
753
  }
570
754
 
571
755
  // --- edges ---
@@ -593,7 +777,7 @@ export function buildCallGraph(
593
777
  scopeLocal = new Map();
594
778
  const ownerName = event.source.metadata?.name as string | undefined;
595
779
  if (!ownerName || !event.source.kind) return;
596
- const ownerId = resourceId(event.source.kind, ownerName);
780
+ const ownerId = nodeIdFor(event.source);
597
781
 
598
782
  // Inline declarations inside `x-telo-scope` arrays become nodes of
599
783
  // their own, keyed by their scope site — the declaration-site identity
@@ -620,8 +804,9 @@ export function buildCallGraph(
620
804
  }
621
805
  if (scopeLocal.size === 0) return;
622
806
 
807
+ const ownerModule = declaringModule(event.source);
623
808
  const resolveScoped = (name: string): ResourceGraphNode | undefined =>
624
- scopeLocal.get(name) ?? byName.get(name);
809
+ scopeLocal.get(name) ?? resolveByName(name, ownerModule);
625
810
 
626
811
  // The owner's own step edges were emitted before this scope was seen
627
812
  // (step collection precedes the visit), so their names resolved
@@ -680,7 +865,7 @@ export function buildCallGraph(
680
865
  onRef: (event) => {
681
866
  const sourceName = event.source.metadata?.name as string | undefined;
682
867
  if (!sourceName || !event.source.kind) return;
683
- const sourceId = resourceId(event.source.kind, sourceName);
868
+ const sourceId = nodeIdFor(event.source);
684
869
  if (!nodes.has(sourceId)) return;
685
870
 
686
871
  // A site inside a step was already emitted by the step walk, which
@@ -719,9 +904,10 @@ export function buildCallGraph(
719
904
  path: event.concretePath,
720
905
  use,
721
906
  };
907
+ const sourceModule = declaringModule(event.source);
722
908
  const target = scopedNames.has(targetName)
723
- ? (scopeLocal.get(targetName) ?? byName.get(targetName))
724
- : byName.get(targetName);
909
+ ? (scopeLocal.get(targetName) ?? resolveByName(targetName, sourceModule))
910
+ : resolveByName(targetName, sourceModule);
725
911
  if (target) edge.to = target.id;
726
912
  if (unresolved) edge.unresolved = unresolved;
727
913
  if (unresolvedReason) edge.unresolvedReason = unresolvedReason;
@@ -758,8 +944,15 @@ export function buildCallGraph(
758
944
  edges,
759
945
  edgesFrom: (id) => fromIndex.get(id) ?? [],
760
946
  edgesTo: (id) => toIndex.get(id) ?? [],
761
- resource: (kind, name) => nodes.get(resourceId(kind, name)) as ResourceGraphNode | undefined,
762
- resourceByName: (name) => byName.get(name),
947
+ // Both accessors take a name with no module, so both answer for the one
948
+ // declaration that name has — and, where a flattened set has several, the
949
+ // first in declaration order. A caller that must tell two apart holds a
950
+ // node id, which carries the module.
951
+ resource: (kind, name) =>
952
+ (nodes.get(resourceId(kind, name)) as ResourceGraphNode | undefined) ??
953
+ byName.get(name)?.find((n) => n.kind === kind),
954
+ resourceByName: (name, fromModule) =>
955
+ resolveScopedName(byName.get(name), (node) => declaringModule(node.manifest), fromModule),
763
956
  steps: (ownerId) => stepsByOwner.get(ownerId) ?? [],
764
957
  controlEdges: () => edges.filter(reachesTarget),
765
958
  };
@@ -137,6 +137,39 @@ export function effectiveAuthorSchema(
137
137
  return mergeTypeSchemas([parentSchema, own]) as Record<string, any>;
138
138
  }
139
139
 
140
+ /**
141
+ * The fields a merge-form inheriting child publishes over its parent's reading.
142
+ *
143
+ * A child that inherits its controller by delegation and declares no `base:` IS
144
+ * the parent instance, so `resources.<child>` is the parent's `snapshot()` — and
145
+ * a field the parent has never heard of would be readable from nowhere. These
146
+ * are exactly those fields: the ones the effective (merged) schema declares that
147
+ * the controller-bearing ancestor's does not.
148
+ *
149
+ * A REDECLARED name is deliberately excluded. Narrowing an inherited field (a
150
+ * description, a pattern, a widget hint) is ordinary in an additive extension and
151
+ * says nothing about publication, while the parent's `snapshot()` is the sole
152
+ * authority on what a parent instance publishes — its normalizations, its
153
+ * deliberate omissions, and its redactions. Republishing such a field from raw
154
+ * config would silently undo a provider's decision to keep it out.
155
+ *
156
+ * Empty for the `base:` form, whose fields are construction inputs consumed by
157
+ * the mapping, and for a child with its own controller, which publishes itself.
158
+ */
159
+ export function publishedOwnFields(
160
+ def: ResourceDefinition | undefined,
161
+ resolve: DefResolver,
162
+ ): string[] {
163
+ if (body(def).base || !isInheritedDelegation(def, resolve)) return [];
164
+ const ancestor = controllerBearingAncestor(def, resolve);
165
+ if (!ancestor) return [];
166
+ const inherited = new Set(
167
+ Object.keys((effectiveAuthorSchema(ancestor, resolve).properties ?? {}) as object),
168
+ );
169
+ const own = (effectiveAuthorSchema(def, resolve).properties ?? {}) as Record<string, unknown>;
170
+ return Object.keys(own).filter((name) => !inherited.has(name));
171
+ }
172
+
140
173
  /** The two directions of a kind's invocation contract. `inputType` is what a
141
174
  * caller sends to `invoke()`; `outputType` is what `invoke()` / `provide()`
142
175
  * returns. */
@@ -1,18 +1,16 @@
1
1
  import type { GraphLoadError, LoadedGraph } from "./loaded-types.js";
2
2
  import { isLocalPathSource } from "./sources/local-path-ref.js";
3
- import { isRegistryRef } from "./sources/module-ref.js";
4
3
  import { isOciRef } from "./sources/oci-ref.js";
5
4
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
6
5
 
7
6
  const SOURCE = "telo-analyzer";
8
7
 
9
- /** True when `source` is a shape some transport claims — a registry ref, an OCI
10
- * ref, an HTTP(S) URL, or a relative/absolute path. A source matching none of
11
- * these is malformed (no transport can ever resolve it), which we report
12
- * differently from a well-formed ref that simply failed to fetch. */
8
+ /** True when `source` is a shape some transport claims — an OCI ref, an HTTP(S)
9
+ * URL, or a relative/absolute path. A source matching none of these is
10
+ * malformed (no transport can ever resolve it), which we report differently
11
+ * from a well-formed ref that simply failed to fetch. */
13
12
  function isRecognizedSourceShape(source: string): boolean {
14
13
  return (
15
- isRegistryRef(source) ||
16
14
  isOciRef(source) ||
17
15
  source.startsWith("http://") ||
18
16
  source.startsWith("https://") ||
@@ -27,13 +25,32 @@ function classify(e: GraphLoadError): "INVALID_IMPORT_TARGET" | "INVALID_IMPORT_
27
25
  return isRecognizedSourceShape(e.source ?? e.url) ? "IMPORT_UNRESOLVED" : "INVALID_IMPORT_SOURCE";
28
26
  }
29
27
 
28
+ /** The removed bare `<namespace>/<name>@<version>` ref: no scheme, not a path,
29
+ * carrying both a `/` and an `@`. Recognised only to explain itself — no
30
+ * migration can rewrite it, because the OCI host it moved to is not derivable
31
+ * from the ref, and the failing line is frequently inside a dependency the
32
+ * consumer cannot edit. The message is the whole remedy available to them. */
33
+ function isRemovedBareRef(source: string): boolean {
34
+ return (
35
+ !source.includes("://") &&
36
+ !source.startsWith("/") &&
37
+ !source.startsWith(".") &&
38
+ source.includes("@") &&
39
+ source.includes("/")
40
+ );
41
+ }
42
+
30
43
  function messageFor(e: GraphLoadError, code: ReturnType<typeof classify>): string {
31
44
  const authored = e.source ?? e.url;
32
45
  const via = e.alias ? `import '${e.alias}' → '${authored}'` : `'${authored}'`;
33
46
  if (code === "INVALID_IMPORT_SOURCE") {
47
+ const removedForm = isRemovedBareRef(authored)
48
+ ? ` The bare '<namespace>/<name>@<version>' form was removed — rewrite it to the ` +
49
+ `module's 'oci://' ref (\`telo search <name>\` reports it).`
50
+ : "";
34
51
  return (
35
52
  `Cannot resolve ${via}: not a recognized module reference. Expected ` +
36
- `'namespace/name@version', 'oci://host/repo@tag', 'https://…', or a relative path.`
53
+ `'oci://host/repo@tag', 'https://…', or a relative path.${removedForm}`
37
54
  );
38
55
  }
39
56
  // The target WAS obtained, so "cannot resolve" would name the wrong problem —