@telorun/analyzer 0.65.0 → 0.66.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 (100) hide show
  1. package/dist/analyzer.d.ts +1 -1
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/analyzer.js +157 -63
  4. package/dist/call-graph.d.ts.map +1 -1
  5. package/dist/call-graph.js +7 -1
  6. package/dist/cel-environment.d.ts +12 -0
  7. package/dist/cel-environment.d.ts.map +1 -1
  8. package/dist/cel-environment.js +35 -19
  9. package/dist/cel-scope.d.ts.map +1 -1
  10. package/dist/cel-scope.js +24 -4
  11. package/dist/definition-registry.d.ts +24 -0
  12. package/dist/definition-registry.d.ts.map +1 -1
  13. package/dist/definition-registry.js +41 -0
  14. package/dist/eval-paths.d.ts +68 -0
  15. package/dist/eval-paths.d.ts.map +1 -1
  16. package/dist/eval-paths.js +138 -0
  17. package/dist/flatten-for-analyzer.d.ts +1 -1
  18. package/dist/flatten-for-analyzer.js +1 -1
  19. package/dist/import-resolution-diagnostics.d.ts +1 -1
  20. package/dist/import-resolution-diagnostics.js +1 -1
  21. package/dist/index.d.ts +9 -7
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +6 -5
  24. package/dist/manifest-visitor.d.ts.map +1 -1
  25. package/dist/manifest-visitor.js +8 -1
  26. package/dist/migrations/entries/index.d.ts.map +1 -1
  27. package/dist/migrations/entries/index.js +2 -0
  28. package/dist/migrations/entries/schema-prepare-bucket.json +27 -0
  29. package/dist/peer-binding.d.ts +232 -0
  30. package/dist/peer-binding.d.ts.map +1 -0
  31. package/dist/peer-binding.js +418 -0
  32. package/dist/ref-slot.d.ts +13 -0
  33. package/dist/ref-slot.d.ts.map +1 -1
  34. package/dist/ref-slot.js +26 -0
  35. package/dist/reference-field-map.d.ts +28 -0
  36. package/dist/reference-field-map.d.ts.map +1 -1
  37. package/dist/reference-field-map.js +25 -0
  38. package/dist/referrer-rule.d.ts +17 -0
  39. package/dist/referrer-rule.d.ts.map +1 -1
  40. package/dist/referrer-rule.js +4 -1
  41. package/dist/release/index.d.ts +1 -1
  42. package/dist/release/index.js +1 -1
  43. package/dist/resource-rule.d.ts +40 -27
  44. package/dist/resource-rule.d.ts.map +1 -1
  45. package/dist/resource-rule.js +61 -7
  46. package/dist/rule-condition.d.ts +11 -0
  47. package/dist/rule-condition.d.ts.map +1 -1
  48. package/dist/rule-condition.js +13 -0
  49. package/dist/schema-projection.d.ts +106 -4
  50. package/dist/schema-projection.d.ts.map +1 -1
  51. package/dist/schema-projection.js +206 -18
  52. package/dist/telo-version.d.ts +1 -1
  53. package/dist/telo-version.js +1 -1
  54. package/dist/validate-cel-context.d.ts +1 -17
  55. package/dist/validate-cel-context.d.ts.map +1 -1
  56. package/dist/validate-cel-context.js +5 -70
  57. package/dist/validate-reference-forms.d.ts +10 -0
  58. package/dist/validate-reference-forms.d.ts.map +1 -1
  59. package/dist/validate-reference-forms.js +23 -0
  60. package/dist/validate-references.d.ts.map +1 -1
  61. package/dist/validate-references.js +9 -2
  62. package/dist/validate-referrer-rules.d.ts +38 -4
  63. package/dist/validate-referrer-rules.d.ts.map +1 -1
  64. package/dist/validate-referrer-rules.js +139 -31
  65. package/dist/validate-resource-rules.d.ts +11 -2
  66. package/dist/validate-resource-rules.d.ts.map +1 -1
  67. package/dist/validate-resource-rules.js +18 -12
  68. package/dist/validate-schema-projection.d.ts.map +1 -1
  69. package/dist/validate-schema-projection.js +33 -2
  70. package/dist/with-synthetic-positions.d.ts +1 -1
  71. package/dist/with-synthetic-positions.js +1 -1
  72. package/package.json +3 -3
  73. package/src/analyzer.ts +191 -68
  74. package/src/call-graph.ts +7 -1
  75. package/src/cel-environment.ts +48 -19
  76. package/src/cel-scope.ts +24 -6
  77. package/src/definition-registry.ts +42 -0
  78. package/src/eval-paths.ts +152 -0
  79. package/src/flatten-for-analyzer.ts +1 -1
  80. package/src/import-resolution-diagnostics.ts +1 -1
  81. package/src/index.ts +45 -4
  82. package/src/manifest-visitor.ts +8 -1
  83. package/src/migrations/entries/index.ts +2 -0
  84. package/src/migrations/entries/schema-prepare-bucket.json +27 -0
  85. package/src/peer-binding.ts +641 -0
  86. package/src/ref-slot.ts +36 -0
  87. package/src/reference-field-map.ts +42 -0
  88. package/src/referrer-rule.ts +20 -1
  89. package/src/release/index.ts +1 -1
  90. package/src/resource-rule.ts +69 -7
  91. package/src/rule-condition.ts +15 -0
  92. package/src/schema-projection.ts +283 -20
  93. package/src/telo-version.ts +1 -1
  94. package/src/validate-cel-context.ts +6 -71
  95. package/src/validate-reference-forms.ts +22 -0
  96. package/src/validate-references.ts +15 -2
  97. package/src/validate-referrer-rules.ts +215 -28
  98. package/src/validate-resource-rules.ts +26 -12
  99. package/src/validate-schema-projection.ts +50 -1
  100. package/src/with-synthetic-positions.ts +1 -1
package/src/analyzer.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  buildCelEnvironment,
21
21
  buildImportInputCelEnvironment,
22
22
  buildTypedCelEnvironment,
23
+ isKindDocument,
23
24
  type CelHandlers,
24
25
  } from "./cel-environment.js";
25
26
  import { DefinitionRegistry } from "./definition-registry.js";
@@ -78,7 +79,7 @@ import {
78
79
  type ResourceRuleDiagnostic,
79
80
  type ResourceRuleIssue,
80
81
  } from "./validate-resource-rules.js";
81
- import { readResourceRules, type ResourceRule } from "./resource-rule.js";
82
+ import { pointerToPath, readResourceRules, type ResourceRule } from "./resource-rule.js";
82
83
  import { readReferrerRules, type ReferrerRule } from "./referrer-rule.js";
83
84
  import {
84
85
  evaluateReferrerRules,
@@ -87,11 +88,15 @@ import {
87
88
  reportUnexercisedReferrerRule,
88
89
  validateReferrerRuleDeclarations,
89
90
  type Referrer,
91
+ type ReferrerRuleContext,
90
92
  type ReferrerRuleDiagnostic,
91
93
  type ReferrerRuleIssue,
92
94
  } from "./validate-referrer-rules.js";
95
+ import { analyzerPeerBinder, analyzerPeersTarget } from "./peer-binding.js";
93
96
  import {
94
97
  describeProjectionFailure,
98
+ manifestListScope,
99
+ resolveSchemaProjections,
95
100
  type ProjectionFailure,
96
101
  } from "./schema-projection.js";
97
102
  import {
@@ -118,14 +123,18 @@ import { collectValueSchemaIssues } from "./validate-value-schema.js";
118
123
  import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisOptions } from "./types.js";
119
124
  import {
120
125
  extractAccessChains,
121
- extractCelRegionScopes,
122
126
  extractContextsFromSchema,
123
127
  getManifestItem,
124
- pathMatchesScope,
125
128
  resolveContextAnnotations,
126
129
  resolveTypeFieldToSchema,
127
130
  } from "./validate-cel-context.js";
128
- import { buildEvalPaths, evalPathsCover } from "./eval-paths.js";
131
+ import {
132
+ celEvalModeAt,
133
+ celEvalSites,
134
+ mergeCelEvalSites,
135
+ NO_CEL_EVAL_SITES,
136
+ type CelEvalSites,
137
+ } from "./eval-paths.js";
129
138
  import {
130
139
  BINDINGS_ANNOTATION,
131
140
  bindingContextProperties,
@@ -706,7 +715,7 @@ export class StaticAnalyzer {
706
715
  * `DUPLICATE_RESOURCE_NAME` reads those fields to tell a pipeline echo
707
716
  * apart from a genuine collision, and downstream diagnostic positioning
708
717
  * depends on them too. Real callers stamp positions already (the `Loader`,
709
- * `flattenForAnalyzer`, the telo-editor's `emitDocsFor`, the VSCode
718
+ * `flattenForAnalyzer`, telo studio's `emitDocsFor`, the VSCode
710
719
  * extension). Programmatic callers — tests, ad-hoc scripts — should pass
711
720
  * their inputs through `withSyntheticPositions(...)` before calling
712
721
  * `analyze()`. A missing position throws a clear error rather than
@@ -941,6 +950,11 @@ export class StaticAnalyzer {
941
950
  const projectionIssues: SchemaProjectionIssue[] = [];
942
951
  const resourceRuleIssues: ResourceRuleIssue[] = [];
943
952
  const referrerRuleIssues: ReferrerRuleIssue[] = [];
953
+ /** Definition docs of the entry's own modules that may declare rules. Their
954
+ * referrer-rule declarations are checked after every kind is registered,
955
+ * because a `peers:` pointer is checked against the REFERRER kind's schema
956
+ * and that kind may be declared later in the same file. */
957
+ const ownRuleDeclarers: ResourceManifest[] = [];
944
958
  // A rule that never had anything to iterate is never proven — the second way
945
959
  // coverage varies invisibly, beside the dynamic-leaf skip. Tracked across the
946
960
  // whole run and reported once, since "empty on every resource" is not a fact
@@ -998,9 +1012,10 @@ export class StaticAnalyzer {
998
1012
  effectiveAuthorSchema(m as any, (k) => defs.resolve(aliases.resolveKind(k) ?? k) ?? defs.resolve(k)),
999
1013
  ),
1000
1014
  );
1001
- referrerRuleIssues.push(
1002
- ...validateReferrerRuleDeclarations(m as unknown as ResourceManifest),
1003
- );
1015
+ // Deferred to after the registration loop: the `peers:` half is checked
1016
+ // against the REFERRER kind's schema, and a rule may name a kind
1017
+ // declared later in the same file.
1018
+ ownRuleDeclarers.push(m as unknown as ResourceManifest);
1004
1019
  for (const rule of readReferrerRules((m as Record<string, unknown>).schema)) {
1005
1020
  referrerRuleExercise.set(`${m.metadata?.module}.${m.metadata?.name}#${rule.code}`, {
1006
1021
  manifest: m as unknown as ResourceManifest,
@@ -1056,6 +1071,25 @@ export class StaticAnalyzer {
1056
1071
  defs.register(normalized);
1057
1072
  }
1058
1073
 
1074
+ /**
1075
+ * What a peer rule's `peers:` pointer names in the kind its `referrer:`
1076
+ * filters to.
1077
+ *
1078
+ * Liskov in BOTH directions, which is what the check has to be: the filter
1079
+ * is usually an abstract (`Sql.Schema`, so one rule serves every backend)
1080
+ * while the collection is declared by the backends that implement it, so a
1081
+ * pointer resolving on any candidate resolves the rule. Reported only when
1082
+ * NO candidate declares it, or when every candidate that does holds plain
1083
+ * data — the two shapes where the rule would see no declaration at all.
1084
+ */
1085
+ const peersTarget = analyzerPeersTarget(defs);
1086
+
1087
+ if (!options?.skipValidation) {
1088
+ for (const declarer of ownRuleDeclarers) {
1089
+ referrerRuleIssues.push(...validateReferrerRuleDeclarations(declarer, { peersTarget }));
1090
+ }
1091
+ }
1092
+
1059
1093
  // Reference-form validation — enforce `!ref` as the only reference shape.
1060
1094
  // Runs on the RAW manifests, BEFORE inline extraction and sentinel
1061
1095
  // resolution, while an author-written `{kind, name}` is still
@@ -1498,13 +1532,12 @@ export class StaticAnalyzer {
1498
1532
  return suppressUnreadableModuleDiagnostics(diagnostics, unreadableFiles);
1499
1533
  }
1500
1534
 
1501
- // Build a name→manifest map for looking up referenced resources
1502
- const byName = new Map<string, ResourceManifest>();
1503
- for (const m of allManifests) {
1504
- if (m.metadata?.name) {
1505
- byName.set(m.metadata.name as string, m);
1506
- }
1507
- }
1535
+ // ONE binder for the whole run, built beside the binding it serves: it caches
1536
+ // each referrer's resolved collection, which is what keeps a rule over an
1537
+ // n-entry collection from re-resolving that collection once per entry.
1538
+ const referrerRuleContext: ReferrerRuleContext = {
1539
+ peerBinder: analyzerPeerBinder(defs, aliases, allManifests as ResourceManifest[]),
1540
+ };
1508
1541
 
1509
1542
  // Fail loud on definition schemas AJV cannot compile. `validateAgainstSchema`
1510
1543
  // and `validateWithRefs` swallow compile failures (returning no issues),
@@ -1651,6 +1684,54 @@ export class StaticAnalyzer {
1651
1684
  observedStateContext,
1652
1685
  });
1653
1686
 
1687
+ /**
1688
+ * The schema a resource of this kind is validated against, per DEFINITION.
1689
+ *
1690
+ * Both halves derive a fresh object — `effectiveAuthorSchema` merges along
1691
+ * `extends`, and the closed-schema branch spreads `kind` / `metadata` in —
1692
+ * so asking per resource handed `validateResourceConfig` a schema object it
1693
+ * had never seen. That registry memoizes its compiled AJV validator by
1694
+ * object IDENTITY, precisely because every resource of a kind is checked
1695
+ * against the same one at keystroke time, so a fresh object per resource
1696
+ * recompiled the whole kind schema per resource. Keyed on the definition,
1697
+ * which is stable for the run.
1698
+ */
1699
+ const authorSchemaCache = new WeakMap<object, Record<string, any>>();
1700
+ const validationSchemaFor = (def: ResourceDefinition): Record<string, any> => {
1701
+ const cached = authorSchemaCache.get(def as unknown as object);
1702
+ if (cached) return cached;
1703
+ const authorSchema = effectiveAuthorSchema(def, (k) =>
1704
+ defs.resolve(aliases.resolveKind(k) ?? k) ?? defs.resolve(k),
1705
+ );
1706
+ // `kind` and `metadata` are implicit on every resource — inject them so
1707
+ // module authors don't have to repeat them under `additionalProperties:
1708
+ // false`.
1709
+ const schema =
1710
+ authorSchema.additionalProperties === false
1711
+ ? {
1712
+ ...authorSchema,
1713
+ properties: {
1714
+ kind: { type: "string" },
1715
+ metadata: { type: "object" },
1716
+ ...authorSchema.properties,
1717
+ },
1718
+ }
1719
+ : authorSchema;
1720
+ authorSchemaCache.set(def as unknown as object, schema);
1721
+ return schema;
1722
+ };
1723
+
1724
+ // One scope for the whole run: it closes over the manifest list and the
1725
+ // registry, neither of which changes per resource, and it is asked once per
1726
+ // schema-valued slot that carries a projection.
1727
+ const resourceProjectionScope = manifestListScope(
1728
+ allManifests as Record<string, any>[],
1729
+ (kind) =>
1730
+ (defs.resolve(kind) ?? defs.resolve(aliases.resolveKind(kind) ?? kind)) as
1731
+ | Record<string, any>
1732
+ | undefined,
1733
+ );
1734
+
1654
1735
  // Validate each non-definition, non-system resource
1655
1736
  for (const m of allManifests) {
1656
1737
  const filePath = (m.metadata as { source?: string } | undefined)?.source;
@@ -1749,39 +1830,54 @@ export class StaticAnalyzer {
1749
1830
  }
1750
1831
 
1751
1832
  // Validate resource config against the definition's AUTHOR-FACING schema —
1752
- // inheritance-resolved: with `base:` the child's own schema (parent config
1753
- // is internal), else `merge(parent, own)` so a `base:`-less `extends` child
1754
- // is validated against the inherited fields it may set. For a definition
1755
- // that neither extends nor uses `base:` this is exactly its own schema.
1756
- // `kind` and `metadata` are implicit on every resource — inject them so module
1757
- // authors don't have to repeat them when using additionalProperties: false.
1758
- const authorSchema = effectiveAuthorSchema(definition, (k) =>
1759
- defs.resolve(aliases.resolveKind(k) ?? k) ?? defs.resolve(k),
1760
- );
1761
- if (authorSchema && Object.keys(authorSchema).length > 0) {
1762
- const schema =
1763
- authorSchema.additionalProperties === false
1764
- ? {
1765
- ...authorSchema,
1766
- properties: {
1767
- kind: { type: "string" },
1768
- metadata: { type: "object" },
1769
- ...authorSchema.properties,
1770
- },
1771
- }
1772
- : authorSchema;
1833
+ // inheritance-resolved, with `kind` / `metadata` injected. See
1834
+ // `validationSchemaFor`, which is where both derivations and the reason
1835
+ // they are memoized per definition live.
1836
+ const schema = validationSchemaFor(definition);
1837
+ if (Object.keys(schema).length > 0) {
1773
1838
  // Phase 1: collect the pure-CEL leaves and the schema of the slot each
1774
1839
  // flows into. The expression's own type is resolved later, by the
1775
1840
  // engine walk that owns type-checking; this half only knows the target.
1776
1841
  for (const slot of collectCelValueSlots(m, schema, "")) {
1777
1842
  celReturnSlots.push({ manifest: m, resource, filePath, ...slot });
1778
1843
  }
1844
+ // A kind's own schema may point a slot at a PROJECTION — of a resource it
1845
+ // references, or (the empty pointer) of this very declaration, which is
1846
+ // what types a table's seed rows against its own `columns:`. Resolved
1847
+ // PER RESOURCE, because a projection is declaration-derived: the same
1848
+ // kind schema yields a different row shape for every table.
1849
+ //
1850
+ // A failure here is REPORTED, never dropped. An entry that could not be
1851
+ // read vanishes from the projected shape, so the slot typed from it then
1852
+ // rejects a field the author did declare ("'status' is not allowed") with
1853
+ // nothing saying why — the projection's own failure mode, blaming the
1854
+ // wrong line. Entry-module-scoped, like every other schema issue.
1855
+ const projectionFailures: ProjectionFailure[] = [];
1856
+ const projected = resolveSchemaProjections(
1857
+ schema,
1858
+ m as Record<string, any>,
1859
+ resourceProjectionScope,
1860
+ projectionFailures,
1861
+ ) as Record<string, any>;
1862
+ if (!ownModule || rootModules.has(ownModule)) {
1863
+ for (const failure of projectionFailures) {
1864
+ diagnostics.push({
1865
+ severity: DiagnosticSeverity.Error,
1866
+ code: "SCHEMA_PROJECTION_FROM_UNRESOLVED",
1867
+ source: SOURCE,
1868
+ message: `${m.kind}/${resource.name}: ${describeProjectionFailure(failure)}`,
1869
+ data: { resource, filePath, path: pointerToPath(failure.pointer) },
1870
+ });
1871
+ }
1872
+ }
1779
1873
  // Phase 2+3: AJV on substituted data — CEL fields replaced with typed
1780
1874
  // placeholders. Through the REGISTRY, so a kind whose schema references
1781
1875
  // a shape declared elsewhere is checked on the instance that holds it.
1782
1876
  const ajvIssues = defs.validateResourceConfig(
1783
- substituteCelFields(m, schema, undefined, { external: (ref) => defs.schemaForId(ref) }),
1784
- schema,
1877
+ substituteCelFields(m, projected, undefined, {
1878
+ external: (ref) => defs.schemaForId(ref),
1879
+ }),
1880
+ projected,
1785
1881
  );
1786
1882
  // Phase 4: value slots that must satisfy a type declared elsewhere on
1787
1883
  // the resource (`x-telo-value-schema-from`) — e.g. every row of a
@@ -1816,12 +1912,12 @@ export class StaticAnalyzer {
1816
1912
  for (const report of reportResourceRules(
1817
1913
  m as unknown as ResourceManifest,
1818
1914
  definition as unknown as ResourceManifest,
1819
- evaluateResourceRules(m as unknown as ResourceManifest, authorSchema),
1915
+ evaluateResourceRules(m as unknown as ResourceManifest, schema),
1820
1916
  !ruleDeclarer || rootModules.has(ruleDeclarer),
1821
1917
  )) {
1822
1918
  diagnostics.push(resourceRuleDiagnostic(report));
1823
1919
  }
1824
- for (const rule of readResourceRules(authorSchema)) {
1920
+ for (const rule of readResourceRules(schema)) {
1825
1921
  const key = `${definition.metadata?.module}.${definition.metadata?.name}#${rule.code}`;
1826
1922
  const tracked = ruleExercise.get(key);
1827
1923
  if (!tracked) continue;
@@ -1835,7 +1931,7 @@ export class StaticAnalyzer {
1835
1931
  // literal appears on the referring side, where the spelling would be the
1836
1932
  // consumer's import alias rather than anything the rule's author controls.
1837
1933
  // A consumer of the shared call graph, never a second traversal.
1838
- const referrerRules = readReferrerRules(authorSchema);
1934
+ const referrerRules = readReferrerRules(schema);
1839
1935
  if (referrerRules.length > 0) {
1840
1936
  const referrers = referrersOf(m as unknown as ResourceManifest, getCallGraph());
1841
1937
  for (const report of reportReferrerRules(
@@ -1843,9 +1939,10 @@ export class StaticAnalyzer {
1843
1939
  definition as unknown as ResourceManifest,
1844
1940
  evaluateReferrerRules(
1845
1941
  m as unknown as ResourceManifest,
1846
- authorSchema,
1942
+ schema,
1847
1943
  referrers,
1848
1944
  kindMatches,
1945
+ referrerRuleContext,
1849
1946
  ),
1850
1947
  !ruleDeclarer || rootModules.has(ruleDeclarer),
1851
1948
  )) {
@@ -1857,13 +1954,25 @@ export class StaticAnalyzer {
1857
1954
  continue;
1858
1955
  }
1859
1956
  diagnostics.push(referrerRuleDiagnostic(report));
1957
+ // A rule that VIOLATED plainly ran, whatever its peer set held. The
1958
+ // unexercised report exists to catch silence — a `peers:` naming
1959
+ // nothing makes every `!peers.exists(…)` pass vacuously — so a finding
1960
+ // is exactly the evidence it asks for.
1961
+ if (report.code === "REFERRER_RULE_VIOLATED") {
1962
+ const violated = referrerRuleExercise.get(
1963
+ `${definition.metadata?.module}.${definition.metadata?.name}#${report.rule}`,
1964
+ );
1965
+ if (violated) violated.exercised = true;
1966
+ }
1860
1967
  }
1861
1968
  for (const rule of referrerRules) {
1862
1969
  const key = `${definition.metadata?.module}.${definition.metadata?.name}#${rule.code}`;
1863
1970
  const tracked = referrerRuleExercise.get(key);
1864
1971
  if (!tracked) continue;
1865
1972
  tracked.seen = true;
1866
- if (referrerRuleExercised(rule, referrers, kindMatches)) tracked.exercised = true;
1973
+ if (referrerRuleExercised(rule, referrers, kindMatches, referrerRuleContext)) {
1974
+ tracked.exercised = true;
1975
+ }
1867
1976
  }
1868
1977
  }
1869
1978
 
@@ -2065,18 +2174,15 @@ export class StaticAnalyzer {
2065
2174
  // same schema an expression is typed against.
2066
2175
  let celStepContextSchema: Record<string, any> | undefined;
2067
2176
  let celErrorScopes: ReadonlyMap<string, Record<string, any>> = new Map();
2068
- // Region coverage for the "CEL in a non-eval field" check: the union of
2069
- // `x-telo-eval` paths (own + capability) and `x-telo-context` /
2070
- // `x-telo-step-context` / `x-telo-error-context` scopes. A `!cel` outside
2071
- // every region is read as a literal the runtime never evaluates it.
2072
- let celEvalPaths: string[] = [];
2177
+ // Where this kind says its values are evaluated — `x-telo-eval` paths (own +
2178
+ // capability) and the regions covering their contents. A `!cel` outside every
2179
+ // one of them is read as a literal, and one under a compile path resolves at
2180
+ // startup, where observed state cannot exist yet. Both questions are
2181
+ // `celEvalModeAt`, which the editor asks of the same sites.
2182
+ let celSites: CelEvalSites = NO_CEL_EVAL_SITES;
2073
2183
  // The bindings field this kind declares (if any), read by the CEL sites that
2074
2184
  // see the names it introduces.
2075
2185
  let celBindingSites: BindingSites | undefined;
2076
- // The compile half alone: a field that resolves at startup, where observed
2077
- // state cannot exist yet.
2078
- let celCompilePaths: string[] = [];
2079
- let celRegionScopes: string[] = [];
2080
2186
  let celRuleApplies = false;
2081
2187
 
2082
2188
  visitManifest(
@@ -2231,22 +2337,28 @@ export class StaticAnalyzer {
2231
2337
  celRuleApplies =
2232
2338
  !!e.definition?.schema && capability !== undefined && capability !== "Telo.Template";
2233
2339
  if (celRuleApplies) {
2234
- const ownSchema = e.definition!.schema as Record<string, any>;
2235
- const own = buildEvalPaths(ownSchema);
2340
+ // The INHERITANCE-RESOLVED schema, not the kind's own: without
2341
+ // `base:`, an `extends` child is authored against merge(parent, own),
2342
+ // so a CEL-bearing field the parent declares is a legal field on the
2343
+ // child and the kernel expands it — it stamps the merged schema at
2344
+ // definition registration and builds eval paths from that. Reading
2345
+ // the own schema here reported `CEL_IN_NON_EVAL_FIELD` for an
2346
+ // expression the runtime evaluates correctly, which is the two halves
2347
+ // disagreeing about what the manifest means.
2348
+ const ownSchema = effectiveAuthorSchema(
2349
+ e.definition as unknown as ResourceDefinition,
2350
+ (k) => defs.resolve(aliases.resolveKind(k) ?? k) ?? defs.resolve(k),
2351
+ ) as Record<string, any>;
2236
2352
  const capabilityDef = capability ? defs.resolve(capability) : undefined;
2237
- const parent = capabilityDef?.schema
2238
- ? buildEvalPaths(capabilityDef.schema as Record<string, any>)
2239
- : { compile: [], runtime: [] };
2240
- celEvalPaths = [...own.compile, ...own.runtime, ...parent.compile, ...parent.runtime];
2241
2353
  // A `Telo.Provider`'s fields are implicitly compile-eval — the
2242
2354
  // capability abstract carries the root annotation — so its reads are
2243
2355
  // covered here without the provider restating anything.
2244
- celCompilePaths = [...own.compile, ...parent.compile];
2245
- celRegionScopes = extractCelRegionScopes(ownSchema);
2356
+ celSites = mergeCelEvalSites(
2357
+ celEvalSites(ownSchema),
2358
+ celEvalSites(capabilityDef?.schema as Record<string, any> | undefined),
2359
+ );
2246
2360
  } else {
2247
- celEvalPaths = [];
2248
- celCompilePaths = [];
2249
- celRegionScopes = [];
2361
+ celSites = NO_CEL_EVAL_SITES;
2250
2362
  }
2251
2363
  },
2252
2364
  onCel: (e) => {
@@ -2264,8 +2376,7 @@ export class StaticAnalyzer {
2264
2376
  celRuleApplies &&
2265
2377
  engineName === "cel" &&
2266
2378
  celScope.invocationContextSchema === undefined &&
2267
- !evalPathsCover(celEvalPaths, path) &&
2268
- !celRegionScopes.some((scope) => pathMatchesScope(path, scope)) &&
2379
+ celEvalModeAt(celSites, path) === null &&
2269
2380
  !pathCrossesNestedResource(m, path)
2270
2381
  ) {
2271
2382
  diagnostics.push({
@@ -2293,7 +2404,7 @@ export class StaticAnalyzer {
2293
2404
  read.alias ? `${read.alias}.${read.name}` : read.name,
2294
2405
  );
2295
2406
 
2296
- if (celRuleApplies && evalPathsCover(celCompilePaths, path)) {
2407
+ if (celRuleApplies && celEvalModeAt(celSites, path) === "compile") {
2297
2408
  diagnostics.push({
2298
2409
  severity: DiagnosticSeverity.Error,
2299
2410
  code: "OBSERVED_STATE_IN_STARTUP_FIELD",
@@ -2340,7 +2451,19 @@ export class StaticAnalyzer {
2340
2451
  matchedScope,
2341
2452
  });
2342
2453
 
2343
- const result = engine.analyze(expr, { celEnv: typedEnv, contextSchema: effectiveContext });
2454
+ const result = engine.analyze(expr, {
2455
+ celEnv: typedEnv,
2456
+ contextSchema: effectiveContext,
2457
+ // `scopeFor` registers every kernel global and every name the site's
2458
+ // context declares, so a root this environment does not know is one
2459
+ // nothing puts in scope. Two places where the CEL belongs to another
2460
+ // scope, both already recognised by the non-eval-field check: a kind
2461
+ // document, whose CEL is written for whoever instantiates the kind,
2462
+ // and anything below a nested inline `{ kind }`, whose CEL the
2463
+ // nested kind evaluates — and which is analyzed again, in its own
2464
+ // scope, as the resource it was extracted into.
2465
+ rootsDeclared: !isKindDocument(m) && !pathCrossesNestedResource(m, path),
2466
+ });
2344
2467
 
2345
2468
  if (result.type !== undefined) {
2346
2469
  let byPath = celTypeByPath.get(m);
@@ -2377,7 +2500,7 @@ export class StaticAnalyzer {
2377
2500
  // intent (a boot timestamp, a run id), so it warns rather than
2378
2501
  // blocking. The engine reports which calls re-evaluate; the eval mode
2379
2502
  // is manifest policy and stays here.
2380
- if (celRuleApplies && evalPathsCover(celCompilePaths, path)) {
2503
+ if (celRuleApplies && celEvalModeAt(celSites, path) === "compile") {
2381
2504
  const volatile = [
2382
2505
  ...new Set(result.calls.filter((c) => c.deterministic === false).map((c) => c.name)),
2383
2506
  ].sort();
package/src/call-graph.ts CHANGED
@@ -284,7 +284,13 @@ function resolveUseAtSite(
284
284
  const resolved = entry.useCases.cases[String(selector)];
285
285
  if (resolved) return { use: resolved };
286
286
  }
287
- const slot = { kinds: entry.refs, uses: entry.uses, useCases: entry.useCases, inline: false };
287
+ const slot = {
288
+ kinds: entry.refs,
289
+ uses: entry.uses,
290
+ useCases: entry.useCases,
291
+ inline: false,
292
+ valueBranches: [],
293
+ };
288
294
  const unresolvedReason = isTaggedSentinel(selector)
289
295
  ? "dynamic"
290
296
  : selector === undefined
@@ -47,24 +47,34 @@ export function buildTypedCelEnvironment(
47
47
  (env as any).registerType(brand, { fields: {} });
48
48
  }
49
49
 
50
- // Build typed ObjectSchema from manifest.variables if it looks like a schema map
51
- const vars = (manifest as Record<string, unknown>).variables;
52
- if (vars !== null && typeof vars === "object" && !Array.isArray(vars)) {
53
- const entries = Object.entries(vars as Record<string, unknown>).filter(
54
- ([, v]) => v !== null && typeof v === "object" && !Array.isArray(v),
55
- );
56
- if (entries.length > 0) {
57
- const schema: Record<string, string> = {};
58
- for (const [k, v] of entries) {
59
- schema[k] = jsonSchemaToCelType(v as Record<string, any>);
60
- }
61
- (env as any).registerVariable({ name: "variables", schema });
62
- } else {
63
- env.registerVariable("variables", "map");
64
- }
65
- } else {
66
- env.registerVariable("variables", "map");
67
- }
50
+ // `variables` / `secrets`: the DECLARING module's blocks, which is the
51
+ // contract the resource's CEL is evaluated against at runtime. Read the
52
+ // same three ways `ports` and `module` are, and for the same reason — a
53
+ // resource doc does not carry them, so typing from `manifest` alone left
54
+ // every ordinary resource with an open `variables` and no check at all,
55
+ // while `ports.<typo>` one line away was an error.
56
+ //
57
+ // Order matters: a module-identity doc analyzing itself carries its own
58
+ // block; a resource forwarded from an imported library carries its
59
+ // library's as `metadata.moduleGlobals`, which must win over the consuming
60
+ // application's; everything else is the entry module's own doc.
61
+ const moduleGlobals = (manifest.metadata as Record<string, any> | undefined)?.moduleGlobals as
62
+ | Record<string, unknown>
63
+ | undefined;
64
+ // A KIND document is the exception, and it is not a detail: the CEL inside
65
+ // a `Telo.Definition`'s `schema:` — an `examples:` entry, a `description`
66
+ // showing `${{ secrets.API_KEY }}` — illustrates what a CONSUMER writes, in
67
+ // the consumer's scope. Closing those over the declaring module's blocks
68
+ // reported an error against a name the module never meant to declare, and
69
+ // one nobody could fix without deleting the example.
70
+ const root = (
71
+ isKindDocument(manifest) ? undefined : (rootModuleManifest as Record<string, unknown>)
72
+ ) as Record<string, unknown> | undefined;
73
+ registerConfigNamespace(
74
+ env,
75
+ (manifest as Record<string, unknown>).variables ?? moduleGlobals?.variables ?? root?.variables,
76
+ "variables",
77
+ );
68
78
 
69
79
  // `ports` namespace: each entry types as the brand its `protocol` selects
70
80
  // (tcp → TcpPort, udp → UdpPort), so `${{ ports.http }}` carries a nominal
@@ -87,7 +97,11 @@ export function buildTypedCelEnvironment(
87
97
  env.registerVariable("ports", "map");
88
98
  }
89
99
 
90
- env.registerVariable("secrets", "map");
100
+ registerConfigNamespace(
101
+ env,
102
+ (manifest as Record<string, unknown>).secrets ?? moduleGlobals?.secrets ?? root?.secrets,
103
+ "secrets",
104
+ );
91
105
  env.registerVariable("resources", "map");
92
106
 
93
107
  // `module` — the declaring module's own `metadata`, so a manifest reads its
@@ -132,6 +146,21 @@ export function buildTypedCelEnvironment(
132
146
  }
133
147
  }
134
148
 
149
+ /**
150
+ * A kind document — whose CEL is written for whoever instantiates the kind, not
151
+ * evaluated in the declaring module's own scope.
152
+ *
153
+ * Its `examples:` show a consumer's route reading `request` and `result`, its
154
+ * `description`s show `${{ secrets.API_KEY }}`, and a rule condition reads the
155
+ * `self` / `referrer` its own evaluator binds. None of those names are in scope
156
+ * where they are WRITTEN, and all of them are correct where they are READ — so
157
+ * every check that asks "is this name in scope here" has to stand down on these
158
+ * documents, or it reports errors nobody can fix without deleting the example.
159
+ */
160
+ export function isKindDocument(manifest: ResourceManifest): boolean {
161
+ return manifest.kind === "Telo.Definition" || manifest.kind === "Telo.Abstract";
162
+ }
163
+
135
164
  /** Register a `variables`/`secrets` namespace typed from a module doc's schema map
136
165
  * (`{ name: <schema>, … }`), falling back to dyn `map` when absent or untyped. */
137
166
  function registerConfigNamespace(
package/src/cel-scope.ts CHANGED
@@ -445,19 +445,23 @@ export class CelScopeResolver {
445
445
  this.invocationContext = (m.metadata as any)?.xTeloInvocationContext as
446
446
  | Record<string, any>
447
447
  | undefined;
448
- this.stepContext = definition?.schema
448
+ // The INHERITANCE-RESOLVED schema at both sites, for the reason the CEL
449
+ // context regions use it: an `extends` child is authored against
450
+ // merge(parent, own), so a step body or a `catch:` region the parent
451
+ // declares is in force on the child. All three consumers share one memo so
452
+ // they cannot come to disagree about which schema a kind means.
453
+ const authorSchema = defs.effectiveSchemaOf(definition) as Record<string, any> | undefined;
454
+ this.stepContext = authorSchema
449
455
  ? buildStepContextSchema(
450
456
  m as Record<string, any>,
451
- definition.schema as Record<string, any>,
457
+ authorSchema,
452
458
  allManifests as Record<string, any>[],
453
459
  defs,
454
460
  aliases,
455
461
  scopes,
456
462
  )
457
463
  : undefined;
458
- this.errorScopes = collectErrorContextScopes(
459
- definition?.schema as Record<string, any> | undefined,
460
- );
464
+ this.errorScopes = collectErrorContextScopes(authorSchema);
461
465
  }
462
466
 
463
467
  /**
@@ -518,7 +522,21 @@ export class CelScopeResolver {
518
522
  const base = matched ?? { type: "object", properties: {}, additionalProperties: true };
519
523
  matched = {
520
524
  ...base,
521
- properties: { ...(base.properties ?? {}), steps: this.stepContext },
525
+ properties: {
526
+ // `inputs` is in scope beside `steps` wherever a step body runs — the
527
+ // step engine evaluates a step against the enclosing kind's own
528
+ // arguments, which is what `steps[0].inputs.x` reads. Modelled here
529
+ // because it was modelled NOWHERE: the step context is open, so a
530
+ // chain through it was never checked and the name's absence went
531
+ // unnoticed until something asked whether the root existed.
532
+ //
533
+ // OPEN, not typed from the kind's `inputType`: closing it would newly
534
+ // reject every read of an argument the contract does not spell out,
535
+ // which is a separate decision from knowing the name is legal.
536
+ inputs: { type: "object", additionalProperties: true },
537
+ ...(base.properties ?? {}),
538
+ steps: this.stepContext,
539
+ },
522
540
  };
523
541
  }
524
542