summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 20:46:23 +0900
committerAdam Malczewski <[email protected]>2026-06-02 20:46:23 +0900
commit80212bfb009eaf71a4743310dee6ed08b8f7e1da (patch)
tree81b1873f4a276116cf019cbcdecdd8fcba56beef
parent09914c6ba15214d5ec05c106d5d11fd14a86f532 (diff)
downloaddispatch-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.md30
-rw-r--r--Dockerfile5
-rw-r--r--Dockerfile.dev5
-rw-r--r--UPSTREAM_CS_FUZZY_BUG.md111
-rw-r--r--docker/cs/fuzzy-distance.patch159
-rw-r--r--packages/core/tests/tools/search-code.test.ts160
-rw-r--r--packaging/PKGBUILD4
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
diff --git a/Dockerfile b/Dockerfile
index c8511ee..27a9f1d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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