mirror of
https://github.com/Tencent/teamai-cli.git
synced 2026-10-04 04:08:30 +08:00
fix(env): search every referenced candidate, respect || conditionality (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-#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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
75f3eac35d
commit
aaa1142729
@@ -211,6 +211,43 @@ describe('resolveActiveShellProfile', () => {
|
||||
await fse.writeFile(path.join(homeDir, '.profile'), 'source ~/.bash_profile\n');
|
||||
expect(await resolveActiveShellProfile(envShPath, 'win32')).toBe(path.join(homeDir, '.bash_profile'));
|
||||
});
|
||||
|
||||
// Regression (#693 review round 10): the resolver committed to the first
|
||||
// referenced candidate in SHELL_PROFILE_CANDIDATE_NAMES's fixed order and
|
||||
// gave up if that branch was a dead end, instead of trying every candidate
|
||||
// the active pick actually references. .bash_profile sourcing both
|
||||
// .bashrc and .profile is exactly Git for Windows' own generated content
|
||||
// — .bashrc sorts earlier in the candidate list, so a dead .bashrc branch
|
||||
// would previously stop the search before it ever reached .profile.
|
||||
it('tries every referenced candidate, not just the first in priority order', async () => {
|
||||
await fse.writeFile(
|
||||
path.join(homeDir, '.bash_profile'),
|
||||
'test -f ~/.bashrc && . ~/.bashrc\ntest -f ~/.profile && . ~/.profile\n',
|
||||
);
|
||||
await fse.writeFile(path.join(homeDir, '.bashrc'), '# no block here\n');
|
||||
await fse.writeFile(path.join(homeDir, '.profile'), teamaiBlock());
|
||||
expect(await resolveActiveShellProfile(envShPath, 'win32')).toBe(path.join(homeDir, '.profile'));
|
||||
});
|
||||
|
||||
// Regression (#693 review round 10): splitting on `||` treated its
|
||||
// right-hand side as unconditionally reached, but it only runs if the left
|
||||
// side fails — undetermined here. A stale block behind `||` must not win
|
||||
// over a working one the left side already reaches.
|
||||
it('does not treat the right side of || as reachable', async () => {
|
||||
// Both .profile and .bashrc carry a valid block for this scope; the
|
||||
// point is which one the resolver *reaches* through the || line, not
|
||||
// which one has a well-formed block. .bashrc sorts earlier than
|
||||
// .profile in SHELL_PROFILE_CANDIDATE_NAMES, so a naive "any referenced
|
||||
// candidate in priority order" search would wrongly land on .bashrc even
|
||||
// though it only runs if the left side (.profile) fails.
|
||||
await fse.writeFile(
|
||||
path.join(homeDir, '.bash_profile'),
|
||||
'source ~/.profile || source ~/.bashrc\n',
|
||||
);
|
||||
await fse.writeFile(path.join(homeDir, '.profile'), teamaiBlock());
|
||||
await fse.writeFile(path.join(homeDir, '.bashrc'), teamaiBlock());
|
||||
expect(await resolveActiveShellProfile(envShPath, 'win32')).toBe(path.join(homeDir, '.profile'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('envBlockSourcesPath', () => {
|
||||
|
||||
+35
-20
@@ -222,7 +222,14 @@ function referencesCandidate(content: string, name: string): boolean {
|
||||
const target = new RegExp(`^["']?(?:~|\\$\\{?HOME\\}?)/${escaped}(?![\\w.-])["']?$`);
|
||||
for (const rawLine of content.split('\n')) {
|
||||
if (rawLine.trimStart().startsWith('#')) continue;
|
||||
for (const statement of rawLine.split(/&&|\|\||;/)) {
|
||||
// Only `&&`/`;` split into statements that are still unconditionally
|
||||
// attempted (or gated on the referenced candidate's own existence, which
|
||||
// is independently re-checked by reading that candidate). `||`'s right
|
||||
// side runs only if its left side fails — something not established
|
||||
// here — so it is left folded into the same statement as its left side:
|
||||
// that statement's first `.`/`source` command (the unconditional one)
|
||||
// still matches, but a `source` sitting only after `||` never does.
|
||||
for (const statement of rawLine.split(/&&|;/)) {
|
||||
const tokens = statement.trim().split(/\s+/);
|
||||
if (tokens.length >= 2 && (tokens[0] === '.' || tokens[0] === 'source') && target.test(tokens[1])) {
|
||||
return true;
|
||||
@@ -236,17 +243,22 @@ function referencesCandidate(content: string, name: string): boolean {
|
||||
* Resolve which shell profile file this scope's env block belongs in.
|
||||
*
|
||||
* Starts from `detectShellProfile`'s order-based pick — the file the current
|
||||
* environment actually reads — and follows the chain of files it actually
|
||||
* `source`s (transitively, with cycle protection) looking for one that
|
||||
* already carries this scope's block. A candidate the chain never reaches is
|
||||
* never preferred, regardless of what it contains: an earlier version
|
||||
* matched any candidate with a block anywhere (#693 review round 8: a stale
|
||||
* pre-#682 block in `.bashrc` then outranked a genuinely unwritten,
|
||||
* currently-read `.profile`, reintroducing #682 for exactly the installs
|
||||
* upgrading through this fix) and checked only one hop of sourcing (#693
|
||||
* review round 9: `.bash_profile` sourcing `.profile` sourcing `.bashrc` —
|
||||
* the common Debian `.profile` pattern — would miss a block sitting in
|
||||
* `.bashrc` two hops away and inject a duplicate into `.bash_profile`).
|
||||
* environment actually reads — and searches every file it actually `source`s
|
||||
* (transitively, breadth-first, with cycle protection) for one that already
|
||||
* carries this scope's block. A candidate the search never reaches is never
|
||||
* preferred, regardless of what it contains: earlier versions matched any
|
||||
* candidate with a block anywhere (#693 review round 8: a stale pre-#682
|
||||
* block in `.bashrc` then outranked a genuinely unwritten, currently-read
|
||||
* `.profile`, reintroducing #682 for exactly the installs upgrading through
|
||||
* this fix), checked only one hop of sourcing (#693 review round 9:
|
||||
* `.bash_profile` sourcing `.profile` sourcing `.bashrc` — the common Debian
|
||||
* `.profile` pattern — would miss a block sitting in `.bashrc` two hops away
|
||||
* and inject a duplicate into `.bash_profile`), and followed only the first
|
||||
* referenced candidate in a fixed priority order rather than every one
|
||||
* (#693 review round 10: `.bash_profile` sourcing both `.bashrc` and
|
||||
* `.profile`, with the block actually sitting in `.profile`, would commit to
|
||||
* the dead-end `.bashrc` branch first — earlier in `SHELL_PROFILE_CANDIDATE_
|
||||
* NAMES` — and give up without ever trying `.profile`).
|
||||
*
|
||||
* The common real case this exists for: Git for Windows'
|
||||
* `/etc/profile.d/bash_profile.sh` auto-generates `~/.bash_profile`
|
||||
@@ -266,20 +278,23 @@ export async function resolveActiveShellProfile(
|
||||
const activePick = await detectShellProfile(platform);
|
||||
|
||||
const visited = new Set<string>();
|
||||
let current = activePick;
|
||||
while (!visited.has(current)) {
|
||||
const queue: string[] = [activePick];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift() as string;
|
||||
if (visited.has(current)) continue;
|
||||
visited.add(current);
|
||||
|
||||
const content = await readFileSafe(current);
|
||||
const block = content ? extractEnvBlock(content) : null;
|
||||
if (block && envBlockReferencesDataHome(block, envShPath)) return current;
|
||||
if (!content) break;
|
||||
if (!content) continue;
|
||||
|
||||
const next = SHELL_PROFILE_CANDIDATE_NAMES.find((name) => {
|
||||
for (const name of SHELL_PROFILE_CANDIDATE_NAMES) {
|
||||
const candidate = path.join(home, name);
|
||||
return candidate !== current && !visited.has(candidate) && referencesCandidate(content, name);
|
||||
});
|
||||
if (!next) break;
|
||||
current = path.join(home, next);
|
||||
if (candidate !== current && !visited.has(candidate) && referencesCandidate(content, name)) {
|
||||
queue.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return activePick;
|
||||
|
||||
Reference in New Issue
Block a user