import { existsSync as __existsSyncForDocs } from "node:fs"; import { resolve as __resolveForDocs } from "node:path"; import { hasYamlStatus } from "./docs-frontmatter.ts"; import { auditDocsMetadataRepository, initDocsMetadataAuditContext, } from "./docs-metadata-audit.ts"; // Module-level docs context, initialized by initDocsContext() before any // other function in this file is called. Consumers pass repo metadata // + an optional list of extra excluded path prefixes for project-specific // directories that shouldn't be subject to doc-lint conventions. let REPO_ROOT = ""; let SUBMODULES: readonly string[] = []; let EXTRA_EXCLUDED_PREFIXES: readonly string[] = []; let DOCS_ROOT_ALLOWLIST: readonly string[] = []; export function initDocsContext(opts: { repoRoot: string; submodules: readonly string[]; extraExcludedPrefixes?: readonly string[]; docsRootAllowlist?: readonly string[]; }): void { REPO_ROOT = opts.repoRoot; SUBMODULES = opts.submodules; EXTRA_EXCLUDED_PREFIXES = opts.extraExcludedPrefixes ?? []; DOCS_ROOT_ALLOWLIST = opts.docsRootAllowlist ?? []; initDocsMetadataAuditContext({ repoRoot: opts.repoRoot, submodules: opts.submodules }); } function submodulePath(name: string): string { return __resolveForDocs(REPO_ROOT, name); } function isSubmoduleInitialized(name: string): boolean { return __existsSyncForDocs(__resolveForDocs(REPO_ROOT, name, ".git")); } import { readFileSync } from "node:fs"; import { basename, join } from "node:path"; import { createDocsRepositoryView, type DocsRepositorySource, type DocsRepositoryView, } from "./docs-repository-view.ts"; /** * Documentation linter. Enforces the docs directory-layout + naming contract. * * Each violation carries a severity: `error` fails the lint, `warning` is * informational. `--fast` mode skips content-reading checks so the pre-commit * hook stays cheap. */ export type Severity = "error" | "warning"; export interface Violation { severity: Severity; repo: string; path: string; // relative to monorepo root rule: string; message: string; } export interface LintOpts { fast?: boolean; repo?: string; // limit to one submodule or "." for parent source?: DocsRepositorySource; } /** Files allowed at a submodule root level. Includes: * - In-repo conventions: README.md, CLAUDE.md, LLM-BRIEFING.md, AGENTS.md * - GitHub OSS conventions: CHANGELOG.md, CONTRIBUTING.md, CODE_OF_CONDUCT.md, * LICENSE.md, SECURITY.md, SUPPORT.md, AUTHORS.md, MAINTAINERS.md, * PULL_REQUEST_TEMPLATE.md (these are recognized by the GitHub UI, and * renaming them breaks the integration; LICENSE.md also appears inside * vendored upstream trees, where renaming would damage provenance) */ const ROOT_FILE_ALLOWLIST = new Set([ "README.md", "CLAUDE.md", "LLM-BRIEFING.md", "AGENTS.md", // GitHub-recognized OSS package files "CHANGELOG.md", "CONTRIBUTING.md", "CODE_OF_CONDUCT.md", "LICENSE.md", "SECURITY.md", "SUPPORT.md", "AUTHORS.md", "MAINTAINERS.md", "PULL_REQUEST_TEMPLATE.md", ]); /** Paths that are excluded from markdown discipline even when git-tracked. * * Covers auto-generated reference dumps and vendored content pages that happen * to be checked into git but aren't subject to doc conventions. Paths are * relative to the scanned repo root; any file under one of these prefixes is * ignored. * * Note: git-ignored directories (node_modules, .venv, vendor, dbt_packages, * target, dist, build) are already excluded because we use `git ls-files`. */ // Built-in exclusions: auto-generated/framework dirs that ship in any // project. Consumers extend this via `extraExcludedPrefixes` in // initDocsContext for their own project-specific dirs (auto-generated // API references, vendored content, etc.). const EXCLUDED_PREFIXES = [ ".agents/", // canonical AI-config sources (skills, subagents, rules, claude-addendum) ".claude/", // Claude Code framework files (SKILL.md, agents) ".harnery/", // harnery coord/skill state ".codex/", // OpenAI Codex framework files (skills/, agents/) ".cursor/", // auto-generated Cursor rules ]; /** Filename patterns that should never exist */ const FORBIDDEN_ROOT_NAMES = new Set([ "TODO.md", "PROJECT.md", "VISION.md", "NOTES.md", "DECISIONS.md", // should be docs/decisions.md "CHANGELOG.md", // should be docs/changelogs/YYYY-MM.md ]); /** YYYY-MM-DD_.md */ const DATED_FILE_PATTERN = /^\d{4}-\d{2}-\d{2}_[a-z0-9][a-z0-9_-]*\.md$/i; /** YYYY-MM.md for changelogs */ const CHANGELOG_PATTERN = /^\d{4}-\d{2}\.md$/; /** SCREAMING_SNAKE_CASE.md, excluding allowlisted entry files */ const SCREAMING_SNAKE_PATTERN = /^[A-Z][A-Z0-9_]+\.md$/; /** kebab-case.md: lowercase letters, digits, hyphens */ const KEBAB_CASE_PATTERN = /^[a-z0-9][a-z0-9-]*\.md$/; /** Target repos to lint: parent + every initialized submodule */ function getTargetRepos(opts: LintOpts): { name: string; path: string; isSubmodule: boolean }[] { const all: { name: string; path: string; isSubmodule: boolean }[] = [ { name: "(root)", path: REPO_ROOT, isSubmodule: false }, ]; for (const name of SUBMODULES) { if (!isSubmoduleInitialized(name)) continue; all.push({ name, path: submodulePath(name), isSubmodule: true }); } if (opts.repo) { const filter = opts.repo === "." ? "(root)" : opts.repo; return all.filter((r) => r.name === filter); } return all; } /** Select tracked Markdown from the coherent repository view. */ function findMarkdownFiles(view: DocsRepositoryView): string[] { return view.trackedPaths .filter((f) => f.endsWith(".md")) .filter( (f) => // Framework dirs are excluded at any depth, not just repo root; // in-tree repos (monorepos like harnery) nest .claude/.agents/etc. !EXCLUDED_PREFIXES.some((p) => f.startsWith(p) || f.includes(`/${p}`)) && !EXTRA_EXCLUDED_PREFIXES.some((p) => f.startsWith(p) || f.includes(`/${p}`)), ); } /** Detect whether a file declares itself an intentional monolith */ function isDeclaredMonolith(content: string): boolean { const head = content.split("\n").slice(0, 10).join("\n"); return /INTENTIONAL-MONOLITH/i.test(head); } /** Detect whether a file carries lifecycle status in leading YAML frontmatter. */ export function hasStatusHeader(path: string): boolean { try { return hasYamlStatus(readFileSync(path, "utf8")); } catch { return false; } } // --- Individual checks --- /** Entry tier files exist at the repo root */ function checkEntryTier( repoName: string, view: DocsRepositoryView, isSubmodule: boolean, ): Violation[] { const violations: Violation[] = []; // README.md is required for any repo if (!view.has("README.md")) { violations.push({ severity: "error", repo: repoName, path: join(repoName === "(root)" ? "" : repoName, "README.md"), rule: "entry-tier", message: "README.md missing at repo root", }); } // CLAUDE.md required for submodules (primary LLM context) if (isSubmodule && !view.has("CLAUDE.md")) { violations.push({ severity: "error", repo: repoName, path: join(repoName, "CLAUDE.md"), rule: "entry-tier", message: "CLAUDE.md missing: primary LLM context file", }); } return violations; } /** No forbidden root-level files */ function checkRootAllowlist(repoName: string, view: DocsRepositoryView): Violation[] { const violations: Violation[] = []; const entries = view.directFileNames("."); for (const entry of entries) { if (!entry.endsWith(".md")) continue; if (ROOT_FILE_ALLOWLIST.has(entry)) continue; const displayPath = join(repoName === "(root)" ? "" : repoName, entry); if (FORBIDDEN_ROOT_NAMES.has(entry)) { violations.push({ severity: "error", repo: repoName, path: displayPath, rule: "forbidden-root-file", message: `${entry} is not allowed at repo root`, }); } else if (SCREAMING_SNAKE_PATTERN.test(entry)) { violations.push({ severity: "error", repo: repoName, path: displayPath, rule: "root-caps-file", message: `${entry} is an ad-hoc caps file at repo root: entry tier is reserved for README.md / CLAUDE.md / LLM-BRIEFING.md / AGENTS.md`, }); } } return violations; } /** * The host project's `docs/` root is an entry tier: only allowlisted files may * sit loose there; topic docs belong in `docs//` subdirs. Config-gated — * a no-op unless the host supplies `docsRootAllowlist`. Parent-repo only: * submodule `docs/` roots have their own entry tiers, not this one. */ function checkDocsRootAllowlist(repoName: string, view: DocsRepositoryView): Violation[] { const violations: Violation[] = []; if (DOCS_ROOT_ALLOWLIST.length === 0) return violations; // opt-in if (repoName !== "(root)") return violations; // parent repo only const allow = new Set(DOCS_ROOT_ALLOWLIST); const entries = view.directFileNames("docs"); for (const entry of entries) { if (!(entry.endsWith(".md") || entry.endsWith(".json"))) continue; if (allow.has(entry)) continue; violations.push({ severity: "error", repo: repoName, path: join("docs", entry), rule: "docs-root-file", message: `${entry} is not allowed loose at docs/ root — move it into a docs// subdir (or add it to context.docsRootAllowlist if it's a genuine entry-tier doc)`, }); } return violations; } /** No SCREAMING_SNAKE_CASE filenames anywhere */ function checkNamingConvention(repoName: string, _repoPath: string, files: string[]): Violation[] { const violations: Violation[] = []; for (const rel of files) { const name = basename(rel); // Allowlisted names if (ROOT_FILE_ALLOWLIST.has(name)) continue; // Leading-underscore files are deliberate templates / meta files // (e.g. _template.md), not content. The underscore is a convention // marking "copy me, don't read me as a real doc", so exempt them // from naming discipline rather than forcing kebab-case. if (name.startsWith("_")) continue; // Dated files (audits/issues) if (DATED_FILE_PATTERN.test(name)) continue; // Changelogs if (CHANGELOG_PATTERN.test(name)) continue; // decisions.md, runbook.md: explicit if (name === "decisions.md" || name === "runbook.md") continue; // Known-good kebab-case if (KEBAB_CASE_PATTERN.test(name)) continue; // README.md inside a subdir is fine if (name === "README.md") continue; // SCREAMING_SNAKE_CASE violations if (SCREAMING_SNAKE_PATTERN.test(name)) { violations.push({ severity: "error", repo: repoName, path: join(repoName === "(root)" ? "" : repoName, rel), rule: "screaming-snake-case", message: `filename ${name} uses SCREAMING_SNAKE_CASE; rename to kebab-case`, }); continue; } // Anything else that's not kebab-case is a warning (Title Case, mixed) if (!/^[a-z0-9]/.test(name)) { violations.push({ severity: "warning", repo: repoName, path: join(repoName === "(root)" ? "" : repoName, rel), rule: "non-kebab-filename", message: `filename ${name} is not kebab-case`, }); } } return violations; } /** Files in docs/audits/ and docs/issues/ must match YYYY-MM-DD_.md */ function checkDatedDirs(repoName: string, _repoPath: string, files: string[]): Violation[] { const violations: Violation[] = []; const datedDirs = ["docs/audits/", "docs/issues/"]; for (const rel of files) { for (const d of datedDirs) { if (!rel.startsWith(d)) continue; const name = basename(rel); // README.md is the index file, allowed if (name === "README.md") continue; if (!DATED_FILE_PATTERN.test(name)) { violations.push({ severity: "error", repo: repoName, path: join(repoName === "(root)" ? "" : repoName, rel), rule: "undated-in-dated-dir", message: `${d} file must match YYYY-MM-DD_.md; got ${name}`, }); } } } return violations; } /** Changelog files must match YYYY-MM.md */ function checkChangelogNames(repoName: string, _repoPath: string, files: string[]): Violation[] { const violations: Violation[] = []; for (const rel of files) { if (!rel.startsWith("docs/changelogs/")) continue; const name = basename(rel); if (name === "README.md") continue; if (!CHANGELOG_PATTERN.test(name)) { violations.push({ severity: "error", repo: repoName, path: join(repoName === "(root)" ? "" : repoName, rel), rule: "bad-changelog-name", message: `changelog must match YYYY-MM.md; got ${name}`, }); } } return violations; } /** Intentional monoliths >30KB need a declaration banner */ async function checkMonolithDeclaration( repoName: string, view: DocsRepositoryView, files: string[], ): Promise { const violations: Violation[] = []; const SIZE_THRESHOLD = 30 * 1024; const candidatePaths: string[] = []; for (const rel of files) { // Only flag top-level docs/ files, not per-repo entry tier (LLM-BRIEFING // files are monoliths by convention, no banner needed). if (repoName !== "(root)") continue; if (!rel.startsWith("docs/")) continue; if (rel.startsWith("docs/plans/")) continue; if (rel.startsWith("docs/audits/")) continue; if (rel.startsWith("docs/issues/")) continue; if (rel.startsWith("docs/changelogs/")) continue; const size = await view.byteLength(rel); if (size === null) continue; if (size < SIZE_THRESHOLD) continue; candidatePaths.push(rel); } const texts = await view.readTexts(candidatePaths); for (const rel of candidatePaths) { const content = texts.get(rel); if (content === undefined || isDeclaredMonolith(content)) continue; const size = await view.byteLength(rel); if (size === null) continue; violations.push({ severity: "warning", repo: repoName, path: rel, rule: "undeclared-monolith", message: `${(size / 1024).toFixed(0)} KB file has no INTENTIONAL-MONOLITH banner; add one or split`, }); } return violations; } // --- Runner --- export async function runLint(opts: LintOpts): Promise { const violations: Violation[] = []; const repos = getTargetRepos(opts); for (const { name, path, isSubmodule } of repos) { const view = await createDocsRepositoryView(path, opts.source ?? "worktree"); violations.push(...checkEntryTier(name, view, isSubmodule)); violations.push(...checkRootAllowlist(name, view)); violations.push(...checkDocsRootAllowlist(name, view)); const files = findMarkdownFiles(view); violations.push(...checkNamingConvention(name, path, files)); violations.push(...checkDatedDirs(name, path, files)); violations.push(...checkChangelogNames(name, path, files)); if (!opts.fast) { violations.push(...(await checkMonolithDeclaration(name, view, files))); } const metadataRows = await auditDocsMetadataRepository(name, view); for (const row of metadataRows) { for (const issue of row.issues.filter((entry) => entry.severity === "error")) { violations.push({ severity: "error", repo: row.repo, path: row.path, rule: `metadata-v2:${issue.code}`, message: issue.field ? `${issue.field}: ${issue.message}` : issue.message, }); } } } return violations; }