Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 19 additions & 2 deletions src/toolchain.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>} 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
Expand All @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions tests/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down