@telorun/analyzer 0.56.0 → 0.57.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 (40) hide show
  1. package/README.md +2 -2
  2. package/dist/analyzer.d.ts +5 -0
  3. package/dist/analyzer.d.ts.map +1 -1
  4. package/dist/analyzer.js +155 -94
  5. package/dist/flatten-for-analyzer.d.ts +20 -1
  6. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  7. package/dist/flatten-for-analyzer.js +48 -3
  8. package/dist/index.d.ts +4 -2
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +2 -1
  11. package/dist/kernel-globals.d.ts +25 -11
  12. package/dist/kernel-globals.d.ts.map +1 -1
  13. package/dist/kernel-globals.js +54 -24
  14. package/dist/manifest-visitor.d.ts +4 -0
  15. package/dist/manifest-visitor.d.ts.map +1 -1
  16. package/dist/manifest-visitor.js +3 -3
  17. package/dist/module-file-claims.d.ts +65 -0
  18. package/dist/module-file-claims.d.ts.map +1 -0
  19. package/dist/module-file-claims.js +106 -0
  20. package/dist/schema-compat.d.ts.map +1 -1
  21. package/dist/schema-compat.js +12 -1
  22. package/dist/types.d.ts +34 -0
  23. package/dist/types.d.ts.map +1 -1
  24. package/dist/types.js +6 -0
  25. package/dist/validate-include-placement.d.ts +26 -0
  26. package/dist/validate-include-placement.d.ts.map +1 -0
  27. package/dist/validate-include-placement.js +67 -0
  28. package/dist/validate-throws-coverage.d.ts.map +1 -1
  29. package/dist/validate-throws-coverage.js +15 -12
  30. package/package.json +4 -3
  31. package/src/analyzer.ts +189 -131
  32. package/src/flatten-for-analyzer.ts +61 -3
  33. package/src/index.ts +5 -1
  34. package/src/kernel-globals.ts +74 -25
  35. package/src/manifest-visitor.ts +11 -3
  36. package/src/module-file-claims.ts +168 -0
  37. package/src/schema-compat.ts +19 -1
  38. package/src/types.ts +37 -0
  39. package/src/validate-include-placement.ts +70 -0
  40. package/src/validate-throws-coverage.ts +16 -11
@@ -171,7 +171,7 @@ function checkCatchAllPlacement(entries, resource, channel, filePath, arrayPath)
171
171
  * in coverage-proving `when:` clauses. Phase 2 accepts inherit/passthrough
172
172
  * handler unions too — when the resolved union is unbounded, a catch-all is
173
173
  * required (rule 4 extension). */
174
- function checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env) {
174
+ function checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env, handler) {
175
175
  const diagnostics = [];
176
176
  const declaredCodes = new Set(union.codes.keys());
177
177
  const covered = new Set();
@@ -221,16 +221,19 @@ function checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env
221
221
  });
222
222
  }
223
223
  if (!hasCatchAll) {
224
- for (const code of declaredCodes) {
225
- if (!covered.has(code)) {
226
- diagnostics.push({
227
- severity: DiagnosticSeverity.Error,
228
- code: "UNCOVERED_THROW_CODE",
229
- source: SOURCE,
230
- message: `Code '${code}' is declared by the handler but not covered by any catches: entry (no matching \`when:\` and no catch-all).`,
231
- data: { resource, filePath, path: arrayPath },
232
- });
233
- }
224
+ // One diagnostic per block, not per code: every uncovered code sits at the
225
+ // same `catches:` array, and one catch-all answers all of them at once. A
226
+ // diagnostic each repeated the same location and the same fix N times.
227
+ const uncovered = [...declaredCodes].filter((c) => !covered.has(c)).sort();
228
+ if (uncovered.length > 0) {
229
+ diagnostics.push({
230
+ severity: DiagnosticSeverity.Error,
231
+ code: "UNCOVERED_THROW_CODE",
232
+ source: SOURCE,
233
+ message: `handler ${handler?.name ? `\`!ref ${handler.name}\`` : `\`${handler?.kind ?? "?"}\``} can throw ${uncovered.length} code${uncovered.length === 1 ? "" : "s"} that no catches: entry handles: ${uncovered.map((c) => `'${c}'`).join(", ")}. ` +
234
+ `Give each a matching \`when:\` (e.g. \`when: !cel "error.code == '${uncovered[0]}'"\`), or add a catch-all entry — one with no \`when:\`, placed last.`,
235
+ data: { resource, filePath, path: arrayPath, uncovered },
236
+ });
234
237
  }
235
238
  }
236
239
  return diagnostics;
@@ -447,7 +450,7 @@ export function validateThrowsCoverage(manifests, defs, aliases, env, aliasesByM
447
450
  diagnostics.push(...checkCatchAllPlacement(entries, resource, "catches", filePath, arrayPath));
448
451
  const handlerRef = resolveHandlerRef(siblingData[catchesFor]);
449
452
  const union = handlerRefUnion(handlerRef, manifests, resolveCtx, scopeResolver);
450
- diagnostics.push(...checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env));
453
+ diagnostics.push(...checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env, handlerRef));
451
454
  diagnostics.push(...checkTypedErrorData(entries, union, resource, filePath, arrayPath, env));
452
455
  });
453
456
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.56.0",
3
+ "version": "0.57.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -41,14 +41,15 @@
41
41
  "ajv": "^8.17.1",
42
42
  "ajv-formats": "^3.0.1",
43
43
  "jsonpath-plus": "^10.3.0",
44
+ "packageurl-js": "^2.0.1",
44
45
  "yaml": "^2.8.3",
45
- "@telorun/templating": "0.12.0"
46
+ "@telorun/templating": "0.13.0"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@types/node": "^20.0.0",
49
50
  "typescript": "^5.0.0",
50
51
  "vitest": "^2.1.8",
51
- "@telorun/sdk": "0.68.0"
52
+ "@telorun/sdk": "0.72.0"
52
53
  },
53
54
  "peerDependencies": {
54
55
  "@telorun/sdk": "*"
package/src/analyzer.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import { canonicalTypeSchemaId, OBSERVED_STATE_KEY } from "@telorun/sdk";
3
3
  import type { Environment } from "@marcbachmann/cel-js";
4
- import { defaultRegistry, isRefSentinel, isTaggedSentinel } from "@telorun/templating";
4
+ import {
5
+ defaultRegistry,
6
+ isRefSentinel,
7
+ isTaggedSentinel,
8
+ type CelSurface,
9
+ } from "@telorun/templating";
10
+ import type { DiagnosticFix } from "./types.js";
5
11
  import {
6
12
  AliasResolver,
7
13
  moduleScopedDefResolver,
@@ -25,7 +31,7 @@ import {
25
31
  import { buildCallGraph } from "./call-graph.js";
26
32
  import { buildDependencyGraph, formatCycle } from "./dependency-graph.js";
27
33
  import {
28
- buildKernelGlobalsSchema,
34
+ buildKernelGlobalsIndex,
29
35
  KERNEL_GLOBAL_NAMES,
30
36
  mergeKernelGlobalsIntoContext,
31
37
  } from "./kernel-globals.js";
@@ -84,6 +90,7 @@ import {
84
90
  import { validateExtends } from "./validate-extends.js";
85
91
  import { validateLogging } from "./validate-logging.js";
86
92
  import { validateModuleArtifact } from "./validate-module-artifact.js";
93
+ import { validateIncludePlacement } from "./validate-include-placement.js";
87
94
  import { validateModuleMetadata } from "./validate-module-metadata.js";
88
95
  import { validateBaseMapping } from "./validate-base-mapping.js";
89
96
  import { validateInvocationContract } from "./validate-invocation-contract.js";
@@ -906,89 +913,62 @@ function celAccessChains(env: Environment, expr: string): string[][] {
906
913
  const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
907
914
  const CEL_EXPR_RE = /\$\{\{\s*([^}]+?)\s*\}\}/;
908
915
 
909
- /** Recursively walk `data`+`schema` together, type-checking every pure CEL template
910
- * string via `env.check()`. Returns `SchemaIssue[]` for any type mismatches found. */
911
- function collectCelTypeIssues(
916
+ /** Restore the delimiters an engine's fix was computed without, so the
917
+ * replacement is the whole scalar rather than a bare expression that would be
918
+ * read back as literal text. A tagged scalar carries no wrapper and passes
919
+ * through untouched. */
920
+ function rewrapFix(
921
+ fix: DiagnosticFix | undefined,
922
+ wrapper: CelSurface["wrapper"],
923
+ ): DiagnosticFix | undefined {
924
+ if (!fix || !wrapper) return fix;
925
+ return { replacement: wrapper.prefix + fix.replacement + wrapper.suffix };
926
+ }
927
+
928
+ /** A pure-CEL leaf and the schema of the field it sits in. */
929
+ export interface CelValueSlot {
930
+ readonly path: string;
931
+ readonly schema: Record<string, any>;
932
+ }
933
+
934
+ /** Recursively walk `data`+`schema` together, collecting every pure CEL leaf
935
+ * with the schema of the field holding it.
936
+ *
937
+ * Type *checking* is deliberately not done here. It belongs to the templating
938
+ * engine, which owns the expression's syntax and now runs it once against the
939
+ * environment typed for that path; checking again here would mean two verdicts
940
+ * from two environments about one expression — which is exactly how an opaque
941
+ * "no matching overload" used to survive next to the diagnostic that explained
942
+ * it. What this walk supplies is the half the engine cannot know: the declared
943
+ * type of the slot the value flows into. The comparison happens once both are
944
+ * in hand (`reportCelReturnMismatches`). */
945
+ function collectCelValueSlots(
912
946
  data: unknown,
913
947
  schema: Record<string, any>,
914
948
  path: string,
915
- definition: { schema?: Record<string, any> },
916
- manifest: ResourceManifest,
917
- baseTypedEnv: Environment,
918
- rootEnv: Environment,
919
- rootModuleManifest?: ResourceManifest,
920
- ): SchemaIssue[] {
921
- const issues: SchemaIssue[] = [];
922
-
923
- // A pure CEL value type-checks the same regardless of surface form: a
924
- // `${{ … }}` string and a `!cel`-tagged sentinel must behave identically.
949
+ ): CelValueSlot[] {
950
+ const slots: CelValueSlot[] = [];
951
+
952
+ // A pure CEL value behaves the same regardless of surface form: a
953
+ // `${{ … }}` string and a `!cel`-tagged sentinel are the same expression.
925
954
  let celExpr: string | undefined;
926
955
  if (isTaggedSentinel(data)) {
927
956
  // Non-CEL engines (e.g. `!literal`) are analyzed by their own engine pass.
928
- if (data.engine !== "cel") return issues;
957
+ if (data.engine !== "cel") return slots;
929
958
  celExpr = data.source;
930
959
  } else if (typeof data === "string" && CEL_PURE_RE.test(data)) {
931
960
  celExpr = data.match(CEL_EXPR_RE)?.[1]?.trim();
932
961
  }
933
962
 
934
963
  if (celExpr !== undefined) {
935
- {
936
- const expr = celExpr;
937
-
938
- // Merge x-telo-context variables for this path if applicable
939
- let typedEnv = baseTypedEnv;
940
- if (definition.schema) {
941
- for (const ctx of extractContextsFromSchema(definition.schema)) {
942
- if (!pathMatchesScope(path, ctx.scope)) continue;
943
- typedEnv = buildTypedCelEnvironment(rootEnv, manifest, ctx.schema, rootModuleManifest);
944
- break;
945
- }
946
- }
947
-
948
- let checkResult: ReturnType<typeof typedEnv.check> | undefined;
949
- try {
950
- checkResult = typedEnv.check(expr);
951
- } catch {
952
- /* degrade gracefully */
953
- }
954
-
955
- if (checkResult?.valid === false && checkResult.error) {
956
- // env.check() rejected the expression itself — e.g. wrong method, wrong
957
- // argument types, wrong operator overload. Surface the first line of the
958
- // error message; the tail is a source-code caret diagram we don't need.
959
- const message = String((checkResult.error as { message?: string }).message ?? checkResult.error)
960
- .split("\n")[0]
961
- .trim();
962
- issues.push({ message: `CEL type error: ${message}`, path });
963
- } else if (checkResult?.valid && checkResult.type && schema) {
964
- const celType = checkResult.type.split("<")[0]!;
965
- if (!celTypeSatisfiesJsonSchema(celType, schema)) {
966
- const expected = schema["x-telo-type"] ?? schema.type ?? "unknown";
967
- issues.push({
968
- message: `CEL returns '${checkResult.type}' but field expects '${expected}'`,
969
- path,
970
- });
971
- }
972
- }
973
- }
974
- return issues;
964
+ if (schema) slots.push({ path, schema });
965
+ return slots;
975
966
  }
976
967
 
977
968
  if (Array.isArray(data)) {
978
969
  const itemSchema = (schema.items ?? {}) as Record<string, any>;
979
970
  for (let i = 0; i < data.length; i++) {
980
- issues.push(
981
- ...collectCelTypeIssues(
982
- data[i],
983
- itemSchema,
984
- `${path}[${i}]`,
985
- definition,
986
- manifest,
987
- baseTypedEnv,
988
- rootEnv,
989
- rootModuleManifest,
990
- ),
991
- );
971
+ slots.push(...collectCelValueSlots(data[i], itemSchema, `${path}[${i}]`));
992
972
  }
993
973
  } else if (data !== null && typeof data === "object") {
994
974
  const props = (schema.properties ?? {}) as Record<string, any>;
@@ -997,22 +977,17 @@ function collectCelTypeIssues(
997
977
  ? (schema.additionalProperties as Record<string, any>)
998
978
  : {};
999
979
  for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
1000
- issues.push(
1001
- ...collectCelTypeIssues(
980
+ slots.push(
981
+ ...collectCelValueSlots(
1002
982
  v,
1003
983
  (props[k] ?? mapValueSchema) as Record<string, any>,
1004
984
  path ? `${path}.${k}` : k,
1005
- definition,
1006
- manifest,
1007
- baseTypedEnv,
1008
- rootEnv,
1009
- rootModuleManifest,
1010
985
  ),
1011
986
  );
1012
987
  }
1013
988
  }
1014
989
 
1015
- return issues;
990
+ return slots;
1016
991
  }
1017
992
 
1018
993
  export interface StaticAnalyzerOptions {
@@ -1501,6 +1476,9 @@ export class StaticAnalyzer {
1501
1476
  // these fields, which is precisely why they need a check: a mistyped one
1502
1477
  // has no runtime failure mode that would ever surface it.
1503
1478
  diagnostics.push(...validateModuleMetadata(allManifests, defs, aliases));
1479
+ // A file embed resolves at resource creation, so one written on a doc that
1480
+ // is never instantiated is read by nothing and would ship silently.
1481
+ diagnostics.push(...validateIncludePlacement(allManifests));
1504
1482
  }
1505
1483
  resolveSchemaTypeRefs(allManifests, aliases, aliasesByModule);
1506
1484
 
@@ -1613,7 +1591,7 @@ export class StaticAnalyzer {
1613
1591
 
1614
1592
  // Build typed kernel globals schema so x-telo-context chain validation
1615
1593
  // recognises variables, secrets, resources, env automatically
1616
- const kernelGlobals = buildKernelGlobalsSchema(allManifests, observedState);
1594
+ const kernelGlobals = buildKernelGlobalsIndex(allManifests, observedState);
1617
1595
 
1618
1596
  // Fallback context for CEL in a slot with no `x-telo-context` annotation:
1619
1597
  // everything stays open except the typed `.status` nodes, so unknown-field
@@ -1632,11 +1610,25 @@ export class StaticAnalyzer {
1632
1610
 
1633
1611
  // The module doc (Application/Library) carries the Application-only `ports`
1634
1612
  // namespace; threaded into per-resource CEL typing so `${{ ports.X }}`
1635
- // resolves its nominal brand cross-doc.
1613
+ // resolves its nominal brand cross-doc. A flattened set holds exactly one —
1614
+ // the entry's; see `buildKernelGlobalsSchema`.
1636
1615
  const moduleManifest =
1637
1616
  allManifests.find((mm) => mm.kind === "Telo.Application") ??
1638
1617
  allManifests.find((mm) => mm.kind === "Telo.Library");
1639
1618
 
1619
+ // Every pure-CEL leaf, with the slot it flows into. Compared against the
1620
+ // type the engine walk resolves, once both halves exist — see
1621
+ // `collectCelValueSlots`.
1622
+ const celReturnSlots: (CelValueSlot & {
1623
+ manifest: ResourceManifest;
1624
+ resource: { kind: string; name: string };
1625
+ filePath?: string;
1626
+ })[] = [];
1627
+ const celTypeByPath = new Map<ResourceManifest, Map<string, string>>();
1628
+ // Context-free typed environments, one per manifest. Reused across every
1629
+ // expression in it — see the build site for why a matched context opts out.
1630
+ const typedEnvByManifest = new Map<ResourceManifest, Environment>();
1631
+
1640
1632
  // Validate each non-definition, non-system resource
1641
1633
  for (const m of allManifests) {
1642
1634
  const filePath = (m.metadata as { source?: string } | undefined)?.source;
@@ -1721,7 +1713,15 @@ export class StaticAnalyzer {
1721
1713
  code: "UNDEFINED_KIND",
1722
1714
  source: SOURCE,
1723
1715
  message: `No Telo.Definition found for kind '${m.kind}'.${hint}`,
1724
- data: { resource, filePath, path: "kind", suggestedKind },
1716
+ // `suggestedKind` is kept beside the generic `fix` because it names
1717
+ // what the replacement IS; the fix is how to apply it.
1718
+ data: {
1719
+ resource,
1720
+ filePath,
1721
+ path: "kind",
1722
+ suggestedKind,
1723
+ ...(suggestedKind ? { fix: { replacement: suggestedKind } } : {}),
1724
+ },
1725
1725
  });
1726
1726
  continue;
1727
1727
  }
@@ -1748,37 +1748,12 @@ export class StaticAnalyzer {
1748
1748
  },
1749
1749
  }
1750
1750
  : authorSchema;
1751
- // Phase 1: CEL type checking walk data+schema together, check env.check() return types.
1752
- // A Telo.Import's variables/secrets are a config-only contract evaluated against the
1753
- // IMPORTING module's scope, so type them from the owning module doc (matched by
1754
- // `metadata.module`) and drop `resources`/`env` so referencing them is an error. A
1755
- // library's own internal import is validated against that library in the library's
1756
- // standalone analysis; in this flattened app pass the library doc is absent, so the
1757
- // importer is undefined here and variables/secrets fall back to a permissive `map`
1758
- // (no false positives) while resources/env stay rejected.
1759
- const importerModule =
1760
- m.kind === "Telo.Import"
1761
- ? allManifests.find(
1762
- (mm) =>
1763
- (mm.kind === "Telo.Application" || mm.kind === "Telo.Library") &&
1764
- (mm.metadata as { name?: string } | undefined)?.name ===
1765
- (m.metadata as { module?: string } | undefined)?.module,
1766
- )
1767
- : undefined;
1768
- const baseTypedEnv =
1769
- m.kind === "Telo.Import"
1770
- ? buildImportInputCelEnvironment(this.celEnv, importerModule)
1771
- : buildTypedCelEnvironment(this.celEnv, m, undefined, moduleManifest);
1772
- const celIssues = collectCelTypeIssues(
1773
- m,
1774
- schema,
1775
- "",
1776
- definition,
1777
- m,
1778
- baseTypedEnv,
1779
- this.celEnv,
1780
- moduleManifest,
1781
- );
1751
+ // Phase 1: collect the pure-CEL leaves and the schema of the slot each
1752
+ // flows into. The expression's own type is resolved later, by the
1753
+ // engine walk that owns type-checking; this half only knows the target.
1754
+ for (const slot of collectCelValueSlots(m, schema, "")) {
1755
+ celReturnSlots.push({ manifest: m, resource, filePath, ...slot });
1756
+ }
1782
1757
  // Phase 2+3: AJV on substituted data — CEL fields replaced with typed placeholders
1783
1758
  const ajvIssues = validateAgainstSchema(substituteCelFields(m, schema), schema);
1784
1759
  // Phase 4: value slots that must satisfy a type declared elsewhere on
@@ -1790,7 +1765,7 @@ export class StaticAnalyzer {
1790
1765
  schema,
1791
1766
  allManifests as Record<string, any>[],
1792
1767
  );
1793
- const issues = [...celIssues, ...ajvIssues, ...valueSchemaIssues];
1768
+ const issues = [...ajvIssues, ...valueSchemaIssues];
1794
1769
  for (const issue of issues) {
1795
1770
  diagnostics.push({
1796
1771
  severity: DiagnosticSeverity.Error,
@@ -2217,7 +2192,10 @@ export class StaticAnalyzer {
2217
2192
  });
2218
2193
  effectiveContext = mergeKernelGlobalsIntoContext(
2219
2194
  withBindingNames(resolvedContext, m as Record<string, any>),
2220
- kernelGlobals,
2195
+ // Typed in the module that DECLARED this resource — for a manifest
2196
+ // forwarded from an imported library, that is its `moduleGlobals`
2197
+ // stamp, not the consuming application's block.
2198
+ kernelGlobals.forResource(m),
2221
2199
  );
2222
2200
  } else if (observedStateContext) {
2223
2201
  // No `x-telo-context` matched, so nothing was chain-validated here
@@ -2240,42 +2218,104 @@ export class StaticAnalyzer {
2240
2218
  });
2241
2219
  return;
2242
2220
  }
2243
- const findings = engine.analyze(expr, { celEnv: this.celEnv, contextSchema: effectiveContext });
2244
- for (const f of findings) {
2245
- if (f.code === "CEL_SYNTAX_ERROR") {
2221
+ // The engine type-checks, so it gets the environment typed for THIS
2222
+ // path not the bare base one. A `Telo.Import`'s variables/secrets
2223
+ // are a config-only contract evaluated in the IMPORTING module's
2224
+ // scope, so they type from the owning module doc and drop
2225
+ // `resources`/`env`, making a reference to either an error.
2226
+ //
2227
+ // Cached per manifest when no `x-telo-context` applied, which is most
2228
+ // expressions: the environment then depends only on the manifest, so
2229
+ // rebuilding it per expression is pure waste — a clone plus a
2230
+ // re-registration of every variable, on every keystroke in the IDE.
2231
+ // A matched context makes the environment path-specific (its schema is
2232
+ // resolved against the enclosing array item), so those still build
2233
+ // fresh rather than risk one item's types leaking into another's.
2234
+ const cached = effectiveContext === null ? typedEnvByManifest.get(m) : undefined;
2235
+ const typedEnv =
2236
+ cached ??
2237
+ (m.kind === "Telo.Import"
2238
+ ? buildImportInputCelEnvironment(
2239
+ this.celEnv,
2240
+ allManifests.find(
2241
+ (mm) =>
2242
+ (mm.kind === "Telo.Application" || mm.kind === "Telo.Library") &&
2243
+ (mm.metadata as { name?: string } | undefined)?.name ===
2244
+ (m.metadata as { module?: string } | undefined)?.module,
2245
+ ),
2246
+ )
2247
+ : buildTypedCelEnvironment(
2248
+ this.celEnv,
2249
+ m,
2250
+ effectiveContext ?? undefined,
2251
+ moduleManifest,
2252
+ ));
2253
+ if (effectiveContext === null && !cached) typedEnvByManifest.set(m, typedEnv);
2254
+
2255
+ const result = engine.analyze(expr, { celEnv: typedEnv, contextSchema: effectiveContext });
2256
+
2257
+ if (result.type !== undefined) {
2258
+ let byPath = celTypeByPath.get(m);
2259
+ if (!byPath) celTypeByPath.set(m, (byPath = new Map()));
2260
+ byPath.set(path, result.type);
2261
+ }
2262
+
2263
+ // A non-deterministic call in a compile-eval field is baked once at
2264
+ // load: `nowIso()` there freezes at boot. Sometimes that is the
2265
+ // intent (a boot timestamp, a run id), so it warns rather than
2266
+ // blocking. The engine reports which calls re-evaluate; the eval mode
2267
+ // is manifest policy and stays here.
2268
+ if (celRuleApplies && evalPathsCover(celCompilePaths, path)) {
2269
+ const volatile = [
2270
+ ...new Set(result.calls.filter((c) => c.deterministic === false).map((c) => c.name)),
2271
+ ].sort();
2272
+ if (volatile.length > 0) {
2246
2273
  diagnostics.push({
2247
- severity: DiagnosticSeverity.Error,
2248
- code: "CEL_SYNTAX_ERROR",
2274
+ severity: DiagnosticSeverity.Warning,
2275
+ code: "CEL_NONDETERMINISTIC_IN_COMPILE_FIELD",
2249
2276
  source: SOURCE,
2250
- message: `CEL syntax error at ${path}: ${f.message}`,
2277
+ message: `${m.kind}/${resource.name}: '${path}' is evaluated once at startup, so ${volatile.map((n) => `\`${n}()\``).join(", ")} ${volatile.length === 1 ? "is" : "are"} baked in at load and never re-evaluated. Move the expression to a field evaluated per call (x-telo-eval: runtime) if it should change over time.`,
2251
2278
  data: { resource, filePath, path },
2252
2279
  });
2253
- } else if (f.code === "CEL_UNKNOWN_FIELD") {
2280
+ }
2281
+ }
2282
+
2283
+ for (const f of result.diagnostics) {
2284
+ // A repair is applicable only when the analyzed expression covers
2285
+ // the whole scalar. For one `${{ }}` among literal text, replacing
2286
+ // the node would drop the text around it, so the correction stays
2287
+ // in the message and no fix is stamped.
2288
+ const fix = e.surface.whole ? rewrapFix(f.fix, e.surface.wrapper) : undefined;
2289
+ const data = { resource, filePath, path, ...(fix ? { fix } : {}) };
2290
+ if (f.code === "CEL_SYNTAX_ERROR") {
2254
2291
  diagnostics.push({
2255
2292
  severity: DiagnosticSeverity.Error,
2256
- code: "CEL_UNKNOWN_FIELD",
2293
+ code: "CEL_SYNTAX_ERROR",
2257
2294
  source: SOURCE,
2258
- message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
2259
- data: { resource, filePath, path },
2295
+ message: `CEL syntax error at ${path}: ${f.message}`,
2296
+ data,
2260
2297
  });
2261
- } else if (f.code === "CEL_NULLABLE_ACCESS") {
2298
+ } else if (f.code === undefined) {
2299
+ // No code from a future engine — pass the message through, tagged
2300
+ // with a generic ENGINE_DIAGNOSTIC code so downstream filters can
2301
+ // still bucket it.
2262
2302
  diagnostics.push({
2263
2303
  severity: DiagnosticSeverity.Error,
2264
- code: "CEL_NULLABLE_ACCESS",
2304
+ code: "ENGINE_DIAGNOSTIC",
2265
2305
  source: SOURCE,
2266
- message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
2267
- data: { resource, filePath, path },
2306
+ message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
2307
+ data,
2268
2308
  });
2269
2309
  } else {
2270
- // Unknown code from a future engine pass the message through,
2271
- // tagged with a generic ENGINE_DIAGNOSTIC code so downstream
2272
- // filters can still bucket it.
2310
+ // Named by ENGINE, not hardcoded to CEL: the seam exists so a
2311
+ // second engine can produce coded findings, and labelling them
2312
+ // `CEL` would misattribute the first one that does.
2273
2313
  diagnostics.push({
2274
2314
  severity: DiagnosticSeverity.Error,
2275
- code: f.code ?? "ENGINE_DIAGNOSTIC",
2315
+ code: f.code,
2276
2316
  source: SOURCE,
2277
2317
  message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
2278
- data: { resource, filePath, path },
2318
+ data,
2279
2319
  });
2280
2320
  }
2281
2321
  }
@@ -2284,6 +2324,24 @@ export class StaticAnalyzer {
2284
2324
  { aliases },
2285
2325
  );
2286
2326
 
2327
+ // The two halves of "does this expression fit the slot it flows into" meet
2328
+ // here: the engine resolved the expression's type during the walk above,
2329
+ // and the schema walk recorded the slot. An expression that failed to check
2330
+ // recorded no type and is already reported by its own diagnostic.
2331
+ for (const slot of celReturnSlots) {
2332
+ const type = celTypeByPath.get(slot.manifest)?.get(slot.path);
2333
+ if (type === undefined) continue;
2334
+ if (celTypeSatisfiesJsonSchema(type.split("<")[0]!, slot.schema)) continue;
2335
+ const expected = slot.schema["x-telo-type"] ?? slot.schema.type ?? "unknown";
2336
+ diagnostics.push({
2337
+ severity: DiagnosticSeverity.Error,
2338
+ code: "CEL_TYPE_ERROR",
2339
+ source: SOURCE,
2340
+ message: `${slot.resource.kind}/${slot.resource.name}: CEL at '${slot.path}' returns '${type}' but the field expects '${expected}'.`,
2341
+ data: { resource: slot.resource, filePath: slot.filePath, path: slot.path },
2342
+ });
2343
+ }
2344
+
2287
2345
  // Validate resource references (Phase 3)
2288
2346
  diagnostics.push(
2289
2347
  ...validateReferences(allManifests, { aliases, definitions: defs, aliasesByModule }),
@@ -41,7 +41,17 @@ export function parseExportEntry(entry: string): ParsedExportEntry {
41
41
  * `byModuleName` and `!ref Alias.name` resolves, while `validate-references` /
42
42
  * the per-resource validation loop never re-walk or re-validate it against the
43
43
  * consumer's scope. A consumer that instead emits every module doc as a peer
44
- * local manifest silently breaks both. */
44
+ * local manifest silently breaks both.
45
+ *
46
+ * Dropping the module doc costs the consumer's analysis the one thing a
47
+ * forwarded manifest's CEL still needs: the `variables` / `secrets` / `ports`
48
+ * contract its expressions are evaluated against. That contract is carried
49
+ * across as {@link ModuleGlobals} on each forwarded manifest instead, so the
50
+ * consumer can check those reads in the DECLARING module's scope rather than
51
+ * either skipping them (a diagnostic performed nowhere) or judging them by its
52
+ * own block (a hard error the library author cannot act on). Stamped here
53
+ * because this is the only point where a manifest and its module doc are both
54
+ * in hand — by the time `analyze()` sees the flat list, the doc is gone. */
45
55
  export function selectModuleManifestsForAnalysis(
46
56
  moduleManifests: ResourceManifest[],
47
57
  isRoot: boolean,
@@ -58,10 +68,12 @@ export function selectModuleManifestsForAnalysis(
58
68
  exportedResources.add(parseExportEntry(entry).name);
59
69
  }
60
70
 
71
+ const moduleGlobals = readModuleGlobals(libDoc);
72
+
61
73
  const out: ResourceManifest[] = [];
62
74
  for (const m of moduleManifests) {
63
75
  if (m.kind === "Telo.Definition" || m.kind === "Telo.Abstract" || m.kind === "Telo.Import") {
64
- out.push(m);
76
+ out.push(withModuleGlobals(m, moduleGlobals));
65
77
  } else if (
66
78
  !isModuleKind(m.kind) &&
67
79
  typeof m.metadata?.name === "string" &&
@@ -69,13 +81,59 @@ export function selectModuleManifestsForAnalysis(
69
81
  ) {
70
82
  out.push({
71
83
  ...m,
72
- metadata: { ...m.metadata, forwardedExport: true } as ResourceManifest["metadata"],
84
+ metadata: {
85
+ ...m.metadata,
86
+ forwardedExport: true,
87
+ ...(moduleGlobals ? { moduleGlobals } : {}),
88
+ } as ResourceManifest["metadata"],
73
89
  });
74
90
  }
75
91
  }
76
92
  return out;
77
93
  }
78
94
 
95
+ /** The declaring module's config contract, carried on a forwarded manifest so
96
+ * the consumer's analysis can type its CEL globals in the right scope. Only the
97
+ * namespaces whose typing is scope-dependent: `resources` is deliberately
98
+ * absent, see {@link readModuleGlobals}. */
99
+ export interface ModuleGlobals {
100
+ variables?: Record<string, unknown>;
101
+ secrets?: Record<string, unknown>;
102
+ ports?: Record<string, unknown>;
103
+ }
104
+
105
+ /** Read the schema-map blocks off a module doc, or `undefined` when it declares
106
+ * none (the globals then stay permissive, exactly as an absent doc does).
107
+ *
108
+ * `resources` is NOT carried. The flat list holds only this module's EXPORTED
109
+ * instances, so a name list built from it would omit every internal one — and a
110
+ * `with:`-scoped resource is declared inline rather than as a doc, so it would
111
+ * be missing too. Both would read as "no such resource" in a consumer's pass:
112
+ * a false positive worse than the unchecked read it replaced. The consumer
113
+ * leaves a forwarded manifest's `resources` node open and the library's own
114
+ * analysis, which sees every name, does that check. */
115
+ function readModuleGlobals(libDoc: ResourceManifest | undefined): ModuleGlobals | undefined {
116
+ const doc = libDoc as Record<string, any> | undefined;
117
+ if (!doc) return undefined;
118
+ const globals: ModuleGlobals = {};
119
+ for (const key of ["variables", "secrets", "ports"] as const) {
120
+ const block = doc[key];
121
+ if (block && typeof block === "object" && !Array.isArray(block)) globals[key] = block;
122
+ }
123
+ return Object.keys(globals).length > 0 ? globals : undefined;
124
+ }
125
+
126
+ function withModuleGlobals(
127
+ m: ResourceManifest,
128
+ moduleGlobals: ModuleGlobals | undefined,
129
+ ): ResourceManifest {
130
+ if (!moduleGlobals) return m;
131
+ return {
132
+ ...m,
133
+ metadata: { ...m.metadata, moduleGlobals } as ResourceManifest["metadata"],
134
+ };
135
+ }
136
+
79
137
  /** Produce the flat manifest list `analyze()` consumes today.
80
138
  *
81
139
  * Combines the entry module's manifests with `Telo.Definition`,
package/src/index.ts CHANGED
@@ -207,6 +207,8 @@ export type {
207
207
  PlatformAxis,
208
208
  PlatformTarget,
209
209
  } from "./artifact-selector.js";
210
+ export { collectModuleFileClaims } from "./module-file-claims.js";
211
+ export type { ModuleFileClaim } from "./module-file-claims.js";
210
212
  export {
211
213
  LayerIndexError,
212
214
  matchControllerLayers,
@@ -224,10 +226,12 @@ export { documentToAst, parseToAst } from "./yaml-ast.js";
224
226
  export type { AstDocument, AstMap, AstNode, AstPair, AstScalar, AstSeq } from "./yaml-ast.js";
225
227
  export { CelParseError, buildCelSegments, wrapCelAst } from "./cel-ast.js";
226
228
  export type { CelNode, CelSegment } from "./cel-ast.js";
227
- export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
229
+ export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity, diagnosticFix } from "./types.js";
228
230
  export type {
229
231
  AnalysisDiagnostic,
230
232
  AnalysisOptions,
233
+ DiagnosticData,
234
+ DiagnosticFix,
231
235
  LoaderInitOptions,
232
236
  LoadOptions,
233
237
  ManifestSource,