Skip to content

Commit 5d8b1b4

Browse files
authored
Merge pull request #142 from objectstack-ai/copilot/optimize-kernel-dependencies
2 parents 8ac0b10 + 3a9628f commit 5d8b1b4

21 files changed

+7432
-4805
lines changed

packages/kernel/package-lock.json

Lines changed: 0 additions & 4801 deletions
This file was deleted.

packages/kernel/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"@objectql/types": "^3.0.1",
1414
"@objectstack/spec": "0.6.1",
1515
"fast-glob": "^3.3.2",
16-
"js-yaml": "^4.1.0"
16+
"js-yaml": "^4.1.0",
17+
"zod": "^3.25.76"
1718
},
1819
"devDependencies": {
1920
"@types/jest": "^30.0.0",
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
/**
2+
* Dependency Resolver
3+
*
4+
* Resolves plugin dependencies using semver and topological sorting.
5+
* Ensures plugins are loaded in the correct order based on their dependencies.
6+
*/
7+
8+
import type { ObjectStackManifest } from '@objectstack/spec/system';
9+
import { Logger, createLogger } from './logger';
10+
11+
/**
12+
* Dependency graph node
13+
*/
14+
interface DependencyNode {
15+
id: string;
16+
manifest: ObjectStackManifest;
17+
dependencies: string[];
18+
dependents: Set<string>;
19+
}
20+
21+
/**
22+
* Dependency resolution result
23+
*/
24+
export interface DependencyResolutionResult {
25+
/** Plugins in the order they should be loaded */
26+
loadOrder: string[];
27+
/** Circular dependencies detected */
28+
cycles: string[][];
29+
/** Missing dependencies */
30+
missing: Map<string, string[]>;
31+
}
32+
33+
/**
34+
* Dependency validation error
35+
*/
36+
export class DependencyError extends Error {
37+
constructor(
38+
message: string,
39+
public readonly type: 'CIRCULAR' | 'MISSING' | 'VERSION_CONFLICT',
40+
public readonly plugins?: string[]
41+
) {
42+
super(message);
43+
this.name = 'DependencyError';
44+
}
45+
}
46+
47+
/**
48+
* Dependency Resolver
49+
*
50+
* Analyzes plugin dependencies and determines safe loading order.
51+
*/
52+
export class DependencyResolver {
53+
private logger: Logger;
54+
private nodes: Map<string, DependencyNode> = new Map();
55+
56+
constructor() {
57+
this.logger = createLogger('DependencyResolver');
58+
}
59+
60+
/**
61+
* Add a plugin to the dependency graph
62+
*/
63+
addPlugin(manifest: ObjectStackManifest): void {
64+
const dependencies = this.extractDependencies(manifest);
65+
66+
const node: DependencyNode = {
67+
id: manifest.id,
68+
manifest,
69+
dependencies,
70+
dependents: new Set(),
71+
};
72+
73+
this.nodes.set(manifest.id, node);
74+
this.logger.debug(`Added plugin '${manifest.id}' with ${dependencies.length} dependencies`);
75+
}
76+
77+
/**
78+
* Extract dependency IDs from manifest
79+
*/
80+
private extractDependencies(manifest: ObjectStackManifest): string[] {
81+
const deps: string[] = [];
82+
83+
// Check contributes.dependencies (spec-compliant location)
84+
const contributes = manifest.contributes as any;
85+
if (contributes?.dependencies) {
86+
if (Array.isArray(contributes.dependencies)) {
87+
deps.push(...contributes.dependencies);
88+
} else if (typeof contributes.dependencies === 'object') {
89+
deps.push(...Object.keys(contributes.dependencies));
90+
}
91+
}
92+
93+
// Check top-level dependencies (legacy support)
94+
if ((manifest as any).dependencies) {
95+
const legacyDeps = (manifest as any).dependencies;
96+
if (Array.isArray(legacyDeps)) {
97+
deps.push(...legacyDeps);
98+
} else if (typeof legacyDeps === 'object') {
99+
deps.push(...Object.keys(legacyDeps));
100+
}
101+
}
102+
103+
return [...new Set(deps)]; // Remove duplicates
104+
}
105+
106+
/**
107+
* Resolve dependencies and return load order
108+
*/
109+
resolve(): DependencyResolutionResult {
110+
// Build dependency edges
111+
this.buildDependencyGraph();
112+
113+
// Detect circular dependencies
114+
const cycles = this.detectCycles();
115+
if (cycles.length > 0) {
116+
this.logger.warn(`Detected ${cycles.length} circular dependency cycle(s)`);
117+
}
118+
119+
// Detect missing dependencies
120+
const missing = this.detectMissing();
121+
if (missing.size > 0) {
122+
this.logger.warn(`Detected ${missing.size} plugin(s) with missing dependencies`);
123+
}
124+
125+
// Perform topological sort
126+
const loadOrder = this.topologicalSort();
127+
128+
return {
129+
loadOrder,
130+
cycles,
131+
missing,
132+
};
133+
}
134+
135+
/**
136+
* Build the dependency graph by creating edges between nodes
137+
*/
138+
private buildDependencyGraph(): void {
139+
for (const node of this.nodes.values()) {
140+
for (const depId of node.dependencies) {
141+
const depNode = this.nodes.get(depId);
142+
if (depNode) {
143+
depNode.dependents.add(node.id);
144+
}
145+
}
146+
}
147+
}
148+
149+
/**
150+
* Detect circular dependencies using DFS
151+
*/
152+
private detectCycles(): string[][] {
153+
const cycles: string[][] = [];
154+
const visited = new Set<string>();
155+
const recursionStack = new Set<string>();
156+
const path: string[] = [];
157+
158+
const dfs = (nodeId: string): boolean => {
159+
visited.add(nodeId);
160+
recursionStack.add(nodeId);
161+
path.push(nodeId);
162+
163+
const node = this.nodes.get(nodeId);
164+
if (!node) return false;
165+
166+
for (const depId of node.dependencies) {
167+
if (!visited.has(depId)) {
168+
if (dfs(depId)) {
169+
return true;
170+
}
171+
} else if (recursionStack.has(depId)) {
172+
// Found a cycle
173+
const cycleStart = path.indexOf(depId);
174+
const cycle = path.slice(cycleStart);
175+
cycles.push([...cycle, depId]);
176+
return true;
177+
}
178+
}
179+
180+
path.pop();
181+
recursionStack.delete(nodeId);
182+
return false;
183+
};
184+
185+
for (const nodeId of this.nodes.keys()) {
186+
if (!visited.has(nodeId)) {
187+
dfs(nodeId);
188+
}
189+
}
190+
191+
return cycles;
192+
}
193+
194+
/**
195+
* Detect missing dependencies
196+
*/
197+
private detectMissing(): Map<string, string[]> {
198+
const missing = new Map<string, string[]>();
199+
200+
for (const node of this.nodes.values()) {
201+
const missingDeps = node.dependencies.filter(
202+
depId => !this.nodes.has(depId)
203+
);
204+
205+
if (missingDeps.length > 0) {
206+
missing.set(node.id, missingDeps);
207+
}
208+
}
209+
210+
return missing;
211+
}
212+
213+
/**
214+
* Perform topological sort using Kahn's algorithm
215+
* Returns plugins in the order they should be loaded
216+
*/
217+
private topologicalSort(): string[] {
218+
const result: string[] = [];
219+
const inDegree = new Map<string, number>();
220+
221+
// Calculate in-degree for each node
222+
for (const node of this.nodes.values()) {
223+
if (!inDegree.has(node.id)) {
224+
inDegree.set(node.id, 0);
225+
}
226+
for (const depId of node.dependencies) {
227+
if (this.nodes.has(depId)) {
228+
const currentDegree = inDegree.get(node.id) || 0;
229+
inDegree.set(node.id, currentDegree + 1);
230+
}
231+
}
232+
}
233+
234+
// Queue of nodes with no dependencies
235+
const queue: string[] = [];
236+
for (const [nodeId, degree] of inDegree.entries()) {
237+
if (degree === 0) {
238+
queue.push(nodeId);
239+
}
240+
}
241+
242+
// Process queue
243+
while (queue.length > 0) {
244+
const nodeId = queue.shift()!;
245+
result.push(nodeId);
246+
247+
const node = this.nodes.get(nodeId);
248+
if (!node) continue;
249+
250+
// Reduce in-degree of dependents
251+
for (const dependentId of node.dependents) {
252+
const currentDegree = inDegree.get(dependentId) || 0;
253+
const newDegree = currentDegree - 1;
254+
inDegree.set(dependentId, newDegree);
255+
256+
if (newDegree === 0) {
257+
queue.push(dependentId);
258+
}
259+
}
260+
}
261+
262+
// If not all nodes are processed, there's a cycle
263+
if (result.length !== this.nodes.size) {
264+
this.logger.warn(
265+
`Topological sort incomplete: ${result.length}/${this.nodes.size} plugins sorted. ` +
266+
`This indicates circular dependencies.`
267+
);
268+
}
269+
270+
return result;
271+
}
272+
273+
/**
274+
* Validate that all dependencies can be resolved
275+
* Throws DependencyError if validation fails
276+
*/
277+
validate(): void {
278+
const result = this.resolve();
279+
280+
// Check for missing dependencies
281+
if (result.missing.size > 0) {
282+
const errors: string[] = [];
283+
for (const [pluginId, missingDeps] of result.missing.entries()) {
284+
errors.push(`Plugin '${pluginId}' requires: ${missingDeps.join(', ')}`);
285+
}
286+
throw new DependencyError(
287+
`Missing dependencies:\n${errors.join('\n')}`,
288+
'MISSING',
289+
Array.from(result.missing.keys())
290+
);
291+
}
292+
293+
// Check for circular dependencies
294+
if (result.cycles.length > 0) {
295+
const cycleDescriptions = result.cycles.map(
296+
cycle => cycle.join(' -> ')
297+
);
298+
throw new DependencyError(
299+
`Circular dependencies detected:\n${cycleDescriptions.join('\n')}`,
300+
'CIRCULAR',
301+
result.cycles[0]
302+
);
303+
}
304+
}
305+
306+
/**
307+
* Get the dependency graph as a string for debugging
308+
*/
309+
getGraphDescription(): string {
310+
const lines: string[] = ['Dependency Graph:'];
311+
312+
for (const node of this.nodes.values()) {
313+
const deps = node.dependencies.length > 0
314+
? ` -> [${node.dependencies.join(', ')}]`
315+
: ' (no dependencies)';
316+
lines.push(` ${node.id}${deps}`);
317+
}
318+
319+
return lines.join('\n');
320+
}
321+
322+
/**
323+
* Clear the dependency graph
324+
*/
325+
clear(): void {
326+
this.nodes.clear();
327+
}
328+
}

0 commit comments

Comments
 (0)