Validate markdown specs from the inside out.
βββββββ βββββββ βββββββββββββββββββββββββββ βββ βββββββββββββββ
ββββββββββββββββββββββββββββββββββββββββββββββ βββββββββββββββββββ
βββ ββββββ ββββββ ββββββ ββββββ ββββββ ββββββ ββββββ
βββ ββββββ ββββββ ββββββ ββββββ βββββββββββββ ββββββ
ββββββββββββββββββββββββββββ βββββββββββ ββββββββββββββββββββββ
βββββββ βββββββ ββββββββββ βββββββββββ βββββ βββββββββββββββWhat β’ Install β’ Quick Start β’ Usage β’ Rules β’ Types
A tiny CLI that validates markdown spec docs against rules embedded directly in the document. No external dependencies. Drop it in your project and your AI agent can use it immediately.
AI assistants drift. They sneak in TODOs, break links, forget required
sections, and blow past length limits. docfence lets you define the rules
inside the doc itself β per section or for the whole document β and catch
issues before they compound.
pip install -e .After that, docfence is available globally β run it from any folder.
# scaffold a new doc
docfence new feature > docs/my-feature.md
# validate one file
docfence validate docs/my-feature.md
# validate a whole folder
docfence validate docs/
# stamp a clean file with a timestamp
docfence stamp docs/my-feature.md
# list available types
docfence typesNote:
docfencelooks for.docfence/types/relative to the target path. Runvalidatefrom your project root so type definitions are found.
π See sample doc β bad-feature.md (has errors)
---
id: F-002
type: feature
status: brainstorm
owner:
depends_on: []
last_validated: ~
---
```spec
scope: document
type: feature
required_sections: [Overview, Implementation]
max_chars: 500
banned_words: [TODO, TBD]
```
We want to let users export data. TODO: figure out formats. TBD for now.
```spec
type: feature
max_chars: 200
banned_words: [TODO, TBD]
```
We will build background jobs. TODO add progress tracking.$ docfence validate sample-docs/
# β run on a folder to validate all .md files inside
sample-docs/ # β folder root
βββ β bad-feature.md # β = has errors (stamp blocked)
β βββ L1 frontmatter (type: feature) # β L1 = line 1; frontmatter checks come from the type definition
β β βββ β frontmatter: missing required field 'owner' # 'owner' is required_fields in the type .toml
β β βββ β status: 'brainstorm' not valid β allowed: draft, active, frozen, done # status must be in type's statuses list
β βββ L4 spec block (type: feature, scope: document) # β L4 = line 4; scope: document = rules apply to whole file
β β βββ β banned_words: 'TODO' found in content # banned_words rule caught 'TODO' in the document body
β β βββ β banned_words: 'TBD' found in content # same rule, second hit β each banned word is a separate issue
β βββ L18 spec block (type: feature) # β no scope = section-level; rules only apply to text after this fence
β βββ β banned_words: 'TODO' found in content
βββ β exploration-auth.md # β = clean, no issues found
βββ β good-feature.md
βββ β test-match.md # β = warnings only (non-blocking)
βββ L18 spec block (type: feature)
βββ β inherited: uses inherited defaults for banned_words # rule wasn't set in the block; fell back to type defaults
4 files 5 errors 1 warning # β summary: errors block stamp, warnings are advisorydocfence validate <file|folder> # validate one file or all .md in folder
docfence validate <file|folder> --verbose # show passing checks + section headings
docfence new <type> # scaffold a doc with df-todo placeholders
docfence new <type> --output <path> # scaffold and write to file
docfence new <type> --set owner=alice # override frontmatter defaults
docfence types # list all available types
docfence stamp <file> # write last_validated timestamp (only if clean)Pass --verbose to see passing checks and section headings. β VERBOSE.md
Built-in fallback: story task feature design exploration research
persona pov brainstorm roadmap flow wireframe prototype
test brand handoff
Adding a new type β create .docfence/types/mytype.toml:
name = "mytype"
statuses = ["draft", "active", "done"]
required_fields = ["id", "status", "owner"]
[defaults]
max_chars = 1500
banned_words = ["TODO", "TBD"]No core changes needed. docfence types will pick it up automatically.
Place ```spec ``` fences in your markdown to embed validation rules inline.
Section-level β rules apply to the text that follows the block:
```spec
type: feature
max_chars: 800
banned_words: [TODO, TBD, placeholder]
validate: [file_exists]
```
Your content here...
- src/auth/login.pyDocument-wide β rules apply to the whole file (put near the top):
```spec
scope: document
type: feature
required_sections: [Overview, Acceptance Criteria]
max_chars: 5000
```Section-level spec blocks validate the text after them. Place them at the
top of the section (immediately after the ## heading), before any content
or df-todo blocks:
## Context
```spec
type: plan
banned_words: [possibly, perhaps]
match:
has_problem: '(problem|issue|bug)'
```
The actual section content goes here...If a spec block is at the end of a section, the validator sees empty text and all match rules produce false positives. docfence will emit a π‘ hint when it detects this:
π‘ spec-placement: spec block has no content after it β move to top of section
so validation sees the section content. See README: Section-level spec blocks
The scaffold generator (docfence new <type>) already places spec blocks at the
top of each section β you only need to worry about this when editing existing
documents.
| field | example | what it checks |
|---|---|---|
max_chars |
max_chars: 800 |
sibling text must be shorter |
banned_words |
banned_words: [TODO, TBD] |
none of these appear in sibling text |
match |
match: + indented label: "regex" |
at least one line matches each named pattern |
validate: [file_exists] |
every line in sibling text is a real path | |
validate: [valid_url] |
every http line in sibling text is reachable |
|
placeholders |
placeholders: ["```df-todo"] |
unfilled placeholder blocks remain in doc |
required_sections |
required_sections: [Overview] |
document-scope only; heading must exist |
match example:
```spec
type: feature
max_chars: 1000
match:
data_point: "^\\- .{30,}$"
source_link: "Source: https?://.+"
```Each label: pattern entry must match at least one line. Use (?i) for
case-insensitive matching. Errors reference the label, not the raw regex.
- β ERR β rule violation;
stampis blocked until fixed - β WARN β inherited defaults or unknown type; worth reviewing, not a blocker
- π‘ HINT β advisory suggestion; not a blocker, often a migration nudge
Each spec block gets a bid (8-char sha256 of its sibling text). If content
changes between runs, the bid changes β useful for AI agents to detect drift.