@telorun/analyzer 0.73.0 → 0.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +26 -7
  3. package/dist/artifact-axes.d.ts +14 -0
  4. package/dist/artifact-axes.d.ts.map +1 -0
  5. package/dist/artifact-axes.js +12 -0
  6. package/dist/artifact-layer-index.d.ts +2 -2
  7. package/dist/artifact-layer-index.d.ts.map +1 -1
  8. package/dist/artifact-layer-index.js +9 -4
  9. package/dist/artifact-selector.d.ts +47 -21
  10. package/dist/artifact-selector.d.ts.map +1 -1
  11. package/dist/artifact-selector.js +85 -16
  12. package/dist/builtins.d.ts.map +1 -1
  13. package/dist/builtins.js +73 -16
  14. package/dist/call-graph.d.ts +12 -1
  15. package/dist/call-graph.d.ts.map +1 -1
  16. package/dist/call-graph.js +22 -0
  17. package/dist/catch-scope.js +1 -1
  18. package/dist/forwarded-declaration.d.ts +26 -0
  19. package/dist/forwarded-declaration.d.ts.map +1 -0
  20. package/dist/forwarded-declaration.js +31 -0
  21. package/dist/index.d.ts +9 -3
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -1
  24. package/dist/kernel-globals.d.ts.map +1 -1
  25. package/dist/kernel-globals.js +21 -5
  26. package/dist/module-alias-scope.d.ts +11 -0
  27. package/dist/module-alias-scope.d.ts.map +1 -1
  28. package/dist/module-alias-scope.js +10 -0
  29. package/dist/module-file-claims.d.ts +17 -1
  30. package/dist/module-file-claims.d.ts.map +1 -1
  31. package/dist/module-file-claims.js +38 -3
  32. package/dist/module-named-files.d.ts +72 -0
  33. package/dist/module-named-files.d.ts.map +1 -0
  34. package/dist/module-named-files.js +115 -0
  35. package/dist/native-entries.d.ts +83 -0
  36. package/dist/native-entries.d.ts.map +1 -0
  37. package/dist/native-entries.js +169 -0
  38. package/dist/normalize-inline-resources.d.ts +41 -13
  39. package/dist/normalize-inline-resources.d.ts.map +1 -1
  40. package/dist/normalize-inline-resources.js +236 -54
  41. package/dist/reference-field-map.d.ts +59 -1
  42. package/dist/reference-field-map.d.ts.map +1 -1
  43. package/dist/reference-field-map.js +200 -34
  44. package/dist/release/payload-digest.d.ts +2 -1
  45. package/dist/release/payload-digest.d.ts.map +1 -1
  46. package/dist/release/payload-digest.js +2 -1
  47. package/dist/resolve-ref-sentinels.d.ts.map +1 -1
  48. package/dist/resolve-ref-sentinels.js +8 -5
  49. package/dist/resolve-throws-union.d.ts +25 -4
  50. package/dist/resolve-throws-union.d.ts.map +1 -1
  51. package/dist/resolve-throws-union.js +62 -32
  52. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  53. package/dist/resolve-zone-requirements.js +2 -1
  54. package/dist/rewrite-synthetic-origins.d.ts +6 -1
  55. package/dist/rewrite-synthetic-origins.d.ts.map +1 -1
  56. package/dist/rewrite-synthetic-origins.js +107 -13
  57. package/dist/schema-compat.d.ts.map +1 -1
  58. package/dist/schema-compat.js +18 -5
  59. package/dist/schema-walk.d.ts +47 -13
  60. package/dist/schema-walk.d.ts.map +1 -1
  61. package/dist/schema-walk.js +150 -30
  62. package/dist/scope-declarations.d.ts +59 -0
  63. package/dist/scope-declarations.d.ts.map +1 -0
  64. package/dist/scope-declarations.js +45 -0
  65. package/dist/source-entries.d.ts +119 -0
  66. package/dist/source-entries.d.ts.map +1 -0
  67. package/dist/source-entries.js +349 -0
  68. package/dist/step-bodies.d.ts +24 -0
  69. package/dist/step-bodies.d.ts.map +1 -0
  70. package/dist/step-bodies.js +28 -0
  71. package/dist/telo-version.d.ts +1 -1
  72. package/dist/telo-version.js +1 -1
  73. package/dist/validate-cel-context.d.ts.map +1 -1
  74. package/dist/validate-cel-context.js +3 -2
  75. package/dist/validate-module-artifact.d.ts +4 -2
  76. package/dist/validate-module-artifact.d.ts.map +1 -1
  77. package/dist/validate-module-artifact.js +43 -4
  78. package/dist/validate-native-entries.d.ts +15 -0
  79. package/dist/validate-native-entries.d.ts.map +1 -0
  80. package/dist/validate-native-entries.js +103 -0
  81. package/dist/validate-observed-state.d.ts +7 -1
  82. package/dist/validate-observed-state.d.ts.map +1 -1
  83. package/dist/validate-observed-state.js +24 -6
  84. package/dist/validate-reference-forms.d.ts.map +1 -1
  85. package/dist/validate-reference-forms.js +2 -2
  86. package/dist/validate-references.d.ts.map +1 -1
  87. package/dist/validate-references.js +11 -5
  88. package/dist/validate-scope-reach.d.ts +31 -0
  89. package/dist/validate-scope-reach.d.ts.map +1 -0
  90. package/dist/validate-scope-reach.js +180 -0
  91. package/dist/validate-source-entries.d.ts +16 -0
  92. package/dist/validate-source-entries.d.ts.map +1 -0
  93. package/dist/validate-source-entries.js +90 -0
  94. package/dist/validate-throws-coverage.d.ts.map +1 -1
  95. package/dist/validate-throws-coverage.js +21 -39
  96. package/dist/value-type-keyword.d.ts +22 -5
  97. package/dist/value-type-keyword.d.ts.map +1 -1
  98. package/dist/value-type-keyword.js +16 -7
  99. package/package.json +3 -3
  100. package/src/analyzer.ts +33 -9
  101. package/src/artifact-axes.ts +24 -0
  102. package/src/artifact-layer-index.ts +9 -6
  103. package/src/artifact-selector.ts +110 -35
  104. package/src/builtins.ts +75 -16
  105. package/src/call-graph.ts +30 -0
  106. package/src/catch-scope.ts +1 -1
  107. package/src/forwarded-declaration.ts +32 -0
  108. package/src/index.ts +36 -1
  109. package/src/kernel-globals.ts +28 -5
  110. package/src/migrations/CLAUDE.md +35 -0
  111. package/src/module-alias-scope.ts +17 -0
  112. package/src/module-file-claims.ts +63 -4
  113. package/src/module-named-files.ts +162 -0
  114. package/src/native-entries.ts +227 -0
  115. package/src/normalize-inline-resources.ts +326 -70
  116. package/src/reference-field-map.ts +257 -52
  117. package/src/release/CLAUDE.md +32 -0
  118. package/src/release/payload-digest.ts +2 -1
  119. package/src/resolve-ref-sentinels.ts +7 -6
  120. package/src/resolve-throws-union.ts +87 -32
  121. package/src/resolve-zone-requirements.ts +2 -1
  122. package/src/rewrite-synthetic-origins.ts +107 -16
  123. package/src/schema-compat.ts +18 -4
  124. package/src/schema-walk.ts +162 -34
  125. package/src/scope-declarations.ts +98 -0
  126. package/src/source-entries.ts +502 -0
  127. package/src/step-bodies.ts +55 -0
  128. package/src/telo-version.ts +1 -1
  129. package/src/validate-cel-context.ts +3 -2
  130. package/src/validate-module-artifact.ts +46 -3
  131. package/src/validate-native-entries.ts +147 -0
  132. package/src/validate-observed-state.ts +36 -7
  133. package/src/validate-reference-forms.ts +2 -3
  134. package/src/validate-references.ts +10 -6
  135. package/src/validate-scope-reach.ts +213 -0
  136. package/src/validate-source-entries.ts +120 -0
  137. package/src/validate-throws-coverage.ts +24 -31
  138. package/src/value-type-keyword.ts +35 -9
@@ -4,6 +4,7 @@ import { parseLayerIndex, LayerIndexError } from "./artifact-layer-index.js";
4
4
  import {
5
5
  ArtifactSelectorError,
6
6
  PLATFORM_AXES,
7
+ selectorContradictions,
7
8
  selectorFromQualifiers,
8
9
  selectorKey,
9
10
  } from "./artifact-selector.js";
@@ -26,10 +27,12 @@ const SOURCE = "telo-analyzer";
26
27
  * selector (spec §1), which is what every module with two `js` controllers relies
27
28
  * on.
28
29
  *
29
- * 1. **Controller selector qualifiers.** `os` / `arch` / `libc` / `siblings` are
30
+ * 1. **Controller selector qualifiers.** The platform axes and `siblings` are
30
31
  * authored surface. An unknown qualifier is reported rather than ignored, since
31
32
  * ignoring is what makes a typo invisible; an invalid value is reported here
32
- * instead of throwing from the loader later.
33
+ * instead of throwing from the loader later, and so is a combination no host
34
+ * can match (`selectorContradictions`, shared with `native:` and
35
+ * `exports.code:`).
33
36
  * 2. **The published layer index.** The owner doc's JSON Schema covers shape; the
34
37
  * semantic rules — controller-requires-selector, singletons carry none, no
35
38
  * duplicate selector, the token grammar (`os: Linux` passes the schema and
@@ -79,6 +82,16 @@ function validateLibraryCandidates(manifest: ResourceManifest, out: AnalysisDiag
79
82
  // mean a consumer's import resolves by whichever candidate is read first.
80
83
  const seen = new Map<string, LibraryCandidate>();
81
84
  for (const candidate of candidates) {
85
+ for (const contradiction of selectorContradictions(candidate.selector)) {
86
+ out.push({
87
+ severity: DiagnosticSeverity.Error,
88
+ code: `LIBRARY_${contradiction.rule}`,
89
+ source: SOURCE,
90
+ message:
91
+ `Telo.Library/${metadata?.name ?? "(unnamed)"}: ${candidate.origin}: ${contradiction.detail}`,
92
+ data: { resource, filePath: metadata?.source, path: "exports/code" },
93
+ });
94
+ }
82
95
  const key = selectorKey(candidate.selector);
83
96
  const first = seen.get(key);
84
97
  if (first) {
@@ -162,7 +175,37 @@ function validateControllerSelectors(
162
175
  // Validate the selector; the value is not otherwise needed here, since
163
176
  // candidates sharing a selector legitimately share a layer.
164
177
  try {
165
- selectorFromQualifiers(parsed.format, parsed.qualifiers, candidate);
178
+ const selector = selectorFromQualifiers(parsed.format, parsed.qualifiers, candidate);
179
+ for (const contradiction of selectorContradictions(selector)) {
180
+ out.push({
181
+ severity: DiagnosticSeverity.Error,
182
+ code: `CONTROLLER_${contradiction.rule}`,
183
+ source: SOURCE,
184
+ message:
185
+ `${manifest.kind}/${name ?? "(unnamed)"}: controller candidate "${candidate}": ` +
186
+ contradiction.detail,
187
+ data: { resource, filePath, path: `${at}?${contradiction.axis}` },
188
+ });
189
+ }
190
+ // A `dylib` opens over the Rust controller ABI, and a library built
191
+ // against another version of it must never match — so the candidate states
192
+ // which, in the `telo` family.
193
+ if (parsed.format === "dylib" && !selector.abi?.startsWith("telo-")) {
194
+ out.push({
195
+ severity: DiagnosticSeverity.Error,
196
+ code: "CONTROLLER_DYLIB_ABI_MISSING",
197
+ source: SOURCE,
198
+ message:
199
+ `${manifest.kind}/${name ?? "(unnamed)"}: dylib candidate "${candidate}" ` +
200
+ (selector.abi === undefined
201
+ ? `states no abi. `
202
+ : `states abi=${selector.abi}, which is not a Rust controller ABI. `) +
203
+ `A dylib is built against one version of the Rust controller ABI and must say which — ` +
204
+ `add abi=telo-<version>, e.g. abi=telo-2. Without it the Rust kernel downloads the ` +
205
+ `library on every host and refuses it only when it opens it.`,
206
+ data: { resource, filePath, path: at },
207
+ });
208
+ }
166
209
  } catch (err) {
167
210
  if (!(err instanceof ArtifactSelectorError)) throw err;
168
211
  out.push({
@@ -0,0 +1,147 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+
3
+ import { describeSelector, selectorContradictions, selectorKey } from "./artifact-selector.js";
4
+ import { moduleDocumentClaims } from "./module-file-claims.js";
5
+ import { describeClaim, nativeClaimConflicts } from "./module-named-files.js";
6
+ import { readNativeEntries, type NativeEntry } from "./native-entries.js";
7
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
8
+
9
+ const SOURCE = "telo-analyzer";
10
+
11
+ /**
12
+ * The strict half of `native-entries.ts`: what `telo check` reports about a
13
+ * module doc's `native:` block.
14
+ *
15
+ * Every rule is decidable from the manifest and would otherwise surface only on
16
+ * a consumer's host, as a native file that never matches or a layer that
17
+ * overwrites another's file. Shape errors (a missing key, an unknown one) are
18
+ * left to the owner doc's JSON Schema, which reports them in the same pass.
19
+ *
20
+ * Entry-module-scoped: a dependency's block is not the consumer's to fix.
21
+ */
22
+ export function validateNativeEntries(
23
+ manifests: ResourceManifest[],
24
+ entryModules?: ReadonlySet<string>,
25
+ ): AnalysisDiagnostic[] {
26
+ const out: AnalysisDiagnostic[] = [];
27
+ for (const manifest of manifests) {
28
+ if (manifest.kind !== "Telo.Application" && manifest.kind !== "Telo.Library") continue;
29
+ const metadata = (manifest.metadata ?? {}) as Record<string, unknown>;
30
+ const name = typeof metadata.name === "string" ? metadata.name : undefined;
31
+ const owned =
32
+ entryModules === undefined ||
33
+ (typeof metadata.module === "string"
34
+ ? entryModules.has(metadata.module)
35
+ : name === undefined || entryModules.has(name));
36
+ if (!owned) continue;
37
+
38
+ const label = `${manifest.kind}/${name ?? "(unnamed)"}`;
39
+ const report = (code: string, path: string, message: string) =>
40
+ out.push({
41
+ severity: DiagnosticSeverity.Error,
42
+ code,
43
+ source: SOURCE,
44
+ message: `${label}: ${message}`,
45
+ data: {
46
+ resource: { kind: manifest.kind, name },
47
+ filePath: typeof metadata.source === "string" ? metadata.source : undefined,
48
+ path,
49
+ },
50
+ });
51
+
52
+ const { entries, problems } = readNativeEntries(manifest);
53
+ for (const problem of problems) {
54
+ if (problem.kind === "shape") continue;
55
+ report(
56
+ problem.kind === "escape" ? "NATIVE_PATH_ESCAPES_MODULE" : "NATIVE_ENTRY_INVALID",
57
+ problem.path,
58
+ problem.message,
59
+ );
60
+ }
61
+ if (entries.length === 0) continue;
62
+
63
+ const moduleName = typeof metadata.module === "string" ? metadata.module : name;
64
+ for (const { entry, claim } of nativeClaimConflicts(
65
+ entries,
66
+ moduleDocumentClaims(manifests, moduleName),
67
+ )) {
68
+ report(
69
+ "NATIVE_PATH_CLAIMED",
70
+ `native[${entry.index}].path`,
71
+ `${entry.origin}: path '${entry.path}' is also named by ${describeClaim(claim)}. A native ` +
72
+ `file ships only in its platform's native layer and a file extracts from exactly one ` +
73
+ `layer, so publish refuses it — give the native entry its own file, or drop the other ` +
74
+ `declaration.`,
75
+ );
76
+ }
77
+
78
+ const bySelector = new Map<string, NativeEntry>();
79
+ const byPath = new Map<string, NativeEntry>();
80
+ for (const entry of entries) {
81
+ const at = `native[${entry.index}]`;
82
+ if (entry.selector.format === "node" && entry.selector.abi === undefined) {
83
+ report(
84
+ "NATIVE_NODE_ABI_MISSING",
85
+ at,
86
+ `${entry.origin}: a 'node' addon is built against one Node ABI and must state it — ` +
87
+ `add abi: node-<NODE_MODULE_VERSION>, e.g. 'node-137'. Without it the file matches ` +
88
+ `every Node release and fails to load on all but one.`,
89
+ );
90
+ }
91
+ for (const contradiction of selectorContradictions(entry.selector)) {
92
+ report(
93
+ `NATIVE_${contradiction.rule}`,
94
+ `${at}.${contradiction.axis}`,
95
+ `${entry.origin}: ${contradiction.detail}`,
96
+ );
97
+ }
98
+
99
+ const key = `${entry.name}\0${selectorKey(entry.selector)}`;
100
+ const twin = bySelector.get(key);
101
+ if (twin) {
102
+ report(
103
+ "NATIVE_ENTRY_DUPLICATE",
104
+ at,
105
+ `${entry.origin}: ${twin.origin} already declares '${entry.name}' for ` +
106
+ `${describeSelector(entry.selector)}. A name has one entry per platform tuple, or ` +
107
+ `a lookup by name could resolve to either file.`,
108
+ );
109
+ } else {
110
+ bySelector.set(key, entry);
111
+ }
112
+
113
+ const sharer = byPath.get(entry.path);
114
+ if (sharer && selectorKey(sharer.selector) !== selectorKey(entry.selector)) {
115
+ report(
116
+ "NATIVE_PATH_SHARED",
117
+ `${at}.path`,
118
+ `${entry.origin}: path '${entry.path}' is also declared by ${sharer.origin} for ` +
119
+ `${describeSelector(sharer.selector)}. Every layer of a module extracts into one ` +
120
+ `directory, so each platform tuple needs its own path — e.g. ` +
121
+ `'native/<os>-<arch>/…'.`,
122
+ );
123
+ } else if (!sharer) {
124
+ byPath.set(entry.path, entry);
125
+ }
126
+
127
+ const through = [...byPath.values()].find(
128
+ (other) =>
129
+ other !== entry &&
130
+ (entry.path.startsWith(`${other.path}/`) || other.path.startsWith(`${entry.path}/`)),
131
+ );
132
+ if (through) {
133
+ const relation = entry.path.startsWith(`${through.path}/`)
134
+ ? `runs through '${through.path}' as a directory`
135
+ : `is a directory that '${through.path}' runs through`;
136
+ report(
137
+ "NATIVE_PATH_NESTED",
138
+ `${at}.path`,
139
+ `${entry.origin}: path '${entry.path}' ${relation}, and ${through.origin} declares ` +
140
+ `'${through.path}'. Every layer of a module extracts into one directory, so a path ` +
141
+ `cannot be both a file and a directory — give each entry its own path.`,
142
+ );
143
+ }
144
+ }
145
+ }
146
+ return out;
147
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import { OBSERVED_STATE_KEY } from "@telorun/sdk";
3
3
  import { effectiveStatusSchema } from "./extends-resolution.js";
4
+ import { isForwardedDeclaration } from "./forwarded-declaration.js";
4
5
  import { parseExportEntry } from "./flatten-for-analyzer.js";
5
6
  import {
6
7
  moduleScopedDefResolver,
@@ -129,11 +130,20 @@ export function collectRunReachableNames(graph: CallGraph): Set<string> {
129
130
 
130
131
  /** What a resource name resolves to for CEL purposes. `status` is present only
131
132
  * when the kind declares one; `scoped` marks a resource declared inside an
132
- * `x-telo-scope` slot, which resolves only within that scope's regions. */
133
+ * `x-telo-scope` slot, which resolves only within that scope's regions.
134
+ * `forwardedFrom` marks a dependency's declaration, keyed by
135
+ * `forwardedResourceKey`: it is in no consumer's `resources`, but a read inside
136
+ * that dependency's own manifests still resolves to it. */
133
137
  export interface AnalyzedResource {
134
138
  kind: string;
135
139
  status?: Record<string, any>;
136
140
  scoped?: boolean;
141
+ forwardedFrom?: string;
142
+ }
143
+
144
+ /** The index key of a name a dependency declares, as read from inside it. */
145
+ export function forwardedResourceKey(module: string, name: string): string {
146
+ return `${module}\0${name}`;
137
147
  }
138
148
 
139
149
  /**
@@ -157,16 +167,35 @@ export function buildObservedStateIndex(
157
167
  /** `module` is the resource's DECLARING module: an exported instance is
158
168
  * written with that library's aliases (`kind: Self.Listener`), which the
159
169
  * consumer's table cannot resolve. */
160
- const record = (kind: string, key: string, scoped: boolean, module?: string): void => {
170
+ const record = (
171
+ kind: string,
172
+ key: string,
173
+ scoped: boolean,
174
+ module?: string,
175
+ forwardedFrom?: string,
176
+ ): void => {
161
177
  const status = effectiveStatusSchema(resolve.in(kind, module), resolve);
162
- out.set(key, { kind, ...(status ? { status } : {}), ...(scoped ? { scoped } : {}) });
178
+ out.set(key, {
179
+ kind,
180
+ ...(status ? { status } : {}),
181
+ ...(scoped ? { scoped } : {}),
182
+ ...(forwardedFrom !== undefined ? { forwardedFrom } : {}),
183
+ });
163
184
  };
164
185
 
165
186
  for (const manifest of manifests) {
166
187
  const kind = manifest.kind as string | undefined;
167
188
  const name = manifest.metadata?.name as string | undefined;
168
189
  if (!kind || SYSTEM_KINDS.has(kind)) continue;
169
- if (name) record(kind, name, false, manifest.metadata?.module as string | undefined);
190
+ const module = manifest.metadata?.module as string | undefined;
191
+ // A dependency's declarations publish into ITS module's `resources`, not this
192
+ // one's: they are keyed by that module, and an export is also indexed under
193
+ // `<Alias>.<name>` below.
194
+ const forwardedFrom = isForwardedDeclaration(manifest) ? module : undefined;
195
+ if (forwardedFrom === undefined && isForwardedDeclaration(manifest)) continue;
196
+ const keyOf = (n: string) =>
197
+ forwardedFrom === undefined ? n : forwardedResourceKey(forwardedFrom, n);
198
+ if (name) record(kind, keyOf(name), false, module, forwardedFrom);
170
199
 
171
200
  const schema = resolve(kind)?.schema as Record<string, any> | undefined;
172
201
  if (!schema) continue;
@@ -177,7 +206,7 @@ export function buildObservedStateIndex(
177
206
  const scopedKind = (scopedEntry as ResourceManifest)?.kind;
178
207
  const scopedName = (scopedEntry as ResourceManifest)?.metadata?.name;
179
208
  if (typeof scopedKind === "string" && typeof scopedName === "string") {
180
- record(scopedKind, scopedName, true);
209
+ record(scopedKind, keyOf(scopedName), true, forwardedFrom, forwardedFrom);
181
210
  }
182
211
  }
183
212
  }
@@ -254,8 +283,8 @@ export function buildObservedStateResourcesSchema(
254
283
  open: boolean,
255
284
  ): Record<string, any> {
256
285
  const properties: Record<string, any> = {};
257
- for (const [key, { status }] of index) {
258
- if (!status) continue;
286
+ for (const [key, { status, forwardedFrom }] of index) {
287
+ if (!status || forwardedFrom !== undefined) continue;
259
288
  applyObservedStateNode(properties, key, status);
260
289
  }
261
290
  return open
@@ -2,6 +2,7 @@ import type { ResourceManifest } from "@telorun/sdk";
2
2
  import { isTaggedSentinel } from "@telorun/templating";
3
3
  import type { AliasResolver } from "./alias-resolver.js";
4
4
  import type { DefinitionRegistry } from "./definition-registry.js";
5
+ import { isForwardedDeclaration } from "./forwarded-declaration.js";
5
6
  import { visitManifest } from "./manifest-visitor.js";
6
7
  import { satisfiesValueBranch } from "./reference-field-map.js";
7
8
  import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
@@ -50,9 +51,7 @@ export function validateReferenceForms(
50
51
  if (!aliases) return [];
51
52
  const diagnostics: AnalysisDiagnostic[] = [];
52
53
 
53
- const isForeign = (r: ResourceManifest): boolean =>
54
- (r.metadata as { forwardedExport?: boolean } | undefined)?.forwardedExport === true;
55
- const localResources = resources.filter((r) => !isForeign(r));
54
+ const localResources = resources.filter((r) => !isForwardedDeclaration(r));
56
55
 
57
56
  visitManifest(
58
57
  localResources,
@@ -17,6 +17,8 @@ import type { DefinitionRegistry } from "./definition-registry.js";
17
17
  import { moduleAliasScope } from "./module-alias-scope.js";
18
18
  import { isModuleKind } from "./module-kinds.js";
19
19
  import { isInjectedDeclaration } from "./resource-input.js";
20
+ import { outsideScopesOf } from "./scope-declarations.js";
21
+ import { isForwardedDeclaration, isForwardedExport } from "./forwarded-declaration.js";
20
22
 
21
23
  const SOURCE = "telo-analyzer";
22
24
 
@@ -308,11 +310,10 @@ export function validateReferences(
308
310
  // duplicate detection and local name resolution, and never walked as ref sources.
309
311
  const moduleOf = (r: ResourceManifest): string | undefined =>
310
312
  (r.metadata as { module?: string } | undefined)?.module;
311
- // Forwarded exports are flagged by flattenForAnalyzer (`metadata.forwardedExport`); they're
312
- // cross-module resolution targets only — excluded from duplicate detection and local name
313
- // resolution, and never walked as ref sources.
314
- const isForeign = (r: ResourceManifest): boolean =>
315
- (r.metadata as { forwardedExport?: boolean } | undefined)?.forwardedExport === true;
313
+ // A dependency's code a forwarded export, or what extraction pulled out of one — is
314
+ // excluded from duplicate detection and local name resolution, and never walked as a
315
+ // ref source. Only an EXPORT is a cross-module resolution target.
316
+ const isForeign = isForwardedDeclaration;
316
317
  // Forwarded exported instances keyed `${module}\0${name}` — the lookup that resolves
317
318
  // whether a cross-module `!ref Alias.name` names a real exported instance.
318
319
  const byModuleName = new Map<string, ResourceManifest>();
@@ -329,7 +330,7 @@ export function validateReferences(
329
330
  if (typeof m === "string") loadedModules.add(m);
330
331
  continue;
331
332
  }
332
- if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || !isForeign(r)) continue;
333
+ if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || !isForwardedExport(r)) continue;
333
334
  const m = moduleOf(r);
334
335
  if (!m) continue;
335
336
  byModuleName.set(`${m}\0${r.metadata.name as string}`, r);
@@ -422,6 +423,9 @@ export function validateReferences(
422
423
  visibleScopeManifests.find((m) => m.metadata?.name === localName) ??
423
424
  byName.get(localName);
424
425
  if (!target) {
426
+ // A scoped name out of this declaration's reach has its own
427
+ // diagnostic, which says why; this one would say only "not found".
428
+ if (outsideScopesOf(r).some((scope) => scope.names.includes(localName))) return;
425
429
  diagnostics.push({
426
430
  severity: DiagnosticSeverity.Error,
427
431
  code: "UNRESOLVED_REFERENCE",
@@ -0,0 +1,213 @@
1
+ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
+ import { walkCelExpressions } from "@telorun/templating";
3
+ import type { AliasResolver } from "./alias-resolver.js";
4
+ import type { DefinitionRegistry } from "./definition-registry.js";
5
+ import { isForwardedDeclaration } from "./forwarded-declaration.js";
6
+ import { definitionInScope } from "./module-alias-scope.js";
7
+ import { isRefEntry, resolveFieldEntries } from "./reference-field-map.js";
8
+ import {
9
+ declaredScopes,
10
+ isScopeMember,
11
+ outsideScopesOf,
12
+ scopeEncloses,
13
+ type DeclaredScope,
14
+ type OutsideScope,
15
+ } from "./scope-declarations.js";
16
+ import { forEachStep, stepBodiesOf } from "./step-bodies.js";
17
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
18
+
19
+ const SOURCE = "telo-analyzer";
20
+
21
+ const DECLARATION_KINDS = new Set([
22
+ "Telo.Definition",
23
+ "Telo.Abstract",
24
+ "Telo.Application",
25
+ "Telo.Library",
26
+ "Telo.Import",
27
+ ]);
28
+
29
+ /**
30
+ * `SCOPED_NAME_OUT_OF_REACH` — a declaration written inside a scope's region but
31
+ * created outside the scope (see {@link OutsideScope}) naming one of the scope's
32
+ * resources.
33
+ *
34
+ * Written there, the name reads as reachable — the scope's names shadow the
35
+ * module's in its regions — while at run time it resolves to nothing, or to a
36
+ * module-level resource that merely shares it. Reported at the reference, in
37
+ * each spelling a name can be read in:
38
+ *
39
+ * - a `!ref` sentinel, anywhere — the tag is unambiguous;
40
+ * - a resolved `{kind, name}`, but ONLY where the declaring kind says a
41
+ * reference is: a reference slot of its field map, or a step's dispatch slot.
42
+ * An object that merely has those keys is data;
43
+ * - `resources.<name>` in a CEL expression.
44
+ *
45
+ * Lexical: a scope declared on the way from the out-of-reach declaration down to
46
+ * the reference — found through the kinds' own scope slots — shadows the name
47
+ * again, since that one IS created around it. Entry-module-scoped: a
48
+ * dependency's code is its own analysis's to report.
49
+ *
50
+ * Browser-safe.
51
+ */
52
+ export function validateScopedNameReach(
53
+ manifests: ResourceManifest[],
54
+ registry: DefinitionRegistry,
55
+ aliases: AliasResolver,
56
+ aliasesByModule: Map<string, AliasResolver>,
57
+ rootModules: ReadonlySet<string>,
58
+ /** Member-access chains of a CEL expression — the analyzer's own parse. */
59
+ accessChains: (expression: string) => string[][],
60
+ ): AnalysisDiagnostic[] {
61
+ const out: AnalysisDiagnostic[] = [];
62
+
63
+ for (const root of manifests) {
64
+ if (!root.kind || DECLARATION_KINDS.has(root.kind) || isForwardedDeclaration(root)) continue;
65
+ const meta = root.metadata as { name?: unknown; module?: unknown; source?: unknown } | undefined;
66
+ if (typeof meta?.name !== "string") continue;
67
+ const module = typeof meta.module === "string" ? meta.module : undefined;
68
+ if (module && !rootModules.has(module)) continue;
69
+
70
+ const resource = { kind: root.kind, name: meta.name };
71
+ const filePath = typeof meta.source === "string" ? meta.source : undefined;
72
+ const report = (path: string, name: string, scope: OutsideScope) =>
73
+ out.push({
74
+ severity: DiagnosticSeverity.Error,
75
+ code: "SCOPED_NAME_OUT_OF_REACH",
76
+ source: SOURCE,
77
+ message:
78
+ `${scope.ownerKind}/${scope.ownerName}: '${name}' is declared in '${scope.field}:', but ` +
79
+ `the inline declaration referencing it is created where '${scope.ownerName}' is ` +
80
+ `declared, outside that scope, so the reference cannot reach it. Declare the ` +
81
+ `referencing resource under '${scope.field}:' with a name of its own and point at it ` +
82
+ `with '!ref'.`,
83
+ data: { resource, filePath, path },
84
+ });
85
+
86
+ /** A declaration's kind, field map and scopes, read in its module's aliases. */
87
+ const shape = (declaration: ResourceManifest) => {
88
+ const view =
89
+ module !== undefined && typeof declaration.metadata?.module !== "string"
90
+ ? ({ ...declaration, metadata: { ...declaration.metadata, module } } as ResourceManifest)
91
+ : declaration;
92
+ const fieldMap = registry.expandedFieldMapForResource(view, aliases, aliasesByModule);
93
+ const definition = definitionInScope<ResourceDefinition>(
94
+ registry,
95
+ view.kind,
96
+ view.metadata,
97
+ aliases,
98
+ aliasesByModule,
99
+ );
100
+ return {
101
+ fieldMap,
102
+ scopes: declaredScopes(declaration as Record<string, unknown>, fieldMap),
103
+ schema: registry.effectiveSchemaOf(definition) as Record<string, any> | undefined,
104
+ };
105
+ };
106
+
107
+ const check = (
108
+ declaration: ResourceManifest,
109
+ prefix: string,
110
+ reach: ReadonlyMap<string, OutsideScope>,
111
+ shadowed: ReadonlySet<string>,
112
+ ): void => {
113
+ const stamped = outsideScopesOf(declaration);
114
+ const nextReach = new Map(reach);
115
+ const nextShadowed = new Set(shadowed);
116
+ for (const scope of stamped) {
117
+ for (const name of scope.names) {
118
+ nextReach.set(name, scope);
119
+ nextShadowed.delete(name);
120
+ }
121
+ }
122
+ const { fieldMap, scopes, schema } = shape(declaration);
123
+ for (const scope of scopes) {
124
+ for (const member of scope.declarations) {
125
+ if (isScopeMember(member)) nextShadowed.add(member.metadata.name);
126
+ }
127
+ }
128
+
129
+ const at = (path: string) => (prefix ? (path ? `${prefix}.${path}` : prefix) : path);
130
+ const test = (name: string | undefined, path: string) => {
131
+ if (name === undefined || nextShadowed.has(name)) return;
132
+ const scope = nextReach.get(name);
133
+ if (scope) report(at(path), name, scope);
134
+ };
135
+ const inScopeArray = (path: string) =>
136
+ scopes.some((scope: DeclaredScope) => scopeEncloses({ ...scope, regions: [scope.path] }, path));
137
+
138
+ // Tagged `!ref`s and CEL, anywhere but the declaration's own metadata and
139
+ // scope arrays (their members are checked below, with their own shadowing).
140
+ walkCelExpressions(declaration, "", (source, path, engine) => {
141
+ if (path === "metadata" || path.startsWith("metadata.") || inScopeArray(path)) return;
142
+ if (engine === "ref") {
143
+ test(localName(source), path);
144
+ } else if (engine === "cel") {
145
+ for (const chain of accessChains(source)) {
146
+ if (chain[0] === "resources" && chain.length > 1) test(chain[1], path);
147
+ }
148
+ }
149
+ });
150
+
151
+ // A resolved reference, where the kind declares one.
152
+ if (fieldMap) {
153
+ for (const [fieldPath, entry] of fieldMap) {
154
+ if (!isRefEntry(entry)) continue;
155
+ for (const { value, path } of resolveFieldEntries(declaration, fieldPath)) {
156
+ if (inScopeArray(path)) continue;
157
+ test(resolvedLocalName(value), path);
158
+ }
159
+ }
160
+ }
161
+ if (schema) {
162
+ for (const body of stepBodiesOf(declaration as Record<string, unknown>, schema)) {
163
+ forEachStep(body, schema, (step, stepPath) =>
164
+ test(resolvedLocalName(step[body.invokeField]), `${stepPath}.${body.invokeField}`),
165
+ );
166
+ }
167
+ }
168
+
169
+ for (const scope of scopes) {
170
+ scope.declarations.forEach((member, i) => {
171
+ if (isScopeMember(member)) {
172
+ check(member, at(`${scope.path}[${i}]`), nextReach, nextShadowed);
173
+ }
174
+ });
175
+ }
176
+ };
177
+
178
+ /** Only a stamped declaration can hold an out-of-reach reference; the rest
179
+ * is searched through the scope arrays that can hold one. */
180
+ const visit = (declaration: ResourceManifest, prefix: string): void => {
181
+ if (outsideScopesOf(declaration).length > 0) {
182
+ check(declaration, prefix, new Map(), new Set());
183
+ return;
184
+ }
185
+ for (const scope of shape(declaration).scopes) {
186
+ scope.declarations.forEach((member, i) => {
187
+ if (!isScopeMember(member)) return;
188
+ visit(member, `${prefix ? `${prefix}.` : ""}${scope.path}[${i}]`);
189
+ });
190
+ }
191
+ };
192
+
193
+ visit(root, "");
194
+ }
195
+
196
+ return out;
197
+ }
198
+
199
+ /** The local name a `!ref` source names, or undefined for a cross-module one. */
200
+ function localName(source: string): string | undefined {
201
+ const dot = source.indexOf(".");
202
+ if (dot === -1) return source;
203
+ return source.slice(0, dot) === "Self" ? source.slice(dot + 1) : undefined;
204
+ }
205
+
206
+ /** The local name a resolved `{kind, name, alias?}` reference names. */
207
+ function resolvedLocalName(value: unknown): string | undefined {
208
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
209
+ const ref = value as { kind?: unknown; name?: unknown; alias?: unknown };
210
+ if (typeof ref.kind !== "string" || typeof ref.name !== "string") return undefined;
211
+ if (typeof ref.alias === "string" && ref.alias !== "Self") return undefined;
212
+ return ref.name;
213
+ }