ADR-040
ID:ADR-040Status:accepted

Stale exclusions are two project-config lists, not one ignore list

Source:SPEC-136
claude/v0-37-0-review-vqpl41 View source
Branches 1
claude/v0-37-0-review-vqpl41 current accepted
main accepted
History 1
  1. 1580a8d
    Created (accepted)by bjornolofandersson

Context

The first real run of refrakt stale over this repository put two pages near the top of the report that are not stale in any sense worth acting on:

  • site/content/docs/migration/v0.14.0.md — a migration guide for a version already shipped, scoring 12 against packages/lumina/src/tokens.ts. It is a record of what was true at v0.14.0. Updating it as the tokens move would destroy the only thing it is for.
  • site/content/releases.md — generated by scripts/generate-changelog.mjs on every release, and the target of a described link from the contributing guide scoring 39. "Commits since the page last changed" here measures the release cadence, not a divergence between claim and code.

The first instinct was to special-case docs/migration/ and blog/ in the extractor. That is wrong for a reason that has nothing to do with these two pages: every project using refrakt has a different folder structure, and a tool that compiles in one repository's conventions is a tool built for that repository.

So the exclusion has to be project configuration. The open question is its shape — and SPEC-136, plus WORK-595's Notes, warn specifically against the obvious answer: "an opt-out is both the easy answer and the easy way to make the feature disappear one page at a time."

Options Considered

  1. Hard-coded directory names in the extractor — zero configuration, works for this repository on day one. Fails for every other project, and silently: a project with a changelog/ directory gets no exclusion and no way to ask for one.
  2. A frontmatter opt-out on the page (stale: false) — local to the page that is wrong, which is where the knowledge lives. But it is per-page, so excluding a class of pages is N edits, and the field carries no reason: the next reader sees only that someone wanted the finding gone.
  3. One generic exclude list in refrakt.config.json — project-shaped, one edit per class of page. Every noisy finding has a path, so every noisy finding has an entry, and nothing in the shape of the field distinguishes "this page is structurally the wrong kind of thing" from "this finding annoyed me on a Tuesday". The report converges on empty without anyone deciding that it should.
  4. Two lists named for the two structural mismatches — archival matched against the referring page, generated matched against the target file.

Decision

Option 4. refrakt.config.json grows an optional stale section:

"stale": {
  "archival":  ["site/content/docs/migration/**", "site/content/releases.md"],
  "generated": ["CHANGELOG.md", "site/content/releases.md"]
}

Both lists default to empty, so an unconfigured project excludes nothing. The content roots the report scans come from the sites the project already declares, so the scan needs no configuration at all in the common case.

Two further pieces make the split hold rather than decay into option 3:

  • Whatever the lists remove is counted in the report footer and in both the JSON and MCP payloads (excluded: { archivalPages, generatedTargets }). An exclusion list that hides its own effect is how the report goes quiet.
  • An archival page is skipped before extraction, not filtered after it, so it also stops answering the touching query. "What documents this file" must not answer with a record of what was once true.

Rationale

The two lists exclude opposite sides of an edge, for opposite reasons, and neither reason is "this finding is noisy":

  • archival names pages that are historical records — correct as written, and wrong to update when the code moves on. An edge out of one can only ever be a false positive, for the whole life of the page.
  • generated names targets that change by construction. The commit count against them is not noisy, it is meaningless: it measures a generator.

That is the property option 3 lacks. A single exclude list asks "should this be hidden?", which has an easy yes for anything irritating. Two named lists ask "which kind of structurally wrong thing is this?", which has no answer at all for a page that is merely noisy — and a page that is merely noisy has two real remedies that stay available: fix the extraction rule that found the edge, or narrow the page's subject with documents: in its frontmatter, which replaces every inferred edge for that page with a declared one.

This does not reopen WORK-595's "do not add a frontmatter opt-out yet". That note is about suppressing the prose class on a page the class got right; these two entries are pages the class got structurally wrong, which is the "genuinely miscategorised" case the same note reserves.

The glob subset is deliberately small — *, **, ?, and a trailing / for a directory — with no brace expansion and no negation. A pattern language that can express "everything except" invites the exclusion list to grow into a second content model.

Consequences

  • StaleConfig joins @refrakt-md/types, refrakt.config.schema.json and the generated configuration reference; the schema drift guard covers it.
  • refrakt stale now refuses rather than guessing when there is no refrakt.config.json — it reads the file to learn where content lives. "I could not look" and "nothing to report" stay different answers.
  • The MCP tool reads the same settings, so an agent's touching query and a maintainer's survey exclude the same things. No second source of truth.
  • Removing the site/content default means a project whose content lives elsewhere works without a flag, and a multi-site project is scanned whole.
  • The counted-exclusions footer is the thing to watch. If those numbers climb while the report shrinks, the lists have become option 3 by another name.

References

  • SPEC-136 — the staleness measure and its failure modes
  • WORK-595 — the Notes this decision answers
  • WORK-596 — the touching query an archival page must not answer
  • packages/content/src/edges/exclude.ts — the implementation