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