Skip to content

Commit 94cc573

Browse files
authored
Merge pull request #108 from mapbox/feat/commands-search-eval
Report commands search eval scores against the base in CI
2 parents 28be66d + a5a184d commit 94cc573

26 files changed

Lines changed: 2456 additions & 22 deletions

‎.github/workflows/ci.yml‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,87 @@ jobs:
113113
shell: bash
114114
run: sh scripts/test-completion.sh
115115

116+
# Which `commands search` queries a PR fixed and broke, against its base,
117+
# written to the job summary. It reports and never fails: the floors that
118+
# `cargo test` holds in the build job are the gate. A job summary rather
119+
# than a PR comment because it needs no write token, so a PR from a fork
120+
# gets one too. One runner: ranking is the same everywhere.
121+
search-eval:
122+
name: commands search eval
123+
runs-on: ubuntu-latest
124+
timeout-minutes: 20
125+
permissions:
126+
contents: read
127+
steps:
128+
# Whole history, so the base commit is there to check out beside it.
129+
# No credential left behind: nothing here pushes or fetches again.
130+
- uses: actions/checkout@v7
131+
with:
132+
fetch-depth: 0
133+
persist-credentials: false
134+
135+
# As in the build job: install exactly the pinned toolchain.
136+
- name: Read the pinned toolchain
137+
id: toolchain
138+
run: |
139+
echo "toolchain=$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -1)" >>"$GITHUB_OUTPUT"
140+
141+
- name: Install Rust
142+
uses: dtolnay/rust-toolchain@stable
143+
with:
144+
toolchain: ${{ steps.toolchain.outputs.toolchain }}
145+
146+
# The exit status is recorded rather than failing the step: a run below
147+
# its floor still writes its reports, the build job is where that
148+
# failure is reported, and the summary needs the status to say whether
149+
# missing reports mean a failed run or one with no eval to run.
150+
- name: Run the eval on this branch
151+
id: head
152+
run: |
153+
status=0
154+
MAPBOX_SEARCH_EVAL_REPORT="$RUNNER_TEMP/eval-head" cargo test --locked --bin mapbox eval_set || status=$?
155+
echo "status=$status" >>"$GITHUB_OUTPUT"
156+
157+
# In a worktree sharing this target directory, so only the crate itself
158+
# rebuilds. It rebuilds only because the worktree is added after the
159+
# head build: cargo goes by mtime, and base sources older than that
160+
# build would reuse its binary. Keep this step after the head run.
161+
# A base from before the eval or its reports passes and writes none;
162+
# one that fails to build or check out exits non-zero. The summary
163+
# tells the two apart by the status.
164+
- name: Run the eval on the base
165+
id: base
166+
if: github.event_name == 'pull_request'
167+
env:
168+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
169+
run: |
170+
status=0
171+
{
172+
git worktree add --detach "$RUNNER_TEMP/base" "$BASE_SHA" &&
173+
cd "$RUNNER_TEMP/base" &&
174+
CARGO_TARGET_DIR="$GITHUB_WORKSPACE/target" \
175+
MAPBOX_SEARCH_EVAL_REPORT="$RUNNER_TEMP/eval-base" \
176+
cargo test --locked --bin mapbox eval_set
177+
} || status=$?
178+
echo "status=$status" >>"$GITHUB_OUTPUT"
179+
180+
# No `--base` on a push: the script then says there was no base rather
181+
# than that the base had no reports. A status left empty by a step that
182+
# never finished reads as "did not finish".
183+
- name: Write the summary
184+
if: ${{ !cancelled() }}
185+
env:
186+
EVENT: ${{ github.event_name }}
187+
HEAD_STATUS: ${{ steps.head.outputs.status }}
188+
BASE_STATUS: ${{ steps.base.outputs.status }}
189+
run: |
190+
set -- --head "$RUNNER_TEMP/eval-head" --head-status "$HEAD_STATUS" \
191+
--sets tests/fixtures/commands_search
192+
if [ "$EVENT" = pull_request ]; then
193+
set -- "$@" --base "$RUNNER_TEMP/eval-base" --base-status "$BASE_STATUS"
194+
fi
195+
python3 scripts/commands-search-report.py "$@" >>"$GITHUB_STEP_SUMMARY"
196+
116197
# No Rust: this is where the repo's shell gets checked, install.sh above all
117198
# — it is served to machines we do not control, and this is the only thing
118199
# that checks it. Both runners, because the portability it has to get right
@@ -136,6 +217,7 @@ jobs:
136217
sh -n scripts/install.sh
137218
sh -n scripts/test-install.sh
138219
sh -n scripts/test-completion.sh
220+
sh -n scripts/test-commands-search-report.sh
139221
140222
# -s sh, not the default: bash would accept things `curl | sh` will not.
141223
# Once is enough — the findings do not depend on the runner. The version
@@ -145,11 +227,19 @@ jobs:
145227
# Kept clean against 0.9, 0.10 and 0.11.
146228
- name: Shellcheck
147229
if: runner.os == 'Linux'
148-
run: shellcheck -s sh scripts/install.sh scripts/test-install.sh scripts/test-completion.sh
230+
run: shellcheck -s sh scripts/install.sh scripts/test-install.sh scripts/test-completion.sh scripts/test-commands-search-report.sh
149231

150232
- name: Install, verify and prompt against a local channel
151233
run: sh scripts/test-install.sh
152234

235+
# The `commands search eval` job's summary script, against hand-written
236+
# reports: that job never fails, so this is where a summary that says
237+
# the wrong thing would be caught. Python only, so it lives here rather
238+
# than beside the Rust. Once is enough, as for shellcheck.
239+
- name: Summarize hand-written search eval reports
240+
if: runner.os == 'Linux'
241+
run: sh scripts/test-commands-search-report.sh
242+
153243
# The other half of the same job, for the installer install.sh stops short
154244
# of. Both shells, because both are on a stock Windows 11 and they differ
155245
# exactly where this script lives: the default TLS versions, what `2>` does

‎AGENTS.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,24 @@ One table outside that file works the same way: `GROUPS` in
126126
an entry outlives its command. A command it misses still shows, under
127127
"Other", but CI will not let that ship.
128128

129+
## `commands search` and its query sets
130+
131+
`mapbox commands search` ranks commands by the words in their names,
132+
summaries, descriptions, help-group lines and argument help, so **editing a
133+
command's `about`, an argument's `help` or a line in `DESCRIPTIONS` changes
134+
search results**, not only `--help`. The `eval_set_*` tests in
135+
`src/commands.rs` run the query sets in `tests/fixtures/commands_search/` and
136+
fail when a set ranks more than a few queries fewer right than its recorded
137+
score. Each file's header says where its queries came from and how much the
138+
search was tuned against them. `cargo test eval_set -- --nocapture` prints
139+
the scores and every miss, and that directory's README explains each set and
140+
how to read its score.
141+
142+
When a change raises a score, record it in the same commit. Dropping past the
143+
slack is a decision to explain in the PR. Don't tune against the sets by
144+
special-casing a query; fix the kind of miss. New real queries go in a new
145+
file, so there is always one set the search has not been fitted to.
146+
129147
## What the tests can and cannot tell you
130148

131149
`cargo test` answers to fake tokens and a fake server. It runs offline, on a

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ that may never merge. They are not releases and are not listed here.
5656
from `tilequery get` and the renderer kept matching the old name.
5757
`-o json` is unchanged.
5858

59+
- New `mapbox commands search "<task>"` finds the commands that match a
60+
task, best first, and `mapbox commands list` lists every command with its
61+
summary. Both are local keyword lookups: no request, no token. `json`
62+
gives `[{command, summary, score}]`, where `score` (0 to 1) is how much
63+
of the query the command's own text covers. The query is sent in telemetry as its length
64+
only. Top-level help now points to `commands search` (agents see it in
65+
their hint), and an unknown command with no near spelling suggests it in
66+
the error's `fix`. No existing command or output changes.
67+
5968
- `mapbox usage` reads as a chart: one row per product with no blank
6069
lines between, every sparkline as wide as the period, bars scaled from
6170
zero so their height is proportional to the value, and the tallest

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ clap = { version = "4", features = ["derive", "env", "string", "wrap_help"] }
6565
# top-level help, which src/help_layout.rs renders itself and so has to wrap
6666
# to the terminal itself.
6767
terminal_size = "0.4"
68+
# Typo tolerance for `mapbox commands search`. Already in the graph through
69+
# clap's own "did you mean" suggestions; the same version.
70+
strsim = "0.11"
6871
# Renders `mapbox completion <shell>` from the same `Command` tree everything
6972
# else here is built from. Kept to the default features on purpose: the
7073
# dynamic-completion half is `unstable-dynamic`, and value completion is out

‎README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ mapbox styles list
2121
- [Named profiles](#named-profiles)
2222
- [Commands](#commands)
2323
- [API commands](#api-commands)
24+
- [Finding a command](#finding-a-command)
2425
- [Diagnostics and settings](#diagnostics-and-settings)
2526
- [Shell completion](#shell-completion)
2627
- [Tileset CLI](#tileset-cli)
@@ -307,6 +308,17 @@ the token an event was sent with alongside the event. When the CLI's token
307308
can't be used, the event is dropped, and it is never sent twice.
308309
`MAPBOX_CLI_NO_TELEMETRY=1` or `mapbox config set telemetry off` turns it off.
309310

311+
### Finding a command
312+
313+
```sh
314+
mapbox commands search upload a font # the commands that match a task, best first
315+
mapbox commands list # every command with its summary
316+
```
317+
318+
Both are local keyword lookups over the CLI's own commands: no model, no
319+
request and no token. Agents get the same matches as JSON. See
320+
[Commands](./docs/commands.md#commands).
321+
310322
### Diagnostics and settings
311323

312324
```sh

‎docs/commands.md‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ nests, and is typed `mapbox styles draft get`.
134134
**[History](#history)** — [history.list](#mapbox-history-list) ·
135135
[history.show](#mapbox-history-show)
136136

137+
**[Commands](#commands)** — [commands.search](#mapbox-commands-search) ·
138+
[commands.list](#mapbox-commands-list)
139+
137140
**[Doctor](#doctor)** — [doctor](#mapbox-doctor)
138141

139142
**[Usage](#usage)** — [usage](#mapbox-usage)
@@ -4736,6 +4739,138 @@ Log no longer available: it expired or was removed to keep diagnostic logs
47364739

47374740
---
47384741

4742+
## Commands
4743+
4744+
Finding a command by what it does. Both read the same command surface
4745+
`mapbox --schema` describes, so they cover every command this build has,
4746+
and both are local: no request and no token. `json` gives each command as
4747+
`{command, summary}`, plus a `score` from `search`; `mapbox <command>
4748+
--schema` has the rest.
4749+
4750+
### `mapbox commands search`
4751+
4752+
The commands that match a task, best first. It is keyword matching, not a
4753+
model: each word of the query is matched against a command's name, summary
4754+
and description, its group's description and its arguments' help, with a
4755+
command's name counting most. A word can be a prefix (`geo`), a different
4756+
ending of the same stem (`geocoding` for `geocoder`) or one typo away
4757+
(`uplaod`), unless some command's text uses the word as it is: "match" is a
4758+
word, not a typo for "batch". A plural matches its singular. A few common
4759+
words stand in for the ones commands use ("remove" for "delete", "rename"
4760+
for "update").
4761+
Words like "how", "my", "to" and "mapbox" are ignored. It cannot find a
4762+
command whose text uses none of the query's words, so a query names the
4763+
action and the thing acted on: "upload a font", not "get my typeface onto
4764+
Mapbox". Matches far below the best one are left out. A task no command
4765+
does usually still comes back with a few weak guesses, so check the
4766+
summaries before running one.
4767+
4768+
Under `json`, each match has a `score` from 0 to 1: how much of the query
4769+
the command's own text covers. 1 means its name and summary use every word
4770+
of the query, a few tenths is a word found elsewhere, and under 0.1 is
4771+
barely a match. It is not a confidence, so don't use it as a cutoff: the
4772+
right command can score lower than a wrong one on another query.
4773+
4774+
The query can be quoted or not. One with no words at all, such as an
4775+
empty string, is a usage error (exit 2). No match is not an error: `text`
4776+
prints nothing and says so on stderr, `json` prints `[]`, and both exit 0. The
4777+
`commands` group itself is left out of the results. Telemetry records the
4778+
query's length, never its text.
4779+
4780+
#### Parameters
4781+
4782+
| Parameter | Effect |
4783+
| --- | --- |
4784+
| `<query>...` | What you want to do, in a few words. |
4785+
| `--limit <n>` | How many matches to show. Defaults to `5`; `0` shows every match. |
4786+
4787+
#### Examples
4788+
4789+
```sh
4790+
mapbox commands search upload a font
4791+
4792+
mapbox commands search "snap a gps trace to roads"
4793+
4794+
mapbox commands search tiles --limit 0
4795+
```
4796+
4797+
#### Outputs
4798+
4799+
<table>
4800+
<tr><th width="50%"><code>text</code></th><th width="50%"><code>json</code></th></tr>
4801+
<tr><td>
4802+
4803+
```
4804+
COMMAND SUMMARY
4805+
mapbox fonts upload Upload a font
4806+
mapbox fonts list List fonts for an owner
4807+
mapbox fonts delete Delete a font
4808+
```
4809+
4810+
</td><td>
4811+
4812+
```json
4813+
[
4814+
{
4815+
"command": "mapbox fonts upload",
4816+
"score": 1.0,
4817+
"summary": "Upload a font"
4818+
},
4819+
{
4820+
"command": "mapbox fonts list",
4821+
"score": 0.65,
4822+
"summary": "List fonts for an owner"
4823+
},
4824+
...
4825+
]
4826+
```
4827+
4828+
</td></tr>
4829+
</table>
4830+
4831+
### `mapbox commands list`
4832+
4833+
Every command with its summary, sorted by name.
4834+
4835+
#### Examples
4836+
4837+
```sh
4838+
mapbox commands list
4839+
```
4840+
4841+
#### Outputs
4842+
4843+
<table>
4844+
<tr><th width="50%"><code>text</code></th><th width="50%"><code>json</code></th></tr>
4845+
<tr><td>
4846+
4847+
```
4848+
COMMAND SUMMARY
4849+
mapbox accounts list-scopes List scopes
4850+
mapbox accounts list-tokens List tokens
4851+
mapbox accounts retrieve-token Retrieve a token
4852+
...
4853+
```
4854+
4855+
</td><td>
4856+
4857+
```json
4858+
[
4859+
{
4860+
"command": "mapbox accounts list-scopes",
4861+
"summary": "List scopes"
4862+
},
4863+
{
4864+
"command": "mapbox accounts list-tokens",
4865+
"summary": "List tokens"
4866+
},
4867+
...
4868+
]
4869+
```
4870+
4871+
</td></tr>
4872+
</table>
4873+
47394874
## Doctor
47404875

47414876
### `mapbox doctor`

0 commit comments

Comments
 (0)