Skip to content

feat: ✨ Modular monoliths: module-local config, providers, and Livewire namespaces - #336

Merged
mikebronner merged 10 commits into
mike-bronner:mainfrom
BastenIT:feat/modular-monolith-modules
Aug 28, 2026
Merged

mikebronner merged 10 commits into
mike-bronner:mainfrom
BastenIT:feat/modular-monolith-modules

Conversation

@marlonbasten

@marlonbasten marlonbasten commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #297

In a modular monolith (app/{Parent}/{Module}/ with per-module composer.json, config/, providers, views, and translations), the LSP saw none of it: config('module-group.key') was "not found", module-provider loadViewsFrom/loadTranslationsFrom/Blade::directive/Livewire::addNamespace registrations were invisible. This implements the design from #297, including the acceptance criteria added there.

The setting

"laravel-lsp": { "settings": {
  "modules": {
    "paths": ["app/Common/*", "app/*/*"],   // default: [] (off)
    "livewireRegistrars": ["loadLivewireComponentsFrom"]
  }
} }

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}.php merges into the top-level group through one shared helper pair: config_group_files owns discovery and merge ORDER, config_lookup/config_key_locator own value and position resolution over that order — no surface carries its own merge. Documented precedence, mirroring the runtime array_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 are exit(...)/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.php the 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/Providers one does:

  • loadViewsFrom registers 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.
  • loadTranslationsFrom registers 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 as app/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.key completion now walks every registered namespace, including projects whose only catalogues live under one (no root lang/ at all). Negative-control and collision tests included.
  • Blade::directive registrations are discovered — regression-pinned with its own negative control.
  • Livewire::addNamespace is 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::addNamespace is extracted with the three-parameter signature (namespace, classNamespace, classPath) rather than the AC's two-argument namespace:/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.
  • Namespaced ns::key translation completion is unconditional, not gated behind modules.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 in docs/configuration.md as 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/* and app/*/* are covered by distinct fixtures proving they match at different depths. .. segments are rejected (no escaping the root).

Docs

docs/configuration.md documents the full setting path, type, default-off opt-in guarantee, a config example matching the issue's JSON block, the merge/precedence rules, and livewireRegistrars.

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 warnings clean. One fixture reproduces the issue's full modular-monolith layout.

Marlon Arno Basten added 5 commits August 28, 2026 14:43
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 mikebronner left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.paths entries 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 dead starts_with line 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's walkdir fallback is the other case: those paths are discovered, not configured, so per the #228 convention each entry should go through path_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 maps app/, 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.

mikebronner added a commit that referenced this pull request Aug 28, 2026
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
Marlon Arno Basten added 2 commits August 28, 2026 23:54
…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).
mikebronner added a commit that referenced this pull request Aug 28, 2026
…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
@marlonbasten

Copy link
Copy Markdown
Contributor Author

@mikebronner Thank you so much for the review! I should've implemented everything now!

@mikebronner mikebronner left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(&reg.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.
@marlonbasten
marlonbasten force-pushed the feat/modular-monolith-modules branch from b3df9be to 793ac93 Compare August 28, 2026 22:45
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).
@marlonbasten

Copy link
Copy Markdown
Contributor Author

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 2, the same tier as app providers, so app-over-module would still have depended on iteration order. Modules now have their own tier (0=framework, 1=package, 2=module, 3=app), so the documented rule holds by rank and last-wins only breaks ties within a tier. Both directions pinned; docs/configuration.md records the tier.

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 (max_depth(8)), since the hang hazard is independent of containment.

3. Enumeration still discovers which groups exist (that genuinely is a different shape), but per-group file order now comes from config_group_files, and a test asserts completion's winning source equals the helper's first file — so divergence fails rather than drifts.

Everything else followed the finding as written: the Livewire merge scans modules first and app last with insert(), the PSR-4 arm is lexically gated before probing and matches only at a namespace boundary (empty catch-all prefix excepted), a skipped positional consumes its slot, and the rename and custom-registrar surfaces have their tests. Riders done too — the watcher-dedup comment no longer overclaims, symlink following is driven through the glob expansion itself, and the two named-argument tests are renamed to the permutations they actually cover.

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.

@marlonbasten

Copy link
Copy Markdown
Contributor Author

@mikebronner I'm off for now, thanks for the reviews again and have a nice evening! 🙏

@mikebronner

Copy link
Copy Markdown
Contributor

@marlonbasten Thanks so much! Go to bed already hehe :) I'll try to get this all sorted out and resubmitted to Zed tomorrow.

@mikebronner
mikebronner merged commit e8b3c81 into mike-bronner:main Aug 28, 2026
5 checks passed
mikebronner added a commit that referenced this pull request Aug 29, 2026
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.
mikebronner added a commit that referenced this pull request Aug 29, 2026
…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.
mikebronner added a commit that referenced this pull request Aug 29, 2026
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
mikebronner added a commit that referenced this pull request Aug 29, 2026
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.
mikebronner added a commit that referenced this pull request Aug 29, 2026
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.
mikebronner added a commit that referenced this pull request Aug 29, 2026
…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
mikebronner added a commit that referenced this pull request Aug 29, 2026
* 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
mikebronner added a commit that referenced this pull request Aug 29, 2026
…#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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Modular monoliths: module-local config, providers, and Livewire namespaces are invisible

2 participants