DEV Community

Avery Lin
Avery Lin

Posted on

Regenerate Docs Only Where You Can Prove Them: A Section Lock With Anchor Digests

Most documentation drift is not a writing problem; it is a provenance problem. A model can reread a section and produce beautifully consistent prose that quietly contradicts pyproject.toml, while no reviewer can see the contradiction in the diff. The rule I keep returning to is narrow and mechanical: a section may be regenerated by a model only when every factual claim in it can be traced to files in the repository.

Everything else stays with a named human, and the build refuses the patch instead of asking a reviewer to remember. This article describes a small lock file, two anchor hashes, and a diff gate that runs in about a second on CI. The goal is not better prompts; it is a narrower blast radius for bad ones.

1. Decide ownership from provability, not from seniority

The useful question is not "who understands this area best" but "could a machine detect when this section becomes false". If the answer is yes, a model may draft it and a human may skim it. If the answer is no, a human owns it and the model stays out of the file entirely.

Section content Owner Reason
CLI flags, config keys, env var names model Every claim is checkable against source files
Install commands, pinned versions model Anchored to manifest and lockfile, which change loudly
Describe behavior no test asserts human Source can change without any anchor changing
Support matrix, platform promises human A commitment, not a description
Deprecation dates, migration deadlines human A time-bound promise to users
Error message explanations split Message text is model-owned; "what to do now" is human-owned

Write those decisions into docslock.json at the repository root. The file is the reviewable artifact: when a section changes hands, that change shows up as a diff someone must approve. A section that is model-owned without anchors, or human-owned without a stated reason, should fail review on sight.

{
  "version": 1,
  "sections": [
    {
      "id": "quickstart.flags",
      "file": "docs/quickstart.md",
      "heading": "Install flags",
      "owner": "model",
      "anchors": ["src/cli/install.py", "pyproject.toml"],
      "prompt": "drafts/quickstart-flags.md"
    },
    {
      "id": "quickstart.support",
      "file": "docs/quickstart.md",
      "heading": "Supported platforms",
      "owner": "human",
      "why": "support commitments need release-manager approval"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

2. Make anchors small and boring

An anchor is a file path whose contents justify the section's claims. Keep the list short; five anchors is usually one too many, and a section that needs twelve is really three sections that have not been split yet. Normalize anchors before hashing so that whitespace and line endings do not produce false staleness.

The digest is content-based, not timestamp-based, which matters more than it sounds. Reformatting a file that the section never describes produces no stale signal, while a one-character change to a default value produces one immediately. That inversion is the whole point: you want the doc system to be noisy about semantic inputs and silent about churn.

3. A stdlib-only script for digests and gating

The reference implementation below is roughly 140 lines of Python with no third-party dependencies. It offers three commands: check prints which sections are no longer fresh, digests records the current state after a merge, and gate fails if a patch touches a human-owned section. Line numbers come from the post-patch working tree, so the gate must run after checkout.

#!/usr/bin/env python3
"""docslock: anchor-based regeneration lock for documentation sections."""
from __future__ import annotations

import argparse
import hashlib
import json
import re
import subprocess
from pathlib import Path

LOCK = Path("docslock.json")
STATE = Path("docs/.docslock.state.json")
HEADING = re.compile(r"^(#{1,6})\s+(.+)$")
HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")


def read_lock() -> dict:
    return json.loads(LOCK.read_text(encoding="utf-8"))


def digest(paths: list[str]) -> str:
    h = hashlib.sha256()
    for p in sorted(paths):
        f = Path(p)
        h.update(p.encode("utf-8") + b"\0")
        h.update(f.read_bytes() if f.is_file() else b"<missing>")
        h.update(b"\0")
    return h.hexdigest()[:16]


def sections(text: str) -> list[tuple[int, int, str]]:
    lines = text.splitlines()
    heads = []
    for no, line in enumerate(lines, start=1):
        m = HEADING.match(line)
        if m:
            heads.append((no, m.group(2).strip(), len(m.group(1))))
    spans = []
    for i, (start, title, level) in enumerate(heads):
        end = len(lines)
        for later, _, later_level in heads[i + 1:]:
            if later_level <= level:
                end = later - 1
                break
        spans.append((start, end, title))
    return spans


def span_for(path: str, heading: str) -> tuple[int, int] | None:
    for start, end, title in sections(Path(path).read_text(encoding="utf-8")):
        if title == heading:
            return start, end
    return None


def cmd_check(_args: argparse.Namespace) -> int:
    state = json.loads(STATE.read_text(encoding="utf-8")) if STATE.is_file() else {}
    stale = 0
    for sec in read_lock()["sections"]:
        current, recorded = digest(sec["anchors"]), state.get(sec["id"])
        status = "new" if recorded is None else ("stale" if recorded != current else "fresh")
        stale += status != "fresh"
        print(f"{status:5}  {sec['owner']:5}  {sec['id']}")
    print(f"\n{stale} section(s) not fresh")
    return 0


def cmd_digests(args: argparse.Namespace) -> int:
    state = json.loads(STATE.read_text(encoding="utf-8")) if STATE.is_file() else {}
    for sec in read_lock()["sections"]:
        if args.only and sec["id"] not in args.only:
            continue
        state[sec["id"]] = digest(sec["anchors"])
    STATE.parent.mkdir(parents=True, exist_ok=True)
    STATE.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    print(f"recorded {len(state)} digest(s) in {STATE}")
    return 0


def touched_lines(diff_text: str) -> dict[str, set[int]]:
    touched: dict[str, set[int]] = {}
    current = None
    for line in diff_text.splitlines():
        if line.startswith("+++ "):
            name = line[4:].strip()
            current = None if name == "/dev/null" else name.removeprefix("b/")
            if current:
                touched.setdefault(current, set())
            continue
        m = HUNK.match(line)
        if m and current:
            start, count = int(m.group(3)), int(m.group(4) or 1)
            touched[current].update(range(start, start + max(count, 1)))
    return touched


def cmd_gate(args: argparse.Namespace) -> int:
    if args.diff:
        diff_text = Path(args.diff).read_text(encoding="utf-8")
    else:
        diff_text = subprocess.run(
            ["git", "diff", "--unified=0", args.base, "--", args.path],
            check=True, capture_output=True, text=True).stdout
    by_file: dict[str, list[dict]] = {}
    for sec in read_lock()["sections"]:
        by_file.setdefault(sec["file"], []).append(sec)
    blocked = []
    for path, lines in touched_lines(diff_text).items():
        for sec in by_file.get(path, []):
            if sec["owner"] != "human":
                continue
            span = span_for(path, sec["heading"])
            if span is None:
                blocked.append((sec, "heading not found"))
            elif any(span[0] <= n <= span[1] for n in lines):
                blocked.append((sec, f"lines {span[0]}-{span[1]} touched"))
    for sec, why in blocked:
        print(f"BLOCKED {sec['id']}: {why} (owner: {sec.get('why', 'human')})")
    if blocked:
        print("\nRevert the patch inside these sections, or hand them to their owner.")
        return 1
    print("gate passed: no human-owned section touched")
    return 0


def main() -> int:
    ap = argparse.ArgumentParser(prog="docslock")
    sub = ap.add_subparsers(dest="cmd", required=True)
    sub.add_parser("check").set_defaults(fn=cmd_check)
    d = sub.add_parser("digests")
    d.add_argument("--only", nargs="*", default=[])
    d.set_defaults(fn=cmd_digests)
    g = sub.add_parser("gate")
    g.add_argument("--base", default="origin/main")
    g.add_argument("--path", default="docs")
    g.add_argument("--diff", default=None)
    g.set_defaults(fn=cmd_gate)
    args = ap.parse_args()
    return args.fn(args)


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

A minimal CI sequence looks like this. The --unified=0 diff keeps hunk headers small, which makes the line arithmetic easy to audit when the gate produces a false alarm.

git fetch origin main --depth=1
python docslock.py gate --base origin/main --path docs
python docslock.py check
Enter fullscreen mode Exit fullscreen mode

Illustrative output, hand-trimmed for readability:

BLOCKED quickstart.support: lines 41-58 touched (owner: support commitments need release-manager approval)

Revert the patch inside these sections, or hand them to their owner.
Enter fullscreen mode Exit fullscreen mode

4. Regenerate only the sections the lock allows

Keep one prompt file per model-owned section, and paste anchor contents into the context rather than hoping retrieval finds them. The instruction that earns its place is the escape hatch: if the anchors do not answer the question, return an empty diff instead of guessing. Empty diffs are cheap; a plausible sentence about a flag that no longer exists is not.

As of this writing, MonkeyCode offers free model access and a free server option, which is what makes a scheduled regeneration job practical for a side project rather than a budget line. I use the free model access as the drafting engine for model-owned sections only; the lock file, not the model, decides what is in scope. Check the current product terms for limits, since free tiers move, and do not assume any specific model or capacity. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

5. Close the loop with digests

After a merge that updates a section honestly, record the new anchor state with python docslock.py digests --only quickstart.flags. Recording every digest on every merge defeats the purpose, because the staleness report becomes a mirror of git log rather than a list of actionable work. Record per section, when that section was actually reviewed against its anchors.

The scheduled variant is a weekly job that runs check, regenerates only stale model-owned sections, and opens one pull request if the result is non-empty. Humans then see a review that is already limited to provable sections, and the gate protects the rest of the file from a copy-paste accident.

Limitations and who should skip this

Anchor digests prove that inputs changed, not that the prose stayed correct; a section can be freshly regenerated and still be wrong. The heading text is the join key between lock file and document, so renaming a heading silently breaks the mapping, which is why the gate treats a missing heading as a block rather than a warning. Pure deletions map to the boundary line of the hunk, so the gate prefers a false alarm over a missed overwrite, and someone will occasionally curse at it.

Skip this if your docs tree has fewer than about ten sections, since the lock file costs more to maintain than the drift it prevents. Skip it too if no CI job runs on documentation changes, or if nobody will actually accept ownership of the human sections; a lock that everyone bypasses is worse than no lock, because it looks like a control. And skip it for marketing copy, where the claim that matters is persuasive intent and no anchor in the repository can prove it.

If you adopt the lock file, the part worth sharing is your ownership table rather than the script, because that table is where the real judgement lives.

Top comments (0)