coderaph 0.1.0 → 0.3.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.
- package/dist/cli/analyzer.d.ts +7 -0
- package/dist/cli/analyzer.js +64 -8
- package/dist/cli/index.js +103 -34
- package/dist/cli/server.d.ts +2 -1
- package/dist/cli/server.js +96 -12
- package/dist/types/graph.d.ts +3 -1
- package/package.json +1 -1
- package/src/web/app.js +26 -0
- package/src/web/controls.js +28 -1
package/dist/cli/analyzer.d.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { Project, SourceFile } from 'ts-morph';
|
|
2
2
|
export interface AnalyzerOptions {
|
|
3
3
|
projectPath: string;
|
|
4
|
+
tsConfigPath?: string;
|
|
4
5
|
include?: string[];
|
|
5
6
|
exclude?: string[];
|
|
7
|
+
prefix?: string;
|
|
6
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Reads the `name` field from package.json in the given directory.
|
|
11
|
+
* Returns undefined if the file doesn't exist or can't be parsed.
|
|
12
|
+
*/
|
|
13
|
+
export declare function readPackageName(projectPath: string): string | undefined;
|
|
7
14
|
/**
|
|
8
15
|
* Loads a TypeScript project and returns filtered source files.
|
|
9
16
|
*/
|
package/dist/cli/analyzer.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import { Project } from 'ts-morph';
|
|
4
|
+
/**
|
|
5
|
+
* Reads the `name` field from package.json in the given directory.
|
|
6
|
+
* Returns undefined if the file doesn't exist or can't be parsed.
|
|
7
|
+
*/
|
|
8
|
+
export function readPackageName(projectPath) {
|
|
9
|
+
try {
|
|
10
|
+
const pkgPath = path.resolve(projectPath, 'package.json');
|
|
11
|
+
const content = fs.readFileSync(pkgPath, 'utf-8');
|
|
12
|
+
const pkg = JSON.parse(content);
|
|
13
|
+
return typeof pkg.name === 'string' ? pkg.name : undefined;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
4
19
|
/**
|
|
5
20
|
* Simple glob matcher that supports `*` as a wildcard for any characters.
|
|
6
21
|
* If the pattern contains no `*`, it falls back to substring matching.
|
|
@@ -16,13 +31,55 @@ function matchGlob(filePath, pattern) {
|
|
|
16
31
|
const regex = new RegExp(`^${escaped}$`);
|
|
17
32
|
return regex.test(filePath);
|
|
18
33
|
}
|
|
34
|
+
const TSCONFIG_CANDIDATES = [
|
|
35
|
+
'tsconfig.json',
|
|
36
|
+
'tsconfig.build.json',
|
|
37
|
+
'tsconfig.base.json',
|
|
38
|
+
'tsconfig.app.json',
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* Finds a tsconfig file in the given directory.
|
|
42
|
+
* Checks common tsconfig filenames, then searches subdirectories (1 level deep).
|
|
43
|
+
*/
|
|
44
|
+
function findTsConfig(projectPath) {
|
|
45
|
+
// Check common names in project root
|
|
46
|
+
for (const candidate of TSCONFIG_CANDIDATES) {
|
|
47
|
+
const fullPath = path.resolve(projectPath, candidate);
|
|
48
|
+
if (fs.existsSync(fullPath))
|
|
49
|
+
return fullPath;
|
|
50
|
+
}
|
|
51
|
+
// Search one level of subdirectories (for monorepos: packages/*, apps/*)
|
|
52
|
+
const entries = fs.readdirSync(projectPath, { withFileTypes: true });
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (!entry.isDirectory() || entry.name === 'node_modules' || entry.name.startsWith('.'))
|
|
55
|
+
continue;
|
|
56
|
+
const subTsConfig = path.resolve(projectPath, entry.name, 'tsconfig.json');
|
|
57
|
+
if (fs.existsSync(subTsConfig))
|
|
58
|
+
return subTsConfig;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
19
62
|
/**
|
|
20
63
|
* Loads a TypeScript project and returns filtered source files.
|
|
21
64
|
*/
|
|
22
65
|
export function loadProject(options) {
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
|
|
66
|
+
let tsConfigFilePath;
|
|
67
|
+
if (options.tsConfigPath) {
|
|
68
|
+
tsConfigFilePath = path.resolve(options.projectPath, options.tsConfigPath);
|
|
69
|
+
if (!fs.existsSync(tsConfigFilePath)) {
|
|
70
|
+
throw new Error(`tsconfig not found at ${tsConfigFilePath}. Please check the --tsconfig path.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
const found = findTsConfig(options.projectPath);
|
|
75
|
+
if (!found) {
|
|
76
|
+
throw new Error(`No tsconfig.json found in ${options.projectPath} or its subdirectories. ` +
|
|
77
|
+
`Use --tsconfig to specify the path (e.g. --tsconfig packages/app/tsconfig.json).`);
|
|
78
|
+
}
|
|
79
|
+
tsConfigFilePath = found;
|
|
80
|
+
if (path.basename(tsConfigFilePath) !== 'tsconfig.json' || path.dirname(tsConfigFilePath) !== options.projectPath) {
|
|
81
|
+
console.log(`Using tsconfig: ${path.relative(options.projectPath, tsConfigFilePath)}`);
|
|
82
|
+
}
|
|
26
83
|
}
|
|
27
84
|
const project = new Project({ tsConfigFilePath });
|
|
28
85
|
let sourceFiles = project.getSourceFiles().filter((file) => {
|
|
@@ -50,13 +107,12 @@ export function loadProject(options) {
|
|
|
50
107
|
return !excludePatterns.some((pattern) => matchGlob(relativePath, pattern));
|
|
51
108
|
});
|
|
52
109
|
}
|
|
53
|
-
//
|
|
110
|
+
// Skip files with actual syntax errors (not type errors — those are fine for graph analysis)
|
|
54
111
|
sourceFiles = sourceFiles.filter((file) => {
|
|
55
|
-
const
|
|
56
|
-
const syntaxErrors = diagnostics.filter((d) => d.getCategory() === 1 // DiagnosticCategory.Error
|
|
112
|
+
const syntaxDiagnostics = file.getPreEmitDiagnostics().filter((d) => d.getCode() >= 1000 && d.getCode() < 2000 // TS1xxx = syntax errors only
|
|
57
113
|
);
|
|
58
|
-
if (
|
|
59
|
-
console.warn(`Warning: Skipping ${path.relative(options.projectPath, file.getFilePath())} due to ${
|
|
114
|
+
if (syntaxDiagnostics.length > 0) {
|
|
115
|
+
console.warn(`Warning: Skipping ${path.relative(options.projectPath, file.getFilePath())} due to ${syntaxDiagnostics.length} syntax error(s)`);
|
|
60
116
|
return false;
|
|
61
117
|
}
|
|
62
118
|
return true;
|
package/dist/cli/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
-
import { loadProject } from './analyzer.js';
|
|
3
|
+
import { loadProject, readPackageName } from './analyzer.js';
|
|
4
4
|
import { buildFileGraph } from './file-graph.js';
|
|
5
5
|
import { buildSymbolGraph } from './symbol-graph.js';
|
|
6
6
|
import fs from 'node:fs';
|
|
@@ -9,61 +9,130 @@ const program = new Command();
|
|
|
9
9
|
program
|
|
10
10
|
.name('coderaph')
|
|
11
11
|
.description('TypeScript dependency graph 3D visualizer')
|
|
12
|
-
.version('0.
|
|
12
|
+
.version('0.3.0');
|
|
13
13
|
program
|
|
14
14
|
.command('analyze <project-path>')
|
|
15
15
|
.description('Analyze TypeScript project and generate dependency graph JSON')
|
|
16
16
|
.option('-o, --output <path>', 'Output JSON file path', 'coderaph-output.json')
|
|
17
|
+
.option('--tsconfig <path>', 'Path to tsconfig.json (relative to project path)')
|
|
17
18
|
.option('--include <patterns...>', 'Include glob patterns')
|
|
18
19
|
.option('--exclude <patterns...>', 'Exclude glob patterns')
|
|
20
|
+
.option('--prefix <path>', 'Prefix to prepend to all file IDs (e.g., "packages/core")')
|
|
19
21
|
.action((projectPath, options) => {
|
|
20
22
|
try {
|
|
21
23
|
const resolvedPath = path.resolve(projectPath);
|
|
22
24
|
console.log(`Analyzing ${resolvedPath}...`);
|
|
23
|
-
const
|
|
24
|
-
projectPath: resolvedPath,
|
|
25
|
-
include: options.include,
|
|
26
|
-
exclude: options.exclude,
|
|
27
|
-
});
|
|
28
|
-
console.log(`Found ${sourceFiles.length} source files`);
|
|
29
|
-
if (sourceFiles.length === 0) {
|
|
30
|
-
console.warn('No source files to analyze');
|
|
31
|
-
}
|
|
32
|
-
const { files, edges: fileEdges } = buildFileGraph(sourceFiles, resolvedPath);
|
|
33
|
-
const { symbols, edges: symbolEdges } = buildSymbolGraph(sourceFiles, resolvedPath);
|
|
34
|
-
// Populate FileNode.symbols arrays
|
|
35
|
-
const fileSymbolMap = new Map();
|
|
36
|
-
for (const symbol of symbols) {
|
|
37
|
-
const existing = fileSymbolMap.get(symbol.fileId) || [];
|
|
38
|
-
existing.push(symbol.id);
|
|
39
|
-
fileSymbolMap.set(symbol.fileId, existing);
|
|
40
|
-
}
|
|
41
|
-
for (const file of files) {
|
|
42
|
-
file.symbols = fileSymbolMap.get(file.id) || [];
|
|
43
|
-
}
|
|
44
|
-
const output = {
|
|
45
|
-
version: 1,
|
|
46
|
-
files,
|
|
47
|
-
symbols,
|
|
48
|
-
fileEdges,
|
|
49
|
-
symbolEdges,
|
|
50
|
-
};
|
|
25
|
+
const output = runAnalyze(resolvedPath, options);
|
|
51
26
|
const outputPath = path.resolve(options.output);
|
|
52
27
|
fs.writeFileSync(outputPath, JSON.stringify(output, null, 2));
|
|
53
28
|
console.log(`Output written to ${outputPath}`);
|
|
54
|
-
console.log(`Files: ${files.length}, Symbols: ${symbols.length}, File edges: ${fileEdges.length}, Symbol edges: ${symbolEdges.length}`);
|
|
29
|
+
console.log(`Files: ${output.files.length}, Symbols: ${output.symbols.length}, File edges: ${output.fileEdges.length}, Symbol edges: ${output.symbolEdges.length}`);
|
|
55
30
|
}
|
|
56
31
|
catch (err) {
|
|
57
32
|
console.error(`Error: ${err instanceof Error ? err.message : err}`);
|
|
58
33
|
process.exit(1);
|
|
59
34
|
}
|
|
60
35
|
});
|
|
36
|
+
/**
|
|
37
|
+
* Runs the analysis pipeline and returns a CoderaphOutput.
|
|
38
|
+
* Extracted so it can be used by both the `analyze` and `view --analyze` commands.
|
|
39
|
+
*/
|
|
40
|
+
function runAnalyze(resolvedPath, options) {
|
|
41
|
+
const { sourceFiles } = loadProject({
|
|
42
|
+
projectPath: resolvedPath,
|
|
43
|
+
tsConfigPath: options.tsconfig,
|
|
44
|
+
include: options.include,
|
|
45
|
+
exclude: options.exclude,
|
|
46
|
+
prefix: options.prefix,
|
|
47
|
+
});
|
|
48
|
+
console.log(`Found ${sourceFiles.length} source files`);
|
|
49
|
+
if (sourceFiles.length === 0) {
|
|
50
|
+
console.warn('No source files to analyze');
|
|
51
|
+
}
|
|
52
|
+
const { files, edges: fileEdges } = buildFileGraph(sourceFiles, resolvedPath);
|
|
53
|
+
const { symbols, edges: symbolEdges } = buildSymbolGraph(sourceFiles, resolvedPath);
|
|
54
|
+
// Populate FileNode.symbols arrays
|
|
55
|
+
const fileSymbolMap = new Map();
|
|
56
|
+
for (const symbol of symbols) {
|
|
57
|
+
const existing = fileSymbolMap.get(symbol.fileId) || [];
|
|
58
|
+
existing.push(symbol.id);
|
|
59
|
+
fileSymbolMap.set(symbol.fileId, existing);
|
|
60
|
+
}
|
|
61
|
+
for (const file of files) {
|
|
62
|
+
file.symbols = fileSymbolMap.get(file.id) || [];
|
|
63
|
+
}
|
|
64
|
+
const packageName = readPackageName(resolvedPath);
|
|
65
|
+
const prefix = options.prefix;
|
|
66
|
+
// Apply prefix to all file IDs if provided
|
|
67
|
+
if (prefix) {
|
|
68
|
+
const addPrefix = (id) => `${prefix}/${id}`;
|
|
69
|
+
for (const file of files) {
|
|
70
|
+
file.id = addPrefix(file.id);
|
|
71
|
+
file.symbols = file.symbols.map(addPrefix);
|
|
72
|
+
}
|
|
73
|
+
for (const symbol of symbols) {
|
|
74
|
+
symbol.id = addPrefix(symbol.id);
|
|
75
|
+
symbol.fileId = addPrefix(symbol.fileId);
|
|
76
|
+
}
|
|
77
|
+
for (const edge of fileEdges) {
|
|
78
|
+
edge.source = addPrefix(edge.source);
|
|
79
|
+
edge.target = addPrefix(edge.target);
|
|
80
|
+
}
|
|
81
|
+
for (const edge of symbolEdges) {
|
|
82
|
+
edge.source = addPrefix(edge.source);
|
|
83
|
+
edge.target = addPrefix(edge.target);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const useV2 = !!(prefix || packageName);
|
|
87
|
+
const output = {
|
|
88
|
+
version: useV2 ? 2 : 1,
|
|
89
|
+
...(packageName ? { name: packageName } : {}),
|
|
90
|
+
...(prefix ? { prefix } : {}),
|
|
91
|
+
files,
|
|
92
|
+
symbols,
|
|
93
|
+
fileEdges,
|
|
94
|
+
symbolEdges,
|
|
95
|
+
};
|
|
96
|
+
return output;
|
|
97
|
+
}
|
|
61
98
|
program
|
|
62
|
-
.command('view
|
|
99
|
+
.command('view [json-path...]')
|
|
63
100
|
.description('Start web viewer for dependency graph')
|
|
64
101
|
.option('-p, --port <number>', 'Server port', '3000')
|
|
65
|
-
.
|
|
102
|
+
.option('--analyze <project-path>', 'Run analysis in-memory and start viewer')
|
|
103
|
+
.option('--tsconfig <path>', 'Path to tsconfig.json (used with --analyze)')
|
|
104
|
+
.option('--include <patterns...>', 'Include glob patterns (used with --analyze)')
|
|
105
|
+
.option('--exclude <patterns...>', 'Exclude glob patterns (used with --analyze)')
|
|
106
|
+
.option('-o, --output <path>', 'Save analysis result to file (used with --analyze)')
|
|
107
|
+
.option('--prefix <path>', 'Prefix for file IDs (used with --analyze)')
|
|
108
|
+
.action(async (jsonPaths, options) => {
|
|
66
109
|
const { startServer } = await import('./server.js');
|
|
67
|
-
|
|
110
|
+
const port = parseInt(options.port, 10);
|
|
111
|
+
if (options.analyze) {
|
|
112
|
+
// Run analysis in-memory
|
|
113
|
+
const resolvedPath = path.resolve(options.analyze);
|
|
114
|
+
console.log(`Analyzing ${resolvedPath}...`);
|
|
115
|
+
const output = runAnalyze(resolvedPath, {
|
|
116
|
+
tsconfig: options.tsconfig,
|
|
117
|
+
include: options.include,
|
|
118
|
+
exclude: options.exclude,
|
|
119
|
+
prefix: options.prefix,
|
|
120
|
+
});
|
|
121
|
+
// Optionally save to file
|
|
122
|
+
if (options.output) {
|
|
123
|
+
const outputPath = path.resolve(options.output);
|
|
124
|
+
fs.writeFileSync(outputPath, JSON.stringify(output, null, 2));
|
|
125
|
+
console.log(`Output written to ${outputPath}`);
|
|
126
|
+
}
|
|
127
|
+
startServer(output, port);
|
|
128
|
+
}
|
|
129
|
+
else if (jsonPaths && jsonPaths.length > 0) {
|
|
130
|
+
const resolvedPaths = jsonPaths.map((p) => path.resolve(p));
|
|
131
|
+
startServer(resolvedPaths, port);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
console.error('Error: provide either JSON file path(s) or --analyze <project-path>');
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
68
137
|
});
|
|
69
138
|
program.parse();
|
package/dist/cli/server.d.ts
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import type { CoderaphOutput } from '../types/graph.js';
|
|
2
|
+
export declare function startServer(source: string[] | CoderaphOutput, port: number): void;
|
package/dist/cli/server.js
CHANGED
|
@@ -8,25 +8,109 @@ const MIME_TYPES = {
|
|
|
8
8
|
'.js': 'application/javascript',
|
|
9
9
|
'.css': 'text/css',
|
|
10
10
|
};
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Reads multiple JSON files and merges them into a single CoderaphOutput.
|
|
13
|
+
* Cross-package edges are inferred by suffix matching of file IDs across packages.
|
|
14
|
+
*/
|
|
15
|
+
function mergeJsonFiles(jsonPaths) {
|
|
16
|
+
const outputs = jsonPaths.map((p) => {
|
|
17
|
+
const absolutePath = path.resolve(p);
|
|
18
|
+
if (!fs.existsSync(absolutePath)) {
|
|
19
|
+
console.error(`Error: file not found — ${absolutePath}`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
return JSON.parse(fs.readFileSync(absolutePath, 'utf-8'));
|
|
23
|
+
});
|
|
24
|
+
if (outputs.length === 1) {
|
|
25
|
+
return outputs[0];
|
|
26
|
+
}
|
|
27
|
+
const merged = {
|
|
28
|
+
version: 2,
|
|
29
|
+
files: [],
|
|
30
|
+
symbols: [],
|
|
31
|
+
fileEdges: [],
|
|
32
|
+
symbolEdges: [],
|
|
33
|
+
};
|
|
34
|
+
// Build a map of file ID suffix -> full file ID for cross-package edge inference
|
|
35
|
+
// Key: filename suffix (e.g., "src/utils/helper.ts"), Value: array of full IDs from different packages
|
|
36
|
+
const fileIdsByPackage = new Map();
|
|
37
|
+
const allFileIds = new Set();
|
|
38
|
+
for (let i = 0; i < outputs.length; i++) {
|
|
39
|
+
const output = outputs[i];
|
|
40
|
+
merged.files.push(...output.files);
|
|
41
|
+
merged.symbols.push(...output.symbols);
|
|
42
|
+
merged.fileEdges.push(...output.fileEdges);
|
|
43
|
+
merged.symbolEdges.push(...output.symbolEdges);
|
|
44
|
+
const ids = new Set(output.files.map((f) => f.id));
|
|
45
|
+
fileIdsByPackage.set(i, ids);
|
|
46
|
+
for (const id of ids) {
|
|
47
|
+
allFileIds.add(id);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Cross-package edge inference:
|
|
51
|
+
// For each edge target in each package, if the target doesn't exist in the same package's files
|
|
52
|
+
// but matches a file ID in another package by suffix, add a cross-package edge.
|
|
53
|
+
for (let i = 0; i < outputs.length; i++) {
|
|
54
|
+
const pkgFileIds = fileIdsByPackage.get(i);
|
|
55
|
+
const existingEdgeTargets = new Set(outputs[i].fileEdges.map((e) => `${e.source}->${e.target}`));
|
|
56
|
+
for (const edge of outputs[i].fileEdges) {
|
|
57
|
+
// If the target is not in any known file, try suffix matching
|
|
58
|
+
if (!allFileIds.has(edge.target)) {
|
|
59
|
+
for (const [j, otherIds] of fileIdsByPackage) {
|
|
60
|
+
if (i === j)
|
|
61
|
+
continue;
|
|
62
|
+
for (const otherId of otherIds) {
|
|
63
|
+
if (otherId.endsWith(edge.target) || edge.target.endsWith(otherId)) {
|
|
64
|
+
const edgeKey = `${edge.source}->${otherId}`;
|
|
65
|
+
if (!existingEdgeTargets.has(edgeKey)) {
|
|
66
|
+
merged.fileEdges.push({ source: edge.source, target: otherId });
|
|
67
|
+
existingEdgeTargets.add(edgeKey);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
16
74
|
}
|
|
75
|
+
return merged;
|
|
76
|
+
}
|
|
77
|
+
export function startServer(source, port) {
|
|
17
78
|
const webDir = fileURLToPath(new URL('../../src/web/', import.meta.url));
|
|
79
|
+
// Resolve the graph data
|
|
80
|
+
let graphData = null;
|
|
81
|
+
let jsonPaths = null;
|
|
82
|
+
if (Array.isArray(source) && source.length > 0 && typeof source[0] === 'string') {
|
|
83
|
+
jsonPaths = source;
|
|
84
|
+
// Validate files exist upfront
|
|
85
|
+
for (const p of jsonPaths) {
|
|
86
|
+
const absolutePath = path.resolve(p);
|
|
87
|
+
if (!fs.existsSync(absolutePath)) {
|
|
88
|
+
console.error(`Error: file not found — ${absolutePath}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
graphData = source;
|
|
95
|
+
}
|
|
18
96
|
const server = http.createServer((req, res) => {
|
|
19
97
|
const url = req.url ?? '/';
|
|
20
98
|
if (req.method === 'GET' && url === '/api/graph') {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
99
|
+
try {
|
|
100
|
+
let data;
|
|
101
|
+
if (graphData) {
|
|
102
|
+
data = graphData;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
data = mergeJsonFiles(jsonPaths);
|
|
26
106
|
}
|
|
27
107
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
28
|
-
res.end(data);
|
|
29
|
-
}
|
|
108
|
+
res.end(JSON.stringify(data));
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
112
|
+
res.end(JSON.stringify({ error: 'Failed to read graph file' }));
|
|
113
|
+
}
|
|
30
114
|
return;
|
|
31
115
|
}
|
|
32
116
|
// Serve static files from web directory
|
package/dist/types/graph.d.ts
CHANGED
package/package.json
CHANGED
package/src/web/app.js
CHANGED
|
@@ -579,9 +579,35 @@ function matchGroupQuery(query, node) {
|
|
|
579
579
|
return name.includes(q) || id.includes(q) || file.includes(q);
|
|
580
580
|
}
|
|
581
581
|
|
|
582
|
+
function nodeMatchesSubstring(node, val) {
|
|
583
|
+
const name = (node.name || '').toLowerCase();
|
|
584
|
+
const id = node.id.toLowerCase();
|
|
585
|
+
const file = (node.fileId || '').toLowerCase();
|
|
586
|
+
return name.includes(val) || id.includes(val) || file.includes(val);
|
|
587
|
+
}
|
|
588
|
+
|
|
582
589
|
function computeGroupNodeIds(query) {
|
|
583
590
|
if (!graphData || !query.trim()) return [];
|
|
591
|
+
const q = query.trim().toLowerCase();
|
|
584
592
|
const nodes = currentMode === 'file' ? graphData.files : graphData.symbols;
|
|
593
|
+
const edges = currentMode === 'file' ? graphData.fileEdges : graphData.symbolEdges;
|
|
594
|
+
|
|
595
|
+
// deps:X — nodes that import/depend on X (edges where target matches X)
|
|
596
|
+
if (q.startsWith('deps:')) {
|
|
597
|
+
const val = q.slice(5);
|
|
598
|
+
if (!val) return [];
|
|
599
|
+
const targetIds = new Set(nodes.filter(n => nodeMatchesSubstring(n, val)).map(n => n.id));
|
|
600
|
+
return edges.filter(e => targetIds.has(e.target)).map(e => e.source).filter((v, i, a) => a.indexOf(v) === i);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// rdeps:X — nodes that X depends on (edges where source matches X)
|
|
604
|
+
if (q.startsWith('rdeps:')) {
|
|
605
|
+
const val = q.slice(6);
|
|
606
|
+
if (!val) return [];
|
|
607
|
+
const sourceIds = new Set(nodes.filter(n => nodeMatchesSubstring(n, val)).map(n => n.id));
|
|
608
|
+
return edges.filter(e => sourceIds.has(e.source)).map(e => e.target).filter((v, i, a) => a.indexOf(v) === i);
|
|
609
|
+
}
|
|
610
|
+
|
|
585
611
|
const ids = [];
|
|
586
612
|
for (const node of nodes) {
|
|
587
613
|
if (matchGroupQuery(query, node)) ids.push(node.id);
|
package/src/web/controls.js
CHANGED
|
@@ -568,7 +568,7 @@ function createGroupRow(query, colorNum) {
|
|
|
568
568
|
const input = document.createElement('input');
|
|
569
569
|
input.type = 'text';
|
|
570
570
|
input.className = 'group-query';
|
|
571
|
-
input.placeholder = '검색어 입력... (path: file: kind:)';
|
|
571
|
+
input.placeholder = '검색어 입력... (path: file: kind: deps: rdeps:)';
|
|
572
572
|
input.value = query || '';
|
|
573
573
|
|
|
574
574
|
const colorInput = document.createElement('input');
|
|
@@ -615,6 +615,10 @@ function createGroupRow(query, colorNum) {
|
|
|
615
615
|
el.value = 'file:' + (item.fileId || item.id).split('/').pop();
|
|
616
616
|
} else if (val.startsWith('kind:')) {
|
|
617
617
|
el.value = 'kind:' + (item.kind || 'file');
|
|
618
|
+
} else if (val.startsWith('deps:')) {
|
|
619
|
+
el.value = 'deps:' + (item.name || item.id.split('/').pop());
|
|
620
|
+
} else if (val.startsWith('rdeps:')) {
|
|
621
|
+
el.value = 'rdeps:' + (item.name || item.id.split('/').pop());
|
|
618
622
|
} else {
|
|
619
623
|
el.value = item.name || item.id.split('/').pop();
|
|
620
624
|
}
|
|
@@ -656,6 +660,27 @@ function getGroupQueryCandidates(rawQuery) {
|
|
|
656
660
|
for (const item of items) kinds.add(item.kind || 'file');
|
|
657
661
|
return [...kinds].filter(k => k.includes(val)).map(k => ({ id: k, name: k, kind: k }));
|
|
658
662
|
}
|
|
663
|
+
// For deps:/rdeps: prefix, return matching nodes as candidates
|
|
664
|
+
if (q.startsWith('deps:')) {
|
|
665
|
+
const val = q.slice(5);
|
|
666
|
+
if (!val) return items;
|
|
667
|
+
return items.filter(n => {
|
|
668
|
+
const name = (n.name || '').toLowerCase();
|
|
669
|
+
const id = n.id.toLowerCase();
|
|
670
|
+
const file = (n.fileId || '').toLowerCase();
|
|
671
|
+
return name.includes(val) || id.includes(val) || file.includes(val);
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
if (q.startsWith('rdeps:')) {
|
|
675
|
+
const val = q.slice(6);
|
|
676
|
+
if (!val) return items;
|
|
677
|
+
return items.filter(n => {
|
|
678
|
+
const name = (n.name || '').toLowerCase();
|
|
679
|
+
const id = n.id.toLowerCase();
|
|
680
|
+
const file = (n.fileId || '').toLowerCase();
|
|
681
|
+
return name.includes(val) || id.includes(val) || file.includes(val);
|
|
682
|
+
});
|
|
683
|
+
}
|
|
659
684
|
return items;
|
|
660
685
|
}
|
|
661
686
|
|
|
@@ -669,6 +694,8 @@ function showGroupHint(inputEl) {
|
|
|
669
694
|
{ prefix: 'path:', desc: '일치하는 파일 경로' },
|
|
670
695
|
{ prefix: 'file:', desc: '일치하는 파일 이름' },
|
|
671
696
|
{ prefix: 'kind:', desc: 'class, function, interface 등' },
|
|
697
|
+
{ prefix: 'deps:', desc: '해당 노드를 import하는 노드' },
|
|
698
|
+
{ prefix: 'rdeps:', desc: '해당 노드가 의존하는 노드' },
|
|
672
699
|
];
|
|
673
700
|
for (const opt of options) {
|
|
674
701
|
const item = document.createElement('div');
|