doclify-guardrail 1.2.1 → 1.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doclify-guardrail",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "description": "Quality gate for your Markdown docs. Zero dependencies, catches errors in seconds.",
6
6
  "files": [
package/src/checker.mjs CHANGED
@@ -14,7 +14,22 @@ const RULE_CATALOG = [
14
14
  { id: 'empty-link', severity: 'warning', description: 'No empty link text or URL' },
15
15
  { id: 'img-alt', severity: 'warning', description: 'Images must have alt text' },
16
16
  { id: 'dead-link', severity: 'error', description: 'No broken links (requires --check-links)' },
17
- { id: 'stale-doc', severity: 'warning', description: 'Warn on stale docs (requires --check-freshness)' }
17
+ { id: 'stale-doc', severity: 'warning', description: 'Warn on stale docs (requires --check-freshness)' },
18
+ { id: 'no-trailing-spaces', severity: 'warning', description: 'No trailing whitespace' },
19
+ { id: 'no-multiple-blanks', severity: 'warning', description: 'No multiple consecutive blank lines' },
20
+ { id: 'single-trailing-newline', severity: 'warning', description: 'File must end with a single newline' },
21
+ { id: 'no-missing-space-atx', severity: 'warning', description: 'Space required after # in headings' },
22
+ { id: 'heading-start-left', severity: 'warning', description: 'Headings must not be indented' },
23
+ { id: 'no-trailing-punctuation-heading', severity: 'warning', description: 'No trailing punctuation in headings' },
24
+ { id: 'blanks-around-headings', severity: 'warning', description: 'Blank line required around headings' },
25
+ { id: 'blanks-around-lists', severity: 'warning', description: 'Blank line required around lists' },
26
+ { id: 'blanks-around-fences', severity: 'warning', description: 'Blank line required around fenced code blocks' },
27
+ { id: 'fenced-code-language', severity: 'warning', description: 'Fenced code blocks must specify a language' },
28
+ { id: 'no-bare-urls', severity: 'warning', description: 'URLs must be wrapped in <> or []()' },
29
+ { id: 'no-reversed-links', severity: 'warning', description: 'No reversed link syntax (text)[url]' },
30
+ { id: 'no-space-in-emphasis', severity: 'warning', description: 'No spaces inside emphasis markers' },
31
+ { id: 'no-space-in-links', severity: 'warning', description: 'No spaces inside link brackets' },
32
+ { id: 'no-inline-html', severity: 'warning', description: 'No inline HTML (opt-in via --check-inline-html)' }
18
33
  ];
19
34
 
20
35
  const RULE_SEVERITY = Object.fromEntries(RULE_CATALOG.map(r => [r.id, r.severity]));
@@ -89,6 +104,7 @@ function stripInlineCode(line) {
89
104
  const SUPPRESS_NEXT_LINE_RX = /<!--\s*doclify-disable-next-line\s*(.*?)\s*-->/;
90
105
  const SUPPRESS_BLOCK_START_RX = /<!--\s*doclify-disable\s*(.*?)\s*-->/;
91
106
  const SUPPRESS_BLOCK_END_RX = /<!--\s*doclify-enable\s*(.*?)\s*-->/;
107
+ const SUPPRESS_FILE_RX = /<!--\s*doclify-disable-file\s*(.*?)\s*-->/;
92
108
 
93
109
  function parseRuleIds(raw) {
94
110
  const trimmed = raw.trim();
@@ -172,6 +188,19 @@ function isSuppressed(suppressions, finding) {
172
188
  function checkMarkdown(rawContent, opts = {}) {
173
189
  const maxLineLength = Number(opts.maxLineLength ?? DEFAULTS.maxLineLength);
174
190
  const filePath = opts.filePath || undefined;
191
+
192
+ // File-level suppression: <!-- doclify-disable-file [rules] -->
193
+ const fileDisableMatch = rawContent.match(SUPPRESS_FILE_RX);
194
+ let fileDisabledRules = null;
195
+ if (fileDisableMatch) {
196
+ const ruleIds = parseRuleIds(fileDisableMatch[1]);
197
+ if (ruleIds === null) {
198
+ // All rules disabled — short-circuit
199
+ return { errors: [], warnings: [], summary: { errors: 0, warnings: 0 } };
200
+ }
201
+ fileDisabledRules = new Set(ruleIds);
202
+ }
203
+
175
204
  const errors = [];
176
205
  const warnings = [];
177
206
 
@@ -341,6 +370,183 @@ function checkMarkdown(rawContent, opts = {}) {
341
370
  }
342
371
  });
343
372
 
373
+ // Rule: no-trailing-spaces (uses raw content)
374
+ rawLines.forEach((line, idx) => {
375
+ if (/[ \t]+$/.test(line)) {
376
+ warnings.push(normalizeFinding('no-trailing-spaces', 'Trailing whitespace found.', idx + 1, filePath));
377
+ }
378
+ });
379
+
380
+ // Rule: no-multiple-blanks (uses raw content)
381
+ {
382
+ let consecutiveBlanks = 0;
383
+ rawLines.forEach((line, idx) => {
384
+ if (line.trim() === '') {
385
+ consecutiveBlanks += 1;
386
+ if (consecutiveBlanks >= 2) {
387
+ warnings.push(normalizeFinding('no-multiple-blanks', 'Multiple consecutive blank lines.', idx + 1, filePath));
388
+ }
389
+ } else {
390
+ consecutiveBlanks = 0;
391
+ }
392
+ });
393
+ }
394
+
395
+ // Rule: single-trailing-newline (uses raw content)
396
+ if (rawContent.length > 0) {
397
+ if (!rawContent.endsWith('\n')) {
398
+ warnings.push(normalizeFinding('single-trailing-newline', 'File must end with a single newline.', rawLines.length, filePath));
399
+ } else if (rawContent.endsWith('\n\n')) {
400
+ warnings.push(normalizeFinding('single-trailing-newline', 'File has multiple trailing newlines.', rawLines.length, filePath));
401
+ }
402
+ }
403
+
404
+ // Rule: no-missing-space-atx (uses stripped content)
405
+ lines.forEach((line, idx) => {
406
+ if (/^#{1,6}[^\s#]/.test(line)) {
407
+ warnings.push(normalizeFinding('no-missing-space-atx', 'Missing space after # in heading.', idx + 1, filePath));
408
+ }
409
+ });
410
+
411
+ // Rule: heading-start-left (uses stripped content)
412
+ lines.forEach((line, idx) => {
413
+ if (/^\s+#{1,6}\s/.test(line)) {
414
+ warnings.push(normalizeFinding('heading-start-left', 'Heading must start at the beginning of the line.', idx + 1, filePath));
415
+ }
416
+ });
417
+
418
+ // Rule: no-trailing-punctuation-heading (uses stripped content)
419
+ lines.forEach((line, idx) => {
420
+ const hMatch = line.match(/^#{1,6}\s+(.+)$/);
421
+ if (hMatch) {
422
+ const text = hMatch[1].trim();
423
+ if (/[.,:;!]$/.test(text)) {
424
+ warnings.push(normalizeFinding('no-trailing-punctuation-heading', `Heading has trailing punctuation: "${text.slice(-1)}".`, idx + 1, filePath));
425
+ }
426
+ }
427
+ });
428
+
429
+ // Rule: blanks-around-headings (uses stripped content)
430
+ for (let i = 0; i < lines.length; i += 1) {
431
+ if (!/^#{1,6}\s/.test(lines[i])) continue;
432
+ // Check line before heading (skip first line, skip frontmatter end)
433
+ if (i > 0 && lines[i - 1].trim() !== '' && lines[i - 1] !== '---') {
434
+ warnings.push(normalizeFinding('blanks-around-headings', 'Missing blank line before heading.', i + 1, filePath));
435
+ }
436
+ // Check line after heading
437
+ if (i < lines.length - 1 && lines[i + 1].trim() !== '') {
438
+ warnings.push(normalizeFinding('blanks-around-headings', 'Missing blank line after heading.', i + 1, filePath));
439
+ }
440
+ }
441
+
442
+ // Rule: blanks-around-lists (uses stripped content)
443
+ {
444
+ const isListItem = (l) => /^\s*[-*+]\s|^\s*\d+[.)]\s/.test(l);
445
+ for (let i = 0; i < lines.length; i += 1) {
446
+ if (!isListItem(lines[i])) continue;
447
+ // First list item — check blank before
448
+ if (i > 0 && !isListItem(lines[i - 1]) && lines[i - 1].trim() !== '') {
449
+ warnings.push(normalizeFinding('blanks-around-lists', 'Missing blank line before list.', i + 1, filePath));
450
+ }
451
+ // Last list item — check blank after
452
+ if (i < lines.length - 1 && !isListItem(lines[i + 1]) && lines[i + 1].trim() !== '') {
453
+ warnings.push(normalizeFinding('blanks-around-lists', 'Missing blank line after list.', i + 1, filePath));
454
+ }
455
+ }
456
+ }
457
+
458
+ // Rule: blanks-around-fences (uses raw content to detect fence lines)
459
+ {
460
+ let inFence = false;
461
+ for (let i = 0; i < rawLines.length; i += 1) {
462
+ const isFence = /^(`{3,}|~{3,})/.test(rawLines[i]);
463
+ if (isFence && !inFence) {
464
+ // Opening fence — check blank before
465
+ inFence = true;
466
+ if (i > 0 && rawLines[i - 1].trim() !== '') {
467
+ warnings.push(normalizeFinding('blanks-around-fences', 'Missing blank line before code block.', i + 1, filePath));
468
+ }
469
+ } else if (isFence && inFence) {
470
+ // Closing fence — check blank after
471
+ inFence = false;
472
+ if (i < rawLines.length - 1 && rawLines[i + 1].trim() !== '') {
473
+ warnings.push(normalizeFinding('blanks-around-fences', 'Missing blank line after code block.', i + 1, filePath));
474
+ }
475
+ }
476
+ }
477
+ }
478
+
479
+ // Rule: fenced-code-language (uses raw content)
480
+ rawLines.forEach((line, idx) => {
481
+ if (/^(`{3,}|~{3,})\s*$/.test(line)) {
482
+ // Only flag opening fences (not closing ones). Check if we're not inside a fence.
483
+ // Simple heuristic: count fences above this line
484
+ let fenceCount = 0;
485
+ for (let j = 0; j < idx; j += 1) {
486
+ if (/^(`{3,}|~{3,})/.test(rawLines[j])) fenceCount += 1;
487
+ }
488
+ if (fenceCount % 2 === 0) {
489
+ // Even count → this is an opening fence
490
+ warnings.push(normalizeFinding('fenced-code-language', 'Fenced code block without language specification.', idx + 1, filePath));
491
+ }
492
+ }
493
+ });
494
+
495
+ // Rule: no-bare-urls (uses stripped content)
496
+ lines.forEach((line, idx) => {
497
+ const cleanLine = stripInlineCode(line);
498
+ // Match bare URLs not inside []() or <>
499
+ const bareRx = /(?<![(<\[])\bhttps?:\/\/[^\s>)]+/g;
500
+ let m;
501
+ while ((m = bareRx.exec(cleanLine)) !== null) {
502
+ const url = m[0];
503
+ const before = cleanLine.substring(0, m.index);
504
+ // Skip if inside markdown link [text](url) or <url>
505
+ if (/\]\($/.test(before) || before.endsWith('<')) continue;
506
+ // Skip if inside reference-style [label]: url
507
+ if (/^\[[^\]]*\]:\s*/.test(cleanLine)) continue;
508
+ warnings.push(normalizeFinding('no-bare-urls', `Bare URL found: ${url} — wrap in <> or use [text](url).`, idx + 1, filePath));
509
+ }
510
+ });
511
+
512
+ // Rule: no-reversed-links (uses stripped content)
513
+ lines.forEach((line, idx) => {
514
+ const cleanLine = stripInlineCode(line);
515
+ if (/\([^)]+\)\[[^\]]+\]/.test(cleanLine)) {
516
+ warnings.push(normalizeFinding('no-reversed-links', 'Reversed link syntax: (text)[url] should be [text](url).', idx + 1, filePath));
517
+ }
518
+ });
519
+
520
+ // Rule: no-space-in-emphasis (uses stripped content)
521
+ lines.forEach((line, idx) => {
522
+ const cleanLine = stripInlineCode(line);
523
+ if (/\*\*\s+[^*]+\s+\*\*/.test(cleanLine) || /\*\s+[^*]+\s+\*(?!\*)/.test(cleanLine)) {
524
+ warnings.push(normalizeFinding('no-space-in-emphasis', 'Spaces inside emphasis markers — may not render correctly.', idx + 1, filePath));
525
+ }
526
+ });
527
+
528
+ // Rule: no-space-in-links (uses stripped content)
529
+ lines.forEach((line, idx) => {
530
+ const cleanLine = stripInlineCode(line);
531
+ if (/\[\s+[^\]]*\]\(/.test(cleanLine) || /\[[^\]]*\s+\]\(/.test(cleanLine)) {
532
+ warnings.push(normalizeFinding('no-space-in-links', 'Spaces inside link text brackets.', idx + 1, filePath));
533
+ }
534
+ if (/\]\(\s+[^)]*\)/.test(cleanLine) || /\]\([^)]*\s+\)/.test(cleanLine)) {
535
+ warnings.push(normalizeFinding('no-space-in-links', 'Spaces inside link URL parentheses.', idx + 1, filePath));
536
+ }
537
+ });
538
+
539
+ // Rule: no-inline-html (opt-in via --check-inline-html or config)
540
+ if (opts.checkInlineHtml) {
541
+ lines.forEach((line, idx) => {
542
+ const cleanLine = stripInlineCode(line);
543
+ // Match HTML tags but skip comments (<!-- -->)
544
+ if (/<[a-zA-Z/][^>]*>/.test(cleanLine) && !cleanLine.includes('<!--')) {
545
+ warnings.push(normalizeFinding('no-inline-html', 'Inline HTML found.', idx + 1, filePath));
546
+ }
547
+ });
548
+ }
549
+
344
550
  // Custom rules (uses stripped content)
345
551
  if (opts.customRules && opts.customRules.length > 0) {
346
552
  lines.forEach((line, idx) => {
@@ -355,9 +561,11 @@ function checkMarkdown(rawContent, opts = {}) {
355
561
  });
356
562
  }
357
563
 
358
- // Apply inline suppressions
359
- const filteredErrors = errors.filter(f => !isSuppressed(suppressions, f));
360
- const filteredWarnings = warnings.filter(f => !isSuppressed(suppressions, f));
564
+ // Apply inline suppressions + file-level suppression
565
+ const isFileSuppressed = (f) =>
566
+ fileDisabledRules && (fileDisabledRules.has(f.code));
567
+ const filteredErrors = errors.filter(f => !isSuppressed(suppressions, f) && !isFileSuppressed(f));
568
+ const filteredWarnings = warnings.filter(f => !isSuppressed(suppressions, f) && !isFileSuppressed(f));
361
569
 
362
570
  return {
363
571
  errors: filteredErrors,
package/src/colors.mjs CHANGED
@@ -9,6 +9,7 @@ const CODES = {
9
9
  };
10
10
 
11
11
  let enabled = true;
12
+ let asciiMode = false;
12
13
 
13
14
  function initColors(noColorFlag) {
14
15
  if (noColorFlag || !process.stderr.isTTY || process.env.NO_COLOR !== undefined) {
@@ -16,6 +17,10 @@ function initColors(noColorFlag) {
16
17
  }
17
18
  }
18
19
 
20
+ function setAsciiMode(flag) {
21
+ asciiMode = Boolean(flag);
22
+ }
23
+
19
24
  function wrap(code, text) {
20
25
  if (!enabled) return text;
21
26
  return `${code}${text}${CODES.reset}`;
@@ -30,6 +35,14 @@ const c = {
30
35
  bold: (t) => wrap(CODES.bold, t)
31
36
  };
32
37
 
38
+ const icons = {
39
+ get pass() { return asciiMode ? '[PASS]' : '\u2713'; },
40
+ get fail() { return asciiMode ? '[FAIL]' : '\u2717'; },
41
+ get warn() { return asciiMode ? '[WARN]' : '\u26A0'; },
42
+ get info() { return asciiMode ? '[INFO]' : '\u2139'; },
43
+ get dot() { return asciiMode ? '-' : '\u00B7'; }
44
+ };
45
+
33
46
  function log(icon, message) {
34
47
  console.error(` ${icon} ${message}`);
35
48
  }
@@ -38,12 +51,13 @@ function printBanner(fileCount, version) {
38
51
  console.error('');
39
52
  console.error(` ${c.bold('Doclify Guardrail')} ${c.dim(`v${version || '?'}`)}`);
40
53
  console.error('');
41
- log(c.cyan('ℹ'), `Scanning ${c.bold(String(fileCount))} file${fileCount === 1 ? '' : 's'}...`);
54
+ log(c.cyan(icons.info), `Scanning ${c.bold(String(fileCount))} file${fileCount === 1 ? '' : 's'}...`);
42
55
  }
43
56
 
44
57
  function printResults(output) {
58
+ const strict = output.strict;
45
59
  for (const fileResult of output.files) {
46
- const icon = fileResult.pass ? c.green('\u2713') : c.red('\u2717');
60
+ const icon = fileResult.pass ? c.green(icons.pass) : c.red(icons.fail);
47
61
  const fileName = c.bold(fileResult.file);
48
62
  const score = fileResult.summary.healthScore != null ? c.dim(` [${fileResult.summary.healthScore}/100]`) : '';
49
63
  console.error(` ${icon} ${fileName}${score}`);
@@ -55,16 +69,21 @@ function printResults(output) {
55
69
 
56
70
  for (const f of allFindings) {
57
71
  const lineStr = f.line != null ? c.dim(`:${f.line}`) : '';
58
- const sevLabel = f.severity === 'error'
59
- ? c.red(`\u2717 ${f.severity}`)
60
- : c.yellow(`\u26A0 ${f.severity}`);
72
+ let sevLabel;
73
+ if (f.severity === 'error') {
74
+ sevLabel = c.red(`${icons.fail} error`);
75
+ } else if (strict) {
76
+ sevLabel = c.red(`${icons.fail} error [strict]`);
77
+ } else {
78
+ sevLabel = c.yellow(`${icons.warn} warning`);
79
+ }
61
80
  console.error(` ${sevLabel} ${c.cyan(f.code)}${lineStr} ${f.message}`);
62
81
  }
63
82
  }
64
83
 
65
84
  if (output.fileErrors) {
66
85
  for (const fe of output.fileErrors) {
67
- console.error(` ${c.red('\u2717')} ${c.bold(fe.file)} ${c.red(fe.error)}`);
86
+ console.error(` ${c.red(icons.fail)} ${c.bold(fe.file)} ${c.red(fe.error)}`);
68
87
  }
69
88
  }
70
89
 
@@ -72,11 +91,11 @@ function printResults(output) {
72
91
 
73
92
  const s = output.summary;
74
93
  const parts = [];
75
- if (s.filesPassed > 0) parts.push(c.green(`\u2713 ${s.filesPassed} passed`));
76
- if (s.filesFailed > 0) parts.push(c.red(`\u2717 ${s.filesFailed} failed`));
94
+ if (s.filesPassed > 0) parts.push(c.green(`${icons.pass} ${s.filesPassed} passed`));
95
+ if (s.filesFailed > 0) parts.push(c.red(`${icons.fail} ${s.filesFailed} failed`));
77
96
  if (s.filesErrored > 0) parts.push(c.red(`${s.filesErrored} errored`));
78
97
  console.error(
79
- ` ${parts.join(c.dim(' \u00B7 '))} ${c.dim('\u00B7')} ${s.filesScanned} files scanned in ${c.dim(`${s.elapsed}s`)}`
98
+ ` ${parts.join(c.dim(` ${icons.dot} `))} ${c.dim(icons.dot)} ${s.filesScanned} files scanned in ${c.dim(`${s.elapsed}s`)}`
80
99
  );
81
100
 
82
101
  const scoreValue = s.healthScore != null ? s.healthScore : s.avgHealthScore;
@@ -93,4 +112,4 @@ function printResults(output) {
93
112
  console.error('');
94
113
  }
95
114
 
96
- export { initColors, c, log, printBanner, printResults };
115
+ export { initColors, setAsciiMode, icons, c, log, printBanner, printResults };
package/src/fixer.mjs CHANGED
@@ -14,20 +14,52 @@ function autoFixInsecureLinks(content) {
14
14
  const changes = [];
15
15
  const ambiguous = [];
16
16
 
17
- const fixed = content.replace(/http:\/\/\S+/g, (raw) => {
18
- const cleaned = raw.replace(/[),.;!?]+$/g, '');
19
- if (isAmbiguousHttpUrl(cleaned)) {
20
- ambiguous.push(cleaned);
21
- return raw;
22
- }
17
+ const lines = content.split('\n');
18
+ let inCodeBlock = false;
19
+ let fenceChar = null;
20
+ let fenceLen = 0;
23
21
 
24
- const replaced = raw.replace('http://', 'https://');
25
- if (replaced !== raw) {
26
- changes.push({ from: cleaned, to: cleaned.replace('http://', 'https://') });
22
+ const processedLines = lines.map((line) => {
23
+ // Track fenced code blocks (same logic as stripCodeBlocks in checker.mjs)
24
+ if (!inCodeBlock) {
25
+ const fenceMatch = line.match(/^(`{3,}|~{3,})/);
26
+ if (fenceMatch) {
27
+ inCodeBlock = true;
28
+ fenceChar = fenceMatch[1][0];
29
+ fenceLen = fenceMatch[1].length;
30
+ return line;
31
+ }
32
+ } else {
33
+ const closeMatch = line.match(/^(`{3,}|~{3,})\s*$/);
34
+ if (closeMatch && closeMatch[1][0] === fenceChar && closeMatch[1].length >= fenceLen) {
35
+ inCodeBlock = false;
36
+ fenceChar = null;
37
+ fenceLen = 0;
38
+ }
39
+ return line;
27
40
  }
28
- return replaced;
41
+
42
+ // Outside code blocks: replace http:// but skip inline code spans
43
+ return line.replace(/(`[^`]*`)|http:\/\/\S+/g, (match, inlineCode) => {
44
+ if (inlineCode) return inlineCode;
45
+
46
+ const raw = match;
47
+ const cleaned = raw.replace(/[),.;!?]+$/g, '');
48
+ if (isAmbiguousHttpUrl(cleaned)) {
49
+ ambiguous.push(cleaned);
50
+ return raw;
51
+ }
52
+
53
+ const replaced = raw.replace('http://', 'https://');
54
+ if (replaced !== raw) {
55
+ changes.push({ from: cleaned, to: cleaned.replace('http://', 'https://') });
56
+ }
57
+ return replaced;
58
+ });
29
59
  });
30
60
 
61
+ const fixed = processedLines.join('\n');
62
+
31
63
  return {
32
64
  content: fixed,
33
65
  modified: fixed !== content,
@@ -36,4 +68,176 @@ function autoFixInsecureLinks(content) {
36
68
  };
37
69
  }
38
70
 
39
- export { autoFixInsecureLinks, isAmbiguousHttpUrl };
71
+ function autoFixFormatting(content) {
72
+ const changes = [];
73
+ let lines = content.split('\n');
74
+
75
+ // Track code blocks to skip them
76
+ let inCodeBlock = false;
77
+ let fenceChar = null;
78
+ let fenceLen = 0;
79
+ const inCode = lines.map((line) => {
80
+ if (!inCodeBlock) {
81
+ const fm = line.match(/^(`{3,}|~{3,})/);
82
+ if (fm) { inCodeBlock = true; fenceChar = fm[1][0]; fenceLen = fm[1].length; return true; }
83
+ return false;
84
+ }
85
+ const cm = line.match(/^(`{3,}|~{3,})\s*$/);
86
+ if (cm && cm[1][0] === fenceChar && cm[1].length >= fenceLen) {
87
+ inCodeBlock = false; fenceChar = null; fenceLen = 0;
88
+ }
89
+ return true;
90
+ });
91
+
92
+ // Pass 1: line-level fixes
93
+ lines = lines.map((line, i) => {
94
+ if (inCode[i]) return line;
95
+ let fixed = line;
96
+
97
+ // Fix: trailing spaces
98
+ const trimmed = fixed.replace(/[ \t]+$/, '');
99
+ if (trimmed !== fixed) { changes.push({ rule: 'no-trailing-spaces', line: i + 1 }); fixed = trimmed; }
100
+
101
+ // Fix: missing space after # in heading
102
+ const atxMatch = fixed.match(/^(#{1,6})([^\s#])/);
103
+ if (atxMatch) { changes.push({ rule: 'no-missing-space-atx', line: i + 1 }); fixed = atxMatch[1] + ' ' + fixed.slice(atxMatch[1].length); }
104
+
105
+ // Fix: indented heading
106
+ const indentMatch = fixed.match(/^(\s+)(#{1,6}\s)/);
107
+ if (indentMatch) { changes.push({ rule: 'heading-start-left', line: i + 1 }); fixed = fixed.trimStart(); }
108
+
109
+ // Fix: trailing punctuation in heading
110
+ const hMatch = fixed.match(/^(#{1,6}\s+.+?)([.,:;!])$/);
111
+ if (hMatch) { changes.push({ rule: 'no-trailing-punctuation-heading', line: i + 1 }); fixed = hMatch[1]; }
112
+
113
+ // Fix: reversed links (text)[url] → [text](url)
114
+ fixed = fixed.replace(/\(([^)]+)\)\[([^\]]+)\]/g, (match, text, url) => {
115
+ changes.push({ rule: 'no-reversed-links', line: i + 1 });
116
+ return `[${text}](${url})`;
117
+ });
118
+
119
+ // Fix: spaces in emphasis ** text ** → **text**
120
+ fixed = fixed.replace(/\*\*\s+([^*]+?)\s+\*\*/g, (match, inner) => {
121
+ changes.push({ rule: 'no-space-in-emphasis', line: i + 1 });
122
+ return `**${inner}**`;
123
+ });
124
+ fixed = fixed.replace(/(?<!\*)\*\s+([^*]+?)\s+\*(?!\*)/g, (match, inner) => {
125
+ changes.push({ rule: 'no-space-in-emphasis', line: i + 1 });
126
+ return `*${inner}*`;
127
+ });
128
+
129
+ // Fix: spaces in links [ text ](url) → [text](url)
130
+ fixed = fixed.replace(/\[\s+([^\]]*?)\s*\]\(/g, (match, text) => {
131
+ changes.push({ rule: 'no-space-in-links', line: i + 1 });
132
+ return `[${text}](`;
133
+ });
134
+ fixed = fixed.replace(/\]\(\s+([^)]*?)\s*\)/g, (match, url) => {
135
+ changes.push({ rule: 'no-space-in-links', line: i + 1 });
136
+ return `](${url})`;
137
+ });
138
+
139
+ // Fix: bare URLs → wrap in <>
140
+ fixed = fixed.replace(/(`[^`]*`)|(?<![(<\[])\bhttps?:\/\/[^\s>)]+/g, (match, code) => {
141
+ if (code) return code;
142
+ // Don't wrap if already inside markdown link or reference
143
+ changes.push({ rule: 'no-bare-urls', line: i + 1 });
144
+ return `<${match}>`;
145
+ });
146
+
147
+ return fixed;
148
+ });
149
+
150
+ // Pass 2: multi-line fixes (blanks around headings/lists/fences, multiple blanks)
151
+ const result = [];
152
+ const isListItem = (l) => /^\s*[-*+]\s|^\s*\d+[.)]\s/.test(l);
153
+ const isHeading = (l) => /^#{1,6}\s/.test(l);
154
+ const isFenceOpen = (l) => /^(`{3,}|~{3,})/.test(l);
155
+
156
+ // Rebuild inCode flags after line-level fixes
157
+ inCodeBlock = false;
158
+ fenceChar = null;
159
+ fenceLen = 0;
160
+ const inCode2 = lines.map((line) => {
161
+ if (!inCodeBlock) {
162
+ const fm = line.match(/^(`{3,}|~{3,})/);
163
+ if (fm) { inCodeBlock = true; fenceChar = fm[1][0]; fenceLen = fm[1].length; return true; }
164
+ return false;
165
+ }
166
+ const cm = line.match(/^(`{3,}|~{3,})\s*$/);
167
+ if (cm && cm[1][0] === fenceChar && cm[1].length >= fenceLen) {
168
+ inCodeBlock = false; fenceChar = null; fenceLen = 0;
169
+ }
170
+ return true;
171
+ });
172
+
173
+ let prevBlank = true; // treat start of file as blank
174
+ let consecutiveBlanks = 0;
175
+
176
+ for (let i = 0; i < lines.length; i += 1) {
177
+ const line = lines[i];
178
+ const isBlank = line.trim() === '';
179
+ const codeFlag = inCode2[i];
180
+
181
+ // Fix: multiple consecutive blanks (outside code blocks)
182
+ if (isBlank && !codeFlag) {
183
+ consecutiveBlanks += 1;
184
+ if (consecutiveBlanks >= 2) {
185
+ changes.push({ rule: 'no-multiple-blanks', line: i + 1 });
186
+ continue; // skip extra blank
187
+ }
188
+ } else {
189
+ consecutiveBlanks = 0;
190
+ }
191
+
192
+ // Fix: blank before heading/list/fence (only if not already preceded by blank)
193
+ if (!codeFlag && !prevBlank && !isBlank) {
194
+ const needsBlankBefore = isHeading(line) || (isListItem(line) && i > 0 && !isListItem(lines[i - 1])) || isFenceOpen(line);
195
+ if (needsBlankBefore) {
196
+ const ruleId = isHeading(line) ? 'blanks-around-headings' : isListItem(line) ? 'blanks-around-lists' : 'blanks-around-fences';
197
+ changes.push({ rule: ruleId, line: i + 1 });
198
+ result.push('');
199
+ }
200
+ }
201
+
202
+ result.push(line);
203
+
204
+ // Fix: blank after heading/fence-close/last-list-item
205
+ if (!codeFlag && !isBlank) {
206
+ const nextLine = lines[i + 1];
207
+ const nextIsBlank = nextLine === undefined || nextLine.trim() === '';
208
+ if (!nextIsBlank && nextLine !== undefined) {
209
+ const nextCode = inCode2[i + 1];
210
+ if (!nextCode) {
211
+ const needsBlankAfter = isHeading(line) ||
212
+ (isListItem(line) && !isListItem(nextLine)) ||
213
+ (isFenceOpen(line) === false && i > 0 && /^(`{3,}|~{3,})\s*$/.test(line));
214
+ if (needsBlankAfter) {
215
+ const ruleId = isHeading(line) ? 'blanks-around-headings' : isListItem(line) ? 'blanks-around-lists' : 'blanks-around-fences';
216
+ changes.push({ rule: ruleId, line: i + 1 });
217
+ result.push('');
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ prevBlank = isBlank;
224
+ }
225
+
226
+ // Fix: single trailing newline
227
+ let fixed = result.join('\n');
228
+ if (fixed.length > 0) {
229
+ const trimmedEnd = fixed.replace(/\n+$/, '');
230
+ if (trimmedEnd + '\n' !== fixed) {
231
+ changes.push({ rule: 'single-trailing-newline', line: result.length });
232
+ fixed = trimmedEnd + '\n';
233
+ }
234
+ }
235
+
236
+ return {
237
+ content: fixed,
238
+ modified: fixed !== content,
239
+ changes
240
+ };
241
+ }
242
+
243
+ export { autoFixInsecureLinks, autoFixFormatting, isAmbiguousHttpUrl };
package/src/index.mjs CHANGED
@@ -6,9 +6,9 @@ import { checkMarkdown, RULE_CATALOG } from './checker.mjs';
6
6
  import { resolveFileList } from './glob.mjs';
7
7
  import { generateReport } from './report.mjs';
8
8
  import { loadCustomRules } from './rules-loader.mjs';
9
- import { initColors, c, log, printBanner, printResults } from './colors.mjs';
9
+ import { initColors, setAsciiMode, icons, c, log, printBanner, printResults } from './colors.mjs';
10
10
  import { checkDeadLinks } from './links.mjs';
11
- import { autoFixInsecureLinks } from './fixer.mjs';
11
+ import { autoFixInsecureLinks, autoFixFormatting } from './fixer.mjs';
12
12
  import { computeDocHealthScore, checkDocFreshness } from './quality.mjs';
13
13
  import {
14
14
  generateJUnitReport,
@@ -17,6 +17,19 @@ import {
17
17
  } from './ci-output.mjs';
18
18
 
19
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+
21
+ function writeJsonToStdout(data) {
22
+ return new Promise((resolve, reject) => {
23
+ const json = JSON.stringify(data, null, 2) + '\n';
24
+ const flushed = process.stdout.write(json, 'utf8');
25
+ if (flushed) {
26
+ resolve();
27
+ } else {
28
+ process.stdout.once('drain', resolve);
29
+ process.stdout.once('error', reject);
30
+ }
31
+ });
32
+ }
20
33
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
21
34
  const VERSION = pkg.version;
22
35
 
@@ -64,10 +77,12 @@ function printHelp() {
64
77
 
65
78
  ${y('SETUP')}
66
79
  init Generate a .doclify-guardrail.json config
80
+ init --force Overwrite existing config
67
81
 
68
82
  ${y('OTHER')}
69
83
  --list-rules List all built-in rules
70
84
  --no-color Disable colored output
85
+ --ascii Use ASCII icons ${d('(for CI without UTF-8)')}
71
86
  --debug Show debug info
72
87
  -h, --help Show this help
73
88
 
@@ -124,10 +139,13 @@ function parseArgs(argv) {
124
139
  checkLinks: false,
125
140
  checkFreshness: false,
126
141
  checkFrontmatter: false,
142
+ checkInlineHtml: false,
127
143
  linkAllowList: [],
128
144
  fix: false,
129
145
  dryRun: false,
130
- json: false
146
+ json: false,
147
+ force: false,
148
+ ascii: false
131
149
  };
132
150
 
133
151
  for (let i = 0; i < argv.length; i += 1) {
@@ -198,6 +216,11 @@ function parseArgs(argv) {
198
216
  continue;
199
217
  }
200
218
 
219
+ if (a === '--check-inline-html') {
220
+ args.checkInlineHtml = true;
221
+ continue;
222
+ }
223
+
201
224
  if (a === '--link-allow-list') {
202
225
  const value = argv[i + 1];
203
226
  if (!value || value.startsWith('-')) {
@@ -223,6 +246,16 @@ function parseArgs(argv) {
223
246
  continue;
224
247
  }
225
248
 
249
+ if (a === '--force') {
250
+ args.force = true;
251
+ continue;
252
+ }
253
+
254
+ if (a === '--ascii') {
255
+ args.ascii = true;
256
+ continue;
257
+ }
258
+
226
259
  if (a === '--config') {
227
260
  const value = argv[i + 1];
228
261
  if (!value || value.startsWith('-')) {
@@ -354,6 +387,8 @@ function resolveOptions(args) {
354
387
  const checkFrontmatter = Boolean(args.checkFrontmatter || cfg.checkFrontmatter || args.checkFreshness || cfg.checkFreshness);
355
388
  const cfgAllowList = Array.isArray(cfg.linkAllowList) ? cfg.linkAllowList : [];
356
389
  const linkAllowList = [...args.linkAllowList, ...cfgAllowList];
390
+ const cfgExclude = Array.isArray(cfg.exclude) ? cfg.exclude : [];
391
+ const exclude = [...args.exclude, ...cfgExclude];
357
392
 
358
393
  return {
359
394
  maxLineLength,
@@ -361,6 +396,7 @@ function resolveOptions(args) {
361
396
  ignoreRules,
362
397
  checkFrontmatter,
363
398
  linkAllowList,
399
+ exclude,
364
400
  configPath: args.configPath,
365
401
  configLoaded: fs.existsSync(args.configPath)
366
402
  };
@@ -450,6 +486,7 @@ async function runCli(argv = process.argv.slice(2)) {
450
486
 
451
487
  if (args.listRules) {
452
488
  initColors(args.noColor);
489
+ setAsciiMode(args.ascii);
453
490
  console.log('');
454
491
  console.log(` ${c.bold('Built-in rules')}`);
455
492
  console.log('');
@@ -463,11 +500,13 @@ async function runCli(argv = process.argv.slice(2)) {
463
500
 
464
501
  if (args.init) {
465
502
  initColors(args.noColor);
503
+ setAsciiMode(args.ascii);
466
504
  const configFile = '.doclify-guardrail.json';
467
505
  const configPath = path.resolve(configFile);
468
506
 
469
- if (fs.existsSync(configPath)) {
470
- console.error(` ${c.yellow('⚠')} ${c.bold(configFile)} already exists. Remove it first to re-initialize.`);
507
+ const configExists = fs.existsSync(configPath);
508
+ if (configExists && !args.force) {
509
+ console.error(` ${c.yellow(icons.warn)} ${c.bold(configFile)} already exists. Use ${c.bold('--force')} to overwrite.`);
471
510
  return 1;
472
511
  }
473
512
 
@@ -475,6 +514,7 @@ async function runCli(argv = process.argv.slice(2)) {
475
514
  strict: false,
476
515
  maxLineLength: 160,
477
516
  ignoreRules: [],
517
+ exclude: [],
478
518
  checkLinks: false,
479
519
  checkFreshness: false,
480
520
  checkFrontmatter: false,
@@ -483,7 +523,7 @@ async function runCli(argv = process.argv.slice(2)) {
483
523
 
484
524
  fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf8');
485
525
  console.error('');
486
- console.error(` ${c.green('✓')} Created ${c.bold(configFile)}`);
526
+ console.error(` ${c.green(icons.pass)} ${configExists ? 'Overwrote' : 'Created'} ${c.bold(configFile)}`);
487
527
  console.error('');
488
528
  console.error(` ${c.dim('Edit the file to customise rules, then run:')}`)
489
529
  console.error(` ${c.dim('$')} ${c.cyan('doclify .')}`);
@@ -492,6 +532,7 @@ async function runCli(argv = process.argv.slice(2)) {
492
532
  }
493
533
 
494
534
  initColors(args.noColor);
535
+ setAsciiMode(args.ascii);
495
536
 
496
537
  let filePaths;
497
538
  try {
@@ -501,10 +542,18 @@ async function runCli(argv = process.argv.slice(2)) {
501
542
  return 2;
502
543
  }
503
544
 
504
- if (args.exclude.length > 0) {
545
+ let resolved;
546
+ try {
547
+ resolved = resolveOptions(args);
548
+ } catch (err) {
549
+ console.error(err.message);
550
+ return 2;
551
+ }
552
+
553
+ if (resolved.exclude.length > 0) {
505
554
  filePaths = filePaths.filter(fp => {
506
555
  const rel = path.relative(process.cwd(), fp);
507
- return !args.exclude.some(pattern => {
556
+ return !resolved.exclude.some(pattern => {
508
557
  if (pattern.includes('*')) {
509
558
  const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*') + '$');
510
559
  return regex.test(rel);
@@ -520,14 +569,6 @@ async function runCli(argv = process.argv.slice(2)) {
520
569
  return 2;
521
570
  }
522
571
 
523
- let resolved;
524
- try {
525
- resolved = resolveOptions(args);
526
- } catch (err) {
527
- console.error(err.message);
528
- return 2;
529
- }
530
-
531
572
  let customRules = [];
532
573
  if (args.rules) {
533
574
  try {
@@ -541,7 +582,7 @@ async function runCli(argv = process.argv.slice(2)) {
541
582
  printBanner(filePaths.length, VERSION);
542
583
 
543
584
  if (resolved.configLoaded) {
544
- log(c.cyan('ℹ'), `Loaded config from ${c.dim(resolved.configPath)}`);
585
+ log(c.cyan(icons.info), `Loaded config from ${c.dim(resolved.configPath)}`);
545
586
  }
546
587
 
547
588
  const startTime = process.hrtime.bigint();
@@ -557,7 +598,17 @@ async function runCli(argv = process.argv.slice(2)) {
557
598
 
558
599
  let customRulesCount = customRules.length;
559
600
  if (customRulesCount > 0) {
560
- log(c.cyan('ℹ'), `Loaded ${c.bold(String(customRulesCount))} custom rules from ${c.dim(args.rules)}`);
601
+ log(c.cyan(icons.info), `Loaded ${c.bold(String(customRulesCount))} custom rules from ${c.dim(args.rules)}`);
602
+ }
603
+
604
+ if (resolved.ignoreRules.size > 0) {
605
+ const knownIds = new Set(RULE_CATALOG.map(r => r.id));
606
+ for (const id of customRules) knownIds.add(id.id);
607
+ for (const id of resolved.ignoreRules) {
608
+ if (!knownIds.has(id)) {
609
+ log(c.yellow(icons.warn), `Unknown rule "${c.bold(id)}" in --ignore-rules (ignored)`);
610
+ }
611
+ }
561
612
  }
562
613
 
563
614
  console.error('');
@@ -565,12 +616,13 @@ async function runCli(argv = process.argv.slice(2)) {
565
616
  for (const filePath of filePaths) {
566
617
  const rel = path.relative(process.cwd(), filePath);
567
618
  const shortPath = rel.startsWith('..') ? filePath : rel || filePath;
568
- log(c.cyan('ℹ'), `Checking ${c.bold(shortPath)}...`);
619
+ log(c.cyan(icons.info), `Checking ${c.bold(shortPath)}...`);
569
620
 
570
621
  try {
571
622
  let content = fs.readFileSync(filePath, 'utf8');
572
623
 
573
624
  if (args.fix) {
625
+ // 1. Insecure links fix (http:// → https://)
574
626
  log(c.dim(' ↳'), c.dim(`Auto-fixing insecure links...`));
575
627
  const fixed = autoFixInsecureLinks(content);
576
628
  if (fixed.modified) {
@@ -582,9 +634,8 @@ async function runCli(argv = process.argv.slice(2)) {
582
634
  }
583
635
  }
584
636
  if (!args.dryRun) {
585
- fs.writeFileSync(filePath, fixed.content, 'utf8');
637
+ content = fixed.content;
586
638
  }
587
- content = fixed.content;
588
639
  }
589
640
  if (fixed.ambiguous.length > 0) {
590
641
  fixSummary.ambiguousSkipped.push({
@@ -595,6 +646,21 @@ async function runCli(argv = process.argv.slice(2)) {
595
646
  log(c.dim(' '), c.dim(`⊘ skipped ${url} (localhost/custom port)`));
596
647
  }
597
648
  }
649
+
650
+ // 2. Formatting fixes (trailing spaces, blanks, headings, bare URLs, etc.)
651
+ const formatted = autoFixFormatting(content);
652
+ if (formatted.modified) {
653
+ if (!args.dryRun) {
654
+ content = formatted.content;
655
+ }
656
+ const ruleSet = new Set(formatted.changes.map(ch => ch.rule));
657
+ log(c.dim(' ↳'), c.dim(`Formatting: ${formatted.changes.length} fix${formatted.changes.length === 1 ? '' : 'es'} (${[...ruleSet].join(', ')})${args.dryRun ? ' [dry-run]' : ''}`));
658
+ }
659
+
660
+ // Write all changes to disk
661
+ if (!args.dryRun && (fixed.modified || formatted.modified)) {
662
+ fs.writeFileSync(filePath, content, 'utf8');
663
+ }
598
664
  }
599
665
 
600
666
  const relPath = toRelativePath(filePath);
@@ -602,7 +668,8 @@ async function runCli(argv = process.argv.slice(2)) {
602
668
  maxLineLength: resolved.maxLineLength,
603
669
  filePath: relPath,
604
670
  customRules,
605
- checkFrontmatter: resolved.checkFrontmatter
671
+ checkFrontmatter: resolved.checkFrontmatter,
672
+ checkInlineHtml: Boolean(args.checkInlineHtml)
606
673
  });
607
674
 
608
675
  if (args.checkLinks) {
@@ -638,21 +705,21 @@ async function runCli(argv = process.argv.slice(2)) {
638
705
  if (args.fix && fixSummary.replacements > 0) {
639
706
  const action = args.dryRun ? 'Would fix' : 'Fixed';
640
707
  log(
641
- args.dryRun ? c.yellow('~') : c.green('✓'),
708
+ args.dryRun ? c.yellow('~') : c.green(icons.pass),
642
709
  `${action} ${c.bold(String(fixSummary.replacements))} insecure link${fixSummary.replacements === 1 ? '' : 's'} in ${c.bold(String(fixSummary.filesChanged))} file${fixSummary.filesChanged === 1 ? '' : 's'}${args.dryRun ? c.dim(' (dry-run, no files changed)') : ''}`
643
710
  );
644
711
  } else if (args.fix && fixSummary.replacements === 0) {
645
- log(c.dim('ℹ'), c.dim('No insecure links to fix'));
712
+ log(c.dim(icons.info), c.dim('No insecure links to fix'));
646
713
  }
647
714
 
648
715
  if (args.json) {
649
- console.log(JSON.stringify(output, null, 2));
716
+ await writeJsonToStdout(output);
650
717
  }
651
718
 
652
719
  if (args.report) {
653
720
  try {
654
721
  const reportPath = generateReport(output, { reportPath: args.report });
655
- log(c.green('✓'), `Report written ${c.dim('→')} ${reportPath}`);
722
+ log(c.green(icons.pass), `Report written ${c.dim('→')} ${reportPath}`);
656
723
  } catch (err) {
657
724
  console.error(`Failed to write report: ${err.message}`);
658
725
  return 2;
@@ -662,7 +729,7 @@ async function runCli(argv = process.argv.slice(2)) {
662
729
  if (args.junit) {
663
730
  try {
664
731
  const junitPath = generateJUnitReport(output, { junitPath: args.junit });
665
- log(c.green('✓'), `JUnit report written ${c.dim('→')} ${junitPath}`);
732
+ log(c.green(icons.pass), `JUnit report written ${c.dim('→')} ${junitPath}`);
666
733
  } catch (err) {
667
734
  console.error(`Failed to write JUnit report: ${err.message}`);
668
735
  return 2;
@@ -672,7 +739,7 @@ async function runCli(argv = process.argv.slice(2)) {
672
739
  if (args.sarif) {
673
740
  try {
674
741
  const sarifPath = generateSarifReport(output, { sarifPath: args.sarif });
675
- log(c.green('✓'), `SARIF report written ${c.dim('→')} ${sarifPath}`);
742
+ log(c.green(icons.pass), `SARIF report written ${c.dim('→')} ${sarifPath}`);
676
743
  } catch (err) {
677
744
  console.error(`Failed to write SARIF report: ${err.message}`);
678
745
  return 2;
@@ -682,7 +749,7 @@ async function runCli(argv = process.argv.slice(2)) {
682
749
  if (args.badge) {
683
750
  try {
684
751
  const badge = generateBadge(output, { badgePath: args.badge, label: args.badgeLabel });
685
- log(c.green('✓'), `Badge written ${c.dim('→')} ${badge.badgePath} ${c.dim(`(score ${badge.score})`)}`);
752
+ log(c.green(icons.pass), `Badge written ${c.dim('→')} ${badge.badgePath} ${c.dim(`(score ${badge.score})`)}`);
686
753
  } catch (err) {
687
754
  console.error(`Failed to write badge: ${err.message}`);
688
755
  return 2;
package/src/links.mjs CHANGED
@@ -14,7 +14,7 @@ function extractLinks(content) {
14
14
  const line = stripInlineCode(lines[idx]);
15
15
  const lineNumber = idx + 1;
16
16
 
17
- const inlineRx = /\[[^\]]*\]\(([^)]+)\)/g;
17
+ const inlineRx = /\[[^\]]*\]\(([^()\s]*(?:\([^)]*\)[^()\s]*)*)\)/g;
18
18
  let inline;
19
19
  while ((inline = inlineRx.exec(line)) !== null) {
20
20
  links.push({ url: inline[1].trim(), line: lineNumber, kind: 'inline' });
@@ -33,10 +33,10 @@ function extractLinks(content) {
33
33
  }
34
34
  }
35
35
 
36
- // Remove trailing punctuation from bare URL captures
36
+ // Remove trailing punctuation from bare/reference URL captures (not inline — already delimited by markdown syntax)
37
37
  return links.map((l) => ({
38
38
  ...l,
39
- url: l.url.replace(/[),.;!?]+$/g, '')
39
+ url: l.kind === 'inline' ? l.url : l.url.replace(/[),.;!?]+$/g, '')
40
40
  }));
41
41
  }
42
42
 
package/src/quality.mjs CHANGED
@@ -7,7 +7,11 @@ function clamp(value, min, max) {
7
7
  }
8
8
 
9
9
  function computeDocHealthScore({ errors = 0, warnings = 0 }) {
10
- const raw = 100 - (errors * 25) - (warnings * 8);
10
+ const errorPenalty = errors * 20;
11
+ const warningPenalty = warnings > 0
12
+ ? 5 * Math.sqrt(warnings) + (warnings * 2)
13
+ : 0;
14
+ const raw = 100 - errorPenalty - warningPenalty;
11
15
  return clamp(Math.round(raw), 0, 100);
12
16
  }
13
17