@@ -49,8 +49,8 @@ def parser_surface() -> tuple[dict[str, dict[str, object]], set[str]]:
4949
5050 def walk (parser : argparse .ArgumentParser , prefix : str ) -> None :
5151 opts : dict [str , object ] = {}
52- for action in parser ._actions : # noqa: SLF001 — argparse exposes no public API
53- if isinstance (action , argparse ._SubParsersAction ): # noqa: SLF001
52+ for action in parser ._actions :
53+ if isinstance (action , argparse ._SubParsersAction ):
5454 for name , sub in action .choices .items ():
5555 walk (sub , f"{ prefix } { name } " )
5656 for opt in action .option_strings :
@@ -83,6 +83,77 @@ def _defaults_agree(claimed: str, actual: object) -> bool:
8383 return str (actual ).lower () in c
8484
8585
86+ def check_usage_blocks (problems : list [str ]) -> int :
87+ """Every `usage: da ...` block in the docs must match the parser.
88+
89+ The flag-table check above catches a flag that is documented wrongly or
90+ not at all. It does not look at the `usage:` line above the table, and
91+ that line drifts independently — two were wrong when this was added:
92+ `da daily` had gained `--json`, and `da sync watched` had gained a
93+ mutually-exclusive group, so the docs showed
94+ `[--user USER] [--via-feed]` where argparse prints
95+ `[--user USER | --via-feed]`. Both are exactly the kind of detail a
96+ reader copies verbatim.
97+
98+ Compared on the first line only, and whitespace-normalised: argparse
99+ wraps to terminal width, so the continuation lines are cosmetic.
100+ """
101+ checked = 0
102+ for doc in sorted ((REPO / "docs" ).rglob ("*.md" )):
103+ # cli.md is generated by tools/gen_cli_docs.py from this same parser
104+ # and is verified by a git-diff check in CI; re-checking it here
105+ # would just duplicate that.
106+ if doc .name == "cli.md" :
107+ continue
108+ rel = doc .relative_to (REPO )
109+ for lineno , line in enumerate (doc .read_text ().splitlines (), 1 ):
110+ if not line .startswith ("usage: da " ):
111+ continue
112+ checked += 1
113+ parts = line [len ("usage: " ) :].split ()
114+ path : list [str ] = []
115+ for tok in parts [1 :]:
116+ if tok .startswith (("[" , "-" , "{" )):
117+ break
118+ path .append (tok )
119+ sub = _resolve (path )
120+ if sub is None :
121+ problems .append (
122+ f"{ rel } :{ lineno } : `usage: da { ' ' .join (path )} ` names no such command"
123+ )
124+ continue
125+ real = sub .format_usage ().strip ().splitlines ()[0 ]
126+ if _norm (real ) != _norm (line ):
127+ problems .append (
128+ f"{ rel } :{ lineno } : usage line drifted\n doc: { line } \n real: { real } "
129+ )
130+ return checked
131+
132+
133+ def _norm (s : str ) -> str :
134+ return " " .join (s .split ())
135+
136+
137+ def _resolve (path : list [str ]) -> argparse .ArgumentParser | None :
138+ """Walk the subparser tree to the parser for `da <path...>`.
139+
140+ Reaching into `_actions` / `_SubParsersAction` is the only way argparse
141+ exposes its subparser tree; there is no public accessor. The rest of
142+ this file and tools/gen_cli_docs.py do the same for the same reason.
143+ """
144+ cur = dacli .build_parser ()
145+ for name in path :
146+ nxt = None
147+ for a in cur ._actions :
148+ if isinstance (a , argparse ._SubParsersAction ) and name in a .choices :
149+ nxt = a .choices [name ]
150+ break
151+ if nxt is None :
152+ return None
153+ cur = nxt
154+ return cur
155+
156+
86157def main () -> int :
87158 surface , known = parser_surface ()
88159 problems : list [str ] = []
@@ -162,12 +233,18 @@ def main() -> int:
162233 if missing :
163234 problems .append (f"{ rel } : `{ command } ` table omits { sorted (missing )} " )
164235
236+ usage_checked = check_usage_blocks (problems )
237+
165238 for p in problems :
166239 print (p )
240+
167241 if problems :
168- print (f"\n { len (problems )} flag-table problem(s)." , file = sys .stderr )
242+ print (f"\n { len (problems )} documentation problem(s)." , file = sys .stderr )
169243 return 1
170- print (f"{ rows_checked } flag rows across { tables_checked } tables match the parser" )
244+ print (
245+ f"{ rows_checked } flag rows across { tables_checked } tables and "
246+ f"{ usage_checked } usage lines match the parser"
247+ )
171248 return 0
172249
173250
0 commit comments