diff --git a/CLAUDE.md b/CLAUDE.md index 4861e3a..409d84d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,26 +38,25 @@ Any `ash` invocation auto-starts the daemon. Use `bin/ash` from the repo root, o **Switch criteria — what to actually use ash for now:** +1. **Searching for a pattern** — use `ash grep` instead of `grep`/`rg`. Canonical: `ash grep --pattern 'func Run' --path internal/`. RE2 regex; `--path` defaults to `.` (ASH-206); `--glob` scopes by filename; `--lit true` for literal text; binary and >16 MiB files skipped. `ash help --verb grep` for the full schema. +2. **Finding files** — use `ash find` instead of `find`. Canonical: `ash find --path . --glob '**/*.go'`. Respects `.gitignore`; a relative `--path` yields relative output paths, an absolute one yields absolute (see Gotchas). `ash help --verb find` for the full schema. +3. **Reads of files in this repo** — use `ash read` deliberately, even when the harness Read tool would suffice. Canonical: `ash read --path

--range 100:200`. Default cap 256 KiB; UTF-8 returned as-is; binary base64-encoded. We need ledger data to compare. `ash help --verb read` for the full schema. -4. **Reads of files in this repo** — use `ash read` deliberately, even when the harness Read tool would suffice. Canonical: `ash read --path

--range 100:200`. Default cap 256 KiB; UTF-8 returned as-is; binary base64-encoded. We need ledger data to compare. `ash help --verb read` for the full schema. +4. **Writing files in this repo** — use `ash write` instead of the harness Write tool. (Do not fall back to harness `Write`: it requires a prior harness `Read`, which the hook also denies — go straight to `ash write`.) Canonical: `ash write --path

--content - << 'EOF' … EOF` for non-trivial content; `--content '…'` only for short ASCII-only writes. Atomic via temp-file+rename. `ash help --verb write` for the full schema. +5. **Multi-line two-sided edits** (ASH-152) — use `ash edit --patch -` instead of the `--old @/tmp/x --new @/tmp/y` tmpfile dance. Canonical: `ash edit --path foo.go --patch - << 'EOF' … unified diff … EOF`. The `--fuzz N` arg (default 3, cap 10) means hunk header line numbers are hints — context anchoring lets the patch land within ±N lines of the authored position, so you don't have to count lines exactly. Per-line context matching within the hunk remains strict. Result reports `fuzz_applied` when scan moved any hunk. The two-tmpfile `--old @/tmp/x --new @/tmp/y` pattern stays as a fallback for cases where the diff can't produce stable context lines. +6. **Diffing content in this repo** — use `ash diff`. Canonical: `ash diff --path a.go --other b.go` or `ash diff --path f.go --content - < new.go`. Add `--stat true` for token-cheap counts only. Both inputs capped at 4000 lines. `ash help --verb diff` for the full schema. -7. **Writing files in this repo** — use `ash write` instead of the harness Write tool. (Do not fall back to harness `Write`: it requires a prior harness `Read`, which the hook also denies — go straight to `ash write`.) Canonical: `ash write --path

--content - << 'EOF' … EOF` for non-trivial content; `--content '…'` only for short ASCII-only writes. Atomic via temp-file+rename. `ash help --verb write` for the full schema. +7. **Running Go tests** — use `ash test` instead of `go test`. Canonical: `ash test` (defaults to `./...`, `count=1` to bypass cache, 60s timeout). Add `--packages internal/walker` for one package, `--run TestX` for name filter, `--race true` for race detector, `--short true` for `-short` mode, `--timeout 10m` for big suites. Failures arrive as a structured `Tests []Test` slice with `file:line` extracted; build failures land as `Status=build_failed`. `ash help --verb test` for the full schema. -8. **Multi-line two-sided edits** (ASH-152) — use `ash edit --patch -` instead of the `--old @/tmp/x --new @/tmp/y` tmpfile dance. Canonical: `ash edit --path foo.go --patch - << 'EOF' … unified diff … EOF`. The `--fuzz N` arg (default 3, cap 10) means hunk header line numbers are hints — context anchoring lets the patch land within ±N lines of the authored position, so you don't have to count lines exactly. Per-line context matching within the hunk remains strict. Result reports `fuzz_applied` when scan moved any hunk. The two-tmpfile `--old @/tmp/x --new @/tmp/y` pattern stays as a fallback for cases where the diff can't produce stable context lines. +8. **Blaming a file in this repo** — use `ash git --op blame --path

` instead of `git blame`. Canonical: `ash git --op blame --path internal/foo.go --lines 100:200 --rev HEAD~3`. Output is run-compacted hunks (consecutive lines sharing a commit collapse into one record); follow up with `ash git --op show --ref ` to inspect any hunk's commit in full. Default cap 8000 lines per file, 256 KiB serialized output. go-git backend only — rename-following blame falls back to system git (rare; ASH-190). `ash help --verb git --op blame` for the schema. -9. **Diffing content in this repo** — use `ash diff`. Canonical: `ash diff --path a.go --other b.go` or `ash diff --path f.go --content - < new.go`. Add `--stat true` for token-cheap counts only. Both inputs capped at 4000 lines. `ash help --verb diff` for the full schema. +9. **Bash equivalents to retire** — `find`, `cat`, `head`, `tail`, `ls -R`, `grep`, `rg`, `git status`, `git log`, `git diff`, `git show`, `git blame`, `go test`, `stat`, `sed`, `patch`, `git apply` should be replaced by their `ash` equivalents in this repo. The PreToolUse hook (next subsection) enforces this. (`sed` routes to `ash edit` for in-place edits and `ash read --range` for line-range reads; pure pipeline `cmd | sed …` is allowed through. `patch` and `git apply` route to `ash edit --patch` for single-file diffs; multi-file diffs pass through since `ash edit --patch` is single-file only — ASH-152 deferred.) -10. **Running Go tests** — use `ash test` instead of `go test`. Canonical: `ash test` (defaults to `./...`, `count=1` to bypass cache, 60s timeout). Add `--packages internal/walker` for one package, `--run TestX` for name filter, `--race true` for race detector, `--short true` for `-short` mode, `--timeout 10m` for big suites. Failures arrive as a structured `Tests []Test` slice with `file:line` extracted; build failures land as `Status=build_failed`. `ash help --verb test` for the full schema. - -11. **Blaming a file in this repo** — use `ash git --op blame --path

` instead of `git blame`. Canonical: `ash git --op blame --path internal/foo.go --lines 100:200 --rev HEAD~3`. Output is run-compacted hunks (consecutive lines sharing a commit collapse into one record); follow up with `ash git --op show --ref ` to inspect any hunk's commit in full. Default cap 8000 lines per file, 256 KiB serialized output. go-git backend only — rename-following blame falls back to system git (rare; ASH-190). `ash help --verb git --op blame` for the schema. - -12. **Bash equivalents to retire** — `find`, `cat`, `head`, `tail`, `ls -R`, `grep`, `rg`, `git status`, `git log`, `git diff`, `git show`, `git blame`, `go test`, `stat`, `sed`, `patch`, `git apply` should be replaced by their `ash` equivalents in this repo. The PreToolUse hook (next subsection) enforces this. (`sed` routes to `ash edit` for in-place edits and `ash read --range` for line-range reads; pure pipeline `cmd | sed …` is allowed through. `patch` and `git apply` route to `ash edit --patch` for single-file diffs; multi-file diffs pass through since `ash edit --patch` is single-file only — ASH-152 deferred.) - -13. **Restarting the daemon** (after editing `ash.toml`, or after a rebuild) — use `ash stop`. The next `ash` invocation auto-starts a fresh daemon. Don't reach for `pkill ashd`. +10. **Restarting the daemon** (after editing `ash.toml`, or after a rebuild) — use `ash stop`. The next `ash` invocation auto-starts a fresh daemon. Don't reach for `pkill ashd`. **The whole point** is that you are the first user. If a verb errors, hangs, or feels heavier than the bash equivalent, that's a bug or design gap — investigate, don't paper over. Write the session note. diff --git a/README.md b/README.md index a4a0574..896a0eb 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Agents in repos that ran `ash init` get a CLAUDE.md section with the switch crit ## The verb surface -The verb surface as of phase 2 ship 14 is below. Every verb returns a structured response over a MessagePack-with-schema-dictionary protocol; the same data renders as a token-lean pretty form for human (or LLM) consumption. Run `ash help` for the full schema of every verb, or `ash help --verb ` for one. +The verb surface as of phase 2 is below. Every verb returns a structured response over a MessagePack-with-schema-dictionary protocol; the same data renders as a token-lean pretty form for human (or LLM) consumption. Run `ash help` for the full schema of every verb, or `ash help --verb ` for one. ### File system @@ -104,21 +104,33 @@ The verb surface as of phase 2 ship 14 is below. Every verb returns a structured | Verb | Purpose | |---|---| -| `git --op status\|log\|diff\|show` | Git as structured calls — not text-scraped. Default backend is in-process go-git; `shellout` opt-in for `--staged` / unstaged patch text. | +| `git --op status\|log\|diff\|show\|blame` | Git as structured calls — not text-scraped. Default backend is in-process go-git; `shellout` opt-in for `--staged` / unstaged patch text. | ### Build / test | Verb | Purpose | |---|---| +| `build` | Run `go build`; structured per-package errors with `file:line:col`. | | `test` | Run Go tests via `go test -json`. Structured per-package/per-test results; build failures land as records, not raw stderr. | +### Semantic + +| Verb | Purpose | +|---|---| +| `lang` | Outline / definition / references / callers / impl via a language-server broker. Currently in a usage-validation freeze (ASH-197). | + ### Observability | Verb | Purpose | |---|---| | `metrics` | Raw recent ledger rows. | | `report` | Aggregated per-verb summary: n, ok%, p50/p95 latency, p50/p95 tokens_out, truncation rate, top error histograms, top truncation hotspots. Cross-repo via `--root` / `--all_roots`. | +| `recap` | Compact session summary — files touched, patterns searched, edits made. | +| `workspace` | Re-orientation snapshot — relevant files, recent searches, branch + status, last error. | +| `replay` | Re-run prior ledger calls and report per-verb token deltas vs the originals. | | `bench` | Run canonical cases against ash and the bash equivalent the agent would otherwise have used; tokenize both with the same encoder; report Δtokens / Δlatency per case. | +| `usage` | Estimate cache-friendliness of recent calls from arg-repetition counts. | +| `turn` | Record an Anthropic API turn's usage/cache numbers; fed by the Stop hook. | ### Lifecycle @@ -160,7 +172,7 @@ Full reference: [docs/configuration.md](docs/configuration.md). 5. **Token-aware.** Every response reports its real token cost. The agent can self-budget. 6. **Platform-uniform.** `ash grep` behaves identically on macOS, Linux, and Windows. No GNU-vs-BSD divergence, no missing utilities, no per-platform conditionals in agent prompts. 7. **Persistent context.** The shell is a daemon, not a per-command process. Sessions, objects, and jobs survive across invocations. -8. **Semantic when possible.** The forthcoming `lang` verb gives agents callers/definitions/references via tree-sitter, not regex approximations. +8. **Semantic when possible.** The `lang` verb answers outline / definition / references / callers / impl queries through a language-server broker, not regex approximations. 9. **Instrumented by default.** Every verb call records latency, token cost, output size, truncation events, error class, and sanitized args to a session-scoped ledger. Performance and ergonomics claims are evaluated against the ledger, not against intuition. ## Constraints @@ -190,10 +202,10 @@ These are hard rules. A change that violates one is a stop-and-discuss. ┌──────────┴──────────┐ │ │ ┌───────▼───────┐ ┌────────▼────────┐ - │ builtins │ │ registered │ - │ (find, grep, │ │ tools │ - │ read, ...) │ │ (cargo, npm, │ - └───────────────┘ │ pytest, ...) │ + │ builtins │ │ tool registry │ + │ (find, grep, │ │ (Phase 5 — │ + │ read, ...) │ │ not yet │ + └───────────────┘ │ shipped) │ └─────────────────┘ ``` @@ -305,17 +317,17 @@ Surface locked, wire protocol drafted, switch-criteria doc ([CLAUDE.md](CLAUDE.m ### Phase 1 — walking skeleton — done Go daemon, UDS transport, MessagePack with versioned schema dictionary, three read-side verbs (`find` / `grep` / `read`), instrumentation ledger from day one, self-hosting on this repo. -### Phase 2 — coding-agent core — current (ship 14) +### Phase 2 — coding-agent core — current Live: `write`, `edit`, `stat`, `diff`, `git` (status/log/diff/show/blame), `build`, `test`, `bench`, `metrics`, `report`, `hook`, `help`, `init`, `uninit`, `stop`. Plus: configuration substrate (`ash.toml`), jail policy, ledger retention, in-process go-git backend, cross-repo report aggregation, PreToolUse hook with per-verb exclusions. Upcoming for phase 2: `fmt`, `run`, `proc`, `obj`, persistent session/object store, job ledger for async operations, reference Go client library. -### Phase 3 — semantic layer +### Phase 3 — semantic layer — shipped, under evaluation -- `lang` verb backed by tree-sitter -- Symbol search, callers, definitions, references -- File outlines without bodies (token-efficient orientation) +The `lang` verb — outline, definition, references, callers, impl — shipped +behind a language-server broker. It is currently in a usage-validation +freeze (ASH-197) while real demand is assessed. ### Phase 4 — adoption diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..693abfd --- /dev/null +++ b/docs/README.md @@ -0,0 +1,52 @@ +# ash documentation + +A map of this directory. For the project pitch and verb-surface overview see +the top-level [README.md](../README.md); for the live, authoritative per-verb +schema run `ash help` (never duplicated in markdown). + +## Getting started — [`adoption/`](adoption/) + +- [adoption/install.md](adoption/install.md) — install via Homebrew or from source. +- [adoption/claude-code.md](adoption/claude-code.md) — wire `ashmcp` into Claude Code. +- [adoption/claude-desktop.md](adoption/claude-desktop.md) — wire `ashmcp` into Claude Desktop. +- [adoption/migration-from-hook.md](adoption/migration-from-hook.md) — moving from the PreToolUse hook to MCP. + +## Reference — how ash works + +- [configuration.md](configuration.md) — `ash.toml`: jail, git backend, runner, ledger retention. +- [PreToolUse.md](PreToolUse.md) — the `ash hook` deny/redirect behavior. +- [architecture/layers.md](architecture/layers.md) — the four-tier vocabulary (protocol / verb library / dispatch / clients). +- [agent-guidance.md](agent-guidance.md) — the doc surfaces that drive ash usage and how they are maintained. +- [optimization-tiers.md](optimization-tiers.md) — the framework for deciding what to optimize. +- [ledger-cleanup.md](ledger-cleanup.md) — ledger retention and cleanup. + +## Design notes — rationale for shipped subsystems + +- [bench.md](bench.md), [bench-2.md](bench-2.md) — the `ash bench` measurement framework. +- [report.md](report.md) — `ash report` ledger synthesis. +- [replay.md](replay.md) — `ash replay`, the ledger as a regression suite. +- [streaming.md](streaming.md) — the streaming (Chunk/Final) protocol. +- [cache-shape.md](cache-shape.md) — the cache-aware response envelope (ASH-108). +- [path-prefixes.md](path-prefixes.md) — path-prefix token optimization (ASH-71). +- [mcp/design-decisions.md](mcp/design-decisions.md) — the `ashmcp` adapter. +- [vocab/design.md](vocab/design.md) — the vocab inventory generator. +- [library-audit.md](library-audit.md) — the dependency-reuse audit (ASH-103). + +## Measurements — empirical results + +- [performance-baselines.md](performance-baselines.md) — latency baselines and where the time goes. +- [encoding-results.md](encoding-results.md) — measured token costs and the encoding decisions they drove. +- [cli-tokens.md](cli-tokens.md) — token-efficiency evaluation of the CLI surface. +- [cache-prefix-measurement.md](cache-prefix-measurement.md), [cache-telemetry.md](cache-telemetry.md) — prompt-cache validation. +- [mcp/wire-cost.md](mcp/wire-cost.md) — CLI vs MCP wire cost. +- [lsp-pilot-validation.md](lsp-pilot-validation.md) — the `lang`/LSP pilot evaluation (ASH-141). + +## Generated artifacts — checked in, regenerated by `make` + +- [vocab/inventory.md](vocab/inventory.md), `vocab/inventory.json` — every stable agent-facing string (`make vocab`). +- `mcp/tools.json` — the MCP tool schema (`make schema`). + +## Historical & speculative + +- [value-assessment/](value-assessment/) — a point-in-time go/no-go evaluation (May 2026); see its README. +- [revolutionary-directions.md](revolutionary-directions.md) — an ideas document, **not** a roadmap. diff --git a/docs/revolutionary-directions.md b/docs/revolutionary-directions.md index fb83f6b..d3e6e13 100644 --- a/docs/revolutionary-directions.md +++ b/docs/revolutionary-directions.md @@ -1,5 +1,9 @@ # Revolutionary directions for ash +> **Status: speculative.** An ideas document — not a commitment or roadmap. +> Some parts have since shipped (e.g. the MCP server, ASH-104..107); others +> remain unevaluated. The Linear backlog is the source of truth for plans. + ## Context ash today is well-shaped for *evolutionary* improvement: the ledger captures every call, the vocab inventory ([ASH-102](https://linear.app/stazelabs/issue/ASH-102)) will close the static-protocol surface, Tier 1/2 token wins from [cli-tokens.md](cli-tokens.md) are queued. The encoding-substitution measurement on 2026-05-13 proved non-ASCII glyphs are a dead end and refocused effort on structural changes. diff --git a/docs/value-assessment/README.md b/docs/value-assessment/README.md new file mode 100644 index 0000000..ba962d3 --- /dev/null +++ b/docs/value-assessment/README.md @@ -0,0 +1,10 @@ +# Value assessment — historical snapshot + +A point-in-time go/no-go evaluation of ash and ashmcp, run in mid-May 2026. +The six sweeps (`01`–`06`) gather the evidence; [decision.md](decision.md) is +the conclusion that closed the two adoption-gate questions. + +Kept for the record — these are **not** living documents. The numbers reflect +the surface as of that date; current benchmark figures live in +[../../bench/baseline.md](../../bench/baseline.md). The `06-mcp-table-*.md` +files are raw generated tables retained only as the inputs to `06-mcp.md`. diff --git a/internal/atomicwrite/atomicwrite_test.go b/internal/atomicwrite/atomicwrite_test.go new file mode 100644 index 0000000..ad84ec9 --- /dev/null +++ b/internal/atomicwrite/atomicwrite_test.go @@ -0,0 +1,86 @@ +package atomicwrite + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWrite_CreatesNewFile(t *testing.T) { + p := filepath.Join(t.TempDir(), "new.txt") + if err := Write(p, []byte("hello"), Options{}); err != nil { + t.Fatalf("Write: %v", err) + } + got, err := os.ReadFile(p) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(got) != "hello" { + t.Errorf("content = %q, want hello", got) + } +} + +func TestWrite_ReplacesExisting(t *testing.T) { + p := filepath.Join(t.TempDir(), "f.txt") + if err := os.WriteFile(p, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if err := Write(p, []byte("new contents"), Options{}); err != nil { + t.Fatalf("Write: %v", err) + } + got, _ := os.ReadFile(p) + if string(got) != "new contents" { + t.Errorf("content = %q, want new contents", got) + } +} + +func TestWrite_NoLeftoverTempFile(t *testing.T) { + dir := t.TempDir() + if err := Write(filepath.Join(dir, "f.txt"), []byte("data"), Options{}); err != nil { + t.Fatalf("Write: %v", err) + } + entries, _ := os.ReadDir(dir) + if len(entries) != 1 || entries[0].Name() != "f.txt" { + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("dir contents = %v, want exactly [f.txt]", names) + } +} + +func TestWrite_PreserveMode(t *testing.T) { + p := filepath.Join(t.TempDir(), "f.txt") + if err := os.WriteFile(p, []byte("old"), 0o640); err != nil { + t.Fatal(err) + } + before, _ := os.Stat(p) + if err := Write(p, []byte("new"), Options{PreserveMode: true}); err != nil { + t.Fatalf("Write: %v", err) + } + after, _ := os.Stat(p) + if after.Mode() != before.Mode() { + t.Errorf("mode = %v, want preserved %v", after.Mode(), before.Mode()) + } +} + +func TestWrite_WithoutPreserveModeResetsTo0600(t *testing.T) { + p := filepath.Join(t.TempDir(), "f.txt") + if err := os.WriteFile(p, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if err := Write(p, []byte("new"), Options{}); err != nil { + t.Fatalf("Write: %v", err) + } + info, _ := os.Stat(p) + if info.Mode().Perm() != 0o600 { + t.Errorf("mode = %v, want 0600 (os.CreateTemp default, no PreserveMode)", info.Mode().Perm()) + } +} + +func TestWrite_ErrorWhenParentMissing(t *testing.T) { + p := filepath.Join(t.TempDir(), "does-not-exist", "f.txt") + if err := Write(p, []byte("x"), Options{}); err == nil { + t.Error("expected an error when the parent directory is missing") + } +} diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go new file mode 100644 index 0000000..9c7f96f --- /dev/null +++ b/internal/runner/runner_test.go @@ -0,0 +1,87 @@ +package runner + +import ( + "os/exec" + "testing" +) + +func requireSh(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("sh not on PATH") + } +} + +func TestRun_BinaryNotFound(t *testing.T) { + _, perr := Run("ash-no-such-binary-xyzzy", nil, Opts{}) + if perr == nil { + t.Fatal("expected a proto.Error for a missing binary") + } + if perr.Code != "ash-no-such-binary-xyzzy_not_found" { + t.Errorf("code = %q, want ...-xyzzy_not_found", perr.Code) + } +} + +func TestRun_Success(t *testing.T) { + requireSh(t) + res, perr := Run("sh", []string{"-c", "printf hello"}, Opts{}) + if perr != nil { + t.Fatalf("Run: %+v", perr) + } + if string(res.Stdout) != "hello" { + t.Errorf("stdout = %q, want hello", res.Stdout) + } + if res.ExitCode != 0 { + t.Errorf("exit = %d, want 0", res.ExitCode) + } +} + +func TestRun_NonZeroExitIsNotAnError(t *testing.T) { + requireSh(t) + res, perr := Run("sh", []string{"-c", "exit 3"}, Opts{}) + if perr != nil { + t.Fatalf("Run returned a proto.Error for a non-zero exit: %+v", perr) + } + if res.ExitCode != 3 { + t.Errorf("exit = %d, want 3", res.ExitCode) + } +} + +func TestRun_StderrCaptured(t *testing.T) { + requireSh(t) + res, perr := Run("sh", []string{"-c", "printf oops 1>&2"}, Opts{}) + if perr != nil { + t.Fatalf("Run: %+v", perr) + } + if string(res.Stderr) != "oops" { + t.Errorf("stderr = %q, want oops", res.Stderr) + } +} + +func TestRun_MaxStdoutTruncates(t *testing.T) { + requireSh(t) + res, perr := Run("sh", []string{"-c", "printf aaaaaaaaaa"}, Opts{MaxStdout: 4}) + if perr != nil { + t.Fatalf("Run: %+v", perr) + } + if !res.Truncated { + t.Error("Truncated = false, want true") + } + if len(res.Stdout) != 4 { + t.Errorf("len(stdout) = %d, want 4 (capped)", len(res.Stdout)) + } +} + +func TestRun_MaxStdoutExactlyAtCapNotTruncated(t *testing.T) { + requireSh(t) + res, perr := Run("sh", []string{"-c", "printf aaaa"}, Opts{MaxStdout: 4}) + if perr != nil { + t.Fatalf("Run: %+v", perr) + } + if res.Truncated { + t.Error("Truncated = true for output exactly at the cap") + } + if string(res.Stdout) != "aaaa" { + t.Errorf("stdout = %q, want aaaa", res.Stdout) + } +} diff --git a/internal/session/paths_test.go b/internal/session/paths_test.go new file mode 100644 index 0000000..1f23843 --- /dev/null +++ b/internal/session/paths_test.go @@ -0,0 +1,77 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRoot_FindsGoModMarker(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module x\n"), 0o644); err != nil { + t.Fatal(err) + } + sub := filepath.Join(dir, "a", "b") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + got, err := Root(sub) + if err != nil { + t.Fatalf("Root: %v", err) + } + if got != dir { + t.Errorf("Root = %q, want %q (go.mod marker)", got, dir) + } +} + +func TestRoot_NoMarkerReturnsStart(t *testing.T) { + sub := filepath.Join(t.TempDir(), "deep") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + got, err := Root(sub) + if err != nil { + t.Fatalf("Root: %v", err) + } + if got != sub { + t.Errorf("Root = %q, want %q (no marker -> start)", got, sub) + } +} + +func TestSocketPath_StableAndScoped(t *testing.T) { + a := SocketPath("/project/one") + if a != SocketPath("/project/one") { + t.Error("SocketPath is not stable for the same root") + } + if a == SocketPath("/project/two") { + t.Error("distinct roots produced the same socket path") + } + if !strings.HasSuffix(a, ".sock") || !strings.Contains(filepath.Base(a), "ash-") { + t.Errorf("unexpected socket path shape: %q", a) + } +} + +func TestProjectPaths(t *testing.T) { + root := "/x/y" + for _, c := range []struct{ name, got, want string }{ + {"ledger", LedgerPath(root), filepath.Join(root, ".ash", "ledger.db")}, + {"langcache", LangCachePath(root), filepath.Join(root, ".ash", "lang-cache.db")}, + {"pid", PIDPath(root), filepath.Join(root, ".ash", "ashd.pid")}, + {"log", LogPath(root), filepath.Join(root, ".ash", "ashd.log")}, + } { + if c.got != c.want { + t.Errorf("%s = %q, want %q", c.name, c.got, c.want) + } + } +} + +func TestEnsureRuntimeDirs(t *testing.T) { + root := t.TempDir() + if err := EnsureRuntimeDirs(root); err != nil { + t.Fatalf("EnsureRuntimeDirs: %v", err) + } + if fi, err := os.Stat(filepath.Join(root, ".ash")); err != nil || !fi.IsDir() { + t.Errorf(".ash dir not created: err=%v", err) + } +}