@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.d.ts +45 -3
- package/dist/index.js +926 -73
- package/dist/index.js.map +1 -1
- package/package.json +7 -3
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:
|
|
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:
|
|
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
|
-
|
|
1715
|
-
|
|
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:
|
|
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('--
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
2344
|
-
|
|
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
|
-
|
|
2347
|
-
|
|
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('
|
|
2350
|
-
console.log('
|
|
2351
|
-
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');
|
|
2352
3136
|
return 0;
|
|
2353
3137
|
}
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
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
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
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
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
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(` ❌
|
|
3188
|
+
console.error(` ❌ .haloignore — ${err instanceof Error ? err.message : err}`);
|
|
2392
3189
|
}
|
|
2393
3190
|
}
|
|
3191
|
+
// Step 4: Summary
|
|
2394
3192
|
console.log('');
|
|
2395
|
-
if (
|
|
2396
|
-
|
|
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
|
-
|
|
2399
|
-
console.log(
|
|
3201
|
+
else {
|
|
3202
|
+
console.log('No files created (all exist — use --force to overwrite).');
|
|
2400
3203
|
}
|
|
2401
3204
|
console.log('');
|
|
2402
|
-
console.log('
|
|
2403
|
-
console.log(
|
|
2404
|
-
console.log(
|
|
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
|
|
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
|
|
3344
|
+
.option('--force', 'Overwrite existing files', false)
|
|
2492
3345
|
.action(async (projectPath, options) => {
|
|
2493
3346
|
try {
|
|
2494
3347
|
const exitCode = await init(projectPath, {
|