driftfence 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DriftFence contributors
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,119 @@
1
+ # DriftFence
2
+
3
+ [![CI](https://github.com/CHAPAPOPA/driftfence/actions/workflows/ci.yml/badge.svg)](https://github.com/CHAPAPOPA/driftfence/actions/workflows/ci.yml)
4
+
5
+ Make sure your README doesn't lie.
6
+
7
+ DriftFence is a TypeScript Node.js CLI that catches outdated README and docs commands, package scripts, file references, and env var references before they reach users.
8
+
9
+ ## Install
10
+
11
+ Once published to npm:
12
+
13
+ ```sh
14
+ npm install -D driftfence
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ Check the current project:
20
+
21
+ ```sh
22
+ npx driftfence check
23
+ ```
24
+
25
+ Check a specific project directory:
26
+
27
+ ```sh
28
+ npx driftfence check ./path/to/project
29
+ ```
30
+
31
+ ## Example Output
32
+
33
+ The local clean demo exits with code 0:
34
+
35
+ ```sh
36
+ npm run demo:clean
37
+ ```
38
+
39
+ The local drift demo intentionally contains documentation drift and exits with code 1:
40
+
41
+ ```sh
42
+ npm run demo:drift
43
+ ```
44
+
45
+ Example output from the drift fixture:
46
+
47
+ ```text
48
+ DriftFence found documentation drift.
49
+
50
+ Package scripts:
51
+ - `npm run build` in README.md references missing package.json script `build`.
52
+
53
+ File paths:
54
+ - `docs/missing.md` referenced in README.md does not exist.
55
+ - `docs/advanced.md` referenced in docs/config.md does not exist.
56
+
57
+ Env vars:
58
+ - `DATABASE_URL` is used in src/index.ts but missing from .env.example.
59
+
60
+ 4 issues found.
61
+ ```
62
+
63
+ ## MVP Checks
64
+
65
+ DriftFence checks `README.md` and `docs/**/*.md` for documentation drift.
66
+
67
+ Current checks:
68
+
69
+ - package script references
70
+ - file path references
71
+ - env var references in Markdown docs
72
+ - env var usage in source files
73
+
74
+ Package script references include commands like:
75
+
76
+ ```sh
77
+ npm run build
78
+ npm test
79
+ npm start
80
+ pnpm build
81
+ yarn build
82
+ ```
83
+
84
+ Env var checks currently support references like:
85
+
86
+ ```text
87
+ API_URL
88
+ DATABASE_URL
89
+ VITE_API_URL
90
+ ```
91
+
92
+ and source usages like:
93
+
94
+ ```ts
95
+ process.env.API_URL
96
+ import.meta.env.VITE_API_URL
97
+ ```
98
+
99
+ ## Exit Codes
100
+
101
+ DriftFence uses stable CLI exit codes:
102
+
103
+ - `0` — no documentation drift found
104
+ - `1` — documentation drift found
105
+ - `2` — invalid project directory or CLI usage error
106
+
107
+ ## Roadmap
108
+
109
+ - MDX docs
110
+ - GitHub Action
111
+ - changed-files mode
112
+ - configurable ignore rules
113
+ - richer source analysis
114
+
115
+ ## AI
116
+
117
+ AI features are not included in the MVP.
118
+
119
+ DriftFence is deterministic-first: it checks concrete references in docs and code instead of guessing.
@@ -0,0 +1,576 @@
1
+ // src/checkers/packageScripts.ts
2
+ import { readFile } from "fs/promises";
3
+ import { join } from "path";
4
+ var npmScriptPattern = /\bnpm\s+(?:run\s+([A-Za-z0-9:_-]+)|(start|test))\b/g;
5
+ var shorthandScriptPattern = /\b(pnpm|yarn)\s+(?:run\s+)?([A-Za-z0-9:_-]+)\b/g;
6
+ var packageManagerCommands = /* @__PURE__ */ new Set([
7
+ "add",
8
+ "audit",
9
+ "bin",
10
+ "cache",
11
+ "ci",
12
+ "config",
13
+ "create",
14
+ "dedupe",
15
+ "dlx",
16
+ "doctor",
17
+ "exec",
18
+ "explain",
19
+ "global",
20
+ "help",
21
+ "import",
22
+ "init",
23
+ "install",
24
+ "link",
25
+ "list",
26
+ "login",
27
+ "logout",
28
+ "node",
29
+ "outdated",
30
+ "pack",
31
+ "patch",
32
+ "plugin",
33
+ "publish",
34
+ "rebuild",
35
+ "remove",
36
+ "root",
37
+ "run",
38
+ "search",
39
+ "set",
40
+ "setup",
41
+ "store",
42
+ "uninstall",
43
+ "unlink",
44
+ "update",
45
+ "upgrade",
46
+ "view",
47
+ "why",
48
+ "workspace",
49
+ "workspaces"
50
+ ]);
51
+ async function checkPackageScripts(projectRoot, references) {
52
+ const scriptReferences = findPackageScriptReferences(references);
53
+ if (scriptReferences.length === 0) {
54
+ return [];
55
+ }
56
+ const packageJsonResult = await readPackageJson(projectRoot);
57
+ if ("issue" in packageJsonResult) {
58
+ return [packageJsonResult.issue];
59
+ }
60
+ const scripts = packageJsonResult.packageJson.scripts ?? {};
61
+ return scriptReferences.filter((reference) => !hasOwn(scripts, reference.script)).map((reference) => ({
62
+ type: "package-script",
63
+ path: reference.path,
64
+ command: reference.command,
65
+ script: reference.script
66
+ }));
67
+ }
68
+ function findPackageScriptReferences(references) {
69
+ const scriptReferences = [];
70
+ for (const reference of references) {
71
+ scriptReferences.push(...findNpmScriptReferences(reference.value, reference.path));
72
+ scriptReferences.push(
73
+ ...findShorthandScriptReferences(reference.value, reference.path)
74
+ );
75
+ }
76
+ return uniqueScriptReferences(scriptReferences);
77
+ }
78
+ function findNpmScriptReferences(text, path) {
79
+ const references = [];
80
+ for (const match of text.matchAll(npmScriptPattern)) {
81
+ const npmRunScript = match[1];
82
+ const npmTestScript = match[2];
83
+ const script = npmRunScript ?? npmTestScript;
84
+ if (!script) {
85
+ continue;
86
+ }
87
+ references.push({
88
+ path,
89
+ command: normalizeCommand(match[0]),
90
+ packageManager: "npm",
91
+ script
92
+ });
93
+ }
94
+ return references;
95
+ }
96
+ function findShorthandScriptReferences(text, path) {
97
+ const references = [];
98
+ for (const match of text.matchAll(shorthandScriptPattern)) {
99
+ const packageManager = match[1];
100
+ const script = match[2];
101
+ if (packageManager !== "pnpm" && packageManager !== "yarn" || !script || packageManagerCommands.has(script)) {
102
+ continue;
103
+ }
104
+ references.push({
105
+ path,
106
+ command: normalizeCommand(match[0]),
107
+ packageManager,
108
+ script
109
+ });
110
+ }
111
+ return references;
112
+ }
113
+ async function readPackageJson(projectRoot) {
114
+ const path = "package.json";
115
+ const packageJsonPath = join(projectRoot, path);
116
+ let rawPackageJson;
117
+ try {
118
+ rawPackageJson = await readFile(packageJsonPath, "utf8");
119
+ } catch (error) {
120
+ return {
121
+ issue: {
122
+ type: "package-json",
123
+ path,
124
+ message: getErrorCode(error) === "ENOENT" ? "package.json was not found." : `package.json could not be read: ${getErrorMessage(error)}`
125
+ }
126
+ };
127
+ }
128
+ try {
129
+ return { packageJson: JSON.parse(rawPackageJson) };
130
+ } catch (error) {
131
+ return {
132
+ issue: {
133
+ type: "package-json",
134
+ path,
135
+ message: `package.json is not valid JSON: ${getErrorMessage(error)}`
136
+ }
137
+ };
138
+ }
139
+ }
140
+ function uniqueScriptReferences(references) {
141
+ const seen = /* @__PURE__ */ new Set();
142
+ const uniqueReferences = [];
143
+ for (const reference of references) {
144
+ const key = `${reference.path}:${reference.packageManager}:${reference.command}:${reference.script}`;
145
+ if (seen.has(key)) {
146
+ continue;
147
+ }
148
+ seen.add(key);
149
+ uniqueReferences.push(reference);
150
+ }
151
+ return uniqueReferences;
152
+ }
153
+ function normalizeCommand(command) {
154
+ return command.replace(/\s+/g, " ").trim();
155
+ }
156
+ function hasOwn(object, key) {
157
+ return Object.prototype.hasOwnProperty.call(object, key);
158
+ }
159
+ function getErrorCode(error) {
160
+ return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
161
+ }
162
+ function getErrorMessage(error) {
163
+ return error instanceof Error ? error.message : String(error);
164
+ }
165
+
166
+ // src/checkers/filePaths.ts
167
+ import { access } from "fs/promises";
168
+ import { resolve } from "path";
169
+ var pathTokenPattern = /(?:^|[\s"'`(])((?:\.{1,2}[\\/][^\s"'`()<>[\]{}]+|[A-Za-z0-9_.-]+[\\/][^\s"'`()<>[\]{}]+|\.[A-Za-z0-9_-][A-Za-z0-9_.-]*|[A-Za-z0-9_-]+\.[A-Za-z][A-Za-z0-9]{1,7})(?:[?#][A-Za-z0-9_.:/#?=&-]+)?)/g;
170
+ async function checkFilePaths(projectRoot, references) {
171
+ const pathReferences = findFilePathReferences(references);
172
+ const issues = [];
173
+ for (const reference of pathReferences) {
174
+ const absolutePath = resolve(projectRoot, reference.path);
175
+ try {
176
+ await access(absolutePath);
177
+ } catch {
178
+ issues.push({
179
+ type: "file-path",
180
+ path: reference.path,
181
+ markdownPath: reference.markdownPath
182
+ });
183
+ }
184
+ }
185
+ return issues;
186
+ }
187
+ function findFilePathReferences(references) {
188
+ const pathReferences = [];
189
+ for (const reference of references) {
190
+ pathReferences.push(
191
+ ...findFilePathReferencesInText(reference.value, reference.path)
192
+ );
193
+ }
194
+ return uniquePathReferences(pathReferences);
195
+ }
196
+ function findFilePathReferencesInText(text, markdownPath) {
197
+ const references = [];
198
+ for (const match of text.matchAll(pathTokenPattern)) {
199
+ const path = cleanPathCandidate(match[1]);
200
+ if (!isLikelyFilePath(path)) {
201
+ continue;
202
+ }
203
+ references.push({ path, markdownPath });
204
+ }
205
+ return references;
206
+ }
207
+ function cleanPathCandidate(candidate) {
208
+ const path = (candidate ?? "").trim().replace(/^[`'"]+|[`'"]+$/g, "").replace(/[),.;:!?]+$/g, "").replace(/\\/g, "/");
209
+ const withoutUrlSuffix = path.split(/[?#]/)[0] ?? "";
210
+ return withoutUrlSuffix.replace(/:\d+(?::\d+)?$/g, "").replace(/^\.\//, "");
211
+ }
212
+ function isLikelyFilePath(path) {
213
+ if (path.length === 0 || path.startsWith("@") || path.startsWith("-") || path.includes("://") || path.includes("*") || path.includes("$")) {
214
+ return false;
215
+ }
216
+ if (!path.includes("/") && /^[A-Z][a-z]+(?:\.[a-z0-9]+)+$/.test(path)) {
217
+ return false;
218
+ }
219
+ return path.includes("/") || path.startsWith(".") || hasExtension(path);
220
+ }
221
+ function hasExtension(path) {
222
+ return /(^|[/\\])[^/\\]+\.[A-Za-z][A-Za-z0-9]{1,7}$/.test(path);
223
+ }
224
+ function uniquePathReferences(references) {
225
+ const seen = /* @__PURE__ */ new Set();
226
+ const uniqueReferences = [];
227
+ for (const reference of references) {
228
+ const key = `${reference.markdownPath}:${reference.path}`;
229
+ if (seen.has(key)) {
230
+ continue;
231
+ }
232
+ seen.add(key);
233
+ uniqueReferences.push(reference);
234
+ }
235
+ return uniqueReferences;
236
+ }
237
+
238
+ // src/checkers/envVars.ts
239
+ import { readFile as readFile2, readdir } from "fs/promises";
240
+ import { join as join2, relative } from "path";
241
+ var envVarPattern = /\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/g;
242
+ var processEnvPattern = /\bprocess\.env\.([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\b/g;
243
+ var importMetaEnvPattern = /\bimport\.meta\.env\.([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\b/g;
244
+ var sourceExtensions = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
245
+ async function checkEnvVars(projectRoot, markdownDocuments) {
246
+ const envExampleNames = await readEnvExampleNames(projectRoot);
247
+ const references = [
248
+ ...markdownDocuments.flatMap(
249
+ (document) => findMarkdownEnvVarReferences(document)
250
+ ),
251
+ ...await findSourceEnvVarReferences(projectRoot)
252
+ ];
253
+ return references.filter((reference) => !envExampleNames.has(reference.name)).map((reference) => ({
254
+ type: "env-var",
255
+ name: reference.name,
256
+ source: reference.source,
257
+ path: reference.path
258
+ }));
259
+ }
260
+ function findEnvExampleNames(content) {
261
+ const names = /* @__PURE__ */ new Set();
262
+ for (const line of content.split(/\r?\n/)) {
263
+ const trimmedLine = line.trim();
264
+ if (trimmedLine.length === 0 || trimmedLine.startsWith("#")) {
265
+ continue;
266
+ }
267
+ const match = /^([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\s*=/.exec(trimmedLine);
268
+ if (match?.[1]) {
269
+ names.add(match[1]);
270
+ }
271
+ }
272
+ return names;
273
+ }
274
+ function findMarkdownEnvVarReferences(document) {
275
+ return uniqueEnvVarReferences(
276
+ [...document.content.matchAll(envVarPattern)].map((match) => ({
277
+ name: match[0],
278
+ source: "markdown",
279
+ path: document.path
280
+ }))
281
+ );
282
+ }
283
+ async function readEnvExampleNames(projectRoot) {
284
+ try {
285
+ return findEnvExampleNames(
286
+ await readFile2(join2(projectRoot, ".env.example"), "utf8")
287
+ );
288
+ } catch (error) {
289
+ if (getErrorCode2(error) === "ENOENT") {
290
+ return /* @__PURE__ */ new Set();
291
+ }
292
+ throw error;
293
+ }
294
+ }
295
+ async function findSourceEnvVarReferences(projectRoot) {
296
+ const sourceRoot = join2(projectRoot, "src");
297
+ const sourceFiles = await findSourceFiles(sourceRoot);
298
+ const references = [];
299
+ for (const sourceFile of sourceFiles) {
300
+ const content = await readFile2(sourceFile, "utf8");
301
+ const path = normalizePath(relative(projectRoot, sourceFile));
302
+ references.push(
303
+ ...findSourceEnvVarReferencesInText(content).map((reference) => ({
304
+ ...reference,
305
+ path
306
+ }))
307
+ );
308
+ }
309
+ return uniqueEnvVarReferences(references);
310
+ }
311
+ function findSourceEnvVarReferencesInText(content) {
312
+ const references = [];
313
+ for (const match of content.matchAll(processEnvPattern)) {
314
+ if (match[1]) {
315
+ references.push({ name: match[1], source: "source", path: "" });
316
+ }
317
+ }
318
+ for (const match of content.matchAll(importMetaEnvPattern)) {
319
+ if (match[1]) {
320
+ references.push({ name: match[1], source: "source", path: "" });
321
+ }
322
+ }
323
+ return references;
324
+ }
325
+ async function findSourceFiles(directory) {
326
+ let entries;
327
+ try {
328
+ entries = await readdir(directory, { withFileTypes: true });
329
+ } catch (error) {
330
+ if (getErrorCode2(error) === "ENOENT") {
331
+ return [];
332
+ }
333
+ throw error;
334
+ }
335
+ const files = [];
336
+ for (const entry of entries) {
337
+ const path = join2(directory, entry.name);
338
+ if (entry.isDirectory()) {
339
+ files.push(...await findSourceFiles(path));
340
+ continue;
341
+ }
342
+ if (entry.isFile() && sourceExtensions.has(getExtension(entry.name))) {
343
+ files.push(path);
344
+ }
345
+ }
346
+ return files;
347
+ }
348
+ function uniqueEnvVarReferences(references) {
349
+ const seen = /* @__PURE__ */ new Set();
350
+ const uniqueReferences = [];
351
+ for (const reference of references) {
352
+ const key = `${reference.source}:${reference.path}:${reference.name}`;
353
+ if (seen.has(key)) {
354
+ continue;
355
+ }
356
+ seen.add(key);
357
+ uniqueReferences.push(reference);
358
+ }
359
+ return uniqueReferences;
360
+ }
361
+ function getExtension(path) {
362
+ const index = path.lastIndexOf(".");
363
+ return index === -1 ? "" : path.slice(index);
364
+ }
365
+ function normalizePath(path) {
366
+ return path.replace(/\\/g, "/");
367
+ }
368
+ function getErrorCode2(error) {
369
+ return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
370
+ }
371
+
372
+ // src/markdown/extractMarkdownText.ts
373
+ import remarkParse from "remark-parse";
374
+ import { unified } from "unified";
375
+ function extractMarkdownText(markdown) {
376
+ const tree = unified().use(remarkParse).parse(markdown);
377
+ const references = [];
378
+ collectMarkdownText(tree, references);
379
+ return references;
380
+ }
381
+ function collectMarkdownText(node, references) {
382
+ if ((node.type === "code" || node.type === "inlineCode") && typeof node.value === "string") {
383
+ references.push({ kind: node.type, value: node.value });
384
+ }
385
+ if (!Array.isArray(node.children)) {
386
+ return;
387
+ }
388
+ for (const child of node.children) {
389
+ collectMarkdownText(child, references);
390
+ }
391
+ }
392
+
393
+ // src/markdown/readMarkdownDocuments.ts
394
+ import { readFile as readFile3, readdir as readdir2 } from "fs/promises";
395
+ import { join as join3, relative as relative2 } from "path";
396
+ async function readMarkdownDocuments(projectRoot) {
397
+ const paths = await findMarkdownPaths(projectRoot);
398
+ const documents = [];
399
+ for (const path of paths) {
400
+ const content = await readFile3(join3(projectRoot, path), "utf8");
401
+ documents.push({
402
+ path,
403
+ content,
404
+ references: extractMarkdownText(content).map((reference) => ({
405
+ ...reference,
406
+ path
407
+ }))
408
+ });
409
+ }
410
+ return documents;
411
+ }
412
+ async function findMarkdownPaths(projectRoot) {
413
+ const paths = [];
414
+ if (await fileExists(join3(projectRoot, "README.md"))) {
415
+ paths.push("README.md");
416
+ }
417
+ paths.push(...await findDocsMarkdownPaths(projectRoot));
418
+ return paths;
419
+ }
420
+ async function findDocsMarkdownPaths(projectRoot) {
421
+ const docsRoot = join3(projectRoot, "docs");
422
+ const paths = await findMarkdownPathsInDirectory(docsRoot);
423
+ return paths.map((path) => normalizePath2(relative2(projectRoot, path))).sort((first, second) => first.localeCompare(second));
424
+ }
425
+ async function findMarkdownPathsInDirectory(directory) {
426
+ let entries;
427
+ try {
428
+ entries = await readdir2(directory, { withFileTypes: true });
429
+ } catch (error) {
430
+ if (getErrorCode3(error) === "ENOENT") {
431
+ return [];
432
+ }
433
+ throw error;
434
+ }
435
+ const paths = [];
436
+ for (const entry of entries.sort(
437
+ (first, second) => first.name.localeCompare(second.name)
438
+ )) {
439
+ const path = join3(directory, entry.name);
440
+ if (entry.isDirectory()) {
441
+ paths.push(...await findMarkdownPathsInDirectory(path));
442
+ continue;
443
+ }
444
+ if (entry.isFile() && entry.name.endsWith(".md")) {
445
+ paths.push(path);
446
+ }
447
+ }
448
+ return paths;
449
+ }
450
+ async function fileExists(path) {
451
+ try {
452
+ await readFile3(path, "utf8");
453
+ return true;
454
+ } catch (error) {
455
+ if (getErrorCode3(error) === "ENOENT") {
456
+ return false;
457
+ }
458
+ throw error;
459
+ }
460
+ }
461
+ function normalizePath2(path) {
462
+ return path.replace(/\\/g, "/");
463
+ }
464
+ function getErrorCode3(error) {
465
+ return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
466
+ }
467
+
468
+ // src/report/formatReport.ts
469
+ function formatReport(result) {
470
+ if (result.issues.length === 0) {
471
+ if (result.markdownDocuments.length === 0) {
472
+ return "DriftFence: no Markdown files found.";
473
+ }
474
+ return [
475
+ "DriftFence: no documentation drift found.",
476
+ `Checked ${result.markdownDocuments.map((document) => document.path).join(", ")}.`
477
+ ].join("\n");
478
+ }
479
+ const projectIssues = result.issues.filter(isProjectIssue);
480
+ const packageScriptIssues = result.issues.filter(isPackageScriptIssue);
481
+ const filePathIssues = result.issues.filter(isFilePathIssue);
482
+ const envVarIssues = result.issues.filter(isEnvVarIssue);
483
+ const lines = ["DriftFence found documentation drift.", ""];
484
+ appendSection(
485
+ lines,
486
+ "Project",
487
+ projectIssues.map((issue) => `- ${issue.message}`)
488
+ );
489
+ appendSection(
490
+ lines,
491
+ "Package scripts",
492
+ packageScriptIssues.map(
493
+ (issue) => `- \`${issue.command}\` in ${issue.path} references missing package.json script \`${issue.script}\`.`
494
+ )
495
+ );
496
+ appendSection(
497
+ lines,
498
+ "File paths",
499
+ filePathIssues.map(
500
+ (issue) => `- \`${issue.path}\` referenced in ${issue.markdownPath} does not exist.`
501
+ )
502
+ );
503
+ appendSection(
504
+ lines,
505
+ "Env vars",
506
+ envVarIssues.map(
507
+ (issue) => issue.source === "markdown" ? `- \`${issue.name}\` is mentioned in ${issue.path} but missing from .env.example.` : `- \`${issue.name}\` is used in ${issue.path} but missing from .env.example.`
508
+ )
509
+ );
510
+ lines.push("");
511
+ lines.push(`${result.issues.length} ${pluralize("issue", result.issues.length)} found.`);
512
+ return lines.join("\n");
513
+ }
514
+ function appendSection(lines, title, entries) {
515
+ if (entries.length === 0) {
516
+ return;
517
+ }
518
+ lines.push(`${title}:`);
519
+ lines.push(...entries);
520
+ lines.push("");
521
+ }
522
+ function isProjectIssue(issue) {
523
+ return issue.type === "package-json";
524
+ }
525
+ function isPackageScriptIssue(issue) {
526
+ return issue.type === "package-script";
527
+ }
528
+ function isFilePathIssue(issue) {
529
+ return issue.type === "file-path";
530
+ }
531
+ function isEnvVarIssue(issue) {
532
+ return issue.type === "env-var";
533
+ }
534
+ function pluralize(word, count) {
535
+ return count === 1 ? word : `${word}s`;
536
+ }
537
+
538
+ // src/index.ts
539
+ async function checkProject(projectRoot = process.cwd()) {
540
+ const markdownDocuments = await readMarkdownDocuments(projectRoot);
541
+ const markdownReferences = markdownDocuments.flatMap(
542
+ (document) => document.references
543
+ );
544
+ if (markdownDocuments.length === 0) {
545
+ return {
546
+ projectRoot,
547
+ markdownDocuments,
548
+ markdownReferences,
549
+ issues: []
550
+ };
551
+ }
552
+ const [packageScriptIssues, filePathIssues, envVarIssues] = await Promise.all([
553
+ checkPackageScripts(projectRoot, markdownReferences),
554
+ checkFilePaths(projectRoot, markdownReferences),
555
+ checkEnvVars(projectRoot, markdownDocuments)
556
+ ]);
557
+ return {
558
+ projectRoot,
559
+ markdownDocuments,
560
+ markdownReferences,
561
+ issues: [...packageScriptIssues, ...filePathIssues, ...envVarIssues]
562
+ };
563
+ }
564
+
565
+ export {
566
+ checkPackageScripts,
567
+ findPackageScriptReferences,
568
+ checkFilePaths,
569
+ findFilePathReferences,
570
+ checkEnvVars,
571
+ findEnvExampleNames,
572
+ extractMarkdownText,
573
+ readMarkdownDocuments,
574
+ formatReport,
575
+ checkProject
576
+ };
package/dist/cli.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ interface CliOutput {
3
+ stdout(message: string): void;
4
+ stderr(message: string): void;
5
+ }
6
+ declare function runCli(args?: string[], output?: CliOutput): Promise<number>;
7
+
8
+ export { type CliOutput, runCli };
package/dist/cli.js ADDED
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ checkProject,
4
+ formatReport
5
+ } from "./chunk-4RCBAET6.js";
6
+
7
+ // src/cli.ts
8
+ import { stat } from "fs/promises";
9
+ import { resolve } from "path";
10
+ import { fileURLToPath } from "url";
11
+ import cac from "cac";
12
+ var defaultOutput = {
13
+ stdout: (message) => console.log(message),
14
+ stderr: (message) => console.error(message)
15
+ };
16
+ async function runCli(args = process.argv.slice(2), output = defaultOutput) {
17
+ const cli = cac("driftfence");
18
+ let exitCode = 0;
19
+ cli.command("check [projectDir]", "Check README.md for documentation drift").action(async (projectDir) => {
20
+ try {
21
+ const projectRoot = await resolveProjectRoot(projectDir);
22
+ const result = await checkProject(projectRoot);
23
+ output.stdout(formatReport(result));
24
+ exitCode = result.issues.length > 0 ? 1 : 0;
25
+ } catch (error) {
26
+ if (error instanceof ProjectDirectoryError) {
27
+ output.stderr(`DriftFence: ${error.message}`);
28
+ exitCode = 2;
29
+ return;
30
+ }
31
+ output.stderr(`DriftFence failed: ${getErrorMessage(error)}`);
32
+ exitCode = 1;
33
+ }
34
+ });
35
+ cli.help();
36
+ cli.parse([process.execPath, fileURLToPath(import.meta.url), ...args], {
37
+ run: false
38
+ });
39
+ if (!cli.matchedCommand && !args.some(isHelpArgument)) {
40
+ cli.outputHelp();
41
+ return 1;
42
+ }
43
+ await cli.runMatchedCommand();
44
+ return exitCode;
45
+ }
46
+ async function resolveProjectRoot(projectDir) {
47
+ const projectRoot = resolve(projectDir ?? process.cwd());
48
+ try {
49
+ const stats = await stat(projectRoot);
50
+ if (!stats.isDirectory()) {
51
+ throw new ProjectDirectoryError(
52
+ `Project path is not a directory: ${projectRoot}`
53
+ );
54
+ }
55
+ return projectRoot;
56
+ } catch (error) {
57
+ if (error instanceof ProjectDirectoryError) {
58
+ throw error;
59
+ }
60
+ if (getErrorCode(error) === "ENOENT") {
61
+ throw new ProjectDirectoryError(
62
+ `Project directory does not exist: ${projectRoot}`
63
+ );
64
+ }
65
+ throw error;
66
+ }
67
+ }
68
+ var ProjectDirectoryError = class extends Error {
69
+ };
70
+ function isDirectEntrypoint() {
71
+ return process.argv[1] ? resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false;
72
+ }
73
+ function isHelpArgument(argument) {
74
+ return argument === "--help" || argument === "-h";
75
+ }
76
+ function getErrorCode(error) {
77
+ return typeof error === "object" && error !== null && "code" in error ? String(error.code) : void 0;
78
+ }
79
+ function getErrorMessage(error) {
80
+ return error instanceof Error ? error.message : String(error);
81
+ }
82
+ if (isDirectEntrypoint()) {
83
+ process.exitCode = await runCli();
84
+ }
85
+ export {
86
+ runCli
87
+ };
@@ -0,0 +1,71 @@
1
+ type MarkdownTextKind = "code" | "inlineCode";
2
+ interface MarkdownTextReference {
3
+ kind: MarkdownTextKind;
4
+ value: string;
5
+ }
6
+ interface MarkdownTextReferenceWithPath extends MarkdownTextReference {
7
+ path: string;
8
+ }
9
+ declare function extractMarkdownText(markdown: string): MarkdownTextReference[];
10
+
11
+ interface PackageScriptReference {
12
+ path: string;
13
+ command: string;
14
+ packageManager: "npm" | "pnpm" | "yarn";
15
+ script: string;
16
+ }
17
+ interface PackageScriptIssue {
18
+ type: "package-script";
19
+ path: string;
20
+ command: string;
21
+ script: string;
22
+ }
23
+ interface PackageJsonIssue {
24
+ type: "package-json";
25
+ path: string;
26
+ message: string;
27
+ }
28
+ type PackageScriptCheckIssue = PackageScriptIssue | PackageJsonIssue;
29
+ declare function checkPackageScripts(projectRoot: string, references: MarkdownTextReferenceWithPath[]): Promise<PackageScriptCheckIssue[]>;
30
+ declare function findPackageScriptReferences(references: MarkdownTextReferenceWithPath[]): PackageScriptReference[];
31
+
32
+ interface FilePathReference {
33
+ path: string;
34
+ markdownPath: string;
35
+ }
36
+ interface FilePathIssue {
37
+ type: "file-path";
38
+ path: string;
39
+ markdownPath: string;
40
+ }
41
+ declare function checkFilePaths(projectRoot: string, references: MarkdownTextReferenceWithPath[]): Promise<FilePathIssue[]>;
42
+ declare function findFilePathReferences(references: MarkdownTextReferenceWithPath[]): FilePathReference[];
43
+
44
+ interface MarkdownDocument {
45
+ path: string;
46
+ content: string;
47
+ references: MarkdownTextReferenceWithPath[];
48
+ }
49
+ declare function readMarkdownDocuments(projectRoot: string): Promise<MarkdownDocument[]>;
50
+
51
+ interface EnvVarIssue {
52
+ type: "env-var";
53
+ name: string;
54
+ source: "markdown" | "source";
55
+ path: string;
56
+ }
57
+ declare function checkEnvVars(projectRoot: string, markdownDocuments: MarkdownDocument[]): Promise<EnvVarIssue[]>;
58
+ declare function findEnvExampleNames(content: string): Set<string>;
59
+
60
+ declare function formatReport(result: CheckResult): string;
61
+
62
+ type DriftIssue = PackageScriptCheckIssue | FilePathIssue | EnvVarIssue;
63
+ interface CheckResult {
64
+ projectRoot: string;
65
+ markdownDocuments: MarkdownDocument[];
66
+ markdownReferences: MarkdownTextReferenceWithPath[];
67
+ issues: DriftIssue[];
68
+ }
69
+ declare function checkProject(projectRoot?: string): Promise<CheckResult>;
70
+
71
+ export { type CheckResult, type DriftIssue, checkEnvVars, checkFilePaths, checkPackageScripts, checkProject, extractMarkdownText, findEnvExampleNames, findFilePathReferences, findPackageScriptReferences, formatReport, readMarkdownDocuments };
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ import {
2
+ checkEnvVars,
3
+ checkFilePaths,
4
+ checkPackageScripts,
5
+ checkProject,
6
+ extractMarkdownText,
7
+ findEnvExampleNames,
8
+ findFilePathReferences,
9
+ findPackageScriptReferences,
10
+ formatReport,
11
+ readMarkdownDocuments
12
+ } from "./chunk-4RCBAET6.js";
13
+ export {
14
+ checkEnvVars,
15
+ checkFilePaths,
16
+ checkPackageScripts,
17
+ checkProject,
18
+ extractMarkdownText,
19
+ findEnvExampleNames,
20
+ findFilePathReferences,
21
+ findPackageScriptReferences,
22
+ formatReport,
23
+ readMarkdownDocuments
24
+ };
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "driftfence",
3
+ "version": "0.0.1",
4
+ "license": "MIT",
5
+ "keywords": [
6
+ "cli",
7
+ "documentation",
8
+ "readme",
9
+ "drift",
10
+ "docs",
11
+ "developer-tools",
12
+ "typescript"
13
+ ],
14
+ "description": "A CLI that catches outdated README commands, scripts, file references, and env vars.",
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ }
23
+ },
24
+ "bin": {
25
+ "driftfence": "./dist/cli.js"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md",
30
+ "LICENSE",
31
+ "package.json"
32
+ ],
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "sideEffects": false,
37
+ "scripts": {
38
+ "build": "tsup src/cli.ts src/index.ts --format esm --dts --clean",
39
+ "demo:clean": "node ./dist/cli.js check ./tests/fixtures/basic-clean-project",
40
+ "demo:drift": "node ./dist/cli.js check ./tests/fixtures/basic-drift-project",
41
+ "dev": "tsx src/cli.ts",
42
+ "pack:dry": "npm pack --dry-run",
43
+ "prepublishOnly": "npm run typecheck && npm test && npm run build",
44
+ "test": "vitest run --pool=threads",
45
+ "typecheck": "tsc --noEmit"
46
+ },
47
+ "author": "Yakov Mulyavin",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/CHAPAPOPA/driftfence.git"
51
+ },
52
+ "bugs": {
53
+ "url": "https://github.com/CHAPAPOPA/driftfence/issues"
54
+ },
55
+ "homepage": "https://github.com/CHAPAPOPA/driftfence#readme",
56
+ "dependencies": {
57
+ "cac": "^7.0.0",
58
+ "remark-parse": "^11.0.0",
59
+ "unified": "^11.0.5"
60
+ },
61
+ "devDependencies": {
62
+ "@types/node": "^26.1.0",
63
+ "tsup": "^8.5.1",
64
+ "tsx": "^4.23.0",
65
+ "typescript": "^6.0.3",
66
+ "vitest": "^4.1.10"
67
+ }
68
+ }