@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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { ResourceDefinition } from "@telorun/sdk";
|
|
2
|
+
import { KERNEL_BUILTINS } from "./builtins.js";
|
|
3
|
+
import { buildReferenceFieldMap, type ReferenceFieldMap } from "./reference-field-map.js";
|
|
4
|
+
import { createAjv, formatSingleError } from "./schema-compat.js";
|
|
5
|
+
|
|
6
|
+
/** Pure kind → ResourceDefinition map. No controller loading, no lifecycle. */
|
|
7
|
+
export class DefinitionRegistry {
|
|
8
|
+
constructor() {
|
|
9
|
+
for (const def of KERNEL_BUILTINS) this.register(def);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Per-instance AJV for cross-module $ref resolution. Isolated so each registry
|
|
13
|
+
* (and thus each AnalysisContext) has its own schema store — no stale schemas
|
|
14
|
+
* across analyze() calls and no unbounded growth across the process lifetime. */
|
|
15
|
+
private readonly ajv = createAjv();
|
|
16
|
+
private readonly registeredSchemaIds = new Set<string>();
|
|
17
|
+
|
|
18
|
+
private readonly defs = new Map<string, ResourceDefinition>();
|
|
19
|
+
private readonly fieldMaps = new Map<string, ReferenceFieldMap>();
|
|
20
|
+
/** Reverse inheritance index: parent kind → direct child kinds. */
|
|
21
|
+
private readonly extendedBy = new Map<string, string[]>();
|
|
22
|
+
/** Module identity table: identity string → canonical module name.
|
|
23
|
+
* "kernel" → "Kernel", "std/pipeline" → "pipeline", etc. */
|
|
24
|
+
private readonly identityMap = new Map<string, string>();
|
|
25
|
+
/** Reverse identity table: canonical module name → full identity string.
|
|
26
|
+
* "Kernel" → "kernel", "pipeline" → "std/pipeline", etc.
|
|
27
|
+
* Used to compute definition $id values for the AJV schema store. */
|
|
28
|
+
private readonly reverseIdentityMap = new Map<string, string>();
|
|
29
|
+
|
|
30
|
+
register(definition: ResourceDefinition): void {
|
|
31
|
+
const { name, module: mod } = definition.metadata;
|
|
32
|
+
const key = mod ? `${mod}.${name}` : name;
|
|
33
|
+
this.defs.set(key, definition);
|
|
34
|
+
this.fieldMaps.set(key, buildReferenceFieldMap(definition.schema ?? {}));
|
|
35
|
+
if (definition.capability) {
|
|
36
|
+
const children = this.extendedBy.get(definition.capability);
|
|
37
|
+
if (children) {
|
|
38
|
+
children.push(key);
|
|
39
|
+
} else {
|
|
40
|
+
this.extendedBy.set(definition.capability, [key]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Auto-register the kernel identity when any Kernel built-in is registered.
|
|
44
|
+
if (definition.kind === "Kernel.Abstract" && mod === "Kernel") {
|
|
45
|
+
this.identityMap.set("kernel", "Kernel");
|
|
46
|
+
this.reverseIdentityMap.set("Kernel", "kernel");
|
|
47
|
+
}
|
|
48
|
+
// If identity is already known, register the schema in AJV immediately.
|
|
49
|
+
if (mod && definition.schema) {
|
|
50
|
+
this.tryRegisterSchema(mod, name as string, definition.schema as Record<string, any>);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Register a module identity for x-telo-ref resolution.
|
|
55
|
+
* Call once per Kernel.Module manifest when the manifest is loaded.
|
|
56
|
+
* @param namespace The module's metadata.namespace (e.g. "std"), or null for kernel built-ins.
|
|
57
|
+
* @param moduleName The module's metadata.name (e.g. "pipeline", "http-server"). */
|
|
58
|
+
registerModuleIdentity(namespace: string | null, moduleName: string): void {
|
|
59
|
+
const identity = namespace ? `${namespace}/${moduleName}` : "kernel";
|
|
60
|
+
this.identityMap.set(identity, moduleName);
|
|
61
|
+
this.reverseIdentityMap.set(moduleName, identity);
|
|
62
|
+
// Retroactively register AJV schemas for definitions of this module already in the registry.
|
|
63
|
+
for (const def of this.defs.values()) {
|
|
64
|
+
if (def.metadata.module === moduleName && def.schema) {
|
|
65
|
+
this.tryRegisterSchema(
|
|
66
|
+
moduleName,
|
|
67
|
+
def.metadata.name as string,
|
|
68
|
+
def.schema as Record<string, any>,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Computes the $id for a definition schema: "<identity>/<TypeName>".
|
|
75
|
+
* Returns undefined when the module identity is not yet registered. */
|
|
76
|
+
computeId(moduleName: string, typeName: string): string | undefined {
|
|
77
|
+
const identity = this.reverseIdentityMap.get(moduleName);
|
|
78
|
+
if (!identity) return undefined;
|
|
79
|
+
return `${identity}/${typeName}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Validates data against a schema using this registry's AJV instance, which has all
|
|
83
|
+
* registered definition schemas loaded — enabling cross-module $ref resolution. */
|
|
84
|
+
validateWithRefs(data: unknown, schema: Record<string, any>): string[] {
|
|
85
|
+
let validate: ReturnType<typeof this.ajv.compile>;
|
|
86
|
+
try {
|
|
87
|
+
validate = this.ajv.compile(schema);
|
|
88
|
+
} catch {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
if (validate(data)) return [];
|
|
92
|
+
return (validate.errors ?? []).map(formatSingleError);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private tryRegisterSchema(
|
|
96
|
+
moduleName: string,
|
|
97
|
+
typeName: string,
|
|
98
|
+
schema: Record<string, any>,
|
|
99
|
+
): void {
|
|
100
|
+
const id = this.computeId(moduleName, typeName);
|
|
101
|
+
if (!id || this.registeredSchemaIds.has(id)) return;
|
|
102
|
+
if (this.ajv.getSchema(id)) {
|
|
103
|
+
throw new Error(`Duplicate definition schema $id: "${id}" is already registered`);
|
|
104
|
+
}
|
|
105
|
+
this.ajv.addSchema(schema, id);
|
|
106
|
+
this.registeredSchemaIds.add(id);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Resolves an x-telo-ref string to a canonical registry kind key.
|
|
110
|
+
* Splits on "#", looks up the left side in the identity table, and returns
|
|
111
|
+
* "<canonicalModule>.<TypeName>".
|
|
112
|
+
*
|
|
113
|
+
* "kernel#Invocable" → "Kernel.Invocable"
|
|
114
|
+
* "std/pipeline#Job" → "pipeline.Job"
|
|
115
|
+
* "std/http-server#Server" → "http-server.Server"
|
|
116
|
+
*
|
|
117
|
+
* Returns undefined when the string is malformed or the identity is not registered. */
|
|
118
|
+
resolveRef(xTeloRef: string): string | undefined {
|
|
119
|
+
const hash = xTeloRef.indexOf("#");
|
|
120
|
+
if (hash === -1 || hash === xTeloRef.length - 1) return undefined;
|
|
121
|
+
const identity = xTeloRef.slice(0, hash);
|
|
122
|
+
const typeName = xTeloRef.slice(hash + 1);
|
|
123
|
+
const moduleName = this.identityMap.get(identity);
|
|
124
|
+
if (!moduleName) return undefined;
|
|
125
|
+
return `${moduleName}.${typeName}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
resolve(kind: string): ResourceDefinition | undefined {
|
|
129
|
+
return this.defs.get(kind);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Returns the cached reference field map for the given kind, built once during register(). */
|
|
133
|
+
getFieldMap(kind: string): ReferenceFieldMap | undefined {
|
|
134
|
+
return this.fieldMaps.get(kind);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Returns the field map for `kind`, falling back to the alias-resolved kind when not found. */
|
|
138
|
+
getFieldMapForKind(
|
|
139
|
+
kind: string,
|
|
140
|
+
aliases?: { resolveKind(k: string): string | undefined },
|
|
141
|
+
): ReferenceFieldMap | undefined {
|
|
142
|
+
const fm = this.getFieldMap(kind);
|
|
143
|
+
if (fm) return fm;
|
|
144
|
+
const resolved = aliases?.resolveKind(kind);
|
|
145
|
+
return resolved ? this.getFieldMap(resolved) : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Returns all definitions that transitively extend the given abstract kind.
|
|
149
|
+
* Follows the capability chain to any depth (equivalent to instanceof in OOP).
|
|
150
|
+
* Definitions are included regardless of registration order. */
|
|
151
|
+
getByExtends(abstractKind: string): ResourceDefinition[] {
|
|
152
|
+
const result: ResourceDefinition[] = [];
|
|
153
|
+
const queue = [abstractKind];
|
|
154
|
+
while (queue.length > 0) {
|
|
155
|
+
const parent = queue.shift()!;
|
|
156
|
+
const children = this.extendedBy.get(parent);
|
|
157
|
+
if (!children) continue;
|
|
158
|
+
for (const child of children) {
|
|
159
|
+
const def = this.defs.get(child);
|
|
160
|
+
if (def) result.push(def);
|
|
161
|
+
queue.push(child);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
kinds(): string[] {
|
|
168
|
+
return Array.from(this.defs.keys());
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import type { AliasResolver } from "./alias-resolver.js";
|
|
3
|
+
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
4
|
+
import { isRefEntry, isScopeEntry, resolveFieldValues } from "./reference-field-map.js";
|
|
5
|
+
|
|
6
|
+
export interface ResourceNode {
|
|
7
|
+
kind: string;
|
|
8
|
+
name: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DependencyGraph {
|
|
12
|
+
/** Topological order: each resource appears after all its dependencies (leaves first).
|
|
13
|
+
* Present only when the graph is acyclic. */
|
|
14
|
+
order?: ReadonlyArray<ResourceNode>;
|
|
15
|
+
/** The cycle path when a circular dependency is detected.
|
|
16
|
+
* The first and last elements are the same resource, tracing the full loop. */
|
|
17
|
+
cycle?: ReadonlyArray<ResourceNode>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** System resource kinds that are not runtime nodes in the dependency graph. */
|
|
21
|
+
const SYSTEM_KINDS = new Set(["Kernel.Definition", "Kernel.Import"]);
|
|
22
|
+
|
|
23
|
+
const nodeKey = (kind: string, name: string) => `${kind}\0${name}`;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Builds a directed acyclic graph (DAG) of runtime resource dependencies and
|
|
27
|
+
* returns either a topological initialization order or the cycle path.
|
|
28
|
+
*
|
|
29
|
+
* Edges represent boot-time dependencies only:
|
|
30
|
+
* - x-telo-ref fields that fall within a scope visibility path are excluded
|
|
31
|
+
* (scoped resources are initialized on demand at runtime, not at boot).
|
|
32
|
+
* - x-telo-scope fields themselves are excluded from the graph.
|
|
33
|
+
*
|
|
34
|
+
* The registry is queried for each resource's field map by kind — callers do
|
|
35
|
+
* not pre-compute or pass field maps separately.
|
|
36
|
+
*/
|
|
37
|
+
export function buildDependencyGraph(
|
|
38
|
+
resources: ResourceManifest[],
|
|
39
|
+
registry: DefinitionRegistry,
|
|
40
|
+
aliases?: AliasResolver,
|
|
41
|
+
): DependencyGraph {
|
|
42
|
+
// --- Build node set ---
|
|
43
|
+
const nodes = new Map<string, ResourceNode>();
|
|
44
|
+
for (const r of resources) {
|
|
45
|
+
if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
|
|
46
|
+
const key = nodeKey(r.kind, r.metadata.name as string);
|
|
47
|
+
nodes.set(key, { kind: r.kind, name: r.metadata.name as string });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Build adjacency: from → deps (from depends on dep) ---
|
|
51
|
+
const deps = new Map<string, Set<string>>();
|
|
52
|
+
for (const key of nodes.keys()) deps.set(key, new Set());
|
|
53
|
+
|
|
54
|
+
for (const r of resources) {
|
|
55
|
+
if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
|
|
56
|
+
|
|
57
|
+
const sourceKey = nodeKey(r.kind, r.metadata.name as string);
|
|
58
|
+
const fieldMap = registry.getFieldMapForKind(r.kind, aliases);
|
|
59
|
+
if (!fieldMap) continue;
|
|
60
|
+
|
|
61
|
+
// Collect names of resources declared inside scope fields — these are initialized
|
|
62
|
+
// on-demand at runtime, not at boot, so edges pointing to them are excluded from the DAG.
|
|
63
|
+
const scopedNames = new Set<string>();
|
|
64
|
+
for (const [scopeFieldPath, entry] of fieldMap) {
|
|
65
|
+
if (!isScopeEntry(entry)) continue;
|
|
66
|
+
const scopeVal = (r as Record<string, unknown>)[scopeFieldPath];
|
|
67
|
+
if (!Array.isArray(scopeVal)) continue;
|
|
68
|
+
for (const item of scopeVal) {
|
|
69
|
+
const name = (item as any)?.metadata?.name;
|
|
70
|
+
if (typeof name === "string") scopedNames.add(name);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const [fieldPath, entry] of fieldMap) {
|
|
75
|
+
if (!isRefEntry(entry)) continue;
|
|
76
|
+
|
|
77
|
+
for (const val of resolveFieldValues(r, fieldPath)) {
|
|
78
|
+
if (!val || typeof val !== "object") continue;
|
|
79
|
+
const ref = val as Record<string, unknown>;
|
|
80
|
+
if (!ref.kind || !ref.name) continue;
|
|
81
|
+
// Edges to scoped resources are runtime deps, not boot-time deps — exclude from DAG
|
|
82
|
+
if (scopedNames.has(ref.name as string)) continue;
|
|
83
|
+
const targetKey = nodeKey(ref.kind as string, ref.name as string);
|
|
84
|
+
if (nodes.has(targetKey)) {
|
|
85
|
+
deps.get(sourceKey)!.add(targetKey);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- Kahn's topological sort ---
|
|
92
|
+
// in-degree[X] = number of X's dependencies (size of deps[X])
|
|
93
|
+
// reverse[dep] = set of nodes that depend on dep (for degree decrement)
|
|
94
|
+
const inDegree = new Map<string, number>();
|
|
95
|
+
const reverse = new Map<string, Set<string>>();
|
|
96
|
+
for (const key of nodes.keys()) {
|
|
97
|
+
inDegree.set(key, deps.get(key)!.size);
|
|
98
|
+
reverse.set(key, new Set());
|
|
99
|
+
}
|
|
100
|
+
for (const [from, depSet] of deps) {
|
|
101
|
+
for (const dep of depSet) {
|
|
102
|
+
reverse.get(dep)?.add(from);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const queue: string[] = [];
|
|
107
|
+
for (const [key, deg] of inDegree) {
|
|
108
|
+
if (deg === 0) queue.push(key);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const sorted: ResourceNode[] = [];
|
|
112
|
+
while (queue.length > 0) {
|
|
113
|
+
const key = queue.shift()!;
|
|
114
|
+
sorted.push(nodes.get(key)!);
|
|
115
|
+
for (const dependent of reverse.get(key)!) {
|
|
116
|
+
const deg = inDegree.get(dependent)! - 1;
|
|
117
|
+
inDegree.set(dependent, deg);
|
|
118
|
+
if (deg === 0) queue.push(dependent);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (sorted.length === nodes.size) {
|
|
123
|
+
return { order: sorted };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { cycle: findCycle(nodes, deps) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Formats a cycle result into a human-readable error string matching the spec:
|
|
131
|
+
*
|
|
132
|
+
* Circular dependency detected:
|
|
133
|
+
* Run.Sequence "DataSync"
|
|
134
|
+
* → Http.Server "Api"
|
|
135
|
+
* → Run.Sequence "DataSync"
|
|
136
|
+
*/
|
|
137
|
+
export function formatCycle(cycle: ReadonlyArray<ResourceNode>): string {
|
|
138
|
+
const lines = ["Circular dependency detected:"];
|
|
139
|
+
lines.push(` ${cycle[0].kind} "${cycle[0].name}"`);
|
|
140
|
+
for (const node of cycle.slice(1)) {
|
|
141
|
+
lines.push(` → ${node.kind} "${node.name}"`);
|
|
142
|
+
}
|
|
143
|
+
return lines.join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- Internals ---
|
|
147
|
+
|
|
148
|
+
/** DFS cycle detection — returns the cycle path with the repeated start node appended. */
|
|
149
|
+
function findCycle(
|
|
150
|
+
nodes: Map<string, ResourceNode>,
|
|
151
|
+
deps: Map<string, Set<string>>,
|
|
152
|
+
): ResourceNode[] {
|
|
153
|
+
type State = "unvisited" | "visiting" | "visited";
|
|
154
|
+
const state = new Map<string, State>();
|
|
155
|
+
for (const key of nodes.keys()) state.set(key, "unvisited");
|
|
156
|
+
|
|
157
|
+
const stack: string[] = [];
|
|
158
|
+
|
|
159
|
+
function dfs(key: string): string[] | null {
|
|
160
|
+
state.set(key, "visiting");
|
|
161
|
+
stack.push(key);
|
|
162
|
+
|
|
163
|
+
for (const dep of deps.get(key) ?? []) {
|
|
164
|
+
if (state.get(dep) === "visiting") {
|
|
165
|
+
const start = stack.indexOf(dep);
|
|
166
|
+
return [...stack.slice(start), dep];
|
|
167
|
+
}
|
|
168
|
+
if (state.get(dep) === "unvisited") {
|
|
169
|
+
const result = dfs(dep);
|
|
170
|
+
if (result) return result;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
stack.pop();
|
|
175
|
+
state.set(key, "visited");
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const key of nodes.keys()) {
|
|
180
|
+
if (state.get(key) === "unvisited") {
|
|
181
|
+
const result = dfs(key);
|
|
182
|
+
if (result) return result.map((k) => nodes.get(k)!);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return [];
|
|
187
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { HttpAdapter } from "./adapters/http-adapter.js";
|
|
2
|
+
export { createNodeAdapter, NodeAdapter } from "./adapters/node-adapter.js";
|
|
3
|
+
export { RegistryAdapter } from "./adapters/registry-adapter.js";
|
|
4
|
+
export { AnalysisRegistry } from "./analysis-registry.js";
|
|
5
|
+
export { StaticAnalyzer } from "./analyzer.js";
|
|
6
|
+
export { Loader } from "./manifest-loader.js";
|
|
7
|
+
export { DiagnosticSeverity } from "./types.js";
|
|
8
|
+
export type {
|
|
9
|
+
AnalysisDiagnostic,
|
|
10
|
+
AnalysisOptions,
|
|
11
|
+
LoadOptions,
|
|
12
|
+
ManifestAdapter,
|
|
13
|
+
Position,
|
|
14
|
+
PositionIndex,
|
|
15
|
+
Range
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { isMap, isPair, isScalar, isSeq, parseAllDocuments, type Document } from "yaml";
|
|
3
|
+
import { HttpAdapter } from "./adapters/http-adapter.js";
|
|
4
|
+
import { RegistryAdapter } from "./adapters/registry-adapter.js";
|
|
5
|
+
import { precompileDoc } from "./precompile.js";
|
|
6
|
+
import type { LoadOptions, ManifestAdapter, Position, PositionIndex } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export class Loader {
|
|
9
|
+
protected adapters: ManifestAdapter[] = [new HttpAdapter(), new RegistryAdapter()];
|
|
10
|
+
|
|
11
|
+
constructor(extraAdapters: ManifestAdapter[] = []) {
|
|
12
|
+
this.adapters.unshift(...extraAdapters);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
register(adapter: ManifestAdapter): this {
|
|
16
|
+
this.adapters.unshift(adapter);
|
|
17
|
+
return this;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private pick(url: string): ManifestAdapter {
|
|
21
|
+
const a = this.adapters.find((a) => a.supports(url));
|
|
22
|
+
if (!a) throw new Error(`No adapter found for: ${url}`);
|
|
23
|
+
return a;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async loadModule(url: string, options?: LoadOptions): Promise<ResourceManifest[]> {
|
|
27
|
+
const { text, source } = await this.pick(url).read(url);
|
|
28
|
+
const parsedDocuments = parseAllDocuments(text);
|
|
29
|
+
const rawDocs = parsedDocuments.map((d) => d.toJSON());
|
|
30
|
+
const offsets = documentLineOffsets(text);
|
|
31
|
+
const lineOffsets = buildLineOffsets(text);
|
|
32
|
+
|
|
33
|
+
const resolved: ResourceManifest[] = [];
|
|
34
|
+
let docIdx = 0;
|
|
35
|
+
for (const rawDoc of rawDocs) {
|
|
36
|
+
const currentDocIdx = docIdx++;
|
|
37
|
+
const sourceLine = offsets[currentDocIdx] ?? 0;
|
|
38
|
+
const positionIndex = buildPositionIndex(parsedDocuments[currentDocIdx], lineOffsets);
|
|
39
|
+
if (rawDoc === null || rawDoc === undefined) continue;
|
|
40
|
+
|
|
41
|
+
let compiledDocs: unknown[];
|
|
42
|
+
if (options?.compile) {
|
|
43
|
+
try {
|
|
44
|
+
const result = precompileDoc(rawDoc);
|
|
45
|
+
compiledDocs = Array.isArray(result) ? result : [result];
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Failed to compile manifest in ${source}: ${error instanceof Error ? error.message : String(error)}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
compiledDocs = [rawDoc];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const doc of compiledDocs) {
|
|
56
|
+
if (doc === null || doc === undefined) continue;
|
|
57
|
+
const manifest = doc as ResourceManifest;
|
|
58
|
+
const metadata = { ...manifest.metadata, source, sourceLine };
|
|
59
|
+
// positionIndex is non-enumerable so it is invisible to spread, JSON.stringify,
|
|
60
|
+
// and schema validation — but still accessible via (m.metadata as any).positionIndex.
|
|
61
|
+
Object.defineProperty(metadata, "positionIndex", {
|
|
62
|
+
value: positionIndex,
|
|
63
|
+
enumerable: false,
|
|
64
|
+
writable: true,
|
|
65
|
+
configurable: true,
|
|
66
|
+
});
|
|
67
|
+
resolved.push({ ...manifest, metadata });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const moduleManifests = resolved.filter((m) => m.kind === "Kernel.Module");
|
|
72
|
+
if (moduleManifests.length > 1) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`File '${source}' contains ${moduleManifests.length} Kernel.Module declarations. Maximum one is allowed.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const moduleManifest = moduleManifests[0];
|
|
78
|
+
const moduleName = moduleManifest?.metadata?.name as string | undefined;
|
|
79
|
+
if (moduleName) {
|
|
80
|
+
for (const manifest of resolved) {
|
|
81
|
+
if (manifest.kind !== "Kernel.Module" && !manifest.metadata?.module) {
|
|
82
|
+
manifest.metadata = { ...manifest.metadata, module: moduleName };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return resolved;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async loadManifests(entryUrl: string): Promise<ResourceManifest[]> {
|
|
91
|
+
const visited = new Set<string>([entryUrl]);
|
|
92
|
+
const entry = await this.loadModule(entryUrl);
|
|
93
|
+
|
|
94
|
+
const importedDefs: ResourceManifest[] = [];
|
|
95
|
+
const queue: ResourceManifest[] = [...entry];
|
|
96
|
+
|
|
97
|
+
while (queue.length > 0) {
|
|
98
|
+
const m = queue.shift()!;
|
|
99
|
+
if (m.kind !== "Kernel.Import") continue;
|
|
100
|
+
const importSource = (m as any).source as string | undefined;
|
|
101
|
+
if (!importSource) continue;
|
|
102
|
+
const base = (m.metadata as any)?.source ?? entryUrl;
|
|
103
|
+
const importUrl = this.pick(base).resolveRelative(base, importSource);
|
|
104
|
+
if (visited.has(importUrl)) continue;
|
|
105
|
+
visited.add(importUrl);
|
|
106
|
+
let imported: ResourceManifest[];
|
|
107
|
+
try {
|
|
108
|
+
imported = await this.loadModule(importUrl);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
111
|
+
(e as any).sourceLine = (m.metadata as any)?.sourceLine ?? 0;
|
|
112
|
+
throw e;
|
|
113
|
+
}
|
|
114
|
+
const importedModule = imported.find((im) => im.kind === "Kernel.Module");
|
|
115
|
+
if (importedModule?.metadata?.name) {
|
|
116
|
+
m.metadata = {
|
|
117
|
+
...m.metadata,
|
|
118
|
+
resolvedModuleName: importedModule.metadata.name as string,
|
|
119
|
+
resolvedNamespace: (importedModule.metadata as any).namespace ?? null,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
for (const im of imported) {
|
|
123
|
+
if (im.kind === "Kernel.Definition") importedDefs.push(im);
|
|
124
|
+
if (im.kind === "Kernel.Import") queue.push(im);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return [...entry, ...importedDefs];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function documentLineOffsets(text: string): number[] {
|
|
133
|
+
const offsets = [0];
|
|
134
|
+
const lines = text.split("\n");
|
|
135
|
+
for (let i = 0; i < lines.length; i++) {
|
|
136
|
+
const t = lines[i].trimEnd();
|
|
137
|
+
if (t === "---" || t.startsWith("--- ")) offsets.push(i + 1);
|
|
138
|
+
}
|
|
139
|
+
return offsets;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Builds a byte-offset-to-line/character lookup table from raw text. */
|
|
143
|
+
function buildLineOffsets(text: string): number[] {
|
|
144
|
+
const offsets: number[] = [0];
|
|
145
|
+
for (let i = 0; i < text.length; i++) {
|
|
146
|
+
if (text[i] === "\n") offsets.push(i + 1);
|
|
147
|
+
}
|
|
148
|
+
return offsets;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function offsetToPosition(offset: number, lineOffsets: number[]): Position {
|
|
152
|
+
let lo = 0;
|
|
153
|
+
let hi = lineOffsets.length - 1;
|
|
154
|
+
while (lo < hi) {
|
|
155
|
+
const mid = (lo + hi + 1) >> 1;
|
|
156
|
+
if (lineOffsets[mid] <= offset) lo = mid;
|
|
157
|
+
else hi = mid - 1;
|
|
158
|
+
}
|
|
159
|
+
return { line: lo, character: offset - lineOffsets[lo] };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Walks the YAML AST and records source ranges for every field value, keyed by
|
|
163
|
+
* dotted path (e.g. "kind", "config.handler", "config.routes[0].path"). */
|
|
164
|
+
function buildPositionIndex(doc: Document, lineOffsets: number[]): PositionIndex {
|
|
165
|
+
const index: PositionIndex = new Map();
|
|
166
|
+
|
|
167
|
+
function recordNode(node: any, path: string): void {
|
|
168
|
+
if (!node || !node.range) return;
|
|
169
|
+
const [start, , end] = node.range as [number, number, number];
|
|
170
|
+
index.set(path, {
|
|
171
|
+
start: offsetToPosition(start, lineOffsets),
|
|
172
|
+
end: offsetToPosition(end, lineOffsets),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function walk(node: any, path: string): void {
|
|
177
|
+
if (isMap(node)) {
|
|
178
|
+
for (const pair of node.items) {
|
|
179
|
+
if (!isPair(pair)) continue;
|
|
180
|
+
const key = isScalar(pair.key) ? String(pair.key.value) : null;
|
|
181
|
+
if (key == null) continue;
|
|
182
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
183
|
+
if (pair.value != null) {
|
|
184
|
+
recordNode(pair.value, childPath);
|
|
185
|
+
walk(pair.value, childPath);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} else if (isSeq(node)) {
|
|
189
|
+
for (let i = 0; i < node.items.length; i++) {
|
|
190
|
+
const item = node.items[i];
|
|
191
|
+
const childPath = `${path}[${i}]`;
|
|
192
|
+
recordNode(item, childPath);
|
|
193
|
+
walk(item, childPath);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (doc.contents) {
|
|
199
|
+
walk(doc.contents, "");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return index;
|
|
203
|
+
}
|