@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
@@ -6,8 +6,13 @@ import {
6
6
  parseCanonicalTypeSchemaId,
7
7
  } from "@telorun/sdk";
8
8
  import { KERNEL_BUILTINS } from "./builtins.js";
9
- import { isStepSlot } from "./step-slot.js";
10
9
  import { withRefSlotsAsReadings } from "./ref-slot-reading.js";
10
+ // Where CEL is evaluated is one reader (`eval-paths.ts`), and the region half of
11
+ // it moved there so the scope walk and the `x-telo-eval` walk answer the same
12
+ // question in one place. Re-exported: this module is where every existing
13
+ // consumer imports them from.
14
+ export { extractCelRegionScopes, pathMatchesScope } from "./eval-paths.js";
15
+ import { pathMatchesScope } from "./eval-paths.js";
11
16
 
12
17
  export interface ContextResolveOpts {
13
18
  /** When provided, used to resolve `x-telo-context-from-root` annotations against the
@@ -282,35 +287,6 @@ function collectionBindingWithheld(
282
287
  return isLiveSlot(resolveCollectionSchema(manifestRoot, from, allManifests));
283
288
  }
284
289
 
285
- /**
286
- * Returns true when a CEL expression path (from walkCelExpressions, e.g. "routes[0].inputs.q")
287
- * falls within the scope of a context (e.g. "$.routes[*].inputs").
288
- *
289
- * The scope is matched directly (no sibling sharing): a context at "$.routes[*].inputs" only
290
- * applies to expressions whose path starts with "routes[N].inputs", not to other sibling fields.
291
- */
292
- export function pathMatchesScope(exprPath: string, scope: string): boolean {
293
- const stripped = scope.startsWith("$.") ? scope.slice(2) : scope;
294
- if (!stripped) return false;
295
-
296
- // Split on wildcard array segments; each [*] must match a concrete [N] in exprPath
297
- const parts = stripped.split("[*]");
298
- let remaining = exprPath;
299
- for (let i = 0; i < parts.length; i++) {
300
- const part = parts[i]!;
301
- if (!remaining.startsWith(part)) return false;
302
- remaining = remaining.slice(part.length);
303
- if (i < parts.length - 1) {
304
- // Expect a concrete array index like [0], [12], ...
305
- const m = remaining.match(/^\[\d+\]/);
306
- if (!m) return false;
307
- remaining = remaining.slice(m[0].length);
308
- }
309
- }
310
- // Expression must end here or continue into a child path
311
- return remaining === "" || remaining[0] === "." || remaining[0] === "[";
312
- }
313
-
314
290
  /**
315
291
  * Resolves `x-telo-context-*` annotations in a context schema using the concrete
316
292
  * manifest item (per-scope) and the manifest root.
@@ -579,47 +555,6 @@ export function extractContextsFromSchema(
579
555
  return all.sort((a, b) => b.scope.length - a.scope.length);
580
556
  }
581
557
 
582
- /** Schema keys that declare a CEL-bearing region: a field carrying any of these
583
- * is evaluated at runtime, so a `!cel` inside it (or a descendant) is live. A
584
- * STEP BODY is one too, and says so through the grammar its items point at
585
- * rather than through a key — {@link isStepSlot} reads either spelling. */
586
- const CEL_REGION_KEYS = [
587
- "x-telo-context",
588
- "x-telo-step-context",
589
- "x-telo-error-context",
590
- ] as const;
591
-
592
- /**
593
- * Walk a JSON Schema tree and collect the JSONPath scopes of every field that
594
- * declares a CEL-bearing region (`x-telo-context` / `x-telo-step-context` /
595
- * `x-telo-error-context`). Used — alongside `x-telo-eval` paths — to decide
596
- * whether a `!cel` expression sits in a slot the runtime actually evaluates.
597
- * Scopes use the same `$.a.b[*]` form as `extractContextsFromSchema`, matched
598
- * against expression paths with `pathMatchesScope`.
599
- */
600
- export function extractCelRegionScopes(schema: Record<string, any>, path = "$"): string[] {
601
- if (!schema || typeof schema !== "object") return [];
602
- const out: string[] = [];
603
-
604
- if (CEL_REGION_KEYS.some((k) => schema[k]) || isStepSlot(schema)) out.push(path);
605
-
606
- if (schema.properties) {
607
- for (const [key, value] of Object.entries(schema.properties as Record<string, any>)) {
608
- out.push(...extractCelRegionScopes(value, `${path}.${key}`));
609
- }
610
- }
611
- if (schema.items && typeof schema.items === "object") {
612
- out.push(...extractCelRegionScopes(schema.items, `${path}[*]`));
613
- }
614
- for (const key of ["oneOf", "anyOf", "allOf"] as const) {
615
- if (Array.isArray(schema[key])) {
616
- for (const subschema of schema[key]) out.push(...extractCelRegionScopes(subschema, path));
617
- }
618
- }
619
-
620
- return out;
621
- }
622
-
623
558
  function collectContexts(
624
559
  schema: Record<string, any>,
625
560
  path: string,
@@ -3,6 +3,7 @@ import { isTaggedSentinel } from "@telorun/templating";
3
3
  import type { AliasResolver } from "./alias-resolver.js";
4
4
  import type { DefinitionRegistry } from "./definition-registry.js";
5
5
  import { visitManifest } from "./manifest-visitor.js";
6
+ import { satisfiesValueBranch } from "./reference-field-map.js";
6
7
  import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
7
8
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
8
9
 
@@ -29,6 +30,16 @@ const SOURCE = "telo-analyzer";
29
30
  * Rejected, each with an actionable diagnostic pointing at `!ref`:
30
31
  * - the object form `{ kind, name }` (the old reference object), and
31
32
  * - a bare string (the old name / dotted-FQN reference).
33
+ *
34
+ * **A value-or-reference union slot is the one exception**, and it is decided by
35
+ * the value rather than by a new spelling: where the reference constraint is a
36
+ * *branch* of a union (`type:` on a column, holding either a storage class or a
37
+ * `!ref` to a declared enum), a SCALAR is a value and is left to that branch,
38
+ * while an object is still checked for the removed `{kind, name}` form unless a
39
+ * branch describes it. The rule guards a REMOVED spelling — a bare string is
40
+ * simply not a reference in Telo — so admitting one takes nothing away: what it
41
+ * costs is the answer when an author meant a reference and wrote a bare name,
42
+ * which against a closed branch AJV reports as an unknown value.
32
43
  */
33
44
  export function validateReferenceForms(
34
45
  resources: ResourceManifest[],
@@ -52,6 +63,17 @@ export function validateReferenceForms(
52
63
  // `!ref` and `!cel`/`${{ }}` sentinels are the supported shapes.
53
64
  if (isTaggedSentinel(value)) return;
54
65
 
66
+ // A SCALAR at a slot whose union has a value branch is a value, and the
67
+ // value branch is what judges it. Reporting a mistyped storage class as
68
+ // a malformed reference — "write it as '!ref txt'" — instructs the author
69
+ // to convert a typo into a reference, and AJV has already said what the
70
+ // value must be one of. The object form below is not ambiguous the same
71
+ // way: `{kind, name}` is the removed reference object, so it is still
72
+ // reported unless a branch genuinely describes that shape.
73
+ const hasValueBranch = (e.entry.valueBranches?.length ?? 0) > 0;
74
+ if (hasValueBranch && typeof value !== "object") return;
75
+ if (satisfiesValueBranch(value, e.entry.valueBranches, registry)) return;
76
+
55
77
  const r = e.source;
56
78
  const resourceLabel = `${r.kind}/${r.metadata!.name as string}`;
57
79
  const resourceData = { kind: r.kind, name: r.metadata!.name as string };
@@ -1,7 +1,13 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
2
  import { isRefSentinel } from "@telorun/templating";
3
3
  import { visitManifest } from "./manifest-visitor.js";
4
- import { isInlineResource, resolveFieldEntries, resolveFieldValues, type RefFieldEntry } from "./reference-field-map.js";
4
+ import {
5
+ isInlineResource,
6
+ resolveFieldEntries,
7
+ resolveFieldValues,
8
+ satisfiesValueBranch,
9
+ type RefFieldEntry,
10
+ } from "./reference-field-map.js";
5
11
  import { navigateJsonPointer, substituteCelFields } from "./schema-compat.js";
6
12
  import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
7
13
  import { resolveTypeFieldToSchema } from "./validate-cel-context.js";
@@ -101,7 +107,7 @@ export function validateReferences(
101
107
  // Group manifests by name to detect collisions. Two subtleties:
102
108
  //
103
109
  // 1. Some analyzer hosts emit the SAME physical document twice through
104
- // their pipeline — e.g. the telo-editor's `toAnalysisManifests` walks
110
+ // their pipeline — e.g. telo studio's `toAnalysisManifests` walks
105
111
  // each workspace module's documents independently, and a file
106
112
  // reachable from two angles (entry module + `include:` partial)
107
113
  // shows up twice. The fingerprint includes `sourceLine` so identical
@@ -293,6 +299,13 @@ export function validateReferences(
293
299
  if (typeof val !== "object") return;
294
300
  const refVal = val as Record<string, unknown>;
295
301
 
302
+ // A value the slot's own union describes is a value, not a reference —
303
+ // the same narrowing `validateReferenceForms` applies, through the same
304
+ // function, because it has to be applied here too: an OBJECT-shaped value
305
+ // branch would otherwise reach the structural check below and be reported
306
+ // as a reference missing 'kind' and 'name'.
307
+ if (satisfiesValueBranch(val, entry.valueBranches, registry)) return;
308
+
296
309
  // Skip inline resources — Phase 2 normalization hasn't run yet.
297
310
  if (isInlineResource(refVal)) return;
298
311
 
@@ -29,8 +29,22 @@ import {
29
29
  readReferrerRules,
30
30
  type ReferrerRule,
31
31
  } from "./referrer-rule.js";
32
- import { celSourceOf, findDynamicLeaf, readNodes } from "./resource-rule.js";
33
- import { RULE_BUDGET_MS, compileRuleCondition, conditionRefusals } from "./rule-condition.js";
32
+ import type { PeerBinder, PeerBindingFailure, PeersTarget } from "./peer-binding.js";
33
+ import {
34
+ celSourceOf,
35
+ findDynamicLeaf,
36
+ type DynamicLeaf,
37
+ isTaggedCondition,
38
+ pointerSegments,
39
+ pointerToPath,
40
+ readNodes,
41
+ } from "./resource-rule.js";
42
+ import {
43
+ RULE_BUDGET_MS,
44
+ UNTAGGED_CONDITION,
45
+ compileRuleCondition,
46
+ conditionRefusals,
47
+ } from "./rule-condition.js";
34
48
 
35
49
  export interface ReferrerRuleIssue {
36
50
  code: "REFERRER_RULE_INVALID";
@@ -51,14 +65,40 @@ export interface Referrer {
51
65
  /** One rule's verdict on one referrer. */
52
66
  export type ReferrerRuleFinding =
53
67
  | { kind: "violation"; rule: ReferrerRule; referrer: Referrer; message: string }
54
- | { kind: "skipped"; rule: ReferrerRule; referrer: Referrer; dynamicAt: string }
68
+ | { kind: "skipped"; rule: ReferrerRule; referrer: Referrer; dynamic: DynamicLeaf }
69
+ | { kind: "unbound"; rule: ReferrerRule; referrer: Referrer; failure: PeerBindingFailure }
55
70
  | { kind: "failed"; rule: ReferrerRule; referrer?: Referrer; reason: string }
56
71
  | { kind: "over-budget"; rule: ReferrerRule; referrer: Referrer; elapsedMs: number };
57
72
 
73
+ /** What a peer rule needs beyond the referrers themselves: the binder that
74
+ * resolves a referrer's collection into declarations. Absent for a host that
75
+ * cannot resolve one — a rule declaring `peers:` then reports as unbound rather
76
+ * than running with the binding missing, which would evaluate a condition
77
+ * against names that are simply not there.
78
+ *
79
+ * ONE binder per analysis, because it caches each referrer's resolved
80
+ * collection and both the evaluation and the exercised check ask for it. */
81
+ export interface ReferrerRuleContext {
82
+ readonly peerBinder?: PeerBinder;
83
+ }
84
+
58
85
  function isObject(value: unknown): value is Record<string, unknown> {
59
86
  return !!value && typeof value === "object" && !Array.isArray(value);
60
87
  }
61
88
 
89
+ /** What a `peers:` pointer names in the referrer kind it is filtered to. Defined
90
+ * with the binding vocabulary; re-exported here because this is the surface
91
+ * that consumes it. The caller resolves it — only it holds the definition
92
+ * registry, and only after every kind is registered, since a rule may name a
93
+ * kind declared later in the same file. */
94
+ export type { PeersTarget };
95
+
96
+ export interface ReferrerRuleDeclarationContext {
97
+ /** Resolves a rule's `peers:` against its `referrer:` kind. Omit to skip the
98
+ * check — the safe direction for a host with no registry. */
99
+ readonly peersTarget?: (referrerKind: string, pointer: string) => PeersTarget;
100
+ }
101
+
62
102
  /**
63
103
  * Report every way a kind's referrer-rule declarations are malformed. Runs on
64
104
  * the `Telo.Definition` / `Telo.Abstract` doc, so a defect lands on the line the
@@ -68,7 +108,10 @@ function isObject(value: unknown): value is Record<string, unknown> {
68
108
  * declaring module's scope by `resolveSchemaRefKinds`, the only pass holding
69
109
  * that scope, and a name resolving to nothing is reported from there.
70
110
  */
71
- export function validateReferrerRuleDeclarations(manifest: ResourceManifest): ReferrerRuleIssue[] {
111
+ export function validateReferrerRuleDeclarations(
112
+ manifest: ResourceManifest,
113
+ context: ReferrerRuleDeclarationContext = {},
114
+ ): ReferrerRuleIssue[] {
72
115
  const own = (manifest as unknown as Record<string, unknown>).schema;
73
116
  const raw = readRawReferrerRules(own);
74
117
  if (raw === undefined) return [];
@@ -137,6 +180,12 @@ export function validateReferrerRuleDeclarations(manifest: ResourceManifest): Re
137
180
  issue(`${at}.severity`, "'severity' must be 'error' or 'warning'.");
138
181
  }
139
182
 
183
+ if (condition !== undefined && condition.length > 0 && !isTaggedCondition(entry.condition)) {
184
+ issue(`${at}.condition`, UNTAGGED_CONDITION);
185
+ }
186
+
187
+ validatePeersDeclaration(entry, at, issue, context);
188
+
140
189
  if (condition) {
141
190
  for (const refusal of conditionRefusals(condition)) issue(`${at}.condition`, refusal);
142
191
  }
@@ -145,6 +194,59 @@ export function validateReferrerRuleDeclarations(manifest: ResourceManifest): Re
145
194
  return issues;
146
195
  }
147
196
 
197
+ /** The strict half of `peers:` — the binding that lets a rule reach a referrer's
198
+ * OTHER entries, which is also the one that fails invisibly: a pointer naming a
199
+ * collection nothing in resolves would leave the rule seeing no declaration at
200
+ * all, and a check that never sees its subject reads as passing. */
201
+ function validatePeersDeclaration(
202
+ entry: Record<string, unknown>,
203
+ at: string,
204
+ issue: (path: string, message: string) => void,
205
+ context: ReferrerRuleDeclarationContext,
206
+ ): void {
207
+ const { peers } = entry;
208
+ if (peers === undefined) return;
209
+ if (typeof peers !== "string") {
210
+ issue(
211
+ `${at}.peers`,
212
+ "'peers' is a JSON Pointer naming a collection OF THE REFERRER (e.g. /tables), whose " +
213
+ "other entries bind as 'peers' and whose own entry binds as 'entry'.",
214
+ );
215
+ return;
216
+ }
217
+ if (pointerSegments(peers) === undefined || peers === "" || peers === "/") {
218
+ issue(
219
+ `${at}.peers`,
220
+ `'peers' must be a JSON Pointer to a collection, e.g. /tables — '${peers}' is not one.`,
221
+ );
222
+ return;
223
+ }
224
+ if (typeof entry.referrer !== "string" || entry.referrer.length === 0) {
225
+ issue(
226
+ `${at}.peers`,
227
+ "'peers' names a collection of the REFERRER, so the rule must also declare 'referrer:' — " +
228
+ "without a kind there is nothing to check the pointer against, and a pointer that " +
229
+ "resolves to nothing disables the rule in silence.",
230
+ );
231
+ return;
232
+ }
233
+ const target = context.peersTarget?.(entry.referrer, peers);
234
+ if (target === "absent") {
235
+ issue(
236
+ `${at}.peers`,
237
+ `'${entry.referrer}' declares no collection at '${peers}'. A pointer that resolves to ` +
238
+ "nothing binds no peers, so the rule would never see a sibling declaration.",
239
+ );
240
+ } else if (target === "plain") {
241
+ issue(
242
+ `${at}.peers`,
243
+ `'${peers}' on '${entry.referrer}' holds plain data — nothing in it is a reference, so ` +
244
+ "no entry resolves to a declaration and the rule would run against values it cannot " +
245
+ "compare. Name a collection whose items are, or contain, a reference.",
246
+ );
247
+ }
248
+ }
249
+
148
250
  /**
149
251
  * Run a kind's referrer rules against every resource that references one of its
150
252
  * resources.
@@ -154,6 +256,11 @@ export function validateReferrerRuleDeclarations(manifest: ResourceManifest): Re
154
256
  * manifest identity, since a name alone is module-scoped — because the condition
155
257
  * reads the two manifests and nothing about the site, so a second site could only
156
258
  * produce the identical verdict at a different path.
259
+ *
260
+ * **A rule declaring `peers:` is the exception, and evaluates once per ENTRY.**
261
+ * That is a deliberate departure from judge-once rather than an inconsistency: a
262
+ * rule that binds `entry` is about the entry, so a resource listed twice has two
263
+ * entries to answer for, and the two sites can genuinely disagree.
157
264
  */
158
265
  export function evaluateReferrerRules(
159
266
  manifest: ResourceManifest,
@@ -163,6 +270,7 @@ export function evaluateReferrerRules(
163
270
  * Liskov-substitutable, so a child of the named kind matches. Supplied by the
164
271
  * caller, which holds the definition registry. */
165
272
  kindMatches: (filter: string, kind: string) => boolean,
273
+ context: ReferrerRuleContext = {},
166
274
  ): ReferrerRuleFinding[] {
167
275
  const rules = readReferrerRules(definitionSchema);
168
276
  if (rules.length === 0) return [];
@@ -171,7 +279,11 @@ export function evaluateReferrerRules(
171
279
  const findings: ReferrerRuleFinding[] = [];
172
280
 
173
281
  for (const rule of rules) {
174
- const compiled = compileRuleCondition(rule.condition, ["self", "referrer"]);
282
+ const perEntry = rule.peers !== undefined;
283
+ const compiled = compileRuleCondition(
284
+ rule.condition,
285
+ perEntry ? ["self", "referrer", "entry", "peers"] : ["self", "referrer"],
286
+ );
175
287
  if ("reason" in compiled) {
176
288
  findings.push({ kind: "failed", rule, reason: compiled.reason });
177
289
  continue;
@@ -186,29 +298,47 @@ export function evaluateReferrerRules(
186
298
  const started = Date.now();
187
299
  for (const referrer of referrers) {
188
300
  if (rule.referrer !== undefined && !kindMatches(rule.referrer, referrer.kind)) continue;
189
- if (seen.has(referrer.manifest)) continue;
190
- seen.add(referrer.manifest);
301
+ if (!perEntry) {
302
+ if (seen.has(referrer.manifest)) continue;
303
+ seen.add(referrer.manifest);
304
+ }
305
+
306
+ const scope: Record<string, unknown> = {
307
+ self,
308
+ referrer: referrer.manifest as unknown as Record<string, unknown>,
309
+ };
310
+ if (perEntry) {
311
+ const bound = context.peerBinder
312
+ ? context.peerBinder.bind(referrer.manifest, referrer.kind, rule.peers!, referrer.path)
313
+ : ({
314
+ ok: false,
315
+ failure: { reason: "unknown-shape", at: pointerToPath(rule.peers!) },
316
+ } as const);
317
+ if (!bound.ok) {
318
+ findings.push({ kind: "unbound", rule, referrer, failure: bound.failure });
319
+ continue;
320
+ }
321
+ scope.peers = bound.binding.peers;
322
+ scope.entry = bound.binding.entry;
323
+ }
191
324
 
192
325
  // Only the nodes this condition READS decide whether it can run — the
193
326
  // resource-rule reasoning, and it bites harder here: the referrer is a
194
327
  // whole manifest, so scanning all of it would disable the rule for any
195
328
  // server carrying one unrelated expression.
196
- let dynamicAt: string | undefined;
197
- for (const node of readNodes(chains, {
198
- self,
199
- referrer: referrer.manifest as unknown as Record<string, unknown>,
200
- })) {
201
- dynamicAt = findDynamicLeaf(node);
202
- if (dynamicAt !== undefined) break;
329
+ let dynamic: DynamicLeaf | undefined;
330
+ for (const node of readNodes(chains, scope)) {
331
+ dynamic = findDynamicLeaf(node);
332
+ if (dynamic !== undefined) break;
203
333
  }
204
- if (dynamicAt !== undefined) {
205
- findings.push({ kind: "skipped", rule, referrer, dynamicAt });
334
+ if (dynamic !== undefined) {
335
+ findings.push({ kind: "skipped", rule, referrer, dynamic });
206
336
  continue;
207
337
  }
208
338
 
209
339
  let held: unknown;
210
340
  try {
211
- held = parsed({ self, referrer: referrer.manifest });
341
+ held = parsed(scope);
212
342
  } catch (err) {
213
343
  findings.push({
214
344
  kind: "failed",
@@ -239,10 +369,23 @@ export function referrerRuleExercised(
239
369
  rule: ReferrerRule,
240
370
  referrers: readonly Referrer[],
241
371
  kindMatches: (filter: string, kind: string) => boolean,
372
+ context: ReferrerRuleContext = {},
242
373
  ): boolean {
243
- if (rule.referrer === undefined) return referrers.length > 0;
244
- const filter = rule.referrer;
245
- return referrers.some((referrer) => kindMatches(filter, referrer.kind));
374
+ const matching =
375
+ rule.referrer === undefined
376
+ ? referrers
377
+ : referrers.filter((referrer) => kindMatches(rule.referrer!, referrer.kind));
378
+ if (matching.length === 0) return false;
379
+ if (rule.peers === undefined) return true;
380
+ // A peer rule that ran against an EMPTY peer set proved nothing — and an empty
381
+ // set everywhere is exactly what a typo in `peers:` looks like from outside,
382
+ // which is silence that reads as passing. Asked through the binder, so it
383
+ // shares the resolution the evaluation already paid for.
384
+ const binder = context.peerBinder;
385
+ if (!binder) return false;
386
+ return matching.some((referrer) =>
387
+ binder.hasPeers(referrer.manifest, referrer.kind, rule.peers!, referrer.path),
388
+ );
246
389
  }
247
390
 
248
391
  /** Where a referrer-rule finding is reported, and how loudly. Plain data, so the
@@ -262,6 +405,29 @@ export interface ReferrerRuleDiagnostic {
262
405
  rule: string;
263
406
  }
264
407
 
408
+ /** Why a peer binding could not be produced, as the sentence a reader acts on. */
409
+ function peerBindingReason(failure: PeerBindingFailure): string {
410
+ switch (failure.reason) {
411
+ case "no-collection":
412
+ return `'${failure.at}' holds no collection to bind 'peers' from.`;
413
+ case "unresolved":
414
+ return (
415
+ `a reference at '${failure.at}' names a declaration this analysis does not hold, ` +
416
+ "so a peer would bind to nothing."
417
+ );
418
+ case "dynamic":
419
+ return (
420
+ `a value at '${failure.at}' holds ${failure.what ?? "a value"}, which is not known ` +
421
+ "until the resource is created, so the comparison would run against a placeholder."
422
+ );
423
+ case "unknown-shape":
424
+ return (
425
+ `which paths under '${failure.at}' hold references is not known here, so nothing ` +
426
+ "could be resolved into a declaration."
427
+ );
428
+ }
429
+ }
430
+
265
431
  const nameOf = (manifest: ResourceManifest): string =>
266
432
  (manifest.metadata?.name as string | undefined) ?? "<unnamed>";
267
433
 
@@ -305,8 +471,8 @@ export function reportReferrerRules(
305
471
  message:
306
472
  `${finding.referrer.kind}/${nameOf(finding.referrer.manifest)}: rule ` +
307
473
  `'${finding.rule.code}' from ${declaringKind} did not run at ` +
308
- `'${finding.referrer.path}' — the value holds a CEL expression at ` +
309
- `'${finding.dynamicAt}', which is not known until the resource is created. ` +
474
+ `'${finding.referrer.path}' — the value holds ${finding.dynamic.what} at ` +
475
+ `'${finding.dynamic.path}', which is not known until the resource is created. ` +
310
476
  "Reported rather than dropped: a check whose coverage varies invisibly reads as passing.",
311
477
  manifest: finding.referrer.manifest,
312
478
  path: finding.referrer.path,
@@ -314,6 +480,21 @@ export function reportReferrerRules(
314
480
  });
315
481
  continue;
316
482
  }
483
+ if (finding.kind === "unbound") {
484
+ out.push({
485
+ code: "REFERRER_RULE_SKIPPED",
486
+ severity: "information",
487
+ message:
488
+ `${finding.referrer.kind}/${nameOf(finding.referrer.manifest)}: rule ` +
489
+ `'${finding.rule.code}' from ${declaringKind} did not run at ` +
490
+ `'${finding.referrer.path}' — ${peerBindingReason(finding.failure)}` +
491
+ " Reported rather than dropped: a check whose coverage varies invisibly reads as passing.",
492
+ manifest: finding.referrer.manifest,
493
+ path: finding.referrer.path,
494
+ rule: finding.rule.code,
495
+ });
496
+ continue;
497
+ }
317
498
 
318
499
  const because =
319
500
  finding.kind === "failed"
@@ -360,12 +541,18 @@ export function reportUnexercisedReferrerRule(
360
541
  code: "REFERRER_RULE_UNEXERCISED",
361
542
  severity: "information",
362
543
  message:
363
- `Referrer rule '${rule.code}' never ran: nothing` +
364
- (rule.referrer === undefined ? "" : ` of kind '${rule.referrer}'`) +
365
- " references a resource of this kind, so nothing has proven the condition." +
366
- (rule.referrer === undefined
367
- ? ""
368
- : " A 'referrer' naming a kind no manifest uses disables the rule in silence."),
544
+ rule.peers !== undefined
545
+ ? `Referrer rule '${rule.code}' never ran with peers to compare: nothing` +
546
+ (rule.referrer === undefined ? "" : ` of kind '${rule.referrer}'`) +
547
+ ` references a resource of this kind while '${rule.peers}' held another ` +
548
+ "declaration, so nothing has proven the condition. An empty peer set " +
549
+ "everywhere is what a typo in 'peers:' looks like from outside."
550
+ : `Referrer rule '${rule.code}' never ran: nothing` +
551
+ (rule.referrer === undefined ? "" : ` of kind '${rule.referrer}'`) +
552
+ " references a resource of this kind, so nothing has proven the condition." +
553
+ (rule.referrer === undefined
554
+ ? ""
555
+ : " A 'referrer' naming a kind no manifest uses disables the rule in silence."),
369
556
  manifest: definition,
370
557
  path: `schema.${REFERRER_RULES_ANNOTATION}[${rule.index}]`,
371
558
  rule: rule.code,
@@ -22,11 +22,18 @@
22
22
  * Browser-safe: no Node built-ins.
23
23
  */
24
24
  import type { ResourceManifest } from "@telorun/sdk";
25
- import { RULE_BUDGET_MS, compileRuleCondition, conditionRefusals } from "./rule-condition.js";
25
+ import {
26
+ RULE_BUDGET_MS,
27
+ UNTAGGED_CONDITION,
28
+ compileRuleCondition,
29
+ conditionRefusals,
30
+ } from "./rule-condition.js";
26
31
  import {
27
32
  RESOURCE_RULES_ANNOTATION,
28
33
  celSourceOf,
29
34
  findDynamicLeaf,
35
+ type DynamicLeaf,
36
+ isTaggedCondition,
30
37
  pointerSegments,
31
38
  readRawResourceRules,
32
39
  readResourceRules,
@@ -54,7 +61,7 @@ export type ResourceRuleFinding =
54
61
  path: string;
55
62
  message: string;
56
63
  }
57
- | { kind: "skipped"; rule: ResourceRule; path: string; dynamicAt: string }
64
+ | { kind: "skipped"; rule: ResourceRule; path: string; dynamic: DynamicLeaf }
58
65
  | { kind: "failed"; rule: ResourceRule; path: string; reason: string }
59
66
  | { kind: "over-budget"; rule: ResourceRule; path: string; elapsedMs: number };
60
67
 
@@ -63,8 +70,11 @@ function isObject(value: unknown): value is Record<string, unknown> {
63
70
  }
64
71
 
65
72
  /** Navigate a kind's own schema to the node describing what a pointer names, so
66
- * an `in:` naming a field the kind does not declare is caught at the kind. */
67
- function schemaAtPointer(schema: unknown, pointer: string): unknown {
73
+ * an `in:` naming a field the kind does not declare is caught at the kind.
74
+ * Exported because the peer-rule strict half asks the same question of the
75
+ * REFERRER's schema, and two navigators would eventually disagree about what a
76
+ * pointer names. */
77
+ export function schemaAtPointer(schema: unknown, pointer: string): unknown {
68
78
  const segments = pointerSegments(pointer);
69
79
  if (!segments) return undefined;
70
80
  let node: unknown = schema;
@@ -83,7 +93,7 @@ function schemaAtPointer(schema: unknown, pointer: string): unknown {
83
93
 
84
94
  /** True when a schema node describes something a rule can iterate. Unknown or
85
95
  * absent `type` passes: an open schema is not evidence of a defect. */
86
- function isIterableSchema(node: unknown): boolean {
96
+ export function isIterableSchema(node: unknown): boolean {
87
97
  if (!isObject(node)) return true;
88
98
  const type = node.type;
89
99
  if (type === undefined) return true;
@@ -198,6 +208,10 @@ export function validateResourceRuleDeclarations(
198
208
  }
199
209
  }
200
210
 
211
+ if (condition !== undefined && condition.length > 0 && !isTaggedCondition(entry.condition)) {
212
+ issue(`${at}.condition`, UNTAGGED_CONDITION);
213
+ }
214
+
201
215
  if (condition) {
202
216
  for (const refusal of conditionRefusals(condition)) issue(`${at}.condition`, refusal);
203
217
  }
@@ -243,13 +257,13 @@ export function evaluateResourceRules(
243
257
  // Only the nodes this condition READS decide whether it can run — see
244
258
  // `readNodes`. Scanning the whole subject would disable every
245
259
  // resource-wide rule on any manifest containing one unrelated expression.
246
- let dynamicAt: string | undefined;
260
+ let dynamic: DynamicLeaf | undefined;
247
261
  for (const node of readNodes(chains, { self, this: subject.value })) {
248
- dynamicAt = findDynamicLeaf(node);
249
- if (dynamicAt !== undefined) break;
262
+ dynamic = findDynamicLeaf(node);
263
+ if (dynamic !== undefined) break;
250
264
  }
251
- if (dynamicAt !== undefined) {
252
- findings.push({ kind: "skipped", rule, path: subject.path, dynamicAt });
265
+ if (dynamic !== undefined) {
266
+ findings.push({ kind: "skipped", rule, path: subject.path, dynamic });
253
267
  continue;
254
268
  }
255
269
  let held: unknown;
@@ -347,8 +361,8 @@ export function reportResourceRules(
347
361
  severity: "information",
348
362
  message:
349
363
  `${manifest.kind}/${name}: rule '${finding.rule.code}' did not run` +
350
- `${at ? ` at '${at}'` : ""} — the value holds a CEL expression at ` +
351
- `'${finding.dynamicAt}', which is not known until the resource is created. ` +
364
+ `${at ? ` at '${at}'` : ""} — the value holds ${finding.dynamic.what} at ` +
365
+ `'${finding.dynamic.path}', which is not known until the resource is created. ` +
352
366
  "Reported rather than dropped: a check whose coverage varies invisibly reads as passing.",
353
367
  manifest,
354
368
  path: at,