Documentation Testing
Ze ships native Go tools that compare documentation with the live registries and source tree. Pre-commit verification selects the checks affected by a change.
Quick start
./le doc check verify # Run all documentation tests
./le doc wiring # Run changed-file-aware wiring/doc/inventory gate
./le doc check verify runs every documentation checker and returns non-zero if any of
them report drift. Run it after editing documentation files, after adding or
removing plugins, or as part of review. ./le doc wiring selects the
checks needed for the current diff and is included in ./le verify current mode full.
What gets checked
| Native action | What it validates |
|---|---|
./le docvalid doc-drift |
Published counts and lists agree with live registries and the tree |
./le docvalid command-contract |
Every YANG ze:command has a registered handler |
./le docvalid usage-contract |
The model states every command's argument grammar, and no description spells one in prose |
./le docvalid help-shape |
Every command node, every RPC, every offline local command and every config node declares a one-line summary a row renders whole, with a long text beside it, and the report states how much of each corpus is written |
./le docs-to-code check |
Documentation source paths and claimed symbols resolve |
./le doc check links |
Tracked path citations resolve |
./le digest |
Every file:line anchor in. ai/digests/*.md |
./le consistency |
Design references, cross-references, JSON tags, and package citations agree |
./le doc wiring |
Changed files trigger their documentation and inventory checks |
./le ste check |
✕ |
./le doc check verify combines documentation drift, command validation, the two
command help gates, and source-anchor validation. ./le doc wiring is the changed-file-aware
pre-commit gate.
When to run
| Situation | Recommended target |
|---|---|
| After you write any prose, in any file | ./le ste review-changed |
After editing any file under docs/ |
./le doc check verify |
| After adding or removing a plugin | ./le doc check verify |
| After writing a path reference in ANY tracked file | ./le doc check links (./le doc check verify does not cover it) |
After adding or renaming a YANG ze:command |
./le docvalid command-contract |
After writing the description or the ze:help of a command node, an RPC or a config node |
./le docvalid help-shape |
After writing the Description or the LongHelp of a registry.Meta |
./le docvalid help-shape |
While an agent writes a description in any .yang file |
./le hook-check pretool-writeedit answers on the proposed text, before the file lands |
| After adding a doc validator, inventory source, command source, or exported Go API | ./le doc wiring |
| Before opening a documentation PR | ./le doc check verify |
The full ./le doc check verify remains the explicit documentation review target.
./le verify current mode full runs ./le doc wiring, which invokes the relevant doc,
command, inventory, and wiring checks for changed files.
How to interpret output
./le docvalid doc-drift
Documentation drift detected (N issues)
x docs/DESIGN.md:708: claims 19 interop scenarios, actual is 32
x docs/DESIGN.md:0: plugin "bgp-nlri-vpn" registered but missing from Shipped Plugins table
...
Each issue points at a file, a line number (0 = file-level), and a description. Most fixes are mechanical: update a count, add a missing table row, remove a stale entry.
./le docvalid command-contract
# Command Validation
YANG commands: 97
Registered handlers: 69
## YANG commands with no handler (30)
ze-show:bgp-decode (show > bgp > decode in ze-bgp-tools-cmd)
...
## Handlers with no YANG command (0)
Two-direction check. Both directions are contract bugs:
- YANG declares a command but no Go code registered an RPC or local handler -> dead command
- RPC handler registered but YANG doesn't declare it -> command unreachable from CLI
./le docvalid help-shape
# Command Help Shape
Command tree nodes: 611
Nodes that run a command: 395
Nodes with a summary: 611
Nodes with a long help: 396
RPCs: 211
RPCs with a summary: 211
RPCs with a long help: 87
Offline local commands: 19
Offline local commands with a summary: 19
Offline local commands with a long help: 12
Config nodes: 3174
Config nodes with a summary: 3109
Config nodes with a long help: 1459
Long help judged over: declarations this working tree added or changed against HEAD
Nodes with a broken summary: 1
RPCs with a broken summary: 0
Offline local commands with a broken summary: 0
Config nodes with a broken summary: 11
## Broken rules (23)
missing-long-help 12
char-cap 6
word-cap 5
command show bgp reject-asn known
rule: missing-long-help
problem: the declaration carries a summary and no long text beside it
summary: ...
schema ze-bgp-conf:bgp/policy/reject-asn/name
rule: char-cap
problem: the summary is 110 characters (a one-line row renders 96)
summary: ...
The gate walks FOUR corpora. A -cmd.yang node declares the CLI path an
operator types. An -api.yang rpc declares the wire method that path reaches,
and the plugin IPC modules in internal/core/ipc/yang/ declare 22 more. Every
loaded module is walked, so a module whose name carries no -api suffix is
judged with the rest. An offline local command declares its help in a
registry.Meta beside its handler and reaches no YANG module at all, and
ze help command --json merges it with the tree (cmd/ze/help_command.go,
collectCommands). A CONFIG node declares its help in a -conf module, and
entryDescription (internal/component/cli/completer.go) puts it on the row
under the completion menu.
The first three corpora are held to the eight summary rules. The config corpus
is held to the two caps and to the pair rules, and to nothing else: a YANG
description is written over as many lines as its author needed, and
entryDescription collapses the whitespace before it renders, so a newline in
one is the normal spelling rather than a defect.
What the config corpus IS
The population is the RESOLVED entry tree of the -conf modules, which is the
tree the completer itself walks. Deriving it settles three questions no rule
over statement keywords answers correctly.
| Statement | Judged | Why |
|---|---|---|
A container, list, leaf or leaf-list in the config tree |
✓ | entryDescription puts its description on the one-line row |
A choice or a case |
✕ | effectiveChildren walks THROUGH both and emits neither as a completion row, so neither text ever renders. internal/component/cli/completer.go |
A module, submodule, revision, import, include, grouping, typedef, identity, feature or extension description |
✕ | It never becomes an entry, so no row renders it |
A leaf in a -cmd.yang or an -api.yang module |
✕ | argDefFor builds a command.ArgDef from leaf.Type alone, and ArgDef holds no text field |
An rpc |
By collectRPCs, wherever it is declared |
Judging it here would refuse one declaration twice |
An enum on the leaf a list names as its KEY |
The two caps, never the long-text rule | listKeyCompletions is the one caller of enumKeyVocabulary, and its entry comes from getListKeyEntry. Nothing reads a ze:help on an enum |
An enum on any other leaf, or one reached through a typedef |
✕ | getListKeyEntry answers the key leaf and nil for everything else |
| A node another module AUGMENTS in | ✓ | It is in the tree, so ze-role and the other BGP plugin modules are judged without the gate knowing they exist |
A false refusal here is expensive rather than noisy. Given a brief with no
population rule, three agents shortened exactly the module and revision
descriptions and moved the prose into // comments. A YANG description is
schema that standard tooling reads and the schema output publishes, and a
comment is neither, so all three passes were reverted.
The pair rules
| Rule | What it refuses |
|---|---|
missing-summary |
A config node declaring no description at all. An empty row under the completion menu tells an operator the name exists and nothing else |
missing-long-help |
A declaration carrying a summary and no long text beside it |
long-restates-summary |
A long text byte-equal to the summary beside it, once each is trimmed |
long-cap |
A long text past command.MaxLongHelpBytes, which is the bound validateHelpDecls holds a plugin's declaration to |
Every one of the three is absolute, and so is missing-summary. A declaration
an operator can reach owes both texts wherever it was written.
missing-long-help was scoped to what the working tree added or changed against
HEAD while 193 declarations in the corpus carried a summary alone. Those are
written, so the rule holds over the whole tree and the HEAD baseline that
scoped it is gone. There is no file to append a path to, and no scope line in
the report: a summary with no long text beside it is refused wherever it sits.
The third corpus is read two ways, because Go forbids importing a main package.
The registrations this binary links are read from the registry. The four
cmd/ze declares in package main are read from its source, and a registration
there whose path is not a literal STOPS the gate rather than being skipped. A
path the command tree also holds is left to the command half: the catalog
publishes the node's description for such a path and never the registration's.
Development commands under le are left out, for the reason the published
catalog leaves them out.
The coverage counts say how much of each corpus is written. A node with no
summary and a node with no long help are both counted, so an unwritten command
is visible rather than silent. Each refusal opens with its surface, command,
rpc, local or schema, then the name that finds it. That name is the CLI
path for a command node and for a local command, <module>:<rpc-name> for an
rpc, and <module>:<node>/<node> for a config node. A
summary that breaks two rules is reported twice. The report therefore states the
number of nodes, of RPCs and of local commands as well as the number of
refusals.
Fix the summary in the YANG description of that node or that rpc, or in the
Description of that registration. Prose that does not fit one short sentence
belongs in the ze:help beside it, or in the LongHelp beside it, which no
one-line surface reads.
The write hook, an edit before the gate
./le hook-check pretool-writeedit reads the PROPOSED text of any .yang file
an agent writes or edits, and warns with exit 1. It loads no YANG model. It
reads the file it was handed, which is not on disk yet. It never speaks for the
tree: ./le docvalid help-shape is the gate.
| Rule | What it refuses |
|---|---|
char-cap |
A description longer than 96 characters. overlayInnerWidth clamps every CLI overlay to [48, 96] characters. A longer summary cannot render whole in any of them |
word-cap |
A description of more than 25 words. The bound is ste.MaxDescriptiveWords, which the help-shape gate holds a summary to |
shape |
A description carrying a semicolon, or one that does not end in a full stop |
long-restates-summary |
A ze:help that repeats the description beside it word for word |
The three summary rules hold one population: the statements whose text reaches a
one-line operator surface. Those are container, list, leaf, leaf-list,
choice, case, action, rpc and notification. The owner is the nearest
enclosing statement, so a leaf inside a grouping is judged and the grouping is
not.
A module, submodule, revision, import, include, grouping, typedef,
identity, feature or extension description is schema documentation that no
row renders. The hook passes it whatever its length. Capping one invites the
repair that moves the prose into a // comment. Standard YANG tooling cannot
read a comment, and the schema output does not publish one.
A leaf and a leaf-list are judged in a config module and passed in a
-cmd.yang or an -api.yang one, because two producers read them.
extractArgDefs walks every child of a command container and calls argDefFor,
which admits any entry that carries a type. Both statements carry one, so each
becomes a command.ArgDef built from the type and the mandatory flag. ArgDef
declares nine fields and none of them holds text, so the description is dropped
at the tree boundary. entryDescription puts a config leaf's description on the
completion row. The file name is what separates the two at the level the hook
reads.
Each finding names the file, the statement that encloses the text, the rule, and the measured value beside its bound. The text is joined across its concatenated parts and its lines, and its whitespace is collapsed. The author is therefore told what an operator will read, and not how the module was wrapped.
The hook reads one Edit region as readily as one whole file. That is where its two silences are reported rather than hidden. A description the region carries with no statement around it is counted as NOT judged, because nothing says which surface renders it. A string or a comment the scan cannot close is reported as text that does not read as YANG. No description in that file is judged. A scan that stopped early must not read as a file that broke no rule.
How to fix common issues
| Issue | Fix |
|---|---|
| Plugin count claim wrong in DESIGN.md | Update the number; the script reports the actual count |
| Family list missing entries in DESIGN.md | Add the missing entries; the script lists which |
.ci test count claim wrong |
Update the count or phrase it as an approximate dated claim |
| Feature inventory row has no status | Add one of: Supported, Partial, Experimental, Stub-backed, Rejected, Future |
| Functional test release-gate list wrong | Update. docs/functional-tests.mdinternal/le/functional/catalog.go |
| Stale text parser allocation claim | Update textparse.NewScanner and source-linked result allocations. docs/architecture/api/text-parser.md |
| Stale source anchor path | Fix or remove the <!-- source: ... --> path, then rerun ./le doc check verify |
CLAIM: ... names 'Sym', which is not declared there |
Read the anchored file. When the symbol moved, point the anchor at the file that DECLARES it; when the name changed, write the new one; when the symbol is gone, the sentence above the anchor is wrong too, so fix the sentence. Never reword a real symbol into prose to silence the finding: the check already ignores a token the anchored file names anywhere, so a finding means the token is absent from that file, which no call, field, parameter or env key of that file can be |
cannot read the anchored file, so its symbols are unverifiable |
The anchor points at a file the checker could not read or decode. Fix the path. The check fails closed here on purpose: an unreadable file proves nothing about the claims above it |
doc-links: ignore marker states no reason |
Write the reason inline, <!-- doc-links: ignore (why this path cannot resolve) -->, or delete the marker and repair the reference it was hiding. A marker with no reason is a silent allowlist |
| Plugin in registry but not in Shipped Plugins table | Add a row to 's Shipped Plugins table. docs/DESIGN.md |
YANG ze:command with no handler |
Remove the YANG declaration OR write the handler in internal/component/<area>/cmd/ or cmd/ze/<area>/register.go |
Handler with no YANG ze:command |
Add a YANG declaration in the appropriate *-cmd.yang schema |
How the tools find drift
internal/le/docvalid.Answer imports internal/component/plugin/all so all
plugins register themselves, then queries registry.All() and
registry.FamilyMap(). It walks the .ci files and reads the native functional
suite catalog from internal/le/functional. It compares those facts with
claims in docs/DESIGN.md, docs/comparison.md, README.md,
docs/features.md, and docs/functional-tests.md.
internal/le/docvalid.Answer imports the same set plus the BGP cmd plugin
schema/handler packages, loads the YANG modules, and walks the schema tree
looking for ze:command extensions. For each extension it checks
registry.CollectRPCHandlers() for a matching method name.
./le docs-to-code check scans every Markdown source anchor under docs/ and
refuses a missing source path or symbol. ./le docs-to-code update regenerates
the two documentation indexes.
One walk of docs/ feeds the path and symbol checks. An anchor is
<!-- source: <path> -- Sym1, Sym2 -->. internal/le/docstocode.CheckCodeIndex
keeps an identifier or dotted method chain from the claim and compares it with
the declarations in the anchored Go file. The scan is independent of build
tags, so a Linux declaration remains visible on a macOS host.
The same walk answers the opposite question. internal/le/docstocode.ClaimsByPath
maps a code path to the claims written about it, each with the symbols its
anchor names, and the doc-drift check in internal/le/doc/wiring refuses a
commit that changed one of those symbols and left its page alone.
The check reads the diff hunks, maps each to the declaration it lands in, and
compares those names with the anchor's claim. So an edit elsewhere in the same
file reports nothing, and a page counts for itself alone. A test file is out of
scope. A claim naming no resolvable symbol blocks nothing and is counted in the
verdict line, because silence and "nothing to say" must not read alike.
ai/rules/documentation.md states the obligation this enforces: the page edit
belongs in the same work as the code edit.
internal/le/doc/check.Answer walks the instruction corpus for paths,
// Design: targets and hook names. Its last two checks do not use that
corpus. sweep_tracked reads every file git ls-files names, one time, and
check_ignore_reasons and check_tracked_citations share that one walk.
Check 4 reports a doc-links: ignore marker that states no reason, because a
marker outside the walked corpus suppresses nothing and nobody audits it.
Check 5 reports a path reference that does not resolve, in any tracked file.
Two moves remove a finding: repair the reference, or mark its line with a
marker that states why the path cannot resolve.
The references that predate check 5 are grandfathered in
internal/le/doc/check/testdata/doc_citation_baseline.txt, one
citing file<TAB>dead target pair per line. It records pairs rather than bare
targets, so a new file that cites an already-dead target is reported. The check
compares the file with its version at HEAD and refuses every pair that HEAD
does not hold, so the baseline only shrinks.
Repair a citation and delete its pair from the baseline in the same change.
./le doc check links validates the result against the working tree and warns
when a baseline pair is no longer needed. The command does not rewrite the
shared baseline, so one session cannot absorb another session's unfinished
edits.
The three roots outside the tracked citation scan are vendor/,
third_party/, and plan/handover/: the first two hold another repository's
files, while the last records an earlier tree.
internal/le/consistency parses // Design:, // Detail:, // Overview:, and
// Related: comments, checks their symmetry, and reports references to
packages that no longer exist.
Adding a new documentation check
- Add callable Go behavior to the owning package under
internal/le/. - Add one action to that package's table and register its area through
leroot.Register. A related check joins an existing area rather than opening another root name. - Register the action with
internal/le/lerootand expose it as./le <area> <action>. - Add the callable action to
internal/le/doc/wiringwhen changed files should trigger it during pre-commit verification. - Add its producer and exact command to this page and
ai/INDEX.md. - If agent rules need the command, edit the canonical rule point and render the
rule corpus through
./le rules render-update.
Repository tooling is compiled Go under internal/le; package-owned fixtures
belong in that package's testdata/ directory.
See also
ai/rules/writing.md-- canonical documentation rules, including the BLOCKING Documentation Update Checklist for specsai/rules/repo-maintenance.md-- required discovery updates when new checks, tools, or verification gates are addedai/rules/repo-maintenance.md-- which native gates enforce which rulesinternal/le/doc/checkandinternal/le/doc/wiring-- documentation checks, command validation, and changed-file wiring