@telorun/analyzer 0.1.1
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/LICENSE +17 -0
- package/dist/adapters/http-adapter.d.ts +10 -0
- package/dist/adapters/http-adapter.d.ts.map +1 -0
- package/dist/adapters/http-adapter.js +17 -0
- package/dist/adapters/node-adapter.d.ts +15 -0
- package/dist/adapters/node-adapter.d.ts.map +1 -0
- package/dist/adapters/node-adapter.js +32 -0
- package/dist/adapters/registry-adapter.d.ts +11 -0
- package/dist/adapters/registry-adapter.d.ts.map +1 -0
- package/dist/adapters/registry-adapter.js +33 -0
- package/dist/alias-resolver.d.ts +12 -0
- package/dist/alias-resolver.d.ts.map +1 -0
- package/dist/alias-resolver.js +36 -0
- package/dist/analysis-registry.d.ts +29 -0
- package/dist/analysis-registry.d.ts.map +1 -0
- package/dist/analysis-registry.js +55 -0
- package/dist/analyzer.d.ts +14 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzer.js +314 -0
- package/dist/builtins.d.ts +3 -0
- package/dist/builtins.d.ts.map +1 -0
- package/dist/builtins.js +109 -0
- package/dist/cel-environment.d.ts +12 -0
- package/dist/cel-environment.d.ts.map +1 -0
- package/dist/cel-environment.js +59 -0
- package/dist/definition-registry.d.ts +58 -0
- package/dist/definition-registry.d.ts.map +1 -0
- package/dist/definition-registry.js +155 -0
- package/dist/dependency-graph.d.ts +38 -0
- package/dist/dependency-graph.d.ts.map +1 -0
- package/dist/dependency-graph.js +155 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/manifest-loader.d.ts +11 -0
- package/dist/manifest-loader.d.ts.map +1 -0
- package/dist/manifest-loader.js +194 -0
- package/dist/normalize-inline-resources.d.ts +22 -0
- package/dist/normalize-inline-resources.d.ts.map +1 -0
- package/dist/normalize-inline-resources.js +136 -0
- package/dist/precompile.d.ts +9 -0
- package/dist/precompile.d.ts.map +1 -0
- package/dist/precompile.js +51 -0
- package/dist/reference-field-map.d.ts +53 -0
- package/dist/reference-field-map.d.ts.map +1 -0
- package/dist/reference-field-map.js +107 -0
- package/dist/schema-compat.d.ts +42 -0
- package/dist/schema-compat.d.ts.map +1 -0
- package/dist/schema-compat.js +234 -0
- package/dist/scope-resolver.d.ts +5 -0
- package/dist/scope-resolver.d.ts.map +1 -0
- package/dist/scope-resolver.js +13 -0
- package/dist/types.d.ts +64 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/validate-cel-context.d.ts +24 -0
- package/dist/validate-cel-context.d.ts.map +1 -0
- package/dist/validate-cel-context.js +136 -0
- package/dist/validate-references.d.ts +19 -0
- package/dist/validate-references.d.ts.map +1 -0
- package/dist/validate-references.js +275 -0
- package/package.json +34 -0
- package/src/adapters/http-adapter.ts +23 -0
- package/src/adapters/node-adapter.ts +38 -0
- package/src/adapters/registry-adapter.ts +43 -0
- package/src/alias-resolver.ts +37 -0
- package/src/analysis-registry.ts +68 -0
- package/src/analyzer.ts +399 -0
- package/src/builtins.ts +111 -0
- package/src/cel-environment.ts +70 -0
- package/src/definition-registry.ts +170 -0
- package/src/dependency-graph.ts +187 -0
- package/src/index.ts +17 -0
- package/src/manifest-loader.ts +203 -0
- package/src/normalize-inline-resources.ts +170 -0
- package/src/precompile.ts +54 -0
- package/src/reference-field-map.ts +147 -0
- package/src/schema-compat.ts +264 -0
- package/src/scope-resolver.ts +13 -0
- package/src/types.ts +68 -0
- package/src/validate-cel-context.ts +142 -0
- package/src/validate-references.ts +311 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Matches LSP DiagnosticSeverity values exactly.
|
|
2
|
+
* https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#diagnosticSeverity */
|
|
3
|
+
export const DiagnosticSeverity = {
|
|
4
|
+
Error: 1,
|
|
5
|
+
Warning: 2,
|
|
6
|
+
Information: 3,
|
|
7
|
+
Hint: 4,
|
|
8
|
+
} as const;
|
|
9
|
+
export type DiagnosticSeverity = (typeof DiagnosticSeverity)[keyof typeof DiagnosticSeverity];
|
|
10
|
+
|
|
11
|
+
export interface Position {
|
|
12
|
+
/** 0-based line number */
|
|
13
|
+
line: number;
|
|
14
|
+
/** 0-based character offset */
|
|
15
|
+
character: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Range {
|
|
19
|
+
start: Position;
|
|
20
|
+
end: Position;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Maps a dotted field path (e.g. "config.handler", "kind") to its source Range.
|
|
24
|
+
* Built from the YAML AST before conversion to plain objects, so positions reflect
|
|
25
|
+
* the actual text locations in the source file. */
|
|
26
|
+
export type PositionIndex = Map<string, Range>;
|
|
27
|
+
|
|
28
|
+
/** LSP-compatible Diagnostic shape. range is optional because parsed YAML may not carry
|
|
29
|
+
* position info when only the parsed object (not raw text) is available. */
|
|
30
|
+
export interface AnalysisDiagnostic {
|
|
31
|
+
range?: Range;
|
|
32
|
+
severity?: DiagnosticSeverity;
|
|
33
|
+
code?: string | number;
|
|
34
|
+
/** e.g. "telo-analyzer" */
|
|
35
|
+
source?: string;
|
|
36
|
+
message: string;
|
|
37
|
+
/** Telo-specific extras such as { resource: { kind, name }, path } */
|
|
38
|
+
data?: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ManifestAdapter {
|
|
42
|
+
supports(url: string): boolean;
|
|
43
|
+
read(url: string): Promise<{ text: string; source: string }>;
|
|
44
|
+
resolveRelative(base: string, relative: string): string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LoadOptions {
|
|
48
|
+
/** When true, each YAML document is passed through the CEL precompiler before being
|
|
49
|
+
* returned. All `${{ expr }}` template strings are replaced with `CompiledValue` wrappers
|
|
50
|
+
* so the kernel can evaluate them at runtime. Leave unset (false) for static analysis —
|
|
51
|
+
* the analyzer works on raw strings and does not need compiled values. */
|
|
52
|
+
compile?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AnalysisOptions {
|
|
56
|
+
strictContexts?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pre-seeded state for incremental analysis. Passed to StaticAnalyzer.analyze() so it does
|
|
60
|
+
* not rebuild from scratch on every call. The provided instances are mutated — new definitions
|
|
61
|
+
* and aliases found in the analysed manifests are registered into them. A single context can
|
|
62
|
+
* be reused across successive analyze() calls and accumulates state over time, which is the
|
|
63
|
+
* intended pattern for browser editors (persistent state across edits) and the kernel (live
|
|
64
|
+
* registry updated as resources are registered at runtime). */
|
|
65
|
+
export interface AnalysisContext {
|
|
66
|
+
aliases?: import("./alias-resolver.js").AliasResolver;
|
|
67
|
+
definitions?: import("./definition-registry.js").DefinitionRegistry;
|
|
68
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { ASTNode } from "@marcbachmann/cel-js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract all member-access chains from a CEL AST.
|
|
5
|
+
* Returns arrays like ["request", "query", "name"] for `request.query.name`.
|
|
6
|
+
* Chains that start with a call or non-identifier root are ignored.
|
|
7
|
+
* Bound variables in comprehension macros (filter, map, exists, all, exists_one) are excluded.
|
|
8
|
+
*/
|
|
9
|
+
export function extractAccessChains(node: ASTNode): string[][] {
|
|
10
|
+
const chains: string[][] = [];
|
|
11
|
+
visitNode(node, chains, new Set());
|
|
12
|
+
return chains;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// CEL comprehension macros that bind a variable: list.filter(x, ...), list.map(x, ...), etc.
|
|
16
|
+
const COMPREHENSION_METHODS = new Set(["filter", "map", "exists", "all", "exists_one"]);
|
|
17
|
+
|
|
18
|
+
function visitNode(node: ASTNode, chains: string[][], boundVars: Set<string>): void {
|
|
19
|
+
const chain = extractChain(node, boundVars);
|
|
20
|
+
if (chain !== null) {
|
|
21
|
+
chains.push(chain);
|
|
22
|
+
return; // don't recurse into parts of an already-collected chain
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Comprehension macros bind a variable in their body — handle them specially
|
|
26
|
+
// AST shape: { op: "rcall", args: [methodName, receiver, [boundVarId, body, ...]] }
|
|
27
|
+
if (
|
|
28
|
+
node.op === "rcall" &&
|
|
29
|
+
Array.isArray(node.args) &&
|
|
30
|
+
typeof node.args[0] === "string" &&
|
|
31
|
+
COMPREHENSION_METHODS.has(node.args[0])
|
|
32
|
+
) {
|
|
33
|
+
const receiver = node.args[1];
|
|
34
|
+
const comprehensionArgs = node.args[2];
|
|
35
|
+
if (isASTNode(receiver)) visitNode(receiver, chains, boundVars);
|
|
36
|
+
if (
|
|
37
|
+
Array.isArray(comprehensionArgs) &&
|
|
38
|
+
comprehensionArgs.length >= 2 &&
|
|
39
|
+
isASTNode(comprehensionArgs[0]) &&
|
|
40
|
+
(comprehensionArgs[0] as ASTNode).op === "id"
|
|
41
|
+
) {
|
|
42
|
+
const newBoundVars = new Set(boundVars);
|
|
43
|
+
newBoundVars.add((comprehensionArgs[0] as ASTNode).args as string);
|
|
44
|
+
for (let i = 1; i < comprehensionArgs.length; i++) {
|
|
45
|
+
const arg = comprehensionArgs[i];
|
|
46
|
+
if (isASTNode(arg)) visitNode(arg as ASTNode, chains, newBoundVars);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const args = node.args;
|
|
53
|
+
if (Array.isArray(args)) {
|
|
54
|
+
for (const arg of args) {
|
|
55
|
+
if (isASTNode(arg)) {
|
|
56
|
+
visitNode(arg, chains, boundVars);
|
|
57
|
+
} else if (Array.isArray(arg)) {
|
|
58
|
+
for (const item of arg) {
|
|
59
|
+
if (isASTNode(item)) visitNode(item, chains, boundVars);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isASTNode(v: unknown): v is ASTNode {
|
|
67
|
+
return v !== null && typeof v === "object" && "op" in (v as object);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Returns the member-access chain for a node if it is purely an id or "." chain; else null. */
|
|
71
|
+
function extractChain(node: ASTNode, boundVars: Set<string>): string[] | null {
|
|
72
|
+
if (node.op === "id") {
|
|
73
|
+
const name = node.args as string;
|
|
74
|
+
if (boundVars.has(name)) return null; // bound by a comprehension macro, not a free access
|
|
75
|
+
return [name];
|
|
76
|
+
}
|
|
77
|
+
if (node.op === ".") {
|
|
78
|
+
const [obj, field] = node.args as [ASTNode, string];
|
|
79
|
+
const parent = extractChain(obj, boundVars);
|
|
80
|
+
if (parent !== null) return [...parent, field];
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check whether a member-access chain accesses only fields declared in a JSON Schema.
|
|
87
|
+
* Returns an error string if a field is unknown in a schema that declares explicit
|
|
88
|
+
* properties without `additionalProperties: true`.
|
|
89
|
+
* Returns null when the chain is valid or the schema is too open to judge.
|
|
90
|
+
*/
|
|
91
|
+
export function validateChainAgainstSchema(
|
|
92
|
+
chain: string[],
|
|
93
|
+
schema: Record<string, any>,
|
|
94
|
+
): string | null {
|
|
95
|
+
let current: Record<string, any> = schema;
|
|
96
|
+
for (let i = 0; i < chain.length; i++) {
|
|
97
|
+
const key = chain[i]!;
|
|
98
|
+
if (!current || typeof current !== "object") return null;
|
|
99
|
+
// Open schema: no properties declared or explicitly allows additional properties
|
|
100
|
+
const props: Record<string, any> | undefined = current.properties;
|
|
101
|
+
if (!props) return null;
|
|
102
|
+
if (current.additionalProperties === true) return null;
|
|
103
|
+
if (!(key in props)) {
|
|
104
|
+
const path = chain.slice(0, i + 1).join(".");
|
|
105
|
+
const available = Object.keys(props).join(", ");
|
|
106
|
+
return `'${path}' is not defined (available: ${available})`;
|
|
107
|
+
}
|
|
108
|
+
current = props[key];
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns true when a CEL expression path (from walkCelExpressions, e.g. "routes[0].handler.inputs.name")
|
|
115
|
+
* falls within the container region of a context scope (e.g. "$.routes[*].handler").
|
|
116
|
+
*
|
|
117
|
+
* The container is derived by stripping the last dot-separated segment from the scope, so that
|
|
118
|
+
* sibling fields within the same parent (e.g. routes[*].response) also match.
|
|
119
|
+
*/
|
|
120
|
+
export function pathMatchesScope(exprPath: string, scope: string): boolean {
|
|
121
|
+
const stripped = scope.startsWith("$.") ? scope.slice(2) : scope;
|
|
122
|
+
const lastDot = stripped.lastIndexOf(".");
|
|
123
|
+
if (lastDot <= 0) return false;
|
|
124
|
+
const container = stripped.slice(0, lastDot); // e.g. "routes[*]"
|
|
125
|
+
|
|
126
|
+
// Split on wildcard array segments; each [*] must match a concrete [N] in exprPath
|
|
127
|
+
const parts = container.split("[*]");
|
|
128
|
+
let remaining = exprPath;
|
|
129
|
+
for (let i = 0; i < parts.length; i++) {
|
|
130
|
+
const part = parts[i]!;
|
|
131
|
+
if (!remaining.startsWith(part)) return false;
|
|
132
|
+
remaining = remaining.slice(part.length);
|
|
133
|
+
if (i < parts.length - 1) {
|
|
134
|
+
// Expect a concrete array index like [0], [12], ...
|
|
135
|
+
const m = remaining.match(/^\[\d+\]/);
|
|
136
|
+
if (!m) return false;
|
|
137
|
+
remaining = remaining.slice(m[0].length);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Expression must end here or continue into a child path
|
|
141
|
+
return remaining === "" || remaining[0] === "." || remaining[0] === "[";
|
|
142
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { isRefEntry, isScopeEntry, isSchemaFromEntry, isInlineResource, resolveFieldValues, type RefFieldEntry } from "./reference-field-map.js";
|
|
3
|
+
import { navigateJsonPointer } from "./schema-compat.js";
|
|
4
|
+
import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisContext } from "./types.js";
|
|
5
|
+
import type { AliasResolver } from "./alias-resolver.js";
|
|
6
|
+
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
7
|
+
|
|
8
|
+
const SOURCE = "telo-analyzer";
|
|
9
|
+
const SYSTEM_KINDS = new Set(["Kernel.Definition", "Kernel.Abstract"]);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Checks whether `kind` satisfies the ref constraint in `entry`.
|
|
13
|
+
* Returns an empty array when valid, or mismatch error strings when not.
|
|
14
|
+
* Returns an empty array immediately when the ref identity is not registered
|
|
15
|
+
* (partial context — skip check rather than false-positive).
|
|
16
|
+
*/
|
|
17
|
+
function checkKind(
|
|
18
|
+
kind: string,
|
|
19
|
+
entry: RefFieldEntry,
|
|
20
|
+
registry: DefinitionRegistry,
|
|
21
|
+
aliases: AliasResolver,
|
|
22
|
+
): string[] {
|
|
23
|
+
const resolved = aliases.resolveKind(kind) ?? kind;
|
|
24
|
+
const errors: string[] = [];
|
|
25
|
+
for (const refStr of entry.refs) {
|
|
26
|
+
const targetKind = registry.resolveRef(refStr);
|
|
27
|
+
if (!targetKind) return [];
|
|
28
|
+
const targetDef = registry.resolve(targetKind);
|
|
29
|
+
if (!targetDef) return [];
|
|
30
|
+
if (targetDef.kind === "Kernel.Abstract") {
|
|
31
|
+
const implementing = registry.getByExtends(targetKind);
|
|
32
|
+
if (implementing.length === 0) return []; // partial context — no implementations loaded yet
|
|
33
|
+
const implementingKinds = new Set(
|
|
34
|
+
implementing.map((d) => `${d.metadata.module}.${d.metadata.name}`),
|
|
35
|
+
);
|
|
36
|
+
if (implementingKinds.has(resolved)) return [];
|
|
37
|
+
const options = [...implementingKinds].join(", ");
|
|
38
|
+
errors.push(
|
|
39
|
+
`'${kind}' does not implement '${targetKind}' (known implementations: ${options})`,
|
|
40
|
+
);
|
|
41
|
+
} else {
|
|
42
|
+
if (resolved === targetKind) return [];
|
|
43
|
+
errors.push(`'${kind}' (resolved: '${resolved}') does not match required '${targetKind}'`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return errors;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Phase 3 — Reference validation.
|
|
51
|
+
*
|
|
52
|
+
* For each x-telo-ref slot in every non-system resource, validates:
|
|
53
|
+
* 1. Structural — the value has string `kind` and `name` fields.
|
|
54
|
+
* 2. Kind — the alias-resolved kind satisfies the x-telo-ref constraint
|
|
55
|
+
* (abstract: must extend the target; concrete: must equal it exactly).
|
|
56
|
+
* 3. Resolution — a resource with that name exists in the visible manifest set
|
|
57
|
+
* (outer manifests + scope manifests for in-scope ref paths).
|
|
58
|
+
*
|
|
59
|
+
* Ref values with keys beyond kind/name/metadata are treated as inline resources
|
|
60
|
+
* pending Phase 2 normalization and are skipped without error.
|
|
61
|
+
*
|
|
62
|
+
* Returns an empty array when `context.aliases` or `context.definitions` is absent.
|
|
63
|
+
*/
|
|
64
|
+
export function validateReferences(
|
|
65
|
+
resources: ResourceManifest[],
|
|
66
|
+
context: AnalysisContext,
|
|
67
|
+
): AnalysisDiagnostic[] {
|
|
68
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
69
|
+
const aliases = context.aliases;
|
|
70
|
+
const registry = context.definitions;
|
|
71
|
+
if (!aliases || !registry) return diagnostics;
|
|
72
|
+
|
|
73
|
+
// Build outer resource lookup by name for resolution check.
|
|
74
|
+
// Exclude system kinds (Kernel.Definition) — they are type blueprints, not instances,
|
|
75
|
+
// and their names (e.g. "Server", "Job") would shadow user-defined resource instances.
|
|
76
|
+
const byName = new Map<string, ResourceManifest>();
|
|
77
|
+
for (const r of resources) {
|
|
78
|
+
if (r.metadata?.name && !SYSTEM_KINDS.has(r.kind)) byName.set(r.metadata.name as string, r);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const r of resources) {
|
|
82
|
+
if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
|
|
83
|
+
|
|
84
|
+
const fieldMap = registry.getFieldMapForKind(r.kind, aliases);
|
|
85
|
+
if (!fieldMap) continue;
|
|
86
|
+
|
|
87
|
+
const resourceLabel = `${r.kind}/${r.metadata.name as string}`;
|
|
88
|
+
const resourceData = { kind: r.kind, name: r.metadata.name as string };
|
|
89
|
+
|
|
90
|
+
// Collect scope visibility prefixes (JSON Pointer → dot prefix) and their manifests.
|
|
91
|
+
// scope field path → flat array of ResourceManifest declared in that scope.
|
|
92
|
+
const scopeManifestsByPointer = new Map<string, ResourceManifest[]>();
|
|
93
|
+
for (const [fieldPath, entry] of fieldMap) {
|
|
94
|
+
if (!isScopeEntry(entry)) continue;
|
|
95
|
+
const raw = resolveFieldValues(r, fieldPath)
|
|
96
|
+
.flatMap((v) => (Array.isArray(v) ? v : [v]))
|
|
97
|
+
.filter((v): v is ResourceManifest => !!v && typeof v === "object");
|
|
98
|
+
const pointers = Array.isArray(entry.scope) ? entry.scope : [entry.scope];
|
|
99
|
+
for (const pointer of pointers) {
|
|
100
|
+
scopeManifestsByPointer.set(pointer, raw);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const scopePrefixes = Array.from(scopeManifestsByPointer.keys()).map((p) =>
|
|
105
|
+
p.replace(/^\//, "").replace(/\//g, "."),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
for (const [fieldPath, entry] of fieldMap) {
|
|
109
|
+
if (!isRefEntry(entry)) continue;
|
|
110
|
+
|
|
111
|
+
const inScope = scopePrefixes.some(
|
|
112
|
+
(prefix) =>
|
|
113
|
+
fieldPath === prefix ||
|
|
114
|
+
fieldPath.startsWith(prefix + ".") ||
|
|
115
|
+
fieldPath.startsWith(prefix + "["),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Scope manifests visible to this ref path.
|
|
119
|
+
const visibleScopeManifests: ResourceManifest[] = [];
|
|
120
|
+
if (inScope) {
|
|
121
|
+
for (const [pointer, manifests] of scopeManifestsByPointer) {
|
|
122
|
+
const prefix = pointer.replace(/^\//, "").replace(/\//g, ".");
|
|
123
|
+
if (
|
|
124
|
+
fieldPath === prefix ||
|
|
125
|
+
fieldPath.startsWith(prefix + ".") ||
|
|
126
|
+
fieldPath.startsWith(prefix + "[")
|
|
127
|
+
) {
|
|
128
|
+
visibleScopeManifests.push(...manifests);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const val of resolveFieldValues(r, fieldPath)) {
|
|
134
|
+
if (!val) continue;
|
|
135
|
+
|
|
136
|
+
// Name-only reference (plain string) — look up by name to validate.
|
|
137
|
+
if (typeof val === "string") {
|
|
138
|
+
const target =
|
|
139
|
+
byName.get(val) ?? visibleScopeManifests.find((m) => m.metadata?.name === val);
|
|
140
|
+
if (!target) {
|
|
141
|
+
diagnostics.push({
|
|
142
|
+
severity: DiagnosticSeverity.Error,
|
|
143
|
+
code: "UNRESOLVED_REFERENCE",
|
|
144
|
+
source: SOURCE,
|
|
145
|
+
message: `${resourceLabel}: reference at '${fieldPath}' → resource '${val}' not found`,
|
|
146
|
+
data: { resource: resourceData, path: fieldPath },
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const kindErrors = checkKind(target.kind as string, entry, registry, aliases);
|
|
151
|
+
if (kindErrors.length > 0) {
|
|
152
|
+
diagnostics.push({
|
|
153
|
+
severity: DiagnosticSeverity.Error,
|
|
154
|
+
code: "REFERENCE_KIND_MISMATCH",
|
|
155
|
+
source: SOURCE,
|
|
156
|
+
message: `${resourceLabel}: reference at '${fieldPath}' → ${kindErrors.join("; ")}`,
|
|
157
|
+
data: { resource: resourceData, path: fieldPath },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (typeof val !== "object") continue;
|
|
164
|
+
const refVal = val as Record<string, unknown>;
|
|
165
|
+
|
|
166
|
+
// Skip inline resources — Phase 2 normalization hasn't run yet.
|
|
167
|
+
if (isInlineResource(refVal)) continue;
|
|
168
|
+
|
|
169
|
+
// 1. Structural check
|
|
170
|
+
if (typeof refVal.kind !== "string" || typeof refVal.name !== "string") {
|
|
171
|
+
diagnostics.push({
|
|
172
|
+
severity: DiagnosticSeverity.Error,
|
|
173
|
+
code: "INVALID_REFERENCE",
|
|
174
|
+
source: SOURCE,
|
|
175
|
+
message: `${resourceLabel}: reference at '${fieldPath}' must have string 'kind' and 'name' fields`,
|
|
176
|
+
data: { resource: resourceData, path: fieldPath },
|
|
177
|
+
});
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 2. Kind check
|
|
182
|
+
const kindErrors = checkKind(refVal.kind, entry, registry, aliases);
|
|
183
|
+
if (kindErrors.length > 0) {
|
|
184
|
+
diagnostics.push({
|
|
185
|
+
severity: DiagnosticSeverity.Error,
|
|
186
|
+
code: "REFERENCE_KIND_MISMATCH",
|
|
187
|
+
source: SOURCE,
|
|
188
|
+
message: `${resourceLabel}: reference at '${fieldPath}' → ${kindErrors.join("; ")}`,
|
|
189
|
+
data: { resource: resourceData, path: fieldPath },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 3. Resolution check — resource with this name must exist.
|
|
194
|
+
const exists =
|
|
195
|
+
byName.has(refVal.name) ||
|
|
196
|
+
visibleScopeManifests.some((m) => m.metadata?.name === refVal.name);
|
|
197
|
+
if (!exists) {
|
|
198
|
+
diagnostics.push({
|
|
199
|
+
severity: DiagnosticSeverity.Error,
|
|
200
|
+
code: "UNRESOLVED_REFERENCE",
|
|
201
|
+
source: SOURCE,
|
|
202
|
+
message: `${resourceLabel}: reference at '${fieldPath}' → resource '${refVal.name}' not found`,
|
|
203
|
+
data: { resource: resourceData, path: fieldPath },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Phase 3b — x-telo-schema-from validation.
|
|
211
|
+
// For each field with a schemaFrom path expression, resolve the anchor ref to get the
|
|
212
|
+
// concrete kind, navigate the JSON Pointer into that kind's definition schema, and
|
|
213
|
+
// validate the field value against the resulting sub-schema.
|
|
214
|
+
for (const r of resources) {
|
|
215
|
+
if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
|
|
216
|
+
|
|
217
|
+
const fieldMap = registry.getFieldMapForKind(r.kind, aliases);
|
|
218
|
+
if (!fieldMap) continue;
|
|
219
|
+
|
|
220
|
+
const resourceLabel = `${r.kind}/${r.metadata.name as string}`;
|
|
221
|
+
const resourceData = { kind: r.kind, name: r.metadata.name as string };
|
|
222
|
+
|
|
223
|
+
for (const [fieldPath, entry] of fieldMap) {
|
|
224
|
+
if (!isSchemaFromEntry(entry)) continue;
|
|
225
|
+
|
|
226
|
+
const { schemaFrom } = entry;
|
|
227
|
+
const isAbsolute = schemaFrom.startsWith("/");
|
|
228
|
+
const expr = isAbsolute ? schemaFrom.slice(1) : schemaFrom;
|
|
229
|
+
const slashIdx = expr.indexOf("/");
|
|
230
|
+
if (slashIdx === -1) {
|
|
231
|
+
diagnostics.push({
|
|
232
|
+
severity: DiagnosticSeverity.Error,
|
|
233
|
+
code: "INVALID_SCHEMA_FROM",
|
|
234
|
+
source: SOURCE,
|
|
235
|
+
message: `${resourceLabel}: x-telo-schema-from "${schemaFrom}" must contain at least one "/" to separate anchor from JSON Pointer`,
|
|
236
|
+
data: { resource: resourceData, path: fieldPath },
|
|
237
|
+
});
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const anchorName = expr.slice(0, slashIdx);
|
|
242
|
+
const jsonPointer = "/" + expr.slice(slashIdx + 1);
|
|
243
|
+
|
|
244
|
+
// Derive the anchor path in the resource config.
|
|
245
|
+
let anchorPath: string;
|
|
246
|
+
if (isAbsolute) {
|
|
247
|
+
anchorPath = anchorName;
|
|
248
|
+
} else {
|
|
249
|
+
// Relative: replace the last dot-segment of fieldPath with anchorName.
|
|
250
|
+
// e.g. "nodes[].options" → "nodes[].backend"
|
|
251
|
+
const lastDot = fieldPath.lastIndexOf(".");
|
|
252
|
+
anchorPath = lastDot === -1 ? anchorName : fieldPath.slice(0, lastDot + 1) + anchorName;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const anchorValues = resolveFieldValues(r, anchorPath);
|
|
256
|
+
if (anchorValues.length === 0) continue; // anchor field not set — nothing to validate
|
|
257
|
+
|
|
258
|
+
const fieldValues = resolveFieldValues(r, fieldPath);
|
|
259
|
+
|
|
260
|
+
for (let i = 0; i < fieldValues.length; i++) {
|
|
261
|
+
const fieldValue = fieldValues[i];
|
|
262
|
+
if (fieldValue == null) continue;
|
|
263
|
+
|
|
264
|
+
// For absolute paths, the single anchor applies to all field values.
|
|
265
|
+
const anchorVal = isAbsolute ? anchorValues[0] : anchorValues[i];
|
|
266
|
+
if (!anchorVal || typeof anchorVal !== "object") continue;
|
|
267
|
+
|
|
268
|
+
const refVal = anchorVal as Record<string, unknown>;
|
|
269
|
+
if (typeof refVal.kind !== "string") continue;
|
|
270
|
+
|
|
271
|
+
const refResolvedKind = aliases.resolveKind(refVal.kind) ?? refVal.kind;
|
|
272
|
+
const refDef = registry.resolve(refVal.kind) ?? registry.resolve(refResolvedKind);
|
|
273
|
+
if (!refDef?.schema) {
|
|
274
|
+
diagnostics.push({
|
|
275
|
+
severity: DiagnosticSeverity.Error,
|
|
276
|
+
code: "SCHEMA_FROM_MISSING_PATH",
|
|
277
|
+
source: SOURCE,
|
|
278
|
+
message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${refVal.kind}' has no schema`,
|
|
279
|
+
data: { resource: resourceData, path: fieldPath },
|
|
280
|
+
});
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const subSchema = navigateJsonPointer(refDef.schema, jsonPointer);
|
|
285
|
+
if (subSchema === undefined) {
|
|
286
|
+
diagnostics.push({
|
|
287
|
+
severity: DiagnosticSeverity.Error,
|
|
288
|
+
code: "SCHEMA_FROM_MISSING_PATH",
|
|
289
|
+
source: SOURCE,
|
|
290
|
+
message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${refVal.kind}' has no schema path '${jsonPointer}'`,
|
|
291
|
+
data: { resource: resourceData, path: fieldPath },
|
|
292
|
+
});
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const issues = registry.validateWithRefs(fieldValue, subSchema as Record<string, any>);
|
|
297
|
+
for (const issue of issues) {
|
|
298
|
+
diagnostics.push({
|
|
299
|
+
severity: DiagnosticSeverity.Error,
|
|
300
|
+
code: "DEPENDENT_SCHEMA_MISMATCH",
|
|
301
|
+
source: SOURCE,
|
|
302
|
+
message: `${resourceLabel}: '${fieldPath}' does not match schema from '${refVal.kind}${jsonPointer}': ${issue}`,
|
|
303
|
+
data: { resource: resourceData, path: fieldPath },
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return diagnostics;
|
|
311
|
+
}
|