har-o-scope 0.1.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.
Files changed (75) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +179 -0
  3. package/completions/har-o-scope.bash +64 -0
  4. package/completions/har-o-scope.fish +43 -0
  5. package/completions/har-o-scope.zsh +63 -0
  6. package/dist/cli/colors.d.ts +17 -0
  7. package/dist/cli/colors.d.ts.map +1 -0
  8. package/dist/cli/colors.js +54 -0
  9. package/dist/cli/demo.d.ts +7 -0
  10. package/dist/cli/demo.d.ts.map +1 -0
  11. package/dist/cli/demo.js +62 -0
  12. package/dist/cli/formatters.d.ts +12 -0
  13. package/dist/cli/formatters.d.ts.map +1 -0
  14. package/dist/cli/formatters.js +249 -0
  15. package/dist/cli/index.d.ts +3 -0
  16. package/dist/cli/index.d.ts.map +1 -0
  17. package/dist/cli/index.js +260 -0
  18. package/dist/cli/rules.d.ts +3 -0
  19. package/dist/cli/rules.d.ts.map +1 -0
  20. package/dist/cli/rules.js +36 -0
  21. package/dist/cli/sarif.d.ts +9 -0
  22. package/dist/cli/sarif.d.ts.map +1 -0
  23. package/dist/cli/sarif.js +104 -0
  24. package/dist/lib/analyze.d.ts +10 -0
  25. package/dist/lib/analyze.d.ts.map +1 -0
  26. package/dist/lib/analyze.js +83 -0
  27. package/dist/lib/classifier.d.ts +8 -0
  28. package/dist/lib/classifier.d.ts.map +1 -0
  29. package/dist/lib/classifier.js +74 -0
  30. package/dist/lib/diff.d.ts +15 -0
  31. package/dist/lib/diff.d.ts.map +1 -0
  32. package/dist/lib/diff.js +130 -0
  33. package/dist/lib/errors.d.ts +56 -0
  34. package/dist/lib/errors.d.ts.map +1 -0
  35. package/dist/lib/errors.js +65 -0
  36. package/dist/lib/evaluate.d.ts +19 -0
  37. package/dist/lib/evaluate.d.ts.map +1 -0
  38. package/dist/lib/evaluate.js +189 -0
  39. package/dist/lib/health-score.d.ts +18 -0
  40. package/dist/lib/health-score.d.ts.map +1 -0
  41. package/dist/lib/health-score.js +74 -0
  42. package/dist/lib/html-report.d.ts +15 -0
  43. package/dist/lib/html-report.d.ts.map +1 -0
  44. package/dist/lib/html-report.js +299 -0
  45. package/dist/lib/index.d.ts +26 -0
  46. package/dist/lib/index.d.ts.map +1 -0
  47. package/dist/lib/index.js +24 -0
  48. package/dist/lib/normalizer.d.ts +18 -0
  49. package/dist/lib/normalizer.d.ts.map +1 -0
  50. package/dist/lib/normalizer.js +201 -0
  51. package/dist/lib/rule-engine.d.ts +12 -0
  52. package/dist/lib/rule-engine.d.ts.map +1 -0
  53. package/dist/lib/rule-engine.js +122 -0
  54. package/dist/lib/sanitizer.d.ts +10 -0
  55. package/dist/lib/sanitizer.d.ts.map +1 -0
  56. package/dist/lib/sanitizer.js +129 -0
  57. package/dist/lib/schema.d.ts +85 -0
  58. package/dist/lib/schema.d.ts.map +1 -0
  59. package/dist/lib/schema.js +1 -0
  60. package/dist/lib/trace-sanitizer.d.ts +30 -0
  61. package/dist/lib/trace-sanitizer.d.ts.map +1 -0
  62. package/dist/lib/trace-sanitizer.js +85 -0
  63. package/dist/lib/types.d.ts +161 -0
  64. package/dist/lib/types.d.ts.map +1 -0
  65. package/dist/lib/types.js +1 -0
  66. package/dist/lib/unbatched-detect.d.ts +7 -0
  67. package/dist/lib/unbatched-detect.d.ts.map +1 -0
  68. package/dist/lib/unbatched-detect.js +59 -0
  69. package/dist/lib/validator.d.ts +4 -0
  70. package/dist/lib/validator.d.ts.map +1 -0
  71. package/dist/lib/validator.js +409 -0
  72. package/package.json +98 -0
  73. package/rules/generic/issue-rules.yaml +292 -0
  74. package/rules/generic/shared/base-conditions.yaml +28 -0
  75. package/rules/generic/shared/filters.yaml +12 -0
@@ -0,0 +1,249 @@
1
+ import { bold, dim, red, green, yellow, cyan, severityColor, severityIcon, healthScoreColor, scoreBar, } from './colors.js';
2
+ // ── Shared helpers ──────────────────────────────────────────────
3
+ function ms(n) {
4
+ if (n >= 10_000)
5
+ return `${(n / 1000).toFixed(1)}s`;
6
+ return `${Math.round(n)}ms`;
7
+ }
8
+ function rootCauseLabel(rootCause) {
9
+ const entries = Object.entries(rootCause);
10
+ entries.sort((a, b) => b[1] - a[1]);
11
+ const top = entries[0];
12
+ if (top[1] === 0)
13
+ return 'none (no findings)';
14
+ const pct = Math.round(top[1] * 100);
15
+ return `${top[0]} (confidence: ${pct}%)`;
16
+ }
17
+ function findingsByServerity(findings) {
18
+ const critical = findings.filter(f => f.severity === 'critical');
19
+ const warning = findings.filter(f => f.severity === 'warning');
20
+ const info = findings.filter(f => f.severity === 'info');
21
+ return { critical, warning, info };
22
+ }
23
+ // ── Text formatter ──────────────────────────────────────────────
24
+ export function formatText(result, score, verbose) {
25
+ const lines = [];
26
+ // Health score
27
+ const scoreStr = `${score.score}/100`;
28
+ const bar = scoreBar(score.score);
29
+ const colorFn = healthScoreColor(score.score);
30
+ lines.push('');
31
+ lines.push(` ${bold('Health Score:')} ${colorFn(scoreStr)} ${colorFn(bar)}`);
32
+ // Root cause + stats
33
+ lines.push(` ${bold('Root Cause:')} ${rootCauseLabel(result.rootCause)}`);
34
+ lines.push(` ${dim(`Requests: ${result.metadata.totalRequests} | Time: ${ms(result.metadata.totalTimeMs)} | Analysis: ${ms(result.metadata.analysisTimeMs)}`)}`);
35
+ lines.push('');
36
+ // Findings
37
+ const { findings } = result;
38
+ if (findings.length === 0) {
39
+ lines.push(` ${green('No issues found.')}`);
40
+ }
41
+ else {
42
+ const { critical, warning, info } = findingsByServerity(findings);
43
+ const counts = [];
44
+ if (critical.length)
45
+ counts.push(red(`${critical.length} critical`));
46
+ if (warning.length)
47
+ counts.push(yellow(`${warning.length} warning`));
48
+ if (info.length)
49
+ counts.push(dim(`${info.length} info`));
50
+ lines.push(` ${bold('Findings')} (${counts.join(', ')})`);
51
+ lines.push('');
52
+ const sorted = [...critical, ...warning, ...info];
53
+ for (const finding of sorted) {
54
+ const icon = severityIcon(finding.severity);
55
+ const color = severityColor(finding.severity);
56
+ lines.push(` ${color(icon)} ${color(`[${finding.severity}]`)} ${finding.title}`);
57
+ lines.push(` ${dim(finding.description)}`);
58
+ lines.push(` ${cyan('\u2192')} ${finding.recommendation}`);
59
+ lines.push(` ${dim(`Affected: ${finding.affectedEntries.length} entries`)}`);
60
+ lines.push('');
61
+ }
62
+ }
63
+ // Warnings
64
+ if (result.warnings.length > 0) {
65
+ lines.push(` ${bold('Warnings')}`);
66
+ lines.push('');
67
+ for (const w of result.warnings) {
68
+ lines.push(` ${yellow('\u26A0')} ${dim(`[${w.code}]`)} ${w.message}`);
69
+ lines.push(` ${dim(`Help: ${w.help}`)}`);
70
+ }
71
+ lines.push('');
72
+ }
73
+ // Verbose: per-entry timing
74
+ if (verbose) {
75
+ lines.push(` ${bold('Per-Entry Timing')}`);
76
+ lines.push('');
77
+ lines.push(` ${'URL'.padEnd(60)} ${'Wait'.padStart(8)} ${'Total'.padStart(8)} ${'Status'.padStart(6)}`);
78
+ lines.push(` ${'\u2500'.repeat(60)} ${'\u2500'.repeat(8)} ${'\u2500'.repeat(8)} ${'\u2500'.repeat(6)}`);
79
+ const sorted = [...result.entries]
80
+ .filter(e => !e.isWebSocket)
81
+ .sort((a, b) => b.timings.wait - a.timings.wait);
82
+ for (const entry of sorted) {
83
+ const url = entry.entry.request?.url ?? 'unknown';
84
+ const truncUrl = url.length > 58 ? url.slice(0, 55) + '...' : url;
85
+ const status = entry.entry.response?.status ?? 0;
86
+ const statusStr = status >= 400 ? red(String(status)) : String(status);
87
+ lines.push(` ${truncUrl.padEnd(60)} ${ms(entry.timings.wait).padStart(8)} ${ms(entry.totalDuration).padStart(8)} ${statusStr.padStart(6)}`);
88
+ }
89
+ lines.push('');
90
+ }
91
+ return lines.join('\n');
92
+ }
93
+ // ── JSON formatter ──────────────────────────────────────────────
94
+ export function formatJson(result, score) {
95
+ const output = {
96
+ healthScore: score.score,
97
+ scoreBreakdown: score.breakdown,
98
+ rootCause: result.rootCause,
99
+ findings: result.findings.map(f => ({
100
+ ruleId: f.ruleId,
101
+ severity: f.severity,
102
+ category: f.category,
103
+ title: f.title,
104
+ description: f.description,
105
+ recommendation: f.recommendation,
106
+ affectedEntries: f.affectedEntries.length,
107
+ impact: f.impact,
108
+ })),
109
+ metadata: result.metadata,
110
+ warnings: result.warnings,
111
+ };
112
+ return JSON.stringify(output, null, 2);
113
+ }
114
+ // ── Markdown formatter ──────────────────────────────────────────
115
+ export function formatMarkdown(result, score) {
116
+ const lines = [];
117
+ lines.push(`# HAR Analysis Report`);
118
+ lines.push('');
119
+ lines.push(`**Health Score:** ${score.score}/100`);
120
+ lines.push(`**Root Cause:** ${rootCauseLabel(result.rootCause)}`);
121
+ lines.push(`**Requests:** ${result.metadata.totalRequests} | **Total Time:** ${ms(result.metadata.totalTimeMs)}`);
122
+ lines.push('');
123
+ if (result.findings.length === 0) {
124
+ lines.push('No issues found.');
125
+ }
126
+ else {
127
+ lines.push('## Findings');
128
+ lines.push('');
129
+ lines.push('| Severity | Rule | Title | Affected |');
130
+ lines.push('|----------|------|-------|----------|');
131
+ for (const f of result.findings) {
132
+ const sev = f.severity === 'critical' ? '\u274C' : f.severity === 'warning' ? '\u26A0\uFE0F' : '\u2139\uFE0F';
133
+ lines.push(`| ${sev} ${f.severity} | ${f.ruleId} | ${f.title} | ${f.affectedEntries.length} |`);
134
+ }
135
+ lines.push('');
136
+ for (const f of result.findings) {
137
+ lines.push(`### ${f.title}`);
138
+ lines.push('');
139
+ lines.push(f.description);
140
+ lines.push('');
141
+ lines.push(`**Recommendation:** ${f.recommendation}`);
142
+ lines.push('');
143
+ }
144
+ }
145
+ if (result.warnings.length > 0) {
146
+ lines.push('## Warnings');
147
+ lines.push('');
148
+ for (const w of result.warnings) {
149
+ lines.push(`- **${w.code}:** ${w.message}`);
150
+ }
151
+ lines.push('');
152
+ }
153
+ return lines.join('\n');
154
+ }
155
+ // ── CI annotations ──────────────────────────────────────────────
156
+ export function formatCi(result, score, threshold) {
157
+ const lines = [];
158
+ for (const finding of result.findings) {
159
+ // CI annotations: critical → error, warning → warning, info → skip
160
+ if (finding.severity === 'info')
161
+ continue;
162
+ const level = finding.severity === 'critical' ? 'error' : 'warning';
163
+ const msg = `[${finding.ruleId}] ${finding.title}`.replace(/\n/g, '%0A');
164
+ lines.push(`::${level}::${msg}`);
165
+ }
166
+ if (score.score < threshold) {
167
+ lines.push(`::error::Health score ${score.score} is below threshold ${threshold}`);
168
+ }
169
+ return lines.join('\n');
170
+ }
171
+ // ── Diff formatters ─────────────────────────────────────────────
172
+ export function formatDiffText(diff) {
173
+ const lines = [];
174
+ lines.push('');
175
+ const deltaColor = diff.scoreDelta >= 0 ? green : red;
176
+ const deltaSign = diff.scoreDelta >= 0 ? '+' : '';
177
+ lines.push(` ${bold('Score Delta:')} ${deltaColor(`${deltaSign}${diff.scoreDelta}`)}`);
178
+ lines.push(` ${bold('Request Delta:')} ${diff.requestCountDelta >= 0 ? '+' : ''}${diff.requestCountDelta}`);
179
+ lines.push(` ${bold('Time Delta:')} ${diff.totalTimeDelta >= 0 ? '+' : ''}${ms(diff.totalTimeDelta)}`);
180
+ lines.push('');
181
+ if (diff.newFindings.length > 0) {
182
+ lines.push(` ${red(bold('New Issues'))} (${diff.newFindings.length})`);
183
+ for (const f of diff.newFindings) {
184
+ lines.push(` ${red('+')} [${f.severity}] ${f.title}`);
185
+ }
186
+ lines.push('');
187
+ }
188
+ if (diff.resolvedFindings.length > 0) {
189
+ lines.push(` ${green(bold('Resolved'))} (${diff.resolvedFindings.length})`);
190
+ for (const f of diff.resolvedFindings) {
191
+ lines.push(` ${green('-')} [${f.severity}] ${f.title}`);
192
+ }
193
+ lines.push('');
194
+ }
195
+ if (diff.persistedFindings.length > 0) {
196
+ lines.push(` ${yellow(bold('Persisted'))} (${diff.persistedFindings.length})`);
197
+ for (const f of diff.persistedFindings) {
198
+ const delta = f.afterCount - f.beforeCount;
199
+ const deltaStr = delta === 0 ? '=' : delta > 0 ? `+${delta}` : `${delta}`;
200
+ lines.push(` ${dim('\u2022')} ${f.ruleId}: ${f.beforeSeverity} \u2192 ${f.afterSeverity} (${deltaStr} entries)`);
201
+ }
202
+ lines.push('');
203
+ }
204
+ if (diff.timingDeltas.length > 0) {
205
+ lines.push(` ${bold('Top Timing Changes')}`);
206
+ const top = diff.timingDeltas.slice(0, 10);
207
+ for (const td of top) {
208
+ const color = td.deltaMs > 0 ? red : green;
209
+ const sign = td.deltaMs > 0 ? '+' : '';
210
+ lines.push(` ${td.urlPattern.slice(0, 50).padEnd(50)} ${color(`${sign}${ms(td.deltaMs)}`)} (${sign}${td.deltaPercent}%)`);
211
+ }
212
+ lines.push('');
213
+ }
214
+ return lines.join('\n');
215
+ }
216
+ export function formatDiffJson(diff) {
217
+ return JSON.stringify(diff, null, 2);
218
+ }
219
+ export function formatDiffMarkdown(diff) {
220
+ const lines = [];
221
+ lines.push('# HAR Diff Report');
222
+ lines.push('');
223
+ lines.push(`**Score Delta:** ${diff.scoreDelta >= 0 ? '+' : ''}${diff.scoreDelta}`);
224
+ lines.push(`**Request Delta:** ${diff.requestCountDelta >= 0 ? '+' : ''}${diff.requestCountDelta}`);
225
+ lines.push(`**Time Delta:** ${diff.totalTimeDelta >= 0 ? '+' : ''}${ms(diff.totalTimeDelta)}`);
226
+ lines.push('');
227
+ if (diff.newFindings.length > 0) {
228
+ lines.push('## New Issues');
229
+ for (const f of diff.newFindings)
230
+ lines.push(`- \u274C **[${f.severity}]** ${f.title}`);
231
+ lines.push('');
232
+ }
233
+ if (diff.resolvedFindings.length > 0) {
234
+ lines.push('## Resolved');
235
+ for (const f of diff.resolvedFindings)
236
+ lines.push(`- \u2705 **[${f.severity}]** ${f.title}`);
237
+ lines.push('');
238
+ }
239
+ if (diff.timingDeltas.length > 0) {
240
+ lines.push('## Timing Changes');
241
+ lines.push('| URL Pattern | Before | After | Delta |');
242
+ lines.push('|-------------|--------|-------|-------|');
243
+ for (const td of diff.timingDeltas.slice(0, 15)) {
244
+ lines.push(`| ${td.urlPattern} | ${ms(td.beforeAvgMs)} | ${ms(td.afterAvgMs)} | ${td.deltaMs >= 0 ? '+' : ''}${ms(td.deltaMs)} |`);
245
+ }
246
+ lines.push('');
247
+ }
248
+ return lines.join('\n');
249
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":""}
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * har-o-scope CLI entry point.
4
+ *
5
+ * Subcommands: analyze, diff, sanitize, validate
6
+ * Exit codes: 0 = clean, 1 = warnings, 2 = critical or below threshold
7
+ */
8
+ import { readFile, writeFile } from 'node:fs/promises';
9
+ import { resolve } from 'node:path';
10
+ import { Command } from 'commander';
11
+ import { analyze } from '../lib/analyze.js';
12
+ import { diff } from '../lib/diff.js';
13
+ import { sanitize } from '../lib/sanitizer.js';
14
+ import { validate } from '../lib/validator.js';
15
+ import { computeHealthScore } from '../lib/health-score.js';
16
+ import { parseHar } from '../lib/normalizer.js';
17
+ import { HarError } from '../lib/errors.js';
18
+ import { setColorEnabled } from './colors.js';
19
+ import { loadBuiltinRules, loadCustomRules } from './rules.js';
20
+ import { demoHar } from './demo.js';
21
+ import { formatText, formatJson, formatMarkdown, formatCi, formatDiffText, formatDiffJson, formatDiffMarkdown } from './formatters.js';
22
+ import { formatSarif } from './sarif.js';
23
+ import { generateHtmlReport } from '../lib/html-report.js';
24
+ const VERSION = '0.1.0';
25
+ // ── Exit code logic ─────────────────────────────────────────────
26
+ function computeExitCode(result, score, threshold) {
27
+ const hasCritical = result.findings.some(f => f.severity === 'critical');
28
+ if (hasCritical || score.score < threshold)
29
+ return 2;
30
+ const hasWarning = result.findings.some(f => f.severity === 'warning');
31
+ if (hasWarning)
32
+ return 1;
33
+ return 0;
34
+ }
35
+ // ── Helpers ─────────────────────────────────────────────────────
36
+ async function readHarFile(path) {
37
+ const resolved = resolve(path);
38
+ try {
39
+ return await readFile(resolved, 'utf-8');
40
+ }
41
+ catch (err) {
42
+ const e = err;
43
+ if (e.code === 'ENOENT') {
44
+ process.stderr.write(`Error: File not found: ${resolved}\n`);
45
+ process.exit(2);
46
+ }
47
+ throw err;
48
+ }
49
+ }
50
+ function output(text) {
51
+ process.stdout.write(text + '\n');
52
+ }
53
+ // ── Commands ────────────────────────────────────────────────────
54
+ async function runAnalyze(file, opts) {
55
+ if (!opts.color)
56
+ setColorEnabled(false);
57
+ await loadBuiltinRules();
58
+ // Determine input
59
+ let harData;
60
+ if (opts.demo) {
61
+ harData = JSON.stringify(demoHar);
62
+ }
63
+ else if (file) {
64
+ harData = await readHarFile(file);
65
+ }
66
+ else {
67
+ process.stderr.write('Error: Provide a HAR file or use --demo\n');
68
+ process.exit(2);
69
+ return; // unreachable, but satisfies TS definite assignment
70
+ }
71
+ // Load custom rules if specified
72
+ const customRulesData = opts.rules ? await loadCustomRules(opts.rules) : undefined;
73
+ const result = analyze(harData, { customRulesData });
74
+ const score = computeHealthScore(result);
75
+ const threshold = parseInt(opts.threshold, 10) || 50;
76
+ // Baseline comparison mode
77
+ if (opts.baseline) {
78
+ const baselineData = await readHarFile(opts.baseline);
79
+ const baselineResult = analyze(baselineData, { customRulesData });
80
+ const diffResult = diff(baselineResult, result);
81
+ if (opts.format === 'json') {
82
+ output(formatDiffJson(diffResult));
83
+ }
84
+ else if (opts.format === 'markdown') {
85
+ output(formatDiffMarkdown(diffResult));
86
+ }
87
+ else {
88
+ output(formatDiffText(diffResult));
89
+ }
90
+ // Exit 2 if score dropped below threshold
91
+ const exitCode = computeExitCode(result, score, threshold);
92
+ process.exit(exitCode);
93
+ return;
94
+ }
95
+ // Output format selection
96
+ if (opts.sarif) {
97
+ output(formatSarif(result, score, VERSION));
98
+ }
99
+ else if (opts.ci) {
100
+ const ciOutput = formatCi(result, score, threshold);
101
+ if (ciOutput)
102
+ output(ciOutput);
103
+ }
104
+ else {
105
+ switch (opts.format) {
106
+ case 'json':
107
+ output(formatJson(result, score));
108
+ break;
109
+ case 'markdown':
110
+ output(formatMarkdown(result, score));
111
+ break;
112
+ case 'html':
113
+ output(generateHtmlReport(result, score));
114
+ break;
115
+ default:
116
+ output(formatText(result, score, opts.verbose));
117
+ }
118
+ }
119
+ process.exit(computeExitCode(result, score, threshold));
120
+ }
121
+ async function runDiff(before, after, opts) {
122
+ if (!opts.color)
123
+ setColorEnabled(false);
124
+ await loadBuiltinRules();
125
+ const [beforeData, afterData] = await Promise.all([
126
+ readHarFile(before),
127
+ readHarFile(after),
128
+ ]);
129
+ const beforeResult = analyze(beforeData);
130
+ const afterResult = analyze(afterData);
131
+ const diffResult = diff(beforeResult, afterResult);
132
+ switch (opts.format) {
133
+ case 'json':
134
+ output(formatDiffJson(diffResult));
135
+ break;
136
+ case 'markdown':
137
+ output(formatDiffMarkdown(diffResult));
138
+ break;
139
+ default:
140
+ output(formatDiffText(diffResult));
141
+ }
142
+ // Exit 1 if new findings, 2 if new critical findings
143
+ const hasCriticalNew = diffResult.newFindings.some(f => f.severity === 'critical');
144
+ if (hasCriticalNew)
145
+ process.exit(2);
146
+ if (diffResult.newFindings.length > 0)
147
+ process.exit(1);
148
+ }
149
+ async function runSanitize(file, opts) {
150
+ const data = await readHarFile(file);
151
+ const har = parseHar(data);
152
+ const sanitized = sanitize(har, { mode: opts.mode });
153
+ const json = JSON.stringify(sanitized, null, 2);
154
+ if (opts.output) {
155
+ await writeFile(resolve(opts.output), json, 'utf-8');
156
+ process.stderr.write(`Sanitized HAR written to ${opts.output}\n`);
157
+ }
158
+ else {
159
+ output(json);
160
+ }
161
+ }
162
+ async function runValidate(file, opts) {
163
+ const data = await readHarFile(file);
164
+ // Validate as HAR
165
+ try {
166
+ parseHar(data);
167
+ process.stderr.write('HAR structure: valid\n');
168
+ }
169
+ catch (err) {
170
+ if (err instanceof HarError) {
171
+ process.stderr.write(`HAR structure: invalid\n`);
172
+ process.stderr.write(` ${err.code}: ${err.message}\n`);
173
+ process.stderr.write(` Help: ${err.help}\n`);
174
+ process.exit(2);
175
+ }
176
+ throw err;
177
+ }
178
+ // Validate custom rules if specified
179
+ if (opts.rules) {
180
+ const rulesContent = await readFile(resolve(opts.rules), 'utf-8');
181
+ const result = validate(rulesContent);
182
+ if (result.errors.length > 0) {
183
+ process.stderr.write(`Rules validation: ${result.errors.length} error(s)\n`);
184
+ for (const e of result.errors) {
185
+ process.stderr.write(` ${e.code}: ${e.message}\n`);
186
+ if (e.suggestion)
187
+ process.stderr.write(` Suggestion: ${e.suggestion}\n`);
188
+ }
189
+ }
190
+ if (result.warnings.length > 0) {
191
+ process.stderr.write(`Rules validation: ${result.warnings.length} warning(s)\n`);
192
+ for (const w of result.warnings) {
193
+ process.stderr.write(` ${w.code}: ${w.message}\n`);
194
+ }
195
+ }
196
+ if (result.valid) {
197
+ process.stderr.write('Rules validation: valid\n');
198
+ }
199
+ else {
200
+ process.exit(2);
201
+ }
202
+ }
203
+ }
204
+ // ── Program ─────────────────────────────────────────────────────
205
+ const program = new Command()
206
+ .name('har-o-scope')
207
+ .description('Zero-trust intelligent HAR file analyzer')
208
+ .version(VERSION);
209
+ program
210
+ .command('analyze')
211
+ .argument('[file]', 'HAR file to analyze')
212
+ .description('Analyze a HAR file for performance and security issues')
213
+ .option('-f, --format <format>', 'Output format: text, json, markdown, html', 'text')
214
+ .option('--sarif', 'Output SARIF 2.1.0 JSON')
215
+ .option('--ci', 'Output GitHub-compatible annotations')
216
+ .option('--baseline <file>', 'Compare against a baseline HAR file')
217
+ .option('--threshold <score>', 'Minimum health score (default: 50)', '50')
218
+ .option('--verbose', 'Show per-entry timing details', false)
219
+ .option('--rules <path>', 'Path to custom YAML rules file or directory')
220
+ .option('--demo', 'Analyze a built-in demo HAR file', false)
221
+ .option('--no-color', 'Disable colored output')
222
+ .action(runAnalyze);
223
+ program
224
+ .command('diff')
225
+ .arguments('<before> <after>')
226
+ .description('Compare two HAR files for regressions and improvements')
227
+ .option('-f, --format <format>', 'Output format: text, json, markdown, html', 'text')
228
+ .option('--no-color', 'Disable colored output')
229
+ .action(runDiff);
230
+ program
231
+ .command('sanitize')
232
+ .argument('<file>', 'HAR file to sanitize')
233
+ .description('Strip secrets and sensitive data from a HAR file')
234
+ .option('-o, --output <file>', 'Output file (default: stdout)')
235
+ .option('-m, --mode <mode>', 'Sanitization mode: aggressive, selective', 'aggressive')
236
+ .action(runSanitize);
237
+ program
238
+ .command('validate')
239
+ .argument('<file>', 'HAR file to validate')
240
+ .description('Validate HAR file structure and optionally custom rules')
241
+ .option('--rules <path>', 'Also validate a YAML rules file')
242
+ .action(runValidate);
243
+ // Handle errors globally
244
+ program.exitOverride();
245
+ try {
246
+ await program.parseAsync();
247
+ }
248
+ catch (err) {
249
+ if (err instanceof HarError) {
250
+ process.stderr.write(`\nError [${err.code}]: ${err.message}\n`);
251
+ process.stderr.write(`Help: ${err.help}\n`);
252
+ process.stderr.write(`Docs: ${err.docsUrl}\n`);
253
+ process.exit(2);
254
+ }
255
+ // Commander exits with code 0 for --help and --version
256
+ if (err && typeof err === 'object' && 'exitCode' in err) {
257
+ process.exit(err.exitCode);
258
+ }
259
+ throw err;
260
+ }
@@ -0,0 +1,3 @@
1
+ export declare function loadBuiltinRules(): Promise<void>;
2
+ export declare function loadCustomRules(path: string): Promise<unknown[]>;
3
+ //# sourceMappingURL=rules.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rules.d.ts","sourceRoot":"","sources":["../../src/cli/rules.ts"],"names":[],"mappings":"AAaA,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAatD;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CActE"}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * YAML rule loading for CLI.
3
+ * Reads rules from the package's rules/ directory at runtime.
4
+ */
5
+ import { readFile, readdir, stat } from 'node:fs/promises';
6
+ import { resolve, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import yaml from 'js-yaml';
9
+ import { setBuiltinRules } from '../lib/analyze.js';
10
+ const __dirname = resolve(fileURLToPath(import.meta.url), '..');
11
+ export async function loadBuiltinRules() {
12
+ const packageRoot = resolve(__dirname, '..', '..');
13
+ const rulesDir = join(packageRoot, 'rules', 'generic');
14
+ const rulesYaml = await readFile(join(rulesDir, 'issue-rules.yaml'), 'utf-8');
15
+ const conditionsYaml = await readFile(join(rulesDir, 'shared', 'base-conditions.yaml'), 'utf-8');
16
+ const filtersYaml = await readFile(join(rulesDir, 'shared', 'filters.yaml'), 'utf-8');
17
+ const rules = yaml.load(rulesYaml);
18
+ const conditions = yaml.load(conditionsYaml);
19
+ const filters = yaml.load(filtersYaml);
20
+ setBuiltinRules(rules, conditions, filters);
21
+ }
22
+ export async function loadCustomRules(path) {
23
+ const info = await stat(path);
24
+ if (info.isDirectory()) {
25
+ const files = await readdir(path);
26
+ const yamlFiles = files.filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
27
+ const results = [];
28
+ for (const file of yamlFiles) {
29
+ const content = await readFile(join(path, file), 'utf-8');
30
+ results.push(yaml.load(content));
31
+ }
32
+ return results;
33
+ }
34
+ const content = await readFile(path, 'utf-8');
35
+ return [yaml.load(content)];
36
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * SARIF 2.1.0 output formatter.
3
+ *
4
+ * Uses logicalLocation (URL patterns, not source files) since
5
+ * HAR findings reference network requests, not code.
6
+ */
7
+ import type { AnalysisResult, HealthScore } from '../lib/types.js';
8
+ export declare function formatSarif(result: AnalysisResult, score: HealthScore, version: string): string;
9
+ //# sourceMappingURL=sarif.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sarif.d.ts","sourceRoot":"","sources":["../../src/cli/sarif.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAW,MAAM,iBAAiB,CAAA;AAoD3E,wBAAgB,WAAW,CACzB,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,MAAM,GACd,MAAM,CA+DR"}
@@ -0,0 +1,104 @@
1
+ // ── SARIF severity mapping ─────────────────────────────────────
2
+ // har-o-scope: info | warning | critical
3
+ // SARIF: note | warning | error
4
+ function sarifLevel(severity) {
5
+ switch (severity) {
6
+ case 'critical': return 'error';
7
+ case 'warning': return 'warning';
8
+ case 'info': return 'note';
9
+ default: return 'none';
10
+ }
11
+ }
12
+ // ── Extract URL patterns from affected entries ──────────────────
13
+ function extractLogicalLocations(finding, entries) {
14
+ const patterns = new Map();
15
+ for (const idx of finding.affectedEntries) {
16
+ const entry = entries[idx];
17
+ if (!entry)
18
+ continue;
19
+ const url = entry.entry.request?.url ?? '';
20
+ try {
21
+ const parsed = new URL(url);
22
+ // Normalize: strip query params, replace numeric/UUID segments
23
+ const path = parsed.pathname
24
+ .split('/')
25
+ .map(seg => /^\d+$/.test(seg) ? '*' : seg)
26
+ .join('/');
27
+ const pattern = `${parsed.hostname}${path}`;
28
+ if (!patterns.has(pattern)) {
29
+ patterns.set(pattern, path);
30
+ }
31
+ }
32
+ catch {
33
+ // Malformed URL, skip
34
+ }
35
+ }
36
+ return [...patterns.entries()].map(([fqn, name]) => ({
37
+ name,
38
+ kind: 'url-pattern',
39
+ fullyQualifiedName: fqn,
40
+ }));
41
+ }
42
+ // ── Main SARIF formatter ────────────────────────────────────────
43
+ export function formatSarif(result, score, version) {
44
+ // Build rule definitions
45
+ const ruleMap = new Map();
46
+ for (const finding of result.findings) {
47
+ if (!ruleMap.has(finding.ruleId)) {
48
+ ruleMap.set(finding.ruleId, {
49
+ id: finding.ruleId,
50
+ shortDescription: finding.title,
51
+ });
52
+ }
53
+ }
54
+ const sarif = {
55
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json',
56
+ version: '2.1.0',
57
+ runs: [
58
+ {
59
+ tool: {
60
+ driver: {
61
+ name: 'har-o-scope',
62
+ version,
63
+ informationUri: 'https://github.com/vegaPDX/har-o-scope',
64
+ rules: [...ruleMap.values()].map(r => ({
65
+ id: r.id,
66
+ shortDescription: { text: r.shortDescription },
67
+ helpUri: `https://github.com/vegaPDX/har-o-scope/blob/main/docs/rules/${r.id}.md`,
68
+ })),
69
+ properties: {
70
+ healthScore: score.score,
71
+ scoreBreakdown: score.breakdown,
72
+ },
73
+ },
74
+ },
75
+ results: result.findings.map(finding => ({
76
+ ruleId: finding.ruleId,
77
+ ruleIndex: [...ruleMap.keys()].indexOf(finding.ruleId),
78
+ level: sarifLevel(finding.severity),
79
+ message: {
80
+ text: `${finding.title}\n\n${finding.description}\n\nRecommendation: ${finding.recommendation}`,
81
+ },
82
+ logicalLocations: extractLogicalLocations(finding, result.entries),
83
+ properties: {
84
+ severity: finding.severity,
85
+ category: finding.category,
86
+ affectedEntries: finding.affectedEntries.length,
87
+ impact: finding.impact,
88
+ },
89
+ })),
90
+ invocations: [
91
+ {
92
+ executionSuccessful: true,
93
+ properties: {
94
+ healthScore: score.score,
95
+ totalRequests: result.metadata.totalRequests,
96
+ analysisTimeMs: result.metadata.analysisTimeMs,
97
+ },
98
+ },
99
+ ],
100
+ },
101
+ ],
102
+ };
103
+ return JSON.stringify(sarif, null, 2);
104
+ }