-
Notifications
You must be signed in to change notification settings - Fork 0
feat(docs): lint npm-run script references in docs against package.json #616
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * check-docs-script-refs.mjs — verify that every `npm run <script>` mentioned in | ||
| * the maintained docs corresponds to a real script in package.json. | ||
| * | ||
| * check-docs-links.mjs validates file PATHS referenced in docs, but nothing checks | ||
| * the hundreds of `npm run <script>` references — so a renamed/removed script leaves | ||
| * stale instructions that the agents (Codex/Claude/Cursor) then follow. This closes | ||
| * that gap. | ||
| * | ||
| * Only references inside inline code spans (`npm run x`) and fenced code blocks are | ||
| * scanned, so prose like "npm run the build" is never misread. Placeholder tokens | ||
| * (containing <…>) and an explicit allowlist are skipped. | ||
| * | ||
| * Scans README.md, AGENTS.md, and docs/**\/*.md excluding docs/archive, docs/audit, | ||
| * and dated point-in-time filenames (historical records). Pass --all to include them. | ||
| * | ||
| * Advisory: run `npm run docs:check-scripts` before doc handoffs. Deliberately NOT | ||
| * in verify:cheap/CI so historical docs cannot block unrelated PRs. | ||
| */ | ||
| import { readdirSync, readFileSync } from "node:fs"; | ||
| import path from "node:path"; | ||
| import { fileURLToPath, pathToFileURL } from "node:url"; | ||
|
|
||
| const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); | ||
| const scanAll = process.argv.includes("--all"); | ||
|
|
||
| const DATED_DOC = /\b20\d{2}-\d{2}(-\d{2})?\b/; | ||
| const HISTORICAL_DIRS = new Set(["archive", "audit"]); | ||
|
|
||
| // Script tokens that appear in docs as illustrative placeholders, or real scripts | ||
| // that were renamed but are legitimately referenced in historical records (e.g. the | ||
| // branch-review-ledger "Checks" cells record what was run at the time). | ||
| const ALLOWLIST = new Set([ | ||
| "<script>", | ||
| "<name>", | ||
| "your-script", | ||
| "test:e2e:advisory", // renamed to test:e2e:regression (2026-07); kept for historical ledger accuracy | ||
| ]); | ||
|
|
||
| /** Script names defined in package.json. */ | ||
| export function parsePackageScripts(pkgJsonText) { | ||
| const pkg = JSON.parse(pkgJsonText); | ||
| return new Set(Object.keys(pkg.scripts ?? {})); | ||
| } | ||
|
|
||
| /** | ||
| * Extract `npm run <script>` tokens that appear inside inline code spans or fenced | ||
| * code blocks. Returns the unique script names referenced. `pnpm run` / `yarn run` | ||
| * are matched too for completeness. | ||
| */ | ||
| export function extractScriptRefs(markdown) { | ||
| const codeRegions = []; | ||
| for (const m of markdown.matchAll(/```[\s\S]*?```/g)) codeRegions.push(m[0]); | ||
| for (const m of markdown.matchAll(/`[^`\n]+`/g)) codeRegions.push(m[0]); | ||
|
|
||
| const names = new Set(); | ||
| for (const region of codeRegions) { | ||
| for (const m of region.matchAll(/\b(?:npm|pnpm|yarn)\s+run\s+([A-Za-z0-9][A-Za-z0-9:_-]*)/g)) { | ||
| names.add(m[1]); | ||
|
Comment on lines
+59
to
+60
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '## scripts/check-docs-script-refs.mjs\n'
ast-grep outline scripts/check-docs-script-refs.mjs --view expanded || true
printf '\n## relevant lines\n'
nl -ba scripts/check-docs-script-refs.mjs | sed -n '1,180p'
printf '\n## search for parsePackageScripts and related tests\n'
rg -n "parsePackageScripts|check-docs-script-refs|lint\.fix|npm\\s+run|pnpm\\s+run|yarn\\s+run" -S .Repository: BigSimmo/Database Length of output: 898 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '## scripts/check-docs-script-refs.mjs (relevant section)\n'
sed -n '40,80p' scripts/check-docs-script-refs.mjs
printf '\n## tests and references\n'
rg -n "extractScriptRefs|parsePackageScripts|findStaleRefs|lint\\.fix|npm\\s+run|pnpm\\s+run|yarn\\s+run" -S . --glob '!**/node_modules/**'Repository: BigSimmo/Database Length of output: 50376 Allow dotted npm script names in docs refs. 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| return [...names]; | ||
| } | ||
|
|
||
| /** Referenced script names that are neither defined nor allowlisted. */ | ||
| export function findStaleRefs(refs, validScripts, allowlist = ALLOWLIST) { | ||
| return refs.filter((name) => !validScripts.has(name) && !allowlist.has(name) && !name.includes("<")); | ||
| } | ||
|
|
||
| function collectDocs(dirRelative, targets) { | ||
| for (const entry of readdirSync(path.join(repoRoot, dirRelative), { withFileTypes: true })) { | ||
| const entryRelative = path.posix.join(dirRelative, entry.name); | ||
| if (entry.isDirectory()) { | ||
| if (HISTORICAL_DIRS.has(entry.name) && !scanAll) continue; | ||
| collectDocs(entryRelative, targets); | ||
| continue; | ||
| } | ||
| if (!entry.isFile() || !entry.name.endsWith(".md")) continue; | ||
| if (!scanAll && DATED_DOC.test(entry.name)) continue; | ||
| targets.push(entryRelative); | ||
| } | ||
| } | ||
|
|
||
| function main() { | ||
| const validScripts = parsePackageScripts(readFileSync(path.join(repoRoot, "package.json"), "utf8")); | ||
| const targets = ["README.md", "AGENTS.md"]; | ||
| collectDocs("docs", targets); | ||
|
|
||
| let stale = 0; | ||
| let checked = 0; | ||
| for (const target of targets) { | ||
| let markdown; | ||
| try { | ||
| markdown = readFileSync(path.join(repoRoot, target), "utf8"); | ||
| } catch { | ||
| continue; | ||
| } | ||
| const refs = extractScriptRefs(markdown); | ||
| checked += refs.length; | ||
| const bad = findStaleRefs(refs, validScripts); | ||
| if (bad.length > 0) { | ||
| stale += bad.length; | ||
| console.error(`\n${target}:`); | ||
| for (const name of bad) console.error(` STALE npm run ${name} (no such script in package.json)`); | ||
| } | ||
| } | ||
|
|
||
| if (stale > 0) { | ||
| console.error(`\ndocs script-ref check FAILED: ${stale} stale reference(s) across ${checked} checked.`); | ||
| process.exit(1); | ||
| } | ||
| console.log(`docs script-ref check passed: ${checked} npm-run reference(s) resolve to real scripts.`); | ||
| } | ||
|
|
||
| const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; | ||
| if (invokedDirectly) main(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { extractScriptRefs, findStaleRefs, parsePackageScripts } from "../scripts/check-docs-script-refs.mjs"; | ||
|
|
||
| describe("parsePackageScripts", () => { | ||
| it("returns the set of script names", () => { | ||
| const set = parsePackageScripts(JSON.stringify({ scripts: { build: "x", "verify:cheap": "y" } })); | ||
| expect(set.has("build")).toBe(true); | ||
| expect(set.has("verify:cheap")).toBe(true); | ||
| expect(set.size).toBe(2); | ||
| }); | ||
| }); | ||
|
|
||
| describe("extractScriptRefs", () => { | ||
| it("extracts npm run tokens from inline code spans and fenced blocks", () => { | ||
| const md = ["Run `npm run verify:cheap` first.", "```bash", "npm run build", "pnpm run lint", "```"].join("\n"); | ||
| const refs = extractScriptRefs(md); | ||
| expect(refs).toEqual(expect.arrayContaining(["verify:cheap", "build", "lint"])); | ||
| }); | ||
|
|
||
| it("ignores npm run mentions in prose (outside code)", () => { | ||
| expect(extractScriptRefs("just npm run the thing normally")).toEqual([]); | ||
| }); | ||
|
|
||
| it("captures the script name with flags following it", () => { | ||
| expect(extractScriptRefs("`npm run eval:quality -- --rag-only`")).toEqual(["eval:quality"]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("findStaleRefs", () => { | ||
| const valid = new Set(["build", "verify:cheap"]); | ||
| it("flags references with no matching script", () => { | ||
| expect(findStaleRefs(["build", "verify:gone"], valid)).toEqual(["verify:gone"]); | ||
| }); | ||
| it("skips allowlisted and placeholder tokens", () => { | ||
| expect(findStaleRefs(["<script>", "your-script"], valid)).toEqual([]); | ||
| }); | ||
| it("returns empty when everything resolves", () => { | ||
| expect(findStaleRefs(["build", "verify:cheap"], valid)).toEqual([]); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Scope the retired-script exception to its historical record.
Line 38 suppresses
test:e2e:advisoryin every scanned file, so a newly added active-doc reference to that removed script passes validation. Keep placeholder tokens global, but apply this exception only todocs/branch-review-ledger.md(or exclude that record separately) and add a regression test.🤖 Prompt for AI Agents