feat: ✨ Modular monoliths: module-local config, providers, and Livewire namespaces - #336
Conversation
Modular monoliths merge per-module composer manifests into the workspace
manifest (composer-merge-plugin) and register module resources at
runtime: config/*.php files merged under the file name as top-level key,
service providers listed in each module's `extra.laravel.providers`, and
Livewire namespaces via `Livewire::addNamespace(...)`. All of that is
invisible to the static scans, which only know the workspace-root
config/ dir, app/Providers/, and config/livewire.php.
New opt-in LSP setting (default off — behavior is unchanged without it):
{ "lsp": { "laravel-lsp": { "settings": { "modules": {
"paths": ["app/Common/*", "app/*/*"]
} } } } }
`paths` are root-relative directory patterns (`*` matches one segment)
naming module directories, in ascending config-merge precedence. When
set:
- config: every {module}/config/{name}.php is merged into the `{name}`
group with array_replace_recursive semantics. Completion, hover (with
the winning file's link), goto (every declaring file, at the key),
diagnostics, find-references, rename, and code lens all consult the
merged group via a shared config_group_files helper.
- providers: *ServiceProvider.php files inside module dirs are
registered like app providers, so module loadViewsFrom /
loadTranslationsFrom / Blade::directive registrations work; rescans,
saves, and watcher events keep them fresh.
- Livewire: a new tree-sitter extractor parses Livewire::addNamespace
(positional AND named arguments) plus wrapper conventions
(`modules.livewireRegistrars`, default loadLivewireComponentsFrom)
that derive the class namespace from the provider's own namespace.
Namespaced class components resolve for goto/diagnostics/completion.
Module service providers register Blade::directive() macros the same way app/Providers files do — feed them into the directive completion and semantic-highlighting name set.
…re components
Two gaps in this branch's module-aware completion:
get_all_translation_keys only scanned the project's root lang/ dir, so a
namespace whose catalogues live solely under a registered directory
(vendor package, module, or app loadTranslationsFrom — never published
to lang/vendor/…) offered zero completions. It now also walks every
namespace from vendor_translation_namespaces_for, emitting
{ns}::{file}.{key} through the same per-file key parser (extracted as
translation_keys_in_lang_dir).
get_all_livewire_components already walked registered class namespaces
(this branch's own modular-monolith feature), but returned early when
no conventional app/Livewire path existed, before ever reaching that
walk — so a project whose components live only in a registered
namespace still got zero <livewire: completions. The conventional-path
section is now skipped instead of short-circuiting the whole function;
the namespace walk always runs regardless.
Upstream #293/#328 moved translation resolution into the Salsa translation cache, replacing the RwLock namespace map this branch originally extended. Module providers now reach the same layer: set_translation_provider_extras registers them as first-party provider files (ordered before app/Providers so a real app registration still wins on conflict), module_dirs_for feeds it, and completion_keys walks every provider-registered namespace — so ns::file.key completion works for module, vendor, and app loadTranslationsFrom registrations alike, including projects whose only catalogues live under a registered namespace (no root lang/ at all).
…iteria - Module service providers are discovered through each module composer.json's extra.laravel.providers — the list Laravel actually boots — never by filename convention. A *ServiceProvider.php the manifest doesn't name is not indexed; a provider it names under any filename is. FQCNs resolve via the manifest's own PSR-4 mapping (longest prefix), falling back to a bounded basename walk. - Documented precedence, tested per case: the project config merges first, then each module in glob-match order — last-merged wins for scalar collisions, nested arrays merge per key, and the same rule applies to core-group collisions (app.php) and module-vs-module collisions. Namespace registrations follow the matching rule: last-registered module wins, app/Providers overrides modules. - One test per surface for a module-only config key: completion, hover, goto, the config-not-found diagnostic (with negative control), find-references/rename, code lens. - Guarantees pinned by tests: unset and stale-glob settings produce identical output (diff check), malformed glob entries are a no-op, both example glob shapes match at their distinct depths, config values are parsed statically (a side-effecting/fatal fixture resolves as text, never executed), dynamic loadViewsFrom arguments skip registration, and Livewire::addNamespace has one test per argument form (positional, named declared order, named reversed). - View/translation namespaces from module providers carry negative controls (registration removed → resolution fails) and an unlisted-provider control; Blade::directive discovery in module providers has a regression pin. - docs/configuration.md documents modules.paths, its default-off opt-in guarantee, the merge rules, and modules.livewireRegistrars.
mikebronner
left a comment
There was a problem hiding this comment.
Reviewed this in depth, including running the suite and probing the new helpers directly. Stating the good part first, because it is substantial: full suite verified green locally (2562 lib + 575 integration), cargo clippy --all-targets -- -D warnings clean, and I could not make anything panic — every malformed input I threw at it degraded gracefully. Broken composer.json, providers given as a string instead of an array, missing extra, absent manifest, .. in a pattern, absolute patterns, trailing slashes, ./ prefixes, doubled slashes — all handled, none crash. Config merge order is deterministic (children sorted within each * expansion, stable across repeated runs), which is exactly right and easy to get wrong.
The issues below are about guarantees that don't hold, not code that falls over. I'd like all of them addressed before merge. One pre-existing item I found while reviewing has been split out to #340 and is explicitly not being asked of this PR.
1. Livewire::addNamespace drops the whole registration on any unknown named argument
collect_arguments returns None when a named argument doesn't match a known parameter, and the ? at the call site discards the entire registration:
// Fully valid, extracted as: []
Livewire::addNamespace(
namespace: 'common-ui',
classNamespace: 'App\\Common\\UI\\Livewire',
classPath: __DIR__.'/../Livewire',
lazy: true,
);One extra argument silently disables the namespace, with no diagnostic and no log line. Any future Livewire parameter does the same. An unrecognized named argument should skip that argument, not the call.
Separately, and not blocking: the AC describes a two-argument shape (namespace:, path:) while the implementation requires three (namespace, classNamespace, classPath). Both AC forms currently extract nothing. Your shape is very likely the correct one — it matches real code in the layout that motivated the issue — but it is an undisclosed divergence from the AC, and #335 set a good precedent by calling its deviations out explicitly. A sentence in the PR description would close it.
2. Containment: a fourth private copy of an invariant that was deliberately consolidated
expand_module_dirs gates results with a bare lexical dir.starts_with(root). path_containment.rs exists precisely to prevent that — its module docs open with:
The check used to live in three independent copies … each with its own fallback behaviour that could drift apart (issue #156). They are consolidated here.
That module is the accumulated answer to issues #55, #130, #134, #145, #155, #156, #201, #226 and #228, with four public entry points and a documented fail-open/fail-closed policy for each. The new code reintroduces the exact drift it was written to end.
The doc comment also promises something the code doesn't deliver:
anything escaping the root (e.g. via
..) is dropped
The .. half is true and I couldn't break it. The symlink half is not: Path::is_dir() follows symlinks and starts_with is purely textual, so a symlink under a matched module directory pointing outside the project is followed, and its config/*.php becomes a live config source. Reproduced against expand_module_dirs + config_group_files.
Requested approach — trust configured paths, gate discovered ones:
modules.pathsentries are explicit user configuration, so following symlinks there is correct and should stay. Composer path repositories symlink local packages, sometimes to targets outside the repo, and refusing those would break local package development. Drop the deadstarts_withline and document the intent instead of claiming containment. Keep the..rejection — a pattern escaping the root is a config error, not a deliberate symlink.resolve_provider_class_file'swalkdirfallback is the other case: those paths are discovered, not configured, so per the #228 convention each entry should go throughpath_containment::path_within_root_walk_entry.
That split — configured vs discovered — is the distinction path_containment's entry points already encode.
While in there: expand_module_dirs follows symlinks (via is_dir()) but the walkdir fallback uses the default follow_links(false), so a symlinked module's provider is visible to one and invisible to the other. Worth making consistent.
3. No file watcher covers module directories
build_watchers never receives the module dirs. The comment on the new config watcher says module configs are
covered by the PSR-4
app/**glob below when composer mapsapp/, and this root glob is their floor otherwise
The first clause is true. The second is not — {root}/config/**/*.php cannot match {root}/Modules/Blog/config/app.php. Since modules.paths accepts arbitrary globs, a module tree outside any PSR-4 root (the Modules/* shape) gets no watcher for its config, providers, views, or lang files: edits go unseen until the server restarts.
Requested approach — one watcher pair per expanded module dir, mirroring the PSR-4 loop that already exists a few lines away:
for src_root in psr4_roots {
let glob = format!("{}/**/*.php", glob_base(src_root));
…
}The same loop over module dirs (plus the *.blade.php companion) covers all four resource types, is bounded by the actual module count, and introduces no new concept. The timing works out: pull_and_apply_settings() runs at main.rs:22244 and watcher registration at main.rs:22343 — same initialized flow, settings first — so the expanded dirs are already available when watchers are built.
A root-level {root}/**/config/**/*.php would also work and is cheaper than it looks (vendor/**/*.php is already watched unconditionally), but it fixes only the config third of the gap and would be the first watcher in the set that isn't scoped to a known subtree.
Loose end either way: pull_and_apply_settings also runs on did_change_configuration (main.rs:23122), so changing modules.paths mid-session leaves watchers stale. Either unregister and re-register, or document that it needs a restart.
4. No logging anywhere in the module path
The AC requires a malformed glob entry to be "logged, not a crash". There is no warn!/info!/debug! in expand_module_dirs or module_dirs_for. The no-op half holds; the logged half doesn't.
This matters more than it sounds, because the most likely user typo is silent:
"app/**" -> [] (matches a directory literally named `**`)
"app/*" -> ["app/A"]
"app/*/*" -> ["app/A/B"]
A working config and a typo'd one are indistinguishable from the user's seat. At minimum: log the expanded directory count per pattern, and warn on a pattern that matches nothing.
5. Within-file namespace conflict resolves backwards from runtime
out.entry(prefix).or_insert(reg) keeps the first addNamespace for a prefix. PHP executes both statements and the last one wins in Livewire's registry. Confirmed with a two-call fixture: the extractor reports the first registration's path.
Cross-provider ordering is documented as last-registered-wins; within a single file it's the opposite. The module doc comment states the current behaviour ("First registration wins on prefix conflict, matching provider boot order within a file"), but boot order within a file means the later statement overwrites the earlier one.
6. The opt-in guarantee test compares "off" against "off"
unset_and_stale_glob_produce_identical_output runs modules.paths unset against a stale glob — modules inactive in both cases — over get_all_config_keys() alone. It proves a stale glob equals unset, which is one of the AC's two clauses, but it never compares against pre-change behaviour and it doesn't touch the surface that actually changed.
Which brings up the substantive part: the new namespaced-catalogue block in TranslationCache::completion_keys calls vendor_namespaces() unconditionally. Every project now receives ns::key completions from every namespace-registering vendor package, whether or not modules.paths is set. That is a real behaviour change with the feature off, which the AC specifically forbade.
I think the change itself is right — it's the #293/#328 gap — so I'm not asking you to revert it. I am asking for one of: gate it behind the modules setting, or keep it unconditional and say so plainly in the PR description and docs/configuration.md as a deliberate scope addition rather than something that arrives under an opt-in banner. Either way the diff test should cover translation completion, not just config keys.
7. .rev() is duplicated in both consumers
config_group_files returns ascending precedence, and config_lookup and config_key_locator each call .rev() independently. The AC asked the shared helper to own precedence, not just discovery. A third consumer that forgets the reverse inverts precedence silently, with no test to catch it. Returning descending order from the helper (or exposing an explicit config_group_files_by_precedence) removes the trap.
8. Non-leaf config lookups return one file's subtree, not a merge
resolve_value_with_source returns the first file in which the key resolves. That is correct for leaf keys and for per-key nested resolution — group.a from a module and group.b from the project both resolve, which is what the PR documents. But config('group') for a parent array split across files returns only the winning file's subtree rather than the merged array.
This may well be acceptable; array_replace_recursive semantics for a whole-array hover is a bigger job. I'd like either a test pinning the current behaviour with a comment saying it's intentional, or the merge — your call, but not left undecided.
Not in scope
#340 — translation completion previewing the alphabetically-first locale rather than the configured one — is pre-existing on main. This PR reuses the same rule for namespaced catalogues, which is the right call for consistency. It'll be fixed separately.
Translation-key completion sourced its preview values from the
alphabetically-first locale directory. For a project with lang/de/,
lang/en/ and 'locale' => 'en', every completion detail showed the German
string — silently, since the keys themselves looked right. ar, cs and de
all sort ahead of en, so this hit a large share of multilingual projects.
The sort was itself a fix: locales_in_dir preserves directory-listing
order, which is filesystem-dependent, so completion varied by platform
until it was sorted. It just made the result deterministically wrong.
completion_locale() now resolves the locale by name instead of by
position: app.locale from config/app.php, then app.fallback_locale, each
normalized (a quoted literal is unquoted; env('NAME', 'default') yields
its default argument) and each accepted only when it names a locale the
project actually defines. When neither resolves, the alphabetical minimum
answers as before — computed without mutating or filtering the candidate
list, so the old determinism guarantee is untouched.
It is a standalone module-level function, not a closure, so the
namespaced-catalogue scan can call the same chain once #336 lands rather
than growing a second copy of it.
Fixes #340
…le watchers Findings 1–8 from the #336 review: 1. An unknown named argument to Livewire::addNamespace (lazy: true, or any future parameter) skips THAT argument instead of silently dropping the whole registration; extra positional arguments likewise. 2. Containment now follows path_containment's configured-vs-discovered split instead of a fourth private copy: expand_module_dirs trusts its results (they are the user's own setting, and composer path repositories legitimately symlink outside the repo — the dead lexical starts_with is gone and the doc says what actually holds, keeping only the ..-pattern rejection), while the provider-class walk gates every DISCOVERED entry through path_within_root_walk_entry against the module dir — a symlink inside a module escaping it is refused, a symlinked module keeps working, and the walk now follows links so both sides see symlinked modules identically. Unix test covers both directions. 3. One watcher pair per expanded module dir ({dir}/**/*.php + {dir}/**/*.blade.php, deduped against the PSR-4 set), so a module tree outside every PSR-4 root gets live invalidation for config, providers, views, and lang files. Mid-session modules.paths changes still need a restart for watcher coverage — documented in docs/configuration.md and at the registration site. 4. expand_module_dirs logs each pattern's match count and warns on a pattern matching nothing — the app/** typo is no longer indistinguishable from a working config. 5. Within one file the LAST addNamespace wins on a prefix conflict, matching what Livewire's registry actually holds after PHP executes both statements; doc and two-call test updated. 6. The opt-in diff check now also covers translation completion — the surface the Salsa scan changed — and the unconditional vendor-namespace completion is disclosed in docs/configuration.md as a deliberate #293/#328 gap fix rather than arriving silently under the opt-in banner. 7. config_group_files returns descending merge precedence and OWNS the rule: both lib consumers and the goto fallback drop their private .rev(), so a future consumer cannot invert precedence by forgetting one. 8. Non-leaf lookups (config('group') for a parent array split across files) return the winning file's subtree by design — pinned with a test and an intent comment; per-key nested resolution stays merged.
… does display() yields backslashes on Windows; the glob base never does — the expectation must run through the same normalization (caught by the windows CI leg).
…347) * chore: start work on #340 Watson-Branch: #340 * fix: 🐛 preview the app's configured locale in translation completion Translation-key completion sourced its preview values from the alphabetically-first locale directory. For a project with lang/de/, lang/en/ and 'locale' => 'en', every completion detail showed the German string — silently, since the keys themselves looked right. ar, cs and de all sort ahead of en, so this hit a large share of multilingual projects. The sort was itself a fix: locales_in_dir preserves directory-listing order, which is filesystem-dependent, so completion varied by platform until it was sorted. It just made the result deterministically wrong. completion_locale() now resolves the locale by name instead of by position: app.locale from config/app.php, then app.fallback_locale, each normalized (a quoted literal is unquoted; env('NAME', 'default') yields its default argument) and each accepted only when it names a locale the project actually defines. When neither resolves, the alphabetical minimum answers as before — computed without mutating or filtering the candidate list, so the old determinism guarantee is untouched. It is a standalone module-level function, not a closure, so the namespaced-catalogue scan can call the same chain once #336 lands rather than growing a second copy of it. Fixes #340
…th-modules # Conflicts: # laravel-lsp/src/livewire_resolver/tests.rs # laravel-lsp/src/salsa_impl.rs
|
@mikebronner Thank you so much for the review! I should've implemented everything now! |
mikebronner
left a comment
There was a problem hiding this comment.
Round two — and it starts with two acknowledgments, because both are owed.
First: all eight items from the previous review were followed exactly, and
well. I verified each against the tree rather than the commit messages — the
argument-level skips, the gated provider walk, the per-module watcher pairs,
the logging, last-wins within a file, the disclosed translation scan, the
helper-owned precedence, the pinned non-leaf subtree. All real, all tested,
clippy-clean after the upstream merge. Thank you for the care in that round;
the 1–8 enumeration in the fix commit matches what actually landed, which is
rarer than it should be.
Second: this round exists because I swept the full diff instead of
re-checking my own eight items — a sweep I should have done the first time.
Most of what follows was already in the tree when I wrote the first review
and I missed it then. Three items are more than misses: one bug was induced
by my own instruction, one site I explicitly (and wrongly) vouched for, and
two of my "here are the places to fix" lists turned out to be incomplete
lists that you, entirely reasonably, treated as complete. I've tagged each
finding with where the fault actually sits. I'd still like all of them
addressed before merge — but read this as us finishing the review properly,
not as regressions on your side. None of it is speculative; every item is
reproduced at the cited site.
Precedence: the rule the helper now owns is still re-implemented elsewhere
1. Cross-provider Livewire merge is first-wins — module-vs-module inverted
(pre-existing; my previous review explicitly vouched for this layer — that
was wrong)
Item 5 last round said "cross-provider ordering is documented as
last-registered-wins" and only flagged the within-file case. You fixed
exactly what I flagged. But the layer I vouched for doesn't hold:
load_livewire_config (main.rs:9029) does
config.class_namespaces.entry(prefix).or_insert(reg) over provider files
ordered [app/Providers…, modules in ascending precedence], and the comment
above it still reads "First registration wins on prefix conflict" — the
other copy of the phrase item 5 corrected one layer down.
App-beats-module comes out right only because app is scanned first. Two
modules registering the same prefix resolve to the lowest-precedence
module — backwards from the documented rule, and backwards from the
translation-namespace merge in this same PR (.insert(), last-wins, pinned
by conflicting_namespace_registrations_resolve_last_registered_wins). No
test exercises the multi-provider merge; the completion fixtures construct
LivewireConfig directly and bypass it. Fix the merge, fix the comment, add
the two-module collision test the translation side already has.
2. View-namespace precedence contradicts the docs this PR adds
(merge code pre-existing; the module wiring and the docs claim are new; my
first review never looked here)
docs/configuration.md now promises "last-registered module provider wins;
an app/Providers registration overrides modules." The merge underneath
(salsa_impl.rs:10151) is lexicographic-path first-wins — literally commented
"Keep existing (first wins for now)" — and this PR newly feeds module
providers into that set. app/Legal/…/Providers/X.php sorts before
app/Providers/AppServiceProvider.php, so a module registration beats the
app's — the opposite of the documented rule. Priority-aware merging already
exists forty lines down (class_component_files, existing_prio >= prio);
use it, and pin app-vs-module and module-vs-module with tests.
3. Config-key completion carries its own parallel merge
(pre-existing in this PR's feature commits; item 7's "both consumers" list
was mine, and it was incomplete — you de-.rev()'d exactly what I named)
get_all_config_keys (main.rs:14524–14595) hand-builds the config-dir list
and re-implements last-wins via BTreeMap::insert, agreeing with
config_group_files only because the iteration order happens to match.
Enumeration (all keys) and per-group lookup are legitimately different
shapes — but the precedence rule itself must come from one place, or a
future change lands in one implementation and silently not the other. Route
the precedence decision through the helper (or bind the two with a test that
would fail on divergence).
Containment: the configured-vs-discovered split, finished
4. The PSR-4 provider branch is the gated fallback's unguarded twin
(pre-existing; item 2 named "the walkdir fallback" as "the other case" —
my enumeration implied completeness and missed this branch three lines up)
resolve_provider_class_file's PSR-4 arm (config.rs:1266) joins
module_dir.join(dir) where dir comes straight from composer.json's
autoload.psr-4 value. Path::join with an absolute value discards the
base entirely; .. segments walk out lexically and the OS resolves them at
is_file(). The result is read (read_to_string at four call sites). These
are manifest-derived paths — discovered, by the split's own definition. Gate
the candidate against module_dir and add an absolute/.. psr-4 entry to
the escape suite (the existing symlink test forces the fallback path and
never reaches this branch).
5. PSR-4 prefix matching accepts non-boundary textual matches
(pre-existing; plain first-review miss)
Same function, one line up: .strip_prefix(prefix_trimmed).and_then(|r| r.strip_prefix('\\').or(Some(r)).filter(…)). The .or(Some(r)) fallback
accepts a remainder that does not start with \ — so prefix
App\Legal\ContractManagement matches FQCN
App\Legal\ContractManagementSupport\Provider, and the longest-prefix
tie-break can prefer the bogus candidate over the true mapping. Composer
keeps the trailing \ in the comparison and rejects this. The fallback is
only legitimate for the empty PSR-4 prefix ("": "src/"); restrict it to
that case and pin overlapping prefixes with a test.
6. Livewire class_path is resolved, canonicalized, and never checked
(pre-existing; plain first-review miss — item 2 discussed containment only
for expand_module_dirs and the provider walk)
classify_add_namespace (livewire_namespaces.rs:142–148) receives root
and uses it only as a join base; the final class_path is canonicalized but
never containment-checked, and path_join's contract places that burden on
the caller. The value is then consumed twice without a gate: the completion
walk (main.rs:14936) runs WalkDir::new(®.class_path).follow_links(true)
with no max_depth and no walk-entry gate — a __DIR__.'/../../../..' in a
provider walks everything reachable on every completion request, a hang
hazard before it is anything else — and try_namespaced_class
(livewire_resolver.rs:690) joins + is_file()s into
LivewireComponent.paths, which blade_backing_class_sources reads. These
are provider-source-derived paths: discovered. Gate both consumers (or the
registration at extraction time), bound the walk, add the escape test.
Test debt
7. The rename surface has no test
(pre-existing gap; my first review checked the opt-in diff test and not
this)
collect_config_declaration_target (main.rs:21783) changed to a multi-file
rewrite (Vec<EditTarget> — "rewritten in every declaring file, or the
survivors would resurrect the old key") and has zero test references; the
test file's own header says "the seven surfaces, one test each" and delivers
six. One test: rename a key declared in two files, assert both edits.
8. modules.livewireRegistrars is never tested with a non-default name
(pre-existing gap; first-review miss)
Every registrar test uses the shipped default
(loadLivewireComponentsFrom). Nothing proves the setting is honored rather
than the extractor being hardcoded. One test with a custom wrapper name
closes it.
9. An unparseable positional argument shifts later positionals left
(this one is on me — it follows directly from item 1's instruction, which
specified "skip that argument" without saying what a skipped positional does
to the slots)
In collect_arguments (livewire_namespaces.rs:209), a positional argument
whose value doesn't parse continues without consuming its slot, so later
positionals land one slot early. With the three-parameter signature a
shifted call happens to end up dropped, so it's latent rather than live —
but the guarantee item 1 asked for ("skips that argument") should hold
precisely: a skipped positional should still consume its slot. Two-line
fix; test: leading $variable positional followed by literals extracts
nothing.
Riders, no blocking weight: the watcher-dedup comment claims a nested module
dir "costs nothing extra" but the dedup is exact-string only — fix the
comment; expand_module_dirs's documented symlink-following is never driven
through the glob expansion itself; the two named-argument Livewire tests are
labeled "declared order"/"any order" but neither permutation is the declared
order — rename them; the zero-match warn! is asserted by behavior, not
captured tracing output.
Settled, to prevent churn
The unconditional ns:: translation completion stays as disclosed (docs +
PR description — that adjudication holds). The config watcher's 18→19
assertion change was visible during the item-3 discussion and is accepted.
Non-leaf subtree behavior is pinned by design per item 8 of the last round.
None of these need touching again.
…nished Findings 1-9 from the second #336 review: 1. load_livewire_config merged first-wins over [app, modules], so two modules resolved to the LOWEST-precedence one. Modules are now scanned first (ascending) and app providers last, with insert() last-wins — the rule the translation merge already followed. Comment corrected; two-module and app-vs-module tests added (the completion fixtures build LivewireConfig directly and bypassed this merge). 2. The salsa view/component-namespace merge was lexicographic first-wins, which let a module beat the app — the opposite of what this PR's docs promise. Both merges are priority-aware now, using the existing class_component_files pattern, and modules get their own tier between packages and the app (0=framework, 1=package, 2=module, 3=app) so app-over-module holds by rank rather than by scan order. Pinned both directions. 3. get_all_config_keys hand-rolled its own last-wins merge. It now enumerates which GROUPS exist and takes per-group file order from config_group_files, so precedence has one owner; a test binds completion's winning source to the helper's first file. 4. The PSR-4 arm of resolve_provider_class_file joined manifest-derived values unguarded — an absolute value discards the base, a parent segment walks out. Gated lexically before probing (no existence oracle), with absolute and traversal entries in the escape suite. 5. Prefix matching accepted non-boundary textual matches, so the prefix App\Legal\ContractManagement matched the FQCN App\Legal\ContractManagementSupport\X and could win the longest-prefix tie-break. The remainder must now start with a namespace separator; the empty catch-all prefix keeps its whole-FQCN case. 6. Livewire class_path was canonicalized but never contained, and both consumers read it ungated. Gated at extraction (single point, covers both), and the completion walk is depth-bounded so a deep in-root tree can't turn a keystroke into a full traversal either. 7. The rename surface — the seventh — now has its test: a key declared in two files is rewritten in both. 8. modules.livewireRegistrars is tested with a name that ships nowhere, plus a negative control. 9. A skipped positional argument now consumes its slot, so later positionals can't shift left into the wrong parameters. Riders: the watcher-dedup comment no longer overclaims (exact-string dedup, overlapping coverage for a nested module), expand_module_dirs's symlink following is driven through the glob expansion itself, and the two named-argument tests are renamed to the permutations they actually cover.
b3df9be to
793ac93
Compare
The fixture builds its module path from a forward-slash literal, so on Windows the two sides spell the same path with different separators and a string comparison failed (caught by the windows CI leg).
|
All nine plus the riders, in 793ac93. Two places where I went past the letter of the finding, both because the narrow fix would not have held: 2. Priority-aware merging alone was not sufficient — module providers register at priority 6. Took the extraction-time gate rather than gating both consumers — one gate, and a third consumer cannot reintroduce the hole. I bounded the completion walk anyway ( 3. Enumeration still discovers which groups exist (that genuinely is a different shape), but per-group file order now comes from Everything else followed the finding as written: the Livewire merge scans modules first and app last with One follow-up commit, 78c5012: the new precedence-binding test compared path strings and failed on the windows leg — the fixture builds its module path from a forward-slash literal, so the two sides spell the same path with different separators. Compared by components now. On the framing: no need to apportion fault. Items 1, 4 and 9 followed from lists and instructions that were reasonable to read as complete, and I read them that way — the sweep catching them is the review working, not either of us dropping something. Thanks for doing the full pass. |
|
@mikebronner I'm off for now, thanks for the reviews again and have a nice evening! 🙏 |
|
@marlonbasten Thanks so much! Go to bed already hehe :) I'll try to get this all sorted out and resubmitted to Zed tomorrow. |
The config completion detail and documentation lines render a project-relative file label. #336 replaced its hardcoded `format!("config/{group}.php")` with a `strip_prefix` + `to_string_lossy`, so the label picked up the host separator and read `config\\app.php` on Windows alone. That regression landed on main three minutes before #348 merged the assertion that reads the label, so neither PR's CI ever ran the pair. This branch is the first to, which is why it is fixed here rather than in a branch of its own. Routed through `with_forward_slashes`, the helper the sibling route label already uses. The out-of-root fallback stays the full path: a module config dir may sit outside the root, where a bare `app.php` would not say which module declared the key. Three tests, each mutation-verified to redden alone.
…s disk reads (#357) * chore: start work on #349 Watson-Branch: #349 * fix: 🐛 count and cache the completion path's config read `completion_locale` resolved `app.locale` out of `config/app.php` through `config_lookup::resolve_value`, a free function in another module. Two consequences, both closed here. It bypassed `TranslationCache::disk_reads`, so the cache-hit regression tests could not observe that read at all — and their fixture writes no `config/app.php`, so both requests failed to resolve it identically and the assertion held whether the read was cached or repeated. The test could not fail. And it re-read the file on every completion request, twice over when the chain ran on to `app.fallback_locale`. `TranslationCache` now owns a per-instance config cache keyed by config file path, read through `ensure_config` and counted like every other read. Absence is cached too: a project with no `config/app.php` is probed once, not once per keystroke boundary. `resolve_value` itself is untouched — the cache is a wrapper at the call site, so `hover_for_config` keeps reading fresh. A cache obliges invalidation, so both edit paths now evict it: the watched-files handler for external create/change/delete, and `execute_salsa_update` for an in-editor edit, which is the only notice the actor gets for a file open in Zed. Fixes #349 * fix: 🐛 normalize separators on the watched-files config gate The arm classifying a watched file as config tested the raw path for the substring `/config/`, so on Windows — where the path reads `C:\proj\config\app.php` — it never matched and the whole arm was dead. Issue #292's shape, two lines above its own warning about `/Commands/`. Nothing exercised it until the config cache landed: the file-existence eviction and `invalidate_config_cache` it also guards were silently skipped on Windows, and the new invalidation inherited that. Routed through `with_forward_slashes`, the same helper `execute_salsa_update`'s config arm already uses, so both gates are one predicate rather than two spellings that can drift apart. * fix: 🐛 normalize the config completion label's path separators The config completion detail and documentation lines render a project-relative file label. #336 replaced its hardcoded `format!("config/{group}.php")` with a `strip_prefix` + `to_string_lossy`, so the label picked up the host separator and read `config\\app.php` on Windows alone. That regression landed on main three minutes before #348 merged the assertion that reads the label, so neither PR's CI ever ran the pair. This branch is the first to, which is why it is fixed here rather than in a branch of its own. Routed through `with_forward_slashes`, the helper the sibling route label already uses. The out-of-root fallback stays the full path: a module config dir may sit outside the root, where a bare `app.php` would not say which module declared the key. Three tests, each mutation-verified to redden alone.
The `main` merge reddened `the_config_completion_response_carries_no_dotenv_secret` on Windows alone: the test expected `config\app.php` and got `config/app.php`. The test was right until this merge and is wrong after it. #336 moved the config label from a hardcoded `format!("config/{group}.php")` to `strip_prefix`, which picked up the platform separator; the commit now on `main` fixes that by routing the label through `with_forward_slashes`, because it is user-visible text that must not change shape with the host OS. `config_source_label`'s own doc names the old Windows rendering as the bug. So `config_app_display()` — which built the expectation with `Path::join(...).display()` — encoded the pre-fix behaviour, and its comment ("Windows displays `config\app.php`, which is correct for Windows") now asserts the opposite of what production guarantees. Replaced with a literal `CONFIG_APP_LABEL`. The literal is the stronger assertion, not merely the passing one: the helper mirrored the production logic, so if the normalization were ever dropped the expectation would pick up the native separator alongside it and stay green on Windows — the only platform where that regression is visible. A literal cannot follow it. Mutation: setting the constant to `config\app.php` reddens the test with exactly the CI failure inverted (`left` and `right` swapped), so the assertion discriminates on separator shape rather than merely being reached. Swept the class — `config_app_display` had no other call site, and no sibling test in `src/tests` or `tests/` builds a user-visible expectation through `display()`/`to_string_lossy()`. Watson-Branch: #341
Items 5, 6 and 7 of #354. Item 5: #336 renumbered service-provider priority to 0=framework, 1=package, 2=module, 3=app but left the doc sites behind. Swept the crate for the old order rather than fixing the three the issue named: main.rs's registrar docblock and its six inline section comments, the SalsaRequest priority comment, build_macro_registry, the salsa_impl test comment (whose fixture registered an app provider at 2 — corrected with it), and CLAUDE.md's convention line. Two sites keep their own numbers: command_index and member_resolver:: impl_priority each run an independent path-derived scale that really is 0/1/2, so renumbering them would have made the prose wrong about the code. What was stale there was the claim to be following the service-provider convention — that is what is corrected. Item 6: the cached middleware and binding entries labelled priority 2 "app level" were sitting at the module tier after the renumber. Inert today (no reader compares those priorities) and a real bug the moment one does. Item 7: psr4_entries_escaping_the_module_resolve_nothing's traversal case used ../../outside, which normalized to proj/app/outside — nowhere near the decoy provider the test writes to tmp/outside. It passed with or without the containment gate. Four levels reach the decoy; verified by deleting the gate and watching the test go red.
The three-literal sweep (`app=2`, `2=app`, `App=2`) could not see a tier claim written as prose, and two sites spelled it that way. - `salsa_impl.rs:3563` — the macro/mixin walk's coverage-boundaries doc still carried the pre-#336 three-tier order, four lines above a cross-reference to `build_macro_registry`, whose docstring was already corrected. Now names all four tiers, and keeps "the last two" pointing at the vendor-scanned pair. - `tests/integration_tests.rs:1336` — `priority_merging` documented and encoded `app (2) > package (1) > framework (0)`: the module tier absent and app pinned at what is now the module value. A stale tier oracle in the test tree, which a `src/`-scoped sweep is structurally blind to. The integration test is self-referential by construction, so it records the order rather than observing it; its doc now says so and points at the tests that enforce the order against the real merge. The new module assertion is mutation-verified — swapping the app and module constants fails the build. Left alone, each checked: the env scale (`0=.env.example, 1=.env.local, 2=.env`), `command_index`'s own three-tier path scale, `route_discovery`'s route-name scale, and the `BindingRegistrationData` fixture literals, which assert no tier semantics.
…oracle.
`test_service_provider_priority_by_location` still encoded the three-tier
scale item 5 exists to retire: app providers at 2 — now the module value —
and no module tier at all. It is the immediate sibling of
`test_priority_ordering_constants`, corrected one commit earlier, so
`mod priority_merging` documented two different scales six lines apart.
App is now 3, matching `register_service_provider_files_with_salsa`.
The module tier (2) is deliberately not added as a branch: no path substring
identifies a module provider — they come from the `modules.paths` globs plus
each module's composer `extra.laravel.providers`, resolved by
`config::module_provider_files`. A `contains("modules/")` branch would encode
a rule the real classifier does not implement, replacing a stale oracle with
a false one. The doc now states that limit and points at
`module_view_namespaces` and `module_livewire_namespaces`, which enforce the
module tier against the real merge.
Mutation-verified: restoring `2` in the classifier fails the assertion.
Refs: #354
* chore: start work on #341 Watson-Branch: #341 * feat: ✨ Hover and go-to-definition on keys in .env buffers Both handlers returned before any dispatch on a `.env` buffer: `goto_definition` gated on `.php`, `hover` on `.blade.php`/`.php`. Four other env features already classified files through the shared `env_key_locator::is_env_file_name` gate; these two now join them, and branch to a dedicated env path ahead of the PHP pattern index rather than falling through it into `Ok(None)`. - hover on a key renders its name, effective value, declaring file (via the existing `.env`/`.env.local`/`.env.example` priority ladder) and consumer count. Commented-out and not-defined states mirror `hover_for_env`; the not-defined card keeps its count, which is the point of hovering a key the project cannot resolve. - go-to-definition jumps to the consuming `env('KEY')` call sites — every one of them, as a `Scalar` for a single hit and an `Array` otherwise. A key with no consumers returns `None` where hover still renders. - `enumerate_commented_keys_in_source` finds a key on a `#` line, which `enumerate_keys_in_source` classifies as "not a declaration". Both delegate to the same `parse_key_declaration`, and stay separate so a commented line cannot win the code lens's first-match-wins race. - the reference-count phrasing and the de-duplicated location lookup move into `reference_count_label` / `reference_locations`, so a key's lens count and its hover count come from one call and cannot disagree. `textDocument/references` deliberately keeps its `.php`/`.blade.php` gate: the reference code lens stays the only "find consumers" entry point for env keys, as it is for config and translation keys. Pinned by a test. Docs: `environment.md` claimed hover and go-to-definition "only ever run on `.php` and `.blade.php`", which this change falsifies — corrected, with the surrounding argument (a real `.sh` gets nothing) left intact. Watson-Branch: #341 * fix: 🔒 redact secrets in the .env-buffer hover card, and unpin a path separator Two fixes the merge with main surfaced. `hover_for_env_declaration` rendered the value raw. `hover_for_env` — the same card for the reverse direction — drops a value whose *name* matches `is_sensitive_env_name` and masks URL credentials otherwise (#344, #348). That the value is already on screen in the buffer is not a reason to skip the guards: this is LSP output like any other surface those issues swept. Both guards now apply, with a test for each arm. `env_value_redaction` (new on main in #348) compared a completion detail against a literal `config/app.php`. The detail renders a display path with the platform separator, so Windows produced `config\app.php` and reddened the whole matrix job on a difference that has nothing to do with redaction. The expectation is built from `Path::join` instead; the assertion still pins the redaction string itself. Watson-Branch: #341 * refactor: ♻️ read `.env` comment classification from one rule `enumerate_commented_keys_in_source` re-implemented the trim → `trim_start_matches('#')` → trim classification `parse_env_source` already does, and its doc comment asked the two copies to stay in step. An invariant held by a comment is an invariant that drifts: change one side (require a space after `#`, say) and cursor hit-testing silently disagrees with what Salsa calls commented. `commented_declaration_body` is now the one definition, and every reader of a `.env` line classifies through it — Salsa's `parse_env_source`, the declaration parser that must reject exactly those lines, the commented-key enumeration that parses what they hide, and the inline-comment tokenizer. The rule is unchanged, so no behaviour moves: same `#` run, same whitespace, same body. Direct tests pin it — the marker run, the non-comment shapes, and the suffix property the column arithmetic rests on. Watson-Branch: #341 * fix: 🐛 hover the `.env` declaration under the cursor, not one found by name `env_key_at_position` resolved the cursor to an exact line in an exact file, then `hover_for_env_declaration` threw that away and asked `get_parsed_env_var` — a name-keyed, priority-merged lookup — what state the key was in. Every declaration inside one file carries that file's priority, so two declarations of one key always tie, and the tie kept the textually-first line. The cursor played no part. Both directions were wrong for the ordinary habit of commenting an old value out beside the live one: - `# APP_NAME=old` above `APP_NAME=new` — hovering the live line rendered *(commented out)*, a live declaration reported as disabled. - `APP_NAME=new` above `# APP_NAME=old` — hovering the comment rendered the live value with no commented-out marker at all. The state now comes from the cursor: `env_key_at_position` returns which enumeration matched, and a commented declaration needs no lookup at all — it is the line under the cursor, in this buffer, with no value in effect and its own file to link. The priority ladder still resolves an active declaration's value and declaring file, as the criteria ask. The tie-break itself was the mechanism, so it is fixed where it lives: at equal priority an active declaration now outranks a commented one, in both env merges, through one shared `env_var_supersedes`. A comment is not a declaration competing for the key. A commented declaration in a higher-priority file still wins — switching a key off in `.env` is how a project disables it — and hovering an active declaration it outranks now reports no value in effect rather than quoting one the application never sees. Tests: the fixture pair Holmes asked for (one file, one key, active and commented, hovered on each line, both orderings), the reverse `env('KEY')` direction and the whole-table merge on the same fixture, the commented card's own source link, and the outranked-by-a-comment case. Three thin assertions sharpened: the priority test now spans all three rungs and its middle tier alone, the zero-consumer card asserts value and source link (its fixture key matched the sensitive-name pattern, so it never carried a value at all), and the commented card asserts the value is absent. Watson-Branch: #341 * test: ✅ pin the env merge's first-wins tie on both call sites `env_var_supersedes`'s equal-priority arm has four input rows. The two mixing a comment with a live line were pinned; two *active* declarations of one key were not. Mutating that arm's `&&` to `||` therefore left the whole suite green while first-wins silently became last-wins — and `enumerate_keys_in_source` only ever resolves the cursor onto the first `KEY=` line, so hovering that line would have rendered the second declaration's value. A card describing a line other than the one under the cursor: the defect the previous commit closed, reached from the other side. The fixture for it already existed. It asserted only that the first line resolved *something*, never which declaration answered; it now asserts the first declaration's own value is on the card and the second one's is not. The table merge gets the same tie pinned as well. It already carried the comment-loses row, so leaving first-wins on the by-name side alone would have left one of two call sites of the shared rule free to drift. Mutation-verified live, all three forms the arm can take: - `||` (first-wins becomes last-wins) — both new assertions red, and only those two, which is what made this a gap rather than incidental coverage. - `false` (the pre-fix always-first-wins) — the three comment-loses tests red, the new pair green, so they discriminate on their own row. - `true` (always last-wins) — the comment-below test and both new assertions red. Row four, two commented declarations, stays unpinned deliberately: the commented branch is cursor-derived and renders no value, so no output tells the two apart. Refs #341 * fix: 🔒 render `.env` text as text, not as markdown, in cards and panels A `.env` key is everything before the first `=`, with no charset restriction, and the hover card rendered it as a bolded markdown header. A line spelling `[Update your credentials here](https://evil.example)=1` therefore put a live clickable link inside the card, and an `` variant needed no click at all. `.env.example` ships in public repositories, so opening an unfamiliar project is the whole of the reach. Close it at the renderer rather than at the four call sites that are known-unsafe today, so callers that do not exist yet are safe too. The class is "unconstrained `.env` text reaching markdown unescaped", and sweeping it found three renderer sites, not the two the review named: - `hover::render`'s bold header — the in-PR hole; - `hover::render`'s code fence, which a value carrying three backticks closes early (pre-existing, and it hits `hover_for_env` identically); - `CompletionDoc::render`, which has both shapes and is fed a raw key and a raw value by the `completion` handler's `.env` branch. `markdown_safety::escape_inline` defers its escape set to `char::is_ascii_punctuation`, whose ranges are exactly CommonMark's escapable set — a spec constant rather than a hand-listed set of "the characters that can start a construct", which is the enumeration that goes stale. `fenced_block` sizes each fence one backtick longer than the longest run inside its content. Rendered output is unchanged: CommonMark renders `\.` as `.`. The wire text gains backslashes, which is why 33 assertions across nine test files move to the escaped form — that spread is the sweep, showing which surfaces route through these two renderers. Field contracts are now explicit in both structs, so the boundary reads as a decision rather than an oversight: `header` is plain text and the renderer escapes it; `detail`, `description` and `summary` are markdown-bearing by design and a call site putting untrusted text there owns the escaping. Watson-Branch: #341 * test: ✅ make three env-navigation claims assert what their comments promise All three are prose that described a stronger test than the code under it. The module doc said every test drives the real `hover` / `goto_definition` handler "not the helpers underneath". Three of the 21 do the opposite, on purpose: the reverse direction has no `.env`-buffer handler to drive, and the two table-merge tests read the Salsa layer because that is where the shared merge rule's second call site lives. Scope the claim to the navigation tests and name the exceptions. `hover_in_a_lower_priority_file_shows_the_winning_value` promised to "name `.env` as the declaring file" and asserted three values and no link, so a mutation that resolved the right value through the ladder and then linked the *queried* file survived it. It now rules out both other rungs by name, since `.env` is a substring of each. Verified live: with `hover_for_env_declaration` linking `path` instead of `var.source_file`, the test reddens. `contains("0 references")` is also satisfied by `"10 references"`, and `contains("1 reference")` by `"1 references"`. No live mutation escaped, because no fixture reaches a double-digit count — this closes the shape rather than waiting for the fixture that makes it real. All six count assertions now route through one helper that builds the label from `code_lens::reference_count_label`, so the pluralisation rule cannot drift, and pins both ends of the match to a non-alphanumeric boundary. The helper has its own test for the two escapes above. Adds the fixture the review asked for: a key spelling a markdown link and one spelling an image, plus a value carrying a fence, driven through the real handler. Each was mutation-verified against the renderer change it guards, including on the reverse direction's call site. Watson-Branch: #341 * fix: 🔒 escape the `.env` value the completion panel shows Last round closed "unconstrained `.env` text reaching markdown unescaped" at both renderers, and wrote the boundary down: `header` is plain text the renderer escapes for every caller, `summary` is markdown-bearing and its call site owns the escaping. The `.env` branch of `completion` is the call site that hands `summary` untrusted text, and it escaped nothing. The round that documented the contract left the one site that violates it. A `.env` value has no charset restriction, any more than a key does. `SUPPORT_NOTICE=[Update your credentials here](https://evil.example)` therefore renders a live clickable link in the completion documentation panel, and the `` variant is fetched with no click at all. That is the reach the key had through the hover header, one field over, in the popup most likely to be open while the `.env` file is the one on screen. The escape wraps the whole `summary` expression, not its untrusted arm. All three arms here are plain text meant to render as themselves, so escaping the field rather than one branch of it means a fourth arm added later cannot reopen this. Two test helpers modelled the panel with an unescaped value and move to the escaped form. `assert_no_process_var_leak` now strips backslashes before its whole-response substring search: the summary is escaped, so a leaked value carrying punctuation no longer spells the raw needle there, and the "any field" reach that assertion claims would have quietly stopped covering it. That strip is not what catches the restored `std::env::vars()` loop — `label` and `detail` are plain text and still do — it is what stops the summary becoming a blind spot. The module doc now states the guarantee per field: the renderers cover the header and the code fence, and every other field is verbatim by design. Watson-Branch: #341 * fix: ✅ pin the config label the way `main` now guarantees it The `main` merge reddened `the_config_completion_response_carries_no_dotenv_secret` on Windows alone: the test expected `config\app.php` and got `config/app.php`. The test was right until this merge and is wrong after it. #336 moved the config label from a hardcoded `format!("config/{group}.php")` to `strip_prefix`, which picked up the platform separator; the commit now on `main` fixes that by routing the label through `with_forward_slashes`, because it is user-visible text that must not change shape with the host OS. `config_source_label`'s own doc names the old Windows rendering as the bug. So `config_app_display()` — which built the expectation with `Path::join(...).display()` — encoded the pre-fix behaviour, and its comment ("Windows displays `config\app.php`, which is correct for Windows") now asserts the opposite of what production guarantees. Replaced with a literal `CONFIG_APP_LABEL`. The literal is the stronger assertion, not merely the passing one: the helper mirrored the production logic, so if the normalization were ever dropped the expectation would pick up the native separator alongside it and stay green on Windows — the only platform where that regression is visible. A literal cannot follow it. Mutation: setting the constant to `config\app.php` reddens the test with exactly the CI failure inverted (`left` and `right` swapped), so the assertion discriminates on separator shape rather than merely being reached. Swept the class — `config_app_display` had no other call site, and no sibling test in `src/tests` or `tests/` builds a user-visible expectation through `display()`/`to_string_lossy()`. Watson-Branch: #341 * test: ✅ restore the "any field" reach of both env leak searches Round 5 fixed `assert_no_process_var_leak` to strip backslashes before searching, because this PR's `escape_inline` on `CompletionDoc::summary` spells a punctuated value `s3cr3t\-value\-set…` there. The reasoning was written at that one assertion and carried nowhere else, so two sibling searches were left hunting a needle the panel no longer spells: - `env_completion_system_leak.rs`, the shadowing test's standalone check (the site Holmes flagged); - `env_value_redaction.rs`'s `assert_no_secret_leak`, the same helper shape in another file, blind across all four of its needles — every one carries hyphens. Both files now name the strip as `searchable()` so the reasoning reaches every call site instead of one, and both helper docs' "any field" claim is true again. Proven live, not by inspection. A summary-only leak (the `.env` completion summary made to answer from the real value, `detail` left redacted) went entirely unnoticed by both searches before this change — the tests failed only later, on a panel-equality assertion. After it, each fails on the leak assertion itself: `env_value_redaction.rs:193` and `env_completion_system_leak.rs:488`. The strip is itself unexercised by a green suite — a leak has to exist before the escaping can hide one — so deleting it would have degraded silently, the same shape one level up. Each file now pins it at its own definition, over its own needles, and each fixture reddens alone when the strip is removed. Their first assertion fails if a needle ever loses its ASCII punctuation, which is what stops the second going vacuous. Scope checked and left alone: the hover surface renders values through `fenced_block`, which lengthens the fence and never backslashes content, so its raw-needle assertions still match; `escape_inline` reaches only the two headers and this one summary. Every other negative assertion in the tree either searches a field this PR does not escape or uses a needle with no ASCII punctuation, which escaping leaves byte-identical. Watson-Branch: #341
…#360) * chore: start work on #354 Watson-Branch: #354 * feat(config): ✨ add owning_module, the shared module-ownership lookup Items 1-3 of #354 are one mistake three times: a precedence or containment rule re-implemented away from the helper that owns it. Both the Livewire containment gate and the Salsa registration merge need the same fact — which configured module owns a provider file, and where that module sits in `modules.paths` order — so give them one lookup to read instead of two path-prefix implementations that drift apart. Rank is 1-based so 0 means "no owning module" in the tuple comparison the merge tie-break will use. Matching is lexical on collapsed paths: a module may be a symlinked composer path repository, and canonicalizing either side would move a provider out from under its own module. * fix(livewire): 🐛 gate class paths against the owning module, not the root Item 1 of #354. `contained_class_path` canonicalized the resolved class path and checked it against the project ROOT. A module symlinked in from a composer path repository canonicalizes outside the root, so every Livewire registration it made was dropped silently, with no diagnostic — the exact layout `expand_module_dirs` documents as deliberately supported. Gate lexically against the provider's owning module before canonicalizing, which is what the PSR-4 provider branch (`resolve_provider_class_file`) has always done, falling back to the root for app providers that have no owning module. The gate gets stricter for module providers, not looser: a class path reaching into a sibling module is in-root but outside its own module, and is now dropped. Three tests: the symlinked path-repo module (with an in-root control that isolates the gate as the cause), the sibling-module reach (which a gate stubbed to always fall back to the root would admit), and the unchanged app-provider escape case. Mutation-verified against both the pre-#354 gate and a stubbed ownership lookup. * fix(salsa): 🐛 one tie-break rule for all five registration merges Items 2, 3 and 4 of #354. Item 2: anonymous_component_paths and anonymous_component_namespaces used a bare `.entry().or_insert()` — priority was never read. Provider files iterate in lexicographic path order, so a module's app/Legal/… provider was visited before app/Providers/AppServiceProvider.php and the module won, against the documented "an app/Providers registration always wins". Item 3: the view- and component-namespace merges broke an equal-priority tie by provider path sort, while the docs promise `modules.paths` glob-match order. The actor now holds the configured module directories and reads a provider's rank through config::owning_module — the same lookup the Livewire containment gate uses. Item 4: four registries ran three tie-break rules (last-wins, first-wins, priority ignored). All five now route through one `wins()` comparison on one `MergeRank` — priority, then modules.paths rank, then last-wins — stated once above the loop instead of restated per map. class_component_files changes direction from first-wins to match. Tests: both "later module wins" tests are rewritten onto a three-module fixture listed Alpha, Gamma, Beta, so the winner is neither the first nor the last by name and neither the old sort nor a reversed sort can produce it. One parametrized test constructs the same collision in all five registries and asserts one winner across them; another pins app-over-module in all five. Mutation-verified by dropping the rank and by restoring the or_insert. * docs: 📝 correct the post-#336 priority tiers, and fix two follow-ups Items 5, 6 and 7 of #354. Item 5: #336 renumbered service-provider priority to 0=framework, 1=package, 2=module, 3=app but left the doc sites behind. Swept the crate for the old order rather than fixing the three the issue named: main.rs's registrar docblock and its six inline section comments, the SalsaRequest priority comment, build_macro_registry, the salsa_impl test comment (whose fixture registered an app provider at 2 — corrected with it), and CLAUDE.md's convention line. Two sites keep their own numbers: command_index and member_resolver:: impl_priority each run an independent path-derived scale that really is 0/1/2, so renumbering them would have made the prose wrong about the code. What was stale there was the claim to be following the service-provider convention — that is what is corrected. Item 6: the cached middleware and binding entries labelled priority 2 "app level" were sitting at the module tier after the renumber. Inert today (no reader compares those priorities) and a real bug the moment one does. Item 7: psr4_entries_escaping_the_module_resolve_nothing's traversal case used ../../outside, which normalized to proj/app/outside — nowhere near the decoy provider the test writes to tmp/outside. It passed with or without the containment gate. Four levels reach the decoy; verified by deleting the gate and watching the test go red. * docs: 📝 close item 5 on the semantic claim, not the literal spelling The three-literal sweep (`app=2`, `2=app`, `App=2`) could not see a tier claim written as prose, and two sites spelled it that way. - `salsa_impl.rs:3563` — the macro/mixin walk's coverage-boundaries doc still carried the pre-#336 three-tier order, four lines above a cross-reference to `build_macro_registry`, whose docstring was already corrected. Now names all four tiers, and keeps "the last two" pointing at the vendor-scanned pair. - `tests/integration_tests.rs:1336` — `priority_merging` documented and encoded `app (2) > package (1) > framework (0)`: the module tier absent and app pinned at what is now the module value. A stale tier oracle in the test tree, which a `src/`-scoped sweep is structurally blind to. The integration test is self-referential by construction, so it records the order rather than observing it; its doc now says so and points at the tests that enforce the order against the real merge. The new module assertion is mutation-verified — swapping the app and module constants fails the build. Left alone, each checked: the env scale (`0=.env.example, 1=.env.local, 2=.env`), `command_index`'s own three-tier path scale, `route_discovery`'s route-name scale, and the `BindingRegistrationData` fixture literals, which assert no tier semantics. * test: ✅ Correct the stale pre-#336 app provider tier in the location oracle. `test_service_provider_priority_by_location` still encoded the three-tier scale item 5 exists to retire: app providers at 2 — now the module value — and no module tier at all. It is the immediate sibling of `test_priority_ordering_constants`, corrected one commit earlier, so `mod priority_merging` documented two different scales six lines apart. App is now 3, matching `register_service_provider_files_with_salsa`. The module tier (2) is deliberately not added as a branch: no path substring identifies a module provider — they come from the `modules.paths` globs plus each module's composer `extra.laravel.providers`, resolved by `config::module_provider_files`. A `contains("modules/")` branch would encode a rule the real classifier does not implement, replacing a stale oracle with a false one. The doc now states that limit and points at `module_view_namespaces` and `module_livewire_namespaces`, which enforce the module tier against the real merge. Mutation-verified: restoring `2` in the classifier fails the assertion. Refs: #354 * fix: 🐛 Fail closed on unverifiable Livewire class paths. Item 1's containment rework gated `contained_class_path` with `path_within_root_lexical`, which admits every in-root path it cannot canonicalize. A DANGLING under-root symlink therefore minted a registration where `main` refused one — verified against both trees with the same probe: dangling under-root symlink main: refused branch: ADMITTED live symlink escaping root main: refused branch: refused That value is not a speculative candidate. `contained_class_path`'s own doc says it is consumed without further gating by the component-completion walk and `try_namespaced_class`, so it becomes a read primitive; a target created after the check could then resolve outside the module (#134/#155). The docstring claiming "fail-closed via path_within_root_lexical" was false — `path_containment`'s module doc states that guard is "not a security guard for paths that will be read or emitted". `path_containment` documented four entry points over one canonical-first core, differing only in what they do with an uncanonicalizable path. Two axes vary — whether an out-of-root candidate is probed on disk, and whether an unverifiable in-root path is admitted — and "never probe, never admit" had no member. `path_within_root_registration` fills it, composing the two halves that already existed. No new containment logic. Item 1's fix is untouched: gated against the owning module, a symlinked composer path repository still passes because both sides canonicalize to the real target, and all three of item 1's regression tests stay green. Eight tests added. Mutation-verified: dropping the lexical pre-gate fails only the no-probe test; flipping `unwrap_or(false)` to `true` fails only the dangling and absent tests; reverting the call site to `path_within_root_lexical` fails both new call-site tests. The module's stale "**three** public entry points" count is corrected to five. Refs: #354 * fix: 🐛 Refuse component names that name a path instead of a component. `dotted_to_class_path` promised a relative class path and returned whatever it was given. It splits on `.`, kebab→Pascal's each segment and rejoins with `/` — which destroys `..` by accident, but passes `/` and a leading `/` through untouched. Every caller then does `base.join(converted)`, and `Path::join` REPLACES the base when the right-hand side is absolute. Proven against the real resolver before the fix — no race, no write access, just a name in a file you opened: ui::/tmp/outside/Secret -> paths: ["/tmp/outside/Secret.php"] /tmp/outside/Secret -> paths: ["/tmp/outside/Secret.php"] The name is discovered data: whatever follows `::` in a `<livewire:…>` tag or an `@livewire('…')` literal. Four call sites trusted the converter, not the two the probe found: - livewire_resolver::try_namespaced_class / try_v3_class — goto-definition - component_declaration_locator::conventional_class_file_path — lookup AND rename target - livewire_declaration_locator — rename target Gating only the resolver would have left both rename paths open, so the fix is at the converter, where the contract is. It now returns `Option<String>` and refuses any segment that is empty or contains `/`, `\` or `:`. Rejecting rather than sanitizing keeps it total — there is no "cleaned" name that silently resolves somewhere the author did not write. `\` and `:` are refused on every platform so behaviour does not diverge by host. All four callers fail closed; the two rename paths decline to move a file rather than compute a destination for it. Mutation-verified. The first version of the resolver regression test passed WITHOUT the guard: `TempDir::new()` names its directory `.tmpXXXX`, so the dot-split mangled the probe path before it could escape. It now uses a dot-free prefix and asserts that precondition, so it cannot go vacuous again. Refs: #354 * fix: 🐛 Close the remaining escapes and silences the review surfaced. Three reviewers attacked the two preceding commits; each found something, and every finding was reproduced by probe before being fixed. 1. `dotted_to_class_path` checked the segments going IN and returned the join of the segments coming OUT. `kebab_to_pascal` maps an all-dashes segment to "", and an empty FIRST segment makes the join absolute, so `"-.foo"` minted `"/Foo"` — renaming a component to `-.foo` wrote its class file to `/Foo.php` while the blade file stayed put. The check now runs on the converted segments. 2. The previous commit hardened only the class branch. The V4 SFC/MFC/Volt branch runs FIRST and builds its search directory with `parents_to_path`, which uses `PathBuf::push` — an absolute segment replaces the path. Verified escaping the project root; goto-definition was saved by a guard at main.rs:16573, hover was not. `resolve_component` now refuses any raw segment carrying path syntax, covering every branch at once. 3. The Livewire gate re-derived a provider's owning module by PATH PREFIX. `modules.paths` is user-written and the settings doc offers `app/*/*`, so `app/*` is a shape people write — and it expands to include `app/Providers`. An APP provider then looked like a module provider and was gated against `app/Providers`, silently dropping the most ordinary registration there is. Provenance now comes from the discovery that found the provider. `owning_module` keeps its one honest consumer, the Salsa tie-break, where a prefix match is the only signal available. 4. The registration guard returns the canonical path it verified instead of a bool. The caller canonicalized a second time to obtain the value, so a symlink swapped between the two calls stored an unapproved path. 5. A provider changing on disk now drops the cached Livewire namespace map, not just the vendor translation namespaces. Registrations are gated on the class directory existing, so one that failed the gate at index time stayed failed for the session: `artisan module:make-livewire` creates the directory through the watcher, never the editor, leaving the component dead until restart. 6. A dropped registration logs a warning naming the path and the gate it failed, and a `modules.paths` entry resolving outside the project root announces itself. Both were silent, and there is no Livewire "component not found" diagnostic to make them visible. Neither is refused: outward-linking module directories are the composer path-repository layout this supports. Each behavioural fix is mutation-verified and fails only its own test. Item 1's regression tests stay green, including the symlinked path-repo module and the sibling-module drop. Refs: #354
Fixes #297
In a modular monolith (
app/{Parent}/{Module}/with per-modulecomposer.json,config/, providers, views, and translations), the LSP saw none of it:config('module-group.key')was "not found", module-providerloadViewsFrom/loadTranslationsFrom/Blade::directive/Livewire::addNamespaceregistrations were invisible. This implements the design from #297, including the acceptance criteria added there.The setting
Strictly opt-in, per the issue's explicit-paths proposal (composer-merge-plugin auto-detection stays out of scope). Unset, empty, or a stale glob that matches nothing on disk behaves exactly like today — pinned by a diff test asserting identical completion output — and a malformed glob entry is a per-entry no-op, never a failure.
Module config
Each module's
config/{group}.phpmerges into the top-level group through one shared helper pair:config_group_filesowns discovery and merge ORDER,config_lookup/config_key_locatorown value and position resolution over that order — no surface carries its own merge. Documented precedence, mirroring the runtimearray_replace_recursive: the project file merges first, then each module in glob-match order, so the last-merged declaration of a key wins and nested arrays merge per key rather than replacing. The same rule applies uniformly to a module group colliding with a core group (app.php) and to two modules defining the same group — each case has its own value-asserting test.A module-only key resolves on every surface, one test per surface: completion, hover, goto-definition, the "Config not found" diagnostic (with a negative control: it fires again without
modules.paths), find-references, rename, and the code lens. Values are obtained by the same static parsing as root config files — a fixture whose values areexit(...)/exec(...)expressions resolves as text, proving nothing is ever executed. No new dependencies.Module providers — composer-driven
Discovery is driven by each module composer.json's
extra.laravel.providers— the list Laravel actually boots via the merged manifests — never by filename convention. Tests pin both directions: a*ServiceProvider.phpthe manifest doesn't name is NOT indexed, and a provider it names under a non-conventional filename IS. FQCNs resolve through the manifest's own PSR-4 mapping (longest prefix first), with a bounded basename walk as fallback.An indexed module provider contributes exactly what an
app/Providersone does:loadViewsFromregisters a view namespace — proven by a fixture reachable through no other mechanism, with a negative control (call removed → resolution fails) and an unlisted-provider control.loadTranslationsFromregisters a translation namespace through the Salsa translation layer (the refactor: route translation resolution through Salsa instead of direct fs reads #293/refactor: ♻️ Route translation resolution through Salsa #328 architecture): module providers enter the same provider-file set asapp/Providers, ordered before it — so on a namespace conflict the last-registered module wins and an app registration overrides both, matching the config rule.ns::file.keycompletion now walks every registered namespace, including projects whose only catalogues live under one (no rootlang/at all). Negative-control and collision tests included.Blade::directiveregistrations are discovered — regression-pinned with its own negative control.Livewire::addNamespaceis extracted in all three argument forms, one test each: positional, named in declared order, named reversed. Configurable wrapper methods (modules.livewireRegistrars) recognize app-defined helpers that call it internally.<livewire:ns::component>resolution is covered by the AC trio: a component reachable only via the namespace resolves, resolution fails when the registration is removed, and a genuinely missing component under the valid namespace still fails. Non-literal path/namespace arguments skip registration without error.Deliberate deviations from the AC, called out
Livewire::addNamespaceis extracted with the three-parameter signature (namespace,classNamespace,classPath) rather than the AC's two-argumentnamespace:/path:shape — the three-parameter form is what the modular-monolith layout that motivated the issue actually calls, and the AC's example shape extracts nothing against real code. Unknown parameters (e.g.lazy: true, or anything a future Livewire adds) skip that argument, never the registration.ns::keytranslation completion is unconditional, not gated behindmodules.paths: it closes the pre-existing refactor: route translation resolution through Salsa instead of direct fs reads #293/refactor: ♻️ Route translation resolution through Salsa #328 gap for every provider-registered namespace, vendor packages included. Called out indocs/configuration.mdas a scope addition rather than arriving silently under the opt-in banner; the opt-in diff test covers translation completion alongside config keys.Glob shapes
app/Common/*andapp/*/*are covered by distinct fixtures proving they match at different depths...segments are rejected (no escaping the root).Docs
docs/configuration.mddocuments the full setting path, type, default-off opt-in guarantee, a config example matching the issue's JSON block, the merge/precedence rules, andlivewireRegistrars.Tests
Full suite: 2562 lib + 575 integration tests green (49 new assertions-bearing tests across
config,livewire_namespaces,livewire_resolver, and four integration files; none skip-marked), clippy-D warningsclean. One fixture reproduces the issue's full modular-monolith layout.