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,57 @@
1
+ import { run } from '../shared/process-runner.js';
2
+ import { logger } from '../shared/logger.js';
3
+ import type { OctoManifest, HookDefinition } from '../manifest/manifest-schema.js';
4
+
5
+ export type HookTrigger = 'pre-build' | 'pre-bump';
6
+
7
+ export interface HookContext {
8
+ target: string;
9
+ workingDir: string;
10
+ manifest: OctoManifest;
11
+ }
12
+
13
+ export interface HookResult {
14
+ success: boolean;
15
+ hook: string;
16
+ output?: string;
17
+ durationMs: number;
18
+ }
19
+
20
+ /** Executes hooks sequentially in declaration order. Aborts on first failure. */
21
+ export async function runHooks(trigger: HookTrigger, context: HookContext): Promise<HookResult[]> {
22
+ const hooks: HookDefinition[] = context.manifest.hooks?.[trigger] ?? [];
23
+
24
+ if (hooks.length === 0) return [];
25
+
26
+ const results: HookResult[] = [];
27
+
28
+ for (const hook of hooks) {
29
+ logger.info(`Running hook "${hook.name}" (${trigger})...`);
30
+ const start = Date.now();
31
+
32
+ const result = await run(hook.command, [], { cwd: context.workingDir });
33
+ const durationMs = Date.now() - start;
34
+ const output = (result.stdout + result.stderr).trim() || undefined;
35
+
36
+ if (result.exitCode !== 0) {
37
+ logger.error(`Hook "${hook.name}" failed (exit ${result.exitCode})`);
38
+ results.push({ success: false, hook: hook.name, output, durationMs });
39
+ throw new HookError(hook.name, trigger, output);
40
+ }
41
+
42
+ results.push({ success: true, hook: hook.name, output, durationMs });
43
+ }
44
+
45
+ return results;
46
+ }
47
+
48
+ export class HookError extends Error {
49
+ constructor(
50
+ public readonly hook: string,
51
+ public readonly trigger: HookTrigger,
52
+ public readonly output?: string,
53
+ ) {
54
+ super(`Hook "${hook}" failed during ${trigger}${output ? `: ${output}` : ''}`);
55
+ this.name = 'HookError';
56
+ }
57
+ }
@@ -0,0 +1,152 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parse } from 'yaml';
4
+
5
+ /** A discovered docker-compose.yml from a service directory */
6
+ export interface DiscoveredCompose {
7
+ serviceName: string;
8
+ path: string;
9
+ content: DockerComposeDocument;
10
+ }
11
+
12
+ /** Parsed docker-compose document structure */
13
+ export interface DockerComposeDocument {
14
+ services?: Record<string, any>;
15
+ networks?: Record<string, any>;
16
+ volumes?: Record<string, any>;
17
+ [key: string]: any;
18
+ }
19
+
20
+ /** Merged compose output */
21
+ export interface MergedCompose {
22
+ services: Record<string, any>;
23
+ networks: Record<string, any>;
24
+ volumes: Record<string, any>;
25
+ }
26
+
27
+ /** Conflict detected during merge */
28
+ export interface ComposeConflict {
29
+ type: 'port' | 'volume_name' | 'service_name';
30
+ sources: string[];
31
+ description: string;
32
+ }
33
+
34
+ /** Result of a merge operation */
35
+ export interface MergeResult {
36
+ merged: MergedCompose;
37
+ conflicts: ComposeConflict[];
38
+ }
39
+
40
+ export interface ComposeAggregator {
41
+ discover(servicePaths: string[]): DiscoveredCompose[];
42
+ merge(composes: DiscoveredCompose[]): MergeResult;
43
+ }
44
+
45
+ /** Extract host ports from a compose service ports definition */
46
+ function extractHostPorts(ports: any[]): string[] {
47
+ const result: string[] = [];
48
+ for (const p of ports) {
49
+ const portStr = typeof p === 'object' && p.published != null
50
+ ? String(p.published)
51
+ : String(p);
52
+ // Match host port from formats like "8080:80", "127.0.0.1:8080:80", "8080:80/tcp"
53
+ const match = portStr.match(/(?:[\d.]+:)?(\d+):\d+/);
54
+ if (match) result.push(match[1]);
55
+ }
56
+ return result;
57
+ }
58
+
59
+ export function createComposeAggregator(): ComposeAggregator {
60
+ return {
61
+ discover(servicePaths: string[]): DiscoveredCompose[] {
62
+ const results: DiscoveredCompose[] = [];
63
+ for (const svcPath of servicePaths) {
64
+ const composePath = join(svcPath, 'docker-compose.yml');
65
+ if (!existsSync(composePath)) continue;
66
+ const raw = readFileSync(composePath, 'utf-8');
67
+ const content = (parse(raw) ?? {}) as DockerComposeDocument;
68
+ const serviceName = svcPath.split('/').pop() ?? svcPath;
69
+ results.push({ serviceName, path: composePath, content });
70
+ }
71
+ return results;
72
+ },
73
+
74
+ merge(composes: DiscoveredCompose[]): MergeResult {
75
+ const merged: MergedCompose = { services: {}, networks: {}, volumes: {} };
76
+ const conflicts: ComposeConflict[] = [];
77
+
78
+ // Track origins for conflict detection
79
+ const serviceOrigins = new Map<string, string[]>();
80
+ const portOrigins = new Map<string, string[]>();
81
+
82
+ for (const compose of composes) {
83
+ const { content, path: sourcePath } = compose;
84
+ if (!content) continue;
85
+
86
+ // Merge services
87
+ if (content.services) {
88
+ for (const [name, def] of Object.entries(content.services)) {
89
+ if (!serviceOrigins.has(name)) serviceOrigins.set(name, []);
90
+ serviceOrigins.get(name)!.push(sourcePath);
91
+
92
+ // Detect port conflicts
93
+ if (def?.ports && Array.isArray(def.ports)) {
94
+ for (const hostPort of extractHostPorts(def.ports)) {
95
+ const key = hostPort;
96
+ if (!portOrigins.has(key)) portOrigins.set(key, []);
97
+ portOrigins.get(key)!.push(sourcePath);
98
+ }
99
+ }
100
+
101
+ // First definition wins (deterministic)
102
+ if (!merged.services[name]) {
103
+ merged.services[name] = def;
104
+ }
105
+ }
106
+ }
107
+
108
+ // Merge networks
109
+ if (content.networks) {
110
+ for (const [name, def] of Object.entries(content.networks)) {
111
+ if (!merged.networks[name]) {
112
+ merged.networks[name] = def;
113
+ }
114
+ }
115
+ }
116
+
117
+ // Merge volumes
118
+ if (content.volumes) {
119
+ for (const [name, def] of Object.entries(content.volumes)) {
120
+ if (!merged.volumes[name]) {
121
+ merged.volumes[name] = def;
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ // Report service name conflicts
128
+ for (const [name, sources] of serviceOrigins) {
129
+ if (sources.length > 1) {
130
+ conflicts.push({
131
+ type: 'service_name',
132
+ sources,
133
+ description: `Service "${name}" declared in multiple compose files`,
134
+ });
135
+ }
136
+ }
137
+
138
+ // Report port conflicts
139
+ for (const [port, sources] of portOrigins) {
140
+ if (sources.length > 1) {
141
+ conflicts.push({
142
+ type: 'port',
143
+ sources,
144
+ description: `Host port ${port} mapped in multiple compose files`,
145
+ });
146
+ }
147
+ }
148
+
149
+ return { merged, conflicts };
150
+ },
151
+ };
152
+ }
@@ -0,0 +1,93 @@
1
+ import { Ollama } from 'ollama';
2
+ import { z } from 'zod';
3
+ import { createComposeAggregator, type DiscoveredCompose, type MergedCompose } from './compose-aggregator.js';
4
+ import { logger } from '../shared/logger.js';
5
+
6
+ /** Zod schema to validate LLM output */
7
+ const MergedComposeSchema = z.object({
8
+ services: z.record(z.string(), z.any()).default({}),
9
+ networks: z.record(z.string(), z.any()).default({}),
10
+ volumes: z.record(z.string(), z.any()).default({}),
11
+ });
12
+
13
+ export interface ComposeSmartMerger {
14
+ deduplicate(composes: DiscoveredCompose[]): Promise<MergedCompose>;
15
+ }
16
+
17
+ /** Build the structured prompt for Phi-4 */
18
+ function buildPrompt(composes: DiscoveredCompose[]): string {
19
+ const composesText = composes
20
+ .map((c) => `--- ${c.serviceName} (${c.path}) ---\n${JSON.stringify(c.content, null, 2)}`)
21
+ .join('\n\n');
22
+
23
+ return `You are a Docker Compose expert. Given multiple docker-compose files from different services, merge them into a single unified compose file.
24
+
25
+ Rules:
26
+ 1. Deduplicate containers that represent the same infrastructure (e.g. multiple postgres definitions → keep one)
27
+ 2. When images differ, prefer the most recent version
28
+ 3. Merge all environment variables from duplicates without losing any
29
+ 4. Consolidate networks and volumes, removing redundancies
30
+ 5. Preserve all port mappings, flagging conflicts if any
31
+
32
+ Input compose files:
33
+ ${composesText}
34
+
35
+ Return ONLY valid JSON with this exact structure:
36
+ {"services": {...}, "networks": {...}, "volumes": {...}}`;
37
+ }
38
+
39
+ /** Check if Ollama is available with phi4 model */
40
+ async function isOllamaAvailable(client: Ollama): Promise<boolean> {
41
+ try {
42
+ const models = await client.list();
43
+ return models.models.some((m) => m.name.startsWith('phi4'));
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ export function createComposeSmartMerger(): ComposeSmartMerger {
50
+ const client = new Ollama();
51
+ const aggregator = createComposeAggregator();
52
+
53
+ return {
54
+ async deduplicate(composes: DiscoveredCompose[]): Promise<MergedCompose> {
55
+ if (composes.length === 0) {
56
+ return { services: {}, networks: {}, volumes: {} };
57
+ }
58
+
59
+ // Try LLM-based merge
60
+ if (await isOllamaAvailable(client)) {
61
+ try {
62
+ logger.info('Usando Phi-4 para merge inteligente de compose files...');
63
+ const prompt = buildPrompt(composes);
64
+ const response = await client.generate({
65
+ model: 'phi4',
66
+ prompt,
67
+ format: 'json',
68
+ stream: false,
69
+ });
70
+
71
+ const parsed = JSON.parse(response.response);
72
+ const validated = MergedComposeSchema.safeParse(parsed);
73
+
74
+ if (validated.success) {
75
+ logger.info('Merge inteligente concluído com sucesso.');
76
+ return validated.data;
77
+ }
78
+
79
+ logger.warn('Output da LLM falhou na validação Zod. Usando fallback determinístico.');
80
+ } catch (err) {
81
+ const msg = err instanceof Error ? err.message : String(err);
82
+ logger.warn(`Erro ao usar Phi-4: ${msg}. Usando fallback determinístico.`);
83
+ }
84
+ } else {
85
+ logger.info('Ollama/Phi-4 indisponível. Usando merge determinístico.');
86
+ }
87
+
88
+ // Fallback: deterministic merge
89
+ const { merged } = aggregator.merge(composes);
90
+ return merged;
91
+ },
92
+ };
93
+ }
@@ -0,0 +1,157 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { stringify } from 'yaml';
5
+ import { run } from '../shared/process-runner.js';
6
+ import { logger } from '../shared/logger.js';
7
+ import { OctoError } from '../shared/errors.js';
8
+ import { createComposeSmartMerger } from './compose-smart-merger.js';
9
+ import { createComposeAggregator, type MergedCompose } from './compose-aggregator.js';
10
+
11
+ export type ContainerState = 'running' | 'stopped' | 'unhealthy';
12
+
13
+ export interface ContainerStatus {
14
+ name: string;
15
+ image: string;
16
+ state: ContainerState;
17
+ port: string;
18
+ }
19
+
20
+ export interface InfraResult {
21
+ success: boolean;
22
+ message: string;
23
+ }
24
+
25
+ export interface InfraManager {
26
+ up(services?: string[]): Promise<InfraResult>;
27
+ down(options: { volumes?: boolean }): Promise<InfraResult>;
28
+ status(): Promise<ContainerStatus[]>;
29
+ }
30
+
31
+ const HEALTHCHECK_TIMEOUT_MS = 60_000;
32
+ const HEALTHCHECK_POLL_MS = 2_000;
33
+
34
+ /** Write merged compose to a temp file and return its path */
35
+ async function writeTempCompose(merged: MergedCompose): Promise<string> {
36
+ const tempPath = join(tmpdir(), `octo-compose-${Date.now()}.yml`);
37
+ const content = stringify(merged);
38
+ await writeFile(tempPath, content, 'utf-8');
39
+ return tempPath;
40
+ }
41
+
42
+ /** Poll healthcheck for a container, returns true if healthy within timeout */
43
+ async function waitForHealthcheck(containerName: string): Promise<boolean> {
44
+ const start = Date.now();
45
+ while (Date.now() - start < HEALTHCHECK_TIMEOUT_MS) {
46
+ const result = await run('docker', [
47
+ 'inspect', '--format', '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}', containerName,
48
+ ]);
49
+ const status = result.stdout.trim();
50
+ if (status === 'healthy' || status === 'none') return true;
51
+ if (status === 'unhealthy') return false;
52
+ await new Promise((r) => setTimeout(r, HEALTHCHECK_POLL_MS));
53
+ }
54
+ return false;
55
+ }
56
+
57
+ /** Show last 20 lines of container log */
58
+ async function showContainerLogs(containerName: string): Promise<void> {
59
+ const result = await run('docker', ['logs', '--tail', '20', containerName]);
60
+ const output = result.stdout || result.stderr;
61
+ if (output) logger.error(`Últimas 20 linhas de log (${containerName}):\n${output}`);
62
+ }
63
+
64
+ export function createInfraManager(servicePaths: string[]): InfraManager {
65
+ const aggregator = createComposeAggregator();
66
+ const smartMerger = createComposeSmartMerger();
67
+ let composePath: string | undefined;
68
+
69
+ return {
70
+ async up(services?: string[]): Promise<InfraResult> {
71
+ const paths = services && services.length > 0
72
+ ? servicePaths.filter((p) => services.some((s) => p.endsWith(s)))
73
+ : servicePaths;
74
+
75
+ const discovered = aggregator.discover(paths);
76
+ if (discovered.length === 0) {
77
+ return { success: true, message: 'Nenhum docker-compose.yml encontrado.' };
78
+ }
79
+
80
+ logger.info(`Descobertos ${discovered.length} compose file(s). Merging...`);
81
+ const merged = await smartMerger.deduplicate(discovered);
82
+ composePath = await writeTempCompose(merged);
83
+
84
+ logger.info('Subindo containers...');
85
+ const result = await run('docker', ['compose', '-f', composePath, 'up', '-d']);
86
+ if (result.exitCode !== 0) {
87
+ return { success: false, message: `docker compose up falhou: ${result.stderr}` };
88
+ }
89
+
90
+ // Wait for healthchecks
91
+ const serviceNames = Object.keys(merged.services);
92
+ for (const svc of serviceNames) {
93
+ const healthy = await waitForHealthcheck(svc);
94
+ if (!healthy) {
95
+ logger.error(`Healthcheck timeout para container "${svc}".`);
96
+ await showContainerLogs(svc);
97
+ // Stop dependents but don't tear down everything
98
+ return { success: false, message: `Healthcheck timeout: ${svc}` };
99
+ }
100
+ }
101
+
102
+ return { success: true, message: `${serviceNames.length} container(s) iniciado(s).` };
103
+ },
104
+
105
+ async down(options: { volumes?: boolean }): Promise<InfraResult> {
106
+ // Discover and merge to get the compose file path
107
+ if (!composePath) {
108
+ const discovered = aggregator.discover(servicePaths);
109
+ if (discovered.length === 0) {
110
+ return { success: true, message: 'Nenhum container para parar.' };
111
+ }
112
+ const merged = await smartMerger.deduplicate(discovered);
113
+ composePath = await writeTempCompose(merged);
114
+ }
115
+
116
+ const args = ['compose', '-f', composePath, 'down'];
117
+ if (options.volumes) args.push('--volumes');
118
+
119
+ const result = await run('docker', args);
120
+ if (result.exitCode !== 0) {
121
+ return { success: false, message: `docker compose down falhou: ${result.stderr}` };
122
+ }
123
+
124
+ return { success: true, message: 'Containers parados.' };
125
+ },
126
+
127
+ async status(): Promise<ContainerStatus[]> {
128
+ if (!composePath) {
129
+ const discovered = aggregator.discover(servicePaths);
130
+ if (discovered.length === 0) return [];
131
+ const merged = await smartMerger.deduplicate(discovered);
132
+ composePath = await writeTempCompose(merged);
133
+ }
134
+
135
+ const result = await run('docker', ['compose', '-f', composePath, 'ps', '--format', 'json']);
136
+ if (result.exitCode !== 0 || !result.stdout.trim()) return [];
137
+
138
+ const containers: ContainerStatus[] = [];
139
+ // docker compose ps --format json outputs one JSON object per line
140
+ for (const line of result.stdout.trim().split('\n')) {
141
+ try {
142
+ const entry = JSON.parse(line);
143
+ const state: ContainerState =
144
+ entry.Health === 'unhealthy' ? 'unhealthy' :
145
+ entry.State === 'running' ? 'running' : 'stopped';
146
+ containers.push({
147
+ name: entry.Name ?? entry.Service ?? '',
148
+ image: entry.Image ?? '',
149
+ state,
150
+ port: entry.Publishers?.map((p: any) => `${p.PublishedPort}:${p.TargetPort}`).join(', ') ?? '',
151
+ });
152
+ } catch { /* skip malformed lines */ }
153
+ }
154
+ return containers;
155
+ },
156
+ };
157
+ }
@@ -0,0 +1,144 @@
1
+ import { readdirSync, statSync, existsSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import { ManifestError } from '../shared/errors.js';
4
+ import { logger } from '../shared/logger.js';
5
+
6
+ const MANIFEST_FILENAME = 'octo.yaml';
7
+ const MAX_DEPTH = 5;
8
+ const SKIP_DIRS = new Set(['node_modules', 'dist']);
9
+
10
+ export interface DiscoveredManifest {
11
+ name: string;
12
+ path: string;
13
+ isRoot: boolean;
14
+ }
15
+
16
+ /** Checks if a directory name should be skipped during scan */
17
+ function shouldSkip(dirName: string): boolean {
18
+ return dirName.startsWith('.') || SKIP_DIRS.has(dirName);
19
+ }
20
+
21
+ /**
22
+ * Recursively scans directories for octo.yaml files.
23
+ * Skips node_modules, dist, and dot-prefixed directories.
24
+ * Max scan depth: 5 levels.
25
+ */
26
+ function scanForManifests(dir: string, rootDir: string, depth: number): DiscoveredManifest[] {
27
+ if (depth > MAX_DEPTH) return [];
28
+
29
+ const results: DiscoveredManifest[] = [];
30
+ const manifestPath = join(dir, MANIFEST_FILENAME);
31
+
32
+ if (existsSync(manifestPath)) {
33
+ const dirName = relative(rootDir, dir) || '.';
34
+ results.push({
35
+ name: dirName === '.' ? 'root' : dirName,
36
+ path: manifestPath,
37
+ isRoot: dir === rootDir,
38
+ });
39
+ }
40
+
41
+ let entries: string[];
42
+ try {
43
+ entries = readdirSync(dir);
44
+ } catch {
45
+ return results;
46
+ }
47
+
48
+ for (const entry of entries) {
49
+ if (shouldSkip(entry)) continue;
50
+ const fullPath = join(dir, entry);
51
+ try {
52
+ if (statSync(fullPath).isDirectory()) {
53
+ results.push(...scanForManifests(fullPath, rootDir, depth + 1));
54
+ }
55
+ } catch {
56
+ // Skip inaccessible entries
57
+ }
58
+ }
59
+
60
+ return results;
61
+ }
62
+
63
+ /**
64
+ * Discovers all octo.yaml manifests starting from rootDir.
65
+ * If a root manifest exists alongside sub-manifests, prioritizes root.
66
+ */
67
+ export function discoverManifests(rootDir: string): DiscoveredManifest[] {
68
+ const all = scanForManifests(rootDir, rootDir, 0);
69
+
70
+ // If root manifest exists, filter out sub-manifests already referenced
71
+ const rootManifest = all.find((m) => m.isRoot);
72
+ if (rootManifest && all.length > 1) {
73
+ // Prioritize root — return only root manifest
74
+ return [rootManifest];
75
+ }
76
+
77
+ return all;
78
+ }
79
+
80
+ /**
81
+ * Resolves execution mode based on manifest discovery.
82
+ * - standalone: exactly one octo.yaml in execution dir, no sub-manifests
83
+ * - aggregated: multiple octo.yaml files in subdirectories
84
+ */
85
+ export function resolveMode(rootDir: string): 'standalone' | 'aggregated' {
86
+ const all = scanForManifests(rootDir, rootDir, 0);
87
+ const rootManifest = all.find((m) => m.isRoot);
88
+ const subManifests = all.filter((m) => !m.isRoot);
89
+
90
+ // Root manifest with sub-manifests → standalone (root takes priority)
91
+ if (rootManifest && subManifests.length > 0) {
92
+ return 'standalone';
93
+ }
94
+
95
+ // Exactly one manifest in execution dir → standalone
96
+ if (rootManifest && subManifests.length === 0) {
97
+ return 'standalone';
98
+ }
99
+
100
+ // Multiple sub-manifests without root → aggregated
101
+ if (subManifests.length > 1) {
102
+ return 'aggregated';
103
+ }
104
+
105
+ // Single sub-manifest → standalone
106
+ return 'standalone';
107
+ }
108
+
109
+ /**
110
+ * Detects name collisions across discovered manifests.
111
+ * Throws ManifestError if two manifests declare the same service/package name.
112
+ */
113
+ export function detectNameCollisions(manifests: DiscoveredManifest[]): void {
114
+ const seen = new Map<string, string>(); // name → path
115
+
116
+ for (const manifest of manifests) {
117
+ if (seen.has(manifest.name)) {
118
+ const existing = seen.get(manifest.name)!;
119
+ throw new ManifestError(
120
+ `Name collision detected: "${manifest.name}" declared in both "${existing}" and "${manifest.path}"`,
121
+ );
122
+ }
123
+ seen.set(manifest.name, manifest.path);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Displays discovered projects in aggregated mode.
129
+ * Called at the beginning of execution when in aggregated mode.
130
+ */
131
+ export function displayDiscoveredProjects(rootDir: string): DiscoveredManifest[] {
132
+ const manifests = discoverManifests(rootDir);
133
+ const mode = resolveMode(rootDir);
134
+
135
+ if (mode === 'aggregated') {
136
+ logger.info(`Modo agregado: ${manifests.length} projetos descobertos`);
137
+ for (const m of manifests) {
138
+ logger.info(` → ${m.name} (${relative(rootDir, m.path)})`);
139
+ }
140
+ }
141
+
142
+ detectNameCollisions(manifests);
143
+ return manifests;
144
+ }