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.
Files changed (126) hide show
  1. package/README.md +144 -43
  2. package/code-review-agent/.env.example +8 -0
  3. package/code-review-agent/README.md +142 -0
  4. package/code-review-agent/TODO.md +149 -0
  5. package/code-review-agent/bin/cr-agent.ts +313 -0
  6. package/code-review-agent/dist/bin/cr-agent.d.ts +3 -0
  7. package/code-review-agent/dist/bin/cr-agent.d.ts.map +1 -0
  8. package/code-review-agent/dist/bin/cr-agent.js +299 -0
  9. package/code-review-agent/dist/bin/cr-agent.js.map +1 -0
  10. package/code-review-agent/dist/src/analyzer/engine.d.ts +16 -0
  11. package/code-review-agent/dist/src/analyzer/engine.d.ts.map +1 -0
  12. package/code-review-agent/dist/src/analyzer/engine.js +298 -0
  13. package/code-review-agent/dist/src/analyzer/engine.js.map +1 -0
  14. package/code-review-agent/dist/src/analyzer/intent.d.ts +10 -0
  15. package/code-review-agent/dist/src/analyzer/intent.d.ts.map +1 -0
  16. package/code-review-agent/dist/src/analyzer/intent.js +40 -0
  17. package/code-review-agent/dist/src/analyzer/intent.js.map +1 -0
  18. package/code-review-agent/dist/src/analyzer/semantic.d.ts +19 -0
  19. package/code-review-agent/dist/src/analyzer/semantic.d.ts.map +1 -0
  20. package/code-review-agent/dist/src/analyzer/semantic.js +150 -0
  21. package/code-review-agent/dist/src/analyzer/semantic.js.map +1 -0
  22. package/code-review-agent/dist/src/context/assembler.d.ts +16 -0
  23. package/code-review-agent/dist/src/context/assembler.d.ts.map +1 -0
  24. package/code-review-agent/dist/src/context/assembler.js +135 -0
  25. package/code-review-agent/dist/src/context/assembler.js.map +1 -0
  26. package/code-review-agent/dist/src/context/file.d.ts +6 -0
  27. package/code-review-agent/dist/src/context/file.d.ts.map +1 -0
  28. package/code-review-agent/dist/src/context/file.js +139 -0
  29. package/code-review-agent/dist/src/context/file.js.map +1 -0
  30. package/code-review-agent/dist/src/context/project.d.ts +4 -0
  31. package/code-review-agent/dist/src/context/project.d.ts.map +1 -0
  32. package/code-review-agent/dist/src/context/project.js +252 -0
  33. package/code-review-agent/dist/src/context/project.js.map +1 -0
  34. package/code-review-agent/dist/src/graph/dependency.d.ts +11 -0
  35. package/code-review-agent/dist/src/graph/dependency.d.ts.map +1 -0
  36. package/code-review-agent/dist/src/graph/dependency.js +102 -0
  37. package/code-review-agent/dist/src/graph/dependency.js.map +1 -0
  38. package/code-review-agent/dist/src/graph/resolver.d.ts +9 -0
  39. package/code-review-agent/dist/src/graph/resolver.d.ts.map +1 -0
  40. package/code-review-agent/dist/src/graph/resolver.js +124 -0
  41. package/code-review-agent/dist/src/graph/resolver.js.map +1 -0
  42. package/code-review-agent/dist/src/index.d.ts +21 -0
  43. package/code-review-agent/dist/src/index.d.ts.map +1 -0
  44. package/code-review-agent/dist/src/index.js +21 -0
  45. package/code-review-agent/dist/src/index.js.map +1 -0
  46. package/code-review-agent/dist/src/llm/anthropic.d.ts +13 -0
  47. package/code-review-agent/dist/src/llm/anthropic.d.ts.map +1 -0
  48. package/code-review-agent/dist/src/llm/anthropic.js +83 -0
  49. package/code-review-agent/dist/src/llm/anthropic.js.map +1 -0
  50. package/code-review-agent/dist/src/llm/claude-cli.d.ts +13 -0
  51. package/code-review-agent/dist/src/llm/claude-cli.d.ts.map +1 -0
  52. package/code-review-agent/dist/src/llm/claude-cli.js +142 -0
  53. package/code-review-agent/dist/src/llm/claude-cli.js.map +1 -0
  54. package/code-review-agent/dist/src/llm/openai.d.ts +13 -0
  55. package/code-review-agent/dist/src/llm/openai.d.ts.map +1 -0
  56. package/code-review-agent/dist/src/llm/openai.js +78 -0
  57. package/code-review-agent/dist/src/llm/openai.js.map +1 -0
  58. package/code-review-agent/dist/src/llm/provider.d.ts +18 -0
  59. package/code-review-agent/dist/src/llm/provider.d.ts.map +1 -0
  60. package/code-review-agent/dist/src/llm/provider.js +11 -0
  61. package/code-review-agent/dist/src/llm/provider.js.map +1 -0
  62. package/code-review-agent/dist/src/llm/router.d.ts +14 -0
  63. package/code-review-agent/dist/src/llm/router.d.ts.map +1 -0
  64. package/code-review-agent/dist/src/llm/router.js +67 -0
  65. package/code-review-agent/dist/src/llm/router.js.map +1 -0
  66. package/code-review-agent/dist/src/llm/schemas.d.ts +18 -0
  67. package/code-review-agent/dist/src/llm/schemas.d.ts.map +1 -0
  68. package/code-review-agent/dist/src/llm/schemas.js +91 -0
  69. package/code-review-agent/dist/src/llm/schemas.js.map +1 -0
  70. package/code-review-agent/dist/src/types/analysis.d.ts +56 -0
  71. package/code-review-agent/dist/src/types/analysis.d.ts.map +1 -0
  72. package/code-review-agent/dist/src/types/analysis.js +2 -0
  73. package/code-review-agent/dist/src/types/analysis.js.map +1 -0
  74. package/code-review-agent/dist/src/types/config.d.ts +24 -0
  75. package/code-review-agent/dist/src/types/config.d.ts.map +1 -0
  76. package/code-review-agent/dist/src/types/config.js +42 -0
  77. package/code-review-agent/dist/src/types/config.js.map +1 -0
  78. package/code-review-agent/dist/src/types/findings.d.ts +236 -0
  79. package/code-review-agent/dist/src/types/findings.d.ts.map +1 -0
  80. package/code-review-agent/dist/src/types/findings.js +64 -0
  81. package/code-review-agent/dist/src/types/findings.js.map +1 -0
  82. package/code-review-agent/package.json +36 -0
  83. package/code-review-agent/src/analyzer/engine.ts +374 -0
  84. package/code-review-agent/src/analyzer/intent.ts +49 -0
  85. package/code-review-agent/src/analyzer/semantic.ts +222 -0
  86. package/code-review-agent/src/context/assembler.ts +165 -0
  87. package/code-review-agent/src/context/file.ts +145 -0
  88. package/code-review-agent/src/context/project.ts +253 -0
  89. package/code-review-agent/src/graph/dependency.ts +116 -0
  90. package/code-review-agent/src/graph/resolver.ts +138 -0
  91. package/code-review-agent/src/index.ts +58 -0
  92. package/code-review-agent/src/llm/anthropic.ts +106 -0
  93. package/code-review-agent/src/llm/claude-cli.ts +188 -0
  94. package/code-review-agent/src/llm/openai.ts +95 -0
  95. package/code-review-agent/src/llm/provider.ts +33 -0
  96. package/code-review-agent/src/llm/router.ts +86 -0
  97. package/code-review-agent/src/llm/schemas.ts +125 -0
  98. package/code-review-agent/src/types/analysis.ts +62 -0
  99. package/code-review-agent/src/types/config.ts +72 -0
  100. package/code-review-agent/src/types/findings.ts +81 -0
  101. package/code-review-agent/tests/analyzer/engine.test.ts +194 -0
  102. package/code-review-agent/tests/analyzer/intent.test.ts +76 -0
  103. package/code-review-agent/tests/analyzer/semantic.test.ts +131 -0
  104. package/code-review-agent/tests/context/file.test.ts +21 -0
  105. package/code-review-agent/tests/context/project.test.ts +20 -0
  106. package/code-review-agent/tests/fixtures/safe-build-tool/README.md +19 -0
  107. package/code-review-agent/tests/fixtures/safe-build-tool/builder.js +52 -0
  108. package/code-review-agent/tests/fixtures/safe-file-manager/README.md +16 -0
  109. package/code-review-agent/tests/fixtures/safe-file-manager/organizer.py +70 -0
  110. package/code-review-agent/tests/fixtures/vuln-api-server/README.md +17 -0
  111. package/code-review-agent/tests/fixtures/vuln-api-server/server.js +52 -0
  112. package/code-review-agent/tests/fixtures/vuln-ecommerce/README.md +18 -0
  113. package/code-review-agent/tests/fixtures/vuln-ecommerce/checkout.js +63 -0
  114. package/code-review-agent/tests/graph/dependency.test.ts +136 -0
  115. package/code-review-agent/tests/helpers/mock-provider.ts +48 -0
  116. package/code-review-agent/tests/llm/claude-cli.test.ts +251 -0
  117. package/code-review-agent/tests/llm/router.test.ts +77 -0
  118. package/code-review-agent/tests/llm/schemas.test.ts +142 -0
  119. package/code-review-agent/tsconfig.json +20 -0
  120. package/code-review-agent/vitest.config.ts +11 -0
  121. package/index.js +18 -18
  122. package/openclaw.plugin.json +2 -2
  123. package/package.json +13 -3
  124. package/server.json +3 -3
  125. package/src/cli/init-hooks.js +3 -3
  126. 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
+ }