@runhalo/cli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -252,6 +252,20 @@ function c(color, text) {
252
252
  * Format violations as human-readable text
253
253
  */
254
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
+ }
255
269
  let output = '';
256
270
  let totalViolations = 0;
257
271
  let criticalCount = 0;
@@ -298,15 +312,52 @@ function formatText(results, verbose = false, fileCount = 0, scoreResult) {
298
312
  default:
299
313
  severityTag = c(colors.dim, 'LOW');
300
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
+ }
301
341
  // Always show line:column (developer-standard format)
302
342
  const location = c(colors.dim, `${violation.line}:${violation.column}`);
303
- output += ` ${location} ${severityTag} ${c(colors.cyan, violation.ruleId)}\n`;
343
+ output += ` ${location} ${severityTag} ${c(colors.cyan, violation.ruleId)}${astBadge}${confidenceBadge}\n`;
304
344
  output += ` ${c(colors.dim, '│')} ${violation.message}\n`;
305
345
  if (verbose) {
306
346
  output += ` ${c(colors.dim, '│')} ${c(colors.magenta, '💡')} ${violation.fixSuggestion}\n`;
307
347
  if (violation.penalty) {
308
348
  output += ` ${c(colors.dim, '│')} ${c(colors.red, '⚠')} Penalty: ${violation.penalty}\n`;
309
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
+ }
310
361
  }
311
362
  output += '\n';
312
363
  }
@@ -604,6 +655,10 @@ function escapeHtml(text) {
604
655
  .replace(/"/g, '"')
605
656
  .replace(/'/g, ''');
606
657
  }
658
+ // ==================== Module-level scan data for cross-function access ====================
659
+ // Stores the last scan's results so the action handler can regenerate the PDF
660
+ // with AI Review Board data after the review board runs.
661
+ const _lastScanData = { results: [], scoreResult: null, fileCount: 0, projectPath: '' };
607
662
  // ==================== PDF Report Generator (P3-2) ====================
608
663
  // PDF color constants
609
664
  const PDF_COLORS = {
@@ -639,11 +694,7 @@ function severityColor(severity) {
639
694
  default: return PDF_COLORS.cyan;
640
695
  }
641
696
  }
642
- /**
643
- * Generate a government-procurement-grade PDF compliance report.
644
- * Uses PDFKit — pure JS, no browser dependencies, CI-safe.
645
- */
646
- function generatePdfReport(results, scoreResult, fileCount, projectPath, history) {
697
+ function generatePdfReport(results, scoreResult, fileCount, projectPath, history, reviewData) {
647
698
  return new Promise((resolve, reject) => {
648
699
  const doc = new pdfkit_1.default({
649
700
  size: 'LETTER',
@@ -932,6 +983,117 @@ function generatePdfReport(results, scoreResult, fileCount, projectPath, history
932
983
  }
933
984
  addFooter();
934
985
  }
986
+ // ═══════════════ AI REVIEW BOARD (Sprint 9) ═══════════════
987
+ if (reviewData && reviewData.results.length > 0) {
988
+ doc.addPage();
989
+ doc.fontSize(20).fillColor(PDF_COLORS.darkText).text('AI Review Board Assessment', 60, 60);
990
+ doc.moveTo(60, 88).lineTo(60 + pageWidth, 88).lineWidth(1).strokeColor(PDF_COLORS.border).stroke();
991
+ y = 100;
992
+ // Review Board summary box
993
+ doc.rect(60, y, pageWidth, 70).fill(PDF_COLORS.lightBg);
994
+ doc.fontSize(10).fillColor(PDF_COLORS.bodyText);
995
+ doc.text(`${reviewData.summary.total} violations reviewed by Halo AI Review Board`, 70, y + 8, { width: pageWidth - 20 });
996
+ y += 22;
997
+ const verdictLine = [
998
+ reviewData.summary.escalated > 0 ? `🔴 ${reviewData.summary.escalated} escalated` : '',
999
+ reviewData.summary.confirmed > 0 ? `🟡 ${reviewData.summary.confirmed} confirmed` : '',
1000
+ reviewData.summary.downgraded > 0 ? `🟢 ${reviewData.summary.downgraded} downgraded` : '',
1001
+ reviewData.summary.dismissed > 0 ? `✅ ${reviewData.summary.dismissed} dismissed` : '',
1002
+ ].filter(Boolean).join(' ');
1003
+ doc.fontSize(9).fillColor(PDF_COLORS.bodyText);
1004
+ doc.text(verdictLine, 70, y, { width: pageWidth - 20 });
1005
+ y += 16;
1006
+ // Marshall summary
1007
+ if (reviewData.marshall_summary) {
1008
+ const ms = reviewData.marshall_summary;
1009
+ doc.fontSize(8).fillColor(PDF_COLORS.mutedText);
1010
+ doc.text(`Marshall Intelligence: ${ms.enriched_count} violations enriched | Avg urgency: ${ms.avg_urgency} | ${ms.active_enforcement_count} in active enforcement areas`, 70, y, { width: pageWidth - 20 });
1011
+ }
1012
+ y += 40; // past the summary box
1013
+ // Per-verdict sections
1014
+ const verdictOrder = [
1015
+ { key: 'escalated', label: 'ESCALATED — More Serious Than Initially Detected', color: PDF_COLORS.red, emoji: '🔴' },
1016
+ { key: 'confirmed', label: 'CONFIRMED — Violations Validated by AI Review', color: PDF_COLORS.orange, emoji: '🟡' },
1017
+ { key: 'downgraded', label: 'DOWNGRADED — Lower Risk Than Severity Suggests', color: PDF_COLORS.green, emoji: '🟢' },
1018
+ { key: 'dismissed', label: 'DISMISSED — False Positives Cleared', color: PDF_COLORS.cyan, emoji: '✅' },
1019
+ ];
1020
+ for (const vType of verdictOrder) {
1021
+ const items = reviewData.results.filter(r => r.verdict === vType.key);
1022
+ if (items.length === 0)
1023
+ continue;
1024
+ if (y > doc.page.height - 140) {
1025
+ addFooter();
1026
+ doc.addPage();
1027
+ y = 60;
1028
+ }
1029
+ doc.fontSize(12).fillColor(vType.color);
1030
+ doc.text(`${vType.emoji} ${vType.label} (${items.length})`, 60, y);
1031
+ y += 20;
1032
+ // Show up to 10 per verdict type
1033
+ const itemsToShow = items.slice(0, 10);
1034
+ for (const item of itemsToShow) {
1035
+ if (y > doc.page.height - 100) {
1036
+ addFooter();
1037
+ doc.addPage();
1038
+ y = 60;
1039
+ }
1040
+ // Rule ID + verdict
1041
+ doc.fontSize(9).fillColor(PDF_COLORS.primary);
1042
+ doc.text(item.ruleId, 70, y);
1043
+ y += 14;
1044
+ // Clinical context
1045
+ if (item.clinicalContext) {
1046
+ doc.fontSize(8).fillColor(PDF_COLORS.bodyText);
1047
+ const ctxHeight = doc.heightOfString(item.clinicalContext, { width: pageWidth - 30 });
1048
+ doc.text(item.clinicalContext, 80, y, { width: pageWidth - 30 });
1049
+ y += ctxHeight + 4;
1050
+ }
1051
+ // Age groups
1052
+ if (item.ageGroupImpact && item.ageGroupImpact.length > 0) {
1053
+ doc.fontSize(7).fillColor(PDF_COLORS.mutedText);
1054
+ doc.text(`Ages most affected: ${item.ageGroupImpact.join(', ')}`, 80, y);
1055
+ y += 12;
1056
+ }
1057
+ // Regulatory context (Marshall enrichment)
1058
+ if (item.regulatoryContext) {
1059
+ const rc = item.regulatoryContext;
1060
+ const priorityLabel = rc.enforcement_priority === 'active' ? '🔴 ACTIVE'
1061
+ : rc.enforcement_priority === 'watching' ? '🟡 WATCHING' : '⚪ DORMANT';
1062
+ doc.fontSize(7).fillColor(PDF_COLORS.red);
1063
+ doc.text(`Regulatory: ${rc.regulation} | ${priorityLabel} | Penalty: ${rc.penalty_exposure} | Urgency: ${rc.urgency_score}`, 80, y, { width: pageWidth - 30 });
1064
+ y += 12;
1065
+ if (rc.recent_case) {
1066
+ doc.fontSize(7).fillColor(PDF_COLORS.mutedText);
1067
+ doc.text(`Recent precedent: ${rc.recent_case}`, 80, y, { width: pageWidth - 30 });
1068
+ y += 12;
1069
+ }
1070
+ }
1071
+ // Remediation
1072
+ if (item.remediationGuidance) {
1073
+ doc.fontSize(8).fillColor(PDF_COLORS.green);
1074
+ const remHeight = doc.heightOfString(`Fix: ${item.remediationGuidance}`, { width: pageWidth - 30 });
1075
+ doc.text(`Fix: ${item.remediationGuidance}`, 80, y, { width: pageWidth - 30 });
1076
+ y += remHeight + 4;
1077
+ }
1078
+ y += 8;
1079
+ }
1080
+ if (items.length > 10) {
1081
+ doc.fontSize(8).fillColor(PDF_COLORS.mutedText);
1082
+ doc.text(`+ ${items.length - 10} more ${vType.key} violation(s). See full results on your Halo Dashboard.`, 80, y, { width: pageWidth - 30 });
1083
+ y += 16;
1084
+ }
1085
+ y += 10;
1086
+ }
1087
+ // Review Board footer note
1088
+ if (y > doc.page.height - 80) {
1089
+ addFooter();
1090
+ doc.addPage();
1091
+ y = 60;
1092
+ }
1093
+ doc.fontSize(7).fillColor(PDF_COLORS.lightText);
1094
+ doc.text(`Reviewed by Halo AI Review Board (Richard + Marshall) in ${reviewData.latency_ms}ms. Cost: $${reviewData.cost.estimated_usd.toFixed(4)}. ${reviewData.summary.cache_hits} results served from cache.`, 60, y, { width: pageWidth });
1095
+ addFooter();
1096
+ }
935
1097
  // ═══════════════ RECOMMENDATIONS ═══════════════
936
1098
  doc.addPage();
937
1099
  doc.fontSize(20).fillColor(PDF_COLORS.darkText).text('Recommendations', 60, 60);
@@ -1017,6 +1179,122 @@ function loadHaloignore(startDir) {
1017
1179
  }
1018
1180
  return undefined;
1019
1181
  }
1182
+ /**
1183
+ * Detect the project framework by scanning project files.
1184
+ * Checks package.json, Gemfile, go.mod, Cargo.toml, manage.py, requirements.txt.
1185
+ * Returns a framework identifier string or null if unknown.
1186
+ */
1187
+ function detectProjectFramework(dir) {
1188
+ // Check package.json for JS/TS frameworks
1189
+ const pkgPath = path.join(dir, 'package.json');
1190
+ if (fs.existsSync(pkgPath)) {
1191
+ try {
1192
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
1193
+ const allDeps = {
1194
+ ...(pkg.dependencies || {}),
1195
+ ...(pkg.devDependencies || {}),
1196
+ };
1197
+ // Order matters: check specific frameworks before generic ones
1198
+ if (allDeps['next'])
1199
+ return 'nextjs';
1200
+ if (allDeps['@angular/core'])
1201
+ return 'angular';
1202
+ if (allDeps['vue'])
1203
+ return 'vue';
1204
+ if (allDeps['svelte'])
1205
+ return 'svelte';
1206
+ if (allDeps['react'])
1207
+ return 'react';
1208
+ }
1209
+ catch {
1210
+ // Malformed package.json — continue detection
1211
+ }
1212
+ }
1213
+ // Django: manage.py or requirements.txt with django
1214
+ if (fs.existsSync(path.join(dir, 'manage.py')))
1215
+ return 'django';
1216
+ const reqPath = path.join(dir, 'requirements.txt');
1217
+ if (fs.existsSync(reqPath)) {
1218
+ try {
1219
+ const reqs = fs.readFileSync(reqPath, 'utf-8').toLowerCase();
1220
+ if (reqs.includes('django'))
1221
+ return 'django';
1222
+ }
1223
+ catch {
1224
+ // Continue detection
1225
+ }
1226
+ }
1227
+ // Rails: Gemfile with rails
1228
+ const gemfilePath = path.join(dir, 'Gemfile');
1229
+ if (fs.existsSync(gemfilePath)) {
1230
+ try {
1231
+ const gemfile = fs.readFileSync(gemfilePath, 'utf-8').toLowerCase();
1232
+ if (gemfile.includes('rails'))
1233
+ return 'rails';
1234
+ }
1235
+ catch {
1236
+ // Continue detection
1237
+ }
1238
+ }
1239
+ // Go
1240
+ if (fs.existsSync(path.join(dir, 'go.mod')))
1241
+ return 'go';
1242
+ // Rust
1243
+ if (fs.existsSync(path.join(dir, 'Cargo.toml')))
1244
+ return 'rust';
1245
+ return null;
1246
+ }
1247
+ /**
1248
+ * Get default .haloignore content based on detected framework.
1249
+ */
1250
+ function getDefaultHaloignoreContent(framework) {
1251
+ const lines = [
1252
+ '# Halo ignore patterns',
1253
+ '# Generated by: runhalo init',
1254
+ '',
1255
+ 'node_modules/',
1256
+ 'dist/',
1257
+ 'build/',
1258
+ 'coverage/',
1259
+ '*.min.js',
1260
+ '*.bundle.js',
1261
+ ];
1262
+ // Add framework-specific ignores
1263
+ switch (framework) {
1264
+ case 'nextjs':
1265
+ lines.push('.next/');
1266
+ lines.push('.vercel/');
1267
+ break;
1268
+ case 'angular':
1269
+ lines.push('.angular/');
1270
+ break;
1271
+ case 'vue':
1272
+ lines.push('.nuxt/');
1273
+ break;
1274
+ case 'svelte':
1275
+ lines.push('.svelte-kit/');
1276
+ break;
1277
+ case 'django':
1278
+ lines.push('__pycache__/');
1279
+ lines.push('*.pyc');
1280
+ lines.push('.venv/');
1281
+ lines.push('venv/');
1282
+ break;
1283
+ case 'rails':
1284
+ lines.push('tmp/');
1285
+ lines.push('log/');
1286
+ lines.push('vendor/bundle/');
1287
+ break;
1288
+ case 'go':
1289
+ lines.push('vendor/');
1290
+ break;
1291
+ case 'rust':
1292
+ lines.push('target/');
1293
+ break;
1294
+ }
1295
+ lines.push('');
1296
+ return lines.join('\n');
1297
+ }
1020
1298
  /**
1021
1299
  * Create a Halo engine instance
1022
1300
  */
@@ -1035,7 +1313,9 @@ function scanFile(filePath, content) {
1035
1313
  * Scan a directory
1036
1314
  */
1037
1315
  async function scanDirectory(dirPath, config) {
1316
+ // Create two engines: one for active violations, one with suppressions included
1038
1317
  const engine = new engine_1.HaloEngine(config);
1318
+ const suppressionEngine = new engine_1.HaloEngine({ ...config, includeSuppressed: true });
1039
1319
  const results = [];
1040
1320
  const stats = fs.statSync(dirPath);
1041
1321
  if (stats.isDirectory()) {
@@ -1055,12 +1335,15 @@ async function scanDirectory(dirPath, config) {
1055
1335
  try {
1056
1336
  const content = fs.readFileSync(filePath, 'utf-8');
1057
1337
  const violations = engine.scanFile(filePath, content);
1338
+ const allViolations = suppressionEngine.scanFile(filePath, content);
1339
+ const suppressedViolations = allViolations.filter(v => v.suppressed);
1058
1340
  results.push({
1059
1341
  filePath,
1060
1342
  violations,
1343
+ suppressedViolations,
1061
1344
  scannedAt: new Date().toISOString(),
1062
1345
  totalViolations: violations.length,
1063
- suppressedCount: 0
1346
+ suppressedCount: suppressedViolations.length
1064
1347
  });
1065
1348
  }
1066
1349
  catch (err) {
@@ -1071,12 +1354,15 @@ async function scanDirectory(dirPath, config) {
1071
1354
  else {
1072
1355
  const content = fs.readFileSync(dirPath, 'utf-8');
1073
1356
  const violations = engine.scanFile(dirPath, content);
1357
+ const allViolations = suppressionEngine.scanFile(dirPath, content);
1358
+ const suppressedViolations = allViolations.filter(v => v.suppressed);
1074
1359
  results.push({
1075
1360
  filePath: dirPath,
1076
1361
  violations,
1362
+ suppressedViolations,
1077
1363
  scannedAt: new Date().toISOString(),
1078
1364
  totalViolations: violations.length,
1079
- suppressedCount: 0
1365
+ suppressedCount: suppressedViolations.length
1080
1366
  });
1081
1367
  }
1082
1368
  return results;
@@ -1188,7 +1474,7 @@ function loadBaselineRules(packs) {
1188
1474
  }
1189
1475
  /**
1190
1476
  * Map CLI options to pack IDs.
1191
- * --pack takes precedence. Legacy flags (--ethical-preview, --ai-audit, --sector-au-sbd) are mapped.
1477
+ * --pack takes precedence. Legacy flags (--ethical-preview, --ai-audit, --sector-au-sbd, --sector-au-osa) are mapped.
1192
1478
  */
1193
1479
  function resolvePacks(options) {
1194
1480
  // Explicit --pack flag takes priority
@@ -1203,6 +1489,8 @@ function resolvePacks(options) {
1203
1489
  packs.push('ai-audit');
1204
1490
  if (options.sectorAuSbd)
1205
1491
  packs.push('au-sbd');
1492
+ if (options.sectorAuOsa)
1493
+ packs.push('au-osa');
1206
1494
  return packs;
1207
1495
  }
1208
1496
  /**
@@ -1297,6 +1585,108 @@ function saveHistory(entry) {
1297
1585
  // Silent failure — never block scan
1298
1586
  }
1299
1587
  }
1588
+ /**
1589
+ * Send webhook notifications to Discord and/or Slack after a scan completes.
1590
+ * Non-blocking — failures are logged but never affect the scan exit code.
1591
+ */
1592
+ async function sendWebhookNotifications(rcConfig, lastEntry, verbose) {
1593
+ const notifications = rcConfig?.notifications;
1594
+ if (!notifications)
1595
+ return;
1596
+ const { discord_webhook, slack_webhook } = notifications;
1597
+ if (!discord_webhook && !slack_webhook)
1598
+ return;
1599
+ const hasFailed = lastEntry.totalViolations > 0;
1600
+ const filesScanned = String(lastEntry.filesScanned);
1601
+ const violations = String(lastEntry.totalViolations);
1602
+ const grade = lastEntry.grade || 'N/A';
1603
+ const topRules = lastEntry.rulesTriggered.slice(0, 5).join(', ') || 'None';
1604
+ const timestamp = new Date().toISOString();
1605
+ // Discord webhook
1606
+ if (discord_webhook) {
1607
+ try {
1608
+ const discordPayload = {
1609
+ embeds: [{
1610
+ title: '\u{1F6E1}\uFE0F Halo Scan Complete',
1611
+ color: hasFailed ? 15158332 : 3066993,
1612
+ fields: [
1613
+ { name: 'Files Scanned', value: filesScanned, inline: true },
1614
+ { name: 'Violations', value: violations, inline: true },
1615
+ { name: 'Grade', value: grade, inline: true },
1616
+ { name: 'Top Rules', value: topRules, inline: false },
1617
+ ],
1618
+ footer: { text: 'Halo \u2014 runhalo.dev' },
1619
+ timestamp,
1620
+ }],
1621
+ };
1622
+ const controller = new AbortController();
1623
+ const timeout = setTimeout(() => controller.abort(), 5000);
1624
+ const res = await fetch(discord_webhook, {
1625
+ method: 'POST',
1626
+ headers: { 'Content-Type': 'application/json' },
1627
+ body: JSON.stringify(discordPayload),
1628
+ signal: controller.signal,
1629
+ });
1630
+ clearTimeout(timeout);
1631
+ if (verbose) {
1632
+ console.error(res.ok
1633
+ ? '\u2709\uFE0F Notification sent to Discord'
1634
+ : `\u26A0\uFE0F Discord webhook returned ${res.status}`);
1635
+ }
1636
+ }
1637
+ catch (err) {
1638
+ if (verbose) {
1639
+ console.error(`\u26A0\uFE0F Discord webhook failed: ${err instanceof Error ? err.message : err}`);
1640
+ }
1641
+ }
1642
+ }
1643
+ // Slack webhook
1644
+ if (slack_webhook) {
1645
+ try {
1646
+ const slackPayload = {
1647
+ blocks: [
1648
+ {
1649
+ type: 'header',
1650
+ text: { type: 'plain_text', text: '\u{1F6E1}\uFE0F Halo Scan Complete' },
1651
+ },
1652
+ {
1653
+ type: 'section',
1654
+ fields: [
1655
+ { type: 'mrkdwn', text: `*Files Scanned*\n${filesScanned}` },
1656
+ { type: 'mrkdwn', text: `*Violations*\n${violations}` },
1657
+ { type: 'mrkdwn', text: `*Grade*\n${grade}` },
1658
+ ],
1659
+ },
1660
+ {
1661
+ type: 'section',
1662
+ fields: [
1663
+ { type: 'mrkdwn', text: `*Top Rules*\n${topRules}` },
1664
+ ],
1665
+ },
1666
+ ],
1667
+ };
1668
+ const controller = new AbortController();
1669
+ const timeout = setTimeout(() => controller.abort(), 5000);
1670
+ const res = await fetch(slack_webhook, {
1671
+ method: 'POST',
1672
+ headers: { 'Content-Type': 'application/json' },
1673
+ body: JSON.stringify(slackPayload),
1674
+ signal: controller.signal,
1675
+ });
1676
+ clearTimeout(timeout);
1677
+ if (verbose) {
1678
+ console.error(res.ok
1679
+ ? '\u2709\uFE0F Notification sent to Slack'
1680
+ : `\u26A0\uFE0F Slack webhook returned ${res.status}`);
1681
+ }
1682
+ }
1683
+ catch (err) {
1684
+ if (verbose) {
1685
+ console.error(`\u26A0\uFE0F Slack webhook failed: ${err instanceof Error ? err.message : err}`);
1686
+ }
1687
+ }
1688
+ }
1689
+ }
1300
1690
  async function submitCliLead(email) {
1301
1691
  try {
1302
1692
  const res = await fetch(`${SUPABASE_URL}/rest/v1/halo_leads`, {
@@ -1609,6 +1999,9 @@ async function scan(paths, options) {
1609
1999
  severityFilter: options.severity.length > 0 ? options.severity : undefined,
1610
2000
  ignoreConfig,
1611
2001
  projectDomains: projectDomains.length > 0 ? [...new Set(projectDomains)] : undefined,
2002
+ // Sprint 8: Framework and AST analysis (from .halorc.json via CLIOptions)
2003
+ framework: options.framework,
2004
+ astAnalysis: options.astAnalysis,
1612
2005
  // If we got rules from API/cache, use loadedRules. Otherwise fall through to legacy flags.
1613
2006
  ...(resolvedRules
1614
2007
  ? { loadedRules: resolvedRules }
@@ -1616,9 +2009,11 @@ async function scan(paths, options) {
1616
2009
  ethical: options.ethicalPreview,
1617
2010
  aiAudit: options.aiAudit,
1618
2011
  sectorAuSbd: options.sectorAuSbd,
2012
+ sectorAuOsa: options.sectorAuOsa,
1619
2013
  }),
1620
2014
  };
1621
2015
  const engine = new engine_1.HaloEngine(engineConfig);
2016
+ const suppressionEngine = new engine_1.HaloEngine({ ...engineConfig, includeSuppressed: true });
1622
2017
  const results = [];
1623
2018
  let fileCount = 0;
1624
2019
  // Collect all files to scan
@@ -1689,6 +2084,9 @@ async function scan(paths, options) {
1689
2084
  'ethical': 'Ethical Design',
1690
2085
  'ai-audit': 'AI Audit',
1691
2086
  'au-sbd': 'AU Safety by Design',
2087
+ 'au-osa': 'AU Online Safety Act',
2088
+ 'caadca': 'California AADCA',
2089
+ 'eu-ai-act': 'EU AI Act (Children)',
1692
2090
  };
1693
2091
  const packLabel = packs.map(p => packNameMap[p] || p).join(' + ');
1694
2092
  console.error(c(colors.dim, `🔍 Scanning ${uniqueFiles.length} files (${packLabel})...`));
@@ -1711,14 +2109,24 @@ async function scan(paths, options) {
1711
2109
  if (content.substring(0, 512).includes('\0')) {
1712
2110
  continue;
1713
2111
  }
1714
- const violations = engine.scanFile(filePath, content);
1715
- if (violations.length > 0) {
2112
+ // Sprint 8: Use AST-enhanced scanning for JS/TS when framework or AST configured
2113
+ const ext = path.extname(filePath).toLowerCase();
2114
+ const isJSTS = ['.ts', '.tsx', '.js', '.jsx'].includes(ext);
2115
+ const useAST = isJSTS && (options.framework || options.astAnalysis !== false);
2116
+ const lang = ['.ts', '.tsx'].includes(ext) ? 'typescript' : 'javascript';
2117
+ const violations = useAST
2118
+ ? engine.scanFileWithAST(filePath, content, lang)
2119
+ : engine.scanFile(filePath, content);
2120
+ const allViolations = suppressionEngine.scanFile(filePath, content);
2121
+ const suppressedViolations = allViolations.filter(v => v.suppressed);
2122
+ if (violations.length > 0 || suppressedViolations.length > 0) {
1716
2123
  results.push({
1717
2124
  filePath,
1718
2125
  violations,
2126
+ suppressedViolations,
1719
2127
  scannedAt: new Date().toISOString(),
1720
2128
  totalViolations: violations.length,
1721
- suppressedCount: 0
2129
+ suppressedCount: suppressedViolations.length
1722
2130
  });
1723
2131
  }
1724
2132
  fileCount++;
@@ -1731,6 +2139,7 @@ async function scan(paths, options) {
1731
2139
  }
1732
2140
  // Calculate compliance score
1733
2141
  const allViolations = results.flatMap(r => r.violations);
2142
+ const totalSuppressedCount = results.reduce((sum, r) => sum + r.suppressedCount, 0);
1734
2143
  const scorer = new engine_1.ComplianceScoreEngine();
1735
2144
  const scoreResult = scorer.calculate(allViolations, fileCount);
1736
2145
  // Scan history: compute trend BEFORE saving (so we compare to previous, not current)
@@ -1742,6 +2151,7 @@ async function scan(paths, options) {
1742
2151
  score: scoreResult.score,
1743
2152
  grade: scoreResult.grade,
1744
2153
  totalViolations: scoreResult.totalViolations,
2154
+ suppressedCount: totalSuppressedCount,
1745
2155
  bySeverity: scoreResult.bySeverity,
1746
2156
  filesScanned: fileCount,
1747
2157
  projectPath,
@@ -1763,7 +2173,14 @@ async function scan(paths, options) {
1763
2173
  output += trendLine + '\n';
1764
2174
  }
1765
2175
  }
2176
+ // Store scan data for potential PDF regeneration with AI Review Board data
2177
+ _lastScanData.results = results;
2178
+ _lastScanData.scoreResult = scoreResult;
2179
+ _lastScanData.fileCount = fileCount;
2180
+ _lastScanData.projectPath = projectPath;
1766
2181
  // Generate report if requested (HTML or PDF based on filename extension)
2182
+ // Note: if --review-board is also set, the PDF will be regenerated with AI data
2183
+ // in the action handler after the review board completes.
1767
2184
  if (options.report) {
1768
2185
  const reportFilename = typeof options.report === 'string'
1769
2186
  ? options.report
@@ -2046,10 +2463,14 @@ program
2046
2463
  .option('--ethical-preview', 'Enable experimental ethical design rules (Sprint 5 preview)')
2047
2464
  .option('--ai-audit', 'Enable AI-generated code audit rules (catch AI coding assistant mistakes)')
2048
2465
  .option('--sector-au-sbd', 'Enable Australia Safety by Design sector rules (eSafety Commissioner framework)')
2049
- .option('--pack <packs...>', 'Rule packs to scan against (e.g., coppa ethical ai-audit au-sbd)')
2466
+ .option('--sector-au-osa', 'Enable Australia Online Safety Act rules (2021 as amended 2024, under-16 social media ban)')
2467
+ .option('--pack <packs...>', 'Rule packs to scan against (e.g., coppa ethical ai-audit au-sbd au-osa)')
2050
2468
  .option('--offline', 'Skip API fetch, use cached or bundled rules only')
2051
2469
  .option('--report [filename]', 'Generate HTML compliance report (default: halo-report.html)')
2052
2470
  .option('--upload', 'Upload scan results to Halo Dashboard (requires Pro)')
2471
+ .option('--watch', 'Watch for file changes and re-scan automatically')
2472
+ .option('--review-board', 'Enable AI Review Board — clinical assessment of each violation (Pro/Enterprise)')
2473
+ .option('--license-key <key>', 'License key for Pro/Enterprise features (or set HALO_LICENSE_KEY env var)')
2053
2474
  .option('--no-prompt', 'Skip first-run email prompt')
2054
2475
  .option('-v, --verbose', 'Verbose output')
2055
2476
  .action(async (paths, options) => {
@@ -2068,17 +2489,51 @@ program
2068
2489
  if (options.sectorAuSbd && !checkProFeature('AU Safety by Design Rules', '--sector-au-sbd')) {
2069
2490
  process.exit(0);
2070
2491
  }
2492
+ if (options.sectorAuOsa && !checkProFeature('AU Online Safety Act Rules', '--sector-au-osa')) {
2493
+ process.exit(0);
2494
+ }
2071
2495
  if (options.upload && !checkProFeature('Dashboard Upload', '--upload')) {
2072
2496
  process.exit(0);
2073
2497
  }
2498
+ if (options.reviewBoard && !checkProFeature('AI Review Board', '--review-board')) {
2499
+ process.exit(0);
2500
+ }
2074
2501
  // Scan limit check (soft — exit 0, not error)
2075
2502
  if (!checkScanLimit()) {
2076
2503
  process.exit(0);
2077
2504
  }
2078
- const exitCode = await scan(paths, {
2505
+ // ==================== .halorc.json Config ====================
2506
+ const projectRoot = path.resolve(paths[0] || '.');
2507
+ let rcConfig;
2508
+ for (const rcName of ['.halorc.json', '.halorc']) {
2509
+ const rcPath = path.join(fs.existsSync(projectRoot) && fs.statSync(projectRoot).isDirectory()
2510
+ ? projectRoot
2511
+ : path.dirname(projectRoot), rcName);
2512
+ if (fs.existsSync(rcPath)) {
2513
+ try {
2514
+ rcConfig = JSON.parse(fs.readFileSync(rcPath, 'utf-8'));
2515
+ if (options.verbose) {
2516
+ console.error(`📋 Loaded ${rcName} configuration`);
2517
+ }
2518
+ }
2519
+ catch (e) {
2520
+ console.error(`⚠️ Failed to parse ${rcName}: ${e instanceof Error ? e.message : e}`);
2521
+ }
2522
+ break;
2523
+ }
2524
+ }
2525
+ // Merge .halorc.json with CLI flags (CLI flags override)
2526
+ const mergedPacks = (options.pack && options.pack.length > 0)
2527
+ ? options.pack
2528
+ : (rcConfig?.packs || []);
2529
+ const mergedExclude = [
2530
+ ...(options.exclude || []),
2531
+ ...(rcConfig?.ignore || []),
2532
+ ];
2533
+ const scanOptions = {
2079
2534
  format: options.format || 'text',
2080
2535
  include: options.include || [],
2081
- exclude: options.exclude || [],
2536
+ exclude: mergedExclude,
2082
2537
  rules: options.rules || [],
2083
2538
  severity: options.severity || [],
2084
2539
  output: options.output || '',
@@ -2086,10 +2541,288 @@ program
2086
2541
  ethicalPreview: options.ethicalPreview || false,
2087
2542
  aiAudit: options.aiAudit || false,
2088
2543
  sectorAuSbd: options.sectorAuSbd || false,
2544
+ sectorAuOsa: options.sectorAuOsa || false,
2089
2545
  report: options.report || false,
2090
- pack: options.pack || [],
2546
+ pack: mergedPacks,
2091
2547
  offline: options.offline || false,
2092
- });
2548
+ // Sprint 8: Pass framework and AST config from .halorc.json to scan()
2549
+ framework: rcConfig?.framework,
2550
+ astAnalysis: rcConfig?.astAnalysis,
2551
+ // Sprint 9: AI Review Board
2552
+ reviewBoard: options.reviewBoard || rcConfig?.reviewBoard || false,
2553
+ licenseKey: options.licenseKey || process.env.HALO_LICENSE_KEY || rcConfig?.licenseKey,
2554
+ };
2555
+ // ==================== Watch Mode ====================
2556
+ if (options.watch) {
2557
+ const scanRoot = path.resolve(paths[0] || '.');
2558
+ // Load .haloignore for watch filtering
2559
+ let watchIgnoreConfig;
2560
+ try {
2561
+ watchIgnoreConfig = loadHaloignore(fs.statSync(scanRoot).isDirectory() ? scanRoot : path.dirname(scanRoot));
2562
+ }
2563
+ catch {
2564
+ // Ignore errors loading .haloignore
2565
+ }
2566
+ const scannableExts = new Set([
2567
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
2568
+ '.py', '.go', '.java', '.kt', '.kts',
2569
+ '.swift', '.rb', '.php',
2570
+ '.html', '.htm', '.vue', '.svelte',
2571
+ '.xml', '.cs', '.cpp', '.h', '.hpp', '.qml', '.erb',
2572
+ ]);
2573
+ const isScannable = (filePath) => {
2574
+ return scannableExts.has(path.extname(filePath).toLowerCase());
2575
+ };
2576
+ const isExcluded = (filePath) => {
2577
+ const rel = path.relative(scanRoot, filePath);
2578
+ // Common directory excludes
2579
+ if (rel.includes('node_modules') || rel.includes('.git') ||
2580
+ rel.includes('dist/') || rel.includes('build/') ||
2581
+ rel.includes('coverage/') || rel.includes('.next/')) {
2582
+ return true;
2583
+ }
2584
+ // Respect .haloignore patterns
2585
+ if (watchIgnoreConfig && (0, engine_1.shouldIgnoreFile)(rel, watchIgnoreConfig)) {
2586
+ return true;
2587
+ }
2588
+ return false;
2589
+ };
2590
+ // Count scannable files for status line
2591
+ let watchableFileCount = 0;
2592
+ const countFiles = (dir) => {
2593
+ try {
2594
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
2595
+ for (const entry of entries) {
2596
+ const fullPath = path.join(dir, entry.name);
2597
+ if (entry.isDirectory()) {
2598
+ if (!['node_modules', '.git', 'dist', 'build', 'coverage', '.next'].includes(entry.name)) {
2599
+ countFiles(fullPath);
2600
+ }
2601
+ }
2602
+ else if (isScannable(fullPath) && !isExcluded(fullPath)) {
2603
+ watchableFileCount++;
2604
+ }
2605
+ }
2606
+ }
2607
+ catch {
2608
+ // Skip unreadable directories
2609
+ }
2610
+ };
2611
+ countFiles(scanRoot);
2612
+ // Clear terminal and print header
2613
+ const clearAndPrintHeader = () => {
2614
+ process.stdout.write('\x1B[2J\x1B[0f'); // Clear terminal + move cursor to top
2615
+ console.error('👁️ Halo Watch Mode');
2616
+ console.error(` Watching ${watchableFileCount} file(s) in ${path.basename(scanRoot)}/`);
2617
+ if (watchIgnoreConfig)
2618
+ console.error(' 📋 .haloignore loaded');
2619
+ console.error(' Press Ctrl+C to stop.\n');
2620
+ };
2621
+ clearAndPrintHeader();
2622
+ // Initial full scan
2623
+ let lastViolationCount = 0;
2624
+ let scanNumber = 0;
2625
+ const runScan = async () => {
2626
+ scanNumber++;
2627
+ if (scanNumber > 1)
2628
+ clearAndPrintHeader();
2629
+ const startTime = Date.now();
2630
+ const exitCode = await scan(paths, { ...scanOptions, format: 'text', output: '' });
2631
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
2632
+ const history = loadHistory();
2633
+ const lastEntry = history[history.length - 1];
2634
+ const violationCount = lastEntry?.totalViolations || 0;
2635
+ const delta = violationCount - lastViolationCount;
2636
+ const deltaStr = delta > 0
2637
+ ? ` \x1B[31m↑ ${delta} new\x1B[0m`
2638
+ : delta < 0
2639
+ ? ` \x1B[32m↓ ${Math.abs(delta)} fixed\x1B[0m`
2640
+ : scanNumber > 1 ? ' \x1B[90m(no change)\x1B[0m' : '';
2641
+ console.error(`\n⏱ Scan #${scanNumber} complete in ${elapsed}s — ${violationCount} violation(s)${deltaStr}`);
2642
+ console.error(` ${new Date().toLocaleTimeString()} — Watching for changes...\n`);
2643
+ lastViolationCount = violationCount;
2644
+ return exitCode;
2645
+ };
2646
+ await runScan();
2647
+ // Debounce: collect changes for 500ms before re-scanning
2648
+ let debounceTimer = null;
2649
+ const changedFiles = new Set();
2650
+ try {
2651
+ const watcher = fs.watch(scanRoot, { recursive: true }, (eventType, filename) => {
2652
+ if (!filename)
2653
+ return;
2654
+ const fullPath = path.join(scanRoot, filename);
2655
+ // Only re-scan for scannable file changes, respect .haloignore
2656
+ if (!isScannable(fullPath) || isExcluded(fullPath))
2657
+ return;
2658
+ changedFiles.add(filename);
2659
+ // Debounce — wait 500ms after last change before re-scanning
2660
+ if (debounceTimer)
2661
+ clearTimeout(debounceTimer);
2662
+ debounceTimer = setTimeout(async () => {
2663
+ const files = Array.from(changedFiles);
2664
+ changedFiles.clear();
2665
+ console.error(`\n📝 Changed: ${files.join(', ')}`);
2666
+ await runScan();
2667
+ }, 500);
2668
+ });
2669
+ // Keep process alive until Ctrl+C
2670
+ process.on('SIGINT', () => {
2671
+ watcher.close();
2672
+ console.error('\n\n👋 Watch mode stopped.');
2673
+ process.exit(0);
2674
+ });
2675
+ // Prevent Node from exiting
2676
+ await new Promise(() => { }); // Block forever (until SIGINT)
2677
+ }
2678
+ catch (watchErr) {
2679
+ console.error(`❌ Watch mode error: ${watchErr instanceof Error ? watchErr.message : watchErr}`);
2680
+ console.error(' fs.watch with recursive option requires Node.js 18+ on macOS/Windows.');
2681
+ process.exit(3);
2682
+ }
2683
+ return; // Never reached, but TypeScript needs it
2684
+ }
2685
+ // ==================== Standard Scan (non-watch) ====================
2686
+ let exitCode = await scan(paths, scanOptions);
2687
+ // Apply .halorc.json severity_threshold (overrides default exit code behavior)
2688
+ if (rcConfig?.severity_threshold && exitCode > 0) {
2689
+ const severityOrder = ['low', 'medium', 'high', 'critical'];
2690
+ const thresholdIdx = severityOrder.indexOf(rcConfig.severity_threshold);
2691
+ if (thresholdIdx >= 0) {
2692
+ // Re-check: only fail if violations at or above threshold exist
2693
+ const history = loadHistory();
2694
+ const lastEntry = history[history.length - 1];
2695
+ if (lastEntry?.bySeverity) {
2696
+ const hasAboveThreshold = severityOrder
2697
+ .slice(thresholdIdx)
2698
+ .some(sev => lastEntry.bySeverity[sev] > 0);
2699
+ if (!hasAboveThreshold) {
2700
+ exitCode = 0; // Below threshold — pass
2701
+ if (options.verbose) {
2702
+ console.error(`📋 .halorc.json severity_threshold: ${rcConfig.severity_threshold} — violations below threshold, passing`);
2703
+ }
2704
+ }
2705
+ }
2706
+ }
2707
+ }
2708
+ // ==================== AI Review Board (Sprint 9) ====================
2709
+ let _reviewBoardResult;
2710
+ if (scanOptions.reviewBoard) {
2711
+ const licenseKey = scanOptions.licenseKey || loadConfig().license_key;
2712
+ if (!licenseKey) {
2713
+ console.error('⚠️ AI Review Board requires a license key. Run `halo activate <key>` or pass --license-key.');
2714
+ }
2715
+ else {
2716
+ try {
2717
+ const history = loadHistory();
2718
+ const lastEntry = history[history.length - 1];
2719
+ if (lastEntry && lastEntry.rulesTriggered.length > 0) {
2720
+ console.error('\n🤖 Running AI Review Board...');
2721
+ const reviewUrl = 'https://wrfwcmyxxbafcdvxlmug.supabase.co/functions/v1/ai-review';
2722
+ // Build violations from scan results for review
2723
+ // We re-scan in JSON to get structured violations
2724
+ const jsonResults = await scan(paths, { ...scanOptions, format: 'json', output: '' });
2725
+ // Read the last JSON output from scan history
2726
+ const reviewHistory = loadHistory();
2727
+ const reviewEntry = reviewHistory[reviewHistory.length - 1];
2728
+ if (reviewEntry) {
2729
+ const reviewRes = await fetch(reviewUrl, {
2730
+ method: 'POST',
2731
+ headers: { 'Content-Type': 'application/json' },
2732
+ body: JSON.stringify({
2733
+ license_key: licenseKey,
2734
+ violations: reviewEntry.violations || [],
2735
+ repo_metadata: {
2736
+ framework: scanOptions.framework,
2737
+ },
2738
+ }),
2739
+ });
2740
+ if (reviewRes.ok) {
2741
+ const review = await reviewRes.json();
2742
+ _reviewBoardResult = review; // Store for PDF report
2743
+ // Display Review Board results
2744
+ console.error(`\n🛡️ Halo AI Review Board — ${review.summary.total} violations analyzed (${review.latency_ms}ms)\n`);
2745
+ const critical = review.results.filter(r => r.verdict === 'escalated');
2746
+ const confirmed = review.results.filter(r => r.verdict === 'confirmed');
2747
+ const downgraded = review.results.filter(r => r.verdict === 'downgraded');
2748
+ const dismissed = review.results.filter(r => r.verdict === 'dismissed');
2749
+ if (critical.length > 0) {
2750
+ console.error(`🔴 ESCALATED (${critical.length}) — More serious than initially detected`);
2751
+ for (const r of critical) {
2752
+ console.error(` ${r.ruleId}: ${r.clinicalContext}`);
2753
+ if (r.ageGroupImpact.length > 0)
2754
+ console.error(` Ages most affected: ${r.ageGroupImpact.join(', ')}`);
2755
+ if (r.remediationGuidance)
2756
+ console.error(` Fix: ${r.remediationGuidance}`);
2757
+ }
2758
+ console.error('');
2759
+ }
2760
+ if (confirmed.length > 0) {
2761
+ console.error(`🟡 CONFIRMED (${confirmed.length}) — Violations validated by AI review`);
2762
+ for (const r of confirmed) {
2763
+ console.error(` ${r.ruleId}: ${r.clinicalContext}`);
2764
+ }
2765
+ console.error('');
2766
+ }
2767
+ if (downgraded.length > 0) {
2768
+ console.error(`🟢 DOWNGRADED (${downgraded.length}) — Lower risk than severity suggests`);
2769
+ for (const r of downgraded) {
2770
+ console.error(` ${r.ruleId}: ${r.clinicalContext}`);
2771
+ }
2772
+ console.error('');
2773
+ }
2774
+ if (dismissed.length > 0) {
2775
+ console.error(`✅ DISMISSED (${dismissed.length}) — False positives cleared by AI review`);
2776
+ for (const r of dismissed) {
2777
+ console.error(` ${r.ruleId}: ${r.clinicalContext}`);
2778
+ }
2779
+ console.error('');
2780
+ }
2781
+ const cacheStr = review.summary.cache_hits > 0 ? ` (${review.summary.cache_hits} cached)` : '';
2782
+ console.error(`📊 Cost: $${review.cost.estimated_usd.toFixed(4)}${cacheStr}`);
2783
+ console.error(`🤖 Reviewed by: Halo AI Review Board (${review.latency_ms}ms)\n`);
2784
+ }
2785
+ else {
2786
+ const err = await reviewRes.json().catch(() => ({}));
2787
+ console.error(`⚠️ AI Review failed: ${err.error || reviewRes.statusText}`);
2788
+ }
2789
+ }
2790
+ }
2791
+ }
2792
+ catch (reviewErr) {
2793
+ console.error(`⚠️ AI Review failed: ${reviewErr instanceof Error ? reviewErr.message : reviewErr}`);
2794
+ }
2795
+ }
2796
+ }
2797
+ // ==================== Generate PDF Report with AI Review Board data ====================
2798
+ // If both --review-board and --report *.pdf are set, regenerate the PDF with review data
2799
+ if (options.report && _reviewBoardResult) {
2800
+ const reportFilename = typeof options.report === 'string'
2801
+ ? options.report
2802
+ : 'halo-report.html';
2803
+ if (reportFilename.endsWith('.pdf') && _lastScanData.results.length > 0) {
2804
+ const projectHistory = loadHistory().filter(h => h.projectPath === _lastScanData.projectPath);
2805
+ const historyForReport = projectHistory.slice(0, -1);
2806
+ const pdfBuffer = await generatePdfReport(_lastScanData.results, _lastScanData.scoreResult, _lastScanData.fileCount, _lastScanData.projectPath, historyForReport, _reviewBoardResult);
2807
+ fs.writeFileSync(reportFilename, pdfBuffer);
2808
+ console.error(`📄 PDF report updated with AI Review Board assessment`);
2809
+ }
2810
+ }
2811
+ // ==================== Webhook Notifications (Discord/Slack) ====================
2812
+ if (rcConfig?.notifications) {
2813
+ try {
2814
+ const history = loadHistory();
2815
+ const lastEntry = history[history.length - 1];
2816
+ if (lastEntry) {
2817
+ await sendWebhookNotifications(rcConfig, lastEntry, options.verbose);
2818
+ }
2819
+ }
2820
+ catch (notifyErr) {
2821
+ if (options.verbose) {
2822
+ console.error(`\u26A0\uFE0F Webhook notification error: ${notifyErr instanceof Error ? notifyErr.message : notifyErr}`);
2823
+ }
2824
+ }
2825
+ }
2093
2826
  // Upload to Halo Dashboard (non-blocking — upload failure doesn't affect exit code)
2094
2827
  if (options.upload) {
2095
2828
  try {
@@ -2116,6 +2849,7 @@ program
2116
2849
  grade: lastEntry.grade,
2117
2850
  bySeverity: lastEntry.bySeverity,
2118
2851
  rulesTriggered: lastEntry.rulesTriggered,
2852
+ suppressedCount: lastEntry.suppressedCount || 0,
2119
2853
  };
2120
2854
  const uploadUrl = 'https://wrfwcmyxxbafcdvxlmug.supabase.co/functions/v1/upload-scan';
2121
2855
  const res = await fetch(uploadUrl, {
@@ -2336,78 +3070,142 @@ function getCopilotInstructionsContent() {
2336
3070
  return getIDERulesContent();
2337
3071
  }
2338
3072
  /**
2339
- * Init command — generate IDE rules files and project configuration.
3073
+ * Init command — detect framework, generate .halorc.json, .haloignore, and IDE rules files.
2340
3074
  */
2341
3075
  async function init(projectPath, options) {
2342
3076
  const resolvedPath = path.resolve(projectPath);
2343
- if (!options.ide) {
2344
- console.log('🔮 Halo init — project setup');
3077
+ // --ide flag: generate AI coding assistant rules files (existing behavior)
3078
+ if (options.ide) {
3079
+ console.log('🔮 Halo init — Generating AI coding assistant rules...\n');
3080
+ const files = [
3081
+ {
3082
+ path: path.join(resolvedPath, '.cursor', 'rules'),
3083
+ content: getCursorRulesContent(),
3084
+ label: 'Cursor'
3085
+ },
3086
+ {
3087
+ path: path.join(resolvedPath, '.windsurfrules'),
3088
+ content: getWindsurfRulesContent(),
3089
+ label: 'Windsurf'
3090
+ },
3091
+ {
3092
+ path: path.join(resolvedPath, '.github', 'copilot-instructions.md'),
3093
+ content: getCopilotInstructionsContent(),
3094
+ label: 'GitHub Copilot'
3095
+ }
3096
+ ];
3097
+ let created = 0;
3098
+ let skipped = 0;
3099
+ for (const file of files) {
3100
+ const dir = path.dirname(file.path);
3101
+ const relativePath = path.relative(resolvedPath, file.path);
3102
+ if (fs.existsSync(file.path) && !options.force) {
3103
+ console.log(` ⏭ ${relativePath} (exists — use --force to overwrite)`);
3104
+ skipped++;
3105
+ continue;
3106
+ }
3107
+ try {
3108
+ if (!fs.existsSync(dir)) {
3109
+ fs.mkdirSync(dir, { recursive: true });
3110
+ }
3111
+ fs.writeFileSync(file.path, file.content, 'utf-8');
3112
+ console.log(` ✅ ${relativePath} — ${file.label} rules`);
3113
+ created++;
3114
+ }
3115
+ catch (err) {
3116
+ console.error(` ❌ ${relativePath} — ${err instanceof Error ? err.message : err}`);
3117
+ }
3118
+ }
2345
3119
  console.log('');
2346
- console.log('Usage:');
2347
- console.log(' runhalo init --ide Generate AI coding assistant rules files');
3120
+ if (created > 0) {
3121
+ console.log(`Created ${created} rules file${created > 1 ? 's' : ''}. Your AI assistant now knows COPPA 2.0.`);
3122
+ }
3123
+ if (skipped > 0) {
3124
+ console.log(`Skipped ${skipped} existing file${skipped > 1 ? 's' : ''}.`);
3125
+ }
3126
+ console.log('');
3127
+ console.log('What happens next:');
3128
+ console.log(' • Cursor, Windsurf, and Copilot will read these rules automatically');
3129
+ console.log(' • AI-generated code will follow COPPA compliance patterns');
3130
+ console.log(' • Run "npx runhalo scan ." to verify compliance');
2348
3131
  console.log('');
2349
- console.log('Options:');
2350
- console.log(' --ide Generate .cursor/rules, .windsurfrules, .github/copilot-instructions.md');
2351
- console.log(' --force Overwrite existing rules files');
3132
+ console.log('Full-stack compliance for the AI coding era:');
3133
+ console.log(' CI: uses: runhalo/action@v1 catches violations in PRs');
3134
+ console.log(' Local: npx runhalo scan . catches violations on your machine');
3135
+ console.log(' Proactive: AI rules files prevent violations before they\'re written');
2352
3136
  return 0;
2353
3137
  }
2354
- console.log('🔮 Halo init Generating AI coding assistant rules...\n');
2355
- const files = [
2356
- {
2357
- path: path.join(resolvedPath, '.cursor', 'rules'),
2358
- content: getCursorRulesContent(),
2359
- label: 'Cursor'
2360
- },
2361
- {
2362
- path: path.join(resolvedPath, '.windsurfrules'),
2363
- content: getWindsurfRulesContent(),
2364
- label: 'Windsurf'
2365
- },
2366
- {
2367
- path: path.join(resolvedPath, '.github', 'copilot-instructions.md'),
2368
- content: getCopilotInstructionsContent(),
2369
- label: 'GitHub Copilot'
3138
+ // Default init: auto-detect framework, generate .halorc.json and .haloignore
3139
+ console.log('🔮 Halo init — project setup\n');
3140
+ // Step 1: Detect framework
3141
+ const detectedFramework = detectProjectFramework(resolvedPath);
3142
+ if (detectedFramework) {
3143
+ console.log(` 🔍 Detected framework: ${c(colors.bold, detectedFramework)}`);
3144
+ }
3145
+ else {
3146
+ console.log(` 🔍 Framework: ${c(colors.dim, 'not detected (generic config will be generated)')}`);
3147
+ }
3148
+ let configCreated = false;
3149
+ let ignoreCreated = false;
3150
+ // Step 2: Generate .halorc.json
3151
+ const rcPath = path.join(resolvedPath, '.halorc.json');
3152
+ if (fs.existsSync(rcPath) && !options.force) {
3153
+ console.log(` ⏭ .halorc.json (exists — use --force to overwrite)`);
3154
+ }
3155
+ else {
3156
+ const rcConfig = {
3157
+ packs: ['coppa', 'ethical'],
3158
+ severity_threshold: 'medium',
3159
+ ignore: ['**/test/**', '**/__tests__/**', '**/node_modules/**'],
3160
+ astAnalysis: true,
3161
+ notifications: {},
3162
+ };
3163
+ if (detectedFramework) {
3164
+ rcConfig.framework = detectedFramework;
2370
3165
  }
2371
- ];
2372
- let created = 0;
2373
- let skipped = 0;
2374
- for (const file of files) {
2375
- const dir = path.dirname(file.path);
2376
- const relativePath = path.relative(resolvedPath, file.path);
2377
- if (fs.existsSync(file.path) && !options.force) {
2378
- console.log(` ⏭ ${relativePath} (exists — use --force to overwrite)`);
2379
- skipped++;
2380
- continue;
3166
+ try {
3167
+ fs.writeFileSync(rcPath, JSON.stringify(rcConfig, null, 2) + '\n', 'utf-8');
3168
+ console.log(` ✅ .halorc.json project configuration`);
3169
+ configCreated = true;
3170
+ }
3171
+ catch (err) {
3172
+ console.error(` ❌ .halorc.json ${err instanceof Error ? err.message : err}`);
2381
3173
  }
3174
+ }
3175
+ // Step 3: Generate .haloignore
3176
+ const ignorePath = path.join(resolvedPath, '.haloignore');
3177
+ if (fs.existsSync(ignorePath) && !options.force) {
3178
+ console.log(` ⏭ .haloignore (exists — use --force to overwrite)`);
3179
+ }
3180
+ else {
2382
3181
  try {
2383
- if (!fs.existsSync(dir)) {
2384
- fs.mkdirSync(dir, { recursive: true });
2385
- }
2386
- fs.writeFileSync(file.path, file.content, 'utf-8');
2387
- console.log(` ✅ ${relativePath} — ${file.label} rules`);
2388
- created++;
3182
+ const ignoreContent = getDefaultHaloignoreContent(detectedFramework);
3183
+ fs.writeFileSync(ignorePath, ignoreContent, 'utf-8');
3184
+ console.log(` ✅ .haloignore — scan exclusion patterns`);
3185
+ ignoreCreated = true;
2389
3186
  }
2390
3187
  catch (err) {
2391
- console.error(` ❌ ${relativePath} — ${err instanceof Error ? err.message : err}`);
3188
+ console.error(` ❌ .haloignore — ${err instanceof Error ? err.message : err}`);
2392
3189
  }
2393
3190
  }
3191
+ // Step 4: Summary
2394
3192
  console.log('');
2395
- if (created > 0) {
2396
- console.log(`Created ${created} rules file${created > 1 ? 's' : ''}. Your AI assistant now knows COPPA 2.0.`);
3193
+ if (configCreated || ignoreCreated) {
3194
+ const parts = [];
3195
+ if (configCreated)
3196
+ parts.push('.halorc.json');
3197
+ if (ignoreCreated)
3198
+ parts.push('.haloignore');
3199
+ console.log(`Created: ${parts.join(', ')}`);
2397
3200
  }
2398
- if (skipped > 0) {
2399
- console.log(`Skipped ${skipped} existing file${skipped > 1 ? 's' : ''}.`);
3201
+ else {
3202
+ console.log('No files created (all exist use --force to overwrite).');
2400
3203
  }
2401
3204
  console.log('');
2402
- console.log('What happens next:');
2403
- console.log(' • Cursor, Windsurf, and Copilot will read these rules automatically');
2404
- console.log(' AI-generated code will follow COPPA compliance patterns');
2405
- console.log(' • Run "npx runhalo scan ." to verify compliance');
3205
+ console.log('Next steps:');
3206
+ console.log(` ${c(colors.bold, 'npx runhalo scan .')} Scan your project for compliance issues`);
3207
+ console.log(` ${c(colors.bold, 'npx runhalo init --ide')} Generate AI coding assistant rules files`);
2406
3208
  console.log('');
2407
- console.log('Full-stack compliance for the AI coding era:');
2408
- console.log(' CI: uses: runhalo/action@v1 catches violations in PRs');
2409
- console.log(' Local: npx runhalo scan . catches violations on your machine');
2410
- console.log(' Proactive: AI rules files prevent violations before they\'re written');
2411
3209
  return 0;
2412
3210
  }
2413
3211
  program
@@ -2475,7 +3273,7 @@ program
2475
3273
  console.log('');
2476
3274
  console.log('Usage:');
2477
3275
  console.log(' npx runhalo scan . --pack coppa ethical');
2478
- console.log(' npx runhalo scan . --pack coppa ai-audit au-sbd');
3276
+ console.log(' npx runhalo scan . --pack coppa ai-audit au-sbd au-osa');
2479
3277
  console.log('');
2480
3278
  }
2481
3279
  catch (error) {
@@ -2483,12 +3281,67 @@ program
2483
3281
  process.exit(1);
2484
3282
  }
2485
3283
  });
3284
+ program
3285
+ .command('report')
3286
+ .description('Report a false positive detection')
3287
+ .argument('<rule-id>', 'Rule ID to report (e.g., coppa-auth-001)')
3288
+ .option('-f, --file <path>', 'File path where false positive was detected')
3289
+ .option('-l, --line <number>', 'Line number of the detection')
3290
+ .option('-e, --email <email>', 'Your email for follow-up')
3291
+ .option('--context <text>', 'Code context or explanation')
3292
+ .action(async (ruleId, options) => {
3293
+ try {
3294
+ const config = loadConfig();
3295
+ const licenseKey = config.license_key || null;
3296
+ console.log('');
3297
+ console.log(`${c(colors.bold, '📋 Reporting false positive for rule:')} ${ruleId}`);
3298
+ const body = { rule_id: ruleId };
3299
+ if (options.file)
3300
+ body.file_path = options.file;
3301
+ if (options.line)
3302
+ body.line_number = parseInt(options.line, 10);
3303
+ if (options.email)
3304
+ body.reporter_email = options.email;
3305
+ if (options.context)
3306
+ body.code_context = options.context;
3307
+ if (licenseKey)
3308
+ body.reporter_license_key = licenseKey;
3309
+ const controller = new AbortController();
3310
+ const timeout = setTimeout(() => controller.abort(), RULES_FETCH_TIMEOUT_MS);
3311
+ const res = await fetch(`${RULES_API_BASE}/report-fp`, {
3312
+ method: 'POST',
3313
+ headers: { 'Content-Type': 'application/json' },
3314
+ body: JSON.stringify(body),
3315
+ signal: controller.signal,
3316
+ });
3317
+ clearTimeout(timeout);
3318
+ if (!res.ok) {
3319
+ const err = await res.json().catch(() => ({ error: 'Unknown error' }));
3320
+ throw new Error(err.error || `HTTP ${res.status}`);
3321
+ }
3322
+ const data = await res.json();
3323
+ console.log(`${c(colors.green, '✅ Report submitted')} — ID: ${data.id}`);
3324
+ console.log(` Status: ${data.status}`);
3325
+ console.log('');
3326
+ console.log(c(colors.dim, 'Our compliance team will review this report. Thank you!'));
3327
+ console.log('');
3328
+ }
3329
+ catch (error) {
3330
+ if (error instanceof Error && error.name === 'AbortError') {
3331
+ console.error('❌ Request timed out. Please try again.');
3332
+ }
3333
+ else {
3334
+ console.error('❌ Error:', error instanceof Error ? error.message : error);
3335
+ }
3336
+ process.exit(1);
3337
+ }
3338
+ });
2486
3339
  program
2487
3340
  .command('init')
2488
- .description('Initialize Halo in your project (generate IDE rules files)')
3341
+ .description('Initialize Halo in your project (detect framework, generate .halorc.json and .haloignore)')
2489
3342
  .argument('[path]', 'Project root path (default: current directory)', '.')
2490
3343
  .option('--ide', 'Generate AI coding assistant rules files', false)
2491
- .option('--force', 'Overwrite existing rules files', false)
3344
+ .option('--force', 'Overwrite existing files', false)
2492
3345
  .action(async (projectPath, options) => {
2493
3346
  try {
2494
3347
  const exitCode = await init(projectPath, {