@@ -214,112 +214,143 @@ Each item must include:
214214
215215# Outstanding items
216216
217- ## Shared AST walker — remaining candidates (needs a decision)
218-
219- ** Done so far.** The stateless boolean/name-set/collection walkers have
220- been migrated to the generated ` mago-syntax ` ` Walker ` trait (each is now
221- a small visitor over a shared traversal): the six detector pairs in
222- ` diagnostics/undefined_variables/ ` (dynamic-vars, ` extract() ` ,
223- ` compact() ` , ` get_defined_vars() ` , ` @ ` -suppression, isset/empty guards),
224- the statement-dispatch halves of ` diagnostics/unused_variables.rs ` and
225- ` diagnostics/type_errors.rs ` , and the anonymous-class walker in
226- ` parser/anonymous.rs ` . That removed ~ 1,900 lines of hand-rolled
227- traversal.
228-
229- ** What remains — and why it is not a clean fit.** The remaining
230- hand-rolled walkers are * not* stateless collectors; they are
231- positional/order-sensitive, which the typed ` Walker ` cannot express
232- without re-typing per-node logic (it has no single "visit any node"
233- hook, only per-node-type ` walk_in_* ` ):
234-
235- - ` selection_range.rs ` pushes ** synthetic** spans (brace pairs, paren
236- pairs, block interiors) that are not AST-node spans, and would need a
237- ` walk_in_* ` override for essentially every node type to reproduce
238- them — no net reduction.
239- - ` symbol_map/extraction.rs ` records position-keyed symbol spans and
240- subject text; order and per-kind span shaping dominate, so the same
241- per-node-override problem applies.
242- - The ` property_access.rs ` / ` property_narrowing.rs ` pair walk ** from
243- the method start down to the cursor** , accumulating narrowing state in
244- source order with early returns. That interleaved-mutation,
245- cursor-bounded shape is exactly why the forward walker is out of scope
246- (below).
247-
248- The forward walker is explicitly out of scope (its traversal is
249- interleaved with scope-state mutation).
250-
251- ** Decision needed.** These three are arguably better left as-is: the
252- ` Walker ` migration's payoff (delete child-dispatch boilerplate) does not
253- apply when the walk pushes synthetic/positional data or mutates state in
254- source order. If the goal is purely "one traversal so new AST variants
255- can't create blind spots," a thin ` visit_all_nodes(&Node, &mut FnMut) `
256- helper over mago's ` Node ` enum would serve ` selection_range ` and
257- ` symbol_map/extraction ` better than the typed ` Walker ` . Confirm whether
258- to (a) leave these as-is, (b) migrate ` selection_range ` /
259- ` symbol_map/extraction ` via a ` Node ` -enum visitor, or (c) migrate the
260- property pair despite the state-threading cost.
261-
262- ** Why it matters.** Every new mago AST node variant (new PHP syntax)
263- still needs matching arms in these remaining walkers; missing one
264- produces a silent blind spot in exactly one feature.
265-
266- ---
267-
268- ## ` diagnostics/mod.rs ` is a grab-bag around a thin orchestrator
269-
270- ** What to do.** The actual orchestrator
271- (` collect_slow_diagnostics ` , ~ 85 lines) is fine, but it is buried in
272- 2,928 lines of unrelated logic. Carve out of ` src/diagnostics/mod.rs ` :
273-
274- - ` external/{phpstan,phpcs,mago}.rs ` — the four ` schedule_* `
275- subprocess spawn/debounce/parse pipelines (~ 700 lines, self-contained).
276- - ` stale.rs ` — ` is_stale_phpstan_diagnostic ` plus its helpers
277- (~ 335 lines reconciling cached external diagnostics with edits).
278- - ` suppression.rs ` — ` filter_suppressed ` ,
279- ` suppress_imprecise_overlaps ` , ` is_full_line_range ` (~ 230 lines of
280- post-processing policy).
281-
282- While in there, introduce a shared ` FileDiagnosticContext ` built once
283- in ` collect_slow_diagnostics ` and passed to collectors — seven
284- symbol-span collectors (` unknown_classes.rs ` , ` unknown_functions.rs ` ,
217+ Recommended order: do tasks 2 → 3 → 4 in that order (4 renames fields
218+ across the whole crate, so it goes last to avoid churning code that 2
219+ and 3 are about to move).
220+
221+ All three tasks are pure internal refactors. None of them gets a
222+ changelog entry unless the work uncovers and fixes a concrete
223+ user-visible bug. Run the Rust CI checks (` cargo nextest run ` , `cargo
224+ clippy -- -D warnings` , ` cargo clippy --tests -- -D warnings` , ` cargo
225+ fmt`) after each task.
226+
227+ ## 2. Split ` diagnostics/mod.rs ` around its thin orchestrator
228+
229+ ** What to do.** The actual orchestrator (` collect_slow_diagnostics ` ,
230+ at ~ line 240, ~ 85 lines) is fine, but it is buried in a 3,391-line
231+ ` src/diagnostics/mod.rs ` . This is pure code motion plus one shared
232+ context struct — no behavior changes. Rust allows ` impl Backend `
233+ blocks in any file of the crate, so moving a method means moving it
234+ into a new ` impl Backend ` block in the new file; nothing needs to
235+ become free functions.
236+
237+ ** Step 1 — external-tool pipelines → ` diagnostics/external/ ` .**
238+ Create ` src/diagnostics/external/{mod,phpstan,phpcs,mago}.rs ` and move
239+ the four subprocess spawn/debounce/parse pipelines out of ` mod.rs ` :
240+ ` schedule_phpstan ` (~ line 1299), ` schedule_phpcs ` (~ 1474),
241+ ` schedule_mago_lint ` (~ 1632), and ` schedule_mago_analyze ` (~ 1764,
242+ both mago pipelines go in ` mago.rs ` ) — roughly 730 lines — each with
243+ the private helpers only it uses. Helpers shared by two or more
244+ pipelines go in ` external/mod.rs ` . Do ** not** move
245+ ` schedule_diagnostics ` , ` schedule_external_diagnostics ` , or
246+ ` schedule_diagnostics_for_open_files ` (~ 1077–1298): those are the
247+ native debounce orchestration and stay in ` mod.rs ` .
248+
249+ ** Step 2 — staleness reconciliation → ` diagnostics/stale.rs ` .**
250+ Move ` is_stale_phpstan_diagnostic ` (~ line 467) plus every helper only
251+ it uses (~ 335 lines reconciling cached external diagnostics with
252+ edits).
253+
254+ ** Step 3 — post-processing policy → ` diagnostics/suppression.rs ` .**
255+ Move ` filter_suppressed ` (~ line 2034), ` suppress_imprecise_overlaps `
256+ (~ 2071), and ` is_full_line_range ` (~ 2159) — ~ 230 lines.
257+
258+ ** Step 4 — shared ` FileDiagnosticContext ` .** Seven symbol-span
259+ collectors (` unknown_classes.rs ` , ` unknown_functions.rs ` ,
285260` deprecated.rs ` , ` implementation_errors.rs ` , ` invalid_class_kind.rs ` ,
286- ` unknown_members/mod.rs ` , ` unused_imports.rs ` ) currently open with a
287- near-verbatim 15-20 line lock-gathering preamble (symbol map, resolved
288- names, use map, namespace, local classes), and a shared snapshot also
289- guarantees they observe consistent state.
290-
291- Also split ` src/diagnostics/type_errors.rs ` : ` is_type_compatible ` is a
292- single ~ 607-line function (plus four helper predicates) implementing
293- the diagnostic-policy layer over ` util::is_subtype_of_typed ` . Move it
294- to ` type_errors/compatibility.rs ` , and audit whether some of its
295- "MAYBE escape hatches" (IntRange, union handling, Generic/Traversable)
296- belong in ` is_subtype_of_typed ` where all callers would benefit.
261+ ` unknown_members/mod.rs ` , ` unused_imports.rs ` ) open with variations of
262+ the same lock-gathering preamble. Define, next to the existing
263+ ` diagnostics/helpers.rs ` utilities, a struct holding the per-file
264+ snapshot they gather: ` symbol_map: Arc<SymbolMap> ` ,
265+ ` resolved_names: Option<Arc<OwnedResolvedNames>> ` , the file's use map
266+ (from ` file_imports ` ), its ` Vec<NamespaceSpan> ` (from
267+ ` file_namespaces ` ), and its local classes (from ` uri_classes_index ` ).
268+ Give it a constructor like `FileDiagnosticContext::gather(&Backend,
269+ uri) -> Option<Self >` (returning ` None` when no symbol map exists,
270+ which is every collector's current early-out). Build it ** once** in
271+ ` collect_slow_diagnostics ` and pass ` &FileDiagnosticContext ` to the
272+ seven collectors; collectors that need only some fields just ignore
273+ the rest. Before changing collector signatures, grep for callers
274+ outside ` collect_slow_diagnostics ` (the ` analyse/ ` CLI path, code
275+ actions, and tests call some collectors directly); any such caller
276+ builds its own context via ` gather ` . Besides deduplication, the shared
277+ snapshot guarantees all collectors observe consistent state instead of
278+ re-reading locks that a concurrent parse may update between
279+ collectors.
280+
281+ ** Step 5 — split ` type_errors.rs ` .** Convert
282+ ` src/diagnostics/type_errors.rs ` (1,538 lines) into a directory and
283+ move ` is_type_compatible ` (starts at ~ line 76, ~ 730 lines) plus the
284+ predicates only it uses (` is_bare_array ` , ` is_refined_scalar_pair ` ,
285+ etc.) to ` type_errors/compatibility.rs ` . It is the diagnostic-policy
286+ layer over ` crate::class_lookup::is_subtype_of_typed ` .
287+
288+ ** Step 6 — file the MAYBE audit, do not act on it.** While moving
289+ ` is_type_compatible ` , list its "MAYBE escape hatches" (IntRange,
290+ union handling, iterable/Traversable, bare Closure/callable, bare
291+ array, nullable → non-nullable) and assess which ones describe real
292+ subtype relationships that belong in
293+ ` class_lookup::is_subtype_of_typed ` where all callers would benefit,
294+ versus diagnostic-only leniency that must stay in the policy layer.
295+ Do ** not** move any semantics in this task — file the candidates with
296+ reasoning in ` docs/todo/type-inference.md ` .
297+
298+ ** Acceptance.** ` cargo nextest run ` passes with no test edits (except
299+ mechanical import-path updates); ` mod.rs ` shrinks to the orchestrator,
300+ scheduling, and publishing logic; behavior is unchanged.
297301
298302** Why it matters.** Diagnostics is the most actively developed area;
299303new collectors keep landing in a module whose entry file is 75%
300- unrelated scheduling and filtering code.
304+ unrelated scheduling and filtering code. The ` external/ ` split also
305+ directly de-risks the scheduled D10 task (PHPMD proxy — a fifth
306+ external-tool pipeline that gets a formulaic home instead of growing
307+ ` mod.rs ` further).
301308
302309---
303310
304- ## ` server.rs ` carries a ~ 950-line workspace-init block
305-
306- ** What to do.** ` src/server.rs ` (2,821 lines) contains an
307- ` impl Backend ` block of workspace initialization and indexing that has
308- nothing to do with LSP dispatch: ` init_single_project ` ,
309- ` init_monorepo ` , ` init_no_composer ` , ` add_vendor_dir ` ,
310- ` apply_watched_file_changes ` , ` rescan_composer_indexes ` ,
311- ` scan_autoload_files ` , ` preload_autoload_files ` , ` scan_phar_archive ` ,
312- ` build_self_scan_composer ` , ` populate_autoload_indices ` . Move it to a
313- dedicated module (e.g. ` src/workspace_init.rs ` or ` src/indexing/ ` ).
314- ` warm_laravel_completion_cache ` belongs in ` virtual_members/laravel/ ` .
315- Also move the pull-diagnostics resultId-cache logic embedded in the
316- ` diagnostic ` and ` workspace_diagnostic ` handlers into ` diagnostics/ ` ,
317- leaving the handlers as thin delegations like the rest.
318-
319- Relatedly, ` references/mod.rs ` hosts ` ensure_workspace_indexed ` ,
320- ` parse_files_parallel ` , and ` parse_paths_parallel ` — workspace
321- indexing, not reference finding. They should land in the same new
322- module.
311+ ## 3. Move workspace init/indexing out of ` server.rs ` and ` references/mod.rs `
312+
313+ ** What to do.** ` src/server.rs ` (4,007 lines) ends with an
314+ ` impl Backend ` block of workspace initialization and indexing
315+ (~ lines 2696–4007, ~ 1,300 lines) that has nothing to do with LSP
316+ dispatch, and ` src/references/mod.rs ` hosts workspace-indexing
317+ functions that have nothing to do with reference finding. Consolidate
318+ both into a new ` src/indexing/ ` module (declare ` mod indexing; ` in
319+ ` lib.rs ` ). Pure code motion via ` impl Backend ` blocks in the new
320+ files; approximate current line numbers given for finding things.
321+
322+ - ` indexing/init.rs ` — ` init_single_project ` (~ 2696),
323+ ` init_monorepo ` (~ 2933), ` init_no_composer ` (~ 3102).
324+ - ` indexing/scan.rs ` — ` add_vendor_dir ` (~ 3153),
325+ ` rescan_composer_indexes ` (~ 3374), ` scan_autoload_files ` (~ 3469),
326+ ` scan_phar_archive ` (~ 3686), ` build_self_scan_composer ` (~ 3776),
327+ ` populate_autoload_indices ` (~ 3877).
328+ - ` indexing/preload.rs ` — ` preload_autoload_files ` (~ 3608) and
329+ ` preload_autoload_files_with_progress ` (~ 3614) from ` server.rs ` ,
330+ plus from ` src/references/mod.rs ` : ` ensure_workspace_indexed `
331+ (~ 125), ` ensure_workspace_indexed_for_request ` (~ 137),
332+ ` ensure_workspace_indexed_with_progress ` (~ 166),
333+ ` parse_files_parallel_with_progress ` (~ 314), and
334+ ` parse_paths_parallel_with_progress ` (~ 430).
335+ - ` indexing/watch.rs ` — ` apply_watched_file_changes ` (~ 3194).
336+
337+ Two more relocations while in there:
338+
339+ - ` warm_laravel_completion_cache ` (` server.rs ` ~ 2081) belongs in
340+ ` src/virtual_members/laravel/ ` .
341+ - The pull-diagnostics resultId-cache logic embedded in the
342+ ` diagnostic ` (~ 1589) and ` workspace_diagnostic ` (~ 1651) handlers
343+ belongs in a new ` src/diagnostics/pull.rs ` , leaving both handlers
344+ as thin delegations like the rest of ` server.rs ` .
345+
346+ ** Constraint.** Several of these functions spawn threads sized with
347+ ` PARSE_WORKER_STACK_SIZE ` (see the "Performance Anti-Patterns" §3
348+ note in ` CLAUDE.md ` ). Move that code verbatim — do not "simplify"
349+ thread spawning or stack sizing while relocating it.
350+
351+ ** Acceptance.** ` cargo nextest run ` passes with no test edits (except
352+ import paths); ` server.rs ` is reduced to protocol handlers and
353+ dispatch (~ 1,700 lines); behavior is unchanged.
323354
324355** Why it matters.** Full background indexing has already shipped on
325356top of init logic scattered across ` server.rs ` and
@@ -328,26 +359,57 @@ sprawl as the feature continues to grow.
328359
329360---
330361
331- ## Group ` Backend ` 's remaining fields into sub-systems
332-
333- ** What to do.** ` struct Backend ` in ` src/lib.rs ` still has fields that
334- cluster into implicit sub-systems (the four external-tool field triples
335- have already been consolidated into an ` ExternalToolWorker ` struct —
336- ` phpstan_tool ` , ` phpcs_tool ` , ` mago_lint_tool ` , ` mago_analyze_tool ` ):
337-
338- 1 . Diagnostic state (` diag_version ` , ` diag_notify ` , ` diag_pending_uris ` ,
339- ` diag_last_* ` , ` diag_result_ids ` , ` diag_suppressed ` ) →
340- ` DiagnosticState ` .
341- 2 . Symbol/class indexes (` uri_classes_index ` , ` fqn_class_index ` ,
342- ` fqn_uri_index ` , ` gti_index ` , ` method_store ` , ` global_functions ` ,
343- …) → ` SymbolIndex ` .
344- 3 . Workspace config (` workspace_root ` , ` psr4_mappings ` , ` vendor_* ` ,
345- ` php_version ` , ` config ` ) → ` WorkspaceConfig ` .
346-
347- ** Why it matters.** Item 1 directly de-risks the scheduled D10 task;
362+ ## 4. Group ` Backend ` 's remaining fields into sub-systems
363+
364+ ** What to do.** ` struct Backend ` in ` src/lib.rs ` (starts at ~ line 377)
365+ still has fields that cluster into implicit sub-systems. The four
366+ external-tool fields are already grouped (` ExternalToolWorker ` ); this
367+ task introduces three more groups. It is a mechanical, crate-wide
368+ field rename — do it as three separate passes (one per group), running
369+ ` cargo check ` between passes, and run it as the ** sole active agent**
370+ (no parallel sub-agents; see "Never run project-wide rewrites in
371+ parallel" in ` CLAUDE.md ` ).
372+
373+ For each group: define the struct with a ` fn new() ` used by both
374+ ` Backend ` constructors (~ lines 1096 and 1206) and include the group in
375+ the per-request clone (~ line 1836); the ` Arc ` /` Mutex ` wrappers move
376+ into the new struct unchanged (clone semantics of a ` Backend ` clone
377+ must not change).
378+
379+ ** Group 1 — ` DiagnosticState ` ** (define it in ` src/diagnostics/ ` ,
380+ field name e.g. ` diag ` ): ` diag_version ` , ` diag_notify ` ,
381+ ` diag_pending_uris ` , ` diag_last_slow ` , ` diag_last_fast ` ,
382+ ` diag_last_full ` , ` diag_result_ids ` , ` diag_suppressed ` ,
383+ ` workspace_diags ` , ` workspace_diag_pass_started ` . Drop the ` diag_ `
384+ prefix on the struct's fields (` self.diag_version ` →
385+ ` self.diag.version ` ). Leave ` supports_pull_diagnostics ` with the other
386+ client-capability flags.
387+
388+ ** Group 2 — ` SymbolIndex ` ** (field name e.g. ` symbols ` ):
389+ ` uri_classes_index ` , ` fqn_uri_index ` , ` fqn_origin_index ` ,
390+ ` fqn_class_index ` , ` class_not_found_cache ` , ` gti_index ` ,
391+ ` method_store ` , ` global_functions ` , ` global_defines ` ,
392+ ` uri_globals_index ` , ` autoload_function_index ` ,
393+ ` autoload_function_origin_index ` , ` autoload_constant_index ` ,
394+ ` autoload_constant_origin_index ` , ` autoload_file_paths ` . Do ** not**
395+ pull in the per-file parse artifacts (` symbol_maps ` , ` resolved_names ` ,
396+ ` file_imports ` , ` file_namespaces ` , ` parse_errors ` , ` parsed_uris ` ,
397+ ` parse_inflight ` , ` phar_archives ` ) or the ` stub_* ` indexes — they have
398+ a different lifecycle and are out of scope here.
399+
400+ ** Group 3 — ` WorkspaceEnv ` ** (field name e.g. ` workspace ` — not
401+ "WorkspaceConfig", to avoid colliding with the existing
402+ ` config: Mutex<config::Config> ` field, which becomes a member of this
403+ group): ` workspace_root ` , ` psr4_mappings ` , ` vendor_uri_prefixes ` ,
404+ ` vendor_dir_paths ` , ` vendor_package_origin_roots ` , ` php_version ` ,
405+ ` config ` .
406+
407+ ** Acceptance.** Pure renames: ` cargo nextest run ` passes with only
408+ mechanical field-path updates in tests; no lock types, wrapper types,
409+ or clone semantics change.
410+
411+ ** Why it matters.** Group 1 directly de-risks the scheduled D10 task;
348412the rest makes the Backend's state graph legible and shrinks the
349- constructor .
413+ constructors .
350414
351415---
352-
353-
0 commit comments