diff --git a/CHANGELOG.md b/CHANGELOG.md index 964ca6e..8aab737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。 > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。 +## v0.40.2 — 2026-10-16 + +### 终端工具扫描只认可执行条目:手册页/文档/同名目录不再冒充命令(fix) + +- `scanCommands`(`src/toolchain.js`)原先用 `entry.replace(/\.[^./\\]+$/, '')` 剥掉**任意**扩展名,于是 PATH 目录里任何与工具同名的条目都算命中: + - **真实案例**:winget 便携包目录中 `just.1`(手册页)在字典序上排在 `just.exe` 之前,而扫描是「首个命中者胜出」⇒ 卡片「完整路径 / 由谁提供」列把手册页当成了 `just` 的可执行文件(实测 `commandPath` = `…\Casey.Just_Microsoft.Winget.Source_8wekyb3d8bbwe\just.1`)。 + - **判定层影响**:`classifyToolState` 在 `wingetOwned === false` 时只看 `hasCommand`,所以一台**确实没装**该命令、而 PATH 里恰好有同名文档 / 手册页 / 目录的机器,该行会被判成 `external`(`isActionable` 为 false)——**安装入口永不出现**。本机 `just` 之所以未暴露,只是 winget 清单命中在前把它掩盖了。 +- 修法(**纯函数改动、零 io 变更**):新增扩展名白名单 `EXEC_EXTS = {.exe,.cmd,.bat,.com}`,扩展名不在白名单的条目直接跳过;**无扩展名照旧命中**——Git for Windows 自带 `/usr/bin/make` 这类无扩展名可执行文件(`fzf` 同理),这条不能丢。未加 `.` / `..` 防御分支:`readdirSync` 本就不返回这两项。 +- 回归用例(`tests/smoke.mjs`「only executable extensions resolve」)锁三层:手册页 / 文档 / 目录不命中;无扩展名真可执行仍命中;`just.1` 排在 `just.exe` 之前时**首个可执行命中者**路径正确(另覆盖 `.EXE` 大小写)。判定层补一条联动断言:扫不到 ⇒ `missing` ⇒ `isActionable` 为真。**该用例已在未修复的源码上验证会失败**(`actual: 'C:/pkg\just.1'`),修复后通过。 +- 目录区分(无扩展名目录 vs 无扩展名 MSYS 可执行文件)**留作后续**:需要给 `io.readdir` 增加 `stat`/`Dirent` 能力并牵动调用方,本版不动 io 契约。 +- 验证:Windows 10.0.26200 / Node 24.19.0,`npm test` **237 pass / 0 fail**(基线 236 + 本用例 1)。 +- 相关:[issue #31](https://github.com/KannaKuron/dsh-gitbash-shell/issues/31)。 + ## v0.40.1 — 2026-10-15 ### 清理 gitbash_native 的两个死配置读取(chore) diff --git a/src/toolchain.js b/src/toolchain.js index dab64d2..33573cc 100644 --- a/src/toolchain.js +++ b/src/toolchain.js @@ -213,18 +213,30 @@ export function parseExportJson(text, tools) { return found } +/** + * The extensions a PATH entry must carry to BE the command. Anything else in a + * PATH directory — a man page, a README, a directory — merely shares the stem + * and must never resolve. An entry with NO extension still does: Git for + * Windows ships its own executables that way (`/usr/bin/make`). + */ +const EXEC_EXTS = new Set(['.exe', '.cmd', '.bat', '.com']) + /** * Which catalog commands the machine already has, and WHERE they came from. * One `readdir` per PATH directory (not one `exists` per tool × directory × * extension), and every unreadable directory is skipped rather than fatal. * First PATH hit wins, which is the same precedence the shell itself applies. + * Only EXECUTABLE entries resolve (see EXEC_EXTS): a man page (`just.1`) or a + * document (`jq.md`) shares the stem without being the command, and issue #31 + * measured a man page winning the first-hit race over the `just.exe` beside it. * @param {{pathDirs: () => string[], readdir: (dir: string) => string[]}} io * @param {Array} tools - catalog entries to look for. * @returns {Map} cmd → the first resolving full path. */ export function scanCommands(io, tools) { const list = Array.isArray(tools) ? tools : TOOLCHAIN - // "make.exe" / "make.cmd" / "make" all resolve the command `make`. + // "make.exe" / "make.cmd" / "make" all resolve the command `make`; "make.1" + // (the man page) and "make.txt" do not — see EXEC_EXTS. const wanted = new Map(list.map((tool) => [String(tool.cmd).toLowerCase(), tool.cmd])) const found = new Map() let dirs @@ -236,7 +248,12 @@ export function scanCommands(io, tools) { if (!Array.isArray(entries)) continue for (const entry of entries) { if (typeof entry !== 'string') continue - const base = entry.replace(/\.[^./\\]+$/, '').toLowerCase() + // The LAST extension decides, exactly as before — but it now has to be + // an executable one, so a man page/doc/directory is skipped instead of + // resolving to a command it merely shares a stem with. + const suffix = /(\.[^./\\]+)$/.exec(entry) + if (suffix !== null && !EXEC_EXTS.has(suffix[1].toLowerCase())) continue + const base = (suffix === null ? entry : entry.slice(0, suffix.index)).toLowerCase() const cmd = wanted.get(base) if (cmd !== undefined && !found.has(cmd)) { found.set(cmd, dir.replace(/[\\/]+$/, '') + '\\' + entry) diff --git a/tests/smoke.mjs b/tests/smoke.mjs index f4b755a..14df193 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -2388,6 +2388,37 @@ test('toolchain: the PATH scan resolves commands, first hit wins, bad dirs are s assert.equal(scanCommands(broken, TOOLCHAIN).size, 0, 'an unreadable directory is not fatal') }) +test('toolchain: only executable extensions resolve — a man page is not the command', () => { + // issue #31: the scan used to strip ANY extension, so a man page sitting + // beside the real binary won the first-hit race. MEASURED: readdir sorts + // `just.1` before `just.exe` in winget's portable package directory, so the + // card's path column pointed at the man page. + const io = { + pathDirs: () => ['C:/pkg'], + readdir: () => ['just.1', 'just.exe', 'make.1', 'tree.txt', 'jq.md', 'gdu.d', 'fzf', 'bat.EXE'], + } + const hits = scanCommands(io, TOOLCHAIN) + // The extension-less executable still resolves: Git for Windows PATH + // directories are full of them (`/usr/bin/make` carries no extension), and a + // portable package can drop one beside its own docs. + assert.equal(hits.get('fzf'), 'C:/pkg' + '\\' + 'fzf') + // `just.1` sorts BEFORE `just.exe`, so this pins "first EXECUTABLE hit wins" + // rather than "first entry sharing the stem wins". + assert.equal(hits.get('just'), 'C:/pkg' + '\\' + 'just.exe') + // Case-insensitive, the way Windows itself resolves the extension. + assert.equal(hits.get('bat'), 'C:/pkg' + '\\' + 'bat.EXE') + // A man page / document / directory is NOT the command. + assert.equal(hits.has('make'), false, 'a man page must not resolve as make') + assert.equal(hits.has('tree'), false, 'a text file must not resolve as tree') + assert.equal(hits.has('jq'), false, 'a markdown file must not resolve as jq') + assert.equal(hits.has('gdu'), false, 'a directory named gdu.d must not resolve as gdu') + // The decision layer follows the scan: with no executable found the row stays + // installable, instead of claiming somebody else's toolchain provides it. + const state = classifyToolState({ hasCommand: hits.has('make'), wingetOwned: false, upgradable: false }) + assert.equal(state, 'missing') + assert.equal(isActionable(state), true) +}) + test('toolchain: the state machine — winget inventory first, others never touched', () => { assert.equal(classifyToolState({ hasCommand: false, wingetOwned: false, upgradable: false }), 'missing') // Somebody else's toolchain: present but NOT ours to install or upgrade.