diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3a7db73..18ee7c2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -113,6 +113,87 @@ jobs:
shell: bash
run: sh scripts/test-completion.sh
+ # Which `commands search` queries a PR fixed and broke, against its base,
+ # written to the job summary. It reports and never fails: the floors that
+ # `cargo test` holds in the build job are the gate. A job summary rather
+ # than a PR comment because it needs no write token, so a PR from a fork
+ # gets one too. One runner: ranking is the same everywhere.
+ search-eval:
+ name: commands search eval
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ permissions:
+ contents: read
+ steps:
+ # Whole history, so the base commit is there to check out beside it.
+ # No credential left behind: nothing here pushes or fetches again.
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ # As in the build job: install exactly the pinned toolchain.
+ - name: Read the pinned toolchain
+ id: toolchain
+ run: |
+ echo "toolchain=$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -1)" >>"$GITHUB_OUTPUT"
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: ${{ steps.toolchain.outputs.toolchain }}
+
+ # The exit status is recorded rather than failing the step: a run below
+ # its floor still writes its reports, the build job is where that
+ # failure is reported, and the summary needs the status to say whether
+ # missing reports mean a failed run or one with no eval to run.
+ - name: Run the eval on this branch
+ id: head
+ run: |
+ status=0
+ MAPBOX_SEARCH_EVAL_REPORT="$RUNNER_TEMP/eval-head" cargo test --locked --bin mapbox eval_set || status=$?
+ echo "status=$status" >>"$GITHUB_OUTPUT"
+
+ # In a worktree sharing this target directory, so only the crate itself
+ # rebuilds. It rebuilds only because the worktree is added after the
+ # head build: cargo goes by mtime, and base sources older than that
+ # build would reuse its binary. Keep this step after the head run.
+ # A base from before the eval or its reports passes and writes none;
+ # one that fails to build or check out exits non-zero. The summary
+ # tells the two apart by the status.
+ - name: Run the eval on the base
+ id: base
+ if: github.event_name == 'pull_request'
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ run: |
+ status=0
+ {
+ git worktree add --detach "$RUNNER_TEMP/base" "$BASE_SHA" &&
+ cd "$RUNNER_TEMP/base" &&
+ CARGO_TARGET_DIR="$GITHUB_WORKSPACE/target" \
+ MAPBOX_SEARCH_EVAL_REPORT="$RUNNER_TEMP/eval-base" \
+ cargo test --locked --bin mapbox eval_set
+ } || status=$?
+ echo "status=$status" >>"$GITHUB_OUTPUT"
+
+ # No `--base` on a push: the script then says there was no base rather
+ # than that the base had no reports. A status left empty by a step that
+ # never finished reads as "did not finish".
+ - name: Write the summary
+ if: ${{ !cancelled() }}
+ env:
+ EVENT: ${{ github.event_name }}
+ HEAD_STATUS: ${{ steps.head.outputs.status }}
+ BASE_STATUS: ${{ steps.base.outputs.status }}
+ run: |
+ set -- --head "$RUNNER_TEMP/eval-head" --head-status "$HEAD_STATUS" \
+ --sets tests/fixtures/commands_search
+ if [ "$EVENT" = pull_request ]; then
+ set -- "$@" --base "$RUNNER_TEMP/eval-base" --base-status "$BASE_STATUS"
+ fi
+ python3 scripts/commands-search-report.py "$@" >>"$GITHUB_STEP_SUMMARY"
+
# No Rust: this is where the repo's shell gets checked, install.sh above all
# — it is served to machines we do not control, and this is the only thing
# that checks it. Both runners, because the portability it has to get right
@@ -136,6 +217,7 @@ jobs:
sh -n scripts/install.sh
sh -n scripts/test-install.sh
sh -n scripts/test-completion.sh
+ sh -n scripts/test-commands-search-report.sh
# -s sh, not the default: bash would accept things `curl | sh` will not.
# Once is enough — the findings do not depend on the runner. The version
@@ -145,11 +227,19 @@ jobs:
# Kept clean against 0.9, 0.10 and 0.11.
- name: Shellcheck
if: runner.os == 'Linux'
- run: shellcheck -s sh scripts/install.sh scripts/test-install.sh scripts/test-completion.sh
+ run: shellcheck -s sh scripts/install.sh scripts/test-install.sh scripts/test-completion.sh scripts/test-commands-search-report.sh
- name: Install, verify and prompt against a local channel
run: sh scripts/test-install.sh
+ # The `commands search eval` job's summary script, against hand-written
+ # reports: that job never fails, so this is where a summary that says
+ # the wrong thing would be caught. Python only, so it lives here rather
+ # than beside the Rust. Once is enough, as for shellcheck.
+ - name: Summarize hand-written search eval reports
+ if: runner.os == 'Linux'
+ run: sh scripts/test-commands-search-report.sh
+
# The other half of the same job, for the installer install.sh stops short
# of. Both shells, because both are on a stock Windows 11 and they differ
# exactly where this script lives: the default TLS versions, what `2>` does
diff --git a/scripts/commands-search-report.py b/scripts/commands-search-report.py
new file mode 100644
index 0000000..5fc0e57
--- /dev/null
+++ b/scripts/commands-search-report.py
@@ -0,0 +1,291 @@
+#!/usr/bin/env python3
+"""Markdown summary of `mapbox commands search` eval results, for a CI job
+summary.
+
+ commands-search-report.py --head DIR --head-status N
+ [--base DIR --base-status N] [--sets DIR]
+
+Each directory holds the per-set reports the `eval_set_*` tests write when
+MAPBOX_SEARCH_EVAL_REPORT names it; each status is the exit code of the
+`cargo test` run that wrote them. No `--base` means there was no base to run
+(not a pull request). `--sets` is the fixture directory, so a set whose
+report is missing is named rather than left out.
+
+The status is what tells apart a base whose run passed and wrote nothing
+(one that predates the eval or its reports) from one whose run failed: the reports alone look the
+same for both.
+
+Reports, never gates: the floors in `cargo test` are the gate. So this never
+fails the job on bad input: a malformed line is skipped and counted, and an
+unreadable report is named. Standard library only, so the job needs no
+install step.
+"""
+
+import argparse
+import sys
+from pathlib import Path
+
+
+class Set:
+ def __init__(self):
+ self.floors = None
+ self.rows = {}
+ self.invalid = []
+ self.skipped = 0
+ self.unreadable = None
+
+
+def load(directory):
+ """Every report in `directory`, by set name. Empty when there is none."""
+ sets = {}
+ if directory is None or not directory.is_dir():
+ return sets
+ for path in sorted(directory.iterdir()):
+ report = sets[path.name] = Set()
+ try:
+ text = path.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError) as error:
+ report.unreadable = str(error)
+ continue
+ for line in text.splitlines():
+ parse_line(report, line)
+ return sets
+
+
+def parse_line(report, line):
+ columns = line.split("\t")
+ if columns[0] == "# floors":
+ try:
+ report.floors = (int(columns[1]), int(columns[2]))
+ except (IndexError, ValueError):
+ report.skipped += 1
+ return
+ if columns[0] == "# invalid":
+ report.invalid.append(" ".join(columns[1:]))
+ return
+ if columns[0].startswith("#") or not line.strip():
+ return
+ if len(columns) != 5 or columns[3] not in ("0", "1") or columns[4] not in ("0", "1"):
+ report.skipped += 1
+ return
+ query, expected, found, first, top3 = columns
+ report.rows[query] = {
+ "expected": " or ".join(expected.split("|")) if expected else "(nothing)",
+ "found": ", ".join(found.split("|")) if found else "(nothing)",
+ "first": first == "1",
+ "top3": top3 == "1",
+ }
+
+
+def cell(text):
+ """`text` made safe for one Markdown table cell."""
+ return (
+ str(text)
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("|", "\\|")
+ .replace("\n", " ")
+ )
+
+
+def score(report, key):
+ return sum(row[key] for row in report.rows.values())
+
+
+# Under the table, so a reader need not know the tests to read it.
+LEGEND = (
+ "**Ranked first**: the expected command was the first result. "
+ "**In top 3**: it was among the first three. "
+ "**Required**: the score in `EVAL_SETS` (src/commands.rs) less its slack; a lower score fails `cargo test`. "
+ "A query the CLI cannot serve counts as right only when nothing comes back. "
+ "How each set was gathered is in tests/fixtures/commands_search/README.md."
+)
+
+
+def count(head, total, base):
+ """`22 (37%)`, with the change against the base when there is one:
+ `22 (37%, +1)`."""
+ share = f"{round(100 * head / total)}%" if total else "-"
+ change = head - base if base is not None else 0
+ return f"{head} ({share}, {change:+d})" if change else f"{head} ({share})"
+
+
+def changed(head_rows, base_rows):
+ """Queries in both whose first result went from right to wrong or back."""
+ fixed, broken = [], []
+ for query, row in head_rows.items():
+ before = base_rows.get(query)
+ if before is None or before["first"] == row["first"]:
+ continue
+ line = (
+ f"| {cell(query)} | {cell(row['expected'])} | "
+ f"{cell(before['found'])} | {cell(row['found'])} |"
+ )
+ (fixed if row["first"] else broken).append(line)
+ return fixed, broken
+
+
+def status_note(status):
+ return "did not finish" if status is None else f"failed (exit {status})"
+
+
+def parse_status(text):
+ try:
+ return int(text)
+ except (TypeError, ValueError):
+ return None
+
+
+def base_note(base_given, base, base_status):
+ """Why there is nothing to compare against, or what to know about it."""
+ if not base_given:
+ return "Not a pull request, so there is no base to compare against."
+ if not base:
+ if base_status == 0:
+ return (
+ "The base run wrote no reports, "
+ "so only this branch's scores are shown."
+ )
+ return (
+ f"The base run {status_note(base_status)} before writing "
+ "any report, so only this branch's scores are shown."
+ )
+ if base_status != 0:
+ return (
+ f"The base run {status_note(base_status)}; "
+ "its scores are as far as it got."
+ )
+ return None
+
+
+def notes(report, floors_missed):
+ out = []
+ if report.unreadable:
+ out.append(f"unreadable: {report.unreadable}")
+ if floors_missed:
+ out.append("**below its floor**")
+ if report.invalid:
+ out.append(f"{len(report.invalid)} invalid rows")
+ if report.skipped:
+ out.append(f"{report.skipped} malformed lines skipped")
+ return out
+
+
+def render(head, head_status, base, base_given, base_status, expected_sets):
+ out = ["## `commands search` eval", ""]
+ if not head:
+ if head_status == 0:
+ out.append("No reports were written, though the eval run passed: no `eval_set_*` test ran.")
+ else:
+ out.append(
+ f"The eval run on this branch {status_note(head_status)} "
+ "before writing any report. The build job's test step has the details."
+ )
+ return out
+ if head_status != 0:
+ out += [
+ f"The eval run on this branch {status_note(head_status)}: a set is "
+ "below its floor, a row is invalid, or the tests did not build. The build "
+ "job's test step has the details.",
+ "",
+ ]
+ note = base_note(base_given, base, base_status)
+ if note:
+ out += [note, ""]
+ compare = bool(base)
+
+ out += [
+ "| Query set | Queries | Ranked first | In top 3 | Required (first / top 3) | Note |",
+ "| --- | --- | --- | --- | --- | --- |",
+ ]
+ details = []
+ names = sorted(set(head) | set(base) | set(expected_sets))
+ for name in names:
+ label = f"`{cell(name)}`"
+ if name not in head:
+ where = "only in the base" if name in base else "missing from this run"
+ out.append(f"| {label} | | | | | {where} |")
+ continue
+ report = head[name]
+ base_report = base.get(name)
+ first, top3 = score(report, "first"), score(report, "top3")
+ base_first = score(base_report, "first") if base_report else None
+ base_top3 = score(base_report, "top3") if base_report else None
+ floors = report.floors
+ floor = f"{floors[0]} / {floors[1]}" if floors else "-"
+ missed = bool(floors) and (first < floors[0] or top3 < floors[1])
+ row_notes = notes(report, missed)
+ if compare and base_report is None:
+ row_notes.insert(0, "only in this branch")
+ out.append(
+ f"| {label} | {len(report.rows)} | {count(first, len(report.rows), base_first)} | "
+ f"{count(top3, len(report.rows), base_top3)} | {floor} | {cell('; '.join(row_notes))} |"
+ )
+
+ if report.invalid:
+ details += [f"### {label}: rows that could not be scored", ""]
+ details += [f"- {cell(problem)}" for problem in report.invalid] + [""]
+
+ if base_report:
+ fixed, broken = changed(report.rows, base_report.rows)
+ header = ["| Query | Want | Base | This branch |", "| --- | --- | --- | --- |"]
+ if broken:
+ details += [f"### {label}: {len(broken)} no longer first", ""] + header + broken + [""]
+ if fixed:
+ details += [f"### {label}: {len(fixed)} now first", ""] + header + fixed + [""]
+
+ misses = [
+ f"| {cell(query)} | {cell(row['expected'])} | {cell(row['found'])} |"
+ for query, row in report.rows.items()
+ if not row["first"]
+ ]
+ if misses:
+ details += [
+ f"
",
+ "",
+ "| Query | Want | Got (top 3) |",
+ "| --- | --- | --- |",
+ *misses,
+ "",
+ "{cell(name)}: {len(misses)} not first