kachow 0.1.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 +77 -0
- package/_server/dist/app.js +130 -0
- package/_server/dist/db/index.js +50 -0
- package/_server/dist/db/schema.js +247 -0
- package/_server/dist/queues/ingestQueue.js +49 -0
- package/_server/dist/queues/redis.js +58 -0
- package/_server/dist/routes/agents.js +162 -0
- package/_server/dist/routes/architecture.js +88 -0
- package/_server/dist/routes/config.js +24 -0
- package/_server/dist/routes/github.js +158 -0
- package/_server/dist/routes/graph.js +112 -0
- package/_server/dist/routes/healing.js +137 -0
- package/_server/dist/routes/impact.js +100 -0
- package/_server/dist/routes/ingest.js +182 -0
- package/_server/dist/routes/manager.js +179 -0
- package/_server/dist/routes/notifications.js +85 -0
- package/_server/dist/routes/qa.js +68 -0
- package/_server/dist/routes/scanner.js +221 -0
- package/_server/dist/routes/stream.js +179 -0
- package/_server/dist/routes/webhooks.js +168 -0
- package/_server/dist/server.js +46 -0
- package/_server/dist/services/agentService.js +715 -0
- package/_server/dist/services/architectureService.js +172 -0
- package/_server/dist/services/demoSeed.js +181 -0
- package/_server/dist/services/graphLayout.js +102 -0
- package/_server/dist/services/graphService.js +532 -0
- package/_server/dist/services/healingService.js +253 -0
- package/_server/dist/services/impactService.js +304 -0
- package/_server/dist/services/ingestService.js +129 -0
- package/_server/dist/services/managerService.js +260 -0
- package/_server/dist/services/notificationService.js +283 -0
- package/_server/dist/services/qaService.js +413 -0
- package/_server/dist/services/scannerService.js +748 -0
- package/_server/dist/services/seedService.js +215 -0
- package/_server/dist/sse/sseManager.js +101 -0
- package/_server/dist/types/index.js +38 -0
- package/_server/dist/workers/ingestWorker.js +274 -0
- package/_server/public/assets/index-BTkbB_YF.js +4546 -0
- package/_server/public/assets/index-Bmh3jWBm.css +1 -0
- package/_server/public/favicon.ico +0 -0
- package/_server/public/images/glass-waves-bg.png +0 -0
- package/_server/public/index.html +29 -0
- package/_server/public/placeholder.svg +1 -0
- package/_server/public/robots.txt +14 -0
- package/dist/config.js +133 -0
- package/dist/index.js +510 -0
- package/dist/setup.js +223 -0
- package/package.json +62 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* KA-CHOW Repository Scanner.
|
|
4
|
+
*
|
|
5
|
+
* Capabilities:
|
|
6
|
+
* \u2022 Monorepo awareness \u2014 discovers every service under services/, packages/, apps/, etc.
|
|
7
|
+
* \u2022 Multi-language \u2014 TypeScript, JavaScript, Python, Go, Java, Rust, Ruby, PHP
|
|
8
|
+
* \u2022 Infrastructure \u2014 Terraform resource detection, Docker Compose, Kubernetes
|
|
9
|
+
* \u2022 Health scoring \u2014 OpenAPI, README, tests, CI/CD, Docker, linting, secrets
|
|
10
|
+
* \u2022 Endpoint detection \u2014 Express, Fastify, Hapi, NestJS, Flask, FastAPI, Gin, Echo,
|
|
11
|
+
* Spring MVC, Actix
|
|
12
|
+
* \u2022 Dependency mapping \u2014 HTTP calls, env-var service refs, RabbitMQ, Kafka, gRPC, DB
|
|
13
|
+
* \u2022 Git metadata \u2014 repo URL, subpath, last-commit timestamp per service
|
|
14
|
+
*/
|
|
15
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
16
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
17
|
+
};
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.serviceIdFromName = serviceIdFromName;
|
|
20
|
+
exports.calculateHealthScore = calculateHealthScore;
|
|
21
|
+
exports.detectEndpoints = detectEndpoints;
|
|
22
|
+
exports.detectDependencies = detectDependencies;
|
|
23
|
+
exports.walkSourceFiles = walkSourceFiles;
|
|
24
|
+
exports.bestFileMatch = bestFileMatch;
|
|
25
|
+
exports.scanMonorepo = scanMonorepo;
|
|
26
|
+
exports.scanRepo = scanRepo;
|
|
27
|
+
const fs_1 = __importDefault(require("fs"));
|
|
28
|
+
const path_1 = __importDefault(require("path"));
|
|
29
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
30
|
+
const child_process_1 = require("child_process");
|
|
31
|
+
// \u2500\u2500 Constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
32
|
+
const SKIP_DIRS = new Set([
|
|
33
|
+
'node_modules', '.git', 'dist', 'build', '.next', '__pycache__',
|
|
34
|
+
'venv', '.venv', 'env', '.env', 'coverage', '.nyc_output', 'vendor',
|
|
35
|
+
'target', 'out', '.gradle', '.idea', '.vscode', 'bin', 'obj',
|
|
36
|
+
'terraform.tfstate.d', '.terraform',
|
|
37
|
+
]);
|
|
38
|
+
const SOURCE_EXTS = new Set([
|
|
39
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
40
|
+
'.py', '.go', '.java', '.kt', '.rs', '.rb', '.php',
|
|
41
|
+
]);
|
|
42
|
+
const SERVICE_MARKERS = [
|
|
43
|
+
'package.json', 'go.mod', 'Cargo.toml', 'pom.xml',
|
|
44
|
+
'build.gradle', 'build.gradle.kts', 'setup.py',
|
|
45
|
+
'pyproject.toml', 'requirements.txt', 'Gemfile', 'composer.json',
|
|
46
|
+
];
|
|
47
|
+
const WORKSPACE_CONTAINER_NAMES = new Set([
|
|
48
|
+
'services', 'packages', 'apps', 'microservices',
|
|
49
|
+
'modules', 'libs', 'src', 'cmd', 'internal',
|
|
50
|
+
'frontends', 'infrastructure', 'monitoring', 'orchestration',
|
|
51
|
+
'data', 'scripts', 'deploy', 'deployments',
|
|
52
|
+
]);
|
|
53
|
+
const MONOREPO_ROOT_MARKERS = [
|
|
54
|
+
'pnpm-workspace.yaml', 'lerna.json', 'nx.json', 'turbo.json', 'rush.json',
|
|
55
|
+
];
|
|
56
|
+
// \u2500\u2500 Regex patterns \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
57
|
+
const HTTP_CALL_RE = /(?:axios|got|request)\s*\.\s*(?:get|post|put|patch|delete)\s*\(\s*[`'""]([^`'"]+)[`'""]/g;
|
|
58
|
+
const FETCH_RE = /fetch\s*\(\s*[`'""]([^`'"\s]+)[`'""]/g;
|
|
59
|
+
const REQUESTS_RE = /requests\s*\.\s*(?:get|post|put|patch|delete)\s*\(\s*["']([^"\']+)["']/g;
|
|
60
|
+
const HTTPX_RE = /httpx\s*\.\s*(?:get|post|put|patch|delete)\s*\(\s*["']([^"\']+)["']/g;
|
|
61
|
+
const GO_HTTP_RE = /http\s*\.\s*(?:Get|Post|Put|Patch|Delete)\s*\(\s*"([^"]+)"/g;
|
|
62
|
+
const ENV_VAR_HTTP_RE = /(?:axios|got|request|fetch)\s*(?:\.\s*(?:get|post|put|patch|delete)\s*)?\(\s*`\s*\$\{process\.env\.([A-Za-z_]+)\}([^`]*)`/g;
|
|
63
|
+
const AMQP_LITERAL_RE = /channel\s*\.\s*(?:sendToQueue|publish|assertQueue|consume|bindQueue)\s*\(\s*[`'""]([^`'"]+)[`'""]/g;
|
|
64
|
+
const AMQP_VAR_RE = /channel\s*\.\s*(?:sendToQueue|publish|assertQueue|consume|bindQueue)\s*\(\s*(\w+)/g;
|
|
65
|
+
const AMQP_WRAPPER_RE = /(?:publish|send|enqueue)To(?:Queue|Topic|Exchange)\s*\(\s*[`'""]([^`'"]+)[`'""]/gi;
|
|
66
|
+
const KAFKA_PRODUCER_RE = /producer\.send\s*\(\s*\{\s*topic:\s*[`'""]([^`'"]+)[`'""]/g;
|
|
67
|
+
const KAFKA_CONSUMER_RE = /consumer\.subscribe\s*\(\s*\{\s*topics?:\s*[`'"\[[]([^`'"\]]+)[`'"]/g;
|
|
68
|
+
const NATS_RE = /nc\.publish\s*\(\s*[`'""]([^`'"]+)[`'""]/g;
|
|
69
|
+
const GRPC_RE = /loadPackageDefinition|\.proto[`'""]/g;
|
|
70
|
+
const DB_URL_RE = /DATABASE_URL|Prisma|Sequelize|createConnection|mongoose\.connect|psycopg2|asyncpg|sqlalchemy|GORM|diesel/g;
|
|
71
|
+
const EXPRESS_RE = /(?:router|app|fastify|server)\s*\.\s*(get|post|put|patch|delete|all|route)\s*\(\s*[`'""]([^`'"]+)[`'""]/gi;
|
|
72
|
+
const NESTJS_RE = /@(Get|Post|Put|Patch|Delete|Options|Head|All)\s*\(\s*(?:[`'""]([^`'"]*)[`'"])?\s*\)/gi;
|
|
73
|
+
const FLASK_RE = /@(?:app|blueprint|bp|\w+_bp)\s*\.route\s*\(\s*[`'""]([^`'"]+)[`'""]/gi;
|
|
74
|
+
const FASTAPI_RE = /@(?:app|router|api_router)\s*\.\s*(get|post|put|patch|delete|options|head)\s*\(\s*[`'""]([^`'"]+)[`'""]/gi;
|
|
75
|
+
const GIN_RE = /(?:r|router|e|mux|v\d|api)\s*\.\s*(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s*\(\s*"([^"]+)"/g;
|
|
76
|
+
const SPRING_RE = /@(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping|RequestMapping)\s*\(\s*(?:value\s*=\s*)?[`'""]([^`'"]+)[`'""]/gi;
|
|
77
|
+
const ACTIX_RE = /#\[\s*(get|post|put|patch|delete|options|head)\s*\(\s*"([^"]+)"\s*\)\s*\]/gi;
|
|
78
|
+
const HAPI_METHOD_RE = /method\s*:\s*[`'""]([A-Z]+)[`'""]/g;
|
|
79
|
+
const HAPI_PATH_RE = /path\s*:\s*[`'""]([^`'"]+)[`'""]/g;
|
|
80
|
+
// \u2500\u2500 Utility helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
81
|
+
function walkDir(dir, maxDepth = 12, _depth = 0) {
|
|
82
|
+
if (_depth > maxDepth)
|
|
83
|
+
return [];
|
|
84
|
+
const results = [];
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return results;
|
|
91
|
+
}
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
if (SKIP_DIRS.has(entry.name))
|
|
94
|
+
continue;
|
|
95
|
+
const full = path_1.default.join(dir, entry.name);
|
|
96
|
+
if (entry.isDirectory()) {
|
|
97
|
+
results.push(...walkDir(full, maxDepth, _depth + 1));
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
results.push(full);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return results;
|
|
104
|
+
}
|
|
105
|
+
function readSafe(filePath) {
|
|
106
|
+
try {
|
|
107
|
+
return fs_1.default.readFileSync(filePath, 'utf8');
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return '';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function serviceIdFromName(name) {
|
|
114
|
+
return crypto_1.default.createHash('sha256').update(name).digest('hex').slice(0, 12);
|
|
115
|
+
}
|
|
116
|
+
function extractHostname(rawUrl) {
|
|
117
|
+
const cleaned = rawUrl.replace(/\$\{[^}]+\}/g, 'placeholder');
|
|
118
|
+
try {
|
|
119
|
+
return new URL(cleaned).hostname;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return rawUrl.split('/')[0] ?? rawUrl;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function detectGitInfo(dir) {
|
|
126
|
+
try {
|
|
127
|
+
const gitRoot = fs_1.default.realpathSync((0, child_process_1.execSync)('git rev-parse --show-toplevel', { cwd: dir, stdio: 'pipe' }).toString().trim());
|
|
128
|
+
let repoUrl = null;
|
|
129
|
+
try {
|
|
130
|
+
repoUrl = (0, child_process_1.execSync)('git remote get-url origin', { cwd: dir, stdio: 'pipe' })
|
|
131
|
+
.toString().trim()
|
|
132
|
+
.replace(/\.git$/, '')
|
|
133
|
+
.replace(/^git@([^:]+):(.+)$/, 'https://$1/$2');
|
|
134
|
+
}
|
|
135
|
+
catch { /* no remote */ }
|
|
136
|
+
return { gitRoot, repoUrl };
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return { gitRoot: null, repoUrl: null };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function detectLastCommit(dir) {
|
|
143
|
+
try {
|
|
144
|
+
return (0, child_process_1.execSync)('git log -1 --format="%ci" -- .', { cwd: dir, stdio: 'pipe' }).toString().trim() || null;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// \u2500\u2500 Language detection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
151
|
+
function detectLanguage(allFiles) {
|
|
152
|
+
const exts = new Set(allFiles.map((f) => path_1.default.extname(f)));
|
|
153
|
+
const bases = new Set(allFiles.map((f) => path_1.default.basename(f)));
|
|
154
|
+
if (bases.has('tsconfig.json') || exts.has('.ts') || exts.has('.tsx'))
|
|
155
|
+
return 'typescript';
|
|
156
|
+
if (bases.has('package.json') || exts.has('.js') || exts.has('.jsx'))
|
|
157
|
+
return 'javascript';
|
|
158
|
+
if (bases.has('go.mod') || exts.has('.go'))
|
|
159
|
+
return 'go';
|
|
160
|
+
if (bases.has('Cargo.toml') || exts.has('.rs'))
|
|
161
|
+
return 'rust';
|
|
162
|
+
if (bases.has('pom.xml') || bases.has('build.gradle') || exts.has('.java') || exts.has('.kt'))
|
|
163
|
+
return 'java';
|
|
164
|
+
if (exts.has('.py') || bases.has('setup.py') || bases.has('pyproject.toml') || bases.has('requirements.txt'))
|
|
165
|
+
return 'python';
|
|
166
|
+
if (bases.has('Gemfile') || exts.has('.rb'))
|
|
167
|
+
return 'ruby';
|
|
168
|
+
if (bases.has('composer.json') || exts.has('.php'))
|
|
169
|
+
return 'php';
|
|
170
|
+
return 'unknown';
|
|
171
|
+
}
|
|
172
|
+
// \u2500\u2500 Health score \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
173
|
+
function calculateHealthScore(serviceDir, allFiles, repoRoot) {
|
|
174
|
+
// For CI/CD and Docker checks, also consider repo-root files
|
|
175
|
+
const rootFiles = repoRoot && repoRoot !== serviceDir
|
|
176
|
+
? walkDir(repoRoot, 3).filter((f) => {
|
|
177
|
+
const rel = path_1.default.relative(repoRoot, f);
|
|
178
|
+
return rel.split(path_1.default.sep).length <= 3; // max 3 levels from root
|
|
179
|
+
})
|
|
180
|
+
: [];
|
|
181
|
+
const issues = [];
|
|
182
|
+
let score = 0;
|
|
183
|
+
// 20 pts \u2014 OpenAPI/Swagger spec
|
|
184
|
+
const hasOpenApi = allFiles.some((f) => {
|
|
185
|
+
const b = path_1.default.basename(f).toLowerCase();
|
|
186
|
+
return b.includes('openapi') || b.includes('swagger') || b === 'api.yaml' || b === 'api.json';
|
|
187
|
+
});
|
|
188
|
+
if (hasOpenApi) {
|
|
189
|
+
score += 20;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
issues.push('Missing OpenAPI/Swagger spec');
|
|
193
|
+
}
|
|
194
|
+
// 15 pts \u2014 README >= 300 chars
|
|
195
|
+
const readmeContent = readSafe(path_1.default.join(serviceDir, 'README.md'));
|
|
196
|
+
const hasReadme = readmeContent.length >= 300;
|
|
197
|
+
if (hasReadme) {
|
|
198
|
+
score += 15;
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
issues.push(readmeContent.length === 0 ? 'Missing README.md' : 'README.md too short (<300 chars)');
|
|
202
|
+
}
|
|
203
|
+
const docCoverage = hasOpenApi && hasReadme ? 100 : (hasOpenApi || hasReadme) ? 50 : 0;
|
|
204
|
+
// 15 pts \u2014 test coverage
|
|
205
|
+
let testCoverage = 0;
|
|
206
|
+
let coverageFound = false;
|
|
207
|
+
const coveragePaths = [
|
|
208
|
+
path_1.default.join(serviceDir, 'coverage', 'coverage-summary.json'),
|
|
209
|
+
path_1.default.join(serviceDir, 'coverage', 'coverage-final.json'),
|
|
210
|
+
];
|
|
211
|
+
for (const cp of coveragePaths) {
|
|
212
|
+
const raw = readSafe(cp);
|
|
213
|
+
if (!raw)
|
|
214
|
+
continue;
|
|
215
|
+
try {
|
|
216
|
+
const p = JSON.parse(raw);
|
|
217
|
+
const pct = p['total']?.lines?.pct;
|
|
218
|
+
if (pct !== undefined) {
|
|
219
|
+
testCoverage = pct;
|
|
220
|
+
coverageFound = true;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch { /* skip */ }
|
|
225
|
+
}
|
|
226
|
+
const hasTestDir = allFiles.some((f) => {
|
|
227
|
+
const rel = path_1.default.relative(serviceDir, f);
|
|
228
|
+
return /^(test|tests|__tests__|spec)[\\/]/.test(rel) || /\.(test|spec)\.[jt]sx?$/.test(f);
|
|
229
|
+
});
|
|
230
|
+
if (coverageFound) {
|
|
231
|
+
if (testCoverage >= 70) {
|
|
232
|
+
score += 15;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
issues.push(`Test coverage ${testCoverage.toFixed(1)}% (need >=70%)`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
else if (hasTestDir) {
|
|
239
|
+
score += 8;
|
|
240
|
+
testCoverage = 50;
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
issues.push('No tests detected');
|
|
244
|
+
}
|
|
245
|
+
// 10 pts \u2014 no @deprecated markers
|
|
246
|
+
const srcFiles = allFiles.filter((f) => SOURCE_EXTS.has(path_1.default.extname(f)));
|
|
247
|
+
const hasDeprecated = srcFiles.some((f) => /@deprecated/.test(readSafe(f)));
|
|
248
|
+
if (!hasDeprecated) {
|
|
249
|
+
score += 10;
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
issues.push('Deprecated endpoint markers found');
|
|
253
|
+
}
|
|
254
|
+
// 10 pts \u2014 CI/CD
|
|
255
|
+
const ciCdCheck = (f, base) => {
|
|
256
|
+
const rel = path_1.default.relative(base, f);
|
|
257
|
+
return /^\.github[\\/]workflows[\\/].+\.ya?ml$/.test(rel) ||
|
|
258
|
+
path_1.default.basename(f) === '.gitlab-ci.yml' ||
|
|
259
|
+
path_1.default.basename(f) === 'Jenkinsfile' ||
|
|
260
|
+
path_1.default.basename(f) === '.travis.yml' ||
|
|
261
|
+
/^\.circleci[\\/]config\.ya?ml$/.test(rel) ||
|
|
262
|
+
path_1.default.basename(f) === 'azure-pipelines.yml';
|
|
263
|
+
};
|
|
264
|
+
const hasCiCd = allFiles.some((f) => ciCdCheck(f, serviceDir)) ||
|
|
265
|
+
rootFiles.some((f) => ciCdCheck(f, repoRoot));
|
|
266
|
+
if (hasCiCd) {
|
|
267
|
+
score += 10;
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
issues.push('No CI/CD configuration detected');
|
|
271
|
+
}
|
|
272
|
+
// 10 pts \u2014 Dockerfile / docker-compose
|
|
273
|
+
const dockerCheck = (f) => {
|
|
274
|
+
const b = path_1.default.basename(f);
|
|
275
|
+
return b === 'Dockerfile' || b.startsWith('Dockerfile.') ||
|
|
276
|
+
b === 'docker-compose.yml' || b === 'docker-compose.yaml';
|
|
277
|
+
};
|
|
278
|
+
const hasDocker = allFiles.some(dockerCheck) || rootFiles.some(dockerCheck);
|
|
279
|
+
if (hasDocker) {
|
|
280
|
+
score += 10;
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
issues.push('No Dockerfile or docker-compose found');
|
|
284
|
+
}
|
|
285
|
+
// 10 pts \u2014 error handling on external calls
|
|
286
|
+
const bareCallFiles = srcFiles.filter((f) => {
|
|
287
|
+
const c = readSafe(f);
|
|
288
|
+
return /^\s*(?:await\s+)?(?:fetch|axios)\s*\(/m.test(c) && !/try\s*\{/.test(c);
|
|
289
|
+
});
|
|
290
|
+
if (bareCallFiles.length === 0) {
|
|
291
|
+
score += 10;
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
issues.push(`${bareCallFiles.length} file(s) with unprotected external calls`);
|
|
295
|
+
}
|
|
296
|
+
// 5 pts \u2014 linting config
|
|
297
|
+
const hasLint = allFiles.some((f) => {
|
|
298
|
+
const b = path_1.default.basename(f);
|
|
299
|
+
return [
|
|
300
|
+
'.eslintrc.js', '.eslintrc.json', '.eslintrc.yml',
|
|
301
|
+
'eslint.config.js', 'eslint.config.mjs',
|
|
302
|
+
'.pylintrc', 'golangci.yml', '.golangci.yml',
|
|
303
|
+
'.rubocop.yml', 'phpcs.xml',
|
|
304
|
+
].includes(b);
|
|
305
|
+
});
|
|
306
|
+
if (hasLint) {
|
|
307
|
+
score += 5;
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
issues.push('No linter config found');
|
|
311
|
+
}
|
|
312
|
+
// 5 pts \u2014 no hardcoded secrets
|
|
313
|
+
const secretPattern = /(?:password|secret|api_key|apikey|token)\s*=\s*[""][^""]{6,}/i;
|
|
314
|
+
const hasSecrets = srcFiles.some((f) => secretPattern.test(readSafe(f)));
|
|
315
|
+
if (!hasSecrets) {
|
|
316
|
+
score += 5;
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
issues.push('Possible hardcoded secrets detected');
|
|
320
|
+
}
|
|
321
|
+
return { score: Math.min(100, Math.max(0, score)), docCoverage, testCoverage: Math.round(testCoverage), issues };
|
|
322
|
+
}
|
|
323
|
+
// \u2500\u2500 Endpoint detection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
324
|
+
function detectEndpoints(allFiles) {
|
|
325
|
+
const endpoints = [];
|
|
326
|
+
const srcFiles = allFiles.filter((f) => SOURCE_EXTS.has(path_1.default.extname(f)));
|
|
327
|
+
for (const filePath of srcFiles) {
|
|
328
|
+
const content = readSafe(filePath);
|
|
329
|
+
if (!content)
|
|
330
|
+
continue;
|
|
331
|
+
let m;
|
|
332
|
+
EXPRESS_RE.lastIndex = 0;
|
|
333
|
+
while ((m = EXPRESS_RE.exec(content)) !== null) {
|
|
334
|
+
endpoints.push({ method: m[1].toUpperCase(), path: m[2], deprecated: /@deprecated/.test(content), hasOpenApiSpec: false });
|
|
335
|
+
}
|
|
336
|
+
NESTJS_RE.lastIndex = 0;
|
|
337
|
+
while ((m = NESTJS_RE.exec(content)) !== null) {
|
|
338
|
+
endpoints.push({ method: m[1].toUpperCase(), path: m[2] || '/', deprecated: false, hasOpenApiSpec: false });
|
|
339
|
+
}
|
|
340
|
+
FLASK_RE.lastIndex = 0;
|
|
341
|
+
while ((m = FLASK_RE.exec(content)) !== null) {
|
|
342
|
+
endpoints.push({ method: 'GET', path: m[1], deprecated: false, hasOpenApiSpec: false });
|
|
343
|
+
}
|
|
344
|
+
FASTAPI_RE.lastIndex = 0;
|
|
345
|
+
while ((m = FASTAPI_RE.exec(content)) !== null) {
|
|
346
|
+
endpoints.push({ method: m[1].toUpperCase(), path: m[2], deprecated: false, hasOpenApiSpec: false });
|
|
347
|
+
}
|
|
348
|
+
GIN_RE.lastIndex = 0;
|
|
349
|
+
while ((m = GIN_RE.exec(content)) !== null) {
|
|
350
|
+
endpoints.push({ method: m[1].toUpperCase(), path: m[2], deprecated: false, hasOpenApiSpec: false });
|
|
351
|
+
}
|
|
352
|
+
SPRING_RE.lastIndex = 0;
|
|
353
|
+
while ((m = SPRING_RE.exec(content)) !== null) {
|
|
354
|
+
const verb = m[1].replace('Mapping', '').replace('Request', 'GET');
|
|
355
|
+
endpoints.push({ method: verb.toUpperCase(), path: m[2], deprecated: false, hasOpenApiSpec: false });
|
|
356
|
+
}
|
|
357
|
+
ACTIX_RE.lastIndex = 0;
|
|
358
|
+
while ((m = ACTIX_RE.exec(content)) !== null) {
|
|
359
|
+
endpoints.push({ method: m[1].toUpperCase(), path: m[2], deprecated: false, hasOpenApiSpec: false });
|
|
360
|
+
}
|
|
361
|
+
// Hapi route blocks
|
|
362
|
+
const routeBlocks = content.matchAll(/server\s*\.\s*route\s*\(\s*\{([^}]+)\}/gs);
|
|
363
|
+
for (const block of routeBlocks) {
|
|
364
|
+
const body = block[1];
|
|
365
|
+
HAPI_METHOD_RE.lastIndex = 0;
|
|
366
|
+
HAPI_PATH_RE.lastIndex = 0;
|
|
367
|
+
const methodM = HAPI_METHOD_RE.exec(body);
|
|
368
|
+
const pathM = HAPI_PATH_RE.exec(body);
|
|
369
|
+
if (methodM && pathM) {
|
|
370
|
+
endpoints.push({ method: methodM[1].toUpperCase(), path: pathM[1], deprecated: false, hasOpenApiSpec: false });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const seen = new Set();
|
|
375
|
+
return endpoints.filter((ep) => {
|
|
376
|
+
const key = `${ep.method}:${ep.path}`;
|
|
377
|
+
if (seen.has(key))
|
|
378
|
+
return false;
|
|
379
|
+
seen.add(key);
|
|
380
|
+
return true;
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
// \u2500\u2500 Dependency detection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
384
|
+
function detectDependencies(_serviceDir, serviceId, allFiles) {
|
|
385
|
+
const deps = [];
|
|
386
|
+
const srcFiles = allFiles.filter((f) => SOURCE_EXTS.has(path_1.default.extname(f)));
|
|
387
|
+
for (const filePath of srcFiles) {
|
|
388
|
+
const content = readSafe(filePath);
|
|
389
|
+
if (!content)
|
|
390
|
+
continue;
|
|
391
|
+
let m;
|
|
392
|
+
// HTTP calls JS/TS
|
|
393
|
+
for (const re of [HTTP_CALL_RE, FETCH_RE]) {
|
|
394
|
+
re.lastIndex = 0;
|
|
395
|
+
while ((m = re.exec(content)) !== null) {
|
|
396
|
+
const rawUrl = m[1];
|
|
397
|
+
if (!rawUrl || rawUrl.startsWith('/'))
|
|
398
|
+
continue;
|
|
399
|
+
deps.push({ sourceId: serviceId, targetName: extractHostname(rawUrl), targetUrl: rawUrl, type: 'REST', endpoints: [rawUrl] });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// HTTP calls Python
|
|
403
|
+
for (const re of [REQUESTS_RE, HTTPX_RE]) {
|
|
404
|
+
re.lastIndex = 0;
|
|
405
|
+
while ((m = re.exec(content)) !== null) {
|
|
406
|
+
const rawUrl = m[1];
|
|
407
|
+
if (!rawUrl || rawUrl.startsWith('/'))
|
|
408
|
+
continue;
|
|
409
|
+
deps.push({ sourceId: serviceId, targetName: extractHostname(rawUrl), targetUrl: rawUrl, type: 'REST', endpoints: [rawUrl] });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// HTTP calls Go
|
|
413
|
+
GO_HTTP_RE.lastIndex = 0;
|
|
414
|
+
while ((m = GO_HTTP_RE.exec(content)) !== null) {
|
|
415
|
+
const rawUrl = m[1];
|
|
416
|
+
if (!rawUrl || rawUrl.startsWith('/'))
|
|
417
|
+
continue;
|
|
418
|
+
deps.push({ sourceId: serviceId, targetName: extractHostname(rawUrl), targetUrl: rawUrl, type: 'REST', endpoints: [rawUrl] });
|
|
419
|
+
}
|
|
420
|
+
// Env-var HTTP
|
|
421
|
+
ENV_VAR_HTTP_RE.lastIndex = 0;
|
|
422
|
+
while ((m = ENV_VAR_HTTP_RE.exec(content)) !== null) {
|
|
423
|
+
const envVarName = m[1];
|
|
424
|
+
const pathPart = (m[2] ?? '').split(/\$\{/)[0].split('?')[0];
|
|
425
|
+
deps.push({ sourceId: serviceId, targetName: `env:${envVarName}`, targetUrl: `env://${envVarName}${pathPart}`, type: 'REST', endpoints: [pathPart || '/'] });
|
|
426
|
+
}
|
|
427
|
+
// Build var map for queue resolution
|
|
428
|
+
const varAssigns = new Map();
|
|
429
|
+
const VAR_RE = /(?:const|let|var)\s+(\w+)\s*=\s*["'`]([^"'`\n]{2,80})["'`]/g;
|
|
430
|
+
VAR_RE.lastIndex = 0;
|
|
431
|
+
while ((m = VAR_RE.exec(content)) !== null)
|
|
432
|
+
varAssigns.set(m[1], m[2]);
|
|
433
|
+
// RabbitMQ
|
|
434
|
+
AMQP_LITERAL_RE.lastIndex = 0;
|
|
435
|
+
while ((m = AMQP_LITERAL_RE.exec(content)) !== null) {
|
|
436
|
+
if (m[1] && m[1].length > 1)
|
|
437
|
+
deps.push({ sourceId: serviceId, targetName: `amqp:${m[1]}`, targetUrl: `amqp://${m[1]}`, type: 'Event', endpoints: [m[1]] });
|
|
438
|
+
}
|
|
439
|
+
AMQP_VAR_RE.lastIndex = 0;
|
|
440
|
+
while ((m = AMQP_VAR_RE.exec(content)) !== null) {
|
|
441
|
+
const q = varAssigns.get(m[1]);
|
|
442
|
+
if (q && q.length > 1)
|
|
443
|
+
deps.push({ sourceId: serviceId, targetName: `amqp:${q}`, targetUrl: `amqp://${q}`, type: 'Event', endpoints: [q] });
|
|
444
|
+
}
|
|
445
|
+
AMQP_WRAPPER_RE.lastIndex = 0;
|
|
446
|
+
while ((m = AMQP_WRAPPER_RE.exec(content)) !== null) {
|
|
447
|
+
if (m[1] && m[1].length > 1)
|
|
448
|
+
deps.push({ sourceId: serviceId, targetName: `amqp:${m[1]}`, targetUrl: `amqp://${m[1]}`, type: 'Event', endpoints: [m[1]] });
|
|
449
|
+
}
|
|
450
|
+
// Kafka
|
|
451
|
+
KAFKA_PRODUCER_RE.lastIndex = 0;
|
|
452
|
+
while ((m = KAFKA_PRODUCER_RE.exec(content)) !== null) {
|
|
453
|
+
deps.push({ sourceId: serviceId, targetName: `kafka:${m[1]}`, targetUrl: `kafka://${m[1]}`, type: 'Event', endpoints: [m[1]] });
|
|
454
|
+
}
|
|
455
|
+
KAFKA_CONSUMER_RE.lastIndex = 0;
|
|
456
|
+
while ((m = KAFKA_CONSUMER_RE.exec(content)) !== null) {
|
|
457
|
+
deps.push({ sourceId: serviceId, targetName: `kafka:${m[1]}`, targetUrl: `kafka://${m[1]}`, type: 'Event', endpoints: [m[1]] });
|
|
458
|
+
}
|
|
459
|
+
// NATS
|
|
460
|
+
NATS_RE.lastIndex = 0;
|
|
461
|
+
while ((m = NATS_RE.exec(content)) !== null) {
|
|
462
|
+
deps.push({ sourceId: serviceId, targetName: `nats:${m[1]}`, targetUrl: `nats://${m[1]}`, type: 'Event', endpoints: [m[1]] });
|
|
463
|
+
}
|
|
464
|
+
// gRPC
|
|
465
|
+
GRPC_RE.lastIndex = 0;
|
|
466
|
+
if (GRPC_RE.test(content)) {
|
|
467
|
+
deps.push({ sourceId: serviceId, targetName: 'grpc-target', targetUrl: 'grpc://unknown', type: 'gRPC', endpoints: [] });
|
|
468
|
+
}
|
|
469
|
+
// DB
|
|
470
|
+
DB_URL_RE.lastIndex = 0;
|
|
471
|
+
if (DB_URL_RE.test(content)) {
|
|
472
|
+
deps.push({ sourceId: serviceId, targetName: 'database', targetUrl: 'db://internal', type: 'DB', endpoints: [] });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const seen = new Set();
|
|
476
|
+
return deps.filter((d) => {
|
|
477
|
+
const key = `${d.targetName}:${d.type}`;
|
|
478
|
+
if (seen.has(key))
|
|
479
|
+
return false;
|
|
480
|
+
seen.add(key);
|
|
481
|
+
return true;
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
function findServiceRoots(repoPath) {
|
|
485
|
+
const roots = [];
|
|
486
|
+
_findRec(repoPath, roots, 0, 5);
|
|
487
|
+
return roots;
|
|
488
|
+
}
|
|
489
|
+
function _findRec(dir, results, depth, maxDepth) {
|
|
490
|
+
if (depth > maxDepth)
|
|
491
|
+
return;
|
|
492
|
+
let entries;
|
|
493
|
+
try {
|
|
494
|
+
entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const fileNames = new Set(entries.filter((e) => e.isFile()).map((e) => e.name));
|
|
500
|
+
const isMonorepoRoot = MONOREPO_ROOT_MARKERS.some((m) => fileNames.has(m)) ||
|
|
501
|
+
(fileNames.has('package.json') && _isMonorepoPackageJson(path_1.default.join(dir, 'package.json')));
|
|
502
|
+
const markerFile = SERVICE_MARKERS.find((m) => fileNames.has(m));
|
|
503
|
+
if (markerFile && !isMonorepoRoot) {
|
|
504
|
+
results.push({ dir, name: _extractServiceName(dir, markerFile), markerFile });
|
|
505
|
+
return; // don't recurse further into a service
|
|
506
|
+
}
|
|
507
|
+
// Implicit service: directory under a workspace container that has source files
|
|
508
|
+
// but no explicit marker (e.g. services/evaluator with only runner.py)
|
|
509
|
+
const parentIsContainer = WORKSPACE_CONTAINER_NAMES.has(path_1.default.basename(path_1.default.dirname(dir)));
|
|
510
|
+
if (!markerFile && parentIsContainer && depth >= 2) {
|
|
511
|
+
const dirEntryFiles = entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
512
|
+
const hasSourceFiles = dirEntryFiles.some((f) => SOURCE_EXTS.has(path_1.default.extname(f)) ||
|
|
513
|
+
f === 'Makefile' || f === 'Dockerfile');
|
|
514
|
+
if (hasSourceFiles) {
|
|
515
|
+
results.push({ dir, name: path_1.default.basename(dir), markerFile: '' });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const isWorkspaceContainer = WORKSPACE_CONTAINER_NAMES.has(path_1.default.basename(dir));
|
|
520
|
+
for (const entry of entries) {
|
|
521
|
+
if (!entry.isDirectory() || SKIP_DIRS.has(entry.name))
|
|
522
|
+
continue;
|
|
523
|
+
if (depth === 0 || isMonorepoRoot || isWorkspaceContainer ||
|
|
524
|
+
WORKSPACE_CONTAINER_NAMES.has(entry.name)) {
|
|
525
|
+
_findRec(path_1.default.join(dir, entry.name), results, depth + 1, maxDepth);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function _isMonorepoPackageJson(pkgPath) {
|
|
530
|
+
try {
|
|
531
|
+
const pkg = JSON.parse(readSafe(pkgPath));
|
|
532
|
+
// Must have explicit workspaces field — `private: true` alone is NOT enough
|
|
533
|
+
// (many child packages in a monorepo are also marked private)
|
|
534
|
+
return !!(pkg.workspaces);
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
function _extractServiceName(dir, markerFile) {
|
|
541
|
+
const full = path_1.default.join(dir, markerFile);
|
|
542
|
+
if (markerFile === 'package.json') {
|
|
543
|
+
try {
|
|
544
|
+
const pkg = JSON.parse(readSafe(full));
|
|
545
|
+
if (pkg.name && pkg.name !== 'root' && !pkg.name.includes('workspace')) {
|
|
546
|
+
return pkg.name.replace(/^@[^\/]+\//, '');
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
catch { /* fall through */ }
|
|
550
|
+
}
|
|
551
|
+
if (markerFile === 'go.mod') {
|
|
552
|
+
const firstLine = readSafe(full).split('\n')[0] ?? '';
|
|
553
|
+
const mod = firstLine.replace(/^module\s+/, '').trim();
|
|
554
|
+
if (mod)
|
|
555
|
+
return path_1.default.basename(mod);
|
|
556
|
+
}
|
|
557
|
+
if (markerFile === 'Cargo.toml') {
|
|
558
|
+
const m = readSafe(full).match(/^name\s*=\s*"([^"]+)"/m);
|
|
559
|
+
if (m)
|
|
560
|
+
return m[1];
|
|
561
|
+
}
|
|
562
|
+
if (markerFile === 'pom.xml') {
|
|
563
|
+
const m = readSafe(full).match(/<artifactId>([^<]+)<\/artifactId>/);
|
|
564
|
+
if (m)
|
|
565
|
+
return m[1];
|
|
566
|
+
}
|
|
567
|
+
if (markerFile === 'pyproject.toml') {
|
|
568
|
+
const m = readSafe(full).match(/^name\s*=\s*["'\']([^"'\']+)["'\']/m);
|
|
569
|
+
if (m)
|
|
570
|
+
return m[1];
|
|
571
|
+
}
|
|
572
|
+
return path_1.default.basename(dir);
|
|
573
|
+
}
|
|
574
|
+
function detectInfrastructure(repoPath, repoUrl, gitRoot) {
|
|
575
|
+
const services = [];
|
|
576
|
+
const dependencies = [];
|
|
577
|
+
const allFiles = walkDir(repoPath, 8);
|
|
578
|
+
// Terraform
|
|
579
|
+
const tfFiles = allFiles.filter((f) => path_1.default.extname(f) === '.tf');
|
|
580
|
+
if (tfFiles.length > 0) {
|
|
581
|
+
const providers = new Set();
|
|
582
|
+
const resources = new Set();
|
|
583
|
+
for (const tf of tfFiles) {
|
|
584
|
+
const content = readSafe(tf);
|
|
585
|
+
for (const m of content.matchAll(/^provider\s+"([^"]+)"/gm))
|
|
586
|
+
providers.add(m[1]);
|
|
587
|
+
for (const m of content.matchAll(/^resource\s+"([^"]+)"\s+"([^"]+)"/gm))
|
|
588
|
+
resources.add(m[1]);
|
|
589
|
+
}
|
|
590
|
+
const providerLabel = providers.size ? [...providers].join('+') : 'terraform';
|
|
591
|
+
const infraId = serviceIdFromName(`infra:terraform:${repoPath}`);
|
|
592
|
+
const subpath = gitRoot ? path_1.default.relative(gitRoot, repoPath) : null;
|
|
593
|
+
services.push({
|
|
594
|
+
id: infraId, name: `${providerLabel}-infra`,
|
|
595
|
+
repoPath, repoUrl, repoSubpath: subpath,
|
|
596
|
+
team: null, language: 'infra',
|
|
597
|
+
healthScore: resources.size > 0 ? 85 : 40,
|
|
598
|
+
docCoverage: 50, testCoverage: 0, lastCommit: null,
|
|
599
|
+
endpoints: [],
|
|
600
|
+
rawIssues: resources.size === 0 ? ['No Terraform resources found'] : [],
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
// CI/CD workflows (GitHub Actions, GitLab CI, etc.)
|
|
604
|
+
const ciFiles = allFiles.filter((f) => {
|
|
605
|
+
const rel = path_1.default.relative(repoPath, f);
|
|
606
|
+
return /^\.github[\\/]workflows[\\/].+\.ya?ml$/.test(rel) ||
|
|
607
|
+
path_1.default.basename(f) === '.gitlab-ci.yml' ||
|
|
608
|
+
path_1.default.basename(f) === 'Jenkinsfile' ||
|
|
609
|
+
path_1.default.basename(f) === '.travis.yml' ||
|
|
610
|
+
/^\.circleci[\\/]config\.ya?ml$/.test(rel) ||
|
|
611
|
+
path_1.default.basename(f) === 'azure-pipelines.yml';
|
|
612
|
+
});
|
|
613
|
+
if (ciFiles.length > 0) {
|
|
614
|
+
const ciId = serviceIdFromName(`infra:ci-cd:${repoPath}`);
|
|
615
|
+
const subpath = gitRoot ? path_1.default.relative(gitRoot, repoPath) : null;
|
|
616
|
+
services.push({
|
|
617
|
+
id: ciId, name: 'ci-cd-pipelines',
|
|
618
|
+
repoPath, repoUrl, repoSubpath: subpath,
|
|
619
|
+
team: null, language: 'infra',
|
|
620
|
+
healthScore: 80,
|
|
621
|
+
docCoverage: 30, testCoverage: 0, lastCommit: null,
|
|
622
|
+
endpoints: ciFiles.map((f) => ({
|
|
623
|
+
method: 'WORKFLOW',
|
|
624
|
+
path: path_1.default.relative(repoPath, f),
|
|
625
|
+
deprecated: false,
|
|
626
|
+
hasOpenApiSpec: false,
|
|
627
|
+
})),
|
|
628
|
+
rawIssues: [],
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
// Docker Compose (repo-level)
|
|
632
|
+
const composeFiles = allFiles.filter((f) => {
|
|
633
|
+
const b = path_1.default.basename(f);
|
|
634
|
+
return (b === 'docker-compose.yml' || b === 'docker-compose.yaml') &&
|
|
635
|
+
path_1.default.dirname(f) === repoPath;
|
|
636
|
+
});
|
|
637
|
+
if (composeFiles.length > 0) {
|
|
638
|
+
const content = readSafe(composeFiles[0]);
|
|
639
|
+
const dockerServices = [];
|
|
640
|
+
for (const m of content.matchAll(/^\s{2}([a-z][a-z0-9_-]+):\s*$/gm)) {
|
|
641
|
+
dockerServices.push(m[1]);
|
|
642
|
+
}
|
|
643
|
+
if (dockerServices.length > 0) {
|
|
644
|
+
// Add dependency links: each docker-compose service → potential matching service
|
|
645
|
+
const composeId = serviceIdFromName('infra:docker-compose');
|
|
646
|
+
for (const ds of dockerServices) {
|
|
647
|
+
dependencies.push({
|
|
648
|
+
sourceId: composeId,
|
|
649
|
+
targetName: ds,
|
|
650
|
+
targetUrl: `docker-compose://${ds}`,
|
|
651
|
+
type: 'infra',
|
|
652
|
+
endpoints: [],
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return { services, dependencies };
|
|
658
|
+
}
|
|
659
|
+
// \u2500\u2500 Source file walker for QA citations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
660
|
+
function walkSourceFiles(dir, max = 200) {
|
|
661
|
+
if (!fs_1.default.existsSync(dir))
|
|
662
|
+
return [];
|
|
663
|
+
return walkDir(dir, 8)
|
|
664
|
+
.filter((f) => SOURCE_EXTS.has(path_1.default.extname(f)))
|
|
665
|
+
.slice(0, max)
|
|
666
|
+
.map((f) => path_1.default.relative(dir, f));
|
|
667
|
+
}
|
|
668
|
+
function bestFileMatch(hallucinated, realFiles) {
|
|
669
|
+
if (realFiles.length === 0)
|
|
670
|
+
return null;
|
|
671
|
+
const norm = hallucinated.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
672
|
+
if (realFiles.includes(norm))
|
|
673
|
+
return norm;
|
|
674
|
+
const base = path_1.default.basename(norm);
|
|
675
|
+
const nameNoExt = base.replace(/\.[^.]+$/, '');
|
|
676
|
+
const ext = path_1.default.extname(norm);
|
|
677
|
+
const byBase = realFiles.find((f) => path_1.default.basename(f) === base);
|
|
678
|
+
if (byBase)
|
|
679
|
+
return byBase;
|
|
680
|
+
const byName = realFiles.find((f) => path_1.default.basename(f).replace(/\.[^.]+$/, '') === nameNoExt);
|
|
681
|
+
if (byName)
|
|
682
|
+
return byName;
|
|
683
|
+
const byContains = realFiles.find((f) => f.includes(nameNoExt) && nameNoExt.length > 3);
|
|
684
|
+
if (byContains)
|
|
685
|
+
return byContains;
|
|
686
|
+
const segments = norm.split('/').filter((s) => s.length > 3);
|
|
687
|
+
for (const seg of segments) {
|
|
688
|
+
const bySeg = realFiles.find((f) => f.includes(seg) && path_1.default.extname(f) === ext);
|
|
689
|
+
if (bySeg)
|
|
690
|
+
return bySeg;
|
|
691
|
+
}
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
// \u2500\u2500 scanMonorepo \u2014 main entry point \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
695
|
+
function scanMonorepo(repoPath, repoUrl, team) {
|
|
696
|
+
if (!fs_1.default.existsSync(repoPath))
|
|
697
|
+
throw new Error(`Path does not exist: ${repoPath}`);
|
|
698
|
+
if (!fs_1.default.statSync(repoPath).isDirectory())
|
|
699
|
+
throw new Error(`Not a directory: ${repoPath}`);
|
|
700
|
+
// Resolve symlinks (macOS /tmp → /private/tmp) so path.relative works with gitRoot
|
|
701
|
+
repoPath = fs_1.default.realpathSync(repoPath);
|
|
702
|
+
const gitInfo = detectGitInfo(repoPath);
|
|
703
|
+
const resolvedUrl = repoUrl ?? gitInfo.repoUrl;
|
|
704
|
+
const gitRoot = gitInfo.gitRoot;
|
|
705
|
+
let serviceRoots = findServiceRoots(repoPath);
|
|
706
|
+
if (serviceRoots.length === 0) {
|
|
707
|
+
serviceRoots = [{ dir: repoPath, name: path_1.default.basename(repoPath), markerFile: '' }];
|
|
708
|
+
}
|
|
709
|
+
const services = [];
|
|
710
|
+
const allDeps = [];
|
|
711
|
+
const allIssues = [];
|
|
712
|
+
for (const root of serviceRoots) {
|
|
713
|
+
const allFiles = walkDir(root.dir, 10);
|
|
714
|
+
const language = detectLanguage(allFiles);
|
|
715
|
+
const { score, docCoverage, testCoverage, issues } = calculateHealthScore(root.dir, allFiles, repoPath);
|
|
716
|
+
const endpoints = detectEndpoints(allFiles);
|
|
717
|
+
const serviceId = serviceIdFromName(root.name);
|
|
718
|
+
const deps = detectDependencies(root.dir, serviceId, allFiles);
|
|
719
|
+
const lastCommit = detectLastCommit(root.dir);
|
|
720
|
+
const subpath = gitRoot ? path_1.default.relative(gitRoot, root.dir) || null : null;
|
|
721
|
+
services.push({
|
|
722
|
+
id: serviceId, name: root.name,
|
|
723
|
+
repoPath: root.dir, repoUrl: resolvedUrl, repoSubpath: subpath,
|
|
724
|
+
team: team ?? null, language,
|
|
725
|
+
healthScore: score, docCoverage, testCoverage, lastCommit,
|
|
726
|
+
endpoints, rawIssues: issues,
|
|
727
|
+
});
|
|
728
|
+
allDeps.push(...deps);
|
|
729
|
+
allIssues.push(...issues.map((i) => `[${root.name}] ${i}`));
|
|
730
|
+
}
|
|
731
|
+
const infra = detectInfrastructure(repoPath, resolvedUrl ?? null, gitRoot);
|
|
732
|
+
services.push(...infra.services);
|
|
733
|
+
allDeps.push(...infra.dependencies);
|
|
734
|
+
const seen = new Set();
|
|
735
|
+
const uniqueDeps = allDeps.filter((d) => {
|
|
736
|
+
const key = `${d.sourceId}:${d.targetName}:${d.type}`;
|
|
737
|
+
if (seen.has(key))
|
|
738
|
+
return false;
|
|
739
|
+
seen.add(key);
|
|
740
|
+
return true;
|
|
741
|
+
});
|
|
742
|
+
return { services, dependencies: uniqueDeps, issues: allIssues };
|
|
743
|
+
}
|
|
744
|
+
// \u2500\u2500 scanRepo \u2014 legacy alias for ingestWorker \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
745
|
+
function scanRepo(repoPath, repoUrl, team) {
|
|
746
|
+
return scanMonorepo(repoPath, repoUrl, team);
|
|
747
|
+
}
|
|
748
|
+
//# sourceMappingURL=scannerService.js.map
|