DocDag

module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 27, 2026 License: Apache-2.0

README

DocDag

CI Release

DocDag reads a directory of Markdown documents with YAML frontmatter, extracts a typed directed graph from it, enforces DAG invariants on that graph, and answers queries about it. It ships one preset, adr, for Architecture Decision Records.

The model

DocDag keeps two layers apart:

  • Constraint layer — typed edges declared in frontmatter (supersedes:, depends-on:) plus edges derived from configured field patterns. Only these carry invariants: acyclicity and rules.
  • Reference layer — untyped links found in bodies: [[wikilink]], [[wikilink|alias]] and relative Markdown links to other managed documents. A link joins the layer only when its whole target is identifier-shaped, or, for a Markdown link, names a managed file; a link inside a fenced code block or an inline code span is an example and is skipped. Surfaced by --include-refs and stats; never part of a constraint, and unvalidated unless references.dangling asks for it.

A document's identity is its digit run, so 339, ADR-339, 000339 and 0339-use-postgres.md all name the same node, displayed zero-padded to id_width. Renaming a file's title suffix does not change identity; two files that normalize to the same identifier are an id_collision error.

Status is a projection of the graph, not an independent fact. With the ADR preset, an inbound supersedes edge and a status other than superseded is an error (status_drift); superseded with nothing superseding it is a warning (superseded_orphan). A document is binding when its status is accepted and no document supersedes it.

Why

Decision records rot in ways review does not catch: a decision superseded twice with the status never updated, a supersession cycle, a supersedes: 0042 pointing at a file nobody wrote. Those are graph properties, so a graph check can enforce them, and docdag validate exits 1 on any error, in one CI line. An existing MADR repository needs no edits first: status: superseded by 0003 becomes a derived supersedes edge, so the invariants hold as the files already are.

Install

In CI, use the composite action — see Continuous integration. Locally, install a pinned version:

go install github.com/Kaikei-e/DocDag/cmd/docdag@v0.2.0

This installs docdag into $(go env GOPATH)/bin. Prebuilt binaries for tagged versions, and the checksums.txt that covers them, are attached to the repository's Releases page.

Quickstart

No configuration is needed. From the root of a repository whose decisions live in docs/decisions:

$ docdag validate
docs/decisions/0002-store-thumbnails-on-the-local-disk.md:3: WARN unstructured_supersedes 0002: supersedes edge 0003 -> 0002 comes from a field value; declare it in frontmatter
  fix: declare supersedes: 0002 in 0003
OK: 4 docs, 3 typed edges, no cycles

$ docdag resolve 0002          # what replaced this decision?
0003

$ docdag query --binding       # what is in force right now?
0001
0003

$ docdag query 0001 --ancestors --edge depends-on   # what rests on this decision?
0003
0004

DocDag looks in docs/adr, doc/adr, docs/decisions, docs/ADR, adr — the first that exists and holds a file named NNNN.md or NNNN-kebab-title.md, 3 to 6 digits; --dir overrides it.

The corpus above ships in this repository as testdata/fixtures/ok-madr, next to one corpus per failure mode. From a checkout, docdag validate --dir testdata/fixtures/status-drift prints testdata/fixtures/status-drift/0001-serve-images-from-the-application-server.md:3: ERROR status_drift 0001: has inbound supersedes but status is not superseded and exits 1.

Every finding names a file and, wherever a frontmatter key carries the fault, the line of that key:

<path>:<line>: <SEVERITY> <rule> <id>: <detail>

:<line> is dropped when the position is unknown. Findings sort by severity, path, line, rule and identifier, so a report reads in file order and diffs cleanly. Besides the configured rules, validate reports cardinality, cycle, dangling_ref, dangling_reference, derived_conflict, empty_edge, id_collision, immutable_violation, invalid_frontmatter, invalid_ref, inverse_mismatch, missing_frontmatter, unknown_status and unstructured_supersedes.

Commands

Global flags: --dir <path>, --config <path> and --format text|json, which every command answers in; validate also answers in github and rdjson, context in md, and export replaces the format flag with its own mermaid|dot|json.

Command What it prints Notable failures
docdag validate [--touching <path>]... [--immutable-since <rev>] [--format text|json|github|rdjson] one line per finding, each followed by an indented fix: where there is a remedy, then OK: N docs, M typed edges, no cycles exit 1 if any finding is an error, exit 3 if --immutable-since is given outside a git repository or without git
docdag resolve <ref> [--fields <list>] the current successor(s) of a reference, one per line, or the document itself when nothing supersedes it exit 1 on an unknown reference or a supersedes cycle
docdag query <ref> [--ancestors|--descendants] [--edge <type>] [--include-refs] [--fields <list>] the reachable set over typed edges, descendants by default; reference-layer hits are suffixed (reference) exit 1 unknown reference, exit 2 unknown edge type or conflicting flags
docdag query --binding [--fields <list>] every binding document exit 2 if combined with a walk flag
docdag context <ref> [--depth N] [--edge <type>]... [--section <heading>] [--budget N] [--all] the document, what it resolves to and its neighbourhood, each quoting one section exit 1 unknown reference, exit 2 unknown edge type
docdag export [--format mermaid|dot|json] [--include-refs] [--connected] [--edge <type>]... [--out PATH] the typed graph; mermaid on stdout by default, - also means stdout exit 2 on an unknown edge type, exit 3 if the output file cannot be written
docdag stats document count, binding count, orphan rate, edge count per type, supersedes chain-depth distribution, top-10 reference in-degree —
docdag new <title> [--id <ref>] [--supersedes <ref>]... [--depends-on <ref>]... [--dry-run] the path of the created document, or the plan under --dry-run exit 1 on an unknown reference or a claimed identifier, exit 3 on a write error

docdag export --edge <type> keeps only the named edge types, repeat it to keep several, and --connected drops every document no remaining typed edge touches — the two compose, so --connected --edge supersedes draws the supersession chains alone.

docdag new takes the next free identifier, writes <id>-<kebab-title>.md from the template with status: proposed and today's date, and rewrites only the status: value of each superseded document: bodies and line endings stay byte-identical, and every rewrite is computed before any file is touched. The name comes from the filename: template, so a corpus of bare NNNNNN.md files configures filename: "{id}.md" and keeps that shape.

--dry-run runs every computation, writes nothing and prints the plan, as create <id> <path> followed by one rewrite <path> status: superseded line each, or as JSON under --format json. It exits exactly as the real run would, so it is a safe way to see what a creation would cost. The plan names its files the way a finding does, relative to the working directory, and its JSON always carries exists — true when the corpus already holds the document and nothing would be written, where the text form says exists instead of create.

--id <ref> creates the document under a chosen identifier instead of the next free one. If that identifier already names a document with the same title, new prints its path and writes nothing, so re-running an agent's command is harmless; a different title under that identifier is an error. new also refuses to run at all while the corpus carries an id_collision.

Exit codes: 0 success (warnings allowed), 1 domain failure, 2 usage error, 3 I/O or config error — including "no documents directory found", so a repository without one needs --dir.

validate output

A finding with a mechanical remedy carries one, indented under the finding in text and as a fix key in json:

docs/decisions/0002-ship-logs.md:4: ERROR dangling_ref 0002: supersedes reference "0009" does not name a document
  fix: did you mean 0001?

--format json writes one object, versioned so a consumer can tell a shape change from a content change. location is the primary position and related names the other files a finding involves — the peers of a collision, the rest of a cycle:

{
  "schema_version": 1,
  "findings": [
    {
      "severity": "error",
      "rule": "status_drift",
      "id": "0001",
      "detail": "has inbound supersedes but status is not superseded",
      "location": { "path": "docs/decisions/0001-serve-images-from-the-app.md", "line": 3 },
      "fix": "set status: superseded in docs/decisions/0001-serve-images-from-the-app.md"
    }
  ],
  "summary": { "documents": 2, "edges": 1, "errors": 1, "warnings": 0, "cycles": 0 }
}

--format github writes one GitHub Actions workflow command per finding, followed by the same summary line as text:

::error file=docs/decisions/0001-serve-images-from-the-app.md,line=3,title=status_drift::0001: has inbound supersedes but status is not superseded

A workflow step renders at most ten annotations, so a corpus with more findings than that needs a second --format text run written into $GITHUB_STEP_SUMMARY to show the rest.

--format rdjson writes the reviewdog diagnostic format as a single DiagnosticResult, for reviewdog -f=rdjson. It carries neither a summary line nor a remedy: the format has no field for one.

docdag.yaml

Optional, read from the repository root or from --config <path>, merged over the preset; flags win over both. The file below is the ADR preset in full — what DocDag applies with no configuration:

preset: adr
dir: docs/decisions            # default: discovered, see above
id_width: 4
status_field: status
status_values: [proposed, accepted, rejected, deprecated, superseded, withdrawn]
filename: "{id}-{slug}.md"     # what `docdag new` names a document

edges:
  - name: supersedes
    key: supersedes            # frontmatter key holding the references
    acyclic: true              # subject to the cycle check
    direction: forward         # the containing document is the edge source
  - name: depends-on
    key: depends-on
    acyclic: true
    direction: forward

derived_edges:
  - field: status
    pattern: '(?i)^superseded[\s-]+by[\s-]+(\S+)'   # capture group 1 is the reference
    edge: supersedes
    direction: reverse         # the referenced document is the edge source

rules:
  - name: status_drift
    severity: error
    when:
      inbound: supersedes
      attr:
        status: { not: superseded }
    message: "has inbound supersedes but status is not superseded"
  - name: superseded_orphan
    severity: warn
    when:
      not_inbound: supersedes
      attr:
        status: { eq: superseded }
    message: "status is superseded but no document supersedes it"

Every other key is optional, and off unless the file says otherwise:

acyclic_union: true            # also report a cycle that only the union of the acyclic types closes

references:                    # reference-layer validation; without it the layer is unvalidated
  dangling: error              # off (default) | warn | error
  pattern: '^(?i)(?:adr-?)?(\d{3,6})$'   # the default: what an identifier-shaped target looks like
  scan: [body, frontmatter]    # default [body]; frontmatter scans string scalars and list items

structural:                    # raise a built-in check; lowering one is a configuration error
  missing_frontmatter: error

edges:
  - name: amends
    key: amends
    acyclic: true
    direction: forward
    inverse: amended_by        # the target must list the source here; the key declares no edges
    max_inbound: 1             # 0, the default, is unbounded
    max_outbound: 0
    min_outbound: 1

rules:
  - name: unexplained_retirement
    severity: warn
    when:
      not_inbound: supersedes
      any_of:                                    # holds when any alternative holds
        - attr: { status: { eq: deprecated } }
        - attr: { status: { eq: rejected } }
      not:                                       # holds when this condition does not
        attr: { tags: { contains: explained } }
    message: "is retired, nothing supersedes it, and it carries no explained tag"

The edges: and rules: lists above show where the new keys go; writing one of them — or derived_edges: — replaces the preset's list rather than adding to it, and writing it as an explicit empty list (derived_edges: []) clears the preset's without putting anything in its place.

A rule's when block ANDs its top-level clauses. The vocabulary is fixed and complete: inbound, not_inbound, outbound, not_outbound — each naming a declared edge type — attr: {<key>: {eq|not: <value>}} on a scalar, attr: {<key>: {contains|not_contains: <value>}} and attr: {<key>: {subset_of: [<value>, …]}} on a list, and the two combinators any_of: [<condition>, …] and not: <condition>, which nest. A scalar read as a list is a one-element list; comparison is case-insensitive; a positive clause needs the attribute to be there and a negative one is satisfied by its absence. There is no expression language. Set template: <path> to replace the document template docdag new uses, and filename: to change what it names the result — the template must carry {id}, may carry {slug}, and may not carry a path separator.

Structural checks are not rules. structural: may raise one — missing_frontmatter and unstructured_supersedes are the two that warn by default — but lowering one, or naming a check that does not exist, is a configuration error (exit 3), and no check can be disabled.

Append-only history

docdag validate --immutable-since <rev> treats a decision that was accepted, superseded or withdrawn at <rev> as a record rather than a draft. It compares the working tree against git merge-base <rev> HEAD and allows exactly three kinds of change to such a document:

  • the value of the status_field,
  • entries added under a configured inverse: key, so a later decision can record that it replaced this one,
  • lines appended to the end of the body.

Anything else — another frontmatter key changed, added or removed; an inverse entry removed; a line of the existing body rewritten; the file deleted — is an immutable_violation error naming what changed. Renames are not followed, so moving a closed document reads as the deletion it is:

$ docdag validate --immutable-since origin/main
docs/adr/0001-serve-images-from-the-application.md:11: ERROR immutable_violation 0001: the body changed at line 11, which append-only history forbids

A document that <rev> did not hold is new and is always allowed. The check runs git from PATH and is off unless the flag is given; a corpus outside a git repository, or a machine without git, exits 3.

Continuous integration

The composite action downloads the release binary for the runner, verifies it against the release's checksums.txt before running it, and needs no Go toolchain:

name: decisions
on: [push, pull_request]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: Kaikei-e/DocDag@v0.2.0
        with:
          version: v0.2.0                  # default: latest
          args: validate --format github   # this is the default
          working-directory: .             # this is the default

A tag can be moved, so pin the action by commit SHA when the supply chain matters — uses: Kaikei-e/DocDag@<40-char-sha> # v0.2.0. This repository pins the actions it uses that way.

The action runs on Linux and macOS runners, amd64 and arm64. On a Windows runner it fails with a diagnostic rather than passing silently; install with go install there instead.

A pre-commit hook is also shipped:

repos:
  - repo: https://github.com/Kaikei-e/DocDag
    rev: v0.2.0
    hooks:
      - id: docdag-validate

Hooks are advisory: git commit --no-verify walks straight past them, and a contributor who never ran pre-commit install never had them. CI is the gate; the hook only shortens the loop.

MADR compatibility

A conventional MADR repository needs no changes:

  • Filenames NNNN-kebab-title.md and bare NNNNNN.md are both recognized, 3 to 6 digits.
  • status: superseded by 0003 derives the supersedes edge with 0003 as the newer document and makes the containing one count as superseded. Each derived edge raises an unstructured_supersedes warning — a suggestion to declare the edge in frontmatter, not a failure. Moving the string to a supersedes: key clears it; the graph is the same either way.
  • Status comparison is case-insensitive; a value outside the vocabulary is unknown_status. withdrawn is in the vocabulary for a proposal that was dropped rather than replaced: it binds nothing, and because nothing supersedes it, it raises no superseded_orphan warning either.
  • Unrecognized frontmatter keys are ignored, so other tooling's fields are safe.
  • A file without frontmatter is skipped, or warned about (missing_frontmatter) if its name matches.
  • An edge key written down and then left empty — supersedes: with nothing under it, [], or a list of blank items — is empty_edge, because it reads as a declared relation and builds none.
  • A supersedes: entry that is not an identifier (see 0042, a sentence, a slug) is invalid_ref; one that is an identifier the corpus does not hold is dangling_ref. A wikilink around an identifier is unwrapped first, so an Obsidian vault writing supersedes: ["[[0042]]"] or [[0042|alias]] names document 0042 like everyone else.
  • Body links stay in the reference layer, so prose cannot fail a build unless references.dangling opts in — and even then [[upstream]], [[3days-recap]] and a link inside a code fence are not references at all.

For agents

An agent should ask the graph rather than read the directory: one command replaces a fan-out of file reads, and every answer carries the identity, the status and the position of the documents it names.

docdag context <ref> is the entry point. It prints the document, the successor it resolves to if it is superseded, and its neighbourhood over typed edges, each entry followed by the first paragraph of its Decision section, verbatim and unsummarised:

$ docdag context 0002
ref
  0002  Store thumbnails on the local disk  [superseded]  docs/decisions/0002-store-thumbnails-on-the-local-disk.md
    Chosen option: a directory on the local disk, sharded by the first two characters
    of the cache key.

resolves to (0002 is superseded)
  0003  Store thumbnails in object storage  [accepted]  docs/decisions/0003-store-thumbnails-in-object-storage.md
    Chosen option: object storage behind a read-through layer, keeping the
    content-addressed key from the caching decision unchanged.

--depth N widens the walk from its default of one hop, --edge <type> narrows it to one relation and repeats, and --all adds the documents that are not binding, which are left out by default. --section <heading> quotes a different section: the value matches an H2 or H3 whose text starts with it, so Decision finds MADR's Decision Outcome in preference to its Decision Drivers. --budget N caps the prose at roughly N tokens, counted as four characters each and defaulting to 2000; entries the budget cannot afford degrade to their one-line form rather than being cut mid-sentence, and the report ends with a line counting them. --format md answers with a Markdown document, --format json with schema_version, ref, resolves_to, ancestors, descendants and budget.

Columns. resolve and query take --fields id,title,status,path and print those columns, tab separated, in the order asked for; the default is id alone, so a pipeline reading identifiers is unaffected. Under --format json these commands answer with an array of objects carrying every field, plus "reference": true on a reference-layer hit — where v0.1 answered with an array of identifiers.

$ docdag query --binding --fields id,title,status
0001	Cache rendered thumbnails	accepted
0003	Store thumbnails in object storage	accepted

One edit at a time. docdag validate --touching <path> runs the whole corpus and reports only the findings about that file, the files a finding relates to it, and the documents one typed edge away — what changing it can break. The flag repeats and accepts a directory. The exit code still answers for the corpus, so a narrowed report never turns a failing repository into a passing one, and the number of findings withheld goes to stderr. The summary line and the summary object answer for the corpus too: only the list of findings narrows.

The plugin. This repository is a Claude Code plugin. It installs a skill teaching the vocabulary above and which command answers which question, plus a PostToolUse hook that runs docdag validate --touching on every Markdown file written inside the documents directory and reports back when that document is what broke:

$ claude
/plugin marketplace add Kaikei-e/DocDag
/plugin install docdag@docdag

The hook needs docdag and jq on PATH and does nothing without them. Allow the commands once with "permissions": {"allow": ["Bash(docdag *)"]} in .claude/settings.json.

License

Apache License 2.0. See LICENSE.

Directories

Path Synopsis
cmd
Package cmd wires the docdag CLI: a cobra command tree over the document graph.
Package cmd wires the docdag CLI: a cobra command tree over the document graph.
docdag command
Command docdag extracts a typed directed graph from a directory of Markdown documents, enforces DAG invariants on it and answers graph queries.
Command docdag extracts a typed directed graph from a directory of Markdown documents, enforces DAG invariants on it and answers graph queries.
internal
brief
Package brief assembles the reading a caller needs about one document: where it resolves to, its typed-edge neighbourhood, and one section of each of those documents, inside a token budget.
Package brief assembles the reading a caller needs about one document: where it resolves to, its typed-edge neighbourhood, and one section of each of those documents, inside a token budget.
config
Package config defines the DocDag configuration schema, the built-in presets and the discovery/merge rules that produce an effective configuration.
Package config defines the DocDag configuration schema, the built-in presets and the discovery/merge rules that produce an effective configuration.
graph
Package graph builds the two-layer document graph and answers every question asked of it: invariants, reachability, resolution and degree statistics.
Package graph builds the two-layer document graph and answers every question asked of it: invariants, reachability, resolution and degree statistics.
model
Package model holds the vocabulary shared by every DocDag layer: document identity, the typed constraint graph, and validation findings.
Package model holds the vocabulary shared by every DocDag layer: document identity, the typed constraint graph, and validation findings.
newdoc
Package newdoc creates the next document from a template and keeps the documents it supersedes consistent.
Package newdoc creates the next document from a template and keeps the documents it supersedes consistent.
parse
Package parse turns Markdown files with YAML frontmatter into documents: the frontmatter split, a strict YAML decode, body links and derived edges.
Package parse turns Markdown files with YAML frontmatter into documents: the frontmatter split, a strict YAML decode, body links and derived edges.
render
Package render writes graphs, findings and statistics in the output formats the CLI offers.
Package render writes graphs, findings and statistics in the output formats the CLI offers.
vcs
Package vcs asks git about a working tree by running it.
Package vcs asks git about a working tree by running it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL