@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.
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +6 -0
- package/dist/analyzer.d.ts +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +343 -13
- package/dist/call-graph.d.ts.map +1 -1
- package/dist/call-graph.js +2 -7
- package/dist/flatten-for-analyzer.js +1 -1
- package/dist/import-resolution-diagnostics.d.ts +9 -2
- package/dist/import-resolution-diagnostics.d.ts.map +1 -1
- package/dist/import-resolution-diagnostics.js +26 -7
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -1
- package/dist/invocation-contract.d.ts +6 -1
- package/dist/invocation-contract.d.ts.map +1 -1
- package/dist/invocation-contract.js +27 -4
- package/dist/loaded-types.d.ts +12 -3
- package/dist/loaded-types.d.ts.map +1 -1
- package/dist/manifest-loader.d.ts +0 -1
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +62 -41
- package/dist/manifest-schemas.d.ts +699 -0
- package/dist/manifest-schemas.d.ts.map +1 -1
- package/dist/manifest-schemas.js +306 -14
- package/dist/module-compatibility.d.ts +40 -0
- package/dist/module-compatibility.d.ts.map +1 -0
- package/dist/module-compatibility.js +52 -0
- package/dist/ref-slot-reading.d.ts +30 -0
- package/dist/ref-slot-reading.d.ts.map +1 -0
- package/dist/ref-slot-reading.js +139 -0
- package/dist/referrer-rule.d.ts +67 -0
- package/dist/referrer-rule.d.ts.map +1 -0
- package/dist/referrer-rule.js +96 -0
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +1 -1
- package/dist/release/payload-digest.d.ts +7 -3
- package/dist/release/payload-digest.d.ts.map +1 -1
- package/dist/release/payload-digest.js +7 -3
- package/dist/release/version-stamp.d.ts +16 -0
- package/dist/release/version-stamp.d.ts.map +1 -1
- package/dist/release/version-stamp.js +52 -0
- package/dist/release/workspace-config.d.ts +10 -6
- package/dist/release/workspace-config.d.ts.map +1 -1
- package/dist/release/workspace-config.js +10 -6
- package/dist/resolve-schema-ref-kinds.d.ts +4 -3
- package/dist/resolve-schema-ref-kinds.d.ts.map +1 -1
- package/dist/resolve-schema-ref-kinds.js +16 -0
- package/dist/resolve-throws-union.d.ts.map +1 -1
- package/dist/resolve-throws-union.js +4 -3
- package/dist/resolve-zone-containment.d.ts +99 -0
- package/dist/resolve-zone-containment.d.ts.map +1 -0
- package/dist/resolve-zone-containment.js +212 -0
- package/dist/resolve-zone-requirements.d.ts +5 -0
- package/dist/resolve-zone-requirements.d.ts.map +1 -1
- package/dist/resolve-zone-requirements.js +48 -7
- package/dist/resource-rule.d.ts +106 -0
- package/dist/resource-rule.d.ts.map +1 -0
- package/dist/resource-rule.js +236 -0
- package/dist/rule-condition.d.ts +50 -0
- package/dist/rule-condition.d.ts.map +1 -0
- package/dist/rule-condition.js +108 -0
- package/dist/schema-keywords.d.ts.map +1 -1
- package/dist/schema-keywords.js +20 -0
- package/dist/schema-projection.d.ts +171 -0
- package/dist/schema-projection.d.ts.map +1 -0
- package/dist/schema-projection.js +317 -0
- package/dist/step-slot.d.ts +39 -0
- package/dist/step-slot.d.ts.map +1 -0
- package/dist/step-slot.js +49 -0
- package/dist/telo-version.d.ts +1 -1
- package/dist/telo-version.js +1 -1
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +17 -3
- package/dist/validate-durable-regions.d.ts +15 -0
- package/dist/validate-durable-regions.d.ts.map +1 -0
- package/dist/validate-durable-regions.js +336 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +44 -2
- package/dist/validate-referrer-rules.d.ts +120 -0
- package/dist/validate-referrer-rules.d.ts.map +1 -0
- package/dist/validate-referrer-rules.js +255 -0
- package/dist/validate-resource-rules.d.ts +114 -0
- package/dist/validate-resource-rules.d.ts.map +1 -0
- package/dist/validate-resource-rules.js +283 -0
- package/dist/validate-schema-projection.d.ts +32 -0
- package/dist/validate-schema-projection.d.ts.map +1 -0
- package/dist/validate-schema-projection.js +110 -0
- package/dist/validate-step-inputs.d.ts +2 -1
- package/dist/validate-step-inputs.d.ts.map +1 -1
- package/dist/validate-step-inputs.js +5 -3
- package/dist/validate-throws-coverage.d.ts.map +1 -1
- package/dist/validate-throws-coverage.js +11 -5
- package/dist/validate-zone-slots.d.ts +2 -2
- package/dist/validate-zone-slots.d.ts.map +1 -1
- package/dist/validate-zone-slots.js +202 -6
- package/dist/validate-zone-violations.d.ts +12 -0
- package/dist/validate-zone-violations.d.ts.map +1 -0
- package/dist/validate-zone-violations.js +169 -0
- package/dist/value-type-keyword.d.ts +1 -1
- package/dist/value-type-keyword.d.ts.map +1 -1
- package/dist/value-type-keyword.js +4 -0
- package/dist/zone-slot.d.ts +61 -0
- package/dist/zone-slot.d.ts.map +1 -1
- package/dist/zone-slot.js +86 -5
- package/package.json +2 -2
- package/src/analysis-registry.ts +6 -0
- package/src/analyzer.ts +395 -14
- package/src/call-graph.ts +2 -8
- package/src/flatten-for-analyzer.ts +1 -1
- package/src/import-resolution-diagnostics.ts +25 -7
- package/src/index.ts +73 -0
- package/src/invocation-contract.ts +45 -2
- package/src/loaded-types.ts +12 -3
- package/src/manifest-loader.ts +87 -55
- package/src/manifest-schemas.ts +328 -14
- package/src/module-compatibility.ts +72 -0
- package/src/ref-slot-reading.ts +156 -0
- package/src/referrer-rule.ts +116 -0
- package/src/release/index.ts +1 -0
- package/src/release/payload-digest.ts +7 -3
- package/src/release/version-stamp.ts +58 -0
- package/src/release/workspace-config.ts +10 -6
- package/src/resolve-schema-ref-kinds.ts +28 -4
- package/src/resolve-throws-union.ts +4 -3
- package/src/resolve-zone-containment.ts +329 -0
- package/src/resolve-zone-requirements.ts +67 -7
- package/src/resource-rule.ts +252 -0
- package/src/rule-condition.ts +126 -0
- package/src/schema-keywords.ts +24 -0
- package/src/schema-projection.ts +403 -0
- package/src/step-slot.ts +64 -0
- package/src/telo-version.ts +1 -1
- package/src/validate-cel-context.ts +17 -3
- package/src/validate-durable-regions.ts +401 -0
- package/src/validate-references.ts +57 -2
- package/src/validate-referrer-rules.ts +373 -0
- package/src/validate-resource-rules.ts +401 -0
- package/src/validate-schema-projection.ts +184 -0
- package/src/validate-step-inputs.ts +5 -3
- package/src/validate-throws-coverage.ts +12 -5
- package/src/validate-zone-slots.ts +192 -8
- package/src/validate-zone-violations.ts +190 -0
- package/src/value-type-keyword.ts +4 -0
- package/src/zone-slot.ts +114 -5
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The strict half of the `x-telo-resource-rules` accessor split
|
|
3
|
+
* (`validate-ref-slots.ts` / `validate-zone-slots.ts` precedent), plus the
|
|
4
|
+
* evaluation pass that runs the rules against each resource.
|
|
5
|
+
*
|
|
6
|
+
* Both halves live here because they fail in opposite directions and must agree
|
|
7
|
+
* about what a rule MEANS: a declaration the reader cannot parse is silently
|
|
8
|
+
* unenforced — the check reads as passing when it never ran — while a rule that
|
|
9
|
+
* throws would otherwise be reported against the consumer's manifest, blaming an
|
|
10
|
+
* author for a defect in someone else's kind.
|
|
11
|
+
*
|
|
12
|
+
* Scoping follows `X_TELO_REF_UNRESOLVED`, and it splits by WHOSE defect it is.
|
|
13
|
+
* A declaration defect belongs to the kind, so it is reported only for
|
|
14
|
+
* definitions in the entry's own modules. A VIOLATION belongs to the data, so it
|
|
15
|
+
* is reported wherever the offending resource is the entry's — the opposite
|
|
16
|
+
* direction, for the same reason. A rule that THROWS or runs out of budget is a
|
|
17
|
+
* defect in the rule found while checking someone else's data: the caller
|
|
18
|
+
* anchors it on the declaring definition when that is ours, and downgrades it to
|
|
19
|
+
* a warning when it is a published dependency's, so it is never an error on a
|
|
20
|
+
* line the author cannot change.
|
|
21
|
+
*
|
|
22
|
+
* Browser-safe: no Node built-ins.
|
|
23
|
+
*/
|
|
24
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
25
|
+
import { RULE_BUDGET_MS, compileRuleCondition, conditionRefusals } from "./rule-condition.js";
|
|
26
|
+
import {
|
|
27
|
+
RESOURCE_RULES_ANNOTATION,
|
|
28
|
+
celSourceOf,
|
|
29
|
+
findDynamicLeaf,
|
|
30
|
+
pointerSegments,
|
|
31
|
+
readRawResourceRules,
|
|
32
|
+
readResourceRules,
|
|
33
|
+
readNodes,
|
|
34
|
+
resolveRuleSubjects,
|
|
35
|
+
type ResourceRule,
|
|
36
|
+
} from "./resource-rule.js";
|
|
37
|
+
|
|
38
|
+
/** Published name for the shared budget — see `rule-condition.ts`. */
|
|
39
|
+
export const RESOURCE_RULE_BUDGET_MS = RULE_BUDGET_MS;
|
|
40
|
+
|
|
41
|
+
export interface ResourceRuleIssue {
|
|
42
|
+
code: "RESOURCE_RULE_INVALID";
|
|
43
|
+
manifest: ResourceManifest;
|
|
44
|
+
path: string;
|
|
45
|
+
message: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One rule's verdict on one resource. */
|
|
49
|
+
export type ResourceRuleFinding =
|
|
50
|
+
| {
|
|
51
|
+
kind: "violation";
|
|
52
|
+
rule: ResourceRule;
|
|
53
|
+
/** Path of the offending element, or "" for a rule with no `in:`. */
|
|
54
|
+
path: string;
|
|
55
|
+
message: string;
|
|
56
|
+
}
|
|
57
|
+
| { kind: "skipped"; rule: ResourceRule; path: string; dynamicAt: string }
|
|
58
|
+
| { kind: "failed"; rule: ResourceRule; path: string; reason: string }
|
|
59
|
+
| { kind: "over-budget"; rule: ResourceRule; path: string; elapsedMs: number };
|
|
60
|
+
|
|
61
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
62
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 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 {
|
|
68
|
+
const segments = pointerSegments(pointer);
|
|
69
|
+
if (!segments) return undefined;
|
|
70
|
+
let node: unknown = schema;
|
|
71
|
+
for (const segment of segments) {
|
|
72
|
+
if (!isObject(node)) return undefined;
|
|
73
|
+
const properties = isObject(node.properties) ? node.properties : undefined;
|
|
74
|
+
const next =
|
|
75
|
+
properties?.[segment] ??
|
|
76
|
+
(node.type === "array" ? node.items : undefined) ??
|
|
77
|
+
(isObject(node.additionalProperties) ? node.additionalProperties : undefined);
|
|
78
|
+
if (next === undefined) return undefined;
|
|
79
|
+
node = next;
|
|
80
|
+
}
|
|
81
|
+
return node;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** True when a schema node describes something a rule can iterate. Unknown or
|
|
85
|
+
* absent `type` passes: an open schema is not evidence of a defect. */
|
|
86
|
+
function isIterableSchema(node: unknown): boolean {
|
|
87
|
+
if (!isObject(node)) return true;
|
|
88
|
+
const type = node.type;
|
|
89
|
+
if (type === undefined) return true;
|
|
90
|
+
const types = Array.isArray(type) ? type : [type];
|
|
91
|
+
return types.some((t) => t === "array" || t === "object");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Report every way a kind's rule declarations are malformed. Runs on the
|
|
96
|
+
* `Telo.Definition` / `Telo.Abstract` doc, so a defect lands on the line the
|
|
97
|
+
* kind's author wrote rather than on a consumer's resource.
|
|
98
|
+
*/
|
|
99
|
+
export function validateResourceRuleDeclarations(
|
|
100
|
+
manifest: ResourceManifest,
|
|
101
|
+
/**
|
|
102
|
+
* The schema an `in:` pointer is checked against — the SAME merged schema the
|
|
103
|
+
* evaluation pass reads, so the two halves cannot disagree about which fields
|
|
104
|
+
* a kind has. Reading the doc's own `schema:` here was correct only for a leaf
|
|
105
|
+
* kind: a rule declared on a parent and evaluated against a child's field
|
|
106
|
+
* would be reported invalid at the parent, and a child's rule naming an
|
|
107
|
+
* INHERITED field would be told the kind does not declare it. Both arrive as
|
|
108
|
+
* soon as rules are declared on an abstract, which is where a rule shared by
|
|
109
|
+
* every backend belongs.
|
|
110
|
+
*/
|
|
111
|
+
effectiveSchema?: unknown,
|
|
112
|
+
): ResourceRuleIssue[] {
|
|
113
|
+
const own = (manifest as unknown as Record<string, unknown>).schema;
|
|
114
|
+
const schema = effectiveSchema ?? own;
|
|
115
|
+
const raw = readRawResourceRules(own);
|
|
116
|
+
if (raw === undefined) return [];
|
|
117
|
+
|
|
118
|
+
const base = `schema.${RESOURCE_RULES_ANNOTATION}`;
|
|
119
|
+
const issues: ResourceRuleIssue[] = [];
|
|
120
|
+
const issue = (path: string, message: string): void => {
|
|
121
|
+
issues.push({ code: "RESOURCE_RULE_INVALID", manifest, path, message });
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (!Array.isArray(raw)) {
|
|
125
|
+
issue(base, `'${RESOURCE_RULES_ANNOTATION}' must be an array of rules.`);
|
|
126
|
+
return issues;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const seen = new Map<string, number>();
|
|
130
|
+
raw.forEach((entry, index) => {
|
|
131
|
+
const at = `${base}[${index}]`;
|
|
132
|
+
if (!isObject(entry)) {
|
|
133
|
+
issue(at, "A rule must be an object with 'condition', 'code' and 'message'.");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const condition = celSourceOf(entry.condition);
|
|
138
|
+
if (condition === undefined || condition.length === 0) {
|
|
139
|
+
issue(
|
|
140
|
+
`${at}.condition`,
|
|
141
|
+
"A rule needs a 'condition' — a CEL expression that is TRUE when the rule holds " +
|
|
142
|
+
"(the polarity Telo.JsonSchema rules use). Write it with the !cel tag.",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (typeof entry.code !== "string" || entry.code.length === 0) {
|
|
146
|
+
issue(
|
|
147
|
+
`${at}.code`,
|
|
148
|
+
"A rule needs a 'code' naming it. It is reported in the diagnostic's data.rule, " +
|
|
149
|
+
"not as a diagnostic code — every violation reports under RESOURCE_RULE_VIOLATED.",
|
|
150
|
+
);
|
|
151
|
+
} else {
|
|
152
|
+
const first = seen.get(entry.code);
|
|
153
|
+
if (first !== undefined) {
|
|
154
|
+
issue(
|
|
155
|
+
`${at}.code`,
|
|
156
|
+
`Rule code '${entry.code}' is already used by rule ${first}. A code names one rule, ` +
|
|
157
|
+
"so two rules sharing it are indistinguishable in data.rule.",
|
|
158
|
+
);
|
|
159
|
+
} else {
|
|
160
|
+
seen.set(entry.code, index);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (typeof entry.message !== "string" || entry.message.length === 0) {
|
|
164
|
+
issue(
|
|
165
|
+
`${at}.message`,
|
|
166
|
+
"A rule needs a 'message' saying what the relationship means — only the kind's " +
|
|
167
|
+
"author knows that, and the analyzer supplies only where and what.",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (
|
|
171
|
+
entry.severity !== undefined &&
|
|
172
|
+
entry.severity !== "error" &&
|
|
173
|
+
entry.severity !== "warning"
|
|
174
|
+
) {
|
|
175
|
+
issue(`${at}.severity`, "'severity' must be 'error' or 'warning'.");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (entry.in !== undefined) {
|
|
179
|
+
if (typeof entry.in !== "string") {
|
|
180
|
+
issue(`${at}.in`, "'in' must be a JSON Pointer to the collection the rule iterates.");
|
|
181
|
+
} else if (!pointerSegments(entry.in)) {
|
|
182
|
+
issue(`${at}.in`, `'in' must be a JSON Pointer starting with '/', got '${entry.in}'.`);
|
|
183
|
+
} else {
|
|
184
|
+
const node = schemaAtPointer(schema, entry.in);
|
|
185
|
+
if (node === undefined) {
|
|
186
|
+
issue(
|
|
187
|
+
`${at}.in`,
|
|
188
|
+
`'in' points at '${entry.in}', which this kind's schema does not declare. ` +
|
|
189
|
+
"The pointer is the diagnostic's anchor, so it must name a field of this kind.",
|
|
190
|
+
);
|
|
191
|
+
} else if (!isIterableSchema(node)) {
|
|
192
|
+
issue(
|
|
193
|
+
`${at}.in`,
|
|
194
|
+
`'in' points at '${entry.in}', which is not a collection. A rule iterates an ` +
|
|
195
|
+
"array or a map; omit 'in' for a rule about the resource as a whole.",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (condition) {
|
|
202
|
+
for (const refusal of conditionRefusals(condition)) issue(`${at}.condition`, refusal);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return issues;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Run a kind's rules against one resource.
|
|
211
|
+
*
|
|
212
|
+
* `self` binds the resource, `this` the element under test — the two coexist in
|
|
213
|
+
* cel-js, which is what lets a rule correlate an element against the whole
|
|
214
|
+
* (`c in self.columns`) with no path language.
|
|
215
|
+
*/
|
|
216
|
+
export function evaluateResourceRules(
|
|
217
|
+
manifest: ResourceManifest,
|
|
218
|
+
definitionSchema: unknown,
|
|
219
|
+
): ResourceRuleFinding[] {
|
|
220
|
+
const rules = readResourceRules(definitionSchema);
|
|
221
|
+
if (rules.length === 0) return [];
|
|
222
|
+
|
|
223
|
+
const self = manifest as unknown as Record<string, unknown>;
|
|
224
|
+
const findings: ResourceRuleFinding[] = [];
|
|
225
|
+
|
|
226
|
+
for (const rule of rules) {
|
|
227
|
+
const subjects =
|
|
228
|
+
rule.in === undefined ? [{ path: "", value: self }] : resolveRuleSubjects(self, rule.in);
|
|
229
|
+
// `undefined` means the pointer resolved to a scalar — a declaration defect
|
|
230
|
+
// the strict half reports at the kind. Evaluating anyway would report it
|
|
231
|
+
// against the consumer instead.
|
|
232
|
+
if (subjects === undefined) continue;
|
|
233
|
+
|
|
234
|
+
const compiled = compileRuleCondition(rule.condition, ["self", "this"]);
|
|
235
|
+
if ("reason" in compiled) {
|
|
236
|
+
findings.push({ kind: "failed", rule, path: "", reason: compiled.reason });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const { parsed, chains } = compiled;
|
|
240
|
+
|
|
241
|
+
const started = Date.now();
|
|
242
|
+
for (const subject of subjects) {
|
|
243
|
+
// Only the nodes this condition READS decide whether it can run — see
|
|
244
|
+
// `readNodes`. Scanning the whole subject would disable every
|
|
245
|
+
// resource-wide rule on any manifest containing one unrelated expression.
|
|
246
|
+
let dynamicAt: string | undefined;
|
|
247
|
+
for (const node of readNodes(chains, { self, this: subject.value })) {
|
|
248
|
+
dynamicAt = findDynamicLeaf(node);
|
|
249
|
+
if (dynamicAt !== undefined) break;
|
|
250
|
+
}
|
|
251
|
+
if (dynamicAt !== undefined) {
|
|
252
|
+
findings.push({ kind: "skipped", rule, path: subject.path, dynamicAt });
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
let held: unknown;
|
|
256
|
+
try {
|
|
257
|
+
held = parsed({ self, this: subject.value, key: subject.key ?? null });
|
|
258
|
+
} catch (err) {
|
|
259
|
+
findings.push({
|
|
260
|
+
kind: "failed",
|
|
261
|
+
rule,
|
|
262
|
+
path: subject.path,
|
|
263
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
264
|
+
});
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
if (held !== true) {
|
|
268
|
+
findings.push({ kind: "violation", rule, path: subject.path, message: rule.message });
|
|
269
|
+
}
|
|
270
|
+
const elapsed = Date.now() - started;
|
|
271
|
+
if (elapsed > RESOURCE_RULE_BUDGET_MS) {
|
|
272
|
+
findings.push({ kind: "over-budget", rule, path: subject.path, elapsedMs: elapsed });
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return findings;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Whether a rule found anything to iterate on this resource — the input to the
|
|
282
|
+
* never-exercised report, which is the second way coverage varies invisibly. */
|
|
283
|
+
export function ruleExercised(manifest: ResourceManifest, rule: ResourceRule): boolean {
|
|
284
|
+
if (rule.in === undefined) return true;
|
|
285
|
+
const subjects = resolveRuleSubjects(manifest as unknown as Record<string, unknown>, rule.in);
|
|
286
|
+
return subjects !== undefined && subjects.length > 0;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Where a rule finding is reported, and how loudly. Plain data, so the caller
|
|
290
|
+
* pushes it exactly as it does for `zoneSlotIssues` / `refSlotIssues` rather
|
|
291
|
+
* than composing severity, ownership and prose inline in the resource loop. */
|
|
292
|
+
export interface ResourceRuleDiagnostic {
|
|
293
|
+
code:
|
|
294
|
+
| "RESOURCE_RULE_VIOLATED"
|
|
295
|
+
| "RESOURCE_RULE_SKIPPED"
|
|
296
|
+
| "RESOURCE_RULE_INVALID"
|
|
297
|
+
| "RESOURCE_RULE_UNEXERCISED";
|
|
298
|
+
severity: "error" | "warning" | "information";
|
|
299
|
+
message: string;
|
|
300
|
+
/** The resource the finding is reported ON — the offending one for a
|
|
301
|
+
* violation, the DECLARING definition for a defect in the rule itself. */
|
|
302
|
+
manifest: ResourceManifest;
|
|
303
|
+
path?: string;
|
|
304
|
+
rule: string;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Map one resource's findings to what should be reported.
|
|
309
|
+
*
|
|
310
|
+
* The split by whose defect it is lives here, with the finding vocabulary,
|
|
311
|
+
* rather than in the analyzer's resource loop: a violation belongs to the DATA
|
|
312
|
+
* and is reported on the resource, while a rule that throws or exhausts its
|
|
313
|
+
* budget is a defect in the RULE and belongs on the definition that declared it
|
|
314
|
+
* — downgraded to a warning when that definition is a published dependency's,
|
|
315
|
+
* since an error there blocks `telo check` on a line the consumer cannot change.
|
|
316
|
+
*/
|
|
317
|
+
export function reportResourceRules(
|
|
318
|
+
manifest: ResourceManifest,
|
|
319
|
+
definition: ResourceManifest | undefined,
|
|
320
|
+
findings: readonly ResourceRuleFinding[],
|
|
321
|
+
/** Whether the DECLARING definition is one of the entry's own modules. */
|
|
322
|
+
declarationIsOurs: boolean,
|
|
323
|
+
): ResourceRuleDiagnostic[] {
|
|
324
|
+
const name = (manifest.metadata?.name as string | undefined) ?? "<unnamed>";
|
|
325
|
+
const out: ResourceRuleDiagnostic[] = [];
|
|
326
|
+
|
|
327
|
+
for (const finding of findings) {
|
|
328
|
+
const at = finding.path === "" ? undefined : finding.path;
|
|
329
|
+
if (finding.kind === "violation") {
|
|
330
|
+
out.push({
|
|
331
|
+
// One analyzer-owned envelope: surfaces branch on `code`, so a published
|
|
332
|
+
// module free to emit any string could shadow machinery that never
|
|
333
|
+
// expected a third party in that space. The rule's own name rides in
|
|
334
|
+
// `data.rule`.
|
|
335
|
+
code: "RESOURCE_RULE_VIOLATED",
|
|
336
|
+
severity: finding.rule.severity,
|
|
337
|
+
message: `${manifest.kind}/${name}${at ? ` at '${at}'` : ""}: ${finding.message}`,
|
|
338
|
+
manifest,
|
|
339
|
+
path: at,
|
|
340
|
+
rule: finding.rule.code,
|
|
341
|
+
});
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (finding.kind === "skipped") {
|
|
345
|
+
out.push({
|
|
346
|
+
code: "RESOURCE_RULE_SKIPPED",
|
|
347
|
+
severity: "information",
|
|
348
|
+
message:
|
|
349
|
+
`${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. ` +
|
|
352
|
+
"Reported rather than dropped: a check whose coverage varies invisibly reads as passing.",
|
|
353
|
+
manifest,
|
|
354
|
+
path: at,
|
|
355
|
+
rule: finding.rule.code,
|
|
356
|
+
});
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const because =
|
|
361
|
+
finding.kind === "failed"
|
|
362
|
+
? `failed to evaluate: ${finding.reason}. Guard an optional field with \`in\` or \`.?\`.`
|
|
363
|
+
: `exceeded its evaluation budget (${finding.elapsedMs}ms) and was stopped, so ` +
|
|
364
|
+
"coverage from here on is incomplete. Simplify the condition.";
|
|
365
|
+
out.push({
|
|
366
|
+
code: "RESOURCE_RULE_INVALID",
|
|
367
|
+
severity: declarationIsOurs ? "error" : "warning",
|
|
368
|
+
message:
|
|
369
|
+
`Rule '${finding.rule.code}' on kind '${manifest.kind}' ${because} ` +
|
|
370
|
+
`This is a defect in the rule, not in ${name}` +
|
|
371
|
+
(declarationIsOurs ? "." : " — it is declared by a module this workspace does not own."),
|
|
372
|
+
manifest: declarationIsOurs && definition ? definition : manifest,
|
|
373
|
+
path:
|
|
374
|
+
declarationIsOurs && definition
|
|
375
|
+
? `schema.${RESOURCE_RULES_ANNOTATION}[${finding.rule.index}]`
|
|
376
|
+
: at,
|
|
377
|
+
rule: finding.rule.code,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** The report for a rule nothing ever exercised — the second way coverage varies
|
|
385
|
+
* invisibly, beside the dynamic-leaf skip. */
|
|
386
|
+
export function reportUnexercisedRule(
|
|
387
|
+
definition: ResourceManifest,
|
|
388
|
+
rule: ResourceRule,
|
|
389
|
+
): ResourceRuleDiagnostic {
|
|
390
|
+
return {
|
|
391
|
+
code: "RESOURCE_RULE_UNEXERCISED",
|
|
392
|
+
severity: "information",
|
|
393
|
+
message:
|
|
394
|
+
`Rule '${rule.code}' never ran: '${rule.in}' was empty on every resource of this kind, ` +
|
|
395
|
+
"so nothing has proven the condition. A nested typo in the condition is caught only at " +
|
|
396
|
+
"evaluation.",
|
|
397
|
+
manifest: definition,
|
|
398
|
+
path: `schema.${RESOURCE_RULES_ANNOTATION}[${rule.index}]`,
|
|
399
|
+
rule: rule.code,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static validation of the schema-projection annotations themselves — the
|
|
3
|
+
* strict half of the accessor split, mirroring `validate-ref-slots.ts` and
|
|
4
|
+
* `validate-zone-slots.ts`.
|
|
5
|
+
*
|
|
6
|
+
* `readSchemaProjection` / `readSchemaMap` are deliberately lenient: anything
|
|
7
|
+
* they cannot read reads as absent. Without this pass that leniency is silent in
|
|
8
|
+
* the direction that matters — a projection nothing can read does not fail, it
|
|
9
|
+
* simply stops typing the consumers that were counting on it, so a misspelled
|
|
10
|
+
* column reaches the database instead of `telo check`. The whole point of the
|
|
11
|
+
* projection is to move that failure earlier; an unreported malformed
|
|
12
|
+
* declaration puts it back.
|
|
13
|
+
*
|
|
14
|
+
* A map that is merely INCOMPLETE is the same failure per value: a declared type
|
|
15
|
+
* with no entry projects to nothing, so a column of that type silently vanishes
|
|
16
|
+
* from every consumer's view of the row.
|
|
17
|
+
*
|
|
18
|
+
* Scoping follows `X_TELO_REF_UNRESOLVED`: reported only for definitions in the
|
|
19
|
+
* entry's own modules — a published dependency's annotation is not the
|
|
20
|
+
* consumer's to fix.
|
|
21
|
+
*
|
|
22
|
+
* Browser-safe: no Node built-ins.
|
|
23
|
+
*/
|
|
24
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
25
|
+
import {
|
|
26
|
+
projectionKeyMap,
|
|
27
|
+
rawSchemaProjection,
|
|
28
|
+
readSchemaProjection,
|
|
29
|
+
schemaProjectionIsMisplaced,
|
|
30
|
+
} from "./schema-projection.js";
|
|
31
|
+
|
|
32
|
+
export interface SchemaProjectionIssue {
|
|
33
|
+
code: "SCHEMA_PROJECTION_INVALID" | "SCHEMA_MAP_INCOMPLETE";
|
|
34
|
+
manifest: ResourceManifest;
|
|
35
|
+
path: string;
|
|
36
|
+
message: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PROJECTION = "x-telo-schema-projection";
|
|
40
|
+
|
|
41
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
42
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function validateSchemaProjection(manifest: ResourceManifest): SchemaProjectionIssue[] {
|
|
46
|
+
const doc = manifest as unknown as Record<string, unknown>;
|
|
47
|
+
const raw = rawSchemaProjection(doc);
|
|
48
|
+
if (raw === undefined) return [];
|
|
49
|
+
|
|
50
|
+
const misplaced = schemaProjectionIsMisplaced(doc);
|
|
51
|
+
const base = misplaced ? `schema.${PROJECTION}` : PROJECTION;
|
|
52
|
+
const issue = (
|
|
53
|
+
code: SchemaProjectionIssue["code"],
|
|
54
|
+
path: string,
|
|
55
|
+
message: string,
|
|
56
|
+
): SchemaProjectionIssue => ({ code, manifest, path, message });
|
|
57
|
+
|
|
58
|
+
const issues: SchemaProjectionIssue[] = [];
|
|
59
|
+
// Read from both positions so a misplaced annotation still WORKS, and
|
|
60
|
+
// reported so it does not stay misplaced. The alternative — reading only the
|
|
61
|
+
// document — makes the inner spelling silently inert, and the resulting
|
|
62
|
+
// failure surfaces on a consumer's slot as "this kind declares no projection",
|
|
63
|
+
// which blames an author who wrote the annotation correctly enough to mean it.
|
|
64
|
+
if (misplaced) {
|
|
65
|
+
issues.push(
|
|
66
|
+
issue(
|
|
67
|
+
"SCHEMA_PROJECTION_INVALID",
|
|
68
|
+
base,
|
|
69
|
+
`'${PROJECTION}' belongs on the kind DOCUMENT, beside 'schema:', not inside it — ` +
|
|
70
|
+
`it describes the whole declaration rather than one field. It is read from here ` +
|
|
71
|
+
`too, so nothing is broken; move it up a level.`,
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!isObject(raw)) {
|
|
77
|
+
return [
|
|
78
|
+
...issues,
|
|
79
|
+
issue(
|
|
80
|
+
"SCHEMA_PROJECTION_INVALID",
|
|
81
|
+
base,
|
|
82
|
+
`'${PROJECTION}' must be an object naming the entry collection and the field that keys it.`,
|
|
83
|
+
),
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
const missing = ["entries", "key"].filter((field) => typeof raw[field] !== "string");
|
|
87
|
+
if (missing.length > 0) {
|
|
88
|
+
return [
|
|
89
|
+
...issues,
|
|
90
|
+
issue(
|
|
91
|
+
"SCHEMA_PROJECTION_INVALID",
|
|
92
|
+
base,
|
|
93
|
+
`'${PROJECTION}' is missing ${missing.map((f) => `'${f}'`).join(" and ")}. ` +
|
|
94
|
+
`'entries' is a JSON Pointer to the collection, 'key' the entry field whose value ` +
|
|
95
|
+
`selects an 'x-telo-schema-map' entry.`,
|
|
96
|
+
),
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const projection = readSchemaProjection(doc)!;
|
|
101
|
+
const schema = doc.schema;
|
|
102
|
+
|
|
103
|
+
// An ARRAY-shaped collection has no key to name its entries by, so without
|
|
104
|
+
// `name` every entry projects to nothing and the whole projection is an object
|
|
105
|
+
// schema with no properties and `additionalProperties: false` — one that
|
|
106
|
+
// rejects every value. A keyed map needs no `name`: the map key IS the
|
|
107
|
+
// identity.
|
|
108
|
+
if (collectionIsArray(schema, projection.entries) && !projection.nameField) {
|
|
109
|
+
return [
|
|
110
|
+
...issues,
|
|
111
|
+
issue(
|
|
112
|
+
"SCHEMA_PROJECTION_INVALID",
|
|
113
|
+
base,
|
|
114
|
+
`'${PROJECTION}' names '${projection.entries}', which is an array, but declares no ` +
|
|
115
|
+
`'name'. An array's entries have no key, so 'name' must say which field holds an ` +
|
|
116
|
+
`entry's identity — without it the projection yields a schema that rejects every value.`,
|
|
117
|
+
),
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const map = projectionKeyMap(schema, projection);
|
|
122
|
+
if (!map) {
|
|
123
|
+
return [
|
|
124
|
+
...issues,
|
|
125
|
+
issue(
|
|
126
|
+
"SCHEMA_PROJECTION_INVALID",
|
|
127
|
+
base,
|
|
128
|
+
`'${PROJECTION}' names '${projection.entries}' keyed on '${projection.key}', but that ` +
|
|
129
|
+
`field carries no 'x-telo-schema-map'. The map declares the JSON Schema each of its ` +
|
|
130
|
+
`values means, and without it the projection types nothing.`,
|
|
131
|
+
),
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// An `enum` on the keyed field is the kind's own closed vocabulary, so every
|
|
136
|
+
// member of it is a value the map has to answer for. Only an enum is checked:
|
|
137
|
+
// an open string field has no set to be complete against.
|
|
138
|
+
const keyField = keySchema(schema, projection.entries, projection.key);
|
|
139
|
+
const values = Array.isArray(keyField?.enum) ? (keyField!.enum as unknown[]) : [];
|
|
140
|
+
const unmapped = values.filter((value) => typeof value === "string" && !(value in map));
|
|
141
|
+
if (unmapped.length === 0) return issues;
|
|
142
|
+
return [
|
|
143
|
+
...issues,
|
|
144
|
+
issue(
|
|
145
|
+
"SCHEMA_MAP_INCOMPLETE",
|
|
146
|
+
base,
|
|
147
|
+
`'x-telo-schema-map' has no entry for ${unmapped.map((v) => `'${v}'`).join(", ")}. ` +
|
|
148
|
+
`An entry declaring an unmapped value projects to nothing, so it disappears from every ` +
|
|
149
|
+
`consumer's view of the shape.`,
|
|
150
|
+
),
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function collectionIsArray(schema: unknown, entries: string): boolean {
|
|
155
|
+
let node: unknown = schema;
|
|
156
|
+
for (const segment of entries.split("/")) {
|
|
157
|
+
if (segment === "") continue;
|
|
158
|
+
if (!isObject(node) || !isObject(node.properties)) return false;
|
|
159
|
+
node = node.properties[segment];
|
|
160
|
+
}
|
|
161
|
+
return isObject(node) && isObject(node.items);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function keySchema(
|
|
165
|
+
schema: unknown,
|
|
166
|
+
entries: string,
|
|
167
|
+
key: string,
|
|
168
|
+
): Record<string, unknown> | undefined {
|
|
169
|
+
let node: unknown = schema;
|
|
170
|
+
for (const segment of entries.split("/")) {
|
|
171
|
+
if (segment === "") continue;
|
|
172
|
+
if (!isObject(node) || !isObject(node.properties)) return undefined;
|
|
173
|
+
node = node.properties[segment];
|
|
174
|
+
}
|
|
175
|
+
if (!isObject(node)) return undefined;
|
|
176
|
+
const entry = isObject(node.additionalProperties)
|
|
177
|
+
? node.additionalProperties
|
|
178
|
+
: isObject(node.items)
|
|
179
|
+
? node.items
|
|
180
|
+
: undefined;
|
|
181
|
+
if (!isObject(entry) || !isObject(entry.properties)) return undefined;
|
|
182
|
+
const field = entry.properties[key];
|
|
183
|
+
return isObject(field) ? field : undefined;
|
|
184
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
resolveLocalRef,
|
|
20
20
|
walkStepArray,
|
|
21
21
|
} from "./analyzer.js";
|
|
22
|
+
import { readStepSlot } from "./step-slot.js";
|
|
22
23
|
|
|
23
24
|
export interface StepInputIssue {
|
|
24
25
|
path: string;
|
|
@@ -42,7 +43,8 @@ export interface StepInputIssue {
|
|
|
42
43
|
* CEL leaves are replaced by schema-shaped placeholders first (`substituteCelFields`),
|
|
43
44
|
* so an expression is never a false positive — only structural disagreement is
|
|
44
45
|
* reported. Nothing is hardcoded about `Run.Sequence`: the invoke field comes
|
|
45
|
-
* from
|
|
46
|
+
* from the step slot (the shared grammar, or the legacy `x-telo-step-context`),
|
|
47
|
+
* and the paired inputs field from whichever sibling
|
|
46
48
|
* property carries `x-telo-topology-role: inputs`.
|
|
47
49
|
*/
|
|
48
50
|
export function collectStepInputIssues(
|
|
@@ -65,8 +67,8 @@ export function collectStepInputIssues(
|
|
|
65
67
|
const readingModule = (manifest.metadata as { module?: string } | undefined)?.module;
|
|
66
68
|
|
|
67
69
|
for (const [fieldName, fieldSchema] of Object.entries(props)) {
|
|
68
|
-
const stepCtx = fieldSchema
|
|
69
|
-
if (!stepCtx
|
|
70
|
+
const stepCtx = readStepSlot(fieldSchema);
|
|
71
|
+
if (!stepCtx) continue;
|
|
70
72
|
const steps = manifest[fieldName];
|
|
71
73
|
if (!Array.isArray(steps)) continue;
|
|
72
74
|
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "./resolve-throws-union.js";
|
|
16
16
|
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
17
17
|
import { extractAccessChains, validateChainAgainstSchema } from "./validate-cel-context.js";
|
|
18
|
+
import { isStepSlot } from "./step-slot.js";
|
|
18
19
|
|
|
19
20
|
const SOURCE = "telo-analyzer";
|
|
20
21
|
const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
|
|
@@ -457,9 +458,11 @@ function checkCelChainAgainstDataSchema(
|
|
|
457
458
|
}
|
|
458
459
|
|
|
459
460
|
/** Rule 8 extension: `inherit: true` only makes sense on a definition whose
|
|
460
|
-
* schema
|
|
461
|
-
*
|
|
462
|
-
*
|
|
461
|
+
* schema declares at least one STEP BODY — an array whose items point at the
|
|
462
|
+
* shared grammar, or, for a module published before that fragment existed, one
|
|
463
|
+
* carrying the legacy `x-telo-step-context` annotation. That is what drives the
|
|
464
|
+
* resolver's generic step traversal; a definition with `inherit: true` and no
|
|
465
|
+
* such array has no invocables to inherit from. */
|
|
463
466
|
function validateThrowsDeclarations(manifests: ResourceManifest[]): AnalysisDiagnostic[] {
|
|
464
467
|
const diagnostics: AnalysisDiagnostic[] = [];
|
|
465
468
|
for (const m of manifests) {
|
|
@@ -475,7 +478,11 @@ function validateThrowsDeclarations(manifests: ResourceManifest[]): AnalysisDiag
|
|
|
475
478
|
severity: DiagnosticSeverity.Error,
|
|
476
479
|
code: "INHERIT_WITHOUT_STEP_CONTEXT",
|
|
477
480
|
source: SOURCE,
|
|
478
|
-
message:
|
|
481
|
+
message:
|
|
482
|
+
`Telo.Definition '${name}' declares throws.inherit: true but its schema declares no step ` +
|
|
483
|
+
`body. inherit is only meaningful on a definition that drives invocables through steps — ` +
|
|
484
|
+
`give an array field 'items: { $ref: "telo://manifest#/$defs/Step" }' (the legacy ` +
|
|
485
|
+
`x-telo-step-context annotation is also recognised).`,
|
|
479
486
|
data: { resource: { kind: m.kind, name }, filePath, path: "throws.inherit" },
|
|
480
487
|
});
|
|
481
488
|
}
|
|
@@ -486,7 +493,7 @@ function validateThrowsDeclarations(manifests: ResourceManifest[]): AnalysisDiag
|
|
|
486
493
|
|
|
487
494
|
function schemaHasStepContext(schema: Record<string, any> | undefined): boolean {
|
|
488
495
|
if (!schema || typeof schema !== "object") return false;
|
|
489
|
-
if (
|
|
496
|
+
if (isStepSlot(schema)) return true;
|
|
490
497
|
const props = schema.properties;
|
|
491
498
|
if (props && typeof props === "object") {
|
|
492
499
|
for (const v of Object.values(props as Record<string, any>)) {
|