@laitszkin/apollo-toolkit 4.0.11 → 4.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.
Files changed (145) hide show
  1. package/AGENTS.md +37 -27
  2. package/CHANGELOG.md +47 -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 +549 -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 +613 -5
  17. package/packages/tools/codegraph/dist/index.d.ts +3 -0
  18. package/packages/tools/codegraph/dist/index.js +343 -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 +362 -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/scripts/test.sh +39 -0
  133. package/skills/design/SKILL.md +33 -0
  134. package/skills/init-project-html/SKILL.md +66 -56
  135. package/skills/init-project-html/lib/atlas/assets/architecture.css +2 -1
  136. package/skills/init-project-html/lib/atlas/render.js +11 -1
  137. package/skills/init-project-html/lib/atlas/schema.js +44 -7
  138. package/skills/init-project-html/references/TEMPLATE_SPEC.md +20 -0
  139. package/skills/init-project-html/references/architecture.md +35 -35
  140. package/skills/init-project-html/references/definition.md +12 -17
  141. package/skills/update-project-html/README.md +16 -27
  142. package/skills/update-project-html/SKILL.md +54 -41
  143. package/skills/update-project-html/references/architecture.md +35 -35
  144. package/skills/update-project-html/references/definition.md +12 -17
  145. package/tsconfig.json +1 -0
@@ -0,0 +1,145 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ const require = createRequire(import.meta.url);
5
+ import { closeIndex } from './cg-instance.js';
6
+ import { formatOutput } from './formatter.js';
7
+ import { verifyOverlay } from './verify/checker.js';
8
+ import yaml from 'js-yaml';
9
+
10
+ export interface VerifyOptions {
11
+ json?: boolean;
12
+ }
13
+
14
+ /**
15
+ * Read a spec overlay from the standard atlas directory layout.
16
+ * Loads the overlay in the same way as skills/init-project-html/lib/atlas/state.js::loadOverlay.
17
+ */
18
+ function loadOverlay(specDir: string): any {
19
+ const overlayDir = path.join(specDir, 'architecture_diff', 'atlas');
20
+
21
+ if (!fs.existsSync(overlayDir)) {
22
+ throw new Error(`No architecture diff atlas found at: ${overlayDir}. Run "apltk architecture diff" first to generate the overlay.`);
23
+ }
24
+
25
+ const overlay: any = {
26
+ meta: null,
27
+ actors: null,
28
+ edges: null,
29
+ featureOrder: null,
30
+ features: {},
31
+ removed: { features: [], submodules: [] },
32
+ };
33
+
34
+ // Parse atlas.index.yaml via js-yaml
35
+ const indexFile = path.join(overlayDir, 'atlas.index.yaml');
36
+ if (fs.existsSync(indexFile)) {
37
+ const raw = fs.readFileSync(indexFile, 'utf8');
38
+ if (raw.trim()) {
39
+ const index = yaml.load(raw) as any;
40
+ if (index && typeof index === 'object' && !Array.isArray(index)) {
41
+ if (index.meta !== undefined) overlay.meta = index.meta;
42
+ if (index.actors !== undefined) overlay.actors = index.actors;
43
+ if (index.edges !== undefined) overlay.edges = index.edges;
44
+ if (Array.isArray(index.features)) {
45
+ overlay.featureOrder = index.features
46
+ .map((entry: any) => (typeof entry === 'string' ? entry : entry?.slug))
47
+ .filter(Boolean);
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ // Load feature files via js-yaml
54
+ const featuresDir = path.join(overlayDir, 'features');
55
+ if (fs.existsSync(featuresDir)) {
56
+ for (const entry of fs.readdirSync(featuresDir)) {
57
+ if (!entry.endsWith('.yaml')) continue;
58
+ const featureFile = path.join(featuresDir, entry);
59
+ const raw = fs.readFileSync(featureFile, 'utf8');
60
+ if (raw.trim()) {
61
+ const data = yaml.load(raw) as any;
62
+ if (data && typeof data === 'object' && data.slug) {
63
+ const feature: any = {
64
+ slug: data.slug,
65
+ submodules: Array.isArray(data.submodules)
66
+ ? data.submodules.map(normalizeSubmodule)
67
+ : [],
68
+ edges: Array.isArray(data.edges) ? data.edges : [],
69
+ };
70
+ if (data.action !== undefined) feature.action = data.action;
71
+ overlay.features[data.slug] = feature;
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ // Load _removed.yaml via js-yaml
78
+ const removedFile = path.join(overlayDir, '_removed.yaml');
79
+ if (fs.existsSync(removedFile)) {
80
+ const raw = fs.readFileSync(removedFile, 'utf8');
81
+ if (raw.trim()) {
82
+ const removed = yaml.load(raw) as any;
83
+ if (removed && typeof removed === 'object' && !Array.isArray(removed)) {
84
+ if (Array.isArray(removed.features)) overlay.removed.features = removed.features;
85
+ if (Array.isArray(removed.submodules)) overlay.removed.submodules = removed.submodules;
86
+ }
87
+ }
88
+ }
89
+
90
+ return overlay;
91
+ }
92
+
93
+ function normalizeSubmodule(sub: any): any {
94
+ if (!sub || typeof sub !== 'object') return sub;
95
+ return {
96
+ slug: sub.slug,
97
+ kind: sub.kind || 'service',
98
+ role: sub.role || '',
99
+ functions: Array.isArray(sub.functions) ? sub.functions : [],
100
+ variables: Array.isArray(sub.variables) ? sub.variables : [],
101
+ ...(sub.action !== undefined ? { action: sub.action } : {}),
102
+ };
103
+ }
104
+
105
+ export async function handleVerify(
106
+ projectRoot: string,
107
+ specDir: string,
108
+ options: VerifyOptions = {},
109
+ ): Promise<number> {
110
+ const resolvedSpecDir = path.resolve(specDir);
111
+
112
+ let overlay: any;
113
+ try {
114
+ overlay = loadOverlay(resolvedSpecDir);
115
+ } catch (err: any) {
116
+ process.stderr.write(`Error loading overlay: ${err.message}\n`);
117
+ return 1;
118
+ }
119
+
120
+ const { CodeGraph } = require('@colbymchenry/codegraph');
121
+ const cg = await CodeGraph.open(projectRoot, { sync: false, readOnly: true });
122
+ const report = await verifyOverlay(cg, overlay);
123
+ closeIndex(cg);
124
+
125
+ if (options.json) {
126
+ process.stdout.write(formatOutput(report, { json: true }) + '\n');
127
+ } else {
128
+ process.stdout.write(`\n=== Verify Report ===\n\n`);
129
+ process.stdout.write(`Total: ${report.total}\n`);
130
+ process.stdout.write(`Passed: ${report.passed}\n`);
131
+ process.stdout.write(`Failed: ${report.failed.length}\n`);
132
+ process.stdout.write(`Skipped: ${report.skipped}\n`);
133
+
134
+ if (report.failed.length > 0) {
135
+ process.stdout.write('\nFailures:\n');
136
+ for (const f of report.failed) {
137
+ process.stdout.write(` [${f.type}] ${f.location}\n`);
138
+ if (f.suggestion) process.stdout.write(` Suggestion: ${f.suggestion}\n`);
139
+ }
140
+ }
141
+ process.stdout.write('\n');
142
+ }
143
+
144
+ return report.failed.length > 0 ? 1 : 0;
145
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { formatApiList } from './formatter.js';
4
+
5
+ describe('formatApiList', () => {
6
+ it('should show caller names in output when callers exist', () => {
7
+ const apis = [
8
+ {
9
+ name: 'myFunc',
10
+ kind: 'function',
11
+ filePath: 'src/a.ts',
12
+ startLine: 10,
13
+ callerCount: 3,
14
+ callers: [
15
+ { name: 'callerOne', filePath: 'src/b.ts', startLine: 5 },
16
+ { name: 'callerTwo', filePath: 'src/c.ts', startLine: 15 },
17
+ { name: 'callerThree', filePath: 'src/d.ts', startLine: 25 },
18
+ ],
19
+ },
20
+ ];
21
+
22
+ const output = formatApiList(apis);
23
+
24
+ assert.ok(output.includes('callerOne'), 'output should contain callerOne');
25
+ assert.ok(output.includes('callerTwo'), 'output should contain callerTwo');
26
+ assert.ok(output.includes('callerThree'), 'output should contain callerThree');
27
+ assert.ok(output.includes('(3 callers)'), 'output should show caller count');
28
+ assert.ok(output.includes('myFunc'), 'output should contain function name');
29
+ });
30
+
31
+ it('should show "(0 callers)" when there are no callers', () => {
32
+ const apis = [
33
+ {
34
+ name: 'noCallers',
35
+ kind: 'function',
36
+ filePath: 'src/a.ts',
37
+ startLine: 1,
38
+ callerCount: 0,
39
+ },
40
+ ];
41
+
42
+ const output = formatApiList(apis);
43
+
44
+ assert.ok(output.includes('(0 callers)'), 'output should show zero callers');
45
+ assert.ok(!output.includes('Called by:'), 'output should not contain caller lines');
46
+ });
47
+ });
@@ -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
+ }