Skip to content

Commit 3c4c769

Browse files
erraggyclaude
andauthored
docs: fix ~100 inaccuracies and add CI prevention checks (#341)
Full documentation accuracy audit across all 11 packages, whitepaper, and site pages. Fixes ~100 inaccuracies (wrong types, missing fields, outdated examples, broken links) and adds automated CI checks to prevent drift from recurring: - lychee link checker in docs workflow and Makefile - TestCLIFlagsDocumented: bidirectional CLI flag/doc verification - TestDeepDiveOptionTables: AST-based With* function/doc verification - Refactor 6 walk subcommands to exported Setup*Flags pattern - Add 12 previously undocumented CLI flags to cli-reference.md Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fe1c642 commit 3c4c769

32 files changed

Lines changed: 1446 additions & 334 deletions

.github/workflows/docs.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,18 @@ jobs:
3636
- name: Prepare Documentation
3737
run: ./scripts/prepare-docs.sh
3838

39+
- name: Restore lychee cache
40+
uses: actions/cache@v4
41+
with:
42+
path: .lycheecache
43+
key: lychee-${{ hashFiles('lychee.toml') }}
44+
restore-keys: lychee-
45+
46+
- name: Check links
47+
uses: lycheeverse/lychee-action@v2
48+
with:
49+
args: --no-progress docs/
50+
fail: true
51+
3952
- name: Deploy to GitHub Pages
4053
run: mkdocs gh-deploy --force

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ testdata/corpus/*.yaml
6767
testdata/corpus/*.yml
6868
!testdata/corpus/README.md
6969

70+
# Lychee link checker cache
71+
.lycheecache
72+
7073
# Generated Documentation
7174
site/
7275
.tmp/

CONTRIBUTORS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,16 @@ go test -bench=. ./parser
304304
4. ✅ Verify test coverage is sufficient
305305
5. ✅ Update benchmarks with `make bench-save` for performance-impacting changes
306306

307+
### Documentation Checks
308+
309+
CI automatically verifies that documentation stays in sync with code through automated checks:
310+
311+
- **CLI flag tables** (`TestCLIFlagsDocumented`) - CLI flags must match `docs/cli-reference.md`
312+
- **Option tables** (`TestDeepDiveOptionTables`) - `With*` functions must appear in respective `deep_dive.md`
313+
- **Link checking** (lychee) - Broken links caught in CI and via `make docs-check`
314+
315+
If you add a CLI flag or `With*` option, the tests will tell you which doc to update.
316+
307317
### Commit Message Format
308318

309319
Use conventional commit format:

Makefile

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,21 @@ docs-clean:
572572
@rm -f docs/CONTRIBUTORS.md docs/LICENSE.md docs/benchmarks.md
573573
@rm -rf docs/packages docs/examples
574574

575+
## lint-links: Check for broken links in assembled docs
576+
.PHONY: lint-links
577+
lint-links: docs-prepare
578+
@echo "Checking links in docs/..."
579+
@if command -v lychee >/dev/null 2>&1; then \
580+
lychee --no-progress docs/; \
581+
else \
582+
echo "WARNING: lychee not found — link checking SKIPPED." >&2; \
583+
echo "Install: brew install lychee (or cargo install lychee)" >&2; \
584+
fi
585+
586+
## docs-check: Full documentation validation (prepare + link check)
587+
.PHONY: docs-check
588+
docs-check: lint-links
589+
575590
# =============================================================================
576591
# Help Target
577592
# =============================================================================

builder/deep_dive.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1110,10 +1110,12 @@ result := srv.MustBuildServer()
11101110
| `WithoutValidation()` | Disable request validation |
11111111
| `WithValidationConfig(cfg)` | Configure validation behavior |
11121112
| `WithRecovery()` | Enable panic recovery middleware |
1113-
| `WithRequestLogger(fn)` | Add request logging |
1113+
| `WithRequestLogging(fn)` | Enable request logging with a logger function |
11141114
| `WithErrorHandler(fn)` | Custom error handler |
11151115
| `WithNotFoundHandler(h)` | Custom 404 handler |
11161116
| `WithMethodNotAllowedHandler(h)` | Custom 405 handler |
1117+
| `WithRouter(strategy)` | Set the routing strategy |
1118+
| `WithStdlibRouter()` | Use net/http with PathMatcherSet for routing (default) |
11171119

11181120
[↑ Back to top](#top)
11191121

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package commands
2+
3+
import (
4+
"flag"
5+
"os"
6+
"path/filepath"
7+
"regexp"
8+
"runtime"
9+
"strings"
10+
"testing"
11+
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
// TestCLIFlagsDocumented verifies that every registered CLI flag appears in
17+
// docs/cli-reference.md, and every documented flag corresponds to a registered flag.
18+
func TestCLIFlagsDocumented(t *testing.T) {
19+
// Resolve docs path from this test file's location.
20+
_, thisFile, _, ok := runtime.Caller(0)
21+
require.True(t, ok, "runtime.Caller(0) failed to retrieve file path")
22+
docPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "docs", "cli-reference.md")
23+
24+
docBytes, err := os.ReadFile(docPath)
25+
require.NoError(t, err, "reading cli-reference.md")
26+
docContent := string(docBytes)
27+
28+
// Build the map of command -> FlagSet by calling every Setup*Flags function.
29+
commands := map[string]*flag.FlagSet{
30+
"validate": mustFS(SetupValidateFlags()),
31+
"parse": mustFS(SetupParseFlags()),
32+
"fix": mustFS(SetupFixFlags()),
33+
"convert": mustFS(SetupConvertFlags()),
34+
"diff": mustFS(SetupDiffFlags()),
35+
"join": mustFS(SetupJoinFlags()),
36+
"generate": mustFS(SetupGenerateFlags()),
37+
"overlay apply": mustFS(SetupOverlayApplyFlags()),
38+
"overlay validate": mustFS(SetupOverlayValidateFlags()),
39+
"walk operations": mustFS(SetupWalkOperationsFlags()),
40+
"walk schemas": mustFS(SetupWalkSchemasFlags()),
41+
"walk parameters": mustFS(SetupWalkParametersFlags()),
42+
"walk responses": mustFS(SetupWalkResponsesFlags()),
43+
"walk security": mustFS(SetupWalkSecurityFlags()),
44+
"walk paths": mustFS(SetupWalkPathsFlags()),
45+
}
46+
47+
// Parse documented flags from markdown tables per command section.
48+
knownCmds := make(map[string]bool, len(commands))
49+
for name := range commands {
50+
knownCmds[name] = true
51+
}
52+
docFlags := parseDocFlags(docContent, knownCmds)
53+
54+
for cmdName, fs := range commands {
55+
t.Run(cmdName, func(t *testing.T) {
56+
documented := docFlags[cmdName]
57+
require.NotNil(t, documented, "no flag table found in cli-reference.md for command %q", cmdName)
58+
59+
// Collect registered long-form flag names (skip single-char aliases and help).
60+
registeredSet := make(map[string]bool)
61+
fs.VisitAll(func(f *flag.Flag) {
62+
if len(f.Name) == 1 || f.Name == "help" {
63+
return
64+
}
65+
registeredSet[f.Name] = true
66+
})
67+
68+
// Check: every registered flag should be documented.
69+
for name := range registeredSet {
70+
assert.True(t, documented[name], "flag --%s is registered for %q but not documented in cli-reference.md", name, cmdName)
71+
}
72+
73+
// Check: every documented flag should be registered.
74+
for name := range documented {
75+
if name == "help" {
76+
continue
77+
}
78+
assert.True(t, registeredSet[name], "flag --%s is documented for %q in cli-reference.md but not registered", name, cmdName)
79+
}
80+
})
81+
}
82+
}
83+
84+
// mustFS extracts the *flag.FlagSet from a Setup*Flags return pair.
85+
func mustFS[T any](fs *flag.FlagSet, _ T) *flag.FlagSet {
86+
return fs
87+
}
88+
89+
// parseDocFlags parses cli-reference.md and returns a map of command name -> set of documented flag names.
90+
// It looks for markdown sections (## command / ### subcommand) and flag table rows (| `--flagname` |).
91+
// knownCmds is the authoritative set of command names to match against section headers.
92+
func parseDocFlags(content string, knownCmds map[string]bool) map[string]map[string]bool {
93+
result := make(map[string]map[string]bool)
94+
95+
// Split into lines for section tracking.
96+
lines := strings.Split(content, "\n")
97+
98+
// allFlagsRe matches all --flag-name occurrences in a table row.
99+
// This handles rows like:
100+
// | `--flag-name` | description |
101+
// | `-s, --flag-name` | description |
102+
// | `--prune-all, --prune` | description |
103+
// | `-o, --flag-name string` | description |
104+
allFlagsRe := regexp.MustCompile(`--([a-zA-Z][a-zA-Z0-9-]*)`)
105+
106+
var currentCmd string
107+
108+
for _, line := range lines {
109+
trimmed := strings.TrimSpace(line)
110+
111+
// Track current section from ## and ### headers.
112+
// - ## headers are top-level commands: set currentCmd if recognized, reset otherwise
113+
// - ### headers are subcommands (overlay apply, walk operations, etc.): set if recognized, keep otherwise
114+
// - #### headers are sub-subsections (Flags, Examples): always ignore
115+
if strings.HasPrefix(trimmed, "## ") || strings.HasPrefix(trimmed, "### ") {
116+
var headerText string
117+
switch {
118+
case strings.HasPrefix(trimmed, "### "):
119+
headerText = strings.TrimPrefix(trimmed, "### ")
120+
case strings.HasPrefix(trimmed, "## "):
121+
headerText = strings.TrimPrefix(trimmed, "## ")
122+
}
123+
headerText = strings.TrimSpace(headerText)
124+
headerLower := strings.ToLower(headerText)
125+
126+
if knownCmds[headerLower] {
127+
currentCmd = headerLower
128+
if result[currentCmd] == nil {
129+
result[currentCmd] = make(map[string]bool)
130+
}
131+
} else if strings.HasPrefix(trimmed, "## ") {
132+
// Reset currentCmd on unrecognized ## headers to avoid
133+
// attributing flags from unrelated sections to the previous command.
134+
currentCmd = ""
135+
}
136+
// For unrecognized ### headers (e.g., "### Flags", "### Examples"),
137+
// keep currentCmd as-is — they are subsections of the current command.
138+
}
139+
140+
// Extract flags from the first cell of table rows.
141+
if currentCmd != "" && strings.HasPrefix(trimmed, "|") {
142+
// Extract the first table cell (between the first and second |).
143+
parts := strings.SplitN(trimmed, "|", 3)
144+
if len(parts) >= 3 {
145+
firstCell := parts[1]
146+
for _, matches := range allFlagsRe.FindAllStringSubmatch(firstCell, -1) {
147+
if len(matches) > 1 {
148+
result[currentCmd][matches[1]] = true
149+
}
150+
}
151+
}
152+
}
153+
}
154+
155+
return result
156+
}

cmd/oastools/commands/walk_operations.go

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,27 +12,42 @@ import (
1212
"github.com/erraggy/oastools/walker"
1313
)
1414

15-
// handleWalkOperations implements the "walk operations" subcommand.
16-
// It collects operations from the spec, applies filters, and renders output.
17-
func handleWalkOperations(args []string) error {
15+
// WalkOperationsFlags contains flags for the walk operations subcommand.
16+
type WalkOperationsFlags struct {
17+
Method string
18+
Path string
19+
Tag string
20+
Deprecated bool
21+
OperationID string
22+
WalkFlags
23+
}
24+
25+
// SetupWalkOperationsFlags creates and configures a FlagSet for the walk operations subcommand.
26+
func SetupWalkOperationsFlags() (*flag.FlagSet, *WalkOperationsFlags) {
1827
fs := flag.NewFlagSet("walk operations", flag.ContinueOnError)
28+
flags := &WalkOperationsFlags{}
1929

20-
// Operation-specific flags
21-
method := fs.String("method", "", "Filter by HTTP method (e.g., get, post)")
22-
path := fs.String("path", "", "Filter by path pattern (supports glob with *)")
23-
tag := fs.String("tag", "", "Filter by tag")
24-
deprecated := fs.Bool("deprecated", false, "Only show deprecated operations")
25-
operationID := fs.String("operationId", "", "Select by operationId")
30+
fs.StringVar(&flags.Method, "method", "", "Filter by HTTP method (e.g., get, post)")
31+
fs.StringVar(&flags.Path, "path", "", "Filter by path pattern (supports glob with *)")
32+
fs.StringVar(&flags.Tag, "tag", "", "Filter by tag")
33+
fs.BoolVar(&flags.Deprecated, "deprecated", false, "Only show deprecated operations")
34+
fs.StringVar(&flags.OperationID, "operationId", "", "Select by operationId")
2635

27-
// Common walk flags
28-
var flags WalkFlags
2936
fs.StringVar(&flags.Format, "format", FormatText, "Output format: text, json, yaml")
3037
fs.BoolVar(&flags.Quiet, "quiet", false, "Suppress headers and decoration")
3138
fs.BoolVar(&flags.Quiet, "q", false, "Suppress headers and decoration (shorthand)")
3239
fs.BoolVar(&flags.Detail, "detail", false, "Show full operation instead of summary table")
3340
fs.StringVar(&flags.Extension, "extension", "", "Filter by extension (e.g., x-internal=true)")
3441
fs.BoolVar(&flags.ResolveRefs, "resolve-refs", false, "Resolve $ref pointers before output")
3542

43+
return fs, flags
44+
}
45+
46+
// handleWalkOperations implements the "walk operations" subcommand.
47+
// It collects operations from the spec, applies filters, and renders output.
48+
func handleWalkOperations(args []string) error {
49+
fs, flags := SetupWalkOperationsFlags()
50+
3651
if err := fs.Parse(args); err != nil {
3752
if errors.Is(err, flag.ErrHelp) {
3853
return nil
@@ -63,7 +78,7 @@ func handleWalkOperations(args []string) error {
6378
// 2. Filter
6479
matched := collector.All
6580

66-
matched, err = filterOperations(matched, *method, *path, *tag, *deprecated, *operationID, flags.Extension)
81+
matched, err = filterOperations(matched, flags.Method, flags.Path, flags.Tag, flags.Deprecated, flags.OperationID, flags.Extension)
6782
if err != nil {
6883
return err
6984
}
@@ -75,9 +90,9 @@ func handleWalkOperations(args []string) error {
7590

7691
// 3. Render
7792
if flags.Detail {
78-
return renderOperationsDetail(matched, flags)
93+
return renderOperationsDetail(matched, flags.WalkFlags)
7994
}
80-
return renderOperationsSummary(matched, flags)
95+
return renderOperationsSummary(matched, flags.WalkFlags)
8196
}
8297

8398
// filterOperations applies all operation filters and returns the matching subset.

cmd/oastools/commands/walk_parameters.go

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,30 +18,39 @@ type parameterDetailView struct {
1818
Parameter *parser.Parameter `json:"parameter" yaml:"parameter"`
1919
}
2020

21-
// handleWalkParameters implements the "walk parameters" subcommand.
22-
func handleWalkParameters(args []string) error {
23-
fs := flag.NewFlagSet("walk parameters", flag.ContinueOnError)
21+
// WalkParametersFlags contains flags for the walk parameters subcommand.
22+
type WalkParametersFlags struct {
23+
In string
24+
Name string
25+
Path string
26+
Method string
27+
WalkFlags
28+
}
2429

25-
// Subcommand-specific flags
26-
var filterIn string
27-
var filterName string
28-
var filterPath string
29-
var filterMethod string
30+
// SetupWalkParametersFlags creates and configures a FlagSet for the walk parameters subcommand.
31+
func SetupWalkParametersFlags() (*flag.FlagSet, *WalkParametersFlags) {
32+
fs := flag.NewFlagSet("walk parameters", flag.ContinueOnError)
33+
flags := &WalkParametersFlags{}
3034

31-
fs.StringVar(&filterIn, "in", "", "Filter by location (path, query, header, cookie)")
32-
fs.StringVar(&filterName, "name", "", "Filter by parameter name")
33-
fs.StringVar(&filterPath, "path", "", "Filter by owning path pattern (supports glob with *)")
34-
fs.StringVar(&filterMethod, "method", "", "Filter by owning operation method")
35+
fs.StringVar(&flags.In, "in", "", "Filter by location (path, query, header, cookie)")
36+
fs.StringVar(&flags.Name, "name", "", "Filter by parameter name")
37+
fs.StringVar(&flags.Path, "path", "", "Filter by owning path pattern (supports glob with *)")
38+
fs.StringVar(&flags.Method, "method", "", "Filter by owning operation method")
3539

36-
// Common flags
37-
var flags WalkFlags
3840
fs.StringVar(&flags.Format, "format", FormatText, "Output format: text, json, yaml")
39-
fs.BoolVar(&flags.Quiet, "q", false, "Suppress headers and decoration")
4041
fs.BoolVar(&flags.Quiet, "quiet", false, "Suppress headers and decoration")
42+
fs.BoolVar(&flags.Quiet, "q", false, "Suppress headers and decoration (shorthand)")
4143
fs.BoolVar(&flags.Detail, "detail", false, "Show full parameter instead of summary table")
4244
fs.StringVar(&flags.Extension, "extension", "", "Filter by extension (e.g., x-internal=true)")
4345
fs.BoolVar(&flags.ResolveRefs, "resolve-refs", false, "Resolve $ref pointers before output")
4446

47+
return fs, flags
48+
}
49+
50+
// handleWalkParameters implements the "walk parameters" subcommand.
51+
func handleWalkParameters(args []string) error {
52+
fs, flags := SetupWalkParametersFlags()
53+
4554
if err := fs.Parse(args); err != nil {
4655
if errors.Is(err, flag.ErrHelp) {
4756
return nil
@@ -58,9 +67,6 @@ func handleWalkParameters(args []string) error {
5867
}
5968
specPath := fs.Arg(0)
6069

61-
// Normalize method filter to lowercase
62-
filterMethod = strings.ToLower(filterMethod)
63-
6470
// 1. Collect
6571
result, err := parseSpec(specPath, flags.ResolveRefs)
6672
if err != nil {
@@ -85,16 +91,16 @@ func handleWalkParameters(args []string) error {
8591

8692
var filtered []*walker.ParameterInfo
8793
for _, info := range collector.All {
88-
if filterIn != "" && !strings.EqualFold(info.In, filterIn) {
94+
if flags.In != "" && !strings.EqualFold(info.In, flags.In) {
8995
continue
9096
}
91-
if filterName != "" && !strings.EqualFold(info.Name, filterName) {
97+
if flags.Name != "" && !strings.EqualFold(info.Name, flags.Name) {
9298
continue
9399
}
94-
if !matchPath(info.PathTemplate, filterPath) {
100+
if !matchPath(info.PathTemplate, flags.Path) {
95101
continue
96102
}
97-
if filterMethod != "" && info.Method != filterMethod {
103+
if flags.Method != "" && !strings.EqualFold(info.Method, flags.Method) {
98104
continue
99105
}
100106
if hasExtFilter && !extFilter.Match(info.Parameter.Extra) {

0 commit comments

Comments
 (0)