@telorun/analyzer 0.65.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analyzer.d.ts +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +157 -63
- package/dist/call-graph.d.ts.map +1 -1
- package/dist/call-graph.js +7 -1
- package/dist/cel-environment.d.ts +12 -0
- package/dist/cel-environment.d.ts.map +1 -1
- package/dist/cel-environment.js +35 -19
- package/dist/cel-scope.d.ts.map +1 -1
- package/dist/cel-scope.js +24 -4
- package/dist/definition-registry.d.ts +24 -0
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +41 -0
- package/dist/eval-paths.d.ts +68 -0
- package/dist/eval-paths.d.ts.map +1 -1
- package/dist/eval-paths.js +138 -0
- package/dist/flatten-for-analyzer.d.ts +1 -1
- package/dist/flatten-for-analyzer.js +1 -1
- package/dist/import-resolution-diagnostics.d.ts +1 -1
- package/dist/import-resolution-diagnostics.js +1 -1
- package/dist/index.d.ts +9 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -5
- package/dist/manifest-visitor.d.ts.map +1 -1
- package/dist/manifest-visitor.js +8 -1
- package/dist/migrations/entries/index.d.ts.map +1 -1
- package/dist/migrations/entries/index.js +2 -0
- package/dist/migrations/entries/schema-prepare-bucket.json +27 -0
- package/dist/peer-binding.d.ts +232 -0
- package/dist/peer-binding.d.ts.map +1 -0
- package/dist/peer-binding.js +418 -0
- package/dist/ref-slot.d.ts +13 -0
- package/dist/ref-slot.d.ts.map +1 -1
- package/dist/ref-slot.js +26 -0
- package/dist/reference-field-map.d.ts +28 -0
- package/dist/reference-field-map.d.ts.map +1 -1
- package/dist/reference-field-map.js +25 -0
- package/dist/referrer-rule.d.ts +17 -0
- package/dist/referrer-rule.d.ts.map +1 -1
- package/dist/referrer-rule.js +4 -1
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.js +1 -1
- package/dist/resource-rule.d.ts +40 -27
- package/dist/resource-rule.d.ts.map +1 -1
- package/dist/resource-rule.js +61 -7
- package/dist/rule-condition.d.ts +11 -0
- package/dist/rule-condition.d.ts.map +1 -1
- package/dist/rule-condition.js +13 -0
- package/dist/schema-projection.d.ts +106 -4
- package/dist/schema-projection.d.ts.map +1 -1
- package/dist/schema-projection.js +206 -18
- package/dist/telo-version.d.ts +1 -1
- package/dist/telo-version.js +1 -1
- package/dist/validate-cel-context.d.ts +1 -17
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +5 -70
- package/dist/validate-reference-forms.d.ts +10 -0
- package/dist/validate-reference-forms.d.ts.map +1 -1
- package/dist/validate-reference-forms.js +23 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +9 -2
- package/dist/validate-referrer-rules.d.ts +38 -4
- package/dist/validate-referrer-rules.d.ts.map +1 -1
- package/dist/validate-referrer-rules.js +139 -31
- package/dist/validate-resource-rules.d.ts +11 -2
- package/dist/validate-resource-rules.d.ts.map +1 -1
- package/dist/validate-resource-rules.js +18 -12
- package/dist/validate-schema-projection.d.ts.map +1 -1
- package/dist/validate-schema-projection.js +33 -2
- package/dist/with-synthetic-positions.d.ts +1 -1
- package/dist/with-synthetic-positions.js +1 -1
- package/package.json +3 -3
- package/src/analyzer.ts +191 -68
- package/src/call-graph.ts +7 -1
- package/src/cel-environment.ts +48 -19
- package/src/cel-scope.ts +24 -6
- package/src/definition-registry.ts +42 -0
- package/src/eval-paths.ts +152 -0
- package/src/flatten-for-analyzer.ts +1 -1
- package/src/import-resolution-diagnostics.ts +1 -1
- package/src/index.ts +45 -4
- package/src/manifest-visitor.ts +8 -1
- package/src/migrations/entries/index.ts +2 -0
- package/src/migrations/entries/schema-prepare-bucket.json +27 -0
- package/src/peer-binding.ts +641 -0
- package/src/ref-slot.ts +36 -0
- package/src/reference-field-map.ts +42 -0
- package/src/referrer-rule.ts +20 -1
- package/src/release/index.ts +1 -1
- package/src/resource-rule.ts +69 -7
- package/src/rule-condition.ts +15 -0
- package/src/schema-projection.ts +283 -20
- package/src/telo-version.ts +1 -1
- package/src/validate-cel-context.ts +6 -71
- package/src/validate-reference-forms.ts +22 -0
- package/src/validate-references.ts +15 -2
- package/src/validate-referrer-rules.ts +215 -28
- package/src/validate-resource-rules.ts +26 -12
- package/src/validate-schema-projection.ts +50 -1
- package/src/with-synthetic-positions.ts +1 -1
|
@@ -19,6 +19,10 @@ export interface RefFieldEntry {
|
|
|
19
19
|
inputs?: string;
|
|
20
20
|
/** True when the field path traversed through at least one array (path contains "[]"). */
|
|
21
21
|
isArray: boolean;
|
|
22
|
+
/** The slot's non-reference branches, when the reference constraint is a
|
|
23
|
+
* branch of a union — see {@link RefSlot.valueBranches}. A value satisfying
|
|
24
|
+
* one of these is a value, not a malformed reference. */
|
|
25
|
+
valueBranches?: Record<string, any>[];
|
|
22
26
|
/** x-telo-context schema declared on this ref slot, if any. Describes the CEL invocation
|
|
23
27
|
* context available to resources placed in this slot. */
|
|
24
28
|
context?: Record<string, any>;
|
|
@@ -62,6 +66,43 @@ export function isRefEntry(entry: FieldMapEntry): entry is RefFieldEntry {
|
|
|
62
66
|
return "refs" in entry;
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
/** The half of a definition registry this question needs — structural, so the
|
|
70
|
+
* field map keeps depending on nothing. */
|
|
71
|
+
export interface ValueBranchValidator {
|
|
72
|
+
schemaCompileError(schema: Record<string, any>): string | undefined;
|
|
73
|
+
validateWithRefs(data: unknown, schema: Record<string, any>): string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* True when a value at a ref slot satisfies one of the slot's VALUE branches —
|
|
78
|
+
* a storage class beside a `!ref`, so it is a value and not a malformed
|
|
79
|
+
* reference.
|
|
80
|
+
*
|
|
81
|
+
* One implementation, because BOTH reference passes have to narrow the same way:
|
|
82
|
+
* `validateReferenceForms` would otherwise call it a removed string reference,
|
|
83
|
+
* and `validateReferences` a reference missing `kind` and `name`. Two copies of
|
|
84
|
+
* the rule would eventually disagree about which of the two reported a value.
|
|
85
|
+
*
|
|
86
|
+
* A branch AJV cannot COMPILE is not a branch the value satisfies.
|
|
87
|
+
* `validateWithRefs` returns no issues for one — it swallows the compile failure
|
|
88
|
+
* by design, so one bad schema does not abort the pass — and reading that as
|
|
89
|
+
* "no issues, therefore a value" would switch the reference-form rule off for
|
|
90
|
+
* the slot silently. The uncompilable schema is reported on its own definition
|
|
91
|
+
* by `schemaCompileError`.
|
|
92
|
+
*/
|
|
93
|
+
export function satisfiesValueBranch(
|
|
94
|
+
value: unknown,
|
|
95
|
+
branches: readonly Record<string, any>[] | undefined,
|
|
96
|
+
registry: ValueBranchValidator,
|
|
97
|
+
): boolean {
|
|
98
|
+
if (!branches?.length) return false;
|
|
99
|
+
return branches.some(
|
|
100
|
+
(branch) =>
|
|
101
|
+
registry.schemaCompileError(branch) === undefined &&
|
|
102
|
+
registry.validateWithRefs(value, branch).length === 0,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
65
106
|
export function isScopeEntry(entry: FieldMapEntry): entry is ScopeFieldEntry {
|
|
66
107
|
return "scope" in entry;
|
|
67
108
|
}
|
|
@@ -234,6 +275,7 @@ function traverseNode(
|
|
|
234
275
|
};
|
|
235
276
|
if (slot.useCases) entry.useCases = slot.useCases;
|
|
236
277
|
if (slot.inputs !== undefined) entry.inputs = slot.inputs;
|
|
278
|
+
if (slot.valueBranches.length > 0) entry.valueBranches = slot.valueBranches;
|
|
237
279
|
if (node["x-telo-context"]) entry.context = node["x-telo-context"] as Record<string, any>;
|
|
238
280
|
if (slot.inline) entry.inline = true;
|
|
239
281
|
map.set(path, entry);
|
package/src/referrer-rule.ts
CHANGED
|
@@ -37,6 +37,23 @@ export interface ReferrerRule {
|
|
|
37
37
|
* relation the rule is about, so a kind should write it.
|
|
38
38
|
*/
|
|
39
39
|
readonly referrer?: string;
|
|
40
|
+
/**
|
|
41
|
+
* JSON Pointer to a collection OF THE REFERRER to resolve — the binding that
|
|
42
|
+
* lets a rule state a relation between SIBLING declarations, which neither a
|
|
43
|
+
* resource rule (one resource) nor a plain referrer rule (a pair joined by one
|
|
44
|
+
* reference) can reach.
|
|
45
|
+
*
|
|
46
|
+
* Declaring it binds `peers` (the collection's OTHER entries) and `entry` (my
|
|
47
|
+
* own), and makes the rule evaluate once per entry rather than once per
|
|
48
|
+
* referrer: a rule reading `entry` is about the entry, and a resource listed
|
|
49
|
+
* twice has two entries to answer for.
|
|
50
|
+
*
|
|
51
|
+
* Entries bind AS WRITTEN with the references *inside them* resolved one level
|
|
52
|
+
* — `p` is the declaration where the entry is a bare `!ref`, `p.mount` is the
|
|
53
|
+
* declaration with `p.prefix` beside it where it is not. Nothing is guessed
|
|
54
|
+
* from the item schema and there is no second pointer to write.
|
|
55
|
+
*/
|
|
56
|
+
readonly peers?: string;
|
|
40
57
|
/** CEL source. TRUE when the rule holds. */
|
|
41
58
|
readonly condition: string;
|
|
42
59
|
/** The rule's own name, carried in `data.rule`. Never a diagnostic code —
|
|
@@ -68,15 +85,17 @@ export function readReferrerRules(schema: unknown): ReferrerRule[] {
|
|
|
68
85
|
raw.forEach((entry, index) => {
|
|
69
86
|
if (!isObject(entry)) return;
|
|
70
87
|
const condition = celSourceOf(entry.condition);
|
|
71
|
-
const { code, message, referrer } = entry;
|
|
88
|
+
const { code, message, referrer, peers } = entry;
|
|
72
89
|
if (!condition || typeof code !== "string" || typeof message !== "string") return;
|
|
73
90
|
if (code.length === 0 || message.length === 0) return;
|
|
74
91
|
if (referrer !== undefined && typeof referrer !== "string") return;
|
|
92
|
+
if (peers !== undefined && typeof peers !== "string") return;
|
|
75
93
|
if (entry.severity !== undefined && entry.severity !== "warning" && entry.severity !== "error") {
|
|
76
94
|
return;
|
|
77
95
|
}
|
|
78
96
|
rules.push({
|
|
79
97
|
...(referrer === undefined ? {} : { referrer }),
|
|
98
|
+
...(peers === undefined ? {} : { peers }),
|
|
80
99
|
condition,
|
|
81
100
|
code,
|
|
82
101
|
message,
|
package/src/release/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The release model: module identity, fragments, the ledger, the edge graph,
|
|
3
3
|
* level propagation and version planning.
|
|
4
4
|
*
|
|
5
|
-
* Browser-safe by construction — pure data in, plan out — so
|
|
5
|
+
* Browser-safe by construction — pure data in, plan out — so telo studio can
|
|
6
6
|
* answer "what does changing this library bump?" from the same model the CLI
|
|
7
7
|
* releases from. Everything Node-shaped (finding the workspace, running the
|
|
8
8
|
* controller builder, building payloads, reading git, writing files) is
|
package/src/resource-rule.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*
|
|
22
22
|
* Browser-safe: no Node built-ins.
|
|
23
23
|
*/
|
|
24
|
+
import { CEL_ENGINE, isRefSentinel, isTaggedSentinel } from "@telorun/templating";
|
|
24
25
|
|
|
25
26
|
export const RESOURCE_RULES_ANNOTATION = "x-telo-resource-rules";
|
|
26
27
|
|
|
@@ -58,9 +59,42 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|
|
58
59
|
* Both markers are tested because they are not always both present: a
|
|
59
60
|
* registered definition's schema reaches the analyzer with `call` and
|
|
60
61
|
* `__compiled` dropped, keeping only `__tagged` + `source`. Testing one would
|
|
61
|
-
* make a rule readable on some paths and invisible on others.
|
|
62
|
+
* make a rule readable on some paths and invisible on others.
|
|
63
|
+
*
|
|
64
|
+
* A tagged sentinel of ANOTHER engine is not one. `__tagged` marks every tag
|
|
65
|
+
* the loader parses — `!ref` above all — so testing it alone read a reference
|
|
66
|
+
* as an expression: a column whose `type:` holds a `!ref` skipped every rule
|
|
67
|
+
* that touched `self.columns`, and said "the value holds a CEL expression" about
|
|
68
|
+
* a manifest containing none. A reference names a declaration and is a
|
|
69
|
+
* perfectly comparable value; what a rule cannot compare is a value COMPUTED at
|
|
70
|
+
* create time, which is what this predicate exists to find. */
|
|
62
71
|
function isCelNode(value: unknown): value is { source?: unknown } {
|
|
63
|
-
|
|
72
|
+
if (!isObject(value)) return false;
|
|
73
|
+
if (value.__compiled === true) return true;
|
|
74
|
+
return value.__tagged === true && value.engine === CEL_ENGINE;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The engine of a non-CEL tagged sentinel — a `!ref`, an `!include-*` — or
|
|
78
|
+
* `undefined`. A reference is comparable and never blocks a rule; the other
|
|
79
|
+
* tags hold a value only known once the resource is created, so they do, and
|
|
80
|
+
* the diagnostic has to name the tag rather than claim CEL. */
|
|
81
|
+
export function deferredTagOf(value: unknown): string | undefined {
|
|
82
|
+
if (!isTaggedSentinel(value) || isRefSentinel(value)) return undefined;
|
|
83
|
+
return value.engine === CEL_ENGINE ? undefined : value.engine;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* True when a condition was written with the `!cel` tag.
|
|
88
|
+
*
|
|
89
|
+
* The readers stay lenient and take a bare string — a rule still runs either
|
|
90
|
+
* way. What an untagged condition loses is everything outside evaluation: to the
|
|
91
|
+
* editor's colouring, completion and hover it is a plain string, so a rule author
|
|
92
|
+
* writes CEL with no help and gets none of the checks a `!cel` scalar gets.
|
|
93
|
+
* Losing that silently is exactly what a strict half exists to move earlier, so
|
|
94
|
+
* the tag is reported by the strict halves and never enforced by the readers.
|
|
95
|
+
*/
|
|
96
|
+
export function isTaggedCondition(value: unknown): boolean {
|
|
97
|
+
return isCelNode(value);
|
|
64
98
|
}
|
|
65
99
|
|
|
66
100
|
/** A precompiled `!cel` node keeps its author-written text on `source`; a plain
|
|
@@ -179,17 +213,45 @@ export function resolveRuleSubjects(
|
|
|
179
213
|
return undefined;
|
|
180
214
|
}
|
|
181
215
|
|
|
216
|
+
/** The first leaf a rule cannot compare, and what it is. `what` is a noun
|
|
217
|
+
* phrase the diagnostic quotes verbatim, because "a CEL expression" printed
|
|
218
|
+
* over an `!include-bytes` embed sends its author looking for an expression
|
|
219
|
+
* that is not there. */
|
|
220
|
+
export interface DynamicLeaf {
|
|
221
|
+
readonly path: string;
|
|
222
|
+
readonly what: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Classify ONE node, without descending. Exported because a caller that draws
|
|
226
|
+
* its own bound on how far to look (`peer-binding`'s top-level-scalar scan)
|
|
227
|
+
* must classify by the same rule as the recursive walk, or a `!ref` is a
|
|
228
|
+
* reference to one of them and an expression to the other. */
|
|
229
|
+
export function dynamicNode(value: unknown, path: string): DynamicLeaf | undefined {
|
|
230
|
+
const at = path || "(value)";
|
|
231
|
+
if (isCelNode(value)) return { path: at, what: "a CEL expression" };
|
|
232
|
+
const tag = deferredTagOf(value);
|
|
233
|
+
return tag ? { path: at, what: `an !${tag} embed` } : undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
182
236
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* the subject is skipped — and the
|
|
237
|
+
* The first leaf inside a value whose contents are not known until the resource
|
|
238
|
+
* is created, or `undefined` when every leaf is literal. A rule reading one
|
|
239
|
+
* would be comparing against a placeholder, so the subject is skipped — and the
|
|
240
|
+
* skip is reported, never silent.
|
|
241
|
+
*
|
|
242
|
+
* A `!ref` is NOT one of them. It is a tagged sentinel like `!cel`, and testing
|
|
243
|
+
* `__tagged` alone read every reference as an expression: a column whose `type:`
|
|
244
|
+
* holds a `!ref` switched off every rule touching `self.columns` and reported a
|
|
245
|
+
* CEL expression in a manifest containing none. A reference names a declaration
|
|
246
|
+
* — a value a rule compares perfectly well, and the one peer rules are built on.
|
|
186
247
|
*
|
|
187
248
|
* Stops at nested inline `{ kind }` declarations for the reason every other walk
|
|
188
249
|
* does: that CEL belongs to the nested kind, evaluated in its own scope.
|
|
189
250
|
*/
|
|
190
|
-
export function findDynamicLeaf(value: unknown, base = ""):
|
|
251
|
+
export function findDynamicLeaf(value: unknown, base = ""): DynamicLeaf | undefined {
|
|
191
252
|
if (isObject(value)) {
|
|
192
|
-
|
|
253
|
+
const own = dynamicNode(value, base);
|
|
254
|
+
if (own) return own;
|
|
193
255
|
if (typeof value.kind === "string" && base !== "") return undefined;
|
|
194
256
|
for (const [key, child] of Object.entries(value)) {
|
|
195
257
|
const found = findDynamicLeaf(child, base === "" ? key : `${base}.${key}`);
|
package/src/rule-condition.ts
CHANGED
|
@@ -32,6 +32,21 @@ import {
|
|
|
32
32
|
*/
|
|
33
33
|
export const RULE_BUDGET_MS = 50;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The one message for an untagged `condition:`, shared by every rule family so
|
|
37
|
+
* all of them say the same thing about the same defect.
|
|
38
|
+
*
|
|
39
|
+
* The readers stay lenient and a bare string still runs. What it loses is
|
|
40
|
+
* everything outside evaluation — to the editor's colouring, completion and
|
|
41
|
+
* hover an untagged condition is a plain string, so its author writes CEL with
|
|
42
|
+
* no help and none of the checks a `!cel` scalar gets. Losing that silently is
|
|
43
|
+
* the failure a strict half exists to move earlier.
|
|
44
|
+
*/
|
|
45
|
+
export const UNTAGGED_CONDITION =
|
|
46
|
+
"Write 'condition' with the !cel tag. The reader is lenient and a bare string still " +
|
|
47
|
+
"runs, but untagged the expression is not CEL to the editor's colouring, completion " +
|
|
48
|
+
"or hover, so a rule silently stops being CEL to every surface but this one.";
|
|
49
|
+
|
|
35
50
|
const HOST_BACKED = new Set(CEL_FUNCTIONS.filter((f) => f.hostBacked).map((f) => f.name));
|
|
36
51
|
const NON_DETERMINISTIC = new Set(
|
|
37
52
|
CEL_FUNCTIONS.filter((f) => !f.deterministic).map((f) => f.name),
|
package/src/schema-projection.ts
CHANGED
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
* derivation can reach.
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
+
import { isRefSentinel } from "@telorun/templating";
|
|
39
|
+
|
|
38
40
|
/** How a kind's entry collection projects to an object schema. */
|
|
39
41
|
export interface SchemaProjection {
|
|
40
42
|
/** JSON Pointer, from the resource root, to the entries. */
|
|
@@ -48,6 +50,33 @@ export interface SchemaProjection {
|
|
|
48
50
|
readonly nullable?: string;
|
|
49
51
|
/** Entry field that wraps the mapped node in an array. */
|
|
50
52
|
readonly array?: string;
|
|
53
|
+
/**
|
|
54
|
+
* How an entry whose keyed field holds a REFERENCE projects.
|
|
55
|
+
*
|
|
56
|
+
* The map is keyed on the field's VALUE, and a reference is not a key, so a
|
|
57
|
+
* `type:` holding one falls through to this path. It is declared as data by the
|
|
58
|
+
* backend, which is what keeps the analyzer from learning that an enum exists:
|
|
59
|
+
* `from` names the field of the target declaration to read, `keyword` the
|
|
60
|
+
* schema keyword its values become, and `base` / `baseFrom` where the node's
|
|
61
|
+
* own type comes from — a literal for an engine whose named type IS its own
|
|
62
|
+
* base, a field of the target for one that declares a storage class.
|
|
63
|
+
*
|
|
64
|
+
* A backend that declares none projects exactly as it did before.
|
|
65
|
+
*/
|
|
66
|
+
readonly reference?: ProjectionReference;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The reference path of a projection — see {@link SchemaProjection.reference}. */
|
|
70
|
+
export interface ProjectionReference {
|
|
71
|
+
/** Field of the TARGET declaration whose value the keyword takes. */
|
|
72
|
+
readonly from: string;
|
|
73
|
+
/** The JSON Schema keyword those values become (`enum`). */
|
|
74
|
+
readonly keyword: string;
|
|
75
|
+
/** The node the keyword is added to, written literally. */
|
|
76
|
+
readonly base?: Record<string, unknown>;
|
|
77
|
+
/** Field of the target declaration naming a value in the kind's own
|
|
78
|
+
* `x-telo-schema-map`, whose mapped node is the base. */
|
|
79
|
+
readonly baseFrom?: string;
|
|
51
80
|
}
|
|
52
81
|
|
|
53
82
|
export type SchemaMap = Readonly<Record<string, Record<string, unknown>>>;
|
|
@@ -71,6 +100,19 @@ export function readSchemaProjection(definition: unknown): SchemaProjection | un
|
|
|
71
100
|
nameField: typeof raw.name === "string" ? raw.name : undefined,
|
|
72
101
|
nullable: typeof raw.nullable === "string" ? raw.nullable : undefined,
|
|
73
102
|
array: typeof raw.array === "string" ? raw.array : undefined,
|
|
103
|
+
reference: readProjectionReference(raw.reference),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readProjectionReference(raw: unknown): ProjectionReference | undefined {
|
|
108
|
+
if (!isObject(raw)) return undefined;
|
|
109
|
+
const { from, keyword, base, baseFrom } = raw;
|
|
110
|
+
if (typeof from !== "string" || typeof keyword !== "string") return undefined;
|
|
111
|
+
return {
|
|
112
|
+
from,
|
|
113
|
+
keyword,
|
|
114
|
+
base: isObject(base) ? (base as Record<string, unknown>) : undefined,
|
|
115
|
+
baseFrom: typeof baseFrom === "string" ? baseFrom : undefined,
|
|
74
116
|
};
|
|
75
117
|
}
|
|
76
118
|
|
|
@@ -94,7 +136,35 @@ export function schemaProjectionIsMisplaced(definition: unknown): boolean {
|
|
|
94
136
|
return isObject(schema) && schema["x-telo-schema-projection"] !== undefined;
|
|
95
137
|
}
|
|
96
138
|
|
|
139
|
+
/**
|
|
140
|
+
* The schema node that CARRIES the value vocabulary — the node itself, or the
|
|
141
|
+
* branch of a union that declares the map.
|
|
142
|
+
*
|
|
143
|
+
* A slot unioning a closed value vocabulary with a reference keeps its map on the
|
|
144
|
+
* value branch, exactly as the ref-slot reader peels the same union for its
|
|
145
|
+
* constraint. Exported because the strict half checks the map against the same
|
|
146
|
+
* branch's `enum`, and two implementations of "which branch is the value one"
|
|
147
|
+
* would eventually disagree — silently, since the failure of missing one is a
|
|
148
|
+
* completeness check that quietly stops running.
|
|
149
|
+
*/
|
|
150
|
+
export function schemaMapBranch(node: unknown): Record<string, unknown> | undefined {
|
|
151
|
+
if (!isObject(node)) return undefined;
|
|
152
|
+
if (node["x-telo-schema-map"] !== undefined) return node;
|
|
153
|
+
for (const key of ["oneOf", "anyOf"] as const) {
|
|
154
|
+
const branches = node[key];
|
|
155
|
+
if (!Array.isArray(branches)) continue;
|
|
156
|
+
for (const branch of branches) {
|
|
157
|
+
if (isObject(branch) && branch["x-telo-schema-map"] !== undefined) return branch;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
97
163
|
export function readSchemaMap(node: unknown): SchemaMap | undefined {
|
|
164
|
+
return ownSchemaMap(schemaMapBranch(node));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function ownSchemaMap(node: unknown): SchemaMap | undefined {
|
|
98
168
|
if (!isObject(node)) return undefined;
|
|
99
169
|
const raw = node["x-telo-schema-map"];
|
|
100
170
|
if (!isObject(raw)) return undefined;
|
|
@@ -146,6 +216,76 @@ export function projectionKeyMap(
|
|
|
146
216
|
return readSchemaMap(entry.properties[projection.key]);
|
|
147
217
|
}
|
|
148
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The node an entry whose keyed field holds a REFERENCE projects to.
|
|
221
|
+
*
|
|
222
|
+
* This is the one place a projection crosses to another declaration, and it is
|
|
223
|
+
* a deliberate exception to the projection's lossiness: length, precision and
|
|
224
|
+
* collation stop at the boundary because the database enforces them, while a
|
|
225
|
+
* domain crosses because it IS the type at the granularity a consumer acts on —
|
|
226
|
+
* the enum in a CRUD model's OpenAPI operation, a completion list in the editor,
|
|
227
|
+
* a filter a repository can reject before the query.
|
|
228
|
+
*
|
|
229
|
+
* **A reference that cannot be read projects OPEN, never to nothing**, and that
|
|
230
|
+
* is the opposite of the rule an unmapped VALUE follows. The two failures are
|
|
231
|
+
* not the same failure: an unmapped value is a gap in the kind's own vocabulary,
|
|
232
|
+
* so there is no entry to speak of, while an unreadable reference names an entry
|
|
233
|
+
* the declaration plainly HAS and only leaves its type unknown. Dropping it made
|
|
234
|
+
* the projection deny the entry exists — a table whose enum reference had a typo
|
|
235
|
+
* reported `'status' is not allowed` against a column declared three lines up,
|
|
236
|
+
* blaming the seed row for the reference's mistake. Open is the honest
|
|
237
|
+
* under-approximation, and the reason is reported alongside.
|
|
238
|
+
*/
|
|
239
|
+
function referencedNode(
|
|
240
|
+
value: unknown,
|
|
241
|
+
entryName: string,
|
|
242
|
+
projection: SchemaProjection,
|
|
243
|
+
map: SchemaMap,
|
|
244
|
+
options?: {
|
|
245
|
+
readonly scope?: ProjectionScope;
|
|
246
|
+
readonly pointer?: string;
|
|
247
|
+
readonly failures?: ProjectionFailure[];
|
|
248
|
+
},
|
|
249
|
+
): Record<string, unknown> | undefined {
|
|
250
|
+
const reference = projection.reference;
|
|
251
|
+
if (!reference || !isObject(value)) return undefined;
|
|
252
|
+
// Through the single reader, so the name in the diagnostic is the one the
|
|
253
|
+
// author wrote whichever shape the slot holds — reading `value.name` here
|
|
254
|
+
// reported `<unnamed>` for an unresolved `!ref`, which is precisely the case
|
|
255
|
+
// that produces the diagnostic.
|
|
256
|
+
const name = readProjectionRef(value)?.name ?? "<unnamed>";
|
|
257
|
+
const report = (): Record<string, unknown> => {
|
|
258
|
+
options?.failures?.push({
|
|
259
|
+
reason: "entry-reference",
|
|
260
|
+
// The EMPTY pointer means the projected declaration is the one carrying
|
|
261
|
+
// the diagnostic, so the entry's own path is a real anchor in that file —
|
|
262
|
+
// the column, not the document root. Any other pointer names a slot
|
|
263
|
+
// holding a reference to a DIFFERENT manifest, whose entry paths mean
|
|
264
|
+
// nothing here, so the slot stays the anchor.
|
|
265
|
+
pointer:
|
|
266
|
+
options?.pointer === ""
|
|
267
|
+
? `${projection.entries}/${entryName}`
|
|
268
|
+
: (options?.pointer ?? projection.entries),
|
|
269
|
+
entry: entryName,
|
|
270
|
+
name,
|
|
271
|
+
});
|
|
272
|
+
return {};
|
|
273
|
+
};
|
|
274
|
+
const found = options?.scope?.resolveManifest(value);
|
|
275
|
+
if (!found || "ambiguous" in found) return report();
|
|
276
|
+
|
|
277
|
+
const values = (found.manifest as Record<string, unknown>)[reference.from];
|
|
278
|
+
if (!Array.isArray(values) || values.length === 0) return report();
|
|
279
|
+
|
|
280
|
+
let base: Record<string, unknown> | undefined = reference.base;
|
|
281
|
+
if (reference.baseFrom !== undefined) {
|
|
282
|
+
const declared = (found.manifest as Record<string, unknown>)[reference.baseFrom];
|
|
283
|
+
base = typeof declared === "string" ? map[declared] : undefined;
|
|
284
|
+
}
|
|
285
|
+
if (!base) return report();
|
|
286
|
+
return { ...base, [reference.keyword]: values };
|
|
287
|
+
}
|
|
288
|
+
|
|
149
289
|
/**
|
|
150
290
|
* Project one declaration to an object schema.
|
|
151
291
|
*
|
|
@@ -164,6 +304,15 @@ export function projectEntries(
|
|
|
164
304
|
manifest: unknown,
|
|
165
305
|
projection: SchemaProjection,
|
|
166
306
|
map: SchemaMap,
|
|
307
|
+
/** What a REFERENCE at the keyed field is resolved through, and where a
|
|
308
|
+
* failure to resolve one is reported. A caller with no scope cannot resolve
|
|
309
|
+
* one, so such an entry projects OPEN — present, untyped — rather than
|
|
310
|
+
* vanishing from the row. */
|
|
311
|
+
options?: {
|
|
312
|
+
readonly scope?: ProjectionScope;
|
|
313
|
+
readonly pointer?: string;
|
|
314
|
+
readonly failures?: ProjectionFailure[];
|
|
315
|
+
},
|
|
167
316
|
): Record<string, unknown> | undefined {
|
|
168
317
|
const entries = navigate(manifest, projection.entries);
|
|
169
318
|
if (entries === undefined) return undefined;
|
|
@@ -172,7 +321,10 @@ export function projectEntries(
|
|
|
172
321
|
const consider = (name: string | undefined, entry: unknown): void => {
|
|
173
322
|
if (!isObject(entry) || name === undefined) return;
|
|
174
323
|
const key = entry[projection.key];
|
|
175
|
-
const mapped =
|
|
324
|
+
const mapped =
|
|
325
|
+
typeof key === "string"
|
|
326
|
+
? map[key]
|
|
327
|
+
: referencedNode(key, name, projection, map, options);
|
|
176
328
|
// A value with no map entry projects to nothing rather than to `any`: the
|
|
177
329
|
// vocabulary is the kind's own enum, so an unmapped value is a gap in the
|
|
178
330
|
// kind's declaration, not a shape to guess at.
|
|
@@ -215,10 +367,25 @@ export interface ProjectionRef {
|
|
|
215
367
|
readonly alias?: string;
|
|
216
368
|
}
|
|
217
369
|
|
|
218
|
-
/**
|
|
219
|
-
*
|
|
370
|
+
/**
|
|
371
|
+
* The `{kind, name, alias?}` reference a value holds, or undefined. Exported so
|
|
372
|
+
* a host whose slot may hold EITHER shape can fall back to this reading.
|
|
373
|
+
*
|
|
374
|
+
* The unresolved `!ref` SENTINEL is read too. `resolveRefSentinels` normally
|
|
375
|
+
* rewrites one before this pass, but not when the reference names nothing — and
|
|
376
|
+
* that is exactly when a projection failure is reported, so reading only the
|
|
377
|
+
* resolved shape made the diagnostic name the target `<unnamed>`, which is the
|
|
378
|
+
* one fact the author needed from it. A round-trip host (`compile` off) carries
|
|
379
|
+
* the sentinel for every reference, resolved or not.
|
|
380
|
+
*/
|
|
220
381
|
export function readProjectionRef(value: unknown): ProjectionRef | undefined {
|
|
221
382
|
if (!isObject(value)) return undefined;
|
|
383
|
+
if (isRefSentinel(value)) {
|
|
384
|
+
const dot = value.source.indexOf(".");
|
|
385
|
+
return dot > 0
|
|
386
|
+
? { name: value.source.slice(dot + 1), alias: value.source.slice(0, dot) }
|
|
387
|
+
: { name: value.source };
|
|
388
|
+
}
|
|
222
389
|
const name = value.name;
|
|
223
390
|
if (typeof name !== "string") return undefined;
|
|
224
391
|
return {
|
|
@@ -306,11 +473,42 @@ export function manifestListScope(
|
|
|
306
473
|
};
|
|
307
474
|
}
|
|
308
475
|
|
|
476
|
+
/**
|
|
477
|
+
* Why a slot could not be typed from a projection.
|
|
478
|
+
*
|
|
479
|
+
* Each reason is a DIFFERENT repair, which is why the three ways a target can
|
|
480
|
+
* carry no usable projection are kept apart rather than collapsed into
|
|
481
|
+
* `no-projection`: that one message ("declares no 'x-telo-schema-projection'")
|
|
482
|
+
* was printed for a kind that declares one whose key field carries no map, and
|
|
483
|
+
* for a declaration whose entry collection is simply absent — accusing the wrong
|
|
484
|
+
* author of the wrong omission in both.
|
|
485
|
+
*/
|
|
309
486
|
export type ProjectionFailure =
|
|
310
487
|
| { readonly reason: "no-ref"; readonly pointer: string }
|
|
311
488
|
| { readonly reason: "unresolved"; readonly pointer: string; readonly name: string }
|
|
312
489
|
| { readonly reason: "ambiguous"; readonly pointer: string; readonly name: string }
|
|
313
|
-
|
|
490
|
+
/** The target's KIND declares no `x-telo-schema-projection` at all. */
|
|
491
|
+
| { readonly reason: "no-projection"; readonly pointer: string; readonly kind: string }
|
|
492
|
+
/** It declares one, but the field it keys on carries no `x-telo-schema-map`. */
|
|
493
|
+
| { readonly reason: "no-projection-map"; readonly pointer: string; readonly kind: string }
|
|
494
|
+
/** Both are declared and the DECLARATION holds no entry collection to project
|
|
495
|
+
* — an absent `columns:`, or a value that is not a collection. */
|
|
496
|
+
| {
|
|
497
|
+
readonly reason: "no-entries";
|
|
498
|
+
readonly pointer: string;
|
|
499
|
+
readonly kind: string;
|
|
500
|
+
readonly entries: string;
|
|
501
|
+
}
|
|
502
|
+
/** An ENTRY of the projected declaration references a shape that could not be
|
|
503
|
+
* read. Reported rather than dropped: the entry would silently vanish from
|
|
504
|
+
* the projected row, so a consumer naming it would be told the property does
|
|
505
|
+
* not exist. */
|
|
506
|
+
| {
|
|
507
|
+
readonly reason: "entry-reference";
|
|
508
|
+
readonly pointer: string;
|
|
509
|
+
readonly entry: string;
|
|
510
|
+
readonly name: string;
|
|
511
|
+
};
|
|
314
512
|
|
|
315
513
|
function refTarget(
|
|
316
514
|
value: unknown,
|
|
@@ -331,10 +529,25 @@ function refTarget(
|
|
|
331
529
|
return { manifest, definition };
|
|
332
530
|
}
|
|
333
531
|
|
|
532
|
+
/** The declaration the annotation is written on, as a projection target. */
|
|
533
|
+
function ownTarget(
|
|
534
|
+
manifest: Record<string, any>,
|
|
535
|
+
scope: ProjectionScope,
|
|
536
|
+
): { manifest: Record<string, any>; definition: Record<string, any> } | ProjectionFailure {
|
|
537
|
+
if (typeof manifest.kind !== "string") {
|
|
538
|
+
return { reason: "no-ref", pointer: "" };
|
|
539
|
+
}
|
|
540
|
+
const definition = scope.resolveDefinition(manifest.kind);
|
|
541
|
+
if (!definition) return { reason: "no-projection", pointer: "", kind: manifest.kind };
|
|
542
|
+
return { manifest, definition };
|
|
543
|
+
}
|
|
544
|
+
|
|
334
545
|
export function describeProjectionFailure(failure: ProjectionFailure): string {
|
|
335
546
|
switch (failure.reason) {
|
|
336
547
|
case "no-ref":
|
|
337
|
-
return
|
|
548
|
+
return failure.pointer === ""
|
|
549
|
+
? "this resource declares no 'kind:', so there is no definition to project it through."
|
|
550
|
+
: `'${failure.pointer}' does not hold a reference, so there is no declaration to project.`;
|
|
338
551
|
case "unresolved":
|
|
339
552
|
return `'${failure.pointer}' references '${failure.name}', which resolves to no resource.`;
|
|
340
553
|
case "ambiguous":
|
|
@@ -347,6 +560,23 @@ export function describeProjectionFailure(failure: ProjectionFailure): string {
|
|
|
347
560
|
`'${failure.pointer}' references a resource of kind '${failure.kind}', which declares no ` +
|
|
348
561
|
`'x-telo-schema-projection' — so there is nothing for this slot to be typed from.`
|
|
349
562
|
);
|
|
563
|
+
case "no-projection-map":
|
|
564
|
+
return (
|
|
565
|
+
`kind '${failure.kind}' declares an 'x-telo-schema-projection' whose key field carries ` +
|
|
566
|
+
`no 'x-telo-schema-map', so there is no vocabulary to project its entries through and ` +
|
|
567
|
+
`'${failure.pointer || "this declaration"}' cannot be typed from it.`
|
|
568
|
+
);
|
|
569
|
+
case "no-entries":
|
|
570
|
+
return (
|
|
571
|
+
`'${failure.entries}' holds no entry collection on this ${failure.kind}, so the ` +
|
|
572
|
+
`projection has nothing to type '${failure.pointer || "this declaration"}' from.`
|
|
573
|
+
);
|
|
574
|
+
case "entry-reference":
|
|
575
|
+
return (
|
|
576
|
+
`entry '${failure.entry}' at '${failure.pointer}' references '${failure.name}', which ` +
|
|
577
|
+
`resolves to no declaration this analysis can read — so that entry is projected as an ` +
|
|
578
|
+
`open value and nothing typed from it is checked against the shape it was meant to have.`
|
|
579
|
+
);
|
|
350
580
|
}
|
|
351
581
|
}
|
|
352
582
|
|
|
@@ -360,6 +590,16 @@ export function describeProjectionFailure(failure: ProjectionFailure): string {
|
|
|
360
590
|
* `failures`, because degrading SILENTLY is the failure this whole mechanism
|
|
361
591
|
* exists to move earlier: the consumer's contract quietly reopens and a
|
|
362
592
|
* misspelled field passes `telo check` exactly as it did before.
|
|
593
|
+
*
|
|
594
|
+
* **A node that projected NOTHING is returned by IDENTITY**, and that is a
|
|
595
|
+
* correctness property of the caller rather than a micro-optimization:
|
|
596
|
+
* `DefinitionRegistry` memoizes a compiled AJV validator per schema OBJECT,
|
|
597
|
+
* because every resource of a kind is checked against the same one at keystroke
|
|
598
|
+
* time. Rebuilding each node unconditionally — which this did — misses that memo
|
|
599
|
+
* for every resource in the analysis, so AJV recompiled the whole kind schema
|
|
600
|
+
* once per resource: on `apps/hub` that was 197 compiles instead of 54, and 723
|
|
601
|
+
* ms instead of 97. Returning the input where nothing changed restores it for
|
|
602
|
+
* every kind that declares no projection at all, which is nearly all of them.
|
|
363
603
|
*/
|
|
364
604
|
export function resolveSchemaProjections(
|
|
365
605
|
schema: unknown,
|
|
@@ -368,36 +608,59 @@ export function resolveSchemaProjections(
|
|
|
368
608
|
failures?: ProjectionFailure[],
|
|
369
609
|
): unknown {
|
|
370
610
|
if (Array.isArray(schema)) {
|
|
371
|
-
|
|
611
|
+
let moved = false;
|
|
612
|
+
const items = schema.map((item) => {
|
|
613
|
+
const next = resolveSchemaProjections(item, manifest, scope, failures);
|
|
614
|
+
if (next !== item) moved = true;
|
|
615
|
+
return next;
|
|
616
|
+
});
|
|
617
|
+
return moved ? items : schema;
|
|
372
618
|
}
|
|
373
619
|
if (!isObject(schema)) return schema;
|
|
374
620
|
|
|
375
621
|
const pointer = readProjectionFrom(schema);
|
|
376
|
-
if (pointer && manifest) {
|
|
377
|
-
|
|
622
|
+
if (pointer !== undefined && manifest) {
|
|
623
|
+
// The EMPTY pointer names the declaration this annotation is written on,
|
|
624
|
+
// rather than a slot holding a reference to another. Resolution is skipped
|
|
625
|
+
// because the declaration is already in hand — which is what lets a kind
|
|
626
|
+
// type its own data against its own entries (a table's seed rows against its
|
|
627
|
+
// columns), something no reference could reach.
|
|
628
|
+
const target =
|
|
629
|
+
pointer === ""
|
|
630
|
+
? ownTarget(manifest, scope)
|
|
631
|
+
: refTarget(navigate(manifest, pointer), scope, pointer);
|
|
378
632
|
if ("reason" in target) {
|
|
379
633
|
failures?.push(target);
|
|
380
634
|
} else {
|
|
635
|
+
const kind = String(target.manifest.kind ?? "<unknown>");
|
|
381
636
|
const projection = readSchemaProjection(target.definition);
|
|
382
637
|
const map = projection && projectionKeyMap(target.definition.schema, projection);
|
|
383
638
|
const projected =
|
|
384
|
-
projection && map
|
|
639
|
+
projection && map
|
|
640
|
+
? projectEntries(target.manifest, projection, map, { scope, pointer, failures })
|
|
641
|
+
: undefined;
|
|
385
642
|
if (projected) {
|
|
386
643
|
const { ["x-telo-schema-projection-from"]: _dropped, ...rest } = schema;
|
|
387
644
|
return { ...rest, ...projected };
|
|
388
645
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
});
|
|
646
|
+
// Three distinct omissions, three repairs by three different authors: the
|
|
647
|
+
// kind declares no projection, the kind declares one the key field has no
|
|
648
|
+
// vocabulary for, or this DECLARATION simply lists no entries.
|
|
649
|
+
if (!projection) failures?.push({ reason: "no-projection", pointer, kind });
|
|
650
|
+
else if (!map) failures?.push({ reason: "no-projection-map", pointer, kind });
|
|
651
|
+
else {
|
|
652
|
+
failures?.push({ reason: "no-entries", pointer, kind, entries: projection.entries });
|
|
653
|
+
}
|
|
394
654
|
}
|
|
395
655
|
}
|
|
396
656
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
657
|
+
let moved = false;
|
|
658
|
+
const entries = Object.entries(schema).map(([key, value]) => {
|
|
659
|
+
const next = key.startsWith("x-telo-")
|
|
660
|
+
? value
|
|
661
|
+
: resolveSchemaProjections(value, manifest, scope, failures);
|
|
662
|
+
if (next !== value) moved = true;
|
|
663
|
+
return [key, next] as const;
|
|
664
|
+
});
|
|
665
|
+
return moved ? Object.fromEntries(entries) : schema;
|
|
403
666
|
}
|
package/src/telo-version.ts
CHANGED