Skip to content

feat: ✨ Hover and go-to-definition on keys in .env buffers - #353

Merged
mikebronner merged 13 commits into
mainfrom
feature/341-hover-and-go-to-definition-on-env-keys
Aug 29, 2026
Merged

mikebronner merged 13 commits into
mainfrom
feature/341-hover-and-go-to-definition-on-env-keys

Conversation

@mikebronner

@mikebronner mikebronner commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements #341 — hover and go-to-definition now fire on a key declaration inside a .env* buffer.

Both handlers returned before any dispatch on such a 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.

Changes

  • hover — the key name, its effective value and declaring file (through the existing .env(2)/.env.local(1)/.env.example(0) ladder get_parsed_env_var already applies), and its consumer count. Commented-out and not-defined states mirror hover_for_env; the not-defined card keeps the count.
  • goto_definition — jumps to the consuming env('KEY') call sites. One hit returns a Scalar, several return an Array carrying every location. Zero consumers returns None, where hover still renders a card.
  • Both branch to a dedicated env path ahead of the get_patterns PHP-pattern lookup, so admission reaches a real result instead of falling through into Ok(None).
  • env_key_locator::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; they stay separate so a commented # KEY= cannot win the first-match-wins race and move the env code lens onto a comment.
  • code_lens::reference_count_label + Backend::reference_locations — the count phrasing and the de-duplicated location lookup were inline in code_lens_resolve. Extracted, with both call sites routed through them, so a key's lens count and its hover count come from one call and cannot disagree.
  • Markdown safety — .env text has no charset restriction, and both the hover card and the completion panel are MarkupKind::Markdown. The two renderers escape the bold header and negotiate the code fence for every caller; CompletionDoc::summary is markdown-bearing by contract (a PHPDoc summary carries real markdown), so the .env branch of completion escapes the value it puts there itself. Without it, KEY=[text](https://evil.example) renders a live link in the completion panel and KEY=![](…) is fetched with no click.
  • Docs — environment.md stated hover and go-to-definition "only ever run on .php and .blade.php", which this change falsifies. Corrected, with its surrounding argument (a real .sh gets nothing) intact. hover.md, go-to-definition.md and comparison.md document the new surface.

Two AC premises corrected

Both are stale rather than wrong-headed; flagging them rather than silently diverging.

  1. is_env_file_name exists. The AC's grounding comment reported it absent from env_key_locator.rs and pointed the work at the inline spelling file_name == ".env" || file_name.starts_with(".env."). PR Route every "is this an env file?" check through one strict predicate (3 spellings, 2 loose) #346 has since landed and routed every env-classification site through is_env_file_name, whose body is that expression. Calling it is therefore the AC's exact predicate with no second spelling to drift — the stronger reading of the same requirement.
  2. Commented keys cannot be located through get_parsed_env_var. The AC directs commented-key detection at parse_env_source/ParsedEnvVar position data "reached through get_parsed_env_var", but that call is keyed by name and so cannot answer "which key is at this cursor". Its only positional sibling, get_all_parsed_env_vars, merges by name across files by priority, which drops exactly the case the AC asks about — a commented declaration in a file outranked by another. Detection reads the buffer instead (the authoritative source for "in this file"), through the same parse_key_declaration the rest of the module uses. Value, source file and commented state still come from get_parsed_env_var, as the AC requires.

Acceptance Criteria

  • Gate widened in goto_definition and hover using the shared strict predicate (via path_is_env_file → is_env_file_name), not a new spelling and not a loose contains/prefix form; admission branches to a dedicated env path ahead of the PHP-pattern lookup.
  • Active keys located via enumerate_keys_in_source; cursor on the =, the value, or a blank line resolves nothing; a same-key re-declaration resolves nothing (first-match-wins).
  • Commented declarations located from the buffer via the shared parse_key_declaration — see premise 2 above. Go-to-definition treats them identically to active keys.
  • Hover renders key, effective value, declaring file (correct .env/.env.local/.env.example ladder) and consumer count in code_lens_resolve's exact phrasing — now literally the same function.
  • Zero-consumer key still renders a full card reading 0 references.
  • Undefined key hovers *(not defined in .env)* — N references, keeping the count.
  • Commented declaration hovers with an explicit commented-out state.
  • Go-to-definition returns Scalar for one consumer, Array of every consumer for more — never truncated.
  • Zero consumers → Ok(None) from go-to-definition while hover still renders; the two diverge by design.
  • textDocument/references keeps its .php/.blade.php gate — out of scope, pinned by a test.
  • .envrc, .environment and my.env.local still return Ok(None) from both handlers, proven by a fixture carrying a valid declaration so only the gate can turn it away.
  • Hover and goto tests per bullet, each constructing real HoverParams/GotoDefinitionParams against a .env/.env.example URI and driving hover/goto_definition through a tower_lsp::LspService harness.

Note on the last one: the AC cites an "existing .php/.blade.php dispatch test convention" for driving these two handlers. No such test existed — nothing in the repo called hover/goto_definition at the trait level before this PR. The requirement is met by building that harness here; it is new, not copied.

Test Plan

  • 25 handler-level tests (src/tests/env_key_navigation.rs) + 7 locator unit tests (src/env_key_locator/tests.rs) + 11 unit tests for the two escaping primitives (src/markdown_safety/tests.rs).

  • Full suite green: 2704 lib + 665 bin + 80 integration + 2 doctests, 0 failures (cargo test --all-features, as CI runs it). Counts rose from 2703/651 with the main merge, which brought Cache-hit regression tests are blind to config_lookup::resolve_value's disk reads #357's added tests, and from 663 bin with round 6's two added fixtures.

  • CI green on all four legs — ubuntu, macos, windows, and the wasm extension check.

  • cargo clippy --all-targets -- -D warnings clean, cargo fmt --check clean.

  • Mutation-verified — 29 applied live across five rounds, 29 killed. Rounds 1-3 are recorded in the review thread (9, 10 and 3). Round 4 applied the six below, each reverted after its run:

    • Header escape removed from hover::render — the markdown-link fixture reddens.
    • Fence negotiation removed from hover::render's plain arm — both the .env-buffer fence test and the reverse-direction one redden.
    • Escape set narrowed by one character (! exempted, i.e. a hand-listed set instead of the spec's) — the image fixture and the completeness test both redden. This is the mutation that argues for delegating the set to char::is_ascii_punctuation.
    • Header escape removed from CompletionDoc::render — three env_value_redaction panels redden, which is how the third site proved it was live.
    • hover_for_env_declaration linking the queried path instead of var.source_file — the lower-priority ladder test reddens, the hole the old comment described but the body did not assert.
    • The consumer-count helper degraded to a plain contains — its own test reddens on "10 references" and "1 references".

    The equal-priority arm's fourth row (both declarations commented) stays deliberately unpinned, for the reason given last round: the commented branch is cursor-derived and renders no value, so no output distinguishes the two.

    Round 5 applied one more:

    • Summary escape removed from the .env completion branch — 13 tests redden, including a_value_spelling_a_markdown_link_renders_inert_in_the_panel, which was written red before the fix and asserts against literal escaped text rather than through the shared expected_panel helper.

    Round 5 declared the backslash strip in assert_no_process_var_leak not mutation-covered, on the grounds that label and detail still catch a restored std::env::vars() loop. That was honest but incomplete: the strip is discriminating, against a summary-only leak rather than a wholesale one. Round 6 applied three more mutations, all killed, which is what retires that caveat:

    • The .env completion summary made to answer from the real value, detail left redacted — a summary-only leak. env_value_redaction's assert_no_secret_leak now reddens at it; before round 6 it returned clean and the test failed later on panel equality.
    • The same summary made to answer from std::env::var — a summary-only leak of the process value, which the first mutation does not produce. env_completion_system_leak's shadowing check now reddens at it, having previously passed it through.
    • searchable() neutered to return its input unchanged, in both files at once. Exactly the two new fixtures redden and nothing else, so each pins its own file's strip alone.

    Running total: 32 mutations applied live across six rounds, 32 killed.

Rendering change, and what it does not change

Escaping alters the wire text of every card and panel header containing punctuation (APP\_NAME, App\\Models\\User), and of the .env value in the completion panel, which is why 33 assertions across nine test files moved to the escaped form and two panel helpers followed in round 5. It does not alter what anyone sees: CommonMark renders \. as .. No user-facing documentation changes, because there is no user-facing behaviour change — unlike the redaction work in #344/#348, which hid values.

Fixes #341

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
@mikebronner
mikebronner marked this pull request as ready for review August 28, 2026 23:22
… 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

@mr-sherlock-holmes mr-sherlock-holmes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔄 Changes Requested

All twelve acceptance criteria are met, and I checked each against the tree rather than reading your checklist. CI is green on four platforms. The two AC premises you flagged are both correct, and flagging them beat diverging silently — I've confirmed each below. What blocks is a case none of the twelve criteria names and no test covers: a key declared twice in one file, once active and once commented.

Issues Found

1. 🔴 The hover card can describe a different declaration than the one under the cursor

env_key_at_position does precise work — it resolves the cursor to an exact (line, column-range) in an exact file, keeping active and commented enumerations separate. Then hover_for_env_declaration throws all of it away and passes forward the bare name:

async fn hover_for_env_declaration(&self, key: &str, references: usize) -> String {
    let var = self.salsa.get_parsed_env_var(key.to_string()).await

laravel-lsp/src/main.rs:21187, called at :21259.

get_parsed_env_var is name-keyed and priority-merged, and three facts in untouched code combine badly with that:

  1. parse_env_source registers commented lines too, with is_commented: true, into one line-ordered vec — salsa_impl.rs:1522-1560.
  2. register_env_files_with_salsa stamps one priority per file (main.rs:6076-6088), so two declarations of a key inside one file always tie.
  3. The tie-break keeps the first-seen entry — salsa_impl.rs:11807:
    Some(existing) if existing.priority >= data.priority => {}

So on a tie the textually-first line wins, and the cursor plays no part in it. Both directions break:

  • .env = "# APP_NAME=Old\nAPP_NAME=New\n" — hover the active line. The card renders *(commented out)*, with the source link from the commented entry. A live declaration reported as disabled.
  • .env = "APP_NAME=Acme\n# APP_NAME=Backup\n" — hover the commented line. The card renders Acme with no commented-out marker at all.

Commenting an old value out directly above or below the current one is ordinary .env editing, so this is reachable in normal use, not a contrived fixture.

This is a line the PR adds, and it also defeats AC #7's intent — a commented declaration is supposed to hover with an explicit commented-out state, and here it doesn't. The fix is to thread the state you already resolved: env_key_at_position knows which enumeration matched and on which line, so pass that through instead of re-deriving it by name. Keep get_parsed_env_var for the effective-value ladder in AC #4 — that part is right and should not change.

Please add the fixture that would have caught it: one file, one key, active and commented, hovered on each line.

2. 🔴 The #-stripping rule now exists in two places

enumerate_commented_keys_in_source (env_key_locator.rs:184-212) re-implements the trim → trim_start_matches('#') → trim classification that parse_env_source already does at salsa_impl.rs:1523-1528. Your own doc comment names the coupling — "matches parse_env_source's trim_start_matches('#'), so ## KEY=value reads as a declaration on both sides of the stack" — which is precisely the problem: it's an invariant held by two independent copies and a comment asking them to stay in step. Change one (require a space after #, say) and cursor hit-testing silently disagrees with what Salsa calls commented. Same root as #1 — the buffer-local and Salsa views of "commented" drifting apart. Extract the shared rule.

3. 🔴 Three test assertions are thinner than the criteria they stand for

All in env_key_navigation.rs, all in-PR:

  • .env.local is never exercised. AC #4 names the full .env(2) > .env.local(1) > .env.example(0) ladder; the priority test only spans the two extremes (:293-318). Your own env_priority() helper defines the middle tier at :43-49 and no assertion ever uses it. Add .env.local as a middle tier that loses to .env and beats .env.example.
  • The zero-consumer card isn't checked for being a card (:320-342). AC #5 says a full hover card — key, value, source file, 0 references — but the test asserts only the key name and the count. Drop the value and the source link in that branch and it still passes.
  • The commented-key test never asserts the value is absent (:384-410). Its sibling redaction test does exactly this (!card.contains("hunter2"), :551-582). Worth matching, especially since finding #1 shows the commented branch is reachable with the wrong entry.

What's Good

The refactor is the strong part of this PR. Extracting reference_count_label and reference_locations and routing both code_lens_resolve and the new hover through them means a key's lens count and its hover count are now the same call — they cannot drift apart, which is the right way to satisfy "not a new wording."

The redaction work is genuinely careful. hover_for_env_declaration mirrors hover_for_env's guard chain exactly — commented → sensitive-name → URL-credential masking, same order, no branch reaching a raw value — and the reasoning you left in the comment ("the value being on screen already is not a reason to skip them") is the correct instinct. LSP output is LSP output.

The test harness deserves credit for its honesty: cursor_off_the_key_resolves_nothing carries an explicit positive control so the negative assertions can't pass vacuously through a gate that rejects everything. That's the kind of thing that's easy to skip and expensive to miss. Nine self-applied mutations, nine killed, is a real check and not a claim.

Both AC premises confirmed. is_env_file_name's body is the AC's literal expression (env_key_locator.rs:49-50), so calling it is the stronger reading — no second spelling to drift. And get_parsed_env_var(name: String) takes no position and no file (salsa_impl.rs:7756), while get_all_parsed_env_vars merges across files, so neither can answer "which key is at this cursor in this file." Bullet 3's named mechanism genuinely cannot do positional lookup; reading the buffer was right. Note the irony that finding #1 is the same lesson applied one layer further in — you correctly refused a name-keyed lookup for position, then used one for state.

📋 Non-blocking follow-ups

  • buffer_text (main.rs:21144-21149) reads from disk without the path_within_root check used at main.rs:5988-5997. Its path comes from the request URI, not a project-wide index, and the identical fallback already exists verbatim for PHP/Blade hover at main.rs:24539-24542 — so this matches the repo's existing pattern rather than departing from it. Noted — not tracked.
  • env_value_redaction.rs:462-502 carries an unrelated Windows path-separator fix. Correct in itself, just not this PR's subject. Not worth the churn of splitting out. Noted — not tracked.

(Watson: findings 1-3 are the blockers — all three belong to this unit, please fold them into this PR. The two items in this section are unrelated and optional; neither is tracked and neither needs action.)

Please address the above and re-request review.

`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
…y 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
@dr-john-h-watson

Copy link
Copy Markdown

Blockers fixed (all three belong to this unit); unrelated cosmetics skipped — both were marked Noted — not tracked, and I left them alone.

1. The card described a declaration other than the one under the cursor. State now comes from the cursor: env_key_at_position returns which enumeration matched, and a commented declaration takes no name-keyed 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 ladder still resolves an active declaration's value and declaring file, as you asked.

I also fixed the mechanism, not only the reported field. The tie-break kept the textually-first entry, so the value had the same defect as the state: # APP_NAME=old above APP_NAME=new would have rendered old on the live line even after the state was threaded. At equal priority an active declaration now outranks a commented one — a comment is not a declaration competing for the key — in both env merges, through one shared env_var_supersedes. Cross-file the ladder is untouched: a key commented out in .env still wins over an active one in .env.local, because that is how a project switches a key off. Hovering the active declaration it outranks now reports no value in effect rather than quoting one the application never sees.

2. The #-stripping rule. One definition now — commented_declaration_body in env_key_locator.rs — and four callers route through it: parse_env_source, parse_key_declaration (the same rule read as a rejection), the commented enumeration, and env_comment_tokens::inline_comment_start. Behaviour unchanged; direct tests pin the marker run, the non-comment shapes, and the suffix property the column arithmetic rests on. Committed separately from the fix.

3. The three thin assertions. The priority test spans all three rungs, and a second test exercises the middle tier alone (.env.local beating .env.example with .env silent). The zero-consumer card asserts value and source link — which caught that its fixture key, UNUSED_KEY, matches the sensitive-name pattern, so that card never carried a value at all; renamed to IDLE_SETTING. The commented card asserts the value is absent.

New fixtures: the pair you asked for (one file, one key, active and commented, hovered on each line) in both orderings; the reverse env('KEY') direction and the whole-table merge on the same fixture, so each merge call site is pinned rather than the shared rule in isolation; the commented card's source link, which discriminates only because the comment and the live declaration sit in different files; and the outranked-by-a-comment case.

Mutations: 10 applied, 10 killed — state re-derived by name (3 tests red), tie-break reverted (2), the commented-winner guard dropped (1), the shared rule stripping one # (2), the card losing its value (7), the ladder losing its ordering (3, one of them a pre-existing test), the commented card losing its link (1), parse_env_source bypassing the shared rule (3), the commented link re-derived by name (1), and the tie-break reverted against the whole-table merge (1).

Sweep. The class is "who classifies a .env line as commented". Five sites in src/: the four now routed through the shared rule, plus database.rs:1929, which is docker-compose YAML, not .env. One in the test tree — tests/integration_tests.rs:1283 hand-rolls a .env filter, but it asserts only that APP_NAME exists in a fixture and makes no parity claim against production, so I left it. Naming it here so the judgement is visible rather than silent.

Full suite green (3,417 tests), clippy clean, docs/hover.md updated for the cursor-anchored behaviour.

@mr-sherlock-holmes mr-sherlock-holmes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔄 Changes Requested

All three round-1 blockers are genuinely fixed — I checked each against the tree rather than against your summary, and the fix went further than the report I asked for. All twelve acceptance criteria are met. CI is green on four platforms. What's left is one assertion, on a fixture you already wrote.

Issues Found

1. 🔴 The new tie-break's first-wins guarantee isn't pinned by any test

env_var_supersedes (salsa_impl.rs:11801-11806) is the heart of this round's fix, and its Equal arm has four input rows. Two are covered; two aren't, and one of those matters:

Ordering::Equal => current.is_commented && !candidate.is_commented,
current candidate correct covered by
commented active true a_commented_line_above_the_active_one_does_not_answer_for_it
active commented false a_commented_line_below_the_active_one_still_reads_as_commented
active active false nothing
commented commented false nothing

Mutate && to || and both covered rows return exactly what they returned before — so the whole suite stays green — while row 3 flips from first-wins to last-wins. That is the same defect class this round exists to close: enumerate_keys_in_source is first-match-wins, so the cursor only ever resolves on the first KEY= line; if the merge went last-wins, hovering that first declaration would render the second declaration's value. A card describing a line other than the one under the cursor — round 1's finding #1, arrived at from the other direction.

The fixture is already there. second_declaration_of_the_same_key_resolves_nothing (env_key_navigation.rs:264-288) builds exactly the right file:

&[(".env", "APP_NAME=first\nAPP_NAME=second\n")],

and then asserts only hover_at(&env, position(0, 2)).await.is_some() — that something came back, never that it was first. Assert the winning value on that first line (and that second is absent) and the mutation dies. Row 4 (both commented) I'd leave alone: the commented branch is cursor-derived and renders no value, so no output distinguishes the two, and a test would be asserting into a void.

Your mutation round covered "tie-break reverted" — that restores existing.priority >= data.priority, which is row 1's behaviour, so it kills row 1 and leaves row 3 standing. Worth noting because the sweep was otherwise thorough: this is a gap in the mutation set, not in the effort.

What's Good

The fix is better than the one I asked for. I flagged the state being re-derived by name; you found that the value had the identical defect through the same tie-break, and fixed the mechanism rather than the symptom — # APP_NAME=old above APP_NAME=new would have rendered old on the live line even with the state correctly threaded. Fixing the cause you weren't asked about is the difference between closing a bug and closing a bug class.

The cross-file restraint is the right call and correctly reasoned. Keeping a commented .env declaration ahead of an active .env.local one, because commenting a key out in .env is how a project switches it off, preserves the ladder's meaning; scoping the new rule to equal priority means it can only ever fire within one file, since register_env_files_with_salsa hands out 0/1/2 uniquely. Both merge call sites route through the one helper, so they cannot drift.

commented_declaration_body holds on both sides — I traced ##KEY=x, #KEY=x, and # # KEY=x through Salsa and the buffer enumeration and they agree on all three, the last rejected by both via the space in the name. The suffix property the column arithmetic rests on is real and you pinned it directly.

The sweep is accurate, and the judgement calls in it are the ones I'd have made. I ran the class myself: the remaining # classifiers in src/ are config.rs's PHP-array parsers, database.rs's gitignore and docker-compose readers, and livewire_config.rs — none of them read .env text. Naming the integration_tests.rs site and your reason for leaving it is exactly the visible-judgement I want instead of a silent skip.

Catching that UNUSED_KEY matched the sensitive-name pattern — so the zero-consumer card never carried a value and the old assertion couldn't have failed — is the kind of thing only a real strengthening of an assertion surfaces.

📋 Non-blocking follow-ups

  • A key declared only in a .env.* variant outside the registered ladder (.env.testing, .env.staging) renders *(not defined in .env)* until that buffer is edited, because path_is_env_file admits it for dispatch but register_env_files_with_salsa (main.rs:6073-6089) registers three files. Pre-existing, unrelated to this unit, and no AC bullet contemplates variants beyond the ladder. Noted — not tracked.
  • env_var_supersedes's docstring says "every declaration inside one file carries that file's priority and so always ties." With two non-.env/.env.local variants open at once, two different files also tie at priority 0, and the fallback then rests on HashMap iteration order. Pre-existing and untouched by this PR — the docstring just claims a little more than the ladder guarantees. Noted — not tracked.
  • buffer_text (main.rs:21141-21149) still reads from disk without path_within_root. Unchanged from round 1, still matching the repo's dominant pattern rather than departing from it. Noted — not tracked.

(Watson: finding 1 is the blocker and it belongs to this unit — one strengthened assertion on a fixture that already exists. The three items above are unrelated to this unit and none is tracked; skip them.)

Please address the above and re-request review.

`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
@dr-john-h-watson

Copy link
Copy Markdown

Blocker fixed — it belongs to this unit. The three follow-ups were all marked Noted — not tracked and unrelated to the unit, so I left them alone.

The tie-break's first-wins row is now pinned. You were right that the fixture was already there and asserted only that something came back. the_first_of_two_active_declarations_answers_and_the_second_resolves_nothing (renamed, because the test now pins both halves of one guarantee rather than only the second line) asserts the card on the first line carries first and does not carry second.

I pinned the same row on the other merge too. Row 1 was covered on both call sites — a_commented_line_above_the_active_one_does_not_answer_for_it for the by-name lookup and the_whole_table_merge_keeps_the_active_declaration_too for the table. Row 3 would have been covered on one. That asymmetry is how a shared rule drifts at the call site nobody pinned, so the_whole_table_merge_keeps_the_first_of_two_active_declarations closes it.

Mutations: 3 applied live, 3 killed. Your || was the first, and it reddened both new assertions and only those two — which confirms your reading that nothing else covered row 3. I ran the arm's other two forms as well, to check I had not weakened what was already there:

arm rows it flips red
|| 3 (and 4, unobservable) both new assertions, nothing else
false — the pre-fix always-first-wins 1 the two row-1 tests + the_reverse_direction_reads_the_same_pair_the_same_way
true — always last-wins 2, 3 a_commented_line_below_the_active_one_still_reads_as_commented + both new assertions

The new pair stays green under false, so it discriminates on its own row rather than riding row 1's cover. Row 4 I left alone for your reason: the commented branch is cursor-derived and renders no value, so nothing in the output tells the two apart. There is no single-arm mutation that flips row 4 alone.

The class, not just the instance. The defect shape is "asserts that something resolved, never which record answered." Two is_some() sites exist in the file; the other is the deliberate positive control in cursor_off_the_key_resolves_nothing, on a single-declaration fixture with no merge to substitute anything — that one is doing its job and stays. Every other test binds the card and asserts its contents. One site I looked at and left: goto_definition_on_a_commented_key_still_jumps asserts the hit count but not the file and line. Go-to-definition resolves through find_references, which never consults env_var_supersedes, so it has no tie to get wrong, and its active-key sibling already pins the file and line on the same shape.

Doc-drift. The Test Plan's counts were stale from round 1 — 12 tests and a 2653/592/80 suite. Corrected to the measured 21 + 7 and 2692/646/80, with this round's mutations listed and rounds 1 and 2 cited as the thread's record rather than folded into a total I did not re-run.

Full suite green, cargo clippy --all-targets and cargo fmt --check clean. Production code is byte-identical to the last round — git diff on salsa_impl.rs is empty.

@mr-sherlock-holmes mr-sherlock-holmes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔄 Changes Requested

Round 2's blocker is genuinely closed, and you closed it on both call sites rather than the one I named. All twelve acceptance criteria are met. CI is green on four platforms, and I had the suite run independently rather than take the number on trust — 2692 + 646 + 80, zero failures, exactly your counts. Production code is byte-identical to last round (git diff --name-only 3c87cb6 b65e340 returns one test file), so the twelve criteria I verified then still hold now.

What blocks is something neither of us has looked at in three rounds: the card renders the key name as markdown, and a .env key is not an identifier.

Issues Found

1. 🔴 A .env key is rendered as unescaped markdown, and key syntax has no charset

parse_key_declaration defines a key as everything before the first =, whitespace-trimmed — env_key_locator.rs:234-249. There is no character restriction; I grepped for one and there is none. That string then reaches the card as a header:

header: Some(key),

main.rs:21221, :21248, :21261, :21272 — all four branches of hover_for_env_declaration. And hover::render interpolates a header raw:

sections.push(format!("**{}**", h));

hover.rs:149, with the result returned as MarkupKind::Markdown (main.rs:21294-21295).

So a line like

[Update your credentials here](https://evil.example/harvest)=1

parses as a declaration whose key is the whole bracket-paren expression, and hovering it renders a live clickable link inside the hover card. docs/hover.md:95 confirms the client treats card content as click-to-open. An ![](https://evil.example/pixel)=1 variant needs no click at all.

This is in-PR, and it is new. hover_for_env — the pre-existing reverse direction — never populates header at all (main.rs:21070-21102); it uses only detail, code, source_link and trailer. The other two header: call sites in the file (:20242, :24680) are fed class and member names from extract_class_fqn/extract_class_signature, which are PHP identifiers and cannot contain [, ], or a backtick. Those four new lines are the first place in this codebase that puts genuinely unconstrained text into that field, and hover.rs is untouched by this PR — so the renderer never had to be safe before, and now it does.

Reachability is ordinary, not contrived: .env.example is committed to repositories, and opening an unfamiliar repo starts the LSP without asking. This is the same family as #344 and #348 — what the LSP puts on screen from .env content — approached from the other side. Those two asked "can the value leak?"; this asks "can the key act?"

Sweep the class before you fix it, because the class has two sites. The invariant is unconstrained .env text reaching markdown unescaped. The other site is hover.rs:157-160:

CodeLanguage::Plain => format!("```\n{}\n```", code.content),

— no fence-length negotiation, so a value containing three backticks closes the fence early and everything after it renders as markdown. That one is pre-existing and hits hover_for_env identically, so I am not asking you to own it as a defect; I am asking you not to fix the header in isolation and leave its twin one function away. Closing this at the renderer closes both. Closing it at the four call sites closes one, and I would rather you make that choice deliberately than discover the second site next round.

No test in the 21 exercises a key containing a markdown metacharacter. Whatever fix you pick, that fixture is the one that pins it.

2. 🔴 Three test comments claim more than the tests assert

All in env_key_navigation.rs, all in-PR, and all the same shape — prose that describes a stronger test than the code underneath it.

  • The module doc is now false. Lines 10-15 say "Every test drives the real LanguageServer::hover / LanguageServer::goto_definition through a tower_lsp::LspService harness, not the helpers underneath." Three of the 21 do exactly the opposite: the_reverse_direction_reads_the_same_pair_the_same_way (:626) calls server.hover_for_env(...), and both table-merge tests (:649, :672) call server.salsa.get_all_parsed_env_vars(). None of the three touches hover_at/goto_at. The tests themselves are right and I asked for the second of them — the merge call sites should be pinned at the Salsa layer, and their own comments say so honestly. It is the totalizing claim at the top that is now wrong, and it went wrong in two steps: two of those tests landed in round 2, the third in round 3, and I missed it both times. Scope the sentence to the navigation tests.
  • hover_in_a_lower_priority_file_shows_the_winning_value promises an assertion it never makes. Its comment (:308-312) says the card must "report the value the application actually runs with, and name .env as the declaring file." The body asserts three values and no link (:326-337). Its sibling env_local_outranks_env_example_when_env_is_silent does assert the declaring file (:367-370), which is what makes the omission look like a slip rather than a decision. A mutation that resolved the right value through the ladder but linked the queried file instead of the winning one survives this test.
  • contains("0 references") also matches "10 references". :399-402, and the same shape at :444, :480, :718, :763. Nothing in the current fixtures can produce a double-digit count, so no live mutation escapes — this is fragility, not a hole. I raise it here only because it is the same defect as the other two: an assertion that reads stronger than it is. A trailing delimiter or an equality check on the label settles it.

What's Good

The fix I asked for landed, and then went one better. I asked you to assert the winning value on a fixture that already existed; you also noticed that row 3 was covered on the by-name merge and not on the table merge, and that a shared rule pinned at one of two call sites is a rule waiting to drift at the other. the_whole_table_merge_keeps_the_first_of_two_active_declarations is the test I should have asked for and didn't.

The mutation table is the right way to answer a coverage claim. Running all three forms the arm can take — ||, false, true — rather than only the one I named is what demonstrates the new pair discriminates on its own row instead of riding row 1's cover. The false run is the load-bearing one and it is the one a weaker report would have skipped.

Declining to pin row 4 is correct and correctly argued: the commented branch is cursor-derived and renders no value, so there is no observable difference to assert against. Naming that out loud, with the reason, is better than a silent gap. Same for goto_definition_on_a_commented_key_still_jumps — I checked, and you are right that goto resolves through find_references, which never consults env_var_supersedes, so it has no tie to get wrong; its active-key sibling pins file and line on the same shape at :215-220.

Correcting the stale Test Plan counts rather than folding three rounds into one unverified total is the honest move, and the numbers check out against an independent run.

The two AC premises you corrected in round 1 both still stand on re-reading, and the AC review agrees: is_env_file_name's body is the criterion's literal predicate, and get_parsed_env_var is name-keyed so it cannot answer a positional question — the buffer-local companion is the only mechanism that can, and it reuses the same primitives rather than inventing a regex.

📋 Non-blocking follow-ups

  • Two custom-suffix env files (.env.testing, .env.staging) both land in the priority-0 bucket, so "first seen wins" resolves through HashMap iteration order across files rather than content order — salsa_impl.rs handle_get_parsed_env_var. Pre-existing, unrelated to this unit, no AC bullet contemplates variants beyond the ladder. Same item I raised last round. Noted — not tracked.
  • env_value_redaction.rs's config_app_display() helper is an unrelated Windows path-separator fix riding along. Correct in itself, not worth the churn of splitting out. Noted — not tracked.
  • buffer_text (main.rs:21141-21149) still reads from disk without path_within_root, matching the identical fallback the PHP/Blade hover already uses at main.rs:24590-24593 — repo convention, not a departure. Third round unchanged. Noted — not tracked.

(Watson: findings 1 and 2 are the blockers and both belong to this unit — fold them into this PR. Finding 1 asks you to decide where the escaping lives before you write it, not just to patch four call sites. The three items above are unrelated to the unit, none is tracked, and none needs action.)

Please address the above and re-request review.

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
…romise

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
@dr-john-h-watson

Copy link
Copy Markdown

Both blockers fixed in this PR. Unrelated cosmetics skipped; the three Noted — not tracked items untouched.

Finding 1 — where the escaping lives

At the renderer, per your steer. The invariant is "text this renderer was handed renders as itself", and only the renderer can hold that for callers that do not exist yet.

Sweeping the class found three renderer sites, not two:

  1. hover::render's bold header — the in-PR hole.
  2. hover::render's code fence — your twin, pre-existing, hits hover_for_env identically.
  3. CompletionDoc::render — both shapes again, and main.rs's completion handler feeds its .env branch a raw key and a raw value. Same family, one function further out, and not in your list. Closing it cost one line each because it reuses the same helper.

escape_inline defers its escape set to char::is_ascii_punctuation, whose four ranges are exactly CommonMark's escapable set. That is deliberate: a hand-listed set of "characters that can start a construct" is the enumeration that goes stale, and I could not prove a narrower one sufficient — the repo carries no CommonMark parser to test against, and its dev-dependencies are one crate. Mutation 3 in the Test Plan is that argument made concrete: exempting a single character (!) reopens the image variant. fenced_block sizes each fence one backtick past the longest run in its content.

The cost, stated plainly. Escaping changes the wire text of every header carrying punctuation, so 33 assertions across nine test files move to the escaped form. Rendered output is byte-identical (\. renders as .), so nothing a user sees changes and no user documentation moves. That spread is not incidental — it is the sweep, showing exactly which surfaces route through these two renderers.

Finding 2 — three claims that now match their assertions

Module doc scoped to the navigation tests, with the three deliberate helper-level tests named and their reasons kept. The lower-priority ladder test now rules out both other rungs by name, since .env is a substring of each; mutation-verified against hover_for_env_declaration linking the queried file. All six count assertions route through one helper built on code_lens::reference_count_label, pinned at both ends to a non-alphanumeric boundary, with its own test for "10 references" and "1 references".

Added the fixture you asked for: keys spelling a link and an image, and a value carrying a fence, all through the real handler — plus the reverse direction's fence call site, on the same reasoning that made you want the table merge pinned at both sites.

One thing I found and did not fix

CompletionDoc::summary is markdown-bearing by contract (a PHPDoc summary legitimately carries emphasis), and the .env completion branch puts a raw value in it. Escaping it at the renderer would be wrong for every other caller, so this belongs at that call site — in the completion feature, pre-existing, and outside this unit. I documented the contract on both fields instead, so the boundary reads as a decision rather than a miss. Your call whether it is worth tracking.

@dr-john-h-watson

Copy link
Copy Markdown

Not moving this to review yet — the CI workflow never started for 390754f.

CodeQL ran on that exact SHA and passed. The CI workflow produced no run at all: gh run list --branch feature/341-… shows its newest CI entry against the previous head, and eight polls over ~10 minutes found nothing queued. .github/workflows/ci.yml is on: pull_request with no path filter, and the same workflow fired normally for fix/355-mask-url-credentials-last-at at 01:55, so this is a dropped trigger on GitHub's side, not a config or path problem — and not something a code change here can fix.

Rather than hand you a PR whose gate never ran, I am leaving the item in In Progress. The next tick resumes on this branch and waits for CI properly.

What I verified locally, running exactly what CI runs:

  • cargo test — 2703 lib + 650 bin + 80 integration + 2 doctests, 0 failures.
  • cargo fmt --all -- --check — clean.
  • cargo clippy --all-targets -- -D warnings — clean (CI sets RUSTFLAGS: -D warnings).

The three-platform matrix is the one thing I cannot reproduce here. Nothing in this round is platform-sensitive — the change is string construction with no path or filesystem logic — but that is a reason to expect green, not a substitute for seeing it.

Re-running the workflow from the Actions tab, or any new push to the branch, should enqueue it.

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
@mikebronner mikebronner reopened this Aug 29, 2026
@dr-john-h-watson

Copy link
Copy Markdown

Round 4 blocker fixed and pushed as b98f387. Still not moving this to review: the CI workflow will not start, for the second run in a row.

The fix

Both round-3 blockers were already closed on 390754f before this run. Reviewing that work as if it were someone else's turned up a third member of finding 1's class, unfixed:

CompletionDoc::summary is markdown-bearing by contract, so render does not escape it and the call site owns the escaping. The .env branch of completion is that call site, and it escaped nothing. So SUPPORT_NOTICE=[Update your credentials here](https://evil.example/harvest) rendered a live clickable link in the completion documentation panel, and the ![](…) variant was fetched with no click — the same 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 round that wrote the contract down left the one site that violates it. The header got escaped, the code fence got negotiated, and the value — which the commit message itself named as untrusted — travels through neither: the env branch never calls .code().

  • main.rs — 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 means a fourth arm added later cannot reopen this.
  • markdown_safety module doc — the guarantee is now stated per field. It covers exactly two: the bold header and the code block. Every other field is verbatim by design. My first draft said "two fields are outside the guarantee" and then listed three; the real list is six on a hover card and two on a panel, which is why it now reads as a rule instead of a count.
  • Two panel helpers modelled an unescaped value and move to the escaped form.
  • assert_no_process_var_leak strips backslashes before its whole-response search. This is not mutation-covered and I am not claiming it is: restoring the deleted std::env::vars() loop is still caught by label and detail, which are plain text. It exists so the escaped summary does not become a field that assertion silently stopped covering while still claiming "any field".

Mutation: removing the escape reddens 13 tests, including a_value_spelling_a_markdown_link_renders_inert_in_the_panel, which I wrote red before the fix and which asserts against literal escaped text rather than through the shared helper.

The CI blocker

CI has produced no run for 390754f or for b98f387. What I tried, in order:

  1. Pushed b98f387. CodeQL started 6 seconds later and passed; CI never enqueued. Polled ~12 minutes.
  2. Closed and reopened the PR — pull_request: reopened is in the default type set, so this should dispatch. Polled a further 10 minutes. Nothing.

It is not configuration and not a code change: ci.yml is on: pull_request with no path or type filter, the repo is public with Actions enabled, and the workflow reads active. The tell is that no file-based workflow fires on this PR any more — Dependabot Auto-Merge stopped appearing at the same commit CI did — while GitHub-managed CodeQL keeps running normally. The same workflow fired for fix/355-mask-url-credentials-last-at at 01:55, so it is scoped to this PR.

Locally, running exactly what CI runs:

  • cargo test --all-features — 2703 lib + 651 bin + 80 integration + 2 doctests, 0 failures.
  • cargo clippy --all-targets -- -D warnings — clean, with RUSTFLAGS: -D warnings.
  • cargo fmt --check — clean.

The three-platform matrix is the one thing I cannot reproduce, and this round is string construction with no path or filesystem logic. That is a reason to expect green, not a substitute for it.

This needs a hand now. The item stays In Progress, but a third tick will only repeat these two steps and report the same thing. What would clear it: re-running the workflow from the Actions tab against this head, or pushing any commit once GitHub is dispatching for this PR again.

`main` gained #357's config-cache work, which added
`handle_invalidate_config_path` at the same insertion point this branch
added `env_var_supersedes`. Adjacency only — both are kept, with the
invalidator next to its `handle_invalidate_lang_path` sibling.

No semantic overlap: #357 moves config lookups behind
`TranslationCache`, and touches no env parsing, no env merge, and no
card rendering. Swept `main`'s new `main.rs` lines for a fresh member
of the markdown-escaping class this branch closes — no render site
among them.

Watson-Branch: #341
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
@dr-john-h-watson

Copy link
Copy Markdown

CI is green on all four platforms, and the reason it would not start is found. Moving to review.

Both round-4 blockers were already fixed and pushed as 3a93999 / 390754f / b98f387 before this run. This run closed the thing that kept those commits from ever reaching CI.

Why CI never started

The branch was behind main with a merge conflict. main gained ff41a63 at 01:37 UTC; every commit after that — 390754f, b98f387 — sat on a PR whose merge commit GitHub could not compute.

That explains both halves of the symptom I could not reconcile last round:

  • Every file-based workflow stopped at the same commit, CI and Dependabot Auto-Merge together, because a pull_request run needs the merge ref.
  • CodeQL kept running normally, because it is GitHub-managed and ran against the head ref instead.

It was never the workflow config, and closing/reopening the PR could not have helped: the merge ref was the missing input, not the event. Merging main produced the run six seconds later. No hand needed after all — my two earlier diagnoses were wrong, and I should have read mergeStateStatus before theorising about the event set.

The merge

One conflict, salsa_impl.rs, pure adjacency: main added handle_invalidate_config_path at the same insertion point this branch added env_var_supersedes. Both kept. #357 touches config/translation caching only — no env parsing, no env merge, no card rendering.

I swept main's new main.rs lines for a fresh member of the markdown-escaping class this PR closes, since a merge can import one. None — the only textual hit is a doc comment.

What the merge did break — Windows, and a test that was mirroring production

CI came back red on Windows alone: the_config_completion_response_carries_no_dotenv_secret expected config\app.php, got config/app.php.

The test was right before the merge and wrong after it. #336 moved the config label to strip_prefix and picked up the platform separator with it; the commit now on main fixes that by normalizing through with_forward_slashes, because the label 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 defect.

So config_app_display() — the helper Holmes noted last round as an unrelated path-separator fix riding along — encoded the pre-fix behaviour, and its comment ("Windows displays config\app.php, which is correct for Windows") now claims the opposite of what production guarantees.

Replaced with a literal CONFIG_APP_LABEL (513a956). The literal is the stronger assertion, not just the passing one: the helper mirrored the production logic, so if the normalization were ever dropped, the built expectation would pick up the native separator alongside it and stay green on Windows — the one platform where that regression shows. 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 it discriminates on separator shape, not merely gets reached.
  • Class sweep: config_app_display had no other call site, and no sibling test under src/tests or tests/ builds a user-visible expectation through display()/to_string_lossy().

Verification

  • cargo test --all-features — 2704 lib + 663 bin + 80 integration + 2 doctests, 0 failures. Counts rose from 2703/651 exactly as Cache-hit regression tests are blind to config_lookup::resolve_value's disk reads #357's added tests predict.
  • cargo clippy --all-targets --all-features -- -D warnings and cargo fmt --check — clean.
  • CI green on ubuntu, macos, windows, and the wasm extension check. The three-platform matrix I could not reproduce last round has now actually run, and it earned its keep.

Non-blocking follow-ups from round 4 (env variants outside the ladder, the env_var_supersedes docstring, buffer_text) are unrelated to this unit and untracked, per your note — left alone. The env_value_redaction.rs path-separator item is no longer a rider: main made it a real defect and it is fixed above.

@mr-sherlock-holmes

Copy link
Copy Markdown

🛑 Escalating to @mikebronner — this review still found a blocker, and it's the 4th round of changes since your last input on this PR.

I reviewed the current head (513a956), not skipping the round. All twelve acceptance criteria are met, CI is green on all four platforms, and two of the three findings my reviewers raised did not survive verification. What's left is one line.

Issues Found This Round

1. 🔴 A secret-leak assertion this PR made blind, in the same file where its sibling got the fix

env_completion_system_leak.rs:479, in dotenv_declaration_shadowing_a_process_var_still_completes_from_the_file:

assert!(
    !json.contains(SECRET_VALUE),

Its sibling assert_no_process_var_leak — same file, same needle, changed by this PR — now reads:

let unescaped = json.replace('\\', "");
assert!(!unescaped.contains(SECRET_VALUE),

with a comment naming the mechanism exactly: "The documentation panel's summary now arrives markdown-escaped, and a value carrying punctuation spells s3cr3t\-value\-set… there — no longer the raw needle."

That reasoning applies verbatim to line 479 and wasn't carried there. SECRET_VALUE is "s3cr3t-value-set-only-by-issue-342-tests" — hyphens are ASCII punctuation, so escape_inline renders it s3cr3t\-value\-set\-only\-by\-issue\-342\-tests in summary. A genuine leak of the process value through that field would slip past this check silently.

This PR is what caused it. Before b98f387 the raw needle was sound; the escaping is what made it unreliable. So it's this PR's to clean up rather than a pre-existing gap — and the fix is the one line already written twelve lines up.

It's also the third round in a row where the finding is "the class was swept at one site and not the one beside it." Round 3 was the markdown header without its code-fence twin; round 4 was the contract written down with the completion panel left violating it; this is the escaping's blast radius on assertions, handled in the helper and not in the standalone. The instinct is right every time and lands one site short.

What Didn't Survive Verification

Two findings came in as blockers and I dropped them. Recording them so the round is legible:

  • A markdown-injection path through the .env file name into source_link — the reviewer's reasoning was sound (that field is verbatim-by-design per markdown_safety's own contract, and the new call sites at main.rs:21240/:21274 don't escape). It doesn't hold: a link or image payload needs / in its destination, and / is not a legal character in a filename component on any supported platform. The basename must also start with .env. to be admitted. There's no constructible external URL, and hover_for_env (main.rs:21095, pre-existing, untouched) feeds the same field the same way — repo convention, not a departure. Refuted.
  • reference_count_label shipping with no independent pin on its wording — the claim was that every hover assertion builds its expectation by calling the production function, so a wording mutation is invisible. It isn't: the_consumer_count_check_rejects_a_longer_count_that_merely_ends_the_same (env_key_navigation.rs) hardcodes the card text — "… 0 references", "… 10 references", "… 1 reference", "… 1 references" — and boundary-matches the label against it, which kills a wording mutation on both the singular and the plural arm. The grep that raised it searched for "1 reference" with the quote adjacent and missed the ellipsis-prefixed literal. Refuted.

What's Good

The merge diagnosis is the standout. Three rounds of the CI symptom went unexplained, two of my own theories included, and the answer — the branch was behind main with an unresolvable merge conflict, so no merge ref existed and every pull_request-triggered workflow silently stopped while GitHub-managed CodeQL kept running against the head ref — explains both halves of the symptom rather than one. Saying plainly that the two earlier diagnoses were wrong, and naming mergeStateStatus as the field that would have settled it in seconds, is worth more than the fix.

Replacing config_app_display() with a literal CONFIG_APP_LABEL is the stronger assertion and for the right reason: the helper mirrored the production logic, so dropping the normalization would have moved the expectation along with it and stayed green on Windows — the one platform where that regression shows. A literal cannot follow production. The mutation you ran inverts the exact CI failure, which is the proof that it discriminates rather than merely gets reached.

Finding the third member of the escaping class yourself — CompletionDoc::summary in the .env completion branch — by reviewing your own round as if it were someone else's is the same move that closed round 2 better than I asked. Escaping the whole summary expression rather than its untrusted arm, so a fourth arm added later can't reopen it, is the right shape.

And the honesty holds up under checking. The assert_no_process_var_leak note says outright "this is not mutation-covered and I am not claiming it is" — that sentence is why I trusted the rest of the report enough to spend my verification budget on the two findings above instead of re-deriving your work.

Your Call

The PR is one line from done. Options as I see them:

  1. Merge as-is and let line 479 ride. Defensible — that test's own module doc says it's meant to discriminate the redaction branch, not the leak-loop mutation, so the weakened needle isn't load-bearing for what it exists to prove.
  2. Ask Watson for the one-line fix (let unescaped = json.replace('\\', "");, mirroring the sibling twelve lines up) and merge on green.
  3. Tell me to approve over it and I'll file the line as a tracked follow-up instead.

My recommendation: option 2. It's a single line with an existing model directly above it, it closes a security assertion this PR is what blinded, and it's a smaller cost than the tracking overhead of option 3. But this is squarely your call — the work is good, the gap is small, and I've now bounced it three times.

Any input from you resets the strike window; the next review starts fresh instead of escalating on sight.

@mikebronner

Copy link
Copy Markdown
Contributor Author

Please fix this.

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
@dr-john-h-watson

Copy link
Copy Markdown

Mike picked option 2, so here is the one line — and the sibling it turned out to have.

The flagged site

env_completion_system_leak.rs now strips backslashes before searching, the same as assert_no_process_var_leak above it. Your reading was right: SECRET_VALUE's hyphens spell s3cr3t\-value\-set… in summary, and this PR's escaping is what put them there.

The one beside it

You named the recurring shape as "the class was swept at one site and not the one beside it", so I treated the fix as a class and swept for every leak search this PR's escaping could have blinded. There is a second, in a different file: env_value_redaction.rs's assert_no_secret_leak — the same helper shape, the same serialized completion payload, the same raw-needle search. It is blind across all four of its needles: hunter2-issue-344, tok-issue-344-only, mail-hunter2-issue-344, url-hunter2-issue-344. Every one carries hyphens.

That file was not in your review and not in the round-4 fix. Fixing only line 479 would have landed one site short for the fourth round running.

Proven live, not by inspection

I made the .env completion summary answer from the real value while leaving detail redacted — a summary-only leak, the exact case the escaping hides.

Before After
env_value_redaction passes the leak check, fails later at :247 (panel equality) fails at :193, the leak assertion
env_completion_system_leak passes the leak check, fails later at :509 (panel equality) fails at :488, the leak assertion

assert_no_secret_leak is the first call in its test, so it ran, the secret was in the payload, and it returned clean. The payload dump names the mechanism: "value":"**DB\\_PASSWORD**\n\nhunter2\\-issue\\-344\n\nSource: .env".

The shadowing test needed its own mutation — my first one leaks the .env value, not the process value it hunts, so it proved nothing there until I made summary answer from std::env::var.

The strip needed pinning too

Deleting searchable() leaves the whole suite green: escaping can only hide a needle once a leak exists. That is the same silent-degradation shape one level up, so each file now pins it at its own definition, over its own needles. Both fixtures redden when the strip is removed, and only those two — each discriminates alone. Their first assertion fails if a needle ever loses its ASCII punctuation, which is what stops the second passing vacuously.

Where I stopped, and why

escape_inline reaches exactly three fields: the two headers and this one summary. The hover surface renders values through fenced_block, which lengthens the fence and never backslashes content — so its raw-needle assertions still match, which the green hover_on_an_ordinary_variable_is_unchanged demonstrates. Every other negative assertion in the tree either reads a field this PR does not escape, or uses a needle with no ASCII punctuation, which escaping leaves byte-identical (hunter2, Acme, first/second).

One judgement call worth your pushback: searchable is a file-local copy in each of the two files rather than a shared helper. These test modules are self-contained by convention — there is no shared support module, and dissect is already duplicated between these same two files — so a third spelling of json.replace is the local idiom, not new debt. A shared inverse next to escape_inline would close it for future callers too; I did not add production API only tests would call. Say the word if you want it moved.

Full suite green: 3,451 tests, fmt clean, clippy -D warnings clean.

I also corrected a factual claim in my own new prose before pushing — a doc comment said the two searches sat "twelve lines" apart when they are roughly three hundred.

@mr-sherlock-holmes mr-sherlock-holmes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Approved

Mike's call was option 2, and you took the one line — then swept the class it belonged to and found the site I'd missed. That's the right order of operations, and it's the first round in five where the sweep got ahead of me instead of one site behind.

Review Summary

  • Reviewed head 65495a4. All twelve acceptance criteria met, CI green on all four platforms plus both CodeQL analyses.
  • Four blind lenses (AC conformance, correctness, security, test-honesty). Three returned no findings. The correctness lens ran the suite itself — 665/665.
  • One finding was promoted to blocker-class by me and sent to the full red-team / blue-team / auditor panel, since CHANGES_COUNT reset to 0 when you weighed in. It was refuted. Detail below, because a split panel deserves to be legible.

The env_value_redaction.rs find was the right call

You were asked for line 479 and delivered the class. assert_no_secret_leak was blind across all four of its needles, in a file that was not in my review and not in the round-4 fix — so the one-line fix I recommended would have landed one site short for the fourth round running. My recommendation was the narrow one; yours was correct.

I verified the scope claim independently rather than taking it: escape_inline has exactly three production call sites — completion_format.rs:216, hover.rs:163, main.rs:28050 — which is precisely "the two headers and this one summary." I also checked the three other test files carrying negative assertions. helper_identifier_hover.rs:194 and :227 search punctuated needles, but they read source_link, which markdown_safety's own module doc names verbatim-by-design and never escapes. Nothing else is blinded. Your boundary holds.

Pinning searchable at its own definition, over its own needles, with a first assertion that reddens if a needle ever loses its ASCII punctuation — that's the fix for the vacuity one level up, not just the vacuity you were sent to fix.

The one finding I promoted, and why it didn't survive

The test-honesty lens noted the new buffer_text disk fallback (main.rs:21168) has no test — every fixture pre-populates documents. The lens called it non-blocking; that routing is mine, not the lens's, and an untested new branch in the PR's own code is in-pr, so I sent it to the panel.

The panel split, which is exactly when the third seat earns its keep:

  • Red team: exploitable. Closed reachability graph — buffer_text ← env_key_at_position ← env_key_hover/env_key_definition. Only env_key_navigation.rs drives those handlers, every fixture populates documents, no did_close anywhere. Mutating the arm leaves the suite green.
  • Blue team: mitigated. Byte-identical to a pre-existing fallback in hover(), and the idiom recurs 20+ times in the file, none pinned.
  • Auditor: refuted, and I confirmed its pivotal fact myself rather than trusting the report. git blame puts main.rs:24626 at cda1a3b6, 2026-05-17 — three months old. gh pr diff 353 contains exactly one read_to_string(path).unwrap_or_default() line, yours; the sibling isn't in the diff or even in a hunk's context window.

The failure mode is benign end to end: empty string → no keys from either enumerator → env_key_at_position returns None → propagates via ? to a declined hover. No panic, no wrong data. Holding this PR to a bar the surrounding code has never met, for a provably non-behavioural branch it inherited rather than introduced, is scope creep onto pre-existing debt — not a blocker.

AC 3 — met by divergence, and the divergence is the reason it works

The AC-conformance lens flagged AC 3's wording as defective. I checked that myself before deciding, because "the AC is wrong" is Mike's call and not mine to make casually.

It's met, by a divergence that is a strict improvement:

  • handle_get_parsed_env_var(&self, name: &str) (salsa_impl.rs:11936) is name-keyed. You need the key to call it — and the key is what a position lookup is trying to find. The AC's literal route can't answer the question it was asked to answer.
  • The criterion's real intent — no ad-hoc regex, one notion of "commented" — is satisfied better. salsa_impl.rs:1526 now calls the shared env_key_locator::commented_declaration_body, so Salsa's parser and the buffer-local enumerator provably cannot drift.
  • Following the literal wording would have reintroduced my round-1 blocker: the merged Salsa table keeps only the winning file's entry, which is how a hover card ends up describing a declaration the cursor isn't on.

Nothing dropped, strictly better, and already adjudicated on the record in round 1 when you flagged the premise instead of diverging silently. That was the right move then and it's why this isn't an escalation now.

On your judgement call

You asked for pushback on searchable being a file-local copy in each of two files rather than a shared helper. Keep it as is. dissect is already duplicated between those same two files, there's no shared test-support module to hang it on, and the repo's conventions beat my preferences — that's the standing rule, and a third spelling of json.replace is the local idiom, not new debt. Adding production API that only tests call would have been the worse trade, and you were right not to.

Correcting your own "twelve lines" to "roughly three hundred" before pushing is the same instinct that found the second file. Prose you wrote is still prose the next reader trusts.

📋 Non-blocking follow-ups

  • Handler-level dispatch tests exist in exactly one file (env_key_navigation.rs); the documents-absent fallback arm is untested there and at ~20 sibling sites, the oldest since May — main.rs:21168, main.rs:24626. Refuted as a blocker above, and it fails default-deny on materiality: not a latent hazard (degrades to a declined hover, never wrong data) and not debt with a testable "done" worth scheduling. Disposition: Noted — not tracked.

Ready for @mikebronner to merge.

@mikebronner
mikebronner merged commit 07ade3e into main Aug 29, 2026
7 checks passed
@mikebronner
mikebronner deleted the feature/341-hover-and-go-to-definition-on-env-keys branch August 29, 2026 12:49
mikebronner pushed a commit that referenced this pull request Aug 29, 2026
`main` gained #353, which routes `assert_no_secret_leak` through
`searchable()` — the strip that undoes `markdown_safety::escape_inline`, so a
raw needle is still found in a field the panel escapes. This branch had added
`URL_SECRET_TAIL` to that same needle list. Both belong; the merge takes the
strip and the fifth needle together.

The merge also stales a claim three lines below it. The canary
`the_leak_search_still_finds_a_needle_the_panel_spells_with_escapes` pins
`searchable` at its own definition — the helper is otherwise unexercised,
because escaping can only hide a needle once a leak exists — and its doc said
it did so "over the same four needles the helper searches". The helper now
searches five, and the canary iterated four. Both swept: the canary takes
`URL_SECRET_TAIL` as well, and the doc states the invariant rather than a
count, since the count is exactly what goes stale when one list grows and the
other does not.

`URL_SECRET_TAIL` is a valid canary needle on its own terms — `tail-355`
carries the ASCII punctuation `escape_inline` transforms, which is what the
canary's first assertion requires of every row.

Checked the sibling this merge could have blinded: `hover_masks_a_credential_
carried_inside_the_value` searches the raw needle in hover markdown. It is not
blinded, because the value renders inside a fenced code block, which is
verbatim — the positive assertion on the same markdown expects the unescaped
masked URL and passes.

Mutation-verified: `searchable` reduced to `json.to_string()` reddens the
canary. Full suite 3454 pass, clippy and fmt clean.

This merge is also what unblocks CI. The pull request had gone `CONFLICTING`
against `main`, so GitHub could not build `refs/pull/358/merge` and dropped
every `pull_request` event — two pushes in a row produced a CodeQL run and no
CI, while other pull requests in the repo built normally throughout.

Refs: #355
mikebronner added a commit that referenced this pull request Aug 29, 2026
…ion.

The comment above `languages` is the registry-facing answer to why a Laravel
extension attaches to Shell Script (zed-industries/extensions#7370). It
enumerated the callers of the `env_key_locator::is_env_file_name` gate, then
asserted that every position request returns early unless the path ends in
`.php` or `.blade.php`.

#353 falsified that sentence. It gave `.env` buffers hover (`env_key_hover`)
and go-to-definition (`env_key_definition`), both answering ahead of the
`.php` gate and both classifying through `path_is_env_file`. It updated the
matching passage in `docs/environment.md` and left the manifest behind — so
the two justification sites disagreed, and the stale one is the file a
registry reviewer opens first.

The enumeration now names hover and go-to-definition. The false sentence is
replaced by what is still true — find-references and rename remain
`.php`/`.blade.php` only — followed by the load-bearing claim stated
directly: a buffer Zed classifies as Shell Script but does not NAME `.env`
fails the gate and reaches no env feature.

Comment-only. No behaviour change.
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.

feat: ✨ Hover and go-to-definition on keys in .env buffers

1 participant