doclify-guardrail 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lorenzo Borgato
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,250 @@
1
+ # Doclify Guardrail
2
+
3
+ **Quality gate for your Markdown docs. Catches errors in seconds.**
4
+
5
+ Zero dependencies. Node.js built-in only. Works everywhere Node 20+ runs.
6
+
7
+ ## Features
8
+
9
+ - Multi-file and directory scanning with glob support
10
+ - Line numbers in every finding
11
+ - Code block exclusion (no false positives on code examples)
12
+ - Markdown report generation for CI
13
+ - Custom regex-based rules via JSON
14
+ - Colored terminal output
15
+ - Extended placeholder detection (TODO, FIXME, TBD, WIP, and more)
16
+ - Insecure link detection (inline, bare URLs, reference-style)
17
+ - Doc Health Score per file and project average (0–100)
18
+ - Optional dead link checker (`--check-links`)
19
+ - Optional freshness checker (`--check-freshness`)
20
+ - CI-ready output exports (`--junit`, `--sarif`)
21
+ - Local docs health badge generation (`--badge`, `--badge-label`)
22
+ - Optional safe auto-fix mode (`--fix`, `--dry-run`)
23
+
24
+ ## Quick Start
25
+
26
+ ```bash
27
+ # Scan a single file
28
+ npx doclify-guardrail README.md
29
+
30
+ # Scan an entire directory
31
+ npx doclify-guardrail docs/
32
+
33
+ # Strict mode (warnings = failure)
34
+ npx doclify-guardrail docs/ --strict
35
+
36
+ # Generate a report
37
+ npx doclify-guardrail docs/ --report
38
+
39
+ # Check dead links (HTTP status + local relative paths)
40
+ npx doclify-guardrail docs/ --check-links
41
+
42
+ # Check document freshness (warn if stale or missing updated date)
43
+ npx doclify-guardrail docs/ --check-freshness
44
+
45
+ # Export CI reports
46
+ npx doclify-guardrail docs/ --junit --sarif
47
+
48
+ # Generate local badge SVG
49
+ npx doclify-guardrail docs/ --badge --badge-label "docs health"
50
+
51
+ # Auto-fix safe issues (v1: http:// -> https://)
52
+ npx doclify-guardrail docs/ --fix
53
+
54
+ # Preview auto-fix without writing files
55
+ npx doclify-guardrail docs/ --fix --dry-run
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ ```
61
+ doclify-guardrail <file.md ...> [options]
62
+ doclify-guardrail --dir <path> [options]
63
+ ```
64
+
65
+ ### Options
66
+
67
+ | Flag | Description |
68
+ |------|-------------|
69
+ | `--strict` | Treat warnings as failures |
70
+ | `--max-line-length <n>` | Maximum line length (default: 160) |
71
+ | `--config <path>` | Config file path (default: `.doclify-guardrail.json`) |
72
+ | `--dir <path>` | Scan all `.md` files in directory (recursive) |
73
+ | `--report [path]` | Generate markdown report (default: `doclify-report.md`) |
74
+ | `--rules <path>` | Load custom rules from JSON file |
75
+ | `--check-links` | Validate links and fail on dead links |
76
+ | `--check-freshness` | Warn if docs are stale or missing an updated date (default max age: 180 days) |
77
+ | `--junit [path]` | Export JUnit XML report (default: `doclify-junit.xml`) |
78
+ | `--sarif [path]` | Export SARIF report (default: `doclify.sarif`) |
79
+ | `--badge [path]` | Generate SVG docs health badge (default: `doclify-badge.svg`) |
80
+ | `--badge-label <text>` | Custom label used in the generated badge |
81
+ | `--fix` | Auto-fix safe issues (v1: `http://` to `https://`) |
82
+ | `--dry-run` | Preview `--fix` changes without writing files (only valid with `--fix`) |
83
+ | `--no-color` | Disable colored output |
84
+ | `--debug` | Show runtime details |
85
+ | `-h, --help` | Show help |
86
+
87
+ ### Exit Codes
88
+
89
+ | Code | Meaning |
90
+ |------|---------|
91
+ | `0` | PASS -- all files clean |
92
+ | `1` | FAIL -- errors found, or warnings in strict mode |
93
+ | `2` | Usage error / invalid input |
94
+
95
+ ## Configuration
96
+
97
+ Create a `.doclify-guardrail.json` in your project root:
98
+
99
+ ```json
100
+ {
101
+ "maxLineLength": 120,
102
+ "strict": true
103
+ }
104
+ ```
105
+
106
+ CLI flags override config file values.
107
+
108
+ ## Built-in Rules
109
+
110
+ | Rule | Severity | Description |
111
+ |------|----------|-------------|
112
+ | `frontmatter` | warning | Missing YAML frontmatter block |
113
+ | `single-h1` | error | Zero or multiple H1 headings |
114
+ | `line-length` | warning | Lines exceeding max length |
115
+ | `placeholder` | warning | TODO, FIXME, TBD, WIP, HACK, CHANGEME, lorem ipsum, etc. |
116
+ | `insecure-link` | warning | HTTP links (should be HTTPS) |
117
+ | `dead-link` | error | Broken links (enabled with `--check-links`) |
118
+ | `stale-doc` | warning | Missing/old freshness date (enabled with `--check-freshness`) |
119
+
120
+ All rules respect code block exclusion -- content inside fenced code blocks
121
+ and inline code is never flagged.
122
+
123
+ ## Custom Rules
124
+
125
+ Create a JSON file with custom regex-based rules:
126
+
127
+ ```json
128
+ {
129
+ "rules": [
130
+ {
131
+ "id": "no-internal-urls",
132
+ "severity": "error",
133
+ "pattern": "https://internal\\.company\\.com",
134
+ "message": "Internal URL found -- remove before publishing"
135
+ },
136
+ {
137
+ "id": "no-draft-marker",
138
+ "severity": "warning",
139
+ "pattern": "\\[DRAFT\\]",
140
+ "message": "Draft marker found in document"
141
+ }
142
+ ]
143
+ }
144
+ ```
145
+
146
+ ```bash
147
+ doclify-guardrail docs/ --rules my-rules.json
148
+ ```
149
+
150
+ Custom rules are applied after built-in rules and respect code block exclusion.
151
+
152
+ ## CI Integration
153
+
154
+ ### GitHub Actions
155
+
156
+ ```yaml
157
+ - name: Docs quality gate
158
+ run: npx doclify-guardrail docs/ --strict --report
159
+ ```
160
+
161
+ See `.github/workflows/docs-check.yml` for a complete example workflow.
162
+
163
+ ### JSON Output
164
+
165
+ Pipe JSON output to other tools:
166
+
167
+ ```bash
168
+ doclify-guardrail docs/ 2>/dev/null | jq '.summary'
169
+ ```
170
+
171
+ ## Dead Link Checker
172
+
173
+ Use `--check-links` to validate:
174
+ - `http(s)` links via HTTP status checks
175
+ - relative local file links (e.g. `./guide.md`)
176
+
177
+ When dead links are found, they are reported as `dead-link` errors.
178
+
179
+ ## CI Outputs
180
+
181
+ Use CI export flags for native pipeline integrations:
182
+
183
+ ```bash
184
+ doclify-guardrail docs/ --junit --sarif
185
+ ```
186
+
187
+ - `--junit` writes XML test output (useful for generic CI test dashboards)
188
+ - `--sarif` writes SARIF v2.1.0 (GitHub code scanning compatible)
189
+
190
+ ## Badge SVG
191
+
192
+ Generate a local SVG badge from the current scan score:
193
+
194
+ ```bash
195
+ doclify-guardrail docs/ --badge --badge-label "docs health"
196
+ ```
197
+
198
+ The value is computed from findings severity and can be embedded in your README.
199
+
200
+ ## Auto-fix (safe v1)
201
+
202
+ Use `--fix` to automatically upgrade safe `http://` links to `https://`.
203
+ Ambiguous URLs (for example `localhost` or custom ports) are reported and left unchanged.
204
+
205
+ Use `--dry-run` only together with `--fix` to preview changes without writing files.
206
+ Using `--dry-run` alone is a usage error (exit code `2`).
207
+
208
+ ## Doc Health Score
209
+
210
+ Each file now includes `summary.healthScore` (0–100) in JSON output.
211
+ Overall summary includes `summary.avgHealthScore`.
212
+
213
+ Scoring is deterministic and compatibility-safe:
214
+ - starts at `100`
215
+ - `-25` per error
216
+ - `-8` per warning
217
+ - clamped to `0..100`
218
+
219
+ ## Doc Freshness
220
+
221
+ Use `--check-freshness` to detect stale docs.
222
+ The checker looks for dates in:
223
+ - frontmatter keys: `updated`, `last_updated`, `lastModified`, `date`
224
+ - body marker: `Last updated: YYYY-MM-DD`
225
+
226
+ If no date is found or the doc is older than 180 days, a `stale-doc` warning is added.
227
+
228
+ ## Report
229
+
230
+ Use `--report` to generate a markdown report:
231
+
232
+ ```bash
233
+ doclify-guardrail docs/ --report quality-report.md
234
+ ```
235
+
236
+ The report includes a summary table, per-file details with line numbers,
237
+ and execution metadata.
238
+
239
+ ## License
240
+
241
+ MIT
242
+
243
+ ---
244
+
245
+ ## Italiano
246
+
247
+ Doclify Guardrail e' un quality gate per la documentazione Markdown.
248
+ Zero dipendenze esterne, funziona ovunque giri Node.js 20+.
249
+ Rileva errori, placeholder dimenticati, link insicuri e problemi di
250
+ formattazione con numeri di riga precisi e report in formato Markdown.
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "doclify-guardrail",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Quality gate for your Markdown docs. Zero dependencies, catches errors in seconds.",
6
+ "files": [
7
+ "src/",
8
+ "README.md"
9
+ ],
10
+ "bin": {
11
+ "doclify-guardrail": "./src/index.mjs"
12
+ },
13
+ "scripts": {
14
+ "start": "node ./src/index.mjs",
15
+ "test": "node --test",
16
+ "demo": "bash ./scripts/demo.sh"
17
+ },
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "license": "MIT",
22
+ "keywords": [
23
+ "markdown",
24
+ "lint",
25
+ "documentation",
26
+ "quality",
27
+ "guardrail",
28
+ "cli"
29
+ ]
30
+ }
@@ -0,0 +1,206 @@
1
+ const DEFAULTS = {
2
+ maxLineLength: 160,
3
+ strict: false
4
+ };
5
+
6
+ const RULE_SEVERITY = {
7
+ frontmatter: 'warning',
8
+ 'single-h1': 'error',
9
+ 'line-length': 'warning',
10
+ placeholder: 'warning',
11
+ 'insecure-link': 'warning',
12
+ 'dead-link': 'error',
13
+ 'stale-doc': 'warning'
14
+ };
15
+
16
+ const PLACEHOLDER_PATTERNS = [
17
+ { rx: /\bTODO\b/i, msg: 'TODO marker found — remove before publishing' },
18
+ { rx: /\bFIXME\b/i, msg: 'FIXME marker found — remove before publishing' },
19
+ { rx: /\bHACK\b/i, msg: 'HACK marker found — remove before publishing' },
20
+ { rx: /\bTBD\b/i, msg: 'TBD (to be determined) marker found' },
21
+ { rx: /\bWIP\b/i, msg: 'WIP (work in progress) marker found' },
22
+ { rx: /\bCHANGEME\b/i, msg: 'CHANGEME marker found — update before publishing' },
23
+ { rx: /\bPLACEHOLDER\b/i, msg: 'PLACEHOLDER marker found — replace with actual content' },
24
+ { rx: /\[insert\s+here\]/i, msg: '"[insert here]" placeholder found' },
25
+ { rx: /lorem ipsum/i, msg: 'Lorem ipsum placeholder text found' },
26
+ { rx: /\bxxx\b/i, msg: '"xxx" placeholder found' }
27
+ ];
28
+
29
+ function normalizeFinding(rule, message, line, source) {
30
+ const finding = {
31
+ code: rule,
32
+ severity: RULE_SEVERITY[rule] || 'warning',
33
+ message
34
+ };
35
+ if (line != null) finding.line = line;
36
+ if (source != null) finding.source = source;
37
+ return finding;
38
+ }
39
+
40
+ /**
41
+ * Replace content inside fenced code blocks with empty lines.
42
+ * Preserves line count so line numbers remain accurate.
43
+ */
44
+ function stripCodeBlocks(content) {
45
+ const lines = content.split('\n');
46
+ const result = [];
47
+ let inCodeBlock = false;
48
+ let fenceChar = null;
49
+ let fenceLen = 0;
50
+
51
+ for (const line of lines) {
52
+ if (!inCodeBlock) {
53
+ const fenceMatch = line.match(/^(`{3,}|~{3,})/);
54
+ if (fenceMatch) {
55
+ inCodeBlock = true;
56
+ fenceChar = fenceMatch[1][0];
57
+ fenceLen = fenceMatch[1].length;
58
+ result.push('');
59
+ } else {
60
+ result.push(line);
61
+ }
62
+ } else {
63
+ const closeMatch = line.match(/^(`{3,}|~{3,})\s*$/);
64
+ if (closeMatch && closeMatch[1][0] === fenceChar && closeMatch[1].length >= fenceLen) {
65
+ inCodeBlock = false;
66
+ fenceChar = null;
67
+ fenceLen = 0;
68
+ }
69
+ result.push('');
70
+ }
71
+ }
72
+
73
+ return result.join('\n');
74
+ }
75
+
76
+ /**
77
+ * Strip inline code from a single line for rule matching.
78
+ */
79
+ function stripInlineCode(line) {
80
+ return line.replace(/`[^`]+`/g, '');
81
+ }
82
+
83
+ function checkMarkdown(rawContent, opts = {}) {
84
+ const maxLineLength = Number(opts.maxLineLength ?? DEFAULTS.maxLineLength);
85
+ const filePath = opts.filePath || undefined;
86
+ const errors = [];
87
+ const warnings = [];
88
+
89
+ // Strip code blocks for semantic rules
90
+ const content = stripCodeBlocks(rawContent);
91
+ const lines = content.split('\n');
92
+ const rawLines = rawContent.split('\n');
93
+
94
+ // Rule: frontmatter
95
+ if (!rawContent.startsWith('---\n')) {
96
+ warnings.push(normalizeFinding('frontmatter', 'Missing frontmatter block at the beginning of the file.', 1, filePath));
97
+ }
98
+
99
+ // Rule: single-h1
100
+ const h1Lines = [];
101
+ lines.forEach((line, idx) => {
102
+ if (/^#\s/.test(line)) {
103
+ h1Lines.push(idx + 1);
104
+ }
105
+ });
106
+
107
+ if (h1Lines.length === 0) {
108
+ errors.push(normalizeFinding('single-h1', 'Missing H1 heading.', 1, filePath));
109
+ } else if (h1Lines.length > 1) {
110
+ const lineList = h1Lines.join(', ');
111
+ for (const lineNum of h1Lines) {
112
+ errors.push(normalizeFinding(
113
+ 'single-h1',
114
+ `Found ${h1Lines.length} H1 headings (expected 1) at lines ${lineList}.`,
115
+ lineNum,
116
+ filePath
117
+ ));
118
+ }
119
+ }
120
+
121
+ // Rule: line-length (uses raw content — code block lines can still be too long)
122
+ rawLines.forEach((line, idx) => {
123
+ if (line.length > maxLineLength) {
124
+ warnings.push(
125
+ normalizeFinding(
126
+ 'line-length',
127
+ `Line exceeds ${maxLineLength} characters (${line.length}).`,
128
+ idx + 1,
129
+ filePath
130
+ )
131
+ );
132
+ }
133
+ });
134
+
135
+ // Rule: placeholder (uses stripped content)
136
+ lines.forEach((line, idx) => {
137
+ const cleanLine = stripInlineCode(line);
138
+ for (const { rx, msg } of PLACEHOLDER_PATTERNS) {
139
+ if (rx.test(cleanLine)) {
140
+ warnings.push(normalizeFinding('placeholder', msg, idx + 1, filePath));
141
+ }
142
+ }
143
+ });
144
+
145
+ // Rule: insecure-link (uses stripped content)
146
+ lines.forEach((line, idx) => {
147
+ const cleanLine = stripInlineCode(line);
148
+
149
+ // Inline markdown links: [text](http://...)
150
+ const inlineMatches = cleanLine.match(/\[.*?\]\(http:\/\/[^)]+\)/g);
151
+ if (inlineMatches) {
152
+ for (const match of inlineMatches) {
153
+ const url = match.match(/\((http:\/\/[^)]+)\)/)?.[1] || '';
154
+ warnings.push(
155
+ normalizeFinding('insecure-link', `Insecure link found: ${url} — use https:// instead`, idx + 1, filePath)
156
+ );
157
+ }
158
+ }
159
+
160
+ // Reference-style link definitions: [label]: http://...
161
+ const refMatch = cleanLine.match(/^\[.*?\]:\s*(http:\/\/\S+)/);
162
+ if (refMatch) {
163
+ warnings.push(
164
+ normalizeFinding('insecure-link', `Insecure link found: ${refMatch[1]} — use https:// instead`, idx + 1, filePath)
165
+ );
166
+ }
167
+
168
+ // Bare URLs: http://... (not inside markdown link syntax)
169
+ // Only check if no inline links were found on this line to avoid duplicates
170
+ if (!inlineMatches && !refMatch) {
171
+ const bareMatch = cleanLine.match(/\bhttp:\/\/\S+/g);
172
+ if (bareMatch) {
173
+ for (const url of bareMatch) {
174
+ warnings.push(
175
+ normalizeFinding('insecure-link', `Insecure link found: ${url} — use https:// instead`, idx + 1, filePath)
176
+ );
177
+ }
178
+ }
179
+ }
180
+ });
181
+
182
+ // Custom rules (uses stripped content)
183
+ if (opts.customRules && opts.customRules.length > 0) {
184
+ lines.forEach((line, idx) => {
185
+ const cleanLine = stripInlineCode(line);
186
+ for (const rule of opts.customRules) {
187
+ rule.pattern.lastIndex = 0;
188
+ if (rule.pattern.test(cleanLine)) {
189
+ const bucket = rule.severity === 'error' ? errors : warnings;
190
+ bucket.push(normalizeFinding(rule.id, rule.message, idx + 1, filePath));
191
+ }
192
+ }
193
+ });
194
+ }
195
+
196
+ return {
197
+ errors,
198
+ warnings,
199
+ summary: {
200
+ errors: errors.length,
201
+ warnings: warnings.length
202
+ }
203
+ };
204
+ }
205
+
206
+ export { DEFAULTS, RULE_SEVERITY, normalizeFinding, checkMarkdown, stripCodeBlocks, stripInlineCode };