A markdown predicate linter and backlink reconciler, shipped as an LSP server.
Lattice encodes link predicates in CommonMark title text and maintains backlinks in YAML frontmatter. The graph lives in the files themselves — no database, everything in git.
cargo install latticeOr download a prebuilt binary (Linux, macOS, Windows) from the latest release. Building from source needs Rust 1.95 or newer.
# Lint all markdown files in the current project (exit 1 on errors)
lattice lint
# Run as an LSP server (used by editors)
lattice serve
# Print the full configuration reference (every option, with defaults)
lattice config
# Print the version (with git commit and dirty state)
lattice --versionLattice is not shipped by nvim-lspconfig, so configure it directly.
On Neovim 0.11+:
vim.lsp.config.lattice = {
cmd = { "lattice", "serve" },
filetypes = { "markdown" },
root_markers = { ".lattice.toml", ".git" },
}
vim.lsp.enable("lattice")Diagnostics appear inline on open, change, and save. Lattice is diagnostic-first, but the server is a full markdown LSP: completion (paths, headings, predicates, reference labels, footnotes), hover, document and workspace symbols, references, rename, folding, document links, formatting, and go-to-definition/declaration/type/implementation plus call and type hierarchy over the predicate graph.
On Neovim older than 0.11, start it per buffer instead:
vim.api.nvim_create_autocmd("FileType", {
pattern = "markdown",
callback = function(args)
vim.lsp.start({
name = "lattice",
cmd = { "lattice", "serve" },
root_dir = vim.fs.root(args.buf, { ".lattice.toml", ".git" }),
})
end,
})Add to .githooks/pre-commit or .git/hooks/pre-commit:
#!/bin/sh
lattice lintCommits with broken links, unknown predicates, or missing backlinks will be rejected.
An optional .lattice.toml at the project root overrides defaults:
[predicates]
supersedes = "superseded_by"
implements = "implemented_by"
depends_on = "dependency_of"
amends = "amended_by"
blocks = "blocked_by"
references = "referenced_by"
[policy]
predicates = "optional" # or "required"
backlinks = true
bare_paths = "warn" # or "deny", "disabled"
stale_references = "warn" # or "hint", "deny", "disabled" — dangling `.md` references
# fragments = "github" # or "gitlab", "vscode"; omit to try all
# connectivity = "off" # or "no-orphans", "no-islands", "reachable"
# roots = ["README.md"] # entry points for "reachable"; default = root README
# Opt-in convention checks (off by default — they flag valid CommonMark, not defects):
# code_block_language = "disabled" # or "hint", "warn", "deny" — flag untagged code fences
# multiple_h1 = false # flag more than one H1 heading
# skipped_heading_level = false # flag a skipped heading level (e.g. H1 -> H3)
# image_empty_alt = false # flag images with empty alt text
# Cross-repo references: a `{Name}/path` citation (backtick/quoted/bare, not a
# link) is checked existence-only against an aliased directory — never read,
# indexed, or treated as a graph edge. An undefined alias, or one whose
# directory is absent, is exempt; a present directory with a missing file is a
# stale reference. Relative (sibling-checkout) values are preferred.
# [external]
# Catenary = "../Catenary"A path-shaped string that is deliberately not a live reference (a worked
example, a counterexample, a knowingly-dead link) can be excepted from the
stale_references / bare_paths lints in the document's own frontmatter,
keyed by the literal reference with a required reason as the value:
---
exceptions:
stale_references:
"tickets/acquire/DESIGN.md": "hypothetical path in the worked example"
"{Catenary}/old/layout.md": "pre-refactor path, kept for the changelog note"
bare_paths:
"README.md": "naming the file, deliberately not a link"
---Exceptions are reconciled like backlinks, not silenced: an entry that matches
no live diagnostic — its reference gone, or now resolving — is itself flagged
as an unused exception whose message echoes the stored reason, and an entry
with an empty reason is flagged too. Lattice flags, never auto-removes — the
reason is the surviving record of a vanished reference's intent. A {Name}/…
key flows through identically. An exception is never a graph edge and imposes
no backlink obligation.
Add this to your project's AGENTS or CLAUDE file:
Markdown links follow Lattice conventions: predicates are encoded in title text, e.g.
[Doc](doc.md "supersedes"). The predicate vocabulary is: supersedes, implements, depends_on, amends, blocks, references. Backlinks are maintained in YAML frontmatter.
AGPL-3.0-or-later. Commercial license available — contact Two Wells.