Skip to content

Commit e898652

Browse files
committed
feat(matcher): match sigil-prefixed positionals (fixes #361)
dig ns foo.bar @8.8.8.8 explained @8.8.8.8 with the wrong positional's text: positionals were assigned purely by order, so the @-token landed on whatever was next in line. Option gains a prefix field — a literal sigil a token must start with for that positional to claim it (dig's [@server]). Prefixed positionals leave ordered consumption entirely: the matcher tries the prefix pool first, then falls back to today's ordered/variadic logic, so ns and foo.bar now correctly consume name and type. The LLM prompt asks for the sigil from the synopsis; both sanitize sites (response.py, postprocess.py) restrict it to a corpus-grounded allowlist (@, +, :) so optional sub-components like ssh's [user@]hostname can't knock a popular operand out of ordered matching. Eval on the 12-page corpus: zero false-positive prefix emissions, including ssh. The e2e fixture db is rebuilt with llm:codex/gpt-5.4/medium (gpt-5.2 reproducibly dropped dig's positionals) in one parallel pass; snapshots refreshed for the richer help-text markdown and new positional help boxes, plus a new test pinning the issue-361 command end-to-end. biome now skips .claude/ (agent worktrees with nested configs) and the untracked parse-conformance corpus so make lint reflects repo code.
1 parent 9320d7e commit e898652

28 files changed

Lines changed: 438 additions & 64 deletions

AGENTS.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,13 @@ Use the LLM eval (`tests/evals/llm/llm_eval.py`) to compare before/after metrics
3535
git stash push -- explainshell/extraction/llm/
3636

3737
# 2. Run on the old code
38-
python tests/evals/llm/llm_eval.py run --label baseline --model openai/gpt-5-mini --jobs 10 -d "baseline before <short summary of change>"
38+
python tests/evals/llm/llm_eval.py run --label baseline --model codex/gpt-5.4/medium --jobs 10 -d "baseline before <short summary of change>"
3939

4040
# 3. Restore your changes
4141
git stash pop
4242

4343
# 4. Run on the new code
44-
python tests/evals/llm/llm_eval.py run --label change --model openai/gpt-5-mini --jobs 10 -d "<short summary of change>"
44+
python tests/evals/llm/llm_eval.py run --label change --model codex/gpt-5.4/medium --jobs 10 -d "<short summary of change>"
4545

4646
# 5. Compare the two run directories (oldest first)
4747
python tests/evals/llm/llm_eval.py compare tests/evals/llm/runs/<baseline-run> tests/evals/llm/runs/<change-run>
@@ -51,10 +51,10 @@ python tests/evals/llm/llm_eval.py compare tests/evals/llm/runs/<baseline-run> t
5151

5252
```bash
5353
# Run on the default corpus, parallelizing realtime calls
54-
python tests/evals/llm/llm_eval.py run --label smoke --model openai/gpt-5-mini --jobs 10
54+
python tests/evals/llm/llm_eval.py run --label smoke --model codex/gpt-5.4/medium --jobs 10
5555

5656
# Run on specific files (overrides --corpus)
57-
python tests/evals/llm/llm_eval.py run --label probe --model openai/gpt-5-mini --jobs 10 path/to/file.1.gz
57+
python tests/evals/llm/llm_eval.py run --label probe --model codex/gpt-5.4/medium --jobs 10 path/to/file.1.gz
5858

5959
# Use --batch <size> instead of --jobs to route through the provider's batch API
6060
# (cheaper, but minutes-to-hours of queue latency; pays off only on much larger corpora).
@@ -121,7 +121,7 @@ make ubuntu-archive UBUNTU_RELEASE=resolute
121121
make arch-archive
122122

123123
# Process a man page into the database
124-
python -m explainshell.manager extract --mode llm:openai/gpt-5-mini /path/to/manpage.1.gz
124+
python -m explainshell.manager extract --mode llm:codex/gpt-5.4/medium /path/to/manpage.1.gz
125125
```
126126

127127
## Project Structure
@@ -194,15 +194,16 @@ SQLite with two tables:
194194
- **mapping** - command name → manpage id lookup (many-to-one, with score for preference)
195195

196196
Key classes (Pydantic models in models.py):
197-
- `Option` - text, short/long flag lists, has_argument, positional, nested_cmd
198-
- `ParsedManpage` - container with options/positionals properties and `find_option(flag)` lookup
197+
- `Option` - text, short/long flag lists, has_argument, positional, prefix (literal sigil a token must start with for a positional to claim it, e.g. `@` in dig's `[@server]`; restricted to the `OPTION_PREFIX_SIGILS` allowlist `@`/`+`/`:`), nested_cmd
198+
- `ParsedManpage` - container with options/positionals/prefixed_positionals properties and `find_option(flag)` lookup; `positionals` excludes prefix-bearing options, which are exposed via `prefixed_positionals` (name → (prefix, text))
199199

200200
### Command Matching (matcher.py)
201201

202202
Uses bashlex AST visitor pattern:
203203
- `Matcher` inherits from `bashlex.ast.nodevisitor`
204204
- `visitcommand()` - looks up man page, handles multi-command (e.g., `git commit`)
205205
- `visitword()` - matches tokens to options (exact match, then fuzzy split for combined short flags like `-abc`)
206+
- Positional operands use two pools: prefixed positionals are claimed only by tokens starting with their sigil (e.g. `@8.8.8.8` → dig's `server`); remaining tokens consume the non-prefixed positionals in order, reusing the last one (variadic). A token whose sigil no positional declares falls through to ordered consumption; if all positionals are prefixed and none match, the token is unknown.
206207
- Produces `MatchResult(start, end, text, match)` where start/end are character positions in the original string
207208

208209
### E2E Tests

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ E2E_DB := tests/e2e/e2e.db
88

99
e2e-db:
1010
rm -f $(E2E_DB)
11-
python -m explainshell.manager --db $(E2E_DB) extract --mode llm:openai/gpt-5.2 $(E2E_MANPAGES_25)/tar.1.gz $(E2E_MANPAGES_25)/echo.1.gz $(E2E_MANPAGES_25)/grep.1.gz $(E2E_MANPAGES_24)/tar.1.gz $(E2E_MANPAGES_24)/echo.1.gz $(E2E_MANPAGES_24)/grep.1.gz $(E2E_MANPAGES_ARCH)/tar.1.gz $(E2E_MANPAGES_25)/git-rebase.1.gz
11+
python -m explainshell.manager --db $(E2E_DB) extract --mode llm:codex/gpt-5.4/medium -j 9 $(E2E_MANPAGES_25)/tar.1.gz $(E2E_MANPAGES_25)/echo.1.gz $(E2E_MANPAGES_25)/grep.1.gz $(E2E_MANPAGES_24)/tar.1.gz $(E2E_MANPAGES_24)/echo.1.gz $(E2E_MANPAGES_24)/grep.1.gz $(E2E_MANPAGES_ARCH)/tar.1.gz $(E2E_MANPAGES_25)/git-rebase.1.gz $(E2E_MANPAGES_25)/dig.1.gz
1212

1313
e2e:
1414
@npx playwright --version >/dev/null 2>&1 || (echo "playwright is required. Install with: npm install && npx playwright install chromium"; exit 1)

biome.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
"includes": [
1111
"explainshell/web/static/js/es.js",
1212
"tests/**/*.js",
13-
"playwright.config.js"
13+
"playwright.config.js",
14+
"!.claude",
15+
"!tests/parse_conformance"
1416
]
1517
},
1618
"formatter": {

explainshell/diff.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@
2222
)
2323

2424
# Per-option fields to compare in diff mode.
25-
_OPT_FIELDS = ("has_argument", "positional", "nested_cmd", "text")
25+
_OPT_FIELDS = ("has_argument", "positional", "prefix", "nested_cmd", "text")
2626

2727
# Fields where None and False should be treated as equivalent.
28-
_FALSY_EQUIVALENT = {"nested_cmd", "positional"}
28+
_FALSY_EQUIVALENT = {"nested_cmd", "positional", "prefix"}
2929

3030
# ANSI color helpers.
3131
_RED = "\033[31m"

explainshell/extraction/llm/prompt.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@
2828
"positional": null, // name string for positional operands with no flags (e.g.
2929
// "FILE"). NEVER set this if the option has short or long
3030
// flags. Omit if null.
31+
"prefix": null, // literal sigil character the SYNOPSIS attaches to this
32+
// positional operand (e.g. "@" in dig's "[@server]").
33+
// Only valid together with "positional". Do NOT use for
34+
// optional sub-components like "[user@]hostname" or for
35+
// placeholder styling like "<FILE>". Omit if none.
3136
"nested_cmd": false, // true only when the argument is itself a shell command
3237
// (e.g. find -exec CMD ;). Omit if false.
3338
"lines": [111, 115] // [start, end] line range from the left margin, covering the

explainshell/extraction/llm/response.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ def normalize_option_fields(raw: dict) -> dict:
132132
- ``has_argument: null`` → ``False`` (8 positional-arg options)
133133
- ``has_argument`` list with int elements → stringified (hdparm --set-sector-size)
134134
- ``has_argument`` bare string (e.g. ``"1..99"``) → ``True`` (avrdude range params)
135+
- sigil embedded in the positional name (``"positional": "@server"``) →
136+
split into ``prefix`` + bare name; only for allowlisted sigil characters,
137+
so placeholder styling like ``<FILE>`` is untouched
135138
"""
136139
raw = dict(raw) # shallow copy to avoid mutating caller's data
137140

@@ -147,6 +150,17 @@ def normalize_option_fields(raw: dict) -> dict:
147150
if isinstance(ha, str):
148151
raw["has_argument"] = True
149152

153+
# Strip a leading sigil from the positional name into prefix.
154+
pos = raw.get("positional")
155+
if (
156+
isinstance(pos, str)
157+
and len(pos) > 1
158+
and pos[0] in models.OPTION_PREFIX_SIGILS
159+
and not raw.get("prefix")
160+
):
161+
raw["prefix"] = pos[0]
162+
raw["positional"] = pos[1:]
163+
150164
return raw
151165

152166

@@ -156,21 +170,34 @@ def sanitize_option_fields(
156170
has_argument: bool | list[str],
157171
positional: str | None,
158172
nested_cmd: bool,
159-
) -> tuple[list[str], list[str], bool | list[str], str | None, bool]:
173+
prefix: str | None = None,
174+
) -> tuple[list[str], list[str], bool | list[str], str | None, bool, str | None]:
160175
"""Fix common LLM mistakes in option fields.
161176
162-
Returns (short, long, has_argument, positional, nested_cmd).
177+
Returns (short, long, has_argument, positional, nested_cmd, prefix).
163178
"""
164179
if positional and (short or long):
165180
logger.debug(
166181
"clearing positional=%r on flagged option %s/%s", positional, short, long
167182
)
168183
positional = None
169184

185+
if prefix and not positional:
186+
logger.debug("clearing prefix=%r on non-positional option", prefix)
187+
prefix = None
188+
189+
if prefix and prefix not in models.OPTION_PREFIX_SIGILS:
190+
logger.debug(
191+
"dropping prefix=%r on positional %r: not in sigil allowlist",
192+
prefix,
193+
positional,
194+
)
195+
prefix = None
196+
170197
if nested_cmd and not has_argument:
171198
has_argument = True
172199

173-
return short, long, has_argument, positional, nested_cmd
200+
return short, long, has_argument, positional, nested_cmd, prefix
174201

175202

176203
def llm_option_to_store_option(
@@ -185,6 +212,7 @@ def llm_option_to_store_option(
185212
long = raw.get("long") or []
186213
has_argument = raw.get("has_argument", False)
187214
positional = raw.get("positional") or None
215+
prefix = raw.get("prefix") or None
188216
nested_cmd = bool(raw.get("nested_cmd", False))
189217

190218
if not isinstance(short, list):
@@ -198,8 +226,8 @@ def llm_option_to_store_option(
198226
start, end = int(lines[0]), int(lines[1])
199227
text = extract_text_from_lines(original_lines, start, end)
200228

201-
short, long, has_argument, positional, nested_cmd = sanitize_option_fields(
202-
short, long, has_argument, positional, nested_cmd
229+
short, long, has_argument, positional, nested_cmd, prefix = sanitize_option_fields(
230+
short, long, has_argument, positional, nested_cmd, prefix
203231
)
204232

205233
return models.Option(
@@ -208,6 +236,7 @@ def llm_option_to_store_option(
208236
long=long,
209237
has_argument=has_argument,
210238
positional=positional,
239+
prefix=prefix,
211240
nested_cmd=nested_cmd,
212241
meta={"lines": [start, end]},
213242
)

explainshell/extraction/postprocess.py

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from dataclasses import dataclass
88

99
from explainshell.errors import ExtractionError, FailureReason
10-
from explainshell.models import Option
10+
from explainshell.models import OPTION_PREFIX_SIGILS, Option
1111

1212
logger = logging.getLogger(__name__)
1313

@@ -34,9 +34,12 @@ def sanitize_option(opt: Option) -> Option:
3434
"""Fix universally-invalid field combinations.
3535
3636
- positional + flags: clear positional
37+
- prefix without positional: clear prefix
38+
- prefix outside the sigil allowlist: clear prefix
3739
- nested_cmd without has_argument: set has_argument = True
3840
"""
3941
positional = opt.positional
42+
prefix = opt.prefix
4043
has_argument = opt.has_argument
4144
changed = False
4245

@@ -50,21 +53,33 @@ def sanitize_option(opt: Option) -> Option:
5053
positional = None
5154
changed = True
5255

56+
if prefix and not positional:
57+
logger.debug("clearing prefix=%r on non-positional option", prefix)
58+
prefix = None
59+
changed = True
60+
61+
if prefix and prefix not in OPTION_PREFIX_SIGILS:
62+
logger.debug(
63+
"dropping prefix=%r on positional %r: not in sigil allowlist",
64+
prefix,
65+
positional,
66+
)
67+
prefix = None
68+
changed = True
69+
5370
if opt.nested_cmd and not has_argument:
5471
has_argument = True
5572
changed = True
5673

5774
if not changed:
5875
return opt
5976

60-
return Option(
61-
text=opt.text,
62-
short=opt.short,
63-
long=opt.long,
64-
has_argument=has_argument,
65-
positional=positional,
66-
nested_cmd=opt.nested_cmd,
67-
meta=opt.meta,
77+
return opt.model_copy(
78+
update={
79+
"has_argument": has_argument,
80+
"positional": positional,
81+
"prefix": prefix,
82+
}
6883
)
6984

7085

@@ -73,15 +88,7 @@ def strip_trailing_blanks(opt: Option) -> Option:
7388
stripped = opt.text.rstrip("\n ")
7489
if stripped == opt.text:
7590
return opt
76-
return Option(
77-
text=stripped,
78-
short=opt.short,
79-
long=opt.long,
80-
has_argument=opt.has_argument,
81-
positional=opt.positional,
82-
nested_cmd=opt.nested_cmd,
83-
meta=opt.meta,
84-
)
91+
return opt.model_copy(update={"text": stripped})
8592

8693

8794
def _subset_has_cross_reference(subset: Option, extra_flags: frozenset[str]) -> bool:
@@ -179,14 +186,8 @@ def dedup_options(options: list[Option]) -> tuple[list[Option], int]:
179186
sup = options[best_j]
180187
sub = options[i]
181188
if len(sub.text) > len(sup.text):
182-
options[best_j] = Option(
183-
text=sub.text,
184-
short=sup.short,
185-
long=sup.long,
186-
has_argument=sup.has_argument,
187-
positional=sup.positional,
188-
nested_cmd=sup.nested_cmd,
189-
meta=sub.meta,
189+
options[best_j] = sup.model_copy(
190+
update={"text": sub.text, "meta": sub.meta}
190191
)
191192
removed.add(i)
192193

explainshell/manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,6 +1090,8 @@ def show_manpage(
10901090
click.echo(f" has_argument: {opt.has_argument}")
10911091
if opt.positional:
10921092
click.echo(f" positional: {opt.positional}")
1093+
if opt.prefix:
1094+
click.echo(f" prefix: {opt.prefix}")
10931095
if opt.nested_cmd:
10941096
click.echo(f" nested_cmd: {opt.nested_cmd}")
10951097
desc = opt.text.strip()

explainshell/matcher.py

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def _option_debug(option):
5757
"long": option.long,
5858
"has_argument": option.has_argument,
5959
"positional": option.positional,
60+
"prefix": option.prefix,
6061
"nested_cmd": option.nested_cmd,
6162
}
6263

@@ -667,7 +668,8 @@ def _visitword(node, word):
667668
self.matches.extend(m)
668669
return
669670

670-
if self.man_page.positionals:
671+
prefixed = self.man_page.prefixed_positionals
672+
if self.man_page.positionals or prefixed:
671673
if self.man_page.nested_cmd:
672674
logger.info("manpage %r can nest commands", self.man_page)
673675
if self.startcommand(
@@ -676,6 +678,33 @@ def _visitword(node, word):
676678
self._current_option = None
677679
return
678680

681+
# Prefix pool first: a token carrying a declared
682+
# sigil claims that positional, regardless of order.
683+
# Multiple prefixed tokens may claim the same
684+
# positional; first declaration in document order
685+
# wins when several share a prefix.
686+
for k, (prefix, text) in prefixed.items():
687+
if word.startswith(prefix):
688+
logger.info(
689+
"word %r starts with prefix %r, using %r",
690+
word,
691+
prefix,
692+
k,
693+
)
694+
mr = MatchResult(
695+
node.pos[0],
696+
node.pos[1],
697+
text,
698+
None,
699+
{
700+
"kind": "argument",
701+
"positional": k,
702+
"prefix": prefix,
703+
},
704+
)
705+
self.matches.append(mr)
706+
return
707+
679708
d = self.man_page.positionals
680709
keys = list(d.keys())
681710
group = self.group_stack[-1][1]
@@ -689,22 +718,27 @@ def _visitword(node, word):
689718
# Ordered consumption: advance through positionals.
690719
k = keys[group.positional_index]
691720
group.positional_index += 1
692-
else:
721+
elif keys:
693722
# All positionals consumed; reuse the last
694723
# (variadic).
695724
k = keys[-1]
696-
697-
logger.info("got arguments, using %r", k)
698-
text = d[k]
699-
mr = MatchResult(
700-
node.pos[0],
701-
node.pos[1],
702-
text,
703-
None,
704-
{"kind": "argument", "positional": k},
705-
)
706-
self.matches.append(mr)
707-
return
725+
else:
726+
# Every positional is prefix-bearing and this
727+
# token carries none of the prefixes.
728+
k = None
729+
730+
if k is not None:
731+
logger.info("got arguments, using %r", k)
732+
text = d[k]
733+
mr = MatchResult(
734+
node.pos[0],
735+
node.pos[1],
736+
text,
737+
None,
738+
{"kind": "argument", "positional": k},
739+
)
740+
self.matches.append(mr)
741+
return
708742

709743
# if all of that failed, we can't explain it so mark it unknown
710744
self.matches.append(self.unknown(word, node.pos[0], node.pos[1]))

0 commit comments

Comments
 (0)