diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1d92fb..ee5c16e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,8 @@ jobs: - name: Run test suite with coverage thresholds (flaky tests block merge; no quarantine) run: bun test + env: + GH_TOKEN: ${{ github.token }} - name: Run drift detection run: bun run scripts/drift-detect.ts . diff --git a/scripts/docs-reality-contract.test.ts b/scripts/docs-reality-contract.test.ts new file mode 100644 index 0000000..853b8af --- /dev/null +++ b/scripts/docs-reality-contract.test.ts @@ -0,0 +1,68 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { expect, test } from "bun:test"; +import { findDocsRealityDriftFailures } from "./docs-reality-contract.ts"; + +const REPO_ROOT = resolve(import.meta.dir, ".."); + +test("shipped drift phases are not described as planned in current docs", () => { + expect(findDocsRealityDriftFailures(REPO_ROOT)).toEqual([]); +}); + +test("reports the exact stale claim and source line", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "anvil-doc-reality-")); + mkdirSync(join(fixtureRoot, "docs"), { recursive: true }); + writeFileSync( + join(fixtureRoot, "README.md"), + "Drift detection: command drift detection is planned.\n", + ); + + try { + expect(findDocsRealityDriftFailures(fixtureRoot)).toEqual([ + { + file: "README.md", + line: 1, + claim: "Drift detection: command drift detection is planned.", + }, + ]); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } +}); + +test("does not mistake a planned future phase for a stale shipped claim", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "anvil-doc-reality-")); + mkdirSync(join(fixtureRoot, "docs"), { recursive: true }); + writeFileSync( + join(fixtureRoot, "README.md"), + "Drift detection ships path, glob, date, and command checks; coverage gap analysis is planned.\n", + ); + + try { + expect(findDocsRealityDriftFailures(fixtureRoot)).toEqual([]); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } +}); + +test("catches a stale shipped phase table entry", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "anvil-doc-reality-")); + mkdirSync(join(fixtureRoot, "docs"), { recursive: true }); + writeFileSync( + join(fixtureRoot, "docs/drift-detection-design.md"), + "| Phase 1c | Command drift | Planned |\n", + ); + + try { + expect(findDocsRealityDriftFailures(fixtureRoot)).toEqual([ + { + file: "docs/drift-detection-design.md", + line: 1, + claim: "| Phase 1c | Command drift | Planned |", + }, + ]); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } +}); diff --git a/scripts/docs-reality-contract.ts b/scripts/docs-reality-contract.ts new file mode 100644 index 0000000..429b83c --- /dev/null +++ b/scripts/docs-reality-contract.ts @@ -0,0 +1,69 @@ +import { existsSync, readFileSync } from "node:fs"; +import { relative, resolve } from "node:path"; + +export type DocsRealityDriftFailure = { + file: string; + line: number; + claim: string; +}; + +type ClaimPattern = { + file: string; + pattern: RegExp; +}; + +// These patterns are deliberately narrow: they guard the shipped Phase 1 +// drift checks without attempting general semantic documentation inference. +const STALE_SHIPPED_CLAIM_PATTERNS: ClaimPattern[] = [ + { + file: "README.md", + pattern: + /\b(?:path|glob|command|date) drift(?: detection)?\b[^;|.\n]{0,40}\b(?:planned|not yet implemented)\b/i, + }, + { + file: "README.md", + pattern: + /\bfull drift detection\b[^;|.\n]{0,40}\b(?:planned|not yet implemented)\b/i, + }, + { + file: "docs/drift-detection-design.md", + pattern: + /\b(?:path|glob|command|date) drift\b\s*[-:|]\s*(?:planned|not yet implemented)\b/i, + }, + { + file: "docs/drift-detection-design.md", + pattern: /\bStatus:\s*Design only\s*[—-]\s*not yet implemented\b/i, + }, +]; + +function lineNumberAt(lines: string[], index: number): number { + return index + 1; +} + +export function findDocsRealityDriftFailures( + projectRoot: string, +): DocsRealityDriftFailure[] { + const failures: DocsRealityDriftFailure[] = []; + + for (const { file, pattern } of STALE_SHIPPED_CLAIM_PATTERNS) { + const absolutePath = resolve(projectRoot, file); + if (!existsSync(absolutePath)) { + continue; + } + + const lines = readFileSync(absolutePath, "utf8").split("\n"); + lines.forEach((claim, index) => { + if (!pattern.test(claim)) { + return; + } + + failures.push({ + file: relative(projectRoot, absolutePath), + line: lineNumberAt(lines, index), + claim: claim.trim(), + }); + }); + } + + return failures; +}