Skip to content

docs: describe every CLI argument and MCP property, with tests that keep them described (#39) - #49

Open
vlsi wants to merge 8 commits into
aeroxy:mainfrom
vlsi:claude/ast-bro-issue-39-ea8f73
Open

docs: describe every CLI argument and MCP property, with tests that keep them described (#39)#49
vlsi wants to merge 8 commits into
aeroxy:mainfrom
vlsi:claude/ast-bro-issue-39-ea8f73

Conversation

@vlsi

@vlsi vlsi commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Why

The projection work for #39 landed in e50056e and works, but nothing outside the source said so. The five --no-* flags carried no clap doc comment at all, so map --help printed them with an empty description:

$ ast-bro map --help
      --no-private
          
      --no-fields
          

A caller hitting the oversized-payload problem the issue describes had no way to find the lever that fixes it.

Documenting those five by hand leaves the next empty description to whoever happens to run --help, so this asks the code instead — and five was not the number. A guard over Cli::command() found 62 more; the maintainer's review then pointed out that guard can never reach src/mcp/tools.rs, where 21 of 109 properties were blank. Two surfaces, documented separately, so guarded separately.

What

Five commits, each building and testing on its own.

1. docs: the #39 flags. Each --no-* gets a description. --no-docs and --no-lines additionally name the payload keys they remove, since docs_inside and the byte offsets do not follow from the flag names; the other three don't, because "hide private declarations" is the whole story. --json names the projected object a stripped payload carries, --max-members the truncated / dropped_members pair — the marker half of #32, previously documented for the text renderer only. Same facts in README.md, wiki/architecture.md, skills/ast-bro/SKILL.md, and the MCP schemas.

2. docs: the other 62 CLI arguments, across sixteen subcommands: every positional of show, implements, deps, reverse-deps, cycles, graph, the whole flag set of install / uninstall / status / hook, and the --json / --compact / --rebuild triple on nine commands that had documented it elsewhere. Repeated flags reuse the wording already in the file. Descriptions come from the code, not the flag names: --always and --min-lines from hook::decide, --force from the two conflict branches in installers::common (which fail the install rather than skipping it, and only one of which carries a diff), --global from resolve_scope, where it is the default rather than a distinct mode.

3. test: the CLI guard. Walks the subcommand tree from Cli::command(); every argument needs help text, every subcommand an about line. Both walks recurse. Whitespace-only counts as missing.

4. docs: every MCP property, and three removed. The 21 blank properties are described, and each json names the schema its tool actually returns rather than the .v1 the pattern suggests — show is ast-bro.show.v2, index is ast-bro.index-stats.v1.

Three of the six rebuild properties are removed instead of described. CallersArgs / CalleesArgs / TraceArgs declare no such field and nothing sets deny_unknown_fields, so serde drops the key and load_calls_graph calls get_or_init regardless: tools/call callers {rebuild: true} returns isError: false and normal rows. Describing an inert flag is worse than the blank it replaced — a blank gives an agent no grounds to believe anything. rebuild now appears only on the five tools that read it, matching impact and context, which never advertised it. A client still sending the key gets the same result as before; only the false promise is gone.

5. test: the MCP guard. Same shape over tools::list(). inputSchema.properties is demanded rather than probed: as_object() answers None for an absent key and a misspelled one alike, so if let Some would let a typo drop a whole tool's properties past the check meant to police them.

No behavior change beyond that one schema removal: the only non-comment lines added to src/lib.rs are the test module.

How to verify

cargo test --lib every_

Removing any one doc comment turns it red and names the argument:

arguments with no help text (add a doc comment above the field): [
    "ast-bro map no_attrs",
    "ast-bro digest no_attrs",
]

Live tools/list is 19 tools, 106 properties, 0 undescribed, with rebuild on deps / reverse_deps / cycles / graph / index only. Full suite: 26 test binaries green, on each of the five commits in isolation.

Split out

The show defect found while verifying — a markdown heading containing / is rejected as a mistyped path and never searched — is #55, with a minimal reproducer. Pre-existing, untouched here.

Not addressed, noted for whoever wants them: run's json is the one of nineteen not naming its schema at the property level (the tool description does), and impact / context expose no rebuild over MCP despite having --rebuild on the CLI.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c0258d21-46ad-43a0-8153-c796c9a7490a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@vlsi
vlsi force-pushed the claude/ast-bro-issue-39-ea8f73 branch 2 times, most recently from 265c111 to 1a2ff3d Compare August 9, 2026 12:37
@vlsi vlsi changed the title docs: document the JSON projection flags in --help and the wiki (#39) docs: document every CLI argument, with a test that keeps them documented (#39) Aug 9, 2026
@vlsi
vlsi force-pushed the claude/ast-bro-issue-39-ea8f73 branch from 68a9e42 to c33d93d Compare August 9, 2026 19:40
@aeroxy

aeroxy commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Why

The projection work for #39 landed in e50056e and works, but nothing outside the source said so. The five --no-* flags carried no clap doc comment at all, so map --help printed them with an empty description:

$ ast-bro map --help
      --no-private
          
      --no-fields
          

A caller hitting the oversized-payload problem the issue describes had no way to find the lever that fixes it.

Writing those five by hand leaves the next empty description to whoever happens to run --help, so the second commit asks clap instead — and it turned out five was not the number.

What

Documentation and one test. No behavior change: the only non-comment line the diff adds to src/lib.rs is the test module.

Commit 1 — the #39 flags. Each --no-* flag gets a description. --no-docs and --no-lines additionally name the payload keys they remove, since docs_inside and the byte offsets do not follow from the flag names; the other three don't, because "hide private declarations" is the whole story. --json names the projected object a stripped payload carries, --max-members the truncated / dropped_members pair. Same facts in wiki/architecture.md, README.md, skills/ast-bro/SKILL.md, and the MCP tool schemas.

Commit 2 — the test, and the 62 it found. every_argument_documents_itself walks the subcommand tree from Cli::command() and asserts every argument has help text; every_subcommand_documents_itself does the same for about lines. Whitespace-only counts as missing.

It failed immediately on 62 arguments across sixteen subcommands: every positional of show, implements, deps, reverse-deps, cycles, graph, the entire flag set of install / uninstall / status / hook, and the --json / --compact / --rebuild triple on nine commands that had documented it elsewhere. All are now described, reusing the wording the file already used for the repeated flags.

Descriptions were read off the code rather than guessed: --always and --min-lines from hook::decide, --force from the unmanaged-content branch in installers::common, --global from resolve_scope — where it is the default rather than a distinct mode, so it is documented as such.

How to verify

cargo test --lib every_

Removing any one doc comment turns it red and names the argument:

arguments with no help text (add a doc comment above the field): [
    "ast-bro map no_attrs",
    "ast-bro digest no_attrs",
]

Full suite: 26 test binaries, all green.

🤖 Generated with Claude Code

Great. One finding, and it's the interesting kind: the same defect, in a file this PR edits.

Verified rather than assumed

The premise is real. Diffing map --help against main, five blank description lines become five real ones:

       --no-private
-          
+          Hide private declarations
       --no-fields
-          
+          Hide fields, properties, events, and indexers

The guard test genuinely bites. Deleted the --no-attrs doc comment and it failed naming both call sites, matching the PR body byte for byte:

arguments with no help text (add a doc comment above the field): [
    "ast-bro map no_attrs",
    "ast-bro digest no_attrs",
]

"62 arguments across sixteen subcommands" is exact. Reproduced it by running the test at commit 1: 62 entries, 16 distinct subcommands.

The descriptions are read off the code, as claimed. Spot-checked the ones that assert something a reader couldn't guess:

  • --no-fields says "fields, properties, events, and indexers" — _map_eligible matches Field | Property | Event | Indexer. Exact.
  • --no-docs and --no-lines both list doc_start_byte, which looks like a copy-paste error and isn't: _strip_projected_keys drops it under either flag.
  • --always matches the if !opts.always bypass in hook::decide.
  • --global — "The default; pass it to say so" is the honest description of resolve_scope(local, _global), which ignores the parameter outright.
  • "status lists the names" — it does, first column.

And "the only non-comment line the diff adds to src/lib.rs is the test module" holds.

Finding: the MCP surface has the same defect, and the new test can't see it

tools/list over the built binary: 21 of 109 tool properties ship with no description.

json     — show, implements, surface, deps, reverse_deps, cycles,
           find_related, index, callers, callees, trace, impact, context
rebuild  — reverse_deps, cycles, graph, callers, callees, trace
digest.include_private, digest.include_fields

Those are the same flags commit 2 just documented on the CLI side — the --json / --rebuild pair it found on nine commands. And digest.include_private / digest.include_fields are untouched context lines in this PR's own diff hunk, two lines above the max_members description it rewrote.

The structural half matters more than the count. collect_undocumented walks Cli::command(), so it can never reach src/mcp/tools.rs — a hand-written JSON literal with no guard of any kind. The PR's own argument applies verbatim: writing these by hand leaves the next empty description to whoever happens to call tools/list. An agent reading the MCP schema is the audience least able to go find the answer elsewhere.

Same shape as the CLI test, over mcp::tools::list(): assert every properties.* has a non-blank description, and every tool has one too (all 19 currently do).

Not a finding

every_subcommand_documents_itself passes on arrival — no variant was missing an about line, and no variant doc comments appear in the diff. That's fine, and the test's own comment says so ("the tree is flat today, which is exactly when a check like this stops being enforced and starts being assumed"). Only noting it because the PR body pairs it with the 62 as though both found something.

@vlsi
vlsi force-pushed the claude/ast-bro-issue-39-ea8f73 branch from 872a50e to 3a2df7c Compare August 10, 2026 07:22
@vlsi

vlsi commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — the MCP finding was right, and it turned out to have a second half.

Your finding. All 21 blank properties now have descriptions, and each json names the schema its tool actually returns rather than the .v1 the pattern suggests — show is ast-bro.show.v2, index is ast-bro.index-stats.v1. Guarded the same way the CLI is, over tools::list(): every_tool_and_property_documents_itself. Live tools/list is now 106 properties, 0 undescribed.

The second half. Three of the six rebuild properties are removed rather than described. CallersArgs / CalleesArgs / TraceArgs declare no such field and nothing sets deny_unknown_fields, so serde drops the key and load_calls_graph calls get_or_init regardless — tools/call callers {rebuild: true} returns isError: false and normal rows. Describing it would have been worse than the blank it replaced: a blank gives an agent no grounds to believe anything. rebuild now appears only on the five tools that read it, matching impact and context, which never advertised it. A client still sending the key gets the same result as before; only the false promise is gone.

The guard also demands inputSchema.properties instead of probing with if let Someas_object() answers None for an absent key and a misspelled one alike, so the probe would have let a typo drop a whole tool's properties past the check meant to police them.

Also in this push. History is restructured into five independent commits, each building and testing on its own — the earlier fix-up commits are folded into what they corrected. Rebased onto current main; three Markdown files conflicted where upstream had moved frontier_truncated into its own paragraph, resolved by taking upstream and reapplying only this branch's own clause.

Split out. The show defect I hit while verifying — a markdown heading containing / is rejected as a mistyped path and never searched — is #55, with a minimal reproducer. Pre-existing, untouched here.

Not addressed, noted for whoever wants them: run's json is the one of nineteen not naming its schema at the property level (the tool description does), and impact / context expose no rebuild over MCP at all despite having --rebuild on the CLI.

@vlsi vlsi changed the title docs: document every CLI argument, with a test that keeps them documented (#39) docs: describe every CLI argument and MCP property, with tests that keep them described (#39) Aug 10, 2026
@aeroxy

aeroxy commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Reviewed the whole thing, @vlsi — this is a clean PR and I like the shape of it: documenting by hand would have left the next blank description to whoever happened to run --help, and the two guards are the right answer. One fix and I'll merge.

no_fields / include_fields understate what they drop. src/mcp/tools.rs:29 and :53 both say "field declarations", but _map_eligible also drops Property | Event | Indexer. You corrected the CLI's --no-fields in this same PR — "Hide fields, properties, events, and indexers" — and the MCP twin didn't get the same treatment. An agent mapping a C# or Kotlin file with no_fields: true loses every property and concludes the type has none, which is the exact silent-wrong-answer shape the rest of this PR is out to eliminate.

Everything else held up under direct testing:

  • The CLI guard isn't vacuous. I deleted the --no-attrs doc comment and it failed as advertised, naming ["ast-bro map no_attrs", "ast-bro digest no_attrs"].
  • The rebuild removal is correct, and I agree with the reasoning. CallersArgs / CalleesArgs / TraceArgs declare no such field and nothing sets deny_unknown_fields, so serde was dropping the key outright. It's also not a capability loss: graph_cache::shared::get_or_init re-validates against the working tree on every call and patches in place, so freshness never depended on the flag. Clients still sending the key get the same result as before.
  • Every new schema-name claim traces to the actual constant, including the two that break the .v1 pattern (show.v2, index-stats.v1 via run_indexrender_index_stats_json).
  • The projection claims match _strip_projected_keys — exactly docs/docs_inside/doc_start_byte under --no-docs, the four offsets plus doc_start_byte under --no-lines — and run_digest does set include_docs: false, so the digest --jsonprojected claim holds.
  • The hand-written help text I most expected to drift all matches behaviour: --min-lines against line_count_at_least (it's >=, so "files below this are read as-is" is right), --global against resolve_scope (genuinely the default rather than a distinct mode), --force against the two conflict branches in installers/common.rs, cycles --min-size 2 against single-node SCCs.

Tests green here — cargo test --lib every_, mcp_e2e, cli_ergonomics, plus the full suite. cargo clippy --all-targets is clean apart from a pre-existing warning in src/search/chunker.rs:1558 that you didn't touch.

Thanks for splitting the show heading-with-/ defect out to #55 rather than folding it in here. Fix the MCP wording and this is good to go.

vlsi and others added 5 commits August 12, 2026 10:43
…eroxy#39)

Issue aeroxy#39 asked for the `--no-*` flags to apply to `--json`, which they
now do. Nothing outside the source said so: the five flags carried no
clap doc comment at all, so `map --help` and `digest --help` printed
them with an empty description, and neither the wiki nor the MCP tool
schema mentioned that a projection reaches the payload.

`--no-docs` and `--no-lines` name the payload keys they remove, since
`docs_inside` and the byte offsets do not follow from the flag names;
the other three do not, because "hide private declarations" is the whole
story. `--json` names the `projected` object a stripped payload carries,
and `--max-members` the `truncated` / `dropped_members` pair reporting
its cut — the marker half of aeroxy#32, previously documented for the text
renderer only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The aeroxy#39 flags were not the only ones shipping with an empty `--help`
description; they were the ones someone noticed. 62 more across sixteen
subcommands had no clap doc comment: every positional of `show`,
`implements`, `deps`, `reverse-deps`, `cycles`, and `graph`, the whole
flag set of `install` / `uninstall` / `status` / `hook`, and the
`--json` / `--compact` / `--rebuild` triple on nine commands that had
documented it elsewhere.

Repeated flags reuse the wording the file already used for them, so the
same flag reads the same way on every subcommand.

Descriptions come from the code, not from the flag names: `--always` and
`--min-lines` from `hook::decide`, `--force` from the two conflict
branches in `installers::common` — which fail the install rather than
skipping it, and only one of which carries a diff — and `--global` from
`resolve_scope`, where it is the default rather than a distinct mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Documenting the arguments by hand leaves the next empty description to
whoever happens to run `--help`. This asks clap instead: walk the
subcommand tree from `Cli::command()` and assert every argument has help
text, and every subcommand an about line. Both walks recurse, so a
nested subcommand is held to the rule its parent is. Whitespace-only
counts as missing — clap prints the line either way and the reader
learns nothing.

A failure names the subcommand path and the argument, so the fix is to
write the doc comment, not to extend a list here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CLI half of this branch documented the arguments an agent reaches
through `--help`. The MCP half reaches the same operations through
`tools/list`, and 21 of its 109 input properties carried no description:
`json` on thirteen tools, `rebuild` on six, and `include_private` /
`include_fields` on digest. The two digest ones sat as untouched context
lines in this branch's own hunk, two lines above a description it
rewrote.

Each `json` now names the schema its tool returns, read off the render
path rather than assumed from the pattern: `show` is `ast-bro.show.v2`,
`index` is `ast-bro.index-stats.v1`.

Three of the six `rebuild` properties are removed instead of described.
`CallersArgs`, `CalleesArgs`, and `TraceArgs` have no such field, and
nothing sets `deny_unknown_fields`, so serde drops the key and
`load_calls_graph` calls `get_or_init` regardless: `callers` with
`rebuild: true` returns normal rows and no error. Describing it would
have been worse than the blank it replaced — a blank gives an agent no
grounds to believe anything. `rebuild` now appears only on the five
tools that read it, matching `impact` and `context`, which never
advertised it.

`no_fields` and `include_fields` name every kind they act on, on both
surfaces. `_map_eligible` matches `Field | Property | Event | Indexer`,
so "field declarations" understated it: an agent mapping a C# or Kotlin
file with `no_fields: true` loses every property and concludes the type
has none — the silent-wrong-answer shape this branch is out to remove.
The CLI's `--include-fields` said the same and is corrected with them,
so the pair describes one set in one wording.

An agent reading the schema is the audience least able to go and find
the answer somewhere else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sibling CLI test walks `Cli::command()`, so it can never reach
`src/mcp/tools.rs` — a hand-written JSON literal with no guard of any
kind. Two surfaces documented separately need guarding separately, or
the next blank description waits for whoever calls `tools/list`.

Same shape as the CLI test, over `tools::list()`: every tool and every
input property carries a non-blank description, and a failure names
`<tool>.<property>`.

`inputSchema.properties` is demanded rather than probed. `as_object()`
answers `None` for a key that is absent and for one that is misspelled
alike, so accepting `None` as "this tool takes no arguments" would let a
typo drop a whole tool's properties past the check that exists to police
them; a tool that genuinely takes none says so with an empty object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vlsi
vlsi force-pushed the claude/ast-bro-issue-39-ea8f73 branch from 3a2df7c to 06d04fe Compare August 12, 2026 07:56
@vlsi

vlsi commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Fixed, and it was in three places rather than two.

map.no_fields and digest.include_fields now read "fields, properties, events, and indexers", matching _map_eligible's Field | Property | Event | Indexer and the CLI wording. Your framing of the failure is what made the third one worth finding: the CLI's own --include-fields still said "Include fields", so --no-fields and --include-fields described the same set two different ways. That one predates this branch; I corrected it alongside rather than leave the pair inconsistent right after a conversation about exactly this.

map.no_fields:          Hide fields, properties, events, and indexers.
digest.include_fields:  Include fields, properties, events, and indexers, overriding the digest preset.
--include-fields:       Include fields, properties, events, and indexers (overrides a preset that hides them)

Folded into the MCP commit rather than appended, so the history stays five independent commits with no fix-up on top.

Rebased onto aae4c86, which was not a clean replay — the defaults refactor landed in exactly the lines this branch documents. Three of the five commits conflicted:

  • tools.rs (8): value from upstream (format! over crate::defaults::*), description from here. trace needed care so the removed rebuild did not come back with the upstream hunk — it did not.
  • lib.rs (9): upstream swapped literals for constants where this branch added doc comments; merged as upstream's attribute plus this branch's comment.
  • lib.rs test module: upstream added its own #[cfg(test)] mod tests where this branch adds one. Merged into a single module; upstream's use super::* already covers Cli, so the narrower import here is gone.

Checked that no hardcoded default crept back in during the resolution: 52 crate::defaults:: references intact, no default 50 / default 200 / etc. left in any description.

Live tools/list: 19 tools, 106 properties, 0 undescribed, rebuild on deps / reverse_deps / cycles / graph / index only. All five commits build and pass 26 test binaries in isolation. cargo clippy --all-targets clean apart from the chunker.rs warning you flagged as pre-existing.

@aeroxy

aeroxy commented Aug 16, 2026

Copy link
Copy Markdown
Owner

@vlsi thanks — the defaults centralisation is a real improvement over what I asked for, and I'd take src/defaults.rs even without the docs work attached to it. Re-reviewed the whole thing at 10 commits. Six things, and the first one is my fault.

I got the no_fields finding wrong last round

I checked _map_eligible, saw Field | Property | Event | Indexer, and told you the CLI wording was correct and only the MCP twin was understated. That was the wrong conclusion: digest doesn't go through _map_eligible at all. _digest_one_flatten_types / _flatten_free_functions / _digest_members take DigestOptions and gate d.kind == Field only (src/core.rs:1146, src/core.rs:1168). So the claim is wrong in both places, and the fix propagated it to MCP rather than narrowing it. Sorry for sending you the wrong way.

$ ast-bro digest W.cs        # --detail names, fields hidden
Name [property]  Changed [event]

Two consequences:

  • src/lib.rs:662--no-fields / --include-fields, and MCP map.no_fields / digest.include_fields, all promise properties/events/indexers are covered. Either route the digest path through _map_eligible or narrow the wording back to fields; I'd prefer the former, since the CLI behaviour is the surprising half.
  • wiki/architecture.md:54 — the new paragraph says _map_eligible is shared by the text renderer and the JSON filter "so the two cannot drift". They disagree today: digest W.cs shows the property and event, digest W.cs --json doesn't. Worth not asserting the invariant until it holds.

The two new guards reject legitimate code

Both fail the build on edits that aren't mistakes, which will land on whoever touches these files next rather than on this PR.

  • src/defaults.rs:379impl_default_bodies_hold_no_literals rejects any integer literal in any impl Default. Adding Self { hits: 0 } anywhere in the crate breaks cargo test. Reproduced. A counter starting at zero isn't a tunable default and there's no constant that would make it one.
  • src/defaults.rs:326struct_defaults_come_from_this_module matches on field name alone, so a deliberate non-default override trips it. DepOptions { max_depth: 1, ..Default::default() } fails with a message telling the author to move it into defaults.rs, which would be exactly wrong. Reproduced. The rule wants to distinguish "constructing the default" from "deliberately departing from it", and a field name can't carry that.

I like the intent — it's the same shape as the other two guards and it caught real drift. It just needs to not fire on the legitimate cases.

install --all --local writes to CLIs that aren't there

src/lib.rs:142, and uninstall --all at 181. The help you added says "every agent CLI detected on this machine", but select_all skips detection for Scope::Local:

$ cd "$(mktemp -d)" && ast-bro install --all --local --dry-run
# writes into all eight registered CLIs, none of them present

The help text is the accurate description of what a reader expects, so I'd treat this as the code being wrong rather than the doc — but it predates this PR, so splitting it out is fine by me if you'd rather not widen the scope.

Literal defaults in three new doc comments

src/lib.rs:351, :370, :475Cycles.path, Graph.path and Callees.path hard-code (default: "."), so --help renders:

Repository root to scan (default: ".") [default: .]

That's the drift this PR closes everywhere else, re-introduced in the docs half. The new guard doesn't catch it because it only scans "description" lines and never clap doc comments — worth extending, since that's the surface the guard was written for.

What checked out

  • All 16 constants round-trip unchanged: ROOT, LIMIT, SHOW_LIMIT, CALL_DEPTH, IMPACT_DEPTH, FILE_DEPTH, TRACE_DEPTH, TOP_K, MAX_MEMBERS, MAX_DOC_LINES, MAX_HEADING_DEPTH, BUDGET, MIN_SIZE, SURFACE_MAX_DEPTH, HOOK_MIN_LINES, IMPACT_MODE.
  • The three dropped rebuild properties are still genuinely inert, and every remaining schema property is backed by a real serde field.
  • Full suite green — 412 lib tests plus the integration binaries.

The docs half and the rebuild removal I'm happy with as-is. Fix the two guards and the no_fields claim and I'll merge; the install --all one is yours to split off if you'd rather.

vlsi and others added 2 commits August 16, 2026 12:37
The digest path tested `kind == Field` while the map path tested
`Field | Property | Event | Indexer`, so the same flag dropped different
members depending on which renderer answered:

    $ ast-bro digest W.cs               # C# type
    Name [property]  Changed [event]  Go()
    $ ast-bro digest W.cs --json        # same flags, same file
    Go

Reported by the maintainer on aeroxy#49, correcting his own earlier finding:
this branch had documented `--no-fields` as covering all four kinds,
which was true of `map --detail full` and false of `digest`. Rather than
narrow the wording to the weaker of the two behaviours, the two now share
one predicate.

`_member_visible` takes the two flags as plain booleans instead of an
options struct, which is what lets `MapOptions` and `DigestOptions` reach
the same answer — carrying the same pair of fields in separate structs is
how they came to disagree.

This changes what `digest` prints: a C# property or event, a Python
`@property`, are field-like, so the preset hides them and
`--include-fields` brings them back. README and the agent skill say so,
since the flag descriptions alone reach nobody reading either. Kotlin is
not among the examples on purpose — no `val` or `var` spelling produces a
field or property at all, so citing one would send a reader looking for
output that never appears.

Covered on all three routes to the predicate — text at each `--detail`
level, JSON, and MCP, the last in its own test because its option
plumbing is separate. `Indexer` is in the predicate but absent from the
fixtures: `csharp.rs` maps `indexer_declaration`, yet a C# `this[int i]`
yields no declaration at all, so a case would assert nothing.

The Python decorator test moves to `--include-fields`: a `@property` is
a field-like member, and that test is about the decorator reaching the
rendered modifier rather than about the projection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both guards rejected correct code: every integer literal in an
`impl Default`, so `Self { hits: 0 }` on a counter was refused, and every
field whose name reads a constant elsewhere, so
`DepOptions { max_depth: 1, ..Default::default() }` — a deliberate
departure — was told to move into `defaults.rs`, which would be exactly
wrong. Both reported by the maintainer on aeroxy#49, and both reproduced by
injecting the shapes, since no committed line has ever tripped either
guard.

The first attempt inferred intent from the source text: exempt every
zero, and track brace frames to find `..Default::default()`. Cross-review
took both apart. The zero rule reopened the hole the guard exists for.
The brace scan counted braces inside comments and strings, so replaying
it over `src/` showed 11 files whose frame stack never balances and 24
lines already exempted blind, while the two `..Type::default()` spellings
in the tree went unrecognised. Neither inference was exercised by any
line in the tree, which is why only a replay could show it.

So the guards stop guessing and ask. A line that is not making the
mistake says why:

    Self { hits: 0 } // defaults-ok: a counter's starting point

The reason is required, and the marker counts only in a comment the
compiler would see — a *value* spelling `"// defaults-ok: x"` exempts
nothing, because the comment split shares its string handling with the
rest of this module rather than scanning raw text.

The verdict is asserted as a composition, not predicate by predicate.
That distinction is what the second review round turned on: `is_exempt`
had a passing test while being unreachable, because a trailing comment
left the value unparseable and `is_literal_field` answered first — so
every commented line was silently exempt, `// TODO` as much as a stated
reason. `is_unmarked_literal` is what the guards call and what the table
covers, and the table counts the verdicts it observed rather than the
expectations it declared, so the coverage number measures the code
instead of the fixture.

Numbers are read the way this tree writes them: `budget: 8_000` is a
literal here, as `src/calls/trace.rs` and `src/lib.rs` already spell
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vlsi added a commit to vlsi/ast-bro that referenced this pull request Aug 16, 2026
`Cycles.path`, `Graph.path` and `Callees.path` each named their default
in prose beside a `default_value` that already prints it, so `--help`
rendered the value twice:

    [PATH]  Repository root to scan (default: ".") [default: .]

That is the drift this branch closes everywhere else, reintroduced in
the docs half — spotted by the maintainer on aeroxy#49, who noted the existing
guard could not see it: it scans MCP `"description"` strings and never
clap doc comments. `impact --mode` said `(default)` with no value, which
this guard does not flag and which was removed here anyway, since clap
appends `[default: all]` beside it.

The rule differs between the two surfaces, so this is a second guard
rather than a widened one. An MCP description is a runtime `String` and
the repair is to interpolate the constant; a clap doc comment is an
attribute fixed at compile time and cannot interpolate anything, so
there the rule is to say nothing and let clap say it once.

Six shapes cost four rounds of cross-review, and each has a case:

  - the doc block is read forwards, so a multi-line `#[arg(` cannot hide
    a `default_value` that is not its first argument;
  - an ordinary comment between the docs and the attribute does not end
    the declaration;
  - only clap's own attribute counts, matched at a word boundary, so
    neither `#[my_macro(default_value = 5)]` nor `#[my_arg(…)]` is
    mistaken for it while `#[cfg_attr(unix, arg(…))]` still is;
  - commented-out text inside another attribute cannot forge that match,
    because the compiler drops it before it means anything;
  - a `[` inside a string does not latch the attribute scan open;
  - and if some spelling this does not model latches it anyway, the scan
    asserts rather than going quiet — a guard that checks nothing must
    not report success, so the alarm has its own test.

A digit is a stated value only when the sentence ends on it, in the
spellings a default is actually written in: `8_000`, `200ms` and `4KB`
flag, while "the default 64-bit mode" and "the default 2 levels up" are
prose and pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Cycles.path`, `Graph.path` and `Callees.path` each named their default
in prose beside a `default_value` that already prints it, so `--help`
rendered the value twice:

    [PATH]  Repository root to scan (default: ".") [default: .]

That is the drift this branch closes everywhere else, reintroduced in
the docs half — spotted by the maintainer on aeroxy#49, who noted the existing
guard could not see it: it scans MCP `"description"` strings and never
clap doc comments. `impact --mode` said `(default)` with no value, which
this guard does not flag and which was removed here anyway, since clap
appends `[default: all]` beside it.

The rule differs between the two surfaces, so this is a second guard
rather than a widened one. An MCP description is a runtime `String` and
the repair is to interpolate the constant; a clap doc comment is an
attribute fixed at compile time and cannot interpolate anything, so
there the rule is to say nothing and let clap say it once.

Six shapes cost four rounds of cross-review, and each has a case:

  - the doc block is read forwards, so a multi-line `#[arg(` cannot hide
    a `default_value` that is not its first argument;
  - an ordinary comment between the docs and the attribute does not end
    the declaration;
  - only clap's own attribute counts, matched at a word boundary, so
    neither `#[my_macro(default_value = 5)]` nor `#[my_arg(…)]` is
    mistaken for it while `#[cfg_attr(unix, arg(…))]` still is;
  - commented-out text inside another attribute cannot forge that match,
    because the compiler drops it before it means anything;
  - a `[` inside a string does not latch the attribute scan open;
  - and if some spelling this does not model latches it anyway, the scan
    asserts rather than going quiet — a guard that checks nothing must
    not report success, so the alarm has its own test.

A digit is a stated value only when the sentence ends on it, in the
spellings a default is actually written in: `8_000`, `200ms` and `4KB`
flag, while "the default 64-bit mode" and "the default 2 levels up" are
prose and pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vlsi
vlsi force-pushed the claude/ast-bro-issue-39-ea8f73 branch from f21a88d to 8b4442a Compare August 16, 2026 11:00
@vlsi

vlsi commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

All four points addressed, plus the install --all one split out. Three new commits; the five existing ones are unchanged except where a claim in their message was wrong.

You were right that I got no_fields wrong, and it was wider than either of us said

digest really does gate on kind == Field alone. Rather than narrow the wording to the weaker behaviour, _member_visible is now the single predicate every renderer asks — and it takes the two flags as plain booleans rather than an options struct, since carrying the same pair of fields in separate MapOptions / DigestOptions is how they came to disagree in the first place.

Two things beyond what you reported: map --detail names --no-fields leaked the same way (it is the digest renderer under another name), and Python @property leaked with the C# ones.

This changes digest output, so README and the agent skill say so now — the flag descriptions alone reach nobody reading either. Covered on all three routes, the MCP one in its own test because its option plumbing is separate.

Two claims I could not honestly keep:

  • Kotlin. I wrote that --include-fields reveals a Kotlin val. It does not — constructor val, body val, computed val, var with setter, companion, data-class params, @JvmField: none emit a field or property at all. Replaced with Python @property, which does.
  • Indexers. csharp.rs:119 maps indexer_declaration, but public int this[int i] yields no declaration, so a test case would assert nothing. Said so in the test rather than faking coverage.

The wiki paragraph asserting _map_eligible "cannot drift" is rewritten — it was false when written.

The two guards

Both reproduced before fixing. My first repair inferred intent from the source text — exempt every zero, track brace frames for ..Default::default() — and cross-review took it apart:

  • the zero rule reopened the first-site hole the guard exists for;
  • the brace scan counted braces inside comments and strings. Replaying it over src/: 11 files whose frame stack never balances, 24 lines already exempted blind, and the two ..Type::default() spellings in the tree invisible to it.

Neither inference was exercised by a single line in the tree, so only a replay could show it. So the guards stop guessing and ask:

Self { hits: 0 } // defaults-ok: a counter's starting point

The reason is required, the marker counts only in a comment the compiler would see, and both failure messages name it so an author meets it at the failure rather than in defaults.rs. What the guards catch is unchanged: an unmarked Self { max_depth: 3 } is still caught by both.

Literal defaults in doc comments

Three sites removed; the guard flags 3 of the 4 — impact --mode said (default) with no value, which it does not flag and which I removed myself. The commit says that rather than crediting the guard.

The rule is a second guard, not a widened one: an MCP description is a runtime String and interpolates the constant, while a clap doc comment is fixed at compile time and cannot, so there the rule is to say nothing and let clap say it once.

What the review cost, and what it caught in my own fixes

Four rounds, and every round found something in the round before it:

Round Found in my fix
1 both exemptions were dead code; the brace scan was already wrong on 11 files
2 the marker was unreachable — a trailing comment left the value unparseable, so // TODO bought the same silence as a stated reason
3 contains("arg(") matched my_arg(; budget: 8_000 walked past both guards; assert!(rejected >= 7) summed the table's expectations, so it could not fail
4 commented-out text could forge a clap attribute; the new bound had no test

The round-2 one is the one worth naming: is_exempt had a green test while never being called. Testing the links instead of the chain is what hid it, so the guards now call one composed is_unmarked_literal, and the table asserts the verdict.

After that, @vlsi asked whether hard-coding // and /* was safe for other languages. It is scoped correctly — sources() yields *.rs and these helpers live inside #[cfg(test)], so the multi-language adapters in src/adapters/ share nothing with them — but checking it turned up a real gap in Rust's own syntax: block comments nest, and I matched the first */, so /* a /* b */ arg(default_value = 5) */ put the tail back outside the comment and forged the match again. Fixed by matching depth.

Every fix above is checked by reverting it and watching the test fail — including the mutation the reviewer named for the scan bound (moving the counter reset above the increment).

Verification

cargo test: 26 binaries green, on each of the eight commits in isolation. cargo clippy --all-targets clean apart from the pre-existing chunker.rs warning.

install --all --local writing into absent CLIs is #64 — reproduced there with the select_all detection bypass, untouched here as you offered.

@aeroxy

aeroxy commented Aug 17, 2026

Copy link
Copy Markdown
Owner

@vlsi three commits for three findings, and #64 split out — thanks, that's exactly the shape I wanted. _member_visible is the right fix rather than the wording retreat, and // defaults-ok: <why> is a better answer to the guard problem than the one I'd have reached for: asking the author to state intent beats trying to infer it from syntax, and it fails loud rather than silently permitting a whole class.

Confirmed fixed on the PR build — C# text and JSON now agree:

$ ast-bro digest W.cs          # property + event both gone
class W  L1-6
  Go()
$ ast-bro digest W.cs --json   # kinds: class, method

Two left, both reproduced.

--no-fields still means three things for markdown

_digest_markdown keeps its own gate on fenced code blocks (src/core.rs:1432), outside _member_visible — which covers Field | Property | Event | Indexer but not CodeBlock. So the invariant this PR now documents holds for every language except the one where the flag is doing something else entirely:

$ ast-bro map x.md --detail names --no-fields     # code block DROPPED
# Title  L1-7

$ ast-bro map x.md --detail full  --no-fields     # code block KEPT
# Title  L1-7
    rust code block  L5-7

$ ast-bro map x.md --detail names --no-fields --json   # code block KEPT
"kind": "heading" … "kind": "code_block"

The third line is the one that matters: --detail names --no-fields is the digest preset, so digest x.md and digest x.md --json hand back different declaration sets — the exact drift the unification was meant to end. The new test only uses a C# fixture, so markdown never reaches it.

This is pre-existing behaviour and I'm not asking you to redesign it here. But README.md, SKILL.md, wiki/architecture.md and the _member_visible doc comment now all assert it's fixed, and --include-fields' new wording ("Include fields, properties, events, and indexers") describes the wrong thing for markdown, where that flag is what reveals code blocks. Either fold CodeBlock into _member_visible or scope the claim to say markdown is its own case — I don't much mind which, but the docs shouldn't promise the stronger one. A markdown fixture next to the C# one would keep it honest either way.

The guard judges commented-out lines

impl_default_bodies_hold_no_literals reads a comment as code (src/defaults.rs:879). field_and_value strips a trailing comment off the value but never checks whether the line begins as one — the same forged-syntax hole strip_comments was added to close on the clap side:

$ # add `// was max_depth: 16,` inside SurfaceOptions::default()
$ cargo test --lib impl_default_bodies_hold_no_literals
literal values inside an `impl Default` …
[ "src/surface/options.rs:54: // was max_depth: 16," ]

Annotating a comment with // defaults-ok: to quiet the guard would be an odd thing to have to do. Skipping a line whose first non-whitespace is // should cover it.

What checked out

Every constant swap matches its prior literal value, --help and JSON output are identical to main outside the intended changes, and the full suite is green (424 lib plus the integration binaries). I mutation-tested the new guards rather than trusting them: the clap-doc-comment one does fire on a restated default, and defaults-ok does require a reason.

Fix those two — or scope the markdown claim — and I'll merge. No rush on either, though: take the week.

Thanks

Genuinely good work on this one, and the part worth calling out isn't any single fix. It's that you kept auditing your own repairs and then reported what the audit found. The round-2 case is the one I keep thinking about: is_exempt with a green test and no caller. That normally ships and stays shipped for years. Same instinct behind replaying the brace scan over src/ rather than reasoning about whether it was right — 11 unbalanced files and 24 blind exemptions is not something you talk yourself into finding.

And pulling the Kotlin and indexer claims instead of writing tests that assert nothing is the harder half of the job. Retracting something you'd already written costs more than adding a fix, and you did it three times in one round.

Good luck with the week, whatever it's for. Pick this up whenever you feel like it — the markdown CodeBlock gate and the commented-out-line hole will both keep, and nothing here is time-sensitive.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants