@runhalo/cli 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -37,10 +37,14 @@ var __importStar = (this && this.__importStar) || (function () {
37
37
  return result;
38
38
  };
39
39
  })();
40
+ var __importDefault = (this && this.__importDefault) || function (mod) {
41
+ return (mod && mod.__esModule) ? mod : { "default": mod };
42
+ };
40
43
  Object.defineProperty(exports, "__esModule", { value: true });
41
- exports.MAX_HISTORY_ENTRIES = exports.HALO_HISTORY_PATH = exports.HALO_CONFIG_PATH = exports.HALO_CONFIG_DIR = void 0;
44
+ exports.RULES_CACHE_PATH = exports.MAX_HISTORY_ENTRIES = exports.HALO_HISTORY_PATH = exports.HALO_CONFIG_PATH = exports.HALO_CONFIG_DIR = exports.FREE_SCAN_LIMIT = void 0;
42
45
  exports.scan = scan;
43
46
  exports.fix = fix;
47
+ exports.init = init;
44
48
  exports.scanFile = scanFile;
45
49
  exports.scanDirectory = scanDirectory;
46
50
  exports.createEngine = createEngine;
@@ -54,7 +58,18 @@ exports.loadHistory = loadHistory;
54
58
  exports.saveHistory = saveHistory;
55
59
  exports.formatTrend = formatTrend;
56
60
  exports.generateHtmlReport = generateHtmlReport;
61
+ exports.generatePdfReport = generatePdfReport;
57
62
  exports.escapeHtml = escapeHtml;
63
+ exports.validateLicenseKey = validateLicenseKey;
64
+ exports.activateLicense = activateLicense;
65
+ exports.checkScanLimit = checkScanLimit;
66
+ exports.checkProFeature = checkProFeature;
67
+ exports.resolvePacks = resolvePacks;
68
+ exports.resolveRules = resolveRules;
69
+ exports.fetchRulesFromAPI = fetchRulesFromAPI;
70
+ exports.readRulesCache = readRulesCache;
71
+ exports.writeRulesCache = writeRulesCache;
72
+ exports.loadBaselineRules = loadBaselineRules;
58
73
  const commander_1 = require("commander");
59
74
  const glob_1 = require("glob");
60
75
  const fs = __importStar(require("fs"));
@@ -62,6 +77,7 @@ const path = __importStar(require("path"));
62
77
  const os = __importStar(require("os"));
63
78
  const readline = __importStar(require("readline"));
64
79
  const engine_1 = require("@runhalo/engine");
80
+ const pdfkit_1 = __importDefault(require("pdfkit"));
65
81
  // CLI configuration
66
82
  const program = new commander_1.Command();
67
83
  /**
@@ -87,7 +103,12 @@ function getDefaultPatterns() {
87
103
  '**/*.h',
88
104
  '**/*.hpp',
89
105
  '**/*.cs',
90
- '**/*.qml'
106
+ '**/*.qml',
107
+ // Added P3-0: Go, Ruby, XML for multi-language coverage
108
+ '**/*.go',
109
+ '**/*.rb',
110
+ '**/*.xml',
111
+ '**/*.erb'
91
112
  ];
92
113
  }
93
114
  /**
@@ -220,6 +241,7 @@ const colors = {
220
241
  bgYellow: '\x1b[43m',
221
242
  bgBlue: '\x1b[44m',
222
243
  magenta: '\x1b[35m',
244
+ green: '\x1b[32m',
223
245
  };
224
246
  // Detect if color should be used (respect NO_COLOR env and pipe detection)
225
247
  const useColor = !process.env.NO_COLOR && process.stdout.isTTY !== false;
@@ -230,6 +252,20 @@ function c(color, text) {
230
252
  * Format violations as human-readable text
231
253
  */
232
254
  function formatText(results, verbose = false, fileCount = 0, scoreResult) {
255
+ // Load confidence map for verbose mode
256
+ let confidenceMap = {};
257
+ if (verbose) {
258
+ try {
259
+ const rulesJsonPath = require.resolve('@runhalo/engine/rules/rules.json');
260
+ const rulesData = JSON.parse(fs.readFileSync(rulesJsonPath, 'utf-8'));
261
+ for (const rule of rulesData.rules || []) {
262
+ if (rule.id && rule.confidence) {
263
+ confidenceMap[rule.id] = rule.confidence;
264
+ }
265
+ }
266
+ }
267
+ catch { /* ignore — confidence display is optional */ }
268
+ }
233
269
  let output = '';
234
270
  let totalViolations = 0;
235
271
  let criticalCount = 0;
@@ -276,15 +312,52 @@ function formatText(results, verbose = false, fileCount = 0, scoreResult) {
276
312
  default:
277
313
  severityTag = c(colors.dim, 'LOW');
278
314
  }
315
+ // Sprint 8: AST verdict badge
316
+ let astBadge = '';
317
+ if (violation.astVerdict === 'confirmed') {
318
+ astBadge = c(colors.red, ' [AST ✓]');
319
+ }
320
+ else if (violation.astVerdict === 'suppressed') {
321
+ astBadge = c(colors.dim, ' [AST suppressed]');
322
+ }
323
+ else if (violation.astVerdict === 'regex_only') {
324
+ astBadge = c(colors.dim, ' [regex]');
325
+ }
326
+ // Sprint 8: Confidence badge
327
+ let confidenceBadge = '';
328
+ if (violation.confidence !== undefined) {
329
+ const confVal = violation.confidence;
330
+ const confStr = confVal.toFixed(2);
331
+ if (confVal >= 0.7) {
332
+ confidenceBadge = c(colors.red, ` [${confStr}]`);
333
+ }
334
+ else if (confVal >= 0.4) {
335
+ confidenceBadge = c(colors.yellow, ` [${confStr}]`);
336
+ }
337
+ else {
338
+ confidenceBadge = c(colors.dim, ` [${confStr}]`);
339
+ }
340
+ }
279
341
  // Always show line:column (developer-standard format)
280
342
  const location = c(colors.dim, `${violation.line}:${violation.column}`);
281
- output += ` ${location} ${severityTag} ${c(colors.cyan, violation.ruleId)}\n`;
343
+ output += ` ${location} ${severityTag} ${c(colors.cyan, violation.ruleId)}${astBadge}${confidenceBadge}\n`;
282
344
  output += ` ${c(colors.dim, '│')} ${violation.message}\n`;
283
345
  if (verbose) {
284
346
  output += ` ${c(colors.dim, '│')} ${c(colors.magenta, '💡')} ${violation.fixSuggestion}\n`;
285
347
  if (violation.penalty) {
286
348
  output += ` ${c(colors.dim, '│')} ${c(colors.red, '⚠')} Penalty: ${violation.penalty}\n`;
287
349
  }
350
+ if (violation.astReason) {
351
+ output += ` ${c(colors.dim, '│')} ${c(colors.dim, '🔬')} AST: ${violation.astReason}\n`;
352
+ }
353
+ if (violation.confidenceReason) {
354
+ output += ` ${c(colors.dim, '│')} ${c(colors.dim, '📊')} ${violation.confidenceReason}\n`;
355
+ }
356
+ const conf = confidenceMap[violation.ruleId];
357
+ if (conf) {
358
+ const confColor = conf === 'high' ? colors.green : conf === 'medium' ? colors.yellow : colors.red;
359
+ output += ` ${c(colors.dim, '│')} Rule confidence: ${c(confColor, conf)}\n`;
360
+ }
288
361
  }
289
362
  output += '\n';
290
363
  }
@@ -582,6 +655,398 @@ function escapeHtml(text) {
582
655
  .replace(/"/g, '"')
583
656
  .replace(/'/g, ''');
584
657
  }
658
+ // ==================== PDF Report Generator (P3-2) ====================
659
+ // PDF color constants
660
+ const PDF_COLORS = {
661
+ primary: '#6366f1', // Halo accent (indigo)
662
+ purple: '#a855f7',
663
+ green: '#22c55e',
664
+ cyan: '#3b82f6',
665
+ yellow: '#eab308',
666
+ orange: '#f97316',
667
+ red: '#ef4444',
668
+ darkText: '#0f172a',
669
+ bodyText: '#1e293b',
670
+ mutedText: '#64748b',
671
+ lightText: '#94a3b8',
672
+ border: '#e2e8f0',
673
+ lightBg: '#f8fafc',
674
+ white: '#ffffff',
675
+ };
676
+ function gradeColor(grade) {
677
+ switch (grade) {
678
+ case 'A': return PDF_COLORS.green;
679
+ case 'B': return PDF_COLORS.cyan;
680
+ case 'C': return PDF_COLORS.yellow;
681
+ case 'D': return PDF_COLORS.orange;
682
+ default: return PDF_COLORS.red;
683
+ }
684
+ }
685
+ function severityColor(severity) {
686
+ switch (severity) {
687
+ case 'critical': return PDF_COLORS.red;
688
+ case 'high': return PDF_COLORS.orange;
689
+ case 'medium': return PDF_COLORS.yellow;
690
+ default: return PDF_COLORS.cyan;
691
+ }
692
+ }
693
+ /**
694
+ * Generate a government-procurement-grade PDF compliance report.
695
+ * Uses PDFKit — pure JS, no browser dependencies, CI-safe.
696
+ */
697
+ function generatePdfReport(results, scoreResult, fileCount, projectPath, history) {
698
+ return new Promise((resolve, reject) => {
699
+ const doc = new pdfkit_1.default({
700
+ size: 'LETTER',
701
+ margins: { top: 60, bottom: 60, left: 60, right: 60 },
702
+ info: {
703
+ Title: 'COPPA 2.0 Compliance Report',
704
+ Author: 'Halo by Mindful Media',
705
+ Subject: `Compliance scan of ${projectPath}`,
706
+ Creator: `Halo v${CLI_VERSION}`,
707
+ },
708
+ });
709
+ const chunks = [];
710
+ doc.on('data', (chunk) => chunks.push(chunk));
711
+ doc.on('end', () => resolve(Buffer.concat(chunks)));
712
+ doc.on('error', reject);
713
+ const pageWidth = doc.page.width - 120; // 60px margins each side
714
+ const scanDate = new Date().toLocaleDateString('en-US', {
715
+ weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
716
+ });
717
+ const scanTime = new Date().toLocaleTimeString('en-US', {
718
+ hour: '2-digit', minute: '2-digit'
719
+ });
720
+ const totalViolations = results.reduce((sum, r) => sum + r.violations.length, 0);
721
+ const allViolations = results.flatMap(r => r.violations);
722
+ const { critical = 0, high = 0, medium = 0, low = 0 } = scoreResult.bySeverity || {};
723
+ // ═══════════════ HELPER: Page footer ═══════════════
724
+ let pageNum = 0;
725
+ function addFooter() {
726
+ pageNum++;
727
+ const y = doc.page.height - 40;
728
+ doc.save();
729
+ doc.fontSize(7).fillColor(PDF_COLORS.lightText);
730
+ doc.text(`Generated by Halo v${CLI_VERSION} — runhalo.dev`, 60, y, { width: pageWidth / 2, align: 'left' });
731
+ doc.text(`Page ${pageNum}`, 60, y, { width: pageWidth, align: 'right' });
732
+ doc.restore();
733
+ }
734
+ // ═══════════════ COVER PAGE ═══════════════
735
+ doc.save();
736
+ // Logo block
737
+ const logoX = 60;
738
+ const logoY = 100;
739
+ doc.roundedRect(logoX, logoY, 48, 48, 10).fill(PDF_COLORS.primary);
740
+ doc.fontSize(24).fillColor(PDF_COLORS.white).text('H', logoX + 14, logoY + 10, { width: 48 });
741
+ // Title
742
+ doc.fontSize(32).fillColor(PDF_COLORS.darkText).text('COPPA 2.0', 60, 180, { width: pageWidth });
743
+ doc.fontSize(32).fillColor(PDF_COLORS.darkText).text('Compliance Report', 60, 220, { width: pageWidth });
744
+ // Divider
745
+ doc.moveTo(60, 270).lineTo(60 + pageWidth, 270).lineWidth(2).strokeColor(PDF_COLORS.primary).stroke();
746
+ // Metadata
747
+ doc.fontSize(11).fillColor(PDF_COLORS.mutedText);
748
+ doc.text(`Project: ${projectPath}`, 60, 290);
749
+ doc.text(`Date: ${scanDate} at ${scanTime}`, 60, 308);
750
+ doc.text(`Files: ${fileCount} files scanned`, 60, 326);
751
+ doc.text(`Scanner: Halo v${CLI_VERSION}`, 60, 344);
752
+ // Score display (centered, large)
753
+ const scoreY = 420;
754
+ const scoreCenterX = 60 + pageWidth / 2;
755
+ // Score circle background
756
+ doc.circle(scoreCenterX, scoreY, 60).lineWidth(8).strokeColor(PDF_COLORS.border).stroke();
757
+ // Score arc (proportional to score)
758
+ if (scoreResult.score > 0) {
759
+ const startAngle = -Math.PI / 2;
760
+ const endAngle = startAngle + (scoreResult.score / 100) * 2 * Math.PI;
761
+ // Draw score arc
762
+ doc.save();
763
+ doc.circle(scoreCenterX, scoreY, 60).lineWidth(8).strokeColor(gradeColor(scoreResult.grade)).stroke();
764
+ doc.restore();
765
+ }
766
+ // Score number
767
+ doc.fontSize(36).fillColor(PDF_COLORS.darkText);
768
+ const scoreText = `${scoreResult.score}`;
769
+ doc.text(scoreText, scoreCenterX - 30, scoreY - 20, { width: 60, align: 'center' });
770
+ doc.fontSize(10).fillColor(PDF_COLORS.mutedText);
771
+ doc.text('out of 100', scoreCenterX - 30, scoreY + 20, { width: 60, align: 'center' });
772
+ // Grade badge
773
+ doc.fontSize(48).fillColor(gradeColor(scoreResult.grade));
774
+ doc.text(`Grade ${scoreResult.grade}`, 60, scoreY + 80, { width: pageWidth, align: 'center' });
775
+ // Summary line
776
+ doc.fontSize(12).fillColor(PDF_COLORS.bodyText);
777
+ doc.text(`${totalViolations} violation${totalViolations !== 1 ? 's' : ''} found across ${results.filter(r => r.violations.length > 0).length} file${results.filter(r => r.violations.length > 0).length !== 1 ? 's' : ''}`, 60, scoreY + 140, { width: pageWidth, align: 'center' });
778
+ // Confidentiality notice
779
+ doc.fontSize(8).fillColor(PDF_COLORS.lightText);
780
+ doc.text('CONFIDENTIAL — FOR INTERNAL COMPLIANCE USE ONLY', 60, doc.page.height - 80, { width: pageWidth, align: 'center' });
781
+ addFooter();
782
+ doc.restore();
783
+ // ═══════════════ EXECUTIVE SUMMARY ═══════════════
784
+ doc.addPage();
785
+ doc.fontSize(20).fillColor(PDF_COLORS.darkText).text('Executive Summary', 60, 60);
786
+ doc.moveTo(60, 88).lineTo(60 + pageWidth, 88).lineWidth(1).strokeColor(PDF_COLORS.border).stroke();
787
+ let y = 100;
788
+ // Score + grade inline
789
+ doc.fontSize(14).fillColor(PDF_COLORS.bodyText);
790
+ doc.text(`Compliance Score: ${scoreResult.score}/100 (Grade ${scoreResult.grade})`, 60, y);
791
+ y += 30;
792
+ // Trend (if available)
793
+ if (history && history.length > 0) {
794
+ const projectHistory = history.filter((h) => h.projectPath === projectPath);
795
+ if (projectHistory.length > 0) {
796
+ const last = projectHistory[projectHistory.length - 1];
797
+ const diff = scoreResult.score - last.score;
798
+ const arrow = diff > 0 ? '↑' : diff < 0 ? '↓' : '→';
799
+ const trendColor = diff > 0 ? PDF_COLORS.green : diff < 0 ? PDF_COLORS.red : PDF_COLORS.mutedText;
800
+ doc.fontSize(11).fillColor(trendColor);
801
+ doc.text(`${arrow} ${last.score}% → ${scoreResult.score}% (${diff > 0 ? '+' : ''}${diff} since last scan)`, 60, y);
802
+ y += 25;
803
+ }
804
+ }
805
+ // Severity breakdown table
806
+ y += 10;
807
+ doc.fontSize(14).fillColor(PDF_COLORS.darkText).text('Severity Breakdown', 60, y);
808
+ y += 25;
809
+ // Table header
810
+ const col1 = 60, col2 = 220, col3 = 320, col4 = 420;
811
+ doc.fontSize(9).fillColor(PDF_COLORS.mutedText);
812
+ doc.text('SEVERITY', col1, y);
813
+ doc.text('COUNT', col2, y);
814
+ doc.text('POINTS DEDUCTED', col3, y);
815
+ doc.text('% OF TOTAL', col4, y);
816
+ y += 18;
817
+ doc.moveTo(60, y).lineTo(60 + pageWidth, y).lineWidth(0.5).strokeColor(PDF_COLORS.border).stroke();
818
+ y += 8;
819
+ const severities = [
820
+ { label: 'Critical', count: critical, points: critical * 10, color: PDF_COLORS.red },
821
+ { label: 'High', count: high, points: high * 5, color: PDF_COLORS.orange },
822
+ { label: 'Medium', count: medium, points: medium * 2, color: PDF_COLORS.yellow },
823
+ { label: 'Low', count: low, points: low * 1, color: PDF_COLORS.cyan },
824
+ ];
825
+ for (const sev of severities) {
826
+ // Colored dot
827
+ doc.circle(col1 + 5, y + 5, 4).fill(sev.color);
828
+ doc.fontSize(10).fillColor(PDF_COLORS.bodyText);
829
+ doc.text(sev.label, col1 + 16, y);
830
+ doc.text(`${sev.count}`, col2, y);
831
+ doc.text(sev.count > 0 ? `-${sev.points}` : '—', col3, y);
832
+ const pct = totalViolations > 0 ? ((sev.count / totalViolations) * 100).toFixed(0) : '0';
833
+ doc.text(`${pct}%`, col4, y);
834
+ y += 20;
835
+ }
836
+ // Total row
837
+ doc.moveTo(60, y).lineTo(60 + pageWidth, y).lineWidth(0.5).strokeColor(PDF_COLORS.border).stroke();
838
+ y += 8;
839
+ doc.fontSize(10).fillColor(PDF_COLORS.darkText);
840
+ doc.text('Total', col1 + 16, y, { bold: true });
841
+ doc.text(`${totalViolations}`, col2, y);
842
+ doc.text(`-${scoreResult.pointsDeducted}`, col3, y);
843
+ y += 30;
844
+ // Key findings
845
+ doc.fontSize(14).fillColor(PDF_COLORS.darkText).text('Key Findings', 60, y);
846
+ y += 25;
847
+ doc.fontSize(10).fillColor(PDF_COLORS.bodyText);
848
+ if (totalViolations === 0) {
849
+ doc.text('No COPPA compliance issues were detected in this codebase. All 20 rules passed.', 60, y, { width: pageWidth });
850
+ }
851
+ else {
852
+ if (critical > 0) {
853
+ doc.fillColor(PDF_COLORS.red);
854
+ doc.text(`• ${critical} critical issue${critical !== 1 ? 's' : ''} require immediate attention — these represent the highest compliance risk.`, 60, y, { width: pageWidth });
855
+ y += 18;
856
+ }
857
+ if (high > 0) {
858
+ doc.fillColor(PDF_COLORS.orange);
859
+ doc.text(`• ${high} high-severity issue${high !== 1 ? 's' : ''} should be resolved before production release.`, 60, y, { width: pageWidth });
860
+ y += 18;
861
+ }
862
+ // Auto-fixable count
863
+ const autoFixable = allViolations.filter(v => ['coppa-sec-006', 'coppa-sec-010', 'coppa-sec-015', 'coppa-default-020'].includes(v.ruleId));
864
+ if (autoFixable.length > 0) {
865
+ doc.fillColor(PDF_COLORS.green);
866
+ doc.text(`• ${autoFixable.length} issue${autoFixable.length !== 1 ? 's' : ''} can be auto-fixed by running: npx runhalo fix .`, 60, y, { width: pageWidth });
867
+ y += 18;
868
+ }
869
+ // Unique rules triggered
870
+ const uniqueRules = [...new Set(allViolations.map(v => v.ruleId))];
871
+ doc.fillColor(PDF_COLORS.bodyText);
872
+ doc.text(`• ${uniqueRules.length} unique COPPA rule${uniqueRules.length !== 1 ? 's' : ''} triggered across ${fileCount} scanned files.`, 60, y, { width: pageWidth });
873
+ }
874
+ addFooter();
875
+ // ═══════════════ DETAILED FINDINGS ═══════════════
876
+ if (totalViolations > 0) {
877
+ doc.addPage();
878
+ doc.fontSize(20).fillColor(PDF_COLORS.darkText).text('Detailed Findings', 60, 60);
879
+ doc.moveTo(60, 88).lineTo(60 + pageWidth, 88).lineWidth(1).strokeColor(PDF_COLORS.border).stroke();
880
+ y = 100;
881
+ // Group violations by severity
882
+ const bySeverity = {
883
+ critical: [], high: [], medium: [], low: []
884
+ };
885
+ for (const result of results) {
886
+ for (const v of result.violations) {
887
+ const relPath = path.relative(projectPath, result.filePath) || result.filePath;
888
+ bySeverity[v.severity]?.push({ file: relPath, violation: v });
889
+ }
890
+ }
891
+ // PDF Cap: Show max 25 violations to keep PDF under ~10 pages
892
+ // Priority: ALL critical/high first, then fill remaining slots with medium/low
893
+ const PDF_VIOLATION_CAP = 25;
894
+ let violationsShown = 0;
895
+ let violationsOmitted = 0;
896
+ const totalAllItems = Object.values(bySeverity).reduce((sum, items) => sum + items.length, 0);
897
+ for (const severity of ['critical', 'high', 'medium', 'low']) {
898
+ const items = bySeverity[severity];
899
+ if (!items || items.length === 0)
900
+ continue;
901
+ // Determine how many of this severity to show
902
+ const remainingSlots = PDF_VIOLATION_CAP - violationsShown;
903
+ if (remainingSlots <= 0) {
904
+ violationsOmitted += items.length;
905
+ continue;
906
+ }
907
+ const itemsToShow = items.slice(0, remainingSlots);
908
+ const itemsSkipped = items.length - itemsToShow.length;
909
+ violationsOmitted += itemsSkipped;
910
+ // Check if we need a new page
911
+ if (y > doc.page.height - 150) {
912
+ addFooter();
913
+ doc.addPage();
914
+ y = 60;
915
+ }
916
+ // Severity header
917
+ doc.fontSize(14).fillColor(severityColor(severity));
918
+ doc.text(`${severity.charAt(0).toUpperCase() + severity.slice(1)} (${items.length}${itemsSkipped > 0 ? `, showing ${itemsToShow.length}` : ''})`, 60, y);
919
+ y += 22;
920
+ for (const item of itemsToShow) {
921
+ // Check page break
922
+ if (y > doc.page.height - 120) {
923
+ addFooter();
924
+ doc.addPage();
925
+ y = 60;
926
+ }
927
+ // Violation entry
928
+ doc.fontSize(9).fillColor(severityColor(severity));
929
+ doc.text(severity.toUpperCase(), 60, y);
930
+ doc.fillColor(PDF_COLORS.primary);
931
+ doc.text(item.violation.ruleId, 120, y);
932
+ doc.fillColor(PDF_COLORS.mutedText);
933
+ doc.text(`${item.file}:${item.violation.line}`, 260, y);
934
+ y += 14;
935
+ // Message
936
+ doc.fontSize(9).fillColor(PDF_COLORS.bodyText);
937
+ const msgHeight = doc.heightOfString(item.violation.message, { width: pageWidth - 20 });
938
+ doc.text(item.violation.message, 70, y, { width: pageWidth - 20 });
939
+ y += msgHeight + 4;
940
+ // Code snippet (if available)
941
+ if (item.violation.codeSnippet) {
942
+ const snippet = item.violation.codeSnippet.length > 120
943
+ ? item.violation.codeSnippet.substring(0, 117) + '...'
944
+ : item.violation.codeSnippet;
945
+ doc.rect(70, y, pageWidth - 20, 16).fill(PDF_COLORS.lightBg);
946
+ doc.fontSize(7).fillColor(PDF_COLORS.mutedText);
947
+ doc.text(snippet, 74, y + 4, { width: pageWidth - 28 });
948
+ y += 22;
949
+ }
950
+ // Fix suggestion
951
+ if (item.violation.fixSuggestion) {
952
+ doc.fontSize(8).fillColor(PDF_COLORS.green);
953
+ const fixText = `Fix: ${item.violation.fixSuggestion}`;
954
+ const fixHeight = doc.heightOfString(fixText, { width: pageWidth - 20 });
955
+ doc.text(fixText, 70, y, { width: pageWidth - 20 });
956
+ y += fixHeight + 4;
957
+ }
958
+ // Penalty
959
+ if (item.violation.penalty) {
960
+ doc.fontSize(7).fillColor(PDF_COLORS.red);
961
+ doc.text(`Penalty: ${item.violation.penalty}`, 70, y);
962
+ y += 12;
963
+ }
964
+ y += 8; // spacing between violations
965
+ violationsShown++;
966
+ }
967
+ y += 10; // spacing between severity groups
968
+ }
969
+ // Dashboard CTA for omitted violations
970
+ if (violationsOmitted > 0) {
971
+ if (y > doc.page.height - 120) {
972
+ addFooter();
973
+ doc.addPage();
974
+ y = 60;
975
+ }
976
+ y += 10;
977
+ doc.rect(60, y, pageWidth, 60).fill('#f0f0ff');
978
+ doc.fontSize(11).fillColor(PDF_COLORS.primary);
979
+ doc.text(`${violationsOmitted} additional violation(s) not shown in this report.`, 70, y + 12, { width: pageWidth - 20 });
980
+ doc.fontSize(10).fillColor(PDF_COLORS.bodyText);
981
+ doc.text('View all violations on your Halo Dashboard: https://runhalo.dev/app/dashboard.html', 70, y + 30, { width: pageWidth - 20 });
982
+ y += 70;
983
+ }
984
+ addFooter();
985
+ }
986
+ // ═══════════════ RECOMMENDATIONS ═══════════════
987
+ doc.addPage();
988
+ doc.fontSize(20).fillColor(PDF_COLORS.darkText).text('Recommendations', 60, 60);
989
+ doc.moveTo(60, 88).lineTo(60 + pageWidth, 88).lineWidth(1).strokeColor(PDF_COLORS.border).stroke();
990
+ y = 100;
991
+ let recNum = 1;
992
+ doc.fontSize(10).fillColor(PDF_COLORS.bodyText);
993
+ if (totalViolations === 0) {
994
+ doc.text('No issues detected. This codebase passes all current COPPA 2.0 compliance checks.', 60, y, { width: pageWidth });
995
+ y += 20;
996
+ doc.text('Recommended next steps:', 60, y, { width: pageWidth });
997
+ y += 16;
998
+ doc.text(`${recNum++}. Schedule regular scans as part of your CI/CD pipeline.`, 70, y, { width: pageWidth - 20 });
999
+ y += 16;
1000
+ doc.text(`${recNum++}. Enable ethical design rules with --ethical-preview for proactive compliance.`, 70, y, { width: pageWidth - 20 });
1001
+ y += 16;
1002
+ doc.text(`${recNum++}. Run "npx runhalo init --ide" to teach your AI coding assistants COPPA rules.`, 70, y, { width: pageWidth - 20 });
1003
+ }
1004
+ else {
1005
+ if (critical > 0) {
1006
+ doc.fillColor(PDF_COLORS.red);
1007
+ doc.text(`${recNum++}. Fix ${critical} critical issue${critical !== 1 ? 's' : ''} immediately — these represent the highest compliance risk and largest potential penalties.`, 70, y, { width: pageWidth - 20 });
1008
+ y += 22;
1009
+ }
1010
+ if (high > 0) {
1011
+ doc.fillColor(PDF_COLORS.orange);
1012
+ doc.text(`${recNum++}. Address ${high} high-severity issue${high !== 1 ? 's' : ''} before production release — these are significant compliance gaps.`, 70, y, { width: pageWidth - 20 });
1013
+ y += 22;
1014
+ }
1015
+ const autoFixable = allViolations.filter(v => ['coppa-sec-006', 'coppa-sec-010', 'coppa-sec-015', 'coppa-default-020'].includes(v.ruleId));
1016
+ if (autoFixable.length > 0) {
1017
+ doc.fillColor(PDF_COLORS.green);
1018
+ doc.text(`${recNum++}. Run "npx runhalo fix ." to automatically resolve ${autoFixable.length} issue${autoFixable.length !== 1 ? 's' : ''}.`, 70, y, { width: pageWidth - 20 });
1019
+ y += 22;
1020
+ }
1021
+ if (medium > 0) {
1022
+ doc.fillColor(PDF_COLORS.yellow);
1023
+ doc.text(`${recNum++}. Review ${medium} medium-severity issue${medium !== 1 ? 's' : ''} — these may require design changes or policy updates.`, 70, y, { width: pageWidth - 20 });
1024
+ y += 22;
1025
+ }
1026
+ doc.fillColor(PDF_COLORS.bodyText);
1027
+ doc.text(`${recNum++}. Integrate Halo into your CI pipeline: uses: runhalo/action@v1 in GitHub Actions.`, 70, y, { width: pageWidth - 20 });
1028
+ y += 22;
1029
+ doc.text(`${recNum++}. Run "npx runhalo init --ide" to teach AI coding assistants COPPA compliance patterns.`, 70, y, { width: pageWidth - 20 });
1030
+ y += 22;
1031
+ doc.text(`${recNum++}. Schedule re-scan after remediations to track compliance improvement.`, 70, y, { width: pageWidth - 20 });
1032
+ }
1033
+ // COPPA 2.0 context
1034
+ y += 30;
1035
+ doc.fontSize(14).fillColor(PDF_COLORS.darkText).text('Regulatory Context', 60, y);
1036
+ y += 22;
1037
+ doc.fontSize(9).fillColor(PDF_COLORS.bodyText);
1038
+ doc.text('The COPPA 2.0 Final Rule (published April 22, 2025) updates the Children\'s Online Privacy Protection Act with new requirements for data retention, biometric data, push notifications, and advertising. The 12-month compliance grace period ends April 22, 2026, after which enforcement begins with penalties up to $54,540 per violation per child per day.', 60, y, { width: pageWidth });
1039
+ // Disclaimer
1040
+ y = doc.page.height - 130;
1041
+ doc.moveTo(60, y).lineTo(60 + pageWidth, y).lineWidth(0.5).strokeColor(PDF_COLORS.border).stroke();
1042
+ y += 10;
1043
+ doc.fontSize(7).fillColor(PDF_COLORS.lightText);
1044
+ doc.text('DISCLAIMER: Halo is a developer tool designed to assist with code analysis and identifying potential privacy issues. It is not legal advice and does not guarantee compliance with COPPA, GDPR, or any other regulation. Always consult with qualified legal counsel regarding your specific compliance obligations. This report is generated automatically and should be reviewed by a qualified compliance professional.', 60, y, { width: pageWidth });
1045
+ addFooter();
1046
+ // Finalize
1047
+ doc.end();
1048
+ });
1049
+ }
585
1050
  /**
586
1051
  * Load .haloignore from a directory (walks up to find it)
587
1052
  */
@@ -621,7 +1086,9 @@ function scanFile(filePath, content) {
621
1086
  * Scan a directory
622
1087
  */
623
1088
  async function scanDirectory(dirPath, config) {
1089
+ // Create two engines: one for active violations, one with suppressions included
624
1090
  const engine = new engine_1.HaloEngine(config);
1091
+ const suppressionEngine = new engine_1.HaloEngine({ ...config, includeSuppressed: true });
625
1092
  const results = [];
626
1093
  const stats = fs.statSync(dirPath);
627
1094
  if (stats.isDirectory()) {
@@ -641,12 +1108,15 @@ async function scanDirectory(dirPath, config) {
641
1108
  try {
642
1109
  const content = fs.readFileSync(filePath, 'utf-8');
643
1110
  const violations = engine.scanFile(filePath, content);
1111
+ const allViolations = suppressionEngine.scanFile(filePath, content);
1112
+ const suppressedViolations = allViolations.filter(v => v.suppressed);
644
1113
  results.push({
645
1114
  filePath,
646
1115
  violations,
1116
+ suppressedViolations,
647
1117
  scannedAt: new Date().toISOString(),
648
1118
  totalViolations: violations.length,
649
- suppressedCount: 0
1119
+ suppressedCount: suppressedViolations.length
650
1120
  });
651
1121
  }
652
1122
  catch (err) {
@@ -657,12 +1127,15 @@ async function scanDirectory(dirPath, config) {
657
1127
  else {
658
1128
  const content = fs.readFileSync(dirPath, 'utf-8');
659
1129
  const violations = engine.scanFile(dirPath, content);
1130
+ const allViolations = suppressionEngine.scanFile(dirPath, content);
1131
+ const suppressedViolations = allViolations.filter(v => v.suppressed);
660
1132
  results.push({
661
1133
  filePath: dirPath,
662
1134
  violations,
1135
+ suppressedViolations,
663
1136
  scannedAt: new Date().toISOString(),
664
1137
  totalViolations: violations.length,
665
- suppressedCount: 0
1138
+ suppressedCount: suppressedViolations.length
666
1139
  });
667
1140
  }
668
1141
  return results;
@@ -679,6 +1152,162 @@ exports.MAX_HISTORY_ENTRIES = MAX_HISTORY_ENTRIES;
679
1152
  const CLI_VERSION = '0.2.1';
680
1153
  const SUPABASE_URL = 'https://wrfwcmyxxbafcdvxlmug.supabase.co';
681
1154
  const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6IndyZndjbXl4eGJhZmNkdnhsbXVnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzIzNDc5MzIsImV4cCI6MjA4NzkyMzkzMn0.6Wj58QDuojPAY_ArVbZvjhcFVuX5VvzqjaEg0FkoYJI';
1155
+ // Rules Engine API
1156
+ const RULES_API_BASE = `${SUPABASE_URL}/functions/v1`;
1157
+ const RULES_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
1158
+ const RULES_CACHE_PATH = path.join(os.homedir(), '.halo', 'rules-cache.json');
1159
+ exports.RULES_CACHE_PATH = RULES_CACHE_PATH;
1160
+ const RULES_FETCH_TIMEOUT_MS = 5000; // 5 second timeout
1161
+ /**
1162
+ * Fetch rules from the Supabase rules-fetch edge function.
1163
+ * Returns raw JSON rules (not compiled) or null on failure.
1164
+ */
1165
+ async function fetchRulesFromAPI(packs, verbose) {
1166
+ try {
1167
+ const url = `${RULES_API_BASE}/rules-fetch?packs=${packs.join(',')}`;
1168
+ const cachedEtag = readRulesCache()?.etag;
1169
+ const headers = {};
1170
+ if (cachedEtag) {
1171
+ headers['If-None-Match'] = cachedEtag;
1172
+ }
1173
+ const controller = new AbortController();
1174
+ const timeout = setTimeout(() => controller.abort(), RULES_FETCH_TIMEOUT_MS);
1175
+ const res = await fetch(url, { headers, signal: controller.signal });
1176
+ clearTimeout(timeout);
1177
+ if (res.status === 304) {
1178
+ if (verbose)
1179
+ console.error('📡 Rules API: 304 Not Modified (cache hit)');
1180
+ return null; // Caller should use cache
1181
+ }
1182
+ if (!res.ok) {
1183
+ if (verbose)
1184
+ console.error(`📡 Rules API: ${res.status} ${res.statusText}`);
1185
+ return null;
1186
+ }
1187
+ const data = await res.json();
1188
+ const etag = res.headers.get('etag');
1189
+ if (verbose)
1190
+ console.error(`📡 Rules API: fetched ${data.rules?.length || 0} rules`);
1191
+ return { rules: data.rules || [], etag };
1192
+ }
1193
+ catch (err) {
1194
+ if (verbose)
1195
+ console.error(`📡 Rules API: fetch failed (${err.name === 'AbortError' ? 'timeout' : err.message})`);
1196
+ return null;
1197
+ }
1198
+ }
1199
+ /**
1200
+ * Read the local rules cache.
1201
+ */
1202
+ function readRulesCache() {
1203
+ try {
1204
+ if (fs.existsSync(RULES_CACHE_PATH)) {
1205
+ const cache = JSON.parse(fs.readFileSync(RULES_CACHE_PATH, 'utf-8'));
1206
+ return cache;
1207
+ }
1208
+ }
1209
+ catch {
1210
+ // Corrupt cache — ignore
1211
+ }
1212
+ return null;
1213
+ }
1214
+ /**
1215
+ * Write rules to the local cache.
1216
+ */
1217
+ function writeRulesCache(etag, packs, rules) {
1218
+ try {
1219
+ const cacheDir = path.dirname(RULES_CACHE_PATH);
1220
+ if (!fs.existsSync(cacheDir)) {
1221
+ fs.mkdirSync(cacheDir, { recursive: true });
1222
+ }
1223
+ const cache = {
1224
+ etag,
1225
+ packs,
1226
+ rules,
1227
+ fetchedAt: new Date().toISOString(),
1228
+ };
1229
+ fs.writeFileSync(RULES_CACHE_PATH, JSON.stringify(cache, null, 2), 'utf-8');
1230
+ }
1231
+ catch {
1232
+ // Silent failure — never block scan
1233
+ }
1234
+ }
1235
+ /**
1236
+ * Load bundled baseline rules from @runhalo/engine's rules.json.
1237
+ */
1238
+ function loadBaselineRules(packs) {
1239
+ try {
1240
+ const rulesJsonPath = require.resolve('@runhalo/engine/rules/rules.json');
1241
+ const data = JSON.parse(fs.readFileSync(rulesJsonPath, 'utf-8'));
1242
+ return (data.rules || []).filter((r) => r.packs.some(p => packs.includes(p)));
1243
+ }
1244
+ catch {
1245
+ return null;
1246
+ }
1247
+ }
1248
+ /**
1249
+ * Map CLI options to pack IDs.
1250
+ * --pack takes precedence. Legacy flags (--ethical-preview, --ai-audit, --sector-au-sbd, --sector-au-osa) are mapped.
1251
+ */
1252
+ function resolvePacks(options) {
1253
+ // Explicit --pack flag takes priority
1254
+ if (options.pack && options.pack.length > 0) {
1255
+ return options.pack;
1256
+ }
1257
+ // Legacy boolean flags
1258
+ const packs = ['coppa'];
1259
+ if (options.ethicalPreview)
1260
+ packs.push('ethical');
1261
+ if (options.aiAudit)
1262
+ packs.push('ai-audit');
1263
+ if (options.sectorAuSbd)
1264
+ packs.push('au-sbd');
1265
+ if (options.sectorAuOsa)
1266
+ packs.push('au-osa');
1267
+ return packs;
1268
+ }
1269
+ /**
1270
+ * Resolve rules with fallback chain:
1271
+ * API (fresh) → 304 cache hit → local cache (stale OK) → bundled baseline → null
1272
+ */
1273
+ async function resolveRules(packs, offline, verbose) {
1274
+ // 1. Try API (unless offline)
1275
+ if (!offline) {
1276
+ const apiResult = await fetchRulesFromAPI(packs, verbose);
1277
+ if (apiResult && apiResult.rules.length > 0) {
1278
+ // Fresh rules from API — cache and use
1279
+ writeRulesCache(apiResult.etag, packs, apiResult.rules);
1280
+ return apiResult.rules;
1281
+ }
1282
+ // apiResult === null could mean 304 (use cache) or failure (also try cache)
1283
+ }
1284
+ // 2. Try local cache
1285
+ const cache = readRulesCache();
1286
+ if (cache && cache.rules.length > 0) {
1287
+ const cacheAge = Date.now() - new Date(cache.fetchedAt).getTime();
1288
+ const isFresh = cacheAge < RULES_CACHE_TTL_MS;
1289
+ const packsMatch = packs.every(p => cache.packs.includes(p));
1290
+ if (packsMatch) {
1291
+ if (verbose) {
1292
+ console.error(`📦 Using cached rules (${isFresh ? 'fresh' : 'stale'}, ${cache.rules.length} rules)`);
1293
+ }
1294
+ return cache.rules;
1295
+ }
1296
+ }
1297
+ // 3. Try bundled baseline
1298
+ const baseline = loadBaselineRules(packs);
1299
+ if (baseline && baseline.length > 0) {
1300
+ if (verbose) {
1301
+ console.error(`📦 Using bundled baseline rules (${baseline.length} rules)`);
1302
+ }
1303
+ return baseline;
1304
+ }
1305
+ // 4. Return null — engine will use hardcoded fallback
1306
+ if (verbose) {
1307
+ console.error('📦 No cached/baseline rules found, engine will use hardcoded fallback');
1308
+ }
1309
+ return null;
1310
+ }
682
1311
  function loadConfig() {
683
1312
  try {
684
1313
  if (fs.existsSync(HALO_CONFIG_PATH)) {
@@ -755,6 +1384,153 @@ async function submitCliLead(email) {
755
1384
  // Silent failure — never block scan
756
1385
  }
757
1386
  }
1387
+ // ==================== License Validation & Scan Limits (P3-1) ====================
1388
+ const FREE_SCAN_LIMIT = 5;
1389
+ exports.FREE_SCAN_LIMIT = FREE_SCAN_LIMIT;
1390
+ /**
1391
+ * Validate a license key against Supabase validate-license edge function.
1392
+ * Returns license info or null on failure.
1393
+ */
1394
+ async function validateLicenseKey(licenseKey) {
1395
+ try {
1396
+ const res = await fetch(`${SUPABASE_URL}/functions/v1/validate-license`, {
1397
+ method: 'POST',
1398
+ headers: {
1399
+ 'Content-Type': 'application/json',
1400
+ 'Authorization': `Bearer ${SUPABASE_ANON_KEY}`,
1401
+ },
1402
+ body: JSON.stringify({ license_key: licenseKey }),
1403
+ });
1404
+ const data = await res.json();
1405
+ return {
1406
+ valid: !!data.valid,
1407
+ tier: data.tier,
1408
+ email: data.email,
1409
+ status: data.status,
1410
+ expires_at: data.expires_at,
1411
+ error: data.error,
1412
+ };
1413
+ }
1414
+ catch {
1415
+ return null;
1416
+ }
1417
+ }
1418
+ /**
1419
+ * Activate a license key — validates via Supabase, stores in ~/.halo/config.json.
1420
+ */
1421
+ async function activateLicense(licenseKey) {
1422
+ console.log(`\n ${c(colors.dim, 'Validating license key...')}`);
1423
+ const result = await validateLicenseKey(licenseKey);
1424
+ if (!result || !result.valid) {
1425
+ console.error(`\n ${c(colors.red + colors.bold, '✗ Invalid license key')}`);
1426
+ if (result?.error) {
1427
+ console.error(` ${c(colors.dim, result.error)}`);
1428
+ }
1429
+ console.error(`\n ${c(colors.dim, 'Get a license at')} ${c(colors.cyan, 'https://runhalo.dev/#pricing')}\n`);
1430
+ return 1;
1431
+ }
1432
+ // Save to config
1433
+ const existing = loadConfig() || {
1434
+ prompted: true,
1435
+ promptedAt: new Date().toISOString(),
1436
+ consent: false,
1437
+ };
1438
+ saveConfig({
1439
+ ...existing,
1440
+ license_key: licenseKey,
1441
+ tier: result.tier,
1442
+ email: result.email || existing.email,
1443
+ });
1444
+ const tierLabel = result.tier === 'enterprise' ? 'Enterprise' : 'Pro';
1445
+ console.log(`\n ${c('\x1b[32m' + colors.bold, `✓ Halo ${tierLabel} activated!`)}`);
1446
+ console.log(` ${c(colors.dim, 'Email:')} ${result.email}`);
1447
+ console.log(` ${c(colors.dim, 'Tier:')} ${c(colors.cyan, tierLabel)}`);
1448
+ if (result.expires_at) {
1449
+ const expiryDate = new Date(result.expires_at).toLocaleDateString('en-US', {
1450
+ year: 'numeric', month: 'long', day: 'numeric'
1451
+ });
1452
+ console.log(` ${c(colors.dim, 'Valid until:')} ${expiryDate}`);
1453
+ }
1454
+ console.log(`\n ${c(colors.dim, 'Unlimited scans. All Pro features unlocked.')}`);
1455
+ console.log(` ${c(colors.dim, 'Run')} ${c(colors.cyan, 'halo scan . --report --ethical')} ${c(colors.dim, 'to get started.')}\n`);
1456
+ return 0;
1457
+ }
1458
+ /**
1459
+ * Check scan limit for free-tier users.
1460
+ * Returns true if scan is allowed, false if blocked.
1461
+ * CI environments always bypass limits.
1462
+ */
1463
+ function checkScanLimit() {
1464
+ // CI always unlimited — never break builds
1465
+ if (process.env.CI || process.stdout.isTTY === false) {
1466
+ return true;
1467
+ }
1468
+ const config = loadConfig();
1469
+ // Pro/Enterprise: unlimited
1470
+ if (config?.tier === 'pro' || config?.tier === 'enterprise') {
1471
+ return true;
1472
+ }
1473
+ // Free tier: check daily limit
1474
+ const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
1475
+ if (config?.scan_date !== today) {
1476
+ // New day — reset counter
1477
+ saveConfig({
1478
+ ...config,
1479
+ prompted: config?.prompted ?? true,
1480
+ promptedAt: config?.promptedAt ?? new Date().toISOString(),
1481
+ consent: config?.consent ?? false,
1482
+ scans_today: 1,
1483
+ scan_date: today,
1484
+ });
1485
+ return true;
1486
+ }
1487
+ const scansToday = config?.scans_today ?? 0;
1488
+ if (scansToday >= FREE_SCAN_LIMIT) {
1489
+ // Limit reached — show upgrade message
1490
+ console.error('');
1491
+ console.error(` ${c(colors.yellow + colors.bold, `⚡ Daily scan limit reached (${FREE_SCAN_LIMIT}/${FREE_SCAN_LIMIT})`)}`);
1492
+ console.error(` ${c(colors.dim, 'Free tier allows 5 scans per day. Your scans reset at midnight.')}`);
1493
+ console.error('');
1494
+ console.error(` ${c(colors.cyan, 'Upgrade to Halo Pro ($29/mo)')}`);
1495
+ console.error(` ${c(colors.dim, '→ Unlimited scans, ethical design rules, HTML reports, guided fixes')}`);
1496
+ console.error(` ${c(colors.dim, '→')} ${c(colors.cyan, 'https://runhalo.dev/#pricing')}`);
1497
+ console.error('');
1498
+ return false;
1499
+ }
1500
+ // Increment counter
1501
+ saveConfig({
1502
+ ...config,
1503
+ prompted: config?.prompted ?? true,
1504
+ promptedAt: config?.promptedAt ?? new Date().toISOString(),
1505
+ consent: config?.consent ?? false,
1506
+ scans_today: scansToday + 1,
1507
+ scan_date: today,
1508
+ });
1509
+ return true;
1510
+ }
1511
+ /**
1512
+ * Check if a Pro feature is available for the current user.
1513
+ * Returns true if allowed, false with upsell message if blocked.
1514
+ */
1515
+ function checkProFeature(featureName, flagName) {
1516
+ // CI always has access — don't break pipelines
1517
+ if (process.env.CI || process.stdout.isTTY === false) {
1518
+ return true;
1519
+ }
1520
+ const config = loadConfig();
1521
+ if (config?.tier === 'pro' || config?.tier === 'enterprise') {
1522
+ return true;
1523
+ }
1524
+ console.error('');
1525
+ console.error(` ${c(colors.yellow + colors.bold, `⚡ ${featureName} requires Halo Pro`)}`);
1526
+ console.error(` ${c(colors.dim, `The ${flagName} flag is a Pro feature.`)}`);
1527
+ console.error('');
1528
+ console.error(` ${c(colors.cyan, 'Upgrade to Halo Pro ($29/mo)')}`);
1529
+ console.error(` ${c(colors.dim, '→ Unlimited scans, ethical design rules, HTML reports, guided fixes')}`);
1530
+ console.error(` ${c(colors.dim, '→')} ${c(colors.cyan, 'https://runhalo.dev/#pricing')}`);
1531
+ console.error('');
1532
+ return false;
1533
+ }
758
1534
  /**
759
1535
  * First-run email prompt — one-time, optional, non-blocking.
760
1536
  * Auto-skips when: config exists, --no-prompt, !isTTY, CI env.
@@ -883,15 +1659,32 @@ async function scan(paths, options) {
883
1659
  if (options.verbose && projectDomains.length > 0) {
884
1660
  console.error(`🏠 Detected project domains: ${[...new Set(projectDomains)].join(', ')}`);
885
1661
  }
886
- const engine = new engine_1.HaloEngine({
1662
+ // Resolve rules via API/cache/baseline fallback chain
1663
+ const packs = resolvePacks(options);
1664
+ const resolvedRawRules = await resolveRules(packs, options.offline, options.verbose);
1665
+ const resolvedRules = resolvedRawRules ? (0, engine_1.compileRawRules)(resolvedRawRules) : undefined;
1666
+ const engineConfig = {
887
1667
  includePatterns: options.include,
888
1668
  excludePatterns: options.exclude,
889
1669
  rules: options.rules.length > 0 ? options.rules : undefined,
890
1670
  severityFilter: options.severity.length > 0 ? options.severity : undefined,
891
1671
  ignoreConfig,
892
1672
  projectDomains: projectDomains.length > 0 ? [...new Set(projectDomains)] : undefined,
893
- ethical: options.ethicalPreview
894
- });
1673
+ // Sprint 8: Framework and AST analysis (from .halorc.json via CLIOptions)
1674
+ framework: options.framework,
1675
+ astAnalysis: options.astAnalysis,
1676
+ // If we got rules from API/cache, use loadedRules. Otherwise fall through to legacy flags.
1677
+ ...(resolvedRules
1678
+ ? { loadedRules: resolvedRules }
1679
+ : {
1680
+ ethical: options.ethicalPreview,
1681
+ aiAudit: options.aiAudit,
1682
+ sectorAuSbd: options.sectorAuSbd,
1683
+ sectorAuOsa: options.sectorAuOsa,
1684
+ }),
1685
+ };
1686
+ const engine = new engine_1.HaloEngine(engineConfig);
1687
+ const suppressionEngine = new engine_1.HaloEngine({ ...engineConfig, includeSuppressed: true });
895
1688
  const results = [];
896
1689
  let fileCount = 0;
897
1690
  // Collect all files to scan
@@ -955,6 +1748,19 @@ async function scan(paths, options) {
955
1748
  }
956
1749
  console.error(`🔍 Scanning ${uniqueFiles.length} files...`);
957
1750
  }
1751
+ // Scan start banner (text format only, stderr so it doesn't pollute JSON/SARIF)
1752
+ if (options.format === 'text') {
1753
+ const packNameMap = {
1754
+ 'coppa': 'COPPA',
1755
+ 'ethical': 'Ethical Design',
1756
+ 'ai-audit': 'AI Audit',
1757
+ 'au-sbd': 'AU Safety by Design',
1758
+ 'au-osa': 'AU Online Safety Act',
1759
+ 'caadca': 'California AADCA',
1760
+ };
1761
+ const packLabel = packs.map(p => packNameMap[p] || p).join(' + ');
1762
+ console.error(c(colors.dim, `🔍 Scanning ${uniqueFiles.length} files (${packLabel})...`));
1763
+ }
958
1764
  // Max file size: 1MB (skip large/binary files)
959
1765
  const MAX_FILE_SIZE = 1024 * 1024;
960
1766
  // Scan each file
@@ -973,14 +1779,24 @@ async function scan(paths, options) {
973
1779
  if (content.substring(0, 512).includes('\0')) {
974
1780
  continue;
975
1781
  }
976
- const violations = engine.scanFile(filePath, content);
977
- if (violations.length > 0) {
1782
+ // Sprint 8: Use AST-enhanced scanning for JS/TS when framework or AST configured
1783
+ const ext = path.extname(filePath).toLowerCase();
1784
+ const isJSTS = ['.ts', '.tsx', '.js', '.jsx'].includes(ext);
1785
+ const useAST = isJSTS && (options.framework || options.astAnalysis !== false);
1786
+ const lang = ['.ts', '.tsx'].includes(ext) ? 'typescript' : 'javascript';
1787
+ const violations = useAST
1788
+ ? engine.scanFileWithAST(filePath, content, lang)
1789
+ : engine.scanFile(filePath, content);
1790
+ const allViolations = suppressionEngine.scanFile(filePath, content);
1791
+ const suppressedViolations = allViolations.filter(v => v.suppressed);
1792
+ if (violations.length > 0 || suppressedViolations.length > 0) {
978
1793
  results.push({
979
1794
  filePath,
980
1795
  violations,
1796
+ suppressedViolations,
981
1797
  scannedAt: new Date().toISOString(),
982
1798
  totalViolations: violations.length,
983
- suppressedCount: 0
1799
+ suppressedCount: suppressedViolations.length
984
1800
  });
985
1801
  }
986
1802
  fileCount++;
@@ -993,6 +1809,7 @@ async function scan(paths, options) {
993
1809
  }
994
1810
  // Calculate compliance score
995
1811
  const allViolations = results.flatMap(r => r.violations);
1812
+ const totalSuppressedCount = results.reduce((sum, r) => sum + r.suppressedCount, 0);
996
1813
  const scorer = new engine_1.ComplianceScoreEngine();
997
1814
  const scoreResult = scorer.calculate(allViolations, fileCount);
998
1815
  // Scan history: compute trend BEFORE saving (so we compare to previous, not current)
@@ -1004,6 +1821,7 @@ async function scan(paths, options) {
1004
1821
  score: scoreResult.score,
1005
1822
  grade: scoreResult.grade,
1006
1823
  totalViolations: scoreResult.totalViolations,
1824
+ suppressedCount: totalSuppressedCount,
1007
1825
  bySeverity: scoreResult.bySeverity,
1008
1826
  filesScanned: fileCount,
1009
1827
  projectPath,
@@ -1025,7 +1843,7 @@ async function scan(paths, options) {
1025
1843
  output += trendLine + '\n';
1026
1844
  }
1027
1845
  }
1028
- // Generate HTML report if requested (in addition to normal output)
1846
+ // Generate report if requested (HTML or PDF based on filename extension)
1029
1847
  if (options.report) {
1030
1848
  const reportFilename = typeof options.report === 'string'
1031
1849
  ? options.report
@@ -1033,9 +1851,18 @@ async function scan(paths, options) {
1033
1851
  const projectHistory = loadHistory().filter(h => h.projectPath === projectPath);
1034
1852
  // Exclude the entry we just saved (last one) so trend is accurate
1035
1853
  const historyForReport = projectHistory.slice(0, -1);
1036
- const html = generateHtmlReport(results, scoreResult, fileCount, projectPath, historyForReport);
1037
- fs.writeFileSync(reportFilename, html, 'utf-8');
1038
- console.error(`📄 HTML report written to ${reportFilename}`);
1854
+ if (reportFilename.endsWith('.pdf')) {
1855
+ // PDF report (government-procurement grade)
1856
+ const pdfBuffer = await generatePdfReport(results, scoreResult, fileCount, projectPath, historyForReport);
1857
+ fs.writeFileSync(reportFilename, pdfBuffer);
1858
+ console.error(`📄 PDF report written to ${reportFilename}`);
1859
+ }
1860
+ else {
1861
+ // HTML report (default)
1862
+ const html = generateHtmlReport(results, scoreResult, fileCount, projectPath, historyForReport);
1863
+ fs.writeFileSync(reportFilename, html, 'utf-8');
1864
+ console.error(`📄 HTML report written to ${reportFilename}`);
1865
+ }
1039
1866
  }
1040
1867
  // Write output (only one path — no duplication)
1041
1868
  if (options.output) {
@@ -1045,6 +1872,15 @@ async function scan(paths, options) {
1045
1872
  else {
1046
1873
  process.stdout.write(output);
1047
1874
  }
1875
+ // Post-scan CTA (text format only — goes to stderr so it won't pollute piped output)
1876
+ if (options.format === 'text') {
1877
+ console.error('');
1878
+ console.error('─────────────────────────────────────────');
1879
+ console.error('📊 Track results over time: runhalo.dev/app/dashboard');
1880
+ console.error('🔗 Share your score: runhalo.dev/app/score');
1881
+ console.error('⬆️ Upload (Pro): runhalo scan . --upload');
1882
+ console.error('─────────────────────────────────────────');
1883
+ }
1048
1884
  // Return exit code based on violations
1049
1885
  const hasCriticalOrHigh = results.some(r => r.violations.some(v => v.severity === 'critical' || v.severity === 'high'));
1050
1886
  if (hasCriticalOrHigh) {
@@ -1275,11 +2111,11 @@ async function fix(paths, options) {
1275
2111
  // CLI setup
1276
2112
  program
1277
2113
  .name('runhalo')
1278
- .description('Halo - Child Safety Compliance Scanner for COPPA 2.0')
2114
+ .description('Halo \u2014 Ethical code scanner for children\u2019s digital safety')
1279
2115
  .version('1.0.0');
1280
2116
  program
1281
2117
  .command('scan')
1282
- .description('Scan files or directories for COPPA violations')
2118
+ .description('Scan source code for child safety compliance violations')
1283
2119
  .argument('[paths...]', 'Paths to scan (default: current directory)', ['.'])
1284
2120
  .option('-f, --format <format>', 'Output format: json, sarif, text', 'text')
1285
2121
  .option('-i, --include <patterns...>', 'File patterns to include')
@@ -1288,23 +2124,296 @@ program
1288
2124
  .option('-s, --severity <levels...>', 'Filter by severity: critical, high, medium, low')
1289
2125
  .option('-o, --output <file>', 'Output file path')
1290
2126
  .option('--ethical-preview', 'Enable experimental ethical design rules (Sprint 5 preview)')
2127
+ .option('--ai-audit', 'Enable AI-generated code audit rules (catch AI coding assistant mistakes)')
2128
+ .option('--sector-au-sbd', 'Enable Australia Safety by Design sector rules (eSafety Commissioner framework)')
2129
+ .option('--sector-au-osa', 'Enable Australia Online Safety Act rules (2021 as amended 2024, under-16 social media ban)')
2130
+ .option('--pack <packs...>', 'Rule packs to scan against (e.g., coppa ethical ai-audit au-sbd au-osa)')
2131
+ .option('--offline', 'Skip API fetch, use cached or bundled rules only')
1291
2132
  .option('--report [filename]', 'Generate HTML compliance report (default: halo-report.html)')
2133
+ .option('--upload', 'Upload scan results to Halo Dashboard (requires Pro)')
2134
+ .option('--watch', 'Watch for file changes and re-scan automatically')
1292
2135
  .option('--no-prompt', 'Skip first-run email prompt')
1293
2136
  .option('-v, --verbose', 'Verbose output')
1294
2137
  .action(async (paths, options) => {
1295
2138
  try {
1296
2139
  await firstRunPrompt(options.prompt === false);
1297
- const exitCode = await scan(paths, {
2140
+ // Pro feature gating (soft upsell — exit 0, not error)
2141
+ if (options.report && !checkProFeature('HTML Compliance Reports', '--report')) {
2142
+ process.exit(0);
2143
+ }
2144
+ if (options.ethicalPreview && !checkProFeature('Ethical Design Rules', '--ethical-preview')) {
2145
+ process.exit(0);
2146
+ }
2147
+ if (options.aiAudit && !checkProFeature('AI-Generated Code Audit', '--ai-audit')) {
2148
+ process.exit(0);
2149
+ }
2150
+ if (options.sectorAuSbd && !checkProFeature('AU Safety by Design Rules', '--sector-au-sbd')) {
2151
+ process.exit(0);
2152
+ }
2153
+ if (options.sectorAuOsa && !checkProFeature('AU Online Safety Act Rules', '--sector-au-osa')) {
2154
+ process.exit(0);
2155
+ }
2156
+ if (options.upload && !checkProFeature('Dashboard Upload', '--upload')) {
2157
+ process.exit(0);
2158
+ }
2159
+ // Scan limit check (soft — exit 0, not error)
2160
+ if (!checkScanLimit()) {
2161
+ process.exit(0);
2162
+ }
2163
+ // ==================== .halorc.json Config ====================
2164
+ const projectRoot = path.resolve(paths[0] || '.');
2165
+ let rcConfig;
2166
+ for (const rcName of ['.halorc.json', '.halorc']) {
2167
+ const rcPath = path.join(fs.existsSync(projectRoot) && fs.statSync(projectRoot).isDirectory()
2168
+ ? projectRoot
2169
+ : path.dirname(projectRoot), rcName);
2170
+ if (fs.existsSync(rcPath)) {
2171
+ try {
2172
+ rcConfig = JSON.parse(fs.readFileSync(rcPath, 'utf-8'));
2173
+ if (options.verbose) {
2174
+ console.error(`📋 Loaded ${rcName} configuration`);
2175
+ }
2176
+ }
2177
+ catch (e) {
2178
+ console.error(`⚠️ Failed to parse ${rcName}: ${e instanceof Error ? e.message : e}`);
2179
+ }
2180
+ break;
2181
+ }
2182
+ }
2183
+ // Merge .halorc.json with CLI flags (CLI flags override)
2184
+ const mergedPacks = (options.pack && options.pack.length > 0)
2185
+ ? options.pack
2186
+ : (rcConfig?.packs || []);
2187
+ const mergedExclude = [
2188
+ ...(options.exclude || []),
2189
+ ...(rcConfig?.ignore || []),
2190
+ ];
2191
+ const scanOptions = {
1298
2192
  format: options.format || 'text',
1299
2193
  include: options.include || [],
1300
- exclude: options.exclude || [],
2194
+ exclude: mergedExclude,
1301
2195
  rules: options.rules || [],
1302
2196
  severity: options.severity || [],
1303
2197
  output: options.output || '',
1304
2198
  verbose: options.verbose || false,
1305
2199
  ethicalPreview: options.ethicalPreview || false,
1306
- report: options.report || false
1307
- });
2200
+ aiAudit: options.aiAudit || false,
2201
+ sectorAuSbd: options.sectorAuSbd || false,
2202
+ sectorAuOsa: options.sectorAuOsa || false,
2203
+ report: options.report || false,
2204
+ pack: mergedPacks,
2205
+ offline: options.offline || false,
2206
+ // Sprint 8: Pass framework and AST config from .halorc.json to scan()
2207
+ framework: rcConfig?.framework,
2208
+ astAnalysis: rcConfig?.astAnalysis,
2209
+ };
2210
+ // ==================== Watch Mode ====================
2211
+ if (options.watch) {
2212
+ const scanRoot = path.resolve(paths[0] || '.');
2213
+ // Load .haloignore for watch filtering
2214
+ let watchIgnoreConfig;
2215
+ try {
2216
+ watchIgnoreConfig = loadHaloignore(fs.statSync(scanRoot).isDirectory() ? scanRoot : path.dirname(scanRoot));
2217
+ }
2218
+ catch {
2219
+ // Ignore errors loading .haloignore
2220
+ }
2221
+ const scannableExts = new Set([
2222
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
2223
+ '.py', '.go', '.java', '.kt', '.kts',
2224
+ '.swift', '.rb', '.php',
2225
+ '.html', '.htm', '.vue', '.svelte',
2226
+ '.xml', '.cs', '.cpp', '.h', '.hpp', '.qml', '.erb',
2227
+ ]);
2228
+ const isScannable = (filePath) => {
2229
+ return scannableExts.has(path.extname(filePath).toLowerCase());
2230
+ };
2231
+ const isExcluded = (filePath) => {
2232
+ const rel = path.relative(scanRoot, filePath);
2233
+ // Common directory excludes
2234
+ if (rel.includes('node_modules') || rel.includes('.git') ||
2235
+ rel.includes('dist/') || rel.includes('build/') ||
2236
+ rel.includes('coverage/') || rel.includes('.next/')) {
2237
+ return true;
2238
+ }
2239
+ // Respect .haloignore patterns
2240
+ if (watchIgnoreConfig && (0, engine_1.shouldIgnoreFile)(rel, watchIgnoreConfig)) {
2241
+ return true;
2242
+ }
2243
+ return false;
2244
+ };
2245
+ // Count scannable files for status line
2246
+ let watchableFileCount = 0;
2247
+ const countFiles = (dir) => {
2248
+ try {
2249
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
2250
+ for (const entry of entries) {
2251
+ const fullPath = path.join(dir, entry.name);
2252
+ if (entry.isDirectory()) {
2253
+ if (!['node_modules', '.git', 'dist', 'build', 'coverage', '.next'].includes(entry.name)) {
2254
+ countFiles(fullPath);
2255
+ }
2256
+ }
2257
+ else if (isScannable(fullPath) && !isExcluded(fullPath)) {
2258
+ watchableFileCount++;
2259
+ }
2260
+ }
2261
+ }
2262
+ catch {
2263
+ // Skip unreadable directories
2264
+ }
2265
+ };
2266
+ countFiles(scanRoot);
2267
+ // Clear terminal and print header
2268
+ const clearAndPrintHeader = () => {
2269
+ process.stdout.write('\x1B[2J\x1B[0f'); // Clear terminal + move cursor to top
2270
+ console.error('👁️ Halo Watch Mode');
2271
+ console.error(` Watching ${watchableFileCount} file(s) in ${path.basename(scanRoot)}/`);
2272
+ if (watchIgnoreConfig)
2273
+ console.error(' 📋 .haloignore loaded');
2274
+ console.error(' Press Ctrl+C to stop.\n');
2275
+ };
2276
+ clearAndPrintHeader();
2277
+ // Initial full scan
2278
+ let lastViolationCount = 0;
2279
+ let scanNumber = 0;
2280
+ const runScan = async () => {
2281
+ scanNumber++;
2282
+ if (scanNumber > 1)
2283
+ clearAndPrintHeader();
2284
+ const startTime = Date.now();
2285
+ const exitCode = await scan(paths, { ...scanOptions, format: 'text', output: '' });
2286
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
2287
+ const history = loadHistory();
2288
+ const lastEntry = history[history.length - 1];
2289
+ const violationCount = lastEntry?.totalViolations || 0;
2290
+ const delta = violationCount - lastViolationCount;
2291
+ const deltaStr = delta > 0
2292
+ ? ` \x1B[31m↑ ${delta} new\x1B[0m`
2293
+ : delta < 0
2294
+ ? ` \x1B[32m↓ ${Math.abs(delta)} fixed\x1B[0m`
2295
+ : scanNumber > 1 ? ' \x1B[90m(no change)\x1B[0m' : '';
2296
+ console.error(`\n⏱ Scan #${scanNumber} complete in ${elapsed}s — ${violationCount} violation(s)${deltaStr}`);
2297
+ console.error(` ${new Date().toLocaleTimeString()} — Watching for changes...\n`);
2298
+ lastViolationCount = violationCount;
2299
+ return exitCode;
2300
+ };
2301
+ await runScan();
2302
+ // Debounce: collect changes for 500ms before re-scanning
2303
+ let debounceTimer = null;
2304
+ const changedFiles = new Set();
2305
+ try {
2306
+ const watcher = fs.watch(scanRoot, { recursive: true }, (eventType, filename) => {
2307
+ if (!filename)
2308
+ return;
2309
+ const fullPath = path.join(scanRoot, filename);
2310
+ // Only re-scan for scannable file changes, respect .haloignore
2311
+ if (!isScannable(fullPath) || isExcluded(fullPath))
2312
+ return;
2313
+ changedFiles.add(filename);
2314
+ // Debounce — wait 500ms after last change before re-scanning
2315
+ if (debounceTimer)
2316
+ clearTimeout(debounceTimer);
2317
+ debounceTimer = setTimeout(async () => {
2318
+ const files = Array.from(changedFiles);
2319
+ changedFiles.clear();
2320
+ console.error(`\n📝 Changed: ${files.join(', ')}`);
2321
+ await runScan();
2322
+ }, 500);
2323
+ });
2324
+ // Keep process alive until Ctrl+C
2325
+ process.on('SIGINT', () => {
2326
+ watcher.close();
2327
+ console.error('\n\n👋 Watch mode stopped.');
2328
+ process.exit(0);
2329
+ });
2330
+ // Prevent Node from exiting
2331
+ await new Promise(() => { }); // Block forever (until SIGINT)
2332
+ }
2333
+ catch (watchErr) {
2334
+ console.error(`❌ Watch mode error: ${watchErr instanceof Error ? watchErr.message : watchErr}`);
2335
+ console.error(' fs.watch with recursive option requires Node.js 18+ on macOS/Windows.');
2336
+ process.exit(3);
2337
+ }
2338
+ return; // Never reached, but TypeScript needs it
2339
+ }
2340
+ // ==================== Standard Scan (non-watch) ====================
2341
+ let exitCode = await scan(paths, scanOptions);
2342
+ // Apply .halorc.json severity_threshold (overrides default exit code behavior)
2343
+ if (rcConfig?.severity_threshold && exitCode > 0) {
2344
+ const severityOrder = ['low', 'medium', 'high', 'critical'];
2345
+ const thresholdIdx = severityOrder.indexOf(rcConfig.severity_threshold);
2346
+ if (thresholdIdx >= 0) {
2347
+ // Re-check: only fail if violations at or above threshold exist
2348
+ const history = loadHistory();
2349
+ const lastEntry = history[history.length - 1];
2350
+ if (lastEntry?.bySeverity) {
2351
+ const hasAboveThreshold = severityOrder
2352
+ .slice(thresholdIdx)
2353
+ .some(sev => lastEntry.bySeverity[sev] > 0);
2354
+ if (!hasAboveThreshold) {
2355
+ exitCode = 0; // Below threshold — pass
2356
+ if (options.verbose) {
2357
+ console.error(`📋 .halorc.json severity_threshold: ${rcConfig.severity_threshold} — violations below threshold, passing`);
2358
+ }
2359
+ }
2360
+ }
2361
+ }
2362
+ }
2363
+ // Upload to Halo Dashboard (non-blocking — upload failure doesn't affect exit code)
2364
+ if (options.upload) {
2365
+ try {
2366
+ const config = loadConfig();
2367
+ if (!config.license_key) {
2368
+ console.error('⚠️ No license key found. Run `halo activate <key>` first.');
2369
+ }
2370
+ else {
2371
+ console.error('☁️ Uploading scan results to Halo Dashboard...');
2372
+ // Re-scan in JSON format to get structured data for upload
2373
+ // Use the scan history to get the latest results
2374
+ const history = loadHistory();
2375
+ const lastEntry = history[history.length - 1];
2376
+ if (lastEntry) {
2377
+ const projectPath = path.resolve(paths[0] || '.');
2378
+ // Build minimal scan_json from last scan entry
2379
+ const scanJsonForUpload = {
2380
+ repo: projectPath,
2381
+ scannedAt: lastEntry.scannedAt,
2382
+ filesScanned: lastEntry.filesScanned,
2383
+ totalFiles: lastEntry.filesScanned,
2384
+ violations: [], // Full violations not in history; send metadata only
2385
+ score: lastEntry.score,
2386
+ grade: lastEntry.grade,
2387
+ bySeverity: lastEntry.bySeverity,
2388
+ rulesTriggered: lastEntry.rulesTriggered,
2389
+ suppressedCount: lastEntry.suppressedCount || 0,
2390
+ };
2391
+ const uploadUrl = 'https://wrfwcmyxxbafcdvxlmug.supabase.co/functions/v1/upload-scan';
2392
+ const res = await fetch(uploadUrl, {
2393
+ method: 'POST',
2394
+ headers: { 'Content-Type': 'application/json' },
2395
+ body: JSON.stringify({
2396
+ license_key: config.license_key,
2397
+ scan_json: scanJsonForUpload,
2398
+ repo_url: projectPath,
2399
+ }),
2400
+ });
2401
+ if (res.ok) {
2402
+ const data = await res.json();
2403
+ console.error(`✅ Uploaded to dashboard: ${data.dashboard_url}`);
2404
+ console.error(`🔗 Share: ${data.share_url}`);
2405
+ }
2406
+ else {
2407
+ const err = await res.json().catch(() => ({}));
2408
+ console.error(`⚠️ Upload failed: ${err.error || res.statusText}`);
2409
+ }
2410
+ }
2411
+ }
2412
+ }
2413
+ catch (uploadErr) {
2414
+ console.error(`⚠️ Upload failed: ${uploadErr instanceof Error ? uploadErr.message : uploadErr}`);
2415
+ }
2416
+ }
1308
2417
  process.exit(exitCode);
1309
2418
  }
1310
2419
  catch (error) {
@@ -1328,6 +2437,10 @@ program
1328
2437
  .action(async (paths, options) => {
1329
2438
  try {
1330
2439
  await firstRunPrompt(options.prompt === false);
2440
+ // Pro feature gating for guided fixes
2441
+ if (options.guided && !checkProFeature('Guided Scaffold Generation', '--guided')) {
2442
+ process.exit(0);
2443
+ }
1331
2444
  const exitCode = await fix(paths, {
1332
2445
  dryRun: options.dryRun || false,
1333
2446
  rules: options.rules || [],
@@ -1345,6 +2458,376 @@ program
1345
2458
  process.exit(3);
1346
2459
  }
1347
2460
  });
2461
+ // ==================== Activate Command (License Key) ====================
2462
+ program
2463
+ .command('activate')
2464
+ .description('Activate a Halo Pro or Enterprise license key')
2465
+ .argument('<key>', 'License key (UUID from your purchase confirmation)')
2466
+ .action(async (key) => {
2467
+ try {
2468
+ const exitCode = await activateLicense(key);
2469
+ process.exit(exitCode);
2470
+ }
2471
+ catch (error) {
2472
+ console.error('❌ Error:', error instanceof Error ? error.message : error);
2473
+ process.exit(3);
2474
+ }
2475
+ });
2476
+ /**
2477
+ * COPPA compliance rules content for AI coding assistants.
2478
+ * Teaches the assistant to write COPPA-compliant code proactively.
2479
+ */
2480
+ function getIDERulesContent() {
2481
+ return `# Halo — COPPA 2.0 Compliance Rules for AI Coding Assistants
2482
+ # Generated by: npx runhalo init --ide
2483
+ # Docs: https://runhalo.dev/rules
2484
+ # Scanner: npx runhalo scan .
2485
+
2486
+ You are working on a child-directed application subject to COPPA 2.0 (effective April 22, 2026).
2487
+ All code you generate MUST comply with these rules. Violations carry penalties up to $53,088/day.
2488
+
2489
+ ## CRITICAL Rules (auto-block in CI)
2490
+
2491
+ 1. **No Social Login Without Age Gate** (coppa-auth-001)
2492
+ Never use signInWithPopup(), passport.authenticate('google'), or OAuth flows
2493
+ without first verifying the user is 13+ via date-of-birth check.
2494
+
2495
+ 2. **No Ad Trackers** (coppa-tracking-003)
2496
+ Never add Google Analytics (gtag), Facebook Pixel (fbq), or AdSense (adsbygoogle)
2497
+ without setting child_directed_treatment: true.
2498
+
2499
+ 3. **No Biometric Data Collection** (coppa-bio-012)
2500
+ Never use FaceID, TouchID, face-api.js, or voice print APIs without
2501
+ explicit verifiable parental consent. Voice prints are biometric data under COPPA 2.0.
2502
+
2503
+ 4. **No Unencrypted PII** (coppa-sec-006)
2504
+ Never use http:// for API endpoints that handle personal information.
2505
+ All PII transmission must use https://.
2506
+
2507
+ 5. **Default Privacy = Private** (coppa-default-020)
2508
+ Never set isProfileVisible: true, visibility: "public", or defaultPrivacy: "public".
2509
+ All profiles default to private. Privacy by design is required.
2510
+
2511
+ ## HIGH Rules (flag in PR review)
2512
+
2513
+ 6. **No PII in URL Parameters** (coppa-data-002)
2514
+ Never pass email, name, DOB, or phone as GET query parameters.
2515
+ Use POST with request body instead.
2516
+
2517
+ 7. **No Precise Geolocation** (coppa-geo-004)
2518
+ Never use navigator.geolocation.getCurrentPosition() without parental consent.
2519
+ Downgrade accuracy to city-level if needed.
2520
+
2521
+ 8. **No Passive Audio Recording** (coppa-audio-007)
2522
+ Never call getUserMedia({audio: true}) or MediaRecorder without click handler
2523
+ and parental consent check.
2524
+
2525
+ 9. **No Unmoderated Chat Widgets** (coppa-ext-011)
2526
+ Never add Intercom, Zendesk, Drift, or Freshdesk without age-gating.
2527
+ Chat widgets allow children to disclose PII freely.
2528
+
2529
+ 10. **No PII in Analytics** (coppa-analytics-018)
2530
+ Never pass email, name, or phone to analytics.identify(), mixpanel.identify(),
2531
+ or segment.identify(). Hash user IDs instead.
2532
+
2533
+ 11. **No UGC Without PII Filter** (coppa-ugc-014)
2534
+ Text areas for bio, about-me, or comments must pass through PII scrubbing
2535
+ before database storage.
2536
+
2537
+ 12. **Parent Email Required for Child Contact** (coppa-flow-009)
2538
+ Forms collecting child_email or student_email must also require parent_email.
2539
+
2540
+ ## MEDIUM Rules (compliance warnings)
2541
+
2542
+ 13. **Data Retention Required** (coppa-retention-005)
2543
+ Database schemas must include deleted_at, expiration_date, or TTL index.
2544
+ COPPA requires data retention policies.
2545
+
2546
+ 14. **Privacy Policy on Registration** (coppa-ui-008)
2547
+ Registration forms must include a visible link to the privacy policy.
2548
+
2549
+ 15. **Secure Default Passwords** (coppa-sec-010)
2550
+ Never use "password", "123456", or "changeme" as default/initial passwords.
2551
+
2552
+ 16. **External Link Warnings** (coppa-ext-017)
2553
+ External links opening in _blank should trigger a "You are leaving..." modal.
2554
+
2555
+ 17. **Push Notification Consent** (coppa-notif-013)
2556
+ Push notifications are "Online Contact Info" under COPPA 2.0.
2557
+ Gate subscriptions behind parental dashboard settings.
2558
+
2559
+ 18. **Teacher Account Verification** (coppa-edu-019)
2560
+ Teacher sign-ups using @gmail.com bypass the School Official consent exception.
2561
+ Restrict to .edu domains or require manual approval.
2562
+
2563
+ 19. **XSS Prevention** (coppa-sec-015)
2564
+ Never use dangerouslySetInnerHTML or .innerHTML with user-controlled content.
2565
+ Use DOMPurify or standard JSX rendering.
2566
+
2567
+ 20. **Cookie Consent** (coppa-cookies-016)
2568
+ Cookies or localStorage storing tracking/PII data require a consent banner.
2569
+
2570
+ ## AI Code Generation Guidelines
2571
+
2572
+ When generating code for this project:
2573
+ - Always add age verification before authentication flows
2574
+ - Always use HTTPS for API endpoints
2575
+ - Always default user profiles to private
2576
+ - Always add cookie consent before setting tracking cookies
2577
+ - Always use POST (not GET) for forms collecting personal information
2578
+ - Always add PII scrubbing middleware before storing user-generated content
2579
+ - Always include data retention/deletion utilities in database schemas
2580
+ - Never add analytics, tracking, or ad scripts without child_directed_treatment flags
2581
+ - Never embed third-party chat/support widgets without age-gating logic
2582
+ - Prefer privacy-preserving alternatives (hashed IDs, aggregated analytics, on-device processing)
2583
+
2584
+ ## Scan Your Code
2585
+
2586
+ Run \`npx runhalo scan .\` to check compliance.
2587
+ Run \`npx runhalo fix . --guided\` for guided remediation scaffolds.
2588
+ Run \`npx runhalo scan . --report\` for an HTML compliance report.
2589
+ `;
2590
+ }
2591
+ /**
2592
+ * Generate .cursor/rules content (Cursor-specific format)
2593
+ */
2594
+ function getCursorRulesContent() {
2595
+ return getIDERulesContent();
2596
+ }
2597
+ /**
2598
+ * Generate .windsurfrules content (Windsurf-specific format)
2599
+ */
2600
+ function getWindsurfRulesContent() {
2601
+ return getIDERulesContent();
2602
+ }
2603
+ /**
2604
+ * Generate .github/copilot-instructions.md (GitHub Copilot)
2605
+ */
2606
+ function getCopilotInstructionsContent() {
2607
+ return getIDERulesContent();
2608
+ }
2609
+ /**
2610
+ * Init command — generate IDE rules files and project configuration.
2611
+ */
2612
+ async function init(projectPath, options) {
2613
+ const resolvedPath = path.resolve(projectPath);
2614
+ if (!options.ide) {
2615
+ console.log('🔮 Halo init — project setup');
2616
+ console.log('');
2617
+ console.log('Usage:');
2618
+ console.log(' runhalo init --ide Generate AI coding assistant rules files');
2619
+ console.log('');
2620
+ console.log('Options:');
2621
+ console.log(' --ide Generate .cursor/rules, .windsurfrules, .github/copilot-instructions.md');
2622
+ console.log(' --force Overwrite existing rules files');
2623
+ return 0;
2624
+ }
2625
+ console.log('🔮 Halo init — Generating AI coding assistant rules...\n');
2626
+ const files = [
2627
+ {
2628
+ path: path.join(resolvedPath, '.cursor', 'rules'),
2629
+ content: getCursorRulesContent(),
2630
+ label: 'Cursor'
2631
+ },
2632
+ {
2633
+ path: path.join(resolvedPath, '.windsurfrules'),
2634
+ content: getWindsurfRulesContent(),
2635
+ label: 'Windsurf'
2636
+ },
2637
+ {
2638
+ path: path.join(resolvedPath, '.github', 'copilot-instructions.md'),
2639
+ content: getCopilotInstructionsContent(),
2640
+ label: 'GitHub Copilot'
2641
+ }
2642
+ ];
2643
+ let created = 0;
2644
+ let skipped = 0;
2645
+ for (const file of files) {
2646
+ const dir = path.dirname(file.path);
2647
+ const relativePath = path.relative(resolvedPath, file.path);
2648
+ if (fs.existsSync(file.path) && !options.force) {
2649
+ console.log(` ⏭ ${relativePath} (exists — use --force to overwrite)`);
2650
+ skipped++;
2651
+ continue;
2652
+ }
2653
+ try {
2654
+ if (!fs.existsSync(dir)) {
2655
+ fs.mkdirSync(dir, { recursive: true });
2656
+ }
2657
+ fs.writeFileSync(file.path, file.content, 'utf-8');
2658
+ console.log(` ✅ ${relativePath} — ${file.label} rules`);
2659
+ created++;
2660
+ }
2661
+ catch (err) {
2662
+ console.error(` ❌ ${relativePath} — ${err instanceof Error ? err.message : err}`);
2663
+ }
2664
+ }
2665
+ console.log('');
2666
+ if (created > 0) {
2667
+ console.log(`Created ${created} rules file${created > 1 ? 's' : ''}. Your AI assistant now knows COPPA 2.0.`);
2668
+ }
2669
+ if (skipped > 0) {
2670
+ console.log(`Skipped ${skipped} existing file${skipped > 1 ? 's' : ''}.`);
2671
+ }
2672
+ console.log('');
2673
+ console.log('What happens next:');
2674
+ console.log(' • Cursor, Windsurf, and Copilot will read these rules automatically');
2675
+ console.log(' • AI-generated code will follow COPPA compliance patterns');
2676
+ console.log(' • Run "npx runhalo scan ." to verify compliance');
2677
+ console.log('');
2678
+ console.log('Full-stack compliance for the AI coding era:');
2679
+ console.log(' CI: uses: runhalo/action@v1 catches violations in PRs');
2680
+ console.log(' Local: npx runhalo scan . catches violations on your machine');
2681
+ console.log(' Proactive: AI rules files prevent violations before they\'re written');
2682
+ return 0;
2683
+ }
2684
+ program
2685
+ .command('packs')
2686
+ .description('List available rule packs')
2687
+ .option('-v, --verbose', 'Show detailed pack information')
2688
+ .action(async (options) => {
2689
+ try {
2690
+ const verbose = options.verbose || false;
2691
+ // Try API first, then cache, then bundled
2692
+ let packData = null;
2693
+ // Try API
2694
+ try {
2695
+ const controller = new AbortController();
2696
+ const timeout = setTimeout(() => controller.abort(), RULES_FETCH_TIMEOUT_MS);
2697
+ const res = await fetch(`${RULES_API_BASE}/rules-catalog`, { signal: controller.signal });
2698
+ clearTimeout(timeout);
2699
+ if (res.ok) {
2700
+ const data = await res.json();
2701
+ packData = data.packs || [];
2702
+ }
2703
+ }
2704
+ catch {
2705
+ // API failed — try bundled
2706
+ }
2707
+ // Fallback: load from bundled rules.json
2708
+ if (!packData) {
2709
+ try {
2710
+ const rulesJsonPath = require.resolve('@runhalo/engine/rules/rules.json');
2711
+ const data = JSON.parse(fs.readFileSync(rulesJsonPath, 'utf-8'));
2712
+ packData = Object.values(data.packs).map((pack) => {
2713
+ const ruleCount = (data.rules || []).filter((r) => r.packs.includes(pack.id)).length;
2714
+ return {
2715
+ pack_id: pack.id,
2716
+ name: pack.name,
2717
+ description: pack.description,
2718
+ jurisdiction: pack.jurisdiction,
2719
+ is_free: pack.is_free,
2720
+ rule_count: ruleCount,
2721
+ };
2722
+ });
2723
+ }
2724
+ catch {
2725
+ console.error('❌ Could not load pack information');
2726
+ process.exit(1);
2727
+ }
2728
+ }
2729
+ console.log('');
2730
+ console.log('Available Rule Packs:');
2731
+ console.log('');
2732
+ for (const pack of packData) {
2733
+ const tier = pack.is_free ? c(colors.green, 'free') : c(colors.yellow, 'pro');
2734
+ const id = c(colors.bold, pack.pack_id);
2735
+ console.log(` ${id} (${tier}) — ${pack.name} — ${pack.rule_count} rules`);
2736
+ if (verbose && pack.description) {
2737
+ console.log(` ${c(colors.dim, pack.description)}`);
2738
+ if (pack.jurisdiction) {
2739
+ console.log(` ${c(colors.dim, `Jurisdiction: ${pack.jurisdiction}`)}`);
2740
+ }
2741
+ }
2742
+ }
2743
+ const totalRules = packData.reduce((sum, p) => sum + p.rule_count, 0);
2744
+ console.log('');
2745
+ console.log(` ${c(colors.dim, `${packData.length} packs, ${totalRules} total rules`)}`);
2746
+ console.log('');
2747
+ console.log('Usage:');
2748
+ console.log(' npx runhalo scan . --pack coppa ethical');
2749
+ console.log(' npx runhalo scan . --pack coppa ai-audit au-sbd au-osa');
2750
+ console.log('');
2751
+ }
2752
+ catch (error) {
2753
+ console.error('❌ Error:', error instanceof Error ? error.message : error);
2754
+ process.exit(1);
2755
+ }
2756
+ });
2757
+ program
2758
+ .command('report')
2759
+ .description('Report a false positive detection')
2760
+ .argument('<rule-id>', 'Rule ID to report (e.g., coppa-auth-001)')
2761
+ .option('-f, --file <path>', 'File path where false positive was detected')
2762
+ .option('-l, --line <number>', 'Line number of the detection')
2763
+ .option('-e, --email <email>', 'Your email for follow-up')
2764
+ .option('--context <text>', 'Code context or explanation')
2765
+ .action(async (ruleId, options) => {
2766
+ try {
2767
+ const config = loadConfig();
2768
+ const licenseKey = config.license_key || null;
2769
+ console.log('');
2770
+ console.log(`${c(colors.bold, '📋 Reporting false positive for rule:')} ${ruleId}`);
2771
+ const body = { rule_id: ruleId };
2772
+ if (options.file)
2773
+ body.file_path = options.file;
2774
+ if (options.line)
2775
+ body.line_number = parseInt(options.line, 10);
2776
+ if (options.email)
2777
+ body.reporter_email = options.email;
2778
+ if (options.context)
2779
+ body.code_context = options.context;
2780
+ if (licenseKey)
2781
+ body.reporter_license_key = licenseKey;
2782
+ const controller = new AbortController();
2783
+ const timeout = setTimeout(() => controller.abort(), RULES_FETCH_TIMEOUT_MS);
2784
+ const res = await fetch(`${RULES_API_BASE}/report-fp`, {
2785
+ method: 'POST',
2786
+ headers: { 'Content-Type': 'application/json' },
2787
+ body: JSON.stringify(body),
2788
+ signal: controller.signal,
2789
+ });
2790
+ clearTimeout(timeout);
2791
+ if (!res.ok) {
2792
+ const err = await res.json().catch(() => ({ error: 'Unknown error' }));
2793
+ throw new Error(err.error || `HTTP ${res.status}`);
2794
+ }
2795
+ const data = await res.json();
2796
+ console.log(`${c(colors.green, '✅ Report submitted')} — ID: ${data.id}`);
2797
+ console.log(` Status: ${data.status}`);
2798
+ console.log('');
2799
+ console.log(c(colors.dim, 'Our compliance team will review this report. Thank you!'));
2800
+ console.log('');
2801
+ }
2802
+ catch (error) {
2803
+ if (error instanceof Error && error.name === 'AbortError') {
2804
+ console.error('❌ Request timed out. Please try again.');
2805
+ }
2806
+ else {
2807
+ console.error('❌ Error:', error instanceof Error ? error.message : error);
2808
+ }
2809
+ process.exit(1);
2810
+ }
2811
+ });
2812
+ program
2813
+ .command('init')
2814
+ .description('Initialize Halo in your project (generate IDE rules files)')
2815
+ .argument('[path]', 'Project root path (default: current directory)', '.')
2816
+ .option('--ide', 'Generate AI coding assistant rules files', false)
2817
+ .option('--force', 'Overwrite existing rules files', false)
2818
+ .action(async (projectPath, options) => {
2819
+ try {
2820
+ const exitCode = await init(projectPath, {
2821
+ ide: options.ide || false,
2822
+ force: options.force || false,
2823
+ });
2824
+ process.exit(exitCode);
2825
+ }
2826
+ catch (error) {
2827
+ console.error('❌ Error:', error instanceof Error ? error.message : error);
2828
+ process.exit(3);
2829
+ }
2830
+ });
1348
2831
  // Run CLI
1349
2832
  if (require.main === module) {
1350
2833
  program.parse();