@telorun/analyzer 0.64.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.
- package/dist/analyzer.d.ts +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +175 -65
- package/dist/call-graph.d.ts.map +1 -1
- package/dist/call-graph.js +7 -1
- package/dist/cel-environment.d.ts +12 -0
- package/dist/cel-environment.d.ts.map +1 -1
- package/dist/cel-environment.js +35 -19
- package/dist/cel-scope.d.ts.map +1 -1
- package/dist/cel-scope.js +24 -4
- package/dist/definition-registry.d.ts +47 -6
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +82 -13
- package/dist/eval-paths.d.ts +68 -0
- package/dist/eval-paths.d.ts.map +1 -1
- package/dist/eval-paths.js +138 -0
- package/dist/flatten-for-analyzer.d.ts +1 -1
- package/dist/flatten-for-analyzer.js +1 -1
- package/dist/import-resolution-diagnostics.d.ts +1 -1
- package/dist/import-resolution-diagnostics.js +1 -1
- package/dist/index.d.ts +13 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -6
- package/dist/manifest-visitor.d.ts.map +1 -1
- package/dist/manifest-visitor.js +8 -1
- package/dist/migrations/entries/index.d.ts.map +1 -1
- package/dist/migrations/entries/index.js +2 -0
- package/dist/migrations/entries/schema-prepare-bucket.json +27 -0
- package/dist/peer-binding.d.ts +232 -0
- package/dist/peer-binding.d.ts.map +1 -0
- package/dist/peer-binding.js +418 -0
- package/dist/ref-slot.d.ts +13 -0
- package/dist/ref-slot.d.ts.map +1 -1
- package/dist/ref-slot.js +26 -0
- package/dist/reference-field-map.d.ts +28 -0
- package/dist/reference-field-map.d.ts.map +1 -1
- package/dist/reference-field-map.js +25 -0
- package/dist/referrer-rule.d.ts +17 -0
- package/dist/referrer-rule.d.ts.map +1 -1
- package/dist/referrer-rule.js +4 -1
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.js +1 -1
- package/dist/resource-rule.d.ts +40 -27
- package/dist/resource-rule.d.ts.map +1 -1
- package/dist/resource-rule.js +61 -7
- package/dist/rule-condition.d.ts +11 -0
- package/dist/rule-condition.d.ts.map +1 -1
- package/dist/rule-condition.js +13 -0
- package/dist/schema-compat.d.ts +59 -22
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +60 -75
- package/dist/schema-error-report.d.ts +68 -0
- package/dist/schema-error-report.d.ts.map +1 -0
- package/dist/schema-error-report.js +356 -0
- package/dist/schema-projection.d.ts +106 -4
- package/dist/schema-projection.d.ts.map +1 -1
- package/dist/schema-projection.js +206 -18
- package/dist/telo-version.d.ts +1 -1
- package/dist/telo-version.js +1 -1
- package/dist/validate-cel-context.d.ts +1 -17
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +5 -70
- package/dist/validate-nested-inline.d.ts +22 -1
- package/dist/validate-nested-inline.d.ts.map +1 -1
- package/dist/validate-nested-inline.js +17 -9
- package/dist/validate-reference-forms.d.ts +10 -0
- package/dist/validate-reference-forms.d.ts.map +1 -1
- package/dist/validate-reference-forms.js +23 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +9 -2
- package/dist/validate-referrer-rules.d.ts +38 -4
- package/dist/validate-referrer-rules.d.ts.map +1 -1
- package/dist/validate-referrer-rules.js +139 -31
- package/dist/validate-resource-rules.d.ts +11 -2
- package/dist/validate-resource-rules.d.ts.map +1 -1
- package/dist/validate-resource-rules.js +18 -12
- package/dist/validate-schema-projection.d.ts.map +1 -1
- package/dist/validate-schema-projection.js +33 -2
- package/dist/validate-step-inputs.js +11 -3
- package/dist/with-synthetic-positions.d.ts +1 -1
- package/dist/with-synthetic-positions.js +1 -1
- package/package.json +3 -3
- package/src/analyzer.ts +211 -68
- package/src/call-graph.ts +7 -1
- package/src/cel-environment.ts +48 -19
- package/src/cel-scope.ts +24 -6
- package/src/definition-registry.ts +90 -11
- package/src/eval-paths.ts +152 -0
- package/src/flatten-for-analyzer.ts +1 -1
- package/src/import-resolution-diagnostics.ts +1 -1
- package/src/index.ts +55 -6
- package/src/manifest-visitor.ts +8 -1
- package/src/migrations/entries/index.ts +2 -0
- package/src/migrations/entries/schema-prepare-bucket.json +27 -0
- package/src/peer-binding.ts +641 -0
- package/src/ref-slot.ts +36 -0
- package/src/reference-field-map.ts +42 -0
- package/src/referrer-rule.ts +20 -1
- package/src/release/index.ts +1 -1
- package/src/resource-rule.ts +69 -7
- package/src/rule-condition.ts +15 -0
- package/src/schema-compat.ts +92 -79
- package/src/schema-error-report.ts +417 -0
- package/src/schema-projection.ts +283 -20
- package/src/telo-version.ts +1 -1
- package/src/validate-cel-context.ts +6 -71
- package/src/validate-nested-inline.ts +35 -14
- package/src/validate-reference-forms.ts +22 -0
- package/src/validate-references.ts +15 -2
- package/src/validate-referrer-rules.ts +215 -28
- package/src/validate-resource-rules.ts +26 -12
- package/src/validate-schema-projection.ts +50 -1
- package/src/validate-step-inputs.ts +10 -4
- package/src/with-synthetic-positions.ts +1 -1
|
@@ -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 {
|
|
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.
|
|
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 {
|
|
33
|
-
import {
|
|
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;
|
|
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(
|
|
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
|
|
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 (
|
|
190
|
-
|
|
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
|
|
197
|
-
for (const node of readNodes(chains, {
|
|
198
|
-
|
|
199
|
-
|
|
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 (
|
|
205
|
-
findings.push({ kind: "skipped", rule, referrer,
|
|
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(
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
309
|
-
`'${finding.
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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 {
|
|
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;
|
|
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
|
-
|
|
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
|
|
260
|
+
let dynamic: DynamicLeaf | undefined;
|
|
247
261
|
for (const node of readNodes(chains, { self, this: subject.value })) {
|
|
248
|
-
|
|
249
|
-
if (
|
|
262
|
+
dynamic = findDynamicLeaf(node);
|
|
263
|
+
if (dynamic !== undefined) break;
|
|
250
264
|
}
|
|
251
|
-
if (
|
|
252
|
-
findings.push({ kind: "skipped", rule, path: subject.path,
|
|
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
|
|
351
|
-
`'${finding.
|
|
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,
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
projectionKeyMap,
|
|
27
27
|
rawSchemaProjection,
|
|
28
28
|
readSchemaProjection,
|
|
29
|
+
schemaMapBranch,
|
|
29
30
|
schemaProjectionIsMisplaced,
|
|
30
31
|
} from "./schema-projection.js";
|
|
31
32
|
|
|
@@ -118,6 +119,48 @@ export function validateSchemaProjection(manifest: ResourceManifest): SchemaProj
|
|
|
118
119
|
];
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
const reference = raw.reference;
|
|
123
|
+
if (reference !== undefined) {
|
|
124
|
+
if (!isObject(reference)) {
|
|
125
|
+
issues.push(
|
|
126
|
+
issue(
|
|
127
|
+
"SCHEMA_PROJECTION_INVALID",
|
|
128
|
+
`${base}.reference`,
|
|
129
|
+
`'reference' says how an entry whose '${projection.key}' holds a REFERENCE projects. ` +
|
|
130
|
+
`It is an object naming 'from' (the target field to read), 'keyword' (the schema ` +
|
|
131
|
+
`keyword its values become) and one of 'base' / 'baseFrom'.`,
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
} else {
|
|
135
|
+
const absent = ["from", "keyword"].filter((f) => typeof reference[f] !== "string");
|
|
136
|
+
if (absent.length > 0) {
|
|
137
|
+
issues.push(
|
|
138
|
+
issue(
|
|
139
|
+
"SCHEMA_PROJECTION_INVALID",
|
|
140
|
+
`${base}.reference`,
|
|
141
|
+
`'reference' is missing ${absent.map((f) => `'${f}'`).join(" and ")}. Without them a ` +
|
|
142
|
+
`referenced entry projects to nothing, so it vanishes from every consumer's view.`,
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const bases = ["base", "baseFrom"].filter((f) => reference[f] !== undefined);
|
|
147
|
+
if (bases.length !== 1) {
|
|
148
|
+
issues.push(
|
|
149
|
+
issue(
|
|
150
|
+
"SCHEMA_PROJECTION_INVALID",
|
|
151
|
+
`${base}.reference`,
|
|
152
|
+
bases.length === 0
|
|
153
|
+
? `'reference' declares neither 'base' nor 'baseFrom', so the projected node has no ` +
|
|
154
|
+
`type. Write 'base' where the named type IS its own base type, or 'baseFrom' ` +
|
|
155
|
+
`naming the target field that holds a value of this kind's own map.`
|
|
156
|
+
: `'reference' declares both 'base' and 'baseFrom'. They are two answers to one ` +
|
|
157
|
+
`question — where the projected node's type comes from — so declare exactly one.`,
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
121
164
|
const map = projectionKeyMap(schema, projection);
|
|
122
165
|
if (!map) {
|
|
123
166
|
return [
|
|
@@ -180,5 +223,11 @@ function keySchema(
|
|
|
180
223
|
: undefined;
|
|
181
224
|
if (!isObject(entry) || !isObject(entry.properties)) return undefined;
|
|
182
225
|
const field = entry.properties[key];
|
|
183
|
-
|
|
226
|
+
if (!isObject(field)) return undefined;
|
|
227
|
+
// The vocabulary may sit on a BRANCH — a slot unioning a closed value set with
|
|
228
|
+
// a reference — and the branch carrying the map is the one whose `enum` the map
|
|
229
|
+
// has to answer for. Reading the union node instead finds no `enum` at all, so
|
|
230
|
+
// the completeness check would silently stop running exactly where the two
|
|
231
|
+
// halves can disagree. Asked through the single reader, never re-derived here.
|
|
232
|
+
return schemaMapBranch(field) ?? field;
|
|
184
233
|
}
|
|
@@ -248,9 +248,15 @@ function checkCallSite(site: CallSite, ctx: CallCheckContext): StepInputIssue[]
|
|
|
248
248
|
// findings (missing required, unknown property) are located at the
|
|
249
249
|
// container and survive the filter.
|
|
250
250
|
const celPaths = new Set<string>();
|
|
251
|
-
const substituted = substituteCelFields(values, contract.schema, undefined,
|
|
252
|
-
celPaths.add(p),
|
|
253
|
-
|
|
251
|
+
const substituted = substituteCelFields(values, contract.schema, undefined, {
|
|
252
|
+
onSubstitute: (p) => celPaths.add(p),
|
|
253
|
+
// A contract may name a shape declared elsewhere. Both halves need the
|
|
254
|
+
// resolver or they disagree about the same slot: the stand-in walk hands
|
|
255
|
+
// its expressions a typeless value, and the check below compiles nothing
|
|
256
|
+
// at all — so a step's arguments went unchecked against exactly the
|
|
257
|
+
// contracts that describe them most precisely.
|
|
258
|
+
external: (ref) => defs.schemaForId(ref),
|
|
259
|
+
});
|
|
254
260
|
// The type-argument check, at the one site where a produced value's schema
|
|
255
261
|
// meets a consuming slot's. A CEL leaf's placeholder says nothing about
|
|
256
262
|
// what the expression yields, so AJV above is silent here by design — and
|
|
@@ -328,7 +334,7 @@ function checkCallSite(site: CallSite, ctx: CallCheckContext): StepInputIssue[]
|
|
|
328
334
|
}
|
|
329
335
|
}
|
|
330
336
|
|
|
331
|
-
for (const issue of
|
|
337
|
+
for (const issue of defs.validateResourceConfig(substituted, contract.schema)) {
|
|
332
338
|
if (celPaths.has(issue.path)) continue;
|
|
333
339
|
// A missing-required issue names the property that ISN'T there, so
|
|
334
340
|
// anchoring on it finds no node and the diagnostic degrades to 1:1 —
|
|
@@ -10,7 +10,7 @@ import { REF_VALIDATION_SKIP_KINDS } from "./system-kinds.js";
|
|
|
10
10
|
* manifest (the dedup that backs `DUPLICATE_RESOURCE_NAME` reads
|
|
11
11
|
* `(source, sourceLine)` to distinguish pipeline echoes from real
|
|
12
12
|
* collisions). Production callers — the `Loader`, `flattenForAnalyzer`,
|
|
13
|
-
*
|
|
13
|
+
* telo studio's `emitDocsFor`, the VSCode extension — all stamp
|
|
14
14
|
* positions already. This helper is the escape hatch for **programmatic
|
|
15
15
|
* callers** (tests, ad-hoc scripts) that construct `ResourceManifest`
|
|
16
16
|
* literals without going through a loader: it gives every otherwise-naked
|