@compr/opscontext-mcp 2.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/CHANGELOG.md +313 -0
- package/LICENSE +83 -0
- package/README.md +470 -0
- package/defaults/learnings.json +146 -0
- package/dist/activation.d.ts +48 -0
- package/dist/activation.js +377 -0
- package/dist/adapters.d.ts +101 -0
- package/dist/adapters.js +171 -0
- package/dist/agents.d.ts +137 -0
- package/dist/agents.js +1638 -0
- package/dist/audit.d.ts +23 -0
- package/dist/audit.js +163 -0
- package/dist/cache.d.ts +15 -0
- package/dist/cache.js +117 -0
- package/dist/claude-integration.d.ts +95 -0
- package/dist/claude-integration.js +247 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +1823 -0
- package/dist/code-chunker.d.ts +12 -0
- package/dist/code-chunker.js +270 -0
- package/dist/collectors.d.ts +63 -0
- package/dist/collectors.js +617 -0
- package/dist/config.d.ts +73 -0
- package/dist/config.js +239 -0
- package/dist/embeddings.d.ts +36 -0
- package/dist/embeddings.js +124 -0
- package/dist/firewall.d.ts +133 -0
- package/dist/firewall.js +631 -0
- package/dist/hooks.d.ts +76 -0
- package/dist/hooks.js +313 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1081 -0
- package/dist/ingest.d.ts +32 -0
- package/dist/ingest.js +162 -0
- package/dist/learnings.d.ts +108 -0
- package/dist/learnings.js +714 -0
- package/dist/license-sig.d.ts +47 -0
- package/dist/license-sig.js +104 -0
- package/dist/policy.d.ts +131 -0
- package/dist/policy.js +182 -0
- package/dist/search.d.ts +11 -0
- package/dist/search.js +99 -0
- package/dist/sessions.d.ts +46 -0
- package/dist/sessions.js +153 -0
- package/examples/adapters/notion-adapter.js +108 -0
- package/examples/adapters/rss-adapter.js +76 -0
- package/package.json +87 -0
- package/skills/opscontext/SKILL.md +260 -0
package/dist/agents.js
ADDED
|
@@ -0,0 +1,1638 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import { readFileSync, existsSync, readdirSync, lstatSync } from "fs";
|
|
3
|
+
import { resolve, join, dirname } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
// Read version from package.json at module load
|
|
6
|
+
const __agents_dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
let AGENTS_VERSION = "1.23.0";
|
|
8
|
+
try {
|
|
9
|
+
const pkg = JSON.parse(readFileSync(join(__agents_dirname, "..", "package.json"), "utf-8"));
|
|
10
|
+
AGENTS_VERSION = pkg.version || AGENTS_VERSION;
|
|
11
|
+
}
|
|
12
|
+
catch { /* fallback */ }
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
function exec(cmd, cwd) {
|
|
17
|
+
try {
|
|
18
|
+
return execSync(cmd, {
|
|
19
|
+
cwd,
|
|
20
|
+
encoding: "utf-8",
|
|
21
|
+
timeout: 10_000,
|
|
22
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
23
|
+
}).trim();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Check if a path is a symlink. Returns true if file exists AND is a symlink.
|
|
31
|
+
*/
|
|
32
|
+
function isSymlink(filePath) {
|
|
33
|
+
try {
|
|
34
|
+
return lstatSync(filePath).isSymbolicLink();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Check if an ESLint config is actually backed by installed packages.
|
|
42
|
+
* Returns true if at least `eslint` itself is installed in node_modules.
|
|
43
|
+
*/
|
|
44
|
+
function isLintInstalled(projectPath) {
|
|
45
|
+
// Check for ESLint
|
|
46
|
+
if (existsSync(join(projectPath, "node_modules", "eslint")) ||
|
|
47
|
+
existsSync(join(projectPath, "node_modules", ".package-lock.json"))) {
|
|
48
|
+
// If node_modules exists, check if eslint is actually there
|
|
49
|
+
if (existsSync(join(projectPath, "node_modules", "eslint")))
|
|
50
|
+
return true;
|
|
51
|
+
// Also accept if there's no node_modules at all (monorepo with hoisted deps)
|
|
52
|
+
if (!existsSync(join(projectPath, "node_modules")))
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
// For PHP projects, check phpcs is runnable
|
|
56
|
+
if (existsSync(join(projectPath, "vendor", "bin", "phpcs")))
|
|
57
|
+
return true;
|
|
58
|
+
// No node_modules but has lint config → ghost config
|
|
59
|
+
return !existsSync(join(projectPath, "node_modules"));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Count real test files recursively (not just directories or symlinks).
|
|
63
|
+
* Returns the number of actual test files (*.test.*, *.spec.*, *_test.*).
|
|
64
|
+
*/
|
|
65
|
+
function countTestFiles(dirPath, depth = 0) {
|
|
66
|
+
if (depth > 3)
|
|
67
|
+
return 0; // Don't recurse too deep
|
|
68
|
+
try {
|
|
69
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
70
|
+
let count = 0;
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const fullPath = join(dirPath, entry.name);
|
|
73
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
|
|
74
|
+
count += countTestFiles(fullPath, depth + 1);
|
|
75
|
+
}
|
|
76
|
+
else if (entry.isFile()) {
|
|
77
|
+
if (/\.(test|spec|_test)\.(ts|tsx|js|jsx|py|php)$/.test(entry.name) ||
|
|
78
|
+
entry.name.startsWith("test_") ||
|
|
79
|
+
entry.name.endsWith("_test.py")) {
|
|
80
|
+
count++;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return count;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Project Discovery & Analysis
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
/**
|
|
94
|
+
* Analyze a project directory and determine its type, framework, runtime.
|
|
95
|
+
*/
|
|
96
|
+
export function analyzeProject(dir) {
|
|
97
|
+
const info = {
|
|
98
|
+
name: dir.name,
|
|
99
|
+
path: dir.path,
|
|
100
|
+
type: "unknown",
|
|
101
|
+
framework: "unknown",
|
|
102
|
+
runtime: "unknown",
|
|
103
|
+
hasGit: existsSync(join(dir.path, ".git")),
|
|
104
|
+
gitRemotes: [],
|
|
105
|
+
hasDocker: existsSync(join(dir.path, "Dockerfile")) || existsSync(join(dir.path, "docker-compose.yml")),
|
|
106
|
+
hasPm2: existsSync(join(dir.path, "ecosystem.config.js")) || existsSync(join(dir.path, "ecosystem.config.cjs")),
|
|
107
|
+
deps: {},
|
|
108
|
+
};
|
|
109
|
+
// Git remotes
|
|
110
|
+
if (info.hasGit) {
|
|
111
|
+
const remotes = exec("git remote -v", dir.path);
|
|
112
|
+
info.gitRemotes = [...new Set(remotes.split("\n").map(l => l.split(/\s+/)[0]).filter(Boolean))];
|
|
113
|
+
}
|
|
114
|
+
// Node.js project
|
|
115
|
+
const pkgPath = join(dir.path, "package.json");
|
|
116
|
+
if (existsSync(pkgPath)) {
|
|
117
|
+
try {
|
|
118
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
119
|
+
info.type = "node";
|
|
120
|
+
info.runtime = `node`;
|
|
121
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
122
|
+
// Detect framework
|
|
123
|
+
if (allDeps["next"]) {
|
|
124
|
+
info.framework = "next.js";
|
|
125
|
+
info.deps["next"] = allDeps["next"];
|
|
126
|
+
}
|
|
127
|
+
else if (allDeps["expo"]) {
|
|
128
|
+
info.framework = "expo";
|
|
129
|
+
info.deps["expo"] = allDeps["expo"];
|
|
130
|
+
}
|
|
131
|
+
else if (allDeps["react-scripts"]) {
|
|
132
|
+
info.framework = "react-cra";
|
|
133
|
+
info.deps["react-scripts"] = allDeps["react-scripts"];
|
|
134
|
+
}
|
|
135
|
+
else if (allDeps["vite"]) {
|
|
136
|
+
info.framework = "vite";
|
|
137
|
+
info.deps["vite"] = allDeps["vite"];
|
|
138
|
+
}
|
|
139
|
+
else if (allDeps["@modelcontextprotocol/sdk"]) {
|
|
140
|
+
info.framework = "mcp-server";
|
|
141
|
+
info.deps["@modelcontextprotocol/sdk"] = allDeps["@modelcontextprotocol/sdk"];
|
|
142
|
+
}
|
|
143
|
+
else if (allDeps["express"]) {
|
|
144
|
+
info.framework = "express";
|
|
145
|
+
info.deps["express"] = allDeps["express"];
|
|
146
|
+
}
|
|
147
|
+
else if (allDeps["fastify"]) {
|
|
148
|
+
info.framework = "fastify";
|
|
149
|
+
info.deps["fastify"] = allDeps["fastify"];
|
|
150
|
+
}
|
|
151
|
+
// Key deps
|
|
152
|
+
if (allDeps["react"])
|
|
153
|
+
info.deps["react"] = allDeps["react"];
|
|
154
|
+
if (allDeps["typescript"])
|
|
155
|
+
info.deps["typescript"] = allDeps["typescript"];
|
|
156
|
+
if (allDeps["vue"])
|
|
157
|
+
info.deps["vue"] = allDeps["vue"];
|
|
158
|
+
if (allDeps["@mui/material"])
|
|
159
|
+
info.deps["@mui/material"] = allDeps["@mui/material"];
|
|
160
|
+
if (allDeps["@material-ui/core"])
|
|
161
|
+
info.deps["@material-ui/core"] = allDeps["@material-ui/core"];
|
|
162
|
+
}
|
|
163
|
+
catch { /* ignore */ }
|
|
164
|
+
}
|
|
165
|
+
// PHP project
|
|
166
|
+
const composerPath = join(dir.path, "composer.json");
|
|
167
|
+
if (existsSync(composerPath)) {
|
|
168
|
+
try {
|
|
169
|
+
const composer = JSON.parse(readFileSync(composerPath, "utf-8"));
|
|
170
|
+
info.type = "php";
|
|
171
|
+
info.runtime = "php";
|
|
172
|
+
const allDeps = { ...composer.require, ...composer["require-dev"] };
|
|
173
|
+
if (allDeps["laravel/framework"]) {
|
|
174
|
+
info.framework = "laravel";
|
|
175
|
+
info.deps["laravel/framework"] = allDeps["laravel/framework"];
|
|
176
|
+
}
|
|
177
|
+
else if (allDeps["symfony/framework-bundle"]) {
|
|
178
|
+
info.framework = "symfony";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch { /* ignore */ }
|
|
182
|
+
}
|
|
183
|
+
// Python project
|
|
184
|
+
const pyProjectPath = join(dir.path, "pyproject.toml");
|
|
185
|
+
const requirementsPath = join(dir.path, "requirements.txt");
|
|
186
|
+
if (existsSync(pyProjectPath) || existsSync(requirementsPath)) {
|
|
187
|
+
info.type = "python";
|
|
188
|
+
info.runtime = "python";
|
|
189
|
+
if (existsSync(requirementsPath)) {
|
|
190
|
+
const reqs = readFileSync(requirementsPath, "utf-8");
|
|
191
|
+
if (reqs.includes("fastapi"))
|
|
192
|
+
info.framework = "fastapi";
|
|
193
|
+
else if (reqs.includes("django"))
|
|
194
|
+
info.framework = "django";
|
|
195
|
+
else if (reqs.includes("flask"))
|
|
196
|
+
info.framework = "flask";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Flutter project
|
|
200
|
+
if (existsSync(join(dir.path, "pubspec.yaml"))) {
|
|
201
|
+
info.type = "flutter";
|
|
202
|
+
info.framework = "flutter";
|
|
203
|
+
info.runtime = "dart";
|
|
204
|
+
}
|
|
205
|
+
// Flutter web build (compiled only)
|
|
206
|
+
if (existsSync(join(dir.path, "main.dart.js")) && existsSync(join(dir.path, "flutter_service_worker.js"))) {
|
|
207
|
+
info.type = "flutter";
|
|
208
|
+
info.framework = "flutter-web-build";
|
|
209
|
+
info.runtime = "static";
|
|
210
|
+
}
|
|
211
|
+
return info;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* List all projects with tech analysis.
|
|
215
|
+
*/
|
|
216
|
+
export function listProjects(projectDirs) {
|
|
217
|
+
return projectDirs.map(analyzeProject);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Scan all projects for port declarations and detect conflicts.
|
|
221
|
+
*/
|
|
222
|
+
export function checkPorts(projectDirs) {
|
|
223
|
+
const allPorts = [];
|
|
224
|
+
for (const dir of projectDirs) {
|
|
225
|
+
// ecosystem.config.js — parse port from args or env
|
|
226
|
+
for (const ecFile of ["ecosystem.config.js", "ecosystem.config.cjs"]) {
|
|
227
|
+
const ecPath = join(dir.path, ecFile);
|
|
228
|
+
if (!existsSync(ecPath))
|
|
229
|
+
continue;
|
|
230
|
+
const content = readFileSync(ecPath, "utf-8");
|
|
231
|
+
// Match port patterns: --port 8000, PORT=8000, port: 8000, WEB_PORT=19012
|
|
232
|
+
const portPatterns = [
|
|
233
|
+
/--port\s+(\d+)/g,
|
|
234
|
+
/PORT[=:]\s*['"]?(\d+)/gi,
|
|
235
|
+
/WEB_PORT[=:]\s*['"]?(\d+)/gi,
|
|
236
|
+
/RCT_METRO_PORT[=:]\s*['"]?(\d+)/gi,
|
|
237
|
+
/port:\s*(\d+)/g,
|
|
238
|
+
];
|
|
239
|
+
for (const regex of portPatterns) {
|
|
240
|
+
let match;
|
|
241
|
+
while ((match = regex.exec(content)) !== null) {
|
|
242
|
+
const port = parseInt(match[1], 10);
|
|
243
|
+
if (port > 0 && port < 65536) {
|
|
244
|
+
// Try to extract the app name from context
|
|
245
|
+
const lines = content.split("\n");
|
|
246
|
+
const matchLine = content.substring(0, match.index).split("\n").length - 1;
|
|
247
|
+
let appName = dir.name;
|
|
248
|
+
for (let i = matchLine; i >= Math.max(0, matchLine - 10); i--) {
|
|
249
|
+
const nameMatch = lines[i]?.match(/name:\s*['"]([^'"]+)['"]/);
|
|
250
|
+
if (nameMatch) {
|
|
251
|
+
appName = nameMatch[1];
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
allPorts.push({
|
|
256
|
+
port,
|
|
257
|
+
project: dir.name,
|
|
258
|
+
source: ecFile,
|
|
259
|
+
details: appName,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// docker-compose.yml — published ports
|
|
266
|
+
for (const dcFile of ["docker-compose.yml", "docker-compose.yaml", "docker-compose.prod.yml"]) {
|
|
267
|
+
const dcPath = join(dir.path, dcFile);
|
|
268
|
+
if (!existsSync(dcPath))
|
|
269
|
+
continue;
|
|
270
|
+
const content = readFileSync(dcPath, "utf-8");
|
|
271
|
+
const portMatches = content.matchAll(/["']?(\d+):(\d+)["']?/g);
|
|
272
|
+
for (const m of portMatches) {
|
|
273
|
+
const hostPort = parseInt(m[1], 10);
|
|
274
|
+
if (hostPort > 0 && hostPort < 65536) {
|
|
275
|
+
allPorts.push({
|
|
276
|
+
port: hostPort,
|
|
277
|
+
project: dir.name,
|
|
278
|
+
source: dcFile,
|
|
279
|
+
details: `host:${m[1]}→container:${m[2]}`,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// .env — PORT= or APP_PORT= or DB_PORT=
|
|
285
|
+
const envPath = join(dir.path, ".env");
|
|
286
|
+
if (existsSync(envPath)) {
|
|
287
|
+
const content = readFileSync(envPath, "utf-8");
|
|
288
|
+
const portLines = content.match(/^(?:APP_)?(?:PORT|DB_PORT|REDIS_PORT)\s*=\s*(\d+)$/gm);
|
|
289
|
+
if (portLines) {
|
|
290
|
+
for (const line of portLines) {
|
|
291
|
+
const [key, val] = line.split("=").map(s => s.trim());
|
|
292
|
+
const port = parseInt(val, 10);
|
|
293
|
+
if (port > 0 && port < 65536) {
|
|
294
|
+
allPorts.push({
|
|
295
|
+
port,
|
|
296
|
+
project: dir.name,
|
|
297
|
+
source: ".env",
|
|
298
|
+
details: key,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// package.json — "start" script with --port
|
|
305
|
+
const pkgPath = join(dir.path, "package.json");
|
|
306
|
+
if (existsSync(pkgPath)) {
|
|
307
|
+
try {
|
|
308
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
309
|
+
const scripts = pkg.scripts || {};
|
|
310
|
+
for (const [scriptName, scriptCmd] of Object.entries(scripts)) {
|
|
311
|
+
const cmd = String(scriptCmd);
|
|
312
|
+
const portMatch = cmd.match(/--port\s+(\d+)/);
|
|
313
|
+
if (portMatch) {
|
|
314
|
+
allPorts.push({
|
|
315
|
+
port: parseInt(portMatch[1], 10),
|
|
316
|
+
project: dir.name,
|
|
317
|
+
source: "package.json",
|
|
318
|
+
details: `script: ${scriptName}`,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch { /* ignore */ }
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// Deduplicate (same port+project+source = one entry)
|
|
327
|
+
const seen = new Set();
|
|
328
|
+
const dedupPorts = allPorts.filter(p => {
|
|
329
|
+
const key = `${p.port}:${p.project}:${p.source}`;
|
|
330
|
+
if (seen.has(key))
|
|
331
|
+
return false;
|
|
332
|
+
seen.add(key);
|
|
333
|
+
return true;
|
|
334
|
+
});
|
|
335
|
+
// Detect conflicts (same port used by different projects)
|
|
336
|
+
const portMap = new Map();
|
|
337
|
+
for (const p of dedupPorts) {
|
|
338
|
+
const list = portMap.get(p.port) || [];
|
|
339
|
+
list.push(p);
|
|
340
|
+
portMap.set(p.port, list);
|
|
341
|
+
}
|
|
342
|
+
const conflicts = [];
|
|
343
|
+
for (const [port, usages] of portMap) {
|
|
344
|
+
const uniqueProjects = new Set(usages.map(u => u.project));
|
|
345
|
+
if (uniqueProjects.size > 1) {
|
|
346
|
+
conflicts.push({ port, usages });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return { ports: dedupPorts, conflicts };
|
|
350
|
+
}
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// Compliance Agent — Audits
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
/**
|
|
355
|
+
* Check git remotes: every project should have both 'origin' (GitHub) and 'gdrive'.
|
|
356
|
+
*/
|
|
357
|
+
function auditGitRemotes(projects) {
|
|
358
|
+
const findings = [];
|
|
359
|
+
for (const p of projects) {
|
|
360
|
+
if (!p.hasGit) {
|
|
361
|
+
findings.push({
|
|
362
|
+
check: "git-remotes",
|
|
363
|
+
status: "warn",
|
|
364
|
+
message: `${p.name}: Not a git repository`,
|
|
365
|
+
project: p.name,
|
|
366
|
+
severity: "medium",
|
|
367
|
+
remediation: `cd ${p.path} && git init && git remote add origin <url>`,
|
|
368
|
+
});
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const hasOrigin = p.gitRemotes.includes("origin");
|
|
372
|
+
const hasGdrive = p.gitRemotes.includes("gdrive");
|
|
373
|
+
if (hasOrigin && hasGdrive) {
|
|
374
|
+
findings.push({
|
|
375
|
+
check: "git-remotes",
|
|
376
|
+
status: "pass",
|
|
377
|
+
message: `${p.name}: origin ✅ gdrive ✅`,
|
|
378
|
+
project: p.name,
|
|
379
|
+
severity: "info",
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
const missing = [];
|
|
384
|
+
if (!hasOrigin)
|
|
385
|
+
missing.push("origin");
|
|
386
|
+
if (!hasGdrive)
|
|
387
|
+
missing.push("gdrive");
|
|
388
|
+
findings.push({
|
|
389
|
+
check: "git-remotes",
|
|
390
|
+
status: "fail",
|
|
391
|
+
message: `${p.name}: Missing remotes: ${missing.join(", ")}`,
|
|
392
|
+
project: p.name,
|
|
393
|
+
severity: "high",
|
|
394
|
+
remediation: missing.map(r => `cd ${p.path} && git remote add ${r} <url>`).join("\n"),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return findings;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Check for post-commit hook (auto-push to all remotes).
|
|
402
|
+
* Also audits hook quality: error handling, gdrive best-effort pattern.
|
|
403
|
+
*/
|
|
404
|
+
function auditGitHooks(projects) {
|
|
405
|
+
const findings = [];
|
|
406
|
+
for (const p of projects) {
|
|
407
|
+
if (!p.hasGit)
|
|
408
|
+
continue;
|
|
409
|
+
// Check both .git/hooks/ and hooks/ (custom dir)
|
|
410
|
+
const hookPaths = [
|
|
411
|
+
join(p.path, ".git", "hooks", "post-commit"),
|
|
412
|
+
join(p.path, "hooks", "post-commit"),
|
|
413
|
+
];
|
|
414
|
+
let foundHook = false;
|
|
415
|
+
let hookContent = "";
|
|
416
|
+
for (const hookPath of hookPaths) {
|
|
417
|
+
if (existsSync(hookPath)) {
|
|
418
|
+
hookContent = readFileSync(hookPath, "utf-8");
|
|
419
|
+
if (hookContent.includes("push") || hookContent.includes("remote")) {
|
|
420
|
+
foundHook = true;
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Also check git config for core.hooksPath
|
|
426
|
+
const hooksPath = exec("git config core.hooksPath", p.path);
|
|
427
|
+
if (hooksPath) {
|
|
428
|
+
const customHook = resolve(p.path, hooksPath, "post-commit");
|
|
429
|
+
if (existsSync(customHook)) {
|
|
430
|
+
foundHook = true;
|
|
431
|
+
if (!hookContent)
|
|
432
|
+
hookContent = readFileSync(customHook, "utf-8");
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
findings.push({
|
|
436
|
+
check: "git-hooks",
|
|
437
|
+
status: foundHook ? "pass" : "warn",
|
|
438
|
+
message: `${p.name}: post-commit auto-push ${foundHook ? "✅" : "⚠ not found"}`,
|
|
439
|
+
project: p.name,
|
|
440
|
+
severity: foundHook ? "info" : "low",
|
|
441
|
+
remediation: foundHook ? undefined : `Add post-commit hook: cp hooks/post-commit ${p.path}/.git/hooks/`,
|
|
442
|
+
});
|
|
443
|
+
// Hook quality checks (only if hook exists)
|
|
444
|
+
if (foundHook && hookContent) {
|
|
445
|
+
// Check: gdrive push should be best-effort (error-suppressed)
|
|
446
|
+
const pushesGdrive = hookContent.includes("gdrive");
|
|
447
|
+
const gdriveErrorSuppressed = hookContent.includes("2>/dev/null") ||
|
|
448
|
+
hookContent.includes("2>&1") ||
|
|
449
|
+
hookContent.includes("|| true") ||
|
|
450
|
+
hookContent.includes("|| echo");
|
|
451
|
+
if (pushesGdrive && !gdriveErrorSuppressed) {
|
|
452
|
+
findings.push({
|
|
453
|
+
check: "hook-quality",
|
|
454
|
+
status: "warn",
|
|
455
|
+
message: `${p.name}: gdrive push has no error suppression — FUSE failures will produce noisy output`,
|
|
456
|
+
project: p.name,
|
|
457
|
+
severity: "low",
|
|
458
|
+
remediation: `Update hook: git push gdrive "$BRANCH" 2>/dev/null && echo "✅ gdrive" || echo "⚠️ gdrive (best-effort)"`,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
else if (pushesGdrive && gdriveErrorSuppressed) {
|
|
462
|
+
findings.push({
|
|
463
|
+
check: "hook-quality",
|
|
464
|
+
status: "pass",
|
|
465
|
+
message: `${p.name}: gdrive push is best-effort ✅ (errors suppressed)`,
|
|
466
|
+
project: p.name,
|
|
467
|
+
severity: "info",
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
// Check: hook should use BRANCH variable, not hardcoded branch name
|
|
471
|
+
const usesBranchVar = hookContent.includes("$BRANCH") ||
|
|
472
|
+
hookContent.includes("$(git") ||
|
|
473
|
+
hookContent.includes("`git");
|
|
474
|
+
const hardcodesBranch = hookContent.includes("push origin main") ||
|
|
475
|
+
hookContent.includes("push origin master") ||
|
|
476
|
+
hookContent.includes("push gdrive main") ||
|
|
477
|
+
hookContent.includes("push gdrive master");
|
|
478
|
+
if (hardcodesBranch && !usesBranchVar) {
|
|
479
|
+
findings.push({
|
|
480
|
+
check: "hook-quality",
|
|
481
|
+
status: "warn",
|
|
482
|
+
message: `${p.name}: hook hardcodes branch name — won't work on feature branches`,
|
|
483
|
+
project: p.name,
|
|
484
|
+
severity: "low",
|
|
485
|
+
remediation: `Use BRANCH=$(git rev-parse --abbrev-ref HEAD) then git push origin "$BRANCH"`,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
// Check: hook should have shebang
|
|
489
|
+
if (!hookContent.startsWith("#!")) {
|
|
490
|
+
findings.push({
|
|
491
|
+
check: "hook-quality",
|
|
492
|
+
status: "warn",
|
|
493
|
+
message: `${p.name}: post-commit hook missing shebang (#!/bin/zsh or #!/bin/bash)`,
|
|
494
|
+
project: p.name,
|
|
495
|
+
severity: "low",
|
|
496
|
+
remediation: `Add #!/bin/zsh as the first line of the hook`,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return findings;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Validate .env files exist for projects that need them.
|
|
505
|
+
*/
|
|
506
|
+
function auditEnvFiles(projects) {
|
|
507
|
+
const findings = [];
|
|
508
|
+
for (const p of projects) {
|
|
509
|
+
// Only check projects that should have .env
|
|
510
|
+
const needsEnv = ["laravel", "fastapi", "django", "express", "fastify", "next.js", "vite"].includes(p.framework)
|
|
511
|
+
|| existsSync(join(p.path, ".env.example"));
|
|
512
|
+
if (!needsEnv)
|
|
513
|
+
continue;
|
|
514
|
+
const envPath = join(p.path, ".env");
|
|
515
|
+
const envExamplePath = join(p.path, ".env.example");
|
|
516
|
+
if (!existsSync(envPath)) {
|
|
517
|
+
findings.push({
|
|
518
|
+
check: "env-file",
|
|
519
|
+
status: "fail",
|
|
520
|
+
message: `${p.name}: .env file missing (framework: ${p.framework})`,
|
|
521
|
+
project: p.name,
|
|
522
|
+
severity: "high",
|
|
523
|
+
remediation: existsSync(envExamplePath)
|
|
524
|
+
? `cd ${p.path} && cp .env.example .env`
|
|
525
|
+
: `Create ${p.path}/.env from project documentation`,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
// Check .env is in .gitignore
|
|
530
|
+
const giPath = join(p.path, ".gitignore");
|
|
531
|
+
if (existsSync(giPath)) {
|
|
532
|
+
const gi = readFileSync(giPath, "utf-8");
|
|
533
|
+
if (!gi.includes(".env")) {
|
|
534
|
+
findings.push({
|
|
535
|
+
check: "env-file",
|
|
536
|
+
status: "fail",
|
|
537
|
+
message: `${p.name}: .env exists but NOT in .gitignore — secrets could be committed!`,
|
|
538
|
+
project: p.name,
|
|
539
|
+
severity: "critical",
|
|
540
|
+
remediation: `echo ".env" >> ${p.path}/.gitignore`,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
findings.push({
|
|
545
|
+
check: "env-file",
|
|
546
|
+
status: "pass",
|
|
547
|
+
message: `${p.name}: .env ✅ (in .gitignore)`,
|
|
548
|
+
project: p.name,
|
|
549
|
+
severity: "info",
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
findings.push({
|
|
555
|
+
check: "env-file",
|
|
556
|
+
status: "warn",
|
|
557
|
+
message: `${p.name}: .env exists but no .gitignore found`,
|
|
558
|
+
project: p.name,
|
|
559
|
+
severity: "medium",
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return findings;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Check Docker configuration consistency.
|
|
568
|
+
*/
|
|
569
|
+
function auditDocker(projects) {
|
|
570
|
+
const findings = [];
|
|
571
|
+
for (const p of projects) {
|
|
572
|
+
if (!p.hasDocker)
|
|
573
|
+
continue;
|
|
574
|
+
// Check Dockerfile exists
|
|
575
|
+
const dockerfilePath = join(p.path, "Dockerfile");
|
|
576
|
+
if (existsSync(dockerfilePath)) {
|
|
577
|
+
const content = readFileSync(dockerfilePath, "utf-8");
|
|
578
|
+
// Check platform specification for production builds
|
|
579
|
+
// (important: Apple Silicon → amd64)
|
|
580
|
+
findings.push({
|
|
581
|
+
check: "docker-config",
|
|
582
|
+
status: "pass",
|
|
583
|
+
message: `${p.name}: Dockerfile found`,
|
|
584
|
+
project: p.name,
|
|
585
|
+
severity: "info",
|
|
586
|
+
});
|
|
587
|
+
// Check for WORKDIR
|
|
588
|
+
const workdirMatch = content.match(/WORKDIR\s+(\S+)/);
|
|
589
|
+
if (workdirMatch) {
|
|
590
|
+
findings.push({
|
|
591
|
+
check: "docker-workdir",
|
|
592
|
+
status: "pass",
|
|
593
|
+
message: `${p.name}: WORKDIR = ${workdirMatch[1]}`,
|
|
594
|
+
project: p.name,
|
|
595
|
+
severity: "info",
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// Check docker-compose volume mounts
|
|
600
|
+
for (const dcFile of ["docker-compose.yml", "docker-compose.prod.yml"]) {
|
|
601
|
+
const dcPath = join(p.path, dcFile);
|
|
602
|
+
if (!existsSync(dcPath))
|
|
603
|
+
continue;
|
|
604
|
+
const content = readFileSync(dcPath, "utf-8");
|
|
605
|
+
// Check for restart policy
|
|
606
|
+
if (content.includes("restart:")) {
|
|
607
|
+
findings.push({
|
|
608
|
+
check: "docker-restart",
|
|
609
|
+
status: "pass",
|
|
610
|
+
message: `${p.name} (${dcFile}): restart policy configured`,
|
|
611
|
+
project: p.name,
|
|
612
|
+
severity: "info",
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
findings.push({
|
|
617
|
+
check: "docker-restart",
|
|
618
|
+
status: "warn",
|
|
619
|
+
message: `${p.name} (${dcFile}): No restart policy — containers won't auto-restart`,
|
|
620
|
+
project: p.name,
|
|
621
|
+
severity: "medium",
|
|
622
|
+
remediation: `Add 'restart: unless-stopped' to services in ${dcPath}`,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return findings;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Check PM2 ecosystem configs for best practices.
|
|
631
|
+
*/
|
|
632
|
+
function auditPm2(projects) {
|
|
633
|
+
const findings = [];
|
|
634
|
+
for (const p of projects) {
|
|
635
|
+
if (!p.hasPm2)
|
|
636
|
+
continue;
|
|
637
|
+
for (const ecFile of ["ecosystem.config.js", "ecosystem.config.cjs"]) {
|
|
638
|
+
const ecPath = join(p.path, ecFile);
|
|
639
|
+
if (!existsSync(ecPath))
|
|
640
|
+
continue;
|
|
641
|
+
const content = readFileSync(ecPath, "utf-8");
|
|
642
|
+
// Check for bash wrapper anti-pattern
|
|
643
|
+
if (content.includes("bash -c") || content.includes("bash -i")) {
|
|
644
|
+
findings.push({
|
|
645
|
+
check: "pm2-no-bash",
|
|
646
|
+
status: "fail",
|
|
647
|
+
message: `${p.name}: PM2 uses bash wrapper — causes orphan processes on restart`,
|
|
648
|
+
project: p.name,
|
|
649
|
+
severity: "high",
|
|
650
|
+
remediation: `Remove 'bash -c' wrapper in ${ecPath} — use npx/node directly`,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
// Check treekill
|
|
654
|
+
if (!content.includes("treekill")) {
|
|
655
|
+
findings.push({
|
|
656
|
+
check: "pm2-treekill",
|
|
657
|
+
status: "warn",
|
|
658
|
+
message: `${p.name}: PM2 config missing treekill: true — child processes may orphan on restart`,
|
|
659
|
+
project: p.name,
|
|
660
|
+
severity: "medium",
|
|
661
|
+
remediation: `Add 'treekill: true' to ${ecPath}`,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
// Check kill_timeout
|
|
665
|
+
if (!content.includes("kill_timeout")) {
|
|
666
|
+
findings.push({
|
|
667
|
+
check: "pm2-kill-timeout",
|
|
668
|
+
status: "warn",
|
|
669
|
+
message: `${p.name}: PM2 config missing kill_timeout — processes may hang on stop`,
|
|
670
|
+
project: p.name,
|
|
671
|
+
severity: "low",
|
|
672
|
+
remediation: `Add 'kill_timeout: 10000' to ${ecPath}`,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
// Check autorestart
|
|
676
|
+
if (content.includes("autorestart: false") || content.includes("autorestart:false")) {
|
|
677
|
+
findings.push({
|
|
678
|
+
check: "pm2-autorestart",
|
|
679
|
+
status: "pass",
|
|
680
|
+
message: `${p.name}: PM2 autorestart disabled (intentional for dev servers)`,
|
|
681
|
+
project: p.name,
|
|
682
|
+
severity: "info",
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return findings;
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Check for version issues — EOL runtimes, outdated deps, MUI v4/v5 coexistence.
|
|
691
|
+
*/
|
|
692
|
+
function auditVersions(projects) {
|
|
693
|
+
const findings = [];
|
|
694
|
+
// Known EOL dates (approximate)
|
|
695
|
+
const eolRuntimes = {
|
|
696
|
+
"php 7.4": { eol: "Nov 2022", replacement: "PHP 8.2+" },
|
|
697
|
+
"node 14": { eol: "Apr 2023", replacement: "Node 20 LTS" },
|
|
698
|
+
"node 16": { eol: "Sep 2023", replacement: "Node 20 LTS" },
|
|
699
|
+
"python 3.7": { eol: "Jun 2023", replacement: "Python 3.11+" },
|
|
700
|
+
"python 3.8": { eol: "Oct 2024", replacement: "Python 3.11+" },
|
|
701
|
+
};
|
|
702
|
+
for (const p of projects) {
|
|
703
|
+
// Check MUI v4/v5 coexistence
|
|
704
|
+
if (p.deps["@mui/material"] && p.deps["@material-ui/core"]) {
|
|
705
|
+
findings.push({
|
|
706
|
+
check: "dep-conflict",
|
|
707
|
+
status: "warn",
|
|
708
|
+
message: `${p.name}: MUI v4 AND v5 both installed — should consolidate to v5`,
|
|
709
|
+
project: p.name,
|
|
710
|
+
severity: "medium",
|
|
711
|
+
remediation: `Migrate all @material-ui/* imports to @mui/* equivalents`,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
// Check for very old react-scripts
|
|
715
|
+
if (p.deps["react-scripts"]) {
|
|
716
|
+
const version = p.deps["react-scripts"].replace(/[^0-9.]/g, "");
|
|
717
|
+
const major = parseInt(version.split(".")[0], 10);
|
|
718
|
+
if (major < 5) {
|
|
719
|
+
findings.push({
|
|
720
|
+
check: "dep-outdated",
|
|
721
|
+
status: "warn",
|
|
722
|
+
message: `${p.name}: react-scripts v${version} (CRA is deprecated — consider Vite migration)`,
|
|
723
|
+
project: p.name,
|
|
724
|
+
severity: "medium",
|
|
725
|
+
remediation: `Migrate to Vite: npm create vite@latest -- --template react-ts`,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return findings;
|
|
731
|
+
}
|
|
732
|
+
// ---------------------------------------------------------------------------
|
|
733
|
+
// Compliance Agent — Full Audit
|
|
734
|
+
// ---------------------------------------------------------------------------
|
|
735
|
+
/**
|
|
736
|
+
* Run the full compliance audit across all projects.
|
|
737
|
+
* Returns a structured plan document.
|
|
738
|
+
*/
|
|
739
|
+
export function runComplianceAudit(projectDirs) {
|
|
740
|
+
const projects = listProjects(projectDirs);
|
|
741
|
+
const portResult = checkPorts(projectDirs);
|
|
742
|
+
const findings = [];
|
|
743
|
+
// Port conflicts
|
|
744
|
+
for (const conflict of portResult.conflicts) {
|
|
745
|
+
findings.push({
|
|
746
|
+
check: "port-conflict",
|
|
747
|
+
status: "fail",
|
|
748
|
+
message: `Port ${conflict.port} used by: ${conflict.usages.map(u => `${u.project} (${u.source}: ${u.details})`).join(", ")}`,
|
|
749
|
+
severity: "high",
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
// All individual audit checks
|
|
753
|
+
findings.push(...auditGitRemotes(projects));
|
|
754
|
+
findings.push(...auditGitHooks(projects));
|
|
755
|
+
findings.push(...auditEnvFiles(projects));
|
|
756
|
+
findings.push(...auditDocker(projects));
|
|
757
|
+
findings.push(...auditPm2(projects));
|
|
758
|
+
findings.push(...auditVersions(projects));
|
|
759
|
+
// Generate remediation steps for failures
|
|
760
|
+
const steps = [];
|
|
761
|
+
for (const f of findings) {
|
|
762
|
+
if (f.status === "fail" && f.remediation) {
|
|
763
|
+
steps.push({
|
|
764
|
+
action: `Fix: ${f.check}`,
|
|
765
|
+
description: f.message,
|
|
766
|
+
command: f.command || f.remediation,
|
|
767
|
+
risk: f.severity === "critical" ? "red" : "yellow",
|
|
768
|
+
reversible: f.check !== "env-file", // most are reversible
|
|
769
|
+
estimatedTime: "30s",
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
const summary = {
|
|
774
|
+
pass: findings.filter(f => f.status === "pass").length,
|
|
775
|
+
warn: findings.filter(f => f.status === "warn").length,
|
|
776
|
+
fail: findings.filter(f => f.status === "fail").length,
|
|
777
|
+
critical: findings.filter(f => f.severity === "critical").length,
|
|
778
|
+
};
|
|
779
|
+
return {
|
|
780
|
+
agent: "Compliance Agent",
|
|
781
|
+
timestamp: new Date().toISOString(),
|
|
782
|
+
trigger: "manual",
|
|
783
|
+
scope: `${projectDirs.length} projects`,
|
|
784
|
+
findings,
|
|
785
|
+
steps,
|
|
786
|
+
summary,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
// ---------------------------------------------------------------------------
|
|
790
|
+
// Plan Document Formatter
|
|
791
|
+
// ---------------------------------------------------------------------------
|
|
792
|
+
/**
|
|
793
|
+
* Format an audit plan as a readable Markdown document.
|
|
794
|
+
*/
|
|
795
|
+
export function formatPlan(plan) {
|
|
796
|
+
const lines = [];
|
|
797
|
+
lines.push(`## 📋 Agent Plan: ${plan.agent} — ${plan.timestamp.split("T")[0]}`);
|
|
798
|
+
lines.push("");
|
|
799
|
+
lines.push(`### Context`);
|
|
800
|
+
lines.push(`- **Trigger**: ${plan.trigger}`);
|
|
801
|
+
lines.push(`- **Scope**: ${plan.scope}`);
|
|
802
|
+
lines.push(`- **Summary**: ✅ ${plan.summary.pass} pass | ⚠ ${plan.summary.warn} warn | ❌ ${plan.summary.fail} fail | 🔴 ${plan.summary.critical} critical`);
|
|
803
|
+
lines.push("");
|
|
804
|
+
// Findings grouped by status
|
|
805
|
+
const failures = plan.findings.filter(f => f.status === "fail");
|
|
806
|
+
const warnings = plan.findings.filter(f => f.status === "warn");
|
|
807
|
+
const passes = plan.findings.filter(f => f.status === "pass");
|
|
808
|
+
if (failures.length > 0) {
|
|
809
|
+
lines.push(`### ❌ Failures (${failures.length})`);
|
|
810
|
+
for (const f of failures) {
|
|
811
|
+
const severity = f.severity === "critical" ? "🔴 CRITICAL" : `⚠ ${f.severity}`;
|
|
812
|
+
lines.push(`- **[${severity}] ${f.check}**: ${f.message}`);
|
|
813
|
+
if (f.remediation) {
|
|
814
|
+
lines.push(` - Fix: \`${f.remediation}\``);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
lines.push("");
|
|
818
|
+
}
|
|
819
|
+
if (warnings.length > 0) {
|
|
820
|
+
lines.push(`### ⚠ Warnings (${warnings.length})`);
|
|
821
|
+
for (const f of warnings) {
|
|
822
|
+
lines.push(`- **${f.check}**: ${f.message}`);
|
|
823
|
+
if (f.remediation) {
|
|
824
|
+
lines.push(` - Fix: \`${f.remediation}\``);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
lines.push("");
|
|
828
|
+
}
|
|
829
|
+
if (passes.length > 0) {
|
|
830
|
+
lines.push(`### ✅ Passed (${passes.length})`);
|
|
831
|
+
for (const f of passes) {
|
|
832
|
+
lines.push(`- ${f.check}: ${f.message}`);
|
|
833
|
+
}
|
|
834
|
+
lines.push("");
|
|
835
|
+
}
|
|
836
|
+
// Remediation steps
|
|
837
|
+
if (plan.steps.length > 0) {
|
|
838
|
+
lines.push(`### 🛠 Proposed Remediation Steps`);
|
|
839
|
+
lines.push("");
|
|
840
|
+
for (let i = 0; i < plan.steps.length; i++) {
|
|
841
|
+
const s = plan.steps[i];
|
|
842
|
+
const risk = s.risk === "red" ? "🔴 High" : s.risk === "yellow" ? "🟡 Medium" : "🟢 Low";
|
|
843
|
+
lines.push(`${i + 1}. **${s.action}** — ${s.description}`);
|
|
844
|
+
if (s.command) {
|
|
845
|
+
lines.push(` - Command: \`${s.command}\``);
|
|
846
|
+
}
|
|
847
|
+
lines.push(` - Risk: ${risk} | Reversible: ${s.reversible ? "Yes" : "No"} | Time: ${s.estimatedTime}`);
|
|
848
|
+
}
|
|
849
|
+
lines.push("");
|
|
850
|
+
lines.push(`> ⚠ Review each step before approving. Only approved steps will be executed.`);
|
|
851
|
+
}
|
|
852
|
+
return lines.join("\n");
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Format project list as a readable summary.
|
|
856
|
+
*/
|
|
857
|
+
export function formatProjectList(projects) {
|
|
858
|
+
const lines = [];
|
|
859
|
+
lines.push(`## 📂 FASTPROD Projects — ${projects.length} discovered`);
|
|
860
|
+
lines.push("");
|
|
861
|
+
// Group by type
|
|
862
|
+
const grouped = new Map();
|
|
863
|
+
for (const p of projects) {
|
|
864
|
+
const key = p.type;
|
|
865
|
+
const list = grouped.get(key) || [];
|
|
866
|
+
list.push(p);
|
|
867
|
+
grouped.set(key, list);
|
|
868
|
+
}
|
|
869
|
+
for (const [type, projs] of grouped) {
|
|
870
|
+
lines.push(`### ${type.toUpperCase()} (${projs.length})`);
|
|
871
|
+
for (const p of projs) {
|
|
872
|
+
const remotes = p.gitRemotes.length > 0 ? p.gitRemotes.join(", ") : "none";
|
|
873
|
+
const depList = Object.entries(p.deps).map(([k, v]) => `${k}@${v}`).join(", ");
|
|
874
|
+
const flags = [
|
|
875
|
+
p.hasGit ? "git" : null,
|
|
876
|
+
p.hasDocker ? "docker" : null,
|
|
877
|
+
p.hasPm2 ? "pm2" : null,
|
|
878
|
+
].filter(Boolean).join("+");
|
|
879
|
+
lines.push(`- **${p.name}** — ${p.framework} (${p.runtime})`);
|
|
880
|
+
lines.push(` - Path: ${p.path}`);
|
|
881
|
+
if (depList)
|
|
882
|
+
lines.push(` - Key deps: ${depList}`);
|
|
883
|
+
lines.push(` - Infra: ${flags || "none"} | Remotes: ${remotes}`);
|
|
884
|
+
}
|
|
885
|
+
lines.push("");
|
|
886
|
+
}
|
|
887
|
+
return lines.join("\n");
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Format port map as a readable table.
|
|
891
|
+
*/
|
|
892
|
+
export function formatPortMap(ports, conflicts) {
|
|
893
|
+
const lines = [];
|
|
894
|
+
lines.push(`## 🔌 Port Allocation Map`);
|
|
895
|
+
lines.push("");
|
|
896
|
+
if (conflicts.length > 0) {
|
|
897
|
+
lines.push(`### ⚠ Conflicts (${conflicts.length})`);
|
|
898
|
+
for (const c of conflicts) {
|
|
899
|
+
lines.push(`- **Port ${c.port}**: ${c.usages.map(u => `${u.project}/${u.source} (${u.details})`).join(" vs ")}`);
|
|
900
|
+
}
|
|
901
|
+
lines.push("");
|
|
902
|
+
}
|
|
903
|
+
// Sort by port number
|
|
904
|
+
const sorted = [...ports].sort((a, b) => a.port - b.port);
|
|
905
|
+
lines.push("| Port | Project | Source | Details |");
|
|
906
|
+
lines.push("|------|---------|--------|---------|");
|
|
907
|
+
for (const p of sorted) {
|
|
908
|
+
lines.push(`| ${p.port} | ${p.project} | ${p.source} | ${p.details} |`);
|
|
909
|
+
}
|
|
910
|
+
return lines.join("\n");
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Score a project's AI-readiness (0-100%).
|
|
914
|
+
* Checks how well-prepared a project is for AI coding agents.
|
|
915
|
+
*/
|
|
916
|
+
export function scoreProject(dir) {
|
|
917
|
+
const checks = [];
|
|
918
|
+
const p = dir.path;
|
|
919
|
+
// --- Documentation (30 points max) ---
|
|
920
|
+
// copilot-instructions.md (10 pts)
|
|
921
|
+
const copilotPath = join(p, ".github", "copilot-instructions.md");
|
|
922
|
+
if (existsSync(copilotPath)) {
|
|
923
|
+
const copilotIsSymlink = isSymlink(copilotPath);
|
|
924
|
+
const content = readFileSync(copilotPath, "utf-8");
|
|
925
|
+
const lines = content.split("\n").length;
|
|
926
|
+
if (copilotIsSymlink) {
|
|
927
|
+
checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 4, maxPoints: 10, status: "partial", detail: `⚠ Symlink (${lines} lines) — should be a real file with project-specific context` });
|
|
928
|
+
}
|
|
929
|
+
else if (lines > 50) {
|
|
930
|
+
checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 10, maxPoints: 10, status: "pass", detail: `${lines} lines — comprehensive` });
|
|
931
|
+
}
|
|
932
|
+
else if (lines > 15) {
|
|
933
|
+
checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 6, maxPoints: 10, status: "partial", detail: `${lines} lines — could be more detailed` });
|
|
934
|
+
}
|
|
935
|
+
else {
|
|
936
|
+
checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 3, maxPoints: 10, status: "partial", detail: `${lines} lines — too sparse, add architecture, rules, key files` });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
else {
|
|
940
|
+
checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 0, maxPoints: 10, status: "fail", detail: "Missing — AI agents lack project context" });
|
|
941
|
+
}
|
|
942
|
+
// README.md (8 pts)
|
|
943
|
+
const readmePath = join(p, "README.md");
|
|
944
|
+
if (existsSync(readmePath)) {
|
|
945
|
+
const content = readFileSync(readmePath, "utf-8");
|
|
946
|
+
const readmeLines = content.split("\n").length;
|
|
947
|
+
if (readmeLines > 30) {
|
|
948
|
+
checks.push({ name: "README.md", category: "Documentation", points: 8, maxPoints: 8, status: "pass", detail: `${readmeLines} lines` });
|
|
949
|
+
}
|
|
950
|
+
else {
|
|
951
|
+
checks.push({ name: "README.md", category: "Documentation", points: 4, maxPoints: 8, status: "partial", detail: `${readmeLines} lines — sparse` });
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
else {
|
|
955
|
+
checks.push({ name: "README.md", category: "Documentation", points: 0, maxPoints: 8, status: "fail", detail: "Missing" });
|
|
956
|
+
}
|
|
957
|
+
// CLAUDE.md / .cursorrules / AGENTS.md (6 pts)
|
|
958
|
+
const altPatterns = ["CLAUDE.md", ".cursorrules", ".cursor/rules", "AGENTS.md"];
|
|
959
|
+
const foundAlt = altPatterns.filter(pat => existsSync(join(p, pat)));
|
|
960
|
+
const realAlt = foundAlt.filter(pat => !isSymlink(join(p, pat)));
|
|
961
|
+
const symlinkAlt = foundAlt.filter(pat => isSymlink(join(p, pat)));
|
|
962
|
+
if (realAlt.length >= 2) {
|
|
963
|
+
checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 6, maxPoints: 6, status: "pass", detail: `Found: ${realAlt.join(", ")}` });
|
|
964
|
+
}
|
|
965
|
+
else if (realAlt.length === 1 && symlinkAlt.length >= 1) {
|
|
966
|
+
checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 4, maxPoints: 6, status: "partial", detail: `${realAlt[0]} + ${symlinkAlt.length} symlink(s) — symlinks count as partial` });
|
|
967
|
+
}
|
|
968
|
+
else if (foundAlt.length >= 2 && realAlt.length === 0) {
|
|
969
|
+
checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 2, maxPoints: 6, status: "partial", detail: `${foundAlt.join(", ")} — all symlinks, create real per-agent files` });
|
|
970
|
+
}
|
|
971
|
+
else if (foundAlt.length === 1) {
|
|
972
|
+
const pts = isSymlink(join(p, foundAlt[0])) ? 1 : 3;
|
|
973
|
+
checks.push({ name: "Multi-agent patterns", category: "Documentation", points: pts, maxPoints: 6, status: "partial", detail: `Found: ${foundAlt[0]}${isSymlink(join(p, foundAlt[0])) ? " (symlink)" : ""} only` });
|
|
974
|
+
}
|
|
975
|
+
else {
|
|
976
|
+
checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 0, maxPoints: 6, status: "fail", detail: "No CLAUDE.md, .cursorrules, or AGENTS.md" });
|
|
977
|
+
}
|
|
978
|
+
// .github/SKILLS.md (3 pts)
|
|
979
|
+
const skillsPath = join(p, ".github", "SKILLS.md");
|
|
980
|
+
if (existsSync(skillsPath)) {
|
|
981
|
+
const skillsContent = readFileSync(skillsPath, "utf-8");
|
|
982
|
+
const skillsLines = skillsContent.split("\n").length;
|
|
983
|
+
if (skillsLines > 10 && !isSymlink(skillsPath)) {
|
|
984
|
+
checks.push({ name: "SKILLS.md", category: "Documentation", points: 3, maxPoints: 3, status: "pass", detail: `${skillsLines} lines` });
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
checks.push({ name: "SKILLS.md", category: "Documentation", points: 1, maxPoints: 3, status: "partial", detail: `${skillsLines} lines${isSymlink(skillsPath) ? " (symlink)" : ""} — add real skill descriptions` });
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
else {
|
|
991
|
+
checks.push({ name: "SKILLS.md", category: "Documentation", points: 0, maxPoints: 3, status: "fail", detail: "Missing — agents can't discover capabilities" });
|
|
992
|
+
}
|
|
993
|
+
// .env.example (3 pts) — validates actual content, not just existence
|
|
994
|
+
const envExamplePath = join(p, ".env.example");
|
|
995
|
+
if (existsSync(envExamplePath)) {
|
|
996
|
+
const envContent = readFileSync(envExamplePath, "utf-8");
|
|
997
|
+
const envVarLines = envContent.split("\n").filter(l => /^[A-Z_]+=/.test(l.trim())).length;
|
|
998
|
+
if (envVarLines >= 3) {
|
|
999
|
+
checks.push({ name: ".env.example", category: "Documentation", points: 3, maxPoints: 3, status: "pass", detail: `${envVarLines} env vars documented` });
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
checks.push({ name: ".env.example", category: "Documentation", points: 1, maxPoints: 3, status: "partial", detail: `Only ${envVarLines} env var(s) — add all required vars` });
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
else {
|
|
1006
|
+
checks.push({ name: ".env.example", category: "Documentation", points: 0, maxPoints: 3, status: "fail", detail: "Missing — agents can't set up env" });
|
|
1007
|
+
}
|
|
1008
|
+
// --- Infrastructure (30 points max) ---
|
|
1009
|
+
// Git repo (5 pts)
|
|
1010
|
+
if (existsSync(join(p, ".git"))) {
|
|
1011
|
+
checks.push({ name: "Git repository", category: "Infrastructure", points: 5, maxPoints: 5, status: "pass", detail: "Initialized" });
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
1014
|
+
checks.push({ name: "Git repository", category: "Infrastructure", points: 0, maxPoints: 5, status: "fail", detail: "Not a git repo" });
|
|
1015
|
+
}
|
|
1016
|
+
// .gitignore (3 pts) — validates essential patterns, not just existence
|
|
1017
|
+
const gitignoreScorePath = join(p, ".gitignore");
|
|
1018
|
+
if (existsSync(gitignoreScorePath)) {
|
|
1019
|
+
const giContent = readFileSync(gitignoreScorePath, "utf-8");
|
|
1020
|
+
const essentialPatterns = [".env", "node_modules", "dist", "vendor", ".DS_Store", "*.log"];
|
|
1021
|
+
const foundPatterns = essentialPatterns.filter(pat => giContent.includes(pat));
|
|
1022
|
+
if (foundPatterns.length >= 3) {
|
|
1023
|
+
checks.push({ name: ".gitignore", category: "Infrastructure", points: 3, maxPoints: 3, status: "pass", detail: `${foundPatterns.length} essential patterns` });
|
|
1024
|
+
}
|
|
1025
|
+
else if (foundPatterns.length >= 1) {
|
|
1026
|
+
checks.push({ name: ".gitignore", category: "Infrastructure", points: 2, maxPoints: 3, status: "partial", detail: `Only ${foundPatterns.length} essential pattern(s) — add .env, node_modules, dist` });
|
|
1027
|
+
}
|
|
1028
|
+
else {
|
|
1029
|
+
checks.push({ name: ".gitignore", category: "Infrastructure", points: 1, maxPoints: 3, status: "partial", detail: "Exists but missing essential patterns (.env, node_modules)" });
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
else {
|
|
1033
|
+
checks.push({ name: ".gitignore", category: "Infrastructure", points: 0, maxPoints: 3, status: "fail", detail: "Missing" });
|
|
1034
|
+
}
|
|
1035
|
+
// Git hooks (5 pts)
|
|
1036
|
+
const hookDir = join(p, "hooks");
|
|
1037
|
+
const gitHookDir = join(p, ".git", "hooks");
|
|
1038
|
+
const hasPostCommit = existsSync(join(hookDir, "post-commit")) || existsSync(join(gitHookDir, "post-commit"));
|
|
1039
|
+
if (hasPostCommit) {
|
|
1040
|
+
checks.push({ name: "Git hooks", category: "Infrastructure", points: 5, maxPoints: 5, status: "pass", detail: "post-commit hook configured" });
|
|
1041
|
+
}
|
|
1042
|
+
else {
|
|
1043
|
+
checks.push({ name: "Git hooks", category: "Infrastructure", points: 0, maxPoints: 5, status: "fail", detail: "No hooks — consider auto-push" });
|
|
1044
|
+
}
|
|
1045
|
+
// Docker / containerization (5 pts)
|
|
1046
|
+
// Context-aware: only award points if Docker is actually used for deployment.
|
|
1047
|
+
// A project that deploys via rsync/PM2/Vercel/Netlify shouldn't be penalized
|
|
1048
|
+
// for not having Docker, and shouldn't be rewarded for a dummy docker-compose.
|
|
1049
|
+
const hasDockerfile = existsSync(join(p, "Dockerfile"));
|
|
1050
|
+
const hasCompose = existsSync(join(p, "docker-compose.yml")) || existsSync(join(p, "docker-compose.prod.yml"));
|
|
1051
|
+
// Detect if project actually uses Docker in its deployment
|
|
1052
|
+
const usesDockerForReal = (() => {
|
|
1053
|
+
// If Dockerfile has real content (not just a stub), it's genuine
|
|
1054
|
+
if (hasDockerfile) {
|
|
1055
|
+
try {
|
|
1056
|
+
const df = readFileSync(join(p, "Dockerfile"), "utf-8");
|
|
1057
|
+
const effectiveLines = df.split("\n").filter(l => l.trim() && !l.trim().startsWith("#")).length;
|
|
1058
|
+
if (effectiveLines >= 3)
|
|
1059
|
+
return true; // Real Dockerfile
|
|
1060
|
+
}
|
|
1061
|
+
catch { /* ignore */ }
|
|
1062
|
+
}
|
|
1063
|
+
// If docker-compose has services with image/build, it's genuine
|
|
1064
|
+
if (hasCompose) {
|
|
1065
|
+
try {
|
|
1066
|
+
const composePath = existsSync(join(p, "docker-compose.yml"))
|
|
1067
|
+
? join(p, "docker-compose.yml")
|
|
1068
|
+
: join(p, "docker-compose.prod.yml");
|
|
1069
|
+
const dc = readFileSync(composePath, "utf-8");
|
|
1070
|
+
if (dc.includes("image:") || dc.includes("build:"))
|
|
1071
|
+
return true; // Real compose
|
|
1072
|
+
}
|
|
1073
|
+
catch { /* ignore */ }
|
|
1074
|
+
}
|
|
1075
|
+
return false;
|
|
1076
|
+
})();
|
|
1077
|
+
// Check if project has alternative deploy methods (rsync, Vercel, Netlify, Render, etc.)
|
|
1078
|
+
const hasAltDeploy = existsSync(join(p, "vercel.json")) ||
|
|
1079
|
+
existsSync(join(p, "netlify.toml")) ||
|
|
1080
|
+
existsSync(join(p, "render.yaml")) ||
|
|
1081
|
+
existsSync(join(p, "fly.toml")) ||
|
|
1082
|
+
existsSync(join(p, "railway.json"));
|
|
1083
|
+
if (usesDockerForReal && hasDockerfile && hasCompose) {
|
|
1084
|
+
checks.push({ name: "Docker", category: "Infrastructure", points: 5, maxPoints: 5, status: "pass", detail: "Dockerfile + compose (active deployment)" });
|
|
1085
|
+
}
|
|
1086
|
+
else if (usesDockerForReal && (hasDockerfile || hasCompose)) {
|
|
1087
|
+
checks.push({ name: "Docker", category: "Infrastructure", points: 3, maxPoints: 5, status: "partial", detail: hasDockerfile ? "Dockerfile only" : "Compose only" });
|
|
1088
|
+
}
|
|
1089
|
+
else if ((hasDockerfile || hasCompose) && !usesDockerForReal) {
|
|
1090
|
+
// Files exist but look like stubs/placeholders — minimal credit
|
|
1091
|
+
checks.push({ name: "Docker", category: "Infrastructure", points: 1, maxPoints: 5, status: "partial", detail: "Docker files exist but appear to be placeholders — not used in deployment" });
|
|
1092
|
+
}
|
|
1093
|
+
else if (hasAltDeploy) {
|
|
1094
|
+
// Project uses a different deploy platform — Docker is N/A, award full points
|
|
1095
|
+
checks.push({ name: "Containerization", category: "Infrastructure", points: 5, maxPoints: 5, status: "pass", detail: "Uses managed platform (Vercel/Netlify/Render/Fly)" });
|
|
1096
|
+
}
|
|
1097
|
+
else {
|
|
1098
|
+
checks.push({ name: "Docker", category: "Infrastructure", points: 0, maxPoints: 5, status: "fail", detail: "Not containerized" });
|
|
1099
|
+
}
|
|
1100
|
+
// CI config (5 pts) — validates workflows have real actions, not empty stubs
|
|
1101
|
+
const ciPaths = [".github/workflows", ".gitlab-ci.yml", "Jenkinsfile", ".circleci", ".travis.yml"];
|
|
1102
|
+
const foundCI = ciPaths.filter(ci => existsSync(join(p, ci)));
|
|
1103
|
+
if (foundCI.length > 0) {
|
|
1104
|
+
let ciHasActions = false;
|
|
1105
|
+
const ghWorkflows = join(p, ".github", "workflows");
|
|
1106
|
+
if (existsSync(ghWorkflows)) {
|
|
1107
|
+
try {
|
|
1108
|
+
const wfFiles = readdirSync(ghWorkflows).filter(f => f.endsWith(".yml") || f.endsWith(".yaml"));
|
|
1109
|
+
for (const wf of wfFiles) {
|
|
1110
|
+
const wfContent = readFileSync(join(ghWorkflows, wf), "utf-8");
|
|
1111
|
+
if (wfContent.includes("run:") || wfContent.includes("uses:")) {
|
|
1112
|
+
ciHasActions = true;
|
|
1113
|
+
break;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
catch { /* ignore read errors */ }
|
|
1118
|
+
}
|
|
1119
|
+
else {
|
|
1120
|
+
// Non-GH CI (gitlab-ci.yml, Jenkinsfile, etc.) — trust existence since formats vary
|
|
1121
|
+
ciHasActions = true;
|
|
1122
|
+
}
|
|
1123
|
+
if (ciHasActions) {
|
|
1124
|
+
checks.push({ name: "CI/CD", category: "Infrastructure", points: 5, maxPoints: 5, status: "pass", detail: foundCI.join(", ") });
|
|
1125
|
+
}
|
|
1126
|
+
else {
|
|
1127
|
+
checks.push({ name: "CI/CD", category: "Infrastructure", points: 1, maxPoints: 5, status: "partial", detail: "Workflows exist but no run/uses actions found — may be stubs" });
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
else {
|
|
1131
|
+
checks.push({ name: "CI/CD", category: "Infrastructure", points: 0, maxPoints: 5, status: "fail", detail: "No CI pipeline" });
|
|
1132
|
+
}
|
|
1133
|
+
// Deploy script (4 pts) — verifies real content, not empty placeholder
|
|
1134
|
+
const deployPaths = ["deploy.sh", "deploy.js", "Makefile"];
|
|
1135
|
+
const foundDeploy = deployPaths.filter(d => existsSync(join(p, d)));
|
|
1136
|
+
if (foundDeploy.length > 0) {
|
|
1137
|
+
const deployFile = join(p, foundDeploy[0]);
|
|
1138
|
+
const deployContent = readFileSync(deployFile, "utf-8");
|
|
1139
|
+
const deployLines = deployContent.split("\n").filter(l => l.trim() && !l.trim().startsWith("#")).length;
|
|
1140
|
+
if (deployLines >= 3) {
|
|
1141
|
+
checks.push({ name: "Deploy script", category: "Infrastructure", points: 4, maxPoints: 4, status: "pass", detail: `${foundDeploy[0]} (${deployLines} effective lines)` });
|
|
1142
|
+
}
|
|
1143
|
+
else {
|
|
1144
|
+
checks.push({ name: "Deploy script", category: "Infrastructure", points: 1, maxPoints: 4, status: "partial", detail: `${foundDeploy[0]} — only ${deployLines} effective lines, looks like a placeholder` });
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
else {
|
|
1148
|
+
checks.push({ name: "Deploy script", category: "Infrastructure", points: 0, maxPoints: 4, status: "fail", detail: "No deploy automation" });
|
|
1149
|
+
}
|
|
1150
|
+
// PM2 / process manager (3 pts)
|
|
1151
|
+
if (existsSync(join(p, "ecosystem.config.js")) || existsSync(join(p, "ecosystem.config.cjs"))) {
|
|
1152
|
+
checks.push({ name: "Process manager", category: "Infrastructure", points: 3, maxPoints: 3, status: "pass", detail: "PM2 ecosystem config" });
|
|
1153
|
+
}
|
|
1154
|
+
else {
|
|
1155
|
+
checks.push({ name: "Process manager", category: "Infrastructure", points: 0, maxPoints: 3, status: "fail", detail: "No PM2 config" });
|
|
1156
|
+
}
|
|
1157
|
+
// --- Code Quality (20 points max) ---
|
|
1158
|
+
// Tests directory (8 pts) — checks for real test files, detects symlinks
|
|
1159
|
+
const testDirs = ["tests", "test", "__tests__", "spec", "src/__tests__"];
|
|
1160
|
+
const foundTests = testDirs.filter(td => existsSync(join(p, td)));
|
|
1161
|
+
if (foundTests.length > 0) {
|
|
1162
|
+
const testDirPath = join(p, foundTests[0]);
|
|
1163
|
+
const testIsSymlink = isSymlink(testDirPath);
|
|
1164
|
+
const testFileCount = countTestFiles(testDirPath);
|
|
1165
|
+
if (testIsSymlink) {
|
|
1166
|
+
checks.push({ name: "Tests", category: "Code Quality", points: 3, maxPoints: 8, status: "partial", detail: `${foundTests[0]}/ is a symlink (${testFileCount} test files) — should be real test directory` });
|
|
1167
|
+
}
|
|
1168
|
+
else if (testFileCount >= 5) {
|
|
1169
|
+
checks.push({ name: "Tests", category: "Code Quality", points: 8, maxPoints: 8, status: "pass", detail: `${foundTests[0]}/ — ${testFileCount} test files` });
|
|
1170
|
+
}
|
|
1171
|
+
else if (testFileCount > 0) {
|
|
1172
|
+
checks.push({ name: "Tests", category: "Code Quality", points: 5, maxPoints: 8, status: "partial", detail: `${foundTests[0]}/ — only ${testFileCount} test files` });
|
|
1173
|
+
}
|
|
1174
|
+
else {
|
|
1175
|
+
try {
|
|
1176
|
+
const hasAnyFiles = readdirSync(testDirPath).length > 0;
|
|
1177
|
+
if (hasAnyFiles) {
|
|
1178
|
+
checks.push({ name: "Tests", category: "Code Quality", points: 4, maxPoints: 8, status: "partial", detail: `${foundTests[0]}/ has files but no standard test files detected` });
|
|
1179
|
+
}
|
|
1180
|
+
else {
|
|
1181
|
+
checks.push({ name: "Tests", category: "Code Quality", points: 1, maxPoints: 8, status: "partial", detail: `${foundTests[0]}/ exists but empty` });
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
catch {
|
|
1185
|
+
checks.push({ name: "Tests", category: "Code Quality", points: 1, maxPoints: 8, status: "partial", detail: `${foundTests[0]}/ exists but unreadable` });
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
else {
|
|
1190
|
+
checks.push({ name: "Tests", category: "Code Quality", points: 0, maxPoints: 8, status: "fail", detail: "No test directory" });
|
|
1191
|
+
}
|
|
1192
|
+
// TypeScript / type checking (5 pts)
|
|
1193
|
+
const tsconfigPath = join(p, "tsconfig.json");
|
|
1194
|
+
if (existsSync(tsconfigPath)) {
|
|
1195
|
+
const tsconfigContent = readFileSync(tsconfigPath, "utf-8").trim();
|
|
1196
|
+
const tsconfigIsSymlink = isSymlink(tsconfigPath);
|
|
1197
|
+
// Detect minimal/reference-only tsconfigs (just project references with no real config)
|
|
1198
|
+
const isSubstantive = tsconfigContent.length > 50 && (tsconfigContent.includes('"compilerOptions"') || tsconfigContent.includes('"extends"'));
|
|
1199
|
+
if (tsconfigIsSymlink) {
|
|
1200
|
+
checks.push({ name: "TypeScript", category: "Code Quality", points: 2, maxPoints: 5, status: "partial", detail: "tsconfig.json is a symlink — create root config" });
|
|
1201
|
+
}
|
|
1202
|
+
else if (isSubstantive) {
|
|
1203
|
+
checks.push({ name: "TypeScript", category: "Code Quality", points: 5, maxPoints: 5, status: "pass", detail: "tsconfig.json present" });
|
|
1204
|
+
}
|
|
1205
|
+
else {
|
|
1206
|
+
checks.push({ name: "TypeScript", category: "Code Quality", points: 3, maxPoints: 5, status: "partial", detail: "tsconfig.json is minimal — add compilerOptions for full type safety" });
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
else if (existsSync(join(p, "jsconfig.json"))) {
|
|
1210
|
+
checks.push({ name: "Type checking", category: "Code Quality", points: 2, maxPoints: 5, status: "partial", detail: "jsconfig.json only" });
|
|
1211
|
+
}
|
|
1212
|
+
else {
|
|
1213
|
+
checks.push({ name: "Type checking", category: "Code Quality", points: 0, maxPoints: 5, status: "fail", detail: "No tsconfig/jsconfig" });
|
|
1214
|
+
}
|
|
1215
|
+
// Linting config (4 pts) — verifies linting tools are installed, not just config
|
|
1216
|
+
const lintConfigs = [".eslintrc.js", ".eslintrc.json", ".eslintrc.yml", "eslint.config.js", "eslint.config.mjs", ".prettierrc", "phpcs.xml"];
|
|
1217
|
+
const foundLint = lintConfigs.filter(l => existsSync(join(p, l)));
|
|
1218
|
+
const lintSymlinks = foundLint.filter(l => isSymlink(join(p, l)));
|
|
1219
|
+
if (foundLint.length > 0) {
|
|
1220
|
+
const lintToolsInstalled = isLintInstalled(p);
|
|
1221
|
+
if (lintSymlinks.length === foundLint.length) {
|
|
1222
|
+
checks.push({ name: "Linting", category: "Code Quality", points: 1, maxPoints: 4, status: "partial", detail: `${foundLint.join(", ")} — all symlinks, create root lint config` });
|
|
1223
|
+
}
|
|
1224
|
+
else if (!lintToolsInstalled) {
|
|
1225
|
+
checks.push({ name: "Linting", category: "Code Quality", points: 2, maxPoints: 4, status: "partial", detail: `${foundLint.join(", ")} — ⚠ config exists but linting tools not installed` });
|
|
1226
|
+
}
|
|
1227
|
+
else {
|
|
1228
|
+
checks.push({ name: "Linting", category: "Code Quality", points: 4, maxPoints: 4, status: "pass", detail: foundLint.join(", ") });
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
else {
|
|
1232
|
+
checks.push({ name: "Linting", category: "Code Quality", points: 0, maxPoints: 4, status: "fail", detail: "No lint config" });
|
|
1233
|
+
}
|
|
1234
|
+
// Package scripts / build commands (3 pts)
|
|
1235
|
+
const pkgPath = join(p, "package.json");
|
|
1236
|
+
if (existsSync(pkgPath)) {
|
|
1237
|
+
try {
|
|
1238
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
1239
|
+
const scripts = Object.keys(pkg.scripts || {});
|
|
1240
|
+
const hasUseful = scripts.some(s => ["build", "dev", "start", "test", "lint"].includes(s));
|
|
1241
|
+
if (hasUseful) {
|
|
1242
|
+
checks.push({ name: "npm scripts", category: "Code Quality", points: 3, maxPoints: 3, status: "pass", detail: scripts.slice(0, 6).join(", ") });
|
|
1243
|
+
}
|
|
1244
|
+
else {
|
|
1245
|
+
checks.push({ name: "npm scripts", category: "Code Quality", points: 1, maxPoints: 3, status: "partial", detail: `Has scripts but no build/dev/test: ${scripts.join(", ")}` });
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
catch { /* ignore */ }
|
|
1249
|
+
}
|
|
1250
|
+
// --- Security (20 points max) ---
|
|
1251
|
+
// .env in .gitignore (8 pts)
|
|
1252
|
+
const gitignorePath = join(p, ".gitignore");
|
|
1253
|
+
if (existsSync(gitignorePath)) {
|
|
1254
|
+
const gitignore = readFileSync(gitignorePath, "utf-8");
|
|
1255
|
+
if (gitignore.includes(".env")) {
|
|
1256
|
+
checks.push({ name: ".env in .gitignore", category: "Security", points: 8, maxPoints: 8, status: "pass", detail: ".env is gitignored" });
|
|
1257
|
+
}
|
|
1258
|
+
else {
|
|
1259
|
+
checks.push({ name: ".env in .gitignore", category: "Security", points: 0, maxPoints: 8, status: "fail", detail: ".env NOT in .gitignore — secrets at risk!" });
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
else {
|
|
1263
|
+
checks.push({ name: ".env in .gitignore", category: "Security", points: 0, maxPoints: 8, status: "fail", detail: "No .gitignore at all" });
|
|
1264
|
+
}
|
|
1265
|
+
// No secrets in tracked files (6 pts)
|
|
1266
|
+
if (existsSync(join(p, ".env")) && existsSync(join(p, ".git"))) {
|
|
1267
|
+
const tracked = exec("git ls-files .env", p);
|
|
1268
|
+
if (tracked === ".env") {
|
|
1269
|
+
checks.push({ name: "Secrets exposure", category: "Security", points: 0, maxPoints: 6, status: "fail", detail: ".env is tracked by git!" });
|
|
1270
|
+
}
|
|
1271
|
+
else {
|
|
1272
|
+
checks.push({ name: "Secrets exposure", category: "Security", points: 6, maxPoints: 6, status: "pass", detail: ".env not tracked" });
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
else {
|
|
1276
|
+
checks.push({ name: "Secrets exposure", category: "Security", points: 6, maxPoints: 6, status: "pass", detail: "No .env or not a git repo" });
|
|
1277
|
+
}
|
|
1278
|
+
// Lockfile present (3 pts)
|
|
1279
|
+
const lockfiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "composer.lock"];
|
|
1280
|
+
const foundLock = lockfiles.filter(l => existsSync(join(p, l)));
|
|
1281
|
+
if (foundLock.length > 0) {
|
|
1282
|
+
checks.push({ name: "Lockfile", category: "Security", points: 3, maxPoints: 3, status: "pass", detail: foundLock.join(", ") });
|
|
1283
|
+
}
|
|
1284
|
+
else if (existsSync(pkgPath) || existsSync(join(p, "composer.json"))) {
|
|
1285
|
+
checks.push({ name: "Lockfile", category: "Security", points: 0, maxPoints: 3, status: "fail", detail: "No lockfile — deps not pinned" });
|
|
1286
|
+
}
|
|
1287
|
+
// node_modules in .gitignore (3 pts)
|
|
1288
|
+
if (existsSync(gitignorePath)) {
|
|
1289
|
+
const gitignore = readFileSync(gitignorePath, "utf-8");
|
|
1290
|
+
if (gitignore.includes("node_modules") || gitignore.includes("vendor")) {
|
|
1291
|
+
checks.push({ name: "Deps gitignored", category: "Security", points: 3, maxPoints: 3, status: "pass", detail: "node_modules/vendor gitignored" });
|
|
1292
|
+
}
|
|
1293
|
+
else if (!existsSync(join(p, "package.json")) && !existsSync(join(p, "composer.json"))) {
|
|
1294
|
+
checks.push({ name: "Deps gitignored", category: "Security", points: 3, maxPoints: 3, status: "pass", detail: "N/A — no package manager" });
|
|
1295
|
+
}
|
|
1296
|
+
else {
|
|
1297
|
+
checks.push({ name: "Deps gitignored", category: "Security", points: 0, maxPoints: 3, status: "fail", detail: "node_modules/vendor not in .gitignore" });
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
// --- Calculate totals ---
|
|
1301
|
+
const totalScore = checks.reduce((sum, c) => sum + c.points, 0);
|
|
1302
|
+
const maxScore = checks.reduce((sum, c) => sum + c.maxPoints, 0);
|
|
1303
|
+
const percentage = Math.round((totalScore / maxScore) * 100);
|
|
1304
|
+
let grade;
|
|
1305
|
+
if (percentage >= 90)
|
|
1306
|
+
grade = "A+";
|
|
1307
|
+
else if (percentage >= 80)
|
|
1308
|
+
grade = "A";
|
|
1309
|
+
else if (percentage >= 70)
|
|
1310
|
+
grade = "B";
|
|
1311
|
+
else if (percentage >= 60)
|
|
1312
|
+
grade = "C";
|
|
1313
|
+
else if (percentage >= 50)
|
|
1314
|
+
grade = "D";
|
|
1315
|
+
else
|
|
1316
|
+
grade = "F";
|
|
1317
|
+
return {
|
|
1318
|
+
project: dir.name,
|
|
1319
|
+
path: dir.path,
|
|
1320
|
+
score: totalScore,
|
|
1321
|
+
maxScore,
|
|
1322
|
+
percentage,
|
|
1323
|
+
grade,
|
|
1324
|
+
checks,
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* Format a project score report as Markdown.
|
|
1329
|
+
*/
|
|
1330
|
+
export function formatScoreReport(scores) {
|
|
1331
|
+
const lines = [];
|
|
1332
|
+
// Summary table
|
|
1333
|
+
lines.push("# 🎯 AI-Readiness Scores\n");
|
|
1334
|
+
lines.push("| Project | Score | Grade | Doc | Infra | Quality | Security |");
|
|
1335
|
+
lines.push("|---------|-------|-------|-----|-------|---------|----------|");
|
|
1336
|
+
for (const s of scores) {
|
|
1337
|
+
const byCategory = new Map();
|
|
1338
|
+
for (const c of s.checks) {
|
|
1339
|
+
const cat = byCategory.get(c.category) || { pts: 0, max: 0 };
|
|
1340
|
+
cat.pts += c.points;
|
|
1341
|
+
cat.max += c.maxPoints;
|
|
1342
|
+
byCategory.set(c.category, cat);
|
|
1343
|
+
}
|
|
1344
|
+
const doc = byCategory.get("Documentation") || { pts: 0, max: 0 };
|
|
1345
|
+
const infra = byCategory.get("Infrastructure") || { pts: 0, max: 0 };
|
|
1346
|
+
const quality = byCategory.get("Code Quality") || { pts: 0, max: 0 };
|
|
1347
|
+
const security = byCategory.get("Security") || { pts: 0, max: 0 };
|
|
1348
|
+
const gradeEmoji = s.percentage >= 80 ? "🟢" : s.percentage >= 60 ? "🟡" : "🔴";
|
|
1349
|
+
lines.push(`| ${s.project} | **${s.percentage}%** | ${gradeEmoji} ${s.grade} | ${doc.pts}/${doc.max} | ${infra.pts}/${infra.max} | ${quality.pts}/${quality.max} | ${security.pts}/${security.max} |`);
|
|
1350
|
+
}
|
|
1351
|
+
// Sort by score descending
|
|
1352
|
+
const sorted = [...scores].sort((a, b) => b.percentage - a.percentage);
|
|
1353
|
+
// Top performers
|
|
1354
|
+
const top3 = sorted.slice(0, 3);
|
|
1355
|
+
lines.push("\n## 🏆 Top Performers");
|
|
1356
|
+
for (const s of top3) {
|
|
1357
|
+
lines.push(`- **${s.project}** — ${s.percentage}% (${s.grade})`);
|
|
1358
|
+
}
|
|
1359
|
+
// Needs work
|
|
1360
|
+
const needsWork = sorted.filter(s => s.percentage < 60);
|
|
1361
|
+
if (needsWork.length > 0) {
|
|
1362
|
+
lines.push("\n## ⚠️ Needs Work");
|
|
1363
|
+
for (const s of needsWork) {
|
|
1364
|
+
const fails = s.checks.filter(c => c.status === "fail");
|
|
1365
|
+
lines.push(`- **${s.project}** — ${s.percentage}% (${s.grade}): ${fails.map(f => f.name).join(", ")}`);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
// Detailed per-project breakdown (top 5 only to keep output manageable)
|
|
1369
|
+
lines.push("\n## 📋 Detailed Breakdown\n");
|
|
1370
|
+
for (const s of sorted.slice(0, 5)) {
|
|
1371
|
+
lines.push(`### ${s.project} — ${s.percentage}% (${s.grade})\n`);
|
|
1372
|
+
lines.push("| Check | Category | Score | Status | Detail |");
|
|
1373
|
+
lines.push("|-------|----------|-------|--------|--------|");
|
|
1374
|
+
for (const c of s.checks) {
|
|
1375
|
+
const icon = c.status === "pass" ? "✅" : c.status === "partial" ? "🟡" : "❌";
|
|
1376
|
+
lines.push(`| ${c.name} | ${c.category} | ${c.points}/${c.maxPoints} | ${icon} | ${c.detail} |`);
|
|
1377
|
+
}
|
|
1378
|
+
lines.push("");
|
|
1379
|
+
}
|
|
1380
|
+
// Overall stats
|
|
1381
|
+
const avgScore = Math.round(scores.reduce((sum, s) => sum + s.percentage, 0) / scores.length);
|
|
1382
|
+
lines.push(`\n---\n**${scores.length} projects scanned** | Average AI-readiness: **${avgScore}%**`);
|
|
1383
|
+
return lines.join("\n");
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Generate a per-project SCORE.md file content.
|
|
1387
|
+
* Written to each project root so the score is committed alongside code.
|
|
1388
|
+
*/
|
|
1389
|
+
export function generateProjectScoreMD(score) {
|
|
1390
|
+
const lines = [];
|
|
1391
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
1392
|
+
lines.push("# SCORE.md — AI-Readiness Score\n");
|
|
1393
|
+
lines.push(`**Project**: ${score.project}`);
|
|
1394
|
+
lines.push(`**Score**: ${score.score}/${score.maxScore} (${score.grade})`);
|
|
1395
|
+
lines.push(`**Date**: ${date}\n`);
|
|
1396
|
+
// Category summary
|
|
1397
|
+
const byCategory = new Map();
|
|
1398
|
+
for (const c of score.checks) {
|
|
1399
|
+
const cat = byCategory.get(c.category) || { pts: 0, max: 0 };
|
|
1400
|
+
cat.pts += c.points;
|
|
1401
|
+
cat.max += c.maxPoints;
|
|
1402
|
+
byCategory.set(c.category, cat);
|
|
1403
|
+
}
|
|
1404
|
+
lines.push("## Summary\n");
|
|
1405
|
+
lines.push("| Category | Score | Max | Status |");
|
|
1406
|
+
lines.push("|---|---|---|---|");
|
|
1407
|
+
for (const [cat, vals] of byCategory) {
|
|
1408
|
+
const pct = Math.round((vals.pts / vals.max) * 100);
|
|
1409
|
+
const icon = pct >= 80 ? "✅" : pct >= 60 ? "🟡" : "❌";
|
|
1410
|
+
lines.push(`| ${cat} | ${vals.pts} | ${vals.max} | ${icon} ${pct}% |`);
|
|
1411
|
+
}
|
|
1412
|
+
// Detailed checks
|
|
1413
|
+
lines.push("\n## Breakdown\n");
|
|
1414
|
+
lines.push("| Check | Category | Score | Max | Status | Detail |");
|
|
1415
|
+
lines.push("|---|---|---|---|---|---|");
|
|
1416
|
+
for (const c of score.checks) {
|
|
1417
|
+
const icon = c.status === "pass" ? "✅" : c.status === "partial" ? "🟡" : "❌";
|
|
1418
|
+
lines.push(`| ${c.name} | ${c.category} | ${c.points} | ${c.maxPoints} | ${icon} | ${c.detail} |`);
|
|
1419
|
+
}
|
|
1420
|
+
// Failures and improvements
|
|
1421
|
+
const fails = score.checks.filter(c => c.status === "fail");
|
|
1422
|
+
const partials = score.checks.filter(c => c.status === "partial");
|
|
1423
|
+
if (fails.length > 0 || partials.length > 0) {
|
|
1424
|
+
lines.push("\n## Improvements Needed\n");
|
|
1425
|
+
for (const f of fails) {
|
|
1426
|
+
lines.push(`- ❌ **${f.name}**: ${f.detail}`);
|
|
1427
|
+
}
|
|
1428
|
+
for (const p of partials) {
|
|
1429
|
+
lines.push(`- 🟡 **${p.name}**: ${p.detail}`);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
lines.push(`\n---\n*Generated by [ContextEngine](https://www.npmjs.com/package/@compr/contextengine-mcp) on ${date}*\n`);
|
|
1433
|
+
return lines.join("\n");
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* Generate an HTML visual report for project scores.
|
|
1437
|
+
* Produces a self-contained HTML page with embedded CSS — no external dependencies.
|
|
1438
|
+
*/
|
|
1439
|
+
export function generateScoreHTML(scores) {
|
|
1440
|
+
const sorted = [...scores].sort((a, b) => b.percentage - a.percentage);
|
|
1441
|
+
const avgScore = Math.round(scores.reduce((sum, s) => sum + s.percentage, 0) / scores.length);
|
|
1442
|
+
const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
1443
|
+
function gradeColor(pct) {
|
|
1444
|
+
if (pct >= 80)
|
|
1445
|
+
return "#22c55e";
|
|
1446
|
+
if (pct >= 60)
|
|
1447
|
+
return "#eab308";
|
|
1448
|
+
if (pct >= 40)
|
|
1449
|
+
return "#f97316";
|
|
1450
|
+
return "#ef4444";
|
|
1451
|
+
}
|
|
1452
|
+
function statusIcon(status) {
|
|
1453
|
+
if (status === "pass")
|
|
1454
|
+
return "✅";
|
|
1455
|
+
if (status === "partial")
|
|
1456
|
+
return "🟡";
|
|
1457
|
+
return "❌";
|
|
1458
|
+
}
|
|
1459
|
+
function categoryByScore(checks) {
|
|
1460
|
+
const m = new Map();
|
|
1461
|
+
for (const c of checks) {
|
|
1462
|
+
const cat = m.get(c.category) || { pts: 0, max: 0 };
|
|
1463
|
+
cat.pts += c.points;
|
|
1464
|
+
cat.max += c.maxPoints;
|
|
1465
|
+
m.set(c.category, cat);
|
|
1466
|
+
}
|
|
1467
|
+
return m;
|
|
1468
|
+
}
|
|
1469
|
+
const categoryColors = {
|
|
1470
|
+
Documentation: "#3b82f6",
|
|
1471
|
+
Infrastructure: "#8b5cf6",
|
|
1472
|
+
"Code Quality": "#06b6d4",
|
|
1473
|
+
Security: "#f59e0b",
|
|
1474
|
+
};
|
|
1475
|
+
// Build project cards
|
|
1476
|
+
const projectCards = sorted.map(s => {
|
|
1477
|
+
const cats = categoryByScore(s.checks);
|
|
1478
|
+
const gc = gradeColor(s.percentage);
|
|
1479
|
+
const categoryBars = ["Documentation", "Infrastructure", "Code Quality", "Security"]
|
|
1480
|
+
.map(cat => {
|
|
1481
|
+
const data = cats.get(cat) || { pts: 0, max: 0 };
|
|
1482
|
+
const pct = data.max > 0 ? Math.round((data.pts / data.max) * 100) : 0;
|
|
1483
|
+
const color = categoryColors[cat] || "#888";
|
|
1484
|
+
return `
|
|
1485
|
+
<div class="cat-row">
|
|
1486
|
+
<span class="cat-label">${cat}</span>
|
|
1487
|
+
<div class="bar-bg">
|
|
1488
|
+
<div class="bar-fill" style="width:${pct}%;background:${color}"></div>
|
|
1489
|
+
</div>
|
|
1490
|
+
<span class="cat-score">${data.pts}/${data.max}</span>
|
|
1491
|
+
</div>`;
|
|
1492
|
+
}).join("");
|
|
1493
|
+
const checkRows = s.checks.map(c => {
|
|
1494
|
+
const barPct = c.maxPoints > 0 ? Math.round((c.points / c.maxPoints) * 100) : 0;
|
|
1495
|
+
const isSymlinkWarning = c.detail.includes("symlink") || c.detail.includes("Symlink");
|
|
1496
|
+
return `
|
|
1497
|
+
<tr${isSymlinkWarning ? ' class="symlink-warning"' : ""}>
|
|
1498
|
+
<td>${statusIcon(c.status)}</td>
|
|
1499
|
+
<td>${c.name}</td>
|
|
1500
|
+
<td><span class="badge" style="background:${categoryColors[c.category] || "#888"}22;color:${categoryColors[c.category] || "#888"}">${c.category}</span></td>
|
|
1501
|
+
<td>
|
|
1502
|
+
<div class="mini-bar-bg"><div class="mini-bar-fill" style="width:${barPct}%;background:${gradeColor(barPct)}"></div></div>
|
|
1503
|
+
<span class="check-score">${c.points}/${c.maxPoints}</span>
|
|
1504
|
+
</td>
|
|
1505
|
+
<td class="detail">${c.detail}</td>
|
|
1506
|
+
</tr>`;
|
|
1507
|
+
}).join("");
|
|
1508
|
+
return `
|
|
1509
|
+
<div class="card">
|
|
1510
|
+
<div class="card-header">
|
|
1511
|
+
<div class="project-info">
|
|
1512
|
+
<h2>${s.project}</h2>
|
|
1513
|
+
<span class="project-path">${s.path}</span>
|
|
1514
|
+
</div>
|
|
1515
|
+
<div class="grade-circle" style="border-color:${gc}">
|
|
1516
|
+
<span class="grade-pct">${s.percentage}%</span>
|
|
1517
|
+
<span class="grade-letter" style="color:${gc}">${s.grade}</span>
|
|
1518
|
+
</div>
|
|
1519
|
+
</div>
|
|
1520
|
+
<div class="category-bars">${categoryBars}</div>
|
|
1521
|
+
<details>
|
|
1522
|
+
<summary>Show ${s.checks.length} checks</summary>
|
|
1523
|
+
<table class="checks-table">
|
|
1524
|
+
<thead>
|
|
1525
|
+
<tr><th></th><th>Check</th><th>Category</th><th>Score</th><th>Detail</th></tr>
|
|
1526
|
+
</thead>
|
|
1527
|
+
<tbody>${checkRows}</tbody>
|
|
1528
|
+
</table>
|
|
1529
|
+
</details>
|
|
1530
|
+
</div>`;
|
|
1531
|
+
}).join("");
|
|
1532
|
+
// Summary stats
|
|
1533
|
+
const gradeDistribution = { "A+": 0, A: 0, B: 0, C: 0, D: 0, F: 0 };
|
|
1534
|
+
for (const s of scores) {
|
|
1535
|
+
gradeDistribution[s.grade]++;
|
|
1536
|
+
}
|
|
1537
|
+
const gradeBars = Object.entries(gradeDistribution)
|
|
1538
|
+
.filter(([, count]) => count > 0)
|
|
1539
|
+
.map(([grade, count]) => {
|
|
1540
|
+
const pct = Math.round((count / scores.length) * 100);
|
|
1541
|
+
const color = grade.startsWith("A") ? "#22c55e" : grade === "B" ? "#3b82f6" : grade === "C" ? "#eab308" : "#ef4444";
|
|
1542
|
+
return `<div class="dist-bar"><span class="dist-label">${grade}</span><div class="dist-fill" style="width:${pct}%;background:${color}"></div><span class="dist-count">${count}</span></div>`;
|
|
1543
|
+
}).join("");
|
|
1544
|
+
return `<!DOCTYPE html>
|
|
1545
|
+
<html lang="en">
|
|
1546
|
+
<head>
|
|
1547
|
+
<meta charset="UTF-8">
|
|
1548
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1549
|
+
<title>ContextEngine Score Report</title>
|
|
1550
|
+
<style>
|
|
1551
|
+
:root {
|
|
1552
|
+
--bg: #0f172a; --surface: #1e293b; --border: #334155;
|
|
1553
|
+
--text: #f1f5f9; --muted: #94a3b8; --accent: #3b82f6;
|
|
1554
|
+
}
|
|
1555
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
1556
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); padding: 24px; max-width: 1200px; margin: 0 auto; }
|
|
1557
|
+
h1 { font-size: 1.75rem; margin-bottom: 4px; }
|
|
1558
|
+
.subtitle { color: var(--muted); font-size: 0.875rem; margin-bottom: 24px; }
|
|
1559
|
+
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 32px; }
|
|
1560
|
+
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px; text-align: center; }
|
|
1561
|
+
.stat-value { font-size: 2rem; font-weight: 700; }
|
|
1562
|
+
.stat-label { color: var(--muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
|
|
1563
|
+
.dist-bar { display: flex; align-items: center; gap: 8px; margin: 4px 0; }
|
|
1564
|
+
.dist-label { width: 24px; font-weight: 600; font-size: 0.85rem; }
|
|
1565
|
+
.dist-fill { height: 18px; border-radius: 4px; min-width: 4px; transition: width 0.5s; }
|
|
1566
|
+
.dist-count { color: var(--muted); font-size: 0.8rem; }
|
|
1567
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 24px; margin-bottom: 16px; }
|
|
1568
|
+
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
|
1569
|
+
.project-info h2 { font-size: 1.2rem; }
|
|
1570
|
+
.project-path { color: var(--muted); font-size: 0.75rem; font-family: monospace; }
|
|
1571
|
+
.grade-circle { width: 72px; height: 72px; border-radius: 50%; border: 3px solid; display: flex; flex-direction: column; align-items: center; justify-content: center; flex-shrink: 0; }
|
|
1572
|
+
.grade-pct { font-size: 1.1rem; font-weight: 700; line-height: 1.2; }
|
|
1573
|
+
.grade-letter { font-size: 0.75rem; font-weight: 600; }
|
|
1574
|
+
.category-bars { margin-bottom: 12px; }
|
|
1575
|
+
.cat-row { display: flex; align-items: center; gap: 8px; margin: 6px 0; }
|
|
1576
|
+
.cat-label { width: 110px; font-size: 0.8rem; color: var(--muted); flex-shrink: 0; }
|
|
1577
|
+
.bar-bg { flex: 1; height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; }
|
|
1578
|
+
.bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s; }
|
|
1579
|
+
.cat-score { width: 40px; text-align: right; font-size: 0.8rem; font-weight: 600; flex-shrink: 0; }
|
|
1580
|
+
details { margin-top: 8px; }
|
|
1581
|
+
summary { cursor: pointer; color: var(--accent); font-size: 0.85rem; padding: 4px 0; }
|
|
1582
|
+
.checks-table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 0.8rem; }
|
|
1583
|
+
.checks-table th { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); color: var(--muted); font-weight: 500; }
|
|
1584
|
+
.checks-table td { padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
|
1585
|
+
.checks-table tr:last-child td { border-bottom: none; }
|
|
1586
|
+
.checks-table tr.symlink-warning td { background: #f59e0b11; }
|
|
1587
|
+
.badge { padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; }
|
|
1588
|
+
.mini-bar-bg { display: inline-block; width: 48px; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; vertical-align: middle; margin-right: 4px; }
|
|
1589
|
+
.mini-bar-fill { height: 100%; border-radius: 3px; }
|
|
1590
|
+
.check-score { font-size: 0.75rem; }
|
|
1591
|
+
.detail { color: var(--muted); max-width: 350px; }
|
|
1592
|
+
.footer { text-align: center; color: var(--muted); font-size: 0.75rem; margin-top: 32px; padding-top: 16px; border-top: 1px solid var(--border); }
|
|
1593
|
+
.anti-gaming { background: #f59e0b11; border: 1px solid #f59e0b44; border-radius: 8px; padding: 12px 16px; margin-bottom: 24px; font-size: 0.8rem; color: #fbbf24; }
|
|
1594
|
+
.anti-gaming strong { color: #f59e0b; }
|
|
1595
|
+
@media (max-width: 640px) {
|
|
1596
|
+
.card-header { flex-direction: column; gap: 12px; text-align: center; }
|
|
1597
|
+
.summary { grid-template-columns: 1fr 1fr; }
|
|
1598
|
+
.detail { max-width: 180px; }
|
|
1599
|
+
}
|
|
1600
|
+
</style>
|
|
1601
|
+
</head>
|
|
1602
|
+
<body>
|
|
1603
|
+
<h1>🎯 AI-Readiness Score Report</h1>
|
|
1604
|
+
<p class="subtitle">Generated by ContextEngine v${AGENTS_VERSION} · ${timestamp}</p>
|
|
1605
|
+
|
|
1606
|
+
<div class="summary">
|
|
1607
|
+
<div class="stat-card">
|
|
1608
|
+
<div class="stat-value">${scores.length}</div>
|
|
1609
|
+
<div class="stat-label">Projects Scanned</div>
|
|
1610
|
+
</div>
|
|
1611
|
+
<div class="stat-card">
|
|
1612
|
+
<div class="stat-value" style="color:${gradeColor(avgScore)}">${avgScore}%</div>
|
|
1613
|
+
<div class="stat-label">Average Score</div>
|
|
1614
|
+
</div>
|
|
1615
|
+
<div class="stat-card">
|
|
1616
|
+
<div class="stat-value">${sorted[0]?.project || "—"}</div>
|
|
1617
|
+
<div class="stat-label">Top Project (${sorted[0]?.percentage || 0}%)</div>
|
|
1618
|
+
</div>
|
|
1619
|
+
${scores.length > 1 ? `<div class="stat-card">
|
|
1620
|
+
<div class="stat-label" style="margin-bottom:8px">Grade Distribution</div>
|
|
1621
|
+
${gradeBars}
|
|
1622
|
+
</div>` : ""}
|
|
1623
|
+
</div>
|
|
1624
|
+
|
|
1625
|
+
<div class="anti-gaming">
|
|
1626
|
+
<strong>⚠ Anti-gaming v2:</strong> Symlinks, ghost configs (ESLint without packages), empty test dirs, and placeholder files are detected and scored as partial. Only genuine project artifacts earn full points.
|
|
1627
|
+
</div>
|
|
1628
|
+
|
|
1629
|
+
${projectCards}
|
|
1630
|
+
|
|
1631
|
+
<div class="footer">
|
|
1632
|
+
<p>ContextEngine · <a href="https://www.npmjs.com/package/@compr/contextengine-mcp" style="color:var(--accent)">npm</a></p>
|
|
1633
|
+
<p style="margin-top:4px">Scoring: Documentation (30pts) · Infrastructure (30pts) · Code Quality (20pts) · Security (20pts)</p>
|
|
1634
|
+
</div>
|
|
1635
|
+
</body>
|
|
1636
|
+
</html>`;
|
|
1637
|
+
}
|
|
1638
|
+
//# sourceMappingURL=agents.js.map
|