@telorun/analyzer 0.69.0 → 0.71.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 (98) hide show
  1. package/dist/analysis-registry.d.ts.map +1 -1
  2. package/dist/analysis-registry.js +4 -5
  3. package/dist/analyzer.d.ts.map +1 -1
  4. package/dist/analyzer.js +50 -8
  5. package/dist/call-graph.d.ts.map +1 -1
  6. package/dist/call-graph.js +10 -4
  7. package/dist/canonical-json.d.ts +18 -0
  8. package/dist/canonical-json.d.ts.map +1 -0
  9. package/dist/canonical-json.js +26 -0
  10. package/dist/cel-access-chains.d.ts +14 -0
  11. package/dist/cel-access-chains.d.ts.map +1 -0
  12. package/dist/cel-access-chains.js +45 -0
  13. package/dist/cel-scope-query.d.ts +14 -0
  14. package/dist/cel-scope-query.d.ts.map +1 -1
  15. package/dist/cel-scope-query.js +36 -6
  16. package/dist/definition-registry.d.ts.map +1 -1
  17. package/dist/definition-registry.js +3 -4
  18. package/dist/flatten-for-analyzer.d.ts +2 -2
  19. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  20. package/dist/flatten-for-analyzer.js +1 -1
  21. package/dist/index.d.ts +5 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -1
  24. package/dist/manifest-diff.d.ts +111 -0
  25. package/dist/manifest-diff.d.ts.map +1 -0
  26. package/dist/manifest-diff.js +130 -0
  27. package/dist/manifest-visitor.d.ts.map +1 -1
  28. package/dist/manifest-visitor.js +11 -3
  29. package/dist/module-alias-scope.d.ts +65 -0
  30. package/dist/module-alias-scope.d.ts.map +1 -0
  31. package/dist/module-alias-scope.js +25 -0
  32. package/dist/{zone-module-documents.d.ts → module-documents.d.ts} +11 -7
  33. package/dist/module-documents.d.ts.map +1 -0
  34. package/dist/module-graph.d.ts.map +1 -1
  35. package/dist/module-graph.js +3 -25
  36. package/dist/ref-sentinel-target.d.ts +38 -0
  37. package/dist/ref-sentinel-target.d.ts.map +1 -0
  38. package/dist/ref-sentinel-target.js +13 -0
  39. package/dist/resolve-schema-type-refs.d.ts.map +1 -1
  40. package/dist/resolve-schema-type-refs.js +2 -1
  41. package/dist/resolve-throws-union.d.ts +18 -1
  42. package/dist/resolve-throws-union.d.ts.map +1 -1
  43. package/dist/resolve-throws-union.js +93 -9
  44. package/dist/resolve-zone-requirements.d.ts +3 -3
  45. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  46. package/dist/resolve-zone-requirements.js +12 -12
  47. package/dist/telo-version.d.ts +1 -1
  48. package/dist/telo-version.js +1 -1
  49. package/dist/template-body.d.ts.map +1 -1
  50. package/dist/template-body.js +2 -4
  51. package/dist/types.d.ts +3 -3
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/validate-invocation-contract.d.ts +5 -0
  54. package/dist/validate-invocation-contract.d.ts.map +1 -1
  55. package/dist/validate-invocation-contract.js +114 -3
  56. package/dist/validate-logging.d.ts.map +1 -1
  57. package/dist/validate-logging.js +2 -2
  58. package/dist/validate-references.d.ts +16 -5
  59. package/dist/validate-references.d.ts.map +1 -1
  60. package/dist/validate-references.js +30 -12
  61. package/dist/validate-resource-inputs.d.ts +1 -26
  62. package/dist/validate-resource-inputs.d.ts.map +1 -1
  63. package/dist/validate-resource-inputs.js +12 -2
  64. package/dist/validate-schema-type-refs.d.ts.map +1 -1
  65. package/dist/validate-schema-type-refs.js +2 -1
  66. package/dist/validate-throws-coverage.d.ts +5 -1
  67. package/dist/validate-throws-coverage.d.ts.map +1 -1
  68. package/dist/validate-throws-coverage.js +6 -2
  69. package/package.json +2 -2
  70. package/src/analysis-registry.ts +4 -5
  71. package/src/analyzer.ts +61 -10
  72. package/src/call-graph.ts +9 -3
  73. package/src/canonical-json.ts +24 -0
  74. package/src/cel-access-chains.ts +47 -0
  75. package/src/cel-scope-query.ts +45 -8
  76. package/src/definition-registry.ts +3 -6
  77. package/src/flatten-for-analyzer.ts +3 -3
  78. package/src/index.ts +10 -2
  79. package/src/manifest-diff.ts +219 -0
  80. package/src/manifest-visitor.ts +11 -3
  81. package/src/module-alias-scope.ts +94 -0
  82. package/src/{zone-module-documents.ts → module-documents.ts} +10 -6
  83. package/src/module-graph.ts +2 -24
  84. package/src/ref-sentinel-target.ts +46 -0
  85. package/src/resolve-schema-type-refs.ts +2 -1
  86. package/src/resolve-throws-union.ts +123 -8
  87. package/src/resolve-zone-requirements.ts +14 -14
  88. package/src/telo-version.ts +1 -1
  89. package/src/template-body.ts +2 -5
  90. package/src/types.ts +3 -3
  91. package/src/validate-invocation-contract.ts +128 -2
  92. package/src/validate-logging.ts +2 -3
  93. package/src/validate-references.ts +41 -11
  94. package/src/validate-resource-inputs.ts +26 -3
  95. package/src/validate-schema-type-refs.ts +2 -1
  96. package/src/validate-throws-coverage.ts +12 -1
  97. package/dist/zone-module-documents.d.ts.map +0 -1
  98. /package/dist/{zone-module-documents.js → module-documents.js} +0 -0
@@ -1,6 +1,8 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import { isTaggedSentinel } from "@telorun/templating";
3
3
  import { scopeResolverForModule, type AliasResolver } from "./alias-resolver.js";
4
+ import { resolveScopedName } from "./call-graph.js";
5
+ import { refSentinelTarget, type RefSentinelTarget } from "./ref-sentinel-target.js";
4
6
  import type { DefinitionRegistry } from "./definition-registry.js";
5
7
  import { readStepSlot } from "./step-slot.js";
6
8
 
@@ -40,6 +42,23 @@ export interface ResolveCtx {
40
42
  aliasesByModule: Map<string, AliasResolver>;
41
43
  /** The consumer/root module names; resources owned by these resolve against `aliases`. */
42
44
  rootModules: Set<string>;
45
+ /**
46
+ * Every imported library's FULL manifest list, keyed by module name.
47
+ *
48
+ * A consumer's flat set holds a library's EXPORTED instances and nothing else,
49
+ * so the siblings an exported entry point invokes are not in it. Without this
50
+ * the walk stops at the first such hop, and the difference is not academic: a
51
+ * library whose entry point raises its own code through an internal guard
52
+ * presented an empty union to its consumer, which then had its `catches:`
53
+ * rejected for the very code the entry point documents.
54
+ *
55
+ * Consulted only as a FALLBACK, after the flat set — the flat set is what the
56
+ * consumer's own resources resolve against, and a library-internal name must
57
+ * never shadow one of them.
58
+ */
59
+ moduleManifests: Map<string, ResourceManifest[]>;
60
+ /** Keyed `<module>\0<name>`: resource names are module-scoped, so two
61
+ * libraries each declaring a `query` are two different unions. */
43
62
  memo: Map<string, ThrowsUnion>;
44
63
  inProgress: Set<string>;
45
64
  }
@@ -50,6 +69,7 @@ export function createResolveCtx(
50
69
  aliases: AliasResolver,
51
70
  aliasesByModule: Map<string, AliasResolver> = new Map(),
52
71
  rootModules: Set<string> = new Set(),
72
+ moduleManifests: Map<string, ResourceManifest[]> = new Map(),
53
73
  ): ResolveCtx {
54
74
  return {
55
75
  allManifests,
@@ -57,11 +77,90 @@ export function createResolveCtx(
57
77
  aliases,
58
78
  aliasesByModule,
59
79
  rootModules,
80
+ moduleManifests,
60
81
  memo: new Map(),
61
82
  inProgress: new Set(),
62
83
  };
63
84
  }
64
85
 
86
+ /**
87
+ * A dispatch target named by a resolved `{kind, name}` ref.
88
+ *
89
+ * The flat set is the scope such a ref was resolved in, so it is asked first;
90
+ * the owning library's own documents are the fallback for a name the flattened
91
+ * view dropped. `kindMatches` applies to BOTH — one function, one rule, or the
92
+ * next caller inherits whichever half it happened to hit.
93
+ */
94
+ function findTarget(
95
+ ctx: ResolveCtx,
96
+ name: string,
97
+ ownerModule: string | undefined,
98
+ kindMatches: (m: ResourceManifest) => boolean,
99
+ ): ResourceManifest | undefined {
100
+ const flat = ctx.allManifests.find((m) => m.metadata?.name === name && kindMatches(m));
101
+ if (flat) return flat;
102
+ if (!ownerModule) return undefined;
103
+ return ctx.moduleManifests
104
+ .get(ownerModule)
105
+ ?.find((m) => m.metadata?.name === name && kindMatches(m));
106
+ }
107
+
108
+ /**
109
+ * The target of a `!ref` that still carries its sentinel — a library-internal
110
+ * reference inside a manifest forwarded into a consumer's flat set, where Phase
111
+ * 2.5 had nothing to resolve it against.
112
+ *
113
+ * **The declaring library is asked FIRST**, and that ordering is the whole rule:
114
+ * a bare name in a library manifest is unambiguously library-internal, so
115
+ * searching the consumer's flat set first let any consumer resource that
116
+ * happened to share the name supply another library's throw union — the same
117
+ * false `{∅}` this branch exists to remove, arrived at from the other side.
118
+ *
119
+ * An ALIAS-qualified source is the cross-module case and is resolved through the
120
+ * declaring module's own alias table, never by dropping the alias and matching
121
+ * the bare name anywhere.
122
+ *
123
+ * Ambiguity resolves to NOTHING rather than to a guess (`resolveScopedName`'s
124
+ * rule), and the caller reads that as unbounded — the safe direction here, since
125
+ * a union that cannot be enumerated must not read as empty.
126
+ */
127
+ function findSentinelTarget(
128
+ ctx: ResolveCtx,
129
+ target: RefSentinelTarget,
130
+ ownerModule: string | undefined,
131
+ ): ResourceManifest | undefined {
132
+ const named = (pool: readonly ResourceManifest[] | undefined): ResourceManifest[] =>
133
+ (pool ?? []).filter((m) => m.metadata?.name === target.name);
134
+
135
+ if (target.alias !== undefined && target.alias !== "Self") {
136
+ // A forwarded export: its module is whatever the DECLARING module aliases
137
+ // that prefix to, and it keeps its export name in the flat set.
138
+ // A root-owned manifest resolves aliases against the global table, which is
139
+ // what `scopeResolverForModule` returns undefined for — so fall back to it
140
+ // rather than reading a root's own alias as unresolvable.
141
+ const resolver = scopeResolverFor(ctx, ownerModule) ?? ctx.aliases;
142
+ const module = resolver.moduleForAlias(target.alias);
143
+ if (!module) return undefined;
144
+ return named(ctx.allManifests).find((m) => declaringModuleOf(m) === module);
145
+ }
146
+
147
+ const own = named(ctx.moduleManifests.get(ownerModule ?? ""));
148
+ if (own.length === 1) return own[0];
149
+ if (own.length > 1) return undefined;
150
+ return resolveScopedName(named(ctx.allManifests), declaringModuleOf, ownerModule);
151
+ }
152
+
153
+ const declaringModuleOf = (m: ResourceManifest): string | undefined =>
154
+ (m.metadata as { module?: string } | undefined)?.module;
155
+
156
+ /** Memo key. Module-scoped, because resource names are. */
157
+ function memoKey(manifest: ResourceManifest): string | undefined {
158
+ const name = manifest.metadata?.name as string | undefined;
159
+ if (!name) return undefined;
160
+ const mod = (manifest.metadata as { module?: string } | undefined)?.module ?? "";
161
+ return `${mod}\0${name}`;
162
+ }
163
+
65
164
  function emptyUnion(): ThrowsUnion {
66
165
  return { codes: new Map(), unbounded: false };
67
166
  }
@@ -114,7 +213,7 @@ export function resolveThrowsUnion(
114
213
  manifest: ResourceManifest,
115
214
  ctx: ResolveCtx,
116
215
  ): ThrowsUnion {
117
- const name = manifest.metadata?.name as string | undefined;
216
+ const name = memoKey(manifest);
118
217
 
119
218
  if (name) {
120
219
  const cached = ctx.memo.get(name);
@@ -314,7 +413,21 @@ function resolveStepInvokeThrows(
314
413
  const invokeRef = step[invokeField];
315
414
  if (!invokeRef || typeof invokeRef !== "object") return emptyUnion();
316
415
  const invokedKind = invokeRef.kind as string | undefined;
317
- if (!invokedKind) return emptyUnion();
416
+ // A reference that still carries its parse-time sentinel — a library-internal
417
+ // `!ref` inside a manifest forwarded into a consumer's flat set, where Phase
418
+ // 2.5 had nothing to resolve it against. The target is in the declaring
419
+ // library's own documents, so it is looked up there; only a name that is not
420
+ // there either is UNKNOWN, which is unbounded rather than empty. Reading it as
421
+ // empty is what made a library's exported entry point present a `{∅}` union to
422
+ // its consumer and get the consumer's `catches:` rejected for the code the
423
+ // entry point documents.
424
+ if (!invokedKind) {
425
+ const sentinel = refSentinelTarget(invokeRef);
426
+ if (!sentinel) return emptyUnion();
427
+ const target = findSentinelTarget(ctx, sentinel, ownerModule);
428
+ if (target) return resolveThrowsUnion(target, ctx);
429
+ return { codes: new Map(), unbounded: true };
430
+ }
318
431
 
319
432
  // The invoked kind's alias resolves in the OWNER manifest's lexical scope (the
320
433
  // composer that declares the step), so a library's step referencing its own
@@ -331,13 +444,15 @@ function resolveStepInvokeThrows(
331
444
  const invokeName = invokeRef.name as string | undefined;
332
445
  if (invokeName) {
333
446
  const scopedInvokedKind = scopeResolver?.resolveKind(invokedKind);
334
- const target = ctx.allManifests.find(
447
+ const target = findTarget(
448
+ ctx,
449
+ invokeName,
450
+ ownerModule,
335
451
  (m) =>
336
- m.metadata?.name === invokeName &&
337
- (m.kind === invokedKind ||
338
- ctx.aliases.resolveKind(m.kind) === invokedKind ||
339
- m.kind === ctx.aliases.resolveKind(invokedKind) ||
340
- (scopedInvokedKind !== undefined && m.kind === scopedInvokedKind)),
452
+ m.kind === invokedKind ||
453
+ ctx.aliases.resolveKind(m.kind) === invokedKind ||
454
+ m.kind === ctx.aliases.resolveKind(invokedKind) ||
455
+ (scopedInvokedKind !== undefined && m.kind === scopedInvokedKind),
341
456
  );
342
457
  if (target) return resolveThrowsUnion(target, ctx);
343
458
  }
@@ -14,7 +14,7 @@
14
14
  * Browser-safe: no Node built-ins.
15
15
  */
16
16
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
17
- import { isRefSentinel } from "@telorun/templating";
17
+ import { refSentinelTarget } from "./ref-sentinel-target.js";
18
18
  import type { AliasResolver } from "./alias-resolver.js";
19
19
  import {
20
20
  buildCallGraph,
@@ -28,9 +28,10 @@ import {
28
28
  propertySchemas,
29
29
  resolveLocalRef,
30
30
  } from "./manifest-navigation.js";
31
- import type { ZoneModuleDocuments } from "./zone-module-documents.js";
31
+ import type { ModuleDocuments } from "./module-documents.js";
32
32
  import { readProvidesZone, readRequiresZone } from "./zone-slot.js";
33
33
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
34
+ import { moduleAliasScope } from "./module-alias-scope.js";
34
35
 
35
36
  const SOURCE = "telo-analyzer";
36
37
 
@@ -125,7 +126,7 @@ function definitionResolver(
125
126
  aliasesByModule: Map<string, AliasResolver>,
126
127
  ) {
127
128
  return (kind: string, module?: string): ResourceDefinition | undefined => {
128
- const scope = (module ? aliasesByModule.get(module) : undefined) ?? aliases;
129
+ const scope = moduleAliasScope({ module }, aliases, aliasesByModule);
129
130
  const canonical = scope.resolveKind(kind);
130
131
  return defs.resolve(kind) ?? (canonical ? defs.resolve(canonical) : undefined);
131
132
  };
@@ -193,17 +194,16 @@ function schemaNodeAt(
193
194
  * under-approximating direction the whole pass leans on. `Self.` is a local
194
195
  * name written the long way and does resolve.
195
196
  *
196
- * This deliberately differs from `call-graph`'s `refTargetName`, which answers a
197
- * different question (what an EDGE points at, cross-module included, for a graph
198
- * whose consumers tolerate an unresolved target) hence two functions rather
199
- * than one shared helper.
197
+ * This is this pass's REDUCTION over the shared `refSentinelTarget` parse, not a
198
+ * second reading of the tag: `call-graph`'s answers a different question (what
199
+ * an EDGE points at, cross-module included, for a graph whose consumers tolerate
200
+ * an unresolved target) and states its own reduction the same way.
200
201
  */
201
202
  function refName(value: unknown): string | undefined {
202
- if (isRefSentinel(value)) {
203
- const source = value.source;
204
- const dot = source.indexOf(".");
205
- if (dot <= 0) return source;
206
- return source.slice(0, dot) === "Self" ? source.slice(dot + 1) : undefined;
203
+ const sentinel = refSentinelTarget(value);
204
+ if (sentinel) {
205
+ if (sentinel.alias === undefined) return sentinel.name;
206
+ return sentinel.alias === "Self" ? sentinel.name : undefined;
207
207
  }
208
208
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
209
209
  const v = value as Record<string, unknown>;
@@ -712,7 +712,7 @@ export function zoneDocumentsSignature(manifests: readonly ResourceManifest[]):
712
712
  * the projection derive-only, and keep what reaches an exported instance.
713
713
  */
714
714
  export function deriveLibraryExportRequirements(
715
- docs: ZoneModuleDocuments,
715
+ docs: ModuleDocuments,
716
716
  defs: DefinitionRegistry,
717
717
  aliases: AliasResolver,
718
718
  aliasesByModule: Map<string, AliasResolver>,
@@ -745,7 +745,7 @@ export interface ZoneAnalysisArgs {
745
745
  aliases: AliasResolver;
746
746
  aliasesByModule: Map<string, AliasResolver>;
747
747
  rootModules: ReadonlySet<string>;
748
- moduleDocuments?: readonly ZoneModuleDocuments[];
748
+ moduleDocuments?: readonly ModuleDocuments[];
749
749
  cache?: ZoneExportCache;
750
750
  }
751
751
 
@@ -6,4 +6,4 @@
6
6
  // written against, and every kernel in every language reports the same scale.
7
7
 
8
8
  /** The surface generation this analyzer implements. */
9
- export const TELO_SURFACE_VERSION = "0.85.0";
9
+ export const TELO_SURFACE_VERSION = "0.87.0";
@@ -2,6 +2,7 @@ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import type { AliasResolver } from "./alias-resolver.js";
3
3
  import type { DefinitionRegistry } from "./definition-registry.js";
4
4
  import type { ModuleScopes } from "./alias-resolver.js";
5
+ import { moduleAliasScope } from "./module-alias-scope.js";
5
6
 
6
7
  /**
7
8
  * A `Telo.Definition`'s `resources:` entries — the bodies a kind writes for
@@ -47,11 +48,7 @@ export function templateBodies(
47
48
  // A nested kind is written through the alias scope of the module that DECLARED
48
49
  // the definition, never the consumer's — the same rule every other kind
49
50
  // resolution in a forwarded manifest follows.
50
- const ownModule = (m.metadata as { module?: string } | undefined)?.module;
51
- const scope =
52
- (ownModule && scopes && !scopes.rootModules.has(ownModule)
53
- ? scopes.aliasesByModule.get(ownModule)
54
- : undefined) ?? aliases;
51
+ const scope = moduleAliasScope(m.metadata, aliases, scopes?.aliasesByModule);
55
52
 
56
53
  const out: TemplateBody[] = [];
57
54
  for (let i = 0; i < bodies.length; i++) {
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { HostVersions } from "./requires-block.js";
2
2
 
3
- import type { ZoneModuleDocuments } from "./zone-module-documents.js";
3
+ import type { ModuleDocuments } from "./module-documents.js";
4
4
  /** Matches LSP DiagnosticSeverity values exactly.
5
5
  * https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#diagnosticSeverity */
6
6
  export const DiagnosticSeverity = {
@@ -142,10 +142,10 @@ export interface AnalysisOptions {
142
142
  /** Imported libraries' FULL document sets, for the zone stage's per-library
143
143
  * export derivation — the flattened analysis view forwards only each
144
144
  * library's export surface, never its internal dispatch chain. Collected
145
- * from a LoadedGraph via `collectZoneModuleDocuments`. Omitting it skips
145
+ * from a LoadedGraph via `collectModuleDocuments`. Omitting it skips
146
146
  * the derivation (the under-approximating direction — the runtime check
147
147
  * remains the enforcement). */
148
- moduleDocuments?: ZoneModuleDocuments[];
148
+ moduleDocuments?: ModuleDocuments[];
149
149
  /** When true, `analyze()` runs the state-mutating setup (module identity /
150
150
  * alias / definition registration plus `normalizeInlineResources`) but
151
151
  * skips every diagnostic-producing pass — per-resource validation, the
@@ -2,13 +2,17 @@ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import type { AliasResolver } from "./alias-resolver.js";
3
3
  import type { DefinitionRegistry } from "./definition-registry.js";
4
4
  import {
5
+ ancestorChain,
5
6
  type ContractDirection,
6
7
  type DefResolver,
7
8
  effectiveContractField,
8
9
  mappingFieldFor,
9
10
  needsContractMapping,
10
11
  } from "./extends-resolution.js";
12
+ import { resolveTypeFieldToSchema } from "./validate-cel-context.js";
13
+ import { moduleAliasScope } from "./module-alias-scope.js";
11
14
  import { buildReferenceFieldMap, isRefEntry } from "./reference-field-map.js";
15
+ import { checkSchemaCompatibility } from "./schema-compat.js";
12
16
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
13
17
 
14
18
  const SOURCE = "telo-analyzer";
@@ -29,6 +33,11 @@ const SOURCE = "telo-analyzer";
29
33
  * whose input contract is now `inputType:`.
30
34
  * - CONTRACT_TYPE_NOT_FOUND: a contract names a type that is not declared in
31
35
  * scope, so every call through it would fail at dispatch.
36
+ * - CONTRACT_NOT_SUBSTITUTABLE: a definition replaces an ancestor's contract
37
+ * with a shape that cannot stand in for it. The one check nothing else makes:
38
+ * contracts resolve to the nearest declaration in BOTH halves, so a child that
39
+ * declares its own is compared against its abstract by neither the analyzer
40
+ * nor dispatch.
32
41
  *
33
42
  * Deliberately NOT diagnosed: an input that is neither `required:` nor
34
43
  * defaulted. It is indistinguishable from a genuinely optional one — `Ai.Text`
@@ -45,8 +54,7 @@ export function validateInvocationContract(
45
54
  ): AnalysisDiagnostic[] {
46
55
  const diagnostics: AnalysisDiagnostic[] = [];
47
56
  const resolveDef: DefResolver = (kind, from) => {
48
- const module = (from?.metadata as { module?: string } | undefined)?.module;
49
- const scope = (module ? aliasesByModule.get(module) : undefined) ?? aliases;
57
+ const scope = moduleAliasScope(from?.metadata, aliases, aliasesByModule);
50
58
  return registry.resolve(kind) ?? registry.resolve(scope.resolveKind(kind) ?? kind);
51
59
  };
52
60
 
@@ -73,6 +81,7 @@ export function validateInvocationContract(
73
81
  if (m.kind === "Telo.Definition" || m.kind === "Telo.Abstract") {
74
82
  checkMappingRequired(m, resource, filePath, resolveDef, diagnostics);
75
83
  checkContractResolves(m, md, manifests, resource, filePath, diagnostics);
84
+ checkAncestorSubstitutability(m, md, manifests, resource, filePath, resolveDef, diagnostics);
76
85
  continue;
77
86
  }
78
87
 
@@ -360,6 +369,123 @@ function namedTypeReference(value: unknown): string | undefined {
360
369
  return typeof ref.name === "string" ? ref.name : undefined;
361
370
  }
362
371
 
372
+ /**
373
+ * A REPLACING CONTRACT MUST STILL STAND IN FOR THE ONE IT REPLACES.
374
+ *
375
+ * Contracts resolve to the NEAREST declaration and never merge, which is right —
376
+ * a call signature is not additive, and a backend narrowing `status` to the
377
+ * states it actually has could not express that through a merge. But the
378
+ * consequence is that an abstract's contract binds only the children that
379
+ * declare none of their own: a child that declares one is checked against
380
+ * nothing, statically or at dispatch, since `resolveBoundContract` stops at the
381
+ * same nearest declaration. So an abstract could state a floor every
382
+ * implementation must answer with (`Durable.Run`'s `runId` / `status`, the
383
+ * identity every later question about a run is asked by) and have that floor hold
384
+ * for exactly the implementations that declared nothing — while the real ones,
385
+ * which all declare their own, escaped it. A slot typed by the abstract would
386
+ * then be typed by a promise nothing kept.
387
+ *
388
+ * The rule is substitutability, and its direction differs per contract because
389
+ * one is produced and the other consumed:
390
+ *
391
+ * - `outputType` is COVARIANT — a consumer written against the ancestor reads
392
+ * the ancestor's shape, so the child's output must be acceptable where the
393
+ * ancestor's is declared. Dropping a required property is what breaks it.
394
+ * - `inputType` is CONTRAVARIANT — a caller written against the ancestor sends
395
+ * the ancestor's shape, so the ancestor's input must be acceptable where the
396
+ * child's is declared. Requiring an input the ancestor never mentions is what
397
+ * breaks it: no such caller could satisfy it.
398
+ *
399
+ * **Only an ABSTRACT ancestor's contract is a contract**, and that narrowing is a
400
+ * decision rather than an oversight. Extending a CONCRETE kind is how a friendlier
401
+ * schema is put over an existing controller — `base:` reshapes its config and
402
+ * `inputs:` / `result:` translate its call signature — so a child there is
403
+ * SUPPOSED to present different inputs, and demanding substitutability would
404
+ * reject the pattern the standard library and every custom-kind example are built
405
+ * on. An abstract has no implementation to reshape: extending one is a claim to
406
+ * BE the thing, its contract is written for implementors, and a slot typed by it
407
+ * is polymorphic by construction — which is exactly where an unkept promise has
408
+ * nowhere to be caught.
409
+ *
410
+ * A direction the child BRIDGES (`inputs:` for inputs, `result:` for outputs) is
411
+ * skipped for the same reason one hop down: the mapping is the author saying the
412
+ * shapes differ deliberately and are translated.
413
+ *
414
+ * The comparison is {@link checkSchemaCompatibility}, which reports only DEFINITE
415
+ * mismatches — a union, an absent `type`, an undeclared argument all read as
416
+ * compatible — so narrowing a value's type, adding an optional property or
417
+ * restricting an enum stays legal, which is the whole point of letting a child
418
+ * replace the contract at all.
419
+ */
420
+ function checkAncestorSubstitutability(
421
+ m: ResourceManifest,
422
+ md: Record<string, unknown>,
423
+ manifests: ResourceManifest[],
424
+ resource: { kind: string; name: string },
425
+ filePath: string | undefined,
426
+ resolveDef: DefResolver,
427
+ diagnostics: AnalysisDiagnostic[],
428
+ ): void {
429
+ const def = m as unknown as ResourceDefinition;
430
+
431
+ for (const direction of ["inputType", "outputType"] as ContractDirection[]) {
432
+ const own = md[direction];
433
+ if (own === undefined || own === null) continue;
434
+ // A declared bridge says the shapes differ on purpose and are translated.
435
+ if (md[mappingFieldFor(direction)] != null) continue;
436
+
437
+ // The nearest ANCESTOR that declares it — the contract this one replaces.
438
+ // Walking past self is the whole question: `contractDeclarer` would answer
439
+ // "this one", which is what nothing checks.
440
+ const ancestor = ancestorChain(def, resolveDef).find((a) => {
441
+ const inherited = (a as unknown as Record<string, unknown>)[direction];
442
+ return inherited !== undefined && inherited !== null;
443
+ });
444
+ if (!ancestor || ancestor.kind !== "Telo.Abstract") continue;
445
+ const inherited = (ancestor as unknown as Record<string, unknown>)[direction];
446
+
447
+ // The WHOLE manifest set, which is what `analyzerContractScope`'s
448
+ // `typeManifestsFor` hands `resolveContract` — so this resolves a named type
449
+ // exactly as the resolver the kernel binds with does. Filtering to the
450
+ // declaring module looked more careful and was strictly worse: a contract
451
+ // naming a shape from a shared module resolved to nothing, and an
452
+ // unresolvable side is skipped, so the check silently switched itself off for
453
+ // precisely the libraries that factor their shapes out. Nothing else caught
454
+ // it either — `CONTRACT_TYPE_NOT_FOUND` finds the type through its own global
455
+ // fallback, so such a kind passed `telo check` with no diagnostic at all.
456
+ const ownSchema = resolveTypeFieldToSchema(own, manifests);
457
+ const ancestorSchema = resolveTypeFieldToSchema(inherited, manifests);
458
+ // An unresolvable side says nothing about compatibility; CONTRACT_TYPE_NOT_FOUND
459
+ // is what reports a contract that names a type nothing declares.
460
+ if (!ownSchema || !ancestorSchema) continue;
461
+
462
+ const { compatible, issues } =
463
+ direction === "outputType"
464
+ ? checkSchemaCompatibility(ownSchema, ancestorSchema)
465
+ : checkSchemaCompatibility(ancestorSchema, ownSchema);
466
+ if (compatible) continue;
467
+
468
+ const ancestorName = `${(ancestor.metadata as { module?: string } | undefined)?.module ?? ""}.${
469
+ ancestor.metadata?.name ?? "?"
470
+ }`.replace(/^\./, "");
471
+ const why =
472
+ direction === "outputType"
473
+ ? `every caller that holds this through '${ancestorName}' reads the shape that kind declares`
474
+ : `a caller that holds this through '${ancestorName}' sends the shape that kind declares`;
475
+ diagnostics.push({
476
+ severity: DiagnosticSeverity.Error,
477
+ code: "CONTRACT_NOT_SUBSTITUTABLE",
478
+ source: SOURCE,
479
+ message:
480
+ `${m.kind}/${resource.name}: \`${direction}\` replaces the one declared by '${ancestorName}' ` +
481
+ `with a shape that cannot stand in for it — ${issues.join("; ")}. Contracts replace rather ` +
482
+ `than merge, so nothing re-checks this at dispatch: ${why}. Restate the fields ` +
483
+ `'${ancestorName}' declares, or drop \`${direction}\` to inherit the contract unchanged.`,
484
+ data: { resource, filePath, path: direction },
485
+ });
486
+ }
487
+ }
488
+
363
489
  /** A child that inherits its controller and REPLACES a contract must bridge it:
364
490
  * contracts resolve to the nearest declaration and never merge, so the
365
491
  * inherited controller only understands its own shape. Without the mapping the
@@ -3,6 +3,7 @@ import type { AliasResolver } from "./alias-resolver.js";
3
3
  import type { DefinitionRegistry } from "./definition-registry.js";
4
4
  import { parseRedactionPath, RedactionPathError } from "./redaction-path.js";
5
5
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
6
+ import { moduleAliasScope } from "./module-alias-scope.js";
6
7
 
7
8
  const SOURCE = "telo-analyzer";
8
9
 
@@ -124,9 +125,7 @@ function isSinkKind(
124
125
  ): boolean {
125
126
  if (typeof manifest.kind !== "string") return false;
126
127
  if (manifest.kind === "Telo.ConsoleSink" || manifest.kind === "Telo.FileSink") return true;
127
- const ownModule = (manifest.metadata as { module?: string } | undefined)?.module;
128
- const resolver =
129
- (ownModule ? aliasesByModule?.get(ownModule) : undefined) ?? aliases;
128
+ const resolver = moduleAliasScope(manifest.metadata, aliases, aliasesByModule);
130
129
  const canonical = resolver.resolveKind(manifest.kind) ?? manifest.kind;
131
130
  return registry.resolve(canonical)?.capability === "Telo.Sink";
132
131
  }
@@ -14,6 +14,8 @@ import { resolveTypeFieldToSchema } from "./validate-cel-context.js";
14
14
  import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisContext } from "./types.js";
15
15
  import type { AliasResolver } from "./alias-resolver.js";
16
16
  import type { DefinitionRegistry } from "./definition-registry.js";
17
+ import { moduleAliasScope } from "./module-alias-scope.js";
18
+ import { isInjectedDeclaration } from "./resource-input.js";
17
19
 
18
20
  const SOURCE = "telo-analyzer";
19
21
 
@@ -27,20 +29,31 @@ const SOURCE = "telo-analyzer";
27
29
  *
28
30
  * A value satisfies the slot when it transitively extends the target kind, or —
29
31
  * for a CONCRETE target — IS that kind; `getByExtends` is the same transitive
30
- * subtype index for both, and an abstract is satisfied only by an implementer,
31
- * never by the abstract itself (which is non-instantiable). Accepts a constraint
32
- * that resolves to nothing, and an abstract with no loaded implementations:
33
- * partial context, where a rejection would be a guess.
32
+ * subtype index for both. Accepts a constraint that resolves to nothing, and an
33
+ * abstract with no loaded implementations: partial context, where a rejection
34
+ * would be a guess.
35
+ *
36
+ * An abstract is satisfied only by an implementer, never by the abstract itself,
37
+ * because a resource declared `kind: <some abstract>` is refused at `create()`
38
+ * and reported as `ABSTRACT_KIND_INSTANTIATED` at its declaration — so accepting
39
+ * it here would leave the reference sites silent about a manifest that cannot
40
+ * run. `isDeclaration` is the ONE exception and it is not an instance: a
41
+ * library's `resources:` entry is constrained by kind alone, so its kind-only
42
+ * stand-in routinely IS an abstract (`connection: {kind: Sql.Connection}`), and
43
+ * every use of that name inside the library is a reference to it.
34
44
  */
35
45
  export function kindSatisfies(
36
46
  resolved: string,
37
47
  targetKind: string,
38
48
  registry: DefinitionRegistry,
49
+ /** The referent is a kind-only stand-in for a `resources:` entry rather than a
50
+ * resource anything instantiates, so identity satisfies an abstract slot. */
51
+ isDeclaration = false,
39
52
  ): boolean {
40
53
  const canonical = registry.resolveRef(targetKind) ?? targetKind;
41
54
  const targetDef = registry.resolve(canonical);
42
55
  if (!targetDef) return true;
43
- if (targetDef.kind !== "Telo.Abstract" && resolved === canonical) return true;
56
+ if ((targetDef.kind !== "Telo.Abstract" || isDeclaration) && resolved === canonical) return true;
44
57
  const subtypes = registry.getByExtends(canonical);
45
58
  if (subtypes.some((d) => `${d.metadata.module}.${d.metadata.name}` === resolved)) return true;
46
59
  // Leniency is about the CANDIDATE, not about the population, and it is the
@@ -70,6 +83,8 @@ function checkKind(
70
83
  entry: RefFieldEntry,
71
84
  registry: DefinitionRegistry,
72
85
  aliases: AliasResolver,
86
+ /** See {@link kindSatisfies}: the referent is a `resources:` stand-in. */
87
+ isDeclaration = false,
73
88
  ): string[] {
74
89
  const resolved = aliases.resolveKind(kind) ?? kind;
75
90
  // A qualified kind whose prefix names no import in this scope is a bad NAME
@@ -92,7 +107,7 @@ function checkKind(
92
107
  if (!targetKind) return [];
93
108
  const targetDef = registry.resolve(targetKind);
94
109
  if (!targetDef) return [];
95
- if (!unknownAlias && kindSatisfies(resolved, targetKind, registry)) return [];
110
+ if (!unknownAlias && kindSatisfies(resolved, targetKind, registry, isDeclaration)) return [];
96
111
  const subtypes = registry.getByExtends(targetKind);
97
112
  const subtypeKinds = new Set(subtypes.map((d) => `${d.metadata.module}.${d.metadata.name}`));
98
113
  if (targetDef.kind === "Telo.Abstract") {
@@ -335,7 +350,13 @@ export function validateReferences(
335
350
  });
336
351
  return;
337
352
  }
338
- const kindErrors = checkKind(target.kind as string, entry, registry, aliases);
353
+ const kindErrors = checkKind(
354
+ target.kind as string,
355
+ entry,
356
+ registry,
357
+ aliases,
358
+ isInjectedDeclaration(target),
359
+ );
339
360
  if (kindErrors.length > 0) {
340
361
  diagnostics.push({
341
362
  severity: DiagnosticSeverity.Error,
@@ -385,7 +406,18 @@ export function validateReferences(
385
406
  }
386
407
 
387
408
  // 2. Kind check
388
- const kindErrors = checkKind(refVal.kind, entry, registry, aliases);
409
+ const objectTarget =
410
+ typeof refVal.name === "string"
411
+ ? (visibleScopeManifests.find((m) => m.metadata?.name === refVal.name) ??
412
+ byName.get(refVal.name))
413
+ : undefined;
414
+ const kindErrors = checkKind(
415
+ refVal.kind,
416
+ entry,
417
+ registry,
418
+ aliases,
419
+ isInjectedDeclaration(objectTarget),
420
+ );
389
421
  if (kindErrors.length > 0) {
390
422
  diagnostics.push({
391
423
  severity: DiagnosticSeverity.Error,
@@ -484,9 +516,7 @@ export function validateReferences(
484
516
  const resolvedResourceKind = aliases.resolveKind(r.kind) ?? r.kind;
485
517
  const resourceDef =
486
518
  registry.resolve(r.kind) ?? registry.resolve(resolvedResourceKind);
487
- const owningModule = (resourceDef?.metadata as { module?: string } | undefined)?.module;
488
- const ownerScope =
489
- (owningModule ? aliasesByModule?.get(owningModule) : undefined) ?? aliases;
519
+ const ownerScope = moduleAliasScope(resourceDef?.metadata, aliases, aliasesByModule);
490
520
 
491
521
  const targetKind = ownerScope.resolveKind(anchorName);
492
522
  if (!targetKind) {
@@ -3,7 +3,11 @@ import { isRefSentinel } from "@telorun/templating";
3
3
  import type { AliasResolver } from "./alias-resolver.js";
4
4
  import type { DefinitionRegistry } from "./definition-registry.js";
5
5
  import { findDynamicLeaf } from "./resource-rule.js";
6
- import { readResourceInputs, readSuppliedResources } from "./resource-input.js";
6
+ import {
7
+ isInjectedDeclaration,
8
+ readResourceInputs,
9
+ readSuppliedResources,
10
+ } from "./resource-input.js";
7
11
  import { type AnalysisDiagnostic, DiagnosticSeverity } from "./types.js";
8
12
 
9
13
  const SOURCE = "telo-analyzer";
@@ -38,6 +42,9 @@ type RequiredResources = Record<string, string>;
38
42
  *
39
43
  * Browser-safe.
40
44
  */
45
+ const declaringModule = (m: ResourceManifest): string | undefined =>
46
+ (m.metadata as { module?: string } | undefined)?.module;
47
+
41
48
  export function validateResourceInputs(
42
49
  manifests: ResourceManifest[],
43
50
  registry: DefinitionRegistry,
@@ -46,7 +53,11 @@ export function validateResourceInputs(
46
53
  /** Kind acceptance, transitively — the `checkKind` rule `validate-references`
47
54
  * applies at an ordinary ref slot, passed in rather than re-derived so the
48
55
  * two cannot disagree about what satisfies a constraint. */
49
- acceptsKind: (suppliedKind: string, requiredKind: string) => boolean,
56
+ acceptsKind: (
57
+ suppliedKind: string,
58
+ requiredKind: string,
59
+ suppliedIsDeclaration: boolean,
60
+ ) => boolean,
50
61
  ): AnalysisDiagnostic[] {
51
62
  const out: AnalysisDiagnostic[] = [];
52
63
 
@@ -220,7 +231,19 @@ export function validateResourceInputs(
220
231
  const suppliedKind = (value as { kind?: unknown }).kind;
221
232
  if (typeof suppliedKind !== "string") continue;
222
233
  const canonical = aliases.resolveKind(suppliedKind) ?? suppliedKind;
223
- if (acceptsKind(canonical, entry)) continue;
234
+ // A library forwarding an input it was itself handed supplies a kind-only
235
+ // stand-in, whose kind is routinely the abstract the target declares. That
236
+ // is identity against an abstract, which an ordinary reference is refused
237
+ // — the referent there would be a resource nothing can instantiate, while
238
+ // here it is another declaration.
239
+ const suppliedName = (value as { name?: unknown }).name;
240
+ const supplied =
241
+ typeof suppliedName === "string"
242
+ ? manifests.find(
243
+ (c) => c.metadata?.name === suppliedName && isOwn(declaringModule(c)),
244
+ )
245
+ : undefined;
246
+ if (acceptsKind(canonical, entry, isInjectedDeclaration(supplied))) continue;
224
247
  out.push({
225
248
  severity: DiagnosticSeverity.Error,
226
249
  code: "RESOURCE_INPUT_KIND_MISMATCH",