@telorun/analyzer 0.12.1 → 1.0.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 (51) hide show
  1. package/dist/analysis-registry.d.ts +13 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +15 -0
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +154 -83
  6. package/dist/builtins.d.ts.map +1 -1
  7. package/dist/builtins.js +85 -0
  8. package/dist/cel-environment.d.ts +1 -1
  9. package/dist/cel-environment.d.ts.map +1 -1
  10. package/dist/cel-environment.js +40 -2
  11. package/dist/dependency-graph.d.ts.map +1 -1
  12. package/dist/dependency-graph.js +41 -62
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/kernel-globals.d.ts +1 -1
  17. package/dist/kernel-globals.d.ts.map +1 -1
  18. package/dist/kernel-globals.js +19 -1
  19. package/dist/manifest-visitor.d.ts +124 -0
  20. package/dist/manifest-visitor.d.ts.map +1 -0
  21. package/dist/manifest-visitor.js +181 -0
  22. package/dist/reference-field-map.js +16 -0
  23. package/dist/resolve-throws-union.d.ts +10 -0
  24. package/dist/resolve-throws-union.d.ts.map +1 -1
  25. package/dist/resolve-throws-union.js +35 -7
  26. package/dist/schema-compat.d.ts +10 -0
  27. package/dist/schema-compat.d.ts.map +1 -1
  28. package/dist/schema-compat.js +32 -0
  29. package/dist/validate-cel-context.d.ts +14 -0
  30. package/dist/validate-cel-context.d.ts.map +1 -1
  31. package/dist/validate-cel-context.js +38 -0
  32. package/dist/validate-references.d.ts.map +1 -1
  33. package/dist/validate-references.js +124 -160
  34. package/dist/validate-unused-declarations.d.ts +25 -0
  35. package/dist/validate-unused-declarations.d.ts.map +1 -0
  36. package/dist/validate-unused-declarations.js +91 -0
  37. package/package.json +3 -3
  38. package/src/analysis-registry.ts +20 -0
  39. package/src/analyzer.ts +256 -168
  40. package/src/builtins.ts +85 -0
  41. package/src/cel-environment.ts +42 -1
  42. package/src/dependency-graph.ts +37 -52
  43. package/src/index.ts +11 -0
  44. package/src/kernel-globals.ts +22 -1
  45. package/src/manifest-visitor.ts +340 -0
  46. package/src/reference-field-map.ts +14 -0
  47. package/src/resolve-throws-union.ts +36 -8
  48. package/src/schema-compat.ts +32 -0
  49. package/src/validate-cel-context.ts +50 -0
  50. package/src/validate-references.ts +175 -211
  51. package/src/validate-unused-declarations.ts +95 -0
@@ -1,6 +1,7 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
2
  import { isRefSentinel } from "@telorun/templating";
3
- import { isRefEntry, isScopeEntry, isSchemaFromEntry, isInlineResource, resolveFieldEntries, resolveFieldValues, type RefFieldEntry } from "./reference-field-map.js";
3
+ import { visitManifest } from "./manifest-visitor.js";
4
+ import { isInlineResource, resolveFieldEntries, resolveFieldValues, type RefFieldEntry } from "./reference-field-map.js";
4
5
  import { navigateJsonPointer } from "./schema-compat.js";
5
6
  import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
6
7
  import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisContext } from "./types.js";
@@ -155,66 +156,20 @@ export function validateReferences(
155
156
  const byName = new Map<string, ResourceManifest>();
156
157
  for (const [name, list] of byNameAll) byName.set(name, list[0]);
157
158
 
158
- for (const r of resources) {
159
- if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
160
-
161
- // Use the expanded map so refs nested behind x-telo-schema-from get the
162
- // same kind-check / unresolved-name validation as locally-declared refs.
163
- // Falls back to the base map when aliasesByModule isn't supplied.
164
- const fieldMap = aliasesByModule
165
- ? registry.expandedFieldMapForResource(r, aliases, aliasesByModule)
166
- : registry.getFieldMapForKind(r.kind, aliases);
167
- if (!fieldMap) continue;
168
-
169
- const resourceLabel = `${r.kind}/${r.metadata.name as string}`;
170
- const resourceData = { kind: r.kind, name: r.metadata.name as string };
171
- const filePath = (r.metadata as { source?: string } | undefined)?.source;
172
-
173
- // Collect scope visibility prefixes (JSON Pointer → dot prefix) and their manifests.
174
- // scope field path → flat array of ResourceManifest declared in that scope.
175
- const scopeManifestsByPointer = new Map<string, ResourceManifest[]>();
176
- for (const [fieldPath, entry] of fieldMap) {
177
- if (!isScopeEntry(entry)) continue;
178
- const raw = resolveFieldValues(r, fieldPath)
179
- .flatMap((v) => (Array.isArray(v) ? v : [v]))
180
- .filter((v): v is ResourceManifest => !!v && typeof v === "object");
181
- const pointers = Array.isArray(entry.scope) ? entry.scope : [entry.scope];
182
- for (const pointer of pointers) {
183
- scopeManifestsByPointer.set(pointer, raw);
184
- }
185
- }
186
-
187
- const scopePrefixes = Array.from(scopeManifestsByPointer.keys()).map((p) =>
188
- p.replace(/^\//, "").replace(/\//g, "."),
189
- );
190
-
191
- for (const [fieldPath, entry] of fieldMap) {
192
- if (!isRefEntry(entry)) continue;
193
-
194
- const inScope = scopePrefixes.some(
195
- (prefix) =>
196
- fieldPath === prefix ||
197
- fieldPath.startsWith(prefix + ".") ||
198
- fieldPath.startsWith(prefix + "["),
199
- );
200
-
201
- // Scope manifests visible to this ref path.
202
- const visibleScopeManifests: ResourceManifest[] = [];
203
- if (inScope) {
204
- for (const [pointer, manifests] of scopeManifestsByPointer) {
205
- const prefix = pointer.replace(/^\//, "").replace(/\//g, ".");
206
- if (
207
- fieldPath === prefix ||
208
- fieldPath.startsWith(prefix + ".") ||
209
- fieldPath.startsWith(prefix + "[")
210
- ) {
211
- visibleScopeManifests.push(...manifests);
212
- }
213
- }
214
- }
215
-
216
- for (const { value: val, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
217
- if (!val) continue;
159
+ // Phase 3 per-ref validation. The walker supplies each ref site already
160
+ // resolved against the schema-from-expanded field map, with its source
161
+ // enclosure (`inScope`) and the scope manifests visible to it — so this
162
+ // handler only validates, it does not re-walk.
163
+ visitManifest(
164
+ resources,
165
+ registry,
166
+ {
167
+ onRef: (e) => {
168
+ const r = e.source;
169
+ const resourceLabel = `${r.kind}/${r.metadata!.name as string}`;
170
+ const resourceData = { kind: r.kind, name: r.metadata!.name as string };
171
+ const filePath = (r.metadata as { source?: string } | undefined)?.source;
172
+ const { value: val, concretePath, entry, visibleScopeManifests } = e;
218
173
 
219
174
  // `!ref <name>` sentinel — bare resource name marked at parse time as a
220
175
  // reference. Look it up against the slot's x-telo-ref constraint exactly
@@ -233,7 +188,7 @@ export function validateReferences(
233
188
  message: `${resourceLabel}: reference at '${concretePath}' → resource '${refName}' not found`,
234
189
  data: { resource: resourceData, filePath, path: concretePath },
235
190
  });
236
- continue;
191
+ return;
237
192
  }
238
193
  const kindErrors = checkKind(target.kind as string, entry, registry, aliases);
239
194
  if (kindErrors.length > 0) {
@@ -245,7 +200,7 @@ export function validateReferences(
245
200
  data: { resource: resourceData, filePath, path: concretePath },
246
201
  });
247
202
  }
248
- continue;
203
+ return;
249
204
  }
250
205
 
251
206
  // Name-only reference (plain string) — look up by name to validate.
@@ -263,7 +218,7 @@ export function validateReferences(
263
218
  // Multi-dot prefixes like "Alias.Kind.Name" are local resources with qualified
264
219
  // kinds — those must be validated.
265
220
  if (refKindPrefix && !refKindPrefix.includes(".") && aliases.hasAlias(refKindPrefix)) {
266
- continue;
221
+ return;
267
222
  }
268
223
  diagnostics.push({
269
224
  severity: DiagnosticSeverity.Error,
@@ -272,7 +227,7 @@ export function validateReferences(
272
227
  message: `${resourceLabel}: reference at '${concretePath}' → resource '${val}' not found`,
273
228
  data: { resource: resourceData, filePath, path: concretePath },
274
229
  });
275
- continue;
230
+ return;
276
231
  }
277
232
  const kindErrors = checkKind(target.kind as string, entry, registry, aliases);
278
233
  if (kindErrors.length > 0) {
@@ -284,14 +239,21 @@ export function validateReferences(
284
239
  data: { resource: resourceData, filePath, path: concretePath },
285
240
  });
286
241
  }
287
- continue;
242
+ return;
288
243
  }
289
244
 
290
- if (typeof val !== "object") continue;
245
+ if (typeof val !== "object") return;
291
246
  const refVal = val as Record<string, unknown>;
292
247
 
293
248
  // Skip inline resources — Phase 2 normalization hasn't run yet.
294
- if (isInlineResource(refVal)) continue;
249
+ if (isInlineResource(refVal)) return;
250
+
251
+ // Polymorphic ref slots (Application `targets`) accept object forms
252
+ // whose references live in nested slots rather than being a `{kind,
253
+ // name}` ref themselves — inline `{ invoke }` and gated `{ ref }`.
254
+ // Those nested refs are validated via their own field-map entries, so
255
+ // skip the item-level structural check here.
256
+ if (typeof refVal.kind !== "string" && ("invoke" in refVal || "ref" in refVal)) return;
295
257
 
296
258
  // 1. Structural check
297
259
  if (typeof refVal.kind !== "string" || typeof refVal.name !== "string") {
@@ -302,7 +264,7 @@ export function validateReferences(
302
264
  message: `${resourceLabel}: reference at '${concretePath}' must have string 'kind' and 'name' fields`,
303
265
  data: { resource: resourceData, filePath, path: concretePath },
304
266
  });
305
- continue;
267
+ return;
306
268
  }
307
269
 
308
270
  // 2. Kind check
@@ -330,177 +292,179 @@ export function validateReferences(
330
292
  data: { resource: resourceData, filePath, path: concretePath },
331
293
  });
332
294
  }
333
- }
334
- }
335
- }
295
+ },
296
+ },
297
+ { aliases, aliasesByModule, skipKinds: SYSTEM_KINDS, expand: true },
298
+ );
336
299
 
337
300
  // Phase 3b — x-telo-schema-from validation.
338
301
  // For each field with a schemaFrom path expression, resolve the anchor ref to get the
339
302
  // concrete kind, navigate the JSON Pointer into that kind's definition schema, and
340
- // validate the field value against the resulting sub-schema.
341
- for (const r of resources) {
342
- if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
343
-
344
- const fieldMap = registry.getFieldMapForKind(r.kind, aliases);
345
- if (!fieldMap) continue;
346
-
347
- const resourceLabel = `${r.kind}/${r.metadata.name as string}`;
348
- const resourceData = { kind: r.kind, name: r.metadata.name as string };
349
- const filePath = (r.metadata as { source?: string } | undefined)?.source;
350
-
351
- for (const [fieldPath, entry] of fieldMap) {
352
- if (!isSchemaFromEntry(entry)) continue;
353
-
354
- const { schemaFrom } = entry;
355
- const isAbsolute = schemaFrom.startsWith("/");
356
- const expr = isAbsolute ? schemaFrom.slice(1) : schemaFrom;
357
- const slashIdx = expr.indexOf("/");
358
- if (slashIdx === -1) {
359
- diagnostics.push({
360
- severity: DiagnosticSeverity.Error,
361
- code: "INVALID_SCHEMA_FROM",
362
- source: SOURCE,
363
- message: `${resourceLabel}: x-telo-schema-from "${schemaFrom}" must contain at least one "/" to separate anchor from JSON Pointer`,
364
- data: { resource: resourceData, filePath, path: fieldPath },
365
- });
366
- continue;
367
- }
368
-
369
- const anchorName = expr.slice(0, slashIdx);
370
- const jsonPointer = "/" + expr.slice(slashIdx + 1);
371
-
372
- // Aliased absolute kind path — first segment carries a dot, e.g.
373
- // "HttpDispatch.Outcomes/$defs/Returns". Resolves the alias through the
374
- // *kind owner's* scope (not the consumer's), navigates the JSON Pointer
375
- // into the resolved definition's schema, and validates each field value.
376
- //
377
- // Relative anchors are property names that cannot contain a dot
378
- // (CEL-style identifiers), so a dot in anchorName is unambiguous.
379
- if (!isAbsolute && anchorName.includes(".")) {
380
- const resolvedResourceKind = aliases.resolveKind(r.kind) ?? r.kind;
381
- const resourceDef =
382
- registry.resolve(r.kind) ?? registry.resolve(resolvedResourceKind);
383
- const owningModule = (resourceDef?.metadata as { module?: string } | undefined)?.module;
384
- const ownerScope =
385
- (owningModule ? aliasesByModule?.get(owningModule) : undefined) ?? aliases;
386
-
387
- const targetKind = ownerScope.resolveKind(anchorName);
388
- if (!targetKind) {
303
+ // validate the field value against the resulting sub-schema. Driven off the base map
304
+ // (un-expanded) so each schema-from slot is seen as its own site.
305
+ visitManifest(
306
+ resources,
307
+ registry,
308
+ {
309
+ onSchemaFrom: (e) => {
310
+ const r = e.source;
311
+ const fieldPath = e.fieldPath;
312
+ const resourceLabel = `${r.kind}/${r.metadata!.name as string}`;
313
+ const resourceData = { kind: r.kind, name: r.metadata!.name as string };
314
+ const filePath = (r.metadata as { source?: string } | undefined)?.source;
315
+
316
+ const { schemaFrom } = e.entry;
317
+ const isAbsolute = schemaFrom.startsWith("/");
318
+ const expr = isAbsolute ? schemaFrom.slice(1) : schemaFrom;
319
+ const slashIdx = expr.indexOf("/");
320
+ if (slashIdx === -1) {
389
321
  diagnostics.push({
390
322
  severity: DiagnosticSeverity.Error,
391
- code: "SCHEMA_FROM_MISSING_PATH",
323
+ code: "INVALID_SCHEMA_FROM",
392
324
  source: SOURCE,
393
- message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' cannot resolve alias '${anchorName}'`,
325
+ message: `${resourceLabel}: x-telo-schema-from "${schemaFrom}" must contain at least one "/" to separate anchor from JSON Pointer`,
394
326
  data: { resource: resourceData, filePath, path: fieldPath },
395
327
  });
396
- continue;
328
+ return;
397
329
  }
398
330
 
399
- const targetDef = registry.resolve(targetKind);
400
- if (!targetDef?.schema) {
401
- diagnostics.push({
402
- severity: DiagnosticSeverity.Error,
403
- code: "SCHEMA_FROM_MISSING_PATH",
404
- source: SOURCE,
405
- message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' kind '${targetKind}' has no schema`,
406
- data: { resource: resourceData, filePath, path: fieldPath },
407
- });
408
- continue;
409
- }
331
+ const anchorName = expr.slice(0, slashIdx);
332
+ const jsonPointer = "/" + expr.slice(slashIdx + 1);
333
+
334
+ // Aliased absolute kind path — first segment carries a dot, e.g.
335
+ // "HttpDispatch.Outcomes/$defs/Returns". Resolves the alias through the
336
+ // *kind owner's* scope (not the consumer's), navigates the JSON Pointer
337
+ // into the resolved definition's schema, and validates each field value.
338
+ //
339
+ // Relative anchors are property names that cannot contain a dot
340
+ // (CEL-style identifiers), so a dot in anchorName is unambiguous.
341
+ if (!isAbsolute && anchorName.includes(".")) {
342
+ const resolvedResourceKind = aliases.resolveKind(r.kind) ?? r.kind;
343
+ const resourceDef =
344
+ registry.resolve(r.kind) ?? registry.resolve(resolvedResourceKind);
345
+ const owningModule = (resourceDef?.metadata as { module?: string } | undefined)?.module;
346
+ const ownerScope =
347
+ (owningModule ? aliasesByModule?.get(owningModule) : undefined) ?? aliases;
348
+
349
+ const targetKind = ownerScope.resolveKind(anchorName);
350
+ if (!targetKind) {
351
+ diagnostics.push({
352
+ severity: DiagnosticSeverity.Error,
353
+ code: "SCHEMA_FROM_MISSING_PATH",
354
+ source: SOURCE,
355
+ message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → cannot resolve alias '${anchorName}'`,
356
+ data: { resource: resourceData, filePath, path: fieldPath },
357
+ });
358
+ return;
359
+ }
410
360
 
411
- const subSchema = navigateJsonPointer(targetDef.schema, jsonPointer);
412
- if (subSchema === undefined) {
413
- diagnostics.push({
414
- severity: DiagnosticSeverity.Error,
415
- code: "SCHEMA_FROM_MISSING_PATH",
416
- source: SOURCE,
417
- message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${targetKind}' has no schema path '${jsonPointer}'`,
418
- data: { resource: resourceData, filePath, path: fieldPath },
419
- });
420
- continue;
421
- }
361
+ const targetDef = registry.resolve(targetKind);
362
+ if (!targetDef?.schema) {
363
+ diagnostics.push({
364
+ severity: DiagnosticSeverity.Error,
365
+ code: "SCHEMA_FROM_MISSING_PATH",
366
+ source: SOURCE,
367
+ message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${targetKind}' has no schema`,
368
+ data: { resource: resourceData, filePath, path: fieldPath },
369
+ });
370
+ return;
371
+ }
422
372
 
423
- for (const { value: fieldValue, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
424
- if (fieldValue == null) continue;
425
- const issues = registry.validateWithRefs(fieldValue, subSchema as Record<string, any>);
426
- for (const issue of issues) {
373
+ const subSchema = navigateJsonPointer(targetDef.schema, jsonPointer);
374
+ if (subSchema === undefined) {
427
375
  diagnostics.push({
428
376
  severity: DiagnosticSeverity.Error,
429
- code: "DEPENDENT_SCHEMA_MISMATCH",
377
+ code: "SCHEMA_FROM_MISSING_PATH",
430
378
  source: SOURCE,
431
- message: `${resourceLabel}: '${concretePath}' does not match schema from '${anchorName}${jsonPointer}': ${issue}`,
432
- data: { resource: resourceData, filePath, path: concretePath },
379
+ message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' kind '${targetKind}' has no schema path '${jsonPointer}'`,
380
+ data: { resource: resourceData, filePath, path: fieldPath },
433
381
  });
382
+ return;
434
383
  }
384
+
385
+ for (const { value: fieldValue, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
386
+ if (fieldValue == null) continue;
387
+ const issues = registry.validateWithRefs(fieldValue, subSchema as Record<string, any>);
388
+ for (const issue of issues) {
389
+ diagnostics.push({
390
+ severity: DiagnosticSeverity.Error,
391
+ code: "DEPENDENT_SCHEMA_MISMATCH",
392
+ source: SOURCE,
393
+ message: `${resourceLabel}: '${concretePath}' does not match schema from '${anchorName}${jsonPointer}': ${issue}`,
394
+ data: { resource: resourceData, filePath, path: concretePath },
395
+ });
396
+ }
397
+ }
398
+ return;
435
399
  }
436
- continue;
437
- }
438
400
 
439
- // Derive the anchor path in the resource config.
440
- let anchorPath: string;
441
- if (isAbsolute) {
442
- anchorPath = anchorName;
443
- } else {
444
- // Relative: replace the last dot-segment of fieldPath with anchorName.
445
- // e.g. "nodes[].options" → "nodes[].backend"
446
- const lastDot = fieldPath.lastIndexOf(".");
447
- anchorPath = lastDot === -1 ? anchorName : fieldPath.slice(0, lastDot + 1) + anchorName;
448
- }
401
+ // Derive the anchor path in the resource config.
402
+ let anchorPath: string;
403
+ if (isAbsolute) {
404
+ anchorPath = anchorName;
405
+ } else {
406
+ // Relative: replace the last dot-segment of fieldPath with anchorName.
407
+ // e.g. "nodes[].options" → "nodes[].backend"
408
+ const lastDot = fieldPath.lastIndexOf(".");
409
+ anchorPath = lastDot === -1 ? anchorName : fieldPath.slice(0, lastDot + 1) + anchorName;
410
+ }
449
411
 
450
- const anchorValues = resolveFieldValues(r, anchorPath);
451
- if (anchorValues.length === 0) continue; // anchor field not set — nothing to validate
412
+ const anchorValues = resolveFieldValues(r, anchorPath);
413
+ if (anchorValues.length === 0) return; // anchor field not set — nothing to validate
452
414
 
453
- const fieldEntries = resolveFieldEntries(r, fieldPath);
415
+ const fieldEntries = resolveFieldEntries(r, fieldPath);
454
416
 
455
- for (let i = 0; i < fieldEntries.length; i++) {
456
- const { value: fieldValue, path: concretePath } = fieldEntries[i];
457
- if (fieldValue == null) continue;
417
+ for (let i = 0; i < fieldEntries.length; i++) {
418
+ const { value: fieldValue, path: concretePath } = fieldEntries[i];
419
+ if (fieldValue == null) continue;
458
420
 
459
- // For absolute paths, the single anchor applies to all field values.
460
- const anchorVal = isAbsolute ? anchorValues[0] : anchorValues[i];
461
- if (!anchorVal || typeof anchorVal !== "object") continue;
421
+ // For absolute paths, the single anchor applies to all field values.
422
+ const anchorVal = isAbsolute ? anchorValues[0] : anchorValues[i];
423
+ if (!anchorVal || typeof anchorVal !== "object") continue;
462
424
 
463
- const refVal = anchorVal as Record<string, unknown>;
464
- if (typeof refVal.kind !== "string") continue;
425
+ const refVal = anchorVal as Record<string, unknown>;
426
+ if (typeof refVal.kind !== "string") continue;
465
427
 
466
- const refResolvedKind = aliases.resolveKind(refVal.kind) ?? refVal.kind;
467
- const refDef = registry.resolve(refVal.kind) ?? registry.resolve(refResolvedKind);
468
- if (!refDef?.schema) {
469
- diagnostics.push({
470
- severity: DiagnosticSeverity.Error,
471
- code: "SCHEMA_FROM_MISSING_PATH",
472
- source: SOURCE,
473
- message: `${resourceLabel}: x-telo-schema-from at '${concretePath}' → kind '${refVal.kind}' has no schema`,
474
- data: { resource: resourceData, filePath, path: concretePath },
475
- });
476
- continue;
477
- }
428
+ const refResolvedKind = aliases.resolveKind(refVal.kind) ?? refVal.kind;
429
+ const refDef = registry.resolve(refVal.kind) ?? registry.resolve(refResolvedKind);
430
+ if (!refDef?.schema) {
431
+ diagnostics.push({
432
+ severity: DiagnosticSeverity.Error,
433
+ code: "SCHEMA_FROM_MISSING_PATH",
434
+ source: SOURCE,
435
+ message: `${resourceLabel}: x-telo-schema-from at '${concretePath}' → kind '${refVal.kind}' has no schema`,
436
+ data: { resource: resourceData, filePath, path: concretePath },
437
+ });
438
+ continue;
439
+ }
478
440
 
479
- const subSchema = navigateJsonPointer(refDef.schema, jsonPointer);
480
- if (subSchema === undefined) {
481
- diagnostics.push({
482
- severity: DiagnosticSeverity.Error,
483
- code: "SCHEMA_FROM_MISSING_PATH",
484
- source: SOURCE,
485
- message: `${resourceLabel}: x-telo-schema-from at '${concretePath}' → kind '${refVal.kind}' has no schema path '${jsonPointer}'`,
486
- data: { resource: resourceData, filePath, path: concretePath },
487
- });
488
- continue;
489
- }
441
+ const subSchema = navigateJsonPointer(refDef.schema, jsonPointer);
442
+ if (subSchema === undefined) {
443
+ diagnostics.push({
444
+ severity: DiagnosticSeverity.Error,
445
+ code: "SCHEMA_FROM_MISSING_PATH",
446
+ source: SOURCE,
447
+ message: `${resourceLabel}: x-telo-schema-from at '${concretePath}' → kind '${refVal.kind}' has no schema path '${jsonPointer}'`,
448
+ data: { resource: resourceData, filePath, path: concretePath },
449
+ });
450
+ continue;
451
+ }
490
452
 
491
- const issues = registry.validateWithRefs(fieldValue, subSchema as Record<string, any>);
492
- for (const issue of issues) {
493
- diagnostics.push({
494
- severity: DiagnosticSeverity.Error,
495
- code: "DEPENDENT_SCHEMA_MISMATCH",
496
- source: SOURCE,
497
- message: `${resourceLabel}: '${concretePath}' does not match schema from '${refVal.kind}${jsonPointer}': ${issue}`,
498
- data: { resource: resourceData, filePath, path: concretePath },
499
- });
453
+ const issues = registry.validateWithRefs(fieldValue, subSchema as Record<string, any>);
454
+ for (const issue of issues) {
455
+ diagnostics.push({
456
+ severity: DiagnosticSeverity.Error,
457
+ code: "DEPENDENT_SCHEMA_MISMATCH",
458
+ source: SOURCE,
459
+ message: `${resourceLabel}: '${concretePath}' does not match schema from '${refVal.kind}${jsonPointer}': ${issue}`,
460
+ data: { resource: resourceData, filePath, path: concretePath },
461
+ });
462
+ }
500
463
  }
501
- }
502
- }
503
- }
464
+ },
465
+ },
466
+ { aliases, aliasesByModule, skipKinds: SYSTEM_KINDS, expand: false },
467
+ );
504
468
 
505
469
  return diagnostics;
506
470
  }
@@ -0,0 +1,95 @@
1
+ import type { Environment } from "@marcbachmann/cel-js";
2
+ import type { ResourceManifest } from "@telorun/sdk";
3
+ import { extractAccessChains, INDEX_SEGMENT, walkCelExpressions } from "@telorun/templating";
4
+ import { type AnalysisDiagnostic, DiagnosticSeverity } from "./types.js";
5
+
6
+ const SOURCE = "telo-analyzer";
7
+
8
+ /** Module-doc namespaces whose entries are consumed via `<ns>.<name>` CEL
9
+ * access. One table drives the whole check — adding a namespace is an entry,
10
+ * not a branch. */
11
+ const NAMESPACES = ["variables", "secrets", "ports"] as const;
12
+
13
+ /**
14
+ * Warn about declared `variables` / `secrets` / `ports` entries that no CEL
15
+ * expression references. A declared-but-unconsumed entry is dead weight at
16
+ * best and misleading at worst (an unbound `ports` entry makes a runner
17
+ * advertise a port the app never listens on).
18
+ *
19
+ * Generic across all three namespaces. References are collected from every CEL
20
+ * expression (both `${{ … }}` and `!cel`, via `walkCelExpressions`) by
21
+ * extracting member-access chains: a `<ns>.<name>` chain marks `<name>` used.
22
+ * Dynamic access (`<ns>[expr]`, or the namespace passed whole, e.g.
23
+ * `keys(variables)`) yields a chain that stops at the namespace root — that
24
+ * can't be attributed to a name, so the whole namespace is conservatively
25
+ * suppressed to avoid false positives.
26
+ *
27
+ * Application-only: an Application's `variables` / `secrets` / `ports` flow
28
+ * exclusively through CEL (into resource fields, or into `Telo.Import` inputs),
29
+ * so unreferenced means dead. A `Telo.Library`'s `variables` / `secrets` are a
30
+ * public input contract consumed by its controllers — invisible to CEL
31
+ * analysis — so they are deliberately not flagged.
32
+ */
33
+ export function validateUnusedDeclarations(
34
+ manifests: ResourceManifest[],
35
+ celEnv: Environment,
36
+ ): AnalysisDiagnostic[] {
37
+ const moduleManifest = manifests.find((m) => m.kind === "Telo.Application") as
38
+ | Record<string, any>
39
+ | undefined;
40
+ if (!moduleManifest) return [];
41
+
42
+ const declared = new Map<string, string[]>();
43
+ for (const ns of NAMESPACES) {
44
+ const block = moduleManifest[ns];
45
+ if (block && typeof block === "object" && !Array.isArray(block)) {
46
+ const names = Object.keys(block);
47
+ if (names.length > 0) declared.set(ns, names);
48
+ }
49
+ }
50
+ if (declared.size === 0) return [];
51
+
52
+ const used = new Map<string, Set<string>>(NAMESPACES.map((ns) => [ns, new Set<string>()]));
53
+ const suppressed = new Set<string>();
54
+
55
+ for (const m of manifests) {
56
+ walkCelExpressions(m, "", (expr, _path, engineName) => {
57
+ if (engineName !== "cel") return;
58
+ let ast: unknown;
59
+ try {
60
+ ast = celEnv.parse(expr).ast;
61
+ } catch {
62
+ return; // syntax errors are reported by the CEL engine pass
63
+ }
64
+ for (const chain of extractAccessChains(ast as Parameters<typeof extractAccessChains>[0])) {
65
+ const ns = chain[0];
66
+ if (!used.has(ns)) continue;
67
+ const member = chain[1];
68
+ // No static member after the namespace root — either the namespace is
69
+ // used whole (`keys(ports)` → ["ports"]) or accessed dynamically
70
+ // (`ports[x]` → ["ports", "[*]"]). Neither can be attributed to a
71
+ // declared name, so suppress the namespace rather than false-positive.
72
+ if (member === undefined || member === INDEX_SEGMENT) suppressed.add(ns);
73
+ else used.get(ns)!.add(member);
74
+ }
75
+ });
76
+ }
77
+
78
+ const diagnostics: AnalysisDiagnostic[] = [];
79
+ const filePath = (moduleManifest.metadata as { source?: string } | undefined)?.source;
80
+ for (const [ns, names] of declared) {
81
+ if (suppressed.has(ns)) continue;
82
+ const seen = used.get(ns)!;
83
+ for (const name of names) {
84
+ if (seen.has(name)) continue;
85
+ diagnostics.push({
86
+ severity: DiagnosticSeverity.Warning,
87
+ code: "UNUSED_DECLARATION",
88
+ source: SOURCE,
89
+ message: `${ns}.${name} is declared but never referenced in any CEL expression.`,
90
+ data: { filePath, path: `${ns}.${name}` },
91
+ });
92
+ }
93
+ }
94
+ return diagnostics;
95
+ }