maestro-flow 0.4.19 → 0.4.21
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.
- package/.agents/agents/workflow-collab-planner.md +4 -1
- package/.agents/agents/workflow-plan-checker.md +11 -1
- package/.agents/agents/workflow-planner.md +4 -1
- package/.agents/skills/maestro/SKILL.md +8 -5
- package/.agents/skills/maestro-analyze/SKILL.md +1 -1
- package/.agents/skills/maestro-brainstorm/SKILL.md +2 -1
- package/.agents/skills/maestro-companion/SKILL.md +533 -0
- package/.agents/skills/maestro-grill/SKILL.md +116 -0
- package/.agents/skills/maestro-plan/SKILL.md +4 -0
- package/.agents/skills/maestro-ralph/SKILL.md +11 -7
- package/.agents/skills/maestro-ralph-execute/SKILL.md +2 -1
- package/.agents/skills/maestro-swarm-workflow/SKILL.md +266 -0
- package/.agents/skills/maestro-universal-workflow/SKILL.md +563 -0
- package/.agents/skills/manage-codebase-rebuild/SKILL.md +13 -1
- package/.agents/skills/manage-codebase-refresh/SKILL.md +3 -0
- package/.agents/skills/spec-setup/SKILL.md +9 -5
- package/.agents/skills/team-adversarial-swarm/SKILL.md +235 -0
- package/.agents/skills/team-adversarial-swarm/scripts/aco.py +473 -0
- package/.agents/skills/team-adversarial-swarm/scripts/pheromone.py +144 -0
- package/.agents/skills/team-adversarial-swarm/scripts/scoring.py +92 -0
- package/.agents/skills/team-adversarial-swarm/scripts/test_aco.py +475 -0
- package/.agents/skills/team-adversarial-swarm/specs/ant-output-schema.md +115 -0
- package/.agents/skills/team-adversarial-swarm/specs/convergence-criteria.md +75 -0
- package/.agents/skills/team-adversarial-swarm/specs/pheromone-schema.md +90 -0
- package/.agents/skills/team-adversarial-swarm/specs/swarm-config-template.json +66 -0
- package/.agents/skills/team-adversarial-swarm/specs/swarm-protocol.md +105 -0
- package/.agents/skills/team-adversarial-swarm/workflows/wf-swarm-converge.js +197 -0
- package/.agents/skills/team-adversarial-swarm/workflows/wf-swarm-explore.js +194 -0
- package/.agents/skills/team-adversarial-swarm/workflows/wf-swarm-score.js +188 -0
- package/.agents/skills/team-adversarial-swarm/workflows/wf-swarm-synthesize.js +248 -0
- package/.agy/agents/workflow-collab-planner.md +4 -1
- package/.agy/agents/workflow-plan-checker.md +11 -1
- package/.agy/agents/workflow-planner.md +4 -1
- package/.agy/skills/maestro/SKILL.md +8 -5
- package/.agy/skills/maestro-analyze/SKILL.md +1 -1
- package/.agy/skills/maestro-brainstorm/SKILL.md +2 -1
- package/.agy/skills/maestro-companion/SKILL.md +529 -0
- package/.agy/skills/maestro-grill/SKILL.md +116 -0
- package/.agy/skills/maestro-plan/SKILL.md +4 -0
- package/.agy/skills/maestro-ralph/SKILL.md +11 -7
- package/.agy/skills/maestro-ralph-execute/SKILL.md +2 -1
- package/.agy/skills/maestro-swarm-workflow/SKILL.md +263 -0
- package/.agy/skills/maestro-universal-workflow/SKILL.md +560 -0
- package/.agy/skills/manage-codebase-rebuild/SKILL.md +13 -1
- package/.agy/skills/manage-codebase-refresh/SKILL.md +3 -0
- package/.agy/skills/spec-setup/SKILL.md +9 -5
- package/.agy/skills/team-adversarial-swarm/SKILL.md +244 -0
- package/.agy/skills/team-adversarial-swarm/scripts/aco.py +473 -0
- package/.agy/skills/team-adversarial-swarm/scripts/pheromone.py +144 -0
- package/.agy/skills/team-adversarial-swarm/scripts/scoring.py +92 -0
- package/.agy/skills/team-adversarial-swarm/scripts/test_aco.py +475 -0
- package/.agy/skills/team-adversarial-swarm/specs/ant-output-schema.md +115 -0
- package/.agy/skills/team-adversarial-swarm/specs/convergence-criteria.md +75 -0
- package/.agy/skills/team-adversarial-swarm/specs/pheromone-schema.md +90 -0
- package/.agy/skills/team-adversarial-swarm/specs/swarm-config-template.json +66 -0
- package/.agy/skills/team-adversarial-swarm/specs/swarm-protocol.md +105 -0
- package/.agy/skills/team-adversarial-swarm/workflows/wf-swarm-converge.js +197 -0
- package/.agy/skills/team-adversarial-swarm/workflows/wf-swarm-explore.js +194 -0
- package/.agy/skills/team-adversarial-swarm/workflows/wf-swarm-score.js +188 -0
- package/.agy/skills/team-adversarial-swarm/workflows/wf-swarm-synthesize.js +248 -0
- package/.claude/agents/workflow-collab-planner.md +4 -1
- package/.claude/agents/workflow-plan-checker.md +11 -1
- package/.claude/agents/workflow-planner.md +4 -1
- package/.claude/commands/maestro-analyze.md +1 -1
- package/.claude/commands/maestro-brainstorm.md +2 -1
- package/.claude/commands/maestro-companion.md +531 -0
- package/.claude/commands/maestro-grill.md +114 -0
- package/.claude/commands/maestro-plan.md +4 -0
- package/.claude/commands/maestro-ralph-execute.md +2 -1
- package/.claude/commands/maestro-ralph.md +11 -7
- package/.claude/commands/maestro-swarm-workflow.md +264 -0
- package/.claude/commands/maestro-universal-workflow.md +561 -0
- package/.claude/commands/maestro.md +8 -5
- package/.claude/commands/manage-codebase-rebuild.md +13 -1
- package/.claude/commands/manage-codebase-refresh.md +3 -0
- package/.claude/commands/spec-setup.md +9 -5
- package/.claude/skills/team-adversarial-swarm/SKILL.md +233 -0
- package/.claude/skills/team-adversarial-swarm/scripts/aco.py +473 -0
- package/.claude/skills/team-adversarial-swarm/scripts/pheromone.py +144 -0
- package/.claude/skills/team-adversarial-swarm/scripts/scoring.py +92 -0
- package/.claude/skills/team-adversarial-swarm/scripts/test_aco.py +475 -0
- package/.claude/skills/team-adversarial-swarm/specs/ant-output-schema.md +115 -0
- package/.claude/skills/team-adversarial-swarm/specs/convergence-criteria.md +75 -0
- package/.claude/skills/team-adversarial-swarm/specs/pheromone-schema.md +90 -0
- package/.claude/skills/team-adversarial-swarm/specs/swarm-config-template.json +66 -0
- package/.claude/skills/team-adversarial-swarm/specs/swarm-protocol.md +105 -0
- package/.claude/skills/team-adversarial-swarm/workflows/wf-swarm-converge.js +197 -0
- package/.claude/skills/team-adversarial-swarm/workflows/wf-swarm-explore.js +194 -0
- package/.claude/skills/team-adversarial-swarm/workflows/wf-swarm-score.js +188 -0
- package/.claude/skills/team-adversarial-swarm/workflows/wf-swarm-synthesize.js +248 -0
- package/.codex/skills/maestro/SKILL.md +7 -2
- package/.codex/skills/maestro-companion/SKILL.md +485 -0
- package/.codex/skills/maestro-grill/SKILL.md +111 -0
- package/.codex/skills/maestro-ralph/SKILL.md +11 -7
- package/.codex/skills/manage-codebase-rebuild/SKILL.md +6 -0
- package/.codex/skills/manage-codebase-refresh/SKILL.md +6 -0
- package/dashboard/dist-server/dashboard/src/server/wiki/graph-analysis.d.ts +36 -0
- package/dashboard/dist-server/dashboard/src/server/wiki/graph-analysis.js +138 -2
- package/dashboard/dist-server/dashboard/src/server/wiki/graph-analysis.js.map +1 -1
- package/dashboard/dist-server/dashboard/src/server/wiki/search.js +13 -0
- package/dashboard/dist-server/dashboard/src/server/wiki/search.js.map +1 -1
- package/dashboard/dist-server/dashboard/src/server/wiki/virtual-wiki-adapters.d.ts +11 -0
- package/dashboard/dist-server/dashboard/src/server/wiki/virtual-wiki-adapters.js +178 -0
- package/dashboard/dist-server/dashboard/src/server/wiki/virtual-wiki-adapters.js.map +1 -1
- package/dashboard/dist-server/dashboard/src/server/wiki/wiki-indexer.d.ts +1 -0
- package/dashboard/dist-server/dashboard/src/server/wiki/wiki-indexer.js +39 -23
- package/dashboard/dist-server/dashboard/src/server/wiki/wiki-indexer.js.map +1 -1
- package/dashboard/dist-server/src/graph/types.d.ts +111 -0
- package/dashboard/dist-server/src/graph/types.js +2 -0
- package/dashboard/dist-server/src/graph/types.js.map +1 -0
- package/dist/src/cli.js +1 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/commands/kg.d.ts +11 -0
- package/dist/src/commands/kg.d.ts.map +1 -0
- package/dist/src/commands/kg.js +486 -0
- package/dist/src/commands/kg.js.map +1 -0
- package/dist/src/graph/analyzers/fs-analyzer.d.ts +10 -0
- package/dist/src/graph/analyzers/fs-analyzer.d.ts.map +1 -0
- package/dist/src/graph/analyzers/fs-analyzer.js +959 -0
- package/dist/src/graph/analyzers/fs-analyzer.js.map +1 -0
- package/dist/src/graph/index.d.ts +6 -0
- package/dist/src/graph/index.d.ts.map +1 -0
- package/dist/src/graph/index.js +6 -0
- package/dist/src/graph/index.js.map +1 -0
- package/dist/src/graph/loader.d.ts +3 -0
- package/dist/src/graph/loader.d.ts.map +1 -0
- package/dist/src/graph/loader.js +12 -0
- package/dist/src/graph/loader.js.map +1 -0
- package/dist/src/graph/merger.d.ts +56 -0
- package/dist/src/graph/merger.d.ts.map +1 -0
- package/dist/src/graph/merger.js +896 -0
- package/dist/src/graph/merger.js.map +1 -0
- package/dist/src/graph/query.d.ts +7 -0
- package/dist/src/graph/query.d.ts.map +1 -0
- package/dist/src/graph/query.js +126 -0
- package/dist/src/graph/query.js.map +1 -0
- package/dist/src/graph/types.d.ts +112 -0
- package/dist/src/graph/types.d.ts.map +1 -0
- package/dist/src/graph/types.js +2 -0
- package/dist/src/graph/types.js.map +1 -0
- package/dist/src/tui/install-ui/KgVendorConfig.d.ts +7 -0
- package/dist/src/tui/install-ui/KgVendorConfig.d.ts.map +1 -0
- package/dist/src/tui/install-ui/KgVendorConfig.js +9 -0
- package/dist/src/tui/install-ui/KgVendorConfig.js.map +1 -0
- package/dist/src/utils/update-notices.js +23 -0
- package/dist/src/utils/update-notices.js.map +1 -1
- package/package.json +1 -1
- package/workflows/analyze.md +2 -1
- package/workflows/brainstorm.md +24 -1
- package/workflows/codebase-rebuild.md +141 -1
- package/workflows/codebase-refresh.md +20 -0
- package/workflows/finish-work.md +7 -2
- package/workflows/grill.md +513 -0
- package/workflows/plan.md +7 -4
- package/workflows/specs-setup.md +99 -3
- package/workflows/swarm/wf-analyze.js +347 -0
- package/workflows/swarm/wf-brainstorm.js +456 -0
- package/workflows/swarm/wf-execute.js +379 -0
- package/workflows/swarm/wf-grill.js +359 -0
- package/workflows/swarm/wf-milestone-audit.js +385 -0
- package/workflows/swarm/wf-plan.js +468 -0
- package/workflows/swarm/wf-review.js +341 -0
- package/workflows/swarm/wf-verify.js +395 -0
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// fs-analyzer.ts -- Filesystem-based code analyzer.
|
|
3
|
+
//
|
|
4
|
+
// Recursively walks a project directory, extracts file nodes, import edges,
|
|
5
|
+
// exported symbols, module groupings, and architectural layers.
|
|
6
|
+
//
|
|
7
|
+
// No external dependencies -- uses only Node.js built-in modules.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
|
10
|
+
import { join, relative, extname, basename, dirname, sep, posix } from 'node:path';
|
|
11
|
+
import { execSync } from 'node:child_process';
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Constants
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
16
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
17
|
+
'.vue', '.py', '.go', '.java', '.rs',
|
|
18
|
+
]);
|
|
19
|
+
/** Extensions that are recognized as config/doc/infra (non-source) files. */
|
|
20
|
+
const NON_SOURCE_EXTENSIONS = new Set([
|
|
21
|
+
'.json', '.yaml', '.yml', '.toml', '.ini',
|
|
22
|
+
'.md', '.txt', '.rst',
|
|
23
|
+
'.dockerfile',
|
|
24
|
+
]);
|
|
25
|
+
const DEFAULT_EXCLUDES = [
|
|
26
|
+
'node_modules', 'dist', '.git', '.workflow',
|
|
27
|
+
];
|
|
28
|
+
const LAYER_PATTERNS = {
|
|
29
|
+
'commands': { name: 'CLI Commands', description: 'Command-line interface entry points' },
|
|
30
|
+
'coordinator': { name: 'Workflow Coordinator', description: 'Workflow orchestration and coordination' },
|
|
31
|
+
'hooks': { name: 'Hook System', description: 'Plugin and extensibility hooks' },
|
|
32
|
+
'tools': { name: 'Tool Layer', description: 'External tool integrations' },
|
|
33
|
+
'core': { name: 'Core Infrastructure', description: 'Core modules and shared infrastructure' },
|
|
34
|
+
'graph': { name: 'Graph Module', description: 'Knowledge graph data structures and queries' },
|
|
35
|
+
'agents': { name: 'Agent Management', description: 'Agent lifecycle and orchestration' },
|
|
36
|
+
'async': { name: 'Async Delegation', description: 'Asynchronous task delegation' },
|
|
37
|
+
'tui': { name: 'Terminal UI', description: 'Terminal user interface components' },
|
|
38
|
+
'db': { name: 'Backend', description: 'Backend services, data, and middleware' },
|
|
39
|
+
'services': { name: 'Backend', description: 'Backend services, data, and middleware' },
|
|
40
|
+
'routes': { name: 'Backend', description: 'Backend services, data, and middleware' },
|
|
41
|
+
'middleware': { name: 'Backend', description: 'Backend services, data, and middleware' },
|
|
42
|
+
'config': { name: 'Utilities', description: 'Configuration, utilities, and i18n' },
|
|
43
|
+
'utils': { name: 'Utilities', description: 'Configuration, utilities, and i18n' },
|
|
44
|
+
'i18n': { name: 'Utilities', description: 'Configuration, utilities, and i18n' },
|
|
45
|
+
};
|
|
46
|
+
const EXT_LANGUAGE = {
|
|
47
|
+
'.ts': 'TypeScript', '.tsx': 'TypeScript',
|
|
48
|
+
'.js': 'JavaScript', '.jsx': 'JavaScript',
|
|
49
|
+
'.mjs': 'JavaScript', '.cjs': 'JavaScript',
|
|
50
|
+
'.vue': 'Vue', '.py': 'Python',
|
|
51
|
+
'.go': 'Go', '.java': 'Java', '.rs': 'Rust',
|
|
52
|
+
};
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// File category classification
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
/** Config file names and patterns. */
|
|
57
|
+
const CONFIG_NAMES = new Set([
|
|
58
|
+
'package.json', 'tsconfig.json', 'tsconfig.base.json',
|
|
59
|
+
'.eslintrc', '.eslintrc.js', '.eslintrc.json', '.eslintrc.cjs',
|
|
60
|
+
'eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs',
|
|
61
|
+
'.prettierrc', '.prettierrc.js', '.prettierrc.json',
|
|
62
|
+
'jest.config.js', 'jest.config.ts', 'vitest.config.ts',
|
|
63
|
+
'webpack.config.js', 'vite.config.ts', 'rollup.config.js',
|
|
64
|
+
'.babelrc', 'babel.config.js',
|
|
65
|
+
'Makefile', 'CMakeLists.txt',
|
|
66
|
+
'pyproject.toml', 'setup.py', 'setup.cfg',
|
|
67
|
+
'go.mod', 'go.sum', 'Cargo.toml', 'Cargo.lock',
|
|
68
|
+
'pom.xml', 'build.gradle', 'build.gradle.kts',
|
|
69
|
+
]);
|
|
70
|
+
/** Infra directory patterns. */
|
|
71
|
+
const INFRA_DIRS = new Set([
|
|
72
|
+
'.github', '.gitlab', '.circleci', 'k8s', 'kubernetes',
|
|
73
|
+
'terraform', 'helm', 'deploy', 'docker', 'infra',
|
|
74
|
+
]);
|
|
75
|
+
const INFRA_NAMES = new Set([
|
|
76
|
+
'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml',
|
|
77
|
+
'.dockerignore', 'Vagrantfile',
|
|
78
|
+
]);
|
|
79
|
+
/** Test directory patterns. */
|
|
80
|
+
const TEST_DIRS = new Set([
|
|
81
|
+
'__tests__', 'test', 'tests', 'spec', 'specs',
|
|
82
|
+
]);
|
|
83
|
+
const TEST_INFIXES = ['.test.', '.spec.', '_test.', 'test_'];
|
|
84
|
+
/** Classify a file into a category based on name, extension, and path. */
|
|
85
|
+
function classifyFileCategory(relPath, name, ext) {
|
|
86
|
+
// Test files
|
|
87
|
+
const parts = relPath.split(posix.sep);
|
|
88
|
+
if (parts.some(p => TEST_DIRS.has(p)))
|
|
89
|
+
return 'test';
|
|
90
|
+
if (TEST_INFIXES.some(infix => name.includes(infix)))
|
|
91
|
+
return 'test';
|
|
92
|
+
if (name.startsWith('test_') || name.endsWith('_test' + ext))
|
|
93
|
+
return 'test';
|
|
94
|
+
// Config files
|
|
95
|
+
if (CONFIG_NAMES.has(name))
|
|
96
|
+
return 'config';
|
|
97
|
+
if (name.startsWith('.') && (ext === '.json' || ext === '.js' || ext === '.cjs' || ext === '.yaml' || ext === '.yml'))
|
|
98
|
+
return 'config';
|
|
99
|
+
// Infra files
|
|
100
|
+
if (INFRA_NAMES.has(name))
|
|
101
|
+
return 'infra';
|
|
102
|
+
if (parts.some(p => INFRA_DIRS.has(p)))
|
|
103
|
+
return 'infra';
|
|
104
|
+
// Docs
|
|
105
|
+
if (ext === '.md' || ext === '.txt' || ext === '.rst')
|
|
106
|
+
return 'docs';
|
|
107
|
+
// Default: code
|
|
108
|
+
return 'code';
|
|
109
|
+
}
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Test file detection (aligned with merger.ts)
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
/** Check if a relative path looks like a test file (aligned with merger.ts isTestPath). */
|
|
114
|
+
function isTestFile(relPath) {
|
|
115
|
+
const name = basename(relPath);
|
|
116
|
+
const ext = extname(name);
|
|
117
|
+
const stem = name.slice(0, name.length - ext.length);
|
|
118
|
+
// JS/TS family: infix pattern
|
|
119
|
+
if (SOURCE_EXTENSIONS.has(ext)) {
|
|
120
|
+
if (stem.endsWith('.test') || stem.endsWith('.spec'))
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
// Go
|
|
124
|
+
if (ext === '.go' && stem.endsWith('_test'))
|
|
125
|
+
return true;
|
|
126
|
+
// Python
|
|
127
|
+
if (ext === '.py' && (stem.startsWith('test_') || stem.endsWith('_test')))
|
|
128
|
+
return true;
|
|
129
|
+
// Java/Kotlin/C#
|
|
130
|
+
if ((ext === '.java' || ext === '.kt' || ext === '.cs') &&
|
|
131
|
+
(stem.endsWith('Test') || stem.endsWith('Tests') || stem.endsWith('IT')))
|
|
132
|
+
return true;
|
|
133
|
+
// Directory-based
|
|
134
|
+
const parts = relPath.split(posix.sep);
|
|
135
|
+
if (parts.some(p => TEST_DIRS.has(p)))
|
|
136
|
+
return true;
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* For a test file, compute candidate production file paths.
|
|
141
|
+
* Simplified version of merger.ts productionCandidates, covering the
|
|
142
|
+
* most common patterns: sibling de-infix, walk out of test dir, mirrored tree.
|
|
143
|
+
*/
|
|
144
|
+
function findProductionFile(testPath, fileSet) {
|
|
145
|
+
const name = basename(testPath);
|
|
146
|
+
const ext = extname(name);
|
|
147
|
+
const stem = name.slice(0, name.length - ext.length);
|
|
148
|
+
const dir = dirname(testPath);
|
|
149
|
+
const dirParts = dir.split(posix.sep).filter(s => s !== '.' && s !== '');
|
|
150
|
+
const JS_TS_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
|
|
151
|
+
// Helper: try candidate path
|
|
152
|
+
function tryPath(candidate) {
|
|
153
|
+
const norm = posix.normalize(candidate);
|
|
154
|
+
return fileSet.has(norm) ? norm : null;
|
|
155
|
+
}
|
|
156
|
+
// Helper: try all JS/TS extensions for a stem in a directory
|
|
157
|
+
function tryStem(dir, baseStem) {
|
|
158
|
+
for (const e of JS_TS_EXTS) {
|
|
159
|
+
const result = tryPath(dir ? `${dir}/${baseStem}${e}` : `${baseStem}${e}`);
|
|
160
|
+
if (result)
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
// JS/TS family: strip .test / .spec infix
|
|
166
|
+
if (SOURCE_EXTENSIONS.has(ext)) {
|
|
167
|
+
let baseStem = null;
|
|
168
|
+
if (stem.endsWith('.test'))
|
|
169
|
+
baseStem = stem.slice(0, -5);
|
|
170
|
+
else if (stem.endsWith('.spec'))
|
|
171
|
+
baseStem = stem.slice(0, -5);
|
|
172
|
+
if (baseStem) {
|
|
173
|
+
// 1. Sibling
|
|
174
|
+
const sibling = tryStem(dir === '.' ? '' : dir, baseStem);
|
|
175
|
+
if (sibling)
|
|
176
|
+
return sibling;
|
|
177
|
+
// 2. Walk out of __tests__ / test / spec directory
|
|
178
|
+
if (dirParts.length > 0 && TEST_DIRS.has(dirParts[dirParts.length - 1])) {
|
|
179
|
+
const parentDir = dirParts.slice(0, -1).join('/');
|
|
180
|
+
const result = tryStem(parentDir, baseStem);
|
|
181
|
+
if (result)
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
// 3. Mirrored tree (tests/... -> src/...)
|
|
185
|
+
if (dirParts.length > 0 && TEST_DIRS.has(dirParts[0])) {
|
|
186
|
+
const tailPath = dirParts.slice(1).join('/');
|
|
187
|
+
for (const root of ['src', 'app', 'lib', '']) {
|
|
188
|
+
const newDir = [root, tailPath].filter(Boolean).join('/');
|
|
189
|
+
const result = tryStem(newDir, baseStem);
|
|
190
|
+
if (result)
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Go
|
|
197
|
+
if (ext === '.go' && stem.endsWith('_test')) {
|
|
198
|
+
const baseStem = stem.slice(0, -5);
|
|
199
|
+
return tryPath(dir === '.' ? `${baseStem}.go` : `${dir}/${baseStem}.go`);
|
|
200
|
+
}
|
|
201
|
+
// Python
|
|
202
|
+
if (ext === '.py') {
|
|
203
|
+
let baseStem = null;
|
|
204
|
+
if (stem.startsWith('test_'))
|
|
205
|
+
baseStem = stem.slice(5);
|
|
206
|
+
else if (stem.endsWith('_test'))
|
|
207
|
+
baseStem = stem.slice(0, -5);
|
|
208
|
+
if (baseStem) {
|
|
209
|
+
const candidate = dir === '.' ? `${baseStem}.py` : `${dir}/${baseStem}.py`;
|
|
210
|
+
return tryPath(candidate);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Helpers
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
/** Normalize path separators to forward slashes. */
|
|
219
|
+
function toForward(p) {
|
|
220
|
+
return p.split(sep).join(posix.sep);
|
|
221
|
+
}
|
|
222
|
+
/** Simple glob-like match: supports leading *, trailing *, and exact. */
|
|
223
|
+
function simpleMatch(pattern, value) {
|
|
224
|
+
if (pattern === value)
|
|
225
|
+
return true;
|
|
226
|
+
if (pattern.startsWith('*') && value.endsWith(pattern.slice(1)))
|
|
227
|
+
return true;
|
|
228
|
+
if (pattern.endsWith('*') && value.startsWith(pattern.slice(0, -1)))
|
|
229
|
+
return true;
|
|
230
|
+
if (pattern.startsWith('*') && pattern.endsWith('*')) {
|
|
231
|
+
return value.includes(pattern.slice(1, -1));
|
|
232
|
+
}
|
|
233
|
+
// Support *.test.* style patterns
|
|
234
|
+
if (pattern.includes('*')) {
|
|
235
|
+
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
|
|
236
|
+
return regex.test(value);
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
/** Check whether a file or directory should be excluded. */
|
|
241
|
+
function shouldExclude(name, relPath, excludes) {
|
|
242
|
+
for (const pattern of excludes) {
|
|
243
|
+
if (simpleMatch(pattern, name))
|
|
244
|
+
return true;
|
|
245
|
+
if (simpleMatch(pattern, relPath))
|
|
246
|
+
return true;
|
|
247
|
+
// Also check if any path segment matches (e.g. "node_modules" deep in tree)
|
|
248
|
+
const segments = relPath.split(posix.sep);
|
|
249
|
+
if (segments.some(seg => simpleMatch(pattern, seg)))
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
/** Determine complexity heuristic from line count (legacy, kept for backward compat). */
|
|
255
|
+
function complexityFromLines(lineCount) {
|
|
256
|
+
if (lineCount < 100)
|
|
257
|
+
return 'simple';
|
|
258
|
+
if (lineCount <= 300)
|
|
259
|
+
return 'moderate';
|
|
260
|
+
return 'complex';
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Enhanced complexity heuristic factoring in line count, exports, imports,
|
|
264
|
+
* and nesting depth. Returns a score-based classification.
|
|
265
|
+
*/
|
|
266
|
+
function enhancedComplexity(lineCount, exportCount, importCount, content) {
|
|
267
|
+
// Base score from lines
|
|
268
|
+
let score = 0;
|
|
269
|
+
if (lineCount >= 300)
|
|
270
|
+
score += 3;
|
|
271
|
+
else if (lineCount >= 100)
|
|
272
|
+
score += 2;
|
|
273
|
+
else
|
|
274
|
+
score += 1;
|
|
275
|
+
// Export complexity: more public surface = more complex interface
|
|
276
|
+
if (exportCount >= 10)
|
|
277
|
+
score += 2;
|
|
278
|
+
else if (exportCount >= 5)
|
|
279
|
+
score += 1;
|
|
280
|
+
// Import coupling: many dependencies = higher complexity
|
|
281
|
+
if (importCount >= 10)
|
|
282
|
+
score += 2;
|
|
283
|
+
else if (importCount >= 5)
|
|
284
|
+
score += 1;
|
|
285
|
+
// Nesting depth heuristic: count deeply nested blocks
|
|
286
|
+
let depth = 0;
|
|
287
|
+
let maxDepth = 0;
|
|
288
|
+
for (let i = 0; i < content.length; i++) {
|
|
289
|
+
if (content[i] === '{') {
|
|
290
|
+
depth++;
|
|
291
|
+
if (depth > maxDepth)
|
|
292
|
+
maxDepth = depth;
|
|
293
|
+
}
|
|
294
|
+
else if (content[i] === '}') {
|
|
295
|
+
depth = Math.max(0, depth - 1);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (maxDepth >= 6)
|
|
299
|
+
score += 2;
|
|
300
|
+
else if (maxDepth >= 4)
|
|
301
|
+
score += 1;
|
|
302
|
+
if (score <= 2)
|
|
303
|
+
return 'simple';
|
|
304
|
+
if (score <= 5)
|
|
305
|
+
return 'moderate';
|
|
306
|
+
return 'complex';
|
|
307
|
+
}
|
|
308
|
+
/** Derive tags from directory name and file extension. */
|
|
309
|
+
function deriveTags(relPath, ext) {
|
|
310
|
+
const tags = [];
|
|
311
|
+
const parts = relPath.split(posix.sep);
|
|
312
|
+
// Add first meaningful directory as tag
|
|
313
|
+
if (parts.length > 1) {
|
|
314
|
+
tags.push(parts[0]);
|
|
315
|
+
}
|
|
316
|
+
// Add language tag
|
|
317
|
+
const lang = EXT_LANGUAGE[ext];
|
|
318
|
+
if (lang)
|
|
319
|
+
tags.push(lang.toLowerCase());
|
|
320
|
+
return tags;
|
|
321
|
+
}
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Git-aware file enumeration
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
/**
|
|
326
|
+
* Use `git ls-files` to enumerate tracked + untracked (non-ignored) files.
|
|
327
|
+
* Returns null if git is unavailable or the directory is not a git repo.
|
|
328
|
+
*/
|
|
329
|
+
function gitLsFiles(root) {
|
|
330
|
+
try {
|
|
331
|
+
const output = execSync('git ls-files -z -co --exclude-standard', {
|
|
332
|
+
cwd: root,
|
|
333
|
+
encoding: 'utf-8',
|
|
334
|
+
timeout: 10000,
|
|
335
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
336
|
+
});
|
|
337
|
+
return output.split('\0').filter(f => f.length > 0);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// Import extraction
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
/** Extract import targets from source code. */
|
|
347
|
+
function extractImports(content) {
|
|
348
|
+
const targets = [];
|
|
349
|
+
// ESM: import ... from '...'
|
|
350
|
+
const esmRegex = /import\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
351
|
+
let match;
|
|
352
|
+
while ((match = esmRegex.exec(content)) !== null) {
|
|
353
|
+
targets.push(match[1]);
|
|
354
|
+
}
|
|
355
|
+
// CJS: require('...')
|
|
356
|
+
const cjsRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
357
|
+
while ((match = cjsRegex.exec(content)) !== null) {
|
|
358
|
+
targets.push(match[1]);
|
|
359
|
+
}
|
|
360
|
+
return targets;
|
|
361
|
+
}
|
|
362
|
+
/** Extract imports with named symbols for call graph analysis. */
|
|
363
|
+
function extractImportsWithSymbols(content) {
|
|
364
|
+
const results = [];
|
|
365
|
+
// ESM: import { foo, bar } from '...'
|
|
366
|
+
// ESM: import DefaultName from '...'
|
|
367
|
+
// ESM: import DefaultName, { foo } from '...'
|
|
368
|
+
const esmRegex = /import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/g;
|
|
369
|
+
let match;
|
|
370
|
+
while ((match = esmRegex.exec(content)) !== null) {
|
|
371
|
+
const clause = match[1];
|
|
372
|
+
const specifier = match[2];
|
|
373
|
+
const symbols = [];
|
|
374
|
+
let defaultImport;
|
|
375
|
+
// Extract named imports: { foo, bar, baz as qux }
|
|
376
|
+
const namedMatch = clause.match(/\{([^}]+)\}/);
|
|
377
|
+
if (namedMatch) {
|
|
378
|
+
const parts = namedMatch[1].split(',');
|
|
379
|
+
for (const p of parts) {
|
|
380
|
+
const trimmed = p.trim();
|
|
381
|
+
if (!trimmed)
|
|
382
|
+
continue;
|
|
383
|
+
// handle "foo as bar" -> use the local name "bar"
|
|
384
|
+
const asMatch = trimmed.match(/(\w+)\s+as\s+(\w+)/);
|
|
385
|
+
if (asMatch) {
|
|
386
|
+
symbols.push(asMatch[2]);
|
|
387
|
+
}
|
|
388
|
+
else if (/^\w+$/.test(trimmed)) {
|
|
389
|
+
symbols.push(trimmed);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// Extract default import
|
|
394
|
+
const defaultMatch = clause.match(/^(\w+)/);
|
|
395
|
+
if (defaultMatch && defaultMatch[1] !== 'type') {
|
|
396
|
+
defaultImport = defaultMatch[1];
|
|
397
|
+
}
|
|
398
|
+
results.push({ specifier, symbols, defaultImport });
|
|
399
|
+
}
|
|
400
|
+
return results;
|
|
401
|
+
}
|
|
402
|
+
/** Extract exported symbol names from source code. */
|
|
403
|
+
function extractExports(content) {
|
|
404
|
+
const exports = [];
|
|
405
|
+
const seen = new Set();
|
|
406
|
+
// export function/class/interface/type/const/enum
|
|
407
|
+
const namedRegex = /export\s+(?:default\s+)?(?:async\s+)?(function|class|interface|type|const|let|var|enum)\s+(\w+)/g;
|
|
408
|
+
let match;
|
|
409
|
+
while ((match = namedRegex.exec(content)) !== null) {
|
|
410
|
+
const kind = match[1];
|
|
411
|
+
const name = match[2];
|
|
412
|
+
if (!seen.has(name)) {
|
|
413
|
+
seen.add(name);
|
|
414
|
+
exports.push({ name, kind });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return exports;
|
|
418
|
+
}
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// Call graph extraction
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
/**
|
|
423
|
+
* Find call sites in content that reference imported symbols.
|
|
424
|
+
* Returns the set of symbol names that are actually called.
|
|
425
|
+
*/
|
|
426
|
+
function extractCallSites(content, importedSymbols) {
|
|
427
|
+
const called = new Set();
|
|
428
|
+
if (importedSymbols.size === 0)
|
|
429
|
+
return called;
|
|
430
|
+
// Build a regex that matches any imported symbol followed by '('
|
|
431
|
+
// This catches: symbolName(, obj.symbolName( patterns
|
|
432
|
+
for (const sym of importedSymbols) {
|
|
433
|
+
// Match: word boundary + symbol + optional whitespace + '('
|
|
434
|
+
// Exclude: import/export/from/type keywords followed by the symbol
|
|
435
|
+
const pattern = new RegExp(`(?<!\\.)\\b${escapeRegex(sym)}\\s*\\(`, 'g');
|
|
436
|
+
if (pattern.test(content)) {
|
|
437
|
+
called.add(sym);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return called;
|
|
441
|
+
}
|
|
442
|
+
/** Escape special regex characters. */
|
|
443
|
+
function escapeRegex(str) {
|
|
444
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
445
|
+
}
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
// Topological sort (Kahn's algorithm)
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
/** Entry-point file names that should appear first in tour. */
|
|
450
|
+
const ENTRY_POINT_NAMES = new Set([
|
|
451
|
+
'index.ts', 'index.js', 'index.tsx', 'index.jsx',
|
|
452
|
+
'main.ts', 'main.js', 'cli.ts', 'cli.js',
|
|
453
|
+
'app.ts', 'app.js', 'server.ts', 'server.js',
|
|
454
|
+
]);
|
|
455
|
+
/**
|
|
456
|
+
* Topological sort of module names using Kahn's algorithm.
|
|
457
|
+
* Modules with no incoming edges (entry points) come first.
|
|
458
|
+
* Falls back to alphabetical for cycles.
|
|
459
|
+
*/
|
|
460
|
+
function topologicalSortModules(modules, moduleEdges, entryModules) {
|
|
461
|
+
const inDegree = new Map();
|
|
462
|
+
const adjacency = new Map();
|
|
463
|
+
for (const m of modules) {
|
|
464
|
+
inDegree.set(m, 0);
|
|
465
|
+
adjacency.set(m, []);
|
|
466
|
+
}
|
|
467
|
+
for (const edge of moduleEdges) {
|
|
468
|
+
if (!inDegree.has(edge.source) || !inDegree.has(edge.target))
|
|
469
|
+
continue;
|
|
470
|
+
if (edge.source === edge.target)
|
|
471
|
+
continue;
|
|
472
|
+
adjacency.get(edge.source).push(edge.target);
|
|
473
|
+
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1);
|
|
474
|
+
}
|
|
475
|
+
// Priority queue: entry modules first, then by in-degree (ascending)
|
|
476
|
+
const queue = [];
|
|
477
|
+
const result = [];
|
|
478
|
+
const visited = new Set();
|
|
479
|
+
// Seed with zero-degree nodes, prioritizing entry modules
|
|
480
|
+
const zeroDegree = modules.filter(m => (inDegree.get(m) ?? 0) === 0);
|
|
481
|
+
const entryFirst = zeroDegree.filter(m => entryModules.has(m));
|
|
482
|
+
const rest = zeroDegree.filter(m => !entryModules.has(m)).sort();
|
|
483
|
+
queue.push(...entryFirst, ...rest);
|
|
484
|
+
while (queue.length > 0) {
|
|
485
|
+
const current = queue.shift();
|
|
486
|
+
if (visited.has(current))
|
|
487
|
+
continue;
|
|
488
|
+
visited.add(current);
|
|
489
|
+
result.push(current);
|
|
490
|
+
const neighbors = (adjacency.get(current) ?? []).sort();
|
|
491
|
+
for (const neighbor of neighbors) {
|
|
492
|
+
const newDeg = (inDegree.get(neighbor) ?? 1) - 1;
|
|
493
|
+
inDegree.set(neighbor, newDeg);
|
|
494
|
+
if (newDeg === 0 && !visited.has(neighbor)) {
|
|
495
|
+
queue.push(neighbor);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// Add any remaining nodes (cycles) in alphabetical order
|
|
500
|
+
for (const m of modules.sort()) {
|
|
501
|
+
if (!visited.has(m)) {
|
|
502
|
+
result.push(m);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return result;
|
|
506
|
+
}
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
// File resolution
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
/** Known source extensions for resolution attempts. */
|
|
511
|
+
const RESOLVE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
|
|
512
|
+
/**
|
|
513
|
+
* Resolve a relative import specifier to a file: node ID.
|
|
514
|
+
* Returns null if the import is a package (not relative).
|
|
515
|
+
*/
|
|
516
|
+
function resolveImport(importSpecifier, sourceRelPath, fileSet) {
|
|
517
|
+
// Only resolve relative imports
|
|
518
|
+
if (!importSpecifier.startsWith('.'))
|
|
519
|
+
return null;
|
|
520
|
+
const sourceDir = dirname(sourceRelPath);
|
|
521
|
+
let resolved = posix.normalize(posix.join(sourceDir, importSpecifier));
|
|
522
|
+
// Strip .js extension that TypeScript uses in ESM imports
|
|
523
|
+
if (resolved.endsWith('.js')) {
|
|
524
|
+
resolved = resolved.slice(0, -3);
|
|
525
|
+
}
|
|
526
|
+
// Try exact match first
|
|
527
|
+
if (fileSet.has(resolved))
|
|
528
|
+
return `file:${resolved}`;
|
|
529
|
+
// Try adding extensions
|
|
530
|
+
for (const ext of RESOLVE_EXTENSIONS) {
|
|
531
|
+
if (fileSet.has(resolved + ext))
|
|
532
|
+
return `file:${resolved + ext}`;
|
|
533
|
+
}
|
|
534
|
+
// Try index file in directory
|
|
535
|
+
for (const ext of RESOLVE_EXTENSIONS) {
|
|
536
|
+
const indexPath = posix.join(resolved, `index${ext}`);
|
|
537
|
+
if (fileSet.has(indexPath))
|
|
538
|
+
return `file:${indexPath}`;
|
|
539
|
+
}
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
function walkDirectory(root, options) {
|
|
543
|
+
const entries = [];
|
|
544
|
+
function walk(dir) {
|
|
545
|
+
let items;
|
|
546
|
+
try {
|
|
547
|
+
items = readdirSync(dir);
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
for (const item of items) {
|
|
553
|
+
const fullPath = join(dir, item);
|
|
554
|
+
const rel = toForward(relative(root, fullPath));
|
|
555
|
+
let stat;
|
|
556
|
+
try {
|
|
557
|
+
stat = statSync(fullPath);
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (stat.isDirectory()) {
|
|
563
|
+
if (!shouldExclude(item, rel, options.excludes)) {
|
|
564
|
+
walk(fullPath);
|
|
565
|
+
}
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (!stat.isFile())
|
|
569
|
+
continue;
|
|
570
|
+
const ext = extname(item).toLowerCase();
|
|
571
|
+
if (!SOURCE_EXTENSIONS.has(ext) && !NON_SOURCE_EXTENSIONS.has(ext))
|
|
572
|
+
continue;
|
|
573
|
+
if (shouldExclude(item, rel, options.excludes))
|
|
574
|
+
continue;
|
|
575
|
+
// Apply include filter if specified
|
|
576
|
+
if (options.includes.length > 0) {
|
|
577
|
+
const matched = options.includes.some(p => simpleMatch(p, rel) || simpleMatch(p, item));
|
|
578
|
+
if (!matched)
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
entries.push({ absolutePath: fullPath, relPath: rel });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
walk(root);
|
|
585
|
+
return entries;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Git-aware file enumeration. Uses `git ls-files` for accurate file listing
|
|
589
|
+
* that respects .gitignore. Falls back to walkDirectory on failure.
|
|
590
|
+
*/
|
|
591
|
+
function gitWalkDirectory(root, options) {
|
|
592
|
+
const gitFiles = gitLsFiles(root);
|
|
593
|
+
if (!gitFiles)
|
|
594
|
+
return walkDirectory(root, options);
|
|
595
|
+
const entries = [];
|
|
596
|
+
for (const relFile of gitFiles) {
|
|
597
|
+
const rel = toForward(relFile);
|
|
598
|
+
const name = basename(rel);
|
|
599
|
+
const ext = extname(name).toLowerCase();
|
|
600
|
+
// Filter by known extensions
|
|
601
|
+
if (!SOURCE_EXTENSIONS.has(ext) && !NON_SOURCE_EXTENSIONS.has(ext))
|
|
602
|
+
continue;
|
|
603
|
+
// Apply exclusion rules
|
|
604
|
+
if (shouldExclude(name, rel, options.excludes))
|
|
605
|
+
continue;
|
|
606
|
+
// Apply include filter
|
|
607
|
+
if (options.includes.length > 0) {
|
|
608
|
+
const matched = options.includes.some(p => simpleMatch(p, rel) || simpleMatch(p, name));
|
|
609
|
+
if (!matched)
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
entries.push({ absolutePath: join(root, relFile), relPath: rel });
|
|
613
|
+
}
|
|
614
|
+
return entries;
|
|
615
|
+
}
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
// FsAnalyzer
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
export class FsAnalyzer {
|
|
620
|
+
name = 'fs-analyzer';
|
|
621
|
+
async analyze(projectRoot, options) {
|
|
622
|
+
const root = projectRoot;
|
|
623
|
+
const excludes = options?.exclude ?? DEFAULT_EXCLUDES;
|
|
624
|
+
const includes = options?.include ?? [];
|
|
625
|
+
// 1. Walk filesystem -- prefer git ls-files when available
|
|
626
|
+
const files = gitWalkDirectory(root, { includes, excludes });
|
|
627
|
+
const fileSet = new Set(files.map(f => f.relPath));
|
|
628
|
+
// 2. Build nodes, edges, and collect metadata
|
|
629
|
+
const nodes = [];
|
|
630
|
+
const edges = [];
|
|
631
|
+
const languagesFound = new Set();
|
|
632
|
+
const moduleFiles = new Map(); // module dir -> file node IDs
|
|
633
|
+
// Track exported symbols per file for call graph extraction
|
|
634
|
+
const fileExportedSymbols = new Map(); // fileId -> Set<symbolName>
|
|
635
|
+
// Track imports with symbols per file for call graph
|
|
636
|
+
const fileImportSymbols = new Map();
|
|
637
|
+
// Track test files for tested_by linking
|
|
638
|
+
const testFiles = [];
|
|
639
|
+
// Track module-level import edges for topological sort
|
|
640
|
+
const moduleImportEdges = [];
|
|
641
|
+
// Track entry-point modules
|
|
642
|
+
const entryModules = new Set();
|
|
643
|
+
for (const file of files) {
|
|
644
|
+
const ext = extname(file.relPath).toLowerCase();
|
|
645
|
+
const name = basename(file.relPath);
|
|
646
|
+
const parts = file.relPath.split(posix.sep);
|
|
647
|
+
const moduleDir = parts.length > 1 ? parts[0] : '_root';
|
|
648
|
+
const isSource = SOURCE_EXTENSIONS.has(ext);
|
|
649
|
+
// Track language
|
|
650
|
+
const lang = EXT_LANGUAGE[ext];
|
|
651
|
+
if (lang)
|
|
652
|
+
languagesFound.add(lang);
|
|
653
|
+
// Classify file category
|
|
654
|
+
const category = classifyFileCategory(file.relPath, name, ext);
|
|
655
|
+
// For non-source files, create lightweight nodes without parsing
|
|
656
|
+
if (!isSource) {
|
|
657
|
+
const fileId = `file:${file.relPath}`;
|
|
658
|
+
const tags = deriveTags(file.relPath, ext);
|
|
659
|
+
tags.push(category);
|
|
660
|
+
nodes.push({
|
|
661
|
+
id: fileId,
|
|
662
|
+
type: 'file',
|
|
663
|
+
name,
|
|
664
|
+
filePath: file.relPath,
|
|
665
|
+
summary: `${category} file: ${name}`,
|
|
666
|
+
tags,
|
|
667
|
+
complexity: 'simple',
|
|
668
|
+
});
|
|
669
|
+
if (!moduleFiles.has(moduleDir))
|
|
670
|
+
moduleFiles.set(moduleDir, []);
|
|
671
|
+
moduleFiles.get(moduleDir).push(fileId);
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
// Read file content (source files only)
|
|
675
|
+
let content;
|
|
676
|
+
try {
|
|
677
|
+
content = readFileSync(file.absolutePath, 'utf-8');
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
const lineCount = content.split('\n').length;
|
|
683
|
+
// Extract exports and imports for enhanced complexity
|
|
684
|
+
const exportedSymbols = extractExports(content);
|
|
685
|
+
const importTargets = extractImports(content);
|
|
686
|
+
const importInfos = extractImportsWithSymbols(content);
|
|
687
|
+
// Create file node with enhanced complexity and category tag
|
|
688
|
+
const fileId = `file:${file.relPath}`;
|
|
689
|
+
const tags = deriveTags(file.relPath, ext);
|
|
690
|
+
tags.push(category);
|
|
691
|
+
const complexity = enhancedComplexity(lineCount, exportedSymbols.length, importTargets.length, content);
|
|
692
|
+
// Detect entry points
|
|
693
|
+
if (ENTRY_POINT_NAMES.has(name)) {
|
|
694
|
+
entryModules.add(moduleDir);
|
|
695
|
+
}
|
|
696
|
+
// Track test files for tested_by linking
|
|
697
|
+
const isTest = isTestFile(file.relPath);
|
|
698
|
+
if (isTest && !tags.includes('test')) {
|
|
699
|
+
tags.push('test');
|
|
700
|
+
}
|
|
701
|
+
nodes.push({
|
|
702
|
+
id: fileId,
|
|
703
|
+
type: 'file',
|
|
704
|
+
name,
|
|
705
|
+
filePath: file.relPath,
|
|
706
|
+
summary: `${lang ?? 'Source'} file in ${moduleDir} module`,
|
|
707
|
+
tags,
|
|
708
|
+
complexity,
|
|
709
|
+
});
|
|
710
|
+
if (isTest) {
|
|
711
|
+
testFiles.push({ relPath: file.relPath, fileId });
|
|
712
|
+
}
|
|
713
|
+
// Track module membership
|
|
714
|
+
if (!moduleFiles.has(moduleDir))
|
|
715
|
+
moduleFiles.set(moduleDir, []);
|
|
716
|
+
moduleFiles.get(moduleDir).push(fileId);
|
|
717
|
+
// Track exported symbols for call graph
|
|
718
|
+
const exportNames = new Set(exportedSymbols.map(s => s.name));
|
|
719
|
+
fileExportedSymbols.set(fileId, exportNames);
|
|
720
|
+
// Extract exports as child nodes
|
|
721
|
+
for (const sym of exportedSymbols) {
|
|
722
|
+
const symId = `${sym.kind}:${file.relPath}:${sym.name}`;
|
|
723
|
+
nodes.push({
|
|
724
|
+
id: symId,
|
|
725
|
+
type: sym.kind,
|
|
726
|
+
name: sym.name,
|
|
727
|
+
filePath: file.relPath,
|
|
728
|
+
summary: `Exported ${sym.kind} "${sym.name}" in ${name}`,
|
|
729
|
+
tags: [...tags.filter(t => t !== 'test' && t !== category), sym.kind],
|
|
730
|
+
});
|
|
731
|
+
edges.push({
|
|
732
|
+
source: fileId,
|
|
733
|
+
target: symId,
|
|
734
|
+
type: 'contains',
|
|
735
|
+
direction: 'forward',
|
|
736
|
+
weight: 1,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
// Extract imports as edges + track for call graph
|
|
740
|
+
const importSymbolsForFile = [];
|
|
741
|
+
for (const target of importTargets) {
|
|
742
|
+
const resolvedId = resolveImport(target, file.relPath, fileSet);
|
|
743
|
+
if (resolvedId) {
|
|
744
|
+
edges.push({
|
|
745
|
+
source: fileId,
|
|
746
|
+
target: resolvedId,
|
|
747
|
+
type: 'imports',
|
|
748
|
+
direction: 'forward',
|
|
749
|
+
weight: 1,
|
|
750
|
+
});
|
|
751
|
+
// Track module-level dependencies for topological sort
|
|
752
|
+
const targetRelPath = resolvedId.slice('file:'.length);
|
|
753
|
+
const targetParts = targetRelPath.split(posix.sep);
|
|
754
|
+
const targetModule = targetParts.length > 1 ? targetParts[0] : '_root';
|
|
755
|
+
if (moduleDir !== targetModule && moduleDir !== '_root' && targetModule !== '_root') {
|
|
756
|
+
moduleImportEdges.push({ source: moduleDir, target: targetModule });
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// Track named import symbols for call graph extraction
|
|
761
|
+
for (const info of importInfos) {
|
|
762
|
+
const resolvedId = resolveImport(info.specifier, file.relPath, fileSet);
|
|
763
|
+
if (resolvedId && info.symbols.length > 0) {
|
|
764
|
+
importSymbolsForFile.push({ targetFileId: resolvedId, symbols: info.symbols });
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (importSymbolsForFile.length > 0) {
|
|
768
|
+
fileImportSymbols.set(fileId, importSymbolsForFile);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
// 2b. Call graph extraction: find call sites for imported symbols
|
|
772
|
+
for (const file of files) {
|
|
773
|
+
const ext = extname(file.relPath).toLowerCase();
|
|
774
|
+
if (!SOURCE_EXTENSIONS.has(ext))
|
|
775
|
+
continue;
|
|
776
|
+
const fileId = `file:${file.relPath}`;
|
|
777
|
+
const importedRefs = fileImportSymbols.get(fileId);
|
|
778
|
+
if (!importedRefs || importedRefs.length === 0)
|
|
779
|
+
continue;
|
|
780
|
+
let content;
|
|
781
|
+
try {
|
|
782
|
+
content = readFileSync(file.absolutePath, 'utf-8');
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
// Collect all imported symbols and their source files
|
|
788
|
+
const symbolToFile = new Map();
|
|
789
|
+
for (const ref of importedRefs) {
|
|
790
|
+
for (const sym of ref.symbols) {
|
|
791
|
+
symbolToFile.set(sym, ref.targetFileId);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
const calledSymbols = extractCallSites(content, new Set(symbolToFile.keys()));
|
|
795
|
+
for (const sym of calledSymbols) {
|
|
796
|
+
const targetFileId = symbolToFile.get(sym);
|
|
797
|
+
if (!targetFileId)
|
|
798
|
+
continue;
|
|
799
|
+
edges.push({
|
|
800
|
+
source: fileId,
|
|
801
|
+
target: targetFileId,
|
|
802
|
+
type: 'calls',
|
|
803
|
+
direction: 'forward',
|
|
804
|
+
weight: 0.9,
|
|
805
|
+
description: `Calls imported symbol "${sym}"`,
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
// 2c. Test file pairing: create tested_by edges
|
|
810
|
+
for (const test of testFiles) {
|
|
811
|
+
const prodPath = findProductionFile(test.relPath, fileSet);
|
|
812
|
+
if (prodPath) {
|
|
813
|
+
const prodFileId = `file:${prodPath}`;
|
|
814
|
+
edges.push({
|
|
815
|
+
source: prodFileId,
|
|
816
|
+
target: test.fileId,
|
|
817
|
+
type: 'tested_by',
|
|
818
|
+
direction: 'forward',
|
|
819
|
+
weight: 0.8,
|
|
820
|
+
});
|
|
821
|
+
// Add "tested" tag to the production node
|
|
822
|
+
const prodNode = nodes.find(n => n.id === prodFileId);
|
|
823
|
+
if (prodNode && !prodNode.tags.includes('tested')) {
|
|
824
|
+
prodNode.tags.push('tested');
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
// 3. Create module nodes
|
|
829
|
+
for (const [moduleDir, memberIds] of moduleFiles) {
|
|
830
|
+
if (moduleDir === '_root')
|
|
831
|
+
continue;
|
|
832
|
+
const moduleId = `module:${moduleDir}`;
|
|
833
|
+
nodes.push({
|
|
834
|
+
id: moduleId,
|
|
835
|
+
type: 'module',
|
|
836
|
+
name: moduleDir,
|
|
837
|
+
summary: `Module: ${moduleDir} (${memberIds.length} files)`,
|
|
838
|
+
tags: [moduleDir],
|
|
839
|
+
});
|
|
840
|
+
for (const memberId of memberIds) {
|
|
841
|
+
edges.push({
|
|
842
|
+
source: moduleId,
|
|
843
|
+
target: memberId,
|
|
844
|
+
type: 'contains',
|
|
845
|
+
direction: 'forward',
|
|
846
|
+
weight: 1,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
// 4. Build layers from directory patterns
|
|
851
|
+
const layerMap = new Map();
|
|
852
|
+
for (const [moduleDir] of moduleFiles) {
|
|
853
|
+
if (moduleDir === '_root')
|
|
854
|
+
continue;
|
|
855
|
+
const patternEntry = LAYER_PATTERNS[moduleDir];
|
|
856
|
+
if (!patternEntry)
|
|
857
|
+
continue;
|
|
858
|
+
const layerId = `layer:${patternEntry.name.toLowerCase().replace(/\s+/g, '-')}`;
|
|
859
|
+
if (!layerMap.has(layerId)) {
|
|
860
|
+
layerMap.set(layerId, {
|
|
861
|
+
id: layerId,
|
|
862
|
+
name: patternEntry.name,
|
|
863
|
+
description: patternEntry.description,
|
|
864
|
+
nodeIds: [],
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
const layer = layerMap.get(layerId);
|
|
868
|
+
// Add module node and its file nodes
|
|
869
|
+
layer.nodeIds.push(`module:${moduleDir}`);
|
|
870
|
+
const memberIds = moduleFiles.get(moduleDir);
|
|
871
|
+
if (memberIds) {
|
|
872
|
+
layer.nodeIds.push(...memberIds);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
const layers = Array.from(layerMap.values());
|
|
876
|
+
// 5. Generate tour using topological sort
|
|
877
|
+
const tour = [];
|
|
878
|
+
let order = 1;
|
|
879
|
+
const moduleList = Array.from(moduleFiles.keys()).filter(m => m !== '_root');
|
|
880
|
+
const sortedModules = topologicalSortModules(moduleList, moduleImportEdges, entryModules);
|
|
881
|
+
for (const moduleDir of sortedModules) {
|
|
882
|
+
const memberIds = moduleFiles.get(moduleDir) ?? [];
|
|
883
|
+
const patternEntry = LAYER_PATTERNS[moduleDir];
|
|
884
|
+
const layerName = patternEntry?.name ?? moduleDir;
|
|
885
|
+
tour.push({
|
|
886
|
+
order: order++,
|
|
887
|
+
title: layerName,
|
|
888
|
+
description: patternEntry?.description ?? `Files in the ${moduleDir} directory`,
|
|
889
|
+
nodeIds: [`module:${moduleDir}`, ...memberIds.slice(0, 5)],
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
// 6. Assemble project metadata
|
|
893
|
+
const project = {
|
|
894
|
+
name: this.detectProjectName(root),
|
|
895
|
+
languages: Array.from(languagesFound).sort(),
|
|
896
|
+
frameworks: [],
|
|
897
|
+
description: `Code analysis of ${files.length} source files`,
|
|
898
|
+
analyzedAt: new Date().toISOString(),
|
|
899
|
+
};
|
|
900
|
+
// Try to detect frameworks from package.json
|
|
901
|
+
project.frameworks = this.detectFrameworks(root);
|
|
902
|
+
return {
|
|
903
|
+
version: '1.0.0',
|
|
904
|
+
valid: true,
|
|
905
|
+
project,
|
|
906
|
+
nodes,
|
|
907
|
+
edges,
|
|
908
|
+
layers,
|
|
909
|
+
tour,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
/** Read project name from package.json or use directory name. */
|
|
913
|
+
detectProjectName(root) {
|
|
914
|
+
const pkgPath = join(root, 'package.json');
|
|
915
|
+
if (existsSync(pkgPath)) {
|
|
916
|
+
try {
|
|
917
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
918
|
+
if (pkg.name)
|
|
919
|
+
return pkg.name;
|
|
920
|
+
}
|
|
921
|
+
catch { /* fallback */ }
|
|
922
|
+
}
|
|
923
|
+
return basename(root);
|
|
924
|
+
}
|
|
925
|
+
/** Detect frameworks from package.json dependencies. */
|
|
926
|
+
detectFrameworks(root) {
|
|
927
|
+
const pkgPath = join(root, 'package.json');
|
|
928
|
+
if (!existsSync(pkgPath))
|
|
929
|
+
return [];
|
|
930
|
+
try {
|
|
931
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
932
|
+
const allDeps = {
|
|
933
|
+
...pkg.dependencies,
|
|
934
|
+
...pkg.devDependencies,
|
|
935
|
+
};
|
|
936
|
+
const frameworks = [];
|
|
937
|
+
const checks = [
|
|
938
|
+
['react', 'React'],
|
|
939
|
+
['vue', 'Vue'],
|
|
940
|
+
['angular', 'Angular'],
|
|
941
|
+
['express', 'Express'],
|
|
942
|
+
['fastify', 'Fastify'],
|
|
943
|
+
['next', 'Next.js'],
|
|
944
|
+
['nuxt', 'Nuxt'],
|
|
945
|
+
['commander', 'Commander'],
|
|
946
|
+
['ink', 'Ink'],
|
|
947
|
+
];
|
|
948
|
+
for (const [pkg, name] of checks) {
|
|
949
|
+
if (allDeps?.[pkg])
|
|
950
|
+
frameworks.push(name);
|
|
951
|
+
}
|
|
952
|
+
return frameworks;
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
return [];
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
//# sourceMappingURL=fs-analyzer.js.map
|