@laitszkin/apollo-toolkit 4.0.11 → 4.1.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.
Files changed (134) hide show
  1. package/AGENTS.md +37 -27
  2. package/CHANGELOG.md +31 -0
  3. package/CLAUDE.md +37 -27
  4. package/README.md +15 -2
  5. package/assets/spec/rg13-1780435029246/test-questions.json +1 -0
  6. package/assets/spec/rg13-1780468345132/test-questions.json +1 -0
  7. package/package.json +3 -3
  8. package/packages/cli/dist/tool-registration.js +1 -0
  9. package/packages/cli/dist/tsconfig.tsbuildinfo +1 -1
  10. package/packages/cli/tool-registration.ts +1 -0
  11. package/packages/tools/architecture/dist/index.js +539 -2
  12. package/packages/tools/architecture/dist/index.test.d.ts +1 -0
  13. package/packages/tools/architecture/dist/index.test.js +229 -0
  14. package/packages/tools/architecture/dist/tsconfig.tsbuildinfo +1 -1
  15. package/packages/tools/architecture/index.test.ts +329 -0
  16. package/packages/tools/architecture/index.ts +607 -5
  17. package/packages/tools/codegraph/dist/index.d.ts +3 -0
  18. package/packages/tools/codegraph/dist/index.js +157 -0
  19. package/packages/tools/codegraph/dist/lib/cg-instance.d.ts +29 -0
  20. package/packages/tools/codegraph/dist/lib/cg-instance.js +59 -0
  21. package/packages/tools/codegraph/dist/lib/cg-instance.test.d.ts +1 -0
  22. package/packages/tools/codegraph/dist/lib/cg-instance.test.js +27 -0
  23. package/packages/tools/codegraph/dist/lib/cmd-explore.d.ts +5 -0
  24. package/packages/tools/codegraph/dist/lib/cmd-explore.js +95 -0
  25. package/packages/tools/codegraph/dist/lib/cmd-explore.test.d.ts +1 -0
  26. package/packages/tools/codegraph/dist/lib/cmd-explore.test.js +133 -0
  27. package/packages/tools/codegraph/dist/lib/cmd-flag-splice.test.d.ts +1 -0
  28. package/packages/tools/codegraph/dist/lib/cmd-flag-splice.test.js +83 -0
  29. package/packages/tools/codegraph/dist/lib/cmd-init.d.ts +5 -0
  30. package/packages/tools/codegraph/dist/lib/cmd-init.js +50 -0
  31. package/packages/tools/codegraph/dist/lib/cmd-init.test.d.ts +1 -0
  32. package/packages/tools/codegraph/dist/lib/cmd-init.test.js +51 -0
  33. package/packages/tools/codegraph/dist/lib/cmd-list-apis.d.ts +5 -0
  34. package/packages/tools/codegraph/dist/lib/cmd-list-apis.js +64 -0
  35. package/packages/tools/codegraph/dist/lib/cmd-list-apis.test.d.ts +1 -0
  36. package/packages/tools/codegraph/dist/lib/cmd-list-apis.test.js +69 -0
  37. package/packages/tools/codegraph/dist/lib/cmd-search.d.ts +5 -0
  38. package/packages/tools/codegraph/dist/lib/cmd-search.js +21 -0
  39. package/packages/tools/codegraph/dist/lib/cmd-search.test.d.ts +1 -0
  40. package/packages/tools/codegraph/dist/lib/cmd-search.test.js +30 -0
  41. package/packages/tools/codegraph/dist/lib/cmd-status.d.ts +4 -0
  42. package/packages/tools/codegraph/dist/lib/cmd-status.js +44 -0
  43. package/packages/tools/codegraph/dist/lib/cmd-status.test.d.ts +1 -0
  44. package/packages/tools/codegraph/dist/lib/cmd-status.test.js +72 -0
  45. package/packages/tools/codegraph/dist/lib/cmd-survey.d.ts +36 -0
  46. package/packages/tools/codegraph/dist/lib/cmd-survey.js +142 -0
  47. package/packages/tools/codegraph/dist/lib/cmd-survey.test.d.ts +1 -0
  48. package/packages/tools/codegraph/dist/lib/cmd-survey.test.js +136 -0
  49. package/packages/tools/codegraph/dist/lib/cmd-sync.d.ts +4 -0
  50. package/packages/tools/codegraph/dist/lib/cmd-sync.js +51 -0
  51. package/packages/tools/codegraph/dist/lib/cmd-sync.test.d.ts +1 -0
  52. package/packages/tools/codegraph/dist/lib/cmd-sync.test.js +30 -0
  53. package/packages/tools/codegraph/dist/lib/cmd-verify.d.ts +4 -0
  54. package/packages/tools/codegraph/dist/lib/cmd-verify.js +134 -0
  55. package/packages/tools/codegraph/dist/lib/cmd-verify.test.d.ts +1 -0
  56. package/packages/tools/codegraph/dist/lib/cmd-verify.test.js +139 -0
  57. package/packages/tools/codegraph/dist/lib/formatter.d.ts +67 -0
  58. package/packages/tools/codegraph/dist/lib/formatter.js +107 -0
  59. package/packages/tools/codegraph/dist/lib/formatter.test.d.ts +1 -0
  60. package/packages/tools/codegraph/dist/lib/formatter.test.js +41 -0
  61. package/packages/tools/codegraph/dist/lib/survey/grouper.d.ts +19 -0
  62. package/packages/tools/codegraph/dist/lib/survey/grouper.js +194 -0
  63. package/packages/tools/codegraph/dist/lib/survey/grouper.test.d.ts +1 -0
  64. package/packages/tools/codegraph/dist/lib/survey/grouper.test.js +62 -0
  65. package/packages/tools/codegraph/dist/lib/survey/scanner.d.ts +31 -0
  66. package/packages/tools/codegraph/dist/lib/survey/scanner.js +50 -0
  67. package/packages/tools/codegraph/dist/lib/verify/checker.d.ts +32 -0
  68. package/packages/tools/codegraph/dist/lib/verify/checker.js +146 -0
  69. package/packages/tools/codegraph/dist/lib/verify/checker.test.d.ts +1 -0
  70. package/packages/tools/codegraph/dist/lib/verify/checker.test.js +128 -0
  71. package/packages/tools/codegraph/dist/tsconfig.tsbuildinfo +1 -0
  72. package/packages/tools/codegraph/env.d.ts +56 -0
  73. package/packages/tools/codegraph/index.ts +173 -0
  74. package/packages/tools/codegraph/lib/cg-instance.test.ts +36 -0
  75. package/packages/tools/codegraph/lib/cg-instance.ts +66 -0
  76. package/packages/tools/codegraph/lib/cmd-explore.test.ts +195 -0
  77. package/packages/tools/codegraph/lib/cmd-explore.ts +129 -0
  78. package/packages/tools/codegraph/lib/cmd-flag-splice.test.ts +94 -0
  79. package/packages/tools/codegraph/lib/cmd-init.test.ts +68 -0
  80. package/packages/tools/codegraph/lib/cmd-init.ts +60 -0
  81. package/packages/tools/codegraph/lib/cmd-list-apis.test.ts +80 -0
  82. package/packages/tools/codegraph/lib/cmd-list-apis.ts +90 -0
  83. package/packages/tools/codegraph/lib/cmd-search.test.ts +37 -0
  84. package/packages/tools/codegraph/lib/cmd-search.ts +32 -0
  85. package/packages/tools/codegraph/lib/cmd-status.test.ts +86 -0
  86. package/packages/tools/codegraph/lib/cmd-status.ts +53 -0
  87. package/packages/tools/codegraph/lib/cmd-survey.test.ts +161 -0
  88. package/packages/tools/codegraph/lib/cmd-survey.ts +199 -0
  89. package/packages/tools/codegraph/lib/cmd-sync.test.ts +41 -0
  90. package/packages/tools/codegraph/lib/cmd-sync.ts +62 -0
  91. package/packages/tools/codegraph/lib/cmd-verify.test.ts +162 -0
  92. package/packages/tools/codegraph/lib/cmd-verify.ts +145 -0
  93. package/packages/tools/codegraph/lib/formatter.test.ts +47 -0
  94. package/packages/tools/codegraph/lib/formatter.ts +130 -0
  95. package/packages/tools/codegraph/lib/survey/grouper.test.ts +72 -0
  96. package/packages/tools/codegraph/lib/survey/grouper.ts +226 -0
  97. package/packages/tools/codegraph/lib/survey/scanner.ts +89 -0
  98. package/packages/tools/codegraph/lib/verify/checker.test.ts +140 -0
  99. package/packages/tools/codegraph/lib/verify/checker.ts +172 -0
  100. package/packages/tools/codegraph/package.json +23 -0
  101. package/packages/tools/codegraph/tsconfig.json +22 -0
  102. package/resources/project-architecture/atlas/atlas.history.log +32 -0
  103. package/resources/project-architecture/atlas/atlas.history.undo.json +356 -28
  104. package/resources/project-architecture/atlas/atlas.history.undo.stack.json +14350 -0
  105. package/resources/project-architecture/atlas/atlas.index.yaml +76 -12
  106. package/resources/project-architecture/atlas/features/codegraph.yaml +95 -0
  107. package/resources/project-architecture/atlas/features/eval-ci-gate.yaml +6 -1
  108. package/resources/project-architecture/atlas/features/eval-cli.yaml +16 -1
  109. package/resources/project-architecture/atlas/features/eval-executor.yaml +12 -2
  110. package/resources/project-architecture/atlas/features/eval-isolation.yaml +6 -1
  111. package/resources/project-architecture/atlas/features/eval-optimizer.yaml +17 -2
  112. package/resources/project-architecture/atlas/features/eval-question.yaml +12 -2
  113. package/resources/project-architecture/atlas/features/eval-reporter.yaml +6 -1
  114. package/resources/project-architecture/atlas/features/eval-scorer.yaml +12 -2
  115. package/resources/project-architecture/features/codegraph/cg-discovery.html +47 -0
  116. package/resources/project-architecture/features/codegraph/cg-lifecycle.html +48 -0
  117. package/resources/project-architecture/features/codegraph/cg-validation.html +47 -0
  118. package/resources/project-architecture/features/codegraph/index.html +58 -0
  119. package/resources/project-architecture/features/eval-ci-gate/workflow-trigger.html +6 -1
  120. package/resources/project-architecture/features/eval-cli/cli-handler.html +8 -1
  121. package/resources/project-architecture/features/eval-executor/exec-api-client.html +6 -1
  122. package/resources/project-architecture/features/eval-executor/trace-recorder.html +6 -1
  123. package/resources/project-architecture/features/eval-isolation/tool-dispatcher.html +6 -1
  124. package/resources/project-architecture/features/eval-optimizer/dedup-engine.html +6 -1
  125. package/resources/project-architecture/features/eval-optimizer/issue-extractor.html +7 -1
  126. package/resources/project-architecture/features/eval-question/question-loader.html +6 -1
  127. package/resources/project-architecture/features/eval-question/variant-generator.html +6 -1
  128. package/resources/project-architecture/features/eval-reporter/report-composer.html +6 -1
  129. package/resources/project-architecture/features/eval-scorer/judge-api-client.html +6 -1
  130. package/resources/project-architecture/features/eval-scorer/judge-prompt-builder.html +6 -1
  131. package/resources/project-architecture/index.html +200 -94
  132. package/skills/design/SKILL.md +33 -0
  133. package/skills/init-project-html/SKILL.md +12 -11
  134. package/tsconfig.json +1 -0
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Format structured data for CLI output.
3
+ *
4
+ * - TTY mode produces human-readable tables and headings.
5
+ * - Non-TTY or `--json` produces JSON.stringify with 2-space indent.
6
+ * - Auto-detects TTY via `process.stdout.isTTY`.
7
+ */
8
+
9
+ export interface FormatOptions {
10
+ json?: boolean;
11
+ tty?: boolean;
12
+ }
13
+
14
+ function isTTY(options: FormatOptions): boolean {
15
+ if (options.json) return false;
16
+ if (options.tty !== undefined) return options.tty;
17
+ return !!process.stdout.isTTY;
18
+ }
19
+
20
+ /**
21
+ * Format generic data for output.
22
+ */
23
+ export function formatOutput(data: unknown, options: FormatOptions = {}): string {
24
+ if (!isTTY(options)) {
25
+ return JSON.stringify(data, null, 2);
26
+ }
27
+ // Fallback: JSON for complex objects in TTY too
28
+ if (typeof data === 'string') return data;
29
+ if (data === null || data === undefined) return '';
30
+ return JSON.stringify(data, null, 2);
31
+ }
32
+
33
+ /**
34
+ * Format a key-value summary block for human-readable output.
35
+ * Example:
36
+ * Files: 42
37
+ * Nodes: 1280
38
+ * Edges: 5600
39
+ */
40
+ export function formatSummary(rows: [string, string | number][]): string {
41
+ const width = rows.reduce((max, [key]) => Math.max(max, key.length + 1), 0);
42
+ return rows
43
+ .map(([key, val]) => `${key.padEnd(width, ' ')} ${val}`)
44
+ .join('\n');
45
+ }
46
+
47
+ /**
48
+ * Format a list of search results as a human-readable table.
49
+ */
50
+ export function formatSearchResults(
51
+ results: Array<{ node: { name: string; kind: string; filePath: string; startLine: number }; score: number }>,
52
+ ): string {
53
+ if (results.length === 0) return 'No results found.';
54
+ const lines = results.map((r, i) => {
55
+ const score = (r.score * 100).toFixed(0);
56
+ return ` ${i + 1}. ${r.node.name} [${r.node.kind}] ${r.node.filePath}:${r.node.startLine} (${score}%)`;
57
+ });
58
+ return `Results (${results.length}):\n${lines.join('\n')}`;
59
+ }
60
+
61
+ /**
62
+ * Format an API directory listing for human-readable output.
63
+ */
64
+ export function formatApiList(
65
+ apis: Array<{
66
+ name: string;
67
+ kind: string;
68
+ filePath: string;
69
+ startLine: number;
70
+ signature?: string;
71
+ callerCount: number;
72
+ callers?: Array<{ name: string; filePath: string; startLine: number }>;
73
+ }>,
74
+ ): string {
75
+ if (apis.length === 0) return 'No public APIs found.';
76
+ const lines: string[] = [];
77
+ for (const a of apis) {
78
+ const sig = a.signature ? ` ${a.signature}` : '';
79
+ lines.push(` ${a.name} [${a.kind}] ${a.filePath}:${a.startLine} (${a.callerCount} callers)${sig}`);
80
+ if (a.callerCount > 0 && a.callers) {
81
+ const callerLines = a.callers.slice(0, 5).map(c => ` Called by: ${c.name} ${c.filePath}:${c.startLine}`);
82
+ lines.push(...callerLines);
83
+ if (a.callerCount > 5) lines.push(` ... and ${a.callerCount - 5} more`);
84
+ }
85
+ }
86
+ return `APIs (${apis.length}):\n${lines.join('\n')}`;
87
+ }
88
+
89
+ /**
90
+ * Format an API listing grouped by directory.
91
+ */
92
+ export function formatApiListGrouped(
93
+ apis: Array<{
94
+ name: string;
95
+ kind: string;
96
+ filePath: string;
97
+ startLine: number;
98
+ signature?: string;
99
+ callerCount: number;
100
+ callers?: Array<{ name: string; filePath: string; startLine: number }>;
101
+ }>,
102
+ ): string {
103
+ if (apis.length === 0) return 'No public APIs found.';
104
+ // Group by directory
105
+ const groups = new Map<string, typeof apis>();
106
+ for (const a of apis) {
107
+ const dir = a.filePath.substring(0, a.filePath.lastIndexOf('/'));
108
+ if (!groups.has(dir)) groups.set(dir, []);
109
+ groups.get(dir)!.push(a);
110
+ }
111
+ // Render with directory headers
112
+ const lines: string[] = [];
113
+ const sortedDirs = [...groups.keys()].sort();
114
+ for (const dir of sortedDirs) {
115
+ const entries = groups.get(dir)!;
116
+ lines.push('');
117
+ lines.push(`=== ${dir}/ ===`);
118
+ for (const a of entries) {
119
+ const sig = a.signature ? ` ${a.signature}` : '';
120
+ lines.push(` ${a.name} [${a.kind}] ${a.filePath}:${a.startLine} (${a.callerCount} callers)${sig}`);
121
+ if (a.callerCount > 0 && a.callers) {
122
+ const callerLines = a.callers.slice(0, 5).map(c => ` Called by: ${c.name} ${c.filePath}:${c.startLine}`);
123
+ lines.push(...callerLines);
124
+ if (a.callerCount > 5) lines.push(` ... and ${a.callerCount - 5} more`);
125
+ }
126
+ }
127
+ }
128
+ lines.push('');
129
+ return lines.join('\n').trimStart();
130
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { groupIntoSubmodules } from './grouper.js';
4
+
5
+ describe('groupIntoSubmodules', () => {
6
+ it('groups connected call graph symbols into one submodule (hybrid connectivity grouping)', () => {
7
+ // -----------------------------------------------------------------------
8
+ // GIVEN a scan result with functions A, B, C where A calls B and B calls C
9
+ // (all in the same file, forming a connected chain)
10
+ // -----------------------------------------------------------------------
11
+ const filePath = 'src/module.ts';
12
+
13
+ const scan = {
14
+ directory: 'src',
15
+ files: [
16
+ {
17
+ filePath,
18
+ language: 'typescript',
19
+ symbols: [
20
+ { name: 'A', kind: 'function', qualifiedName: 'A', startLine: 1, endLine: 5, isExported: true },
21
+ { name: 'B', kind: 'function', qualifiedName: 'B', startLine: 6, endLine: 10, isExported: true },
22
+ { name: 'C', kind: 'function', qualifiedName: 'C', startLine: 11, endLine: 15, isExported: false },
23
+ ],
24
+ },
25
+ ],
26
+ allSymbols: [
27
+ { name: 'A', kind: 'function', filePath, qualifiedName: 'A', startLine: 1, isExported: true },
28
+ { name: 'B', kind: 'function', filePath, qualifiedName: 'B', startLine: 6, isExported: true },
29
+ { name: 'C', kind: 'function', filePath, qualifiedName: 'C', startLine: 11, isExported: false },
30
+ ],
31
+ totalFiles: 1,
32
+ totalSymbols: 3,
33
+ };
34
+
35
+ // Mock call graph: A -> B -> C
36
+ const mockCg = {
37
+ getNodesByName(name: string) {
38
+ const nodeId = `node-${name}`;
39
+ return [{ id: nodeId, filePath }];
40
+ },
41
+ getCallees(nodeId: string) {
42
+ const calleeMap: Record<string, string[]> = {
43
+ 'node-A': ['B'],
44
+ 'node-B': ['C'],
45
+ 'node-C': [],
46
+ };
47
+ return (calleeMap[nodeId] || []).map((calleeName: string) => ({
48
+ node: { name: calleeName },
49
+ }));
50
+ },
51
+ };
52
+
53
+ // -----------------------------------------------------------------------
54
+ // WHEN groupIntoSubmodules(scan, mockCg) runs
55
+ // -----------------------------------------------------------------------
56
+ const result = groupIntoSubmodules(scan as any, mockCg);
57
+
58
+ // -----------------------------------------------------------------------
59
+ // THEN A, B, C are grouped into one submodule
60
+ // (connectivity-based grouping takes priority over per-file isolation)
61
+ // -----------------------------------------------------------------------
62
+ assert.equal(result.length, 1, 'should produce exactly one submodule');
63
+
64
+ const sortedMembers = [...result[0].memberFunctions].sort();
65
+ assert.deepEqual(sortedMembers, ['A', 'B', 'C'], 'should contain A, B, C');
66
+
67
+ assert.ok(
68
+ result[0].memberFiles.includes(filePath),
69
+ 'should include the source file in memberFiles',
70
+ );
71
+ });
72
+ });
@@ -0,0 +1,226 @@
1
+ import type { ScanResult } from './scanner.js';
2
+
3
+ export interface SubmoduleSuggestion {
4
+ slug: string;
5
+ kind: 'api' | 'service' | 'db' | 'ui' | 'pure-fn' | 'queue';
6
+ role: string;
7
+ memberFunctions: string[];
8
+ memberFiles: string[];
9
+ }
10
+
11
+ /**
12
+ * Group scanned symbols into suggested submodule groupings.
13
+ *
14
+ * Algorithm (hybrid):
15
+ * 1. Build adjacency map from call graph: for each symbol, find callees within the scan
16
+ * 2. BFS on undirected graph to find connected components
17
+ * 3. Components with >=2 symbols create connectivity-based submodule suggestions
18
+ * 4. Remaining (isolated) symbols fall back to per-file grouping
19
+ * 5. Apply mergeByDirectoryPrefix as final step
20
+ */
21
+ export function groupIntoSubmodules(scan: ScanResult, cg: any): SubmoduleSuggestion[] {
22
+ if (scan.allSymbols.length === 0) return [];
23
+
24
+ // --- Phase 1: Build adjacency map from call graph connectivity ---
25
+ const allNameSet = new Set(scan.allSymbols.map(s => s.name));
26
+ const adj = new Map<string, Set<string>>();
27
+ const nodeKeyMap = new Map<string, { name: string; filePath: string; kind: string; isExported: boolean }>();
28
+
29
+ for (const sym of scan.allSymbols) {
30
+ const key = `${sym.filePath}::${sym.name}`;
31
+ const calleeSet = new Set<string>();
32
+ adj.set(key, calleeSet);
33
+ nodeKeyMap.set(key, sym);
34
+
35
+ const nodes = cg.getNodesByName(sym.name);
36
+ for (const node of nodes) {
37
+ if (node.filePath !== sym.filePath) continue;
38
+ const callees = cg.getCallees(node.id);
39
+ for (const callee of callees) {
40
+ if (allNameSet.has(callee.node.name)) {
41
+ calleeSet.add(callee.node.name);
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ // Build reverse index: name -> list of symbol keys (for efficient BFS lookup)
48
+ const nameToKeys = new Map<string, string[]>();
49
+ for (const [key, sym] of nodeKeyMap.entries()) {
50
+ const arr = nameToKeys.get(sym.name) || [];
51
+ arr.push(key);
52
+ nameToKeys.set(sym.name, arr);
53
+ }
54
+
55
+ // --- Phase 2: BFS to find connected components (undirected graph) ---
56
+ const visited = new Set<string>();
57
+ const components: Array<Array<{ name: string; filePath: string; kind: string; isExported: boolean }>> = [];
58
+
59
+ for (const key of adj.keys()) {
60
+ if (visited.has(key)) continue;
61
+
62
+ const component: Array<{ name: string; filePath: string; kind: string; isExported: boolean }> = [];
63
+ const queue = [key];
64
+ visited.add(key);
65
+
66
+ while (queue.length > 0) {
67
+ const currentKey = queue.shift()!;
68
+ const sym = nodeKeyMap.get(currentKey)!;
69
+ component.push(sym);
70
+
71
+ const calleeNames = adj.get(currentKey) || new Set();
72
+ for (const calleeName of calleeNames) {
73
+ const targetKeys = nameToKeys.get(calleeName) || [];
74
+ for (const tk of targetKeys) {
75
+ if (!visited.has(tk)) {
76
+ visited.add(tk);
77
+ queue.push(tk);
78
+ }
79
+ }
80
+ }
81
+ }
82
+
83
+ if (component.length > 0) {
84
+ components.push(component);
85
+ }
86
+ }
87
+
88
+ // --- Phase 3: Build suggestions from connected components ---
89
+ const suggestions: SubmoduleSuggestion[] = [];
90
+ const processed = new Set<string>();
91
+
92
+ // Components with >=2 symbols become connectivity-based submodules
93
+ for (const component of components) {
94
+ if (component.length < 2) continue;
95
+
96
+ const files = [...new Set(component.map(s => s.filePath))];
97
+ const representativePath = files[0];
98
+ const fileName = representativePath.split('/').pop() || '';
99
+ const slug = fileName.replace(/\.\w+$/, '').replace(/[_ ]/g, '-').toLowerCase();
100
+ const kind = inferKind(representativePath, component);
101
+ const role = inferRole(kind, slug, component.filter(s => s.isExported));
102
+
103
+ for (const s of component) {
104
+ processed.add(`${s.filePath}::${s.name}`);
105
+ }
106
+
107
+ suggestions.push({
108
+ slug,
109
+ kind,
110
+ role,
111
+ memberFunctions: [...new Set(component.map(s => s.name))],
112
+ memberFiles: files,
113
+ });
114
+ }
115
+
116
+ // --- Phase 4: Remaining (isolated) symbols fall back to per-file grouping ---
117
+ for (const file of scan.files) {
118
+ const unprocessedSymbols = file.symbols.filter(
119
+ s => !processed.has(`${file.filePath}::${s.name}`)
120
+ );
121
+ if (unprocessedSymbols.length === 0 && file.symbols.length === 0) continue;
122
+
123
+ const fileName = file.filePath.split('/').pop() || '';
124
+ const slug = fileName.replace(/\.\w+$/, '').replace(/[_ ]/g, '-').toLowerCase();
125
+ const kind = inferKind(file.filePath, file.symbols);
126
+ const symbols = unprocessedSymbols.map(s => s.name);
127
+ if (symbols.length === 0) continue;
128
+
129
+ for (const s of unprocessedSymbols) processed.add(`${file.filePath}::${s.name}`);
130
+
131
+ const role = inferRole(kind, slug, file.symbols.filter(s => s.isExported));
132
+ suggestions.push({
133
+ slug,
134
+ kind,
135
+ role,
136
+ memberFunctions: symbols,
137
+ memberFiles: [file.filePath],
138
+ });
139
+ }
140
+
141
+ // --- Phase 5: Merge small files sharing a directory prefix ---
142
+ const merged = mergeByDirectoryPrefix(suggestions, scan.directory);
143
+ return merged;
144
+ }
145
+
146
+ function inferKind(filePath: string, symbols: Array<{ name: string; kind: string }>): SubmoduleSuggestion['kind'] {
147
+ const lower = filePath.toLowerCase();
148
+
149
+ // Detect by path patterns
150
+ if (lower.includes('/api/') || lower.includes('/routes/') || lower.includes('/controller')) return 'api';
151
+ if (lower.includes('/db/') || lower.includes('/model/') || lower.includes('/repository') || lower.includes('/schema')) return 'db';
152
+ if (lower.includes('/ui/') || lower.includes('/component/') || lower.includes('/page/') || lower.includes('/view')) return 'ui';
153
+ if (lower.includes('/queue/') || lower.includes('/job/') || lower.includes('/worker')) return 'queue';
154
+
155
+ // Detect by symbol kinds
156
+ const hasHandler = symbols.some((s) => s.kind === 'route' || s.kind === 'component');
157
+ if (hasHandler) return 'api';
158
+
159
+ const hasModel = symbols.some((s) => s.kind === 'interface' || s.kind === 'struct');
160
+ if (hasModel) return 'db';
161
+
162
+ return 'service';
163
+ }
164
+
165
+ function inferRole(kind: SubmoduleSuggestion['kind'], slug: string, exportedSymbols: Array<{ name: string; kind: string }>): string {
166
+ const name = slug.replace(/-/g, ' ');
167
+ switch (kind) {
168
+ case 'api':
169
+ return `Handles API requests for ${name}`;
170
+ case 'db':
171
+ return `Manages data access and persistence for ${name}`;
172
+ case 'service':
173
+ return `Contains business logic for ${name}`;
174
+ case 'ui':
175
+ return `Renders UI components for ${name}`;
176
+ case 'queue':
177
+ return `Processes background jobs for ${name}`;
178
+ case 'pure-fn':
179
+ return `Provides pure utility functions for ${name}`;
180
+ default:
181
+ return `Supports ${name} functionality`;
182
+ }
183
+ }
184
+
185
+ function mergeByDirectoryPrefix(
186
+ suggestions: SubmoduleSuggestion[],
187
+ _directory: string,
188
+ ): SubmoduleSuggestion[] {
189
+ // When there are many single-file suggestions, merge those
190
+ // that share a common 2-segment directory prefix
191
+ const prefixMap = new Map<string, SubmoduleSuggestion>();
192
+
193
+ for (const s of suggestions) {
194
+ if (s.memberFiles.length !== 1) {
195
+ // Already contains multiple files, keep as-is
196
+ const key = s.slug;
197
+ prefixMap.set(key, s);
198
+ continue;
199
+ }
200
+
201
+ const filePath = s.memberFiles[0];
202
+ const parts = filePath.split('/');
203
+ // Use the parent directory as merge key for files in subdirs
204
+ const mergeKey = parts.length >= 3 ? parts.slice(0, -1).join('/') : s.slug;
205
+
206
+ if (prefixMap.has(mergeKey)) {
207
+ const existing = prefixMap.get(mergeKey)!;
208
+ existing.memberFunctions.push(...s.memberFunctions);
209
+ existing.memberFiles.push(...s.memberFiles);
210
+ // Keep more specific kind
211
+ if (existing.kind === 'service' && s.kind !== 'service') {
212
+ existing.kind = s.kind;
213
+ }
214
+ } else {
215
+ prefixMap.set(mergeKey, {
216
+ slug: mergeKey.replace(/\//g, '-'),
217
+ kind: s.kind,
218
+ role: s.role,
219
+ memberFunctions: [...s.memberFunctions],
220
+ memberFiles: [...s.memberFiles],
221
+ });
222
+ }
223
+ }
224
+
225
+ return Array.from(prefixMap.values());
226
+ }
@@ -0,0 +1,89 @@
1
+ import { createRequire } from 'node:module';
2
+ const require = createRequire(import.meta.url);
3
+
4
+ export interface FileScan {
5
+ filePath: string;
6
+ language: string;
7
+ symbols: Array<{
8
+ name: string;
9
+ kind: string;
10
+ qualifiedName: string;
11
+ startLine: number;
12
+ endLine: number;
13
+ isExported: boolean;
14
+ signature?: string;
15
+ }>;
16
+ }
17
+
18
+ export interface ScanResult {
19
+ directory: string;
20
+ files: FileScan[];
21
+ allSymbols: Array<{
22
+ name: string;
23
+ kind: string;
24
+ filePath: string;
25
+ qualifiedName: string;
26
+ startLine: number;
27
+ isExported: boolean;
28
+ }>;
29
+ totalFiles: number;
30
+ totalSymbols: number;
31
+ }
32
+
33
+ /**
34
+ * Scan a directory for all files and symbols tracked by CodeGraph.
35
+ */
36
+ export async function scanDirectory(
37
+ cg: any,
38
+ dirPath: string,
39
+ ): Promise<ScanResult> {
40
+ const { CodeGraph } = require('@colbymchenry/codegraph');
41
+ const files = cg.getFiles();
42
+ const dirPrefix = dirPath.replace(/^\/?/, '').replace(/\/?$/, '') + '/';
43
+
44
+ // Filter files within the directory
45
+ const dirFiles = files.filter((f: { path: string }) => f.path.startsWith(dirPrefix));
46
+
47
+ const fileScans: FileScan[] = [];
48
+ const allSymbols: ScanResult['allSymbols'] = [];
49
+
50
+ for (const file of dirFiles) {
51
+ const nodes = cg.getNodesInFile(file.path);
52
+ const symbols = nodes
53
+ .filter((n: any) => !['file', 'import', 'parameter'].includes(n.kind))
54
+ .map((n: any) => ({
55
+ name: n.name,
56
+ kind: n.kind,
57
+ qualifiedName: n.qualifiedName,
58
+ startLine: n.startLine,
59
+ endLine: n.endLine,
60
+ isExported: !!n.isExported,
61
+ signature: n.signature,
62
+ }));
63
+
64
+ fileScans.push({
65
+ filePath: file.path,
66
+ language: file.language,
67
+ symbols,
68
+ });
69
+
70
+ for (const sym of symbols) {
71
+ allSymbols.push({
72
+ name: sym.name,
73
+ kind: sym.kind,
74
+ filePath: file.path,
75
+ qualifiedName: sym.qualifiedName,
76
+ startLine: sym.startLine,
77
+ isExported: sym.isExported,
78
+ });
79
+ }
80
+ }
81
+
82
+ return {
83
+ directory: dirPath,
84
+ files: fileScans,
85
+ allSymbols,
86
+ totalFiles: dirFiles.length,
87
+ totalSymbols: allSymbols.length,
88
+ };
89
+ }
@@ -0,0 +1,140 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ /**
5
+ * REGTEST-2: Verify that `verifyOverlay` correctly validates edge
6
+ * relationships by checking `getCallees()` / `getCallers()`.
7
+ *
8
+ * The fix added a caller/callee relationship check after confirming both
9
+ * edge endpoints exist in the CodeGraph index. Previously, edges were
10
+ * only validated for existence of the from/to symbols, not for actual
11
+ * call relationships.
12
+ */
13
+ describe('REGTEST-2: Edge relationship validation', () => {
14
+ it('should report edge failure when no caller/callee relationship exists', async () => {
15
+ // Mock CodeGraph where:
16
+ // - funcA and funcB both exist as nodes
17
+ // - funcA has NO callees (no relationship with funcB)
18
+ // - The feature slug resolves to a known file so the feature check passes
19
+ const mockCg = {
20
+ searchNodes: (name: string) => {
21
+ if (name === 'test-feature') return [{ node: { id: 'id-feature', name: 'test-feature' } }];
22
+ if (name === 'funcA') return [{ node: { id: 'id-a', name: 'funcA' } }];
23
+ if (name === 'funcB') return [{ node: { id: 'id-b', name: 'funcB' } }];
24
+ return [];
25
+ },
26
+ getFiles: () => [{ path: 'test-feature/mod-a.ts' }],
27
+ getCallees: () => [],
28
+ getCallers: () => [],
29
+ };
30
+
31
+ const { verifyOverlay } = await import('./checker.js');
32
+
33
+ const overlay = {
34
+ features: {
35
+ 'test-feature': {
36
+ submodules: [{ slug: 'mod-a', functions: ['funcA', 'funcB'] }],
37
+ edges: [{ from: 'funcA', to: 'funcB', kind: 'call' }],
38
+ },
39
+ },
40
+ };
41
+
42
+ const report = await verifyOverlay(mockCg as any, overlay);
43
+
44
+ // Feature and functions should pass; the edge should fail
45
+ assert.ok(Array.isArray(report.failed), 'failed should be an array');
46
+ const edgeFails = report.failed.filter((f) => f.type === 'edge');
47
+ assert.strictEqual(edgeFails.length, 1, 'should report exactly 1 edge failure');
48
+
49
+ const edgeFail = edgeFails[0];
50
+ assert.match(
51
+ edgeFail.location,
52
+ /funcA\s*->\s*funcB/,
53
+ 'edge failure location should mention funcA -> funcB',
54
+ );
55
+ assert.ok(
56
+ edgeFail.suggestion?.toLowerCase().includes('no actual call'),
57
+ 'failure suggestion should mention missing call relationship',
58
+ );
59
+
60
+ assert.strictEqual(report.passed, 3, 'feature + 2 functions should pass');
61
+ assert.strictEqual(report.total, 4, 'total = 3 passed + 1 failed');
62
+ });
63
+
64
+ it('should pass edge when caller/callee relationship exists', async () => {
65
+ // Mock CodeGraph where:
66
+ // - funcA and funcB both exist as nodes
67
+ // - funcA's getCallees returns funcB (relationship exists)
68
+ const mockCg = {
69
+ searchNodes: (name: string) => {
70
+ if (name === 'test-feature') return [{ node: { id: 'id-feature', name: 'test-feature' } }];
71
+ if (name === 'funcA') return [{ node: { id: 'id-a', name: 'funcA' } }];
72
+ if (name === 'funcB') return [{ node: { id: 'id-b', name: 'funcB' } }];
73
+ return [];
74
+ },
75
+ getFiles: () => [{ path: 'test-feature/mod-a.ts' }],
76
+ getCallees: (id: string) => {
77
+ if (id === 'id-a') return [{ node: { id: 'id-b', name: 'funcB' } }];
78
+ return [];
79
+ },
80
+ getCallers: () => [],
81
+ };
82
+
83
+ const { verifyOverlay } = await import('./checker.js');
84
+
85
+ const overlay = {
86
+ features: {
87
+ 'test-feature': {
88
+ submodules: [{ slug: 'mod-a', functions: ['funcA', 'funcB'] }],
89
+ edges: [{ from: 'funcA', to: 'funcB', kind: 'call' }],
90
+ },
91
+ },
92
+ };
93
+
94
+ const report = await verifyOverlay(mockCg as any, overlay);
95
+
96
+ assert.strictEqual(report.failed.length, 0, 'no failures expected');
97
+ // 1 feature + 2 functions + 1 edge = 4 passed
98
+ assert.strictEqual(report.passed, 4, 'feature + 2 functions + edge should all pass');
99
+ assert.strictEqual(report.total, 4, 'total = 4 passed');
100
+ });
101
+
102
+ it('should handle getCallees throwing gracefully (fallback to next result)', async () => {
103
+ // Mock CodeGraph where getCallees throws on the first search result
104
+ // but the second result has the relationship
105
+ const mockCg = {
106
+ searchNodes: (name: string) => {
107
+ if (name === 'test-feature') return [{ node: { id: 'id-feature', name: 'test-feature' } }];
108
+ if (name === 'funcA') return [
109
+ { node: { id: 'id-a-bad', name: 'funcA' } },
110
+ { node: { id: 'id-a-good', name: 'funcA' } },
111
+ ];
112
+ if (name === 'funcB') return [{ node: { id: 'id-b', name: 'funcB' } }];
113
+ return [];
114
+ },
115
+ getFiles: () => [{ path: 'test-feature/mod-a.ts' }],
116
+ getCallees: (id: string) => {
117
+ if (id === 'id-a-bad') throw new Error('index error');
118
+ if (id === 'id-a-good') return [{ node: { id: 'id-b', name: 'funcB' } }];
119
+ return [];
120
+ },
121
+ getCallers: () => [],
122
+ };
123
+
124
+ const { verifyOverlay } = await import('./checker.js');
125
+
126
+ const overlay = {
127
+ features: {
128
+ 'test-feature': {
129
+ submodules: [{ slug: 'mod-a', functions: ['funcA', 'funcB'] }],
130
+ edges: [{ from: 'funcA', to: 'funcB', kind: 'call' }],
131
+ },
132
+ },
133
+ };
134
+
135
+ const report = await verifyOverlay(mockCg as any, overlay);
136
+
137
+ assert.strictEqual(report.failed.length, 0, 'no failures expected despite thrown error on first result');
138
+ assert.strictEqual(report.passed, 4, 'all checks should pass');
139
+ });
140
+ });