feat: analyze hook via a persistent Dart analysis server instead of a cold `dart analyze` per edit
Nessuno ha ancora preso questa issue.
Valutazione
- Difficoltà
- 5/5
- Tempo stimato
- Più di una settimana
- Idoneità per principianti
- 25/100
- Tipo di issue
- Funzionalità
- Chiarezza
- Abbastanza chiara
- Stato di attività
- Attiva
- Stack tecnologico
- flutter, shell
- Ambito
- developer-experience, tooling
Direzione di ricerca
Start by reading CLAUDE.md, AGENTS.md, hooks/hooks.json, hooks/scripts/analyze.sh, hooks/scripts/format.sh, hooks/scripts/vgv-cli-common.sh, and hooks/scripts/*_test.sh, as the issue's suggested starting prompt specifies. Then investigate the existing hook behavior and script-test conventions before tackling the proposed phases. Done means the requirements and tests in the issue are satisfied, including fallback behavior, opt-out, SessionStart warm-up, and CI checks.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
Description
The analyze.sh PostToolUse hook runs dart analyze <file> as a fresh process after every Edit/Write of a .dart file. Each run starts a Dart VM, loads the analyzer, resolves the whole context root the file belongs to, and exits. The hook has a 30 s timeout, and on large projects a single dart analyze call can exceed it (see "Why this matters" below).
Proposal: keep one Dart analysis server warm per project root for the life of a session and have the hook ask it for the file's diagnostics instead of cold-starting dart analyze on every edit. The analysis server already ships in the SDK (dart language-server --protocol=lsp), it is the same analyzer and the same analysis_options.yaml, so diagnostics are identical. It is what the IDE already does. We would be giving the agent the same inner loop a human has.
This was measured and written up by Claude Code in a session with @deremer; numbers and the probe script are below so maintainers can reproduce.
Why this matters
Agents edit far more often than humans do and wait on the hook every time. The per-edit cost today depends on project size and on whether the analyzer's on-disk cache (~/.dartServer/.analysis-driver) is warm:
| Project | dart analyze <file>, warm cache |
dart analyze <file>, cold cache |
|---|---|---|
| VGV layered app (187 files, 25k lines) | 0.26 s | 2.0 s |
flutter/packages/flutter (1,690 files, 1.3M lines) |
1.4–2.1 s | 4.2 s |
| Reported large monorepo with analyzer plugins (dart-lang/sdk#61684) | 42 s |
A dart analyze <file> call still resolves every file in the context root (dart-lang/sdk#64381), so "single file" does not make it cheap. On a 42 s project the hook times out on every edit and the agent gets no signal at all.
Measured gains with a warm analysis server
A small LSP probe (attached) started dart language-server, waited for the initial analysis, then sent textDocument/didChange with an injected type error and timed publishDiagnostics for that file.
| Project | Initial analysis (once per session) | Per-edit diagnostics, warm server | Per-edit today (dart analyze <file>) |
Server RSS |
|---|---|---|---|---|
VGV layered app, lib/bootstrap.dart |
5.9 s | 6–90 ms | 260 ms warm / 2.0 s cold | 613 MB |
Flutter framework, widgets/placeholder.dart (108 lines) |
3.9 s (warm cache) | 0.95–1.03 s | 2.07 s warm / 4.2 s cold | 682 MB |
Flutter framework, material/app_bar.dart (2,620 lines) |
1.4 s | 1.85 s | ||
Flutter framework, widgets/basic.dart (8,475 lines) |
1.14 s | 1.39 s warm / 4.2 s cold | 726 MB |
Reading the table:
- On an app structured the way VGV builds apps (layered packages, no giant import cycles) a warm server answers in milliseconds. That is a 3x to 40x per-edit improvement over the warm case and 20x to 300x over the cold case.
- The Flutter framework itself is the worst case:
lib/widgetsis one large import cycle, so every edit re-resolves the cycle (~1 s). The gain there is only about 2x. Client apps do not look like this. - Over a session the cost model changes from
N × (0.3–42 s)toone warm-up (≈ one cold analyze) + N × (10 ms–1 s). A 50-edit session on the VGV app goes from roughly 15–100 s of hook time to about 6 s plus a few hundred milliseconds. - Memory: 600–730 MB resident per project daemon, the same order as the IDE's own server. When the IDE is open there will be two servers for the project. Needs an idle shutdown and an opt-out (see Risks).
Machine: M-series Mac, 8 cores, 16 GB, Dart 3.13.5.
Proposed design
1. A small sidecar daemon that owns the language server
hooks/daemon/analysis_daemon.dart, a Dart program shipped in the plugin and compiled once per Dart SDK version with dart compile exe into ${XDG_CACHE_HOME:-$HOME/.cache}/vgv-ai-flutter-plugin/<dart-version>/analysis_daemon on first use (the plugin root may not be writable). The plugin already requires a Dart SDK, so this adds no prerequisite.
Responsibilities:
- One daemon per project root. Root is the nearest ancestor with
pubspec.yaml, promoted to the pub workspace root when one exists (so a monorepo gets one server, not one per package, which the pub workspaces docs recommend for memory). - Spawns
dart language-server --protocol=lsp, sendsinitializewithrootUri, answers server-to-client requests (window/workDoneProgress/create,client/registerCapability), and tracks theANALYZINGprogress token so it knows when the root is ready. Note: the server stalls silently if the client never replies towindow/workDoneProgress/create; the probe hit this. - Loopback HTTP endpoint on
127.0.0.1with a random port and a random token, written to~/.cache/vgv-ai-flutter-plugin/daemons/<sha1-of-root>.jsonas{pid, port, token, root, dartVersion, startedAt}. HTTP over loopback is chosen because the hook is Bash andcurlis on every macOS and Linux box; Unix sockets would neednc/socat. POST /analyze{ "file": "<abs path>" }: reads the file from disk, sendsdidOpen(first time) ordidChange(version++), waits forpublishDiagnosticsfor that URI, and returns{ "diagnostics": [{severity, line, col, code, message}] }plus adart analyze-style text rendering. Should also collect diagnostics the server publishes for other files in the same flush, so a change that breaks a dependent is reported, whichdart analyze <file>cannot do today.POST /format{ "file" }(optional, phase 2):textDocument/formatting, apply the edits, write the file. Replaces the second VM start informat.sh.GET /health:{ ready, analyzing, dartVersion, uptime }.- Lifecycle: exit after 30 min idle; exit if
dart --versionno longer matches the version it was built against; restart the language server once if it crashes, then give up and let the hook fall back. The server watches the file system itself, sopub get,pubspecedits andanalysis_optionsedits are picked up without restarts. - Shared across sessions. Several Claude Code sessions or worktrees on the same root share one daemon; the pidfile is the lock. Different worktrees are different roots and get their own.
2. analyze.sh changes
file is .dart? no → exit 0 (unchanged)
resolve project root none → fall back to `dart analyze <file>` (unchanged)
daemon json present and /health ok? yes → POST /analyze (curl --max-time 25)
no → spawn daemon detached (nohup/setsid), then
fall back to `dart analyze <file>` for THIS call
any error talking to the daemon → fall back to `dart analyze <file>`
Exit semantics stay exactly as today: print the diagnostics and exit 2 on the same severities that make dart analyze fail now, exit 0 otherwise. The fallback means a broken daemon can never make the hook worse than the status quo.
Opt-out: VGV_ANALYZE_DAEMON=0 keeps today's behavior. Useful on memory-constrained machines and for bisecting.
3. SessionStart warm-up
warm-missing-mcp.sh (or a new warm-analysis-daemon.sh) starts the daemon for $CLAUDE_PROJECT_DIR in the background, non-blocking, so the first edit of the session is already fast. This is where the one-time cost moves to.
4. Out of scope for the first PR
/formatthrough the server (phase 2, saves one more VM start per edit).- Exposing the daemon to the
flutter-revieweragent or thegreen-gateskill for a fast "is the package clean" check (phase 3;green-gatecurrently shells out to the full analyze). - Windows support beyond "falls back cleanly".
Expected gains, summarised
- Per-edit hook latency on typical apps: hundreds of milliseconds to seconds → tens of milliseconds.
- Large monorepos where the hook currently times out: the hook becomes usable at all.
- Cross-file breakage (edit A, break B) becomes visible to the agent on the edit that caused it.
- Fewer
dartVM starts per edit: 2 today (analyze + format), 1 after phase 1, 0 after phase 2.
Risks and open questions
- Memory. 600–730 MB per root measured; the fluttercandies
dart_analysis_daemonREADME reports 10 GB on a 15k-file project. Mitigations: workspace-root promotion, idle shutdown, opt-out, and a warning in the SessionStart hook when the root is very large. Should the daemon refuse to start above a file-count threshold unless opted in? - Zombie daemons. Detached processes outliving the host. Idle timeout and a pidfile with liveness check cover the common case; a
vgv-analyze-daemon --stop-allescape hatch would help. - Host matrix. The plugin targets Claude Code first but is being made portable (#126, #46, #125, #127).
nohup/setsidandcurlbehave the same on macOS and Linux; Windows needs verification. The fallback keeps every host no worse than today. - SDK upgrades mid-session. Covered by the
dartVersionstamp check in/health; the hook treats a mismatch as "no daemon" and respawns. - Diagnostics parity. Same analyzer, same options, same plugins. The one difference is that
dart analyze <file>filters to the requested file while the server publishes for everything it re-analyzed. That is a feature if we surface it well and noise if we do not; propose reporting other files' new errors only. - Edits outside any pubspec (scratch files, generated code in
build/): fall back. - Security. Loopback only, random token, daemon json is user-readable only. Nothing is exposed off-box.
- Test strategy.
hooks/scripts/analyze_test.shwith a fake daemon (apython3 -m http.serveror a tiny Dart stub) covering: daemon absent → fallback; daemon unhealthy → fallback; daemon returns errors → exit 2 with text; daemon returns clean → exit 0; opt-out env var. A daemon integration test on a sample project in the Script Tests CI job that asserts an injected type error is reported in under 1 s after warm-up.
Alternatives considered
- Keep
dart analyzeand rely on its disk cache. Already the case; the cold path and the large-project path are the problem, and the cache does not help the 42 s case. - Host the server inside the Very Good CLI MCP server process. Hooks cannot call MCP tools, and it couples the fix to a
very_good_clirelease. The MCP server could later also talk to the same daemon. - AST-only linting (fast_linter style). Fast, but cannot report type errors, which are most of what an agent needs to hear about.
- A Rust/native toolchain. Does not change the type-checking cost, which is where the time goes.
Requirements
-
analyze.shuses a warm analysis server when one is available and falls back todart analyze <file>on any failure, with unchanged output format and exit codes. - One daemon per project root (pub workspace aware), shared across sessions, idle shutdown, SDK-version stamp.
- SessionStart warms the daemon for the project directory without blocking.
-
VGV_ANALYZE_DAEMON=0restores today's behavior. - Hook script tests cover fallback, unhealthy daemon, errors, clean, and opt-out; CI integration test proves sub-second diagnostics after warm-up on a sample project.
- README hooks table,
CLAUDE.md/AGENTS.mdhook docs and the Prerequisites section updated (addscurl). - All CI/CD checks are passing.
- There is no drop in the test coverage percentage.
Suggested starting prompt for Claude Code
Read CLAUDE.md, AGENTS.md, hooks/hooks.json, hooks/scripts/analyze.sh, hooks/scripts/format.sh,
hooks/scripts/vgv-cli-common.sh and hooks/scripts/*_test.sh, then implement GitHub issue #182
("analysis daemon for the analyze hook") in three PR-sized phases. Do not start phase 2 until
phase 1 is green.
Phase 0 (spike, no PR): write hooks/daemon/analysis_daemon.dart that spawns
`dart language-server --protocol=lsp`, initialises it on a root, answers server-to-client
requests (window/workDoneProgress/create, client/registerCapability), tracks the ANALYZING
progress token, and exposes GET /health and POST /analyze on a loopback port with a token.
Prove it on this repo's own fixture project: after warm-up, inject `final int x = 'oops';`
into a file and show publishDiagnostics for that file in under 1 s. Record the numbers.
Phase 1 (PR): daemon lifecycle (pidfile under ~/.cache/vgv-ai-flutter-plugin/daemons/,
one per pub workspace root, 30 min idle exit, dart --version stamp, single crash restart),
compile-on-first-use with `dart compile exe` into the cache dir, and the analyze.sh changes:
try the daemon, spawn it detached if missing, fall back to `dart analyze <file>` on any error,
keep output format and exit codes identical, honour VGV_ANALYZE_DAEMON=0. Add
hooks/scripts/analyze_test.sh cases for fallback, unhealthy daemon, errors, clean and opt-out
using a stub daemon, and a CI integration test in the Script Tests job. Update README hooks
table, CLAUDE.md, AGENTS.md and Prerequisites (curl).
Phase 2 (PR): SessionStart warm-up for $CLAUDE_PROJECT_DIR (non-blocking), then route
format.sh through POST /format using textDocument/formatting so an edit costs zero VM starts.
Constraints: follow the hook-scoping rule in CLAUDE.md (confirm from the payload that the
call is yours, exit 0 otherwise). Never make the hook slower or stricter than today when the
daemon is unavailable. Measure before and after on the fixture project and put the numbers in
the PR description.
Additional context
- The same technique is what
dart_analysis_daemon(fluttercandies) does for CI gates, which is useful prior art for the lifecycle code even though it is not a hook. - Related: #81 (reduce token usage) benefits indirectly, since faster, more precise diagnostics mean fewer retry turns.
LSP probe used for the measurements (Python, stdlib only)
import json, subprocess, sys, time, os, threading, queue
root = os.path.abspath(sys.argv[1]); target = os.path.abspath(sys.argv[2])
p = subprocess.Popen(["dart","language-server","--protocol=lsp","--client-id=vgv-probe"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
q = queue.Queue()
def reader():
while True:
line = p.stdout.readline()
if not line: return
if line.startswith(b"Content-Length:"):
n = int(line.split(b":")[1])
while p.stdout.readline() not in (b"\r\n", b"\n", b""): pass
buf = b""
while len(buf) < n: buf += p.stdout.read(n - len(buf))
m = json.loads(buf)
if "id" in m and "method" in m: # server -> client request: acknowledge or it stalls
r = json.dumps({"jsonrpc":"2.0","id":m["id"],"result":None}).encode()
p.stdin.write(b"Content-Length: %d\r\n\r\n" % len(r) + r); p.stdin.flush(); continue
q.put(m)
threading.Thread(target=reader, daemon=True).start()
rid = 0
def send(method, params, notify=False):
global rid; msg = {"jsonrpc":"2.0","method":method,"params":params}
if not notify: rid += 1; msg["id"] = rid
b = json.dumps(msg).encode(); p.stdin.write(b"Content-Length: %d\r\n\r\n" % len(b) + b); p.stdin.flush()
uri = "file://" + target; rooturi = "file://" + root
t0 = time.time()
send("initialize", {"processId": os.getpid(), "rootUri": rooturi,
"capabilities": {"window":{"workDoneProgress":True}}, "workspaceFolders":[{"uri":rooturi,"name":"r"}]})
send("initialized", {}, notify=True)
while True:
m = q.get()
if m.get("method") == "$/progress" and m["params"].get("value",{}).get("kind") == "end": break
print(f"initial analysis of root: {time.time()-t0:.2f}s")
src = open(target).read()
send("textDocument/didOpen", {"textDocument":{"uri":uri,"languageId":"dart","version":1,"text":src}}, notify=True)
def wait_diag(timeout=60):
t = time.time()
while time.time()-t < timeout:
try: m = q.get(timeout=max(0.1, timeout-(time.time()-t)))
except queue.Empty: break
if m.get("method") == "textDocument/publishDiagnostics" and m["params"]["uri"] == uri:
return time.time()-t, m["params"]["diagnostics"]
return None, []
wait_diag(5) # the server may already have published for this file at startup
for i in range(3):
bad = src + f"\nvoid _probe{i}() {{ final int x = 'oops'; }}\n"
send("textDocument/didChange", {"textDocument":{"uri":uri,"version":2+i},"contentChanges":[{"text":bad}]}, notify=True)
dt, d = wait_diag(); print(f"edit {i+1} -> diagnostics: {dt:.3f}s ({len(d)} diags)")
send("textDocument/didChange", {"textDocument":{"uri":uri,"version":9},"contentChanges":[{"text":src}]}, notify=True)
dt, d = wait_diag(); print(f"revert -> diagnostics: {dt:.3f}s ({len(d)} diags)")
rss = subprocess.run(["ps","-o","rss=","-p",str(p.pid)],capture_output=True,text=True).stdout.strip()
print(f"server RSS: {int(rss)/1024:.0f} MB")
send("shutdown", {}); send("exit", {}, notify=True); p.wait(timeout=10)
Run as python3 probe.py <project-root> <file-in-project>.
- Lingua principale
- Shell
- Stelle
- 178
- Fork
- 24
- Merge medio
- 1g 5h
- PR unite (30g)
- 9
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di VeryGoodOpenSource/vgv-ai-flutter-plugin
-
bug p1
Difficoltà 3/5 1-2 giorni Idoneità per principianti 74/100
-
bug external dependency p2
Difficoltà 5/5 Più di una settimana Idoneità per principianti 30/100
VeryGoodOpenSource/vgv-ai-flutter-plugin#180 · 1 commento ·
-
feature p3
Difficoltà 5/5 Più di una settimana Idoneità per principianti 42/100
-
feat: debug agent/skillForse di nuovo libera Una pull request per questa issue è stata chiusa senza essere unita. Apertafeature p2
Difficoltà 5/5 Più di una settimana Idoneità per principianti 35/100
-
perf: reduce token usageApertap2 performance
VeryGoodOpenSource/vgv-ai-flutter-plugin#81 · 1 commento ·
Tutte le issue di VeryGoodOpenSource/vgv-ai-flutter-plugin
Issue simili
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 77/100
I maintainer di solito rispondono entro 1 giorno
-
`sideEffects: false` in 7.9.1+ drops d3-transition: `selection.transition is not a function`Forse già presa @dhairyajangir l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
-
p2 refactor
Difficoltà 2/5 1-3 ore Idoneità per principianti 84/100
VeryGoodOpenSource/vgv-wingspan#284 ·
I maintainer di solito rispondono entro 6 giorni
-
documentation good first issue
Difficoltà 2/5 1-3 ore Idoneità per principianti 95/100
mnema-work/mnema#1185 ·
I maintainer di solito rispondono entro 1 giorno