@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,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single reader for `x-telo-referrer-rules` — a kind declaring, as data,
|
|
3
|
+
* what must be true of whoever REFERENCES one of its resources.
|
|
4
|
+
*
|
|
5
|
+
* `x-telo-resource-rules` relates the fields of one resource; this relates a
|
|
6
|
+
* resource to the one that reached it. `Http.Reference` renders the OpenAPI
|
|
7
|
+
* document its server collects, so a server mounting it without an `openapi:`
|
|
8
|
+
* block has nothing to render — a disagreement neither kind can state alone,
|
|
9
|
+
* and one that otherwise surfaces only at boot, after the port is bound.
|
|
10
|
+
*
|
|
11
|
+
* Declared by the kind that HAS the requirement, never by the kind that must
|
|
12
|
+
* satisfy it. That is what lets a third-party mount carry its own requirement
|
|
13
|
+
* without `Http.Server` knowing the kind exists — and it is what keeps the rule
|
|
14
|
+
* free of a kind literal on the subject side, where the spelling would be the
|
|
15
|
+
* consumer's import alias rather than anything the rule author controls.
|
|
16
|
+
*
|
|
17
|
+
* The CEL scope is `self` (the declaring resource) and `referrer` (the one that
|
|
18
|
+
* references it). Deliberately NOT `this`: in a resource rule `this` is an
|
|
19
|
+
* ELEMENT of a collection, and binding it to a whole foreign manifest would give
|
|
20
|
+
* one word two meanings across the two families.
|
|
21
|
+
*
|
|
22
|
+
* Lenient by design, the `ref-slot.ts` precedent: anything unreadable here reads
|
|
23
|
+
* as absent, and `validate-referrer-rules.ts` is the strict half that reports it.
|
|
24
|
+
*
|
|
25
|
+
* Browser-safe: no Node built-ins.
|
|
26
|
+
*/
|
|
27
|
+
import { celSourceOf, type ResourceRuleSeverity } from "./resource-rule.js";
|
|
28
|
+
|
|
29
|
+
export const REFERRER_RULES_ANNOTATION = "x-telo-referrer-rules";
|
|
30
|
+
|
|
31
|
+
export interface ReferrerRule {
|
|
32
|
+
/**
|
|
33
|
+
* Kind the referring resource must be for this rule to apply, in the
|
|
34
|
+
* alias-qualified grammar `extends:` and `x-telo-ref` use — canonicalized in
|
|
35
|
+
* the declaring module's scope at registration, so evaluation never sees an
|
|
36
|
+
* alias. Absent = any referrer, which conflates "references me" with the
|
|
37
|
+
* relation the rule is about, so a kind should write it.
|
|
38
|
+
*/
|
|
39
|
+
readonly referrer?: string;
|
|
40
|
+
/** CEL source. TRUE when the rule holds. */
|
|
41
|
+
readonly condition: string;
|
|
42
|
+
/** The rule's own name, carried in `data.rule`. Never a diagnostic code —
|
|
43
|
+
* every violation reports under the analyzer-owned envelope. */
|
|
44
|
+
readonly code: string;
|
|
45
|
+
readonly message: string;
|
|
46
|
+
readonly severity: ResourceRuleSeverity;
|
|
47
|
+
/** Position in the annotation array — the anchor for a declaration defect. */
|
|
48
|
+
readonly index: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
52
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The annotation exactly as written, for the strict half. `undefined` when the
|
|
56
|
+
* kind declares none; a non-array is returned as-is so the shape can be
|
|
57
|
+
* reported rather than silently skipped. */
|
|
58
|
+
export function readRawReferrerRules(schema: unknown): unknown {
|
|
59
|
+
if (!isObject(schema)) return undefined;
|
|
60
|
+
return schema[REFERRER_RULES_ANNOTATION];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Every rule this kind declares that is well-formed enough to run. */
|
|
64
|
+
export function readReferrerRules(schema: unknown): ReferrerRule[] {
|
|
65
|
+
const raw = readRawReferrerRules(schema);
|
|
66
|
+
if (!Array.isArray(raw)) return [];
|
|
67
|
+
const rules: ReferrerRule[] = [];
|
|
68
|
+
raw.forEach((entry, index) => {
|
|
69
|
+
if (!isObject(entry)) return;
|
|
70
|
+
const condition = celSourceOf(entry.condition);
|
|
71
|
+
const { code, message, referrer } = entry;
|
|
72
|
+
if (!condition || typeof code !== "string" || typeof message !== "string") return;
|
|
73
|
+
if (code.length === 0 || message.length === 0) return;
|
|
74
|
+
if (referrer !== undefined && typeof referrer !== "string") return;
|
|
75
|
+
if (entry.severity !== undefined && entry.severity !== "warning" && entry.severity !== "error") {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
rules.push({
|
|
79
|
+
...(referrer === undefined ? {} : { referrer }),
|
|
80
|
+
condition,
|
|
81
|
+
code,
|
|
82
|
+
message,
|
|
83
|
+
severity: entry.severity === "warning" ? "warning" : "error",
|
|
84
|
+
index,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
return rules;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True when this node carries the annotation — the recognizer the schema-kind
|
|
91
|
+
* canonicalization walk tests, so shape knowledge stays in this file. */
|
|
92
|
+
export function hasReferrerRules(node: Record<string, unknown>): boolean {
|
|
93
|
+
return Array.isArray(node[REFERRER_RULES_ANNOTATION]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Rewrite each rule's `referrer:` kind through `rewrite`, in place.
|
|
98
|
+
*
|
|
99
|
+
* The mirror of `rewriteRequiresZoneKind`: the alias→canonical rule lives in
|
|
100
|
+
* `resolve-schema-ref-kinds.ts` and the shape lives here, so a filter is
|
|
101
|
+
* canonical everywhere downstream and a name that resolves to nothing is
|
|
102
|
+
* reported once, at the kind that wrote it. A rewrite returning `undefined`
|
|
103
|
+
* leaves the value as written — the caller reports it.
|
|
104
|
+
*/
|
|
105
|
+
export function rewriteReferrerRuleKinds(
|
|
106
|
+
node: Record<string, unknown>,
|
|
107
|
+
rewrite: (kind: string) => string | undefined,
|
|
108
|
+
): void {
|
|
109
|
+
const raw = node[REFERRER_RULES_ANNOTATION];
|
|
110
|
+
if (!Array.isArray(raw)) return;
|
|
111
|
+
for (const entry of raw) {
|
|
112
|
+
if (!isObject(entry) || typeof entry.referrer !== "string") continue;
|
|
113
|
+
const rewritten = rewrite(entry.referrer);
|
|
114
|
+
if (rewritten !== undefined) entry.referrer = rewritten;
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/release/index.ts
CHANGED
|
@@ -29,9 +29,13 @@ export const MANIFEST_LAYER = "manifest";
|
|
|
29
29
|
* Exactly one today, and for a structural reason rather than an omission: the
|
|
30
30
|
* published `layers:` index lives *inside* `telo.yaml`, so it cannot carry that
|
|
31
31
|
* file's own digest — the entry would have to be computed over bytes containing
|
|
32
|
-
* it.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* it. `readPublishedDigests` reads that index, so it has no answer for this key.
|
|
33
|
+
*
|
|
34
|
+
* It is no longer *unknowable*, though, only absent from the index: the payload
|
|
35
|
+
* builder now writes the index, so its manifest is the published text and this
|
|
36
|
+
* digest is what hashing the served `telo.yaml` yields. Reconciling it would
|
|
37
|
+
* mean fetching the manifest per module rather than reading one index — worth
|
|
38
|
+
* doing, and a separate change from the one that made it possible.
|
|
35
39
|
*
|
|
36
40
|
* It stays in the ledger regardless, because it is the only thing that sees a
|
|
37
41
|
* **manifest-only change**: a schema edit, a new kind, a description, a
|
|
@@ -66,6 +66,64 @@ export function stampManifestVersion(
|
|
|
66
66
|
return spliceScalar(text, node, version, where);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* `pkg:npm/<name>@<version>` candidates naming the module's OWN package.
|
|
71
|
+
*
|
|
72
|
+
* A module has one version, and its manifest pins the package it ships itself —
|
|
73
|
+
* so the pin has to move with the rest of it. Left behind, the manifest names a
|
|
74
|
+
* tarball older than the module describing it, which is exactly how an
|
|
75
|
+
* npm-delivered module came to point at a years-old version of itself.
|
|
76
|
+
*
|
|
77
|
+
* Anchored on the `controllers:` SCALARS the YAML actually holds, not on a free
|
|
78
|
+
* text match: this file's whole rule is find the scalar, splice its span, touch
|
|
79
|
+
* nothing else, and a bare regex over the document would also reach a PURL
|
|
80
|
+
* written in a description, an example or a comment. Matched by exact package
|
|
81
|
+
* name, so a PURL naming a DIFFERENT package — a real dependency, pinned
|
|
82
|
+
* deliberately — is untouched.
|
|
83
|
+
*/
|
|
84
|
+
export function stampSelfNpmPins(
|
|
85
|
+
text: string,
|
|
86
|
+
packageName: string,
|
|
87
|
+
version: string,
|
|
88
|
+
where = "controllers",
|
|
89
|
+
): string {
|
|
90
|
+
const docs = parseAllDocuments(text, { customTags: defaultCustomTags() });
|
|
91
|
+
const prefix = `pkg:npm/${packageName}@`;
|
|
92
|
+
const edits: { start: number; end: number; newText: string }[] = [];
|
|
93
|
+
|
|
94
|
+
for (const doc of docs) {
|
|
95
|
+
const controllers = doc.get("controllers", true);
|
|
96
|
+
const items = (controllers as { items?: unknown[] } | undefined)?.items;
|
|
97
|
+
if (!Array.isArray(items)) continue;
|
|
98
|
+
for (const item of items) {
|
|
99
|
+
if (!isScalar(item) || typeof item.value !== "string") continue;
|
|
100
|
+
const purl = item.value;
|
|
101
|
+
if (!purl.startsWith(prefix)) continue;
|
|
102
|
+
// Everything from the version up to the first qualifier or fragment is the
|
|
103
|
+
// pin; the rest of the PURL (`?local_path=…#Export`) is carried through.
|
|
104
|
+
const rest = purl.slice(prefix.length);
|
|
105
|
+
const tail = rest.search(/[?#]/);
|
|
106
|
+
const rewritten = `${prefix}${version}${tail === -1 ? "" : rest.slice(tail)}`;
|
|
107
|
+
if (rewritten === purl) continue;
|
|
108
|
+
const range = item.range;
|
|
109
|
+
if (!range) {
|
|
110
|
+
throw new VersionStampError(`${where}: a controller PURL carries no source range.`);
|
|
111
|
+
}
|
|
112
|
+
const [start, end] = range;
|
|
113
|
+
const replacement = renderFixReplacement(text.slice(start, end), rewritten);
|
|
114
|
+
if (replacement === undefined) {
|
|
115
|
+
throw new VersionStampError(
|
|
116
|
+
`${where}: the controller PURL '${purl}' cannot be rewritten in place. ` +
|
|
117
|
+
`Write it as a plain or quoted scalar on one line.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
edits.push({ start, end, newText: replacement });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return edits.length === 0 ? text : applyTextEdits(text, edits);
|
|
125
|
+
}
|
|
126
|
+
|
|
69
127
|
/** Read the module doc's `metadata.version` without rewriting it. */
|
|
70
128
|
export function readManifestVersion(text: string): string | undefined {
|
|
71
129
|
const docs = parseAllDocuments(text, { customTags: defaultCustomTags() });
|
|
@@ -8,12 +8,16 @@
|
|
|
8
8
|
* would read every example and every cached `.telo/manifests` copy as a released
|
|
9
9
|
* module).
|
|
10
10
|
*
|
|
11
|
-
* The file is **optional and
|
|
12
|
-
* `
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* The file is **optional**, and everything reading it is harmless without one.
|
|
12
|
+
* `telo release` requires one. `telo run` uses its LOCATION — never `modules:`,
|
|
13
|
+
* which is release scope — as the bound on the walk-up that collects `.env` /
|
|
14
|
+
* `.env.local`, and with no marker reads the manifest's own directory alone, as
|
|
15
|
+
* it did before this file existed; a bound is what keeps that walk out of a
|
|
16
|
+
* user's home directory. Nothing else — not `check`, `publish`, `install`,
|
|
17
|
+
* `upgrade`, `migrate`, `module`, and not the kernel — looks for it, so a
|
|
18
|
+
* single-manifest repo, a bare `examples/` directory and a third-party module
|
|
19
|
+
* checkout keep working with nothing added. Any field added later must be true
|
|
20
|
+
* of the whole tree, not derivable from it, and harmless by its absence.
|
|
17
21
|
*
|
|
18
22
|
* Parsing lives here, in the browser-safe half, because the editor answers
|
|
19
23
|
* "what does changing this library bump?" from the same model. Finding the file
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
3
3
|
import { rewriteRefSlotKinds } from "./ref-slot.js";
|
|
4
|
+
import {
|
|
5
|
+
REFERRER_RULES_ANNOTATION,
|
|
6
|
+
hasReferrerRules,
|
|
7
|
+
rewriteReferrerRuleKinds,
|
|
8
|
+
} from "./referrer-rule.js";
|
|
4
9
|
import { hasRequiresZone, rewriteRequiresZoneKind } from "./zone-slot.js";
|
|
5
10
|
|
|
6
11
|
const REF_ANNOTATION = "x-telo-ref";
|
|
@@ -27,9 +32,10 @@ export interface RefConstraintIssue {
|
|
|
27
32
|
manifest: ResourceManifest;
|
|
28
33
|
reason: RefConstraintReason;
|
|
29
34
|
/** Which annotation carried the unresolved name: an `x-telo-ref` constraint
|
|
30
|
-
* (the default)
|
|
31
|
-
*
|
|
32
|
-
|
|
35
|
+
* (the default), an `x-telo-requires-zone` provider kind, or an
|
|
36
|
+
* `x-telo-referrer-rules` `referrer:` filter — the caller reports those as
|
|
37
|
+
* ZONE_PROVIDER_UNRESOLVED and REFERRER_RULE_INVALID. */
|
|
38
|
+
annotation?: "ref" | "zone" | "referrer";
|
|
33
39
|
/** For `gated`: the target module and the kinds it does export. */
|
|
34
40
|
gate?: { module: string; exported: string[] };
|
|
35
41
|
/** Aliases the declaring scope does know — the "did you mean" material for
|
|
@@ -75,7 +81,11 @@ export function resolveSchemaRefKinds(
|
|
|
75
81
|
): RefConstraintIssue[] {
|
|
76
82
|
const issues: RefConstraintIssue[] = [];
|
|
77
83
|
|
|
78
|
-
const record = (
|
|
84
|
+
const record = (
|
|
85
|
+
ref: string,
|
|
86
|
+
path: string,
|
|
87
|
+
annotation: "ref" | "zone" | "referrer" = "ref",
|
|
88
|
+
): void => {
|
|
79
89
|
if (isLegacyRefIdentity(ref)) {
|
|
80
90
|
issues.push({ ref, path, manifest: definition, reason: "legacy", annotation });
|
|
81
91
|
return;
|
|
@@ -135,6 +145,20 @@ export function resolveSchemaRefKinds(
|
|
|
135
145
|
return undefined;
|
|
136
146
|
});
|
|
137
147
|
}
|
|
148
|
+
if (hasReferrerRules(obj)) {
|
|
149
|
+
// A referrer rule's `referrer:` filter names a kind through the identical
|
|
150
|
+
// alias-qualified grammar, resolved in the same declaring scope — so the
|
|
151
|
+
// evaluation pass compares canonical kinds and never has to know which
|
|
152
|
+
// alias the CONSUMER happened to import the kind under. That is the whole
|
|
153
|
+
// reason such a rule can be sound where the same test written on the
|
|
154
|
+
// referring kind cannot.
|
|
155
|
+
rewriteReferrerRuleKinds(obj, (kind) => {
|
|
156
|
+
const result = resolver.resolveKindResult(kind);
|
|
157
|
+
if (result.status === "ok") return result.kind;
|
|
158
|
+
record(kind, `${path}.${REFERRER_RULES_ANNOTATION}`, "referrer");
|
|
159
|
+
return undefined;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
138
162
|
for (const key of Object.keys(obj)) {
|
|
139
163
|
walk(obj[key], path ? `${path}.${key}` : key);
|
|
140
164
|
}
|
|
@@ -2,6 +2,7 @@ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
|
2
2
|
import { isTaggedSentinel } from "@telorun/templating";
|
|
3
3
|
import { scopeResolverForModule, type AliasResolver } from "./alias-resolver.js";
|
|
4
4
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
5
|
+
import { readStepSlot } from "./step-slot.js";
|
|
5
6
|
|
|
6
7
|
export interface ThrowsCodeMeta {
|
|
7
8
|
data?: Record<string, any>;
|
|
@@ -174,8 +175,8 @@ function resolveInherited(
|
|
|
174
175
|
if (!props) return result;
|
|
175
176
|
|
|
176
177
|
for (const [fieldName, fieldSchema] of Object.entries(props)) {
|
|
177
|
-
const stepCtx = fieldSchema
|
|
178
|
-
if (!stepCtx
|
|
178
|
+
const stepCtx = readStepSlot(fieldSchema);
|
|
179
|
+
if (!stepCtx) continue;
|
|
179
180
|
const steps = (manifest as Record<string, any>)[fieldName];
|
|
180
181
|
if (!Array.isArray(steps)) continue;
|
|
181
182
|
unionInto(result, collectStepArrayThrows(steps, stepCtx.invoke, undefined, ctx, ownerModule));
|
|
@@ -205,7 +206,7 @@ function collectStepArrayThrows(
|
|
|
205
206
|
/** Walk one step, dispatching by shape. Generic for any Run.Sequence-style
|
|
206
207
|
* composer: the step keys it recognises (`try` / `catch` / `finally` / `then`
|
|
207
208
|
* / `else` / `elseif` / `do` / `cases` / `default`) are the same set already
|
|
208
|
-
* traversed by the analyzer's
|
|
209
|
+
* traversed by the analyzer's step-body walk, so future
|
|
209
210
|
* composers that reuse those shape conventions work without changes here. */
|
|
210
211
|
function collectStepThrows(
|
|
211
212
|
step: Record<string, any>,
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zone containment — *what is inside this zone*, which is the opposite question
|
|
3
|
+
* from the one `resolve-zone-requirements.ts` answers.
|
|
4
|
+
*
|
|
5
|
+
* The landed projection propagates a requirement callee→caller and asks *is this
|
|
6
|
+
* requirement satisfied*. Every check built on zone ATTRIBUTES asks the reverse:
|
|
7
|
+
* a `noSuspend` region has to know what it contains before it can forbid parking
|
|
8
|
+
* inside it, a `replayed` region before it can reject a detached dispatch, an
|
|
9
|
+
* `idempotent` region before it can call impure CEL a broken promise. So this is
|
|
10
|
+
* a DOWNWARD walk from a providing slot to everything its body reaches.
|
|
11
|
+
*
|
|
12
|
+
* It is a second consumer of the shared call graph, not a second graph, and it
|
|
13
|
+
* is **parameterized over the attribute that opens the region** — so the durable
|
|
14
|
+
* zone (`replayed`) and the constraint zones (`noSuspend`, `atomic`,
|
|
15
|
+
* `idempotent`) share one traversal. It resolves an attribute NAME and nothing
|
|
16
|
+
* else: no kind is named here, which is what keeps `modules/durable` out of the
|
|
17
|
+
* analyzer's surface and satisfies the topology-driven constraint.
|
|
18
|
+
*
|
|
19
|
+
* **Two shapes of body slot, one region.** A slot may hold its body as a
|
|
20
|
+
* reference to an executable (`Sql.Transaction.steps`) or carry a step array
|
|
21
|
+
* natively (the shared `Step` fragment, which any kind may point at). Both are
|
|
22
|
+
* regions; the first is entered through the slot's edge, the second through the
|
|
23
|
+
* step nodes the slot declares. Handling only the first would have made the
|
|
24
|
+
* checks silently vacuous on exactly the kinds written after the grammar became
|
|
25
|
+
* shared vocabulary.
|
|
26
|
+
*
|
|
27
|
+
* **The walk under-approximates, and that is the safe direction — for a CHECK.**
|
|
28
|
+
* A dynamically dispatched edge is invisible here, so a check over the result
|
|
29
|
+
* may stay silent where it should have spoken, and the runtime enforcement is
|
|
30
|
+
* what closes it. A consumer at the OPPOSITE polarity — the durable manifest
|
|
31
|
+
* digest, whose blind spot is silent replay against changed code rather than a
|
|
32
|
+
* missed diagnostic — must not inherit this tolerance, and verifies its coverage
|
|
33
|
+
* against the journal instead of trusting this set.
|
|
34
|
+
*
|
|
35
|
+
* Browser-safe: no Node built-ins.
|
|
36
|
+
*/
|
|
37
|
+
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
38
|
+
import type { CallGraph, CallGraphEdge, CallGraphNode, ResourceGraphNode } from "./call-graph.js";
|
|
39
|
+
import { propertySchemas, resolveLocalRef } from "./manifest-navigation.js";
|
|
40
|
+
import { isStepSlot } from "./step-slot.js";
|
|
41
|
+
import { readProvidesZone } from "./zone-slot.js";
|
|
42
|
+
|
|
43
|
+
/** One node the region reaches, with the route taken to it. */
|
|
44
|
+
export interface ContainedNode {
|
|
45
|
+
node: CallGraphNode;
|
|
46
|
+
/** Labels of the hops from the body slot to this node, for a diagnostic that
|
|
47
|
+
* names the path rather than only the endpoint. */
|
|
48
|
+
via: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A dispatch that leaves the region without extending it.
|
|
52
|
+
*
|
|
53
|
+
* Recorded rather than followed, because the two facts are different and both
|
|
54
|
+
* are wanted: the zone's lifetime does NOT reach the target (so nothing inside
|
|
55
|
+
* it is contained), while the dispatch itself is a site inside the region that
|
|
56
|
+
* a check may forbid outright — `DURABLE_DETACH_FORBIDDEN` is exactly that. */
|
|
57
|
+
export interface RegionBoundary {
|
|
58
|
+
edge: CallGraphEdge;
|
|
59
|
+
/** The resource whose slot dispatches this way. */
|
|
60
|
+
from: ResourceGraphNode;
|
|
61
|
+
via: string[];
|
|
62
|
+
/** Why the zone stops here: the uses that do not extend it. */
|
|
63
|
+
escaping: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A region opened by one providing slot carrying the requested attribute. */
|
|
67
|
+
export interface ZoneRegion {
|
|
68
|
+
/** The attribute that opened it — the one the walk was asked for. */
|
|
69
|
+
attribute: string;
|
|
70
|
+
/** The author's reason, quoted verbatim by every diagnostic over this region.
|
|
71
|
+
* Present by construction: an attribute's value IS the reason. */
|
|
72
|
+
reason: string;
|
|
73
|
+
/** The resource whose slot establishes the zone. */
|
|
74
|
+
provider: ResourceGraphNode;
|
|
75
|
+
/** Field-map path of the body slot (`steps`, `invoke`). */
|
|
76
|
+
slot: string;
|
|
77
|
+
/** Every attribute the slot declares, not only the requested one — a consumer
|
|
78
|
+
* deciding collapse reads `atomic` and `idempotent` off one region. */
|
|
79
|
+
attributes: Readonly<Record<string, string>>;
|
|
80
|
+
/** Everything control reaches from the body, keyed by node id. Includes the
|
|
81
|
+
* step nodes of a native body, since a step is where a check anchors. */
|
|
82
|
+
contents: ReadonlyMap<string, ContainedNode>;
|
|
83
|
+
/** Dispatches inside the region that the zone does not extend through. */
|
|
84
|
+
boundaries: readonly RegionBoundary[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Resolves a kind name to its definition in the scope of the module that
|
|
88
|
+
* DECLARED it — the same resolver the projection takes, passed in rather than
|
|
89
|
+
* rebuilt so both walks agree about what a kind means. */
|
|
90
|
+
export type DefinitionLookup = (kind: string, module?: string) => ResourceDefinition | undefined;
|
|
91
|
+
|
|
92
|
+
const moduleOf = (node: ResourceGraphNode): string | undefined =>
|
|
93
|
+
(node.manifest.metadata as { module?: string } | undefined)?.module;
|
|
94
|
+
|
|
95
|
+
/** The schema node at a field-map path, following `[]` into `items`, `{}` into
|
|
96
|
+
* `additionalProperties` and local `$defs` refs. Same navigation the projection
|
|
97
|
+
* performs — a slot's annotations live wherever this lands. */
|
|
98
|
+
function schemaNodeAt(
|
|
99
|
+
rootSchema: Record<string, any> | undefined,
|
|
100
|
+
slotPath: string,
|
|
101
|
+
): Record<string, any> | undefined {
|
|
102
|
+
if (!rootSchema) return undefined;
|
|
103
|
+
let current: Record<string, any> | undefined = rootSchema;
|
|
104
|
+
for (const segment of slotPath.split(".")) {
|
|
105
|
+
if (!current) return undefined;
|
|
106
|
+
const bare = segment.replace(/(\[\]|\{\})+$/g, "");
|
|
107
|
+
let next: Record<string, any> | undefined = propertySchemas(current).find(
|
|
108
|
+
([k]) => k === bare,
|
|
109
|
+
)?.[1];
|
|
110
|
+
for (const marker of segment.slice(bare.length).match(/\[\]|\{\}/g) ?? []) {
|
|
111
|
+
next = resolveLocalRef(
|
|
112
|
+
marker === "[]"
|
|
113
|
+
? (next?.items as Record<string, any> | undefined)
|
|
114
|
+
: (next?.additionalProperties as Record<string, any> | undefined),
|
|
115
|
+
rootSchema,
|
|
116
|
+
);
|
|
117
|
+
if (!next || typeof next !== "object") return undefined;
|
|
118
|
+
}
|
|
119
|
+
current = resolveLocalRef(next, rootSchema);
|
|
120
|
+
}
|
|
121
|
+
return current;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Does a zone's lifetime extend through this edge?
|
|
126
|
+
*
|
|
127
|
+
* EVERY member of `use` must be `call`. This is the landed reduction, and its
|
|
128
|
+
* asymmetry with the propagation rule is deliberate: a set says several
|
|
129
|
+
* relations hold at once, so a slot declaring `[call, detached]` really does
|
|
130
|
+
* detach on some dispatch, and a detached dispatch is never inside the caller's
|
|
131
|
+
* zone. An edge whose `use` could not be read extends nothing either — the
|
|
132
|
+
* conservative direction HERE, since over-reporting containment would invent
|
|
133
|
+
* failures inside regions that are correct.
|
|
134
|
+
*/
|
|
135
|
+
function extendsZone(edge: CallGraphEdge): boolean {
|
|
136
|
+
return edge.use.length > 0 && !edge.unresolved && edge.use.every((u) => u === "call");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Uses on an edge that leave the region, for the boundary record. */
|
|
140
|
+
function escapingUses(edge: CallGraphEdge): string[] {
|
|
141
|
+
return edge.use.filter((u) => u !== "call" && u !== "dependency" && u !== "schema");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Walk down from one entry edge, collecting what the zone reaches. */
|
|
145
|
+
function collect(
|
|
146
|
+
graph: CallGraph,
|
|
147
|
+
entries: readonly { edge?: CallGraphEdge; node: CallGraphNode; via: string[] }[],
|
|
148
|
+
contents: Map<string, ContainedNode>,
|
|
149
|
+
boundaries: RegionBoundary[],
|
|
150
|
+
): void {
|
|
151
|
+
const queue = [...entries];
|
|
152
|
+
while (queue.length > 0) {
|
|
153
|
+
const current = queue.shift()!;
|
|
154
|
+
if (contents.has(current.node.id)) continue;
|
|
155
|
+
contents.set(current.node.id, { node: current.node, via: current.via });
|
|
156
|
+
|
|
157
|
+
// A resource's own step nodes are part of whatever region reaches the
|
|
158
|
+
// resource: a step is where a check anchors (`the retry at
|
|
159
|
+
// importAll/fetch`), and its outgoing edge is the dispatch a rule judges.
|
|
160
|
+
const stepNodes =
|
|
161
|
+
current.node.type === "resource" ? graph.steps(current.node.id) : [];
|
|
162
|
+
for (const step of stepNodes) {
|
|
163
|
+
if (contents.has(step.id)) continue;
|
|
164
|
+
queue.push({
|
|
165
|
+
node: step,
|
|
166
|
+
via: [...current.via, step.name ? `step '${step.name}'` : step.path],
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const owner: ResourceGraphNode | undefined =
|
|
171
|
+
current.node.type === "resource"
|
|
172
|
+
? current.node
|
|
173
|
+
: (graph.nodes.get(current.node.owner) as ResourceGraphNode | undefined);
|
|
174
|
+
|
|
175
|
+
for (const edge of graph.edgesFrom(current.node.id)) {
|
|
176
|
+
if (edge.use.every((u) => u === "dependency" || u === "schema")) continue;
|
|
177
|
+
const target = edge.to ? graph.nodes.get(edge.to) : undefined;
|
|
178
|
+
if (!extendsZone(edge)) {
|
|
179
|
+
if (owner && escapingUses(edge).length > 0) {
|
|
180
|
+
boundaries.push({
|
|
181
|
+
edge,
|
|
182
|
+
from: owner,
|
|
183
|
+
via: current.via,
|
|
184
|
+
escaping: escapingUses(edge),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (!target) continue;
|
|
190
|
+
queue.push({
|
|
191
|
+
node: target,
|
|
192
|
+
via: [...current.via, `${edge.slot} → ${edge.toName}`],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Every region in the graph opened by a slot declaring `attribute`.
|
|
200
|
+
*
|
|
201
|
+
* The provider itself is NOT in `contents` — a zone constrains what runs inside
|
|
202
|
+
* its body, not the resource that establishes it. A transaction's own
|
|
203
|
+
* `connection:` dependency is outside the region it opens, and a rule that
|
|
204
|
+
* treated the provider as contained would report the provider against its own
|
|
205
|
+
* constraint.
|
|
206
|
+
*/
|
|
207
|
+
export function findZoneRegions(
|
|
208
|
+
graph: CallGraph,
|
|
209
|
+
resolveDef: DefinitionLookup,
|
|
210
|
+
attribute: string,
|
|
211
|
+
): ZoneRegion[] {
|
|
212
|
+
const regions: ZoneRegion[] = [];
|
|
213
|
+
|
|
214
|
+
for (const node of graph.nodes.values()) {
|
|
215
|
+
if (node.type !== "resource") continue;
|
|
216
|
+
const def = resolveDef(node.kind, moduleOf(node));
|
|
217
|
+
const rootSchema = def?.schema as Record<string, any> | undefined;
|
|
218
|
+
if (!rootSchema) continue;
|
|
219
|
+
|
|
220
|
+
// The providing slots of this kind, found by walking its own declared
|
|
221
|
+
// properties rather than by knowing any kind's field names.
|
|
222
|
+
for (const [slot, slotSchema] of providingSlots(rootSchema)) {
|
|
223
|
+
const provides = readProvidesZone(slotSchema);
|
|
224
|
+
const reason = provides?.attributes[attribute as keyof typeof provides.attributes];
|
|
225
|
+
if (!provides || typeof reason !== "string") continue;
|
|
226
|
+
|
|
227
|
+
const contents = new Map<string, ContainedNode>();
|
|
228
|
+
const boundaries: RegionBoundary[] = [];
|
|
229
|
+
|
|
230
|
+
// Shape one: the slot carries a step array natively. Its steps are the
|
|
231
|
+
// body, and they are owned by this resource, so they are found by path
|
|
232
|
+
// rather than by an edge.
|
|
233
|
+
if (isStepSlot(slotSchema)) {
|
|
234
|
+
const entries = graph
|
|
235
|
+
.steps(node.id)
|
|
236
|
+
.filter((step) => step.array === slot || step.array.startsWith(`${slot}[`))
|
|
237
|
+
.map((step) => ({
|
|
238
|
+
node: step as CallGraphNode,
|
|
239
|
+
via: [step.name ? `step '${step.name}'` : step.path],
|
|
240
|
+
}));
|
|
241
|
+
collect(graph, entries, contents, boundaries);
|
|
242
|
+
} else {
|
|
243
|
+
// Shape two: the slot references an executable. Its edges are the body.
|
|
244
|
+
// Entered regardless of the slot's OWN use — a providing slot
|
|
245
|
+
// establishes its zone before the enclosing lifetime terminates on that
|
|
246
|
+
// use, which is what lets a detached durable body both shed every
|
|
247
|
+
// enclosing zone and open its own.
|
|
248
|
+
const entries = graph
|
|
249
|
+
.edgesFrom(node.id)
|
|
250
|
+
.filter((edge) => edge.slot === slot && edge.to)
|
|
251
|
+
.map((edge) => ({
|
|
252
|
+
node: graph.nodes.get(edge.to!)!,
|
|
253
|
+
via: [`${slot} → ${edge.toName}`],
|
|
254
|
+
}))
|
|
255
|
+
.filter((entry) => entry.node !== undefined);
|
|
256
|
+
collect(graph, entries, contents, boundaries);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
regions.push({
|
|
260
|
+
attribute,
|
|
261
|
+
reason,
|
|
262
|
+
provider: node,
|
|
263
|
+
slot,
|
|
264
|
+
attributes: provides.attributes as Readonly<Record<string, string>>,
|
|
265
|
+
contents,
|
|
266
|
+
boundaries,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return regions;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Field-map paths of every slot in a kind's schema carrying a provides-zone
|
|
275
|
+
* annotation, with the schema node at each. Walks properties, array items and
|
|
276
|
+
* `additionalProperties`, resolving local `$ref`s — the paths the call graph's
|
|
277
|
+
* own edges are keyed by, so a slot found here matches an edge's `slot`. */
|
|
278
|
+
function providingSlots(
|
|
279
|
+
rootSchema: Record<string, any>,
|
|
280
|
+
): Array<[slot: string, schema: Record<string, any>]> {
|
|
281
|
+
const found: Array<[string, Record<string, any>]> = [];
|
|
282
|
+
const seen = new Set<object>();
|
|
283
|
+
|
|
284
|
+
const walk = (schema: Record<string, any> | undefined, path: string): void => {
|
|
285
|
+
const node = resolveLocalRef(schema, rootSchema);
|
|
286
|
+
if (!node || typeof node !== "object" || seen.has(node)) return;
|
|
287
|
+
seen.add(node);
|
|
288
|
+
if (path && readProvidesZone(node)) found.push([path, node]);
|
|
289
|
+
for (const [key, child] of propertySchemas(node)) {
|
|
290
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
291
|
+
walk(child, childPath);
|
|
292
|
+
// A slot's zone annotation may sit on the ARRAY (`steps`) or on its item
|
|
293
|
+
// (`routes[].handler`); both are real field-map paths.
|
|
294
|
+
const items = resolveLocalRef(child?.items as Record<string, any> | undefined, rootSchema);
|
|
295
|
+
if (items) walk(items, `${childPath}[]`);
|
|
296
|
+
const additional = resolveLocalRef(
|
|
297
|
+
child?.additionalProperties as Record<string, any> | undefined,
|
|
298
|
+
rootSchema,
|
|
299
|
+
);
|
|
300
|
+
if (additional) walk(additional, `${childPath}{}`);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
walk(rootSchema, "");
|
|
305
|
+
return found;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Convenience for a consumer that only wants membership: every node id inside
|
|
309
|
+
* any region opened by `attribute`, mapped to the region that contains it. */
|
|
310
|
+
export function containmentIndex(
|
|
311
|
+
regions: readonly ZoneRegion[],
|
|
312
|
+
): ReadonlyMap<string, ZoneRegion> {
|
|
313
|
+
const index = new Map<string, ZoneRegion>();
|
|
314
|
+
for (const region of regions) {
|
|
315
|
+
for (const id of region.contents.keys()) {
|
|
316
|
+
if (!index.has(id)) index.set(id, region);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return index;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The manifests a region's contents belong to — what a diagnostic anchors on. */
|
|
323
|
+
export function regionManifests(region: ZoneRegion): ResourceManifest[] {
|
|
324
|
+
const out: ResourceManifest[] = [];
|
|
325
|
+
for (const { node } of region.contents.values()) {
|
|
326
|
+
if (node.type === "resource") out.push(node.manifest);
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|