@telorun/analyzer 0.12.1 → 0.14.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 (69) hide show
  1. package/dist/adapters/http-adapter.d.ts +10 -0
  2. package/dist/adapters/http-adapter.d.ts.map +1 -0
  3. package/dist/adapters/http-adapter.js +18 -0
  4. package/dist/adapters/node-adapter.d.ts +17 -0
  5. package/dist/adapters/node-adapter.d.ts.map +1 -0
  6. package/dist/adapters/node-adapter.js +71 -0
  7. package/dist/adapters/registry-adapter.d.ts +15 -0
  8. package/dist/adapters/registry-adapter.d.ts.map +1 -0
  9. package/dist/adapters/registry-adapter.js +53 -0
  10. package/dist/alias-resolver.d.ts +6 -0
  11. package/dist/alias-resolver.d.ts.map +1 -1
  12. package/dist/alias-resolver.js +8 -0
  13. package/dist/analysis-registry.d.ts +13 -0
  14. package/dist/analysis-registry.d.ts.map +1 -1
  15. package/dist/analysis-registry.js +15 -0
  16. package/dist/analyzer.d.ts +1 -1
  17. package/dist/analyzer.d.ts.map +1 -1
  18. package/dist/analyzer.js +176 -88
  19. package/dist/builtins.d.ts.map +1 -1
  20. package/dist/builtins.js +86 -0
  21. package/dist/cel-environment.d.ts +1 -1
  22. package/dist/cel-environment.d.ts.map +1 -1
  23. package/dist/cel-environment.js +40 -2
  24. package/dist/dependency-graph.d.ts.map +1 -1
  25. package/dist/dependency-graph.js +41 -62
  26. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  27. package/dist/flatten-for-analyzer.js +13 -0
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -0
  31. package/dist/kernel-globals.d.ts +1 -1
  32. package/dist/kernel-globals.d.ts.map +1 -1
  33. package/dist/kernel-globals.js +19 -1
  34. package/dist/manifest-visitor.d.ts +124 -0
  35. package/dist/manifest-visitor.d.ts.map +1 -0
  36. package/dist/manifest-visitor.js +181 -0
  37. package/dist/reference-field-map.js +16 -0
  38. package/dist/resolve-ref-sentinels.d.ts +15 -17
  39. package/dist/resolve-ref-sentinels.d.ts.map +1 -1
  40. package/dist/resolve-ref-sentinels.js +94 -40
  41. package/dist/resolve-throws-union.d.ts +10 -0
  42. package/dist/resolve-throws-union.d.ts.map +1 -1
  43. package/dist/resolve-throws-union.js +35 -7
  44. package/dist/schema-compat.d.ts +10 -0
  45. package/dist/schema-compat.d.ts.map +1 -1
  46. package/dist/schema-compat.js +32 -0
  47. package/dist/validate-cel-context.d.ts +14 -0
  48. package/dist/validate-cel-context.d.ts.map +1 -1
  49. package/dist/validate-cel-context.js +38 -0
  50. package/dist/validate-references.d.ts.map +1 -1
  51. package/dist/validate-references.js +215 -160
  52. package/dist/validate-unused-declarations.d.ts +25 -0
  53. package/dist/validate-unused-declarations.d.ts.map +1 -0
  54. package/dist/validate-unused-declarations.js +91 -0
  55. package/package.json +3 -3
  56. package/src/analysis-registry.ts +20 -0
  57. package/src/analyzer.ts +256 -168
  58. package/src/builtins.ts +85 -0
  59. package/src/cel-environment.ts +42 -1
  60. package/src/dependency-graph.ts +37 -52
  61. package/src/index.ts +11 -0
  62. package/src/kernel-globals.ts +22 -1
  63. package/src/manifest-visitor.ts +340 -0
  64. package/src/reference-field-map.ts +14 -0
  65. package/src/resolve-throws-union.ts +36 -8
  66. package/src/schema-compat.ts +32 -0
  67. package/src/validate-cel-context.ts +50 -0
  68. package/src/validate-references.ts +175 -211
  69. package/src/validate-unused-declarations.ts +95 -0
@@ -1,8 +1,10 @@
1
1
  import { isRefSentinel } from "@telorun/templating";
2
- import { isRefEntry, isScopeEntry, isSchemaFromEntry, isInlineResource, resolveFieldEntries, resolveFieldValues } from "./reference-field-map.js";
2
+ import { visitManifest } from "./manifest-visitor.js";
3
+ import { isInlineResource, resolveFieldEntries, resolveFieldValues } from "./reference-field-map.js";
3
4
  import { navigateJsonPointer } from "./schema-compat.js";
4
5
  import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
5
6
  import { DiagnosticSeverity } from "./types.js";
7
+ import { isModuleKind } from "./module-kinds.js";
6
8
  const SOURCE = "telo-analyzer";
7
9
  /**
8
10
  * Checks whether `kind` satisfies the ref constraint in `entry`.
@@ -94,10 +96,41 @@ export function validateReferences(resources, context) {
94
96
  // source, different sourceLine) keep separate fingerprints and still
95
97
  // trip the diagnostic. `analyze()` enforces that every non-system
96
98
  // manifest carries both positional fields — no defensive guard needed.
99
+ // Forwarded foreign exports (an imported library's exported instances, carrying a
100
+ // metadata.module that isn't a root module) are resolution TARGETS only: excluded from
101
+ // duplicate detection and local name resolution, and never walked as ref sources.
102
+ const rootModules = new Set();
103
+ for (const r of resources) {
104
+ if (isModuleKind(r.kind) && typeof r.metadata?.name === "string") {
105
+ rootModules.add(r.metadata.name);
106
+ }
107
+ }
108
+ const moduleOf = (r) => r.metadata?.module;
109
+ const isForeign = (r) => {
110
+ const mod = moduleOf(r);
111
+ return typeof mod === "string" && !rootModules.has(mod);
112
+ };
113
+ const byModuleName = new Map();
114
+ for (const r of resources) {
115
+ if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || !isForeign(r))
116
+ continue;
117
+ byModuleName.set(`${moduleOf(r)}\0${r.metadata.name}`, r);
118
+ }
119
+ /** Whether the analysis set actually carries the named module's exported instances.
120
+ * False in partial single-file analysis (the import subtree wasn't loaded) — used to
121
+ * skip cross-module resolution checks rather than emit false `UNRESOLVED_REFERENCE`. */
122
+ const moduleLoaded = (module) => {
123
+ const prefix = `${module}\0`;
124
+ for (const key of byModuleName.keys())
125
+ if (key.startsWith(prefix))
126
+ return true;
127
+ return false;
128
+ };
129
+ const localResources = resources.filter((r) => !isForeign(r));
97
130
  const byNameAll = new Map();
98
131
  const seen = new Set();
99
132
  for (const r of resources) {
100
- if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || r.kind === "Telo.Import")
133
+ if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || r.kind === "Telo.Import" || isForeign(r))
101
134
  continue;
102
135
  const name = r.metadata.name;
103
136
  // `analyze()` guarantees both fields are present on non-system manifests.
@@ -139,6 +172,32 @@ export function validateReferences(resources, context) {
139
172
  });
140
173
  }
141
174
  }
175
+ // A resource name must contain no dot. The `!ref` resolver splits the tag's source on
176
+ // the first dot to separate an import alias from the resource name, so a dotted name
177
+ // would mis-resolve into a cross-module lookup. This is the load-bearing invariant of
178
+ // the reference grammar, so it is enforced here rather than left to the (unenforced)
179
+ // casing convention.
180
+ for (const [name, list] of byNameAll) {
181
+ if (!name.includes("."))
182
+ continue;
183
+ for (const r of list) {
184
+ const m = r.metadata;
185
+ const range = typeof m?.sourceLine === "number"
186
+ ? {
187
+ start: { line: m.sourceLine, character: 0 },
188
+ end: { line: m.sourceLine, character: Number.MAX_SAFE_INTEGER },
189
+ }
190
+ : undefined;
191
+ diagnostics.push({
192
+ severity: DiagnosticSeverity.Error,
193
+ code: "INVALID_RESOURCE_NAME",
194
+ source: SOURCE,
195
+ message: `${r.kind}/${name}: resource name must not contain '.' — in a '!ref' the '.' separates an import alias from the resource name`,
196
+ ...(range ? { range } : {}),
197
+ data: { resource: { kind: r.kind, name }, filePath: m?.source, path: "metadata.name" },
198
+ });
199
+ }
200
+ }
142
201
  // Single-resource map for the resolution / scope lookups below — when a
143
202
  // collision exists, falling back to the first occurrence keeps the rest
144
203
  // of the pass behaving the same as before the duplicate diagnostic was
@@ -147,142 +206,58 @@ export function validateReferences(resources, context) {
147
206
  const byName = new Map();
148
207
  for (const [name, list] of byNameAll)
149
208
  byName.set(name, list[0]);
150
- for (const r of resources) {
151
- if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind))
152
- continue;
153
- // Use the expanded map so refs nested behind x-telo-schema-from get the
154
- // same kind-check / unresolved-name validation as locally-declared refs.
155
- // Falls back to the base map when aliasesByModule isn't supplied.
156
- const fieldMap = aliasesByModule
157
- ? registry.expandedFieldMapForResource(r, aliases, aliasesByModule)
158
- : registry.getFieldMapForKind(r.kind, aliases);
159
- if (!fieldMap)
160
- continue;
161
- const resourceLabel = `${r.kind}/${r.metadata.name}`;
162
- const resourceData = { kind: r.kind, name: r.metadata.name };
163
- const filePath = r.metadata?.source;
164
- // Collect scope visibility prefixes (JSON Pointer dot prefix) and their manifests.
165
- // scope field path flat array of ResourceManifest declared in that scope.
166
- const scopeManifestsByPointer = new Map();
167
- for (const [fieldPath, entry] of fieldMap) {
168
- if (!isScopeEntry(entry))
169
- continue;
170
- const raw = resolveFieldValues(r, fieldPath)
171
- .flatMap((v) => (Array.isArray(v) ? v : [v]))
172
- .filter((v) => !!v && typeof v === "object");
173
- const pointers = Array.isArray(entry.scope) ? entry.scope : [entry.scope];
174
- for (const pointer of pointers) {
175
- scopeManifestsByPointer.set(pointer, raw);
176
- }
177
- }
178
- const scopePrefixes = Array.from(scopeManifestsByPointer.keys()).map((p) => p.replace(/^\//, "").replace(/\//g, "."));
179
- for (const [fieldPath, entry] of fieldMap) {
180
- if (!isRefEntry(entry))
181
- continue;
182
- const inScope = scopePrefixes.some((prefix) => fieldPath === prefix ||
183
- fieldPath.startsWith(prefix + ".") ||
184
- fieldPath.startsWith(prefix + "["));
185
- // Scope manifests visible to this ref path.
186
- const visibleScopeManifests = [];
187
- if (inScope) {
188
- for (const [pointer, manifests] of scopeManifestsByPointer) {
189
- const prefix = pointer.replace(/^\//, "").replace(/\//g, ".");
190
- if (fieldPath === prefix ||
191
- fieldPath.startsWith(prefix + ".") ||
192
- fieldPath.startsWith(prefix + "[")) {
193
- visibleScopeManifests.push(...manifests);
194
- }
195
- }
196
- }
197
- for (const { value: val, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
198
- if (!val)
199
- continue;
200
- // `!ref <name>` sentinel — bare resource name marked at parse time as a
201
- // reference. Look it up against the slot's x-telo-ref constraint exactly
202
- // like the legacy bare-string path; the only difference is the value's
203
- // shape (a TaggedSentinel rather than a raw string), which removed the
204
- // string/inline ambiguity at the source.
205
- if (isRefSentinel(val)) {
206
- const refName = val.source;
207
- const target = byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
208
- if (!target) {
209
- diagnostics.push({
210
- severity: DiagnosticSeverity.Error,
211
- code: "UNRESOLVED_REFERENCE",
212
- source: SOURCE,
213
- message: `${resourceLabel}: reference at '${concretePath}' → resource '${refName}' not found`,
214
- data: { resource: resourceData, filePath, path: concretePath },
215
- });
216
- continue;
217
- }
218
- const kindErrors = checkKind(target.kind, entry, registry, aliases);
219
- if (kindErrors.length > 0) {
220
- diagnostics.push({
221
- severity: DiagnosticSeverity.Error,
222
- code: "REFERENCE_KIND_MISMATCH",
223
- source: SOURCE,
224
- message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
225
- data: { resource: resourceData, filePath, path: concretePath },
226
- });
227
- }
228
- continue;
229
- }
230
- // Name-only reference (plain string) — look up by name to validate.
231
- // Qualified references use "Kind.Name" format (e.g. "Http.Api.PaymentApi");
232
- // extract the resource name from the last dot segment.
233
- if (typeof val === "string") {
234
- const lastDot = val.lastIndexOf(".");
235
- const refName = lastDot > 0 ? val.slice(lastDot + 1) : val;
236
- const refKindPrefix = lastDot > 0 ? val.slice(0, lastDot) : undefined;
237
- const target = byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
238
- if (!target) {
239
- // Cross-module reference: "Alias.ResourceName" (single dot, bare alias prefix).
240
- // The resource lives in the imported module's scope and can't be validated here.
241
- // Multi-dot prefixes like "Alias.Kind.Name" are local resources with qualified
242
- // kinds — those must be validated.
243
- if (refKindPrefix && !refKindPrefix.includes(".") && aliases.hasAlias(refKindPrefix)) {
244
- continue;
245
- }
246
- diagnostics.push({
247
- severity: DiagnosticSeverity.Error,
248
- code: "UNRESOLVED_REFERENCE",
249
- source: SOURCE,
250
- message: `${resourceLabel}: reference at '${concretePath}' → resource '${val}' not found`,
251
- data: { resource: resourceData, filePath, path: concretePath },
252
- });
253
- continue;
254
- }
255
- const kindErrors = checkKind(target.kind, entry, registry, aliases);
256
- if (kindErrors.length > 0) {
257
- diagnostics.push({
258
- severity: DiagnosticSeverity.Error,
259
- code: "REFERENCE_KIND_MISMATCH",
260
- source: SOURCE,
261
- message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
262
- data: { resource: resourceData, filePath, path: concretePath },
263
- });
264
- }
265
- continue;
209
+ // Phase 3 per-ref validation. The walker supplies each ref site already
210
+ // resolved against the schema-from-expanded field map, with its source
211
+ // enclosure (`inScope`) and the scope manifests visible to it — so this
212
+ // handler only validates, it does not re-walk.
213
+ visitManifest(localResources, registry, {
214
+ onRef: (e) => {
215
+ const r = e.source;
216
+ const resourceLabel = `${r.kind}/${r.metadata.name}`;
217
+ const resourceData = { kind: r.kind, name: r.metadata.name };
218
+ const filePath = r.metadata?.source;
219
+ const { value: val, concretePath, entry, visibleScopeManifests } = e;
220
+ // `!ref <name>` sentinel — bare resource name marked at parse time as a
221
+ // reference. Look it up against the slot's x-telo-ref constraint exactly
222
+ // like the legacy bare-string path; the only difference is the value's
223
+ // shape (a TaggedSentinel rather than a raw string), which removed the
224
+ // string/inline ambiguity at the source.
225
+ if (isRefSentinel(val)) {
226
+ const refName = val.source;
227
+ const dot = refName.indexOf(".");
228
+ const aliasPrefix = dot > 0 ? refName.slice(0, dot) : undefined;
229
+ // Cross-module sentinel left unresolved by Phase 2.5 — it qualifies an import
230
+ // alias. If that module's exports are loaded in this analysis, the miss is real
231
+ // (name not in exports.resources, or a typo); if not (partial single-file
232
+ // analysis), skip rather than emit a false UNRESOLVED_REFERENCE.
233
+ if (aliasPrefix && aliasPrefix !== "Self" && aliases.hasAlias(aliasPrefix)) {
234
+ const module = aliases.moduleForAlias(aliasPrefix);
235
+ if (module && !moduleLoaded(module))
236
+ return;
237
+ diagnostics.push({
238
+ severity: DiagnosticSeverity.Error,
239
+ code: "UNRESOLVED_REFERENCE",
240
+ source: SOURCE,
241
+ message: `${resourceLabel}: reference at '${concretePath}' '${refName}' is not exported by module '${module ?? aliasPrefix}' (add it to exports.resources)`,
242
+ data: { resource: resourceData, filePath, path: concretePath },
243
+ });
244
+ return;
266
245
  }
267
- if (typeof val !== "object")
268
- continue;
269
- const refVal = val;
270
- // Skip inline resources — Phase 2 normalization hasn't run yet.
271
- if (isInlineResource(refVal))
272
- continue;
273
- // 1. Structural check
274
- if (typeof refVal.kind !== "string" || typeof refVal.name !== "string") {
246
+ // Local reference (bare name or explicit `Self.`-qualified).
247
+ const localName = aliasPrefix === "Self" ? refName.slice(dot + 1) : refName;
248
+ const target = byName.get(localName) ??
249
+ visibleScopeManifests.find((m) => m.metadata?.name === localName);
250
+ if (!target) {
275
251
  diagnostics.push({
276
252
  severity: DiagnosticSeverity.Error,
277
- code: "INVALID_REFERENCE",
253
+ code: "UNRESOLVED_REFERENCE",
278
254
  source: SOURCE,
279
- message: `${resourceLabel}: reference at '${concretePath}' must have string 'kind' and 'name' fields`,
255
+ message: `${resourceLabel}: reference at '${concretePath}' resource '${localName}' not found`,
280
256
  data: { resource: resourceData, filePath, path: concretePath },
281
257
  });
282
- continue;
258
+ return;
283
259
  }
284
- // 2. Kind check
285
- const kindErrors = checkKind(refVal.kind, entry, registry, aliases);
260
+ const kindErrors = checkKind(target.kind, entry, registry, aliases);
286
261
  if (kindErrors.length > 0) {
287
262
  diagnostics.push({
288
263
  severity: DiagnosticSeverity.Error,
@@ -292,38 +267,118 @@ export function validateReferences(resources, context) {
292
267
  data: { resource: resourceData, filePath, path: concretePath },
293
268
  });
294
269
  }
295
- // 3. Resolution check — resource with this name must exist.
296
- const exists = byName.has(refVal.name) ||
297
- visibleScopeManifests.some((m) => m.metadata?.name === refVal.name);
298
- if (!exists) {
270
+ return;
271
+ }
272
+ // Name-only reference (plain string) look up by name to validate.
273
+ // Qualified references use "Kind.Name" format (e.g. "Http.Api.PaymentApi");
274
+ // extract the resource name from the last dot segment.
275
+ if (typeof val === "string") {
276
+ const lastDot = val.lastIndexOf(".");
277
+ const refName = lastDot > 0 ? val.slice(lastDot + 1) : val;
278
+ const refKindPrefix = lastDot > 0 ? val.slice(0, lastDot) : undefined;
279
+ const target = byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
280
+ if (!target) {
281
+ // Cross-module reference: "Alias.ResourceName" (single dot, bare alias prefix).
282
+ // The resource lives in the imported module's scope and can't be validated here.
283
+ // Multi-dot prefixes like "Alias.Kind.Name" are local resources with qualified
284
+ // kinds — those must be validated.
285
+ if (refKindPrefix && !refKindPrefix.includes(".") && aliases.hasAlias(refKindPrefix)) {
286
+ return;
287
+ }
299
288
  diagnostics.push({
300
289
  severity: DiagnosticSeverity.Error,
301
290
  code: "UNRESOLVED_REFERENCE",
302
291
  source: SOURCE,
303
- message: `${resourceLabel}: reference at '${concretePath}' → resource '${refVal.name}' not found`,
292
+ message: `${resourceLabel}: reference at '${concretePath}' → resource '${val}' not found`,
304
293
  data: { resource: resourceData, filePath, path: concretePath },
305
294
  });
295
+ return;
306
296
  }
297
+ const kindErrors = checkKind(target.kind, entry, registry, aliases);
298
+ if (kindErrors.length > 0) {
299
+ diagnostics.push({
300
+ severity: DiagnosticSeverity.Error,
301
+ code: "REFERENCE_KIND_MISMATCH",
302
+ source: SOURCE,
303
+ message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
304
+ data: { resource: resourceData, filePath, path: concretePath },
305
+ });
306
+ }
307
+ return;
307
308
  }
308
- }
309
- }
309
+ if (typeof val !== "object")
310
+ return;
311
+ const refVal = val;
312
+ // Skip inline resources — Phase 2 normalization hasn't run yet.
313
+ if (isInlineResource(refVal))
314
+ return;
315
+ // Polymorphic ref slots (Application `targets`) accept object forms
316
+ // whose references live in nested slots rather than being a `{kind,
317
+ // name}` ref themselves — inline `{ invoke }` and gated `{ ref }`.
318
+ // Those nested refs are validated via their own field-map entries, so
319
+ // skip the item-level structural check here.
320
+ if (typeof refVal.kind !== "string" && ("invoke" in refVal || "ref" in refVal))
321
+ return;
322
+ // 1. Structural check
323
+ if (typeof refVal.kind !== "string" || typeof refVal.name !== "string") {
324
+ diagnostics.push({
325
+ severity: DiagnosticSeverity.Error,
326
+ code: "INVALID_REFERENCE",
327
+ source: SOURCE,
328
+ message: `${resourceLabel}: reference at '${concretePath}' must have string 'kind' and 'name' fields`,
329
+ data: { resource: resourceData, filePath, path: concretePath },
330
+ });
331
+ return;
332
+ }
333
+ // 2. Kind check
334
+ const kindErrors = checkKind(refVal.kind, entry, registry, aliases);
335
+ if (kindErrors.length > 0) {
336
+ diagnostics.push({
337
+ severity: DiagnosticSeverity.Error,
338
+ code: "REFERENCE_KIND_MISMATCH",
339
+ source: SOURCE,
340
+ message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
341
+ data: { resource: resourceData, filePath, path: concretePath },
342
+ });
343
+ }
344
+ // 3. Resolution check — resource with this name must exist.
345
+ let exists;
346
+ if (typeof refVal.alias === "string" && refVal.alias !== "Self") {
347
+ // Cross-module ref resolved by Phase 2.5. Validate against the forwarded
348
+ // exports when loaded; in partial context (module not loaded) assume resolvable.
349
+ const module = aliases.moduleForAlias(refVal.alias);
350
+ exists =
351
+ !module || !moduleLoaded(module) || byModuleName.has(`${module}\0${refVal.name}`);
352
+ }
353
+ else {
354
+ exists =
355
+ byName.has(refVal.name) ||
356
+ visibleScopeManifests.some((m) => m.metadata?.name === refVal.name);
357
+ }
358
+ if (!exists) {
359
+ diagnostics.push({
360
+ severity: DiagnosticSeverity.Error,
361
+ code: "UNRESOLVED_REFERENCE",
362
+ source: SOURCE,
363
+ message: `${resourceLabel}: reference at '${concretePath}' → resource '${refVal.name}' not found`,
364
+ data: { resource: resourceData, filePath, path: concretePath },
365
+ });
366
+ }
367
+ },
368
+ }, { aliases, aliasesByModule, skipKinds: SYSTEM_KINDS, expand: true });
310
369
  // Phase 3b — x-telo-schema-from validation.
311
370
  // For each field with a schemaFrom path expression, resolve the anchor ref to get the
312
371
  // concrete kind, navigate the JSON Pointer into that kind's definition schema, and
313
- // validate the field value against the resulting sub-schema.
314
- for (const r of resources) {
315
- if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind))
316
- continue;
317
- const fieldMap = registry.getFieldMapForKind(r.kind, aliases);
318
- if (!fieldMap)
319
- continue;
320
- const resourceLabel = `${r.kind}/${r.metadata.name}`;
321
- const resourceData = { kind: r.kind, name: r.metadata.name };
322
- const filePath = r.metadata?.source;
323
- for (const [fieldPath, entry] of fieldMap) {
324
- if (!isSchemaFromEntry(entry))
325
- continue;
326
- const { schemaFrom } = entry;
372
+ // validate the field value against the resulting sub-schema. Driven off the base map
373
+ // (un-expanded) so each schema-from slot is seen as its own site.
374
+ visitManifest(localResources, registry, {
375
+ onSchemaFrom: (e) => {
376
+ const r = e.source;
377
+ const fieldPath = e.fieldPath;
378
+ const resourceLabel = `${r.kind}/${r.metadata.name}`;
379
+ const resourceData = { kind: r.kind, name: r.metadata.name };
380
+ const filePath = r.metadata?.source;
381
+ const { schemaFrom } = e.entry;
327
382
  const isAbsolute = schemaFrom.startsWith("/");
328
383
  const expr = isAbsolute ? schemaFrom.slice(1) : schemaFrom;
329
384
  const slashIdx = expr.indexOf("/");
@@ -335,7 +390,7 @@ export function validateReferences(resources, context) {
335
390
  message: `${resourceLabel}: x-telo-schema-from "${schemaFrom}" must contain at least one "/" to separate anchor from JSON Pointer`,
336
391
  data: { resource: resourceData, filePath, path: fieldPath },
337
392
  });
338
- continue;
393
+ return;
339
394
  }
340
395
  const anchorName = expr.slice(0, slashIdx);
341
396
  const jsonPointer = "/" + expr.slice(slashIdx + 1);
@@ -360,7 +415,7 @@ export function validateReferences(resources, context) {
360
415
  message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → cannot resolve alias '${anchorName}'`,
361
416
  data: { resource: resourceData, filePath, path: fieldPath },
362
417
  });
363
- continue;
418
+ return;
364
419
  }
365
420
  const targetDef = registry.resolve(targetKind);
366
421
  if (!targetDef?.schema) {
@@ -371,7 +426,7 @@ export function validateReferences(resources, context) {
371
426
  message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${targetKind}' has no schema`,
372
427
  data: { resource: resourceData, filePath, path: fieldPath },
373
428
  });
374
- continue;
429
+ return;
375
430
  }
376
431
  const subSchema = navigateJsonPointer(targetDef.schema, jsonPointer);
377
432
  if (subSchema === undefined) {
@@ -382,7 +437,7 @@ export function validateReferences(resources, context) {
382
437
  message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${targetKind}' has no schema path '${jsonPointer}'`,
383
438
  data: { resource: resourceData, filePath, path: fieldPath },
384
439
  });
385
- continue;
440
+ return;
386
441
  }
387
442
  for (const { value: fieldValue, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
388
443
  if (fieldValue == null)
@@ -398,7 +453,7 @@ export function validateReferences(resources, context) {
398
453
  });
399
454
  }
400
455
  }
401
- continue;
456
+ return;
402
457
  }
403
458
  // Derive the anchor path in the resource config.
404
459
  let anchorPath;
@@ -413,7 +468,7 @@ export function validateReferences(resources, context) {
413
468
  }
414
469
  const anchorValues = resolveFieldValues(r, anchorPath);
415
470
  if (anchorValues.length === 0)
416
- continue; // anchor field not set — nothing to validate
471
+ return; // anchor field not set — nothing to validate
417
472
  const fieldEntries = resolveFieldEntries(r, fieldPath);
418
473
  for (let i = 0; i < fieldEntries.length; i++) {
419
474
  const { value: fieldValue, path: concretePath } = fieldEntries[i];
@@ -460,7 +515,7 @@ export function validateReferences(resources, context) {
460
515
  });
461
516
  }
462
517
  }
463
- }
464
- }
518
+ },
519
+ }, { aliases, aliasesByModule, skipKinds: SYSTEM_KINDS, expand: false });
465
520
  return diagnostics;
466
521
  }
@@ -0,0 +1,25 @@
1
+ import type { Environment } from "@marcbachmann/cel-js";
2
+ import type { ResourceManifest } from "@telorun/sdk";
3
+ import { type AnalysisDiagnostic } from "./types.js";
4
+ /**
5
+ * Warn about declared `variables` / `secrets` / `ports` entries that no CEL
6
+ * expression references. A declared-but-unconsumed entry is dead weight at
7
+ * best and misleading at worst (an unbound `ports` entry makes a runner
8
+ * advertise a port the app never listens on).
9
+ *
10
+ * Generic across all three namespaces. References are collected from every CEL
11
+ * expression (both `${{ … }}` and `!cel`, via `walkCelExpressions`) by
12
+ * extracting member-access chains: a `<ns>.<name>` chain marks `<name>` used.
13
+ * Dynamic access (`<ns>[expr]`, or the namespace passed whole, e.g.
14
+ * `keys(variables)`) yields a chain that stops at the namespace root — that
15
+ * can't be attributed to a name, so the whole namespace is conservatively
16
+ * suppressed to avoid false positives.
17
+ *
18
+ * Application-only: an Application's `variables` / `secrets` / `ports` flow
19
+ * exclusively through CEL (into resource fields, or into `Telo.Import` inputs),
20
+ * so unreferenced means dead. A `Telo.Library`'s `variables` / `secrets` are a
21
+ * public input contract consumed by its controllers — invisible to CEL
22
+ * analysis — so they are deliberately not flagged.
23
+ */
24
+ export declare function validateUnusedDeclarations(manifests: ResourceManifest[], celEnv: Environment): AnalysisDiagnostic[];
25
+ //# sourceMappingURL=validate-unused-declarations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate-unused-declarations.d.ts","sourceRoot":"","sources":["../src/validate-unused-declarations.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,OAAO,EAAE,KAAK,kBAAkB,EAAsB,MAAM,YAAY,CAAC;AASzE;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,0BAA0B,CACxC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,MAAM,EAAE,WAAW,GAClB,kBAAkB,EAAE,CA2DtB"}
@@ -0,0 +1,91 @@
1
+ import { extractAccessChains, INDEX_SEGMENT, walkCelExpressions } from "@telorun/templating";
2
+ import { DiagnosticSeverity } from "./types.js";
3
+ const SOURCE = "telo-analyzer";
4
+ /** Module-doc namespaces whose entries are consumed via `<ns>.<name>` CEL
5
+ * access. One table drives the whole check — adding a namespace is an entry,
6
+ * not a branch. */
7
+ const NAMESPACES = ["variables", "secrets", "ports"];
8
+ /**
9
+ * Warn about declared `variables` / `secrets` / `ports` entries that no CEL
10
+ * expression references. A declared-but-unconsumed entry is dead weight at
11
+ * best and misleading at worst (an unbound `ports` entry makes a runner
12
+ * advertise a port the app never listens on).
13
+ *
14
+ * Generic across all three namespaces. References are collected from every CEL
15
+ * expression (both `${{ … }}` and `!cel`, via `walkCelExpressions`) by
16
+ * extracting member-access chains: a `<ns>.<name>` chain marks `<name>` used.
17
+ * Dynamic access (`<ns>[expr]`, or the namespace passed whole, e.g.
18
+ * `keys(variables)`) yields a chain that stops at the namespace root — that
19
+ * can't be attributed to a name, so the whole namespace is conservatively
20
+ * suppressed to avoid false positives.
21
+ *
22
+ * Application-only: an Application's `variables` / `secrets` / `ports` flow
23
+ * exclusively through CEL (into resource fields, or into `Telo.Import` inputs),
24
+ * so unreferenced means dead. A `Telo.Library`'s `variables` / `secrets` are a
25
+ * public input contract consumed by its controllers — invisible to CEL
26
+ * analysis — so they are deliberately not flagged.
27
+ */
28
+ export function validateUnusedDeclarations(manifests, celEnv) {
29
+ const moduleManifest = manifests.find((m) => m.kind === "Telo.Application");
30
+ if (!moduleManifest)
31
+ return [];
32
+ const declared = new Map();
33
+ for (const ns of NAMESPACES) {
34
+ const block = moduleManifest[ns];
35
+ if (block && typeof block === "object" && !Array.isArray(block)) {
36
+ const names = Object.keys(block);
37
+ if (names.length > 0)
38
+ declared.set(ns, names);
39
+ }
40
+ }
41
+ if (declared.size === 0)
42
+ return [];
43
+ const used = new Map(NAMESPACES.map((ns) => [ns, new Set()]));
44
+ const suppressed = new Set();
45
+ for (const m of manifests) {
46
+ walkCelExpressions(m, "", (expr, _path, engineName) => {
47
+ if (engineName !== "cel")
48
+ return;
49
+ let ast;
50
+ try {
51
+ ast = celEnv.parse(expr).ast;
52
+ }
53
+ catch {
54
+ return; // syntax errors are reported by the CEL engine pass
55
+ }
56
+ for (const chain of extractAccessChains(ast)) {
57
+ const ns = chain[0];
58
+ if (!used.has(ns))
59
+ continue;
60
+ const member = chain[1];
61
+ // No static member after the namespace root — either the namespace is
62
+ // used whole (`keys(ports)` → ["ports"]) or accessed dynamically
63
+ // (`ports[x]` → ["ports", "[*]"]). Neither can be attributed to a
64
+ // declared name, so suppress the namespace rather than false-positive.
65
+ if (member === undefined || member === INDEX_SEGMENT)
66
+ suppressed.add(ns);
67
+ else
68
+ used.get(ns).add(member);
69
+ }
70
+ });
71
+ }
72
+ const diagnostics = [];
73
+ const filePath = moduleManifest.metadata?.source;
74
+ for (const [ns, names] of declared) {
75
+ if (suppressed.has(ns))
76
+ continue;
77
+ const seen = used.get(ns);
78
+ for (const name of names) {
79
+ if (seen.has(name))
80
+ continue;
81
+ diagnostics.push({
82
+ severity: DiagnosticSeverity.Warning,
83
+ code: "UNUSED_DECLARATION",
84
+ source: SOURCE,
85
+ message: `${ns}.${name} is declared but never referenced in any CEL expression.`,
86
+ data: { filePath, path: `${ns}.${name}` },
87
+ });
88
+ }
89
+ }
90
+ return diagnostics;
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -42,7 +42,7 @@
42
42
  "ajv-formats": "^3.0.1",
43
43
  "jsonpath-plus": "^10.3.0",
44
44
  "yaml": "^2.8.3",
45
- "@telorun/templating": "0.3.0"
45
+ "@telorun/templating": "0.4.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^20.0.0",
@@ -50,7 +50,7 @@
50
50
  "vitest": "^2.1.8"
51
51
  },
52
52
  "peerDependencies": {
53
- "@telorun/sdk": "0.12.0"
53
+ "@telorun/sdk": "0.13.0"
54
54
  },
55
55
  "scripts": {
56
56
  "build": "tsc -p tsconfig.lib.json",