@telorun/analyzer 0.8.0 → 0.9.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 +1 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +2 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +23 -21
- 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/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/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 +62 -0
- package/package.json +3 -3
- package/src/analysis-registry.ts +2 -1
- package/src/analyzer.ts +33 -29
- 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/parse-loaded-file.ts +70 -0
- package/src/position-metadata.ts +106 -0
- package/src/types.ts +6 -0
- package/src/validate-references.ts +68 -0
|
@@ -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
|
+
}
|
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.
|
|
@@ -260,6 +261,73 @@ export function validateReferences(
|
|
|
260
261
|
const anchorName = expr.slice(0, slashIdx);
|
|
261
262
|
const jsonPointer = "/" + expr.slice(slashIdx + 1);
|
|
262
263
|
|
|
264
|
+
// Aliased absolute kind path — first segment carries a dot, e.g.
|
|
265
|
+
// "HttpDispatch.Outcomes/$defs/Returns". Resolves the alias through the
|
|
266
|
+
// *kind owner's* scope (not the consumer's), navigates the JSON Pointer
|
|
267
|
+
// into the resolved definition's schema, and validates each field value.
|
|
268
|
+
//
|
|
269
|
+
// Relative anchors are property names that cannot contain a dot
|
|
270
|
+
// (CEL-style identifiers), so a dot in anchorName is unambiguous.
|
|
271
|
+
if (!isAbsolute && anchorName.includes(".")) {
|
|
272
|
+
const resolvedResourceKind = aliases.resolveKind(r.kind) ?? r.kind;
|
|
273
|
+
const resourceDef =
|
|
274
|
+
registry.resolve(r.kind) ?? registry.resolve(resolvedResourceKind);
|
|
275
|
+
const owningModule = (resourceDef?.metadata as { module?: string } | undefined)?.module;
|
|
276
|
+
const ownerScope =
|
|
277
|
+
(owningModule ? aliasesByModule?.get(owningModule) : undefined) ?? aliases;
|
|
278
|
+
|
|
279
|
+
const targetKind = ownerScope.resolveKind(anchorName);
|
|
280
|
+
if (!targetKind) {
|
|
281
|
+
diagnostics.push({
|
|
282
|
+
severity: DiagnosticSeverity.Error,
|
|
283
|
+
code: "SCHEMA_FROM_MISSING_PATH",
|
|
284
|
+
source: SOURCE,
|
|
285
|
+
message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → cannot resolve alias '${anchorName}'`,
|
|
286
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
287
|
+
});
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const targetDef = registry.resolve(targetKind);
|
|
292
|
+
if (!targetDef?.schema) {
|
|
293
|
+
diagnostics.push({
|
|
294
|
+
severity: DiagnosticSeverity.Error,
|
|
295
|
+
code: "SCHEMA_FROM_MISSING_PATH",
|
|
296
|
+
source: SOURCE,
|
|
297
|
+
message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${targetKind}' has no schema`,
|
|
298
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
299
|
+
});
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const subSchema = navigateJsonPointer(targetDef.schema, jsonPointer);
|
|
304
|
+
if (subSchema === undefined) {
|
|
305
|
+
diagnostics.push({
|
|
306
|
+
severity: DiagnosticSeverity.Error,
|
|
307
|
+
code: "SCHEMA_FROM_MISSING_PATH",
|
|
308
|
+
source: SOURCE,
|
|
309
|
+
message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${targetKind}' has no schema path '${jsonPointer}'`,
|
|
310
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
311
|
+
});
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for (const fieldValue of resolveFieldValues(r, fieldPath)) {
|
|
316
|
+
if (fieldValue == null) continue;
|
|
317
|
+
const issues = registry.validateWithRefs(fieldValue, subSchema as Record<string, any>);
|
|
318
|
+
for (const issue of issues) {
|
|
319
|
+
diagnostics.push({
|
|
320
|
+
severity: DiagnosticSeverity.Error,
|
|
321
|
+
code: "DEPENDENT_SCHEMA_MISMATCH",
|
|
322
|
+
source: SOURCE,
|
|
323
|
+
message: `${resourceLabel}: '${fieldPath}' does not match schema from '${anchorName}${jsonPointer}': ${issue}`,
|
|
324
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
263
331
|
// Derive the anchor path in the resource config.
|
|
264
332
|
let anchorPath: string;
|
|
265
333
|
if (isAbsolute) {
|