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 +1 -1
- package/src/checker.mjs +123 -19
- package/src/ci-output.mjs +4 -1
- package/src/fixer.mjs +1 -1
- package/src/index.mjs +45 -4
- package/src/links.mjs +24 -1
package/package.json
CHANGED
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
|
-
//
|
|
101
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 (
|
|
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
|
|
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:
|
|
263
|
-
warnings:
|
|
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:
|
|
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
|
-
|
|
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
|
}
|