@runhalo/cli 0.4.1 → 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.d.ts +40 -2
- package/dist/index.js +587 -60
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -655,6 +655,10 @@ function escapeHtml(text) {
|
|
|
655
655
|
.replace(/"/g, '"')
|
|
656
656
|
.replace(/'/g, ''');
|
|
657
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: '' };
|
|
658
662
|
// ==================== PDF Report Generator (P3-2) ====================
|
|
659
663
|
// PDF color constants
|
|
660
664
|
const PDF_COLORS = {
|
|
@@ -690,11 +694,7 @@ function severityColor(severity) {
|
|
|
690
694
|
default: return PDF_COLORS.cyan;
|
|
691
695
|
}
|
|
692
696
|
}
|
|
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) {
|
|
697
|
+
function generatePdfReport(results, scoreResult, fileCount, projectPath, history, reviewData) {
|
|
698
698
|
return new Promise((resolve, reject) => {
|
|
699
699
|
const doc = new pdfkit_1.default({
|
|
700
700
|
size: 'LETTER',
|
|
@@ -983,6 +983,117 @@ function generatePdfReport(results, scoreResult, fileCount, projectPath, history
|
|
|
983
983
|
}
|
|
984
984
|
addFooter();
|
|
985
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
|
+
}
|
|
986
1097
|
// ═══════════════ RECOMMENDATIONS ═══════════════
|
|
987
1098
|
doc.addPage();
|
|
988
1099
|
doc.fontSize(20).fillColor(PDF_COLORS.darkText).text('Recommendations', 60, 60);
|
|
@@ -1068,6 +1179,122 @@ function loadHaloignore(startDir) {
|
|
|
1068
1179
|
}
|
|
1069
1180
|
return undefined;
|
|
1070
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
|
+
}
|
|
1071
1298
|
/**
|
|
1072
1299
|
* Create a Halo engine instance
|
|
1073
1300
|
*/
|
|
@@ -1358,6 +1585,108 @@ function saveHistory(entry) {
|
|
|
1358
1585
|
// Silent failure — never block scan
|
|
1359
1586
|
}
|
|
1360
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
|
+
}
|
|
1361
1690
|
async function submitCliLead(email) {
|
|
1362
1691
|
try {
|
|
1363
1692
|
const res = await fetch(`${SUPABASE_URL}/rest/v1/halo_leads`, {
|
|
@@ -1757,6 +2086,7 @@ async function scan(paths, options) {
|
|
|
1757
2086
|
'au-sbd': 'AU Safety by Design',
|
|
1758
2087
|
'au-osa': 'AU Online Safety Act',
|
|
1759
2088
|
'caadca': 'California AADCA',
|
|
2089
|
+
'eu-ai-act': 'EU AI Act (Children)',
|
|
1760
2090
|
};
|
|
1761
2091
|
const packLabel = packs.map(p => packNameMap[p] || p).join(' + ');
|
|
1762
2092
|
console.error(c(colors.dim, `🔍 Scanning ${uniqueFiles.length} files (${packLabel})...`));
|
|
@@ -1843,7 +2173,14 @@ async function scan(paths, options) {
|
|
|
1843
2173
|
output += trendLine + '\n';
|
|
1844
2174
|
}
|
|
1845
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;
|
|
1846
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.
|
|
1847
2184
|
if (options.report) {
|
|
1848
2185
|
const reportFilename = typeof options.report === 'string'
|
|
1849
2186
|
? options.report
|
|
@@ -2132,6 +2469,8 @@ program
|
|
|
2132
2469
|
.option('--report [filename]', 'Generate HTML compliance report (default: halo-report.html)')
|
|
2133
2470
|
.option('--upload', 'Upload scan results to Halo Dashboard (requires Pro)')
|
|
2134
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)')
|
|
2135
2474
|
.option('--no-prompt', 'Skip first-run email prompt')
|
|
2136
2475
|
.option('-v, --verbose', 'Verbose output')
|
|
2137
2476
|
.action(async (paths, options) => {
|
|
@@ -2156,6 +2495,9 @@ program
|
|
|
2156
2495
|
if (options.upload && !checkProFeature('Dashboard Upload', '--upload')) {
|
|
2157
2496
|
process.exit(0);
|
|
2158
2497
|
}
|
|
2498
|
+
if (options.reviewBoard && !checkProFeature('AI Review Board', '--review-board')) {
|
|
2499
|
+
process.exit(0);
|
|
2500
|
+
}
|
|
2159
2501
|
// Scan limit check (soft — exit 0, not error)
|
|
2160
2502
|
if (!checkScanLimit()) {
|
|
2161
2503
|
process.exit(0);
|
|
@@ -2206,6 +2548,9 @@ program
|
|
|
2206
2548
|
// Sprint 8: Pass framework and AST config from .halorc.json to scan()
|
|
2207
2549
|
framework: rcConfig?.framework,
|
|
2208
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,
|
|
2209
2554
|
};
|
|
2210
2555
|
// ==================== Watch Mode ====================
|
|
2211
2556
|
if (options.watch) {
|
|
@@ -2360,6 +2705,124 @@ program
|
|
|
2360
2705
|
}
|
|
2361
2706
|
}
|
|
2362
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
|
+
}
|
|
2363
2826
|
// Upload to Halo Dashboard (non-blocking — upload failure doesn't affect exit code)
|
|
2364
2827
|
if (options.upload) {
|
|
2365
2828
|
try {
|
|
@@ -2607,78 +3070,142 @@ function getCopilotInstructionsContent() {
|
|
|
2607
3070
|
return getIDERulesContent();
|
|
2608
3071
|
}
|
|
2609
3072
|
/**
|
|
2610
|
-
* Init command — generate IDE rules files
|
|
3073
|
+
* Init command — detect framework, generate .halorc.json, .haloignore, and IDE rules files.
|
|
2611
3074
|
*/
|
|
2612
3075
|
async function init(projectPath, options) {
|
|
2613
3076
|
const resolvedPath = path.resolve(projectPath);
|
|
2614
|
-
|
|
2615
|
-
|
|
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
|
+
}
|
|
2616
3119
|
console.log('');
|
|
2617
|
-
|
|
2618
|
-
|
|
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
|
+
}
|
|
2619
3126
|
console.log('');
|
|
2620
|
-
console.log('
|
|
2621
|
-
console.log('
|
|
2622
|
-
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');
|
|
3131
|
+
console.log('');
|
|
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');
|
|
2623
3136
|
return 0;
|
|
2624
3137
|
}
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
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;
|
|
2641
3165
|
}
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
console.log(` ⏭ ${relativePath} (exists — use --force to overwrite)`);
|
|
2650
|
-
skipped++;
|
|
2651
|
-
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}`);
|
|
2652
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 {
|
|
2653
3181
|
try {
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
console.log(` ✅ ${relativePath} — ${file.label} rules`);
|
|
2659
|
-
created++;
|
|
3182
|
+
const ignoreContent = getDefaultHaloignoreContent(detectedFramework);
|
|
3183
|
+
fs.writeFileSync(ignorePath, ignoreContent, 'utf-8');
|
|
3184
|
+
console.log(` ✅ .haloignore — scan exclusion patterns`);
|
|
3185
|
+
ignoreCreated = true;
|
|
2660
3186
|
}
|
|
2661
3187
|
catch (err) {
|
|
2662
|
-
console.error(` ❌
|
|
3188
|
+
console.error(` ❌ .haloignore — ${err instanceof Error ? err.message : err}`);
|
|
2663
3189
|
}
|
|
2664
3190
|
}
|
|
3191
|
+
// Step 4: Summary
|
|
2665
3192
|
console.log('');
|
|
2666
|
-
if (
|
|
2667
|
-
|
|
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(', ')}`);
|
|
2668
3200
|
}
|
|
2669
|
-
|
|
2670
|
-
console.log(
|
|
3201
|
+
else {
|
|
3202
|
+
console.log('No files created (all exist — use --force to overwrite).');
|
|
2671
3203
|
}
|
|
2672
3204
|
console.log('');
|
|
2673
|
-
console.log('
|
|
2674
|
-
console.log(
|
|
2675
|
-
console.log(
|
|
2676
|
-
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`);
|
|
2677
3208
|
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
3209
|
return 0;
|
|
2683
3210
|
}
|
|
2684
3211
|
program
|
|
@@ -2811,10 +3338,10 @@ program
|
|
|
2811
3338
|
});
|
|
2812
3339
|
program
|
|
2813
3340
|
.command('init')
|
|
2814
|
-
.description('Initialize Halo in your project (generate
|
|
3341
|
+
.description('Initialize Halo in your project (detect framework, generate .halorc.json and .haloignore)')
|
|
2815
3342
|
.argument('[path]', 'Project root path (default: current directory)', '.')
|
|
2816
3343
|
.option('--ide', 'Generate AI coding assistant rules files', false)
|
|
2817
|
-
.option('--force', 'Overwrite existing
|
|
3344
|
+
.option('--force', 'Overwrite existing files', false)
|
|
2818
3345
|
.action(async (projectPath, options) => {
|
|
2819
3346
|
try {
|
|
2820
3347
|
const exitCode = await init(projectPath, {
|