import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { type DocKind, readDocStatusFromText } from "./docs-frontmatter.ts"; import { sh } from "./exec.ts"; /** * Internal Markdown link checker. * * Answers one question per link: does this relative target, and the heading * fragment it points at, actually exist right now? File-existence checks alone * miss fragments, which silently land a reader at the top of the page, and they * miss case-only mismatches, which work on macOS and break on Linux. * * The design constraint is noise. A checker that reports hundreds of items * nobody intends to fix gets ignored, so several categories are excluded by * construction rather than left for a human to filter: * * - External links, mail/tel schemes, protocol-relative URLs, and bare anchors * into non-Markdown targets are not resolved at all. * - Fenced code blocks and inline code spans are stripped before parsing, so a * documented example link is never mistaken for a real one. * - Targets carrying template syntax (`{{x}}`, `${x}`, ``) are * skipped as unresolvable by design. * - Root-absolute targets (`/foo`) are counted but not resolved: in practice * they are site routes far more often than repo paths. * - Findings in immutable-history documents are downgraded to warnings, because * an audit or changelog that names a path as it existed then is correct. * * The remaining escape hatch is explicit: `` on the * link's line, or `` anywhere in the file. */ // Module-level context, initialized by initDocsContext() before any other // function here runs. Mirrors the docs-lint.ts convention. let REPO_ROOT = ""; let SUBMODULES: readonly string[] = []; let EXTRA_EXCLUDED_PREFIXES: readonly string[] = []; export function initDocsContext(opts: { repoRoot: string; submodules: readonly string[]; extraExcludedPrefixes?: readonly string[]; }): void { REPO_ROOT = opts.repoRoot; SUBMODULES = opts.submodules; EXTRA_EXCLUDED_PREFIXES = opts.extraExcludedPrefixes ?? []; } export type LinkSeverity = "error" | "warning"; export type LinkRule = "missing-target" | "missing-fragment" | "case-mismatch" | "escapes-repo"; export interface LinkFinding { severity: LinkSeverity; repo: string; /** Source file, relative to its repo root. */ path: string; line: number; rule: LinkRule; /** The raw link target as written, fragment included. */ target: string; message: string; /** Populated for case-mismatch: the path that does exist on disk. */ suggestion?: string; } export interface LinkOpts { /** Limit to one submodule, or "." for the parent repo. */ repo?: string; /** Skip heading-fragment validation; check target existence only. */ noFragments?: boolean; /** Report findings in immutable-history docs at error severity too. */ strict?: boolean; /** Also flag links that resolve outside their own repo root. */ checkEscapes?: boolean; } export interface LinkReport { repo: string | null; files_scanned: number; links_checked: number; links_skipped: number; error_count: number; warning_count: number; findings: LinkFinding[]; } /** Framework/generated dirs excluded at any depth. Mirrors docs-lint.ts. */ const EXCLUDED_PREFIXES = [".agents/", ".claude/", ".harnery/", ".codex/", ".cursor/"]; /** * Path segments whose documents record a past state. A link there naming a file * that has since moved is accurate history, not a defect, so findings are * downgraded to warnings unless --strict. */ const HISTORY_SEGMENTS = ["archive/", "audits/", "changelogs/", "handoffs/", "decisions/"]; /** Schemes and forms that are never resolved against the filesystem. */ const EXTERNAL_SCHEME = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i; /** Template/placeholder syntax that makes a target unresolvable by design. */ const PLACEHOLDER = /[{}<>$*]|\.\.\.|%s|%d/; /** Fragments like #L42 or #L10-L20 are line refs into source, not headings. */ const LINE_REF_FRAGMENT = /^L\d+(?:[-,]L?\d+)?$/; /** * A fragment beginning with a slash is a single-page-app hash route * (`#/marketing/contact`), not a heading anchor. These show up in Markdown * captured from a rendered site and can never resolve to a heading. */ const HASH_ROUTE_FRAGMENT = /^\//; /** Fragments that are structurally incapable of naming a heading. */ function isNonHeadingFragment(fragment: string): boolean { return LINE_REF_FRAGMENT.test(fragment) || HASH_ROUTE_FRAGMENT.test(fragment); } const ALLOW_LINE = /