Skip to content

feat: add unlinked mentions as graph edges - #42

Open
aponcedeleonch wants to merge 6 commits into
mainfrom
feat/unlinked-mentions
Open

aponcedeleonch wants to merge 6 commits into
mainfrom
feat/unlinked-mentions

Conversation

@aponcedeleonch

@aponcedeleonch aponcedeleonch commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Summary

matlatl's graph only saw link syntax, so references written as text were invisible. A consumer repo (stacklok-enterprise-platform) enforces "docs must not reference agent context files" by post-processing graph.json edges, and keeps a bespoke regex scanner for the roughly half of references that are unlinked. This PR makes those mentions native, typed edges so that scanner can go away. Design and rationale are in ADR 0026.

Three kinds of mention:

  • path: a path-shaped token in prose, a code span or an HTML comment (Per `.claude/rules/metrics.md`, .claude/skills/foo/). Resolved doc-relative first, then relative to each parent directory of the doc, nearest first, ending at the repo root, all through the same root-containment guard as links. That covers paths written relative to an enclosing subproject (.claude/rules/x.md in enterprise/app/docs/). A directory resolves to its README/index, else its SKILL.md.

  • filename: a bare name like metrics.md, matched by basename.

  • invocation: a configured prefix plus a name (/panel-review), resolved through the existing front-matter name:/aliases: index and filtered by the rule's targets globs. The prefix and globs come from .matlatl.yml, so matlatl still ships no tool-specific knowledge:

    mentions:
      invocations:
        - prefix: "/"
          targets: ["**/.claude/skills/*/SKILL.md"]

When a file name or invocation matches several docs and every candidate lives under a dot-directory, the one in the nearest enclosing project wins, so a subproject's .claude/skills/x shadows the repo's for docs inside that subproject. If a plain doc is among the candidates (docs/architecture.md beside a skill's reference/architecture.md), nothing is picked.

Behavior

  • Always on. Path and filename mentions need no config; invocations need the block above.
  • Mentions join the navigational set, so they count in reachability, orphan/unreachable/under-linked/dead-end, hops, PageRank, HITS, betweenness, navigability, link suggestions, backlinks and trails. Information scent skips them, since their text is the target's own name.
  • Never scanned: fenced/indented code, raw HTML other than comments, link labels, reference definitions, autolinks, wikilinks, URLs, and glob patterns (*-overlay.md). A link stays a link with no double counting. HTML comments are scanned, since templates and maintainer notes point at files from them.
  • Each mention gets a health. valid: one doc. broken: a .md path or file name that names nothing, i.e. a stale reference. ambiguous: a shared name with no single nearest candidate. Any other unresolved token is dropped, and so is a mention of the doc itself.
  • A mention is never a finding, whatever its health, and only valid mentions become graph edges or count in summary.references. check can only get softer.
  • graph.json is schema v8: one "mention" edge per occurrence with kind, line, text and its health, beside the existing "reference" edges (now built from a link-only projection), plus a required summary.mentions. A broken edge's to is the missing doc. An ambiguous edge's to is the name as written, with a candidates array, so a common name like SKILL.md adds one edge, not one per file. findings.json and trails.json are unchanged.

Things reviewers should know

  • This changes output for existing users with no config change: graph.json v8, and fewer orphans / shifted scores wherever docs name each other in prose. Called out in the CHANGELOG.
  • Two fixtures named files in prose to describe them as unlinked (testdata/corpus/docs/island/*, testdata/dirlinks/adr/*). Those names are now mentions, so I reworded the prose to use titles instead of loosening assertions. The only golden change is the graph.json version, note and summary.mentions.
  • This repo's own llms.txt changed: our docs contain 24 mentions, all true references. task dogfood stays green.
  • matlatl serve doesn't read .matlatl.yml, so its analysis carries path and filename mentions but not invocations.
  • I fixed a stale "findings schema version 7" in the user guide while there (it's 8).

Tests

  • mdparser: code span and plain-text paths, trailing-slash directories, filenames split by goldmark at _, fragments, invocations, URL/path-prefixed non-matches, glob patterns, fenced and indented code, link syntax, reference definitions, non-comment raw HTML, block and inline HTML comments, headings.
  • reference: doc-relative, parent-directory and repo-root paths, root-absolute and content roots, directory to index and to SKILL.md, stale path and file name as broken, shared basename among plain docs vs among tool files vs mixed, nearest-scope invocation (deepest wins, repo root encloses all, no enclosing scope is ambiguous), a shared name that may be the origin, invocation via name: filtered by glob, unmatched invocation, unconfigured prefix, self-mention, dedup, MatchGlob/ValidateGlob.
  • graphmodel: mentions in the projection but not LinkProjectionOut, not scored for scent (mutation-checked), lift orphans.
  • config: valid rules, unknown-key notices, every hard-error shape.
  • application: mentions never become findings or broken-link placeholders, and unresolved ones stay out of the references count.
  • New golden graph-mentions.json over testdata/mentions/ (valid, broken, ambiguous with candidates, HTML comment), schema validation including a negative case for a mention edge without line, and CLI integration tests for graph --format json and for check --strict staying green with dangling mentions.

Checked against the consumer repo

Ran the branch on stacklok-enterprise-platform and compared with its regex scanner over docs/ (150 mentions of rules, skills and agents). This drove the resolution changes above.

  • First commit: 57 of 150 matched. The rest were project-relative paths, skill names shared between the root and a subproject, stale paths, HTML comments, and symlinked skills.
  • Now: 145 of 150, plus 7 real CLAUDE.md / SKILL.md mentions the regex misses. With that repo's .matlatlignore loosened (its atrium .agents/ skills and one excluded doc), 149 of 150. The one left is a regex false positive on a link split across two lines.
  • check on that repo is unchanged: 0 orphans, 0 unreachable, 0 ambiguous.

Pre-existing failures, same on main: eval/internal/nimbus (freeze.json mismatch), eval/internal/agentoutcome (needs an OCI runtime helper), and TestServeListenerOwnsListenerAndOnlyServesMCP, which is flaky on main (7/25 failures on HEAD vs 6/25 on this branch under -race).

Consumer follow-up

Enable mentions.invocations in .matlatl.yml (targets for .claude/skills and .agents/skills), stop ignoring atrium's .agents/, bump the matlatl pin, and have docs-cite-context read type: "mention" edges by health, reading candidates for ambiguous ones. Then delete scripts/context-lint/mention.go and the docs-mention-context step. Mentions only resolve to in-corpus docs, so .matlatlignore there must not exclude .claude/.

🤖 Generated with Claude Code

aponcedeleonch and others added 5 commits October 16, 2026 09:41
References written as text rather than link syntax now become graph
edges (ADR 0026): path tokens in prose or code spans, bare file names
that match exactly one document, and config-declared name-prefixed
invocations resolved through front-matter name/aliases and filtered by
target globs (.matlatl.yml mentions.invocations).

Mentions are always on and join the navigational set, so they count in
reachability, orphans, PageRank and every other metric. Code blocks,
raw HTML and text inside link syntax are never scanned, information
scent ignores mentions, and an unresolved or ambiguous mention is never
a finding, so check can only get softer.

graph.json bumps to schema v8: one "mention" edge per resolved
occurrence carrying kind, line and text, beside the "reference" edges,
plus a required summary.mentions count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unlinked mentions missed or hid most cross-references in a repo with
subprojects:

- A path written relative to the enclosing project
  (`.claude/rules/x.md` in a subproject's docs) never resolved: only
  the origin's directory and the repo root were tried. Paths now try
  each ancestor directory of the origin, nearest first.
- A file name or invocation shared by several documents was always
  dropped, even when one is clearly in scope. The candidate whose
  dot-directory scope most closely encloses the origin now wins, so a
  subproject's `.claude/skills/x` shadows the repository's.
- A mention naming a deleted or renamed file vanished, which is the
  stale reference a consumer most needs. A markdown-named path or file
  name that resolves to nothing is now a Broken mention, and a shared
  name with no nearest candidate is Ambiguous with every candidate.
  Both are emitted as graph.json mention edges with that health, and
  neither becomes a finding or a graph edge, so check stays softer-only.
- HTML comments were never scanned, though templates and maintainer
  notes point at files from them. Comments are now scanned; other raw
  HTML still is not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An ambiguous mention of a common name fanned out to one graph.json
edge per candidate: a bare SKILL.md produced about a hundred edges.
Emit one edge per occurrence instead, with `to` set to the name as
written and a `candidates` array listing what it may name.

Glob patterns in prose (`*-test-quality-overlay.md`, `docs/*.md`) were
read as stale file names. A token touching a glob character is now not
a mention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run against a real repo, the nearest-scope tie-break picked
`.claude/rules/frontdoor.md` for a prose `frontdoor.md` that meant the
design doc of that name, and a skill's reference `architecture.md`
over every docs/architecture.md: a plain document's scope rarely
encloses the mentioning doc, so a dot-directory file always won.
Apply the tie-break only when every candidate lives under a
dot-directory; any mix stays ambiguous.

Also drop an ambiguous mention whose candidates include its own
origin (most likely a self-reference, like `notes.md` inside a
notes.md), and keep unresolved mentions out of the references count,
which llms.txt and the report present as resolved references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TestIntegration_GraphJSONMentions still asserted the edge set from the
first mentions commit. The later commits keep stale mentions as Broken
edges, emit one Ambiguous edge per shared name, and extract mentions
from HTML comments, so graph.json now carries four more mention edges
(docs/nope.md, ghost.md, testing.md, and the comment's docs/sibling.md).
The test only runs under -tags=integration, which is why it went
unnoticed until CI. Update the expectation to the ADR 0026 behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
freeze.json hashes the Go sources and static binaries of everything
eval-supervisor and fake-opencode import. The mention work changes
mdparser, reference, graphmodel, corpus, application, emit and
fakeopencode, so the recorded hashes no longer match and the eval
scaffold job fails with "freeze.json does not match current Nimbus
tree".

Regenerated with the go.mod toolchain (go1.26.4) and the committed
runtime image IDs, so only the hashes of packages this branch touches
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant