@vibecheckai/cli 3.6.1 → 3.8.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/README.md +135 -63
- package/bin/_deprecations.js +447 -19
- package/bin/_router.js +1 -1
- package/bin/registry.js +347 -280
- package/bin/runners/context/generators/cursor-enhanced.js +2439 -0
- package/bin/runners/lib/agent-firewall/enforcement/gateway.js +1059 -0
- package/bin/runners/lib/agent-firewall/enforcement/index.js +98 -0
- package/bin/runners/lib/agent-firewall/enforcement/mode.js +318 -0
- package/bin/runners/lib/agent-firewall/enforcement/orchestrator.js +484 -0
- package/bin/runners/lib/agent-firewall/enforcement/proof-artifact.js +418 -0
- package/bin/runners/lib/agent-firewall/enforcement/schemas/change-event.schema.json +173 -0
- package/bin/runners/lib/agent-firewall/enforcement/schemas/intent.schema.json +181 -0
- package/bin/runners/lib/agent-firewall/enforcement/schemas/verdict.schema.json +222 -0
- package/bin/runners/lib/agent-firewall/enforcement/verdict-v2.js +333 -0
- package/bin/runners/lib/agent-firewall/index.js +200 -0
- package/bin/runners/lib/agent-firewall/integration/index.js +20 -0
- package/bin/runners/lib/agent-firewall/integration/ship-gate.js +437 -0
- package/bin/runners/lib/agent-firewall/intent/alignment-engine.js +622 -0
- package/bin/runners/lib/agent-firewall/intent/auto-detect.js +426 -0
- package/bin/runners/lib/agent-firewall/intent/index.js +102 -0
- package/bin/runners/lib/agent-firewall/intent/schema.js +352 -0
- package/bin/runners/lib/agent-firewall/intent/store.js +283 -0
- package/bin/runners/lib/agent-firewall/interception/fs-interceptor.js +502 -0
- package/bin/runners/lib/agent-firewall/interception/index.js +23 -0
- package/bin/runners/lib/agent-firewall/policy/rules/fake-success.js +31 -38
- package/bin/runners/lib/agent-firewall/policy/rules/ghost-env.js +68 -3
- package/bin/runners/lib/agent-firewall/policy/rules/ghost-route.js +4 -2
- package/bin/runners/lib/agent-firewall/risk/thresholds.js +5 -4
- package/bin/runners/lib/agent-firewall/session/collector.js +451 -0
- package/bin/runners/lib/agent-firewall/session/index.js +26 -0
- package/bin/runners/lib/artifact-envelope.js +540 -0
- package/bin/runners/lib/auth-shared.js +977 -0
- package/bin/runners/lib/checkpoint.js +941 -0
- package/bin/runners/lib/cleanup/engine.js +571 -0
- package/bin/runners/lib/cleanup/index.js +53 -0
- package/bin/runners/lib/cleanup/output.js +375 -0
- package/bin/runners/lib/cleanup/rules.js +1060 -0
- package/bin/runners/lib/doctor/diagnosis-receipt.js +454 -0
- package/bin/runners/lib/doctor/failure-signatures.js +526 -0
- package/bin/runners/lib/doctor/fix-script.js +336 -0
- package/bin/runners/lib/doctor/modules/build-tools.js +453 -0
- package/bin/runners/lib/doctor/modules/index.js +62 -3
- package/bin/runners/lib/doctor/modules/os-quirks.js +706 -0
- package/bin/runners/lib/doctor/modules/repo-integrity.js +485 -0
- package/bin/runners/lib/doctor/safe-repair.js +384 -0
- package/bin/runners/lib/engines/attack-detector.js +1192 -0
- package/bin/runners/lib/entitlements-v2.js +2 -2
- package/bin/runners/lib/error-messages.js +1 -1
- package/bin/runners/lib/missions/briefing.js +427 -0
- package/bin/runners/lib/missions/checkpoint.js +753 -0
- package/bin/runners/lib/missions/hardening.js +851 -0
- package/bin/runners/lib/missions/plan.js +421 -32
- package/bin/runners/lib/missions/safety-gates.js +645 -0
- package/bin/runners/lib/missions/schema.js +478 -0
- package/bin/runners/lib/packs/bundle.js +675 -0
- package/bin/runners/lib/packs/evidence-pack.js +671 -0
- package/bin/runners/lib/packs/pack-factory.js +837 -0
- package/bin/runners/lib/packs/permissions-pack.js +686 -0
- package/bin/runners/lib/packs/proof-graph-pack.js +779 -0
- package/bin/runners/lib/report-output.js +6 -6
- package/bin/runners/lib/safelist/index.js +96 -0
- package/bin/runners/lib/safelist/integration.js +334 -0
- package/bin/runners/lib/safelist/matcher.js +696 -0
- package/bin/runners/lib/safelist/schema.js +948 -0
- package/bin/runners/lib/safelist/store.js +438 -0
- package/bin/runners/lib/schemas/ship-manifest.schema.json +251 -0
- package/bin/runners/lib/ship-gate.js +832 -0
- package/bin/runners/lib/ship-manifest.js +1153 -0
- package/bin/runners/lib/ship-output.js +1 -1
- package/bin/runners/lib/unified-cli-output.js +710 -383
- package/bin/runners/lib/upsell.js +3 -3
- package/bin/runners/lib/why-tree.js +650 -0
- package/bin/runners/runAllowlist.js +33 -4
- package/bin/runners/runApprove.js +240 -1122
- package/bin/runners/runAudit.js +692 -0
- package/bin/runners/runAuth.js +325 -29
- package/bin/runners/runCheckpoint.js +442 -494
- package/bin/runners/runCleanup.js +343 -0
- package/bin/runners/runDoctor.js +269 -19
- package/bin/runners/runFix.js +411 -32
- package/bin/runners/runForge.js +411 -0
- package/bin/runners/runIntent.js +906 -0
- package/bin/runners/runKickoff.js +878 -0
- package/bin/runners/runLaunch.js +2000 -0
- package/bin/runners/runLink.js +785 -0
- package/bin/runners/runMcp.js +1741 -837
- package/bin/runners/runPacks.js +2089 -0
- package/bin/runners/runPolish.js +41 -0
- package/bin/runners/runSafelist.js +1190 -0
- package/bin/runners/runScan.js +21 -9
- package/bin/runners/runShield.js +1282 -0
- package/bin/runners/runShip.js +395 -16
- package/bin/vibecheck.js +34 -6
- package/mcp-server/README.md +117 -158
- package/mcp-server/handlers/tool-handler.ts +3 -3
- package/mcp-server/index.js +16 -0
- package/mcp-server/intent-firewall-interceptor.js +529 -0
- package/mcp-server/manifest.json +473 -0
- package/mcp-server/package.json +1 -1
- package/mcp-server/registry/tool-registry.js +315 -523
- package/mcp-server/registry/tools.json +442 -428
- package/mcp-server/tier-auth.js +164 -16
- package/mcp-server/tools-v3.js +70 -16
- package/package.json +1 -1
- package/bin/runners/runProof.zip +0 -0
|
@@ -0,0 +1,1190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vibecheck safelist - Responsible Finding Suppression
|
|
3
|
+
*
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* A SCALPEL, NOT A TRASH CAN
|
|
6
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
7
|
+
*
|
|
8
|
+
* Let teams suppress known findings responsibly with:
|
|
9
|
+
* - Required justification with category
|
|
10
|
+
* - Owner accountability
|
|
11
|
+
* - Optional expiration (enforced for some categories)
|
|
12
|
+
* - Audit trail
|
|
13
|
+
* - Repo-wide vs local-only scopes
|
|
14
|
+
* - Suppressed findings reported separately
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* vibecheck safelist # List all entries
|
|
18
|
+
* vibecheck safelist add # Add entry (interactive)
|
|
19
|
+
* vibecheck safelist add --id <id> ... # Add entry (CLI)
|
|
20
|
+
* vibecheck safelist remove --id <id> # Remove entry
|
|
21
|
+
* vibecheck safelist check --id <id> # Check if suppressed
|
|
22
|
+
* vibecheck safelist report # Suppression report
|
|
23
|
+
* vibecheck safelist migrate # Migrate legacy allowlist
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
"use strict";
|
|
27
|
+
|
|
28
|
+
const fs = require("fs");
|
|
29
|
+
const path = require("path");
|
|
30
|
+
const safelist = require("./lib/safelist");
|
|
31
|
+
const { EXIT } = require("./lib/exit-codes");
|
|
32
|
+
const { parseGlobalFlags, shouldSuppressOutput, isJsonMode } = require("./lib/global-flags");
|
|
33
|
+
|
|
34
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
35
|
+
// UNIFIED CLI OUTPUT
|
|
36
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
37
|
+
|
|
38
|
+
let _cliOutput = null;
|
|
39
|
+
function getCliOutput() {
|
|
40
|
+
if (!_cliOutput) _cliOutput = require("./lib/unified-cli-output");
|
|
41
|
+
return _cliOutput;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
45
|
+
// TERMINAL STYLING
|
|
46
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
47
|
+
|
|
48
|
+
const SUPPORTS_COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
49
|
+
|
|
50
|
+
// Use unified CLI output when available, fallback to local
|
|
51
|
+
let c, icons;
|
|
52
|
+
try {
|
|
53
|
+
const cli = getCliOutput();
|
|
54
|
+
c = cli.ansi;
|
|
55
|
+
icons = cli.sym;
|
|
56
|
+
} catch {
|
|
57
|
+
c = SUPPORTS_COLOR ? {
|
|
58
|
+
reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", italic: "\x1b[3m",
|
|
59
|
+
red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m",
|
|
60
|
+
magenta: "\x1b[35m", cyan: "\x1b[36m",
|
|
61
|
+
bgRed: "\x1b[41m", bgGreen: "\x1b[42m", bgYellow: "\x1b[43m",
|
|
62
|
+
} : {
|
|
63
|
+
reset: "", bold: "", dim: "", italic: "",
|
|
64
|
+
red: "", green: "", yellow: "", blue: "", magenta: "", cyan: "",
|
|
65
|
+
bgRed: "", bgGreen: "", bgYellow: "",
|
|
66
|
+
};
|
|
67
|
+
icons = {
|
|
68
|
+
check: "✓", cross: "✗", warning: "⚠", info: "ℹ",
|
|
69
|
+
add: "+", remove: "-", list: "📋", shield: "🛡️",
|
|
70
|
+
clock: "⏱️", person: "👤", lock: "🔒", unlock: "🔓",
|
|
71
|
+
expired: "⌛", review: "👁️",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
76
|
+
// HELP
|
|
77
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
78
|
+
|
|
79
|
+
function printHelp(projectRoot = process.cwd()) {
|
|
80
|
+
const categories = Object.entries(safelist.JUSTIFICATION_CATEGORIES)
|
|
81
|
+
.map(([key, val]) => ` ${c.cyan}${key.padEnd(16)}${c.reset} ${val.description}`)
|
|
82
|
+
.join("\n");
|
|
83
|
+
|
|
84
|
+
// Print unified header
|
|
85
|
+
try {
|
|
86
|
+
const cli = getCliOutput();
|
|
87
|
+
cli.renderMinimalHeader("safelist", "free");
|
|
88
|
+
} catch {
|
|
89
|
+
console.log(`\n${c.bold}${icons.shield || "🛡️"} vibecheck safelist${c.reset}\n`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(`${c.bold}SAFELIST${c.reset} - Responsible Finding Suppression
|
|
93
|
+
|
|
94
|
+
${c.dim}A scalpel, not a trash can. Suppress findings responsibly with
|
|
95
|
+
required justification, owner accountability, and optional expiration.${c.reset}
|
|
96
|
+
|
|
97
|
+
${c.bold}USAGE${c.reset}
|
|
98
|
+
vibecheck safelist <action> [options]
|
|
99
|
+
|
|
100
|
+
${c.bold}ACTIONS${c.reset}
|
|
101
|
+
${c.cyan}list${c.reset} List all safelist entries ${c.dim}(default)${c.reset}
|
|
102
|
+
${c.cyan}add${c.reset} Add a new entry (requires justification)
|
|
103
|
+
${c.cyan}remove${c.reset} Remove an entry
|
|
104
|
+
${c.cyan}check${c.reset} Check if a finding is suppressed
|
|
105
|
+
${c.cyan}search${c.reset} Search entries by pattern, owner, file, etc.
|
|
106
|
+
${c.cyan}report${c.reset} Show suppression health report
|
|
107
|
+
${c.cyan}stats${c.reset} Show detailed statistics
|
|
108
|
+
${c.cyan}migrate${c.reset} Migrate legacy allowlist.json
|
|
109
|
+
${c.cyan}validate${c.reset} Validate safelist files
|
|
110
|
+
${c.cyan}clean${c.reset} Remove expired entries
|
|
111
|
+
|
|
112
|
+
${c.bold}ADD OPTIONS${c.reset}
|
|
113
|
+
${c.cyan}--id <finding-id>${c.reset} Specific finding ID to suppress
|
|
114
|
+
${c.cyan}--pattern <regex>${c.reset} Pattern to match (message, file, etc.)
|
|
115
|
+
${c.cyan}--rule <rule-id>${c.reset} Rule ID to suppress everywhere
|
|
116
|
+
${c.cyan}--file <path>${c.reset} File path (supports globs)
|
|
117
|
+
${c.cyan}--lines <start-end>${c.reset} Line range (e.g., "10-20")
|
|
118
|
+
|
|
119
|
+
${c.cyan}--reason <text>${c.reset} ${c.yellow}REQUIRED${c.reset} - Why this is suppressed
|
|
120
|
+
${c.cyan}--category <cat>${c.reset} ${c.yellow}REQUIRED${c.reset} - Justification category (see below)
|
|
121
|
+
${c.cyan}--ticket <url>${c.reset} Link to issue/ticket (required for some categories)
|
|
122
|
+
|
|
123
|
+
${c.cyan}--owner <name>${c.reset} ${c.yellow}REQUIRED${c.reset} - Person/team responsible
|
|
124
|
+
${c.cyan}--email <email>${c.reset} Owner contact email
|
|
125
|
+
${c.cyan}--team <team>${c.reset} Team name
|
|
126
|
+
|
|
127
|
+
${c.cyan}--scope <type>${c.reset} Scope: repo, local ${c.dim}(default: repo)${c.reset}
|
|
128
|
+
${c.cyan}--expires <days>${c.reset} Auto-expire after N days
|
|
129
|
+
${c.cyan}--review <days>${c.reset} Schedule review after N days
|
|
130
|
+
|
|
131
|
+
${c.bold}GLOBAL OPTIONS${c.reset}
|
|
132
|
+
${c.cyan}--json${c.reset} Output as JSON (for CI/automation)
|
|
133
|
+
${c.cyan}--verbose, -v${c.reset} Show detailed information
|
|
134
|
+
${c.cyan}--dry-run${c.reset} Preview changes without applying
|
|
135
|
+
${c.cyan}--no-strict${c.reset} Disable strict validation warnings
|
|
136
|
+
${c.cyan}--path <dir>${c.reset} Project root ${c.dim}(default: cwd)${c.reset}
|
|
137
|
+
|
|
138
|
+
${c.bold}JUSTIFICATION CATEGORIES${c.reset}
|
|
139
|
+
${categories}
|
|
140
|
+
|
|
141
|
+
${c.bold}EXAMPLES${c.reset}
|
|
142
|
+
|
|
143
|
+
${c.dim}# Suppress a specific finding (false positive)${c.reset}
|
|
144
|
+
vibecheck safelist add \\
|
|
145
|
+
--id MOCK_DATA_abc123 \\
|
|
146
|
+
--reason "Test fixture data, not production code" \\
|
|
147
|
+
--category false-positive \\
|
|
148
|
+
--owner "Jane Developer"
|
|
149
|
+
|
|
150
|
+
${c.dim}# Suppress a pattern in test files${c.reset}
|
|
151
|
+
vibecheck safelist add \\
|
|
152
|
+
--pattern "lorem ipsum" \\
|
|
153
|
+
--file "**/*.test.ts" \\
|
|
154
|
+
--reason "Placeholder text in tests" \\
|
|
155
|
+
--category test-fixture \\
|
|
156
|
+
--owner "QA Team"
|
|
157
|
+
|
|
158
|
+
${c.dim}# Accept risk for legacy code with expiration${c.reset}
|
|
159
|
+
vibecheck safelist add \\
|
|
160
|
+
--pattern "vulnerable-function" \\
|
|
161
|
+
--reason "Legacy code pending refactor - tracked in JIRA-123" \\
|
|
162
|
+
--category accepted-risk \\
|
|
163
|
+
--ticket "https://jira.example.com/JIRA-123" \\
|
|
164
|
+
--owner "Security Team" \\
|
|
165
|
+
--expires 90
|
|
166
|
+
|
|
167
|
+
${c.dim}# Local-only suppression (not committed)${c.reset}
|
|
168
|
+
vibecheck safelist add \\
|
|
169
|
+
--id DEV_xyz \\
|
|
170
|
+
--reason "Local dev environment artifact" \\
|
|
171
|
+
--category temporary \\
|
|
172
|
+
--owner "Me" \\
|
|
173
|
+
--scope local \\
|
|
174
|
+
--expires 7
|
|
175
|
+
|
|
176
|
+
${c.dim}# Show suppression health report${c.reset}
|
|
177
|
+
vibecheck safelist report
|
|
178
|
+
|
|
179
|
+
${c.dim}# Clean up expired entries${c.reset}
|
|
180
|
+
vibecheck safelist clean
|
|
181
|
+
|
|
182
|
+
${c.bold}SCOPE TYPES${c.reset}
|
|
183
|
+
${c.cyan}repo${c.reset} Committed to version control, applies to everyone
|
|
184
|
+
${c.cyan}local${c.reset} Machine-specific, .gitignored
|
|
185
|
+
|
|
186
|
+
${c.bold}ENFORCEMENT${c.reset}
|
|
187
|
+
Safelist is checked by: ${safelist.SAFELIST_COMMANDS.map(cmd => c.cyan + cmd + c.reset).join(", ")}
|
|
188
|
+
Suppressed findings are reported separately (not hidden).
|
|
189
|
+
`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
193
|
+
// ARGUMENT PARSING
|
|
194
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Check for conflicts with existing entries
|
|
198
|
+
*/
|
|
199
|
+
function checkForConflicts(newEntry, existingSafelist) {
|
|
200
|
+
const conflicts = [];
|
|
201
|
+
|
|
202
|
+
if (!existingSafelist?.entries) return conflicts;
|
|
203
|
+
|
|
204
|
+
for (const existing of existingSafelist.entries) {
|
|
205
|
+
// Same finding ID
|
|
206
|
+
if (newEntry.target?.findingId && existing.target?.findingId === newEntry.target.findingId) {
|
|
207
|
+
conflicts.push({
|
|
208
|
+
id: existing.id,
|
|
209
|
+
type: "duplicate",
|
|
210
|
+
reason: `Same finding ID: ${newEntry.target.findingId}`,
|
|
211
|
+
});
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Same rule ID
|
|
216
|
+
if (newEntry.target?.ruleId && existing.target?.ruleId === newEntry.target.ruleId) {
|
|
217
|
+
conflicts.push({
|
|
218
|
+
id: existing.id,
|
|
219
|
+
type: "duplicate",
|
|
220
|
+
reason: `Same rule ID: ${newEntry.target.ruleId}`,
|
|
221
|
+
});
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Overlapping patterns
|
|
226
|
+
if (newEntry.target?.pattern && existing.target?.pattern) {
|
|
227
|
+
if (safelist.patternsOverlap(newEntry.target.pattern, existing.target.pattern)) {
|
|
228
|
+
conflicts.push({
|
|
229
|
+
id: existing.id,
|
|
230
|
+
type: "overlap",
|
|
231
|
+
reason: `Overlapping pattern with: ${existing.target.pattern}`,
|
|
232
|
+
});
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Same file (without different line ranges)
|
|
238
|
+
if (newEntry.target?.file && existing.target?.file) {
|
|
239
|
+
if (safelist.filesOverlap(newEntry.target.file, existing.target.file)) {
|
|
240
|
+
// Only conflict if line ranges overlap or are not specified
|
|
241
|
+
if (!newEntry.target.lines || !existing.target.lines ||
|
|
242
|
+
rangesOverlap(newEntry.target.lines, existing.target.lines)) {
|
|
243
|
+
conflicts.push({
|
|
244
|
+
id: existing.id,
|
|
245
|
+
type: "overlap",
|
|
246
|
+
reason: `Overlapping file scope: ${existing.target.file}`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return conflicts;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Check if two line ranges overlap
|
|
258
|
+
*/
|
|
259
|
+
function rangesOverlap(range1, range2) {
|
|
260
|
+
if (!range1 || !range2) return true; // No range = all lines = overlap
|
|
261
|
+
return range1[0] <= range2[1] && range2[0] <= range1[1];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function parseArgs(args) {
|
|
265
|
+
const opts = {
|
|
266
|
+
action: "list",
|
|
267
|
+
// Target
|
|
268
|
+
id: null,
|
|
269
|
+
pattern: null,
|
|
270
|
+
rule: null,
|
|
271
|
+
file: null,
|
|
272
|
+
lines: null,
|
|
273
|
+
// Justification
|
|
274
|
+
reason: null,
|
|
275
|
+
category: null,
|
|
276
|
+
ticket: null,
|
|
277
|
+
// Owner
|
|
278
|
+
owner: null,
|
|
279
|
+
email: null,
|
|
280
|
+
team: null,
|
|
281
|
+
// Scope & lifecycle
|
|
282
|
+
scope: "repo",
|
|
283
|
+
expires: null,
|
|
284
|
+
review: null,
|
|
285
|
+
commands: null,
|
|
286
|
+
// General
|
|
287
|
+
help: false,
|
|
288
|
+
json: false,
|
|
289
|
+
verbose: false,
|
|
290
|
+
dryRun: false,
|
|
291
|
+
strict: true,
|
|
292
|
+
path: process.cwd(),
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
for (let i = 0; i < args.length; i++) {
|
|
296
|
+
const arg = args[i];
|
|
297
|
+
const next = args[i + 1];
|
|
298
|
+
|
|
299
|
+
// Flags
|
|
300
|
+
if (arg === "--help" || arg === "-h") opts.help = true;
|
|
301
|
+
else if (arg === "--json") opts.json = true;
|
|
302
|
+
else if (arg === "--verbose" || arg === "-v") opts.verbose = true;
|
|
303
|
+
else if (arg === "--dry-run" || arg === "--preview") opts.dryRun = true;
|
|
304
|
+
else if (arg === "--no-strict") opts.strict = false;
|
|
305
|
+
// Target
|
|
306
|
+
else if (arg === "--id") opts.id = args[++i];
|
|
307
|
+
else if (arg === "--pattern") opts.pattern = args[++i];
|
|
308
|
+
else if (arg === "--rule") opts.rule = args[++i];
|
|
309
|
+
else if (arg === "--file" || arg === "-f") opts.file = args[++i];
|
|
310
|
+
else if (arg === "--lines") opts.lines = args[++i];
|
|
311
|
+
// Justification
|
|
312
|
+
else if (arg === "--reason" || arg === "-r") opts.reason = args[++i];
|
|
313
|
+
else if (arg === "--category" || arg === "-c") opts.category = args[++i];
|
|
314
|
+
else if (arg === "--ticket" || arg === "-t") opts.ticket = args[++i];
|
|
315
|
+
// Owner
|
|
316
|
+
else if (arg === "--owner" || arg === "-o") opts.owner = args[++i];
|
|
317
|
+
else if (arg === "--email") opts.email = args[++i];
|
|
318
|
+
else if (arg === "--team") opts.team = args[++i];
|
|
319
|
+
// Scope & lifecycle
|
|
320
|
+
else if (arg === "--scope" || arg === "-s") opts.scope = args[++i];
|
|
321
|
+
else if (arg === "--expires" || arg === "-e") opts.expires = parseInt(args[++i], 10);
|
|
322
|
+
else if (arg === "--review") opts.review = parseInt(args[++i], 10);
|
|
323
|
+
else if (arg === "--commands") opts.commands = args[++i].split(",");
|
|
324
|
+
else if (arg === "--path" || arg === "-p") opts.path = args[++i];
|
|
325
|
+
// Action (positional)
|
|
326
|
+
else if (!arg.startsWith("-") && !opts.action || opts.action === "list") {
|
|
327
|
+
if (["list", "add", "remove", "check", "report", "migrate", "validate", "clean", "preview", "search", "stats"].includes(arg)) {
|
|
328
|
+
opts.action = arg;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return opts;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
337
|
+
// ACTIONS
|
|
338
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
339
|
+
|
|
340
|
+
async function actionList(projectRoot, opts) {
|
|
341
|
+
const { safelist: sl, warnings, errors } = safelist.loadSafelist(projectRoot);
|
|
342
|
+
|
|
343
|
+
if (opts.json) {
|
|
344
|
+
console.log(JSON.stringify({
|
|
345
|
+
entries: sl.entries,
|
|
346
|
+
warnings,
|
|
347
|
+
errors,
|
|
348
|
+
stats: {
|
|
349
|
+
total: sl.entries.length,
|
|
350
|
+
repo: sl.entries.filter(e => e._source === "repo").length,
|
|
351
|
+
local: sl.entries.filter(e => e._source === "local").length,
|
|
352
|
+
expired: safelist.getExpired(sl).length,
|
|
353
|
+
expiringSoon: safelist.getExpiringSoon(sl).length,
|
|
354
|
+
},
|
|
355
|
+
}, null, 2));
|
|
356
|
+
return EXIT.SUCCESS;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
console.log(`\n${c.bold}${icons.list} Safelist Entries${c.reset}\n`);
|
|
360
|
+
|
|
361
|
+
if (errors.length > 0) {
|
|
362
|
+
for (const err of errors) {
|
|
363
|
+
console.log(` ${c.red}${icons.cross}${c.reset} ${err}`);
|
|
364
|
+
}
|
|
365
|
+
console.log();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (warnings.length > 0) {
|
|
369
|
+
for (const warn of warnings) {
|
|
370
|
+
console.log(` ${c.yellow}${icons.warning}${c.reset} ${warn}`);
|
|
371
|
+
}
|
|
372
|
+
console.log();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Filter out expired
|
|
376
|
+
const now = Date.now();
|
|
377
|
+
const active = sl.entries.filter(e => {
|
|
378
|
+
if (!e.lifecycle?.expiresAt) return true;
|
|
379
|
+
return new Date(e.lifecycle.expiresAt).getTime() > now;
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
if (active.length === 0) {
|
|
383
|
+
console.log(` ${c.dim}No active entries in safelist.${c.reset}`);
|
|
384
|
+
console.log(` ${c.dim}Add entries with: vibecheck safelist add --id <id> --reason "..." --category <cat> --owner <name>${c.reset}\n`);
|
|
385
|
+
return EXIT.SUCCESS;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Group by source
|
|
389
|
+
const bySource = { repo: [], local: [] };
|
|
390
|
+
for (const entry of active) {
|
|
391
|
+
(bySource[entry._source] || bySource.repo).push(entry);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
for (const [source, entries] of Object.entries(bySource)) {
|
|
395
|
+
if (entries.length === 0) continue;
|
|
396
|
+
|
|
397
|
+
const scopeIcon = source === "local" ? icons.unlock : icons.lock;
|
|
398
|
+
console.log(`${c.bold}${scopeIcon} ${source.toUpperCase()} SCOPE${c.reset} ${c.dim}(${entries.length})${c.reset}\n`);
|
|
399
|
+
|
|
400
|
+
for (const entry of entries) {
|
|
401
|
+
printEntry(entry, opts.verbose);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Summary
|
|
406
|
+
const expired = safelist.getExpired(sl);
|
|
407
|
+
const expiringSoon = safelist.getExpiringSoon(sl);
|
|
408
|
+
const dueForReview = safelist.getDueForReview(sl);
|
|
409
|
+
|
|
410
|
+
console.log(`${c.dim}${"─".repeat(60)}${c.reset}`);
|
|
411
|
+
console.log(` Total: ${active.length} active, ${expired.length} expired`);
|
|
412
|
+
if (expiringSoon.length > 0) {
|
|
413
|
+
console.log(` ${c.yellow}${icons.clock}${c.reset} ${expiringSoon.length} expiring soon (7 days)`);
|
|
414
|
+
}
|
|
415
|
+
if (dueForReview.length > 0) {
|
|
416
|
+
console.log(` ${c.yellow}${icons.review}${c.reset} ${dueForReview.length} due for review`);
|
|
417
|
+
}
|
|
418
|
+
console.log();
|
|
419
|
+
|
|
420
|
+
return EXIT.SUCCESS;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function printEntry(entry, verbose = false) {
|
|
424
|
+
const cat = safelist.JUSTIFICATION_CATEGORIES[entry.justification?.category];
|
|
425
|
+
const catBadge = cat ? `${c.cyan}[${entry.justification.category}]${c.reset}` : "";
|
|
426
|
+
|
|
427
|
+
// Expiry indicator
|
|
428
|
+
let expiryStr = "";
|
|
429
|
+
if (entry.lifecycle?.expiresAt) {
|
|
430
|
+
const expiresAt = new Date(entry.lifecycle.expiresAt);
|
|
431
|
+
const daysLeft = Math.ceil((expiresAt - Date.now()) / (1000 * 60 * 60 * 24));
|
|
432
|
+
if (daysLeft <= 7) {
|
|
433
|
+
expiryStr = `${c.yellow}${icons.clock} ${daysLeft}d${c.reset}`;
|
|
434
|
+
} else {
|
|
435
|
+
expiryStr = `${c.dim}expires ${expiresAt.toLocaleDateString()}${c.reset}`;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
console.log(` ${c.green}${icons.check}${c.reset} ${c.bold}${entry.id}${c.reset} ${catBadge} ${expiryStr}`);
|
|
440
|
+
|
|
441
|
+
// Target
|
|
442
|
+
if (entry.target?.findingId) {
|
|
443
|
+
console.log(` ${c.dim}Finding:${c.reset} ${entry.target.findingId}`);
|
|
444
|
+
}
|
|
445
|
+
if (entry.target?.pattern) {
|
|
446
|
+
console.log(` ${c.dim}Pattern:${c.reset} ${entry.target.pattern}`);
|
|
447
|
+
}
|
|
448
|
+
if (entry.target?.ruleId) {
|
|
449
|
+
console.log(` ${c.dim}Rule:${c.reset} ${entry.target.ruleId}`);
|
|
450
|
+
}
|
|
451
|
+
if (entry.target?.file) {
|
|
452
|
+
console.log(` ${c.dim}File:${c.reset} ${entry.target.file}${entry.target.lines ? `:${entry.target.lines.join("-")}` : ""}`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Justification
|
|
456
|
+
console.log(` ${c.dim}Reason:${c.reset} ${entry.justification?.reason || "No reason"}`);
|
|
457
|
+
if (entry.justification?.ticket) {
|
|
458
|
+
console.log(` ${c.dim}Ticket:${c.reset} ${entry.justification.ticket}`);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Owner
|
|
462
|
+
console.log(` ${c.dim}Owner:${c.reset} ${entry.owner?.name || "Unknown"}${entry.owner?.team ? ` (${entry.owner.team})` : ""}`);
|
|
463
|
+
|
|
464
|
+
if (verbose) {
|
|
465
|
+
console.log(` ${c.dim}Created:${c.reset} ${entry.lifecycle?.createdAt} by ${entry.lifecycle?.createdBy || "unknown"}`);
|
|
466
|
+
console.log(` ${c.dim}Matches:${c.reset} ${entry.lifecycle?.matchCount || 0}`);
|
|
467
|
+
if (entry.lifecycle?.lastMatchedAt) {
|
|
468
|
+
console.log(` ${c.dim}Last match:${c.reset} ${entry.lifecycle.lastMatchedAt}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
console.log();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function actionAdd(projectRoot, opts) {
|
|
476
|
+
// Validate required fields
|
|
477
|
+
const missingFields = [];
|
|
478
|
+
|
|
479
|
+
if (!opts.id && !opts.pattern && !opts.rule && !opts.file) {
|
|
480
|
+
missingFields.push("target (--id, --pattern, --rule, or --file)");
|
|
481
|
+
}
|
|
482
|
+
if (!opts.reason || opts.reason.length < 10) {
|
|
483
|
+
missingFields.push("--reason (min 10 characters)");
|
|
484
|
+
}
|
|
485
|
+
if (!opts.category) {
|
|
486
|
+
missingFields.push("--category");
|
|
487
|
+
} else if (!safelist.JUSTIFICATION_CATEGORIES[opts.category]) {
|
|
488
|
+
console.error(`\n ${c.red}${icons.cross}${c.reset} Invalid category: ${opts.category}`);
|
|
489
|
+
console.error(` ${c.dim}Valid categories: ${Object.keys(safelist.JUSTIFICATION_CATEGORIES).join(", ")}${c.reset}\n`);
|
|
490
|
+
return EXIT.USER_ERROR;
|
|
491
|
+
}
|
|
492
|
+
if (!opts.owner) {
|
|
493
|
+
missingFields.push("--owner");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (missingFields.length > 0) {
|
|
497
|
+
console.error(`\n ${c.red}${icons.cross}${c.reset} Missing required fields:`);
|
|
498
|
+
for (const field of missingFields) {
|
|
499
|
+
console.error(` ${c.yellow}•${c.reset} ${field}`);
|
|
500
|
+
}
|
|
501
|
+
console.error(`\n ${c.dim}Run 'vibecheck safelist --help' for usage${c.reset}\n`);
|
|
502
|
+
return EXIT.USER_ERROR;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Check if category requires ticket
|
|
506
|
+
const category = safelist.JUSTIFICATION_CATEGORIES[opts.category];
|
|
507
|
+
if (category.requiresTicket && !opts.ticket) {
|
|
508
|
+
console.error(`\n ${c.red}${icons.cross}${c.reset} Category '${opts.category}' requires --ticket`);
|
|
509
|
+
console.error(` ${c.dim}${category.description}${c.reset}\n`);
|
|
510
|
+
return EXIT.USER_ERROR;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Check expiry constraints
|
|
514
|
+
if (category.maxExpiry) {
|
|
515
|
+
if (!opts.expires) {
|
|
516
|
+
console.error(`\n ${c.red}${icons.cross}${c.reset} Category '${opts.category}' requires --expires (max ${category.maxExpiry} days)`);
|
|
517
|
+
return EXIT.USER_ERROR;
|
|
518
|
+
}
|
|
519
|
+
if (opts.expires > category.maxExpiry) {
|
|
520
|
+
console.error(`\n ${c.red}${icons.cross}${c.reset} Category '${opts.category}' max expiry is ${category.maxExpiry} days`);
|
|
521
|
+
return EXIT.USER_ERROR;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Determine entry type
|
|
526
|
+
let type = "finding";
|
|
527
|
+
if (opts.pattern) type = "pattern";
|
|
528
|
+
else if (opts.rule) type = "rule";
|
|
529
|
+
else if (opts.file && !opts.id) type = "file";
|
|
530
|
+
|
|
531
|
+
// Parse lines
|
|
532
|
+
let lines = null;
|
|
533
|
+
if (opts.lines) {
|
|
534
|
+
const parts = opts.lines.split("-").map(n => parseInt(n, 10));
|
|
535
|
+
lines = [parts[0], parts[1] || parts[0]];
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Create entry
|
|
539
|
+
const entry = safelist.createEntry({
|
|
540
|
+
type,
|
|
541
|
+
findingId: opts.id,
|
|
542
|
+
pattern: opts.pattern,
|
|
543
|
+
ruleId: opts.rule,
|
|
544
|
+
file: opts.file,
|
|
545
|
+
lines,
|
|
546
|
+
reason: opts.reason,
|
|
547
|
+
category: opts.category,
|
|
548
|
+
ticket: opts.ticket,
|
|
549
|
+
ownerName: opts.owner,
|
|
550
|
+
ownerEmail: opts.email,
|
|
551
|
+
ownerTeam: opts.team,
|
|
552
|
+
scopeType: opts.scope,
|
|
553
|
+
commands: opts.commands,
|
|
554
|
+
expiresIn: opts.expires,
|
|
555
|
+
reviewIn: opts.review,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// Validate
|
|
559
|
+
const validation = safelist.validateEntry(entry);
|
|
560
|
+
if (!validation.valid) {
|
|
561
|
+
console.error(`\n ${c.red}${icons.cross}${c.reset} Validation failed:`);
|
|
562
|
+
for (const err of validation.errors) {
|
|
563
|
+
console.error(` ${c.yellow}•${c.reset} ${err}`);
|
|
564
|
+
}
|
|
565
|
+
return EXIT.USER_ERROR;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Show warnings
|
|
569
|
+
if (validation.warnings && validation.warnings.length > 0 && !opts.json) {
|
|
570
|
+
console.log(`\n ${c.yellow}${icons.warning}${c.reset} Warnings:`);
|
|
571
|
+
for (const warn of validation.warnings) {
|
|
572
|
+
console.log(` ${c.dim}•${c.reset} ${warn}`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Check for duplicate/overlapping entries
|
|
577
|
+
const { safelist: existingSafelist } = safelist.loadSafelist(projectRoot);
|
|
578
|
+
const conflicts = checkForConflicts(entry, existingSafelist);
|
|
579
|
+
|
|
580
|
+
if (conflicts.length > 0 && !opts.json) {
|
|
581
|
+
console.log(`\n ${c.yellow}${icons.warning}${c.reset} Potential conflicts with existing entries:`);
|
|
582
|
+
for (const conflict of conflicts) {
|
|
583
|
+
console.log(` ${c.dim}•${c.reset} ${conflict.id}: ${conflict.reason}`);
|
|
584
|
+
}
|
|
585
|
+
console.log(` ${c.dim}Consider removing duplicates or using more specific patterns.${c.reset}`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Dry-run mode
|
|
589
|
+
if (opts.dryRun) {
|
|
590
|
+
if (opts.json) {
|
|
591
|
+
console.log(JSON.stringify({
|
|
592
|
+
dryRun: true,
|
|
593
|
+
entry,
|
|
594
|
+
validation,
|
|
595
|
+
conflicts,
|
|
596
|
+
}, null, 2));
|
|
597
|
+
} else {
|
|
598
|
+
console.log(`\n ${c.cyan}${icons.info}${c.reset} Dry-run: Would add entry:`);
|
|
599
|
+
printEntry(entry, true);
|
|
600
|
+
}
|
|
601
|
+
return EXIT.SUCCESS;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Save
|
|
605
|
+
const result = safelist.saveEntry(projectRoot, entry, opts.scope);
|
|
606
|
+
|
|
607
|
+
if (!result.success) {
|
|
608
|
+
console.error(`\n ${c.red}${icons.cross}${c.reset} Failed to save: ${result.error}\n`);
|
|
609
|
+
return EXIT.INTERNAL_ERROR;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Ensure local safelist is gitignored
|
|
613
|
+
if (opts.scope === "local") {
|
|
614
|
+
safelist.ensureGitIgnore(projectRoot);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (opts.json) {
|
|
618
|
+
console.log(JSON.stringify({ success: true, entry }, null, 2));
|
|
619
|
+
} else {
|
|
620
|
+
console.log(`\n ${c.green}${icons.add}${c.reset} Added safelist entry: ${c.bold}${entry.id}${c.reset}`);
|
|
621
|
+
console.log(` ${c.dim}Scope: ${opts.scope} | Category: ${opts.category}${c.reset}`);
|
|
622
|
+
if (entry.lifecycle.expiresAt) {
|
|
623
|
+
console.log(` ${c.dim}Expires: ${new Date(entry.lifecycle.expiresAt).toLocaleDateString()}${c.reset}`);
|
|
624
|
+
}
|
|
625
|
+
console.log();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return EXIT.SUCCESS;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function actionRemove(projectRoot, opts) {
|
|
632
|
+
if (!opts.id) {
|
|
633
|
+
console.error(`\n ${c.red}${icons.cross}${c.reset} --id is required for remove\n`);
|
|
634
|
+
return EXIT.USER_ERROR;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const result = safelist.removeEntry(projectRoot, opts.id);
|
|
638
|
+
|
|
639
|
+
if (!result.success) {
|
|
640
|
+
console.error(`\n ${c.red}${icons.cross}${c.reset} ${result.error}\n`);
|
|
641
|
+
return EXIT.INTERNAL_ERROR;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (opts.json) {
|
|
645
|
+
console.log(JSON.stringify({ success: true, removed: result.removed, source: result.source }, null, 2));
|
|
646
|
+
} else if (result.removed) {
|
|
647
|
+
console.log(`\n ${c.green}${icons.remove}${c.reset} Removed entry: ${c.bold}${opts.id}${c.reset}`);
|
|
648
|
+
console.log(` ${c.dim}From: ${result.source} safelist${c.reset}\n`);
|
|
649
|
+
} else {
|
|
650
|
+
console.log(`\n ${c.yellow}${icons.warning}${c.reset} Entry not found: ${opts.id}\n`);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return result.removed ? EXIT.SUCCESS : EXIT.NOT_FOUND;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function actionCheck(projectRoot, opts) {
|
|
657
|
+
if (!opts.id) {
|
|
658
|
+
console.error(`\n ${c.red}${icons.cross}${c.reset} --id is required for check\n`);
|
|
659
|
+
return EXIT.USER_ERROR;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const { safelist: sl } = safelist.loadSafelist(projectRoot);
|
|
663
|
+
const result = safelist.isSuppressed({ id: opts.id }, sl);
|
|
664
|
+
|
|
665
|
+
if (opts.json) {
|
|
666
|
+
console.log(JSON.stringify(result, null, 2));
|
|
667
|
+
} else if (result.suppressed) {
|
|
668
|
+
console.log(`\n ${c.green}${icons.check}${c.reset} ${c.bold}Suppressed${c.reset}`);
|
|
669
|
+
console.log(` ${c.dim}Entry:${c.reset} ${result.entry?.id}`);
|
|
670
|
+
console.log(` ${c.dim}Reason:${c.reset} ${result.reason}`);
|
|
671
|
+
console.log(` ${c.dim}Owner:${c.reset} ${result.entry?.owner?.name || "Unknown"}\n`);
|
|
672
|
+
} else {
|
|
673
|
+
console.log(`\n ${c.yellow}${icons.cross}${c.reset} ${c.bold}Not suppressed${c.reset}\n`);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return result.suppressed ? EXIT.SUCCESS : EXIT.BLOCKING;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function actionReport(projectRoot, opts) {
|
|
680
|
+
const { safelist: sl, warnings, errors } = safelist.loadSafelist(projectRoot);
|
|
681
|
+
|
|
682
|
+
const expired = safelist.getExpired(sl);
|
|
683
|
+
const expiringSoon = safelist.getExpiringSoon(sl);
|
|
684
|
+
const dueForReview = safelist.getDueForReview(sl);
|
|
685
|
+
const unused = safelist.getUnused(sl);
|
|
686
|
+
|
|
687
|
+
// Group by category
|
|
688
|
+
const byCategory = {};
|
|
689
|
+
for (const entry of sl.entries) {
|
|
690
|
+
const cat = entry.justification?.category || "unknown";
|
|
691
|
+
if (!byCategory[cat]) byCategory[cat] = [];
|
|
692
|
+
byCategory[cat].push(entry);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Group by owner
|
|
696
|
+
const byOwner = {};
|
|
697
|
+
for (const entry of sl.entries) {
|
|
698
|
+
const owner = entry.owner?.name || "Unknown";
|
|
699
|
+
if (!byOwner[owner]) byOwner[owner] = [];
|
|
700
|
+
byOwner[owner].push(entry);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (opts.json) {
|
|
704
|
+
console.log(JSON.stringify({
|
|
705
|
+
summary: {
|
|
706
|
+
total: sl.entries.length,
|
|
707
|
+
repo: sl.entries.filter(e => e._source === "repo").length,
|
|
708
|
+
local: sl.entries.filter(e => e._source === "local").length,
|
|
709
|
+
},
|
|
710
|
+
health: {
|
|
711
|
+
expired: expired.length,
|
|
712
|
+
expiringSoon: expiringSoon.length,
|
|
713
|
+
dueForReview: dueForReview.length,
|
|
714
|
+
unused: unused.length,
|
|
715
|
+
},
|
|
716
|
+
byCategory,
|
|
717
|
+
byOwner,
|
|
718
|
+
warnings,
|
|
719
|
+
errors,
|
|
720
|
+
}, null, 2));
|
|
721
|
+
return EXIT.SUCCESS;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
console.log(`
|
|
725
|
+
${c.bold}╔══════════════════════════════════════════════════════════════════════════════╗
|
|
726
|
+
║ ║
|
|
727
|
+
║ ${icons.shield} ${c.cyan}SAFELIST HEALTH REPORT${c.reset}${c.bold} ║
|
|
728
|
+
║ ║
|
|
729
|
+
╚══════════════════════════════════════════════════════════════════════════════╝${c.reset}
|
|
730
|
+
`);
|
|
731
|
+
|
|
732
|
+
// Summary
|
|
733
|
+
console.log(`${c.bold}${icons.list} SUMMARY${c.reset}`);
|
|
734
|
+
console.log(`${"─".repeat(60)}`);
|
|
735
|
+
console.log(` Total entries: ${c.bold}${sl.entries.length}${c.reset}`);
|
|
736
|
+
console.log(` Repo-wide: ${sl.entries.filter(e => e._source === "repo").length}`);
|
|
737
|
+
console.log(` Local-only: ${sl.entries.filter(e => e._source === "local").length}`);
|
|
738
|
+
console.log();
|
|
739
|
+
|
|
740
|
+
// Health
|
|
741
|
+
console.log(`${c.bold}${icons.warning} HEALTH${c.reset}`);
|
|
742
|
+
console.log(`${"─".repeat(60)}`);
|
|
743
|
+
|
|
744
|
+
const healthyColor = (count, threshold) => count > threshold ? c.red : count > 0 ? c.yellow : c.green;
|
|
745
|
+
|
|
746
|
+
console.log(` ${healthyColor(expired.length, 0)}${icons.expired} Expired:${c.reset} ${expired.length}`);
|
|
747
|
+
console.log(` ${healthyColor(expiringSoon.length, 5)}${icons.clock} Expiring soon:${c.reset} ${expiringSoon.length}`);
|
|
748
|
+
console.log(` ${healthyColor(dueForReview.length, 3)}${icons.review} Due for review:${c.reset} ${dueForReview.length}`);
|
|
749
|
+
console.log(` ${healthyColor(unused.length, 10)}${icons.info} Unused (30d):${c.reset} ${unused.length}`);
|
|
750
|
+
console.log();
|
|
751
|
+
|
|
752
|
+
// By category
|
|
753
|
+
console.log(`${c.bold}${icons.info} BY CATEGORY${c.reset}`);
|
|
754
|
+
console.log(`${"─".repeat(60)}`);
|
|
755
|
+
for (const [cat, entries] of Object.entries(byCategory)) {
|
|
756
|
+
const catInfo = safelist.JUSTIFICATION_CATEGORIES[cat];
|
|
757
|
+
console.log(` ${cat.padEnd(20)} ${entries.length} ${c.dim}(${catInfo?.name || "Unknown"})${c.reset}`);
|
|
758
|
+
}
|
|
759
|
+
console.log();
|
|
760
|
+
|
|
761
|
+
// By owner
|
|
762
|
+
console.log(`${c.bold}${icons.person} BY OWNER${c.reset}`);
|
|
763
|
+
console.log(`${"─".repeat(60)}`);
|
|
764
|
+
for (const [owner, entries] of Object.entries(byOwner)) {
|
|
765
|
+
console.log(` ${owner.padEnd(20)} ${entries.length}`);
|
|
766
|
+
}
|
|
767
|
+
console.log();
|
|
768
|
+
|
|
769
|
+
// Action items
|
|
770
|
+
if (expired.length > 0 || dueForReview.length > 0 || unused.length > 5) {
|
|
771
|
+
console.log(`${c.bold}${icons.warning} ACTION ITEMS${c.reset}`);
|
|
772
|
+
console.log(`${"─".repeat(60)}`);
|
|
773
|
+
|
|
774
|
+
if (expired.length > 0) {
|
|
775
|
+
console.log(` ${c.red}•${c.reset} Clean up ${expired.length} expired entries: ${c.cyan}vibecheck safelist clean${c.reset}`);
|
|
776
|
+
}
|
|
777
|
+
if (dueForReview.length > 0) {
|
|
778
|
+
console.log(` ${c.yellow}•${c.reset} Review ${dueForReview.length} entries due for review`);
|
|
779
|
+
}
|
|
780
|
+
if (unused.length > 5) {
|
|
781
|
+
console.log(` ${c.yellow}•${c.reset} Consider removing ${unused.length} unused entries`);
|
|
782
|
+
}
|
|
783
|
+
console.log();
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return EXIT.SUCCESS;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function actionMigrate(projectRoot, opts) {
|
|
790
|
+
const result = safelist.migrateLegacyAllowlist(projectRoot);
|
|
791
|
+
|
|
792
|
+
if (opts.json) {
|
|
793
|
+
console.log(JSON.stringify(result, null, 2));
|
|
794
|
+
} else if (result.success) {
|
|
795
|
+
if (result.migrated > 0) {
|
|
796
|
+
console.log(`\n ${c.green}${icons.check}${c.reset} Migrated ${result.migrated} entries from legacy allowlist`);
|
|
797
|
+
console.log(` ${c.dim}Original file renamed to allowlist.json.migrated${c.reset}\n`);
|
|
798
|
+
} else {
|
|
799
|
+
console.log(`\n ${c.dim}No legacy allowlist.json found${c.reset}\n`);
|
|
800
|
+
}
|
|
801
|
+
} else {
|
|
802
|
+
console.error(`\n ${c.red}${icons.cross}${c.reset} Migration failed: ${result.error}\n`);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
return result.success ? EXIT.SUCCESS : EXIT.INTERNAL_ERROR;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async function actionValidate(projectRoot, opts) {
|
|
809
|
+
const { safelist: sl, warnings, errors } = safelist.loadSafelist(projectRoot);
|
|
810
|
+
|
|
811
|
+
const validation = safelist.validateSafelist(sl);
|
|
812
|
+
const allErrors = [...errors, ...validation.errors];
|
|
813
|
+
const allWarnings = [...warnings, ...validation.warnings];
|
|
814
|
+
|
|
815
|
+
if (opts.json) {
|
|
816
|
+
console.log(JSON.stringify({
|
|
817
|
+
valid: allErrors.length === 0,
|
|
818
|
+
errors: allErrors,
|
|
819
|
+
warnings: allWarnings,
|
|
820
|
+
}, null, 2));
|
|
821
|
+
} else {
|
|
822
|
+
if (allErrors.length === 0 && allWarnings.length === 0) {
|
|
823
|
+
console.log(`\n ${c.green}${icons.check}${c.reset} Safelist is valid\n`);
|
|
824
|
+
} else {
|
|
825
|
+
if (allErrors.length > 0) {
|
|
826
|
+
console.log(`\n ${c.red}${icons.cross} ERRORS${c.reset}`);
|
|
827
|
+
for (const err of allErrors) {
|
|
828
|
+
console.log(` ${c.red}•${c.reset} ${err}`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (allWarnings.length > 0) {
|
|
832
|
+
console.log(`\n ${c.yellow}${icons.warning} WARNINGS${c.reset}`);
|
|
833
|
+
for (const warn of allWarnings) {
|
|
834
|
+
console.log(` ${c.yellow}•${c.reset} ${warn}`);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
console.log();
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
return allErrors.length === 0 ? EXIT.SUCCESS : EXIT.BLOCKING;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async function actionClean(projectRoot, opts) {
|
|
845
|
+
const { safelist: sl, paths } = safelist.loadSafelist(projectRoot);
|
|
846
|
+
const expired = safelist.getExpired(sl);
|
|
847
|
+
|
|
848
|
+
if (expired.length === 0) {
|
|
849
|
+
if (!opts.json) {
|
|
850
|
+
console.log(`\n ${c.dim}No expired entries to clean${c.reset}\n`);
|
|
851
|
+
} else {
|
|
852
|
+
console.log(JSON.stringify({ cleaned: 0 }));
|
|
853
|
+
}
|
|
854
|
+
return EXIT.SUCCESS;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Dry-run mode
|
|
858
|
+
if (opts.dryRun) {
|
|
859
|
+
if (opts.json) {
|
|
860
|
+
console.log(JSON.stringify({
|
|
861
|
+
dryRun: true,
|
|
862
|
+
wouldClean: expired.map(e => ({ id: e.id, expiresAt: e.lifecycle?.expiresAt })),
|
|
863
|
+
}, null, 2));
|
|
864
|
+
} else {
|
|
865
|
+
console.log(`\n ${c.cyan}${icons.info}${c.reset} Dry-run: Would clean ${expired.length} entries:`);
|
|
866
|
+
for (const entry of expired) {
|
|
867
|
+
console.log(` ${c.dim}•${c.reset} ${entry.id} (expired ${new Date(entry.lifecycle?.expiresAt).toLocaleDateString()})`);
|
|
868
|
+
}
|
|
869
|
+
console.log();
|
|
870
|
+
}
|
|
871
|
+
return EXIT.SUCCESS;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Remove expired entries
|
|
875
|
+
let cleaned = 0;
|
|
876
|
+
for (const entry of expired) {
|
|
877
|
+
const result = safelist.removeEntry(projectRoot, entry.id);
|
|
878
|
+
if (result.removed) cleaned++;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (opts.json) {
|
|
882
|
+
console.log(JSON.stringify({ cleaned }));
|
|
883
|
+
} else {
|
|
884
|
+
console.log(`\n ${c.green}${icons.remove}${c.reset} Cleaned ${cleaned} expired entries\n`);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return EXIT.SUCCESS;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
891
|
+
// MAIN
|
|
892
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
893
|
+
|
|
894
|
+
async function runSafelist(args = [], context = {}) {
|
|
895
|
+
const { flags: globalFlags } = parseGlobalFlags(args);
|
|
896
|
+
const opts = parseArgs(args);
|
|
897
|
+
|
|
898
|
+
if (opts.help || globalFlags.help) {
|
|
899
|
+
printHelp();
|
|
900
|
+
return EXIT.SUCCESS;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
opts.json = opts.json || isJsonMode(globalFlags);
|
|
904
|
+
const projectRoot = path.resolve(opts.path || context.repoRoot || process.cwd());
|
|
905
|
+
|
|
906
|
+
// Validate project exists
|
|
907
|
+
if (!fs.existsSync(projectRoot)) {
|
|
908
|
+
if (opts.json) {
|
|
909
|
+
console.log(JSON.stringify({ error: `Path not found: ${projectRoot}` }));
|
|
910
|
+
} else {
|
|
911
|
+
console.error(`\n ${c.red}${icons.cross}${c.reset} Path not found: ${projectRoot}\n`);
|
|
912
|
+
}
|
|
913
|
+
return EXIT.NOT_FOUND;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
switch (opts.action) {
|
|
917
|
+
case "list":
|
|
918
|
+
return actionList(projectRoot, opts);
|
|
919
|
+
case "add":
|
|
920
|
+
return actionAdd(projectRoot, opts);
|
|
921
|
+
case "remove":
|
|
922
|
+
return actionRemove(projectRoot, opts);
|
|
923
|
+
case "check":
|
|
924
|
+
return actionCheck(projectRoot, opts);
|
|
925
|
+
case "report":
|
|
926
|
+
return actionReport(projectRoot, opts);
|
|
927
|
+
case "migrate":
|
|
928
|
+
return actionMigrate(projectRoot, opts);
|
|
929
|
+
case "validate":
|
|
930
|
+
return actionValidate(projectRoot, opts);
|
|
931
|
+
case "clean":
|
|
932
|
+
return actionClean(projectRoot, opts);
|
|
933
|
+
case "search":
|
|
934
|
+
return actionSearch(projectRoot, opts);
|
|
935
|
+
case "stats":
|
|
936
|
+
return actionStats(projectRoot, opts);
|
|
937
|
+
default:
|
|
938
|
+
console.error(`\n ${c.red}${icons.cross}${c.reset} Unknown action: ${opts.action}\n`);
|
|
939
|
+
return EXIT.USER_ERROR;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
944
|
+
// ADDITIONAL ACTIONS
|
|
945
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
946
|
+
|
|
947
|
+
async function actionSearch(projectRoot, opts) {
|
|
948
|
+
const { safelist: sl } = safelist.loadSafelist(projectRoot);
|
|
949
|
+
|
|
950
|
+
// Search criteria
|
|
951
|
+
const query = opts.pattern || opts.id || opts.file || opts.owner;
|
|
952
|
+
if (!query) {
|
|
953
|
+
console.error(`\n ${c.red}${icons.cross}${c.reset} Search requires --pattern, --id, --file, or --owner\n`);
|
|
954
|
+
return EXIT.USER_ERROR;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const results = sl.entries.filter(entry => {
|
|
958
|
+
// Search by ID
|
|
959
|
+
if (opts.id && entry.id.toLowerCase().includes(opts.id.toLowerCase())) {
|
|
960
|
+
return true;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Search by pattern
|
|
964
|
+
if (opts.pattern) {
|
|
965
|
+
const targets = [
|
|
966
|
+
entry.target?.findingId,
|
|
967
|
+
entry.target?.pattern,
|
|
968
|
+
entry.target?.ruleId,
|
|
969
|
+
entry.target?.file,
|
|
970
|
+
entry.justification?.reason,
|
|
971
|
+
].filter(Boolean);
|
|
972
|
+
|
|
973
|
+
const regex = safelist.getCachedRegex(opts.pattern, "i");
|
|
974
|
+
if (regex && targets.some(t => regex.test(t))) {
|
|
975
|
+
return true;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Search by file
|
|
980
|
+
if (opts.file && entry.target?.file) {
|
|
981
|
+
if (entry.target.file.toLowerCase().includes(opts.file.toLowerCase())) {
|
|
982
|
+
return true;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Search by owner
|
|
987
|
+
if (opts.owner) {
|
|
988
|
+
const ownerStr = `${entry.owner?.name || ""} ${entry.owner?.team || ""} ${entry.owner?.email || ""}`.toLowerCase();
|
|
989
|
+
if (ownerStr.includes(opts.owner.toLowerCase())) {
|
|
990
|
+
return true;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Search by category
|
|
995
|
+
if (opts.category && entry.justification?.category === opts.category) {
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
return false;
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
if (opts.json) {
|
|
1003
|
+
console.log(JSON.stringify({ results, count: results.length }, null, 2));
|
|
1004
|
+
return EXIT.SUCCESS;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
console.log(`\n${c.bold}${icons.list} Search Results${c.reset} ${c.dim}(${results.length} found)${c.reset}\n`);
|
|
1008
|
+
|
|
1009
|
+
if (results.length === 0) {
|
|
1010
|
+
console.log(` ${c.dim}No entries match your search.${c.reset}\n`);
|
|
1011
|
+
return EXIT.SUCCESS;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
for (const entry of results) {
|
|
1015
|
+
printEntry(entry, opts.verbose);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
return EXIT.SUCCESS;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
async function actionStats(projectRoot, opts) {
|
|
1022
|
+
const { safelist: sl, warnings, errors } = safelist.loadSafelist(projectRoot);
|
|
1023
|
+
|
|
1024
|
+
// Compute detailed statistics
|
|
1025
|
+
const now = Date.now();
|
|
1026
|
+
const stats = {
|
|
1027
|
+
total: sl.entries.length,
|
|
1028
|
+
byScope: {
|
|
1029
|
+
repo: sl.entries.filter(e => e._source === "repo").length,
|
|
1030
|
+
local: sl.entries.filter(e => e._source === "local").length,
|
|
1031
|
+
},
|
|
1032
|
+
byType: {},
|
|
1033
|
+
byCategory: {},
|
|
1034
|
+
byOwner: {},
|
|
1035
|
+
lifecycle: {
|
|
1036
|
+
expired: 0,
|
|
1037
|
+
expiringSoon: 0,
|
|
1038
|
+
permanent: 0,
|
|
1039
|
+
withExpiry: 0,
|
|
1040
|
+
dueForReview: 0,
|
|
1041
|
+
},
|
|
1042
|
+
activity: {
|
|
1043
|
+
totalMatches: 0,
|
|
1044
|
+
neverMatched: 0,
|
|
1045
|
+
lastWeek: 0,
|
|
1046
|
+
lastMonth: 0,
|
|
1047
|
+
},
|
|
1048
|
+
age: {
|
|
1049
|
+
today: 0,
|
|
1050
|
+
thisWeek: 0,
|
|
1051
|
+
thisMonth: 0,
|
|
1052
|
+
older: 0,
|
|
1053
|
+
},
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
const oneDay = 24 * 60 * 60 * 1000;
|
|
1057
|
+
const oneWeek = 7 * oneDay;
|
|
1058
|
+
const oneMonth = 30 * oneDay;
|
|
1059
|
+
|
|
1060
|
+
for (const entry of sl.entries) {
|
|
1061
|
+
// By type
|
|
1062
|
+
stats.byType[entry.type] = (stats.byType[entry.type] || 0) + 1;
|
|
1063
|
+
|
|
1064
|
+
// By category
|
|
1065
|
+
const cat = entry.justification?.category || "unknown";
|
|
1066
|
+
stats.byCategory[cat] = (stats.byCategory[cat] || 0) + 1;
|
|
1067
|
+
|
|
1068
|
+
// By owner
|
|
1069
|
+
const owner = entry.owner?.name || "Unknown";
|
|
1070
|
+
stats.byOwner[owner] = (stats.byOwner[owner] || 0) + 1;
|
|
1071
|
+
|
|
1072
|
+
// Lifecycle
|
|
1073
|
+
if (entry.lifecycle?.expiresAt) {
|
|
1074
|
+
const expiresAt = new Date(entry.lifecycle.expiresAt).getTime();
|
|
1075
|
+
if (expiresAt < now) {
|
|
1076
|
+
stats.lifecycle.expired++;
|
|
1077
|
+
} else if (expiresAt < now + oneWeek) {
|
|
1078
|
+
stats.lifecycle.expiringSoon++;
|
|
1079
|
+
}
|
|
1080
|
+
stats.lifecycle.withExpiry++;
|
|
1081
|
+
} else {
|
|
1082
|
+
stats.lifecycle.permanent++;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (entry.lifecycle?.reviewAt) {
|
|
1086
|
+
const reviewAt = new Date(entry.lifecycle.reviewAt).getTime();
|
|
1087
|
+
if (reviewAt < now) {
|
|
1088
|
+
stats.lifecycle.dueForReview++;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Activity
|
|
1093
|
+
const matchCount = entry.lifecycle?.matchCount || 0;
|
|
1094
|
+
stats.activity.totalMatches += matchCount;
|
|
1095
|
+
if (matchCount === 0) {
|
|
1096
|
+
stats.activity.neverMatched++;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (entry.lifecycle?.lastMatchedAt) {
|
|
1100
|
+
const lastMatched = new Date(entry.lifecycle.lastMatchedAt).getTime();
|
|
1101
|
+
if (lastMatched > now - oneWeek) {
|
|
1102
|
+
stats.activity.lastWeek++;
|
|
1103
|
+
} else if (lastMatched > now - oneMonth) {
|
|
1104
|
+
stats.activity.lastMonth++;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Age
|
|
1109
|
+
if (entry.lifecycle?.createdAt) {
|
|
1110
|
+
const created = new Date(entry.lifecycle.createdAt).getTime();
|
|
1111
|
+
if (created > now - oneDay) {
|
|
1112
|
+
stats.age.today++;
|
|
1113
|
+
} else if (created > now - oneWeek) {
|
|
1114
|
+
stats.age.thisWeek++;
|
|
1115
|
+
} else if (created > now - oneMonth) {
|
|
1116
|
+
stats.age.thisMonth++;
|
|
1117
|
+
} else {
|
|
1118
|
+
stats.age.older++;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if (opts.json) {
|
|
1124
|
+
console.log(JSON.stringify(stats, null, 2));
|
|
1125
|
+
return EXIT.SUCCESS;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
console.log(`
|
|
1129
|
+
${c.bold}╔══════════════════════════════════════════════════════════════════════════════╗
|
|
1130
|
+
║ ${icons.shield} ${c.cyan}SAFELIST STATISTICS${c.reset}${c.bold} ║
|
|
1131
|
+
╚══════════════════════════════════════════════════════════════════════════════╝${c.reset}
|
|
1132
|
+
`);
|
|
1133
|
+
|
|
1134
|
+
// Overview
|
|
1135
|
+
console.log(`${c.bold}OVERVIEW${c.reset}`);
|
|
1136
|
+
console.log(`${"─".repeat(60)}`);
|
|
1137
|
+
console.log(` Total entries: ${c.bold}${stats.total}${c.reset}`);
|
|
1138
|
+
console.log(` Repo-wide: ${stats.byScope.repo}`);
|
|
1139
|
+
console.log(` Local-only: ${stats.byScope.local}`);
|
|
1140
|
+
console.log();
|
|
1141
|
+
|
|
1142
|
+
// By type
|
|
1143
|
+
console.log(`${c.bold}BY TYPE${c.reset}`);
|
|
1144
|
+
console.log(`${"─".repeat(60)}`);
|
|
1145
|
+
for (const [type, count] of Object.entries(stats.byType)) {
|
|
1146
|
+
const bar = "█".repeat(Math.min(20, Math.round(count / stats.total * 20)));
|
|
1147
|
+
console.log(` ${type.padEnd(12)} ${bar.padEnd(20)} ${count}`);
|
|
1148
|
+
}
|
|
1149
|
+
console.log();
|
|
1150
|
+
|
|
1151
|
+
// Lifecycle
|
|
1152
|
+
console.log(`${c.bold}LIFECYCLE${c.reset}`);
|
|
1153
|
+
console.log(`${"─".repeat(60)}`);
|
|
1154
|
+
console.log(` ${stats.lifecycle.expired > 0 ? c.red : c.green}Expired:${c.reset} ${stats.lifecycle.expired}`);
|
|
1155
|
+
console.log(` ${stats.lifecycle.expiringSoon > 0 ? c.yellow : c.green}Expiring soon:${c.reset} ${stats.lifecycle.expiringSoon}`);
|
|
1156
|
+
console.log(` With expiry: ${stats.lifecycle.withExpiry}`);
|
|
1157
|
+
console.log(` Permanent: ${stats.lifecycle.permanent}`);
|
|
1158
|
+
console.log(` ${stats.lifecycle.dueForReview > 0 ? c.yellow : c.green}Due for review:${c.reset} ${stats.lifecycle.dueForReview}`);
|
|
1159
|
+
console.log();
|
|
1160
|
+
|
|
1161
|
+
// Activity
|
|
1162
|
+
console.log(`${c.bold}ACTIVITY${c.reset}`);
|
|
1163
|
+
console.log(`${"─".repeat(60)}`);
|
|
1164
|
+
console.log(` Total matches: ${stats.activity.totalMatches}`);
|
|
1165
|
+
console.log(` ${stats.activity.neverMatched > 5 ? c.yellow : c.green}Never matched:${c.reset} ${stats.activity.neverMatched}`);
|
|
1166
|
+
console.log(` Active last week: ${stats.activity.lastWeek}`);
|
|
1167
|
+
console.log(` Active last month: ${stats.activity.lastMonth}`);
|
|
1168
|
+
console.log();
|
|
1169
|
+
|
|
1170
|
+
// Age
|
|
1171
|
+
console.log(`${c.bold}AGE DISTRIBUTION${c.reset}`);
|
|
1172
|
+
console.log(`${"─".repeat(60)}`);
|
|
1173
|
+
console.log(` Today: ${stats.age.today}`);
|
|
1174
|
+
console.log(` This week: ${stats.age.thisWeek}`);
|
|
1175
|
+
console.log(` This month: ${stats.age.thisMonth}`);
|
|
1176
|
+
console.log(` Older: ${stats.age.older}`);
|
|
1177
|
+
console.log();
|
|
1178
|
+
|
|
1179
|
+
return EXIT.SUCCESS;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1183
|
+
// EXPORTS
|
|
1184
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1185
|
+
|
|
1186
|
+
module.exports = {
|
|
1187
|
+
runSafelist,
|
|
1188
|
+
// Re-export safelist module for integration
|
|
1189
|
+
...safelist,
|
|
1190
|
+
};
|