Path resolution in prose
Specification for resolving backtick-quoted repository paths in documentation prose. The Spec-to-test map names the tests behind this spec.
Path resolution purpose
Section titled “Path resolution purpose”A shard that names packages/merlon-docs-core/src/links/validate.ts in prose makes a claim a machine can check: that file is in the repository. When the code moves and the prose survives, the claim goes stale and no structural check notices, because a backtick path is not a link. This is the gap that lets a document describe a component that has been removed while merlon check still passes.
The probe resolves the path claims in the shards under the guide directories in compileOrder and in the standalone guides, and fails on the ones that resolve nowhere. A shard that only compile.scopeRoot reaches, such as a shared glossary, isn’t probed.
It cannot tell a path that went stale from one that was always illustrative, because the two are textually identical. So the discriminator is authorial: the document says which of its paths describe this repository. That is the whole design, and it is why the probe is opt-in.
What counts as a path claim
Section titled “What counts as a path claim”A backtick span is a claim when it carries a directory segment and a name.
| Span | Claim | Why |
|---|---|---|
src/links/validate.ts |
Yes | Directory segment plus a name |
docs/glossary/ |
Yes | Directory segment plus a name |
index.md |
No | Bare name — no path to anchor it to a root |
src/ |
No | Single segment, spoken of generically |
pnpm docs:compile |
No | Whitespace — a command line |
packages/*/README.md |
No | Glob — a pattern, not a file |
@merlon-docs/merlon-docs-core |
No | npm scope |
--warn-broken-links |
No | Flag |
lintShardLinks |
No | Code identifier |
Fenced code blocks are skipped entirely: a fence holds examples, not claims. A #fragment and a leading ./ are trimmed before resolution.
Which extensions can name a file is the same set the link validators use — every built-in code and data extension, plus lint.codeExtensions and lint.dataExtensions — widened with .md and .mdx. Prose asks the question links ask, whether the file exists, so both read one pair of knobs and a repository on an unlisted stack configures it once. Membership is a set lookup rather than a generated pattern, so a configured value cannot change how matching behaves.
Resolution ladder
Section titled “Resolution ladder”A claim resolves against the first root that contains it:
- the directory of the file making the claim,
- the scan root (
scan.root, else the invocation directory), - the docs root,
- each guide’s
compile.scopeRoot, - every entry in
lint.paths.searchRoots.
The ladder exists because a monorepo names paths relative to a package as readily as to the repository: a shard describing packages/merlon-docs-presets writes vale/MerlonDocs/, which is correct and resolves only with that package as a root.
Opting out
Section titled “Opting out”Two scopes, one marker — <!-- merlon-paths: illustrative -->:
| Placement | Scope | Use |
|---|---|---|
| Alone on its own line | The file | A document whose paths teach syntax rather than describe this repo |
| Trailing a content line | That line | One illustrative path in a document that otherwise makes real claims |
The line scope exists because a whole-file marker on a mostly-descriptive shard would exempt its real claims too. Prefer the narrower scope.
Two more cases are not authorial at all, and they are declared in config rather than in the prose, because they are properties of the repository rather than of a sentence.
| Knob | Matching | The claim it declares |
|---|---|---|
lint.paths.generated |
Prefix | Real here, absent in a clean checkout: build output, caches, vendor-managed installs |
lint.paths.vocabulary |
Exact | A name this repository documents without having: a roadmap tier, another project’s tree |
They match differently because they claim different things. Nothing under a generated prefix exists until something builds it, so the prefix covers the whole tree. A vocabulary entry is one name the document uses without instantiating, so matching stops at that name: docs/client/ is declared, and docs/client/onboarding.md is still an unresolved path. Neither matches a sibling that merely shares a prefix.
Path resolution config
Section titled “Path resolution config”{ "lint": { "paths": { "severity": "error", "searchRoots": ["packages/merlon-docs-presets"], "generated": ["docs/_build", ".caches"], "vocabulary": ["docs/client"] } }}| Knob | Default | Role |
|---|---|---|
lint.paths.severity |
"off" |
"off" skips the probe; "warn" reports and exits 0; "error" exits 1 |
lint.paths.searchRoots |
[] |
Extra resolution roots, relative to the scan root |
lint.paths.generated |
[] |
Prefixes absent in a clean checkout |
lint.paths.vocabulary |
[] |
Exact names this repository documents without having |
lint.codeExtensions |
[] |
Extra code extensions, shared with link validation |
lint.dataExtensions |
[] |
Extra data extensions, shared with link validation |
The default is off on purpose. Turning the probe on for a corpus written without it in mind produces a burst of findings that are correct as written, and a check nobody can get to green is a check nobody enables. A repository turns it on once, cleans up, and keeps it on.
Diagnostic shape
Section titled “Diagnostic shape”path: docs/features/overview.md:206: unresolved path "packages/merlon-docs-core/src/session/store.ts"The shards under the guide directories and the standalone guides are scanned. A shard under compile.scopeRoot outside every guide directory isn’t. Compiled output isn’t scanned either, since it is generated from the shards and would double-report every claim at a line nobody should edit.
Path resolution acceptance criteria
Section titled “Path resolution acceptance criteria”- A claim with a directory segment that resolves against no root fails
merlon checkatpath:line, in a shard under a guide directory or in a standalone guide - A bare name (
index.md), a single segment (src/), a command, a glob, a flag, an npm scope and a code identifier are not claims - A claim inside a fenced code block is not reported
- A claim resolves against the file’s own directory, the scan root, the docs root, a guide
scopeRoot, or alint.paths.searchRootsentry lint.paths.generatedsuppresses a prefix match but not a sibling sharing that prefixlint.paths.vocabularysuppresses the name itself but not a path beneath it- The marker alone on a line exempts the file; trailing a content line it exempts that line only
lint.paths.severitydefaults tooff;warnreports with apath-warn:prefix and exits 0- An extension added by
lint.codeExtensionsorlint.dataExtensionsmakes a span a claim; documentation extensions stay claimable regardless
Path resolution related
Section titled “Path resolution related”- Built-in link validation — the same question for authored links
- Documentation coverage scan — which files are in scope
- Commands reference