@telorun/analyzer 0.52.0 → 0.54.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 +8 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +21 -3
- package/dist/analyzer.d.ts +3 -2
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +193 -26
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +32 -12
- package/dist/call-graph.d.ts +189 -0
- package/dist/call-graph.d.ts.map +1 -0
- package/dist/call-graph.js +617 -0
- package/dist/dependency-graph.d.ts +17 -7
- package/dist/dependency-graph.d.ts.map +1 -1
- package/dist/dependency-graph.js +36 -65
- package/dist/flatten-for-analyzer.d.ts +8 -0
- package/dist/flatten-for-analyzer.d.ts.map +1 -1
- package/dist/flatten-for-analyzer.js +32 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/manifest-navigation.d.ts +32 -0
- package/dist/manifest-navigation.d.ts.map +1 -0
- package/dist/manifest-navigation.js +91 -0
- package/dist/manifest-visitor.js +1 -1
- package/dist/ref-slot.d.ts +125 -0
- package/dist/ref-slot.d.ts.map +1 -0
- package/dist/ref-slot.js +226 -0
- package/dist/reference-field-map.d.ts +15 -1
- package/dist/reference-field-map.d.ts.map +1 -1
- package/dist/reference-field-map.js +29 -35
- package/dist/resolve-schema-ref-kinds.d.ts +4 -0
- package/dist/resolve-schema-ref-kinds.d.ts.map +1 -1
- package/dist/resolve-schema-ref-kinds.js +31 -8
- package/dist/resolve-zone-requirements.d.ts +110 -0
- package/dist/resolve-zone-requirements.d.ts.map +1 -0
- package/dist/resolve-zone-requirements.js +541 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-module-metadata.d.ts +38 -0
- package/dist/validate-module-metadata.d.ts.map +1 -0
- package/dist/validate-module-metadata.js +256 -0
- package/dist/validate-observed-state.d.ts +14 -13
- package/dist/validate-observed-state.d.ts.map +1 -1
- package/dist/validate-observed-state.js +21 -88
- package/dist/validate-ref-slots.d.ts +48 -0
- package/dist/validate-ref-slots.d.ts.map +1 -0
- package/dist/validate-ref-slots.js +219 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +8 -1
- package/dist/validate-zone-slots.d.ts +39 -0
- package/dist/validate-zone-slots.d.ts.map +1 -0
- package/dist/validate-zone-slots.js +114 -0
- package/dist/zone-module-documents.d.ts +27 -0
- package/dist/zone-module-documents.d.ts.map +1 -0
- package/dist/zone-module-documents.js +1 -0
- package/dist/zone-slot.d.ts +61 -0
- package/dist/zone-slot.d.ts.map +1 -0
- package/dist/zone-slot.js +91 -0
- package/package.json +3 -3
- package/src/analysis-registry.ts +20 -2
- package/src/analyzer.ts +211 -24
- package/src/builtins.ts +32 -12
- package/src/call-graph.ts +827 -0
- package/src/dependency-graph.ts +34 -68
- package/src/flatten-for-analyzer.ts +32 -0
- package/src/index.ts +51 -0
- package/src/manifest-navigation.ts +91 -0
- package/src/manifest-visitor.ts +1 -1
- package/src/ref-slot.ts +273 -0
- package/src/reference-field-map.ts +39 -36
- package/src/resolve-schema-ref-kinds.ts +34 -7
- package/src/resolve-zone-requirements.ts +781 -0
- package/src/types.ts +8 -0
- package/src/validate-module-metadata.ts +335 -0
- package/src/validate-observed-state.ts +26 -92
- package/src/validate-ref-slots.ts +293 -0
- package/src/validate-references.ts +8 -1
- package/src/validate-zone-slots.ts +175 -0
- package/src/zone-module-documents.ts +27 -0
- package/src/zone-slot.ts +116 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static validation of the `x-telo-ref` annotation itself — the strict half of
|
|
3
|
+
* the accessor split. `readRefSlot` is deliberately lenient (it normalizes
|
|
4
|
+
* whatever it can read, because every surface must keep working mid-migration);
|
|
5
|
+
* this pass reads the RAW annotation and reports what leniency would otherwise
|
|
6
|
+
* silently absorb:
|
|
7
|
+
*
|
|
8
|
+
* - an unrecognized `use` token — a typo like `use: cal` would degrade to the
|
|
9
|
+
* legacy no-use reading, indistinguishable from a slot that never answered;
|
|
10
|
+
* - a structured annotation with no `kind` — the editor would recognise the
|
|
11
|
+
* slot but have nothing to pick against;
|
|
12
|
+
* - a structured annotation with no `use` — the structured form is the
|
|
13
|
+
* declaration that answers the question; omitting it is only legal in the
|
|
14
|
+
* legacy bare-string spelling;
|
|
15
|
+
* - `anyOf` branches whose declared uses disagree — a state with no meaning,
|
|
16
|
+
* since `use` is a property of the slot, never of a branch;
|
|
17
|
+
* - a `use` case map whose selector is written in CEL — a call graph known
|
|
18
|
+
* only at runtime is not statically analyzable, which is the property the
|
|
19
|
+
* typed reference graph exists to protect. There is deliberately no
|
|
20
|
+
* fallback: no single value is conservative for every consumer.
|
|
21
|
+
*
|
|
22
|
+
* Scoping follows `X_TELO_REF_UNRESOLVED`: schema issues are reported only for
|
|
23
|
+
* definitions in the entry's own modules, and the dynamic-selector issue only
|
|
24
|
+
* for manifests in them — a published dependency's slot is not the consumer's
|
|
25
|
+
* to fix.
|
|
26
|
+
*
|
|
27
|
+
* Browser-safe: no Node built-ins.
|
|
28
|
+
*/
|
|
29
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
30
|
+
import type { AliasResolver } from "./alias-resolver.js";
|
|
31
|
+
import { buildCallGraph, type CallGraph } from "./call-graph.js";
|
|
32
|
+
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
33
|
+
import { isRefUse, REF_USES, type RefUse } from "./ref-slot.js";
|
|
34
|
+
|
|
35
|
+
export interface RefSlotIssue {
|
|
36
|
+
code:
|
|
37
|
+
| "X_TELO_REF_INVALID_USE"
|
|
38
|
+
| "X_TELO_REF_MISSING_USE"
|
|
39
|
+
| "X_TELO_REF_MISSING_KIND"
|
|
40
|
+
| "X_TELO_REF_USE_CONFLICT"
|
|
41
|
+
| "X_TELO_REF_DYNAMIC_SELECTOR";
|
|
42
|
+
/** The definition (schema issues) or resource (selector issues) at fault. */
|
|
43
|
+
manifest: ResourceManifest;
|
|
44
|
+
/** Schema path of the slot (schema issues) or concrete value path of the
|
|
45
|
+
* selector's site (dynamic-selector issues). */
|
|
46
|
+
path: string;
|
|
47
|
+
message: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const VALID_USES = REF_USES.join(", ");
|
|
51
|
+
|
|
52
|
+
/** Raw `use` tokens carried by one annotation value: scalar, list, and every
|
|
53
|
+
* case of a case map. Returned unfiltered so a typo is visible. */
|
|
54
|
+
function rawUseTokens(use: unknown): unknown[] {
|
|
55
|
+
if (use === undefined) return [];
|
|
56
|
+
if (Array.isArray(use)) return use;
|
|
57
|
+
if (use && typeof use === "object") {
|
|
58
|
+
const cases = (use as Record<string, unknown>).cases;
|
|
59
|
+
if (!cases || typeof cases !== "object") return [];
|
|
60
|
+
return Object.values(cases as Record<string, unknown>).flatMap((v) =>
|
|
61
|
+
Array.isArray(v) ? v : [v],
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return [use];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The declared fixed uses of one annotation (scalar/list form only), for the
|
|
68
|
+
* branch-disagreement check. */
|
|
69
|
+
function declaredUses(use: unknown): RefUse[] {
|
|
70
|
+
if (isRefUse(use)) return [use];
|
|
71
|
+
if (Array.isArray(use)) return use.filter(isRefUse);
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function checkAnnotation(
|
|
76
|
+
annotation: unknown,
|
|
77
|
+
manifest: ResourceManifest,
|
|
78
|
+
path: string,
|
|
79
|
+
issues: RefSlotIssue[],
|
|
80
|
+
): RefUse[] | undefined {
|
|
81
|
+
if (typeof annotation === "string" || annotation === undefined) return undefined;
|
|
82
|
+
if (!annotation || typeof annotation !== "object" || Array.isArray(annotation)) return undefined;
|
|
83
|
+
const obj = annotation as Record<string, unknown>;
|
|
84
|
+
|
|
85
|
+
const kind = obj.kind;
|
|
86
|
+
const hasKind =
|
|
87
|
+
(typeof kind === "string" && kind.length > 0) ||
|
|
88
|
+
(Array.isArray(kind) && kind.some((k) => typeof k === "string" && k.length > 0));
|
|
89
|
+
if (!hasKind) {
|
|
90
|
+
issues.push({
|
|
91
|
+
code: "X_TELO_REF_MISSING_KIND",
|
|
92
|
+
manifest,
|
|
93
|
+
path,
|
|
94
|
+
message:
|
|
95
|
+
`x-telo-ref at '${path}' declares no 'kind'. The structured form is ` +
|
|
96
|
+
`'{ kind: <Alias>.<Kind> | [<kinds>], use: <use> }' — without a kind the slot ` +
|
|
97
|
+
`constrains nothing and the editor has nothing to pick against.`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const use = obj.use;
|
|
102
|
+
const isCaseMap =
|
|
103
|
+
!!use && typeof use === "object" && !Array.isArray(use) && "by" in (use as object);
|
|
104
|
+
if (use === undefined) {
|
|
105
|
+
issues.push({
|
|
106
|
+
code: "X_TELO_REF_MISSING_USE",
|
|
107
|
+
manifest,
|
|
108
|
+
path,
|
|
109
|
+
message:
|
|
110
|
+
`x-telo-ref at '${path}' declares no 'use'. The structured form must say what the ` +
|
|
111
|
+
`declaring resource does with the target — one of: ${VALID_USES} — or a ` +
|
|
112
|
+
`'{ by, cases }' map when a sibling config field selects the mode. Only the legacy ` +
|
|
113
|
+
`bare-string spelling ('x-telo-ref: <Kind>') may omit it.`,
|
|
114
|
+
});
|
|
115
|
+
} else {
|
|
116
|
+
for (const token of rawUseTokens(use)) {
|
|
117
|
+
if (isRefUse(token)) continue;
|
|
118
|
+
issues.push({
|
|
119
|
+
code: "X_TELO_REF_INVALID_USE",
|
|
120
|
+
manifest,
|
|
121
|
+
path,
|
|
122
|
+
message:
|
|
123
|
+
`x-telo-ref at '${path}' declares unrecognized use '${String(token)}'. ` +
|
|
124
|
+
`Valid uses: ${VALID_USES}. An unrecognized token would silently degrade the slot ` +
|
|
125
|
+
`to the legacy no-use reading.`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (isCaseMap) {
|
|
129
|
+
const by = (use as Record<string, unknown>).by;
|
|
130
|
+
if (typeof by !== "string" || !by.startsWith("/")) {
|
|
131
|
+
issues.push({
|
|
132
|
+
code: "X_TELO_REF_INVALID_USE",
|
|
133
|
+
manifest,
|
|
134
|
+
path,
|
|
135
|
+
message:
|
|
136
|
+
`x-telo-ref at '${path}' has a 'use' case map whose 'by' is not a JSON Pointer. ` +
|
|
137
|
+
`'by' names a sibling field of the object enclosing the slot, e.g. '/detach'.`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return declaredUses(use);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** True when a node is a reference slot: it carries `x-telo-ref` directly or on
|
|
147
|
+
* an `anyOf`/`oneOf` branch. */
|
|
148
|
+
function carriesRefAnnotation(obj: Record<string, unknown>): boolean {
|
|
149
|
+
if (obj["x-telo-ref"] !== undefined) return true;
|
|
150
|
+
for (const key of ["anyOf", "oneOf"] as const) {
|
|
151
|
+
const branches = obj[key];
|
|
152
|
+
if (!Array.isArray(branches)) continue;
|
|
153
|
+
if (
|
|
154
|
+
branches.some(
|
|
155
|
+
(b) => b && typeof b === "object" && (b as Record<string, unknown>)["x-telo-ref"] !== undefined,
|
|
156
|
+
)
|
|
157
|
+
) {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Walk a definition schema, invoking `onSlot` for every node that carries an
|
|
165
|
+
* `x-telo-ref` (directly or on an `anyOf`/`oneOf` branch — the SLOT is the
|
|
166
|
+
* node holding the branches, so a branch is never reported twice). Pure-schema
|
|
167
|
+
* walk, so it needs — and has — a visited guard for cyclic `$defs`. */
|
|
168
|
+
function walkSchema(
|
|
169
|
+
node: unknown,
|
|
170
|
+
path: string,
|
|
171
|
+
visited: Set<object>,
|
|
172
|
+
claimedBranches: Set<object>,
|
|
173
|
+
onSlot: (node: Record<string, unknown>, path: string) => void,
|
|
174
|
+
): void {
|
|
175
|
+
if (!node || typeof node !== "object") return;
|
|
176
|
+
if (visited.has(node as object)) return;
|
|
177
|
+
visited.add(node as object);
|
|
178
|
+
if (Array.isArray(node)) {
|
|
179
|
+
node.forEach((item, i) => walkSchema(item, `${path}[${i}]`, visited, claimedBranches, onSlot));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const obj = node as Record<string, unknown>;
|
|
183
|
+
if (carriesRefAnnotation(obj) && !claimedBranches.has(obj)) {
|
|
184
|
+
onSlot(obj, path);
|
|
185
|
+
for (const key of ["anyOf", "oneOf"] as const) {
|
|
186
|
+
const branches = obj[key];
|
|
187
|
+
if (!Array.isArray(branches)) continue;
|
|
188
|
+
for (const branch of branches) {
|
|
189
|
+
if (branch && typeof branch === "object") claimedBranches.add(branch as object);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
194
|
+
if (key === "x-telo-ref" || key === "examples" || key === "default") continue;
|
|
195
|
+
walkSchema(value, path ? `${path}.${key}` : key, visited, claimedBranches, onSlot);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Schema-level checks over one definition/abstract manifest. */
|
|
200
|
+
export function validateRefSlotDeclarations(definition: ResourceManifest): RefSlotIssue[] {
|
|
201
|
+
const issues: RefSlotIssue[] = [];
|
|
202
|
+
const schema = (definition as Record<string, unknown>).schema;
|
|
203
|
+
if (!schema || typeof schema !== "object") return issues;
|
|
204
|
+
|
|
205
|
+
walkSchema(schema, "schema", new Set(), new Set(), (node, path) => {
|
|
206
|
+
const branchUses: RefUse[][] = [];
|
|
207
|
+
const own = checkAnnotation(node["x-telo-ref"], definition, path, issues);
|
|
208
|
+
if (own) branchUses.push(own);
|
|
209
|
+
for (const key of ["anyOf", "oneOf"] as const) {
|
|
210
|
+
const branches = node[key];
|
|
211
|
+
if (!Array.isArray(branches)) continue;
|
|
212
|
+
branches.forEach((branch, i) => {
|
|
213
|
+
if (!branch || typeof branch !== "object") return;
|
|
214
|
+
const declared = checkAnnotation(
|
|
215
|
+
(branch as Record<string, unknown>)["x-telo-ref"],
|
|
216
|
+
definition,
|
|
217
|
+
`${path}.${key}[${i}]`,
|
|
218
|
+
issues,
|
|
219
|
+
);
|
|
220
|
+
if (declared) branchUses.push(declared);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
const nonEmpty = branchUses.filter((uses) => uses.length > 0);
|
|
224
|
+
if (nonEmpty.length > 1) {
|
|
225
|
+
const first = [...nonEmpty[0]].sort().join(",");
|
|
226
|
+
const disagrees = nonEmpty.some((uses) => [...uses].sort().join(",") !== first);
|
|
227
|
+
if (disagrees) {
|
|
228
|
+
issues.push({
|
|
229
|
+
code: "X_TELO_REF_USE_CONFLICT",
|
|
230
|
+
manifest: definition,
|
|
231
|
+
path,
|
|
232
|
+
message:
|
|
233
|
+
`x-telo-ref branches at '${path}' declare disagreeing uses ` +
|
|
234
|
+
`(${nonEmpty.map((u) => u.join("|")).join(" vs ")}). 'use' is a property of the ` +
|
|
235
|
+
`slot, never of a branch — declare several acceptable kinds as one ` +
|
|
236
|
+
`'kind: [<kinds>]' list with one 'use'.`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return issues;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Manifest-level check: a `use` case map whose selector is written in CEL.
|
|
246
|
+
* Reads the built graph's `unresolvedReason`, so the detection lives once, in
|
|
247
|
+
* `resolveUseAtSite`, and this pass cannot disagree with what consumers saw. */
|
|
248
|
+
export function validateDynamicSelectors(
|
|
249
|
+
allManifests: ResourceManifest[],
|
|
250
|
+
registry: DefinitionRegistry,
|
|
251
|
+
aliases?: AliasResolver,
|
|
252
|
+
aliasesByModule?: Map<string, AliasResolver>,
|
|
253
|
+
graph?: CallGraph,
|
|
254
|
+
): RefSlotIssue[] {
|
|
255
|
+
const issues: RefSlotIssue[] = [];
|
|
256
|
+
const callGraph = graph ?? buildCallGraph(allManifests, registry, { aliases, aliasesByModule });
|
|
257
|
+
for (const edge of callGraph.edges) {
|
|
258
|
+
if (edge.unresolvedReason !== "dynamic") continue;
|
|
259
|
+
const from = callGraph.nodes.get(edge.from);
|
|
260
|
+
const owner =
|
|
261
|
+
from?.type === "step" ? callGraph.nodes.get(from.owner) : from;
|
|
262
|
+
if (!owner || owner.type !== "resource") continue;
|
|
263
|
+
// Anchor at the SELECTOR — the field the author must change — not at the
|
|
264
|
+
// ref slot several lines away. Derivable: the slot's enclosing path plus
|
|
265
|
+
// the pointer's segments.
|
|
266
|
+
const selectorPath = selectorPathOf(edge.path, edge.unresolved?.by ?? "");
|
|
267
|
+
issues.push({
|
|
268
|
+
code: "X_TELO_REF_DYNAMIC_SELECTOR",
|
|
269
|
+
manifest: owner.manifest,
|
|
270
|
+
path: selectorPath,
|
|
271
|
+
message:
|
|
272
|
+
`The mode selector at '${selectorPath}' is a CEL expression, so which 'use' holds ` +
|
|
273
|
+
`for the reference at '${edge.path}' cannot be resolved statically. The selector must ` +
|
|
274
|
+
`be a literal or take its schema default — a call graph known only at runtime is not ` +
|
|
275
|
+
`statically analyzable. Write the mode as a literal, or split the wiring into one ` +
|
|
276
|
+
`resource per mode.`,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return issues;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Concrete path of a case-map selector: the slot's enclosing path joined with
|
|
283
|
+
* the pointer's segments (`steps[0].invoke` + `/detach` → `steps[0].detach`). */
|
|
284
|
+
function selectorPathOf(slotPath: string, pointer: string): string {
|
|
285
|
+
const lastDot = slotPath.lastIndexOf(".");
|
|
286
|
+
const enclosing = lastDot < 0 ? "" : slotPath.slice(0, lastDot);
|
|
287
|
+
const segments = pointer
|
|
288
|
+
.replace(/^\//, "")
|
|
289
|
+
.split("/")
|
|
290
|
+
.map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"))
|
|
291
|
+
.join(".");
|
|
292
|
+
return enclosing ? `${enclosing}.${segments}` : segments;
|
|
293
|
+
}
|
|
@@ -40,7 +40,14 @@ function checkKind(
|
|
|
40
40
|
if (subtypeKinds.has(resolved)) return [];
|
|
41
41
|
if (targetDef.kind === "Telo.Abstract") {
|
|
42
42
|
if (subtypes.length === 0) return []; // partial context — no implementations loaded yet
|
|
43
|
-
|
|
43
|
+
// Suggest only what an author can actually wire: with abstract-extends-
|
|
44
|
+
// abstract real (Telo.Executable over Invocable/Runnable), the transitive
|
|
45
|
+
// subtype list contains abstracts, which are non-instantiable and would
|
|
46
|
+
// read as fixes that cannot work.
|
|
47
|
+
const concrete = subtypes
|
|
48
|
+
.filter((d) => d.kind !== "Telo.Abstract")
|
|
49
|
+
.map((d) => `${d.metadata.module}.${d.metadata.name}`);
|
|
50
|
+
const options = (concrete.length > 0 ? concrete : [...subtypeKinds]).join(", ");
|
|
44
51
|
errors.push(
|
|
45
52
|
`'${kind}' does not implement '${targetKind}' (known implementations: ${options})`,
|
|
46
53
|
);
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static validation of the two execution-zone annotations themselves — the
|
|
3
|
+
* strict half of the accessor split, mirroring `validate-ref-slots.ts`.
|
|
4
|
+
*
|
|
5
|
+
* `readProvidesZone` / `readRequiresZone` are deliberately lenient: they return
|
|
6
|
+
* `undefined` for anything they cannot read. Without this pass that leniency is
|
|
7
|
+
* silent in the worst possible direction, because the two annotations fail in
|
|
8
|
+
* OPPOSITE ways:
|
|
9
|
+
*
|
|
10
|
+
* - an unreadable **requires** annotation drops the requirement entirely, so a
|
|
11
|
+
* safety constraint the author wrote is never enforced — and the resource
|
|
12
|
+
* then throws `ERR_ZONE_REQUIRED` / `ERR_ZONE_ANNOTATION_MISSING` at
|
|
13
|
+
* dispatch. That is exactly the silent-non-enforcement `ZONE_PROVIDER_UNRESOLVED`
|
|
14
|
+
* exists to prevent, reached by a different route.
|
|
15
|
+
* - an unreadable **provides** annotation drops the discharge, so the pass
|
|
16
|
+
* reports `ZONE_REQUIREMENT_UNSATISFIED` on manifests that are correct.
|
|
17
|
+
*
|
|
18
|
+
* A third shape is worse than either: a `key` the analyzer skips but the kernel
|
|
19
|
+
* accepts (a pointer with no leading `/` — the kernel's walk splits on `/` and
|
|
20
|
+
* drops empty segments, so it resolves) makes the two halves disagree about what
|
|
21
|
+
* the manifest MEANS, which is the one outcome neither severity can express.
|
|
22
|
+
*
|
|
23
|
+
* Scoping follows `X_TELO_REF_UNRESOLVED`: reported only for definitions in the
|
|
24
|
+
* entry's own modules — a published dependency's slot is not the consumer's to
|
|
25
|
+
* fix.
|
|
26
|
+
*
|
|
27
|
+
* Browser-safe: no Node built-ins.
|
|
28
|
+
*/
|
|
29
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
30
|
+
|
|
31
|
+
export interface ZoneSlotIssue {
|
|
32
|
+
code: "ZONE_ANNOTATION_INVALID";
|
|
33
|
+
manifest: ResourceManifest;
|
|
34
|
+
/** Schema path of the annotated slot. */
|
|
35
|
+
path: string;
|
|
36
|
+
message: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PROVIDES = "x-telo-provides-zone";
|
|
40
|
+
const REQUIRES = "x-telo-requires-zone";
|
|
41
|
+
|
|
42
|
+
/** A self-relative JSON Pointer, the only correlation-key spelling both halves
|
|
43
|
+
* read identically. `""` (whole document) is meaningless as a key, so a
|
|
44
|
+
* pointer must name at least one segment. */
|
|
45
|
+
function isPointer(value: unknown): value is string {
|
|
46
|
+
return typeof value === "string" && value.startsWith("/") && value.length > 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function describe(value: unknown): string {
|
|
50
|
+
if (typeof value === "string") return `'${value}'`;
|
|
51
|
+
if (Array.isArray(value)) return `a list`;
|
|
52
|
+
if (value === null) return "null";
|
|
53
|
+
return typeof value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function checkProvides(
|
|
57
|
+
raw: unknown,
|
|
58
|
+
definition: ResourceManifest,
|
|
59
|
+
path: string,
|
|
60
|
+
issues: ZoneSlotIssue[],
|
|
61
|
+
): void {
|
|
62
|
+
if (raw === true) return;
|
|
63
|
+
if (isPointer(raw)) return;
|
|
64
|
+
issues.push({
|
|
65
|
+
code: "ZONE_ANNOTATION_INVALID",
|
|
66
|
+
manifest: definition,
|
|
67
|
+
path,
|
|
68
|
+
message:
|
|
69
|
+
`${PROVIDES} at '${path}' is ${describe(raw)}. It takes 'true' (the zone is ` +
|
|
70
|
+
`uncorrelated) or a self-relative JSON Pointer naming this kind's own field ` +
|
|
71
|
+
`whose resolved reference the zone carries as its correlation payload ` +
|
|
72
|
+
`(e.g. '/connection'). It never names the zone — the zone a slot provides ` +
|
|
73
|
+
`is always the declaring kind.`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function checkRequires(
|
|
78
|
+
raw: unknown,
|
|
79
|
+
definition: ResourceManifest,
|
|
80
|
+
path: string,
|
|
81
|
+
issues: ZoneSlotIssue[],
|
|
82
|
+
): void {
|
|
83
|
+
const fail = (message: string): void => {
|
|
84
|
+
issues.push({ code: "ZONE_ANNOTATION_INVALID", manifest: definition, path, message });
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Bare-string form: the zone kind, uncorrelated.
|
|
88
|
+
if (typeof raw === "string") {
|
|
89
|
+
if (!raw) fail(`${REQUIRES} at '${path}' is an empty string; name the providing kind.`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
93
|
+
fail(
|
|
94
|
+
`${REQUIRES} at '${path}' is ${describe(raw)}. It takes an alias-qualified kind name ` +
|
|
95
|
+
`(e.g. 'Self.Transaction') or an object with 'zone', an optional 'key' and an ` +
|
|
96
|
+
`optional 'reason'.`,
|
|
97
|
+
);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const obj = raw as Record<string, unknown>;
|
|
102
|
+
if (typeof obj.zone !== "string" || !obj.zone) {
|
|
103
|
+
fail(
|
|
104
|
+
`${REQUIRES} at '${path}' declares no 'zone'. Name the providing kind with the same ` +
|
|
105
|
+
`alias-qualified grammar 'extends' and 'x-telo-ref' use — '<Alias>.<Kind>', ` +
|
|
106
|
+
`'Self.<Kind>', or 'Telo.<Kind>'. Without it the requirement is silently ` +
|
|
107
|
+
`unenforced, and the resource throws at dispatch instead.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (obj.key !== undefined) {
|
|
112
|
+
const pointers = Array.isArray(obj.key) ? obj.key : [obj.key];
|
|
113
|
+
if (Array.isArray(obj.key) && obj.key.length === 0) {
|
|
114
|
+
fail(`${REQUIRES} at '${path}' declares an empty 'key' list; omit 'key' instead.`);
|
|
115
|
+
}
|
|
116
|
+
for (const pointer of pointers) {
|
|
117
|
+
if (isPointer(pointer)) continue;
|
|
118
|
+
fail(
|
|
119
|
+
`${REQUIRES} at '${path}' declares the correlation key ${describe(pointer)}, which is ` +
|
|
120
|
+
`not a self-relative JSON Pointer. Write '/connection' (or a list of pointers tried ` +
|
|
121
|
+
`in order, first hit winning). A bare field name is read as a pointer by the runtime ` +
|
|
122
|
+
`but skipped by the checker, so the two halves would disagree about what this ` +
|
|
123
|
+
`manifest means.`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (obj.reason !== undefined && typeof obj.reason !== "string") {
|
|
129
|
+
fail(`${REQUIRES} at '${path}' declares a non-string 'reason'.`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const key of Object.keys(obj)) {
|
|
133
|
+
if (key === "zone" || key === "key" || key === "reason") {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
fail(
|
|
137
|
+
`${REQUIRES} at '${path}' declares an unknown property '${key}'. The object form takes ` +
|
|
138
|
+
`'zone', 'key' and 'reason'.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Walk a definition schema, reporting every zone annotation it cannot read.
|
|
144
|
+
* Pure-schema walk, so it needs a visited guard for cyclic `$defs`. */
|
|
145
|
+
function walkSchema(
|
|
146
|
+
node: unknown,
|
|
147
|
+
path: string,
|
|
148
|
+
visited: Set<object>,
|
|
149
|
+
definition: ResourceManifest,
|
|
150
|
+
issues: ZoneSlotIssue[],
|
|
151
|
+
): void {
|
|
152
|
+
if (!node || typeof node !== "object") return;
|
|
153
|
+
if (visited.has(node as object)) return;
|
|
154
|
+
visited.add(node as object);
|
|
155
|
+
if (Array.isArray(node)) {
|
|
156
|
+
node.forEach((item, i) => walkSchema(item, `${path}[${i}]`, visited, definition, issues));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const obj = node as Record<string, unknown>;
|
|
160
|
+
if (obj[PROVIDES] !== undefined) checkProvides(obj[PROVIDES], definition, path, issues);
|
|
161
|
+
if (obj[REQUIRES] !== undefined) checkRequires(obj[REQUIRES], definition, path, issues);
|
|
162
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
163
|
+
if (key === PROVIDES || key === REQUIRES || key === "examples" || key === "default") continue;
|
|
164
|
+
walkSchema(value, path ? `${path}.${key}` : key, visited, definition, issues);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Schema-level zone-annotation checks over one definition/abstract manifest. */
|
|
169
|
+
export function validateZoneSlotDeclarations(definition: ResourceManifest): ZoneSlotIssue[] {
|
|
170
|
+
const issues: ZoneSlotIssue[] = [];
|
|
171
|
+
const schema = (definition as Record<string, unknown>).schema;
|
|
172
|
+
if (!schema || typeof schema !== "object") return issues;
|
|
173
|
+
walkSchema(schema, "schema", new Set(), definition, issues);
|
|
174
|
+
return issues;
|
|
175
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One imported library's FULL document set, for the zone stage's per-library
|
|
5
|
+
* export derivation — what the flattened analysis view no longer holds, since
|
|
6
|
+
* it forwards only each library's export surface and never its internal
|
|
7
|
+
* dispatch chain.
|
|
8
|
+
*
|
|
9
|
+
* Plain data in a module of its own, deliberately. It is produced by the
|
|
10
|
+
* loading side (`collectZoneModuleDocuments`), named in `AnalysisOptions`, and
|
|
11
|
+
* consumed by the projection; putting it in any of the three would make the
|
|
12
|
+
* other two import that one, and `types.ts` ↔ the projection is a genuine
|
|
13
|
+
* cycle. A leaf module with no imports of its own breaks it without an inline
|
|
14
|
+
* `import(...)` type expression standing in for the dependency nobody wanted.
|
|
15
|
+
*/
|
|
16
|
+
export interface ZoneModuleDocuments {
|
|
17
|
+
/** The library's module name (its `Telo.Library` doc's `metadata.name`). */
|
|
18
|
+
module: string;
|
|
19
|
+
/** Stable source identity of the library's owner file — the cache key. */
|
|
20
|
+
sourceId: string;
|
|
21
|
+
/** Owner + partial manifests, stamped with `metadata.source` / `.module`. */
|
|
22
|
+
manifests: ResourceManifest[];
|
|
23
|
+
/** Precomputed content signature; derived from the documents when absent. */
|
|
24
|
+
signature?: string;
|
|
25
|
+
/** The library's declared `exports.resources` entries (bare names). */
|
|
26
|
+
exportedNames: readonly string[];
|
|
27
|
+
}
|
package/src/zone-slot.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single reader of the two execution-zone annotations —
|
|
3
|
+
* `x-telo-provides-zone` and `x-telo-requires-zone` (see
|
|
4
|
+
* `kernel/specs/execution-zones.md`). The analyzer's zone projection, the
|
|
5
|
+
* kernel's `withZone` / `requireZone`, and any editor surface all recognise a
|
|
6
|
+
* zone slot here and nowhere else, the same one-accessor rule `ref-slot.ts`
|
|
7
|
+
* established for `x-telo-ref`. Browser-safe: no Node built-ins.
|
|
8
|
+
*
|
|
9
|
+
* Accepted shapes:
|
|
10
|
+
*
|
|
11
|
+
* x-telo-provides-zone: true # uncorrelated — the zone is the kind
|
|
12
|
+
* x-telo-provides-zone: /connection # correlation-key pointer (own field)
|
|
13
|
+
*
|
|
14
|
+
* x-telo-requires-zone: Self.Transaction # uncorrelated string form
|
|
15
|
+
* x-telo-requires-zone: # object form
|
|
16
|
+
* zone: Self.Transaction
|
|
17
|
+
* key: [/connection, /transaction/connection] # ordered, first hit wins
|
|
18
|
+
* reason: the statement would execute outside any transaction
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const PROVIDES = "x-telo-provides-zone";
|
|
22
|
+
const REQUIRES = "x-telo-requires-zone";
|
|
23
|
+
|
|
24
|
+
/** A body slot that establishes the declaring kind's zone when dispatched
|
|
25
|
+
* through. The zone's identity is always the declaring kind — the annotation
|
|
26
|
+
* never names one, so provision-on-behalf-of is unrepresentable. */
|
|
27
|
+
export interface ProvidesZoneSlot {
|
|
28
|
+
/** Self-relative JSON pointer to the declaring kind's own field whose resolved
|
|
29
|
+
* reference the zone carries as its correlation payload. Absent =
|
|
30
|
+
* uncorrelated (`true`). */
|
|
31
|
+
key?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A field declaring that its resource must be reached through a zone. */
|
|
35
|
+
export interface RequiresZoneSlot {
|
|
36
|
+
/** The providing kind, alias-qualified as authored (`Self.Transaction`,
|
|
37
|
+
* `<Alias>.<Kind>`) — canonical `<module>.<Kind>` once
|
|
38
|
+
* `resolveSchemaRefKinds` has rewritten it in the declaring scope. */
|
|
39
|
+
zone: string;
|
|
40
|
+
/** Ordered self-relative JSON pointers tried in order, first hit winning; a
|
|
41
|
+
* pointer may traverse a `!ref` into the referenced resource's own field.
|
|
42
|
+
* Empty = uncorrelated. */
|
|
43
|
+
key: string[];
|
|
44
|
+
/** The runtime consequence, quoted after the path in diagnostics. */
|
|
45
|
+
reason?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A self-relative JSON Pointer — the only correlation-key spelling the
|
|
49
|
+
* analyzer and the kernel read identically. Applied to BOTH the scalar and the
|
|
50
|
+
* list form: the kernel's walk splits on `/` and drops empty segments, so a
|
|
51
|
+
* bare `connection` would resolve there while the checker skipped it, and the
|
|
52
|
+
* two halves would disagree about what the manifest means. `validate-zone-slots`
|
|
53
|
+
* reports what this rejects. */
|
|
54
|
+
function isPointer(value: unknown): value is string {
|
|
55
|
+
return typeof value === "string" && value.startsWith("/") && value.length > 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Reads a schema node's provides-zone declaration, or undefined when it has
|
|
59
|
+
* none or the value is malformed (`validate-zone-slots` reports those). */
|
|
60
|
+
export function readProvidesZone(node: Record<string, any> | undefined): ProvidesZoneSlot | undefined {
|
|
61
|
+
const raw = node?.[PROVIDES];
|
|
62
|
+
if (raw === true) return {};
|
|
63
|
+
if (isPointer(raw)) return { key: raw };
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True when the node carries `x-telo-provides-zone` in any shape, valid or not
|
|
68
|
+
* — the recognition test validation needs before it judges the value. */
|
|
69
|
+
export function hasProvidesZone(node: Record<string, any> | undefined): boolean {
|
|
70
|
+
return node?.[PROVIDES] !== undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Reads a schema node's requires-zone declaration, or undefined when it has
|
|
74
|
+
* none or the value is malformed (`validate-zone-slots` reports those). */
|
|
75
|
+
export function readRequiresZone(node: Record<string, any> | undefined): RequiresZoneSlot | undefined {
|
|
76
|
+
const raw = node?.[REQUIRES];
|
|
77
|
+
if (typeof raw === "string" && raw) return { zone: raw, key: [] };
|
|
78
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
79
|
+
const obj = raw as Record<string, unknown>;
|
|
80
|
+
if (typeof obj.zone !== "string" || !obj.zone) return undefined;
|
|
81
|
+
// One filter for both spellings — see `isPointer`.
|
|
82
|
+
const key = (Array.isArray(obj.key) ? obj.key : [obj.key]).filter(isPointer);
|
|
83
|
+
const slot: RequiresZoneSlot = { zone: obj.zone, key };
|
|
84
|
+
if (typeof obj.reason === "string") slot.reason = obj.reason;
|
|
85
|
+
return slot;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** True when the node carries `x-telo-requires-zone` in any shape. */
|
|
89
|
+
export function hasRequiresZone(node: Record<string, any> | undefined): boolean {
|
|
90
|
+
return node?.[REQUIRES] !== undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Rewrites the requires-zone kind name in place, in whichever shape it is
|
|
95
|
+
* written — the write-side twin of {@link readRequiresZone}, mirroring
|
|
96
|
+
* `rewriteRefSlotKinds` so `resolveSchemaRefKinds` canonicalizes both
|
|
97
|
+
* annotations in one walk. `map` returns the replacement or `undefined` to
|
|
98
|
+
* leave the authored name untouched (idempotence + quotable diagnostics).
|
|
99
|
+
*/
|
|
100
|
+
export function rewriteRequiresZoneKind(
|
|
101
|
+
annotationHolder: Record<string, any>,
|
|
102
|
+
map: (kind: string) => string | undefined,
|
|
103
|
+
): void {
|
|
104
|
+
const raw = annotationHolder[REQUIRES];
|
|
105
|
+
if (typeof raw === "string") {
|
|
106
|
+
const next = map(raw);
|
|
107
|
+
if (next !== undefined) annotationHolder[REQUIRES] = next;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
|
|
111
|
+
const obj = raw as Record<string, unknown>;
|
|
112
|
+
if (typeof obj.zone === "string") {
|
|
113
|
+
const next = map(obj.zone);
|
|
114
|
+
if (next !== undefined) obj.zone = next;
|
|
115
|
+
}
|
|
116
|
+
}
|