@telorun/analyzer 0.8.1 → 0.10.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 +5 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +7 -2
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +5 -5
- package/dist/definition-registry.d.ts +13 -1
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +58 -2
- package/dist/dependency-graph.d.ts +1 -1
- package/dist/dependency-graph.d.ts.map +1 -1
- package/dist/dependency-graph.js +8 -2
- package/dist/flatten-for-analyzer.d.ts +30 -0
- package/dist/flatten-for-analyzer.d.ts.map +1 -0
- package/dist/flatten-for-analyzer.js +119 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/loaded-types.d.ts +81 -0
- package/dist/loaded-types.d.ts.map +1 -0
- package/dist/loaded-types.js +1 -0
- package/dist/manifest-loader.d.ts +30 -9
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +197 -417
- package/dist/normalize-inline-resources.d.ts +1 -1
- package/dist/normalize-inline-resources.d.ts.map +1 -1
- package/dist/normalize-inline-resources.js +7 -2
- package/dist/parse-loaded-file.d.ts +12 -0
- package/dist/parse-loaded-file.d.ts.map +1 -0
- package/dist/parse-loaded-file.js +50 -0
- package/dist/position-metadata.d.ts +27 -0
- package/dist/position-metadata.d.ts.map +1 -0
- package/dist/position-metadata.js +88 -0
- package/dist/reference-field-map.d.ts +5 -0
- package/dist/reference-field-map.d.ts.map +1 -1
- package/dist/reference-field-map.js +9 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +68 -1
- package/package.json +3 -3
- package/src/analysis-registry.ts +11 -2
- package/src/analyzer.ts +17 -5
- package/src/definition-registry.ts +76 -3
- package/src/dependency-graph.ts +9 -1
- package/src/flatten-for-analyzer.ts +134 -0
- package/src/index.ts +18 -0
- package/src/loaded-types.ts +86 -0
- package/src/manifest-loader.ts +230 -459
- package/src/normalize-inline-resources.ts +8 -1
- package/src/parse-loaded-file.ts +70 -0
- package/src/position-metadata.ts +106 -0
- package/src/reference-field-map.ts +13 -0
- package/src/types.ts +6 -0
- package/src/validate-references.ts +74 -1
|
@@ -36,6 +36,7 @@ export function normalizeInlineResources(
|
|
|
36
36
|
resources: ResourceManifest[],
|
|
37
37
|
registry: DefinitionRegistry,
|
|
38
38
|
aliases?: AliasResolver,
|
|
39
|
+
aliasesByModule?: Map<string, AliasResolver>,
|
|
39
40
|
): ResourceManifest[] {
|
|
40
41
|
const result = [...resources];
|
|
41
42
|
|
|
@@ -48,7 +49,13 @@ export function normalizeInlineResources(
|
|
|
48
49
|
let i = 0;
|
|
49
50
|
while (i < queue.length) {
|
|
50
51
|
const resource = queue[i++];
|
|
51
|
-
|
|
52
|
+
// When aliasesByModule is available, use the expanded map so inline refs
|
|
53
|
+
// hidden behind an x-telo-schema-from indirection (e.g. an encoder inside
|
|
54
|
+
// an HttpDispatch.Outcomes/$defs/Returns sub-schema) reach extraction.
|
|
55
|
+
const fieldMap =
|
|
56
|
+
aliases && aliasesByModule
|
|
57
|
+
? registry.expandedFieldMapForResource(resource, aliases, aliasesByModule)
|
|
58
|
+
: registry.getFieldMapForKind(resource.kind, aliases);
|
|
52
59
|
if (!fieldMap) continue;
|
|
53
60
|
|
|
54
61
|
const parentName = resource.metadata.name as string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Environment } from "@marcbachmann/cel-js";
|
|
2
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
3
|
+
import { defaultCustomTags } from "@telorun/templating";
|
|
4
|
+
import { parseAllDocuments } from "yaml";
|
|
5
|
+
import { buildCelEnvironment } from "./cel-environment.js";
|
|
6
|
+
import type { LoadedFile, ParseError } from "./loaded-types.js";
|
|
7
|
+
import { buildDocumentPositions } from "./position-metadata.js";
|
|
8
|
+
import { precompileDoc } from "./precompile.js";
|
|
9
|
+
|
|
10
|
+
export interface ParseOptions {
|
|
11
|
+
/** When true, runs `precompileDoc` per document and stamps compiled CEL
|
|
12
|
+
* on the manifests — same flag `LoadOptions.compile` carries today. */
|
|
13
|
+
compile?: boolean;
|
|
14
|
+
/** CEL environment for precompile. Defaults to `buildCelEnvironment()`. */
|
|
15
|
+
celEnv?: Environment;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Pure: text in, structured load result out. No I/O, no caches. */
|
|
19
|
+
export function parseLoadedFile(
|
|
20
|
+
source: string,
|
|
21
|
+
requestedUrl: string,
|
|
22
|
+
text: string,
|
|
23
|
+
options?: ParseOptions,
|
|
24
|
+
): LoadedFile {
|
|
25
|
+
const documents = parseAllDocuments(text, { customTags: defaultCustomTags() });
|
|
26
|
+
const positions = buildDocumentPositions(text, documents);
|
|
27
|
+
|
|
28
|
+
const parseErrors: ParseError[] = [];
|
|
29
|
+
documents.forEach((doc, documentIndex) => {
|
|
30
|
+
for (const err of doc.errors) {
|
|
31
|
+
parseErrors.push({
|
|
32
|
+
documentIndex,
|
|
33
|
+
message: err.message,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const manifests: Array<ResourceManifest | null> = [];
|
|
39
|
+
let env: Environment | undefined;
|
|
40
|
+
for (const doc of documents) {
|
|
41
|
+
const raw = doc.toJSON();
|
|
42
|
+
if (raw === null || raw === undefined) {
|
|
43
|
+
manifests.push(null);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (options?.compile) {
|
|
47
|
+
env ??= options.celEnv ?? buildCelEnvironment();
|
|
48
|
+
try {
|
|
49
|
+
const compiled = precompileDoc(raw, env);
|
|
50
|
+
manifests.push(compiled as ResourceManifest);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Failed to compile manifest in ${source}: ${error instanceof Error ? error.message : String(error)}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
manifests.push(raw as ResourceManifest);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
source,
|
|
63
|
+
requestedUrl,
|
|
64
|
+
text,
|
|
65
|
+
documents,
|
|
66
|
+
manifests,
|
|
67
|
+
positions,
|
|
68
|
+
parseErrors,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { isMap, isPair, isScalar, isSeq, type Document } from "yaml";
|
|
2
|
+
import type { Position, PositionIndex } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/** Single source of truth for "given the source text of a multi-document YAML
|
|
5
|
+
* file, where does each document start, and what is the byte→(line,char)
|
|
6
|
+
* table for the file." Both the analyzer's `Loader` and editor frontends
|
|
7
|
+
* feed the same parsed `yaml.Document[]` through this so diagnostics
|
|
8
|
+
* resolved against `positionIndex` / `sourceLine` line up identically
|
|
9
|
+
* across hosts. */
|
|
10
|
+
|
|
11
|
+
/** Per-document position metadata used by `normalizeDiagnostic`'s fallback chain. */
|
|
12
|
+
export interface DocumentPosition {
|
|
13
|
+
sourceLine: number;
|
|
14
|
+
positionIndex: PositionIndex;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Builds DocumentPosition entries aligned to `parsedDocs[i]`. */
|
|
18
|
+
export function buildDocumentPositions(
|
|
19
|
+
text: string,
|
|
20
|
+
parsedDocs: Document[],
|
|
21
|
+
): DocumentPosition[] {
|
|
22
|
+
const docOffsets = documentLineOffsets(text);
|
|
23
|
+
const lineOffsets = buildLineOffsets(text);
|
|
24
|
+
return parsedDocs.map((doc, i) => ({
|
|
25
|
+
sourceLine: docOffsets[i] ?? 0,
|
|
26
|
+
positionIndex: buildPositionIndex(doc, lineOffsets),
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Line numbers (0-indexed) where each YAML document in a multi-doc file
|
|
31
|
+
* starts. The first document is always at line 0; subsequent entries point
|
|
32
|
+
* to the line after each `---` directive. */
|
|
33
|
+
export function documentLineOffsets(text: string): number[] {
|
|
34
|
+
const offsets = [0];
|
|
35
|
+
const lines = text.split("\n");
|
|
36
|
+
for (let i = 0; i < lines.length; i++) {
|
|
37
|
+
const t = lines[i].trimEnd();
|
|
38
|
+
if (t === "---" || t.startsWith("--- ")) offsets.push(i + 1);
|
|
39
|
+
}
|
|
40
|
+
return offsets;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Byte-offset → start-of-line lookup table. Index `i` is the byte offset of
|
|
44
|
+
* the first character on line `i`. Used with `offsetToPosition` to turn a
|
|
45
|
+
* yaml-AST node range into Range coordinates. */
|
|
46
|
+
export function buildLineOffsets(text: string): number[] {
|
|
47
|
+
const offsets: number[] = [0];
|
|
48
|
+
for (let i = 0; i < text.length; i++) {
|
|
49
|
+
if (text[i] === "\n") offsets.push(i + 1);
|
|
50
|
+
}
|
|
51
|
+
return offsets;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function offsetToPosition(offset: number, lineOffsets: number[]): Position {
|
|
55
|
+
let lo = 0;
|
|
56
|
+
let hi = lineOffsets.length - 1;
|
|
57
|
+
while (lo < hi) {
|
|
58
|
+
const mid = (lo + hi + 1) >> 1;
|
|
59
|
+
if (lineOffsets[mid] <= offset) lo = mid;
|
|
60
|
+
else hi = mid - 1;
|
|
61
|
+
}
|
|
62
|
+
return { line: lo, character: offset - lineOffsets[lo] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Walks the YAML AST and records source ranges for every field value, keyed
|
|
66
|
+
* by dotted path (e.g. "kind", "config.handler", "config.routes[0].path"). */
|
|
67
|
+
export function buildPositionIndex(doc: Document, lineOffsets: number[]): PositionIndex {
|
|
68
|
+
const index: PositionIndex = new Map();
|
|
69
|
+
|
|
70
|
+
function recordNode(node: any, path: string): void {
|
|
71
|
+
if (!node || !node.range) return;
|
|
72
|
+
const [start, , end] = node.range as [number, number, number];
|
|
73
|
+
index.set(path, {
|
|
74
|
+
start: offsetToPosition(start, lineOffsets),
|
|
75
|
+
end: offsetToPosition(end, lineOffsets),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function walk(node: any, path: string): void {
|
|
80
|
+
if (isMap(node)) {
|
|
81
|
+
for (const pair of node.items) {
|
|
82
|
+
if (!isPair(pair)) continue;
|
|
83
|
+
const key = isScalar(pair.key) ? String(pair.key.value) : null;
|
|
84
|
+
if (key == null) continue;
|
|
85
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
86
|
+
if (pair.value != null) {
|
|
87
|
+
recordNode(pair.value, childPath);
|
|
88
|
+
walk(pair.value, childPath);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} else if (isSeq(node)) {
|
|
92
|
+
for (let i = 0; i < node.items.length; i++) {
|
|
93
|
+
const item = node.items[i];
|
|
94
|
+
const childPath = `${path}[${i}]`;
|
|
95
|
+
recordNode(item, childPath);
|
|
96
|
+
walk(item, childPath);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (doc.contents) {
|
|
102
|
+
walk(doc.contents, "");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return index;
|
|
106
|
+
}
|
|
@@ -135,6 +135,19 @@ function collectRefs(node: Record<string, any>): string[] {
|
|
|
135
135
|
return refs;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/** Traverses an arbitrary JSON Schema starting at the given path prefix. Used to
|
|
139
|
+
* expand x-telo-schema-from sub-schemas into nested ref/scope entries so Phase 2
|
|
140
|
+
* inline normalization and Phase 5 injection see slots that the local field map
|
|
141
|
+
* hid behind the schema-from indirection. */
|
|
142
|
+
export function buildFieldMapAtPath(
|
|
143
|
+
schema: Record<string, any>,
|
|
144
|
+
pathPrefix: string,
|
|
145
|
+
): ReferenceFieldMap {
|
|
146
|
+
const map: ReferenceFieldMap = new Map();
|
|
147
|
+
traverseNode(schema, pathPrefix, map);
|
|
148
|
+
return map;
|
|
149
|
+
}
|
|
150
|
+
|
|
138
151
|
function traverseNode(node: Record<string, any>, path: string, map: ReferenceFieldMap): void {
|
|
139
152
|
// Scope slot — record and stop; do not recurse into scope contents
|
|
140
153
|
if ("x-telo-scope" in node) {
|
package/src/types.ts
CHANGED
|
@@ -92,4 +92,10 @@ export interface AnalysisOptions {
|
|
|
92
92
|
export interface AnalysisContext {
|
|
93
93
|
aliases?: import("./alias-resolver.js").AliasResolver;
|
|
94
94
|
definitions?: import("./definition-registry.js").DefinitionRegistry;
|
|
95
|
+
/** Per-library alias resolvers keyed by the library's module name. Populated by
|
|
96
|
+
* the analyzer when imports are forwarded from inside imported libraries.
|
|
97
|
+
* Validators that resolve schema-side annotations (e.g. x-telo-schema-from
|
|
98
|
+
* pointing at an imported kind) consult the kind owner's scope here, since
|
|
99
|
+
* the consumer's aliases will not contain a library's private imports. */
|
|
100
|
+
aliasesByModule?: Map<string, import("./alias-resolver.js").AliasResolver>;
|
|
95
101
|
}
|
|
@@ -73,6 +73,7 @@ export function validateReferences(
|
|
|
73
73
|
const diagnostics: AnalysisDiagnostic[] = [];
|
|
74
74
|
const aliases = context.aliases;
|
|
75
75
|
const registry = context.definitions;
|
|
76
|
+
const aliasesByModule = context.aliasesByModule;
|
|
76
77
|
if (!aliases || !registry) return diagnostics;
|
|
77
78
|
|
|
78
79
|
// Build outer resource lookup by name for resolution check.
|
|
@@ -86,7 +87,12 @@ export function validateReferences(
|
|
|
86
87
|
for (const r of resources) {
|
|
87
88
|
if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
|
|
88
89
|
|
|
89
|
-
|
|
90
|
+
// Use the expanded map so refs nested behind x-telo-schema-from get the
|
|
91
|
+
// same kind-check / unresolved-name validation as locally-declared refs.
|
|
92
|
+
// Falls back to the base map when aliasesByModule isn't supplied.
|
|
93
|
+
const fieldMap = aliasesByModule
|
|
94
|
+
? registry.expandedFieldMapForResource(r, aliases, aliasesByModule)
|
|
95
|
+
: registry.getFieldMapForKind(r.kind, aliases);
|
|
90
96
|
if (!fieldMap) continue;
|
|
91
97
|
|
|
92
98
|
const resourceLabel = `${r.kind}/${r.metadata.name as string}`;
|
|
@@ -260,6 +266,73 @@ export function validateReferences(
|
|
|
260
266
|
const anchorName = expr.slice(0, slashIdx);
|
|
261
267
|
const jsonPointer = "/" + expr.slice(slashIdx + 1);
|
|
262
268
|
|
|
269
|
+
// Aliased absolute kind path — first segment carries a dot, e.g.
|
|
270
|
+
// "HttpDispatch.Outcomes/$defs/Returns". Resolves the alias through the
|
|
271
|
+
// *kind owner's* scope (not the consumer's), navigates the JSON Pointer
|
|
272
|
+
// into the resolved definition's schema, and validates each field value.
|
|
273
|
+
//
|
|
274
|
+
// Relative anchors are property names that cannot contain a dot
|
|
275
|
+
// (CEL-style identifiers), so a dot in anchorName is unambiguous.
|
|
276
|
+
if (!isAbsolute && anchorName.includes(".")) {
|
|
277
|
+
const resolvedResourceKind = aliases.resolveKind(r.kind) ?? r.kind;
|
|
278
|
+
const resourceDef =
|
|
279
|
+
registry.resolve(r.kind) ?? registry.resolve(resolvedResourceKind);
|
|
280
|
+
const owningModule = (resourceDef?.metadata as { module?: string } | undefined)?.module;
|
|
281
|
+
const ownerScope =
|
|
282
|
+
(owningModule ? aliasesByModule?.get(owningModule) : undefined) ?? aliases;
|
|
283
|
+
|
|
284
|
+
const targetKind = ownerScope.resolveKind(anchorName);
|
|
285
|
+
if (!targetKind) {
|
|
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}' → cannot resolve alias '${anchorName}'`,
|
|
291
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
292
|
+
});
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const targetDef = registry.resolve(targetKind);
|
|
297
|
+
if (!targetDef?.schema) {
|
|
298
|
+
diagnostics.push({
|
|
299
|
+
severity: DiagnosticSeverity.Error,
|
|
300
|
+
code: "SCHEMA_FROM_MISSING_PATH",
|
|
301
|
+
source: SOURCE,
|
|
302
|
+
message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${targetKind}' has no schema`,
|
|
303
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
304
|
+
});
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const subSchema = navigateJsonPointer(targetDef.schema, jsonPointer);
|
|
309
|
+
if (subSchema === undefined) {
|
|
310
|
+
diagnostics.push({
|
|
311
|
+
severity: DiagnosticSeverity.Error,
|
|
312
|
+
code: "SCHEMA_FROM_MISSING_PATH",
|
|
313
|
+
source: SOURCE,
|
|
314
|
+
message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${targetKind}' has no schema path '${jsonPointer}'`,
|
|
315
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
316
|
+
});
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
for (const fieldValue of resolveFieldValues(r, fieldPath)) {
|
|
321
|
+
if (fieldValue == null) continue;
|
|
322
|
+
const issues = registry.validateWithRefs(fieldValue, subSchema as Record<string, any>);
|
|
323
|
+
for (const issue of issues) {
|
|
324
|
+
diagnostics.push({
|
|
325
|
+
severity: DiagnosticSeverity.Error,
|
|
326
|
+
code: "DEPENDENT_SCHEMA_MISMATCH",
|
|
327
|
+
source: SOURCE,
|
|
328
|
+
message: `${resourceLabel}: '${fieldPath}' does not match schema from '${anchorName}${jsonPointer}': ${issue}`,
|
|
329
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
263
336
|
// Derive the anchor path in the resource config.
|
|
264
337
|
let anchorPath: string;
|
|
265
338
|
if (isAbsolute) {
|