diff --git a/.changelog/unreleased/70-doc-policy-lint.md b/.changelog/unreleased/70-doc-policy-lint.md new file mode 100644 index 0000000..49044b2 --- /dev/null +++ b/.changelog/unreleased/70-doc-policy-lint.md @@ -0,0 +1,3 @@ +## Added + +- Add the warning-only `doc-policy-lint` reusable workflow and caller example for document-policy warnings. diff --git a/.claude/friction.md b/.claude/friction.md index 1ef717c..b723475 100644 --- a/.claude/friction.md +++ b/.claude/friction.md @@ -1,4 +1,5 @@ -| date | category | what happened | cost | suggested fix | -|---|---|---|---|---| -| 2026-06-15 | hook | verified friction ledger append path during #238 rollout | none | keep hook allowlist synced with repo-template | + +| date | category | what happened | cost | suggested fix | +| ---------- | -------- | -------------------------------------------------------- | ---- | --------------------------------------------- | +| 2026-06-15 | hook | verified friction ledger append path during #238 rollout | none | keep hook allowlist synced with repo-template | diff --git a/.github/workflows/doc-policy-lint.yml b/.github/workflows/doc-policy-lint.yml new file mode 100644 index 0000000..8ea8884 --- /dev/null +++ b/.github/workflows/doc-policy-lint.yml @@ -0,0 +1,117 @@ +name: Doc Policy Lint (reusable) + +# Warning-only document-policy lint for durable docs. This workflow reports +# status-header drift, charter budget overages, dangling supersession links, +# placeholder tokens in active docs, index coherence, and stale active-doc +# terms near changed current-truth registers. +# +# Promotion into `.agent/check-map.yml` docs requirements is intentionally +# deferred. Findings here are warnings only, following the PR-template +# drift-guard precedent. + +on: + workflow_call: + inputs: + workflow-library-ref: + description: | + Git ref (tag, branch, or SHA) of ArchonVII/github-workflows used to + source scripts/doc-policy-lint.mjs. MUST match the ref the caller + pins this reusable workflow to (for example @v1 plus v1 here). + type: string + default: v1 + max-warnings: + description: "Maximum number of warning annotations to emit before truncating." + type: number + default: 200 + readme-max-lines: + description: "README.md charter budget." + type: number + default: 150 + agents-max-lines: + description: "AGENTS.md charter budget." + type: number + default: 300 + tool-stub-max-lines: + description: "CLAUDE.md/GEMINI.md tool-stub charter budget." + type: number + default: 25 + vision-max-lines: + description: "VISION.md charter budget." + type: number + default: 120 + +jobs: + doc-policy-lint: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out caller repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check out github-workflows for doc-policy helper scripts + uses: actions/checkout@v4 + with: + repository: ArchonVII/github-workflows + ref: ${{ inputs.workflow-library-ref }} + path: __github-workflows__ + + - name: Collect changed files + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before || '' }} + run: | + set -uo pipefail + + : > .doc-policy-changed-files.txt + + if [ -z "$BASE_SHA" ]; then + echo "No base SHA available; stale active-doc term signal will run without PR-change context." + exit 0 + fi + + case "$BASE_SHA" in + 0000000000000000000000000000000000000000) + echo "Base SHA is empty push sentinel; stale active-doc term signal will run without PR-change context." + exit 0 + ;; + esac + + if git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + if ! git diff --name-only "$BASE_SHA"...HEAD > .doc-policy-changed-files.txt; then + echo "::warning title=Doc policy lint context::Could not diff $BASE_SHA...HEAD; stale active-doc term signal will run without PR-change context." + : > .doc-policy-changed-files.txt + fi + else + echo "::warning title=Doc policy lint context::Could not resolve base SHA $BASE_SHA; stale active-doc term signal will run without PR-change context." + fi + + - name: Run doc-policy lint + shell: bash + run: | + set -uo pipefail + + echo "Doc policy lint is warning-only; findings never fail this job." + node __github-workflows__/scripts/doc-policy-lint.mjs \ + --repo . \ + --changed-files .doc-policy-changed-files.txt \ + --github-annotations \ + --summary .doc-policy-lint-summary.md \ + --max-warnings "${{ inputs.max-warnings }}" \ + --readme-max-lines "${{ inputs.readme-max-lines }}" \ + --agents-max-lines "${{ inputs.agents-max-lines }}" \ + --tool-stub-max-lines "${{ inputs.tool-stub-max-lines }}" \ + --vision-max-lines "${{ inputs.vision-max-lines }}" + + status="$?" + if [ "$status" -ne 0 ]; then + echo "::warning title=Doc policy lint skipped::doc-policy-lint exited with $status; warning-only workflow will not fail." + fi + + if [ -f .doc-policy-lint-summary.md ]; then + cat .doc-policy-lint-summary.md >> "$GITHUB_STEP_SUMMARY" + fi + + exit 0 diff --git a/README.md b/README.md index a74af8d..9561fca 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ full immutability can pin to a commit SHA instead of `@v1`. ## Workflow Inventory -This repo currently contains 18 reusable `workflow_call` workflows and 2 +This repo currently contains 19 reusable `workflow_call` workflows and 2 provider self-test workflows. ### Required Gate And PR Policy @@ -85,6 +85,7 @@ provider self-test workflows. | --- | --- | --- | | [`changelog-fragment.yml`](.github/workflows/changelog-fragment.yml) | Requires an added `.changelog/unreleased/*.md` fragment when configured source paths change, with a label-based opt-out. | [`examples/changelog-fragment.yml`](examples/changelog-fragment.yml) | | [`doc-orphan-detector.yml`](.github/workflows/doc-orphan-detector.yml) | Scheduled backstop for committed docs stranded on pushed branches with no open PR. Reports path-only tracking issues and never commits or pushes. | [`examples/doc-orphan-detector.yml`](examples/doc-orphan-detector.yml) | +| [`doc-policy-lint.yml`](.github/workflows/doc-policy-lint.yml) | Warning-only document-policy lint for durable docs: status headers, charter budgets, supersession links, active-doc placeholders, index coherence, and stale active-doc terms. | [`examples/doc-policy-lint.yml`](examples/doc-policy-lint.yml) | | [`anomaly-triage.yml`](.github/workflows/anomaly-triage.yml) | Reads a per-PR anomalies file, classifies entries as PR-related or unrelated, posts sticky review comments, and can open downstream issues. | [`examples/anomaly-triage.yml`](examples/anomaly-triage.yml) | | [`anomaly-to-issue.yml`](.github/workflows/anomaly-to-issue.yml) | On merge, promotes files under `.anomalies/` into GitHub issues with parsed frontmatter. | [`examples/anomaly-to-issue.yml`](examples/anomaly-to-issue.yml) | | [`labeler.yml`](.github/workflows/labeler.yml) | Thin wrapper around `actions/labeler@v5`. | [`examples/labeler.yml`](examples/labeler.yml) | @@ -143,9 +144,10 @@ repo-required-gate / decision Add specialized callers only when the repo needs them. For example, use `doc-orphan-detector.yml` on repos that allow doc-sweep recovery, use -`auto-merge-dependabot.yml` only where Dependabot auto-merge is acceptable, and -use standalone `actionlint.yml` when workflow validation should run outside the -required gate. +`doc-policy-lint.yml` when document-policy warnings should be visible without +blocking merges, use `auto-merge-dependabot.yml` only where Dependabot +auto-merge is acceptable, and use standalone `actionlint.yml` when workflow +validation should run outside the required gate. ## PR Contract And Role Separation diff --git a/docs/repo-update-log.md b/docs/repo-update-log.md index 764991d..87f177c 100644 --- a/docs/repo-update-log.md +++ b/docs/repo-update-log.md @@ -15,6 +15,15 @@ This log records agent-visible repository changes that should be easy to audit l - **Propagation:** none | pending | completed ``` +## 2026-06-15 - Warning-only document policy lint workflow + +- **Issue/PR:** #70 / (pending) +- **Branch:** agent/codex/70-doc-policy-lint +- **Changed paths:** .github/workflows/doc-policy-lint.yml, examples/doc-policy-lint.yml, scripts/doc-policy-lint.mjs, scripts/doc-policy-lint.test.mjs, scripts/workflow-structure.test.mjs, README.md, .changelog/unreleased/70-doc-policy-lint.md, docs/repo-update-log.md +- **What changed:** Added a warning-only reusable `doc-policy-lint` workflow and caller example. The helper checks durable docs status headers, OD4 charter budgets, supersession links, active-doc placeholders, index coherence, and stale active-doc terms near changed current-truth registers without failing the job for findings. +- **Verification:** `npm test` passed 129/129 tests. `C:\Users\josep\go\bin\actionlint.exe .github\workflows\doc-policy-lint.yml examples\doc-policy-lint.yml` exited 0 with no findings. +- **Propagation:** pending `v1` tag movement after merge; consumer required-check promotion is explicitly deferred. + ## 2026-06-15 - Friction ledger wiring - **Issue/PR:** #78 / #79 diff --git a/examples/doc-policy-lint.yml b/examples/doc-policy-lint.yml new file mode 100644 index 0000000..97a62a2 --- /dev/null +++ b/examples/doc-policy-lint.yml @@ -0,0 +1,29 @@ +# Copy to `.github/workflows/doc-policy-lint.yml`. +# +# Warning-only document-policy lint. It emits annotations for durable docs/** +# status headers, charter budgets, supersession links, active-doc placeholders, +# index coherence, and stale active-doc terms. Do not make this a required +# branch-protection check during the warning-first rollout. + +name: Doc Policy Lint + +on: + pull_request: + paths: + - "docs/**" + - ".changelog/**" + - "README.md" + - "AGENTS.md" + - "CLAUDE.md" + - "GEMINI.md" + - "VISION.md" + workflow_dispatch: + +permissions: + contents: read + +jobs: + doc-policy-lint: + uses: ArchonVII/github-workflows/.github/workflows/doc-policy-lint.yml@v1 + with: + workflow-library-ref: v1 diff --git a/scripts/doc-policy-lint.mjs b/scripts/doc-policy-lint.mjs new file mode 100644 index 0000000..e605aba --- /dev/null +++ b/scripts/doc-policy-lint.mjs @@ -0,0 +1,559 @@ +#!/usr/bin/env node +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DEFAULT_BUDGETS = { + 'README.md': 150, + 'AGENTS.md': 300, + 'CLAUDE.md': 25, + 'GEMINI.md': 25, + 'VISION.md': 120, +}; + +const STATUS_HEADER_FIELDS = [ + 'status', + 'owner', + 'scope', + 'source of truth', + 'last reviewed', + 'supersedes', + 'superseded by', +]; + +const PLACEHOLDER_RE = /\b(?:TODO|TBD|N\/A)\b/i; +const STALE_TERM_RE = /\b(?:not deployed|next|remaining|deferred|blocked|pending)\b|(?:#\d+\b)|\b(?:issue|migration)\s+#?\d+\b/i; + +const normalizePath = (value) => + String(value || '') + .replace(/\\/g, '/') + .replace(/^\.\//, '') + .replace(/^\/+/, ''); + +const countLines = (body) => { + const text = String(body || '').replace(/\r?\n$/, ''); + return text === '' ? 0 : text.split(/\r?\n/).length; +}; + +const keyName = (value) => + String(value || '') + .toLowerCase() + .replace(/\s+/g, ' ') + .replace(/-/g, ' ') + .trim(); + +const walkFiles = (root, dir = '') => { + const fullDir = path.join(root, dir); + if (!existsSync(fullDir)) return []; + + const files = []; + for (const entry of readdirSync(fullDir, { withFileTypes: true })) { + if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === '__github-workflows__') continue; + const rel = normalizePath(path.join(dir, entry.name)); + if (entry.isDirectory()) { + files.push(...walkFiles(root, rel)); + } else if (entry.isFile()) { + files.push(rel); + } + } + return files; +}; + +const readText = (repoRoot, relPath) => readFileSync(path.join(repoRoot, relPath), 'utf8'); + +const parseDocHeader = (body) => { + const fields = new Map(); + const lines = String(body || '').split(/\r?\n/); + + if (lines[0] === '---') { + for (let i = 1; i < Math.min(lines.length, 80); i += 1) { + if (lines[i] === '---') break; + const match = lines[i].match(/^([A-Za-z][A-Za-z -]+):\s*(.*)$/); + if (match) fields.set(keyName(match[1]), match[2].trim()); + } + } + + for (const line of lines.slice(0, 40)) { + const quoteBold = line.match(/^>\s*\*\*([^*]+):\*\*\s*(.*)$/); + if (quoteBold) { + fields.set(keyName(quoteBold[1]), quoteBold[2].trim()); + continue; + } + + const plain = line.match(/^(?:>\s*)?([A-Za-z][A-Za-z -]+):\s*(.*)$/); + if (plain) fields.set(keyName(plain[1]), plain[2].trim()); + } + + return { + fields, + status: fields.get('status') || '', + sourceOfTruth: fields.get('source of truth') || '', + supersedes: fields.get('supersedes') || '', + supersededBy: fields.get('superseded by') || '', + }; +}; + +const isMarkdown = (relPath) => /\.md$/i.test(relPath); + +const isStatusHeaderExempt = (relPath) => { + const p = normalizePath(relPath).toLowerCase(); + return ( + /^docs\/adrs?\//.test(p) + || /^docs\/(?:.*\/)?plans\//.test(p) + || /\/fragments?\//.test(p) + || /\/fragment[-\w]*\.md$/.test(p) + ); +}; + +const isDocsLanding = (relPath) => { + const p = normalizePath(relPath).toLowerCase(); + return p === 'docs/index.md' || /\/(?:readme|index)\.md$/.test(p); +}; + +const isDurableDoc = (relPath) => + isMarkdown(relPath) && normalizePath(relPath).toLowerCase().startsWith('docs/'); + +const hasStatusHeader = (header) => + STATUS_HEADER_FIELDS.every((field) => header.fields.has(field)); + +const isActiveDoc = (header) => /^active\b/i.test(header.status.trim()); + +const isCurrentTruthRegister = (header) => /^yes\b/i.test(header.sourceOfTruth.trim()); + +const relativePosix = (from, to) => { + const rel = path.posix.relative(path.posix.dirname(normalizePath(from)), normalizePath(to)); + return rel || path.posix.basename(normalizePath(to)); +}; + +const indexMentions = (indexBody, indexPath, targetPath) => { + const normalizedBody = String(indexBody || '').replace(/\\/g, '/'); + const target = normalizePath(targetPath); + const rel = relativePosix(indexPath, target); + return normalizedBody.includes(target) || normalizedBody.includes(rel); +}; + +const isNoneValue = (value) => /^(|none|no|n\/a|na|-|--|not applicable)$/i.test(String(value || '').trim()); + +const extractLinkTargets = (value) => { + if (isNoneValue(value)) return []; + + const targets = []; + const text = String(value || ''); + for (const match of text.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)) { + targets.push(match[1]); + } + + const withoutMarkdownLinks = text.replace(/\[[^\]]+\]\([^)]+\)/g, ' '); + for (const part of withoutMarkdownLinks.split(/[,;]/)) { + const cleaned = part + .trim() + .replace(/^see\s+/i, '') + .replace(/^`|`$/g, '') + .replace(/^<|>$/g, '') + .replace(/^["']|["']$/g, ''); + if (cleaned) targets.push(cleaned); + } + + return targets; +}; + +const localTargetExists = (repoRoot, fromPath, rawTarget) => { + const target = String(rawTarget || '').trim(); + if (!target || /^(https?:|mailto:)/i.test(target) || /^#\d+$/.test(target)) return true; + + const withoutAnchor = target.split('#')[0]; + if (!withoutAnchor) return true; + + const normalized = normalizePath(withoutAnchor); + const candidates = []; + if (withoutAnchor.startsWith('/')) { + candidates.push(path.join(repoRoot, normalized)); + } else if (normalized.startsWith('docs/') || normalized.startsWith('.')) { + candidates.push(path.join(repoRoot, normalized)); + } else { + candidates.push(path.join(repoRoot, path.posix.dirname(normalizePath(fromPath)), normalized)); + } + + return candidates.some((candidate) => existsSync(candidate)); +}; + +const lineFind = (body, pattern) => { + const lines = String(body || '').split(/\r?\n/); + for (let i = 0; i < lines.length; i += 1) { + if (pattern.test(lines[i])) return { line: i + 1, text: lines[i] }; + } + return null; +}; + +const allLineMatches = (body, pattern) => { + const matches = []; + const lines = String(body || '').split(/\r?\n/); + let inFence = false; + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (/^\s*```/.test(line)) { + inFence = !inFence; + continue; + } + if (!inFence && pattern.test(line)) { + matches.push({ line: i + 1, text: line.trim() }); + } + } + return matches; +}; + +const pushFinding = (findings, finding, maxFindings) => { + if (findings.length < maxFindings) { + findings.push({ + severity: 'warning', + ...finding, + path: normalizePath(finding.path), + }); + } +}; + +const nearbyRootFor = (registerPath) => { + const p = normalizePath(registerPath); + if (!p.includes('/')) return ''; + return path.posix.dirname(p); +}; + +const isNearby = (registerPath, docPath) => { + const root = nearbyRootFor(registerPath); + if (!root) return true; + return normalizePath(docPath).startsWith(`${root}/`); +}; + +export function lintDocPolicy(options = {}) { + const repoRoot = path.resolve(options.repoRoot || process.cwd()); + const maxFindings = Number.isFinite(Number(options.maxFindings)) + ? Math.max(1, Number(options.maxFindings)) + : 200; + const budgets = { + ...DEFAULT_BUDGETS, + ...(options.budgets || {}), + }; + const findings = []; + + const files = walkFiles(repoRoot).filter(isMarkdown).sort(); + const docsFiles = files.filter(isDurableDoc); + const headers = new Map(); + const bodies = new Map(); + + for (const relPath of files) { + const body = readText(repoRoot, relPath); + bodies.set(relPath, body); + headers.set(relPath, parseDocHeader(body)); + } + + for (const [relPath, limit] of Object.entries(budgets)) { + const full = path.join(repoRoot, relPath); + if (!existsSync(full)) continue; + const lineCount = countLines(readText(repoRoot, relPath)); + if (lineCount > limit) { + pushFinding(findings, { + code: 'charter_budget', + title: 'Charter budget exceeded', + path: relPath, + line: 1, + message: `${relPath} has ${lineCount} lines; charter budget is ${limit}.`, + }, maxFindings); + } + } + + for (const relPath of docsFiles) { + if (isStatusHeaderExempt(relPath)) continue; + const header = headers.get(relPath); + if (!hasStatusHeader(header)) { + const missing = STATUS_HEADER_FIELDS.filter((field) => !header.fields.has(field)); + pushFinding(findings, { + code: 'missing_status_header', + title: 'Missing document status header', + path: relPath, + line: 1, + message: `${relPath} is a durable docs/** markdown file missing status header field(s): ${missing.join(', ')}.`, + }, maxFindings); + } + } + + for (const relPath of files) { + const header = headers.get(relPath); + for (const [field, value] of [ + ['supersedes', header.supersedes], + ['superseded_by', header.supersededBy], + ]) { + for (const target of extractLinkTargets(value)) { + if (!localTargetExists(repoRoot, relPath, target)) { + pushFinding(findings, { + code: `dangling_${field}`, + title: 'Dangling supersession link', + path: relPath, + line: lineFind(bodies.get(relPath), new RegExp(field.replace('_', '[- ]'), 'i'))?.line || 1, + message: `${relPath} has ${field.replace('_', ' ')} target \`${target}\`, but that local path does not exist.`, + }, maxFindings); + } + } + } + } + + for (const relPath of files) { + const header = headers.get(relPath); + if (!isActiveDoc(header)) continue; + for (const match of allLineMatches(bodies.get(relPath), PLACEHOLDER_RE)) { + pushFinding(findings, { + code: 'active_placeholder', + title: 'Placeholder token in active doc', + path: relPath, + line: match.line, + message: `${relPath} is active and still contains placeholder token text: ${match.text}`, + }, maxFindings); + } + } + + const docsIndex = 'docs/INDEX.md'; + const indexableDocs = docsFiles.filter((relPath) => !isDocsLanding(relPath)); + if (indexableDocs.length > 0) { + if (existsSync(path.join(repoRoot, docsIndex))) { + const indexBody = readText(repoRoot, docsIndex); + for (const relPath of indexableDocs) { + if (!indexMentions(indexBody, docsIndex, relPath)) { + pushFinding(findings, { + code: 'index_coherence', + title: 'Durable doc absent from docs/INDEX.md', + path: relPath, + line: 1, + message: `${relPath} is a durable docs/** file but is not linked from docs/INDEX.md.`, + }, maxFindings); + } + } + } else { + pushFinding(findings, { + code: 'index_coherence', + title: 'docs/INDEX.md missing', + path: docsIndex, + line: 1, + message: `docs/INDEX.md is missing while ${indexableDocs.length} durable docs/** file(s) exist.`, + }, maxFindings); + } + } + + for (const adrDir of ['docs/adr', 'docs/adrs']) { + const adrDocs = docsFiles.filter((relPath) => { + const p = relPath.toLowerCase(); + return p.startsWith(`${adrDir}/`) && !/\/(?:readme|index)\.md$/i.test(p); + }); + if (adrDocs.length === 0) continue; + + const indexPath = [`${adrDir}/README.md`, `${adrDir}/INDEX.md`, `${adrDir}/index.md`] + .find((candidate) => existsSync(path.join(repoRoot, candidate))); + if (!indexPath) { + pushFinding(findings, { + code: 'index_coherence', + title: 'ADR index missing', + path: adrDir, + line: 1, + message: `${adrDir} contains ADR documents but no README.md or INDEX.md landing index.`, + }, maxFindings); + continue; + } + + const indexBody = readText(repoRoot, indexPath); + for (const relPath of adrDocs) { + if (!indexMentions(indexBody, indexPath, relPath)) { + pushFinding(findings, { + code: 'index_coherence', + title: 'ADR absent from ADR index', + path: relPath, + line: 1, + message: `${relPath} is not linked from ${indexPath}.`, + }, maxFindings); + } + } + } + + const changedFiles = (options.changedFiles || []).map(normalizePath).filter(Boolean); + const changedRegisters = changedFiles + .filter((relPath) => headers.has(relPath)) + .filter((relPath) => isCurrentTruthRegister(headers.get(relPath))); + + if (changedRegisters.length > 0) { + const activeDocs = files + .filter((relPath) => isActiveDoc(headers.get(relPath))) + .filter((relPath) => !changedRegisters.includes(relPath)); + + for (const registerPath of changedRegisters) { + for (const relPath of activeDocs.filter((candidate) => isNearby(registerPath, candidate))) { + for (const match of allLineMatches(bodies.get(relPath), STALE_TERM_RE)) { + pushFinding(findings, { + code: 'stale_active_doc_term', + title: 'Stale active-doc term near changed current truth', + path: relPath, + line: match.line, + message: `${registerPath} changed as Source of truth: yes; nearby active doc ${relPath} still carries stale-looking term text: ${match.text}`, + }, maxFindings); + } + } + } + } + + if (findings.length >= maxFindings) { + findings.push({ + severity: 'warning', + code: 'max_findings', + title: 'Maximum warning count reached', + path: '.', + line: 1, + message: `doc-policy-lint stopped after ${maxFindings} warning(s).`, + }); + } + + return { + ok: true, + findings, + summary: formatMarkdownSummary(findings), + }; +} + +export function formatMarkdownSummary(findings) { + const lines = [ + '### Doc Policy Lint (warning-only)', + '', + 'This reusable workflow reports document-policy findings as warnings only. It does not fail the job for lint findings.', + '', + ]; + + if (findings.length === 0) { + lines.push('No document-policy warnings found.'); + return `${lines.join('\n')}\n`; + } + + lines.push(`Found ${findings.length} warning(s):`, ''); + for (const finding of findings) { + lines.push(`- **${finding.code}** ${finding.path}:${finding.line || 1} - ${finding.message}`); + } + return `${lines.join('\n')}\n`; +} + +const parseArgs = (argv) => { + const args = { + changedFiles: [], + budgets: {}, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const next = () => { + i += 1; + return argv[i]; + }; + + if (arg === '--repo') args.repoRoot = next(); + else if (arg === '--changed-file') args.changedFiles.push(next()); + else if (arg === '--changed-files') args.changedFilesFile = next(); + else if (arg === '--summary') args.summaryPath = next(); + else if (arg === '--github-annotations') args.githubAnnotations = true; + else if (arg === '--max-warnings') args.maxFindings = Number(next()); + else if (arg === '--readme-max-lines') args.budgets['README.md'] = Number(next()); + else if (arg === '--agents-max-lines') args.budgets['AGENTS.md'] = Number(next()); + else if (arg === '--tool-stub-max-lines') { + const value = Number(next()); + args.budgets['CLAUDE.md'] = value; + args.budgets['GEMINI.md'] = value; + } else if (arg === '--vision-max-lines') args.budgets['VISION.md'] = Number(next()); + else if (arg === '--help') args.help = true; + } + + return args; +}; + +const escapeCommandValue = (value) => + String(value || '') + .replace(/%/g, '%25') + .replace(/\r/g, '%0D') + .replace(/\n/g, '%0A'); + +const escapeCommandProperty = (value) => + escapeCommandValue(value) + .replace(/:/g, '%3A') + .replace(/,/g, '%2C'); + +const printGitHubWarning = (finding) => { + const props = [ + `file=${escapeCommandProperty(finding.path)}`, + `line=${escapeCommandProperty(finding.line || 1)}`, + `title=${escapeCommandProperty(finding.title || finding.code)}`, + ].join(','); + process.stdout.write(`::warning ${props}::${escapeCommandValue(finding.message)}\n`); +}; + +const printHelp = () => { + process.stdout.write(`Usage: node scripts/doc-policy-lint.mjs --repo [options]\n\n`); + process.stdout.write(`Options:\n`); + process.stdout.write(` --changed-file Add one changed file path for 8.2 stale-term detection.\n`); + process.stdout.write(` --changed-files Read newline-delimited changed file paths.\n`); + process.stdout.write(` --github-annotations Print GitHub ::warning annotations.\n`); + process.stdout.write(` --summary Write a markdown summary file.\n`); + process.stdout.write(` --max-warnings Cap warning output (default: 200).\n`); +}; + +const main = () => { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + printHelp(); + return; + } + + if (args.changedFilesFile && existsSync(args.changedFilesFile)) { + const changed = readFileSync(args.changedFilesFile, 'utf8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + args.changedFiles.push(...changed); + } + + try { + const result = lintDocPolicy({ + repoRoot: args.repoRoot || process.cwd(), + changedFiles: args.changedFiles, + budgets: args.budgets, + maxFindings: args.maxFindings, + }); + + if (args.githubAnnotations) { + for (const finding of result.findings) printGitHubWarning(finding); + } else { + process.stdout.write(result.summary); + } + + if (args.summaryPath) { + mkdirSync(path.dirname(args.summaryPath), { recursive: true }); + writeFileSync(args.summaryPath, result.summary, 'utf8'); + } + } catch (err) { + const message = `doc-policy-lint skipped after an internal error: ${err && err.message ? err.message : err}`; + if (args.githubAnnotations) { + printGitHubWarning({ + path: '.', + line: 1, + title: 'Doc policy lint skipped', + message, + }); + } else { + process.stdout.write(`${message}\n`); + } + if (args.summaryPath) { + mkdirSync(path.dirname(args.summaryPath), { recursive: true }); + writeFileSync(args.summaryPath, `### Doc Policy Lint (warning-only)\n\n${message}\n`, 'utf8'); + } + } +}; + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/doc-policy-lint.test.mjs b/scripts/doc-policy-lint.test.mjs new file mode 100644 index 0000000..4d524ca --- /dev/null +++ b/scripts/doc-policy-lint.test.mjs @@ -0,0 +1,152 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { afterEach, describe, expect, it } from 'vitest'; +import { lintDocPolicy } from './doc-policy-lint.mjs'; + +const roots = []; + +const makeRepo = () => { + const root = mkdtempSync(join(tmpdir(), 'doc-policy-lint-')); + roots.push(root); + return root; +}; + +const write = (root, path, body) => { + const target = join(root, path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, body, 'utf8'); +}; + +const statusHeader = ({ + status = 'active', + owner = 'agent', + scope = 'repo-local', + sourceOfTruth = 'no - see docs/INDEX.md', + lastReviewed = '2026-06-15', + supersedes = 'none', + supersededBy = 'none', +} = {}) => [ + `> **Status:** ${status}`, + `> **Owner:** ${owner}`, + `> **Scope:** ${scope}`, + `> **Source of truth:** ${sourceOfTruth}`, + `> **Last reviewed:** ${lastReviewed}`, + `> **Supersedes:** ${supersedes}`, + `> **Superseded by:** ${supersededBy}`, + '', +].join('\n'); + +afterEach(() => { + while (roots.length > 0) { + rmSync(roots.pop(), { recursive: true, force: true }); + } +}); + +describe('lintDocPolicy', () => { + it('reports no findings for a headered, indexed, budget-compliant repo', () => { + const repo = makeRepo(); + + write(repo, 'README.md', '# Fixture\n'); + write(repo, 'AGENTS.md', '# Agents\n'); + write(repo, 'CLAUDE.md', '# Claude\n'); + write(repo, 'GEMINI.md', '# Gemini\n'); + write(repo, 'VISION.md', `${statusHeader({ sourceOfTruth: 'yes' })}# Vision\n`); + write( + repo, + 'docs/INDEX.md', + `${statusHeader()}# Docs\n\n- [Policy](agent-process/policy.md)\n- [ADR 0001](adr/0001-record.md)\n`, + ); + write(repo, 'docs/agent-process/policy.md', `${statusHeader()}# Policy\n`); + write(repo, 'docs/adr/README.md', '# ADRs\n\n- [0001](0001-record.md)\n'); + write(repo, 'docs/adr/0001-record.md', '# 0001 Record\n'); + + const result = lintDocPolicy({ repoRoot: repo }); + + expect(result.ok).toBe(true); + expect(result.findings).toEqual([]); + }); + + it('ignores the checked-out helper repository used by the reusable workflow', () => { + const repo = makeRepo(); + + write(repo, 'README.md', '# Fixture\n'); + write( + repo, + 'docs/INDEX.md', + `${statusHeader()}# Docs\n\n- [Policy](agent-process/policy.md)\n`, + ); + write(repo, 'docs/agent-process/policy.md', `${statusHeader()}# Policy\n`); + write(repo, '__github-workflows__/docs/unindexed.md', '# Provider helper doc\n\nTODO\n'); + + const result = lintDocPolicy({ repoRoot: repo }); + + expect(result.findings).toEqual([]); + }); + + it('emits warning-only findings for doc-policy violations including the 8.2 signals', () => { + const repo = makeRepo(); + + write(repo, 'README.md', Array.from({ length: 151 }, (_, i) => `line ${i}`).join('\n')); + write( + repo, + 'docs/INDEX.md', + `${statusHeader()}# Docs\n\n- [Register](current-truth.md)\n- [Superseded](superseded.md)\n`, + ); + write(repo, 'docs/guide.md', '# Guide without status header\n'); + write(repo, 'docs/current-truth.md', `${statusHeader({ sourceOfTruth: 'yes' })}# Current Truth\n`); + write(repo, 'docs/nearby.md', `${statusHeader()}# Nearby\n\nPending migration #42 remains.\n`); + write(repo, 'docs/active.md', `${statusHeader()}# Active\n\nTODO: replace this.\n`); + write( + repo, + 'docs/superseded.md', + `${statusHeader({ supersededBy: 'missing-replacement.md' })}# Superseded\n`, + ); + + const result = lintDocPolicy({ + repoRoot: repo, + changedFiles: ['docs/current-truth.md'], + }); + + expect(result.ok).toBe(true); + expect(new Set(result.findings.map((finding) => finding.code))).toEqual(new Set([ + 'active_placeholder', + 'charter_budget', + 'dangling_superseded_by', + 'index_coherence', + 'missing_status_header', + 'stale_active_doc_term', + ])); + }); +}); + +describe('doc-policy-lint CLI integration', () => { + it('prints warnings and exits zero for warn-path findings', () => { + const repo = makeRepo(); + const summary = join(repo, 'summary.md'); + const scriptPath = fileURLToPath(new URL('./doc-policy-lint.mjs', import.meta.url)); + + write(repo, 'README.md', '# Fixture\n'); + write(repo, 'docs/INDEX.md', `${statusHeader()}# Docs\n`); + write(repo, 'docs/current-truth.md', `${statusHeader({ sourceOfTruth: 'yes' })}# Current Truth\n`); + write(repo, 'docs/nearby.md', `${statusHeader()}# Nearby\n\nBlocked by issue #70.\n`); + + const run = spawnSync(process.execPath, [ + scriptPath, + '--repo', + repo, + '--changed-file', + 'docs/current-truth.md', + '--github-annotations', + '--summary', + summary, + ], { encoding: 'utf8' }); + + expect(run.status).toBe(0); + expect(run.stdout).toContain('::warning'); + expect(readFileSync(summary, 'utf8')).toContain('warning-only'); + }); +}); diff --git a/scripts/workflow-structure.test.mjs b/scripts/workflow-structure.test.mjs index 1b252eb..5829866 100644 --- a/scripts/workflow-structure.test.mjs +++ b/scripts/workflow-structure.test.mjs @@ -154,3 +154,34 @@ describe('pr-body-autoinject scaffold', () => { expect(body).not.toContain('- [x] Automated CI checks green on this PR'); }); }); + +describe('doc-policy-lint workflow contract', () => { + it('is warning-only and declares explicit permissions', () => { + const body = readWorkflow('doc-policy-lint'); + const jobBlock = workflowJobBlock(body, 'doc-policy-lint'); + + expect(jobBlock).toContain('permissions:'); + expect(jobBlock).toContain('contents: read'); + expect(jobBlock).not.toContain('core.setFailed'); + expect(jobBlock).not.toContain('exit 1'); + expect(jobBlock).toContain('Doc policy lint is warning-only'); + }); + + it('checks out helper scripts from the caller-aligned workflow-library-ref', () => { + const body = readWorkflow('doc-policy-lint'); + + expect(body).toContain('workflow-library-ref:'); + expect(body).toContain('ref: ${{ inputs.workflow-library-ref }}'); + expect(body).toContain('__github-workflows__'); + expect(body).toContain('scripts/doc-policy-lint.mjs'); + }); + + it('ships a caller example pinned to the same reusable-workflow and helper refs', () => { + const body = readExample('doc-policy-lint'); + + expect(body).toContain('uses: ArchonVII/github-workflows/.github/workflows/doc-policy-lint.yml@v1'); + expect(body).toContain('workflow-library-ref: v1'); + expect(body).toContain('permissions:'); + expect(body).toContain('contents: read'); + }); +});