DocDag

module
v0.4.1 Latest Latest
Warning

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

Go to latest
Published: Sep 6, 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. 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.

DocDag ships two presets. adr is the default: one directory of Architecture Decision Records, identified by a digit run, superseding one another. spec is a normative standard as a graph — eight kinds, among them the clauses, the conformance tests that enforce them and the deviations recorded against them, each in a directory of its own and with an identifier shape of its own — where a MUST that no test enforces is a finding rather than a rule. Both are plain configuration: docs/configuration.md prints each in full, and docdag.yaml overrides either.

Assembling a configuration in Go

A program that owns a vault can build the same configuration the YAML file describes, validate it without writing a file, and marshal it into docdag.yaml. The field names and YAML tags of config.Config are the contract; see docs/adr/0006 for what is stable.

import (
    "github.com/goccy/go-yaml"
    "github.com/Kaikei-e/DocDag/config"
)

cfg := config.SpecPreset()
cfg.PresetVersion = 3
// … add kinds, edges, rules …
if err := cfg.Validate(); err != nil {
    return err
}
out, err := yaml.Marshal(cfg)

lint.Check(cfg, vaultDir, fixturesDir) runs the three lint layers in process. An empty vaultDir answers only the inherent layer.

The model

DocDag keeps two layers apart. The constraint layer is the typed edges declared in frontmatter (supersedes:, depends-on:) plus the edges derived from configured field patterns; only these carry invariants, acyclicity and rules. The reference layer is the untyped links found in bodies — [[wikilink]] and relative Markdown links to other managed documents — surfaced by --include-refs and stats, never part of a constraint, and unvalidated unless the configuration 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 therefore does not change what it is, and two files that normalize alike are an id_collision error. Where a corpus declares kinds:, identity is per kind — a pattern of its own, such as UZ-V-001, or the digit run where a kind declares none — because the directory a document sits in is what chose the rules it was read under. Status is a projection of that graph rather than an independent fact: a document is binding when its status is accepted, nothing supersedes it and, where a kind declares a period:, the day being asked about falls inside it; a status the edges contradict is a finding rather than a matter of opinion.

Install

Install a pinned version locally:

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

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. docdag --version reports the tag the binary was built from, and dev for one built from a checkout.

In CI, use the composite action, which downloads the release binary, verifies it against that release's checksums.txt, and needs no Go toolchain:

      - uses: actions/checkout@v4
      - uses: Kaikei-e/DocDag@v0.4.1
        with:
          args: validate --format github   # this is the default

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 spec preset

One line adopts the second preset whole, and the eight directories it names appear as the corpus grows into them. The corpus below holds three clauses, the conformance test enforcing one of them, the subject all three speak to and the post-mortem they cite as their counterexample:

$ cat docdag.yaml
preset: spec

$ docdag validate
spec/clauses/UZ-V-002.md:4: ERROR orphan_must UZ-V-002: is MUST or MUST_NOT and accepted but nothing enforces it
as of 2026-09-02

$ docdag query --binding                      # what is in force, and at what strength
UZ-V-001	MUST
UZ-V-002	MUST
UZ-V-003	SHOULD

$ docdag query --binding --as-of 2027-06-01   # UZ-V-003 runs out on 2027-01-01
UZ-V-001	MUST
UZ-V-002	MUST

$ docdag new --kind clause --id UZ-V-004 "A report names its grader"
spec/clauses/UZ-V-004.md

UZ-V-002 claims a MUST that no conformance test enforces, which is a standard saying one thing and checking another; the remedy is the test or a weaker modality, so the finding carries no fix: line — which of the two is right is a decision rather than an edit. The modality column is there because a set spanning permissions and prohibitions cannot be read without it. And the closing as of line is the --as-of run's premise: where a corpus declares a period:, what is in force is an answer about a day, --at <rev> moves the revision the documents are read from, and the two compose into "what the vault at that revision said was in force on that day". The adr preset declares no period, so the corpus above answers the same way on every day.

What it checks

validate prints one line per finding, followed by an indented fix: wherever there is a mechanical remedy, and exits 1 if any finding is an error:

<path>:<line>: <SEVERITY> <rule> <id>: <detail>
  • Structuralinvalid_frontmatter, missing_frontmatter, id_collision, unknown_status, empty_edge, invalid_ref, dangling_ref, padding_mismatch, unstructured_supersedes, derived_conflict, and, for an edge that declares attrs:, edge_attr_unknown, edge_attr_missing, edge_attr_invalid.
  • Kinds and declared fields — for a corpus that declares kinds: or fields:, an identity its kind's pattern rejects, a kind: its directory disagrees with, an undeclared key on a closed kind, an endpoint of the wrong kind and a field value the vocabulary does not hold: id_mismatch, kind_mismatch, unknown_field, edge_kind_mismatch, unknown_field_value, missing_field, deprecated_field.
  • Graphcycle, cardinality, inverse_mismatch, and, for an edge that declares target: or a corpus that declares path_constraints:, stale_target and path_mismatch; for one that declares a modality vocabulary, modality_conflict and excepts_strict; plus the two status rules adr ships, status_drift and superseded_orphan, which spec replaces with ten of its own.
  • Periods — for a kind that declares period:, the two days a document writes are read against the day the run is about: period_invalid, period_conflict, expired_deviation.
  • Reference layerdangling_reference, off until references.dangling asks for it.
  • Historyimmutable_violation, under --immutable-since <rev>.

docs/checks.md gives each finding its severity, its trigger and its remedy.

docdag lint asks the other question — whether the rules themselves hold up. It reports a rule that can never fire, one that fires on every document, one that says what another rule already says, and, with --corpus and --fixtures, the rules the vault never fires and the rules whose own fixtures disagree with them. validate never runs it: a configuration's health and a corpus's state have different lifecycles.

For agents

An agent should ask the graph rather than read the directory: one command replaces a fan-out of file reads. docdag context <ref> prints a document, what it resolves to and its neighbourhood, each entry quoting the first paragraph of its Decision section; --fields id,title,status,path gives resolve and query tab-separated columns; docdag validate --touching <path> reports only what one edit can break; and docdag lint answers for an edit to docdag.yaml rather than to a document. This repository is also a Claude Code plugin, installing a skill and a PostToolUse hook that validates a decision record as it is written and lints the configuration when that is what changed:

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

docs/agents.md covers the flags, the output shapes and the hook's requirements.

Documentation

  • docs/commands.md — every command, the global flags, the exit codes and the four validate output formats.
  • docs/configuration.md — the docdag.yaml reference: the preset in full, every optional key, and the rule vocabulary.
  • docs/checks.md — one entry per finding: what triggers it and what clears it.
  • docs/ci.md — the composite action, append-only history, linting the configuration and the pre-commit hook.
  • docs/agents.mdcontext, --fields, --touching, lint and the plugin.
  • docs/adr/ — the architecture decision records behind the design, indexed and in reading order: the spec preset without an expression language, target conditions, modality, lint, in-force periods, and the public config package.
  • CHANGELOG.md — what each release changed, including the output formats v0.2.0 broke and how to migrate off them.

MADR compatibility

A conventional MADR repository needs no changes — the invariants hold as the files already are:

  • Filenames NNNN-kebab-title.md and bare NNNNNN.md are both recognized, 3 to 6 digits, and unrecognized frontmatter keys are ignored, so another tool's fields are safe.
  • status: superseded by 0003 derives the supersedes edge with 0003 as the newer document and makes the containing one count as superseded, raising an unstructured_supersedes warning — a suggestion to declare the edge in frontmatter, not a failure. The graph is the same either way.
  • Body links stay in the reference layer, so prose cannot fail a build unless references.dangling opts in.

docs/checks.md has the rest: how withdrawn differs from superseded, what makes a supersedes: entry invalid_ref rather than dangling_ref, and which links never join the reference layer at all.

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.
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.
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.
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.
lint
Package lint reports what is wrong with a DocDag configuration rather than with the documents it describes: conditions no document could satisfy, conditions every document satisfies, rules the corpus never fires, and rules whose own fixtures disagree with them.
Package lint reports what is wrong with a DocDag configuration rather than with the documents it describes: conditions no document could satisfy, conditions every document satisfies, rules the corpus never fires, and rules whose own fixtures disagree with them.
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.
Package lint is the small public entry for checking a DocDag configuration in process.
Package lint is the small public entry for checking a DocDag configuration in process.
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.

Jump to

Keyboard shortcuts

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