| name | nodex |
| version | 0.22.0 |
| description | JSON-first CLI for markdown document graphs governed by a root `nodex.toml`. Query supersession / backlinks / orphans / stale, validate frontmatter and body immutability (project-wide or diff-aware via `--since`), scaffold / rename / migrate documents, compute trust and similarity, diff graphs between git refs, analyse merge impact (what breaks if I merge this), repoint id references with retarget, extract `[[annotations]]` body markers with `--min-count` and `--with-frontmatter` enrichment, report graph snapshot freshness with `status`, and export schema / enums / rules / envelope-schema (`--inline-refs` for self-contained schemas) / config / commands for typed codegen. |
| when_to_use | backlinks, supersedes, orphan, stale, frontmatter / body immutability, schema check / lint docs, list nodes by kind / status / tag, reverse path-to-node lookup, scaffold / migrate / rename markdown, impact analysis / what breaks if I merge, retarget / repoint references after supersession, trust score, low trust, doc similarity, graph diff, graph snapshot freshness / `nodex status` / stale graph.json, export schema / enums / rules / envelope-schema / config / commands, codegen / typed client / API drift, query dependents, query annotations, body-line vocabulary check, `check --since <ref>`, write-time validation / `check --content <path>=-` / gate a proposed edit before writing / batch multi-file gate, typed violation `details` for auto-fix, `schema.require_explicit` strict presence, `[search.weights]` ranking, per-rule `kinds` filter |
| argument-hint | <subcommand> [args] |
| allowed-tools | Bash(nodex *) |
nodex — markdown document graph CLI
JSON-first. Every command (bar clap's --help / help / --version) emits one of:
{"ok": true, "data": {...}, "warnings": [{"code": "...", "message": "..."}]}
{"ok": false, "error": {"code": "CODE", "message": "..."}}
List queries put items in data as {"items": [...], "total": N}. On plain listings (nodes, search, backlinks, orphans, stale, components) total counts every match and a --limit cap announces itself via returned (omitted otherwise), so a capped response never reads as complete; selection queries (trust --top/--bottom, similar, recent) select in core, so their total is the selection size itself.
Exit codes: 0 ok, 1 check found Error-severity violations, 2 every error envelope (config, parse, IO, version, CLI-arg, runtime).
Global flags: --pretty (indented JSON), -C <dir> (run against another project root), --check-version <semver-req> (refuse to run unless the binary version satisfies the requirement).
Version pinning: projects can also pin the binary via [meta] nodex_version = "..." in nodex.toml — reads warn, document-writing commands refuse with VERSION_MISMATCH; only the --check-version flag hard-gates every command.
Always run nodex build first for any query — queries read the indexed _index/graph.json; without one they fail with GRAPH_MISSING (exit 2). A snapshot that no longer matches the working tree (files added/removed, graph-shaping config edited) still serves the query but rides one envelope warning naming the divergence — nodex status is the full content probe. Build is incremental and cheap to re-run. (check and scaffold build their view live from the working tree and need no prior build.)
Body links: standard markdown ([text](path.md)) by default. Wikilinks ([[id]]) opt-in via parser.wikilink_enabled = true; arbitrary syntaxes via parser.link_patterns (each block needs a pattern with exactly one capture group and a relation — any name except the built-ins with code-fixed resolution, rejected at load: covers (path-only) and supersedes / implements / related (id-resolved) are declared via their frontmatter fields only; references stays legal). Dot-prefixed paths (.draft.md, .archive/, .claude/) skipped unless an include pattern literally names the dotted segment (e.g. .claude/**/*.md); node_modules / __pycache__ / target / .git / .venv are pruned by default (scope.prune_dirs, configurable — empty list prunes nothing), and dot-prefixed trees (.git / .venv) stay caught by the hidden-path guard regardless.
Build
nodex build
nodex build --full
BuildResult envelope: {nodes, edges, annotations, body_line_matches, cached, parsed, duration_ms}, plus conditionally_excluded (paths a [[scope.conditional_exclude]] rule dropped) and parse_failures ({path, message, content_hash} per in-scope document that failed to parse and has no node) when non-empty. A whole-document failure (unparseable YAML, non-mapping frontmatter, an opened-but-unclosed --- fence) never halts the build — the rest of the project still indexes — but the drop is structural data the next check reds via the parse_failure rule. A single wrong-typed built-in field (bad date, bad bool, non-string scalar) does NOT drop the document: the node stays, the field reads as absent, and check flags it via field_parse.
Status
nodex status
data.state ∈ absent | unreadable | schema_mismatch | outdated | current. outdated carries the exact divergence {config_changed, added_paths, removed_paths, changed_paths} (content probed against each node's recorded content_hash; config_changed is keyed on the parse+scan surface — scope, output, parser, identity, [[annotations]], rules.body_line, statuses.initial — never trust/similarity/detection tuning). unbuildable_paths lists the snapshot's recorded parse failures — covered, never staleness; fix the document, check reds it. snapshot_nodex_version names the producing binary: a binary upgrade flags existing snapshots outdated until one rebuild. CI gates on data.state (e.g. jq -e '.data.state == "current"'); schema_mismatch means nodex build --full.
Query
All read operations live under query.
nodex query search <kw> [--status x,y] [--limit N]
nodex query nodes [--kind K1,K2] [--status S1,S2] [--tag T1,T2 --all-tags] [--where F=V ...] [--limit N] [--fields id,title,...]
nodex query backlinks <id> [--limit N]
nodex query chain <id>
nodex query node <id> [--with-body]
nodex query node --path <file>
nodex query covered-by <path>
nodex query orphans [--limit N]
nodex query stale [--limit N]
nodex query issues
nodex query trust <id>
nodex query trust --bottom N [--kind K] [--status S] [--below S]
nodex query trust --top N [--kind K] [--status S] [--below S]
nodex query similar --id <id> [--limit N] [--min-score S]
nodex query similar --title "<t>" [--kind <k>] [--tags a,b] [--parent-dir <dir>] [--limit N] [--min-score S]
nodex query recent [--days N --field F --kind K --since YYYY-MM-DD --limit N]
nodex query components [--limit N]
nodex query neighborhood <id> [--depth N]
nodex query dependents <id> [--depth N --relations a,b]
nodex query annotations [--name <block-name>] [--with-frontmatter f1,f2,...] [--min-count N]
query issues always carries skipped_rules: [{rule_id, reason}] — silent skips are forbidden. unresolved_edges entries carry a typed cause: missing | target_unparsed | excluded_from_scope | id_not_found | escapes_source | absolute plus the severity (error | warning | info) and policy_name the ordered [[detection.unresolved_policy]] table assigned (policy_name absent = the built-in warning fallthrough), so consumers branch on typed fields instead of re-deriving the project's policy. Severity planes: error rows register check rules unresolved_reference/<name> (matching edges fail nodex check, counted as violation_unresolved_reference/<name>); warning edges count in summary.total under unresolved_edge; info edges are reported out of total under their row's name. Row globs match the link's normalized root-relative resolution candidates, never the raw authored target. Declaring the table replaces the default single row {name = "excluded_target", cause = "excluded_from_scope", severity = "info"} — re-declare it to keep it.
Diff
nodex diff <ref-a> <ref-b>
Output: added_nodes, removed_nodes, added_edges, removed_edges, status_transitions: [{id, from, to}], field_changes: [{id, field, before, after}], added_annotations, removed_annotations. Both snapshots are graphed under a single lens — the after ref's nodex.toml (check --since: the working tree's); the before ref supplies content only.
Impact
nodex impact <ref-a> <ref-b>
nodex impact <ref-a> <ref-b> --depth N --relations implements,supersedes
Output: {diff, impacted, likely_breaking}. diff is the full nodex diff envelope; impacted: [{id, change: removed|modified, dependents: [{id, title, kind, status, path, hops, via}]}] pairs each changed node with its dependents — a modified node's transitive dependents in the after graph, a removed node's direct referrers that still point at it and now dangle (references the same change repointed elsewhere are correctly absent). Each dependent carries inline node metadata plus the witness chain in via — same shape as query dependents. likely_breaking: [id, …] lists removed nodes whose referrers now dangle — the sharpest "this will break" signal. Added nodes and changes that affect nobody are omitted from impacted (the full delta stays in diff). --depth bounds the dependency walk, --relations restricts which edges it follows (validated against the project vocabulary).
Authoring
nodex scaffold --kind <k> --title "<t>"
nodex scaffold --kind <k> --title "<t>" --id <explicit-id>
nodex scaffold --kind <k> --title "<t>" --path docs/foo.md
nodex scaffold --kind <k> --title "<t>" --dry-run
nodex scaffold --kind <k> --title "<t>" --force
nodex scaffold --kind <k> --title "<t>" --path docs/foo.md \
--field 'supersedes=[old-id]' --field created=2026-06-12 --body -
nodex migrate
nodex migrate --apply
nodex rename <old-path> <new-path>
nodex retarget <old-id> <new-id>
scaffold emits an envelope-level warning when a near-duplicate doc exists. rename envelope includes id_stability: {type: already_anchored | unchanged | anchored | bare_no_frontmatter} — when the path change would shift a path-derived id, the previous id is auto-anchored into the moved file's frontmatter so other docs' cross-references stay valid.
retarget rewrites every reference to <old-id> so it names <new-id>: the id-valued frontmatter relation fields (supersedes / implements / related / superseded_by — the first three accept string or array; superseded_by is a single-id scalar, so superseded_by: [id] is a field_parse error) and body id references ([[wikilinks]], custom link_patterns). Matching is by exact id — an id that merely appears in prose is never touched — and the successor document (<new-id>) is skipped so its own supersedes: [<old-id>] never becomes a self-edge. Both ids must exist; a reference-unsafe successor id (trim-unstable / wikilink metacharacters) is refused, and a doc locked by body_immutable (or a frontmatter_immutable block covering a relation field) is skipped with a warning. Envelope: RetargetResult {old_id, new_id, references_updated, total_updated}. Pairs with lifecycle supersede: supersede sets the lifecycle state, retarget moves everyone's forward references onto the successor. Standard markdown path links ([text](old.md)) are path-bound, not id references — they keep resolving to the now-superseded file and are not rewritten; repoint them by hand (or rename the file when the path itself should change).
Lifecycle
nodex lifecycle review <id>
nodex lifecycle set <id> --status <status>
nodex lifecycle supersede <id> --to <new-id>
supersede is its own action because it carries a structural payload (successor + supersession-DAG check); every other status transition goes through set, whose target is validated against the project's vocabulary at the write seam. set refuses a status a cross_field rule governs while the required field is absent (e.g. superseded needs superseded_by — use supersede), so it never writes a doc check would reject. Terminal statuses block further transitions except review; set can never un-terminalize a doc.
Validation
nodex check
nodex check --severity error|warning
nodex check --since <git-ref>
nodex check --content docs/a.md=-
nodex check --content docs/a.md=FILE
nodex check --content docs/a.md=- --content docs/b.md=b.md
--content takes PATH=SOURCE pairs and is repeatable. SOURCE is - (stdin) or a file path resolved against the invoking directory, never -C <dir> — with -C, pass an absolute file path or use stdin. At most one SOURCE may be -; a target PATH may appear once.
--severity is an exact-match display filter: --severity warning shows only warnings, hides every error, and the exit code follows the shown set (exit 0 despite errors) — the suppression is announced as an envelope warning. Gate on errors with --severity error or no filter. It composes with --content.
check --content PATH=SOURCE... is the write-time gate: every proposal is overlaid into ONE graph build, so a reference one proposal authors resolves against another proposal in the same batch (a supersede that rewrites N referrers gates as one atomic edit — one-at-a-time would report a still-dangling link). It diffs against the current on-disk state and runs every rule (schema, cross-field, immutability). The reported set is the exact before/after delta — a violation also present in the pre-overlay report is pre-existing and never refuses the proposal; one the overlay introduces (on any node, or the node-less parse_failure of a proposal that destroys its own node) reds the gate at exit 1. So an agent validates an edit through nodex's own engine before the write lands, is never blocked by someone else's broken document, and an unparseable proposal fails through the same uniform rule path as every other finding. A path need not exist yet; a path inside the project root but outside the scope globs is vacuously clean (and the run warns it validated nothing), while a path that escapes the root is refused with PATH_ESCAPES_ROOT; both builds are read-only (no cache.json write). Mutually exclusive with --since. Caveat: required_field never fires for engine-derived fields — a proposal missing id / status (or a stem-derived title) still passes because the build infers them (and listing those fields in schema.required is rejected at load); a clean gate verdict does not certify those keys are spelled out unless schema.require_explicit is configured (see below).
CheckResult envelope: {violations: [...], skipped_rules: [...], total, has_errors, proposals?, standing?}. In --content mode, proposals carries one {path, in_scope, has_path_errors} verdict per pair (in invocation order) — so a clean or out-of-scope proposal is reported as checked, never a silent green; has_path_errors is scoped to violations attributed to that proposal's own path (the run-wide gate verdict is the top-level has_errors), and the introduced violations live once in violations, each keyed by its path. standing (also --content only) is the proposed nodes' warning-severity violations in the proposed state — the absolute view: violations is the introduced delta, so a node's pre-existing housekeeping warnings (stale_review, git_drift) cancel out of it; read them from standing instead of running a second project-wide check. Every violation also carries a typed details: {type, ...} — a stable machine category (the type discriminator) plus structured params (offending field, expected set, failing value) — so an agent branches on details.type and auto-proposes a fix instead of parsing the human message (which is a single-source rendering of the same details). Built-in rule_ids: parse_failure (node-less, one per dropped in-scope document), field_parse (one per wrong-typed built-in field on a present node), required_field, field_type, field_enum, cross_field, unknown_field (strict mode only), explicit_field (only when schema.require_explicit is set), stale_review, git_drift, filename_pattern, sequential_numbering, unique_numbering, acyclic_relation (always on; relation set is config-driven via rules.acyclic_relations, default ["implements"]). Config-driven rule_ids: body_line/<name>, body_immutable/<name>, frontmatter_immutable/<name>.
[schema].mode = "strict" rejects any frontmatter key that is neither built-in nor declared in types / enums / required / cross_field. Catches typos (relatd: → fail). Default lenient. schema.enums values are string arrays — a non-string member (e.g. a bare TOML integer) is a load-time CONFIG_ERROR; quote numeric vocabulary (["1","2"]).
[[schema.cross_field]] predicates support four forms: when = "field=value" (equality), when = "field in {v1,v2,v3}" (membership), when = "field exists" (presence), when = "field not_exists" (absence). Scalar predicates (=, in) are rejected on collection fields (tags, covers, …) at load; use exists/not_exists for collection presence.
Diff-aware rule families (require --since or rules.immutable_baseline)
rules.immutable_baseline = "<git-ref>" (e.g. "origin/main") — the default ref check diffs against when --since is omitted, so frontmatter_immutable / body_immutable are enforced on plain nodex check. Unlike --since it never narrows the reported violations to changed nodes — it only supplies the before-state the immutability rules need. Outside a git work tree the run proceeds with a warning (the rules are inert for that run).
[[rules.frontmatter_immutable]] — freezes declared fields once a doc is ALREADY terminal (gated on the diff's before status, so the write that first makes a doc terminal is allowed; only later edits lock). Per-block config:
[[rules.frontmatter_immutable]]
name = "identity"
fields = ["kind", "superseded_by"]
id is rejected at load (structurally immutable — a changed id is a different node); status is accepted and enforced via the status-transition stream. Violations carry rule_id = "frontmatter_immutable/<name>"; names must be unique across blocks.
[[rules.body_immutable]] — body locks. Two modes × two triggers:
[[rules.body_immutable]]
name = "adr-decisions"
mode = "frozen"
trigger = "creation"
kinds = ["adr"]
[[rules.body_immutable]]
name = "runbook-history"
mode = "append_only"
kinds = ["runbook"]
trigger = "terminal" (default) uses the same already-terminal boundary as frontmatter_immutable; trigger = "creation" freezes the body as soon as a prior committed snapshot exists — the creating commit is structurally exempt, and frontmatter (including status) stays editable for supersession. Violations carry rule_id = "body_immutable/<name>". Driven by per-node body fingerprints (SHA-256 of body + per-line vector) computed at build time — no file re-reads at check time.
Both families self-report as skipped_rules (with reason) when no diff is available (--since omitted and no resolvable rules.immutable_baseline). Silent non-fires are forbidden.
Vocabulary rule families (always active)
[[rules.body_line]] — per-line vocabulary conformance. Each block declares a regex with named captures; every match outside a code block must carry capture values from declared enums. One violation per failed (line, capture). Lines that don't match the pattern are silently ignored. Rule_id body_line/<name>.
Kind filter (body_immutable / frontmatter_immutable / body_line / [[annotations]])
The content-scoped per-block families — body_immutable, frontmatter_immutable, body_line — and [[annotations]] accept an optional kinds: ["..."] list. Empty = no restriction; otherwise the rule fires only on nodes whose kind appears in the list. Every entry must be in kinds.allowed; Config::load rejects typos so a silent never-fire is impossible. ([[rules.naming]] is path-scoped instead — it carries glob, not kinds.)
Export
nodex export schema
nodex export enums
nodex export rules
nodex export envelope-schema
nodex export envelope-schema --inline-refs
nodex export config
nodex export commands
nodex export diagnostics
External lints consume these instead of re-parsing nodex.toml. envelope-schema, commands, and diagnostics run without nodex.toml (project-independent) so they can be invoked anywhere; the version field in their output is the SoT for downstream codegen drift gates. export config shows post-default resolved values (an omitted scope.include reads ["**/*.md"]) plus the code-level fallbacks identity.fallback_kind / identity.fallback_id_template — derive artifact paths from data.output.dir instead of hardcoding _index. export commands entries carry {path, schema} plus modes / positionals only when applicable (omitted otherwise): schema is the per_command envelope-schema key, modes names flag-selected alternate shapes (query.trust-list behind --bottom/--top). Every release publishes nodex-envelope-schema-v<ver>.json and nodex-commands-v<ver>.json as pinnable assets, and release CI fails any envelope shape change that lacks the promised minor-or-major bump.
export rules RuleManifestEntry: {id, source: builtin|config, severity, description, diff_aware, params}. params carries the rule's configured values (regex, kinds, mode, enums, thresholds, …) — schema is per-rule, kept free-form so adding a new built-in doesn't reshape the manifest.
Report / Init
nodex report
nodex report --format md|json
nodex init
When authoring nodex.toml inline instead of via init, the gotchas —
each is a real load-time rejection: schema.types values are string | integer | bool | date only and collection fields (tags, related, …)
take NO type entry; schema.required takes authored fields only (id /
title / kind / status / orphan_ok are parser-resolved and refused);
default_limit sits under [similarity], not [similarity.weights];
parser.extensions entries carry the leading dot; annotations patterns
need a named capture matching key; narrowing statuses.allowed means
setting statuses.terminal too (every default terminal must stay
allowed). A worked example lives in minimal-config.toml next to this
file — read it before writing a config by hand.
With wikilink_enabled = true, a [[...]]-shaped annotation marker is ALSO
parsed as a wikilink and surfaces as an unresolved edge in query issues —
use a non-bracket marker syntax if you want annotations only.
Error codes
Stable across releases; matched via error.code in the envelope, never by message string.
IO_ERROR, PARSE_ERROR, CONFIG_ERROR, CYCLE_DETECTED, DUPLICATE_ID, INVALID_TRANSITION, NOT_FOUND, GRAPH_MISSING, ALREADY_EXISTS, PATH_ESCAPES_ROOT, CONTENT_VIOLATIONS, VERSION_MISMATCH, GIT_ERROR, INVALID_ARGUMENT, INTERNAL_ERROR.
GRAPH_MISSING = a query ran with no graph.json snapshot — run nodex build.
Workflows
Cleanup triage — no single "cleanup" verb; compose the primitives:
query issues (what's broken) → check --severity error (what blocks) →
query trust --bottom N --status active (what to distrust — the review
queue; terminal docs score near zero by design and would drown it), then
act with lifecycle set --status archived, retarget, or rename.
Before authoring
nodex build
nodex query similar --title "<draft>" [--kind <k>]
nodex scaffold --kind <k> --title "<t>"
nodex build
Before a PR
nodex build
nodex check --severity error
nodex query issues
PR diff gate
nodex check --since origin/main
nodex diff origin/main HEAD
Replacing a doc
nodex lifecycle supersede <old-id> --to <new-id>
External tooling sync
nodex export enums | jq .data > tools/lint/enums.json
nodex export schema | jq .data > tools/lint/frontmatter.schema.json
nodex export rules | jq .data.rules > tools/lint/rules.json
nodex export envelope-schema | jq '.data.per_command["query.issues"]' > tools/codegen/query-issues.schema.json
Impact analysis before refactor
nodex query dependents <id> --depth 3 --relations implements,supersedes
Returns every doc that transitively depends on <id> with shortest-path witness chains.
Body-marker triage
nodex query annotations --name promotes
nodex query annotations --name promotes --min-count 3
nodex query annotations --name promotes --with-frontmatter created,owner,tags
Pre-graph identifiers (TODO topics, promotion candidates, open research questions) — markers that intentionally do not resolve to a node. --with-frontmatter accepts any built-in or project-declared field; unknown names are rejected at load with CONFIG_ERROR. --min-count is the natural primitive for "show me only keys that appear N+ times" without downstream filtering.