@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/LICENSE +83 -0
  3. package/README.md +470 -0
  4. package/defaults/learnings.json +146 -0
  5. package/dist/activation.d.ts +48 -0
  6. package/dist/activation.js +377 -0
  7. package/dist/adapters.d.ts +101 -0
  8. package/dist/adapters.js +171 -0
  9. package/dist/agents.d.ts +137 -0
  10. package/dist/agents.js +1638 -0
  11. package/dist/audit.d.ts +23 -0
  12. package/dist/audit.js +163 -0
  13. package/dist/cache.d.ts +15 -0
  14. package/dist/cache.js +117 -0
  15. package/dist/claude-integration.d.ts +95 -0
  16. package/dist/claude-integration.js +247 -0
  17. package/dist/cli.d.ts +18 -0
  18. package/dist/cli.js +1823 -0
  19. package/dist/code-chunker.d.ts +12 -0
  20. package/dist/code-chunker.js +270 -0
  21. package/dist/collectors.d.ts +63 -0
  22. package/dist/collectors.js +617 -0
  23. package/dist/config.d.ts +73 -0
  24. package/dist/config.js +239 -0
  25. package/dist/embeddings.d.ts +36 -0
  26. package/dist/embeddings.js +124 -0
  27. package/dist/firewall.d.ts +133 -0
  28. package/dist/firewall.js +631 -0
  29. package/dist/hooks.d.ts +76 -0
  30. package/dist/hooks.js +313 -0
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.js +1081 -0
  33. package/dist/ingest.d.ts +32 -0
  34. package/dist/ingest.js +162 -0
  35. package/dist/learnings.d.ts +108 -0
  36. package/dist/learnings.js +714 -0
  37. package/dist/license-sig.d.ts +47 -0
  38. package/dist/license-sig.js +104 -0
  39. package/dist/policy.d.ts +131 -0
  40. package/dist/policy.js +182 -0
  41. package/dist/search.d.ts +11 -0
  42. package/dist/search.js +99 -0
  43. package/dist/sessions.d.ts +46 -0
  44. package/dist/sessions.js +153 -0
  45. package/examples/adapters/notion-adapter.js +108 -0
  46. package/examples/adapters/rss-adapter.js +76 -0
  47. package/package.json +87 -0
  48. 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