@runhalo/cli 0.4.1 → 0.6.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 +894 -69
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
package/dist/index.js
CHANGED
|
@@ -180,7 +180,7 @@ function formatSARIF(results, rules) {
|
|
|
180
180
|
name: r.name,
|
|
181
181
|
shortDescription: { text: r.description },
|
|
182
182
|
helpUri: `https://runhalo.dev/rules/${r.id}`,
|
|
183
|
-
defaultConfiguration: { level: r.severity }
|
|
183
|
+
defaultConfiguration: { level: r.severity === 'critical' || r.severity === 'high' ? 'error' : r.severity === 'medium' ? 'warning' : 'note' }
|
|
184
184
|
}))
|
|
185
185
|
}
|
|
186
186
|
},
|
|
@@ -248,6 +248,81 @@ const useColor = !process.env.NO_COLOR && process.stdout.isTTY !== false;
|
|
|
248
248
|
function c(color, text) {
|
|
249
249
|
return useColor ? `${color}${text}${colors.reset}` : text;
|
|
250
250
|
}
|
|
251
|
+
// Enforcement lookup: rule_id → matching enforcement cases
|
|
252
|
+
// Source of truth: halo-admin/src/data/regulatory-actions.ts
|
|
253
|
+
const ENFORCEMENT_DATA = {};
|
|
254
|
+
// Build the lookup table from inline enforcement data
|
|
255
|
+
(function buildEnforcementLookup() {
|
|
256
|
+
const cases = [
|
|
257
|
+
{ company: 'Epic Games', fine: 275_000_000, date: '2022-12', jurisdiction: 'COPPA', violation: 'Tracking + dark patterns in child accounts', rules: [{ id: 'coppa-tracking-003', c: 'direct' }, { id: 'coppa-ui-008', c: 'direct' }] },
|
|
258
|
+
{ company: 'Amazon Alexa', fine: 25_000_000, date: '2023-05', jurisdiction: 'COPPA', violation: 'Voice recording retention without deletion workflows', rules: [{ id: 'coppa-audio-007', c: 'direct' }, { id: 'coppa-data-002', c: 'related' }] },
|
|
259
|
+
{ company: 'Microsoft Xbox', fine: 20_000_000, date: '2023-06', jurisdiction: 'COPPA', violation: 'Data collection before verifiable parental consent', rules: [{ id: 'coppa-auth-001', c: 'direct' }] },
|
|
260
|
+
{ company: 'Disney YouTube', fine: 10_000_000, date: '2025-03', jurisdiction: 'COPPA', violation: 'Third-party data collection on kids channels', rules: [{ id: 'coppa-ext-017', c: 'direct' }, { id: 'coppa-tracking-003', c: 'direct' }] },
|
|
261
|
+
{ company: 'Edmodo', fine: 6_000_000, date: '2023-05', jurisdiction: 'COPPA', violation: 'Kids data used for advertising profiles', rules: [{ id: 'coppa-tracking-003', c: 'direct' }, { id: 'coppa-analytics-018', c: 'direct' }] },
|
|
262
|
+
{ company: 'NGL Labs', fine: 5_000_000, date: '2024-07', jurisdiction: 'COPPA', violation: 'Anonymous messaging + safety risk exposure', rules: [{ id: 'coppa-default-020', c: 'direct' }, { id: 'coppa-ext-011', c: 'direct' }, { id: 'coppa-ui-008', c: 'related' }] },
|
|
263
|
+
{ company: 'HyperBeard', fine: 4_000_000, date: '2020-06', jurisdiction: 'COPPA', violation: 'Ad network tracking inside kids games', rules: [{ id: 'coppa-tracking-003', c: 'direct' }, { id: 'coppa-analytics-018', c: 'direct' }] },
|
|
264
|
+
{ company: 'Kuuhubb Recolor', fine: 3_000_000, date: '2021-07', jurisdiction: 'COPPA', violation: 'Data collection without consent flow', rules: [{ id: 'coppa-auth-001', c: 'direct' }] },
|
|
265
|
+
{ company: 'OpenX', fine: 2_000_000, date: '2021-12', jurisdiction: 'COPPA', violation: 'Location data harvested from kids apps', rules: [{ id: 'coppa-geo-004', c: 'direct' }, { id: 'coppa-tracking-003', c: 'direct' }] },
|
|
266
|
+
{ company: 'Weight Watchers Kurbo', fine: 1_500_000, date: '2022-03', jurisdiction: 'COPPA', violation: 'Health data captured without consent', rules: [{ id: 'coppa-bio-012', c: 'direct' }, { id: 'coppa-sec-006', c: 'related' }] },
|
|
267
|
+
{ company: 'Tilting Point Media', fine: 500_000, date: '2024-06', jurisdiction: 'COPPA/CCPA', violation: 'Unauthorized disclosure of kids info', rules: [{ id: 'coppa-ext-017', c: 'direct' }, { id: 'coppa-data-002', c: 'direct' }] },
|
|
268
|
+
{ company: 'Instagram (Meta)', fine: 405_000_000, date: '2022-09', jurisdiction: 'GDPR Art.8', violation: 'Exposed children\'s contact info publicly', rules: [{ id: 'coppa-default-020', c: 'direct' }, { id: 'coppa-auth-001', c: 'direct' }, { id: 'coppa-sec-006', c: 'related' }] },
|
|
269
|
+
{ company: 'TikTok (EU)', fine: 345_000_000, date: '2023-09', jurisdiction: 'GDPR Art.8', violation: 'Default public accounts for children, dark patterns', rules: [{ id: 'coppa-default-020', c: 'direct' }, { id: 'coppa-ui-008', c: 'direct' }, { id: 'coppa-auth-001', c: 'direct' }] },
|
|
270
|
+
{ company: 'Google (YouTube)', fine: 170_000_000, date: '2019-09', jurisdiction: 'COPPA', violation: 'Cookies + persistent identifiers from children\'s channels', rules: [{ id: 'coppa-cookies-016', c: 'direct' }, { id: 'coppa-tracking-003', c: 'direct' }, { id: 'coppa-analytics-018', c: 'direct' }] },
|
|
271
|
+
{ company: 'Reddit', fine: 14_470_000, date: '2026-02', jurisdiction: 'AADC', violation: 'Inadequate age gating under UK Children\'s Code', rules: [{ id: 'coppa-auth-001', c: 'direct' }, { id: 'coppa-default-020', c: 'direct' }, { id: 'coppa-ui-008', c: 'related' }] },
|
|
272
|
+
{ company: 'Musical.ly (TikTok)', fine: 5_700_000, date: '2019-02', jurisdiction: 'COPPA', violation: 'PII collection from children, social features without safeguards', rules: [{ id: 'coppa-default-020', c: 'direct' }, { id: 'coppa-ext-011', c: 'direct' }, { id: 'coppa-auth-001', c: 'direct' }] },
|
|
273
|
+
{ company: 'Apitor Technology', fine: 500_000, date: '2025-09', jurisdiction: 'COPPA', violation: 'Robot toy tracked location without parental consent', rules: [{ id: 'coppa-geo-004', c: 'direct' }, { id: 'coppa-auth-001', c: 'direct' }] },
|
|
274
|
+
{ company: 'MediaLab (Imgur)', fine: 247_590, date: '2026-02', jurisdiction: 'AADC', violation: 'No age assurance, children exposed to adult content', rules: [{ id: 'coppa-auth-001', c: 'direct' }, { id: 'coppa-default-020', c: 'direct' }] },
|
|
275
|
+
];
|
|
276
|
+
for (const c of cases) {
|
|
277
|
+
for (const rule of c.rules) {
|
|
278
|
+
if (!ENFORCEMENT_DATA[rule.id])
|
|
279
|
+
ENFORCEMENT_DATA[rule.id] = [];
|
|
280
|
+
ENFORCEMENT_DATA[rule.id].push({
|
|
281
|
+
company: c.company,
|
|
282
|
+
fine: c.fine,
|
|
283
|
+
date: c.date,
|
|
284
|
+
jurisdiction: c.jurisdiction,
|
|
285
|
+
violation: c.violation,
|
|
286
|
+
confidence: rule.c,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
})();
|
|
291
|
+
function formatFine(amount) {
|
|
292
|
+
if (amount >= 1_000_000_000)
|
|
293
|
+
return `$${(amount / 1_000_000_000).toFixed(1)}B`;
|
|
294
|
+
if (amount >= 1_000_000)
|
|
295
|
+
return `$${(amount / 1_000_000).toFixed(0)}M`;
|
|
296
|
+
if (amount >= 1_000)
|
|
297
|
+
return `$${(amount / 1_000).toFixed(0)}K`;
|
|
298
|
+
if (amount === 0)
|
|
299
|
+
return 'DOJ action';
|
|
300
|
+
return `$${amount.toLocaleString()}`;
|
|
301
|
+
}
|
|
302
|
+
// COPPA Rule amendment compliance deadline
|
|
303
|
+
const COPPA_DEADLINE = new Date('2026-04-22');
|
|
304
|
+
function daysUntilDeadline() {
|
|
305
|
+
const now = new Date();
|
|
306
|
+
return Math.max(0, Math.ceil((COPPA_DEADLINE.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)));
|
|
307
|
+
}
|
|
308
|
+
// Rules that are directly addressed by COPPA 2.0 amendment (April 22, 2026)
|
|
309
|
+
const COPPA_2_RULES = new Set([
|
|
310
|
+
'coppa-auth-001', // Age verification strengthened
|
|
311
|
+
'coppa-tracking-003', // Ad tracking restrictions expanded
|
|
312
|
+
'coppa-bio-012', // Biometric data explicitly covered
|
|
313
|
+
'coppa-data-002', // Data minimization requirements
|
|
314
|
+
'coppa-retention-005', // Retention limits codified
|
|
315
|
+
'coppa-notif-013', // Push notification consent required
|
|
316
|
+
'coppa-geo-004', // Location data restrictions
|
|
317
|
+
'coppa-audio-007', // Voice data as biometric
|
|
318
|
+
]);
|
|
319
|
+
function classifyTier(ruleId) {
|
|
320
|
+
if (ENFORCEMENT_DATA[ruleId]?.some(c => c.confidence === 'direct'))
|
|
321
|
+
return 'enforcement-risk';
|
|
322
|
+
if (COPPA_2_RULES.has(ruleId) || ENFORCEMENT_DATA[ruleId]?.some(c => c.confidence === 'related'))
|
|
323
|
+
return 'regulatory-scrutiny';
|
|
324
|
+
return 'hardening';
|
|
325
|
+
}
|
|
251
326
|
/**
|
|
252
327
|
* Format violations as human-readable text
|
|
253
328
|
*/
|
|
@@ -273,6 +348,11 @@ function formatText(results, verbose = false, fileCount = 0, scoreResult) {
|
|
|
273
348
|
let mediumCount = 0;
|
|
274
349
|
let lowCount = 0;
|
|
275
350
|
let filesWithViolations = 0;
|
|
351
|
+
// Sprint 10b: Track enforcement tier counts
|
|
352
|
+
let enforcementRiskCount = 0;
|
|
353
|
+
let regulatoryScrutinyCount = 0;
|
|
354
|
+
let hardeningCount = 0;
|
|
355
|
+
const enforcementMatchedFines = new Set(); // Track unique cases for aggregate fine total
|
|
276
356
|
for (const result of results) {
|
|
277
357
|
if (result.violations.length === 0)
|
|
278
358
|
continue;
|
|
@@ -338,10 +418,43 @@ function formatText(results, verbose = false, fileCount = 0, scoreResult) {
|
|
|
338
418
|
confidenceBadge = c(colors.dim, ` [${confStr}]`);
|
|
339
419
|
}
|
|
340
420
|
}
|
|
421
|
+
// Sprint 10b: Classify enforcement tier
|
|
422
|
+
const tier = classifyTier(violation.ruleId);
|
|
423
|
+
switch (tier) {
|
|
424
|
+
case 'enforcement-risk':
|
|
425
|
+
enforcementRiskCount++;
|
|
426
|
+
break;
|
|
427
|
+
case 'regulatory-scrutiny':
|
|
428
|
+
regulatoryScrutinyCount++;
|
|
429
|
+
break;
|
|
430
|
+
case 'hardening':
|
|
431
|
+
hardeningCount++;
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
341
434
|
// Always show line:column (developer-standard format)
|
|
342
435
|
const location = c(colors.dim, `${violation.line}:${violation.column}`);
|
|
343
436
|
output += ` ${location} ${severityTag} ${c(colors.cyan, violation.ruleId)}${astBadge}${confidenceBadge}\n`;
|
|
344
437
|
output += ` ${c(colors.dim, '│')} ${violation.message}\n`;
|
|
438
|
+
// Sprint 10b: Enforcement context — show related enforcement precedents
|
|
439
|
+
const enforcementCases = ENFORCEMENT_DATA[violation.ruleId];
|
|
440
|
+
if (enforcementCases) {
|
|
441
|
+
// Show top 2 direct matches by fine amount (most impactful)
|
|
442
|
+
const directCases = enforcementCases
|
|
443
|
+
.filter(ec => ec.confidence === 'direct' && ec.fine > 0)
|
|
444
|
+
.sort((a, b) => b.fine - a.fine)
|
|
445
|
+
.slice(0, 2);
|
|
446
|
+
for (const ec of directCases) {
|
|
447
|
+
output += ` ${c(colors.dim, '│')} ${c(colors.red, '⚖️')} Related enforcement precedent: ${ec.jurisdiction} v. ${ec.company} (${formatFine(ec.fine)}, ${ec.date})\n`;
|
|
448
|
+
enforcementMatchedFines.add(`${ec.company}-${ec.date}`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// Show COPPA 2.0 deadline for rules affected by the amendment
|
|
452
|
+
if (COPPA_2_RULES.has(violation.ruleId)) {
|
|
453
|
+
const days = daysUntilDeadline();
|
|
454
|
+
if (days > 0) {
|
|
455
|
+
output += ` ${c(colors.dim, '│')} ${c(colors.yellow, '⚠️')} COPPA Rule amendment compliance deadline: April 22, 2026 (${days} days)\n`;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
345
458
|
if (verbose) {
|
|
346
459
|
output += ` ${c(colors.dim, '│')} ${c(colors.magenta, '💡')} ${violation.fixSuggestion}\n`;
|
|
347
460
|
if (violation.penalty) {
|
|
@@ -369,16 +482,50 @@ function formatText(results, verbose = false, fileCount = 0, scoreResult) {
|
|
|
369
482
|
if (scoreResult) {
|
|
370
483
|
cleanOutput += `\n${formatScoreLine(scoreResult)}\n`;
|
|
371
484
|
}
|
|
485
|
+
// Sprint 15: Scope honesty
|
|
486
|
+
cleanOutput += `\n${c(colors.dim, '📋 Optimized for Python/PHP web applications. Coverage expanding to additional frameworks.')}\n`;
|
|
372
487
|
return cleanOutput;
|
|
373
488
|
}
|
|
489
|
+
// Sprint 10b: Calculate aggregate enforcement fine total
|
|
490
|
+
let totalEnforcementFines = 0;
|
|
491
|
+
const seenCases = new Set();
|
|
492
|
+
for (const result of results) {
|
|
493
|
+
for (const v of result.violations) {
|
|
494
|
+
const cases = ENFORCEMENT_DATA[v.ruleId];
|
|
495
|
+
if (cases) {
|
|
496
|
+
for (const ec of cases) {
|
|
497
|
+
if (ec.confidence === 'direct') {
|
|
498
|
+
const key = `${ec.company}-${ec.date}`;
|
|
499
|
+
if (!seenCases.has(key)) {
|
|
500
|
+
seenCases.add(key);
|
|
501
|
+
totalEnforcementFines += ec.fine;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
374
508
|
// Summary header
|
|
375
509
|
let header = `\n${c(colors.bold, `⚠ Found ${totalViolations} issue(s)`)}`;
|
|
376
510
|
header += ` across ${filesWithViolations} file(s)`;
|
|
377
511
|
if (fileCount > 0) {
|
|
378
512
|
header += ` (${fileCount} files scanned)`;
|
|
379
513
|
}
|
|
514
|
+
// Sprint 10b: Enforcement aggregate in summary line
|
|
515
|
+
if (enforcementRiskCount > 0) {
|
|
516
|
+
header += ` — ${c(colors.red + colors.bold, `${enforcementRiskCount} match active enforcement patterns`)} (${formatFine(totalEnforcementFines)} in historical fines)`;
|
|
517
|
+
}
|
|
380
518
|
header += '\n';
|
|
381
|
-
//
|
|
519
|
+
// Sprint 10b: Three-tier breakdown replaces raw severity counts
|
|
520
|
+
const tierParts = [];
|
|
521
|
+
if (enforcementRiskCount > 0)
|
|
522
|
+
tierParts.push(c(colors.red + colors.bold, `${enforcementRiskCount} enforcement-risk`));
|
|
523
|
+
if (regulatoryScrutinyCount > 0)
|
|
524
|
+
tierParts.push(c(colors.yellow + colors.bold, `${regulatoryScrutinyCount} regulatory scrutiny`));
|
|
525
|
+
if (hardeningCount > 0)
|
|
526
|
+
tierParts.push(c(colors.dim, `${hardeningCount} hardening`));
|
|
527
|
+
header += ` ${tierParts.join(c(colors.dim, ' · '))}\n`;
|
|
528
|
+
// Severity breakdown (secondary)
|
|
382
529
|
const parts = [];
|
|
383
530
|
if (criticalCount > 0)
|
|
384
531
|
parts.push(c(colors.red + colors.bold, `${criticalCount} critical`));
|
|
@@ -388,17 +535,23 @@ function formatText(results, verbose = false, fileCount = 0, scoreResult) {
|
|
|
388
535
|
parts.push(c(colors.blue, `${mediumCount} medium`));
|
|
389
536
|
if (lowCount > 0)
|
|
390
537
|
parts.push(c(colors.dim, `${lowCount} low`));
|
|
391
|
-
header += ` ${parts.join(c(colors.dim, ' · '))}\n`;
|
|
392
|
-
// Compliance score line
|
|
538
|
+
header += ` ${c(colors.dim, 'Severity:')} ${parts.join(c(colors.dim, ' · '))}\n`;
|
|
539
|
+
// Compliance score line with grade context labels
|
|
393
540
|
if (scoreResult) {
|
|
394
|
-
header += `\n${formatScoreLine(scoreResult)}\n`;
|
|
541
|
+
header += `\n${formatScoreLine(scoreResult, enforcementRiskCount, regulatoryScrutinyCount, hardeningCount)}\n`;
|
|
542
|
+
}
|
|
543
|
+
// Sprint 15: Scope honesty
|
|
544
|
+
header += `\n${c(colors.dim, '📋 Optimized for Python/PHP web applications. Coverage expanding to additional frameworks.')}\n`;
|
|
545
|
+
// Sprint 10b: Enforcement disclaimer
|
|
546
|
+
if (enforcementRiskCount > 0) {
|
|
547
|
+
header += `\n${c(colors.dim, 'Enforcement citations are historical references, not risk assessments. Consult legal counsel for compliance guidance.')}\n`;
|
|
395
548
|
}
|
|
396
549
|
return header + output;
|
|
397
550
|
}
|
|
398
551
|
/**
|
|
399
552
|
* Format the compliance score line with grade coloring
|
|
400
553
|
*/
|
|
401
|
-
function formatScoreLine(scoreResult) {
|
|
554
|
+
function formatScoreLine(scoreResult, enforcementRisk = 0, regulatoryScrutiny = 0, hardening = 0) {
|
|
402
555
|
const { score, grade } = scoreResult;
|
|
403
556
|
// Color the grade based on value
|
|
404
557
|
let gradeColor;
|
|
@@ -420,7 +573,27 @@ function formatScoreLine(scoreResult) {
|
|
|
420
573
|
}
|
|
421
574
|
const gradeStr = c(gradeColor + colors.bold, grade);
|
|
422
575
|
const scoreStr = c(colors.bold, `${score}/100`);
|
|
423
|
-
|
|
576
|
+
let line = `${c(colors.bold, '📊 COPPA Compliance Score:')} ${scoreStr} (${gradeStr})`;
|
|
577
|
+
// Sprint 10b: Grade context labels — reframe from "you failed" to "here's your path forward"
|
|
578
|
+
const total = enforcementRisk + regulatoryScrutiny + hardening;
|
|
579
|
+
if (total > 0) {
|
|
580
|
+
const contextParts = [];
|
|
581
|
+
if (enforcementRisk > 0)
|
|
582
|
+
contextParts.push(`${enforcementRisk} enforcement-risk items`);
|
|
583
|
+
if (regulatoryScrutiny > 0)
|
|
584
|
+
contextParts.push(`${regulatoryScrutiny} under regulatory scrutiny`);
|
|
585
|
+
if (hardening > 0)
|
|
586
|
+
contextParts.push(`${hardening} hardening items`);
|
|
587
|
+
line += `\n ${c(colors.dim, contextParts.join(', '))}`;
|
|
588
|
+
// Gap-to-compliance guidance
|
|
589
|
+
if (enforcementRisk > 0 && (grade === 'D' || grade === 'F')) {
|
|
590
|
+
line += `\n ${c(colors.yellow, `Gap to compliance: Address the ${enforcementRisk} enforcement-risk items to reach C grade.`)}`;
|
|
591
|
+
}
|
|
592
|
+
else if (enforcementRisk > 0 && grade === 'C') {
|
|
593
|
+
line += `\n ${c(colors.yellow, `Gap to compliance: Resolve enforcement-risk items and regulatory scrutiny items for B grade.`)}`;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return line;
|
|
424
597
|
}
|
|
425
598
|
/**
|
|
426
599
|
* Format trend line comparing current score to last scan for the same project.
|
|
@@ -655,6 +828,10 @@ function escapeHtml(text) {
|
|
|
655
828
|
.replace(/"/g, '"')
|
|
656
829
|
.replace(/'/g, ''');
|
|
657
830
|
}
|
|
831
|
+
// ==================== Module-level scan data for cross-function access ====================
|
|
832
|
+
// Stores the last scan's results so the action handler can regenerate the PDF
|
|
833
|
+
// with AI Review Board data after the review board runs.
|
|
834
|
+
const _lastScanData = { results: [], scoreResult: null, fileCount: 0, projectPath: '' };
|
|
658
835
|
// ==================== PDF Report Generator (P3-2) ====================
|
|
659
836
|
// PDF color constants
|
|
660
837
|
const PDF_COLORS = {
|
|
@@ -690,11 +867,7 @@ function severityColor(severity) {
|
|
|
690
867
|
default: return PDF_COLORS.cyan;
|
|
691
868
|
}
|
|
692
869
|
}
|
|
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) {
|
|
870
|
+
function generatePdfReport(results, scoreResult, fileCount, projectPath, history, reviewData) {
|
|
698
871
|
return new Promise((resolve, reject) => {
|
|
699
872
|
const doc = new pdfkit_1.default({
|
|
700
873
|
size: 'LETTER',
|
|
@@ -983,6 +1156,117 @@ function generatePdfReport(results, scoreResult, fileCount, projectPath, history
|
|
|
983
1156
|
}
|
|
984
1157
|
addFooter();
|
|
985
1158
|
}
|
|
1159
|
+
// ═══════════════ AI REVIEW BOARD (Sprint 9) ═══════════════
|
|
1160
|
+
if (reviewData && reviewData.results.length > 0) {
|
|
1161
|
+
doc.addPage();
|
|
1162
|
+
doc.fontSize(20).fillColor(PDF_COLORS.darkText).text('AI Review Board Assessment', 60, 60);
|
|
1163
|
+
doc.moveTo(60, 88).lineTo(60 + pageWidth, 88).lineWidth(1).strokeColor(PDF_COLORS.border).stroke();
|
|
1164
|
+
y = 100;
|
|
1165
|
+
// Review Board summary box
|
|
1166
|
+
doc.rect(60, y, pageWidth, 70).fill(PDF_COLORS.lightBg);
|
|
1167
|
+
doc.fontSize(10).fillColor(PDF_COLORS.bodyText);
|
|
1168
|
+
doc.text(`${reviewData.summary.total} violations reviewed by Halo AI Review Board`, 70, y + 8, { width: pageWidth - 20 });
|
|
1169
|
+
y += 22;
|
|
1170
|
+
const verdictLine = [
|
|
1171
|
+
reviewData.summary.escalated > 0 ? `🔴 ${reviewData.summary.escalated} escalated` : '',
|
|
1172
|
+
reviewData.summary.confirmed > 0 ? `🟡 ${reviewData.summary.confirmed} confirmed` : '',
|
|
1173
|
+
reviewData.summary.downgraded > 0 ? `🟢 ${reviewData.summary.downgraded} downgraded` : '',
|
|
1174
|
+
reviewData.summary.dismissed > 0 ? `✅ ${reviewData.summary.dismissed} dismissed` : '',
|
|
1175
|
+
].filter(Boolean).join(' ');
|
|
1176
|
+
doc.fontSize(9).fillColor(PDF_COLORS.bodyText);
|
|
1177
|
+
doc.text(verdictLine, 70, y, { width: pageWidth - 20 });
|
|
1178
|
+
y += 16;
|
|
1179
|
+
// Marshall summary
|
|
1180
|
+
if (reviewData.marshall_summary) {
|
|
1181
|
+
const ms = reviewData.marshall_summary;
|
|
1182
|
+
doc.fontSize(8).fillColor(PDF_COLORS.mutedText);
|
|
1183
|
+
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 });
|
|
1184
|
+
}
|
|
1185
|
+
y += 40; // past the summary box
|
|
1186
|
+
// Per-verdict sections
|
|
1187
|
+
const verdictOrder = [
|
|
1188
|
+
{ key: 'escalated', label: 'ESCALATED — More Serious Than Initially Detected', color: PDF_COLORS.red, emoji: '🔴' },
|
|
1189
|
+
{ key: 'confirmed', label: 'CONFIRMED — Violations Validated by AI Review', color: PDF_COLORS.orange, emoji: '🟡' },
|
|
1190
|
+
{ key: 'downgraded', label: 'DOWNGRADED — Lower Risk Than Severity Suggests', color: PDF_COLORS.green, emoji: '🟢' },
|
|
1191
|
+
{ key: 'dismissed', label: 'DISMISSED — False Positives Cleared', color: PDF_COLORS.cyan, emoji: '✅' },
|
|
1192
|
+
];
|
|
1193
|
+
for (const vType of verdictOrder) {
|
|
1194
|
+
const items = reviewData.results.filter(r => r.verdict === vType.key);
|
|
1195
|
+
if (items.length === 0)
|
|
1196
|
+
continue;
|
|
1197
|
+
if (y > doc.page.height - 140) {
|
|
1198
|
+
addFooter();
|
|
1199
|
+
doc.addPage();
|
|
1200
|
+
y = 60;
|
|
1201
|
+
}
|
|
1202
|
+
doc.fontSize(12).fillColor(vType.color);
|
|
1203
|
+
doc.text(`${vType.emoji} ${vType.label} (${items.length})`, 60, y);
|
|
1204
|
+
y += 20;
|
|
1205
|
+
// Show up to 10 per verdict type
|
|
1206
|
+
const itemsToShow = items.slice(0, 10);
|
|
1207
|
+
for (const item of itemsToShow) {
|
|
1208
|
+
if (y > doc.page.height - 100) {
|
|
1209
|
+
addFooter();
|
|
1210
|
+
doc.addPage();
|
|
1211
|
+
y = 60;
|
|
1212
|
+
}
|
|
1213
|
+
// Rule ID + verdict
|
|
1214
|
+
doc.fontSize(9).fillColor(PDF_COLORS.primary);
|
|
1215
|
+
doc.text(item.ruleId, 70, y);
|
|
1216
|
+
y += 14;
|
|
1217
|
+
// Clinical context
|
|
1218
|
+
if (item.clinicalContext) {
|
|
1219
|
+
doc.fontSize(8).fillColor(PDF_COLORS.bodyText);
|
|
1220
|
+
const ctxHeight = doc.heightOfString(item.clinicalContext, { width: pageWidth - 30 });
|
|
1221
|
+
doc.text(item.clinicalContext, 80, y, { width: pageWidth - 30 });
|
|
1222
|
+
y += ctxHeight + 4;
|
|
1223
|
+
}
|
|
1224
|
+
// Age groups
|
|
1225
|
+
if (item.ageGroupImpact && item.ageGroupImpact.length > 0) {
|
|
1226
|
+
doc.fontSize(7).fillColor(PDF_COLORS.mutedText);
|
|
1227
|
+
doc.text(`Ages most affected: ${item.ageGroupImpact.join(', ')}`, 80, y);
|
|
1228
|
+
y += 12;
|
|
1229
|
+
}
|
|
1230
|
+
// Regulatory context (Marshall enrichment)
|
|
1231
|
+
if (item.regulatoryContext) {
|
|
1232
|
+
const rc = item.regulatoryContext;
|
|
1233
|
+
const priorityLabel = rc.enforcement_priority === 'active' ? '🔴 ACTIVE'
|
|
1234
|
+
: rc.enforcement_priority === 'watching' ? '🟡 WATCHING' : '⚪ DORMANT';
|
|
1235
|
+
doc.fontSize(7).fillColor(PDF_COLORS.red);
|
|
1236
|
+
doc.text(`Regulatory: ${rc.regulation} | ${priorityLabel} | Penalty: ${rc.penalty_exposure} | Urgency: ${rc.urgency_score}`, 80, y, { width: pageWidth - 30 });
|
|
1237
|
+
y += 12;
|
|
1238
|
+
if (rc.recent_case) {
|
|
1239
|
+
doc.fontSize(7).fillColor(PDF_COLORS.mutedText);
|
|
1240
|
+
doc.text(`Recent precedent: ${rc.recent_case}`, 80, y, { width: pageWidth - 30 });
|
|
1241
|
+
y += 12;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
// Remediation
|
|
1245
|
+
if (item.remediationGuidance) {
|
|
1246
|
+
doc.fontSize(8).fillColor(PDF_COLORS.green);
|
|
1247
|
+
const remHeight = doc.heightOfString(`Fix: ${item.remediationGuidance}`, { width: pageWidth - 30 });
|
|
1248
|
+
doc.text(`Fix: ${item.remediationGuidance}`, 80, y, { width: pageWidth - 30 });
|
|
1249
|
+
y += remHeight + 4;
|
|
1250
|
+
}
|
|
1251
|
+
y += 8;
|
|
1252
|
+
}
|
|
1253
|
+
if (items.length > 10) {
|
|
1254
|
+
doc.fontSize(8).fillColor(PDF_COLORS.mutedText);
|
|
1255
|
+
doc.text(`+ ${items.length - 10} more ${vType.key} violation(s). See full results on your Halo Dashboard.`, 80, y, { width: pageWidth - 30 });
|
|
1256
|
+
y += 16;
|
|
1257
|
+
}
|
|
1258
|
+
y += 10;
|
|
1259
|
+
}
|
|
1260
|
+
// Review Board footer note
|
|
1261
|
+
if (y > doc.page.height - 80) {
|
|
1262
|
+
addFooter();
|
|
1263
|
+
doc.addPage();
|
|
1264
|
+
y = 60;
|
|
1265
|
+
}
|
|
1266
|
+
doc.fontSize(7).fillColor(PDF_COLORS.lightText);
|
|
1267
|
+
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 });
|
|
1268
|
+
addFooter();
|
|
1269
|
+
}
|
|
986
1270
|
// ═══════════════ RECOMMENDATIONS ═══════════════
|
|
987
1271
|
doc.addPage();
|
|
988
1272
|
doc.fontSize(20).fillColor(PDF_COLORS.darkText).text('Recommendations', 60, 60);
|
|
@@ -1068,6 +1352,122 @@ function loadHaloignore(startDir) {
|
|
|
1068
1352
|
}
|
|
1069
1353
|
return undefined;
|
|
1070
1354
|
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Detect the project framework by scanning project files.
|
|
1357
|
+
* Checks package.json, Gemfile, go.mod, Cargo.toml, manage.py, requirements.txt.
|
|
1358
|
+
* Returns a framework identifier string or null if unknown.
|
|
1359
|
+
*/
|
|
1360
|
+
function detectProjectFramework(dir) {
|
|
1361
|
+
// Check package.json for JS/TS frameworks
|
|
1362
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
1363
|
+
if (fs.existsSync(pkgPath)) {
|
|
1364
|
+
try {
|
|
1365
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
1366
|
+
const allDeps = {
|
|
1367
|
+
...(pkg.dependencies || {}),
|
|
1368
|
+
...(pkg.devDependencies || {}),
|
|
1369
|
+
};
|
|
1370
|
+
// Order matters: check specific frameworks before generic ones
|
|
1371
|
+
if (allDeps['next'])
|
|
1372
|
+
return 'nextjs';
|
|
1373
|
+
if (allDeps['@angular/core'])
|
|
1374
|
+
return 'angular';
|
|
1375
|
+
if (allDeps['vue'])
|
|
1376
|
+
return 'vue';
|
|
1377
|
+
if (allDeps['svelte'])
|
|
1378
|
+
return 'svelte';
|
|
1379
|
+
if (allDeps['react'])
|
|
1380
|
+
return 'react';
|
|
1381
|
+
}
|
|
1382
|
+
catch {
|
|
1383
|
+
// Malformed package.json — continue detection
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
// Django: manage.py or requirements.txt with django
|
|
1387
|
+
if (fs.existsSync(path.join(dir, 'manage.py')))
|
|
1388
|
+
return 'django';
|
|
1389
|
+
const reqPath = path.join(dir, 'requirements.txt');
|
|
1390
|
+
if (fs.existsSync(reqPath)) {
|
|
1391
|
+
try {
|
|
1392
|
+
const reqs = fs.readFileSync(reqPath, 'utf-8').toLowerCase();
|
|
1393
|
+
if (reqs.includes('django'))
|
|
1394
|
+
return 'django';
|
|
1395
|
+
}
|
|
1396
|
+
catch {
|
|
1397
|
+
// Continue detection
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
// Rails: Gemfile with rails
|
|
1401
|
+
const gemfilePath = path.join(dir, 'Gemfile');
|
|
1402
|
+
if (fs.existsSync(gemfilePath)) {
|
|
1403
|
+
try {
|
|
1404
|
+
const gemfile = fs.readFileSync(gemfilePath, 'utf-8').toLowerCase();
|
|
1405
|
+
if (gemfile.includes('rails'))
|
|
1406
|
+
return 'rails';
|
|
1407
|
+
}
|
|
1408
|
+
catch {
|
|
1409
|
+
// Continue detection
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
// Go
|
|
1413
|
+
if (fs.existsSync(path.join(dir, 'go.mod')))
|
|
1414
|
+
return 'go';
|
|
1415
|
+
// Rust
|
|
1416
|
+
if (fs.existsSync(path.join(dir, 'Cargo.toml')))
|
|
1417
|
+
return 'rust';
|
|
1418
|
+
return null;
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Get default .haloignore content based on detected framework.
|
|
1422
|
+
*/
|
|
1423
|
+
function getDefaultHaloignoreContent(framework) {
|
|
1424
|
+
const lines = [
|
|
1425
|
+
'# Halo ignore patterns',
|
|
1426
|
+
'# Generated by: runhalo init',
|
|
1427
|
+
'',
|
|
1428
|
+
'node_modules/',
|
|
1429
|
+
'dist/',
|
|
1430
|
+
'build/',
|
|
1431
|
+
'coverage/',
|
|
1432
|
+
'*.min.js',
|
|
1433
|
+
'*.bundle.js',
|
|
1434
|
+
];
|
|
1435
|
+
// Add framework-specific ignores
|
|
1436
|
+
switch (framework) {
|
|
1437
|
+
case 'nextjs':
|
|
1438
|
+
lines.push('.next/');
|
|
1439
|
+
lines.push('.vercel/');
|
|
1440
|
+
break;
|
|
1441
|
+
case 'angular':
|
|
1442
|
+
lines.push('.angular/');
|
|
1443
|
+
break;
|
|
1444
|
+
case 'vue':
|
|
1445
|
+
lines.push('.nuxt/');
|
|
1446
|
+
break;
|
|
1447
|
+
case 'svelte':
|
|
1448
|
+
lines.push('.svelte-kit/');
|
|
1449
|
+
break;
|
|
1450
|
+
case 'django':
|
|
1451
|
+
lines.push('__pycache__/');
|
|
1452
|
+
lines.push('*.pyc');
|
|
1453
|
+
lines.push('.venv/');
|
|
1454
|
+
lines.push('venv/');
|
|
1455
|
+
break;
|
|
1456
|
+
case 'rails':
|
|
1457
|
+
lines.push('tmp/');
|
|
1458
|
+
lines.push('log/');
|
|
1459
|
+
lines.push('vendor/bundle/');
|
|
1460
|
+
break;
|
|
1461
|
+
case 'go':
|
|
1462
|
+
lines.push('vendor/');
|
|
1463
|
+
break;
|
|
1464
|
+
case 'rust':
|
|
1465
|
+
lines.push('target/');
|
|
1466
|
+
break;
|
|
1467
|
+
}
|
|
1468
|
+
lines.push('');
|
|
1469
|
+
return lines.join('\n');
|
|
1470
|
+
}
|
|
1071
1471
|
/**
|
|
1072
1472
|
* Create a Halo engine instance
|
|
1073
1473
|
*/
|
|
@@ -1358,6 +1758,108 @@ function saveHistory(entry) {
|
|
|
1358
1758
|
// Silent failure — never block scan
|
|
1359
1759
|
}
|
|
1360
1760
|
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Send webhook notifications to Discord and/or Slack after a scan completes.
|
|
1763
|
+
* Non-blocking — failures are logged but never affect the scan exit code.
|
|
1764
|
+
*/
|
|
1765
|
+
async function sendWebhookNotifications(rcConfig, lastEntry, verbose) {
|
|
1766
|
+
const notifications = rcConfig?.notifications;
|
|
1767
|
+
if (!notifications)
|
|
1768
|
+
return;
|
|
1769
|
+
const { discord_webhook, slack_webhook } = notifications;
|
|
1770
|
+
if (!discord_webhook && !slack_webhook)
|
|
1771
|
+
return;
|
|
1772
|
+
const hasFailed = lastEntry.totalViolations > 0;
|
|
1773
|
+
const filesScanned = String(lastEntry.filesScanned);
|
|
1774
|
+
const violations = String(lastEntry.totalViolations);
|
|
1775
|
+
const grade = lastEntry.grade || 'N/A';
|
|
1776
|
+
const topRules = lastEntry.rulesTriggered.slice(0, 5).join(', ') || 'None';
|
|
1777
|
+
const timestamp = new Date().toISOString();
|
|
1778
|
+
// Discord webhook
|
|
1779
|
+
if (discord_webhook) {
|
|
1780
|
+
try {
|
|
1781
|
+
const discordPayload = {
|
|
1782
|
+
embeds: [{
|
|
1783
|
+
title: '\u{1F6E1}\uFE0F Halo Scan Complete',
|
|
1784
|
+
color: hasFailed ? 15158332 : 3066993,
|
|
1785
|
+
fields: [
|
|
1786
|
+
{ name: 'Files Scanned', value: filesScanned, inline: true },
|
|
1787
|
+
{ name: 'Violations', value: violations, inline: true },
|
|
1788
|
+
{ name: 'Grade', value: grade, inline: true },
|
|
1789
|
+
{ name: 'Top Rules', value: topRules, inline: false },
|
|
1790
|
+
],
|
|
1791
|
+
footer: { text: 'Halo \u2014 runhalo.dev' },
|
|
1792
|
+
timestamp,
|
|
1793
|
+
}],
|
|
1794
|
+
};
|
|
1795
|
+
const controller = new AbortController();
|
|
1796
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
1797
|
+
const res = await fetch(discord_webhook, {
|
|
1798
|
+
method: 'POST',
|
|
1799
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1800
|
+
body: JSON.stringify(discordPayload),
|
|
1801
|
+
signal: controller.signal,
|
|
1802
|
+
});
|
|
1803
|
+
clearTimeout(timeout);
|
|
1804
|
+
if (verbose) {
|
|
1805
|
+
console.error(res.ok
|
|
1806
|
+
? '\u2709\uFE0F Notification sent to Discord'
|
|
1807
|
+
: `\u26A0\uFE0F Discord webhook returned ${res.status}`);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
catch (err) {
|
|
1811
|
+
if (verbose) {
|
|
1812
|
+
console.error(`\u26A0\uFE0F Discord webhook failed: ${err instanceof Error ? err.message : err}`);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
// Slack webhook
|
|
1817
|
+
if (slack_webhook) {
|
|
1818
|
+
try {
|
|
1819
|
+
const slackPayload = {
|
|
1820
|
+
blocks: [
|
|
1821
|
+
{
|
|
1822
|
+
type: 'header',
|
|
1823
|
+
text: { type: 'plain_text', text: '\u{1F6E1}\uFE0F Halo Scan Complete' },
|
|
1824
|
+
},
|
|
1825
|
+
{
|
|
1826
|
+
type: 'section',
|
|
1827
|
+
fields: [
|
|
1828
|
+
{ type: 'mrkdwn', text: `*Files Scanned*\n${filesScanned}` },
|
|
1829
|
+
{ type: 'mrkdwn', text: `*Violations*\n${violations}` },
|
|
1830
|
+
{ type: 'mrkdwn', text: `*Grade*\n${grade}` },
|
|
1831
|
+
],
|
|
1832
|
+
},
|
|
1833
|
+
{
|
|
1834
|
+
type: 'section',
|
|
1835
|
+
fields: [
|
|
1836
|
+
{ type: 'mrkdwn', text: `*Top Rules*\n${topRules}` },
|
|
1837
|
+
],
|
|
1838
|
+
},
|
|
1839
|
+
],
|
|
1840
|
+
};
|
|
1841
|
+
const controller = new AbortController();
|
|
1842
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
1843
|
+
const res = await fetch(slack_webhook, {
|
|
1844
|
+
method: 'POST',
|
|
1845
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1846
|
+
body: JSON.stringify(slackPayload),
|
|
1847
|
+
signal: controller.signal,
|
|
1848
|
+
});
|
|
1849
|
+
clearTimeout(timeout);
|
|
1850
|
+
if (verbose) {
|
|
1851
|
+
console.error(res.ok
|
|
1852
|
+
? '\u2709\uFE0F Notification sent to Slack'
|
|
1853
|
+
: `\u26A0\uFE0F Slack webhook returned ${res.status}`);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
catch (err) {
|
|
1857
|
+
if (verbose) {
|
|
1858
|
+
console.error(`\u26A0\uFE0F Slack webhook failed: ${err instanceof Error ? err.message : err}`);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1361
1863
|
async function submitCliLead(email) {
|
|
1362
1864
|
try {
|
|
1363
1865
|
const res = await fetch(`${SUPABASE_URL}/rest/v1/halo_leads`, {
|
|
@@ -1757,6 +2259,7 @@ async function scan(paths, options) {
|
|
|
1757
2259
|
'au-sbd': 'AU Safety by Design',
|
|
1758
2260
|
'au-osa': 'AU Online Safety Act',
|
|
1759
2261
|
'caadca': 'California AADCA',
|
|
2262
|
+
'eu-ai-act': 'EU AI Act (Children)',
|
|
1760
2263
|
};
|
|
1761
2264
|
const packLabel = packs.map(p => packNameMap[p] || p).join(' + ');
|
|
1762
2265
|
console.error(c(colors.dim, `🔍 Scanning ${uniqueFiles.length} files (${packLabel})...`));
|
|
@@ -1843,7 +2346,14 @@ async function scan(paths, options) {
|
|
|
1843
2346
|
output += trendLine + '\n';
|
|
1844
2347
|
}
|
|
1845
2348
|
}
|
|
2349
|
+
// Store scan data for potential PDF regeneration with AI Review Board data
|
|
2350
|
+
_lastScanData.results = results;
|
|
2351
|
+
_lastScanData.scoreResult = scoreResult;
|
|
2352
|
+
_lastScanData.fileCount = fileCount;
|
|
2353
|
+
_lastScanData.projectPath = projectPath;
|
|
1846
2354
|
// Generate report if requested (HTML or PDF based on filename extension)
|
|
2355
|
+
// Note: if --review-board is also set, the PDF will be regenerated with AI data
|
|
2356
|
+
// in the action handler after the review board completes.
|
|
1847
2357
|
if (options.report) {
|
|
1848
2358
|
const reportFilename = typeof options.report === 'string'
|
|
1849
2359
|
? options.report
|
|
@@ -2132,6 +2642,9 @@ program
|
|
|
2132
2642
|
.option('--report [filename]', 'Generate HTML compliance report (default: halo-report.html)')
|
|
2133
2643
|
.option('--upload', 'Upload scan results to Halo Dashboard (requires Pro)')
|
|
2134
2644
|
.option('--watch', 'Watch for file changes and re-scan automatically')
|
|
2645
|
+
.option('--review-board', 'Enable AI Review Board — clinical assessment of each violation (Pro/Enterprise)')
|
|
2646
|
+
.option('--license-key <key>', 'License key for Pro/Enterprise features (or set HALO_LICENSE_KEY env var)')
|
|
2647
|
+
.option('--framework <framework>', 'Override framework detection (react, nextjs, vue, angular, django, rails)')
|
|
2135
2648
|
.option('--no-prompt', 'Skip first-run email prompt')
|
|
2136
2649
|
.option('-v, --verbose', 'Verbose output')
|
|
2137
2650
|
.action(async (paths, options) => {
|
|
@@ -2156,6 +2669,9 @@ program
|
|
|
2156
2669
|
if (options.upload && !checkProFeature('Dashboard Upload', '--upload')) {
|
|
2157
2670
|
process.exit(0);
|
|
2158
2671
|
}
|
|
2672
|
+
if (options.reviewBoard && !checkProFeature('AI Review Board', '--review-board')) {
|
|
2673
|
+
process.exit(0);
|
|
2674
|
+
}
|
|
2159
2675
|
// Scan limit check (soft — exit 0, not error)
|
|
2160
2676
|
if (!checkScanLimit()) {
|
|
2161
2677
|
process.exit(0);
|
|
@@ -2203,9 +2719,14 @@ program
|
|
|
2203
2719
|
report: options.report || false,
|
|
2204
2720
|
pack: mergedPacks,
|
|
2205
2721
|
offline: options.offline || false,
|
|
2206
|
-
// Sprint
|
|
2207
|
-
framework: rcConfig?.framework
|
|
2722
|
+
// Sprint 10: Framework resolution: CLI flag > .halorc.json > auto-detection
|
|
2723
|
+
framework: options.framework || rcConfig?.framework || detectProjectFramework(fs.existsSync(projectRoot) && fs.statSync(projectRoot).isDirectory()
|
|
2724
|
+
? projectRoot
|
|
2725
|
+
: path.dirname(projectRoot)) || undefined,
|
|
2208
2726
|
astAnalysis: rcConfig?.astAnalysis,
|
|
2727
|
+
// Sprint 9: AI Review Board
|
|
2728
|
+
reviewBoard: options.reviewBoard || rcConfig?.reviewBoard || false,
|
|
2729
|
+
licenseKey: options.licenseKey || process.env.HALO_LICENSE_KEY || rcConfig?.licenseKey,
|
|
2209
2730
|
};
|
|
2210
2731
|
// ==================== Watch Mode ====================
|
|
2211
2732
|
if (options.watch) {
|
|
@@ -2360,6 +2881,179 @@ program
|
|
|
2360
2881
|
}
|
|
2361
2882
|
}
|
|
2362
2883
|
}
|
|
2884
|
+
// ==================== AI Review Board (Sprint 9) ====================
|
|
2885
|
+
let _reviewBoardResult;
|
|
2886
|
+
if (scanOptions.reviewBoard) {
|
|
2887
|
+
const licenseKey = scanOptions.licenseKey || loadConfig().license_key;
|
|
2888
|
+
if (!licenseKey) {
|
|
2889
|
+
console.error('⚠️ AI Review Board requires a license key. Run `halo activate <key>` or pass --license-key.');
|
|
2890
|
+
}
|
|
2891
|
+
else {
|
|
2892
|
+
try {
|
|
2893
|
+
// Extract violations directly from the scan results already in memory
|
|
2894
|
+
const allViolations = _lastScanData.results.flatMap(r => r.violations);
|
|
2895
|
+
if (allViolations.length > 0) {
|
|
2896
|
+
console.error('\n🤖 Running AI Review Board...');
|
|
2897
|
+
const reviewUrl = 'https://wrfwcmyxxbafcdvxlmug.supabase.co/functions/v1/ai-review';
|
|
2898
|
+
// Chunk violations in batches of 50 (endpoint limit)
|
|
2899
|
+
const CHUNK_SIZE = 50;
|
|
2900
|
+
const chunks = [];
|
|
2901
|
+
for (let i = 0; i < allViolations.length; i += CHUNK_SIZE) {
|
|
2902
|
+
chunks.push(allViolations.slice(i, i + CHUNK_SIZE));
|
|
2903
|
+
}
|
|
2904
|
+
if (chunks.length > 1) {
|
|
2905
|
+
console.error(` Sending ${allViolations.length} violations in ${chunks.length} batches...`);
|
|
2906
|
+
}
|
|
2907
|
+
// Process each chunk and merge results
|
|
2908
|
+
const mergedResults = [];
|
|
2909
|
+
let totalCost = 0;
|
|
2910
|
+
let totalLatency = 0;
|
|
2911
|
+
let totalCacheHits = 0;
|
|
2912
|
+
let mergedMarshall;
|
|
2913
|
+
let chunksFailed = 0;
|
|
2914
|
+
for (let ci = 0; ci < chunks.length; ci++) {
|
|
2915
|
+
const chunk = chunks[ci];
|
|
2916
|
+
if (chunks.length > 1) {
|
|
2917
|
+
console.error(` Batch ${ci + 1}/${chunks.length} (${chunk.length} violations)...`);
|
|
2918
|
+
}
|
|
2919
|
+
const reviewRes = await fetch(reviewUrl, {
|
|
2920
|
+
method: 'POST',
|
|
2921
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2922
|
+
body: JSON.stringify({
|
|
2923
|
+
license_key: licenseKey,
|
|
2924
|
+
violations: chunk,
|
|
2925
|
+
repo_metadata: {
|
|
2926
|
+
framework: scanOptions.framework,
|
|
2927
|
+
},
|
|
2928
|
+
}),
|
|
2929
|
+
});
|
|
2930
|
+
if (reviewRes.ok) {
|
|
2931
|
+
const chunkReview = await reviewRes.json();
|
|
2932
|
+
mergedResults.push(...chunkReview.results);
|
|
2933
|
+
totalCost += chunkReview.cost.estimated_usd;
|
|
2934
|
+
totalLatency += chunkReview.latency_ms;
|
|
2935
|
+
totalCacheHits += chunkReview.summary.cache_hits;
|
|
2936
|
+
// Merge marshall summary (take the one with highest urgency)
|
|
2937
|
+
if (chunkReview.marshall_summary) {
|
|
2938
|
+
if (!mergedMarshall || (chunkReview.marshall_summary.avg_urgency > mergedMarshall.avg_urgency)) {
|
|
2939
|
+
mergedMarshall = chunkReview.marshall_summary;
|
|
2940
|
+
}
|
|
2941
|
+
if (mergedMarshall && chunkReview.marshall_summary !== mergedMarshall) {
|
|
2942
|
+
mergedMarshall.enriched_count += chunkReview.marshall_summary.enriched_count;
|
|
2943
|
+
mergedMarshall.active_enforcement_count += chunkReview.marshall_summary.active_enforcement_count;
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
else {
|
|
2948
|
+
const err = await reviewRes.json().catch(() => ({}));
|
|
2949
|
+
console.error(` ⚠️ Batch ${ci + 1} failed: ${err.error || reviewRes.statusText}`);
|
|
2950
|
+
chunksFailed++;
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
if (mergedResults.length > 0) {
|
|
2954
|
+
// Build merged ReviewBoardData
|
|
2955
|
+
const review = {
|
|
2956
|
+
results: mergedResults,
|
|
2957
|
+
summary: {
|
|
2958
|
+
total: mergedResults.length,
|
|
2959
|
+
confirmed: mergedResults.filter(r => r.verdict === 'confirmed').length,
|
|
2960
|
+
downgraded: mergedResults.filter(r => r.verdict === 'downgraded').length,
|
|
2961
|
+
escalated: mergedResults.filter(r => r.verdict === 'escalated').length,
|
|
2962
|
+
dismissed: mergedResults.filter(r => r.verdict === 'dismissed').length,
|
|
2963
|
+
cache_hits: totalCacheHits,
|
|
2964
|
+
},
|
|
2965
|
+
marshall_summary: mergedMarshall,
|
|
2966
|
+
cost: { estimated_usd: totalCost },
|
|
2967
|
+
latency_ms: totalLatency,
|
|
2968
|
+
};
|
|
2969
|
+
_reviewBoardResult = review; // Store for PDF report
|
|
2970
|
+
// Display Review Board results
|
|
2971
|
+
console.error(`\n🛡️ Halo AI Review Board — ${review.summary.total} violations analyzed (${review.latency_ms}ms)`);
|
|
2972
|
+
console.error(`⚠️ AI-assisted review — may over-dismiss valid findings. Deterministic engine results (above) are authoritative.\n`);
|
|
2973
|
+
const critical = review.results.filter(r => r.verdict === 'escalated');
|
|
2974
|
+
const confirmed = review.results.filter(r => r.verdict === 'confirmed');
|
|
2975
|
+
const downgraded = review.results.filter(r => r.verdict === 'downgraded');
|
|
2976
|
+
const dismissed = review.results.filter(r => r.verdict === 'dismissed');
|
|
2977
|
+
if (critical.length > 0) {
|
|
2978
|
+
console.error(`🔴 ESCALATED (${critical.length}) — More serious than initially detected`);
|
|
2979
|
+
for (const r of critical) {
|
|
2980
|
+
console.error(` ${r.ruleId}: ${r.clinicalContext}`);
|
|
2981
|
+
if (r.ageGroupImpact.length > 0)
|
|
2982
|
+
console.error(` Ages most affected: ${r.ageGroupImpact.join(', ')}`);
|
|
2983
|
+
if (r.remediationGuidance)
|
|
2984
|
+
console.error(` Fix: ${r.remediationGuidance}`);
|
|
2985
|
+
}
|
|
2986
|
+
console.error('');
|
|
2987
|
+
}
|
|
2988
|
+
if (confirmed.length > 0) {
|
|
2989
|
+
console.error(`🟡 CONFIRMED (${confirmed.length}) — Violations validated by AI review`);
|
|
2990
|
+
for (const r of confirmed) {
|
|
2991
|
+
console.error(` ${r.ruleId}: ${r.clinicalContext}`);
|
|
2992
|
+
}
|
|
2993
|
+
console.error('');
|
|
2994
|
+
}
|
|
2995
|
+
if (downgraded.length > 0) {
|
|
2996
|
+
console.error(`🟢 DOWNGRADED (${downgraded.length}) — Lower risk than severity suggests`);
|
|
2997
|
+
for (const r of downgraded) {
|
|
2998
|
+
console.error(` ${r.ruleId}: ${r.clinicalContext}`);
|
|
2999
|
+
}
|
|
3000
|
+
console.error('');
|
|
3001
|
+
}
|
|
3002
|
+
if (dismissed.length > 0) {
|
|
3003
|
+
console.error(`✅ DISMISSED (${dismissed.length}) — False positives cleared by AI review`);
|
|
3004
|
+
for (const r of dismissed) {
|
|
3005
|
+
console.error(` ${r.ruleId}: ${r.clinicalContext}`);
|
|
3006
|
+
}
|
|
3007
|
+
console.error('');
|
|
3008
|
+
}
|
|
3009
|
+
const cacheStr = review.summary.cache_hits > 0 ? ` (${review.summary.cache_hits} cached)` : '';
|
|
3010
|
+
console.error(`📊 Cost: $${review.cost.estimated_usd.toFixed(4)}${cacheStr}`);
|
|
3011
|
+
console.error(`🤖 Reviewed by: Halo AI Review Board (${review.latency_ms}ms)\n`);
|
|
3012
|
+
// Output ReviewBoardData as JSON to stdout for batch tooling
|
|
3013
|
+
// (summary generator's readJsonSafe picks up the last JSON object)
|
|
3014
|
+
if (scanOptions.format === 'json') {
|
|
3015
|
+
console.log(JSON.stringify(review));
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
else if (chunksFailed > 0) {
|
|
3019
|
+
console.error(`⚠️ AI Review failed: all ${chunksFailed} batches failed`);
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
catch (reviewErr) {
|
|
3024
|
+
console.error(`⚠️ AI Review failed: ${reviewErr instanceof Error ? reviewErr.message : reviewErr}`);
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
// ==================== Generate PDF Report with AI Review Board data ====================
|
|
3029
|
+
// If both --review-board and --report *.pdf are set, regenerate the PDF with review data
|
|
3030
|
+
if (options.report && _reviewBoardResult) {
|
|
3031
|
+
const reportFilename = typeof options.report === 'string'
|
|
3032
|
+
? options.report
|
|
3033
|
+
: 'halo-report.html';
|
|
3034
|
+
if (reportFilename.endsWith('.pdf') && _lastScanData.results.length > 0) {
|
|
3035
|
+
const projectHistory = loadHistory().filter(h => h.projectPath === _lastScanData.projectPath);
|
|
3036
|
+
const historyForReport = projectHistory.slice(0, -1);
|
|
3037
|
+
const pdfBuffer = await generatePdfReport(_lastScanData.results, _lastScanData.scoreResult, _lastScanData.fileCount, _lastScanData.projectPath, historyForReport, _reviewBoardResult);
|
|
3038
|
+
fs.writeFileSync(reportFilename, pdfBuffer);
|
|
3039
|
+
console.error(`📄 PDF report updated with AI Review Board assessment`);
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
// ==================== Webhook Notifications (Discord/Slack) ====================
|
|
3043
|
+
if (rcConfig?.notifications) {
|
|
3044
|
+
try {
|
|
3045
|
+
const history = loadHistory();
|
|
3046
|
+
const lastEntry = history[history.length - 1];
|
|
3047
|
+
if (lastEntry) {
|
|
3048
|
+
await sendWebhookNotifications(rcConfig, lastEntry, options.verbose);
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
catch (notifyErr) {
|
|
3052
|
+
if (options.verbose) {
|
|
3053
|
+
console.error(`\u26A0\uFE0F Webhook notification error: ${notifyErr instanceof Error ? notifyErr.message : notifyErr}`);
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
2363
3057
|
// Upload to Halo Dashboard (non-blocking — upload failure doesn't affect exit code)
|
|
2364
3058
|
if (options.upload) {
|
|
2365
3059
|
try {
|
|
@@ -2607,78 +3301,142 @@ function getCopilotInstructionsContent() {
|
|
|
2607
3301
|
return getIDERulesContent();
|
|
2608
3302
|
}
|
|
2609
3303
|
/**
|
|
2610
|
-
* Init command — generate IDE rules files
|
|
3304
|
+
* Init command — detect framework, generate .halorc.json, .haloignore, and IDE rules files.
|
|
2611
3305
|
*/
|
|
2612
3306
|
async function init(projectPath, options) {
|
|
2613
3307
|
const resolvedPath = path.resolve(projectPath);
|
|
2614
|
-
|
|
2615
|
-
|
|
3308
|
+
// --ide flag: generate AI coding assistant rules files (existing behavior)
|
|
3309
|
+
if (options.ide) {
|
|
3310
|
+
console.log('🔮 Halo init — Generating AI coding assistant rules...\n');
|
|
3311
|
+
const files = [
|
|
3312
|
+
{
|
|
3313
|
+
path: path.join(resolvedPath, '.cursor', 'rules'),
|
|
3314
|
+
content: getCursorRulesContent(),
|
|
3315
|
+
label: 'Cursor'
|
|
3316
|
+
},
|
|
3317
|
+
{
|
|
3318
|
+
path: path.join(resolvedPath, '.windsurfrules'),
|
|
3319
|
+
content: getWindsurfRulesContent(),
|
|
3320
|
+
label: 'Windsurf'
|
|
3321
|
+
},
|
|
3322
|
+
{
|
|
3323
|
+
path: path.join(resolvedPath, '.github', 'copilot-instructions.md'),
|
|
3324
|
+
content: getCopilotInstructionsContent(),
|
|
3325
|
+
label: 'GitHub Copilot'
|
|
3326
|
+
}
|
|
3327
|
+
];
|
|
3328
|
+
let created = 0;
|
|
3329
|
+
let skipped = 0;
|
|
3330
|
+
for (const file of files) {
|
|
3331
|
+
const dir = path.dirname(file.path);
|
|
3332
|
+
const relativePath = path.relative(resolvedPath, file.path);
|
|
3333
|
+
if (fs.existsSync(file.path) && !options.force) {
|
|
3334
|
+
console.log(` ⏭ ${relativePath} (exists — use --force to overwrite)`);
|
|
3335
|
+
skipped++;
|
|
3336
|
+
continue;
|
|
3337
|
+
}
|
|
3338
|
+
try {
|
|
3339
|
+
if (!fs.existsSync(dir)) {
|
|
3340
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3341
|
+
}
|
|
3342
|
+
fs.writeFileSync(file.path, file.content, 'utf-8');
|
|
3343
|
+
console.log(` ✅ ${relativePath} — ${file.label} rules`);
|
|
3344
|
+
created++;
|
|
3345
|
+
}
|
|
3346
|
+
catch (err) {
|
|
3347
|
+
console.error(` ❌ ${relativePath} — ${err instanceof Error ? err.message : err}`);
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
2616
3350
|
console.log('');
|
|
2617
|
-
|
|
2618
|
-
|
|
3351
|
+
if (created > 0) {
|
|
3352
|
+
console.log(`Created ${created} rules file${created > 1 ? 's' : ''}. Your AI assistant now knows COPPA 2.0.`);
|
|
3353
|
+
}
|
|
3354
|
+
if (skipped > 0) {
|
|
3355
|
+
console.log(`Skipped ${skipped} existing file${skipped > 1 ? 's' : ''}.`);
|
|
3356
|
+
}
|
|
3357
|
+
console.log('');
|
|
3358
|
+
console.log('What happens next:');
|
|
3359
|
+
console.log(' • Cursor, Windsurf, and Copilot will read these rules automatically');
|
|
3360
|
+
console.log(' • AI-generated code will follow COPPA compliance patterns');
|
|
3361
|
+
console.log(' • Run "npx runhalo scan ." to verify compliance');
|
|
2619
3362
|
console.log('');
|
|
2620
|
-
console.log('
|
|
2621
|
-
console.log('
|
|
2622
|
-
console.log('
|
|
3363
|
+
console.log('Full-stack compliance for the AI coding era:');
|
|
3364
|
+
console.log(' CI: uses: runhalo/action@v1 catches violations in PRs');
|
|
3365
|
+
console.log(' Local: npx runhalo scan . catches violations on your machine');
|
|
3366
|
+
console.log(' Proactive: AI rules files prevent violations before they\'re written');
|
|
2623
3367
|
return 0;
|
|
2624
3368
|
}
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
3369
|
+
// Default init: auto-detect framework, generate .halorc.json and .haloignore
|
|
3370
|
+
console.log('🔮 Halo init — project setup\n');
|
|
3371
|
+
// Step 1: Detect framework
|
|
3372
|
+
const detectedFramework = detectProjectFramework(resolvedPath);
|
|
3373
|
+
if (detectedFramework) {
|
|
3374
|
+
console.log(` 🔍 Detected framework: ${c(colors.bold, detectedFramework)}`);
|
|
3375
|
+
}
|
|
3376
|
+
else {
|
|
3377
|
+
console.log(` 🔍 Framework: ${c(colors.dim, 'not detected (generic config will be generated)')}`);
|
|
3378
|
+
}
|
|
3379
|
+
let configCreated = false;
|
|
3380
|
+
let ignoreCreated = false;
|
|
3381
|
+
// Step 2: Generate .halorc.json
|
|
3382
|
+
const rcPath = path.join(resolvedPath, '.halorc.json');
|
|
3383
|
+
if (fs.existsSync(rcPath) && !options.force) {
|
|
3384
|
+
console.log(` ⏭ .halorc.json (exists — use --force to overwrite)`);
|
|
3385
|
+
}
|
|
3386
|
+
else {
|
|
3387
|
+
const rcConfig = {
|
|
3388
|
+
packs: ['coppa', 'ethical'],
|
|
3389
|
+
severity_threshold: 'medium',
|
|
3390
|
+
ignore: ['**/test/**', '**/__tests__/**', '**/node_modules/**'],
|
|
3391
|
+
astAnalysis: true,
|
|
3392
|
+
notifications: {},
|
|
3393
|
+
};
|
|
3394
|
+
if (detectedFramework) {
|
|
3395
|
+
rcConfig.framework = detectedFramework;
|
|
2641
3396
|
}
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
console.log(` ⏭ ${relativePath} (exists — use --force to overwrite)`);
|
|
2650
|
-
skipped++;
|
|
2651
|
-
continue;
|
|
3397
|
+
try {
|
|
3398
|
+
fs.writeFileSync(rcPath, JSON.stringify(rcConfig, null, 2) + '\n', 'utf-8');
|
|
3399
|
+
console.log(` ✅ .halorc.json — project configuration`);
|
|
3400
|
+
configCreated = true;
|
|
3401
|
+
}
|
|
3402
|
+
catch (err) {
|
|
3403
|
+
console.error(` ❌ .halorc.json — ${err instanceof Error ? err.message : err}`);
|
|
2652
3404
|
}
|
|
3405
|
+
}
|
|
3406
|
+
// Step 3: Generate .haloignore
|
|
3407
|
+
const ignorePath = path.join(resolvedPath, '.haloignore');
|
|
3408
|
+
if (fs.existsSync(ignorePath) && !options.force) {
|
|
3409
|
+
console.log(` ⏭ .haloignore (exists — use --force to overwrite)`);
|
|
3410
|
+
}
|
|
3411
|
+
else {
|
|
2653
3412
|
try {
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
console.log(` ✅ ${relativePath} — ${file.label} rules`);
|
|
2659
|
-
created++;
|
|
3413
|
+
const ignoreContent = getDefaultHaloignoreContent(detectedFramework);
|
|
3414
|
+
fs.writeFileSync(ignorePath, ignoreContent, 'utf-8');
|
|
3415
|
+
console.log(` ✅ .haloignore — scan exclusion patterns`);
|
|
3416
|
+
ignoreCreated = true;
|
|
2660
3417
|
}
|
|
2661
3418
|
catch (err) {
|
|
2662
|
-
console.error(` ❌
|
|
3419
|
+
console.error(` ❌ .haloignore — ${err instanceof Error ? err.message : err}`);
|
|
2663
3420
|
}
|
|
2664
3421
|
}
|
|
3422
|
+
// Step 4: Summary
|
|
2665
3423
|
console.log('');
|
|
2666
|
-
if (
|
|
2667
|
-
|
|
3424
|
+
if (configCreated || ignoreCreated) {
|
|
3425
|
+
const parts = [];
|
|
3426
|
+
if (configCreated)
|
|
3427
|
+
parts.push('.halorc.json');
|
|
3428
|
+
if (ignoreCreated)
|
|
3429
|
+
parts.push('.haloignore');
|
|
3430
|
+
console.log(`Created: ${parts.join(', ')}`);
|
|
2668
3431
|
}
|
|
2669
|
-
|
|
2670
|
-
console.log(
|
|
3432
|
+
else {
|
|
3433
|
+
console.log('No files created (all exist — use --force to overwrite).');
|
|
2671
3434
|
}
|
|
2672
3435
|
console.log('');
|
|
2673
|
-
console.log('
|
|
2674
|
-
console.log(
|
|
2675
|
-
console.log(
|
|
2676
|
-
console.log(' • Run "npx runhalo scan ." to verify compliance');
|
|
3436
|
+
console.log('Next steps:');
|
|
3437
|
+
console.log(` ${c(colors.bold, 'npx runhalo scan .')} Scan your project for compliance issues`);
|
|
3438
|
+
console.log(` ${c(colors.bold, 'npx runhalo init --ide')} Generate AI coding assistant rules files`);
|
|
2677
3439
|
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
3440
|
return 0;
|
|
2683
3441
|
}
|
|
2684
3442
|
program
|
|
@@ -2811,10 +3569,10 @@ program
|
|
|
2811
3569
|
});
|
|
2812
3570
|
program
|
|
2813
3571
|
.command('init')
|
|
2814
|
-
.description('Initialize Halo in your project (generate
|
|
3572
|
+
.description('Initialize Halo in your project (detect framework, generate .halorc.json and .haloignore)')
|
|
2815
3573
|
.argument('[path]', 'Project root path (default: current directory)', '.')
|
|
2816
3574
|
.option('--ide', 'Generate AI coding assistant rules files', false)
|
|
2817
|
-
.option('--force', 'Overwrite existing
|
|
3575
|
+
.option('--force', 'Overwrite existing files', false)
|
|
2818
3576
|
.action(async (projectPath, options) => {
|
|
2819
3577
|
try {
|
|
2820
3578
|
const exitCode = await init(projectPath, {
|
|
@@ -2828,6 +3586,73 @@ program
|
|
|
2828
3586
|
process.exit(3);
|
|
2829
3587
|
}
|
|
2830
3588
|
});
|
|
3589
|
+
// ─── Sprint 15: Graduate Command ──────────────────────────────────────────
|
|
3590
|
+
program
|
|
3591
|
+
.command('graduate')
|
|
3592
|
+
.description('Run graduation pipeline — analyze FP patterns for heuristic promotion')
|
|
3593
|
+
.option('--dry-run', 'Show candidates without modifying engine')
|
|
3594
|
+
.option('--report', 'Output JSON report')
|
|
3595
|
+
.option('--log-dir <dir>', 'Verdict log directory', 'verdict-logs')
|
|
3596
|
+
.action(async (options) => {
|
|
3597
|
+
try {
|
|
3598
|
+
const { GraduationPipeline, formatAggregationReport, formatGraduationReport, formatCodegenReport } = require('@runhalo/engine/dist/graduation');
|
|
3599
|
+
const logDir = path.resolve(options.logDir);
|
|
3600
|
+
console.log('🎓 Graduation Pipeline — Sprint 15');
|
|
3601
|
+
console.log(` Log directory: ${logDir}\n`);
|
|
3602
|
+
const pipeline = new GraduationPipeline({ logDir });
|
|
3603
|
+
// Run full pipeline (stages 2-4)
|
|
3604
|
+
const { aggregation, graduation, codegen } = pipeline.runPipeline();
|
|
3605
|
+
if (options.report) {
|
|
3606
|
+
// JSON output
|
|
3607
|
+
console.log(JSON.stringify({
|
|
3608
|
+
aggregation: {
|
|
3609
|
+
totalPatterns: aggregation.patterns.size,
|
|
3610
|
+
totalVerdicts: aggregation.totalVerdicts,
|
|
3611
|
+
},
|
|
3612
|
+
graduation: {
|
|
3613
|
+
eligible: graduation.eligible.length,
|
|
3614
|
+
ineligible: graduation.ineligible.length,
|
|
3615
|
+
candidates: graduation.eligible.map((c) => ({
|
|
3616
|
+
patternId: c.patternId,
|
|
3617
|
+
ruleId: c.ruleId,
|
|
3618
|
+
dismissals: c.dismissals,
|
|
3619
|
+
precision: c.precision,
|
|
3620
|
+
avgConfidence: c.avgConfidence,
|
|
3621
|
+
})),
|
|
3622
|
+
},
|
|
3623
|
+
codegen: codegen.map((c) => ({
|
|
3624
|
+
patternId: c.patternId,
|
|
3625
|
+
type: c.type,
|
|
3626
|
+
})),
|
|
3627
|
+
}, null, 2));
|
|
3628
|
+
}
|
|
3629
|
+
else {
|
|
3630
|
+
// Human-readable output
|
|
3631
|
+
console.log(formatAggregationReport(aggregation));
|
|
3632
|
+
console.log('\n' + formatGraduationReport(graduation));
|
|
3633
|
+
if (codegen.length > 0) {
|
|
3634
|
+
console.log('\n' + formatCodegenReport(codegen));
|
|
3635
|
+
}
|
|
3636
|
+
console.log(`\n📊 Summary:`);
|
|
3637
|
+
console.log(` Patterns analyzed: ${aggregation.patterns.size}`);
|
|
3638
|
+
console.log(` Eligible for graduation: ${graduation.eligible.length}`);
|
|
3639
|
+
console.log(` Code snippets generated: ${codegen.length}`);
|
|
3640
|
+
if (options.dryRun) {
|
|
3641
|
+
console.log('\n (dry-run mode — no changes applied)\n');
|
|
3642
|
+
}
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3645
|
+
catch (error) {
|
|
3646
|
+
if (error.code === 'MODULE_NOT_FOUND' || error.message?.includes('no such file')) {
|
|
3647
|
+
console.error('⚠️ No verdict logs found. Run scans with AI Review Board first to generate graduation data.');
|
|
3648
|
+
console.error(' Usage: runhalo scan . --review-board --license-key <key>');
|
|
3649
|
+
}
|
|
3650
|
+
else {
|
|
3651
|
+
console.error('❌ Error:', error.message || error);
|
|
3652
|
+
}
|
|
3653
|
+
process.exit(3);
|
|
3654
|
+
}
|
|
3655
|
+
});
|
|
2831
3656
|
// Run CLI
|
|
2832
3657
|
if (require.main === module) {
|
|
2833
3658
|
program.parse();
|