docscheck

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 18, 2026 License: MIT Imports: 6 Imported by: 0

README

docscheck

Check that the command examples in a tool's documentation name commands the tool can actually run.

import "gitlab.com/phpboyscout/go/docscheck"

Documentation drifts from a CLI silently. Split a command into a group and every page using the old form starts telling readers to run something that errors — and nothing notices, because no test compares the two.

What it flags

One shape, and only one: inside a fenced code block, a command group followed by a concrete token that is not one of its subcommands.

Example Verdict
krites reset IMG_2043.CR2 findingreset is a group; IMG_2043.CR2 is not a subcommand
krites reset frame IMG_2043.CR2 fine — resolves to a leaf
krites reset fine — a bare group is a synopsis, not an instruction
krites xmp <subcommand> fine — a placeholder is a synopsis
krites config --help fine — a flag is not a subcommand
Undo with `krites reset IMG.CR2`. fine — prose is a reference, not an invocation

That narrowness is what makes it usable. The obvious rule — flag anything that resolves to a node with children — found 15 problems on a corpus whose real defect count was 3, because a group's own reference page names the group in its title, its heading and its prose. Restricting to fenced blocks removed twelve; ignoring bare groups and placeholders removed the rest.

It also dissolves what looks like the hard problem. Some groups run standalone and some error without a subcommand, so it seems the checker has to tell them apart. It does not: a group handed a token that is not one of its subcommands fails either way. There is no allowlist of runnable parents to maintain — and nothing needs executing to find out, which matters, because a command's exit status depends on how its binary was built while its shape does not.

Usage

The tree comes from the running program, so it cannot drift from what the binary accepts. This module imports no CLI framework — see TestDependencyFootprint — so a Cobra tool converts its own root command:

func tree(c *cobra.Command) docscheck.Command {
	n := docscheck.Command{Name: c.Name()}
	for _, sub := range c.Commands() {
		n.Sub = append(n.Sub, tree(sub))
	}

	return n
}

func TestDocumentedCommandsExist(t *testing.T) {
	root, _ := root.NewCmdRoot(version.Info{})

	c, err := docscheck.New(tree(root.Command))
	if err != nil {
		t.Fatal(err)
	}

	rep, err := c.Walk(os.DirFS(".."), "docs", "README.md")
	if err != nil {
		t.Fatal(err)
	}

	if !rep.OK() {
		t.Fatalf("documentation names commands that cannot run:\n%s", rep)
	}
}

Scan is the pure form — content in, findings out, no filesystem — if you want to drive it yourself.

It will not quietly pass

A guard that stops working while still looking green is worse than no guard. New rejects a tree with no name or no subcommands, and Walk returns ErrNoFiles when nothing matched, rather than reporting a clean run.

Scope

Command paths only. It does not validate flags, argument counts, or whether the prose around a command describes it correctly, and it does not execute anything.

Dependencies

One, at runtime: gitlab.com/phpboyscout/go/errors, which is itself dependency-free — so adding a documentation check to a project costs it one module and no transitive graph.

TestDependencyFootprint enforces that against an explicit allowlist, and separately forbids any CLI framework reaching the runtime graph. The allowlist is the decision, not a record of one: adding a line to it is a choice to be made deliberately.

Why it exists

Three pages of one tool's documentation told readers to run `krites reset

` — a form that had exited 1 since `reset` gained subcommands. The getting-started tutorial among them was *written a fortnight after* the change, so that step never worked for anyone who followed it.

Measured against that tool's real corpus: 57 files, 97 documented invocations, 0 false positives, and 3 of 3 defects caught when the fix is reverted.

Licence

MIT — see LICENSE.

Documentation

Overview

Package docscheck checks that the command examples in a tool's documentation name commands the tool can actually run.

Documentation drifts from a CLI silently. Split a command into a group and every page using the old form starts instructing readers to run something that errors — and nothing notices, because no test compares the two.

What it flags, and what it does not

Only one shape is a finding: inside a fenced code block, a command group followed by a concrete token that is not one of its subcommands.

krites reset IMG_2043.CR2     // finding — `reset` is a group, `IMG_2043.CR2` is not a subcommand
krites reset frame IMG.CR2    // fine    — resolves to a leaf
krites reset                  // fine    — a bare group is a synopsis, not an instruction
krites xmp <subcommand>       // fine    — a placeholder is a synopsis
krites config --help          // fine    — a flag is not a subcommand

That narrowness is deliberate, and it is what makes the check usable. The obvious rule — flag any invocation that resolves to a node with children — reports fifteen findings on a corpus whose real defect count is three, because a group's own reference page names the group in its title, its heading and its prose. Restricting to fenced blocks removes twelve of those; ignoring bare groups and placeholders removes the rest.

It also removes what looks like the hard problem. Some groups run standalone and some error without a subcommand, so it seems the checker must tell them apart. It does not: a group handed a token that is not one of its subcommands fails either way. There is no allowlist of runnable parents to maintain, and no need to execute anything to find out — which matters, because a command's exit status depends on how its binary was built, while its shape does not.

Supplying the tree

The tree comes from the caller. This module deliberately imports no CLI framework — TestDependencyFootprint enforces that, along with an allowlist holding its entire runtime graph — so a Cobra-based tool converts its own root command in a few lines:

func tree(c *cobra.Command) docscheck.Command {
	n := docscheck.Command{Name: c.Name()}
	for _, sub := range c.Commands() {
		n.Sub = append(n.Sub, tree(sub))
	}
	return n
}

Taking the tree from the running program rather than from a list is the point: a hand-maintained list of command names is the same drift problem one level down.

Example

A tool checks its own documentation in a test. The tree comes from the running program — in a Cobra tool, converted from the root command — so it cannot drift from what the binary actually accepts.

package main

import (
	"fmt"
	"testing/fstest"

	"gitlab.com/phpboyscout/go/docscheck"
)

func main() {
	root := docscheck.Command{
		Name: "krites",
		Sub: []docscheck.Command{
			{Name: "cull"},
			{Name: "reset", Sub: []docscheck.Command{{Name: "frame"}, {Name: "shoot"}}},
		},
	}

	docs := fstest.MapFS{
		"docs/cull.md": &fstest.MapFile{Data: []byte(
			"Undo a frame:\n\n```bash\nkrites reset IMG_2043.CR2\n```\n",
		)},
	}

	c, err := docscheck.New(root)
	if err != nil {
		panic(err)
	}

	rep, err := c.Walk(docs, "docs")
	if err != nil {
		panic(err)
	}

	fmt.Println(rep)
}
Output:
docs/cull.md:4: `krites reset IMG_2043.CR2` — "IMG_2043.CR2" is not a subcommand of `krites reset` (have: frame, shoot)
1 file(s), 1 documented invocation(s), 1 problem(s)

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrNoTool is returned when the root command has no name.
	ErrNoTool = errors.NewSentinel("docscheck.no_tool", "root command has no name")
	// ErrNoCommands is returned when the root command has no subcommands, which
	// means the tree was never populated.
	ErrNoCommands = errors.NewSentinel("docscheck.no_commands", "root command has no subcommands")
	// ErrNoFiles is returned when a walk matched no files.
	ErrNoFiles = errors.NewSentinel("docscheck.no_files", "no files matched")
)

Errors returned when a check cannot be performed. A checker that cannot see its source of truth must say so rather than report a clean run — a silent pass is how a guard like this stops working without anyone noticing.

Functions

This section is empty.

Types

type Checker

type Checker struct {
	// contains filtered or unexported fields
}

Checker scans documentation for invocations of one tool.

func New

func New(root Command) (*Checker, error)

New returns a Checker for the given command tree. The root's Name is the tool name as a reader types it.

func (*Checker) Scan

func (c *Checker) Scan(name, content string) []Finding

Scan checks one document's content and returns its findings. It is pure: no filesystem, no execution, and the same input always gives the same output.

func (*Checker) Walk

func (c *Checker) Walk(fsys fs.FS, patterns ...string) (Report, error)

Walk scans every file in fsys matching one of the patterns (fs.Glob syntax, so "docs/*.md" but not "**"; pass a directory to walk it recursively).

It returns ErrNoFiles if nothing matched.

type Command

type Command struct {
	Name string
	Sub  []Command
}

Command is a node in a tool's command tree: a name and its subcommands. The root's Name is the tool's own name, as a reader would type it.

Callers build this from their CLI framework's own tree — see the package documentation. Nothing here imports a framework.

type Finding

type Finding struct {
	// File is the name the content was scanned under.
	File string
	// Line is the 1-indexed line the invocation appears on.
	Line int
	// Command is the command as resolved against the tree, e.g. "krites reset".
	Command string
	// Token is the token that broke it — present in the docs, absent from the tree.
	Token string
	// Options are the subcommands Command does accept, in sorted order.
	Options []string
}

Finding is one documented invocation that cannot run as written.

func (Finding) String

func (f Finding) String() string

String renders the finding as one editor-navigable line.

type Report

type Report struct {
	// Findings are the invocations that cannot run, in file then line order.
	Findings []Finding
	// Files is how many files were scanned.
	Files int
	// Invocations is how many fenced invocations of the tool were resolved.
	// A run that scans files but resolves nothing usually means the tool name
	// is wrong, not that the documentation is clean.
	Invocations int
}

Report is the outcome of walking a set of files.

func (Report) OK

func (r Report) OK() bool

OK reports whether the scan found nothing.

func (Report) String

func (r Report) String() string

String renders every finding, one per line, followed by a one-line summary.

Jump to

Keyboard shortcuts

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