@aiready/cli 0.9.25 → 0.9.27

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.
@@ -1,18 +1,17 @@
1
-
2
- 
3
- > @aiready/cli@0.9.23 build /Users/pengcao/projects/aiready/packages/cli
4
- > tsup src/index.ts src/cli.ts --format cjs,esm
5
-
6
- CLI Building entry: src/cli.ts, src/index.ts
7
- CLI Using tsconfig: tsconfig.json
8
- CLI tsup v8.5.1
9
- CLI Target: es2020
10
- CJS Build start
11
- ESM Build start
12
- ESM dist/index.mjs 138.00 B
13
- ESM dist/chunk-5GZDRZ3T.mjs 4.17 KB
14
- ESM dist/cli.mjs 57.52 KB
15
- ESM ⚡️ Build success in 140ms
16
- CJS dist/index.js 4.93 KB
17
- CJS dist/cli.js 65.17 KB
18
- CJS ⚡️ Build success in 141ms
1
+
2
+ > @aiready/cli@0.9.26 build /Users/pengcao/projects/aiready/packages/cli
3
+ > tsup src/index.ts src/cli.ts --format cjs,esm
4
+
5
+ CLI Building entry: src/cli.ts, src/index.ts
6
+ CLI Using tsconfig: tsconfig.json
7
+ CLI tsup v8.5.1
8
+ CLI Target: es2020
9
+ CJS Build start
10
+ ESM Build start
11
+ CJS dist/cli.js 57.70 KB
12
+ CJS dist/index.js 4.93 KB
13
+ CJS ⚡️ Build success in 32ms
14
+ ESM dist/cli.mjs 50.05 KB
15
+ ESM dist/index.mjs 138.00 B
16
+ ESM dist/chunk-5GZDRZ3T.mjs 4.17 KB
17
+ ESM ⚡️ Build success in 32ms
@@ -1,17 +1,17 @@
1
1
 
2
2
  
3
- > @aiready/cli@0.9.23 test /Users/pengcao/projects/aiready/packages/cli
3
+ > @aiready/cli@0.9.25 test /Users/pengcao/projects/aiready/packages/cli
4
4
  > vitest run
5
5
 
6
6
  [?25l
7
7
   RUN  v4.0.18 /Users/pengcao/projects/aiready/packages/cli
8
8
 
9
- ✓ dist/__tests__/cli.test.js (3 tests) 5ms
10
- ✓ src/__tests__/cli.test.ts (3 tests) 67ms
9
+ ✓ dist/__tests__/cli.test.js (3 tests) 39ms
10
+ ✓ src/__tests__/cli.test.ts (3 tests) 7ms
11
11
 
12
12
   Test Files  2 passed (2)
13
13
   Tests  6 passed (6)
14
-  Start at  11:30:30
15
-  Duration  3.93s (transform 604ms, setup 0ms, import 4.85s, tests 72ms, environment 0ms)
14
+  Start at  14:43:49
15
+  Duration  5.00s (transform 1.06s, setup 0ms, import 7.11s, tests 47ms, environment 1ms)
16
16
 
17
17
  [?25h
package/dist/cli.js CHANGED
@@ -157,12 +157,24 @@ VERSION: ${packageJson.version}
157
157
  DOCUMENTATION: https://aiready.dev/docs/cli
158
158
  GITHUB: https://github.com/caopengau/aiready-cli
159
159
  LANDING: https://github.com/caopengau/aiready-landing`);
160
- program.command("scan").description("Run comprehensive AI-readiness analysis (patterns + context + consistency)").argument("[directory]", "Directory to analyze", ".").option("-t, --tools <tools>", "Tools to run (comma-separated: patterns,context,consistency)", "patterns,context,consistency").option("--include <patterns>", "File patterns to include (comma-separated)").option("--exclude <patterns>", "File patterns to exclude (comma-separated)").option("-o, --output <format>", "Output format: console, json", "json").option("--output-file <path>", "Output file path (for json)").option("--no-score", "Disable calculating AI Readiness Score (enabled by default)").option("--weights <weights>", "Custom scoring weights (patterns:40,context:35,consistency:25)").option("--threshold <score>", "Fail CI/CD if score below threshold (0-100)").addHelpText("after", `
160
+ program.command("scan").description("Run comprehensive AI-readiness analysis (patterns + context + consistency)").argument("[directory]", "Directory to analyze", ".").option("-t, --tools <tools>", "Tools to run (comma-separated: patterns,context,consistency)", "patterns,context,consistency").option("--include <patterns>", "File patterns to include (comma-separated)").option("--exclude <patterns>", "File patterns to exclude (comma-separated)").option("-o, --output <format>", "Output format: console, json", "json").option("--output-file <path>", "Output file path (for json)").option("--no-score", "Disable calculating AI Readiness Score (enabled by default)").option("--weights <weights>", "Custom scoring weights (patterns:40,context:35,consistency:25)").option("--threshold <score>", "Fail CI/CD if score below threshold (0-100)").option("--ci", "CI mode: GitHub Actions annotations, no colors, fail on threshold").option("--fail-on <level>", "Fail on issues: critical, major, any", "critical").addHelpText("after", `
161
161
  EXAMPLES:
162
162
  $ aiready scan # Analyze all tools
163
163
  $ aiready scan --tools patterns,context # Skip consistency
164
164
  $ aiready scan --score --threshold 75 # CI/CD with threshold
165
+ $ aiready scan --ci --threshold 70 # GitHub Actions gatekeeper
166
+ $ aiready scan --ci --fail-on major # Fail on major+ issues
165
167
  $ aiready scan --output json --output-file report.json
168
+
169
+ CI/CD INTEGRATION (Gatekeeper Mode):
170
+ Use --ci for GitHub Actions integration:
171
+ - Outputs GitHub Actions annotations for PR checks
172
+ - Fails with exit code 1 if threshold not met
173
+ - Shows clear "blocked" message with remediation steps
174
+
175
+ Example GitHub Actions workflow:
176
+ - name: AI Readiness Check
177
+ run: aiready scan --ci --threshold 70
166
178
  `).action(async (directory, options) => {
167
179
  console.log(import_chalk.default.blue("\u{1F680} Starting AIReady unified analysis...\n"));
168
180
  const startTime = Date.now();
@@ -394,6 +406,91 @@ EXAMPLES:
394
406
  (0, import_core.handleJSONOutput)(outputData, outputPath, `\u2705 Report saved to ${outputPath}`);
395
407
  warnIfGraphCapExceeded(outputData, resolvedDir);
396
408
  }
409
+ const isCI = options.ci || process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
410
+ if (isCI && scoringResult) {
411
+ const threshold = options.threshold ? parseInt(options.threshold) : void 0;
412
+ const failOnLevel = options.failOn || "critical";
413
+ if (process.env.GITHUB_ACTIONS === "true") {
414
+ console.log(`
415
+ ::group::AI Readiness Score`);
416
+ console.log(`score=${scoringResult.overallScore}`);
417
+ if (scoringResult.breakdown) {
418
+ scoringResult.breakdown.forEach((tool) => {
419
+ console.log(`${tool.toolName}=${tool.score}`);
420
+ });
421
+ }
422
+ console.log("::endgroup::");
423
+ if (threshold && scoringResult.overallScore < threshold) {
424
+ console.log(`::error::AI Readiness Score ${scoringResult.overallScore} is below threshold ${threshold}`);
425
+ } else if (threshold) {
426
+ console.log(`::notice::AI Readiness Score: ${scoringResult.overallScore}/100 (threshold: ${threshold})`);
427
+ }
428
+ if (results.patterns) {
429
+ const criticalPatterns = results.patterns.flatMap(
430
+ (p) => p.issues.filter((i) => i.severity === "critical")
431
+ );
432
+ criticalPatterns.slice(0, 10).forEach((issue) => {
433
+ console.log(`::warning file=${issue.location?.file || "unknown"},line=${issue.location?.line || 1}::${issue.message}`);
434
+ });
435
+ }
436
+ }
437
+ let shouldFail = false;
438
+ let failReason = "";
439
+ if (threshold && scoringResult.overallScore < threshold) {
440
+ shouldFail = true;
441
+ failReason = `AI Readiness Score ${scoringResult.overallScore} is below threshold ${threshold}`;
442
+ }
443
+ if (failOnLevel !== "none") {
444
+ const severityLevels = { critical: 4, major: 3, minor: 2, any: 1 };
445
+ const minSeverity = severityLevels[failOnLevel] || 4;
446
+ let criticalCount = 0;
447
+ let majorCount = 0;
448
+ if (results.patterns) {
449
+ results.patterns.forEach((p) => {
450
+ p.issues.forEach((i) => {
451
+ if (i.severity === "critical") criticalCount++;
452
+ if (i.severity === "major") majorCount++;
453
+ });
454
+ });
455
+ }
456
+ if (results.context) {
457
+ results.context.forEach((c) => {
458
+ if (c.severity === "critical") criticalCount++;
459
+ if (c.severity === "major") majorCount++;
460
+ });
461
+ }
462
+ if (results.consistency?.results) {
463
+ results.consistency.results.forEach((r) => {
464
+ r.issues?.forEach((i) => {
465
+ if (i.severity === "critical") criticalCount++;
466
+ if (i.severity === "major") majorCount++;
467
+ });
468
+ });
469
+ }
470
+ if (minSeverity >= 4 && criticalCount > 0) {
471
+ shouldFail = true;
472
+ failReason = `Found ${criticalCount} critical issues`;
473
+ } else if (minSeverity >= 3 && criticalCount + majorCount > 0) {
474
+ shouldFail = true;
475
+ failReason = `Found ${criticalCount} critical and ${majorCount} major issues`;
476
+ }
477
+ }
478
+ if (shouldFail) {
479
+ console.log(import_chalk.default.red("\n\u{1F6AB} PR BLOCKED: AI Readiness Check Failed"));
480
+ console.log(import_chalk.default.red(` Reason: ${failReason}`));
481
+ console.log(import_chalk.default.dim("\n Remediation steps:"));
482
+ console.log(import_chalk.default.dim(" 1. Run `aiready scan` locally to see detailed issues"));
483
+ console.log(import_chalk.default.dim(" 2. Fix the critical issues before merging"));
484
+ console.log(import_chalk.default.dim(" 3. Consider upgrading to Team plan for historical tracking: https://getaiready.dev/pricing"));
485
+ process.exit(1);
486
+ } else {
487
+ console.log(import_chalk.default.green("\n\u2705 PR PASSED: AI Readiness Check"));
488
+ if (threshold) {
489
+ console.log(import_chalk.default.green(` Score: ${scoringResult.overallScore}/100 (threshold: ${threshold})`));
490
+ }
491
+ console.log(import_chalk.default.dim("\n \u{1F4A1} Track historical trends: https://getaiready.dev \u2014 Team plan $99/mo"));
492
+ }
493
+ }
397
494
  } catch (error) {
398
495
  (0, import_core.handleCLIError)(error, "Analysis");
399
496
  }
@@ -817,170 +914,6 @@ function generateMarkdownReport(report, elapsedTime) {
817
914
  }
818
915
  return markdown;
819
916
  }
820
- function generateHTML(graph) {
821
- const payload = JSON.stringify(graph, null, 2);
822
- return `<!doctype html>
823
- <html>
824
- <head>
825
- <meta charset="utf-8" />
826
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
827
- <title>AIReady Visualization</title>
828
- <style>
829
- html,body { height: 100%; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0 }
830
- #container { display:flex; height:100vh }
831
- #panel { width: 320px; padding: 16px; background: #071130; box-shadow: -2px 0 8px rgba(0,0,0,0.3); overflow:auto }
832
- #canvasWrap { flex:1; display:flex; align-items:center; justify-content:center }
833
- canvas { background: #0b1220; border-radius:8px }
834
- .stat { margin-bottom:12px }
835
- </style>
836
- </head>
837
- <body>
838
- <div id="container">
839
- <div id="canvasWrap"><canvas id="canvas" width="1200" height="800"></canvas></div>
840
- <div id="panel">
841
- <h2>AIReady Visualization</h2>
842
- <div class="stat"><strong>Files:</strong> <span id="stat-files"></span></div>
843
- <div class="stat"><strong>Dependencies:</strong> <span id="stat-deps"></span></div>
844
- <div class="stat"><strong>Legend</strong></div>
845
- <div style="font-size:13px;line-height:1.3;color:#cbd5e1;margin-top:8px">
846
- <div style="margin-bottom:8px"><span style="display:inline-block;width:12px;height:12px;background:#ff4d4f;margin-right:8px;border:1px solid rgba(255,255,255,0.06)"></span><strong>Critical</strong>: highest severity issues.</div>
847
- <div style="margin-bottom:8px"><span style="display:inline-block;width:12px;height:12px;background:#ff9900;margin-right:8px;border:1px solid rgba(255,255,255,0.06)"></span><strong>Major</strong>: important issues.</div>
848
- <div style="margin-bottom:8px"><span style="display:inline-block;width:12px;height:12px;background:#ffd666;margin-right:8px;border:1px solid rgba(255,255,255,0.06)"></span><strong>Minor</strong>: low priority issues.</div>
849
- <div style="margin-bottom:8px"><span style="display:inline-block;width:12px;height:12px;background:#91d5ff;margin-right:8px;border:1px solid rgba(255,255,255,0.06)"></span><strong>Info</strong>: informational notes.</div>
850
- <div style="margin-top:10px;color:#94a3b8"><strong>Node size</strong>: larger = higher token cost, more issues or dependency weight.</div>
851
- <div style="margin-top:6px;color:#94a3b8"><strong>Proximity</strong>: nodes that are spatially close are more contextually related; relatedness is represented by distance and size rather than explicit edges.</div>
852
- <div style="margin-top:6px;color:#94a3b8"><strong>Edge colors</strong>: <span style="color:#fb7e81">Similarity</span>, <span style="color:#84c1ff">Dependency</span>, <span style="color:#ffa500">Reference</span>, default <span style="color:#334155">Other</span>.</div>
853
- </div>
854
- </div>
855
- </div>
856
-
857
- <script>
858
- const graphData = ${payload};
859
- document.getElementById('stat-files').textContent = graphData.metadata.totalFiles;
860
- document.getElementById('stat-deps').textContent = graphData.metadata.totalDependencies;
861
-
862
- const canvas = document.getElementById('canvas');
863
- const ctx = canvas.getContext('2d');
864
-
865
- const nodes = graphData.nodes.map((n, i) => ({
866
- ...n,
867
- x: canvas.width / 2 + Math.cos(i / graphData.nodes.length * Math.PI * 2) * (Math.min(canvas.width, canvas.height) / 3),
868
- y: canvas.height / 2 + Math.sin(i / graphData.nodes.length * Math.PI * 2) * (Math.min(canvas.width, canvas.height) / 3),
869
- }));
870
-
871
- function draw() {
872
- ctx.clearRect(0, 0, canvas.width, canvas.height);
873
-
874
- graphData.edges.forEach(edge => {
875
- const s = nodes.find(n => n.id === edge.source);
876
- const t = nodes.find(n => n.id === edge.target);
877
- if (!s || !t) return;
878
- if (edge.type === 'related') return;
879
- if (edge.type === 'similarity') {
880
- ctx.strokeStyle = '#fb7e81';
881
- ctx.lineWidth = 1.2;
882
- } else if (edge.type === 'dependency') {
883
- ctx.strokeStyle = '#84c1ff';
884
- ctx.lineWidth = 1.0;
885
- } else if (edge.type === 'reference') {
886
- ctx.strokeStyle = '#ffa500';
887
- ctx.lineWidth = 0.9;
888
- } else {
889
- ctx.strokeStyle = '#334155';
890
- ctx.lineWidth = 0.8;
891
- }
892
- ctx.beginPath();
893
- ctx.moveTo(s.x, s.y);
894
- ctx.lineTo(t.x, t.y);
895
- ctx.stroke();
896
- });
897
-
898
- const groups = {};
899
- nodes.forEach(n => {
900
- const g = n.group || '__default';
901
- if (!groups[g]) groups[g] = { minX: n.x, minY: n.y, maxX: n.x, maxY: n.y };
902
- groups[g].minX = Math.min(groups[g].minX, n.x);
903
- groups[g].minY = Math.min(groups[g].minY, n.y);
904
- groups[g].maxX = Math.max(groups[g].maxX, n.x);
905
- groups[g].maxY = Math.max(groups[g].maxY, n.y);
906
- });
907
-
908
- const groupRelations = {};
909
- graphData.edges.forEach(edge => {
910
- const sNode = nodes.find(n => n.id === edge.source);
911
- const tNode = nodes.find(n => n.id === edge.target);
912
- if (!sNode || !tNode) return;
913
- const g1 = sNode.group || '__default';
914
- const g2 = tNode.group || '__default';
915
- if (g1 === g2) return;
916
- const key = g1 < g2 ? g1 + '::' + g2 : g2 + '::' + g1;
917
- groupRelations[key] = (groupRelations[key] || 0) + 1;
918
- });
919
-
920
- Object.keys(groupRelations).forEach(k => {
921
- const count = groupRelations[k];
922
- const [ga, gb] = k.split('::');
923
- if (!groups[ga] || !groups[gb]) return;
924
- const ax = (groups[ga].minX + groups[ga].maxX) / 2;
925
- const ay = (groups[ga].minY + groups[ga].maxY) / 2;
926
- const bx = (groups[gb].minX + groups[gb].maxX) / 2;
927
- const by = (groups[gb].minY + groups[gb].maxY) / 2;
928
- ctx.beginPath();
929
- ctx.strokeStyle = 'rgba(148,163,184,0.25)';
930
- ctx.lineWidth = Math.min(6, 0.6 + Math.sqrt(count));
931
- ctx.moveTo(ax, ay);
932
- ctx.lineTo(bx, by);
933
- ctx.stroke();
934
- });
935
-
936
- Object.keys(groups).forEach(g => {
937
- if (g === '__default') return;
938
- const box = groups[g];
939
- const pad = 16;
940
- const x = box.minX - pad;
941
- const y = box.minY - pad;
942
- const w = (box.maxX - box.minX) + pad * 2;
943
- const h = (box.maxY - box.minY) + pad * 2;
944
- ctx.save();
945
- ctx.fillStyle = 'rgba(30,64,175,0.04)';
946
- ctx.strokeStyle = 'rgba(30,64,175,0.12)';
947
- ctx.lineWidth = 1.2;
948
- const r = 8;
949
- ctx.beginPath();
950
- ctx.moveTo(x + r, y);
951
- ctx.arcTo(x + w, y, x + w, y + h, r);
952
- ctx.arcTo(x + w, y + h, x, y + h, r);
953
- ctx.arcTo(x, y + h, x, y, r);
954
- ctx.arcTo(x, y, x + w, y, r);
955
- ctx.closePath();
956
- ctx.fill();
957
- ctx.stroke();
958
- ctx.restore();
959
- ctx.fillStyle = '#94a3b8';
960
- ctx.font = '11px sans-serif';
961
- ctx.fillText(g, x + 8, y + 14);
962
- });
963
-
964
- nodes.forEach(n => {
965
- const sizeVal = (n.size || n.value || 1);
966
- const r = 6 + (sizeVal / 2);
967
- ctx.beginPath();
968
- ctx.fillStyle = n.color || '#60a5fa';
969
- ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
970
- ctx.fill();
971
-
972
- ctx.fillStyle = '#e2e8f0';
973
- ctx.font = '11px sans-serif';
974
- ctx.textAlign = 'center';
975
- ctx.fillText(n.label || n.id.split('/').slice(-1)[0], n.x, n.y + r + 12);
976
- });
977
- }
978
-
979
- draw();
980
- </script>
981
- </body>
982
- </html>`;
983
- }
984
917
  function getReportTimestamp() {
985
918
  const now = /* @__PURE__ */ new Date();
986
919
  const pad = (n) => String(n).padStart(2, "0");
@@ -1173,7 +1106,7 @@ Or specify a custom report:
1173
1106
  }
1174
1107
  }
1175
1108
  console.log("Generating HTML...");
1176
- const html = generateHTML(graph);
1109
+ const html = (0, import_core.generateHTML)(graph);
1177
1110
  const outPath = (0, import_path2.resolve)(dirPath, options.output || "packages/visualizer/visualization.html");
1178
1111
  (0, import_fs.writeFileSync)(outPath, html, "utf8");
1179
1112
  console.log("Visualization written to:", outPath);
@@ -1184,7 +1117,7 @@ Or specify a custom report:
1184
1117
  }
1185
1118
  if (options.serve) {
1186
1119
  try {
1187
- const port = Number(options.serve) || 5173;
1120
+ const port = typeof options.serve === "number" ? options.serve : 5173;
1188
1121
  const http = await import("http");
1189
1122
  const fsp = await import("fs/promises");
1190
1123
  const { exec } = await import("child_process");
package/dist/cli.mjs CHANGED
@@ -20,7 +20,8 @@ import {
20
20
  formatToolScore,
21
21
  getRating,
22
22
  getRatingDisplay,
23
- parseWeightString
23
+ parseWeightString,
24
+ generateHTML
24
25
  } from "@aiready/core";
25
26
  import { readFileSync, existsSync, copyFileSync } from "fs";
26
27
  import { resolve as resolvePath } from "path";
@@ -62,12 +63,24 @@ VERSION: ${packageJson.version}
62
63
  DOCUMENTATION: https://aiready.dev/docs/cli
63
64
  GITHUB: https://github.com/caopengau/aiready-cli
64
65
  LANDING: https://github.com/caopengau/aiready-landing`);
65
- program.command("scan").description("Run comprehensive AI-readiness analysis (patterns + context + consistency)").argument("[directory]", "Directory to analyze", ".").option("-t, --tools <tools>", "Tools to run (comma-separated: patterns,context,consistency)", "patterns,context,consistency").option("--include <patterns>", "File patterns to include (comma-separated)").option("--exclude <patterns>", "File patterns to exclude (comma-separated)").option("-o, --output <format>", "Output format: console, json", "json").option("--output-file <path>", "Output file path (for json)").option("--no-score", "Disable calculating AI Readiness Score (enabled by default)").option("--weights <weights>", "Custom scoring weights (patterns:40,context:35,consistency:25)").option("--threshold <score>", "Fail CI/CD if score below threshold (0-100)").addHelpText("after", `
66
+ program.command("scan").description("Run comprehensive AI-readiness analysis (patterns + context + consistency)").argument("[directory]", "Directory to analyze", ".").option("-t, --tools <tools>", "Tools to run (comma-separated: patterns,context,consistency)", "patterns,context,consistency").option("--include <patterns>", "File patterns to include (comma-separated)").option("--exclude <patterns>", "File patterns to exclude (comma-separated)").option("-o, --output <format>", "Output format: console, json", "json").option("--output-file <path>", "Output file path (for json)").option("--no-score", "Disable calculating AI Readiness Score (enabled by default)").option("--weights <weights>", "Custom scoring weights (patterns:40,context:35,consistency:25)").option("--threshold <score>", "Fail CI/CD if score below threshold (0-100)").option("--ci", "CI mode: GitHub Actions annotations, no colors, fail on threshold").option("--fail-on <level>", "Fail on issues: critical, major, any", "critical").addHelpText("after", `
66
67
  EXAMPLES:
67
68
  $ aiready scan # Analyze all tools
68
69
  $ aiready scan --tools patterns,context # Skip consistency
69
70
  $ aiready scan --score --threshold 75 # CI/CD with threshold
71
+ $ aiready scan --ci --threshold 70 # GitHub Actions gatekeeper
72
+ $ aiready scan --ci --fail-on major # Fail on major+ issues
70
73
  $ aiready scan --output json --output-file report.json
74
+
75
+ CI/CD INTEGRATION (Gatekeeper Mode):
76
+ Use --ci for GitHub Actions integration:
77
+ - Outputs GitHub Actions annotations for PR checks
78
+ - Fails with exit code 1 if threshold not met
79
+ - Shows clear "blocked" message with remediation steps
80
+
81
+ Example GitHub Actions workflow:
82
+ - name: AI Readiness Check
83
+ run: aiready scan --ci --threshold 70
71
84
  `).action(async (directory, options) => {
72
85
  console.log(chalk.blue("\u{1F680} Starting AIReady unified analysis...\n"));
73
86
  const startTime = Date.now();
@@ -299,6 +312,91 @@ EXAMPLES:
299
312
  handleJSONOutput(outputData, outputPath, `\u2705 Report saved to ${outputPath}`);
300
313
  warnIfGraphCapExceeded(outputData, resolvedDir);
301
314
  }
315
+ const isCI = options.ci || process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
316
+ if (isCI && scoringResult) {
317
+ const threshold = options.threshold ? parseInt(options.threshold) : void 0;
318
+ const failOnLevel = options.failOn || "critical";
319
+ if (process.env.GITHUB_ACTIONS === "true") {
320
+ console.log(`
321
+ ::group::AI Readiness Score`);
322
+ console.log(`score=${scoringResult.overallScore}`);
323
+ if (scoringResult.breakdown) {
324
+ scoringResult.breakdown.forEach((tool) => {
325
+ console.log(`${tool.toolName}=${tool.score}`);
326
+ });
327
+ }
328
+ console.log("::endgroup::");
329
+ if (threshold && scoringResult.overallScore < threshold) {
330
+ console.log(`::error::AI Readiness Score ${scoringResult.overallScore} is below threshold ${threshold}`);
331
+ } else if (threshold) {
332
+ console.log(`::notice::AI Readiness Score: ${scoringResult.overallScore}/100 (threshold: ${threshold})`);
333
+ }
334
+ if (results.patterns) {
335
+ const criticalPatterns = results.patterns.flatMap(
336
+ (p) => p.issues.filter((i) => i.severity === "critical")
337
+ );
338
+ criticalPatterns.slice(0, 10).forEach((issue) => {
339
+ console.log(`::warning file=${issue.location?.file || "unknown"},line=${issue.location?.line || 1}::${issue.message}`);
340
+ });
341
+ }
342
+ }
343
+ let shouldFail = false;
344
+ let failReason = "";
345
+ if (threshold && scoringResult.overallScore < threshold) {
346
+ shouldFail = true;
347
+ failReason = `AI Readiness Score ${scoringResult.overallScore} is below threshold ${threshold}`;
348
+ }
349
+ if (failOnLevel !== "none") {
350
+ const severityLevels = { critical: 4, major: 3, minor: 2, any: 1 };
351
+ const minSeverity = severityLevels[failOnLevel] || 4;
352
+ let criticalCount = 0;
353
+ let majorCount = 0;
354
+ if (results.patterns) {
355
+ results.patterns.forEach((p) => {
356
+ p.issues.forEach((i) => {
357
+ if (i.severity === "critical") criticalCount++;
358
+ if (i.severity === "major") majorCount++;
359
+ });
360
+ });
361
+ }
362
+ if (results.context) {
363
+ results.context.forEach((c) => {
364
+ if (c.severity === "critical") criticalCount++;
365
+ if (c.severity === "major") majorCount++;
366
+ });
367
+ }
368
+ if (results.consistency?.results) {
369
+ results.consistency.results.forEach((r) => {
370
+ r.issues?.forEach((i) => {
371
+ if (i.severity === "critical") criticalCount++;
372
+ if (i.severity === "major") majorCount++;
373
+ });
374
+ });
375
+ }
376
+ if (minSeverity >= 4 && criticalCount > 0) {
377
+ shouldFail = true;
378
+ failReason = `Found ${criticalCount} critical issues`;
379
+ } else if (minSeverity >= 3 && criticalCount + majorCount > 0) {
380
+ shouldFail = true;
381
+ failReason = `Found ${criticalCount} critical and ${majorCount} major issues`;
382
+ }
383
+ }
384
+ if (shouldFail) {
385
+ console.log(chalk.red("\n\u{1F6AB} PR BLOCKED: AI Readiness Check Failed"));
386
+ console.log(chalk.red(` Reason: ${failReason}`));
387
+ console.log(chalk.dim("\n Remediation steps:"));
388
+ console.log(chalk.dim(" 1. Run `aiready scan` locally to see detailed issues"));
389
+ console.log(chalk.dim(" 2. Fix the critical issues before merging"));
390
+ console.log(chalk.dim(" 3. Consider upgrading to Team plan for historical tracking: https://getaiready.dev/pricing"));
391
+ process.exit(1);
392
+ } else {
393
+ console.log(chalk.green("\n\u2705 PR PASSED: AI Readiness Check"));
394
+ if (threshold) {
395
+ console.log(chalk.green(` Score: ${scoringResult.overallScore}/100 (threshold: ${threshold})`));
396
+ }
397
+ console.log(chalk.dim("\n \u{1F4A1} Track historical trends: https://getaiready.dev \u2014 Team plan $99/mo"));
398
+ }
399
+ }
302
400
  } catch (error) {
303
401
  handleCLIError(error, "Analysis");
304
402
  }
@@ -722,170 +820,6 @@ function generateMarkdownReport(report, elapsedTime) {
722
820
  }
723
821
  return markdown;
724
822
  }
725
- function generateHTML(graph) {
726
- const payload = JSON.stringify(graph, null, 2);
727
- return `<!doctype html>
728
- <html>
729
- <head>
730
- <meta charset="utf-8" />
731
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
732
- <title>AIReady Visualization</title>
733
- <style>
734
- html,body { height: 100%; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0 }
735
- #container { display:flex; height:100vh }
736
- #panel { width: 320px; padding: 16px; background: #071130; box-shadow: -2px 0 8px rgba(0,0,0,0.3); overflow:auto }
737
- #canvasWrap { flex:1; display:flex; align-items:center; justify-content:center }
738
- canvas { background: #0b1220; border-radius:8px }
739
- .stat { margin-bottom:12px }
740
- </style>
741
- </head>
742
- <body>
743
- <div id="container">
744
- <div id="canvasWrap"><canvas id="canvas" width="1200" height="800"></canvas></div>
745
- <div id="panel">
746
- <h2>AIReady Visualization</h2>
747
- <div class="stat"><strong>Files:</strong> <span id="stat-files"></span></div>
748
- <div class="stat"><strong>Dependencies:</strong> <span id="stat-deps"></span></div>
749
- <div class="stat"><strong>Legend</strong></div>
750
- <div style="font-size:13px;line-height:1.3;color:#cbd5e1;margin-top:8px">
751
- <div style="margin-bottom:8px"><span style="display:inline-block;width:12px;height:12px;background:#ff4d4f;margin-right:8px;border:1px solid rgba(255,255,255,0.06)"></span><strong>Critical</strong>: highest severity issues.</div>
752
- <div style="margin-bottom:8px"><span style="display:inline-block;width:12px;height:12px;background:#ff9900;margin-right:8px;border:1px solid rgba(255,255,255,0.06)"></span><strong>Major</strong>: important issues.</div>
753
- <div style="margin-bottom:8px"><span style="display:inline-block;width:12px;height:12px;background:#ffd666;margin-right:8px;border:1px solid rgba(255,255,255,0.06)"></span><strong>Minor</strong>: low priority issues.</div>
754
- <div style="margin-bottom:8px"><span style="display:inline-block;width:12px;height:12px;background:#91d5ff;margin-right:8px;border:1px solid rgba(255,255,255,0.06)"></span><strong>Info</strong>: informational notes.</div>
755
- <div style="margin-top:10px;color:#94a3b8"><strong>Node size</strong>: larger = higher token cost, more issues or dependency weight.</div>
756
- <div style="margin-top:6px;color:#94a3b8"><strong>Proximity</strong>: nodes that are spatially close are more contextually related; relatedness is represented by distance and size rather than explicit edges.</div>
757
- <div style="margin-top:6px;color:#94a3b8"><strong>Edge colors</strong>: <span style="color:#fb7e81">Similarity</span>, <span style="color:#84c1ff">Dependency</span>, <span style="color:#ffa500">Reference</span>, default <span style="color:#334155">Other</span>.</div>
758
- </div>
759
- </div>
760
- </div>
761
-
762
- <script>
763
- const graphData = ${payload};
764
- document.getElementById('stat-files').textContent = graphData.metadata.totalFiles;
765
- document.getElementById('stat-deps').textContent = graphData.metadata.totalDependencies;
766
-
767
- const canvas = document.getElementById('canvas');
768
- const ctx = canvas.getContext('2d');
769
-
770
- const nodes = graphData.nodes.map((n, i) => ({
771
- ...n,
772
- x: canvas.width / 2 + Math.cos(i / graphData.nodes.length * Math.PI * 2) * (Math.min(canvas.width, canvas.height) / 3),
773
- y: canvas.height / 2 + Math.sin(i / graphData.nodes.length * Math.PI * 2) * (Math.min(canvas.width, canvas.height) / 3),
774
- }));
775
-
776
- function draw() {
777
- ctx.clearRect(0, 0, canvas.width, canvas.height);
778
-
779
- graphData.edges.forEach(edge => {
780
- const s = nodes.find(n => n.id === edge.source);
781
- const t = nodes.find(n => n.id === edge.target);
782
- if (!s || !t) return;
783
- if (edge.type === 'related') return;
784
- if (edge.type === 'similarity') {
785
- ctx.strokeStyle = '#fb7e81';
786
- ctx.lineWidth = 1.2;
787
- } else if (edge.type === 'dependency') {
788
- ctx.strokeStyle = '#84c1ff';
789
- ctx.lineWidth = 1.0;
790
- } else if (edge.type === 'reference') {
791
- ctx.strokeStyle = '#ffa500';
792
- ctx.lineWidth = 0.9;
793
- } else {
794
- ctx.strokeStyle = '#334155';
795
- ctx.lineWidth = 0.8;
796
- }
797
- ctx.beginPath();
798
- ctx.moveTo(s.x, s.y);
799
- ctx.lineTo(t.x, t.y);
800
- ctx.stroke();
801
- });
802
-
803
- const groups = {};
804
- nodes.forEach(n => {
805
- const g = n.group || '__default';
806
- if (!groups[g]) groups[g] = { minX: n.x, minY: n.y, maxX: n.x, maxY: n.y };
807
- groups[g].minX = Math.min(groups[g].minX, n.x);
808
- groups[g].minY = Math.min(groups[g].minY, n.y);
809
- groups[g].maxX = Math.max(groups[g].maxX, n.x);
810
- groups[g].maxY = Math.max(groups[g].maxY, n.y);
811
- });
812
-
813
- const groupRelations = {};
814
- graphData.edges.forEach(edge => {
815
- const sNode = nodes.find(n => n.id === edge.source);
816
- const tNode = nodes.find(n => n.id === edge.target);
817
- if (!sNode || !tNode) return;
818
- const g1 = sNode.group || '__default';
819
- const g2 = tNode.group || '__default';
820
- if (g1 === g2) return;
821
- const key = g1 < g2 ? g1 + '::' + g2 : g2 + '::' + g1;
822
- groupRelations[key] = (groupRelations[key] || 0) + 1;
823
- });
824
-
825
- Object.keys(groupRelations).forEach(k => {
826
- const count = groupRelations[k];
827
- const [ga, gb] = k.split('::');
828
- if (!groups[ga] || !groups[gb]) return;
829
- const ax = (groups[ga].minX + groups[ga].maxX) / 2;
830
- const ay = (groups[ga].minY + groups[ga].maxY) / 2;
831
- const bx = (groups[gb].minX + groups[gb].maxX) / 2;
832
- const by = (groups[gb].minY + groups[gb].maxY) / 2;
833
- ctx.beginPath();
834
- ctx.strokeStyle = 'rgba(148,163,184,0.25)';
835
- ctx.lineWidth = Math.min(6, 0.6 + Math.sqrt(count));
836
- ctx.moveTo(ax, ay);
837
- ctx.lineTo(bx, by);
838
- ctx.stroke();
839
- });
840
-
841
- Object.keys(groups).forEach(g => {
842
- if (g === '__default') return;
843
- const box = groups[g];
844
- const pad = 16;
845
- const x = box.minX - pad;
846
- const y = box.minY - pad;
847
- const w = (box.maxX - box.minX) + pad * 2;
848
- const h = (box.maxY - box.minY) + pad * 2;
849
- ctx.save();
850
- ctx.fillStyle = 'rgba(30,64,175,0.04)';
851
- ctx.strokeStyle = 'rgba(30,64,175,0.12)';
852
- ctx.lineWidth = 1.2;
853
- const r = 8;
854
- ctx.beginPath();
855
- ctx.moveTo(x + r, y);
856
- ctx.arcTo(x + w, y, x + w, y + h, r);
857
- ctx.arcTo(x + w, y + h, x, y + h, r);
858
- ctx.arcTo(x, y + h, x, y, r);
859
- ctx.arcTo(x, y, x + w, y, r);
860
- ctx.closePath();
861
- ctx.fill();
862
- ctx.stroke();
863
- ctx.restore();
864
- ctx.fillStyle = '#94a3b8';
865
- ctx.font = '11px sans-serif';
866
- ctx.fillText(g, x + 8, y + 14);
867
- });
868
-
869
- nodes.forEach(n => {
870
- const sizeVal = (n.size || n.value || 1);
871
- const r = 6 + (sizeVal / 2);
872
- ctx.beginPath();
873
- ctx.fillStyle = n.color || '#60a5fa';
874
- ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
875
- ctx.fill();
876
-
877
- ctx.fillStyle = '#e2e8f0';
878
- ctx.font = '11px sans-serif';
879
- ctx.textAlign = 'center';
880
- ctx.fillText(n.label || n.id.split('/').slice(-1)[0], n.x, n.y + r + 12);
881
- });
882
- }
883
-
884
- draw();
885
- </script>
886
- </body>
887
- </html>`;
888
- }
889
823
  function getReportTimestamp() {
890
824
  const now = /* @__PURE__ */ new Date();
891
825
  const pad = (n) => String(n).padStart(2, "0");
@@ -1089,7 +1023,7 @@ Or specify a custom report:
1089
1023
  }
1090
1024
  if (options.serve) {
1091
1025
  try {
1092
- const port = Number(options.serve) || 5173;
1026
+ const port = typeof options.serve === "number" ? options.serve : 5173;
1093
1027
  const http = await import("http");
1094
1028
  const fsp = await import("fs/promises");
1095
1029
  const { exec } = await import("child_process");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiready/cli",
3
- "version": "0.9.25",
3
+ "version": "0.9.27",
4
4
  "description": "Unified CLI for AIReady analysis tools",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -12,10 +12,10 @@
12
12
  "commander": "^14.0.0",
13
13
  "chalk": "^5.3.0",
14
14
  "@aiready/core": "0.9.22",
15
+ "@aiready/pattern-detect": "0.11.22",
15
16
  "@aiready/visualizer": "0.1.28",
16
- "@aiready/context-analyzer": "0.9.25",
17
- "@aiready/consistency": "0.8.22",
18
- "@aiready/pattern-detect": "0.11.22"
17
+ "@aiready/context-analyzer": "0.9.26",
18
+ "@aiready/consistency": "0.8.22"
19
19
  },
20
20
  "devDependencies": {
21
21
  "tsup": "^8.3.5",
package/src/cli.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  getRating,
18
18
  getRatingDisplay,
19
19
  parseWeightString,
20
+ generateHTML,
20
21
  type AIReadyConfig,
21
22
  type ToolScoringOutput,
22
23
  } from '@aiready/core';
@@ -82,12 +83,26 @@ program
82
83
  .option('--no-score', 'Disable calculating AI Readiness Score (enabled by default)')
83
84
  .option('--weights <weights>', 'Custom scoring weights (patterns:40,context:35,consistency:25)')
84
85
  .option('--threshold <score>', 'Fail CI/CD if score below threshold (0-100)')
86
+ .option('--ci', 'CI mode: GitHub Actions annotations, no colors, fail on threshold')
87
+ .option('--fail-on <level>', 'Fail on issues: critical, major, any', 'critical')
85
88
  .addHelpText('after', `
86
89
  EXAMPLES:
87
90
  $ aiready scan # Analyze all tools
88
91
  $ aiready scan --tools patterns,context # Skip consistency
89
92
  $ aiready scan --score --threshold 75 # CI/CD with threshold
93
+ $ aiready scan --ci --threshold 70 # GitHub Actions gatekeeper
94
+ $ aiready scan --ci --fail-on major # Fail on major+ issues
90
95
  $ aiready scan --output json --output-file report.json
96
+
97
+ CI/CD INTEGRATION (Gatekeeper Mode):
98
+ Use --ci for GitHub Actions integration:
99
+ - Outputs GitHub Actions annotations for PR checks
100
+ - Fails with exit code 1 if threshold not met
101
+ - Shows clear "blocked" message with remediation steps
102
+
103
+ Example GitHub Actions workflow:
104
+ - name: AI Readiness Check
105
+ run: aiready scan --ci --threshold 70
91
106
  `)
92
107
  .action(async (directory, options) => {
93
108
  console.log(chalk.blue('🚀 Starting AIReady unified analysis...\n'));
@@ -385,6 +400,109 @@ EXAMPLES:
385
400
  // Warn if graph caps may be exceeded
386
401
  warnIfGraphCapExceeded(outputData, resolvedDir);
387
402
  }
403
+
404
+ // CI/CD Gatekeeper Mode
405
+ const isCI = options.ci || process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
406
+ if (isCI && scoringResult) {
407
+ const threshold = options.threshold ? parseInt(options.threshold) : undefined;
408
+ const failOnLevel = options.failOn || 'critical';
409
+
410
+ // Output GitHub Actions annotations
411
+ if (process.env.GITHUB_ACTIONS === 'true') {
412
+ console.log(`\n::group::AI Readiness Score`);
413
+ console.log(`score=${scoringResult.overallScore}`);
414
+ if (scoringResult.breakdown) {
415
+ scoringResult.breakdown.forEach(tool => {
416
+ console.log(`${tool.toolName}=${tool.score}`);
417
+ });
418
+ }
419
+ console.log('::endgroup::');
420
+
421
+ // Output annotation for score
422
+ if (threshold && scoringResult.overallScore < threshold) {
423
+ console.log(`::error::AI Readiness Score ${scoringResult.overallScore} is below threshold ${threshold}`);
424
+ } else if (threshold) {
425
+ console.log(`::notice::AI Readiness Score: ${scoringResult.overallScore}/100 (threshold: ${threshold})`);
426
+ }
427
+
428
+ // Output annotations for critical issues
429
+ if (results.patterns) {
430
+ const criticalPatterns = results.patterns.flatMap((p: any) =>
431
+ p.issues.filter((i: any) => i.severity === 'critical')
432
+ );
433
+ criticalPatterns.slice(0, 10).forEach((issue: any) => {
434
+ console.log(`::warning file=${issue.location?.file || 'unknown'},line=${issue.location?.line || 1}::${issue.message}`);
435
+ });
436
+ }
437
+ }
438
+
439
+ // Determine if we should fail
440
+ let shouldFail = false;
441
+ let failReason = '';
442
+
443
+ // Check threshold
444
+ if (threshold && scoringResult.overallScore < threshold) {
445
+ shouldFail = true;
446
+ failReason = `AI Readiness Score ${scoringResult.overallScore} is below threshold ${threshold}`;
447
+ }
448
+
449
+ // Check fail-on severity
450
+ if (failOnLevel !== 'none') {
451
+ const severityLevels = { critical: 4, major: 3, minor: 2, any: 1 };
452
+ const minSeverity = severityLevels[failOnLevel as keyof typeof severityLevels] || 4;
453
+
454
+ let criticalCount = 0;
455
+ let majorCount = 0;
456
+
457
+ if (results.patterns) {
458
+ results.patterns.forEach((p: any) => {
459
+ p.issues.forEach((i: any) => {
460
+ if (i.severity === 'critical') criticalCount++;
461
+ if (i.severity === 'major') majorCount++;
462
+ });
463
+ });
464
+ }
465
+ if (results.context) {
466
+ results.context.forEach((c: any) => {
467
+ if (c.severity === 'critical') criticalCount++;
468
+ if (c.severity === 'major') majorCount++;
469
+ });
470
+ }
471
+ if (results.consistency?.results) {
472
+ results.consistency.results.forEach((r: any) => {
473
+ r.issues?.forEach((i: any) => {
474
+ if (i.severity === 'critical') criticalCount++;
475
+ if (i.severity === 'major') majorCount++;
476
+ });
477
+ });
478
+ }
479
+
480
+ if (minSeverity >= 4 && criticalCount > 0) {
481
+ shouldFail = true;
482
+ failReason = `Found ${criticalCount} critical issues`;
483
+ } else if (minSeverity >= 3 && (criticalCount + majorCount) > 0) {
484
+ shouldFail = true;
485
+ failReason = `Found ${criticalCount} critical and ${majorCount} major issues`;
486
+ }
487
+ }
488
+
489
+ // Output result
490
+ if (shouldFail) {
491
+ console.log(chalk.red('\n🚫 PR BLOCKED: AI Readiness Check Failed'));
492
+ console.log(chalk.red(` Reason: ${failReason}`));
493
+ console.log(chalk.dim('\n Remediation steps:'));
494
+ console.log(chalk.dim(' 1. Run `aiready scan` locally to see detailed issues'));
495
+ console.log(chalk.dim(' 2. Fix the critical issues before merging'));
496
+ console.log(chalk.dim(' 3. Consider upgrading to Team plan for historical tracking: https://getaiready.dev/pricing'));
497
+ process.exit(1);
498
+ } else {
499
+ console.log(chalk.green('\n✅ PR PASSED: AI Readiness Check'));
500
+ if (threshold) {
501
+ console.log(chalk.green(` Score: ${scoringResult.overallScore}/100 (threshold: ${threshold})`));
502
+ }
503
+ console.log(chalk.dim('\n 💡 Track historical trends: https://getaiready.dev — Team plan $99/mo'));
504
+ }
505
+ }
388
506
  } catch (error) {
389
507
  handleCLIError(error, 'Analysis');
390
508
  }
@@ -930,170 +1048,6 @@ program
930
1048
  return markdown;
931
1049
  }
932
1050
 
933
- function generateHTML(graph: GraphData): string {
934
- const payload = JSON.stringify(graph, null, 2);
935
- return `<!doctype html>
936
- <html>
937
- <head>
938
- <meta charset="utf-8" />
939
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
940
- <title>AIReady Visualization</title>
941
- <style>
942
- html,body { height: 100%; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0 }
943
- #container { display:flex; height:100vh }
944
- #panel { width: 320px; padding: 16px; background: #071130; box-shadow: -2px 0 8px rgba(0,0,0,0.3); overflow:auto }
945
- #canvasWrap { flex:1; display:flex; align-items:center; justify-content:center }
946
- canvas { background: #0b1220; border-radius:8px }
947
- .stat { margin-bottom:12px }
948
- </style>
949
- </head>
950
- <body>
951
- <div id="container">
952
- <div id="canvasWrap"><canvas id="canvas" width="1200" height="800"></canvas></div>
953
- <div id="panel">
954
- <h2>AIReady Visualization</h2>
955
- <div class="stat"><strong>Files:</strong> <span id="stat-files"></span></div>
956
- <div class="stat"><strong>Dependencies:</strong> <span id="stat-deps"></span></div>
957
- <div class="stat"><strong>Legend</strong></div>
958
- <div style="font-size:13px;line-height:1.3;color:#cbd5e1;margin-top:8px">
959
- <div style="margin-bottom:8px"><span style="display:inline-block;width:12px;height:12px;background:#ff4d4f;margin-right:8px;border:1px solid rgba(255,255,255,0.06)"></span><strong>Critical</strong>: highest severity issues.</div>
960
- <div style="margin-bottom:8px"><span style="display:inline-block;width:12px;height:12px;background:#ff9900;margin-right:8px;border:1px solid rgba(255,255,255,0.06)"></span><strong>Major</strong>: important issues.</div>
961
- <div style="margin-bottom:8px"><span style="display:inline-block;width:12px;height:12px;background:#ffd666;margin-right:8px;border:1px solid rgba(255,255,255,0.06)"></span><strong>Minor</strong>: low priority issues.</div>
962
- <div style="margin-bottom:8px"><span style="display:inline-block;width:12px;height:12px;background:#91d5ff;margin-right:8px;border:1px solid rgba(255,255,255,0.06)"></span><strong>Info</strong>: informational notes.</div>
963
- <div style="margin-top:10px;color:#94a3b8"><strong>Node size</strong>: larger = higher token cost, more issues or dependency weight.</div>
964
- <div style="margin-top:6px;color:#94a3b8"><strong>Proximity</strong>: nodes that are spatially close are more contextually related; relatedness is represented by distance and size rather than explicit edges.</div>
965
- <div style="margin-top:6px;color:#94a3b8"><strong>Edge colors</strong>: <span style="color:#fb7e81">Similarity</span>, <span style="color:#84c1ff">Dependency</span>, <span style="color:#ffa500">Reference</span>, default <span style="color:#334155">Other</span>.</div>
966
- </div>
967
- </div>
968
- </div>
969
-
970
- <script>
971
- const graphData = ${payload};
972
- document.getElementById('stat-files').textContent = graphData.metadata.totalFiles;
973
- document.getElementById('stat-deps').textContent = graphData.metadata.totalDependencies;
974
-
975
- const canvas = document.getElementById('canvas');
976
- const ctx = canvas.getContext('2d');
977
-
978
- const nodes = graphData.nodes.map((n, i) => ({
979
- ...n,
980
- x: canvas.width / 2 + Math.cos(i / graphData.nodes.length * Math.PI * 2) * (Math.min(canvas.width, canvas.height) / 3),
981
- y: canvas.height / 2 + Math.sin(i / graphData.nodes.length * Math.PI * 2) * (Math.min(canvas.width, canvas.height) / 3),
982
- }));
983
-
984
- function draw() {
985
- ctx.clearRect(0, 0, canvas.width, canvas.height);
986
-
987
- graphData.edges.forEach(edge => {
988
- const s = nodes.find(n => n.id === edge.source);
989
- const t = nodes.find(n => n.id === edge.target);
990
- if (!s || !t) return;
991
- if (edge.type === 'related') return;
992
- if (edge.type === 'similarity') {
993
- ctx.strokeStyle = '#fb7e81';
994
- ctx.lineWidth = 1.2;
995
- } else if (edge.type === 'dependency') {
996
- ctx.strokeStyle = '#84c1ff';
997
- ctx.lineWidth = 1.0;
998
- } else if (edge.type === 'reference') {
999
- ctx.strokeStyle = '#ffa500';
1000
- ctx.lineWidth = 0.9;
1001
- } else {
1002
- ctx.strokeStyle = '#334155';
1003
- ctx.lineWidth = 0.8;
1004
- }
1005
- ctx.beginPath();
1006
- ctx.moveTo(s.x, s.y);
1007
- ctx.lineTo(t.x, t.y);
1008
- ctx.stroke();
1009
- });
1010
-
1011
- const groups = {};
1012
- nodes.forEach(n => {
1013
- const g = n.group || '__default';
1014
- if (!groups[g]) groups[g] = { minX: n.x, minY: n.y, maxX: n.x, maxY: n.y };
1015
- groups[g].minX = Math.min(groups[g].minX, n.x);
1016
- groups[g].minY = Math.min(groups[g].minY, n.y);
1017
- groups[g].maxX = Math.max(groups[g].maxX, n.x);
1018
- groups[g].maxY = Math.max(groups[g].maxY, n.y);
1019
- });
1020
-
1021
- const groupRelations = {};
1022
- graphData.edges.forEach(edge => {
1023
- const sNode = nodes.find(n => n.id === edge.source);
1024
- const tNode = nodes.find(n => n.id === edge.target);
1025
- if (!sNode || !tNode) return;
1026
- const g1 = sNode.group || '__default';
1027
- const g2 = tNode.group || '__default';
1028
- if (g1 === g2) return;
1029
- const key = g1 < g2 ? g1 + '::' + g2 : g2 + '::' + g1;
1030
- groupRelations[key] = (groupRelations[key] || 0) + 1;
1031
- });
1032
-
1033
- Object.keys(groupRelations).forEach(k => {
1034
- const count = groupRelations[k];
1035
- const [ga, gb] = k.split('::');
1036
- if (!groups[ga] || !groups[gb]) return;
1037
- const ax = (groups[ga].minX + groups[ga].maxX) / 2;
1038
- const ay = (groups[ga].minY + groups[ga].maxY) / 2;
1039
- const bx = (groups[gb].minX + groups[gb].maxX) / 2;
1040
- const by = (groups[gb].minY + groups[gb].maxY) / 2;
1041
- ctx.beginPath();
1042
- ctx.strokeStyle = 'rgba(148,163,184,0.25)';
1043
- ctx.lineWidth = Math.min(6, 0.6 + Math.sqrt(count));
1044
- ctx.moveTo(ax, ay);
1045
- ctx.lineTo(bx, by);
1046
- ctx.stroke();
1047
- });
1048
-
1049
- Object.keys(groups).forEach(g => {
1050
- if (g === '__default') return;
1051
- const box = groups[g];
1052
- const pad = 16;
1053
- const x = box.minX - pad;
1054
- const y = box.minY - pad;
1055
- const w = (box.maxX - box.minX) + pad * 2;
1056
- const h = (box.maxY - box.minY) + pad * 2;
1057
- ctx.save();
1058
- ctx.fillStyle = 'rgba(30,64,175,0.04)';
1059
- ctx.strokeStyle = 'rgba(30,64,175,0.12)';
1060
- ctx.lineWidth = 1.2;
1061
- const r = 8;
1062
- ctx.beginPath();
1063
- ctx.moveTo(x + r, y);
1064
- ctx.arcTo(x + w, y, x + w, y + h, r);
1065
- ctx.arcTo(x + w, y + h, x, y + h, r);
1066
- ctx.arcTo(x, y + h, x, y, r);
1067
- ctx.arcTo(x, y, x + w, y, r);
1068
- ctx.closePath();
1069
- ctx.fill();
1070
- ctx.stroke();
1071
- ctx.restore();
1072
- ctx.fillStyle = '#94a3b8';
1073
- ctx.font = '11px sans-serif';
1074
- ctx.fillText(g, x + 8, y + 14);
1075
- });
1076
-
1077
- nodes.forEach(n => {
1078
- const sizeVal = (n.size || n.value || 1);
1079
- const r = 6 + (sizeVal / 2);
1080
- ctx.beginPath();
1081
- ctx.fillStyle = n.color || '#60a5fa';
1082
- ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
1083
- ctx.fill();
1084
-
1085
- ctx.fillStyle = '#e2e8f0';
1086
- ctx.font = '11px sans-serif';
1087
- ctx.textAlign = 'center';
1088
- ctx.fillText(n.label || n.id.split('/').slice(-1)[0], n.x, n.y + r + 12);
1089
- });
1090
- }
1091
-
1092
- draw();
1093
- </script>
1094
- </body>
1095
- </html>`;
1096
- }
1097
1051
 
1098
1052
  /**
1099
1053
  * Generate timestamp for report filenames (YYYYMMDD-HHMMSS)
@@ -1351,7 +1305,7 @@ program
1351
1305
 
1352
1306
  if (options.serve) {
1353
1307
  try {
1354
- const port = Number(options.serve) || 5173;
1308
+ const port = typeof options.serve === 'number' ? options.serve : 5173;
1355
1309
  const http = await import('http');
1356
1310
  const fsp = await import('fs/promises');
1357
1311
  const { exec } = await import('child_process');