doclify-guardrail 1.2.0 → 1.2.1

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.2.1",
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
@@ -32,10 +32,10 @@ const PLACEHOLDER_PATTERNS = [
32
32
  { rx: /\bxxx\b/i, msg: '"xxx" placeholder found' }
33
33
  ];
34
34
 
35
- function normalizeFinding(rule, message, line, source) {
35
+ function normalizeFinding(rule, message, line, source, severityOverride) {
36
36
  const finding = {
37
37
  code: rule,
38
- severity: RULE_SEVERITY[rule] || 'warning',
38
+ severity: severityOverride || RULE_SEVERITY[rule] || 'warning',
39
39
  message
40
40
  };
41
41
  if (line != null) finding.line = line;
@@ -86,6 +86,89 @@ function stripInlineCode(line) {
86
86
  return line.replace(/`[^`]+`/g, '');
87
87
  }
88
88
 
89
+ const SUPPRESS_NEXT_LINE_RX = /<!--\s*doclify-disable-next-line\s*(.*?)\s*-->/;
90
+ const SUPPRESS_BLOCK_START_RX = /<!--\s*doclify-disable\s*(.*?)\s*-->/;
91
+ const SUPPRESS_BLOCK_END_RX = /<!--\s*doclify-enable\s*(.*?)\s*-->/;
92
+
93
+ function parseRuleIds(raw) {
94
+ const trimmed = raw.trim();
95
+ if (!trimmed) return null; // null means "all rules"
96
+ return trimmed.split(/\s+/);
97
+ }
98
+
99
+ /**
100
+ * Build a map of line numbers to sets of suppressed rule IDs.
101
+ * Uses code-block-stripped lines so comments inside fences are ignored.
102
+ * A suppressed set containing '*' means all rules are suppressed.
103
+ */
104
+ function buildSuppressionMap(lines) {
105
+ const suppressions = new Map();
106
+ const activeDisables = new Map(); // ruleId → count (or '*' → count)
107
+
108
+ function addSuppression(lineNum, ruleIds) {
109
+ if (!suppressions.has(lineNum)) suppressions.set(lineNum, new Set());
110
+ const set = suppressions.get(lineNum);
111
+ if (ruleIds === null) {
112
+ set.add('*');
113
+ } else {
114
+ for (const id of ruleIds) set.add(id);
115
+ }
116
+ }
117
+
118
+ for (let i = 0; i < lines.length; i += 1) {
119
+ const line = lines[i];
120
+
121
+ const nextLineMatch = line.match(SUPPRESS_NEXT_LINE_RX);
122
+ if (nextLineMatch) {
123
+ const ruleIds = parseRuleIds(nextLineMatch[1]);
124
+ addSuppression(i + 2, ruleIds); // i is 0-based, line numbers are 1-based, next line = i+2
125
+ }
126
+
127
+ const blockStartMatch = line.match(SUPPRESS_BLOCK_START_RX);
128
+ if (!nextLineMatch && blockStartMatch) {
129
+ const ruleIds = parseRuleIds(blockStartMatch[1]);
130
+ if (ruleIds === null) {
131
+ activeDisables.set('*', (activeDisables.get('*') || 0) + 1);
132
+ } else {
133
+ for (const id of ruleIds) activeDisables.set(id, (activeDisables.get(id) || 0) + 1);
134
+ }
135
+ continue;
136
+ }
137
+
138
+ const blockEndMatch = line.match(SUPPRESS_BLOCK_END_RX);
139
+ if (blockEndMatch) {
140
+ const ruleIds = parseRuleIds(blockEndMatch[1]);
141
+ if (ruleIds === null) {
142
+ activeDisables.delete('*');
143
+ } else {
144
+ for (const id of ruleIds) {
145
+ const count = activeDisables.get(id) || 0;
146
+ if (count <= 1) activeDisables.delete(id);
147
+ else activeDisables.set(id, count - 1);
148
+ }
149
+ }
150
+ continue;
151
+ }
152
+
153
+ // Apply active block disables to this line
154
+ if (activeDisables.size > 0) {
155
+ const lineNum = i + 1;
156
+ if (!suppressions.has(lineNum)) suppressions.set(lineNum, new Set());
157
+ const set = suppressions.get(lineNum);
158
+ for (const id of activeDisables.keys()) set.add(id);
159
+ }
160
+ }
161
+
162
+ return suppressions;
163
+ }
164
+
165
+ function isSuppressed(suppressions, finding) {
166
+ if (!finding.line) return false;
167
+ const set = suppressions.get(finding.line);
168
+ if (!set) return false;
169
+ return set.has('*') || set.has(finding.code);
170
+ }
171
+
89
172
  function checkMarkdown(rawContent, opts = {}) {
90
173
  const maxLineLength = Number(opts.maxLineLength ?? DEFAULTS.maxLineLength);
91
174
  const filePath = opts.filePath || undefined;
@@ -97,8 +180,11 @@ function checkMarkdown(rawContent, opts = {}) {
97
180
  const lines = content.split('\n');
98
181
  const rawLines = rawContent.split('\n');
99
182
 
100
- // Rule: frontmatter
101
- if (!rawContent.startsWith('---\n')) {
183
+ // Build suppression map from inline comments (uses stripped content)
184
+ const suppressions = buildSuppressionMap(lines);
185
+
186
+ // Rule: frontmatter (opt-in via --check-frontmatter or config)
187
+ if (opts.checkFrontmatter && !rawContent.startsWith('---\n')) {
102
188
  warnings.push(normalizeFinding('frontmatter', 'Missing frontmatter block at the beginning of the file.', 1, filePath));
103
189
  }
104
190
 
@@ -114,14 +200,12 @@ function checkMarkdown(rawContent, opts = {}) {
114
200
  errors.push(normalizeFinding('single-h1', 'Missing H1 heading.', 1, filePath));
115
201
  } else if (h1Lines.length > 1) {
116
202
  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
- }
203
+ errors.push(normalizeFinding(
204
+ 'single-h1',
205
+ `Found ${h1Lines.length} H1 headings (expected 1) at lines ${lineList}.`,
206
+ h1Lines[0],
207
+ filePath
208
+ ));
125
209
  }
126
210
 
127
211
  // Rule: heading-hierarchy (h1→h3 without h2 is a skip)
@@ -141,12 +225,28 @@ function checkMarkdown(rawContent, opts = {}) {
141
225
  prevLevel = level;
142
226
  }
143
227
 
144
- // Rule: duplicate-heading (same text at same level)
228
+ // Rule: duplicate-heading (scope-aware: H3-H6 scoped under nearest parent)
145
229
  const headingSeen = new Map();
230
+ const parentStack = new Array(7).fill(''); // indices 1-6 for heading levels
146
231
  for (let i = 0; i < lines.length; i += 1) {
147
232
  const hMatch = lines[i].match(/^(#{1,6})\s+(.+)$/);
148
233
  if (!hMatch) continue;
149
- const key = `${hMatch[1].length}:${hMatch[2].trim().toLowerCase()}`;
234
+ const level = hMatch[1].length;
235
+ const text = hMatch[2].trim().toLowerCase();
236
+
237
+ // Update parent stack: set current level and clear deeper levels
238
+ parentStack[level] = text;
239
+ for (let l = level + 1; l <= 6; l += 1) parentStack[l] = '';
240
+
241
+ // H1-H2: global scope; H3-H6: scoped under parent chain
242
+ let key;
243
+ if (level <= 2) {
244
+ key = `${level}:${text}`;
245
+ } else {
246
+ const scope = parentStack.slice(1, level).join('|');
247
+ key = `${scope}|${level}:${text}`;
248
+ }
249
+
150
250
  if (headingSeen.has(key)) {
151
251
  warnings.push(normalizeFinding(
152
252
  'duplicate-heading',
@@ -249,18 +349,22 @@ function checkMarkdown(rawContent, opts = {}) {
249
349
  rule.pattern.lastIndex = 0;
250
350
  if (rule.pattern.test(cleanLine)) {
251
351
  const bucket = rule.severity === 'error' ? errors : warnings;
252
- bucket.push(normalizeFinding(rule.id, rule.message, idx + 1, filePath));
352
+ bucket.push(normalizeFinding(rule.id, rule.message, idx + 1, filePath, rule.severity));
253
353
  }
254
354
  }
255
355
  });
256
356
  }
257
357
 
358
+ // Apply inline suppressions
359
+ const filteredErrors = errors.filter(f => !isSuppressed(suppressions, f));
360
+ const filteredWarnings = warnings.filter(f => !isSuppressed(suppressions, f));
361
+
258
362
  return {
259
- errors,
260
- warnings,
363
+ errors: filteredErrors,
364
+ warnings: filteredWarnings,
261
365
  summary: {
262
- errors: errors.length,
263
- warnings: warnings.length
366
+ errors: filteredErrors.length,
367
+ warnings: filteredWarnings.length
264
368
  }
265
369
  };
266
370
  }
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/fixer.mjs CHANGED
@@ -23,7 +23,7 @@ function autoFixInsecureLinks(content) {
23
23
 
24
24
  const replaced = raw.replace('http://', 'https://');
25
25
  if (replaced !== raw) {
26
- changes.push({ from: raw, to: replaced });
26
+ changes.push({ from: cleaned, to: cleaned.replace('http://', 'https://') });
27
27
  }
28
28
  return replaced;
29
29
  });
package/src/index.mjs CHANGED
@@ -47,6 +47,8 @@ function printHelp() {
47
47
  ${y('CHECKS')}
48
48
  --check-links Validate HTTP and local links
49
49
  --check-freshness Warn on stale docs ${d('(>180 days)')}
50
+ --check-frontmatter Require YAML frontmatter block
51
+ --link-allow-list <list> Skip URLs/domains for link checks ${d('(comma-separated)')}
50
52
 
51
53
  ${y('FIX')}
52
54
  --fix Auto-fix safe issues ${d('(http → https)')}
@@ -106,6 +108,7 @@ function parseArgs(argv) {
106
108
  maxLineLength: undefined,
107
109
  configPath: path.resolve('.doclify-guardrail.json'),
108
110
  help: false,
111
+ version: false,
109
112
  listRules: false,
110
113
  init: false,
111
114
  dir: null,
@@ -120,6 +123,8 @@ function parseArgs(argv) {
120
123
  exclude: [],
121
124
  checkLinks: false,
122
125
  checkFreshness: false,
126
+ checkFrontmatter: false,
127
+ linkAllowList: [],
123
128
  fix: false,
124
129
  dryRun: false,
125
130
  json: false
@@ -133,6 +138,11 @@ function parseArgs(argv) {
133
138
  continue;
134
139
  }
135
140
 
141
+ if (a === '-v' || a === '--version') {
142
+ args.version = true;
143
+ continue;
144
+ }
145
+
136
146
  if (a === '--list-rules') {
137
147
  args.listRules = true;
138
148
  continue;
@@ -183,6 +193,21 @@ function parseArgs(argv) {
183
193
  continue;
184
194
  }
185
195
 
196
+ if (a === '--check-frontmatter') {
197
+ args.checkFrontmatter = true;
198
+ continue;
199
+ }
200
+
201
+ if (a === '--link-allow-list') {
202
+ const value = argv[i + 1];
203
+ if (!value || value.startsWith('-')) {
204
+ throw new Error('Missing value for --link-allow-list');
205
+ }
206
+ args.linkAllowList.push(...value.split(',').map(s => s.trim()).filter(Boolean));
207
+ i += 1;
208
+ continue;
209
+ }
210
+
186
211
  if (a === '--fix') {
187
212
  args.fix = true;
188
213
  continue;
@@ -326,10 +351,16 @@ function resolveOptions(args) {
326
351
  throw new Error(`Invalid maxLineLength in config: ${cfg.maxLineLength}`);
327
352
  }
328
353
 
354
+ const checkFrontmatter = Boolean(args.checkFrontmatter || cfg.checkFrontmatter || args.checkFreshness || cfg.checkFreshness);
355
+ const cfgAllowList = Array.isArray(cfg.linkAllowList) ? cfg.linkAllowList : [];
356
+ const linkAllowList = [...args.linkAllowList, ...cfgAllowList];
357
+
329
358
  return {
330
359
  maxLineLength,
331
360
  strict,
332
361
  ignoreRules,
362
+ checkFrontmatter,
363
+ linkAllowList,
333
364
  configPath: args.configPath,
334
365
  configLoaded: fs.existsSync(args.configPath)
335
366
  };
@@ -385,6 +416,7 @@ function buildOutput(fileResults, fileErrors, opts, elapsed, fixSummary) {
385
416
  };
386
417
 
387
418
  summary.healthScore = avgHealthScore;
419
+ summary.avgHealthScore = avgHealthScore; // Backward-compatible alias for existing integrations/tests
388
420
 
389
421
  return {
390
422
  version: VERSION,
@@ -411,6 +443,11 @@ async function runCli(argv = process.argv.slice(2)) {
411
443
  return 0;
412
444
  }
413
445
 
446
+ if (args.version) {
447
+ console.log(VERSION);
448
+ return 0;
449
+ }
450
+
414
451
  if (args.listRules) {
415
452
  initColors(args.noColor);
416
453
  console.log('');
@@ -439,7 +476,9 @@ async function runCli(argv = process.argv.slice(2)) {
439
476
  maxLineLength: 160,
440
477
  ignoreRules: [],
441
478
  checkLinks: false,
442
- checkFreshness: false
479
+ checkFreshness: false,
480
+ checkFrontmatter: false,
481
+ linkAllowList: []
443
482
  };
444
483
 
445
484
  fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf8');
@@ -470,7 +509,8 @@ async function runCli(argv = process.argv.slice(2)) {
470
509
  const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*') + '$');
471
510
  return regex.test(rel);
472
511
  }
473
- return rel === pattern || rel.startsWith(pattern + path.sep) || path.basename(rel) === pattern;
512
+ const segments = rel.split(path.sep);
513
+ return segments.includes(pattern);
474
514
  });
475
515
  });
476
516
  }
@@ -561,12 +601,13 @@ async function runCli(argv = process.argv.slice(2)) {
561
601
  const analysis = checkMarkdown(content, {
562
602
  maxLineLength: resolved.maxLineLength,
563
603
  filePath: relPath,
564
- customRules
604
+ customRules,
605
+ checkFrontmatter: resolved.checkFrontmatter
565
606
  });
566
607
 
567
608
  if (args.checkLinks) {
568
609
  log(c.dim(' ↳'), c.dim(`Checking links...`));
569
- const deadLinks = await checkDeadLinks(content, { sourceFile: filePath });
610
+ const deadLinks = await checkDeadLinks(content, { sourceFile: filePath, linkAllowList: resolved.linkAllowList });
570
611
  for (const dl of deadLinks) { dl.source = relPath; }
571
612
  analysis.errors.push(...deadLinks);
572
613
  analysis.summary.errors = analysis.errors.length;
package/src/links.mjs CHANGED
@@ -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
  }