@telorun/analyzer 0.62.0 → 0.63.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 (146) hide show
  1. package/dist/analysis-registry.d.ts.map +1 -1
  2. package/dist/analysis-registry.js +6 -0
  3. package/dist/analyzer.d.ts +1 -1
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +343 -13
  6. package/dist/call-graph.d.ts.map +1 -1
  7. package/dist/call-graph.js +2 -7
  8. package/dist/flatten-for-analyzer.js +1 -1
  9. package/dist/import-resolution-diagnostics.d.ts +9 -2
  10. package/dist/import-resolution-diagnostics.d.ts.map +1 -1
  11. package/dist/import-resolution-diagnostics.js +26 -7
  12. package/dist/index.d.ts +15 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +13 -1
  15. package/dist/invocation-contract.d.ts +6 -1
  16. package/dist/invocation-contract.d.ts.map +1 -1
  17. package/dist/invocation-contract.js +27 -4
  18. package/dist/loaded-types.d.ts +12 -3
  19. package/dist/loaded-types.d.ts.map +1 -1
  20. package/dist/manifest-loader.d.ts +0 -1
  21. package/dist/manifest-loader.d.ts.map +1 -1
  22. package/dist/manifest-loader.js +62 -41
  23. package/dist/manifest-schemas.d.ts +699 -0
  24. package/dist/manifest-schemas.d.ts.map +1 -1
  25. package/dist/manifest-schemas.js +306 -14
  26. package/dist/module-compatibility.d.ts +40 -0
  27. package/dist/module-compatibility.d.ts.map +1 -0
  28. package/dist/module-compatibility.js +52 -0
  29. package/dist/ref-slot-reading.d.ts +30 -0
  30. package/dist/ref-slot-reading.d.ts.map +1 -0
  31. package/dist/ref-slot-reading.js +139 -0
  32. package/dist/referrer-rule.d.ts +67 -0
  33. package/dist/referrer-rule.d.ts.map +1 -0
  34. package/dist/referrer-rule.js +96 -0
  35. package/dist/release/index.d.ts +1 -1
  36. package/dist/release/index.d.ts.map +1 -1
  37. package/dist/release/index.js +1 -1
  38. package/dist/release/payload-digest.d.ts +7 -3
  39. package/dist/release/payload-digest.d.ts.map +1 -1
  40. package/dist/release/payload-digest.js +7 -3
  41. package/dist/release/version-stamp.d.ts +16 -0
  42. package/dist/release/version-stamp.d.ts.map +1 -1
  43. package/dist/release/version-stamp.js +52 -0
  44. package/dist/release/workspace-config.d.ts +10 -6
  45. package/dist/release/workspace-config.d.ts.map +1 -1
  46. package/dist/release/workspace-config.js +10 -6
  47. package/dist/resolve-schema-ref-kinds.d.ts +4 -3
  48. package/dist/resolve-schema-ref-kinds.d.ts.map +1 -1
  49. package/dist/resolve-schema-ref-kinds.js +16 -0
  50. package/dist/resolve-throws-union.d.ts.map +1 -1
  51. package/dist/resolve-throws-union.js +4 -3
  52. package/dist/resolve-zone-containment.d.ts +99 -0
  53. package/dist/resolve-zone-containment.d.ts.map +1 -0
  54. package/dist/resolve-zone-containment.js +212 -0
  55. package/dist/resolve-zone-requirements.d.ts +5 -0
  56. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  57. package/dist/resolve-zone-requirements.js +48 -7
  58. package/dist/resource-rule.d.ts +106 -0
  59. package/dist/resource-rule.d.ts.map +1 -0
  60. package/dist/resource-rule.js +236 -0
  61. package/dist/rule-condition.d.ts +50 -0
  62. package/dist/rule-condition.d.ts.map +1 -0
  63. package/dist/rule-condition.js +108 -0
  64. package/dist/schema-keywords.d.ts.map +1 -1
  65. package/dist/schema-keywords.js +20 -0
  66. package/dist/schema-projection.d.ts +171 -0
  67. package/dist/schema-projection.d.ts.map +1 -0
  68. package/dist/schema-projection.js +317 -0
  69. package/dist/step-slot.d.ts +39 -0
  70. package/dist/step-slot.d.ts.map +1 -0
  71. package/dist/step-slot.js +49 -0
  72. package/dist/telo-version.d.ts +1 -1
  73. package/dist/telo-version.js +1 -1
  74. package/dist/validate-cel-context.d.ts.map +1 -1
  75. package/dist/validate-cel-context.js +17 -3
  76. package/dist/validate-durable-regions.d.ts +15 -0
  77. package/dist/validate-durable-regions.d.ts.map +1 -0
  78. package/dist/validate-durable-regions.js +336 -0
  79. package/dist/validate-references.d.ts.map +1 -1
  80. package/dist/validate-references.js +44 -2
  81. package/dist/validate-referrer-rules.d.ts +120 -0
  82. package/dist/validate-referrer-rules.d.ts.map +1 -0
  83. package/dist/validate-referrer-rules.js +255 -0
  84. package/dist/validate-resource-rules.d.ts +114 -0
  85. package/dist/validate-resource-rules.d.ts.map +1 -0
  86. package/dist/validate-resource-rules.js +283 -0
  87. package/dist/validate-schema-projection.d.ts +32 -0
  88. package/dist/validate-schema-projection.d.ts.map +1 -0
  89. package/dist/validate-schema-projection.js +110 -0
  90. package/dist/validate-step-inputs.d.ts +2 -1
  91. package/dist/validate-step-inputs.d.ts.map +1 -1
  92. package/dist/validate-step-inputs.js +5 -3
  93. package/dist/validate-throws-coverage.d.ts.map +1 -1
  94. package/dist/validate-throws-coverage.js +11 -5
  95. package/dist/validate-zone-slots.d.ts +2 -2
  96. package/dist/validate-zone-slots.d.ts.map +1 -1
  97. package/dist/validate-zone-slots.js +202 -6
  98. package/dist/validate-zone-violations.d.ts +12 -0
  99. package/dist/validate-zone-violations.d.ts.map +1 -0
  100. package/dist/validate-zone-violations.js +169 -0
  101. package/dist/value-type-keyword.d.ts +1 -1
  102. package/dist/value-type-keyword.d.ts.map +1 -1
  103. package/dist/value-type-keyword.js +4 -0
  104. package/dist/zone-slot.d.ts +61 -0
  105. package/dist/zone-slot.d.ts.map +1 -1
  106. package/dist/zone-slot.js +86 -5
  107. package/package.json +2 -2
  108. package/src/analysis-registry.ts +6 -0
  109. package/src/analyzer.ts +395 -14
  110. package/src/call-graph.ts +2 -8
  111. package/src/flatten-for-analyzer.ts +1 -1
  112. package/src/import-resolution-diagnostics.ts +25 -7
  113. package/src/index.ts +73 -0
  114. package/src/invocation-contract.ts +45 -2
  115. package/src/loaded-types.ts +12 -3
  116. package/src/manifest-loader.ts +87 -55
  117. package/src/manifest-schemas.ts +328 -14
  118. package/src/module-compatibility.ts +72 -0
  119. package/src/ref-slot-reading.ts +156 -0
  120. package/src/referrer-rule.ts +116 -0
  121. package/src/release/index.ts +1 -0
  122. package/src/release/payload-digest.ts +7 -3
  123. package/src/release/version-stamp.ts +58 -0
  124. package/src/release/workspace-config.ts +10 -6
  125. package/src/resolve-schema-ref-kinds.ts +28 -4
  126. package/src/resolve-throws-union.ts +4 -3
  127. package/src/resolve-zone-containment.ts +329 -0
  128. package/src/resolve-zone-requirements.ts +67 -7
  129. package/src/resource-rule.ts +252 -0
  130. package/src/rule-condition.ts +126 -0
  131. package/src/schema-keywords.ts +24 -0
  132. package/src/schema-projection.ts +403 -0
  133. package/src/step-slot.ts +64 -0
  134. package/src/telo-version.ts +1 -1
  135. package/src/validate-cel-context.ts +17 -3
  136. package/src/validate-durable-regions.ts +401 -0
  137. package/src/validate-references.ts +57 -2
  138. package/src/validate-referrer-rules.ts +373 -0
  139. package/src/validate-resource-rules.ts +401 -0
  140. package/src/validate-schema-projection.ts +184 -0
  141. package/src/validate-step-inputs.ts +5 -3
  142. package/src/validate-throws-coverage.ts +12 -5
  143. package/src/validate-zone-slots.ts +192 -8
  144. package/src/validate-zone-violations.ts +190 -0
  145. package/src/value-type-keyword.ts +4 -0
  146. package/src/zone-slot.ts +114 -5
@@ -50,6 +50,11 @@ export interface ZoneRequirementSpec {
50
50
  * check compares against `exports.resources`. */
51
51
  correlationName?: string;
52
52
  reason?: string;
53
+ /** Attributes the satisfying zone must declare. Carried across the export
54
+ * boundary with the rest of the requirement: a consumer discharging a
55
+ * library's open requirement must meet the same guarantee the library's own
56
+ * internals would have. */
57
+ attributes?: string[];
53
58
  /** Label of the requiring resource (`sql.Command 'charge'`). */
54
59
  origin: string;
55
60
  /** Resource names on the propagation path so far, origin first. */
@@ -78,6 +83,9 @@ interface Requirement {
78
83
  zone: string;
79
84
  /** Kinds that discharge it: the zone kind plus everything extending it. */
80
85
  accepted: ReadonlySet<string>;
86
+ /** Attributes the discharging zone must declare — the GUARANTEE half of the
87
+ * requirement, checked at the slot that would otherwise discharge it. */
88
+ attributes: string[];
81
89
  correlation?: string;
82
90
  correlationLabel?: string;
83
91
  correlationName?: string;
@@ -368,6 +376,7 @@ export function projectZoneRequirements(args: ProjectionArgs): ProjectionResult
368
376
  if (!bucket.some((r) => `${r.zone}\0${r.correlation ?? ""}` === req.key)) {
369
377
  bucket.push({
370
378
  zone: req.zone,
379
+ ...(req.attributes.length > 0 ? { attributes: req.attributes } : {}),
371
380
  correlation: req.correlation,
372
381
  correlationLabel: req.correlationLabel,
373
382
  correlationName: req.correlationName,
@@ -392,19 +401,57 @@ export function projectZoneRequirements(args: ProjectionArgs): ProjectionResult
392
401
  // site. Checked BEFORE termination, so a terminating provider slot
393
402
  // (a detached durable body) still discharges its own zone.
394
403
  const callerDef = resolveDef(caller.kind, moduleOf(caller));
404
+ // WHICH slot establishes the zone depends on the body's SHAPE, and both
405
+ // shapes are real. A ref body annotates the slot the edge itself names
406
+ // (`Sql.Transaction.steps`, holding a `!ref` to an executable). A NATIVE
407
+ // body annotates the step ARRAY, while the edge comes from a step inside
408
+ // it and names that step's own `invoke:` — so looking only at `edge.slot`
409
+ // finds no annotation and the enclosing zone never discharges anything.
410
+ // Handling one shape would make every requirement inside a natively-bodied
411
+ // zone unsatisfiable, which is exactly what a durable workflow's body is.
412
+ const bodySlot =
413
+ from.type === "step" ? from.array.replace(/\[\d+\].*$/, "") : edge.slot;
395
414
  const slotSchema = schemaNodeAt(
396
415
  callerDef?.schema as Record<string, any> | undefined,
397
- edge.slot,
416
+ bodySlot,
398
417
  );
399
418
  const provides = readProvidesZone(slotSchema);
400
419
  if (provides && callerDef && req.accepted.has(canonicalOf(callerDef))) {
420
+ // A native body's annotation sits on the array, whose enclosing object
421
+ // IS the resource root — so its correlation pointer anchors there,
422
+ // rather than at the step item the edge happens to come from.
401
423
  const providerKey = provides.key
402
- ? resolveProviderKey(caller, edge, provides.key, resolveName)
424
+ ? from.type === "step"
425
+ ? resolveStaticKey(caller, [provides.key], resolveName)
426
+ : resolveProviderKey(caller, edge, provides.key, resolveName)
403
427
  : undefined;
404
428
  const discharged =
405
429
  req.correlation === undefined ||
406
430
  (providerKey !== undefined && correlationIdOf(providerKey) === req.correlation);
407
- if (discharged) continue;
431
+ if (discharged) {
432
+ // The zone is the right KIND but may not make the promise the
433
+ // requirement is built on. Reported here rather than at the providing
434
+ // module, because this is the only point where both halves are in
435
+ // hand: the requirement's demanded attributes and the slot that would
436
+ // satisfy it. The requirement still discharges — the zone IS open, so
437
+ // reporting it unsatisfied as well would name the same defect twice
438
+ // with opposite words.
439
+ const missing = req.attributes.filter((a) => !(a in provides.attributes));
440
+ if (missing.length > 0) {
441
+ emit(
442
+ DiagnosticSeverity.Error,
443
+ "ZONE_ATTRIBUTE_MISSING",
444
+ edge,
445
+ caller,
446
+ req,
447
+ via,
448
+ `reaches a ${canonicalOf(callerDef)} zone that does not declare ` +
449
+ `${missing.join(", ")}, so the guarantee this requirement is built on ` +
450
+ `does not hold inside it`,
451
+ );
452
+ }
453
+ continue;
454
+ }
408
455
  }
409
456
 
410
457
  // A dynamic selector is already a hard diagnostic (validate-ref-slots);
@@ -567,13 +614,24 @@ export function projectZoneRequirements(args: ProjectionArgs): ProjectionResult
567
614
  if (meta?.forwardedExport) continue;
568
615
  const def = resolveDef(node.kind, meta?.module);
569
616
  const schema = def?.schema as Record<string, any> | undefined;
570
- if (!def || !schema?.properties) continue;
617
+ if (!def || !schema) continue;
618
+ // A requirement at the SCHEMA ROOT is unconditional: every instance of the
619
+ // kind must be inside the zone, whatever it is configured with. A parking
620
+ // kind is exactly that shape — it has no option that turns the requirement
621
+ // on — and a field-level annotation would have to be attached to some
622
+ // arbitrary property and would then read "…when you set this one".
623
+ const slots: [Record<string, any>, string | undefined][] = [[schema, undefined]];
571
624
  for (const [field, propSchema] of Object.entries(
572
- schema.properties as Record<string, Record<string, any>>,
625
+ (schema.properties ?? {}) as Record<string, Record<string, any>>,
573
626
  )) {
574
- const requires = readRequiresZone(propSchema);
627
+ slots.push([propSchema, field]);
628
+ }
629
+ for (const [slotSchema, field] of slots) {
630
+ const requires = readRequiresZone(slotSchema);
575
631
  if (!requires) continue;
576
- if ((node.manifest as Record<string, unknown>)[field] === undefined) continue;
632
+ if (field !== undefined && (node.manifest as Record<string, unknown>)[field] === undefined) {
633
+ continue;
634
+ }
577
635
  const zoneDef = resolveDef(requires.zone, def.metadata.module);
578
636
  if (!zoneDef) continue; // ZONE_PROVIDER_UNRESOLVED is reported at registration
579
637
  const zone = canonicalOf(zoneDef);
@@ -581,6 +639,7 @@ export function projectZoneRequirements(args: ProjectionArgs): ProjectionResult
581
639
  const req: Requirement = {
582
640
  zone,
583
641
  accepted: acceptedFor(zone),
642
+ attributes: requires.attributes,
584
643
  correlation: target ? correlationIdOf(target) : undefined,
585
644
  correlationLabel: target ? labelOf(target) : undefined,
586
645
  correlationName: target?.name,
@@ -609,6 +668,7 @@ export function projectZoneRequirements(args: ProjectionArgs): ProjectionResult
609
668
  const req: Requirement = {
610
669
  zone: spec.zone,
611
670
  accepted: zoneDef ? acceptedFor(spec.zone) : new Set([spec.zone]),
671
+ attributes: spec.attributes ?? [],
612
672
  correlation: spec.correlation,
613
673
  correlationLabel: spec.correlationLabel,
614
674
  correlationName: spec.correlationName,
@@ -0,0 +1,252 @@
1
+ /**
2
+ * The single reader for `x-telo-resource-rules` — a kind declaring, as data,
3
+ * relationships between the fields of one resource that JSON Schema cannot
4
+ * state: an index naming a column its table does not declare, a foreign key
5
+ * whose two sides differ in length, a rename whose source is still declared.
6
+ *
7
+ * The predicate is CEL over `self` (the whole resource) and `this` (the element
8
+ * under test), so correlating two collections is a comprehension closure rather
9
+ * than a path language with wildcard bindings to design. `in:` names the
10
+ * collection to iterate and IS the diagnostic anchor: iterating what the pointer
11
+ * names is what makes a reported path exist by construction.
12
+ *
13
+ * Vocabulary borrowed from `Telo.JsonSchema.rules` — `condition` true when the
14
+ * rule HOLDS, the subject bound as `this`, plus `code` and `message` — because
15
+ * two CEL rule vocabularies with opposite polarity is a trap an author falls
16
+ * into once per rule. The two mechanisms stay separate: a `Telo.JsonSchema` rule
17
+ * runs at dispatch against a value, this one at `telo check` against a manifest.
18
+ *
19
+ * Lenient by design, the `ref-slot.ts` precedent: anything unreadable here reads
20
+ * as absent, and `validate-resource-rules.ts` is the strict half that reports it.
21
+ *
22
+ * Browser-safe: no Node built-ins.
23
+ */
24
+
25
+ export const RESOURCE_RULES_ANNOTATION = "x-telo-resource-rules";
26
+
27
+ export type ResourceRuleSeverity = "error" | "warning";
28
+
29
+ export interface ResourceRule {
30
+ /** JSON Pointer to the collection to iterate. Absent = the whole-resource
31
+ * form, which reports at the resource rather than at an element. */
32
+ readonly in?: string;
33
+ /** CEL source. TRUE when the rule holds. */
34
+ readonly condition: string;
35
+ /** The rule's own name, carried in `data.rule`. Never a diagnostic code —
36
+ * every violation reports under the analyzer-owned envelope. */
37
+ readonly code: string;
38
+ readonly message: string;
39
+ readonly severity: ResourceRuleSeverity;
40
+ /** Position in the annotation array — the anchor for a declaration defect. */
41
+ readonly index: number;
42
+ }
43
+
44
+ /** One element a rule is evaluated against. */
45
+ export interface RuleSubject {
46
+ /** Dotted/bracketed path from the resource root, for `data.path`. */
47
+ readonly path: string;
48
+ readonly value: unknown;
49
+ /** Present when the collection is a map: the entry's key. */
50
+ readonly key?: string;
51
+ }
52
+
53
+ function isObject(value: unknown): value is Record<string, unknown> {
54
+ return !!value && typeof value === "object" && !Array.isArray(value);
55
+ }
56
+
57
+ /** True for a node the loader produced from a `!cel` tag or a `${{ }}` string.
58
+ * Both markers are tested because they are not always both present: a
59
+ * registered definition's schema reaches the analyzer with `call` and
60
+ * `__compiled` dropped, keeping only `__tagged` + `source`. Testing one would
61
+ * make a rule readable on some paths and invisible on others. */
62
+ function isCelNode(value: unknown): value is { source?: unknown } {
63
+ return isObject(value) && (value.__compiled === true || value.__tagged === true);
64
+ }
65
+
66
+ /** A precompiled `!cel` node keeps its author-written text on `source`; a plain
67
+ * string is taken verbatim so a rule reads the same however the loader was
68
+ * configured (a round-trip view runs with `compile` off). */
69
+ export function celSourceOf(value: unknown): string | undefined {
70
+ if (typeof value === "string") return value;
71
+ if (isCelNode(value) && typeof value.source === "string") return value.source;
72
+ return undefined;
73
+ }
74
+
75
+ /** The annotation exactly as written, for the strict half. `undefined` when the
76
+ * kind declares none; a non-array is returned as-is so the shape can be
77
+ * reported rather than silently skipped. */
78
+ export function readRawResourceRules(schema: unknown): unknown {
79
+ if (!isObject(schema)) return undefined;
80
+ return schema[RESOURCE_RULES_ANNOTATION];
81
+ }
82
+
83
+ /** Every rule this kind declares that is well-formed enough to run. */
84
+ export function readResourceRules(schema: unknown): ResourceRule[] {
85
+ const raw = readRawResourceRules(schema);
86
+ if (!Array.isArray(raw)) return [];
87
+ const rules: ResourceRule[] = [];
88
+ raw.forEach((entry, index) => {
89
+ if (!isObject(entry)) return;
90
+ const condition = celSourceOf(entry.condition);
91
+ const { code, message } = entry;
92
+ if (!condition || typeof code !== "string" || typeof message !== "string") return;
93
+ if (code.length === 0 || message.length === 0) return;
94
+ const severity = entry.severity === "warning" ? "warning" : "error";
95
+ if (entry.severity !== undefined && entry.severity !== "warning" && entry.severity !== "error") {
96
+ return;
97
+ }
98
+ const pointer = entry.in;
99
+ if (pointer !== undefined && typeof pointer !== "string") return;
100
+ rules.push({
101
+ ...(pointer === undefined ? {} : { in: pointer }),
102
+ condition,
103
+ code,
104
+ message,
105
+ severity,
106
+ index,
107
+ });
108
+ });
109
+ return rules;
110
+ }
111
+
112
+ /** Split a JSON Pointer into its unescaped segments. `/` alone is the root. */
113
+ export function pointerSegments(pointer: string): string[] | undefined {
114
+ if (pointer === "" || pointer === "/") return [];
115
+ if (!pointer.startsWith("/")) return undefined;
116
+ return pointer
117
+ .slice(1)
118
+ .split("/")
119
+ .map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
120
+ }
121
+
122
+ /** Navigate a pointer within a value, stopping at anything that is not a plain
123
+ * container. Returns `undefined` for a pointer that does not resolve — which a
124
+ * resource legitimately produces by omitting an optional collection. */
125
+ export function resolvePointer(value: unknown, pointer: string): unknown {
126
+ const segments = pointerSegments(pointer);
127
+ if (!segments) return undefined;
128
+ let current: unknown = value;
129
+ for (const segment of segments) {
130
+ if (Array.isArray(current)) {
131
+ const index = Number(segment);
132
+ if (!Number.isInteger(index)) return undefined;
133
+ current = current[index];
134
+ } else if (isObject(current)) {
135
+ current = current[segment];
136
+ } else {
137
+ return undefined;
138
+ }
139
+ if (current === undefined) return undefined;
140
+ }
141
+ return current;
142
+ }
143
+
144
+ /** Path prefix for a pointer, in the dotted/bracketed spelling diagnostics use
145
+ * (`foreignKeys[0].references`), so `data.path` is one grammar throughout. */
146
+ export function pointerToPath(pointer: string): string {
147
+ const segments = pointerSegments(pointer) ?? [];
148
+ return segments.reduce<string>((acc, segment) => {
149
+ if (/^\d+$/.test(segment)) return `${acc}[${segment}]`;
150
+ return acc === "" ? segment : `${acc}.${segment}`;
151
+ }, "");
152
+ }
153
+
154
+ /**
155
+ * The elements a rule iterates. An array yields one subject per item, a map one
156
+ * per entry with its key bound; a collection that is absent yields none, which
157
+ * is a rule that had nothing to say rather than a rule that failed.
158
+ *
159
+ * A scalar at the pointer is `undefined` — not an empty list — because that is a
160
+ * declaration defect the strict half reports, and an empty list would hide it.
161
+ */
162
+ export function resolveRuleSubjects(
163
+ config: unknown,
164
+ pointer: string,
165
+ ): RuleSubject[] | undefined {
166
+ const collection = resolvePointer(config, pointer);
167
+ if (collection === undefined || collection === null) return [];
168
+ const base = pointerToPath(pointer);
169
+ if (Array.isArray(collection)) {
170
+ return collection.map((value, index) => ({ path: `${base}[${index}]`, value }));
171
+ }
172
+ if (isObject(collection)) {
173
+ return Object.entries(collection).map(([key, value]) => ({
174
+ path: `${base}.${key}`,
175
+ value,
176
+ key,
177
+ }));
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ /**
183
+ * Path of the first CEL leaf inside a value, or `undefined` when every leaf is
184
+ * literal. A rule reading an expression would be evaluating a placeholder, so
185
+ * the subject is skipped — and the skip is reported, never silent.
186
+ *
187
+ * Stops at nested inline `{ kind }` declarations for the reason every other walk
188
+ * does: that CEL belongs to the nested kind, evaluated in its own scope.
189
+ */
190
+ export function findDynamicLeaf(value: unknown, base = ""): string | undefined {
191
+ if (isObject(value)) {
192
+ if (isCelNode(value)) return base || "(value)";
193
+ if (typeof value.kind === "string" && base !== "") return undefined;
194
+ for (const [key, child] of Object.entries(value)) {
195
+ const found = findDynamicLeaf(child, base === "" ? key : `${base}.${key}`);
196
+ if (found) return found;
197
+ }
198
+ return undefined;
199
+ }
200
+ if (Array.isArray(value)) {
201
+ for (let i = 0; i < value.length; i++) {
202
+ const found = findDynamicLeaf(value[i], `${base}[${i}]`);
203
+ if (found) return found;
204
+ }
205
+ }
206
+ return undefined;
207
+ }
208
+
209
+ /**
210
+ * The nodes a condition actually READS, resolved against the resource and the
211
+ * element under test — the input to the dynamic-leaf check.
212
+ *
213
+ * Scanning the whole subject instead would make the check useless in exactly
214
+ * the shape it matters most: a resource-wide rule takes the whole resource as
215
+ * its subject, so one unrelated `!cel` anywhere (a `version:` read from
216
+ * `module.version`, which is the conventional spelling) would switch off every
217
+ * such rule. Chains are the same primitive the binding-order derivation uses:
218
+ * parsed, never lexed, so a name inside a string literal reads nothing.
219
+ *
220
+ * A chain stops at a computed index (`INDEX_SEGMENT`): what it selects is not
221
+ * known statically, so the node reached so far is what gets checked — the
222
+ * over-approximating direction, which errs toward skipping rather than toward
223
+ * evaluating a placeholder.
224
+ */
225
+ export function readNodes(
226
+ chains: readonly (readonly string[])[],
227
+ /** The bindings in scope, by name — `self`/`this`/`key` for a resource rule,
228
+ * `self`/`referrer` for a referrer rule. A chain rooted at a name that is not
229
+ * bound reads nothing. */
230
+ roots: Record<string, unknown>,
231
+ ): unknown[] {
232
+ const nodes: unknown[] = [];
233
+ for (const chain of chains) {
234
+ const root = chain[0];
235
+ let current: unknown = root !== undefined && root in roots ? roots[root] : undefined;
236
+ if (current === undefined) continue;
237
+ for (const segment of chain.slice(1)) {
238
+ if (segment === "[*]") break;
239
+ if (Array.isArray(current)) {
240
+ const index = Number(segment);
241
+ current = Number.isInteger(index) ? current[index] : undefined;
242
+ } else if (isObject(current)) {
243
+ current = current[segment];
244
+ } else {
245
+ current = undefined;
246
+ }
247
+ if (current === undefined) break;
248
+ }
249
+ if (current !== undefined) nodes.push(current);
250
+ }
251
+ return nodes;
252
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * A rule's CEL condition: compiling it once, and refusing the ones that cannot
3
+ * be checks.
4
+ *
5
+ * Shared by `x-telo-resource-rules` (fields of one resource) and
6
+ * `x-telo-referrer-rules` (a requirement on whoever references it). The two
7
+ * families differ in what they bind — `this` and `key` versus `referrer` — and
8
+ * in nothing else: same polarity, same cache, same refusals, same budget. Two
9
+ * copies would eventually disagree about which functions a rule may call, which
10
+ * is a soundness property rather than a style one.
11
+ *
12
+ * Browser-safe: no Node built-ins.
13
+ */
14
+ import {
15
+ CEL_FUNCTIONS,
16
+ buildCelEnvironment,
17
+ celEngine,
18
+ extractAccessChains,
19
+ } from "@telorun/templating";
20
+
21
+ /**
22
+ * Wall-clock ceiling for one rule over one resource. The rules run on the
23
+ * kernel's boot path and at the editor's keystroke-time analysis, and the
24
+ * comprehension nesting is the RULE AUTHOR's — a dependency's quadratic rule
25
+ * must not be able to hang a consumer's `telo check`.
26
+ *
27
+ * It bounds the SUBJECT LOOP, not one expression: cel-js offers no step limit,
28
+ * so a single pathological expression over one huge element still runs to
29
+ * completion. Stated rather than hidden — the budget catches the shape that
30
+ * actually occurs (a cheap expression over many subjects) and reports the rule
31
+ * as defective rather than truncating coverage silently.
32
+ */
33
+ export const RULE_BUDGET_MS = 50;
34
+
35
+ const HOST_BACKED = new Set(CEL_FUNCTIONS.filter((f) => f.hostBacked).map((f) => f.name));
36
+ const NON_DETERMINISTIC = new Set(
37
+ CEL_FUNCTIONS.filter((f) => !f.deterministic).map((f) => f.name),
38
+ );
39
+
40
+ let sharedEnv: ReturnType<typeof buildCelEnvironment> | undefined;
41
+ /** The analyzer's own environment — no host handlers, so every `hostBacked`
42
+ * entry is a throwing stub. Built once; it is stateless. */
43
+ export function ruleEnv(): ReturnType<typeof buildCelEnvironment> {
44
+ sharedEnv ??= buildCelEnvironment();
45
+ return sharedEnv;
46
+ }
47
+
48
+ export type CompiledRule =
49
+ | { parsed: (ctx: Record<string, unknown>) => unknown; chains: readonly (readonly string[])[] }
50
+ | { reason: string };
51
+
52
+ /**
53
+ * A rule's condition, parsed once per process rather than once per resource.
54
+ *
55
+ * The pass runs over every resource of a kind and at the editor's
56
+ * keystroke-time analysis, so a workspace with fifty tables parsed the same
57
+ * six conditions fifty times each. The source string is the whole key: the
58
+ * environment is stateless and shared, so two identical conditions genuinely
59
+ * compile to the same program. A parse FAILURE is cached too — it is a property
60
+ * of the condition, and re-deriving it per resource costs the same as the
61
+ * success it replaced.
62
+ *
63
+ * BOUNDED, because the editor analyses on every keystroke: a kind author editing
64
+ * a `condition:` interns one entry per character typed, and every one of those
65
+ * intermediate strings is dead the moment the next arrives. Insertion-ordered
66
+ * eviction is enough — the working set is the conditions a workspace actually
67
+ * declares, and a stale entry costs one re-parse.
68
+ */
69
+ const RULE_CACHE_LIMIT = 512;
70
+ const compiledRules = new Map<string, CompiledRule>();
71
+
72
+ export function compileRuleCondition(
73
+ condition: string,
74
+ /** Chain roots to assume when the parse yields no AST — "unknown", not
75
+ * "reads nothing", so the caller checks the whole of every binding. */
76
+ fallbackRoots: readonly string[],
77
+ ): CompiledRule {
78
+ const cached = compiledRules.get(condition);
79
+ if (cached) return cached;
80
+ let result: CompiledRule;
81
+ try {
82
+ const parsed = ruleEnv().parse(condition) as (ctx: Record<string, unknown>) => unknown;
83
+ const ast = (parsed as unknown as { ast?: unknown }).ast;
84
+ result = {
85
+ parsed,
86
+ chains: ast ? extractAccessChains(ast as never) : fallbackRoots.map((root) => [root]),
87
+ };
88
+ } catch (err) {
89
+ result = { reason: err instanceof Error ? err.message : String(err) };
90
+ }
91
+ if (compiledRules.size >= RULE_CACHE_LIMIT) {
92
+ const oldest = compiledRules.keys().next();
93
+ if (!oldest.done) compiledRules.delete(oldest.value);
94
+ }
95
+ compiledRules.set(condition, result);
96
+ return result;
97
+ }
98
+
99
+ /**
100
+ * Why a condition cannot serve as a check, as messages for the declaring kind.
101
+ *
102
+ * Two refusals beyond the CEL diagnostics themselves: a function the kernel
103
+ * supplies at boot (a throwing stub here, so the rule could never run at
104
+ * `telo check`), and a non-deterministic one (a verdict that depends on when it
105
+ * ran is not a verdict).
106
+ */
107
+ export function conditionRefusals(condition: string): string[] {
108
+ const out: string[] = [];
109
+ const result = celEngine.analyze(condition, { celEnv: ruleEnv(), contextSchema: null });
110
+ for (const diagnostic of result.diagnostics) out.push(`Rule condition: ${diagnostic.message}`);
111
+ for (const call of result.calls) {
112
+ if (HOST_BACKED.has(call.name)) {
113
+ out.push(
114
+ `Rule condition calls '${call.name}()', which the kernel supplies at boot ` +
115
+ "(it needs Node crypto / Buffer). The analyzer registers a throwing stub, so " +
116
+ "the rule cannot run at telo check.",
117
+ );
118
+ } else if (NON_DETERMINISTIC.has(call.name) || call.deterministic === false) {
119
+ out.push(
120
+ `Rule condition calls '${call.name}()', which re-evaluates per call. A check ` +
121
+ "whose verdict depends on when it ran is not a check.",
122
+ );
123
+ }
124
+ }
125
+ return out;
126
+ }
@@ -276,6 +276,30 @@ export const TELO_SCHEMA_ANNOTATIONS: Record<
276
276
  description: "Derive this field's validation schema from a sibling ref's definition schema.",
277
277
  type: "string",
278
278
  },
279
+ "x-telo-schema-map": {
280
+ title: "Schema map",
281
+ description:
282
+ "On the field a projection keys on: the JSON Schema node each of its values means. A lookup, never a computed expression.",
283
+ type: "object",
284
+ },
285
+ "x-telo-schema-projection": {
286
+ title: "Schema projection",
287
+ description:
288
+ "How a collection of typed entries projects to a JSON Schema object — which collection, which field keys it, and which fields modify it. Belongs on the kind DOCUMENT, beside 'schema:' rather than inside it, because it describes the whole declaration and not one field; written here it still works and is reported so it can be moved.",
289
+ type: "object",
290
+ },
291
+ "x-telo-resource-rules": {
292
+ title: "Resource rules",
293
+ description:
294
+ "Relationships between this kind's own fields that JSON Schema cannot state, as CEL over `self` (the resource) and `this` (the element `in:` iterates). `condition` is TRUE when the rule holds.",
295
+ type: "array",
296
+ },
297
+ "x-telo-schema-projection-from": {
298
+ title: "Schema projection from",
299
+ description:
300
+ "Replace this node with the projection of the entry collection declared by the kind referenced at the named field.",
301
+ type: "string",
302
+ },
279
303
  "x-telo-value-schema-from": {
280
304
  title: "Value schema from",
281
305
  description: