doclify-guardrail 1.1.2 → 1.2.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 +1 -1
- package/src/checker.mjs +72 -10
- package/src/ci-output.mjs +17 -9
- package/src/colors.mjs +2 -2
- package/src/index.mjs +145 -21
- package/src/links.mjs +55 -14
package/package.json
CHANGED
package/src/checker.mjs
CHANGED
|
@@ -3,15 +3,21 @@ const DEFAULTS = {
|
|
|
3
3
|
strict: false
|
|
4
4
|
};
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
frontmatter: 'warning',
|
|
8
|
-
'single-h1': 'error',
|
|
9
|
-
'
|
|
10
|
-
|
|
11
|
-
'
|
|
12
|
-
'
|
|
13
|
-
'
|
|
14
|
-
}
|
|
6
|
+
const RULE_CATALOG = [
|
|
7
|
+
{ id: 'frontmatter', severity: 'warning', description: 'Require YAML frontmatter block' },
|
|
8
|
+
{ id: 'single-h1', severity: 'error', description: 'Exactly one H1 heading per file' },
|
|
9
|
+
{ id: 'heading-hierarchy', severity: 'warning', description: 'No skipped heading levels (H2 → H4)' },
|
|
10
|
+
{ id: 'duplicate-heading', severity: 'warning', description: 'No duplicate headings at same level' },
|
|
11
|
+
{ id: 'line-length', severity: 'warning', description: 'Max line length (default: 160 chars)' },
|
|
12
|
+
{ id: 'placeholder', severity: 'warning', description: 'No TODO/FIXME/WIP/TBD markers' },
|
|
13
|
+
{ id: 'insecure-link', severity: 'warning', description: 'No http:// links (use https://)' },
|
|
14
|
+
{ id: 'empty-link', severity: 'warning', description: 'No empty link text or URL' },
|
|
15
|
+
{ id: 'img-alt', severity: 'warning', description: 'Images must have alt text' },
|
|
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)' }
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const RULE_SEVERITY = Object.fromEntries(RULE_CATALOG.map(r => [r.id, r.severity]));
|
|
15
21
|
|
|
16
22
|
const PLACEHOLDER_PATTERNS = [
|
|
17
23
|
{ rx: /\bTODO\b/i, msg: 'TODO marker found — remove before publishing' },
|
|
@@ -118,6 +124,41 @@ function checkMarkdown(rawContent, opts = {}) {
|
|
|
118
124
|
}
|
|
119
125
|
}
|
|
120
126
|
|
|
127
|
+
// Rule: heading-hierarchy (h1→h3 without h2 is a skip)
|
|
128
|
+
let prevLevel = 0;
|
|
129
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
130
|
+
const hMatch = lines[i].match(/^(#{1,6})\s/);
|
|
131
|
+
if (!hMatch) continue;
|
|
132
|
+
const level = hMatch[1].length;
|
|
133
|
+
if (prevLevel > 0 && level > prevLevel + 1) {
|
|
134
|
+
warnings.push(normalizeFinding(
|
|
135
|
+
'heading-hierarchy',
|
|
136
|
+
`Heading level skipped: H${prevLevel} → H${level} (expected H${prevLevel + 1}).`,
|
|
137
|
+
i + 1,
|
|
138
|
+
filePath
|
|
139
|
+
));
|
|
140
|
+
}
|
|
141
|
+
prevLevel = level;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Rule: duplicate-heading (same text at same level)
|
|
145
|
+
const headingSeen = new Map();
|
|
146
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
147
|
+
const hMatch = lines[i].match(/^(#{1,6})\s+(.+)$/);
|
|
148
|
+
if (!hMatch) continue;
|
|
149
|
+
const key = `${hMatch[1].length}:${hMatch[2].trim().toLowerCase()}`;
|
|
150
|
+
if (headingSeen.has(key)) {
|
|
151
|
+
warnings.push(normalizeFinding(
|
|
152
|
+
'duplicate-heading',
|
|
153
|
+
`Duplicate heading "${hMatch[2].trim()}" (also at line ${headingSeen.get(key)}).`,
|
|
154
|
+
i + 1,
|
|
155
|
+
filePath
|
|
156
|
+
));
|
|
157
|
+
} else {
|
|
158
|
+
headingSeen.set(key, i + 1);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
121
162
|
// Rule: line-length (uses raw content — code block lines can still be too long)
|
|
122
163
|
rawLines.forEach((line, idx) => {
|
|
123
164
|
if (line.length > maxLineLength) {
|
|
@@ -179,6 +220,27 @@ function checkMarkdown(rawContent, opts = {}) {
|
|
|
179
220
|
}
|
|
180
221
|
});
|
|
181
222
|
|
|
223
|
+
// Rule: empty-link (uses stripped content, excludes images)
|
|
224
|
+
lines.forEach((line, idx) => {
|
|
225
|
+
const cleanLine = stripInlineCode(line);
|
|
226
|
+
// [](url) — empty link text (but not  which is img-alt)
|
|
227
|
+
if (/(?<!!)\[\]\([^)]+\)/.test(cleanLine)) {
|
|
228
|
+
warnings.push(normalizeFinding('empty-link', 'Link has empty text: [](url).', idx + 1, filePath));
|
|
229
|
+
}
|
|
230
|
+
// [text]() — empty link URL
|
|
231
|
+
if (/(?<!!)\[[^\]]+\]\(\s*\)/.test(cleanLine)) {
|
|
232
|
+
warnings.push(normalizeFinding('empty-link', 'Link has empty URL: [text]().', idx + 1, filePath));
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Rule: img-alt (uses stripped content)
|
|
237
|
+
lines.forEach((line, idx) => {
|
|
238
|
+
const cleanLine = stripInlineCode(line);
|
|
239
|
+
if (/!\[\]\([^)]+\)/.test(cleanLine)) {
|
|
240
|
+
warnings.push(normalizeFinding('img-alt', 'Image missing alt text: .', idx + 1, filePath));
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
182
244
|
// Custom rules (uses stripped content)
|
|
183
245
|
if (opts.customRules && opts.customRules.length > 0) {
|
|
184
246
|
lines.forEach((line, idx) => {
|
|
@@ -203,4 +265,4 @@ function checkMarkdown(rawContent, opts = {}) {
|
|
|
203
265
|
};
|
|
204
266
|
}
|
|
205
267
|
|
|
206
|
-
export { DEFAULTS, RULE_SEVERITY, normalizeFinding, checkMarkdown, stripCodeBlocks, stripInlineCode };
|
|
268
|
+
export { DEFAULTS, RULE_SEVERITY, RULE_CATALOG, normalizeFinding, checkMarkdown, stripCodeBlocks, stripInlineCode };
|
package/src/ci-output.mjs
CHANGED
|
@@ -33,7 +33,7 @@ function generateJUnitXml(output) {
|
|
|
33
33
|
const suites = [];
|
|
34
34
|
|
|
35
35
|
const tests = output.files.length + (output.fileErrors?.length || 0);
|
|
36
|
-
const failures = output.files.filter((f) => f.findings.errors.length > 0
|
|
36
|
+
const failures = output.files.filter((f) => f.findings.errors.length > 0).length;
|
|
37
37
|
const errors = output.fileErrors?.length || 0;
|
|
38
38
|
|
|
39
39
|
suites.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
@@ -42,16 +42,24 @@ function generateJUnitXml(output) {
|
|
|
42
42
|
);
|
|
43
43
|
|
|
44
44
|
for (const fileResult of output.files) {
|
|
45
|
+
const hasErrors = fileResult.findings.errors.length > 0;
|
|
46
|
+
const hasWarnings = fileResult.findings.warnings.length > 0;
|
|
47
|
+
|
|
48
|
+
if (!hasErrors && !hasWarnings) {
|
|
49
|
+
suites.push(` <testcase classname="doclify.guardrail" name="${escapeXml(fileResult.file)}"/>`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
suites.push(` <testcase classname="doclify.guardrail" name="${escapeXml(fileResult.file)}">`);
|
|
46
54
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
if (hasErrors) {
|
|
56
|
+
const errorDetail = fileResult.findings.errors.map(makeFindingLine).join('\n');
|
|
57
|
+
suites.push(` <failure message="${escapeXml(`${fileResult.summary.errors} error${fileResult.summary.errors === 1 ? '' : 's'}`)}">${escapeXml(errorDetail)}</failure>`);
|
|
58
|
+
}
|
|
51
59
|
|
|
52
|
-
if (
|
|
53
|
-
const
|
|
54
|
-
suites.push(` <
|
|
60
|
+
if (hasWarnings) {
|
|
61
|
+
const warnDetail = fileResult.findings.warnings.map(makeFindingLine).join('\n');
|
|
62
|
+
suites.push(` <system-out>${escapeXml(warnDetail)}</system-out>`);
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
suites.push(' </testcase>');
|
|
@@ -227,7 +235,7 @@ function generateSarifReport(output, options) {
|
|
|
227
235
|
function generateBadge(output, options = {}) {
|
|
228
236
|
const label = (options.label || 'docs health').trim() || 'docs health';
|
|
229
237
|
const badgePath = path.resolve(options.badgePath || 'doclify-badge.svg');
|
|
230
|
-
const score =
|
|
238
|
+
const score = output.summary?.healthScore ?? 0;
|
|
231
239
|
const svg = generateBadgeSvg(score, label);
|
|
232
240
|
fs.writeFileSync(badgePath, svg, 'utf8');
|
|
233
241
|
return {
|
package/src/colors.mjs
CHANGED
|
@@ -34,9 +34,9 @@ function log(icon, message) {
|
|
|
34
34
|
console.error(` ${icon} ${message}`);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function printBanner(fileCount) {
|
|
37
|
+
function printBanner(fileCount, version) {
|
|
38
38
|
console.error('');
|
|
39
|
-
console.error(` ${c.bold('Doclify Guardrail')} ${c.dim('
|
|
39
|
+
console.error(` ${c.bold('Doclify Guardrail')} ${c.dim(`v${version || '?'}`)}`);
|
|
40
40
|
console.error('');
|
|
41
41
|
log(c.cyan('ℹ'), `Scanning ${c.bold(String(fileCount))} file${fileCount === 1 ? '' : 's'}...`);
|
|
42
42
|
}
|
package/src/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { checkMarkdown } from './checker.mjs';
|
|
5
|
+
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';
|
|
@@ -11,19 +11,22 @@ import { checkDeadLinks } from './links.mjs';
|
|
|
11
11
|
import { autoFixInsecureLinks } from './fixer.mjs';
|
|
12
12
|
import { computeDocHealthScore, checkDocFreshness } from './quality.mjs';
|
|
13
13
|
import {
|
|
14
|
-
computeHealthScore,
|
|
15
14
|
generateJUnitReport,
|
|
16
15
|
generateSarifReport,
|
|
17
16
|
generateBadge
|
|
18
17
|
} from './ci-output.mjs';
|
|
19
18
|
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
21
|
+
const VERSION = pkg.version;
|
|
22
|
+
|
|
20
23
|
function printHelp() {
|
|
21
24
|
const b = (t) => c.bold(t);
|
|
22
25
|
const d = (t) => c.dim(t);
|
|
23
26
|
const y = (t) => c.yellow(t);
|
|
24
27
|
|
|
25
28
|
console.log(`
|
|
26
|
-
${b('Doclify Guardrail')} ${d(
|
|
29
|
+
${b('Doclify Guardrail')} ${d(`v${VERSION}`)}
|
|
27
30
|
Quality gate for Markdown documentation.
|
|
28
31
|
|
|
29
32
|
${y('USAGE')}
|
|
@@ -38,6 +41,8 @@ function printHelp() {
|
|
|
38
41
|
--max-line-length <n> Max line length ${d('(default: 160)')}
|
|
39
42
|
--config <path> Config file ${d('(default: .doclify-guardrail.json)')}
|
|
40
43
|
--rules <path> Custom regex rules from JSON file
|
|
44
|
+
--ignore-rules <list> Disable rules ${d('(comma-separated)')}
|
|
45
|
+
--exclude <list> Exclude files/patterns ${d('(comma-separated)')}
|
|
41
46
|
|
|
42
47
|
${y('CHECKS')}
|
|
43
48
|
--check-links Validate HTTP and local links
|
|
@@ -55,7 +60,11 @@ function printHelp() {
|
|
|
55
60
|
--badge-label <text> Badge label ${d('(default: "docs health")')}
|
|
56
61
|
--json Output raw JSON to stdout
|
|
57
62
|
|
|
63
|
+
${y('SETUP')}
|
|
64
|
+
init Generate a .doclify-guardrail.json config
|
|
65
|
+
|
|
58
66
|
${y('OTHER')}
|
|
67
|
+
--list-rules List all built-in rules
|
|
59
68
|
--no-color Disable colored output
|
|
60
69
|
--debug Show debug info
|
|
61
70
|
-h, --help Show this help
|
|
@@ -97,6 +106,8 @@ function parseArgs(argv) {
|
|
|
97
106
|
maxLineLength: undefined,
|
|
98
107
|
configPath: path.resolve('.doclify-guardrail.json'),
|
|
99
108
|
help: false,
|
|
109
|
+
listRules: false,
|
|
110
|
+
init: false,
|
|
100
111
|
dir: null,
|
|
101
112
|
report: null,
|
|
102
113
|
rules: null,
|
|
@@ -105,6 +116,8 @@ function parseArgs(argv) {
|
|
|
105
116
|
badge: null,
|
|
106
117
|
badgeLabel: 'docs health',
|
|
107
118
|
noColor: false,
|
|
119
|
+
ignoreRules: [],
|
|
120
|
+
exclude: [],
|
|
108
121
|
checkLinks: false,
|
|
109
122
|
checkFreshness: false,
|
|
110
123
|
fix: false,
|
|
@@ -120,6 +133,11 @@ function parseArgs(argv) {
|
|
|
120
133
|
continue;
|
|
121
134
|
}
|
|
122
135
|
|
|
136
|
+
if (a === '--list-rules') {
|
|
137
|
+
args.listRules = true;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
123
141
|
if (a === '--debug') {
|
|
124
142
|
args.debug = true;
|
|
125
143
|
continue;
|
|
@@ -135,6 +153,26 @@ function parseArgs(argv) {
|
|
|
135
153
|
continue;
|
|
136
154
|
}
|
|
137
155
|
|
|
156
|
+
if (a === '--ignore-rules') {
|
|
157
|
+
const value = argv[i + 1];
|
|
158
|
+
if (!value || value.startsWith('-')) {
|
|
159
|
+
throw new Error('Missing value for --ignore-rules');
|
|
160
|
+
}
|
|
161
|
+
args.ignoreRules.push(...value.split(',').map(s => s.trim()).filter(Boolean));
|
|
162
|
+
i += 1;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (a === '--exclude') {
|
|
167
|
+
const value = argv[i + 1];
|
|
168
|
+
if (!value || value.startsWith('-')) {
|
|
169
|
+
throw new Error('Missing value for --exclude');
|
|
170
|
+
}
|
|
171
|
+
args.exclude.push(...value.split(',').map(s => s.trim()).filter(Boolean));
|
|
172
|
+
i += 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
138
176
|
if (a === '--check-links') {
|
|
139
177
|
args.checkLinks = true;
|
|
140
178
|
continue;
|
|
@@ -258,6 +296,11 @@ function parseArgs(argv) {
|
|
|
258
296
|
continue;
|
|
259
297
|
}
|
|
260
298
|
|
|
299
|
+
if (a === 'init') {
|
|
300
|
+
args.init = true;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
261
304
|
if (a.startsWith('-')) {
|
|
262
305
|
throw new Error(`Unknown option: ${a}`);
|
|
263
306
|
}
|
|
@@ -276,6 +319,8 @@ function resolveOptions(args) {
|
|
|
276
319
|
const cfg = parseConfigFile(args.configPath);
|
|
277
320
|
const maxLineLength = Number(args.maxLineLength ?? cfg.maxLineLength ?? 160);
|
|
278
321
|
const strict = Boolean(args.strict ?? cfg.strict ?? false);
|
|
322
|
+
const cfgIgnore = Array.isArray(cfg.ignoreRules) ? cfg.ignoreRules : [];
|
|
323
|
+
const ignoreRules = new Set([...args.ignoreRules, ...cfgIgnore]);
|
|
279
324
|
|
|
280
325
|
if (!Number.isInteger(maxLineLength) || maxLineLength <= 0) {
|
|
281
326
|
throw new Error(`Invalid maxLineLength in config: ${cfg.maxLineLength}`);
|
|
@@ -284,28 +329,34 @@ function resolveOptions(args) {
|
|
|
284
329
|
return {
|
|
285
330
|
maxLineLength,
|
|
286
331
|
strict,
|
|
332
|
+
ignoreRules,
|
|
287
333
|
configPath: args.configPath,
|
|
288
334
|
configLoaded: fs.existsSync(args.configPath)
|
|
289
335
|
};
|
|
290
336
|
}
|
|
291
337
|
|
|
338
|
+
function toRelativePath(filePath) {
|
|
339
|
+
const rel = path.relative(process.cwd(), filePath);
|
|
340
|
+
return rel.startsWith('..') ? filePath : rel || filePath;
|
|
341
|
+
}
|
|
342
|
+
|
|
292
343
|
function buildFileResult(filePath, analysis, opts) {
|
|
293
|
-
const
|
|
344
|
+
const ignore = opts.ignoreRules || new Set();
|
|
345
|
+
const errors = ignore.size > 0 ? analysis.errors.filter(f => !ignore.has(f.code)) : analysis.errors;
|
|
346
|
+
const warnings = ignore.size > 0 ? analysis.warnings.filter(f => !ignore.has(f.code)) : analysis.warnings;
|
|
347
|
+
const pass = errors.length === 0 && (!opts.strict || warnings.length === 0);
|
|
294
348
|
const healthScore = computeDocHealthScore({
|
|
295
|
-
errors:
|
|
296
|
-
warnings:
|
|
349
|
+
errors: errors.length,
|
|
350
|
+
warnings: warnings.length
|
|
297
351
|
});
|
|
298
352
|
|
|
299
353
|
return {
|
|
300
|
-
file: filePath,
|
|
354
|
+
file: toRelativePath(filePath),
|
|
301
355
|
pass,
|
|
302
|
-
findings: {
|
|
303
|
-
errors: analysis.errors,
|
|
304
|
-
warnings: analysis.warnings
|
|
305
|
-
},
|
|
356
|
+
findings: { errors, warnings },
|
|
306
357
|
summary: {
|
|
307
|
-
errors:
|
|
308
|
-
warnings:
|
|
358
|
+
errors: errors.length,
|
|
359
|
+
warnings: warnings.length,
|
|
309
360
|
healthScore,
|
|
310
361
|
status: pass ? 'PASS' : 'FAIL'
|
|
311
362
|
}
|
|
@@ -333,11 +384,10 @@ function buildOutput(fileResults, fileErrors, opts, elapsed, fixSummary) {
|
|
|
333
384
|
elapsed: Math.round(elapsed * 1000) / 1000
|
|
334
385
|
};
|
|
335
386
|
|
|
336
|
-
summary.
|
|
337
|
-
summary.healthScore = computeHealthScore(summary);
|
|
387
|
+
summary.healthScore = avgHealthScore;
|
|
338
388
|
|
|
339
389
|
return {
|
|
340
|
-
version:
|
|
390
|
+
version: VERSION,
|
|
341
391
|
strict: opts.strict,
|
|
342
392
|
files: fileResults,
|
|
343
393
|
fileErrors: fileErrors.length > 0 ? fileErrors : undefined,
|
|
@@ -361,6 +411,47 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
361
411
|
return 0;
|
|
362
412
|
}
|
|
363
413
|
|
|
414
|
+
if (args.listRules) {
|
|
415
|
+
initColors(args.noColor);
|
|
416
|
+
console.log('');
|
|
417
|
+
console.log(` ${c.bold('Built-in rules')}`);
|
|
418
|
+
console.log('');
|
|
419
|
+
for (const rule of RULE_CATALOG) {
|
|
420
|
+
const sev = rule.severity === 'error' ? c.red('error ') : c.yellow('warning');
|
|
421
|
+
console.log(` ${c.cyan(rule.id.padEnd(22))} ${sev} ${c.dim(rule.description)}`);
|
|
422
|
+
}
|
|
423
|
+
console.log('');
|
|
424
|
+
return 0;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (args.init) {
|
|
428
|
+
initColors(args.noColor);
|
|
429
|
+
const configFile = '.doclify-guardrail.json';
|
|
430
|
+
const configPath = path.resolve(configFile);
|
|
431
|
+
|
|
432
|
+
if (fs.existsSync(configPath)) {
|
|
433
|
+
console.error(` ${c.yellow('⚠')} ${c.bold(configFile)} already exists. Remove it first to re-initialize.`);
|
|
434
|
+
return 1;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const defaultConfig = {
|
|
438
|
+
strict: false,
|
|
439
|
+
maxLineLength: 160,
|
|
440
|
+
ignoreRules: [],
|
|
441
|
+
checkLinks: false,
|
|
442
|
+
checkFreshness: false
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf8');
|
|
446
|
+
console.error('');
|
|
447
|
+
console.error(` ${c.green('✓')} Created ${c.bold(configFile)}`);
|
|
448
|
+
console.error('');
|
|
449
|
+
console.error(` ${c.dim('Edit the file to customise rules, then run:')}`)
|
|
450
|
+
console.error(` ${c.dim('$')} ${c.cyan('doclify .')}`);
|
|
451
|
+
console.error('');
|
|
452
|
+
return 0;
|
|
453
|
+
}
|
|
454
|
+
|
|
364
455
|
initColors(args.noColor);
|
|
365
456
|
|
|
366
457
|
let filePaths;
|
|
@@ -371,6 +462,19 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
371
462
|
return 2;
|
|
372
463
|
}
|
|
373
464
|
|
|
465
|
+
if (args.exclude.length > 0) {
|
|
466
|
+
filePaths = filePaths.filter(fp => {
|
|
467
|
+
const rel = path.relative(process.cwd(), fp);
|
|
468
|
+
return !args.exclude.some(pattern => {
|
|
469
|
+
if (pattern.includes('*')) {
|
|
470
|
+
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*') + '$');
|
|
471
|
+
return regex.test(rel);
|
|
472
|
+
}
|
|
473
|
+
return rel === pattern || rel.startsWith(pattern + path.sep) || path.basename(rel) === pattern;
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
374
478
|
if (filePaths.length === 0) {
|
|
375
479
|
console.error('Error: no markdown files found.');
|
|
376
480
|
return 2;
|
|
@@ -394,7 +498,7 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
394
498
|
}
|
|
395
499
|
}
|
|
396
500
|
|
|
397
|
-
printBanner(filePaths.length);
|
|
501
|
+
printBanner(filePaths.length, VERSION);
|
|
398
502
|
|
|
399
503
|
if (resolved.configLoaded) {
|
|
400
504
|
log(c.cyan('ℹ'), `Loaded config from ${c.dim(resolved.configPath)}`);
|
|
@@ -432,6 +536,11 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
432
536
|
if (fixed.modified) {
|
|
433
537
|
fixSummary.filesChanged += 1;
|
|
434
538
|
fixSummary.replacements += fixed.changes.length;
|
|
539
|
+
for (const change of fixed.changes) {
|
|
540
|
+
if (args.dryRun) {
|
|
541
|
+
log(c.dim(' '), c.yellow('~') + ` ${c.dim(change.from)} ${c.dim('→')} ${c.green(change.to)}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
435
544
|
if (!args.dryRun) {
|
|
436
545
|
fs.writeFileSync(filePath, fixed.content, 'utf8');
|
|
437
546
|
}
|
|
@@ -442,32 +551,37 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
442
551
|
file: filePath,
|
|
443
552
|
urls: [...new Set(fixed.ambiguous)]
|
|
444
553
|
});
|
|
554
|
+
for (const url of [...new Set(fixed.ambiguous)]) {
|
|
555
|
+
log(c.dim(' '), c.dim(`⊘ skipped ${url} (localhost/custom port)`));
|
|
556
|
+
}
|
|
445
557
|
}
|
|
446
558
|
}
|
|
447
559
|
|
|
560
|
+
const relPath = toRelativePath(filePath);
|
|
448
561
|
const analysis = checkMarkdown(content, {
|
|
449
562
|
maxLineLength: resolved.maxLineLength,
|
|
450
|
-
filePath,
|
|
563
|
+
filePath: relPath,
|
|
451
564
|
customRules
|
|
452
565
|
});
|
|
453
566
|
|
|
454
567
|
if (args.checkLinks) {
|
|
455
568
|
log(c.dim(' ↳'), c.dim(`Checking links...`));
|
|
456
569
|
const deadLinks = await checkDeadLinks(content, { sourceFile: filePath });
|
|
570
|
+
for (const dl of deadLinks) { dl.source = relPath; }
|
|
457
571
|
analysis.errors.push(...deadLinks);
|
|
458
572
|
analysis.summary.errors = analysis.errors.length;
|
|
459
573
|
}
|
|
460
574
|
|
|
461
575
|
if (args.checkFreshness) {
|
|
462
576
|
log(c.dim(' ↳'), c.dim(`Checking freshness...`));
|
|
463
|
-
const freshnessWarnings = checkDocFreshness(content, { sourceFile:
|
|
577
|
+
const freshnessWarnings = checkDocFreshness(content, { sourceFile: relPath });
|
|
464
578
|
analysis.warnings.push(...freshnessWarnings);
|
|
465
579
|
analysis.summary.warnings = analysis.warnings.length;
|
|
466
580
|
}
|
|
467
581
|
|
|
468
|
-
fileResults.push(buildFileResult(filePath, analysis, { strict: resolved.strict }));
|
|
582
|
+
fileResults.push(buildFileResult(filePath, analysis, { strict: resolved.strict, ignoreRules: resolved.ignoreRules }));
|
|
469
583
|
} catch (err) {
|
|
470
|
-
fileErrors.push({ file: filePath, error: err.message });
|
|
584
|
+
fileErrors.push({ file: toRelativePath(filePath), error: err.message });
|
|
471
585
|
}
|
|
472
586
|
}
|
|
473
587
|
|
|
@@ -480,6 +594,16 @@ async function runCli(argv = process.argv.slice(2)) {
|
|
|
480
594
|
|
|
481
595
|
printResults(output);
|
|
482
596
|
|
|
597
|
+
if (args.fix && fixSummary.replacements > 0) {
|
|
598
|
+
const action = args.dryRun ? 'Would fix' : 'Fixed';
|
|
599
|
+
log(
|
|
600
|
+
args.dryRun ? c.yellow('~') : c.green('✓'),
|
|
601
|
+
`${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
|
+
);
|
|
603
|
+
} else if (args.fix && fixSummary.replacements === 0) {
|
|
604
|
+
log(c.dim('ℹ'), c.dim('No insecure links to fix'));
|
|
605
|
+
}
|
|
606
|
+
|
|
483
607
|
if (args.json) {
|
|
484
608
|
console.log(JSON.stringify(output, null, 2));
|
|
485
609
|
}
|
package/src/links.mjs
CHANGED
|
@@ -2,6 +2,9 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { stripCodeBlocks, stripInlineCode } from './checker.mjs';
|
|
4
4
|
|
|
5
|
+
const LINK_TIMEOUT_MS = 8000;
|
|
6
|
+
const CONCURRENCY = 5;
|
|
7
|
+
|
|
5
8
|
function extractLinks(content) {
|
|
6
9
|
const stripped = stripCodeBlocks(content);
|
|
7
10
|
const lines = stripped.split('\n');
|
|
@@ -42,20 +45,28 @@ function isSkippableUrl(url) {
|
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
async function checkRemoteUrl(url) {
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const timer = setTimeout(() => controller.abort(), LINK_TIMEOUT_MS);
|
|
50
|
+
|
|
45
51
|
try {
|
|
46
|
-
const headRes = await fetch(url, { method: 'HEAD', redirect: 'follow' });
|
|
52
|
+
const headRes = await fetch(url, { method: 'HEAD', redirect: 'follow', signal: controller.signal });
|
|
53
|
+
clearTimeout(timer);
|
|
47
54
|
if (headRes.status < 400) {
|
|
48
55
|
return null;
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
if (headRes.status === 405 || headRes.status === 501) {
|
|
52
|
-
const
|
|
59
|
+
const timer2 = setTimeout(() => controller.abort(), LINK_TIMEOUT_MS);
|
|
60
|
+
const getRes = await fetch(url, { method: 'GET', redirect: 'follow', signal: controller.signal });
|
|
61
|
+
clearTimeout(timer2);
|
|
53
62
|
if (getRes.status < 400) return null;
|
|
54
63
|
return `HTTP ${getRes.status}`;
|
|
55
64
|
}
|
|
56
65
|
|
|
57
66
|
return `HTTP ${headRes.status}`;
|
|
58
67
|
} catch (err) {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
if (err.name === 'AbortError') return `Timeout (${LINK_TIMEOUT_MS / 1000}s)`;
|
|
59
70
|
return err.message;
|
|
60
71
|
}
|
|
61
72
|
}
|
|
@@ -68,11 +79,30 @@ function checkLocalUrl(url, sourceFile) {
|
|
|
68
79
|
return fs.existsSync(targetPath) ? null : 'Target not found';
|
|
69
80
|
}
|
|
70
81
|
|
|
82
|
+
async function runWithConcurrency(tasks, limit) {
|
|
83
|
+
const results = [];
|
|
84
|
+
let idx = 0;
|
|
85
|
+
|
|
86
|
+
async function worker() {
|
|
87
|
+
while (idx < tasks.length) {
|
|
88
|
+
const i = idx++;
|
|
89
|
+
results[i] = await tasks[i]();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker());
|
|
94
|
+
await Promise.all(workers);
|
|
95
|
+
return results;
|
|
96
|
+
}
|
|
97
|
+
|
|
71
98
|
async function checkDeadLinks(content, { sourceFile }) {
|
|
72
99
|
const links = extractLinks(content);
|
|
73
100
|
const findings = [];
|
|
74
101
|
const seen = new Set();
|
|
75
102
|
|
|
103
|
+
// Local links first (sync, fast)
|
|
104
|
+
const remoteChecks = [];
|
|
105
|
+
|
|
76
106
|
for (const link of links) {
|
|
77
107
|
const url = link.url;
|
|
78
108
|
if (!url || isSkippableUrl(url)) continue;
|
|
@@ -82,21 +112,11 @@ async function checkDeadLinks(content, { sourceFile }) {
|
|
|
82
112
|
seen.add(dedupeKey);
|
|
83
113
|
|
|
84
114
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
85
|
-
|
|
86
|
-
if (error) {
|
|
87
|
-
findings.push({
|
|
88
|
-
code: 'dead-link',
|
|
89
|
-
severity: 'error',
|
|
90
|
-
line: link.line,
|
|
91
|
-
message: `Dead link: ${url} (${error})`,
|
|
92
|
-
source: sourceFile
|
|
93
|
-
});
|
|
94
|
-
}
|
|
115
|
+
remoteChecks.push({ url, link });
|
|
95
116
|
continue;
|
|
96
117
|
}
|
|
97
118
|
|
|
98
119
|
if (url.startsWith('/')) {
|
|
99
|
-
// Absolute filesystem paths are intentionally ignored for portability.
|
|
100
120
|
continue;
|
|
101
121
|
}
|
|
102
122
|
|
|
@@ -112,7 +132,28 @@ async function checkDeadLinks(content, { sourceFile }) {
|
|
|
112
132
|
}
|
|
113
133
|
}
|
|
114
134
|
|
|
135
|
+
// Remote links in parallel with concurrency limit
|
|
136
|
+
if (remoteChecks.length > 0) {
|
|
137
|
+
const tasks = remoteChecks.map(({ url, link }) => async () => {
|
|
138
|
+
const error = await checkRemoteUrl(url);
|
|
139
|
+
return { url, link, error };
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const results = await runWithConcurrency(tasks, CONCURRENCY);
|
|
143
|
+
for (const { url, link, error } of results) {
|
|
144
|
+
if (error) {
|
|
145
|
+
findings.push({
|
|
146
|
+
code: 'dead-link',
|
|
147
|
+
severity: 'error',
|
|
148
|
+
line: link.line,
|
|
149
|
+
message: `Dead link: ${url} (${error})`,
|
|
150
|
+
source: sourceFile
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
115
156
|
return findings;
|
|
116
157
|
}
|
|
117
158
|
|
|
118
|
-
export { extractLinks, checkDeadLinks };
|
|
159
|
+
export { extractLinks, checkDeadLinks };
|