Skip to content

fix(env): only stick to a candidate the active profile actually reaches (#682) - #715

Merged
jeff-r2026 merged 9 commits into
Tencent:mainfrom
STiFLeR7:fix/682-resolveActiveShellProfile-followup
Sep 23, 2026
Merged

jeff-r2026 merged 9 commits into
Tencent:mainfrom
STiFLeR7:fix/682-resolveActiveShellProfile-followup

Conversation

@STiFLeR7

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #693. That PR's squash merge (cc38871) landed pinned to commit 02c93fe on the branch, one commit short of 1ed0141 — the fix for a P1 the review bot found in 02c93fe itself. main currently carries the buggy version. Reported directly against #682 by @CarlosWonMore, from the same real-machine fixture used throughout that review.

The bug on current main: resolveActiveShellProfile() (added in #693 to stop Git for Windows' auto-generated .bash_profile from causing duplicate env blocks) scans every candidate file in a fixed order (.zshrc, .bashrc, .bash_profile, .bash_login, .profile) and returns the first one carrying a matching block. Since .bashrc sorts before .profile, a stale block a pre-#682 install left in .bashrc outranks a correctly order-picked, not-yet-written .profile — silently reintroducing #682 for exactly the installs upgrading through this fix. Worse than before #693: doctor no longer catches it, since the stale block is well-formed where it sits.

Fix: start from detectShellProfile's order-based pick (the file the current environment actually reads), and only diverge from it when that pick's own content references another candidate by a home-relative path (~/.bashrc, $HOME/.bashrc) — the shape a real Git-for-Windows-generated forwarding line takes. A block sitting in a candidate the pick never reaches is no longer preferred over the pick, no matter what it contains.

This is commit 1ed0141 from #693's branch, cherry-picked onto current main (clean, no conflicts — the affected files were untouched by anything merged after #693).

Fixes #682

Test Plan

  • npx tsc --noEmit passes
  • npx vitest run — same pre-existing 30-file/70-test Windows-host-only baseline as unmodified main (unrelated POSIX-path-literal test fixtures); no new failures; all tests for this change pass
  • Added/updated tests: resolveActiveShellProfile regression test for the exact upgrade case (stale .bashrc block outranking a correctly-read, not-yet-written .profile), plus a corrected test that had asserted the old, buggy broad-stickiness behavior

Real, end-to-end verification — built dist/index.js from this branch, reproduced @CarlosWonMore's exact reported state (scratch $HOME, real local git team repo, .bashrc carrying a well-formed but unread pre-#682 block, .profile present with nothing in it yet):

$ teamai pull --force
✔ Synced 1 env variable(s) to ...\.teamai/env.sh
  ✖ No stale env blocks left behind
    → ...\.bashrc still carries a teamai env block for this scope from an
      earlier install; run `teamai uninstall` to remove it, or delete the block manually.

$ teamai doctor
  ✔ Env variables injected in shell profile
  ✖ No stale env blocks left behind
    → ...\.bashrc still carries a teamai env block...

New block correctly lands in .profile (the file Git Bash's login shell actually reads); the stale .bashrc copy is surfaced as its own non-blocking cleanup item, not folded into delivery health.

Also re-verified the Git-for-Windows forwarding scenario from #693 still holds (no regression): a fresh install writing to .bashrc, followed by Git for Windows auto-generating ~/.bash_profile sourcing it — still sticks to .bashrc, no duplicate block.

…es (review)

02c93fe's resolveActiveShellProfile scanned every SHELL_PROFILE_CANDIDATE_NAMES
entry for a matching block and returned the first hit, in a fixed order
(.zshrc, .bashrc, .bash_profile, .bash_login, .profile). That's broader
than the Git-for-Windows-forwarding case it was written for: a stale
block a pre-Tencent#682 install left in .bashrc would outrank a correctly
order-picked .profile that hasn't been written to yet, since .bashrc
sorts earlier in the candidate list — silently reintroducing Tencent#682 for
exactly the installs upgrading through this fix, with doctor unable to
catch it because the stale block is well-formed where it sits.

Reworked to start from detectShellProfile's order-based pick (the file
the current environment actually reads) and only diverge from it when
that pick's own content references another candidate by a home-relative
path (~/.bashrc, $HOME/.bashrc) — the shape Git for Windows' generated
forwarding file actually takes. A block sitting in a candidate the pick
never reaches is no longer preferred over the pick, regardless of what
it contains.

Also caught and fixed a case of exactly the failure mode this PR is
about: the first cut of the forwarding check was a bare substring match
on the candidate's filename, and my own test's plain-English comment
("...unrelated to .bashrc") satisfied it. Tightened to require the
home-relative reference form a real sourcing line uses.

Verified both scenarios end-to-end on a real Windows host:
- The exact bot-reported upgrade case (stale .bashrc block, empty
  .profile, no forwarding between them): pull now writes into .profile
  and correctly flags .bashrc as stale; doctor reports delivery healthy.
- The Git-for-Windows forwarding case from the prior round: still
  sticks to .bashrc through the generated .bash_profile, no duplicate,
  no stale-block warning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/utils/shell-profile.ts:249 — Raw includes() does not prove the active profile sources the candidate. A comment such as # source ~/.bashrc, or a real reference to ~/.bashrc.local, matches ~/.bashrc; a stale block in .bashrc then wins and reproduces the exact detectShellProfile() falls back to ~/.bashrc on Windows, where the login shell never reads it #682 upgrade bug. Match an executable source/. command with token boundaries, not an arbitrary substring.
  • [P1 blocking] src/utils/shell-profile.ts:243 — Reachability is checked only one hop deep. With .bash_profile → .profile → .bashrc and the TeamAI block in .bashrc, the active shell does load the block, but this resolver returns .bash_profile and injects a duplicate. Follow candidate references transitively, with cycle protection, or explicitly preserve this previously working configuration.
  • [P2 non-blocking] src/resources/env.ts:417 — The public method documentation still claims it stays on whichever candidate already carries the block, which is no longer true. Update this affected documentation per the repository rules.

The PR description includes both a test plan and a detailed real-CLI end-to-end record, so no testing-description finding is needed.

…ly (review)

Two P1s from the bot's review of Tencent#715:

- resolveActiveShellProfile's reachability check was a bare substring
  search on the active pick's content. A comment mentioning a filename
  (never executed) or a longer file sharing the same prefix
  (~/.bashrc.local) would both satisfy it, letting a stale block win
  the same way Tencent#682 did. Replaced with referencesCandidate(): strips
  full-line comments, splits each remaining line into statements on
  &&/||/;, and only counts a statement whose first word is literally
  `.` or `source` and whose second word is an anchored home-relative
  reference to exactly that candidate.

- The check only followed one hop: .bash_profile sourcing .profile
  sourcing .bashrc (the common Debian .profile pattern, sourcing
  .bashrc for interactive shells) would miss a block two hops away and
  inject a duplicate. Reworked into a loop that walks the chain of
  files the pick actually sources, with a visited set for cycle
  protection, stopping at the first one that carries the block.

Also fixed the P2: EnvHandler.detectShellProfile's doc comment still
claimed it "stays on whichever candidate already carries this scope's
block" unconditionally, which stopped being true once reachability was
required.

Verified end-to-end on a real Windows host:
- The new two-hop chain (.bash_profile -> .profile -> .bashrc, block
  in .bashrc): resolves to .bashrc, no duplicate, doctor fully clean.
- Re-ran the Git-for-Windows one-hop scenario and the Tencent#682 upgrade
  scenario from the prior round — both still correct, no regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

Copy link
Copy Markdown
Contributor Author

Both P1s addressed in `75f3eac`, verified end-to-end.

Substring match (`shell-profile.ts:249`): agreed. Replaced with `referencesCandidate()`: drops full-line comments, splits each remaining line into statements on `&&`/`||`/`;`, and only counts a statement whose first word is literally `.` or `source` and whose second word is an anchored home-relative reference to exactly that candidate — so `# source /.bashrc` (a comment) and `/.bashrc.local` (a different, longer-named file) no longer match.

One-hop-only reachability (`shell-profile.ts:243`): also agreed, and worth calling out that the chain you named — `.bash_profile` sourcing `.profile` sourcing `.bashrc` — isn't hypothetical, it's the standard Debian/Ubuntu `.profile` template (sources `.bashrc` for interactive shells). Reworked into a loop that walks the chain of files the order-based pick actually sources, with a visited set for cycle protection, stopping at the first one carrying the block.

Doc comment (`env.ts:417`, P2): fixed — no longer claims unconditional stickiness.

Real-machine verification, three scenarios:

  1. The new two-hop chain you described, built exactly as the standard Debian template: `.bash_profile` → `. ~/.profile`, `.profile` → `[ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc"`, block in `.bashrc`:
    ```
    ✔ Synced 1 env variable(s) to ....teamai/env.sh
    ✔ Env variables injected in shell profile
    ✔ No stale env blocks left behind
    ```
    Resolves to `.bashrc`, no duplicate.

  2. Re-ran the Git-for-Windows one-hop scenario from fix(env): detect the right shell profile file on Windows (#682) #693 — unaffected, still resolves to `.bashrc` through the generated `.bash_profile`.

  3. Re-ran the original detectShellProfile() falls back to ~/.bashrc on Windows, where the login shell never reads it #682-upgrade scenario from fix(env): only stick to a candidate the active profile actually reaches (#682) #715's own description (stale `.bashrc` block, empty `.profile`) — unaffected, still correctly migrates to `.profile` and flags `.bashrc` as stale.

Added 4 new tests: comment-only mention, filename-prefix collision, the two-hop chain, and a reference-cycle case (asserting it falls back to the order-based pick rather than hanging). `npx tsc --noEmit` clean; full `vitest run` at the same pre-existing 30-file/66-test failure baseline as unmodified `main` (confirmed via `git stash` comparison on this exact run) — no new failures, 4 new passing tests.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/utils/shell-profile.ts:277find() follows only one referenced candidate, chosen by global candidate order. If .bash_profile sources both .bashrc and .profile, .bashrc has no block, and .profile has the active block, traversal stops after .bashrc and injects a duplicate into .bash_profile. Traverse every referenced candidate with the existing cycle protection.
  • [P1 blocking] src/utils/shell-profile.ts:225 — Splitting on &&/|| discards shell control flow and treats every resulting source as executed. For example, source ~/.profile || source ~/.bashrc marks .bashrc reachable even when .profile succeeds, allowing an unread stale .bashrc block to win. Only recognize unconditional sources or supported conditions whose execution can be established.

Earlier Findings

  • The comment/prefix matching issue, linear two-hop regression, and stale public documentation were addressed.
  • The PR description contains both a test plan and a real-CLI end-to-end record; no testing-description finding is needed.

…y (review)

Two more P1s from the bot's round-10 review of 75f3eac:

- The traversal committed to the first referenced candidate in
  SHELL_PROFILE_CANDIDATE_NAMES's fixed priority order and gave up if
  that branch was a dead end, instead of trying every candidate the
  current file actually references. Git for Windows' own generated
  .bash_profile sources both .bashrc and .profile in one file — if the
  real block sits in .profile but .bashrc (sorting earlier) has none,
  the walk stopped at .bashrc without ever trying .profile. Reworked
  into a breadth-first search over the whole reference graph.

- Splitting statements on `||` treated its right side as unconditionally
  reached, but `||`'s right side only runs if the left side fails,
  which isn't something this code can establish. `source ~/.profile ||
  source ~/.bashrc` would mark .bashrc reachable even when .profile
  succeeds. Statements no longer split on `||`; a `source`/`.` sitting
  only after it is folded into its left side's statement and never
  recognized as its own reference, so it's never preferred over a
  block the left side already reaches. Conservative by construction:
  worst case is falling back to the order-based pick (the pre-Tencent#693-fix
  behavior), never a false "reachable".

Verified the exact branching scenario end-to-end on a real Windows
host: .bash_profile with the literal Git-for-Windows-generated content
(sources both .bashrc and .profile), .bashrc empty, real block in
.profile — resolves to .profile, no duplicate, doctor fully clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

Copy link
Copy Markdown
Contributor Author

Both real, addressed in `aaa1142`.

Priority-order traversal (`shell-profile.ts:277`): agreed — `.find()` committed to whichever referenced candidate sorted first in `SHELL_PROFILE_CANDIDATE_NAMES`, not the one that actually led anywhere. Reworked into a breadth-first search over the whole reference graph (queue + visited set), so every candidate a file references gets tried, not just the first.

`||` treated as unconditional (`shell-profile.ts:225`): also agreed. Statements no longer split on `||` — a `source`/`.` sitting only on its right side is folded into the same statement as its left side and never recognized as its own reference, so it can never be preferred over a block the left side already reaches. This is deliberately conservative: worst case for something more exotic than `||` (nested conditionals, subshells) is falling back to the order-based pick and injecting a duplicate — the pre-#693 behavior, not a false "reachable."

Real-machine verification of the exact branching case: `.bash_profile` with the literal Git-for-Windows-generated content (`test -f ~/.bashrc && . ~/.bashrc` / `test -f ~/.profile && . ~/.profile`, both in one file), `.bashrc` empty, the real block in `.profile`:

```
✔ Synced 1 env variable(s) to ....teamai/env.sh
✔ Env variables injected in shell profile
✔ No stale env blocks left behind
```

Resolves to `.profile` even though `.bashrc` sorts earlier in the candidate list.

Added 2 more tests for these (branching reference, `||`-conditional). `npx tsc --noEmit` clean; full `vitest run` at the same pre-existing 30-file/66-test baseline as unmodified `main` — no new failures, 6 new passing tests total across this round and the last.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/utils/shell-profile.ts:232 — Splitting on every && still treats its right-hand source as reachable without evaluating the condition. For example, [ "$TERM_PROGRAM" = vscode ] && source ~/.bashrc causes a stale .bashrc block to win outside VS Code, so the actually-read profile is never updated. Multiline if bodies have the same issue because each source line is treated independently.
  • [P1 blocking] src/utils/shell-profile.ts:232 — Keeping || folded into the left statement creates the opposite regression when failure is knowable: with source ~/.profile || source ~/.bashrc and no .profile, .bashrc is definitely executed, but the resolver never visits it and injects a duplicate into .bash_profile.
  • [P2 non-blocking] docs/usage-guide.md:748 — The documentation describes only a directly referenced candidate, but the implementation now follows references transitively. Update both docs/usage-guide.md and docs/usage-guide.zh-CN.md to document chained traversal.

Resolved

  • The earlier single-branch traversal finding is fixed by breadth-first traversal of every referenced candidate.
  • The earlier successful-left-side || false positive is fixed.
  • The PR description includes a test plan and a real-CLI end-to-end record, so no testing-description finding is needed.

STiFLeR7 and others added 2 commits September 23, 2026 09:46
…s (review)

Two more P1s from the bot's round-11 review of aaa1142, both about
referencesCandidate() trusting shell control flow it can't actually
evaluate:

- Any `&&` was treated as making its right side reachable, without
  checking what the left side's condition even was. A guard like
  `[ "$TERM_PROGRAM" = vscode ] && source ~/.bashrc` would mark
  .bashrc reachable unconditionally, even though it only runs inside
  VS Code. Also flagged: a source sitting inside a multiline `if`
  body looks, line by line, identical to a top-level one.

- Folding `||`'s right side into its left statement (the round-9 fix)
  went too conservative the other way: `source ~/.profile ||
  source ~/.bashrc` DOES guarantee .bashrc runs when ~/.profile
  doesn't exist, and the resolver was never even trying it.

Rather than growing another ad hoc regex tweak, rewrote
referencesCandidate() around what it can actually verify without a
real shell parser:

  - Unconditional: a bare `. REF` / `source REF` — but nothing inside
    an `if` block counts, conditional or not. An `if`'s condition is
    opaque to a line scanner; trusting some conditions and not others
    would just be guessing.
  - Existence-gated `&&`: only the self-referential idiom
    `test -f REF && . REF` / `[ -f REF ] && . REF`, where the tested
    path and the sourced path are the same candidate — the one `&&`
    condition this code can independently verify, by visiting that
    candidate itself later in the search.
  - `||` fallback: the left side always counts (always attempted);
    the right side counts only when the left side's own target file
    does not exist on disk — the one case an `||` fallback is
    actually guaranteed to run.

Anything this can't resolve either way is never trusted: the search
just doesn't queue that candidate, and the caller falls back to the
order-based pick — at worst a harmless duplicate block (the
pre-Tencent#693-fix behavior), never a false "reachable" that would
reintroduce Tencent#682.

Verified end-to-end on a real Windows host: re-ran the core
Git-for-Windows two-pull scenario from Tencent#693 (self-referential &&,
still recognized) with no regression. Added 3 unit tests for the new
boundaries: || recognized when the left target is missing, a
non-existence && condition rejected, and a source nested inside an
if block rejected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…boundary (review)

Round-11 P2: both docs described only a single directly-referenced
candidate, but the resolver has followed transitive chains since
75f3eac and now only trusts specific verifiable && / || forms (60a2da0).
Describes the Debian .profile -> .bashrc two-hop case alongside the
Git-for-Windows one, and names the three reference shapes recognized
(bare source, self-referential existence-gated &&, existence-checked
|| fallback) and that if-bodies are never trusted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

Copy link
Copy Markdown
Contributor Author

Both P1s real, addressed in `60a2da0` (docs P2 in `158df65`).

Unverifiable `&&` (`shell-profile.ts:232`): agreed — any `&&` granted reachability regardless of what the left side actually tested, and a source nested in a multiline `if` body looks, line by line, identical to a top-level one. Rather than another targeted regex patch, rewrote `referencesCandidate()` around exactly what it can verify without a real shell: a bare unconditional source (never inside an `if` block — the condition is opaque either way, trusting some and not others would just be guessing), and `&&` only for the self-referential existence idiom (`test -f X && . X`, same `X` both sides) — the one condition independently checkable by visiting that candidate later in the search.

`||` too conservative (`shell-profile.ts:225`): also agreed, and the fix composes with the above: the left side of `||` always counts (always attempted); the right side counts only when the left side's own target file doesn't exist on disk — the one case that fallback is guaranteed to run, checked via the same file I/O the search already does.

I want to flag directly where I'm drawing the line here, since round 9 → 10 → 11 has each found a real gap in the previous round's shell-construct handling: this now recognizes three verifiable shapes (bare source outside any `if`, self-referential existence-gated `&&`, existence-checked `||` fallback) and nothing else — no nested conditionals, no `case`, no variable expansion in paths, no subshells. That's a deliberate boundary, not an oversight: general shell evaluation is undecidable without actually running a shell, and something more exotic than these three shapes falls back safely to the order-based pick (a harmless duplicate block, the exact pre-#693 behavior) rather than a false "reachable" — the failure direction this whole resolver exists to avoid. If a specific additional pattern shows up on real hardware the way the Debian `.profile` chain and the Git-for-Windows generator did, I'll take another look at it on that basis rather than in the abstract.

Re-ran the core Git-for-Windows two-pull scenario from #693 on the real Windows host — unaffected:
```
✔ Synced 1 env variable(s) to ....teamai/env.sh
✔ Env variables injected in shell profile
✔ No stale env blocks left behind
```

Added 3 tests for the new boundaries (`||` recognized when the left target is missing, a non-existence `&&` condition rejected, a source nested in an `if` rejected) — 35/35 in `shell-profile.test.ts`. `npx tsc --noEmit` clean; full `vitest run` at the same pre-existing 30-file/66-test baseline, +3 new passing tests (one unrelated Vitest worker-IPC timeout on this run, not a real test failure, confirmed by identical counts). Docs (both languages) updated to describe the transitive chain and the three recognized reference shapes.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/utils/shell-profile.ts:208 — The regex accepts quoted ~ and single-quoted $HOME as home-relative references, but shells do not expand either (source "~/.bashrc" and source '$HOME/.bashrc'). A stale real ~/.bashrc block can therefore be selected even though the active profile never reaches it.
  • [P1 blocking] src/utils/shell-profile.ts:267 — Non-existence && expressions discard the definitely-executed left operand. For example, source ~/.profile && echo ready always attempts .profile, but the scanner recognizes neither side, so a block in .profile is missed and duplicated into the active pick.
  • [P1 blocking] src/utils/shell-profile.ts:260 — Skipping every if body prevents traversal of the standard Debian/Ubuntu .profile pattern that conditionally sources .bashrc. This directly contradicts docs/usage-guide.md:748 and causes a duplicate block to be injected into .profile when the existing block is in the .bashrc that it actually loads.
  • [P1 blocking] src/utils/shell-profile.ts:256 — Only if nesting is tracked. A source ~/.bashrc inside an uncalled function, a non-selected case arm, or a loop body is treated as unconditional, allowing an unread stale .bashrc block to outrank the active profile and reintroduce detectShellProfile() falls back to ~/.bashrc on Windows, where the login shell never reads it #682.

Resolved

  • Breadth-first traversal now checks every referenced candidate.
  • Conditional && right-hand sources are no longer treated as unconditional.
  • Both supported || cases from the earlier review are handled.
  • Both usage-guide languages now describe transitive traversal.
  • The PR description includes a test plan and real-CLI end-to-end verification.

…ze block-skip (review)

Round 12 found three more real gaps in referencesCandidate(), plus one
I agree isn't worth chasing further (see PR reply):

- The quote check accepted `source "~/.bashrc"` and `source
  '$HOME/.bashrc'` as valid references, but a shell never tilde-expands
  inside any quotes and never variable-expands inside single quotes —
  both source a literal, near-certainly nonexistent path. Tightened to
  the three forms that actually expand: bare `~/name`, and `$HOME/name`
  either bare or double-quoted.

- `&&`'s left side is always attempted, the same as `||`'s — `source
  ~/.bashrc && echo ready` does reach .bashrc regardless of the
  trailing command, but the old "whole statement must be exactly `.
  REF`" check missed it. The leftmost command before the first `&&` (or
  no `&&` at all) is now checked the same way `||`'s left side already
  was.

- Only `if`/`fi` was tracked, so a source inside an uncalled function,
  a non-selected `case` arm, or a loop body — none of them any more
  guaranteed to run than an `if` body — was wrongly treated as
  top-level. Generalized the "don't trust it" depth counter to cover
  for/while/until, case/esac, and function/brace groups too, sharing
  one counter since we only need to know whether we're inside *any* of
  them, not which one.

Declined to extend if-body trust to cover the standard nested Debian
`.profile` template (`if [ -n "$BASH_VERSION" ]; then if [ -f
"$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi; fi`) — doing so would
mean trusting the outer `$BASH_VERSION` check, which is exactly the
class of unverifiable shell condition this design has refused since
round 11. Fixed the docs instead: they previously (incorrectly)
claimed this exact template was recognized; now they say plainly that
nested conditionals of any kind fall back to the order-based pick.

Verified end-to-end on a real Windows host: re-ran the Git-for-Windows
two-pull scenario unaffected. Added 6 unit tests for the new
boundaries (invalid vs. valid quoting, &&'s left side, function/case/
loop bodies). 41/41 in shell-profile.test.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

Copy link
Copy Markdown
Contributor Author

Three of four are real, addressed in `0e05baa`. Pushing back on the fourth, with a docs fix instead.

Invalid quoting (`shell-profile.ts:208`): agreed, and this one's a good catch — a shell never tilde-expands inside any quotes and never variable-expands inside single quotes, so `source "/.bashrc"` and `source '$HOME/.bashrc'` both source a literal, near-certainly nonexistent path. My reference matcher accepted both anyway. Tightened to only the three forms that actually expand: bare `/name`, and `$HOME/name` either bare or double-quoted.

`&&`'s left side (`shell-profile.ts:267`): agreed — `&&`'s left side is always attempted, same as `||`'s already was. `source ~/.profile && echo ready` does reach `.profile` regardless of the trailing command. The leftmost command before the first `&&` (or no `&&` at all) is now checked the same way `||`'s left side already is.

Only `if` tracked (`shell-profile.ts:256`): agreed — a source inside an uncalled function, a non-selected `case` arm, or a loop body is no more guaranteed to run than an `if` body is, and treating it as top-level was inconsistent with my own stated rule. Generalized the depth counter to cover `for`/`while`/`until`, `case`/`esac`, and function/brace groups too — one shared counter, since all that matters is whether we're inside any of them.

Debian `.profile` template (`shell-profile.ts:260`): this is where I'll push back, though the report itself is accurate — I did overclaim it in the docs, and I've fixed that. The actual standard template is:
```sh
if [ -n "$BASH_VERSION" ]; then
if [ -f "$HOME/.bashrc" ]; then
. "$HOME/.bashrc"
fi
fi
```
Recognizing this means trusting the outer `if [ -n "$BASH_VERSION" ]` — a condition this scanner has no way to verify, exactly the category I drew the line at in the last round ("no nested conditionals... general shell evaluation is undecidable without actually running a shell"). Extending trust to it here would be inconsistent with declining the `$TERM_PROGRAM` case two rounds ago for the identical reason. What I actually got wrong was the docs, which claimed this exact template resolves — that was inaccurate and I've corrected it to state plainly that nested conditionals fall back to the order-based pick, whatever they check. A flat, single-level existence guard (`[ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc"`) is recognized and is the form the doc now uses as its example.

Real-machine re-verification of the Git-for-Windows scenario from #693 — unaffected:
```
✔ Synced 1 env variable(s) to ....teamai/env.sh
✔ Env variables injected in shell profile
✔ No stale env blocks left behind
```

Added 6 tests for the new boundaries (invalid vs. valid quoting, `&&`'s left side, function/case/loop bodies) — 41/41 in `shell-profile.test.ts`. `npx tsc --noEmit` clean; full `vitest run` at the same pre-existing 30-file/66-test baseline, +6 new passing tests. Docs (both languages) corrected.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/utils/shell-profile.ts:284 — The standard Debian/Ubuntu .profile pattern remains unsupported: its nested if conditions are true in Bash and really source .bashrc, but the scanner skips every if body and injects a duplicate into the active profile.
  • [P1 blocking] src/utils/shell-profile.ts:281 — Backslash continuations lose their control-flow context. With [ "$TERM_PROGRAM" = vscode ] && \ followed by source ~/.bashrc, the second line is treated as unconditional even outside VS Code, allowing an unread stale block to win.
  • [P1 blocking] src/utils/shell-profile.ts:284 — Block depth becomes permanently incorrect for common syntax. A one-line if ...; then ...; fi increments without closing, while func()\n{\n...\n} increments twice; subsequent unconditional sources are therefore ignored and duplicated.
  • [P1 blocking] src/utils/shell-profile.ts:289 — The anchored existence-guard regex misses valid chained commands such as [ -f ~/.bashrc ] && . ~/.bashrc && export READY=1; the source executes when the file exists, but traversal falls back and injects a duplicate.
  • [P1 blocking] src/utils/shell-profile.ts:288 — Splitting on semicolons before handling comments can manufacture an executed source from comment text. For example, : # old setup; source ~/.bashrc causes a stale .bashrc block to be selected even though the shell ignores everything after #.
  • [P1 blocking] src/utils/shell-profile.ts:281 — Here-document bodies are parsed as executable profile lines. A literal source ~/.bashrc inside cat <<'EOF' ... EOF therefore makes an unreachable stale block outrank the active profile.
  • [P2 non-blocking] docs/usage-guide.md:748 — “Whatever that file sources” and “through as many hops as it takes” overstate the implementation, which traverses only the five candidate filenames. A chain through a custom intermediary such as ~/.config/shell/profile is not followed; narrow this wording in both language versions.

Resolved

  • Quoted ~ and single-quoted $HOME are no longer treated as expanded paths.
  • Left-hand && sources and missing-left-hand || fallbacks are now recognized.
  • Traversal now checks every candidate branch with cycle protection.
  • Function, loop, and case bodies are no longer treated as unconditional in the tested multiline forms.
  • The PR description includes both a test plan and a real-CLI end-to-end record.

…&& in profile scanning (review)

Round 13 review found five genuine structural gaps in referencesCandidate,
all fixed by moving open/close-block detection to per-statement (post
`;`-split) instead of per-line, and adding a logicalLines() preprocessing
pass:

- Backslash-continued lines were scanned independently, losing the
  conditional context of the line they continue (`cond && \` followed by
  `source X` on the next line looked unconditional).
- A one-line `if ...; then ...; fi` only incremented depth (matched via
  the whole-line "opens" check) and never saw its own `fi` close it,
  permanently disabling recognition of every later unconditional source
  in the file. Two-line function definitions (`fn()` then `{` on its own
  line) double-incremented for the same reason.
- The existence-gated `&&` guard was fully anchored, so a guarded source
  followed by further `&&`-chained commands (`[ -f X ] && . X && export Y`)
  didn't match even though the guard still holds.
- Comment stripping only skipped whole-comment lines; a comment following
  a semicolon on the same line was still split into a "real" statement.
- Heredoc bodies were scanned as literal executable lines.

Declined the sixth (recognizing the Debian/Ubuntu nested
`if [ -n "$BASH_VERSION" ]; then if [ -f ... ]; then . ...; fi; fi`
template) for the same reason given in review round 12: the outer
condition is unverifiable without a real shell, and this resolver's
explicit, repeatedly-restated design boundary is to never trust an
unverifiable condition — falling back to the order-based pick (a
harmless duplicate block) is the intended safe behavior there, not a bug.

Verified with 6 new unit tests (47/47 passing) plus a standalone real-fs
script driving the actual resolveActiveShellProfile against a scratch
HOME for all seven round-13 scenarios (all pass). Full suite unchanged
at the pre-existing 30-failed-file/66-failed-test Windows-host baseline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

Copy link
Copy Markdown
Contributor Author

Five of six real, addressed in 6adaf9e. Pushing back on the sixth again, unchanged from round 12.

Backslash continuation (shell-profile.ts:281): agreed — I was scanning content.split('\n') line by line, so [ "$TERM_PROGRAM" = vscode ] && \ followed by source ~/.bashrc on the next physical line looked like two independent statements, the second unconditional. Added a logicalLines() preprocessing pass that joins a trailing unescaped \ onto the next line before any statement analysis runs.

Depth counter broken by one-line if...fi and two-line function defs (shell-profile.ts:284): also agreed, and worse than described — once broken, depth never recovers for the rest of the file, silently disabling recognition of every later unconditional source. Root cause: open/close detection ran on the whole trimmed line, so if [ -f X ]; then . X; fi on one line matched the "opens" check and consumed the whole line, never seeing its own fi; conversely fn() on one line and { on the next each independently matched "opens" for what's semantically a single function header. Fixed by moving open/close detection to run per ;-split statement instead of per line (so if ..., then ..., fi are seen as three separate tokens with depth updated between each), and by having logicalLines() merge a lone { line onto the header line that opens it.

Existence guard too strict for trailing && (shell-profile.ts:289): agreed — [ -f X ] && . X && export Y still guarantees . X ran if [ -f X ] succeeded; the fully-anchored regex required the statement to end right after the guarded source. Replaced the anchored regex with a &&-split check against just the first two segments, so trailing chained commands no longer break the match.

Comment-then-semicolon (shell-profile.ts:288): agreed — I only skipped lines that started with #, so : # old setup; source ~/.bashrc still got semicolon-split into a "real" source ~/.bashrc statement even though a shell treats everything after # as a comment. Added comment-stripping (# at start-of-word) before line trimming/splitting in logicalLines().

Heredoc bodies (shell-profile.ts:281): agreed — a source line inside cat <<'EOF' ... EOF is data, not a statement. logicalLines() now detects the <<[-]DELIM operator and drops every line up to and including the matching closing delimiter.

All five verified with 6 new unit tests (47/47 passing) and a standalone script driving the real, built resolveActiveShellProfile against a scratch $HOME on disk for all seven round-13 scenarios (including the pushback case below) — all pass.

Debian .profile nested-if template (shell-profile.ts:284): this is the same finding as round 12, and my answer hasn't changed. Recognizing it means trusting the outer if [ -n "$BASH_VERSION" ] condition, which this scanner has no way to verify without running a real shell — exactly the category I drew the line at three rounds ago and have re-affirmed each round since: an unverifiable condition is never trusted, in either direction, so the resolver falls back to the order-based pick (a harmless duplicate block, the pre-#693-fix behavior) rather than risk a false "reachable" that would silently reintroduce #682. I verified this round that the fallback is still exactly what happens (no crash, no wrong file) via the same real-fs script. Happy to revisit if there's a way to verify $BASH_VERSION without a real shell that I'm missing, but I don't think there is one.

Full regression suite unchanged from the pre-existing Windows-host baseline (30 failed files / 66 failed tests, confirmed via git stash to exist identically without this branch's changes) — no new failures.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/utils/shell-profile.ts:325 — The ad-hoc ; split is not quote- or escape-aware. A harmless command such as printf '%s\n' 'x; source ~/.bashrc; y' is parsed as an executed source, allowing an unreachable stale .bashrc block to win and reintroduce detectShellProfile() falls back to ~/.bashrc on Windows, where the login shell never reads it #682.
  • [P1 blocking] src/utils/shell-profile.ts:230 — Subshells are not treated as unverified blocks. In (\n source ~/.bashrc\n), the block executes only in a child shell and its exports do not reach the login shell, but the resolver sticks to .bashrc and doctor can report the ineffective installation as healthy.
  • [P1 blocking] src/utils/shell-profile.ts:324 — Control-flow terminators are ignored. With return followed by source ~/.bashrc, the source is unreachable, yet the scanner follows it and lets a stale block override the active profile.
  • [P1 blocking] src/utils/shell-profile.ts:326 — The standard Debian/Ubuntu .profile pattern remains unsupported because every if body is skipped. When its Bash conditions are true and it sources .bashrc, an existing block there is missed and duplicated into .profile.
  • [P1 blocking] src/utils/shell-profile.ts:321 — Only source commands with exactly one whitespace-free argument are recognized. Valid forms such as . "$HOME/.bashrc" 2>/dev/null or source ~/.bashrc argument execute the candidate but are missed, causing duplicate injection.
  • [P1 blocking] src/utils/shell-profile.ts:340|| handling only works when there are exactly two operands. A fallback chain such as source ~/.profile || source ~/.bash_login || source ~/.bashrc is ignored entirely, including its always-attempted left side, so a reached block can be duplicated.
  • [P1 blocking] src/utils/shell-profile.ts:345 — Existence does not prove that the left-hand source succeeds. If an existing .profile returns failure, source ~/.profile || source ~/.bashrc executes .bashrc, but pathExists() suppresses traversal of that fallback and duplicates its block.
  • [P1 blocking] src/utils/shell-profile.ts:256 — Heredoc tracking stores only one terminator. With multiple heredocs on one command, text in the second heredoc is parsed as executable after the first terminator; a literal source ~/.bashrc there can make an unreachable stale block win.

Resolved

  • The previously reported ordinary continuation, one-line block-depth, chained existence-guard, comment-after-semicolon, single-heredoc, and documentation-scope cases were addressed.
  • The PR description includes a test plan and real-CLI end-to-end verification.

… return/exit, N-way || (review)

Round 14 review found six more genuine gaps in referencesCandidate, all
fixed:

- The `;`/`&&`/`||` splits were plain `String.split`, so a separator
  character inside a quoted argument (e.g. `printf '%s' 'x; source
  ~/.bashrc; y'`) was treated as a real statement boundary, inventing an
  executed source out of string data. Added splitTopLevel(), a small
  quote-aware splitter (tracks single/double-quote spans, skips
  separators inside them) used everywhere a naive split was previously
  used.
- `(...)` subshells weren't tracked as an unverified-block construct —
  a source inside one always runs, but its exports never reach the
  caller, so it must not count as reaching a candidate any more than an
  `if` body does. Added to opensUnverifiedBlock/closesUnverifiedBlock
  alongside the existing if/for/while/until/case/function handling.
- An unconditional, top-level `return`/`exit` ends the file's control
  flow right there; anything textually after it was still being scanned
  as if reachable. Added a `halted` flag set on a bare return/exit
  statement, gating everything after it for the rest of the scan.
- `sourceOf` required the source's argument to be the entire statement,
  so `. "$HOME/.bashrc" 2>/dev/null` and `source ~/.bashrc extra_arg`
  (both valid, both really sourcing the target) went unrecognized.
  Relaxed to capture just the first argument and allow anything after
  it.
- The `||` fallback only handled exactly two operands — a three-way
  chain like `source ~/.profile || source ~/.bash_login || source
  ~/.bashrc` wasn't recognized at all, not even the always-attempted
  left side. Generalized to N operands: each one counts only when every
  operand before it is a recognized source whose target is verifiably
  missing from disk.
- Multiple heredocs opened by one command (`cat <<A <<B`) only tracked
  one terminator, so the second heredoc's body was scanned as real
  statements once the first terminator was seen. heredocEnd is now a
  queue of terminators consumed in order.

Declined the seventh finding again (the Debian/Ubuntu nested `if
[ -n "$BASH_VERSION" ]` template) for the same reason given in rounds 12
and 13: the outer condition is unverifiable without a real shell, and
this resolver's explicit design boundary is to never trust one — the
order-based-pick fallback (a harmless duplicate block) is the intended
safe outcome there, not a bug.

Verified with 8 new unit tests (55/55 passing) plus a standalone real-fs
script driving the actual built resolveActiveShellProfile for all nine
round-14 scenarios (all pass, including confirming the Debian pushback
case is unchanged). Full suite unchanged at the pre-existing
30-failed-file/66-failed-test Windows-host baseline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

Copy link
Copy Markdown
Contributor Author

Six of eight real, addressed in 413b0a4. Pushing back on two, one repeated from rounds 12-13 and one new but the same category of issue.

Quote-unaware ; split (shell-profile.ts:325): agreed, and a sharp catch — printf '%s\n' 'x; source ~/.bashrc; y' is one command whose argument happens to contain a literal ;, but my naive .split(';') cut it into three "statements," inventing an executed source out of string data. Added splitTopLevel(), a small quote-aware splitter that tracks single/double-quote spans and skips separators inside them, and switched every ;/&&/|| split in referencesCandidate to use it.

Subshells not tracked (shell-profile.ts:230): agreed — a (...) subshell's body may always run, but its exports never reach the calling shell, so a source inside one is no more "reachable" for this resolver's purposes than one inside an if body. Added (/) to opensUnverifiedBlock/closesUnverifiedBlock alongside the existing constructs.

Control-flow terminators ignored (shell-profile.ts:324): agreed — an unconditional, top-level return/exit really does end the file right there. Added a halted flag set the moment a bare return/exit statement is seen at depth 0, gating every statement after it for the rest of the scan.

Only single-argument source recognized (shell-profile.ts:321): agreed — . "$HOME/.bashrc" 2>/dev/null and source ~/.bashrc extra_arg are both valid syntax that really source the target; the old regex required the argument to be the entire statement. Relaxed it to capture just the first argument and allow anything after.

|| only two operands (shell-profile.ts:340): agreed, and worse than described — for 3+ operands even the always-attempted leftmost stopped being credited, since the whole branch was skipped by the length === 2 guard and fell through to a leftmost-before-&& check that doesn't understand || at all. Generalized to N operands: each one counts only when every operand before it is a recognized source whose own target is verifiably missing from disk, same one-way verification the two-operand version already used.

Multi-heredoc terminator tracking (shell-profile.ts:256): agreed — cat <<A <<B opens two heredocs read in order, but I only tracked one terminator string, so the second heredoc's body got scanned as real statements once the first terminator was seen. heredocEnd is now a queue, populated in the order terminators appear on the opening line and consumed in that same order.

All six verified with 8 new unit tests (55/55 passing) and the standalone real-fs script against the actual built resolver, extended to all nine round-14 scenarios (all pass).

Debian .profile nested-if (shell-profile.ts:326): same finding as rounds 12 and 13, same answer — recognizing it means trusting the outer if [ -n "$BASH_VERSION" ], unverifiable without a real shell, and the explicit boundary I've held for three rounds now is that an unverifiable condition is never trusted in either direction. The fallback (a harmless duplicate block) is the accepted, intended outcome here, confirmed again this round via the real-fs script.

|| existence-only verification (shell-profile.ts:345): declining this one for the same reason. The claim is that source ~/.profile can fail even when .profile exists (a syntax error inside it, say), so .bashrc might really be reached in that case too — true, but whether a given file's contents actually execute without error is exactly as unverifiable without running a real shell as $BASH_VERSION is. Existence-on-disk is the one condition this scanner can check independently (by visiting the file itself, which it's already doing); extending trust past that means guessing at runtime execution success, which is the same category of guess the resolver exists to refuse. This is the documented, intentional scope of the || handling, not an oversight — happy to hear if there's a way to verify execution success without a real shell that I'm missing, but I don't believe there is one.

Full regression suite unchanged from the pre-existing Windows-host baseline (30 failed files / 66 failed tests) — no new failures.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/utils/shell-profile.ts:376 — The standard Debian/Ubuntu nested-if .profile pattern is still skipped wholesale. A block actually loaded from .bashrc is therefore missed and duplicated into .profile; documenting the limitation does not fix the regression.
  • [P1 blocking] src/utils/shell-profile.ts:408|| traversal still assumes an existing sourced file succeeds. An existing, unreadable, syntactically invalid, directory-valued, or explicitly failing .profile makes source ~/.profile || source ~/.bashrc execute .bashrc, but the resolver never visits it.
  • [P1 blocking] src/utils/shell-profile.ts:308 — Continuations after shell operators are not joined. In [ condition ] && followed by source ~/.bashrc, the second line is treated as unconditional even when the condition is false, allowing an unread stale block to win.
  • [P1 blocking] src/utils/shell-profile.ts:370 — Allowing arbitrary trailing text after the source target mistakes pipelines and background jobs for effective sourcing. source ~/.bashrc | cat and source ~/.bashrc & run in subshells, so their exports do not reach the login shell, yet the stale block is selected.
  • [P1 blocking] src/utils/shell-profile.ts:258 — Separator parsing remains escape-naive. printf '%s' x\\; source ~/.bashrc contains no shell statement boundary, but the scanner splits at the escaped semicolon and treats the argument text as an executed source.
  • [P1 blocking] src/utils/shell-profile.ts:242 — Comment stripping is not quote-aware. printf '%s' '#'; source ~/.bashrc really executes the source, but everything after the quoted # is discarded, causing duplicate injection into the active profile.
  • [P1 blocking] src/utils/shell-profile.ts:315 — Heredoc detection mistakes here-strings such as cat <<<EOF for heredocs and ignores the remainder of the file while waiting for a terminator. A subsequent real source is missed and duplicated.
  • [P1 blocking] src/utils/shell-profile.ts:304 — Heredoc terminators are compared with trim(). For a normal <<EOF, an indented EOF is data, not the terminator, but the scanner ends the heredoc early and can interpret later body text as executable sources.
  • [P1 blocking] src/utils/shell-profile.ts:315 — Valid heredoc delimiters are not limited to \\w+. Bodies using delimiters such as END-CONFIG are parsed as shell statements, so literal source text can make an unreachable stale block win.
  • [P1 blocking] src/utils/shell-profile.ts:474 — Reachable candidates are enqueued in global filename order rather than source execution order. If .bash_profile sources .bashrc and then .profile and both contain blocks, the resolver updates .bashrc, although the later stale .profile block overrides it when the shell starts.
  • [P1 blocking] src/utils/shell-profile.ts:230 — Block tracking still misses valid constructs such as multiline select bodies and command substitutions beginning with value=$(. A source inside either can be treated as top-level even though it is conditional or runs in a subshell.
  • [P1 blocking] src/utils/shell-profile.ts:238 — Compound-command closers with redirections, such as ) >/dev/null or fi 2>/dev/null, never reduce depth. Subsequent unconditional sources are ignored and duplicated.
  • [P1 blocking] src/utils/shell-profile.ts:205 — This change adds a large, incomplete shell parser to address a narrow forwarding case. Its unsupported grammar directly causes the false-positive and false-negative behaviors above, violating the repository’s requirement to reject speculative over-engineering in favor of the smallest targeted solution.

Resolved

  • Breadth-first traversal, quoted-tilde handling, basic continuations, chained existence guards, simple comments, quoted semicolons, basic subshells, trailing source arguments, multi-operand fallbacks, and basic multiple-heredoc handling were addressed.
  • Both usage-guide languages now describe transitive candidate traversal.
  • The PR description includes a test plan and real-CLI end-to-end verification, so no testing-description finding is needed.

…, closed recognizer (review)

Round 15 review found nine more genuine parsing bugs, most of them direct
consequences of the general-purpose statement/operand machinery added in
rounds 13-14 (quote/escape-aware `;`/`&&`/`||` splitting, N-way `||`
chains, trailing-argument tolerance on `source`). It also included a
meta-finding, correctly: this had grown into a large, incomplete ad-hoc
shell parser for what should be a narrow forwarding-detection case, and
each round's fix was mostly patching bugs the previous round's own
machinery introduced. Full shell parsing is undecidable without a real
shell; chasing it one adversarial regex at a time was never going to
finish, and it was already producing real regressions (the round-14
`sourceOf` relaxation meant to recognize valid trailing arguments also
started recognizing `source ~/.bashrc | cat` and `source ~/.bashrc &`,
both of which run in a subshell and never actually reach the caller).

Replaced `referencesCandidate`'s open-ended grammar with a closed
recognizer of exactly two forms, each matched as a complete logical line:

- bare unconditional `. REF` / `source REF`
- the self-referential existence guard `test -f REF && . REF` /
  `[ -f REF ] && . REF` — the literal line Git for Windows itself
  generates

Deleted entirely: quote/escape-aware statement splitting (no longer
needed — nothing is split into statements anymore), `||` fallback
handling (both the original two-operand and round 14's N-way
generalization), trailing-argument/redirection tolerance on `source`
(the source of the pipe/background regression above), and
comment-stripping (unnecessary now — a line with anything extra on it
simply fails the exact-match check, which is a large part of why the
statement machinery could be deleted rather than just patched again).

Kept, since dropping them would reopen a real false-positive risk rather
than just narrow scope: block-depth tracking for
`if`/`for`/`while`/`until`/`case`/`select`/function/subshell/brace-group
(content inside is either conditional or non-propagating, generalized
this round with `select` and a fixed one-liner if/for/while/until/case
collapse so a self-contained one-liner doesn't corrupt depth tracking for
the rest of the file), heredoc body skipping (fixed three real bugs in
it: a `<<<` here-string was mistaken for a `<<` heredoc and swallowed the
rest of the file; a non-`-` heredoc's terminator was compared with
`.trim()`, letting an indented look-alike end it early; the delimiter
charset was `\w` only, missing real delimiters like `END-CONFIG`), a
`return`/`exit` halt flag (cheap, and the alternative — textually dead
code after an unconditional exit still being scanned — is a genuine
false positive, however unlikely the pattern), and joining a line ending
in `\`, `&&`, or `||` onto the next (real, unremarkable shell
continuation with no backslash required for the latter two — the risk
this closes isn't hypothetical: an unrelated trailing `&&` followed by an
unconditional-looking `source` on the next line is exactly the shape
that would have produced a false "reachable").

Declined the Debian/Ubuntu nested-`if` finding a fourth time, unchanged
from rounds 12-14: the outer `$BASH_VERSION` check is unverifiable
without a real shell, and this resolver's explicit boundary is that an
unverifiable condition is never trusted. The `||`-existence-only pushback
from round 14 is now moot — `||` isn't recognized in any form.

Net change to shell-profile.ts is negative (-244/+something smaller)
despite fixing more bugs than it added, confirming this is a real
simplification rather than another round of patches. Verified with an
updated unit test suite (61/61 passing — six tests for now-out-of-scope
behavior replaced with tests confirming the safe fallback, new tests
added for every round-15 fix that was kept) and a standalone real-fs
script against the actual built resolver covering all twelve round-15
scenarios (all pass, including the real motivating Git-for-Windows case
and the still-declined Debian template). Full suite unchanged at the
pre-existing 30-failed-file/66-failed-test Windows-host baseline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@STiFLeR7

Copy link
Copy Markdown
Contributor Author

Taking the last finding seriously and changing course rather than patching further: "This change adds a large, incomplete shell parser to address a narrow forwarding case... violating the repository's requirement to reject speculative over-engineering." That's correct. Looking back at rounds 13-14, most of what got added was patching bugs that the previous round's own general-purpose machinery introduced (quote/escape-aware splitting, N-way ||, trailing-argument tolerance on source — the last of which is directly why source ~/.bashrc | cat and source ~/.bashrc & got flagged this round as false positives; I made that regression myself relaxing the match to accept legitimate trailing redirections). Full shell parsing is undecidable without a real shell, and chasing it one adversarial regex at a time was never going to converge.

3c29290 replaces referencesCandidate's open-ended grammar with a closed recognizer of exactly two forms, each matched as a complete line:

  • bare unconditional . REF / source REF
  • the self-referential existence guard test -f REF && . REF / [ -f REF ] && . REF — the literal line Git for Windows itself generates, the actual motivating case for this whole resolver

Deleted (this is what "large, incomplete shell parser" was actually referring to): quote/escape-aware statement splitting (nothing gets split into statements anymore, so it's not needed), || fallback handling in both its two-operand and N-way forms, trailing-argument/redirection tolerance on source (closes the pipe/background regression directly), comment-stripping (a line with anything extra on it just fails the exact-match check now, no stripping required).

Kept — not narrowed away, because dropping them reopens a real false-positive risk rather than just trimming scope:

  • Block-depth tracking for if/for/while/until/case/select/function/subshell/brace-group, now with select added and a one-liner collapse so a self-contained if ...; then ...; fi doesn't corrupt depth for the rest of the file.
  • Heredoc body-skipping, with the three real bugs in it fixed: a <<< here-string was mistaken for a << heredoc and swallowed the rest of the file; a non-- heredoc's terminator was compared with .trim(), so an indented look-alike ended it early; the delimiter charset was \w-only, missing real delimiters like END-CONFIG.
  • A return/exit halt flag — cheap (one regex, one boolean), and the false-positive it prevents (dead code after an unconditional exit still being scanned) is real even if the pattern is unlikely.
  • Joining a line ending in \, &&, or || onto the next line. This one I want to be explicit about: it's not adversarial-chasing, it closes a real risk — a trailing &&/|| at end of line is ordinary shell continuation with no backslash required, and without joining, an unrelated condition followed by an unconditional-looking source on the next line would be a genuine false "reachable."

Net change to shell-profile.ts is negative despite fixing more bugs than it adds — verified with git diff --stat, not just asserted.

Debian .profile nested-if: declining a fourth time, unchanged from rounds 12-14 — the outer $BASH_VERSION check is unverifiable without a real shell, and this resolver's boundary is that an unverifiable condition is never trusted.

||-existence-only pushback from round 14: moot now — || isn't recognized in any form, so there's nothing to relax.

Verified with an updated unit suite (61/61 passing — six tests for now-out-of-scope behavior replaced with tests confirming the safe fallback, new tests for every fix that was kept) and a standalone real-fs script against the actual built resolver covering all twelve round-15 scenarios, including the real Git-for-Windows case and the still-declined Debian template (all pass). Full regression suite unchanged from the pre-existing Windows-host baseline.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/utils/shell-profile.ts:320 — Brace groups run in the current shell, so { source ~/.bashrc; } does propagate its exports. Treating every {...} body as ineffective misses the active block and injects a duplicate into the outer profile.
  • [P1 blocking] src/utils/shell-profile.ts:336 — Every if body is still skipped, including the standard Debian/Ubuntu .profile logic that conditionally sources .bashrc when running Bash. An existing active block there is duplicated into .profile.
  • [P1 blocking] src/utils/shell-profile.ts:398 — Referenced files are still enqueued in global filename order rather than source execution order. If .bash_profile sources .bashrc and then .profile, and both contain blocks, the resolver updates .bashrc even though .profile executes later and overrides it.
  • [P1 blocking] src/utils/shell-profile.ts:328 — Requiring the source command to occupy the complete logical line misses valid unconditional forms such as source "$HOME/.bashrc" # interactive settings or . "$HOME/.bashrc" 2>/dev/null, causing duplicate injection.
  • [P1 blocking] src/utils/shell-profile.ts:328 — Ignoring all || expressions misses definitely reached fallbacks. If source ~/.profile || source ~/.bashrc encounters an absent, unreadable, invalid, or explicitly failing .profile, .bashrc executes but its block is duplicated into the active pick.
  • [P1 blocking] src/utils/shell-profile.ts:217 — The independently optional braces accept invalid or different paths such as $HOME}/.bashrc and ${HOME/.bashrc. The scanner can therefore select the real .bashrc even though the shell never sourced that path.
  • [P1 blocking] src/utils/shell-profile.ts:286 — Heredoc discovery is not quote- or comment-aware. Text such as printf '%s\n' '<<EOF' starts a fictional heredoc and can hide a later real source ~/.bashrc, causing duplicate injection.
  • [P1 blocking] src/utils/shell-profile.ts:286 — Escaped heredoc delimiters such as <<\EOF are valid but not recognized. A literal source ~/.bashrc inside that heredoc is consequently treated as executable, allowing an unreachable stale block to win.
  • [P1 blocking] src/utils/shell-profile.ts:227 — Command substitutions are still untracked. In value=$( followed by source ~/.bashrc, the source runs in a subshell and cannot update the login shell, but the resolver treats it as top-level and can report the ineffective installation as healthy.
  • [P1 blocking] src/utils/shell-profile.ts:279 — The one-line compound-command special case omits select. A line such as select value in a; do echo "$value"; done permanently increments depth, causing every later unconditional source to be ignored.
  • [P1 blocking] src/utils/shell-profile.ts:267 — Continuation detection ignores shell comments and backslash parity. For example, echo ready # && incorrectly absorbs the following real source, while a line ending in three backslashes fails to absorb a following non-executed source.
  • [P1 blocking] src/utils/shell-profile.ts:340 — Unconditional termination is recognized only in one exact shape. return 0 # stop, return;, or exit 0; leaves later unreachable source lines eligible to select a stale block.
  • [P1 blocking] src/utils/shell-profile.ts:205 — The PR adds roughly 190 lines of incomplete shell parsing and over 400 lines of parser-specific tests for a narrow Git-for-Windows forwarding case. The remaining false positives and false negatives above demonstrate the speculative complexity prohibited by the repository’s review rules; match the known generated forwarding form with a surgical solution instead.

Resolved

  • Basic operator continuations, here-strings, ordinary and multiple heredocs, hyphenated delimiters, multiline select blocks, and compound closers with redirections are addressed.
  • The PR description includes both a test plan and a real-CLI end-to-end verification record, so no testing-description finding is needed.

@jeff-r2026
jeff-r2026 merged commit 94a0d42 into Tencent:main Sep 23, 2026
11 checks passed
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.

detectShellProfile() falls back to ~/.bashrc on Windows, where the login shell never reads it

2 participants