octo-dev 0.2.2

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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +270 -0
  3. package/package.json +62 -0
  4. package/scripts/install.sh +117 -0
  5. package/src/build/adapters/docker-build-engine.adapter.ts +39 -0
  6. package/src/build/affected-detector.ts +126 -0
  7. package/src/build/build-orchestrator.ts +169 -0
  8. package/src/build/build-scheduler.ts +174 -0
  9. package/src/build/ports/build-engine.port.ts +38 -0
  10. package/src/cli/build.command.ts +101 -0
  11. package/src/cli/bump.command.ts +98 -0
  12. package/src/cli/down.command.ts +36 -0
  13. package/src/cli/graph.command.ts +40 -0
  14. package/src/cli/index.ts +80 -0
  15. package/src/cli/init.command.ts +106 -0
  16. package/src/cli/status.command.ts +46 -0
  17. package/src/cli/up.command.ts +52 -0
  18. package/src/graph/aggregated-graph.ts +77 -0
  19. package/src/graph/build-graph.ts +125 -0
  20. package/src/graph/dependency-graph.ts +82 -0
  21. package/src/graph/index.ts +4 -0
  22. package/src/graph/topological-sort.ts +104 -0
  23. package/src/hooks/hook-runner.ts +57 -0
  24. package/src/infra/compose-aggregator.ts +152 -0
  25. package/src/infra/compose-smart-merger.ts +93 -0
  26. package/src/infra/infra-manager.ts +157 -0
  27. package/src/manifest/manifest-discovery.ts +144 -0
  28. package/src/manifest/manifest-parser.ts +109 -0
  29. package/src/manifest/manifest-printer.ts +75 -0
  30. package/src/manifest/manifest-schema.ts +34 -0
  31. package/src/shared/errors.ts +43 -0
  32. package/src/shared/logger.ts +14 -0
  33. package/src/shared/process-runner.ts +47 -0
  34. package/src/shared/shutdown.ts +36 -0
  35. package/src/version/changelog-generator.ts +112 -0
  36. package/src/version/version-bumper.ts +116 -0
  37. package/src/version/version-propagator.ts +120 -0
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { triggerShutdown } from '../shared/shutdown.js';
4
+
5
+ // Graceful shutdown on SIGINT (Ctrl+C) and SIGTERM
6
+ process.on('SIGINT', () => triggerShutdown('SIGINT'));
7
+ process.on('SIGTERM', () => triggerShutdown('SIGTERM'));
8
+
9
+ const program = new Command();
10
+
11
+ program
12
+ .name('octo')
13
+ .description('Monorepo build orchestration, versioning, and infrastructure CLI')
14
+ .version('0.1.0');
15
+
16
+ program
17
+ .command('init')
18
+ .description('Escaneia o monorepo e gera octo.yaml')
19
+ .option('--standalone', 'Gera manifesto apenas para o projeto corrente')
20
+ .action(async (opts) => {
21
+ const { initCommand } = await import('./init.command.js');
22
+ await initCommand(opts);
23
+ });
24
+
25
+ program
26
+ .command('graph')
27
+ .description('Exibe o grafo de dependências')
28
+ .action(async () => {
29
+ const { graphCommand } = await import('./graph.command.js');
30
+ await graphCommand();
31
+ });
32
+
33
+ program
34
+ .command('build [service]')
35
+ .description('Builda serviços do monorepo')
36
+ .option('--affected', 'Builda apenas serviços afetados')
37
+ .action(async (service, opts) => {
38
+ const { buildCommand } = await import('./build.command.js');
39
+ await buildCommand(service, opts);
40
+ });
41
+
42
+ program
43
+ .command('bump <package>')
44
+ .description('Bump package version with semver')
45
+ .argument('[type]', 'Bump type: patch | minor | major', 'patch')
46
+ .option('--install', 'Run pnpm install in updated dependents')
47
+ .option('--push', 'Push commit and tags to remote after bump')
48
+ .option('--tag', 'Create a git tag for the new version')
49
+ .option('--auto', 'Non-interactive mode: skip confirmations, tag and push automatically')
50
+ .action(async (pkg, type, opts) => {
51
+ const { bumpCommand } = await import('./bump.command.js');
52
+ await bumpCommand(pkg, type, opts);
53
+ });
54
+
55
+ program
56
+ .command('up [service]')
57
+ .description('Sobe infraestrutura local')
58
+ .action(async (service) => {
59
+ const { upCommand } = await import('./up.command.js');
60
+ await upCommand(service);
61
+ });
62
+
63
+ program
64
+ .command('down')
65
+ .description('Para infraestrutura local')
66
+ .option('--volumes', 'Remove volumes também')
67
+ .action(async (opts) => {
68
+ const { downCommand } = await import('./down.command.js');
69
+ await downCommand(opts);
70
+ });
71
+
72
+ program
73
+ .command('status')
74
+ .description('Exibe status dos containers')
75
+ .action(async () => {
76
+ const { statusCommand } = await import('./status.command.js');
77
+ await statusCommand();
78
+ });
79
+
80
+ program.parse();
@@ -0,0 +1,106 @@
1
+ import { readdir, readFile, access, writeFile } from 'node:fs/promises';
2
+ import { join, relative } from 'node:path';
3
+ import { logger } from '../shared/logger.js';
4
+ import { OctoError } from '../shared/errors.js';
5
+ import { printManifest } from '../manifest/manifest-printer.js';
6
+ import type { OctoManifest } from '../manifest/manifest-schema.js';
7
+
8
+ const EXCLUDED_DIRS = new Set(['node_modules', 'dist']);
9
+ const MAX_DEPTH = 5;
10
+
11
+ interface DiscoveredProject {
12
+ name: string;
13
+ relativePath: string;
14
+ hasDockerfile: boolean;
15
+ }
16
+
17
+ function isExcluded(name: string): boolean {
18
+ return EXCLUDED_DIRS.has(name) || name.startsWith('.');
19
+ }
20
+
21
+ async function exists(path: string): Promise<boolean> {
22
+ try {
23
+ await access(path);
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ async function scanDirectory(
31
+ dir: string,
32
+ rootDir: string,
33
+ depth: number,
34
+ results: DiscoveredProject[],
35
+ ): Promise<void> {
36
+ if (depth > MAX_DEPTH) return;
37
+
38
+ const pkgPath = join(dir, 'package.json');
39
+ if (await exists(pkgPath)) {
40
+ try {
41
+ const content = await readFile(pkgPath, 'utf-8');
42
+ const pkg = JSON.parse(content);
43
+ if (pkg.name) {
44
+ const hasDockerfile = await exists(join(dir, 'Dockerfile'));
45
+ results.push({
46
+ name: pkg.name,
47
+ relativePath: './' + relative(rootDir, dir),
48
+ hasDockerfile,
49
+ });
50
+ }
51
+ } catch {
52
+ // Invalid JSON — skip
53
+ }
54
+ }
55
+
56
+ let entries;
57
+ try {
58
+ entries = await readdir(dir, { withFileTypes: true });
59
+ } catch {
60
+ return;
61
+ }
62
+
63
+ for (const entry of entries) {
64
+ if (!entry.isDirectory() || isExcluded(entry.name)) continue;
65
+ await scanDirectory(join(dir, entry.name), rootDir, depth + 1, results);
66
+ }
67
+ }
68
+
69
+ export async function initCommand(opts: { standalone?: boolean }): Promise<void> {
70
+ const rootDir = process.cwd();
71
+ const results: DiscoveredProject[] = [];
72
+
73
+ if (opts.standalone) {
74
+ // Only scan current directory, no recursion into subdirectories
75
+ const pkgPath = join(rootDir, 'package.json');
76
+ if (await exists(pkgPath)) {
77
+ try {
78
+ const content = await readFile(pkgPath, 'utf-8');
79
+ const pkg = JSON.parse(content);
80
+ if (pkg.name) {
81
+ const hasDockerfile = await exists(join(rootDir, 'Dockerfile'));
82
+ results.push({ name: pkg.name, relativePath: '.', hasDockerfile });
83
+ }
84
+ } catch {
85
+ // Invalid JSON
86
+ }
87
+ }
88
+ } else {
89
+ await scanDirectory(rootDir, rootDir, 0, results);
90
+ }
91
+
92
+ if (results.length === 0) {
93
+ throw new OctoError('Nenhum pacote com package.json válido encontrado.', 1);
94
+ }
95
+
96
+ const services = results.filter((p) => p.hasDockerfile).map((p) => p.name);
97
+ const packages = results.filter((p) => !p.hasDockerfile).map((p) => p.name);
98
+
99
+ const manifest: OctoManifest = { services, packages };
100
+ const yaml = printManifest(manifest);
101
+
102
+ const outputPath = join(rootDir, 'octo.yaml');
103
+ await writeFile(outputPath, yaml, 'utf-8');
104
+
105
+ logger.info(`octo.yaml gerado com ${services.length} serviço(s) e ${packages.length} pacote(s).`);
106
+ }
@@ -0,0 +1,46 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { parseManifest } from '../manifest/manifest-parser.js';
4
+ import { buildGraphFromManifest } from '../graph/build-graph.js';
5
+ import { createInfraManager } from '../infra/infra-manager.js';
6
+ import { logger } from '../shared/logger.js';
7
+ import { OctoError } from '../shared/errors.js';
8
+
9
+ export async function statusCommand(): Promise<void> {
10
+ const rootDir = process.cwd();
11
+ const manifestPath = resolve(rootDir, 'octo.yaml');
12
+
13
+ let content: string;
14
+ try {
15
+ content = readFileSync(manifestPath, 'utf-8');
16
+ } catch {
17
+ throw new OctoError('octo.yaml não encontrado. Execute `octo init` primeiro.');
18
+ }
19
+
20
+ const parsed = parseManifest(content, manifestPath);
21
+ if (!parsed.ok) throw parsed.error;
22
+
23
+ const graph = buildGraphFromManifest(parsed.value, rootDir);
24
+ const servicePaths = graph.getNodeNames()
25
+ .map((name) => graph.getNode(name)?.path)
26
+ .filter((p): p is string => !!p);
27
+
28
+ const manager = createInfraManager(servicePaths);
29
+ const containers = await manager.status();
30
+
31
+ if (containers.length === 0) {
32
+ logger.info('Nenhum container em execução.');
33
+ return;
34
+ }
35
+
36
+ // Print table header
37
+ const header = `${'NOME'.padEnd(30)} ${'IMAGEM'.padEnd(35)} ${'ESTADO'.padEnd(12)} PORTA`;
38
+ console.log(header);
39
+ console.log('-'.repeat(header.length));
40
+
41
+ for (const c of containers) {
42
+ console.log(
43
+ `${c.name.padEnd(30)} ${c.image.padEnd(35)} ${c.state.padEnd(12)} ${c.port}`,
44
+ );
45
+ }
46
+ }
@@ -0,0 +1,52 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { parseManifest } from '../manifest/manifest-parser.js';
4
+ import { buildGraphFromManifest } from '../graph/build-graph.js';
5
+ import { createInfraManager } from '../infra/infra-manager.js';
6
+ import { logger } from '../shared/logger.js';
7
+ import { OctoError } from '../shared/errors.js';
8
+
9
+ export async function upCommand(service?: string): Promise<void> {
10
+ const rootDir = process.cwd();
11
+ const manifestPath = resolve(rootDir, 'octo.yaml');
12
+
13
+ let content: string;
14
+ try {
15
+ content = readFileSync(manifestPath, 'utf-8');
16
+ } catch {
17
+ throw new OctoError('octo.yaml não encontrado. Execute `octo init` primeiro.');
18
+ }
19
+
20
+ const parsed = parseManifest(content, manifestPath);
21
+ if (!parsed.ok) throw parsed.error;
22
+
23
+ const graph = buildGraphFromManifest(parsed.value, rootDir);
24
+
25
+ // Resolve service paths from graph
26
+ let targetServices: string[] | undefined;
27
+ if (service) {
28
+ const node = graph.getNode(service);
29
+ if (!node) {
30
+ const available = graph.getNodeNames().join(', ');
31
+ throw new OctoError(`Serviço "${service}" não encontrado. Disponíveis: ${available}`);
32
+ }
33
+ // Include service + its dependencies
34
+ const deps = graph.getDependencies(service);
35
+ targetServices = [service, ...deps];
36
+ }
37
+
38
+ // Collect all service paths from graph nodes
39
+ const allNames = targetServices ?? graph.getNodeNames();
40
+ const servicePaths = allNames
41
+ .map((name) => graph.getNode(name)?.path)
42
+ .filter((p): p is string => !!p);
43
+
44
+ const manager = createInfraManager(servicePaths);
45
+ const result = await manager.up(targetServices);
46
+
47
+ if (!result.success) {
48
+ throw new OctoError(result.message);
49
+ }
50
+
51
+ logger.info(result.message);
52
+ }
@@ -0,0 +1,77 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { DependencyGraph } from './dependency-graph.js';
4
+ import { buildGraphFromManifest } from './build-graph.js';
5
+ import { parseManifest } from '../manifest/manifest-parser.js';
6
+ import type { DiscoveredManifest } from '../manifest/manifest-discovery.js';
7
+ import { logger } from '../shared/logger.js';
8
+
9
+ /**
10
+ * Builds a unified DependencyGraph from multiple discovered manifests (aggregated mode).
11
+ * Resolves cross-project dependencies: if service S in manifest B depends on package P
12
+ * declared in manifest A, adds an edge from S to P in the unified graph.
13
+ */
14
+ export function buildAggregatedGraph(manifests: DiscoveredManifest[]): DependencyGraph {
15
+ const unified = new DependencyGraph();
16
+ const localGraphs: DependencyGraph[] = [];
17
+
18
+ for (const manifest of manifests) {
19
+ let content: string;
20
+ try {
21
+ content = readFileSync(manifest.path, 'utf-8');
22
+ } catch {
23
+ logger.warn(`Could not read manifest: ${manifest.path}`);
24
+ continue;
25
+ }
26
+
27
+ const result = parseManifest(content, manifest.path);
28
+ if (!result.ok) {
29
+ logger.warn(`Failed to parse manifest ${manifest.path}: ${result.error.message}`);
30
+ continue;
31
+ }
32
+
33
+ const rootDir = dirname(manifest.path);
34
+ const graph = buildGraphFromManifest(result.value, rootDir);
35
+ localGraphs.push(graph);
36
+
37
+ // Merge nodes into unified graph
38
+ for (const name of graph.getNodeNames()) {
39
+ const node = graph.getNode(name)!;
40
+ unified.addNode(node);
41
+ }
42
+
43
+ // Merge local edges
44
+ for (const name of graph.getNodeNames()) {
45
+ for (const dep of graph.getDependencies(name)) {
46
+ unified.addEdge(name, dep);
47
+ }
48
+ }
49
+ }
50
+
51
+ // Resolve cross-project edges: for each node, check if its package.json
52
+ // dependencies reference a node from another manifest's graph
53
+ const allNodeNames = new Set(unified.getNodeNames());
54
+
55
+ for (const name of unified.getNodeNames()) {
56
+ const node = unified.getNode(name)!;
57
+ // Read package.json to find all dependencies
58
+ const pkgPath = `${node.path}/package.json`;
59
+ let pkg: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
60
+ try {
61
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
62
+ } catch {
63
+ continue;
64
+ }
65
+
66
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
67
+ for (const depName of Object.keys(allDeps)) {
68
+ // Only add edge if the dependency is an internal node and not already connected
69
+ if (allNodeNames.has(depName) && !unified.getDependencies(name).includes(depName)) {
70
+ unified.addEdge(name, depName);
71
+ logger.info(`Cross-project edge: ${name} → ${depName}`);
72
+ }
73
+ }
74
+ }
75
+
76
+ return unified;
77
+ }
@@ -0,0 +1,125 @@
1
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { DependencyGraph, type GraphNode } from './dependency-graph.js';
4
+ import type { OctoManifest, ServiceEntry, PackageEntry } from '../manifest/manifest-schema.js';
5
+
6
+ interface PackageJson {
7
+ name?: string;
8
+ dependencies?: Record<string, string>;
9
+ devDependencies?: Record<string, string>;
10
+ }
11
+
12
+ /** Extract the name and optional path override from a manifest entry */
13
+ function resolveEntry(entry: ServiceEntry | PackageEntry): { name: string; path?: string } {
14
+ if (typeof entry === 'string') return { name: entry };
15
+ // Object format: { "auth": { path?: "./custom" } }
16
+ const key = Object.keys(entry)[0];
17
+ const config = (entry as Record<string, { path?: string }>)[key];
18
+ return { name: key, path: config?.path };
19
+ }
20
+
21
+ /**
22
+ * Recursively search for a directory containing a package.json whose `name` matches `targetName`.
23
+ * Searches up to depth 3 from rootDir.
24
+ */
25
+ function findPackageDir(rootDir: string, targetName: string, maxDepth = 3): string | undefined {
26
+ function search(dir: string, depth: number): string | undefined {
27
+ if (depth > maxDepth) return undefined;
28
+
29
+ const pkgPath = join(dir, 'package.json');
30
+ if (existsSync(pkgPath)) {
31
+ try {
32
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as PackageJson;
33
+ if (pkg.name === targetName) return dir;
34
+ } catch { /* skip invalid json */ }
35
+ }
36
+
37
+ let entries: string[];
38
+ try {
39
+ entries = readdirSync(dir);
40
+ } catch {
41
+ return undefined;
42
+ }
43
+
44
+ for (const entry of entries) {
45
+ if (entry === 'node_modules' || entry === 'dist' || entry.startsWith('.')) continue;
46
+ const full = join(dir, entry);
47
+ try {
48
+ if (statSync(full).isDirectory()) {
49
+ const found = search(full, depth + 1);
50
+ if (found) return found;
51
+ }
52
+ } catch { /* skip inaccessible */ }
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ return search(rootDir, 0);
58
+ }
59
+
60
+ /** Read a package.json from a directory, returning undefined if not found */
61
+ function readPackageJson(dir: string): PackageJson | undefined {
62
+ const pkgPath = join(dir, 'package.json');
63
+ if (!existsSync(pkgPath)) return undefined;
64
+ try {
65
+ return JSON.parse(readFileSync(pkgPath, 'utf-8')) as PackageJson;
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Build a DependencyGraph from an OctoManifest.
73
+ * Reads each service/package's package.json and adds edges only for internal dependencies.
74
+ */
75
+ export function buildGraphFromManifest(manifest: OctoManifest, rootDir: string): DependencyGraph {
76
+ const graph = new DependencyGraph();
77
+ const resolvedPaths = new Map<string, string>();
78
+
79
+ // Collect all declared names (services + packages)
80
+ const allEntries: Array<{ name: string; path?: string; type: 'service' | 'package' }> = [];
81
+
82
+ for (const entry of manifest.services) {
83
+ const resolved = resolveEntry(entry);
84
+ allEntries.push({ ...resolved, type: 'service' });
85
+ }
86
+ for (const entry of manifest.packages) {
87
+ const resolved = resolveEntry(entry);
88
+ allEntries.push({ ...resolved, type: 'package' });
89
+ }
90
+
91
+ // Resolve paths and add nodes
92
+ for (const entry of allEntries) {
93
+ let dir: string | undefined;
94
+
95
+ if (entry.path) {
96
+ dir = resolve(rootDir, entry.path);
97
+ } else {
98
+ dir = findPackageDir(rootDir, entry.name);
99
+ }
100
+
101
+ if (!dir) continue;
102
+
103
+ resolvedPaths.set(entry.name, dir);
104
+ const node: GraphNode = { name: entry.name, type: entry.type, path: dir };
105
+ graph.addNode(node);
106
+ }
107
+
108
+ // Set of all internal package names for quick lookup
109
+ const internalNames = new Set(resolvedPaths.keys());
110
+
111
+ // Add edges based on package.json dependencies
112
+ for (const [name, dir] of resolvedPaths) {
113
+ const pkg = readPackageJson(dir);
114
+ if (!pkg) continue;
115
+
116
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
117
+ for (const depName of Object.keys(allDeps)) {
118
+ if (internalNames.has(depName)) {
119
+ graph.addEdge(name, depName);
120
+ }
121
+ }
122
+ }
123
+
124
+ return graph;
125
+ }
@@ -0,0 +1,82 @@
1
+ import { CycleError } from '../shared/errors.js';
2
+ import { topologicalSort } from './topological-sort.js';
3
+
4
+ export interface GraphNode {
5
+ name: string;
6
+ type: 'service' | 'package';
7
+ path: string;
8
+ }
9
+
10
+ export type SortResult =
11
+ | { ok: true; value: string[] }
12
+ | { ok: false; error: CycleError };
13
+
14
+ export class DependencyGraph {
15
+ /** node name → GraphNode metadata */
16
+ private nodes = new Map<string, GraphNode>();
17
+ /** node name → set of dependency names (edges: from depends on to) */
18
+ private edges = new Map<string, Set<string>>();
19
+ /** reverse edges: node name → set of dependents */
20
+ private reverseEdges = new Map<string, Set<string>>();
21
+
22
+ addNode(node: GraphNode): void {
23
+ this.nodes.set(node.name, node);
24
+ if (!this.edges.has(node.name)) this.edges.set(node.name, new Set());
25
+ if (!this.reverseEdges.has(node.name)) this.reverseEdges.set(node.name, new Set());
26
+ }
27
+
28
+ addEdge(from: string, to: string): void {
29
+ // from depends on to
30
+ if (!this.edges.has(from)) this.edges.set(from, new Set());
31
+ if (!this.reverseEdges.has(to)) this.reverseEdges.set(to, new Set());
32
+ this.edges.get(from)!.add(to);
33
+ this.reverseEdges.get(to)!.add(from);
34
+ }
35
+
36
+ /** Get packages that `packageName` depends on */
37
+ getDependencies(packageName: string): string[] {
38
+ return [...(this.edges.get(packageName) ?? [])];
39
+ }
40
+
41
+ /** Get packages that depend on `packageName` */
42
+ getDependents(packageName: string): string[] {
43
+ return [...(this.reverseEdges.get(packageName) ?? [])];
44
+ }
45
+
46
+ /** Group nodes into independent parallel levels (BFS layers) */
47
+ getIndependentGroups(): string[][] {
48
+ const sortResult = this.topologicalSort();
49
+ if (!sortResult.ok) return [];
50
+
51
+ const sorted = sortResult.value;
52
+ const levels: string[][] = [];
53
+ const assigned = new Set<string>();
54
+
55
+ while (assigned.size < sorted.length) {
56
+ const level = sorted.filter(
57
+ (n) => !assigned.has(n) && this.getDependencies(n).every((d) => assigned.has(d)),
58
+ );
59
+ if (level.length === 0) break;
60
+ levels.push(level);
61
+ level.forEach((n) => assigned.add(n));
62
+ }
63
+
64
+ return levels;
65
+ }
66
+
67
+ topologicalSort(): SortResult {
68
+ return topologicalSort(this.edges);
69
+ }
70
+
71
+ getNode(name: string): GraphNode | undefined {
72
+ return this.nodes.get(name);
73
+ }
74
+
75
+ getNodeNames(): string[] {
76
+ return [...this.nodes.keys()];
77
+ }
78
+
79
+ get size(): number {
80
+ return this.nodes.size;
81
+ }
82
+ }
@@ -0,0 +1,4 @@
1
+ export { DependencyGraph, type GraphNode, type SortResult } from './dependency-graph.js';
2
+ export { topologicalSort } from './topological-sort.js';
3
+ export { buildGraphFromManifest } from './build-graph.js';
4
+ export { buildAggregatedGraph } from './aggregated-graph.js';
@@ -0,0 +1,104 @@
1
+ import { CycleError } from '../shared/errors.js';
2
+
3
+ export type SortResult =
4
+ | { ok: true; value: string[] }
5
+ | { ok: false; error: CycleError };
6
+
7
+ /**
8
+ * Kahn's Algorithm — topological sort with cycle detection.
9
+ * `edges` maps each node to the set of nodes it depends on (outgoing edges point to dependencies).
10
+ */
11
+ export function topologicalSort(edges: Map<string, Set<string>>): SortResult {
12
+ // In-degree: how many nodes depend on this node? No — in-degree = how many dependencies point INTO this node.
13
+ // Actually for Kahn's: in-degree of a node = number of prerequisites it has.
14
+ // We want to produce an order where dependencies come first.
15
+ // edges: from → {deps it depends on}. So "from depends on to" means to must come before from.
16
+ // In-degree of `from` = edges.get(from).size (number of things it depends on).
17
+
18
+ const inDegree = new Map<string, number>();
19
+ // Initialize all nodes
20
+ for (const [node] of edges) {
21
+ if (!inDegree.has(node)) inDegree.set(node, 0);
22
+ }
23
+ // Calculate in-degree: for each node, its in-degree is the count of its dependencies
24
+ for (const [node, deps] of edges) {
25
+ inDegree.set(node, deps.size);
26
+ // Ensure all dependency targets are in the map
27
+ for (const dep of deps) {
28
+ if (!inDegree.has(dep)) inDegree.set(dep, 0);
29
+ }
30
+ }
31
+
32
+ // Enqueue nodes with in-degree 0 (no dependencies)
33
+ const queue: string[] = [];
34
+ for (const [node, degree] of inDegree) {
35
+ if (degree === 0) queue.push(node);
36
+ }
37
+
38
+ const result: string[] = [];
39
+
40
+ while (queue.length > 0) {
41
+ const node = queue.shift()!;
42
+ result.push(node);
43
+
44
+ // For each other node that depends on `node`, decrement its in-degree
45
+ for (const [candidate, deps] of edges) {
46
+ if (deps.has(node)) {
47
+ const newDegree = inDegree.get(candidate)! - 1;
48
+ inDegree.set(candidate, newDegree);
49
+ if (newDegree === 0) queue.push(candidate);
50
+ }
51
+ }
52
+ }
53
+
54
+ if (result.length !== inDegree.size) {
55
+ // Cycle detected — trace it
56
+ const cycleNodes = [...inDegree.entries()]
57
+ .filter(([, d]) => d > 0)
58
+ .map(([n]) => n);
59
+ const cycle = traceCycle(edges, cycleNodes);
60
+ return {
61
+ ok: false,
62
+ error: new CycleError(
63
+ `Dependency cycle detected: ${cycle.join(' -> ')}`,
64
+ cycle,
65
+ ),
66
+ };
67
+ }
68
+
69
+ return { ok: true, value: result };
70
+ }
71
+
72
+ /** Trace a cycle path from the remaining nodes with in-degree > 0 */
73
+ function traceCycle(edges: Map<string, Set<string>>, cycleNodes: string[]): string[] {
74
+ const inCycle = new Set(cycleNodes);
75
+ if (cycleNodes.length === 0) return [];
76
+
77
+ const start = cycleNodes[0];
78
+ const path: string[] = [start];
79
+ const visited = new Set<string>([start]);
80
+ let current = start;
81
+
82
+ while (true) {
83
+ const deps = edges.get(current);
84
+ if (!deps) break;
85
+ const next = [...deps].find((d) => inCycle.has(d) && !visited.has(d));
86
+ if (!next) {
87
+ // Close the cycle back to start
88
+ const closing = [...deps].find((d) => d === start);
89
+ if (closing) path.push(closing);
90
+ else {
91
+ // Find any node already in path to close
92
+ const back = [...deps].find((d) => path.includes(d));
93
+ if (back) path.push(back);
94
+ else path.push(start);
95
+ }
96
+ break;
97
+ }
98
+ path.push(next);
99
+ visited.add(next);
100
+ current = next;
101
+ }
102
+
103
+ return path;
104
+ }