doclify-guardrail 1.2.0 → 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.0",
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]));
@@ -32,10 +47,10 @@ const PLACEHOLDER_PATTERNS = [
32
47
  { rx: /\bxxx\b/i, msg: '"xxx" placeholder found' }
33
48
  ];
34
49
 
35
- function normalizeFinding(rule, message, line, source) {
50
+ function normalizeFinding(rule, message, line, source, severityOverride) {
36
51
  const finding = {
37
52
  code: rule,
38
- severity: RULE_SEVERITY[rule] || 'warning',
53
+ severity: severityOverride || RULE_SEVERITY[rule] || 'warning',
39
54
  message
40
55
  };
41
56
  if (line != null) finding.line = line;
@@ -86,9 +101,106 @@ function stripInlineCode(line) {
86
101
  return line.replace(/`[^`]+`/g, '');
87
102
  }
88
103
 
104
+ const SUPPRESS_NEXT_LINE_RX = /<!--\s*doclify-disable-next-line\s*(.*?)\s*-->/;
105
+ const SUPPRESS_BLOCK_START_RX = /<!--\s*doclify-disable\s*(.*?)\s*-->/;
106
+ const SUPPRESS_BLOCK_END_RX = /<!--\s*doclify-enable\s*(.*?)\s*-->/;
107
+ const SUPPRESS_FILE_RX = /<!--\s*doclify-disable-file\s*(.*?)\s*-->/;
108
+
109
+ function parseRuleIds(raw) {
110
+ const trimmed = raw.trim();
111
+ if (!trimmed) return null; // null means "all rules"
112
+ return trimmed.split(/\s+/);
113
+ }
114
+
115
+ /**
116
+ * Build a map of line numbers to sets of suppressed rule IDs.
117
+ * Uses code-block-stripped lines so comments inside fences are ignored.
118
+ * A suppressed set containing '*' means all rules are suppressed.
119
+ */
120
+ function buildSuppressionMap(lines) {
121
+ const suppressions = new Map();
122
+ const activeDisables = new Map(); // ruleId → count (or '*' → count)
123
+
124
+ function addSuppression(lineNum, ruleIds) {
125
+ if (!suppressions.has(lineNum)) suppressions.set(lineNum, new Set());
126
+ const set = suppressions.get(lineNum);
127
+ if (ruleIds === null) {
128
+ set.add('*');
129
+ } else {
130
+ for (const id of ruleIds) set.add(id);
131
+ }
132
+ }
133
+
134
+ for (let i = 0; i < lines.length; i += 1) {
135
+ const line = lines[i];
136
+
137
+ const nextLineMatch = line.match(SUPPRESS_NEXT_LINE_RX);
138
+ if (nextLineMatch) {
139
+ const ruleIds = parseRuleIds(nextLineMatch[1]);
140
+ addSuppression(i + 2, ruleIds); // i is 0-based, line numbers are 1-based, next line = i+2
141
+ }
142
+
143
+ const blockStartMatch = line.match(SUPPRESS_BLOCK_START_RX);
144
+ if (!nextLineMatch && blockStartMatch) {
145
+ const ruleIds = parseRuleIds(blockStartMatch[1]);
146
+ if (ruleIds === null) {
147
+ activeDisables.set('*', (activeDisables.get('*') || 0) + 1);
148
+ } else {
149
+ for (const id of ruleIds) activeDisables.set(id, (activeDisables.get(id) || 0) + 1);
150
+ }
151
+ continue;
152
+ }
153
+
154
+ const blockEndMatch = line.match(SUPPRESS_BLOCK_END_RX);
155
+ if (blockEndMatch) {
156
+ const ruleIds = parseRuleIds(blockEndMatch[1]);
157
+ if (ruleIds === null) {
158
+ activeDisables.delete('*');
159
+ } else {
160
+ for (const id of ruleIds) {
161
+ const count = activeDisables.get(id) || 0;
162
+ if (count <= 1) activeDisables.delete(id);
163
+ else activeDisables.set(id, count - 1);
164
+ }
165
+ }
166
+ continue;
167
+ }
168
+
169
+ // Apply active block disables to this line
170
+ if (activeDisables.size > 0) {
171
+ const lineNum = i + 1;
172
+ if (!suppressions.has(lineNum)) suppressions.set(lineNum, new Set());
173
+ const set = suppressions.get(lineNum);
174
+ for (const id of activeDisables.keys()) set.add(id);
175
+ }
176
+ }
177
+
178
+ return suppressions;
179
+ }
180
+
181
+ function isSuppressed(suppressions, finding) {
182
+ if (!finding.line) return false;
183
+ const set = suppressions.get(finding.line);
184
+ if (!set) return false;
185
+ return set.has('*') || set.has(finding.code);
186
+ }
187
+
89
188
  function checkMarkdown(rawContent, opts = {}) {
90
189
  const maxLineLength = Number(opts.maxLineLength ?? DEFAULTS.maxLineLength);
91
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
+
92
204
  const errors = [];
93
205
  const warnings = [];
94
206
 
@@ -97,8 +209,11 @@ function checkMarkdown(rawContent, opts = {}) {
97
209
  const lines = content.split('\n');
98
210
  const rawLines = rawContent.split('\n');
99
211
 
100
- // Rule: frontmatter
101
- if (!rawContent.startsWith('---\n')) {
212
+ // Build suppression map from inline comments (uses stripped content)
213
+ const suppressions = buildSuppressionMap(lines);
214
+
215
+ // Rule: frontmatter (opt-in via --check-frontmatter or config)
216
+ if (opts.checkFrontmatter && !rawContent.startsWith('---\n')) {
102
217
  warnings.push(normalizeFinding('frontmatter', 'Missing frontmatter block at the beginning of the file.', 1, filePath));
103
218
  }
104
219
 
@@ -114,14 +229,12 @@ function checkMarkdown(rawContent, opts = {}) {
114
229
  errors.push(normalizeFinding('single-h1', 'Missing H1 heading.', 1, filePath));
115
230
  } else if (h1Lines.length > 1) {
116
231
  const lineList = h1Lines.join(', ');
117
- for (const lineNum of h1Lines) {
118
- errors.push(normalizeFinding(
119
- 'single-h1',
120
- `Found ${h1Lines.length} H1 headings (expected 1) at lines ${lineList}.`,
121
- lineNum,
122
- filePath
123
- ));
124
- }
232
+ errors.push(normalizeFinding(
233
+ 'single-h1',
234
+ `Found ${h1Lines.length} H1 headings (expected 1) at lines ${lineList}.`,
235
+ h1Lines[0],
236
+ filePath
237
+ ));
125
238
  }
126
239
 
127
240
  // Rule: heading-hierarchy (h1→h3 without h2 is a skip)
@@ -141,12 +254,28 @@ function checkMarkdown(rawContent, opts = {}) {
141
254
  prevLevel = level;
142
255
  }
143
256
 
144
- // Rule: duplicate-heading (same text at same level)
257
+ // Rule: duplicate-heading (scope-aware: H3-H6 scoped under nearest parent)
145
258
  const headingSeen = new Map();
259
+ const parentStack = new Array(7).fill(''); // indices 1-6 for heading levels
146
260
  for (let i = 0; i < lines.length; i += 1) {
147
261
  const hMatch = lines[i].match(/^(#{1,6})\s+(.+)$/);
148
262
  if (!hMatch) continue;
149
- const key = `${hMatch[1].length}:${hMatch[2].trim().toLowerCase()}`;
263
+ const level = hMatch[1].length;
264
+ const text = hMatch[2].trim().toLowerCase();
265
+
266
+ // Update parent stack: set current level and clear deeper levels
267
+ parentStack[level] = text;
268
+ for (let l = level + 1; l <= 6; l += 1) parentStack[l] = '';
269
+
270
+ // H1-H2: global scope; H3-H6: scoped under parent chain
271
+ let key;
272
+ if (level <= 2) {
273
+ key = `${level}:${text}`;
274
+ } else {
275
+ const scope = parentStack.slice(1, level).join('|');
276
+ key = `${scope}|${level}:${text}`;
277
+ }
278
+
150
279
  if (headingSeen.has(key)) {
151
280
  warnings.push(normalizeFinding(
152
281
  'duplicate-heading',
@@ -241,6 +370,183 @@ function checkMarkdown(rawContent, opts = {}) {
241
370
  }
242
371
  });
243
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
+
244
550
  // Custom rules (uses stripped content)
245
551
  if (opts.customRules && opts.customRules.length > 0) {
246
552
  lines.forEach((line, idx) => {
@@ -249,18 +555,24 @@ function checkMarkdown(rawContent, opts = {}) {
249
555
  rule.pattern.lastIndex = 0;
250
556
  if (rule.pattern.test(cleanLine)) {
251
557
  const bucket = rule.severity === 'error' ? errors : warnings;
252
- bucket.push(normalizeFinding(rule.id, rule.message, idx + 1, filePath));
558
+ bucket.push(normalizeFinding(rule.id, rule.message, idx + 1, filePath, rule.severity));
253
559
  }
254
560
  }
255
561
  });
256
562
  }
257
563
 
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));
569
+
258
570
  return {
259
- errors,
260
- warnings,
571
+ errors: filteredErrors,
572
+ warnings: filteredWarnings,
261
573
  summary: {
262
- errors: errors.length,
263
- warnings: warnings.length
574
+ errors: filteredErrors.length,
575
+ warnings: filteredWarnings.length
264
576
  }
265
577
  };
266
578
  }
package/src/ci-output.mjs CHANGED
@@ -1,5 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { RULE_CATALOG } from './checker.mjs';
4
+
5
+ const RULE_DESCRIPTIONS = Object.fromEntries(RULE_CATALOG.map(r => [r.id, r.description]));
3
6
 
4
7
  function clamp(value, min, max) {
5
8
  return Math.max(min, Math.min(max, value));
@@ -86,7 +89,7 @@ function collectRuleCatalog(output) {
86
89
  id: finding.code,
87
90
  name: finding.code,
88
91
  shortDescription: { text: finding.code },
89
- help: { text: finding.message || finding.code },
92
+ help: { text: RULE_DESCRIPTIONS[finding.code] || finding.message || finding.code },
90
93
  defaultConfiguration: {
91
94
  level: finding.severity === 'error' ? 'error' : 'warning'
92
95
  }
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: raw, to: replaced });
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
 
@@ -47,6 +60,8 @@ function printHelp() {
47
60
  ${y('CHECKS')}
48
61
  --check-links Validate HTTP and local links
49
62
  --check-freshness Warn on stale docs ${d('(>180 days)')}
63
+ --check-frontmatter Require YAML frontmatter block
64
+ --link-allow-list <list> Skip URLs/domains for link checks ${d('(comma-separated)')}
50
65
 
51
66
  ${y('FIX')}
52
67
  --fix Auto-fix safe issues ${d('(http → https)')}
@@ -62,10 +77,12 @@ function printHelp() {
62
77
 
63
78
  ${y('SETUP')}
64
79
  init Generate a .doclify-guardrail.json config
80
+ init --force Overwrite existing config
65
81
 
66
82
  ${y('OTHER')}
67
83
  --list-rules List all built-in rules
68
84
  --no-color Disable colored output
85
+ --ascii Use ASCII icons ${d('(for CI without UTF-8)')}
69
86
  --debug Show debug info
70
87
  -h, --help Show this help
71
88
 
@@ -106,6 +123,7 @@ function parseArgs(argv) {
106
123
  maxLineLength: undefined,
107
124
  configPath: path.resolve('.doclify-guardrail.json'),
108
125
  help: false,
126
+ version: false,
109
127
  listRules: false,
110
128
  init: false,
111
129
  dir: null,
@@ -120,9 +138,14 @@ function parseArgs(argv) {
120
138
  exclude: [],
121
139
  checkLinks: false,
122
140
  checkFreshness: false,
141
+ checkFrontmatter: false,
142
+ checkInlineHtml: false,
143
+ linkAllowList: [],
123
144
  fix: false,
124
145
  dryRun: false,
125
- json: false
146
+ json: false,
147
+ force: false,
148
+ ascii: false
126
149
  };
127
150
 
128
151
  for (let i = 0; i < argv.length; i += 1) {
@@ -133,6 +156,11 @@ function parseArgs(argv) {
133
156
  continue;
134
157
  }
135
158
 
159
+ if (a === '-v' || a === '--version') {
160
+ args.version = true;
161
+ continue;
162
+ }
163
+
136
164
  if (a === '--list-rules') {
137
165
  args.listRules = true;
138
166
  continue;
@@ -183,6 +211,26 @@ function parseArgs(argv) {
183
211
  continue;
184
212
  }
185
213
 
214
+ if (a === '--check-frontmatter') {
215
+ args.checkFrontmatter = true;
216
+ continue;
217
+ }
218
+
219
+ if (a === '--check-inline-html') {
220
+ args.checkInlineHtml = true;
221
+ continue;
222
+ }
223
+
224
+ if (a === '--link-allow-list') {
225
+ const value = argv[i + 1];
226
+ if (!value || value.startsWith('-')) {
227
+ throw new Error('Missing value for --link-allow-list');
228
+ }
229
+ args.linkAllowList.push(...value.split(',').map(s => s.trim()).filter(Boolean));
230
+ i += 1;
231
+ continue;
232
+ }
233
+
186
234
  if (a === '--fix') {
187
235
  args.fix = true;
188
236
  continue;
@@ -198,6 +246,16 @@ function parseArgs(argv) {
198
246
  continue;
199
247
  }
200
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
+
201
259
  if (a === '--config') {
202
260
  const value = argv[i + 1];
203
261
  if (!value || value.startsWith('-')) {
@@ -326,10 +384,19 @@ function resolveOptions(args) {
326
384
  throw new Error(`Invalid maxLineLength in config: ${cfg.maxLineLength}`);
327
385
  }
328
386
 
387
+ const checkFrontmatter = Boolean(args.checkFrontmatter || cfg.checkFrontmatter || args.checkFreshness || cfg.checkFreshness);
388
+ const cfgAllowList = Array.isArray(cfg.linkAllowList) ? cfg.linkAllowList : [];
389
+ const linkAllowList = [...args.linkAllowList, ...cfgAllowList];
390
+ const cfgExclude = Array.isArray(cfg.exclude) ? cfg.exclude : [];
391
+ const exclude = [...args.exclude, ...cfgExclude];
392
+
329
393
  return {
330
394
  maxLineLength,
331
395
  strict,
332
396
  ignoreRules,
397
+ checkFrontmatter,
398
+ linkAllowList,
399
+ exclude,
333
400
  configPath: args.configPath,
334
401
  configLoaded: fs.existsSync(args.configPath)
335
402
  };
@@ -385,6 +452,7 @@ function buildOutput(fileResults, fileErrors, opts, elapsed, fixSummary) {
385
452
  };
386
453
 
387
454
  summary.healthScore = avgHealthScore;
455
+ summary.avgHealthScore = avgHealthScore; // Backward-compatible alias for existing integrations/tests
388
456
 
389
457
  return {
390
458
  version: VERSION,
@@ -411,8 +479,14 @@ async function runCli(argv = process.argv.slice(2)) {
411
479
  return 0;
412
480
  }
413
481
 
482
+ if (args.version) {
483
+ console.log(VERSION);
484
+ return 0;
485
+ }
486
+
414
487
  if (args.listRules) {
415
488
  initColors(args.noColor);
489
+ setAsciiMode(args.ascii);
416
490
  console.log('');
417
491
  console.log(` ${c.bold('Built-in rules')}`);
418
492
  console.log('');
@@ -426,11 +500,13 @@ async function runCli(argv = process.argv.slice(2)) {
426
500
 
427
501
  if (args.init) {
428
502
  initColors(args.noColor);
503
+ setAsciiMode(args.ascii);
429
504
  const configFile = '.doclify-guardrail.json';
430
505
  const configPath = path.resolve(configFile);
431
506
 
432
- if (fs.existsSync(configPath)) {
433
- 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.`);
434
510
  return 1;
435
511
  }
436
512
 
@@ -438,13 +514,16 @@ async function runCli(argv = process.argv.slice(2)) {
438
514
  strict: false,
439
515
  maxLineLength: 160,
440
516
  ignoreRules: [],
517
+ exclude: [],
441
518
  checkLinks: false,
442
- checkFreshness: false
519
+ checkFreshness: false,
520
+ checkFrontmatter: false,
521
+ linkAllowList: []
443
522
  };
444
523
 
445
524
  fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf8');
446
525
  console.error('');
447
- console.error(` ${c.green('✓')} Created ${c.bold(configFile)}`);
526
+ console.error(` ${c.green(icons.pass)} ${configExists ? 'Overwrote' : 'Created'} ${c.bold(configFile)}`);
448
527
  console.error('');
449
528
  console.error(` ${c.dim('Edit the file to customise rules, then run:')}`)
450
529
  console.error(` ${c.dim('$')} ${c.cyan('doclify .')}`);
@@ -453,6 +532,7 @@ async function runCli(argv = process.argv.slice(2)) {
453
532
  }
454
533
 
455
534
  initColors(args.noColor);
535
+ setAsciiMode(args.ascii);
456
536
 
457
537
  let filePaths;
458
538
  try {
@@ -462,15 +542,24 @@ async function runCli(argv = process.argv.slice(2)) {
462
542
  return 2;
463
543
  }
464
544
 
465
- 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) {
466
554
  filePaths = filePaths.filter(fp => {
467
555
  const rel = path.relative(process.cwd(), fp);
468
- return !args.exclude.some(pattern => {
556
+ return !resolved.exclude.some(pattern => {
469
557
  if (pattern.includes('*')) {
470
558
  const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*') + '$');
471
559
  return regex.test(rel);
472
560
  }
473
- return rel === pattern || rel.startsWith(pattern + path.sep) || path.basename(rel) === pattern;
561
+ const segments = rel.split(path.sep);
562
+ return segments.includes(pattern);
474
563
  });
475
564
  });
476
565
  }
@@ -480,14 +569,6 @@ async function runCli(argv = process.argv.slice(2)) {
480
569
  return 2;
481
570
  }
482
571
 
483
- let resolved;
484
- try {
485
- resolved = resolveOptions(args);
486
- } catch (err) {
487
- console.error(err.message);
488
- return 2;
489
- }
490
-
491
572
  let customRules = [];
492
573
  if (args.rules) {
493
574
  try {
@@ -501,7 +582,7 @@ async function runCli(argv = process.argv.slice(2)) {
501
582
  printBanner(filePaths.length, VERSION);
502
583
 
503
584
  if (resolved.configLoaded) {
504
- log(c.cyan('ℹ'), `Loaded config from ${c.dim(resolved.configPath)}`);
585
+ log(c.cyan(icons.info), `Loaded config from ${c.dim(resolved.configPath)}`);
505
586
  }
506
587
 
507
588
  const startTime = process.hrtime.bigint();
@@ -517,7 +598,17 @@ async function runCli(argv = process.argv.slice(2)) {
517
598
 
518
599
  let customRulesCount = customRules.length;
519
600
  if (customRulesCount > 0) {
520
- 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
+ }
521
612
  }
522
613
 
523
614
  console.error('');
@@ -525,12 +616,13 @@ async function runCli(argv = process.argv.slice(2)) {
525
616
  for (const filePath of filePaths) {
526
617
  const rel = path.relative(process.cwd(), filePath);
527
618
  const shortPath = rel.startsWith('..') ? filePath : rel || filePath;
528
- log(c.cyan('ℹ'), `Checking ${c.bold(shortPath)}...`);
619
+ log(c.cyan(icons.info), `Checking ${c.bold(shortPath)}...`);
529
620
 
530
621
  try {
531
622
  let content = fs.readFileSync(filePath, 'utf8');
532
623
 
533
624
  if (args.fix) {
625
+ // 1. Insecure links fix (http:// → https://)
534
626
  log(c.dim(' ↳'), c.dim(`Auto-fixing insecure links...`));
535
627
  const fixed = autoFixInsecureLinks(content);
536
628
  if (fixed.modified) {
@@ -542,9 +634,8 @@ async function runCli(argv = process.argv.slice(2)) {
542
634
  }
543
635
  }
544
636
  if (!args.dryRun) {
545
- fs.writeFileSync(filePath, fixed.content, 'utf8');
637
+ content = fixed.content;
546
638
  }
547
- content = fixed.content;
548
639
  }
549
640
  if (fixed.ambiguous.length > 0) {
550
641
  fixSummary.ambiguousSkipped.push({
@@ -555,18 +646,35 @@ async function runCli(argv = process.argv.slice(2)) {
555
646
  log(c.dim(' '), c.dim(`⊘ skipped ${url} (localhost/custom port)`));
556
647
  }
557
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
+ }
558
664
  }
559
665
 
560
666
  const relPath = toRelativePath(filePath);
561
667
  const analysis = checkMarkdown(content, {
562
668
  maxLineLength: resolved.maxLineLength,
563
669
  filePath: relPath,
564
- customRules
670
+ customRules,
671
+ checkFrontmatter: resolved.checkFrontmatter,
672
+ checkInlineHtml: Boolean(args.checkInlineHtml)
565
673
  });
566
674
 
567
675
  if (args.checkLinks) {
568
676
  log(c.dim(' ↳'), c.dim(`Checking links...`));
569
- const deadLinks = await checkDeadLinks(content, { sourceFile: filePath });
677
+ const deadLinks = await checkDeadLinks(content, { sourceFile: filePath, linkAllowList: resolved.linkAllowList });
570
678
  for (const dl of deadLinks) { dl.source = relPath; }
571
679
  analysis.errors.push(...deadLinks);
572
680
  analysis.summary.errors = analysis.errors.length;
@@ -597,21 +705,21 @@ async function runCli(argv = process.argv.slice(2)) {
597
705
  if (args.fix && fixSummary.replacements > 0) {
598
706
  const action = args.dryRun ? 'Would fix' : 'Fixed';
599
707
  log(
600
- args.dryRun ? c.yellow('~') : c.green('✓'),
708
+ args.dryRun ? c.yellow('~') : c.green(icons.pass),
601
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)') : ''}`
602
710
  );
603
711
  } else if (args.fix && fixSummary.replacements === 0) {
604
- log(c.dim('ℹ'), c.dim('No insecure links to fix'));
712
+ log(c.dim(icons.info), c.dim('No insecure links to fix'));
605
713
  }
606
714
 
607
715
  if (args.json) {
608
- console.log(JSON.stringify(output, null, 2));
716
+ await writeJsonToStdout(output);
609
717
  }
610
718
 
611
719
  if (args.report) {
612
720
  try {
613
721
  const reportPath = generateReport(output, { reportPath: args.report });
614
- log(c.green('✓'), `Report written ${c.dim('→')} ${reportPath}`);
722
+ log(c.green(icons.pass), `Report written ${c.dim('→')} ${reportPath}`);
615
723
  } catch (err) {
616
724
  console.error(`Failed to write report: ${err.message}`);
617
725
  return 2;
@@ -621,7 +729,7 @@ async function runCli(argv = process.argv.slice(2)) {
621
729
  if (args.junit) {
622
730
  try {
623
731
  const junitPath = generateJUnitReport(output, { junitPath: args.junit });
624
- log(c.green('✓'), `JUnit report written ${c.dim('→')} ${junitPath}`);
732
+ log(c.green(icons.pass), `JUnit report written ${c.dim('→')} ${junitPath}`);
625
733
  } catch (err) {
626
734
  console.error(`Failed to write JUnit report: ${err.message}`);
627
735
  return 2;
@@ -631,7 +739,7 @@ async function runCli(argv = process.argv.slice(2)) {
631
739
  if (args.sarif) {
632
740
  try {
633
741
  const sarifPath = generateSarifReport(output, { sarifPath: args.sarif });
634
- log(c.green('✓'), `SARIF report written ${c.dim('→')} ${sarifPath}`);
742
+ log(c.green(icons.pass), `SARIF report written ${c.dim('→')} ${sarifPath}`);
635
743
  } catch (err) {
636
744
  console.error(`Failed to write SARIF report: ${err.message}`);
637
745
  return 2;
@@ -641,7 +749,7 @@ async function runCli(argv = process.argv.slice(2)) {
641
749
  if (args.badge) {
642
750
  try {
643
751
  const badge = generateBadge(output, { badgePath: args.badge, label: args.badgeLabel });
644
- 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})`)}`);
645
753
  } catch (err) {
646
754
  console.error(`Failed to write badge: ${err.message}`);
647
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
 
@@ -44,6 +44,28 @@ function isSkippableUrl(url) {
44
44
  return url.startsWith('mailto:') || url.startsWith('tel:') || url.startsWith('#');
45
45
  }
46
46
 
47
+ function isAllowListed(url, allowList) {
48
+ if (!allowList || allowList.length === 0) return false;
49
+ let parsed;
50
+ try { parsed = new URL(url); } catch { return false; }
51
+ for (const pattern of allowList) {
52
+ // Wildcard pattern: "https://wger.de/*" → prefix match
53
+ if (pattern.includes('/') && pattern.endsWith('*')) {
54
+ const prefix = pattern.slice(0, -1);
55
+ if (url.startsWith(prefix)) return true;
56
+ continue;
57
+ }
58
+ // Full URL match
59
+ if (pattern.includes('/')) {
60
+ if (url === pattern) return true;
61
+ continue;
62
+ }
63
+ // Domain-only: suffix match on hostname (e.g. "wger.de" matches "api.wger.de")
64
+ if (parsed.hostname === pattern || parsed.hostname.endsWith('.' + pattern)) return true;
65
+ }
66
+ return false;
67
+ }
68
+
47
69
  async function checkRemoteUrl(url) {
48
70
  const controller = new AbortController();
49
71
  const timer = setTimeout(() => controller.abort(), LINK_TIMEOUT_MS);
@@ -95,7 +117,7 @@ async function runWithConcurrency(tasks, limit) {
95
117
  return results;
96
118
  }
97
119
 
98
- async function checkDeadLinks(content, { sourceFile }) {
120
+ async function checkDeadLinks(content, { sourceFile, linkAllowList } = {}) {
99
121
  const links = extractLinks(content);
100
122
  const findings = [];
101
123
  const seen = new Set();
@@ -112,6 +134,7 @@ async function checkDeadLinks(content, { sourceFile }) {
112
134
  seen.add(dedupeKey);
113
135
 
114
136
  if (url.startsWith('http://') || url.startsWith('https://')) {
137
+ if (isAllowListed(url, linkAllowList)) continue;
115
138
  remoteChecks.push({ url, link });
116
139
  continue;
117
140
  }
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