From dd7efa19546b5facbae6ef5243770ad0bd7e349f Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Thu, 4 Jun 2026 21:48:26 -0500 Subject: [PATCH 1/3] feat(workflow): add doc-orphan-detector cron backstop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reusable workflow implementing the doc-sweep gh-cron backstop (doc-sweep spec §4.7). For every branch with no open PR, diffs origin/main..., collects changed paths matching the sweepable-docs allow-list (§4.1, case-insensitive, exclude wins ties), and — when the newest commit touching those paths is older than `stale-hours` (default 12; owner decision 2026-06-02, spec §4.3) — opens or updates a single idempotent tracking issue per branch listing the orphaned paths. Detection only: never commits, never pushes, reports paths only (file contents are never read). Mirrors anomaly-to-issue.yml structure: workflow_call with typed inputs, single job, least-privilege permissions (contents: read, issues: write), checkout@v4 fetch-depth: 0, github-script@v7. Closes #44 Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/doc-orphan-detector.yml | 276 ++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 .github/workflows/doc-orphan-detector.yml diff --git a/.github/workflows/doc-orphan-detector.yml b/.github/workflows/doc-orphan-detector.yml new file mode 100644 index 0000000..72b6e78 --- /dev/null +++ b/.github/workflows/doc-orphan-detector.yml @@ -0,0 +1,276 @@ +name: Doc Orphan Detector (reusable) + +# The doc-sweep gh-cron backstop (doc-sweep spec §4.7). For every branch with +# NO open PR, diff `origin/main...` and collect changed paths that match +# the sweepable-docs allow-list (§4.1). If any allow-list doc paths are present +# AND the latest commit touching those paths is older than `stale-hours`, open +# or update a single idempotent tracking issue per branch listing the orphaned +# paths. +# +# A gh-cron sees only PUSHED commits — committed-but-orphaned docs on stale +# branches. It cannot see uncommitted working-tree files (that is the local +# sweep-on-open / flush-on-close job). +# +# DETECTION ONLY: never commits, never pushes, and reports PATHS ONLY — it never +# reads or surfaces file contents. +# +# Consumers schedule this via a thin caller that triggers `on: schedule` +# (cron) and/or `on: workflow_dispatch`, then `uses:` this reusable workflow. + +on: + workflow_call: + inputs: + stale-hours: + description: "A branch's doc paths are orphaned once the newest commit touching them is older than this many hours." + type: number + # 12h source: owner decision 2026-06-02 (doc-sweep spec §4.3). Long + # enough that a live-but-slow session never trips it; short enough to + # recover within a workday. + default: 12 + base-label: + description: "Label applied to every doc-orphan tracking issue." + type: string + default: "doc-orphan" + default-branch: + description: "The default branch to diff every other branch against." + type: string + default: "main" + +jobs: + detect: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@v4 + with: + # Full history + all branches so `origin/main...` diffs and + # per-path commit timestamps are available for every branch. + fetch-depth: 0 + + - uses: actions/github-script@v7 + env: + STALE_HOURS: ${{ inputs.stale-hours }} + BASE_LABEL: ${{ inputs.base-label }} + DEFAULT_BRANCH: ${{ inputs.default-branch }} + with: + script: | + const { execFileSync } = require('child_process'); + + const defaultBranch = process.env.DEFAULT_BRANCH; + const baseLabel = process.env.BASE_LABEL; + const staleHours = Number(process.env.STALE_HOURS); + if (!Number.isFinite(staleHours) || staleHours < 0) { + core.setFailed(`invalid stale-hours \`${process.env.STALE_HOURS}\``); + return; + } + // milliseconds in one hour (60 min * 60 s * 1000 ms) + const staleMs = staleHours * 60 * 60 * 1000; + + const git = (args) => + execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + + // --- §4.1 allow-list / hard-exclude, matched case-INSENSITIVELY, + // exclude wins ties. Paths come from git output, already + // repo-relative POSIX with forward slashes. + const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg']; + function isSweepableDoc(rawPath) { + const p = rawPath.toLowerCase(); + // exclude wins ties: review-required doc subtrees are never swept. + if (p.startsWith('docs/process/') || p.startsWith('docs/architecture/')) { + return false; + } + if (p.startsWith('docs/')) return true; + if (p.startsWith('.changelog/')) return true; + if (p.startsWith('.html-artifacts/')) return true; + const dot = p.lastIndexOf('.'); + if (dot !== -1 && IMAGE_EXTS.includes(p.slice(dot + 1))) return true; + return false; + } + + // Resolve the default branch ref we diff against (prefer the remote + // tracking ref so this works on a fresh fetch-depth:0 checkout). + let baseRef = null; + for (const candidate of [`origin/${defaultBranch}`, defaultBranch]) { + try { + git(['rev-parse', '--verify', '--quiet', `${candidate}^{commit}`]); + baseRef = candidate; + break; + } catch (_) { + /* try next */ + } + } + if (!baseRef) { + core.setFailed(`cannot resolve default branch ref \`${defaultBranch}\``); + return; + } + + // Enumerate every remote branch except the default branch itself. + const branchLines = git([ + 'for-each-ref', + '--format=%(refname:short)', + 'refs/remotes/origin', + ]) + .split('\n') + .map((s) => s.trim()) + .filter(Boolean); + + const branches = []; + for (const full of branchLines) { + if (full === 'origin/HEAD' || full.startsWith('origin/HEAD ')) continue; + if (!full.startsWith('origin/')) continue; + const name = full.slice('origin/'.length); + if (name === defaultBranch) continue; + branches.push({ remoteRef: full, name }); + } + + // Branches that already have an OPEN PR are skipped — the PR is the + // tracking surface; only no-PR branches can strand docs invisibly. + const openPRs = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + }); + const branchesWithOpenPR = new Set(openPRs.map((pr) => pr.head.ref)); + + const now = Date.now(); + const reportedBranches = []; + + for (const branch of branches) { + if (branchesWithOpenPR.has(branch.name)) { + core.info(`Skipping \`${branch.name}\`: has an open PR.`); + continue; + } + + // Changed paths on this branch relative to the merge base with the + // default branch (three-dot): the branch's own additions/edits. + let changed; + try { + changed = git([ + 'diff', + '--name-only', + `${baseRef}...${branch.remoteRef}`, + ]) + .split('\n') + .map((s) => s.trim()) + .filter(Boolean); + } catch (err) { + core.warning(`diff failed for \`${branch.name}\`: ${err.message}`); + continue; + } + + const docPaths = changed.filter(isSweepableDoc).sort(); + if (docPaths.length === 0) continue; + + // Staleness: newest commit on this branch that touched ANY of the + // matched doc paths. If that commit is older than the threshold, + // the docs are orphaned. PATHS ONLY — contents are never read. + let newestTouchSec = 0; + try { + const out = git([ + 'log', + '-1', + '--format=%ct', + branch.remoteRef, + '--', + ...docPaths, + ]).trim(); + newestTouchSec = out ? Number(out) : 0; + } catch (err) { + core.warning(`log failed for \`${branch.name}\`: ${err.message}`); + continue; + } + if (!newestTouchSec) continue; + + const ageMs = now - newestTouchSec * 1000; + if (ageMs < staleMs) { + core.info( + `Skipping \`${branch.name}\`: doc paths fresh (` + + `${(ageMs / 3600000).toFixed(1)}h < ${staleHours}h).`, + ); + continue; + } + + const lastTouchedIso = new Date(newestTouchSec * 1000).toISOString(); + const issueTitle = `Doc orphans: ${branch.name}`; + const body = [ + `Branch \`${branch.name}\` has **no open PR** and carries committed doc paths`, + `whose newest touching commit (${lastTouchedIso}) is older than ${staleHours}h.`, + '', + 'These add-only/allow-list doc paths look stranded on a stale branch:', + '', + ...docPaths.map((p) => `- \`${p}\``), + '', + '---', + 'Detected by the reusable `doc-orphan-detector` workflow (doc-sweep spec §4.7).', + 'Detection only: no commit, no push, paths only (contents are never read).', + 'Recover via the local doc-sweep runner, or open a PR from this branch.', + 'This issue is idempotent — re-runs update this body in place.', + ].join('\n'); + + // Idempotent: find an existing OPEN issue with this exact title and + // update its body rather than duplicating. + const found = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: baseLabel, + per_page: 100, + }); + const existing = found.find( + (i) => !i.pull_request && i.title === issueTitle, + ); + + if (existing) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + body, + }); + core.info(`Updated #${existing.number} for \`${branch.name}\`.`); + reportedBranches.push({ branch: branch.name, issue: existing.number }); + } else { + // Ensure the base label exists before assigning it. + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: baseLabel, + }); + } catch (e) { + if (e.status === 404) { + // doc-orphan label color: a muted amber for "needs attention". + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: baseLabel, + color: 'D4A017', + description: 'Committed docs stranded on a stale, PR-less branch.', + }); + } else { + throw e; + } + } + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: issueTitle, + body, + labels: [baseLabel], + }); + core.info(`Opened #${issue.number} for \`${branch.name}\`.`); + reportedBranches.push({ branch: branch.name, issue: issue.number }); + } + } + + if (reportedBranches.length === 0) { + core.info('No doc-orphan branches detected.'); + } else { + core.info( + `Reported ${reportedBranches.length} doc-orphan branch(es): ` + + reportedBranches.map((r) => `${r.branch} → #${r.issue}`).join(', '), + ); + } From 2fc7350426eeaf64c0467c7627cdd10286c2dfd2 Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Thu, 4 Jun 2026 21:59:27 -0500 Subject: [PATCH 2/3] fix(doc-orphan-detector): mirror the runner's full 4.1 hard-exclude set (#44) --- .github/workflows/doc-orphan-detector.yml | 53 +++++++++++++++++------ 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/.github/workflows/doc-orphan-detector.yml b/.github/workflows/doc-orphan-detector.yml index 72b6e78..08d3dbd 100644 --- a/.github/workflows/doc-orphan-detector.yml +++ b/.github/workflows/doc-orphan-detector.yml @@ -72,22 +72,47 @@ jobs: execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); // --- §4.1 allow-list / hard-exclude, matched case-INSENSITIVELY, - // exclude wins ties. Paths come from git output, already - // repo-relative POSIX with forward slashes. - const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg']; + // exclude wins ties. This block MIRRORS the canonical doc-sweep + // runner classifier (scripts/doc-sweep/lib.mjs `isSweepable`) so + // the cron detector and the local sweep agree path-for-path. A + // reusable workflow runs in the CALLER's checkout and cannot + // import a module, so the logic is inlined and kept honest by + // scripts/doc-orphan-detector.test.mjs, which extracts the + // classify-block below and exercises it against spec §7. + // classify-block:start + const ALLOW = [ + /^docs\//i, + /^\.changelog\//i, + /^\.html-artifacts\//i, + /\.(png|jpe?g|gif|webp|svg)$/i, + ]; + // docs/process/** and docs/architecture/** require review — §4.1. + const ALLOW_EXCEPT = [/^docs\/process\//i, /^docs\/architecture\//i]; + // Hard-excludes — §4.1: code/CI/hooks, agent config, governance, + // manifests, plus the I4 Docusaurus carve-outs (code/config under docs/). + const EXCLUDE = [ + /^src\//i, + /^scripts\//i, + /^\.github\//i, + /^\.githooks\//i, + /^\.claude\//i, + /^\.codex\//i, + /^\.gemini\//i, + /^\.agent\/schema\//i, + /^(README|AGENTS|CLAUDE|GEMINI)\.md$/i, + /(^|\/)package[^/]*\.json$/i, + /\.(js|jsx|ts|tsx|mjs|cjs|mts|cts|json)$/i, + /^docs\/src\//i, + /^docs\/static\//i, + ]; + const norm = (p) => String(p).replace(/\\/g, '/').replace(/^\.\//, ''); function isSweepableDoc(rawPath) { - const p = rawPath.toLowerCase(); - // exclude wins ties: review-required doc subtrees are never swept. - if (p.startsWith('docs/process/') || p.startsWith('docs/architecture/')) { - return false; - } - if (p.startsWith('docs/')) return true; - if (p.startsWith('.changelog/')) return true; - if (p.startsWith('.html-artifacts/')) return true; - const dot = p.lastIndexOf('.'); - if (dot !== -1 && IMAGE_EXTS.includes(p.slice(dot + 1))) return true; - return false; + const p = norm(rawPath); + if (EXCLUDE.some((r) => r.test(p))) return false; // exclude wins ties + if (ALLOW_EXCEPT.some((r) => r.test(p))) return false; + return ALLOW.some((r) => r.test(p)); } + // classify-block:end // Resolve the default branch ref we diff against (prefer the remote // tracking ref so this works on a fresh fetch-depth:0 checkout). From ab26650b6725e3dc88972413d3cca6b1470f4ae1 Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Thu, 4 Jun 2026 21:59:27 -0500 Subject: [PATCH 3/3] test(doc-orphan-detector): exercise the inline classifier + structure (#44) --- scripts/doc-orphan-detector.test.mjs | 102 +++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 scripts/doc-orphan-detector.test.mjs diff --git a/scripts/doc-orphan-detector.test.mjs b/scripts/doc-orphan-detector.test.mjs new file mode 100644 index 0000000..e446711 --- /dev/null +++ b/scripts/doc-orphan-detector.test.mjs @@ -0,0 +1,102 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +// The reusable doc-orphan-detector runs its logic inline in a github-script +// block (a reusable workflow executes in the CALLER's checkout, so it cannot +// import a repo module). These tests read the workflow file and exercise the +// ACTUAL shipped bytes — the classify-block is extracted and run directly, so a +// drift in the inline classifier is caught here rather than in production. +const wf = readFileSync('.github/workflows/doc-orphan-detector.yml', 'utf8'); + +function loadInlineClassifier(body) { + const start = body.indexOf('// classify-block:start'); + const end = body.indexOf('// classify-block:end'); + if (start === -1 || end === -1) throw new Error('classify-block markers not found'); + const src = body.slice(start, end); + // eslint-disable-next-line no-new-func + return new Function(`${src}\nreturn isSweepableDoc;`)(); +} + +describe('doc-orphan-detector — structural invariants', () => { + it('is a reusable workflow with least-privilege permissions', () => { + expect(wf).toMatch(/on:\s*\n\s*workflow_call:/); + expect(wf).toContain('contents: read'); + expect(wf).toContain('issues: write'); + // never elevate beyond reading the tree + writing issues + expect(wf).not.toContain('contents: write'); + expect(wf).not.toContain('pull-requests: write'); + }); + + it('declares the three documented inputs with their defaults', () => { + for (const name of ['stale-hours', 'base-label', 'default-branch']) { + expect(wf).toContain(`${name}:`); + } + expect(wf).toContain('default: 12'); + expect(wf).toContain('default: "doc-orphan"'); + expect(wf).toContain('default: "main"'); + }); + + it('is detection-only: never mutates the tree/refs and never reads file contents (§4.7)', () => { + // git is only ever invoked with read-only subcommands + expect(wf).not.toMatch(/git\(\[\s*['"](add|commit|push|show|cat-file|checkout|reset|apply)['"]/); + // no content-reading GitHub API surface — paths only + expect(wf).not.toContain('getContent'); + expect(wf).not.toContain('createOrUpdateFileContents'); + expect(wf).not.toContain('readFileSync'); + // never closes/deletes issues — it only opens/updates tracking issues + expect(wf).not.toMatch(/issues\.(delete|update)\b.*state/); + }); + + it('sources the 12h threshold and diffs three-dot against the default branch', () => { + expect(wf).toContain('owner decision 2026-06-02'); + expect(wf).toMatch(/\$\{baseRef\}\.\.\.\$\{branch\.remoteRef\}/); + }); +}); + +describe('doc-orphan-detector — §4.1 classification (the shipped inline logic)', () => { + const isSweepableDoc = loadInlineClassifier(wf); + + // Sweepable: allow-list docs/changelog/html-artifacts + images outside excluded roots + const sweepable = [ + 'docs/archon/ROADMAP.md', + 'docs/notes.md', + '.changelog/unreleased/x.md', + '.html-artifacts/report.html', + 'assets/logo.png', + 'docs/diagram.svg', + ]; + + // Not sweepable: review carve-outs, hard-excludes, governance, manifests, + // images under excluded roots (exclude wins), and Docusaurus code/config. + const excluded = [ + 'docs/process/x.md', + 'docs/architecture/y.md', + 'src/index.ts', + 'src/logo.png', // image under a hard-excluded root → exclude wins + '.github/banner.svg', // image under .github → exclude wins + 'scripts/tool.mjs', + 'README.md', + 'AGENTS.md', + 'CLAUDE.md', + 'GEMINI.md', + 'package.json', + 'sub/package-lock.json', + 'docs/docusaurus.config.ts', + 'docs/src/page.tsx', + 'docs/static/img/x.png', + 'Process/x.md', // not under docs/ → not allow-listed at all + ]; + + for (const p of sweepable) { + it(`sweeps ${p}`, () => expect(isSweepableDoc(p)).toBe(true)); + } + for (const p of excluded) { + it(`excludes ${p}`, () => expect(isSweepableDoc(p)).toBe(false)); + } + + it('matches case-insensitively with exclude winning ties (NTFS, §4.1 D10)', () => { + expect(isSweepableDoc('DOCS/Process/Z.MD')).toBe(false); + expect(isSweepableDoc('Docs/Notes.MD')).toBe(true); + expect(isSweepableDoc('SRC/Logo.PNG')).toBe(false); + }); +});