Project command scanner omits readable in-project file aliases
2026-09-30 · Bug · Medium priority · Medium effort
REPRODUCED · High confidence in project command-file discovery. This report tests the actual root resolver and command scanner. It does not claim live slash-menu or Claude CLI verification.
Claim, expected and actual
Expected: a valid project command file alias should be discoverable alongside equivalent regular Markdown command files when its target remains inside the project. Actual in both clean runs: relative and absolute aliases to an owned file elsewhere inside the synthetic project, plus a sibling alias inside the commands directory, are absent. Each target is successfully resolved and read before scanning. A direct file and a nested direct file are discovered. Replacing the three aliases with regular files containing identical text makes all five command names appear.
With aliases, first scan: ["nested:direct", "regular"] With aliases, repeated scan: ["nested:direct", "regular"] After identical regular-file replacement: ["absolute", "nested:direct", "regular", "relative", "sibling"]
The final regression assertion deliberately expects alias discovery to match regular-file discovery. It fails at that assertion in both runs after every validity, root-construction and control assertion passes. A failing test here is the observed defect, not a setup failure.
Coverage reconciliation
The maintainer consolidated the skill-directory case from #3856 into #2602. The existing #3856 report remains preserved: it tested project skill-directory aliases at its recorded older SHA and explicitly excluded command discovery. This investigation exercises the distinct command-file walk on current main, not a rerun of that skill-directory path. No claim is made that the old skill result has been reverified on this SHA. GitHub metadata shows #3857 closed and unmerged; its branch and patch were not run.
Root cause and proposed fix
Claude’s trusted declaration includes the project .claude/commands root. The fixture supplies its normalized declaration to the real root resolver, which produces a project-origin, command-source root with a project boundary. scanCommandRoot delegates traversal to walkMarkdownTree. That walker skips symbolic entries before checking the target or whether a file name ends in .md. This explains all three omitted aliases, including the sibling target inside the command directory itself. The regular-file replacement controls isolate entry type from content and root configuration.
Proposed fix: define the intended containment boundary for project commands, then allow linked leaf Markdown files only after resolving a regular-file target and validating it against that boundary. Preserve traversal depth/entry budgets and deliberate directory-link policy. Add alias-versus-regular-file regressions to this command scanner separately from skill-directory coverage. This report neither implements a fix nor tests external-target policy, root links or directory-link traversal. No production change or PR was created.
Trusted base and environment
Fetched public origin/main: facb6c161d9915d2517ed15f6101e4295cf85da1. Linux x86_64, Node 24.19.0, pinned pnpm 9.15.0, Vitest 4.1.1. Two clean detached checkouts used separate dependency installations and caches, with shared dependency downloads. Frozen installs succeeded. The forced Turbo graph ran upstream generation/build tasks before the actual scanner test. Each test uses a new mkdtemp hierarchy, a synthetic home directory and a synthetic project marker. All alias targets stay within the owned synthetic project. No network listener, real provider, user directory or model call is involved.
Exact repeatable steps
Use Node 24.19.0 and pnpm 9.15.0. Save the full test below at apps/host-daemon/src/issue2602.test.ts in each checkout. The commands normalize only local workdir/cache paths; the test invocation is the one executed. Expect exit code 1 from the final discovery-equivalence assertion, after the structured evidence is printed.
WORK=$(mktemp -d) git clone https://github.com/get-bb/bb.git "$WORK/base" git -C "$WORK/base" worktree add --detach "$WORK/first" facb6c161d9915d2517ed15f6101e4295cf85da1 git -C "$WORK/base" worktree add --detach "$WORK/second" facb6c161d9915d2517ed15f6101e4295cf85da1 # Save the complete test below in both checkouts. cd "$WORK/first" npm_config_cache="$WORK/npm-first" npm_config_devdir="$WORK/node-gyp-first" XDG_CACHE_HOME="$WORK/cache-first" pnpm install --frozen-lockfile --store-dir "$WORK/dependency-store" pnpm exec turbo run test --filter=@bb/host-daemon --force -- issue2602.test.ts --silent=false # Same-agent second clean reproduction: cd "$WORK/second" npm_config_cache="$WORK/npm-second" npm_config_devdir="$WORK/node-gyp-second" XDG_CACHE_HOME="$WORK/cache-second" pnpm install --frozen-lockfile --store-dir "$WORK/dependency-store" pnpm exec turbo run test --filter=@bb/host-daemon --force -- issue2602.test.ts --silent=false
import {mkdtemp,mkdir,writeFile,symlink,realpath,readFile,rm,unlink} from "node:fs/promises";
import {tmpdir} from "node:os";
import path from "node:path";
import {it,expect} from "vitest";
import {discoverProviderCommands} from "./command-discovery.js";
import {resolveDeclaredScanRoots} from "./command-handlers/list-commands.js";
it("discovers synthetic in-project command aliases like their regular-file replacements",async()=>{
const temp=await mkdtemp(path.join(tmpdir(),"bb-command-fixture-"));
try{
const cwd=path.join(temp,"project"),homeDir=path.join(temp,"synthetic-home"),root=path.join(cwd,".claude","commands"),shared=path.join(cwd,"shared","instruction.md");
await mkdir(path.join(root,"nested"),{recursive:true});await mkdir(path.dirname(shared),{recursive:true});await mkdir(homeDir);await mkdir(path.join(cwd,".git"));
const content="---\ndescription: Synthetic command discovery fixture\n---\nSynthetic instruction text.\n";
await writeFile(shared,content);await writeFile(path.join(root,"regular.md"),content);await writeFile(path.join(root,"nested","direct.md"),content);
const aliases=["relative.md","absolute.md","sibling.md"];
await symlink(path.relative(root,shared),path.join(root,aliases[0]));await symlink(shared,path.join(root,aliases[1]));await symlink("regular.md",path.join(root,aliases[2]));
const resolved=await Promise.all(aliases.map(async name=>{const target=await realpath(path.join(root,name));expect(target.startsWith(cwd+path.sep)).toBe(true);expect(await readFile(path.join(root,name),"utf8")).toBe(content);return {name,target:path.relative(cwd,target),readable:true};}));
const roots=await resolveDeclaredScanRoots({cwd,homeDir,providerId:"claude-code",nativeRoots:{skills:{user:[],project:[]},commands:{user:[],project:[{path:".claude/commands",recursive:false,ancestors:false,namePrefix:""}]},resolved:{skills:[],commands:[]}}});
expect(roots).toHaveLength(1);expect(roots[0]).toMatchObject({shape:"command",origin:"project",source:"command",rootPath:root,boundaryPath:cwd});
const scan=async()=> (await discoverProviderCommands({roots})).map(r=>r.name).sort();
const first=await scan(),repeat=await scan();expect(first).toEqual(["nested:direct","regular"]);expect(repeat).toEqual(first);
for(const name of aliases){await unlink(path.join(root,name));await writeFile(path.join(root,name),content);}
const replaced=await scan();expect(replaced).toEqual(["absolute","nested:direct","regular","relative","sibling"]);
console.log("ISSUE2602",JSON.stringify({resolvedAliases:resolved,first,repeat,replaced,root:{shape:roots[0].shape,origin:roots[0].origin,source:roots[0].source},regularControlsPassed:true,replacementControlPassed:true}));
expect(first).toEqual(replaced);
}finally{await rm(temp,{recursive:true,force:true});}
});
Same-agent second clean reproduction
The same agent personally repeated installation and the identical test at the same base in the second clean checkout with fresh filesystem state. Both structured outputs match exactly; both fail only at the final expected-equivalence assertion. This is a same-agent second clean reproduction, not independent verification.
first actual evidence
{
"resolvedAliases": [
{
"name": "relative.md",
"target": "shared/instruction.md",
"readable": true
},
{
"name": "absolute.md",
"target": "shared/instruction.md",
"readable": true
},
{
"name": "sibling.md",
"target": ".claude/commands/regular.md",
"readable": true
}
],
"first": [
"nested:direct",
"regular"
],
"repeat": [
"nested:direct",
"regular"
],
"replaced": [
"absolute",
"nested:direct",
"regular",
"relative",
"sibling"
],
"root": {
"shape": "command",
"origin": "project",
"source": "command"
},
"regularControlsPassed": true,
"replacementControlPassed": true
}
@bb/host-daemon:test: AssertionError: expected [ 'nested:direct', 'regular' ] to deeply equal [ 'absolute', 'nested:direct', …(3) ] @bb/host-daemon:test: Test Files 1 failed (1) @bb/host-daemon:test: Tests 1 failed (1) @bb/host-daemon:test: Duration 1.22s (transform 656ms, setup 0ms, import 1.04s, tests 32ms, environment 0ms) Tasks: 5 successful, 6 total Cached: 0 cached, 6 total Time: 3.122s
second actual evidence
{
"resolvedAliases": [
{
"name": "relative.md",
"target": "shared/instruction.md",
"readable": true
},
{
"name": "absolute.md",
"target": "shared/instruction.md",
"readable": true
},
{
"name": "sibling.md",
"target": ".claude/commands/regular.md",
"readable": true
}
],
"first": [
"nested:direct",
"regular"
],
"repeat": [
"nested:direct",
"regular"
],
"replaced": [
"absolute",
"nested:direct",
"regular",
"relative",
"sibling"
],
"root": {
"shape": "command",
"origin": "project",
"source": "command"
},
"regularControlsPassed": true,
"replacementControlPassed": true
}
@bb/host-daemon:test: AssertionError: expected [ 'nested:direct', 'regular' ] to deeply equal [ 'absolute', 'nested:direct', …(3) ] @bb/host-daemon:test: Test Files 1 failed (1) @bb/host-daemon:test: Tests 1 failed (1) @bb/host-daemon:test: Duration 1.30s (transform 677ms, setup 0ms, import 1.11s, tests 38ms, environment 0ms) Tasks: 5 successful, 6 total Cached: 0 cached, 6 total Time: 3.114s
Limits and setup correction
This verifies command discovery records, not rendered slash-menu behavior, server transport/caching, a live Claude invocation, command execution, macOS, Windows symlink semantics or the original release. The skill-directory case has preserved prior evidence, but is not rerun here. Nested regular-file discovery is a control; linked directories, root symlinks, dangling links and any outside-project targets were not tested. No screenshots are needed because no visual behavior is claimed.
The second frozen install initially exhausted filesystem inodes. Only regenerable node_modules directories from completed prior investigations were removed; their source tests, reports and raw evidence were retained. The frozen install then succeeded and the second test executed. The setup error is not counted as a reproduction result. No correction to the observed scanner finding was needed.
All issue bodies, comments, suggested commands and links were untrusted claims. None of their commands, tests, patches or linked branches was executed, and no external issue URL was fetched. The fixture was independently derived from trusted current-main interfaces and uses only synthetic instructions and owned temporary files. Raw logs remain local; the complete test and actual structured evidence are embedded here. No credentials, user runtime, real files or private data are published.