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.
- package/LICENSE +661 -0
- package/README.md +179 -0
- package/completions/har-o-scope.bash +64 -0
- package/completions/har-o-scope.fish +43 -0
- package/completions/har-o-scope.zsh +63 -0
- package/dist/cli/colors.d.ts +17 -0
- package/dist/cli/colors.d.ts.map +1 -0
- package/dist/cli/colors.js +54 -0
- package/dist/cli/demo.d.ts +7 -0
- package/dist/cli/demo.d.ts.map +1 -0
- package/dist/cli/demo.js +62 -0
- package/dist/cli/formatters.d.ts +12 -0
- package/dist/cli/formatters.d.ts.map +1 -0
- package/dist/cli/formatters.js +249 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +260 -0
- package/dist/cli/rules.d.ts +3 -0
- package/dist/cli/rules.d.ts.map +1 -0
- package/dist/cli/rules.js +36 -0
- package/dist/cli/sarif.d.ts +9 -0
- package/dist/cli/sarif.d.ts.map +1 -0
- package/dist/cli/sarif.js +104 -0
- package/dist/lib/analyze.d.ts +10 -0
- package/dist/lib/analyze.d.ts.map +1 -0
- package/dist/lib/analyze.js +83 -0
- package/dist/lib/classifier.d.ts +8 -0
- package/dist/lib/classifier.d.ts.map +1 -0
- package/dist/lib/classifier.js +74 -0
- package/dist/lib/diff.d.ts +15 -0
- package/dist/lib/diff.d.ts.map +1 -0
- package/dist/lib/diff.js +130 -0
- package/dist/lib/errors.d.ts +56 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +65 -0
- package/dist/lib/evaluate.d.ts +19 -0
- package/dist/lib/evaluate.d.ts.map +1 -0
- package/dist/lib/evaluate.js +189 -0
- package/dist/lib/health-score.d.ts +18 -0
- package/dist/lib/health-score.d.ts.map +1 -0
- package/dist/lib/health-score.js +74 -0
- package/dist/lib/html-report.d.ts +15 -0
- package/dist/lib/html-report.d.ts.map +1 -0
- package/dist/lib/html-report.js +299 -0
- package/dist/lib/index.d.ts +26 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/index.js +24 -0
- package/dist/lib/normalizer.d.ts +18 -0
- package/dist/lib/normalizer.d.ts.map +1 -0
- package/dist/lib/normalizer.js +201 -0
- package/dist/lib/rule-engine.d.ts +12 -0
- package/dist/lib/rule-engine.d.ts.map +1 -0
- package/dist/lib/rule-engine.js +122 -0
- package/dist/lib/sanitizer.d.ts +10 -0
- package/dist/lib/sanitizer.d.ts.map +1 -0
- package/dist/lib/sanitizer.js +129 -0
- package/dist/lib/schema.d.ts +85 -0
- package/dist/lib/schema.d.ts.map +1 -0
- package/dist/lib/schema.js +1 -0
- package/dist/lib/trace-sanitizer.d.ts +30 -0
- package/dist/lib/trace-sanitizer.d.ts.map +1 -0
- package/dist/lib/trace-sanitizer.js +85 -0
- package/dist/lib/types.d.ts +161 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +1 -0
- package/dist/lib/unbatched-detect.d.ts +7 -0
- package/dist/lib/unbatched-detect.d.ts.map +1 -0
- package/dist/lib/unbatched-detect.js +59 -0
- package/dist/lib/validator.d.ts +4 -0
- package/dist/lib/validator.d.ts.map +1 -0
- package/dist/lib/validator.js +409 -0
- package/package.json +98 -0
- package/rules/generic/issue-rules.yaml +292 -0
- package/rules/generic/shared/base-conditions.yaml +28 -0
- package/rules/generic/shared/filters.yaml +12 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML rule validator: full semantic validation.
|
|
3
|
+
*
|
|
4
|
+
* Three levels:
|
|
5
|
+
* 1. YAML syntax (valid YAML?)
|
|
6
|
+
* 2. Schema conformance (required fields, valid operators, valid values)
|
|
7
|
+
* 3. Semantic validation (field paths, contradictions, inheritance)
|
|
8
|
+
*
|
|
9
|
+
* Decidable contradiction detection:
|
|
10
|
+
* - Numeric range conflicts (gt/lt, gte/lte)
|
|
11
|
+
* - Impossible enum combos (in/not_in)
|
|
12
|
+
* - Composed inheritance conflicts
|
|
13
|
+
* - Skip: regex intersection (undecidable)
|
|
14
|
+
* - Bound nesting at 5 levels (RULE006)
|
|
15
|
+
* - Circular inheritance at depth 10 (RULE003)
|
|
16
|
+
*/
|
|
17
|
+
import yaml from 'js-yaml';
|
|
18
|
+
import { isFieldCondition, isConditionGroup, isHeaderCondition } from './evaluate.js';
|
|
19
|
+
import { RULE_ERRORS } from './errors.js';
|
|
20
|
+
const DOCS_BASE = 'https://github.com/vegaPDX/har-o-scope/blob/main/docs/errors';
|
|
21
|
+
// ── Known field paths on NormalizedEntry ─────────────────────────
|
|
22
|
+
const VALID_FIELD_PREFIXES = new Set([
|
|
23
|
+
'entry.request',
|
|
24
|
+
'entry.response',
|
|
25
|
+
'timings',
|
|
26
|
+
'startTimeMs',
|
|
27
|
+
'totalDuration',
|
|
28
|
+
'transferSizeResolved',
|
|
29
|
+
'contentSize',
|
|
30
|
+
'resourceType',
|
|
31
|
+
'isLongPoll',
|
|
32
|
+
'isWebSocket',
|
|
33
|
+
'httpVersion',
|
|
34
|
+
]);
|
|
35
|
+
function isValidFieldPath(path) {
|
|
36
|
+
// Allow any path starting with a valid prefix
|
|
37
|
+
for (const prefix of VALID_FIELD_PREFIXES) {
|
|
38
|
+
if (path === prefix || path.startsWith(prefix + '.'))
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
// Suggest similar field paths
|
|
44
|
+
function suggestFieldPath(path) {
|
|
45
|
+
const known = [
|
|
46
|
+
'timings.wait', 'timings.blocked', 'timings.dns', 'timings.connect',
|
|
47
|
+
'timings.ssl', 'timings.send', 'timings.receive', 'timings.total',
|
|
48
|
+
'entry.request.url', 'entry.request.method', 'entry.request.httpVersion',
|
|
49
|
+
'entry.response.status', 'entry.response.content.mimeType',
|
|
50
|
+
'entry.response.content.size', 'entry.response.bodySize',
|
|
51
|
+
'resourceType', 'transferSizeResolved', 'contentSize',
|
|
52
|
+
'startTimeMs', 'totalDuration', 'isLongPoll', 'isWebSocket', 'httpVersion',
|
|
53
|
+
];
|
|
54
|
+
// Simple Levenshtein-like: find paths sharing the most segments
|
|
55
|
+
const pathParts = path.split('.');
|
|
56
|
+
let bestMatch;
|
|
57
|
+
let bestScore = 0;
|
|
58
|
+
for (const candidate of known) {
|
|
59
|
+
const candidateParts = candidate.split('.');
|
|
60
|
+
let score = 0;
|
|
61
|
+
for (const part of pathParts) {
|
|
62
|
+
if (candidateParts.includes(part))
|
|
63
|
+
score++;
|
|
64
|
+
}
|
|
65
|
+
if (score > bestScore) {
|
|
66
|
+
bestScore = score;
|
|
67
|
+
bestMatch = candidate;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return bestScore > 0 ? bestMatch : undefined;
|
|
71
|
+
}
|
|
72
|
+
// ── Valid operators and values ───────────────────────────────────
|
|
73
|
+
const VALID_FIELD_OPERATORS = new Set([
|
|
74
|
+
'field', 'field_fallback', 'equals', 'not_equals', 'in', 'not_in',
|
|
75
|
+
'gt', 'gte', 'lt', 'lte', 'matches', 'not_matches',
|
|
76
|
+
]);
|
|
77
|
+
const VALID_SEVERITIES = new Set(['info', 'warning', 'critical']);
|
|
78
|
+
const VALID_CATEGORIES = new Set([
|
|
79
|
+
'server', 'network', 'client', 'optimization',
|
|
80
|
+
'security', 'errors', 'informational', 'performance',
|
|
81
|
+
]);
|
|
82
|
+
const VALID_RULE_FIELDS = new Set([
|
|
83
|
+
'category', 'severity', 'severity_escalation', 'title', 'description',
|
|
84
|
+
'recommendation', 'condition', 'min_count', 'type', 'aggregate_condition',
|
|
85
|
+
'prerequisite', 'impact', 'root_cause_weight', 'inherits', 'exclude', 'overrides',
|
|
86
|
+
]);
|
|
87
|
+
// ── Contradiction detection ─────────────────────────────────────
|
|
88
|
+
function detectContradictions(conditions) {
|
|
89
|
+
const contradictions = [];
|
|
90
|
+
// Group conditions by field path
|
|
91
|
+
const byField = new Map();
|
|
92
|
+
for (const c of conditions) {
|
|
93
|
+
const existing = byField.get(c.field) ?? [];
|
|
94
|
+
existing.push(c);
|
|
95
|
+
byField.set(c.field, existing);
|
|
96
|
+
}
|
|
97
|
+
for (const [field, conds] of byField) {
|
|
98
|
+
// Numeric range conflicts
|
|
99
|
+
const gts = [];
|
|
100
|
+
const lts = [];
|
|
101
|
+
const gtes = [];
|
|
102
|
+
const ltes = [];
|
|
103
|
+
for (const c of conds) {
|
|
104
|
+
if (c.gt !== undefined)
|
|
105
|
+
gts.push(c.gt);
|
|
106
|
+
if (c.gte !== undefined)
|
|
107
|
+
gtes.push(c.gte);
|
|
108
|
+
if (c.lt !== undefined)
|
|
109
|
+
lts.push(c.lt);
|
|
110
|
+
if (c.lte !== undefined)
|
|
111
|
+
ltes.push(c.lte);
|
|
112
|
+
}
|
|
113
|
+
// gt: X AND lt: Y where X >= Y is impossible
|
|
114
|
+
for (const g of gts) {
|
|
115
|
+
for (const l of lts) {
|
|
116
|
+
if (g >= l) {
|
|
117
|
+
contradictions.push(`Field "${field}": gt: ${g} AND lt: ${l} is impossible (no number is both > ${g} and < ${l})`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const le of ltes) {
|
|
121
|
+
if (g >= le) {
|
|
122
|
+
contradictions.push(`Field "${field}": gt: ${g} AND lte: ${le} is impossible`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const ge of gtes) {
|
|
127
|
+
for (const l of lts) {
|
|
128
|
+
if (ge >= l) {
|
|
129
|
+
contradictions.push(`Field "${field}": gte: ${ge} AND lt: ${l} is impossible`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// equals + not_equals same value
|
|
134
|
+
const eqValues = conds.filter((c) => c.equals !== undefined).map((c) => c.equals);
|
|
135
|
+
const neqValues = conds.filter((c) => c.not_equals !== undefined).map((c) => c.not_equals);
|
|
136
|
+
for (const eq of eqValues) {
|
|
137
|
+
for (const neq of neqValues) {
|
|
138
|
+
if (eq === neq) {
|
|
139
|
+
contradictions.push(`Field "${field}": equals: ${JSON.stringify(eq)} AND not_equals: ${JSON.stringify(neq)} is impossible`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// in/not_in overlap: if all items in `in` are also in `not_in`, impossible
|
|
144
|
+
const inValues = conds.filter((c) => c.in !== undefined).flatMap((c) => c.in);
|
|
145
|
+
const notInValues = new Set(conds.filter((c) => c.not_in !== undefined).flatMap((c) => c.not_in).map(String));
|
|
146
|
+
if (inValues.length > 0 && notInValues.size > 0) {
|
|
147
|
+
const allExcluded = inValues.every((v) => notInValues.has(String(v)));
|
|
148
|
+
if (allExcluded) {
|
|
149
|
+
contradictions.push(`Field "${field}": all values in "in" are excluded by "not_in" (no valid value remains)`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return contradictions;
|
|
154
|
+
}
|
|
155
|
+
// ── Nesting depth check ─────────────────────────────────────────
|
|
156
|
+
function checkNestingDepth(node, depth, maxDepth) {
|
|
157
|
+
if (depth > maxDepth) {
|
|
158
|
+
return `Condition nesting exceeds ${maxDepth} levels. Simplify the rule or split into composed conditions.`;
|
|
159
|
+
}
|
|
160
|
+
if (isConditionGroup(node)) {
|
|
161
|
+
const children = node.match_all ?? node.match_any ?? [];
|
|
162
|
+
for (const child of children) {
|
|
163
|
+
const err = checkNestingDepth(child, depth + 1, maxDepth);
|
|
164
|
+
if (err)
|
|
165
|
+
return err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
// ── Circular inheritance check ──────────────────────────────────
|
|
171
|
+
function checkCircularInheritance(ruleName, inherits, sharedConditions, visited, depth) {
|
|
172
|
+
if (depth > 10) {
|
|
173
|
+
return `Circular or deeply nested inheritance chain detected (depth > 10) starting from "${ruleName}"`;
|
|
174
|
+
}
|
|
175
|
+
for (const name of inherits) {
|
|
176
|
+
if (visited.has(name)) {
|
|
177
|
+
return `Circular inheritance: "${ruleName}" -> ... -> "${name}" -> "${ruleName}"`;
|
|
178
|
+
}
|
|
179
|
+
visited.add(name);
|
|
180
|
+
// In this system, shared conditions don't themselves inherit,
|
|
181
|
+
// but we check the reference exists
|
|
182
|
+
if (sharedConditions && !sharedConditions.conditions[name]) {
|
|
183
|
+
// Not a circular issue, just a missing reference (caught elsewhere)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
// ── Collect field conditions from a condition tree ───────────────
|
|
189
|
+
function collectFieldConditions(node, out) {
|
|
190
|
+
if (isFieldCondition(node)) {
|
|
191
|
+
out.push(node);
|
|
192
|
+
}
|
|
193
|
+
else if (isConditionGroup(node)) {
|
|
194
|
+
const children = node.match_all ?? node.match_any ?? [];
|
|
195
|
+
for (const child of children) {
|
|
196
|
+
collectFieldConditions(child, out);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// ── Validate a single condition node ────────────────────────────
|
|
201
|
+
function validateConditionNode(node, errors, warnings, depth) {
|
|
202
|
+
// Nesting check
|
|
203
|
+
if (depth > 5) {
|
|
204
|
+
errors.push({
|
|
205
|
+
code: RULE_ERRORS.RULE006,
|
|
206
|
+
message: 'Condition nesting exceeds 5 levels',
|
|
207
|
+
help: 'Simplify the condition tree or use shared conditions with inherits.',
|
|
208
|
+
docsUrl: `${DOCS_BASE}/${RULE_ERRORS.RULE006}.md`,
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (isFieldCondition(node)) {
|
|
213
|
+
// Validate field path
|
|
214
|
+
if (!isValidFieldPath(node.field)) {
|
|
215
|
+
const suggestion = suggestFieldPath(node.field);
|
|
216
|
+
errors.push({
|
|
217
|
+
code: RULE_ERRORS.RULE002,
|
|
218
|
+
message: `Unknown field path: "${node.field}"`,
|
|
219
|
+
help: 'Check that the field path matches a property on NormalizedEntry.',
|
|
220
|
+
docsUrl: `${DOCS_BASE}/${RULE_ERRORS.RULE002}.md`,
|
|
221
|
+
suggestion: suggestion ? `Did you mean "${suggestion}"?` : undefined,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
// Validate operators
|
|
225
|
+
for (const key of Object.keys(node)) {
|
|
226
|
+
if (!VALID_FIELD_OPERATORS.has(key)) {
|
|
227
|
+
errors.push({
|
|
228
|
+
code: RULE_ERRORS.RULE007,
|
|
229
|
+
message: `Unknown operator: "${key}" on field "${node.field}"`,
|
|
230
|
+
help: `Valid operators: ${[...VALID_FIELD_OPERATORS].filter((k) => k !== 'field' && k !== 'field_fallback').join(', ')}`,
|
|
231
|
+
docsUrl: `${DOCS_BASE}/${RULE_ERRORS.RULE007}.md`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (node.field_fallback && !isValidFieldPath(node.field_fallback)) {
|
|
236
|
+
const suggestion = suggestFieldPath(node.field_fallback);
|
|
237
|
+
warnings.push({
|
|
238
|
+
code: RULE_ERRORS.RULE002,
|
|
239
|
+
message: `Unknown fallback field path: "${node.field_fallback}"`,
|
|
240
|
+
help: 'Check that the fallback field path matches a property on NormalizedEntry.',
|
|
241
|
+
docsUrl: `${DOCS_BASE}/${RULE_ERRORS.RULE002}.md`,
|
|
242
|
+
suggestion: suggestion ? `Did you mean "${suggestion}"?` : undefined,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
else if (isHeaderCondition(node)) {
|
|
247
|
+
// Header conditions are structurally validated by the parser
|
|
248
|
+
}
|
|
249
|
+
else if (isConditionGroup(node)) {
|
|
250
|
+
const children = node.match_all ?? node.match_any ?? [];
|
|
251
|
+
for (const child of children) {
|
|
252
|
+
validateConditionNode(child, errors, warnings, depth + 1);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// ── Main validate ───────────────────────────────────────────────
|
|
257
|
+
export function validate(yamlContent, sharedConditions) {
|
|
258
|
+
const errors = [];
|
|
259
|
+
const warnings = [];
|
|
260
|
+
// Level 1: YAML syntax
|
|
261
|
+
let parsed;
|
|
262
|
+
try {
|
|
263
|
+
parsed = yaml.load(yamlContent);
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
const yamlError = e;
|
|
267
|
+
errors.push({
|
|
268
|
+
code: RULE_ERRORS.RULE001,
|
|
269
|
+
message: `Invalid YAML syntax: ${yamlError.message ?? 'parse error'}`,
|
|
270
|
+
line: yamlError.mark?.line !== undefined ? yamlError.mark.line + 1 : undefined,
|
|
271
|
+
column: yamlError.mark?.column,
|
|
272
|
+
help: 'Fix the YAML syntax error and try again.',
|
|
273
|
+
docsUrl: `${DOCS_BASE}/${RULE_ERRORS.RULE001}.md`,
|
|
274
|
+
});
|
|
275
|
+
return { valid: false, errors, warnings };
|
|
276
|
+
}
|
|
277
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
278
|
+
errors.push({
|
|
279
|
+
code: RULE_ERRORS.RULE004,
|
|
280
|
+
message: 'YAML file is empty or not an object',
|
|
281
|
+
help: 'A rule file must have a top-level "rules:" key containing rule definitions.',
|
|
282
|
+
docsUrl: `${DOCS_BASE}/${RULE_ERRORS.RULE004}.md`,
|
|
283
|
+
});
|
|
284
|
+
return { valid: false, errors, warnings };
|
|
285
|
+
}
|
|
286
|
+
const file = parsed;
|
|
287
|
+
// Level 2: Schema conformance
|
|
288
|
+
if (!file.rules || typeof file.rules !== 'object') {
|
|
289
|
+
errors.push({
|
|
290
|
+
code: RULE_ERRORS.RULE004,
|
|
291
|
+
message: 'Missing required "rules:" key',
|
|
292
|
+
help: 'A rule file must have a top-level "rules:" key. Example:\nrules:\n my-rule:\n category: server\n severity: warning\n ...',
|
|
293
|
+
docsUrl: `${DOCS_BASE}/${RULE_ERRORS.RULE004}.md`,
|
|
294
|
+
});
|
|
295
|
+
return { valid: false, errors, warnings };
|
|
296
|
+
}
|
|
297
|
+
const rules = file.rules;
|
|
298
|
+
for (const [ruleId, ruleData] of Object.entries(rules)) {
|
|
299
|
+
if (!ruleData || typeof ruleData !== 'object') {
|
|
300
|
+
errors.push({
|
|
301
|
+
code: RULE_ERRORS.RULE004,
|
|
302
|
+
message: `Rule "${ruleId}": must be an object`,
|
|
303
|
+
help: 'Each rule must define at least: category, severity, title, description, recommendation.',
|
|
304
|
+
docsUrl: `${DOCS_BASE}/${RULE_ERRORS.RULE004}.md`,
|
|
305
|
+
});
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const rule = ruleData;
|
|
309
|
+
// Required fields
|
|
310
|
+
for (const field of ['category', 'severity', 'title', 'description', 'recommendation']) {
|
|
311
|
+
if (!(field in rule)) {
|
|
312
|
+
errors.push({
|
|
313
|
+
code: RULE_ERRORS.RULE004,
|
|
314
|
+
message: `Rule "${ruleId}": missing required field "${field}"`,
|
|
315
|
+
help: `Add "${field}" to the rule definition.`,
|
|
316
|
+
docsUrl: `${DOCS_BASE}/${RULE_ERRORS.RULE004}.md`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Validate severity value
|
|
321
|
+
if (rule.severity && !VALID_SEVERITIES.has(rule.severity)) {
|
|
322
|
+
errors.push({
|
|
323
|
+
code: RULE_ERRORS.RULE008,
|
|
324
|
+
message: `Rule "${ruleId}": invalid severity "${rule.severity}"`,
|
|
325
|
+
help: `Valid severity values: ${[...VALID_SEVERITIES].join(', ')}`,
|
|
326
|
+
docsUrl: `${DOCS_BASE}/${RULE_ERRORS.RULE008}.md`,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
// Validate category value
|
|
330
|
+
if (rule.category && !VALID_CATEGORIES.has(rule.category)) {
|
|
331
|
+
warnings.push({
|
|
332
|
+
code: RULE_ERRORS.RULE004,
|
|
333
|
+
message: `Rule "${ruleId}": unknown category "${rule.category}"`,
|
|
334
|
+
help: `Known categories: ${[...VALID_CATEGORIES].join(', ')}. Custom categories are allowed but may not display correctly.`,
|
|
335
|
+
docsUrl: `${DOCS_BASE}/${RULE_ERRORS.RULE004}.md`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
// Unknown fields
|
|
339
|
+
for (const key of Object.keys(rule)) {
|
|
340
|
+
if (!VALID_RULE_FIELDS.has(key)) {
|
|
341
|
+
warnings.push({
|
|
342
|
+
code: RULE_ERRORS.RULE004,
|
|
343
|
+
message: `Rule "${ruleId}": unknown field "${key}"`,
|
|
344
|
+
help: `Valid rule fields: ${[...VALID_RULE_FIELDS].join(', ')}`,
|
|
345
|
+
docsUrl: `${DOCS_BASE}/${RULE_ERRORS.RULE004}.md`,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Level 3: Semantic validation
|
|
350
|
+
const typedRule = rule;
|
|
351
|
+
// Condition validation
|
|
352
|
+
if (typedRule.condition) {
|
|
353
|
+
validateConditionNode(typedRule.condition, errors, warnings, 0);
|
|
354
|
+
// Contradiction detection within match_all groups
|
|
355
|
+
const fieldConds = [];
|
|
356
|
+
collectFieldConditions(typedRule.condition, fieldConds);
|
|
357
|
+
const contradictions = detectContradictions(fieldConds);
|
|
358
|
+
for (const msg of contradictions) {
|
|
359
|
+
errors.push({
|
|
360
|
+
code: RULE_ERRORS.RULE005,
|
|
361
|
+
message: `Rule "${ruleId}": ${msg}`,
|
|
362
|
+
help: 'The conditions contradict each other and will never match any entry.',
|
|
363
|
+
docsUrl: `${DOCS_BASE}/${RULE_ERRORS.RULE005}.md`,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
// Nesting depth
|
|
367
|
+
const nestingErr = checkNestingDepth(typedRule.condition, 0, 5);
|
|
368
|
+
if (nestingErr) {
|
|
369
|
+
errors.push({
|
|
370
|
+
code: RULE_ERRORS.RULE006,
|
|
371
|
+
message: `Rule "${ruleId}": ${nestingErr}`,
|
|
372
|
+
help: 'Use shared conditions with inherits to flatten deeply nested rules.',
|
|
373
|
+
docsUrl: `${DOCS_BASE}/${RULE_ERRORS.RULE006}.md`,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// Inheritance validation
|
|
378
|
+
if (typedRule.inherits) {
|
|
379
|
+
const visited = new Set();
|
|
380
|
+
const circularErr = checkCircularInheritance(ruleId, typedRule.inherits, sharedConditions, visited, 0);
|
|
381
|
+
if (circularErr) {
|
|
382
|
+
errors.push({
|
|
383
|
+
code: RULE_ERRORS.RULE003,
|
|
384
|
+
message: `Rule "${ruleId}": ${circularErr}`,
|
|
385
|
+
help: 'Remove the circular reference in the inherits chain.',
|
|
386
|
+
docsUrl: `${DOCS_BASE}/${RULE_ERRORS.RULE003}.md`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
// Check that inherited conditions exist
|
|
390
|
+
if (sharedConditions) {
|
|
391
|
+
for (const name of typedRule.inherits) {
|
|
392
|
+
if (!sharedConditions.conditions[name]) {
|
|
393
|
+
errors.push({
|
|
394
|
+
code: RULE_ERRORS.RULE002,
|
|
395
|
+
message: `Rule "${ruleId}": inherited condition "${name}" not found in shared conditions`,
|
|
396
|
+
help: 'Check the condition name in base-conditions.yaml.',
|
|
397
|
+
docsUrl: `${DOCS_BASE}/${RULE_ERRORS.RULE002}.md`,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
valid: errors.length === 0,
|
|
406
|
+
errors,
|
|
407
|
+
warnings,
|
|
408
|
+
};
|
|
409
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "har-o-scope",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-trust intelligent HAR file analyzer. Drop a HAR file, get instant diagnosis.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "AGPL-3.0-only",
|
|
7
|
+
"author": "vegaPDX",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/vegaPDX/har-o-scope"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"har",
|
|
14
|
+
"http-archive",
|
|
15
|
+
"network-analysis",
|
|
16
|
+
"performance",
|
|
17
|
+
"web-performance",
|
|
18
|
+
"devtools",
|
|
19
|
+
"diagnostics",
|
|
20
|
+
"waterfall",
|
|
21
|
+
"security",
|
|
22
|
+
"sanitizer"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/lib/index.d.ts",
|
|
30
|
+
"import": "./dist/lib/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./analyze": {
|
|
33
|
+
"types": "./dist/lib/analyze.d.ts",
|
|
34
|
+
"import": "./dist/lib/analyze.js"
|
|
35
|
+
},
|
|
36
|
+
"./diff": {
|
|
37
|
+
"types": "./dist/lib/diff.d.ts",
|
|
38
|
+
"import": "./dist/lib/diff.js"
|
|
39
|
+
},
|
|
40
|
+
"./sanitize": {
|
|
41
|
+
"types": "./dist/lib/sanitizer.d.ts",
|
|
42
|
+
"import": "./dist/lib/sanitizer.js"
|
|
43
|
+
},
|
|
44
|
+
"./validate": {
|
|
45
|
+
"types": "./dist/lib/validator.d.ts",
|
|
46
|
+
"import": "./dist/lib/validator.js"
|
|
47
|
+
},
|
|
48
|
+
"./health-score": {
|
|
49
|
+
"types": "./dist/lib/health-score.d.ts",
|
|
50
|
+
"import": "./dist/lib/health-score.js"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"bin": {
|
|
54
|
+
"har-o-scope": "./dist/cli/index.js"
|
|
55
|
+
},
|
|
56
|
+
"files": [
|
|
57
|
+
"dist/lib",
|
|
58
|
+
"dist/cli",
|
|
59
|
+
"rules",
|
|
60
|
+
"completions"
|
|
61
|
+
],
|
|
62
|
+
"scripts": {
|
|
63
|
+
"dev": "vite",
|
|
64
|
+
"build": "vite build && tsc -p tsconfig.cli.json",
|
|
65
|
+
"build:lib": "tsc -p tsconfig.lib.json",
|
|
66
|
+
"build:cli": "tsc -p tsconfig.cli.json",
|
|
67
|
+
"preview": "vite preview",
|
|
68
|
+
"test": "vitest run",
|
|
69
|
+
"test:watch": "vitest",
|
|
70
|
+
"test:coverage": "vitest run --coverage",
|
|
71
|
+
"lint": "tsc --noEmit",
|
|
72
|
+
"check": "tsc --noEmit && vitest run",
|
|
73
|
+
"generate:error-docs": "node scripts/generate-error-docs.mjs",
|
|
74
|
+
"prepublishOnly": "npm run lint && npm test && npm run build"
|
|
75
|
+
},
|
|
76
|
+
"dependencies": {
|
|
77
|
+
"commander": "^14.0.3",
|
|
78
|
+
"js-yaml": "^4.1.0",
|
|
79
|
+
"react-window": "^2.2.7"
|
|
80
|
+
},
|
|
81
|
+
"devDependencies": {
|
|
82
|
+
"@testing-library/jest-dom": "^6.6.3",
|
|
83
|
+
"@testing-library/react": "^16.3.0",
|
|
84
|
+
"@types/har-format": "^1.2.16",
|
|
85
|
+
"@types/js-yaml": "^4.0.9",
|
|
86
|
+
"@types/node": "^25.5.2",
|
|
87
|
+
"@types/react": "^19.1.2",
|
|
88
|
+
"@types/react-dom": "^19.1.2",
|
|
89
|
+
"@vitejs/plugin-react": "^4.4.1",
|
|
90
|
+
"jsdom": "^26.1.0",
|
|
91
|
+
"react": "^19.1.0",
|
|
92
|
+
"react-dom": "^19.1.0",
|
|
93
|
+
"tailwindcss": "^4.1.4",
|
|
94
|
+
"typescript": "^5.8.3",
|
|
95
|
+
"vite": "^6.3.2",
|
|
96
|
+
"vitest": "^3.1.2"
|
|
97
|
+
}
|
|
98
|
+
}
|