agent-security-scanner-mcp 3.20.0 → 4.0.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/README.md +144 -43
- package/code-review-agent/.env.example +8 -0
- package/code-review-agent/README.md +142 -0
- package/code-review-agent/TODO.md +149 -0
- package/code-review-agent/bin/cr-agent.ts +313 -0
- package/code-review-agent/dist/bin/cr-agent.d.ts +3 -0
- package/code-review-agent/dist/bin/cr-agent.d.ts.map +1 -0
- package/code-review-agent/dist/bin/cr-agent.js +299 -0
- package/code-review-agent/dist/bin/cr-agent.js.map +1 -0
- package/code-review-agent/dist/src/analyzer/engine.d.ts +16 -0
- package/code-review-agent/dist/src/analyzer/engine.d.ts.map +1 -0
- package/code-review-agent/dist/src/analyzer/engine.js +298 -0
- package/code-review-agent/dist/src/analyzer/engine.js.map +1 -0
- package/code-review-agent/dist/src/analyzer/intent.d.ts +10 -0
- package/code-review-agent/dist/src/analyzer/intent.d.ts.map +1 -0
- package/code-review-agent/dist/src/analyzer/intent.js +40 -0
- package/code-review-agent/dist/src/analyzer/intent.js.map +1 -0
- package/code-review-agent/dist/src/analyzer/semantic.d.ts +19 -0
- package/code-review-agent/dist/src/analyzer/semantic.d.ts.map +1 -0
- package/code-review-agent/dist/src/analyzer/semantic.js +150 -0
- package/code-review-agent/dist/src/analyzer/semantic.js.map +1 -0
- package/code-review-agent/dist/src/context/assembler.d.ts +16 -0
- package/code-review-agent/dist/src/context/assembler.d.ts.map +1 -0
- package/code-review-agent/dist/src/context/assembler.js +135 -0
- package/code-review-agent/dist/src/context/assembler.js.map +1 -0
- package/code-review-agent/dist/src/context/file.d.ts +6 -0
- package/code-review-agent/dist/src/context/file.d.ts.map +1 -0
- package/code-review-agent/dist/src/context/file.js +139 -0
- package/code-review-agent/dist/src/context/file.js.map +1 -0
- package/code-review-agent/dist/src/context/project.d.ts +4 -0
- package/code-review-agent/dist/src/context/project.d.ts.map +1 -0
- package/code-review-agent/dist/src/context/project.js +252 -0
- package/code-review-agent/dist/src/context/project.js.map +1 -0
- package/code-review-agent/dist/src/graph/dependency.d.ts +11 -0
- package/code-review-agent/dist/src/graph/dependency.d.ts.map +1 -0
- package/code-review-agent/dist/src/graph/dependency.js +102 -0
- package/code-review-agent/dist/src/graph/dependency.js.map +1 -0
- package/code-review-agent/dist/src/graph/resolver.d.ts +9 -0
- package/code-review-agent/dist/src/graph/resolver.d.ts.map +1 -0
- package/code-review-agent/dist/src/graph/resolver.js +124 -0
- package/code-review-agent/dist/src/graph/resolver.js.map +1 -0
- package/code-review-agent/dist/src/index.d.ts +21 -0
- package/code-review-agent/dist/src/index.d.ts.map +1 -0
- package/code-review-agent/dist/src/index.js +21 -0
- package/code-review-agent/dist/src/index.js.map +1 -0
- package/code-review-agent/dist/src/llm/anthropic.d.ts +13 -0
- package/code-review-agent/dist/src/llm/anthropic.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/anthropic.js +83 -0
- package/code-review-agent/dist/src/llm/anthropic.js.map +1 -0
- package/code-review-agent/dist/src/llm/claude-cli.d.ts +13 -0
- package/code-review-agent/dist/src/llm/claude-cli.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/claude-cli.js +142 -0
- package/code-review-agent/dist/src/llm/claude-cli.js.map +1 -0
- package/code-review-agent/dist/src/llm/openai.d.ts +13 -0
- package/code-review-agent/dist/src/llm/openai.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/openai.js +78 -0
- package/code-review-agent/dist/src/llm/openai.js.map +1 -0
- package/code-review-agent/dist/src/llm/provider.d.ts +18 -0
- package/code-review-agent/dist/src/llm/provider.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/provider.js +11 -0
- package/code-review-agent/dist/src/llm/provider.js.map +1 -0
- package/code-review-agent/dist/src/llm/router.d.ts +14 -0
- package/code-review-agent/dist/src/llm/router.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/router.js +67 -0
- package/code-review-agent/dist/src/llm/router.js.map +1 -0
- package/code-review-agent/dist/src/llm/schemas.d.ts +18 -0
- package/code-review-agent/dist/src/llm/schemas.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/schemas.js +91 -0
- package/code-review-agent/dist/src/llm/schemas.js.map +1 -0
- package/code-review-agent/dist/src/types/analysis.d.ts +56 -0
- package/code-review-agent/dist/src/types/analysis.d.ts.map +1 -0
- package/code-review-agent/dist/src/types/analysis.js +2 -0
- package/code-review-agent/dist/src/types/analysis.js.map +1 -0
- package/code-review-agent/dist/src/types/config.d.ts +24 -0
- package/code-review-agent/dist/src/types/config.d.ts.map +1 -0
- package/code-review-agent/dist/src/types/config.js +42 -0
- package/code-review-agent/dist/src/types/config.js.map +1 -0
- package/code-review-agent/dist/src/types/findings.d.ts +236 -0
- package/code-review-agent/dist/src/types/findings.d.ts.map +1 -0
- package/code-review-agent/dist/src/types/findings.js +64 -0
- package/code-review-agent/dist/src/types/findings.js.map +1 -0
- package/code-review-agent/package.json +36 -0
- package/code-review-agent/src/analyzer/engine.ts +374 -0
- package/code-review-agent/src/analyzer/intent.ts +49 -0
- package/code-review-agent/src/analyzer/semantic.ts +222 -0
- package/code-review-agent/src/context/assembler.ts +165 -0
- package/code-review-agent/src/context/file.ts +145 -0
- package/code-review-agent/src/context/project.ts +253 -0
- package/code-review-agent/src/graph/dependency.ts +116 -0
- package/code-review-agent/src/graph/resolver.ts +138 -0
- package/code-review-agent/src/index.ts +58 -0
- package/code-review-agent/src/llm/anthropic.ts +106 -0
- package/code-review-agent/src/llm/claude-cli.ts +188 -0
- package/code-review-agent/src/llm/openai.ts +95 -0
- package/code-review-agent/src/llm/provider.ts +33 -0
- package/code-review-agent/src/llm/router.ts +86 -0
- package/code-review-agent/src/llm/schemas.ts +125 -0
- package/code-review-agent/src/types/analysis.ts +62 -0
- package/code-review-agent/src/types/config.ts +72 -0
- package/code-review-agent/src/types/findings.ts +81 -0
- package/code-review-agent/tests/analyzer/engine.test.ts +194 -0
- package/code-review-agent/tests/analyzer/intent.test.ts +76 -0
- package/code-review-agent/tests/analyzer/semantic.test.ts +131 -0
- package/code-review-agent/tests/context/file.test.ts +21 -0
- package/code-review-agent/tests/context/project.test.ts +20 -0
- package/code-review-agent/tests/fixtures/safe-build-tool/README.md +19 -0
- package/code-review-agent/tests/fixtures/safe-build-tool/builder.js +52 -0
- package/code-review-agent/tests/fixtures/safe-file-manager/README.md +16 -0
- package/code-review-agent/tests/fixtures/safe-file-manager/organizer.py +70 -0
- package/code-review-agent/tests/fixtures/vuln-api-server/README.md +17 -0
- package/code-review-agent/tests/fixtures/vuln-api-server/server.js +52 -0
- package/code-review-agent/tests/fixtures/vuln-ecommerce/README.md +18 -0
- package/code-review-agent/tests/fixtures/vuln-ecommerce/checkout.js +63 -0
- package/code-review-agent/tests/graph/dependency.test.ts +136 -0
- package/code-review-agent/tests/helpers/mock-provider.ts +48 -0
- package/code-review-agent/tests/llm/claude-cli.test.ts +251 -0
- package/code-review-agent/tests/llm/router.test.ts +77 -0
- package/code-review-agent/tests/llm/schemas.test.ts +142 -0
- package/code-review-agent/tsconfig.json +20 -0
- package/code-review-agent/vitest.config.ts +11 -0
- package/index.js +18 -18
- package/openclaw.plugin.json +2 -2
- package/package.json +13 -3
- package/server.json +3 -3
- package/src/cli/init-hooks.js +3 -3
- package/src/cli/init.js +1 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { FileContext, ProjectContext } from '../types/analysis.js';
|
|
2
|
+
import type { IntentProfile } from '../types/findings.js';
|
|
3
|
+
import type { LLMProvider } from '../llm/provider.js';
|
|
4
|
+
import { formatProjectContextForLLM } from './project.js';
|
|
5
|
+
|
|
6
|
+
const TOKEN_BUDGETS: Record<string, number> = {
|
|
7
|
+
anthropic: 100_000,
|
|
8
|
+
openai: 60_000,
|
|
9
|
+
'claude-cli': 100_000,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const TRUNCATION_MARKER = '\n[TRUNCATED — file too large for context window]\n';
|
|
13
|
+
|
|
14
|
+
// Reserve 20% of budget for LLM output tokens
|
|
15
|
+
const OUTPUT_RESERVE = 0.2;
|
|
16
|
+
|
|
17
|
+
export class ContextAssembler {
|
|
18
|
+
constructor(private provider: LLMProvider) {}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Calculate how many lines of source code fit in the remaining
|
|
22
|
+
* token budget after system prompt, intent, project context, and
|
|
23
|
+
* metadata are accounted for.
|
|
24
|
+
*/
|
|
25
|
+
calculateMaxLines(
|
|
26
|
+
intent: IntentProfile,
|
|
27
|
+
project: ProjectContext,
|
|
28
|
+
file: FileContext,
|
|
29
|
+
systemPrompt: string,
|
|
30
|
+
): number {
|
|
31
|
+
const budget = TOKEN_BUDGETS[this.provider.providerName] ?? 60_000;
|
|
32
|
+
const usableBudget = budget * (1 - OUTPUT_RESERVE);
|
|
33
|
+
|
|
34
|
+
// Measure fixed overhead
|
|
35
|
+
const overheadParts = [
|
|
36
|
+
systemPrompt,
|
|
37
|
+
formatIntent(intent),
|
|
38
|
+
formatProjectContextForLLM(project),
|
|
39
|
+
formatFileMetadata(file),
|
|
40
|
+
// Framing text around file content
|
|
41
|
+
`\n## File Content\nFile: ${file.filePath} (${file.language})\n\`\`\`\n\`\`\`\n`,
|
|
42
|
+
];
|
|
43
|
+
const overheadTokens = this.provider.countTokens(overheadParts.join('\n'));
|
|
44
|
+
|
|
45
|
+
const remainingTokens = usableBudget - overheadTokens;
|
|
46
|
+
if (remainingTokens <= 0) return 100; // absolute minimum
|
|
47
|
+
|
|
48
|
+
// Estimate chars per line from actual file content (avg line length)
|
|
49
|
+
const lines = file.content.split('\n');
|
|
50
|
+
const avgCharsPerLine = lines.length > 0
|
|
51
|
+
? file.content.length / lines.length
|
|
52
|
+
: 40;
|
|
53
|
+
// ~4 chars per token + line number prefix ("1234: ")
|
|
54
|
+
const charsPerToken = 4;
|
|
55
|
+
const lineNumberOverhead = 6;
|
|
56
|
+
const tokensPerLine = (avgCharsPerLine + lineNumberOverhead) / charsPerToken;
|
|
57
|
+
|
|
58
|
+
const maxLines = Math.floor(remainingTokens / tokensPerLine);
|
|
59
|
+
return Math.max(maxLines, 100); // never go below 100 lines
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
assembleAnalysisContext(
|
|
63
|
+
intent: IntentProfile,
|
|
64
|
+
project: ProjectContext,
|
|
65
|
+
file: FileContext,
|
|
66
|
+
): string {
|
|
67
|
+
const budget = TOKEN_BUDGETS[this.provider.providerName] ?? 60_000;
|
|
68
|
+
|
|
69
|
+
// Priority order: intent > file content > project context
|
|
70
|
+
const sections: Array<{ label: string; content: string; priority: number }> = [
|
|
71
|
+
{
|
|
72
|
+
label: 'Intent Profile',
|
|
73
|
+
content: formatIntent(intent),
|
|
74
|
+
priority: 1,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
label: 'File Content',
|
|
78
|
+
content: formatFileContent(file),
|
|
79
|
+
priority: 2,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
label: 'Project Context',
|
|
83
|
+
content: formatProjectContextForLLM(project),
|
|
84
|
+
priority: 3,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
label: 'File Metadata',
|
|
88
|
+
content: formatFileMetadata(file),
|
|
89
|
+
priority: 4,
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
// Sort by priority and assemble within budget
|
|
94
|
+
sections.sort((a, b) => a.priority - b.priority);
|
|
95
|
+
|
|
96
|
+
let assembled = '';
|
|
97
|
+
let usedTokens = 0;
|
|
98
|
+
|
|
99
|
+
for (const section of sections) {
|
|
100
|
+
const sectionText = `\n## ${section.label}\n${section.content}\n`;
|
|
101
|
+
const sectionTokens = this.provider.countTokens(sectionText);
|
|
102
|
+
|
|
103
|
+
if (usedTokens + sectionTokens > budget * 0.8) {
|
|
104
|
+
// Truncate this section to fit
|
|
105
|
+
const remainingBudget = Math.floor((budget * 0.8 - usedTokens) * 4); // rough chars
|
|
106
|
+
if (remainingBudget > 200) {
|
|
107
|
+
assembled += `\n## ${section.label}\n${section.content.slice(0, remainingBudget)}${TRUNCATION_MARKER}`;
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
assembled += sectionText;
|
|
113
|
+
usedTokens += sectionTokens;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return assembled;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
assembleTriageContext(project: ProjectContext, file: FileContext): string {
|
|
120
|
+
// Triage needs less context — just file overview + project summary
|
|
121
|
+
const sections = [
|
|
122
|
+
`## File: ${file.filePath}`,
|
|
123
|
+
`Language: ${file.language} | Lines: ${file.lineCount}`,
|
|
124
|
+
`Test: ${file.isTestFile} | Config: ${file.isConfigFile} | Generated: ${file.isGenerated}`,
|
|
125
|
+
`Imports: ${file.imports.slice(0, 10).join(', ')}`,
|
|
126
|
+
'',
|
|
127
|
+
`## Project`,
|
|
128
|
+
`Language: ${project.language} | Framework: ${project.framework}`,
|
|
129
|
+
project.readme ? `README excerpt: ${project.readme.slice(0, 500)}` : 'No README',
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
// Include first 100 lines of file content for triage
|
|
133
|
+
const preview = file.content.split('\n').slice(0, 100).join('\n');
|
|
134
|
+
sections.push('', '## File Preview (first 100 lines)', '```', preview, '```');
|
|
135
|
+
|
|
136
|
+
return sections.join('\n');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function formatIntent(intent: IntentProfile): string {
|
|
141
|
+
return [
|
|
142
|
+
`Purpose: ${intent.purpose}`,
|
|
143
|
+
`Risk Domain: ${intent.riskDomain}`,
|
|
144
|
+
`Framework: ${intent.framework}`,
|
|
145
|
+
`Expected Behaviors: ${intent.expectedBehaviors.join('; ')}`,
|
|
146
|
+
`Unexpected Behaviors: ${intent.unexpectedBehaviors.join('; ')}`,
|
|
147
|
+
].join('\n');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function formatFileContent(file: FileContext): string {
|
|
151
|
+
const numbered = file.content
|
|
152
|
+
.split('\n')
|
|
153
|
+
.map((line, i) => `${i + 1}: ${line}`)
|
|
154
|
+
.join('\n');
|
|
155
|
+
return `File: ${file.filePath} (${file.language})\n\`\`\`\n${numbered}\n\`\`\``;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function formatFileMetadata(file: FileContext): string {
|
|
159
|
+
const parts = [
|
|
160
|
+
`Imports: ${file.imports.join(', ') || 'none'}`,
|
|
161
|
+
`Imported by: ${file.importedBy.join(', ') || 'none'}`,
|
|
162
|
+
`Siblings: ${file.siblingFiles.join(', ') || 'none'}`,
|
|
163
|
+
];
|
|
164
|
+
return parts.join('\n');
|
|
165
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type { DependencyGraph, FileContext } from '../types/analysis.js';
|
|
4
|
+
|
|
5
|
+
const LANGUAGE_MAP: Record<string, string> = {
|
|
6
|
+
'.js': 'javascript',
|
|
7
|
+
'.mjs': 'javascript',
|
|
8
|
+
'.cjs': 'javascript',
|
|
9
|
+
'.jsx': 'javascript',
|
|
10
|
+
'.ts': 'typescript',
|
|
11
|
+
'.tsx': 'typescript',
|
|
12
|
+
'.py': 'python',
|
|
13
|
+
'.go': 'go',
|
|
14
|
+
'.rs': 'rust',
|
|
15
|
+
'.java': 'java',
|
|
16
|
+
'.rb': 'ruby',
|
|
17
|
+
'.php': 'php',
|
|
18
|
+
'.c': 'c',
|
|
19
|
+
'.cpp': 'cpp',
|
|
20
|
+
'.cs': 'csharp',
|
|
21
|
+
'.swift': 'swift',
|
|
22
|
+
'.kt': 'kotlin',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const TEST_PATTERNS = [
|
|
26
|
+
/\.test\.[jt]sx?$/,
|
|
27
|
+
/\.spec\.[jt]sx?$/,
|
|
28
|
+
/_test\.go$/,
|
|
29
|
+
/test_.*\.py$/,
|
|
30
|
+
/.*_test\.py$/,
|
|
31
|
+
/\.test\.py$/,
|
|
32
|
+
/Test\.java$/,
|
|
33
|
+
/\.test\.rb$/,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const CONFIG_PATTERNS = [
|
|
37
|
+
/\.config\.[jt]s$/,
|
|
38
|
+
/\.rc$/,
|
|
39
|
+
/\.json$/,
|
|
40
|
+
/\.ya?ml$/,
|
|
41
|
+
/\.toml$/,
|
|
42
|
+
/\.ini$/,
|
|
43
|
+
/\.env/,
|
|
44
|
+
/Makefile$/,
|
|
45
|
+
/Dockerfile$/,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const GENERATED_MARKERS = [
|
|
49
|
+
'// Code generated',
|
|
50
|
+
'# Generated by',
|
|
51
|
+
'// AUTO-GENERATED',
|
|
52
|
+
'/* eslint-disable */',
|
|
53
|
+
'// @generated',
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export function buildFileContext(
|
|
57
|
+
filePath: string,
|
|
58
|
+
projectRoot: string,
|
|
59
|
+
graph?: DependencyGraph,
|
|
60
|
+
): FileContext {
|
|
61
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
62
|
+
const ext = path.extname(filePath);
|
|
63
|
+
const language = LANGUAGE_MAP[ext] ?? 'unknown';
|
|
64
|
+
const lines = content.split('\n');
|
|
65
|
+
|
|
66
|
+
const relativePath = path.relative(projectRoot, filePath);
|
|
67
|
+
const dirName = path.dirname(filePath);
|
|
68
|
+
|
|
69
|
+
let siblingFiles: string[] = [];
|
|
70
|
+
try {
|
|
71
|
+
siblingFiles = fs
|
|
72
|
+
.readdirSync(dirName)
|
|
73
|
+
.filter((f) => {
|
|
74
|
+
const full = path.join(dirName, f);
|
|
75
|
+
try { return fs.statSync(full).isFile(); } catch { return false; }
|
|
76
|
+
})
|
|
77
|
+
.filter((f) => f !== path.basename(filePath))
|
|
78
|
+
.slice(0, 20);
|
|
79
|
+
} catch { /* dir read error */ }
|
|
80
|
+
|
|
81
|
+
const imports = extractImports(content, language);
|
|
82
|
+
|
|
83
|
+
let importedBy: string[] = [];
|
|
84
|
+
if (graph) {
|
|
85
|
+
const node = graph.nodes.get(relativePath) ?? graph.nodes.get(filePath);
|
|
86
|
+
if (node) {
|
|
87
|
+
importedBy = node.importedBy;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
filePath: relativePath,
|
|
93
|
+
content,
|
|
94
|
+
language,
|
|
95
|
+
lineCount: lines.length,
|
|
96
|
+
imports,
|
|
97
|
+
importedBy,
|
|
98
|
+
siblingFiles,
|
|
99
|
+
isTestFile: isTestFile(relativePath),
|
|
100
|
+
isConfigFile: isConfigFile(relativePath),
|
|
101
|
+
isGenerated: isGeneratedFile(content),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isTestFile(filePath: string): boolean {
|
|
106
|
+
const name = path.basename(filePath);
|
|
107
|
+
// Normalize separators for cross-platform matching
|
|
108
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
109
|
+
return TEST_PATTERNS.some((p) => p.test(name)) ||
|
|
110
|
+
/(^|\/)(test|tests|__tests__)\//.test(normalized);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function isConfigFile(filePath: string): boolean {
|
|
114
|
+
const name = path.basename(filePath);
|
|
115
|
+
return CONFIG_PATTERNS.some((p) => p.test(name));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isGeneratedFile(content: string): boolean {
|
|
119
|
+
const header = content.slice(0, 500);
|
|
120
|
+
return GENERATED_MARKERS.some((m) => header.includes(m));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function extractImports(content: string, language: string): string[] {
|
|
124
|
+
const imports: string[] = [];
|
|
125
|
+
|
|
126
|
+
if (['javascript', 'typescript'].includes(language)) {
|
|
127
|
+
// ES imports
|
|
128
|
+
const esImports = content.matchAll(/import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g);
|
|
129
|
+
for (const m of esImports) imports.push(m[1]);
|
|
130
|
+
// require
|
|
131
|
+
const requires = content.matchAll(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g);
|
|
132
|
+
for (const m of requires) imports.push(m[1]);
|
|
133
|
+
} else if (language === 'python') {
|
|
134
|
+
const pyImports = content.matchAll(/(?:from\s+(\S+)\s+import|import\s+(\S+))/g);
|
|
135
|
+
for (const m of pyImports) imports.push(m[1] ?? m[2]);
|
|
136
|
+
} else if (language === 'go') {
|
|
137
|
+
const goImports = content.matchAll(/import\s+(?:\(\s*)?["']([^"']+)["']/g);
|
|
138
|
+
for (const m of goImports) imports.push(m[1]);
|
|
139
|
+
} else if (language === 'java') {
|
|
140
|
+
const javaImports = content.matchAll(/import\s+([\w.]+);/g);
|
|
141
|
+
for (const m of javaImports) imports.push(m[1]);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return [...new Set(imports)];
|
|
145
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type { ProjectContext } from '../types/analysis.js';
|
|
4
|
+
|
|
5
|
+
const README_MAX_CHARS = 2000;
|
|
6
|
+
const TREE_MAX_DEPTH = 2;
|
|
7
|
+
const LANGUAGE_CENSUS_MAX_FILES = 200;
|
|
8
|
+
|
|
9
|
+
export function buildProjectContext(projectRoot: string): ProjectContext {
|
|
10
|
+
return {
|
|
11
|
+
readme: readReadme(projectRoot),
|
|
12
|
+
packageMeta: readPackageMeta(projectRoot),
|
|
13
|
+
directoryTree: buildDirectoryTree(projectRoot, TREE_MAX_DEPTH),
|
|
14
|
+
envVars: readEnvVarNames(projectRoot),
|
|
15
|
+
hasDockerfile: fileExists(projectRoot, 'Dockerfile'),
|
|
16
|
+
hasCI:
|
|
17
|
+
dirExists(projectRoot, '.github/workflows') ||
|
|
18
|
+
fileExists(projectRoot, '.gitlab-ci.yml') ||
|
|
19
|
+
fileExists(projectRoot, 'Jenkinsfile'),
|
|
20
|
+
language: detectLanguage(projectRoot),
|
|
21
|
+
framework: detectFramework(projectRoot),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatProjectContextForLLM(ctx: ProjectContext): string {
|
|
26
|
+
const sections: string[] = [];
|
|
27
|
+
|
|
28
|
+
if (ctx.readme) {
|
|
29
|
+
sections.push(`## Project README\n${ctx.readme}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (ctx.packageMeta) {
|
|
33
|
+
const meta = ctx.packageMeta;
|
|
34
|
+
const parts: string[] = [];
|
|
35
|
+
if (meta.name) parts.push(`Name: ${meta.name}`);
|
|
36
|
+
if (meta.description) parts.push(`Description: ${meta.description}`);
|
|
37
|
+
if (meta.dependencies) {
|
|
38
|
+
const deps = Object.keys(meta.dependencies as Record<string, string>);
|
|
39
|
+
parts.push(`Dependencies: ${deps.join(', ')}`);
|
|
40
|
+
}
|
|
41
|
+
sections.push(`## Package Metadata\n${parts.join('\n')}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
sections.push(`## Directory Structure\n\`\`\`\n${ctx.directoryTree}\n\`\`\``);
|
|
45
|
+
|
|
46
|
+
if (ctx.envVars.length > 0) {
|
|
47
|
+
sections.push(`## Environment Variables\n${ctx.envVars.join(', ')}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const infra: string[] = [];
|
|
51
|
+
if (ctx.hasDockerfile) infra.push('Dockerfile');
|
|
52
|
+
if (ctx.hasCI) infra.push('CI/CD');
|
|
53
|
+
if (infra.length > 0) {
|
|
54
|
+
sections.push(`## Infrastructure\n${infra.join(', ')}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
sections.push(`## Language: ${ctx.language}\n## Framework: ${ctx.framework}`);
|
|
58
|
+
|
|
59
|
+
return sections.join('\n\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readReadme(root: string): string {
|
|
63
|
+
for (const name of ['README.md', 'README.txt', 'README', 'readme.md']) {
|
|
64
|
+
const filePath = path.join(root, name);
|
|
65
|
+
try {
|
|
66
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
67
|
+
return content.slice(0, README_MAX_CHARS);
|
|
68
|
+
} catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readPackageMeta(root: string): Record<string, unknown> | null {
|
|
76
|
+
for (const name of ['package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml']) {
|
|
77
|
+
const filePath = path.join(root, name);
|
|
78
|
+
try {
|
|
79
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
80
|
+
if (name === 'package.json') {
|
|
81
|
+
return JSON.parse(content);
|
|
82
|
+
}
|
|
83
|
+
return { _raw: content.slice(0, 500), _type: name };
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readEnvVarNames(root: string): string[] {
|
|
92
|
+
const envPath = path.join(root, '.env.example');
|
|
93
|
+
try {
|
|
94
|
+
const content = fs.readFileSync(envPath, 'utf-8');
|
|
95
|
+
return content
|
|
96
|
+
.split('\n')
|
|
97
|
+
.map((line) => line.trim())
|
|
98
|
+
.filter((line) => line && !line.startsWith('#'))
|
|
99
|
+
.map((line) => line.split('=')[0].trim())
|
|
100
|
+
.filter(Boolean);
|
|
101
|
+
} catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildDirectoryTree(root: string, maxDepth: number, prefix = '', depth = 0): string {
|
|
107
|
+
if (depth >= maxDepth) return '';
|
|
108
|
+
|
|
109
|
+
const EXCLUDE = new Set([
|
|
110
|
+
'node_modules', 'dist', 'build', '.git', 'vendor', '__pycache__',
|
|
111
|
+
'.venv', '.next', 'coverage', '.nyc_output',
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
let entries: fs.Dirent[];
|
|
115
|
+
try {
|
|
116
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
117
|
+
} catch {
|
|
118
|
+
return '';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const filtered = entries
|
|
122
|
+
.filter((e) => !EXCLUDE.has(e.name) && !e.name.startsWith('.'))
|
|
123
|
+
.sort((a, b) => {
|
|
124
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
125
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
126
|
+
return a.name.localeCompare(b.name);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const lines: string[] = [];
|
|
130
|
+
for (let i = 0; i < filtered.length; i++) {
|
|
131
|
+
const entry = filtered[i];
|
|
132
|
+
const isLast = i === filtered.length - 1;
|
|
133
|
+
const connector = isLast ? '└── ' : '├── ';
|
|
134
|
+
const childPrefix = isLast ? ' ' : '│ ';
|
|
135
|
+
|
|
136
|
+
lines.push(`${prefix}${connector}${entry.name}${entry.isDirectory() ? '/' : ''}`);
|
|
137
|
+
|
|
138
|
+
if (entry.isDirectory()) {
|
|
139
|
+
const subtree = buildDirectoryTree(
|
|
140
|
+
path.join(root, entry.name),
|
|
141
|
+
maxDepth,
|
|
142
|
+
`${prefix}${childPrefix}`,
|
|
143
|
+
depth + 1,
|
|
144
|
+
);
|
|
145
|
+
if (subtree) lines.push(subtree);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return lines.join('\n');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function detectLanguage(root: string): string {
|
|
153
|
+
// Check manifest files first
|
|
154
|
+
if (fileExists(root, 'package.json') || fileExists(root, 'tsconfig.json')) return 'javascript/typescript';
|
|
155
|
+
if (fileExists(root, 'requirements.txt') || fileExists(root, 'setup.py') || fileExists(root, 'pyproject.toml')) return 'python';
|
|
156
|
+
if (fileExists(root, 'go.mod')) return 'go';
|
|
157
|
+
if (fileExists(root, 'Cargo.toml')) return 'rust';
|
|
158
|
+
if (fileExists(root, 'pom.xml') || fileExists(root, 'build.gradle')) return 'java';
|
|
159
|
+
|
|
160
|
+
// Fallback: recursive census of file extensions across the project
|
|
161
|
+
try {
|
|
162
|
+
const extCounts: Record<string, number> = {};
|
|
163
|
+
let countedFiles = 0;
|
|
164
|
+
const exclude = new Set([
|
|
165
|
+
'node_modules', 'dist', 'build', '.git', 'vendor', '__pycache__',
|
|
166
|
+
'.venv', 'venv', 'env', '.next', 'coverage', '.nyc_output',
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
const walk = (dir: string): void => {
|
|
170
|
+
if (countedFiles >= LANGUAGE_CENSUS_MAX_FILES) return;
|
|
171
|
+
|
|
172
|
+
let entries: fs.Dirent[];
|
|
173
|
+
try {
|
|
174
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
175
|
+
} catch {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const entry of entries) {
|
|
180
|
+
if (countedFiles >= LANGUAGE_CENSUS_MAX_FILES) return;
|
|
181
|
+
if (exclude.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
182
|
+
|
|
183
|
+
const fullPath = path.join(dir, entry.name);
|
|
184
|
+
if (entry.isDirectory()) {
|
|
185
|
+
walk(fullPath);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!entry.isFile()) continue;
|
|
190
|
+
const ext = path.extname(entry.name);
|
|
191
|
+
if (!ext) continue;
|
|
192
|
+
extCounts[ext] = (extCounts[ext] ?? 0) + 1;
|
|
193
|
+
countedFiles++;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
walk(root);
|
|
198
|
+
|
|
199
|
+
if (countedFiles === 0) {
|
|
200
|
+
return 'unknown';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const jsExts = ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx'];
|
|
204
|
+
const pyExts = ['.py'];
|
|
205
|
+
const jsCount = jsExts.reduce((n, e) => n + (extCounts[e] ?? 0), 0);
|
|
206
|
+
const pyCount = pyExts.reduce((n, e) => n + (extCounts[e] ?? 0), 0);
|
|
207
|
+
if (jsCount > 0 && jsCount >= pyCount) return 'javascript/typescript';
|
|
208
|
+
if (pyCount > 0) return 'python';
|
|
209
|
+
if (extCounts['.go']) return 'go';
|
|
210
|
+
if (extCounts['.rs']) return 'rust';
|
|
211
|
+
if (extCounts['.java']) return 'java';
|
|
212
|
+
} catch { /* can't read directory */ }
|
|
213
|
+
|
|
214
|
+
return 'unknown';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function detectFramework(root: string): string {
|
|
218
|
+
try {
|
|
219
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8'));
|
|
220
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
221
|
+
if (deps.next) return 'next.js';
|
|
222
|
+
if (deps.express) return 'express';
|
|
223
|
+
if (deps.fastify) return 'fastify';
|
|
224
|
+
if (deps.react) return 'react';
|
|
225
|
+
if (deps.vue) return 'vue';
|
|
226
|
+
if (deps.hono) return 'hono';
|
|
227
|
+
} catch { /* no package.json */ }
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const req = fs.readFileSync(path.join(root, 'requirements.txt'), 'utf-8');
|
|
231
|
+
if (req.includes('django')) return 'django';
|
|
232
|
+
if (req.includes('flask')) return 'flask';
|
|
233
|
+
if (req.includes('fastapi')) return 'fastapi';
|
|
234
|
+
} catch { /* no requirements.txt */ }
|
|
235
|
+
|
|
236
|
+
return 'none';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function fileExists(root: string, name: string): boolean {
|
|
240
|
+
try {
|
|
241
|
+
return fs.statSync(path.join(root, name)).isFile();
|
|
242
|
+
} catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function dirExists(root: string, name: string): boolean {
|
|
248
|
+
try {
|
|
249
|
+
return fs.statSync(path.join(root, name)).isDirectory();
|
|
250
|
+
} catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type { DependencyGraph, DependencyNode } from '../types/analysis.js';
|
|
4
|
+
import { extractImports, resolveImportPath } from './resolver.js';
|
|
5
|
+
|
|
6
|
+
const MAX_DEPTH = 4;
|
|
7
|
+
const MAX_FILES = 200;
|
|
8
|
+
|
|
9
|
+
const LANGUAGE_MAP: Record<string, string> = {
|
|
10
|
+
'.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.jsx': 'javascript',
|
|
11
|
+
'.ts': 'typescript', '.tsx': 'typescript',
|
|
12
|
+
'.py': 'python',
|
|
13
|
+
'.go': 'go',
|
|
14
|
+
'.rs': 'rust',
|
|
15
|
+
'.java': 'java',
|
|
16
|
+
'.rb': 'ruby',
|
|
17
|
+
'.php': 'php',
|
|
18
|
+
'.c': 'c',
|
|
19
|
+
'.cpp': 'cpp',
|
|
20
|
+
'.h': 'c',
|
|
21
|
+
'.hpp': 'cpp',
|
|
22
|
+
'.cs': 'csharp',
|
|
23
|
+
'.swift': 'swift',
|
|
24
|
+
'.kt': 'kotlin',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class DependencyGraphBuilder {
|
|
28
|
+
private nodes = new Map<string, DependencyNode>();
|
|
29
|
+
private visited = new Set<string>();
|
|
30
|
+
private projectRoot: string;
|
|
31
|
+
|
|
32
|
+
constructor(projectRoot: string) {
|
|
33
|
+
this.projectRoot = projectRoot;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
build(entryFiles: string[]): DependencyGraph {
|
|
37
|
+
const queue: Array<{ file: string; depth: number }> = entryFiles.map((f) => ({
|
|
38
|
+
file: path.resolve(this.projectRoot, f),
|
|
39
|
+
depth: 0,
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
while (queue.length > 0 && this.nodes.size < MAX_FILES) {
|
|
43
|
+
const item = queue.shift()!;
|
|
44
|
+
const { file, depth } = item;
|
|
45
|
+
|
|
46
|
+
if (this.visited.has(file) || depth > MAX_DEPTH) continue;
|
|
47
|
+
this.visited.add(file);
|
|
48
|
+
|
|
49
|
+
const relPath = path.relative(this.projectRoot, file);
|
|
50
|
+
const ext = path.extname(file);
|
|
51
|
+
const language = LANGUAGE_MAP[ext];
|
|
52
|
+
if (!language) continue;
|
|
53
|
+
|
|
54
|
+
let content: string;
|
|
55
|
+
try {
|
|
56
|
+
content = fs.readFileSync(file, 'utf-8');
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const imports = extractImports(content, language);
|
|
62
|
+
const resolvedImports: string[] = [];
|
|
63
|
+
|
|
64
|
+
for (const imp of imports) {
|
|
65
|
+
if (!imp.isLocal) continue;
|
|
66
|
+
|
|
67
|
+
const resolved = resolveImportPath(imp.specifier, file, language);
|
|
68
|
+
if (resolved) {
|
|
69
|
+
const resolvedRel = path.relative(this.projectRoot, resolved);
|
|
70
|
+
resolvedImports.push(resolvedRel);
|
|
71
|
+
|
|
72
|
+
// Ensure the target node exists
|
|
73
|
+
if (!this.nodes.has(resolvedRel)) {
|
|
74
|
+
this.nodes.set(resolvedRel, {
|
|
75
|
+
file: resolvedRel,
|
|
76
|
+
imports: [],
|
|
77
|
+
importedBy: [],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Add reverse edge
|
|
82
|
+
this.nodes.get(resolvedRel)!.importedBy.push(relPath);
|
|
83
|
+
|
|
84
|
+
// Queue for traversal
|
|
85
|
+
if (!this.visited.has(resolved)) {
|
|
86
|
+
queue.push({ file: resolved, depth: depth + 1 });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const existing = this.nodes.get(relPath);
|
|
92
|
+
if (existing) {
|
|
93
|
+
existing.imports = resolvedImports;
|
|
94
|
+
} else {
|
|
95
|
+
this.nodes.set(relPath, {
|
|
96
|
+
file: relPath,
|
|
97
|
+
imports: resolvedImports,
|
|
98
|
+
importedBy: [],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
nodes: this.nodes,
|
|
105
|
+
entryPoints: entryFiles.map((f) => path.relative(this.projectRoot, path.resolve(this.projectRoot, f))),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
getImporters(file: string): string[] {
|
|
110
|
+
return this.nodes.get(file)?.importedBy ?? [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
getImportees(file: string): string[] {
|
|
114
|
+
return this.nodes.get(file)?.imports ?? [];
|
|
115
|
+
}
|
|
116
|
+
}
|