doc-drift-check 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-05-15
9
+
10
+ ### Added
11
+
12
+ - Initial release.
13
+ - Three drift classes: stale file paths, stale npm scripts, broken relative markdown links.
14
+ - Fenced code block skipping (``` and ~~~).
15
+ - Per-file ignore marker (`<!-- doc-drift: ignore -->` within first N header lines).
16
+ - Per-run existence cache.
17
+ - Programmatic API (`checkDocs`) and CLI (`doc-drift-check`).
18
+ - Zero runtime dependencies; requires Node `>=22`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Norm Anderson
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,183 @@
1
+ # doc-drift-check
2
+
3
+ Detect drift between markdown documentation and the code it describes. Zero runtime dependencies, Node 22+.
4
+
5
+ ## What it does
6
+
7
+ Scans markdown files for three classes of stale reference and fails if any are broken:
8
+
9
+ 1. **File paths** — backticked tokens like `` `src/foo.js` `` that point to files no longer in the repo.
10
+ 2. **npm scripts** — backticked tokens like `` `npm run build` `` whose script name is missing from `package.json#scripts`.
11
+ 3. **Relative markdown links** — `[text](path)` and `![alt](src)` whose target file does not exist.
12
+
13
+ Built for agent-centric docs (AGENTS.md, ARCHITECTURE.md, runbooks) that promise specific files and commands to autonomous tooling — and rot the moment anything is renamed.
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ npm i -D doc-drift-check
19
+ ```
20
+
21
+ Requires Node `>=22` (uses native `fs.glob`).
22
+
23
+ ## Quick start
24
+
25
+ Create `doc-drift.config.js` in your repo root:
26
+
27
+ ```js
28
+ export default {
29
+ cwd: process.cwd(),
30
+ files: [
31
+ 'AGENTS.md',
32
+ 'ARCHITECTURE.md',
33
+ 'docs/runbooks/**/*.md',
34
+ ],
35
+ checks: {
36
+ paths: {
37
+ pattern: /`(src\/[a-z0-9-]+(?:\/[a-z0-9-]+)*\.(?:js|cjs|mjs))`/g,
38
+ },
39
+ scripts: {
40
+ pattern: /`npm run ([a-z0-9:_-]+)`/g,
41
+ registry: 'package.json#scripts',
42
+ },
43
+ links: { enabled: true },
44
+ },
45
+ };
46
+ ```
47
+
48
+ Run:
49
+
50
+ ```sh
51
+ npx doc-drift-check
52
+ ```
53
+
54
+ Exit codes: `0` clean, `1` drift detected, `2` usage/config error.
55
+
56
+ ## Configuration reference
57
+
58
+ | Field | Type | Default | Purpose |
59
+ |-------|------|---------|---------|
60
+ | `cwd` | `string` \| `URL` | `process.cwd()` | Root for path resolution. |
61
+ | `files` | `string[]` | _(required)_ | Plain paths or globs (`*`, `?`, `**`, `{a,b}`). |
62
+ | `checks.paths.pattern` | `RegExp` | _(none)_ | Global regex with one capture group for the path. |
63
+ | `checks.paths.resolveFrom` | `string` | `cwd` | Override root for path existence. |
64
+ | `checks.scripts.pattern` | `RegExp` | _(none)_ | Global regex with one capture group for the script name. |
65
+ | `checks.scripts.registry` | `Set`, `string`, `array`, or `() => Set` | _(required if scripts enabled)_ | Source of valid script names. String form `'package.json#<key>'` reads JSON keys. |
66
+ | `checks.links.enabled` | `boolean` | `true` if `links` present | Toggle markdown link check. |
67
+ | `ignore.marker` | `string` | `<!-- doc-drift: ignore -->` | Per-file skip marker. |
68
+ | `ignore.headerLines` | `number` | `3` | How many top lines to scan for the marker. |
69
+
70
+ ## Drift classes
71
+
72
+ ### File paths
73
+
74
+ Useful for AGENTS.md sections like "modules live in `src/...`". The default pattern (configurable per repo) catches lowercase `src/foo/bar.js`-style references. Resolves relative to `resolveFrom` (default cwd).
75
+
76
+ ### npm scripts
77
+
78
+ Catches `` `npm run build` `` references whose script name is gone from `package.json`. Registry can be:
79
+ - a string `'package.json#scripts'` (reads JSON keys),
80
+ - a `Set<string>` (literal list),
81
+ - an array (coerced to Set),
82
+ - a `() => Set | Promise<Set>` (computed at runtime).
83
+
84
+ ### Markdown links
85
+
86
+ Standard `[text](href)` and `![alt](src)` syntax. Skips:
87
+ - absolute URLs (any `scheme:` prefix)
88
+ - bare anchors (`#section`)
89
+
90
+ Strips `#anchor` and `?query` before existence check. Repo-absolute links (`/docs/foo.md`) resolve from `cwd`; relative links resolve from the containing file's directory.
91
+
92
+ ## Fenced code blocks
93
+
94
+ Lines inside ` ``` ` or `~~~` fences are skipped to keep example code from producing false positives. Fence delimiters of length 4+ behave the same.
95
+
96
+ ## Per-file opt-out
97
+
98
+ Place the marker within the first `headerLines` of a file to skip it entirely:
99
+
100
+ ```md
101
+ <!-- doc-drift: ignore -->
102
+
103
+ # Some doc
104
+ ```
105
+
106
+ Useful for design notes that intentionally reference paths that don't exist (yet).
107
+
108
+ ## Programmatic API
109
+
110
+ ```js
111
+ import { checkDocs } from 'doc-drift-check';
112
+
113
+ const result = await checkDocs({
114
+ cwd: process.cwd(),
115
+ files: ['AGENTS.md'],
116
+ checks: { links: { enabled: true } },
117
+ });
118
+
119
+ if (!result.ok) {
120
+ for (const err of result.errors) {
121
+ console.error(`${err.file}:${err.line}: ${err.kind}: ${err.token}`);
122
+ }
123
+ process.exit(1);
124
+ }
125
+ ```
126
+
127
+ `result` shape:
128
+
129
+ ```ts
130
+ {
131
+ ok: boolean;
132
+ errors: Array<{
133
+ file: string;
134
+ line: number;
135
+ kind: 'missing-file' | 'missing-script' | 'broken-link';
136
+ token: string;
137
+ message: string;
138
+ }>;
139
+ filesScanned: number;
140
+ }
141
+ ```
142
+
143
+ ## CI integration
144
+
145
+ ```yml
146
+ - uses: actions/setup-node@v4
147
+ with:
148
+ node-version: 22
149
+ - run: npm ci
150
+ - run: npx doc-drift-check
151
+ ```
152
+
153
+ Or chain into an existing validate script:
154
+
155
+ ```json
156
+ {
157
+ "scripts": {
158
+ "docs:check": "doc-drift-check",
159
+ "validate": "npm run lint && npm run docs:check && npm test"
160
+ }
161
+ }
162
+ ```
163
+
164
+ ## vs `markdown-link-check`
165
+
166
+ | Concern | `markdown-link-check` | `doc-drift-check` |
167
+ |---------|----------------------|-------------------|
168
+ | Runtime deps | 40+ transitive | 0 |
169
+ | Known CVEs | 13 at time of writing | 0 |
170
+ | Drift classes | Links only | Paths, scripts, links |
171
+ | Configurable patterns | Limited | Full regex per class |
172
+ | External URL fetch | Yes (network flake) | No (deferred to v0.2) |
173
+ | Per-file opt-out | No | Yes |
174
+
175
+ `doc-drift-check` does not check live URLs. That's a separate problem with different failure modes (rate limits, transient errors) — outside the scope of "your repo no longer matches its docs."
176
+
177
+ ## Stability
178
+
179
+ `0.x` releases follow semver but the surface is small enough that minor versions may add config keys. Breaking config changes will bump major. The CLI exit codes (`0`/`1`/`2`) are stable.
180
+
181
+ ## License
182
+
183
+ MIT
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ import process from 'node:process';
3
+ import { checkDocs } from '../src/index.js';
4
+ import { loadConfig, ConfigError } from '../src/config/load.js';
5
+ import { walk } from '../src/walker.js';
6
+
7
+ const USAGE = `Usage:
8
+ doc-drift-check [--config <path>] [--list-files] [--help]
9
+
10
+ Options:
11
+ --config <path> path to config file (default: ./doc-drift.config.js)
12
+ --list-files print resolved file list (debug) and exit 0
13
+ --help, -h show this help
14
+
15
+ Exit codes:
16
+ 0 clean
17
+ 1 drift detected
18
+ 2 usage / config error
19
+ `;
20
+
21
+ function parseArgs(argv) {
22
+ const out = { config: null, listFiles: false, help: false };
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const a = argv[i];
25
+ if (a === '--help' || a === '-h') out.help = true;
26
+ else if (a === '--list-files') out.listFiles = true;
27
+ else if (a === '--config') {
28
+ out.config = argv[++i];
29
+ if (!out.config) {
30
+ process.stderr.write('--config requires a path\n');
31
+ process.exit(2);
32
+ }
33
+ } else if (a.startsWith('--config=')) {
34
+ out.config = a.slice('--config='.length);
35
+ } else {
36
+ process.stderr.write(`unknown argument: ${a}\n`);
37
+ process.exit(2);
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+
43
+ function format(err) {
44
+ return `${err.file}:${err.line}: ${err.kind}: ${err.token} — ${err.message}`;
45
+ }
46
+
47
+ async function main() {
48
+ const args = parseArgs(process.argv.slice(2));
49
+ if (args.help) {
50
+ process.stdout.write(USAGE);
51
+ return 0;
52
+ }
53
+
54
+ let cfg;
55
+ try {
56
+ cfg = await loadConfig(args.config, process.cwd());
57
+ } catch (err) {
58
+ process.stderr.write(`${err.message}\n`);
59
+ return err.exitCode ?? 2;
60
+ }
61
+
62
+ if (args.listFiles) {
63
+ const files = await walk(cfg.files ?? [], cfg.cwd ?? process.cwd());
64
+ for (const f of files) process.stdout.write(`${f}\n`);
65
+ return 0;
66
+ }
67
+
68
+ let result;
69
+ try {
70
+ result = await checkDocs(cfg);
71
+ } catch (err) {
72
+ process.stderr.write(`${err.message}\n`);
73
+ return err.exitCode ?? 2;
74
+ }
75
+
76
+ if (!result.ok) {
77
+ for (const e of result.errors) process.stderr.write(`${format(e)}\n`);
78
+ process.stderr.write(`\n${result.errors.length} drift error(s) across ${result.filesScanned} file(s)\n`);
79
+ return 1;
80
+ }
81
+
82
+ process.stdout.write(`doc-drift-check: ${result.filesScanned} file(s) clean\n`);
83
+ return 0;
84
+ }
85
+
86
+ main().then(
87
+ (code) => process.exit(code),
88
+ (err) => {
89
+ process.stderr.write(`unexpected: ${err.stack ?? err.message}\n`);
90
+ process.exit(2);
91
+ },
92
+ );
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "doc-drift-check",
3
+ "version": "0.1.0",
4
+ "description": "Detect drift between markdown docs and the code they describe. Zero runtime dependencies.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "bin": {
11
+ "doc-drift-check": "bin/doc-drift-check.js"
12
+ },
13
+ "scripts": {
14
+ "test": "node --test \"test/**/*.test.js\"",
15
+ "test:coverage": "node --test --experimental-test-coverage \"test/**/*.test.js\""
16
+ },
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "files": [
21
+ "src/",
22
+ "bin/",
23
+ "README.md",
24
+ "LICENSE",
25
+ "CHANGELOG.md"
26
+ ],
27
+ "keywords": [
28
+ "markdown",
29
+ "documentation",
30
+ "lint",
31
+ "doc-drift",
32
+ "agents.md",
33
+ "ci"
34
+ ],
35
+ "author": "Norm Anderson",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/blytzynorm/doc-drift-check.git"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/blytzynorm/doc-drift-check/issues"
43
+ },
44
+ "homepage": "https://github.com/blytzynorm/doc-drift-check#readme"
45
+ }
package/src/cache.js ADDED
@@ -0,0 +1,21 @@
1
+ import { access } from 'node:fs/promises';
2
+
3
+ export function createCache() {
4
+ const map = new Map();
5
+ return {
6
+ async exists(absPath) {
7
+ if (map.has(absPath)) return map.get(absPath);
8
+ let ok = true;
9
+ try {
10
+ await access(absPath);
11
+ } catch {
12
+ ok = false;
13
+ }
14
+ map.set(absPath, ok);
15
+ return ok;
16
+ },
17
+ get size() {
18
+ return map.size;
19
+ },
20
+ };
21
+ }
@@ -0,0 +1,37 @@
1
+ import path from 'node:path';
2
+ import { KINDS } from '../util.js';
3
+
4
+ const LINK_RE = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
5
+ const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
6
+
7
+ export async function checkLinks(ctx) {
8
+ const { line, lineNumber, filePath, absFilePath, cache, cwd } = ctx;
9
+ const fileDir = path.dirname(absFilePath ?? filePath);
10
+ const findings = [];
11
+
12
+ for (const m of line.matchAll(LINK_RE)) {
13
+ const href = m[1];
14
+ if (!href) continue;
15
+ if (SCHEME_RE.test(href)) continue;
16
+ if (href.startsWith('#')) continue;
17
+
18
+ const cleanHref = href.replace(/[#?].*$/, '');
19
+ if (!cleanHref) continue;
20
+
21
+ const absTarget = cleanHref.startsWith('/')
22
+ ? path.join(cwd, cleanHref)
23
+ : path.resolve(fileDir, cleanHref);
24
+
25
+ const ok = await cache.exists(absTarget);
26
+ if (!ok) {
27
+ findings.push({
28
+ file: filePath,
29
+ line: lineNumber,
30
+ kind: KINDS.BROKEN_LINK,
31
+ token: href,
32
+ message: `broken markdown link: ${href}`,
33
+ });
34
+ }
35
+ }
36
+ return findings;
37
+ }
@@ -0,0 +1,28 @@
1
+ import path from 'node:path';
2
+ import { KINDS, clonePattern } from '../util.js';
3
+
4
+ export async function checkPaths(ctx) {
5
+ const { line, lineNumber, filePath, opts, cache, cwd } = ctx;
6
+ if (!opts?.pattern) return [];
7
+
8
+ const pattern = clonePattern(opts.pattern);
9
+ const resolveFrom = opts.resolveFrom ?? cwd;
10
+ const findings = [];
11
+
12
+ for (const m of line.matchAll(pattern)) {
13
+ const token = m[1];
14
+ if (!token) continue;
15
+ const abs = path.resolve(resolveFrom, token);
16
+ const ok = await cache.exists(abs);
17
+ if (!ok) {
18
+ findings.push({
19
+ file: filePath,
20
+ line: lineNumber,
21
+ kind: KINDS.MISSING_FILE,
22
+ token,
23
+ message: `referenced file does not exist: ${token}`,
24
+ });
25
+ }
26
+ }
27
+ return findings;
28
+ }
@@ -0,0 +1,32 @@
1
+ import { KINDS, clonePattern, resolveRegistry } from '../util.js';
2
+
3
+ const resolvedRegistry = new WeakMap();
4
+
5
+ export async function checkScripts(ctx) {
6
+ const { line, lineNumber, filePath, opts, cwd } = ctx;
7
+ if (!opts?.pattern || opts.registry == null) return [];
8
+
9
+ let registry = resolvedRegistry.get(opts);
10
+ if (!registry) {
11
+ registry = await resolveRegistry(opts.registry, cwd);
12
+ resolvedRegistry.set(opts, registry);
13
+ }
14
+
15
+ const pattern = clonePattern(opts.pattern);
16
+ const findings = [];
17
+
18
+ for (const m of line.matchAll(pattern)) {
19
+ const token = m[1];
20
+ if (!token) continue;
21
+ if (!registry.has(token)) {
22
+ findings.push({
23
+ file: filePath,
24
+ line: lineNumber,
25
+ kind: KINDS.MISSING_SCRIPT,
26
+ token,
27
+ message: `referenced script does not exist: ${token}`,
28
+ });
29
+ }
30
+ }
31
+ return findings;
32
+ }
@@ -0,0 +1,68 @@
1
+ import path from 'node:path';
2
+ import { access } from 'node:fs/promises';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ const DEFAULT_NAMES = ['doc-drift.config.js', 'doc-drift.config.mjs'];
6
+
7
+ class ConfigError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.exitCode = 2;
11
+ this.name = 'ConfigError';
12
+ }
13
+ }
14
+
15
+ async function fileExists(p) {
16
+ try {
17
+ await access(p);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ export async function loadConfig(input, cwd = process.cwd()) {
25
+ if (input && typeof input === 'object' && !Array.isArray(input)) {
26
+ return normalize(input);
27
+ }
28
+
29
+ let resolved;
30
+ if (typeof input === 'string') {
31
+ resolved = path.resolve(cwd, input);
32
+ if (!(await fileExists(resolved))) {
33
+ throw new ConfigError(`config file not found: ${resolved}`);
34
+ }
35
+ } else {
36
+ for (const name of DEFAULT_NAMES) {
37
+ const candidate = path.resolve(cwd, name);
38
+ if (await fileExists(candidate)) {
39
+ resolved = candidate;
40
+ break;
41
+ }
42
+ }
43
+ if (!resolved) {
44
+ throw new ConfigError(
45
+ `no doc-drift.config.js found in ${cwd} (pass --config <path> or create one)`,
46
+ );
47
+ }
48
+ }
49
+
50
+ let mod;
51
+ try {
52
+ mod = await import(pathToFileURL(resolved).href);
53
+ } catch (err) {
54
+ throw new ConfigError(`failed to import config ${resolved}: ${err.message}`);
55
+ }
56
+
57
+ const cfg = mod.default ?? mod.config ?? mod;
58
+ if (!cfg || typeof cfg !== 'object') {
59
+ throw new ConfigError(`config at ${resolved} did not export an object`);
60
+ }
61
+ return normalize(cfg);
62
+ }
63
+
64
+ function normalize(cfg) {
65
+ return { ...cfg };
66
+ }
67
+
68
+ export { ConfigError };
package/src/index.js ADDED
@@ -0,0 +1,94 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { walk } from './walker.js';
4
+ import { iterLines } from './parser.js';
5
+ import { createCache } from './cache.js';
6
+ import { checkPaths } from './checks/paths.js';
7
+ import { checkScripts } from './checks/scripts.js';
8
+ import { checkLinks } from './checks/links.js';
9
+ import { resolveCwd } from './util.js';
10
+
11
+ const DEFAULT_IGNORE_MARKER = '<!-- doc-drift: ignore -->';
12
+ const DEFAULT_HEADER_LINES = 3;
13
+
14
+ function hasIgnoreMarker(content, marker, headerLines) {
15
+ let pos = 0;
16
+ for (let i = 0; i < headerLines; i++) {
17
+ const next = content.indexOf('\n', pos);
18
+ const line = next === -1 ? content.slice(pos) : content.slice(pos, next);
19
+ if (line.includes(marker)) return true;
20
+ if (next === -1) break;
21
+ pos = next + 1;
22
+ }
23
+ return false;
24
+ }
25
+
26
+ export async function checkDocs(options = {}) {
27
+ if (!options.files || !Array.isArray(options.files) || options.files.length === 0) {
28
+ const err = new Error('checkDocs: options.files must be a non-empty array');
29
+ err.exitCode = 2;
30
+ throw err;
31
+ }
32
+
33
+ const cwd = resolveCwd(options.cwd);
34
+ const ignore = options.ignore ?? {};
35
+ const marker = ignore.marker ?? DEFAULT_IGNORE_MARKER;
36
+ const headerLines = ignore.headerLines ?? DEFAULT_HEADER_LINES;
37
+
38
+ const checks = options.checks ?? {};
39
+ const cache = createCache();
40
+ const files = await walk(options.files, cwd);
41
+
42
+ const errors = [];
43
+ let filesScanned = 0;
44
+
45
+ for (const filePath of files) {
46
+ let content;
47
+ try {
48
+ content = await readFile(filePath, 'utf8');
49
+ } catch (err) {
50
+ const e = new Error(`failed to read ${filePath}: ${err.message}`);
51
+ e.exitCode = 2;
52
+ throw e;
53
+ }
54
+
55
+ filesScanned++;
56
+ if (hasIgnoreMarker(content, marker, headerLines)) continue;
57
+
58
+ const relFile = path.relative(cwd, filePath) || filePath;
59
+ const ctx = {
60
+ filePath: relFile,
61
+ absFilePath: filePath,
62
+ cache,
63
+ cwd,
64
+ line: '',
65
+ lineNumber: 0,
66
+ opts: null,
67
+ };
68
+
69
+ for (const { line, lineNumber, skip } of iterLines(content)) {
70
+ if (skip) continue;
71
+ ctx.line = line;
72
+ ctx.lineNumber = lineNumber;
73
+
74
+ if (checks.paths) {
75
+ ctx.opts = checks.paths;
76
+ errors.push(...(await checkPaths(ctx)));
77
+ }
78
+ if (checks.scripts) {
79
+ ctx.opts = checks.scripts;
80
+ errors.push(...(await checkScripts(ctx)));
81
+ }
82
+ if (checks.links && checks.links.enabled !== false) {
83
+ ctx.opts = checks.links;
84
+ errors.push(...(await checkLinks(ctx)));
85
+ }
86
+ }
87
+ }
88
+
89
+ return {
90
+ ok: errors.length === 0,
91
+ errors,
92
+ filesScanned,
93
+ };
94
+ }
package/src/parser.js ADDED
@@ -0,0 +1,29 @@
1
+ const BACKTICK_FENCE = /^`{3,}/;
2
+ const TILDE_FENCE = /^~{3,}/;
3
+
4
+ export function* iterLines(content) {
5
+ const text = content.replace(/\r\n/g, '\n');
6
+ const lines = text.split('\n');
7
+ if (lines.length > 0 && lines[lines.length - 1] === '') {
8
+ lines.pop();
9
+ }
10
+
11
+ let inBackTick = false;
12
+ let inTilde = false;
13
+
14
+ for (let i = 0; i < lines.length; i++) {
15
+ const line = lines[i];
16
+ const trimmed = line.replace(/^\s+/, '');
17
+ let toggledThisLine = false;
18
+
19
+ if (!inTilde && BACKTICK_FENCE.test(trimmed)) {
20
+ inBackTick = !inBackTick;
21
+ toggledThisLine = true;
22
+ } else if (!inBackTick && TILDE_FENCE.test(trimmed)) {
23
+ inTilde = !inTilde;
24
+ toggledThisLine = true;
25
+ }
26
+
27
+ yield { line, lineNumber: i + 1, skip: inBackTick || inTilde || toggledThisLine };
28
+ }
29
+ }
package/src/util.js ADDED
@@ -0,0 +1,38 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ export const KINDS = Object.freeze({
6
+ MISSING_FILE: 'missing-file',
7
+ MISSING_SCRIPT: 'missing-script',
8
+ BROKEN_LINK: 'broken-link',
9
+ });
10
+
11
+ export function resolveCwd(cwd) {
12
+ if (!cwd) return process.cwd();
13
+ if (cwd instanceof URL) return fileURLToPath(cwd);
14
+ if (typeof cwd === 'string' && cwd.startsWith('file://')) return fileURLToPath(cwd);
15
+ return cwd;
16
+ }
17
+
18
+ export function clonePattern(rx) {
19
+ const flags = rx.flags.includes('g') ? rx.flags : rx.flags + 'g';
20
+ return new RegExp(rx.source, flags);
21
+ }
22
+
23
+ export async function resolveRegistry(spec, cwd = process.cwd()) {
24
+ if (spec instanceof Set) return spec;
25
+ if (typeof spec === 'function') {
26
+ const r = await spec();
27
+ return r instanceof Set ? r : new Set(r);
28
+ }
29
+ if (Array.isArray(spec)) return new Set(spec);
30
+ if (typeof spec === 'string') {
31
+ const [file, key = 'scripts'] = spec.split('#');
32
+ const abs = path.resolve(cwd, file);
33
+ const raw = await readFile(abs, 'utf8');
34
+ const json = JSON.parse(raw);
35
+ return new Set(Object.keys(json[key] ?? {}));
36
+ }
37
+ return new Set();
38
+ }
package/src/walker.js ADDED
@@ -0,0 +1,22 @@
1
+ import { glob } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { resolveCwd } from './util.js';
4
+
5
+ const GLOB_RE = /[*?[\]{}]/;
6
+
7
+ export async function walk(files, cwd) {
8
+ const root = resolveCwd(cwd);
9
+ const out = new Set();
10
+
11
+ for (const entry of files) {
12
+ if (GLOB_RE.test(entry)) {
13
+ for await (const match of glob(entry, { cwd: root })) {
14
+ out.add(path.resolve(root, match));
15
+ }
16
+ } else {
17
+ out.add(path.resolve(root, entry));
18
+ }
19
+ }
20
+
21
+ return Array.from(out);
22
+ }