diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 20:46:23 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 20:46:23 +0900 |
| commit | 80212bfb009eaf71a4743310dee6ed08b8f7e1da (patch) | |
| tree | 81b1873f4a276116cf019cbcdecdd8fcba56beef | |
| parent | 09914c6ba15214d5ec05c106d5d11fd14a86f532 (diff) | |
| download | dispatch-80212bfb009eaf71a4743310dee6ed08b8f7e1da.tar.gz dispatch-80212bfb009eaf71a4743310dee6ed08b8f7e1da.zip | |
fix(search_code): fuzzy mid-word matching + Luau/fuzzy live test coverage
Address the remaining real defects from the Luau/cs test reports. The wrapper-
level findings (dash-leading queries, context flag, no-match message, empty
query, path-is-file) were already fixed in earlier commits and verified through
the tool; the two genuinely-open items were engine-level, plus a test-coverage
gap (the patch-dependent behaviors were only exercised by live tests that
skip without a cs binary).
- Engine fix (docker/cs/fuzzy-distance.patch): cs's fuzzy `term~N` only scanned
same-length windows, so it matched substitutions but never mid-word
insertions/deletions — e.g. `computSlipAngle~1` (a dropped 'e') failed to find
`computeSlipAngle`, contradicting cs's own "within 1 or 2 distance" docs.
Now scan windows of length termLen±maxDist (true Levenshtein) and keep the
best per offset. Updates one pre-existing cs test that encoded the buggy
substitution-only behaviour and adds mid-word insert/delete cases. Passes
cs's pkg/search + pkg/ranker suites; builds clean against the pinned commit.
- Provisioning: apply the new patch everywhere the Luau patch is applied —
Dockerfile, Dockerfile.dev, packaging/PKGBUILD build() — so every install
path (Docker bin/up, native code-search package via bin/service install)
ships both patches.
- Tests: add skip-gated live tests for Luau declaration detection (function /
local function / type / export type), only=usages exclusion, the Luau
language tag, and fuzzy mid-word matching. New capability probes
(findLuauCapableCs / findFuzzyCapableCs) run these only on a cs that actually
has each patch and skip (never fail) on an unpatched/absent binary. Default
suite: 600 pass / 12 skip; with a both-patched cs: 612 pass / 0 skip.
- Docs: UPSTREAM_CS_FUZZY_BUG.md documents the unreported upstream defect for a
potential boyter/cs PR; CS_ARTIX_DEPLOY.md updated to reflect that
sync-dispatch.sh now ships the code-search package (carrying both patches).
biome + tsc (core/api/frontend) + svelte-check all green.
| -rw-r--r-- | CS_ARTIX_DEPLOY.md | 30 | ||||
| -rw-r--r-- | Dockerfile | 5 | ||||
| -rw-r--r-- | Dockerfile.dev | 5 | ||||
| -rw-r--r-- | UPSTREAM_CS_FUZZY_BUG.md | 111 | ||||
| -rw-r--r-- | docker/cs/fuzzy-distance.patch | 159 | ||||
| -rw-r--r-- | packages/core/tests/tools/search-code.test.ts | 160 | ||||
| -rw-r--r-- | packaging/PKGBUILD | 4 |
7 files changed, 459 insertions, 15 deletions
diff --git a/CS_ARTIX_DEPLOY.md b/CS_ARTIX_DEPLOY.md index 7296f8d..a942ef8 100644 --- a/CS_ARTIX_DEPLOY.md +++ b/CS_ARTIX_DEPLOY.md @@ -12,13 +12,14 @@ feature provisions that binary on **two** deployment paths automatically: There is a **third** path — the Artix cyberdeck box — that is deployed by a personal script living **outside this repo** -(`~/projects/cyberdeck/sync-dispatch.sh`). That script is not updated by this -feature and **must be edited once** (4 small additions) so the Artix box also -gets `cs`. Until then, `search_code` on the cyberdeck will return its graceful +(`~/projects/cyberdeck/sync-dispatch.sh`). That script has now been edited (the +5 small additions below) so the Artix box also gets the patched `cs`. Before the +edit, `search_code` on the cyberdeck returned its graceful `Error: search_code requires the 'cs' binary ...` message on every call. -This file documents that required follow-up. It is committed so the change isn't -forgotten; the actual edit happens in the cyberdeck repo, not here. +This file documents that follow-up (now applied). It is committed so the change +isn't forgotten and can be re-derived if the cyberdeck script is ever reset; the +actual edit lives in the cyberdeck repo, not here. --- @@ -26,8 +27,9 @@ forgotten; the actual edit happens in the cyberdeck repo, not here. `packaging/PKGBUILD` now builds a `code-search` split package (a patched, statically-linked `cs` pinned to upstream commit -`697e0bf194bbc7a4a877e5170c70618989fc92e7`, tag `v3.1.0`, plus -`docker/cs/luau-declarations.patch` for Roblox `.luau` declaration support). It +`697e0bf194bbc7a4a877e5170c70618989fc92e7`, tag `v3.1.0`, plus two patches: +`docker/cs/luau-declarations.patch` for Roblox `.luau` declaration support and +`docker/cs/fuzzy-distance.patch` for correct fuzzy edit-distance matching). It installs `cs` to `/usr/bin/cs`. `code-search` is a plain static binary with **no init-system coupling**, so it @@ -43,11 +45,13 @@ and does not yet include `code-search`. --- -## The required edit to `~/projects/cyberdeck/sync-dispatch.sh` +## The edit applied to `~/projects/cyberdeck/sync-dispatch.sh` -Four small additions. After applying them, run `sync-dispatch.sh --build` (the -`--build` flag rebuilds the packages first, producing the new -`code-search-*.pkg.tar.zst`). +Five small additions (the four below plus mirroring `PKG_CS` into the generated +remote-script preamble alongside `PKG_DISPATCH` / `PKG_S6`). This edit has been +applied. To deploy, run `sync-dispatch.sh --build` (the `--build` flag rebuilds +the packages first, producing the new `code-search-*.pkg.tar.zst` that now +carries both the Luau and fuzzy patches). ### 1. Declare the package name (next to `PKG_DISPATCH` / `PKG_S6`) @@ -111,6 +115,10 @@ For a `.luau` sanity check (confirms the Luau patch is present), search a Roblox project with `only: "declarations"` — `function` / `type` / `export type` lines should be detected. +For a fuzzy sanity check (confirms the fuzzy patch is present), a mid-word +deletion should match, e.g. `cs -- 'computSlipAngle~1'` finds `computeSlipAngle` +(returns empty on an unpatched cs). + --- ## If you ever decouple `cs` from this repo @@ -4,16 +4,19 @@ # --- cs (code spelunker) builder --- # Builds a patched, statically-linked `cs` binary for the search_code tool. # Pinned to the v3.1.0 commit for reproducibility; the patch adds Luau -# declaration support (see docker/cs/luau-declarations.patch). cs vendors its +# declaration support and corrects fuzzy edit-distance matching (see +# docker/cs/luau-declarations.patch and docker/cs/fuzzy-distance.patch). cs vendors its # dependencies, so the `go build` step is offline after the clone. FROM golang:1.25-bookworm AS cs-builder ARG CS_COMMIT=697e0bf194bbc7a4a877e5170c70618989fc92e7 WORKDIR /build COPY docker/cs/luau-declarations.patch /tmp/luau-declarations.patch +COPY docker/cs/fuzzy-distance.patch /tmp/fuzzy-distance.patch RUN git clone https://github.com/boyter/cs.git src \ && cd src \ && git checkout "${CS_COMMIT}" \ && git apply /tmp/luau-declarations.patch \ + && git apply /tmp/fuzzy-distance.patch \ && CGO_ENABLED=0 go build -mod=vendor -ldflags="-s -w" -o /usr/local/bin/cs . \ && /usr/local/bin/cs --version diff --git a/Dockerfile.dev b/Dockerfile.dev index 52b1aa2..94d30fb 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,16 +1,19 @@ # --- cs (code spelunker) builder --- # Builds a patched, statically-linked `cs` binary for the search_code tool. # Pinned to the v3.1.0 commit for reproducibility; the patch adds Luau -# declaration support (see docker/cs/luau-declarations.patch). cs vendors its +# declaration support and corrects fuzzy edit-distance matching (see +# docker/cs/luau-declarations.patch and docker/cs/fuzzy-distance.patch). cs vendors its # dependencies, so the `go build` step is offline after the clone. FROM golang:1.25-bookworm AS cs-builder ARG CS_COMMIT=697e0bf194bbc7a4a877e5170c70618989fc92e7 WORKDIR /build COPY docker/cs/luau-declarations.patch /tmp/luau-declarations.patch +COPY docker/cs/fuzzy-distance.patch /tmp/fuzzy-distance.patch RUN git clone https://github.com/boyter/cs.git src \ && cd src \ && git checkout "${CS_COMMIT}" \ && git apply /tmp/luau-declarations.patch \ + && git apply /tmp/fuzzy-distance.patch \ && CGO_ENABLED=0 go build -mod=vendor -ldflags="-s -w" -o /usr/local/bin/cs . \ && /usr/local/bin/cs --version diff --git a/UPSTREAM_CS_FUZZY_BUG.md b/UPSTREAM_CS_FUZZY_BUG.md new file mode 100644 index 0000000..a177be3 --- /dev/null +++ b/UPSTREAM_CS_FUZZY_BUG.md @@ -0,0 +1,111 @@ +# Upstream bug: `cs` fuzzy search only matches substitutions, not insertions/deletions + +> Drafted for a potential PR/issue to [boyter/cs](https://github.com/boyter/cs). +> Dispatch ships a local fix as `docker/cs/fuzzy-distance.patch` (applied to the +> pinned cs build); this document is the upstream-facing writeup. + +## Summary + +`cs`'s fuzzy operator `term~N` is documented as *"fuzzy match within 1 or 2 +distance"* (`cs --help`, README), i.e. Levenshtein edit distance. In practice it +only ever matches **substitutions**: a mid-word **insertion** or **deletion** +that is genuinely edit distance 1 does **not** match. + +- `computSlipAngle~1` does **not** find `computeSlipAngle` (one dropped `e`) — edit distance 1. +- `houss~1` finds `house` (works — it's a substitution), but `hose~1` does **not** find `house` (one inserted `u`) — edit distance 1. + +Affected version: `cs version 3.1.0` (observed at commit `697e0bf`). The defect +is not reported in the issue tracker (checked all 51 open/closed issues + PRs as +of this writing). + +## Root cause + +`pkg/search/executor.go` implements fuzzy matching by scanning only +**same-length** windows of the content. For a term of length `L` it compares +every `L`-character substring against the term: + +```go +// fuzzyContains checks if any same-length substring of content matches +// the term within the given edit distance (substitution-based matching). +func fuzzyContains(content, term string, maxDist, termLen int) bool { + contentLen := len(content) + if contentLen == 0 || termLen == 0 || termLen > contentLen { + return false + } + for i := 0; i <= contentLen-termLen; i++ { + window := content[i : i+termLen] // <-- always exactly termLen long + if levenshtein(window, term) <= maxDist { + return true + } + } + return false +} +``` + +`fuzzyFind` (which produces match locations) has the same structure. Because +every candidate `window` is exactly `termLen` characters, the only edit the +comparison can ever observe is a **substitution**. An insertion or deletion +changes the string length by one, so the matching substring of the content is +`termLen ± 1` long and is never formed — hence never compared. + +This also makes one of the existing tests assert buggy behavior: +`{"Fuzzy Distance 2", "hovze~2", ...}` expects to match only `house`'s 5-char +window, but `hovze` is also within distance 2 of the 4-char window `" ove"` +elsewhere in the corpus — a match the same-length scan structurally cannot see. + +## Fix + +Scan windows of **every plausible length** in +`[termLen - maxDist, termLen + maxDist]` (clamped to ≥ 1) at each offset, and +keep the best (lowest-distance) window. A Levenshtein distance of `d` requires +the two strings' lengths to differ by at most `d` (each insert/delete shifts +length by one), so this range is exactly the set of window lengths that can be +within `maxDist`. `fuzzyFind` records the best window at each start and then +advances past it, so a single logical match doesn't produce a swarm of +overlapping locations. + +See `docker/cs/fuzzy-distance.patch` for the full diff. Shape: + +```go +func fuzzyWindowBounds(termLen, maxDist int) (int, int) { + minLen := termLen - maxDist + if minLen < 1 { + minLen = 1 + } + return minLen, termLen + maxDist +} + +// bestFuzzyMatchAt returns the length of the lowest-distance window starting at +// i that is within maxDist of term, or -1. Ties prefer the length closest to +// termLen. +func bestFuzzyMatchAt(content, term string, i, maxDist, minLen, maxLen int) int { ... } +``` + +## Complexity note + +Worst-case work per offset goes from one `levenshtein` call to `2*maxDist + 1` +calls. Since `cs` only supports `~1` and `~2`, that's a small constant factor +(≤ 5×) and only on fuzzy queries, which are rare relative to keyword/regex +searches. No measurable impact in practice. + +## Verification + +The change keeps `go test ./pkg/search/... ./pkg/ranker/... ./...` green (with +the one buggy assertion corrected) and adds explicit mid-word insertion/deletion +cases: + +```go +{"Fuzzy Distance 2", "houze~2", false, []string{"src/main/file1.go"}}, +{"Fuzzy Distance 1 deletion", "hose~1", false, []string{"src/main/file1.go"}}, // hose -> house (insert 'u') +{"Fuzzy Distance 1 insertion", "houxse~1", false, []string{"src/main/file1.go"}}, // houxse -> house (delete 'x') +``` + +End-to-end against a real `.luau` codebase, all four of these now match (they +returned empty before): + +``` +computSlipAngle~1 -> computeSlipAngle (CarPhysics.luau) +LauncTuning~1 -> LaunchTuning (LaunchController.luau) +LaunchTunin~1 -> LaunchTuning (LaunchTuning.luau) # already worked (suffix) +TireFrictio~1 -> TireFriction (CarPhysics.luau) # already worked (suffix) +``` diff --git a/docker/cs/fuzzy-distance.patch b/docker/cs/fuzzy-distance.patch new file mode 100644 index 0000000..e432986 --- /dev/null +++ b/docker/cs/fuzzy-distance.patch @@ -0,0 +1,159 @@ +Fix cs fuzzy matching to honour true Levenshtein edit distance (insertions and +deletions), not just same-length substitutions. + +Upstream cs (v3.1.0) implements `term~N` fuzzy search by scanning only +same-length windows of the content: for a term of length L it compares every +L-character substring and keeps those within N edits. Because every candidate +window is exactly L long, the only edits it can ever observe are substitutions. +A mid-word insertion or deletion — e.g. `computSlipAngle~1` for the real symbol +`computeSlipAngle` (a dropped 'e'), or `houss~1` vs `house` — changes the length +by one and so is never matched, even though it is edit distance 1. This +contradicts cs's own documentation ("fuzzy match within 1 or 2 distance"). + +The fix scans windows of every plausible length in +[termLen-maxDist, termLen+maxDist] (clamped at 1) at each offset and keeps the +best (lowest-distance) match, so insertions and deletions match too. fuzzyFind +records the best window per start and advances past it to avoid emitting a swarm +of overlapping locations for one logical match. + +Purely localised to the two fuzzy helpers in pkg/search/executor.go (plus a +test update: the pre-existing `hovze~2` case asserted the old substitution-only +behaviour, where the distance-2 term coincidentally failed to match a shorter +window that is in fact within distance 2; it is replaced with an unambiguous +distance-1 case and new mid-word insertion/deletion cases). Passes cs's own +pkg/search + pkg/ranker test suites. Applied during the Docker build and the +native package build via `git apply` against the pinned v3.1.0 checkout (see +Dockerfile / Dockerfile.dev / packaging/PKGBUILD). Candidate for upstreaming to +boyter/cs (the defect is unreported there). + +diff --git a/pkg/search/executor.go b/pkg/search/executor.go +index 175d458..1c422d2 100644 +--- a/pkg/search/executor.go ++++ b/pkg/search/executor.go +@@ -632,17 +632,67 @@ func min3(a, b, c int) int { + return c + } + +-// fuzzyContains checks if any same-length substring of content matches +-// the term within the given edit distance (substitution-based matching). ++// fuzzyWindowBounds returns the inclusive range of substring lengths that ++// could be within maxDist edits of a term of length termLen. A Levenshtein ++// distance of d can only be achieved between strings whose lengths differ by ++// at most d (each insertion or deletion changes the length by one), so any ++// candidate window must have a length in [termLen-maxDist, termLen+maxDist]. ++// The lower bound is clamped to 1 so we never form a zero-length window. ++func fuzzyWindowBounds(termLen, maxDist int) (int, int) { ++ minLen := termLen - maxDist ++ if minLen < 1 { ++ minLen = 1 ++ } ++ return minLen, termLen + maxDist ++} ++ ++// bestFuzzyMatchAt returns the length of the best (lowest-distance) substring ++// of content starting at offset i that is within maxDist edits of term, or -1 ++// if none is. Windows of every plausible length are tried (see ++// fuzzyWindowBounds) so insertions and deletions match, not just ++// substitutions — e.g. "computSlipAngle" (a dropped 'e') matches ++// "computeSlipAngle" at distance 1. Ties prefer the length closest to termLen. ++func bestFuzzyMatchAt(content, term string, i, maxDist, minLen, maxLen int) int { ++ contentLen := len(content) ++ bestLen := -1 ++ bestDist := maxDist + 1 ++ bestDelta := 0 ++ for wl := minLen; wl <= maxLen; wl++ { ++ if i+wl > contentLen { ++ break ++ } ++ d := levenshtein(content[i:i+wl], term) ++ if d > maxDist { ++ continue ++ } ++ delta := wl - len(term) ++ if delta < 0 { ++ delta = -delta ++ } ++ if d < bestDist || (d == bestDist && delta < bestDelta) { ++ bestDist = d ++ bestLen = wl ++ bestDelta = delta ++ } ++ } ++ return bestLen ++} ++ ++// fuzzyContains reports whether any substring of content is within maxDist ++// edits (true Levenshtein: insertions, deletions, and substitutions) of term. + func fuzzyContains(content, term string, maxDist, termLen int) bool { + contentLen := len(content) +- if contentLen == 0 || termLen == 0 || termLen > contentLen { ++ if contentLen == 0 || termLen == 0 { ++ return false ++ } ++ ++ minLen, maxLen := fuzzyWindowBounds(termLen, maxDist) ++ if minLen > contentLen { + return false + } + +- for i := 0; i <= contentLen-termLen; i++ { +- window := content[i : i+termLen] +- if levenshtein(window, term) <= maxDist { ++ for i := 0; i <= contentLen-minLen; i++ { ++ if bestFuzzyMatchAt(content, term, i, maxDist, minLen, maxLen) >= 0 { + return true + } + } +@@ -651,18 +701,30 @@ func fuzzyContains(content, term string, maxDist, termLen int) bool { + + // fuzzyFind finds all match locations in content that are within the given + // edit distance of the term. Returns [][]int where each entry is [start, end]. ++// Like fuzzyContains it considers variable-length windows so insertions and ++// deletions match. To avoid emitting a swarm of overlapping windows for one ++// logical match, it records the best window at each start and then advances ++// past it. + func fuzzyFind(content, term string, maxDist, termLen int) [][]int { + contentLen := len(content) +- if contentLen == 0 || termLen == 0 || termLen > contentLen { ++ if contentLen == 0 || termLen == 0 { ++ return nil ++ } ++ ++ minLen, maxLen := fuzzyWindowBounds(termLen, maxDist) ++ if minLen > contentLen { + return nil + } + + var locs [][]int +- for i := 0; i <= contentLen-termLen; i++ { +- window := content[i : i+termLen] +- if levenshtein(window, term) <= maxDist { +- locs = append(locs, []int{i, i + termLen}) +- } ++ for i := 0; i <= contentLen-minLen; { ++ wl := bestFuzzyMatchAt(content, term, i, maxDist, minLen, maxLen) ++ if wl < 0 { ++ i++ ++ continue ++ } ++ locs = append(locs, []int{i, i + wl}) ++ i += wl + } + return locs + } +diff --git a/pkg/search/search_test.go b/pkg/search/search_test.go +index 6cbd01f..eacbd75 100644 +--- a/pkg/search/search_test.go ++++ b/pkg/search/search_test.go +@@ -57,7 +57,10 @@ func TestExecutor(t *testing.T) { + {"Fuzzy Distance 1", "houss~1", false, []string{"src/main/file1.go"}}, // "houss" is distance 1 from "house" (only in file1) + {"Fuzzy Distance 1 No Match", "zzz~1", false, []string{}}, + {"Fuzzy AND Keyword", "houss~1 AND brown", false, []string{"src/main/file1.go"}}, +- {"Fuzzy Distance 2", "hovze~2", false, []string{"src/main/file1.go"}}, // "hovze" is distance 2 from "house" (u→v, s→z), only in file1 ++ {"Fuzzy Distance 2", "houze~2", false, []string{"src/main/file1.go"}}, // "houze" is distance 1 from "house" (s→z), only in file1 ++ // Mid-word insertion/deletion must match (true Levenshtein, not just same-length substitution). ++ {"Fuzzy Distance 1 deletion", "hose~1", false, []string{"src/main/file1.go"}}, // "hose" -> "house" (insert 'u'), distance 1 ++ {"Fuzzy Distance 1 insertion", "houxse~1", false, []string{"src/main/file1.go"}}, // "houxse" -> "house" (delete 'x'), distance 1 + + // Colon filter syntax + {"Colon file filter", "cat file:file1", false, []string{"src/main/file1.go"}}, diff --git a/packages/core/tests/tools/search-code.test.ts b/packages/core/tests/tools/search-code.test.ts index 00eee4c..c4e933c 100644 --- a/packages/core/tests/tools/search-code.test.ts +++ b/packages/core/tests/tools/search-code.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { chmodSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdtemp as mkdtempP, rm as rmP, writeFile as writeFileP } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -331,6 +331,110 @@ describe("search_code tool", () => { const out = await tool.execute({ query: "zzz_nonexistent_token_qqq" }); expect(out).toBe("No matches found."); }); + + it("tags .luau files as Luau", async () => { + process.env.DISPATCH_CS_BIN = liveCsBin as string; + await writeFileP(join(workDir, "mod.luau"), "function Mod.doThing()\n\treturn 1\nend\n"); + const tool = createSearchCodeTool(workDir); + const out = await tool.execute({ query: "doThing" }); + expect(out).toContain("mod.luau"); + expect(out).toContain("[Luau]"); + }); + }); + + // ── Luau declaration detection: needs a cs built with the Luau patch + // (docker/cs/luau-declarations.patch). Skipped on an unpatched/older cs. ── + const luauCsBin = findLuauCapableCs(liveCsBin); + describe.runIf(luauCsBin)("live cs binary (Luau declaration patch)", () => { + // A small Luau module exercising every declaration form the patch adds. + const LUAU_MODULE = [ + "local Mod = {}", + "", + "export type StuntResult = {", + "\tscore: number,", + "}", + "", + "type LaunchConfig = StuntResult", + "", + "function Mod.getDefaults(): LaunchConfig", + "\treturn { score = 0 }", + "end", + "", + "local function helperThing(x: number): number", + "\treturn x + 1", + "end", + "", + "Mod.live = Mod.getDefaults()", + "local used = helperThing(1)", + "", + ].join("\n"); + + beforeEach(async () => { + process.env.DISPATCH_CS_BIN = luauCsBin as string; + await writeFileP(join(workDir, "Mod.luau"), LUAU_MODULE); + }); + + it("detects `function Mod.x` declarations in .luau files", async () => { + const tool = createSearchCodeTool(workDir); + const out = await tool.execute({ query: "getDefaults", only: "declarations" }); + expect(out).toContain("Mod.luau"); + expect(out).toContain("function Mod.getDefaults"); + expect(out).not.toContain("No matches found"); + }); + + it("detects `local function` declarations in .luau files", async () => { + const tool = createSearchCodeTool(workDir); + const out = await tool.execute({ query: "helperThing", only: "declarations" }); + expect(out).toContain("Mod.luau"); + expect(out).toContain("local function helperThing"); + }); + + it("detects `type` / `export type` declarations in .luau files", async () => { + const tool = createSearchCodeTool(workDir); + const exportType = await tool.execute({ query: "StuntResult", only: "declarations" }); + expect(exportType).toContain("export type StuntResult"); + const aliasType = await tool.execute({ query: "LaunchConfig", only: "declarations" }); + expect(aliasType).toContain("type LaunchConfig"); + }); + + it("excludes declaration lines when only=usages", async () => { + const tool = createSearchCodeTool(workDir); + const out = await tool.execute({ query: "getDefaults", only: "usages" }); + // The call site is a usage; the `function Mod.getDefaults` definition is not. + expect(out).toContain("Mod.live = Mod.getDefaults()"); + expect(out).not.toContain("function Mod.getDefaults"); + }); + }); + + // ── Fuzzy mid-word matching: needs a cs built with the fuzzy patch + // (docker/cs/fuzzy-distance.patch). Skipped on an unpatched/older cs. ── + const fuzzyCsBin = findFuzzyCapableCs(liveCsBin); + describe.runIf(fuzzyCsBin)("live cs binary (fuzzy edit-distance patch)", () => { + beforeEach(() => { + process.env.DISPATCH_CS_BIN = fuzzyCsBin as string; + }); + + it("matches a mid-word deletion within distance 1", async () => { + await writeFileP( + join(workDir, "phys.ts"), + "export function computeSlipAngle() {\n\treturn 0;\n}\n", + ); + const tool = createSearchCodeTool(workDir); + // "computSlipAngle" drops the 'e' mid-word — edit distance 1. + const out = await tool.execute({ query: "computSlipAngle~1" }); + expect(out).toContain("phys.ts"); + expect(out).toContain("computeSlipAngle"); + expect(out).not.toBe("No matches found."); + }); + + it("matches a mid-word insertion within distance 1", async () => { + await writeFileP(join(workDir, "tire.ts"), "const tireFriction = 1;\n"); + const tool = createSearchCodeTool(workDir); + // "tireFricction" has an extra 'c' — edit distance 1. + const out = await tool.execute({ query: "tireFricction~1" }); + expect(out).toContain("tire.ts"); + expect(out).toContain("tireFriction"); + }); }); }); @@ -351,3 +455,57 @@ function findRealCs(): string | null { } return null; } + +/** + * Probe a `cs` binary against a throwaway corpus and return its trimmed stdout + * (or "" on any failure). Used by the capability gates below so patch-dependent + * live tests run only on a cs that actually has the patch — and skip (not fail) + * on an unpatched/older binary. + */ +function probeCs(bin: string, files: Record<string, string>, args: string[]): string { + let dir: string | undefined; + try { + dir = mkdtempSync(join(tmpdir(), "dispatch-cs-probe-")); + for (const [name, body] of Object.entries(files)) { + writeFileSync(join(dir, name), body); + } + const res = spawnSync(bin, ["-f", "json", "--dir", dir, ...args], { + encoding: "utf8", + }); + if (res.status !== 0 || !res.stdout) return ""; + return res.stdout.trim(); + } catch { + return ""; + } finally { + if (dir) rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * Return the cs binary only if it recognises Luau declarations (i.e. was built + * with docker/cs/luau-declarations.patch): a `--only-declarations` search for a + * top-level `function` in a .luau file yields a result. Otherwise null → skip. + */ +function findLuauCapableCs(bin: string | null): string | null { + if (!bin) return null; + const out = probeCs(bin, { "probe.luau": "function Probe.thing()\n\treturn 1\nend\n" }, [ + "--only-declarations", + "--", + "thing", + ]); + return out !== "" && out !== "null" ? bin : null; +} + +/** + * Return the cs binary only if its fuzzy matcher honours mid-word edits (i.e. + * was built with docker/cs/fuzzy-distance.patch): a distance-1 deletion matches. + * Otherwise null → skip. + */ +function findFuzzyCapableCs(bin: string | null): string | null { + if (!bin) return null; + const out = probeCs(bin, { "probe.txt": "const x = computeSlipAngle;\n" }, [ + "--", + "computSlipAngle~1", + ]); + return out !== "" && out !== "null" ? bin : null; +} diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 8918a30..878b15f 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -62,7 +62,8 @@ build() { bun install --frozen-lockfile --production # --- Build the patched `cs` code-search binary for the search_code tool --- - # Clone the pinned cs commit, apply the Luau declaration patch, and build a + # Clone the pinned cs commit, apply the Luau declaration + fuzzy-distance + # patches, and build a # statically-linked binary. cs vendors its deps, so `go build -mod=vendor` # needs no network beyond the clone. Mirrors the Docker cs-builder stage. # @@ -76,6 +77,7 @@ build() { cd "${srcdir}/cs-src" git checkout "${_cs_commit}" git apply "${_projectdir}/docker/cs/luau-declarations.patch" + git apply "${_projectdir}/docker/cs/fuzzy-distance.patch" CGO_ENABLED=0 GOFLAGS=-mod=vendor GOPATH="${srcdir}/gopath" GOCACHE="${srcdir}/gocache" \ go build -ldflags="-s -w" -o "${srcdir}/cs" . "${srcdir}/cs" --version |
