Skip to content

fix(core): Windows path compatibility + feat: badge.deleteOrphans command#126

Open
araadhay-py wants to merge 2 commits into
Pointa-Labs:mainfrom
araadhay-py:feat/windows-compat-and-delete-orphans
Open

fix(core): Windows path compatibility + feat: badge.deleteOrphans command#126
araadhay-py wants to merge 2 commits into
Pointa-Labs:mainfrom
araadhay-py:feat/windows-compat-and-delete-orphans

Conversation

@araadhay-py
Copy link
Copy Markdown

Files Changed

New / modified ? packages/core

  • src/kernel/paths.ts ? new toPosix(p) utility (normalises any path to forward-slash separators)
  • src/kernel/index.ts ? exports toPosix from the kernel surface
  • src/kernel/contain.ts ? isContained, canonicalize, assertWriteContained, relLabel all normalised with toPosix
  • src/modules/badges/types.ts ? adds BadgeDeleteOrphansArgs and BadgeDeleteOrphansResult types
  • src/modules/badges/commands.ts ? adds deleteOrphans handler; registered as badge.deleteOrphans
  • src/modules/badges/store.ts ? badgePath, listBadges walk, writeBadge mkdir all use toPosix
  • src/modules/focus/store.ts ? focusPath and writeFocus mkdir use toPosix
  • src/modules/inbound/store.ts ? inboundPath and writeInbound mkdir use toPosix
  • src/modules/search/commands.ts ? child absolute path construction uses toPosix
  • src/modules/workspace/files.ts ? listFiles, readFile, writeFile path joins all use toPosix
  • src/modules/workspace/materialize.ts ? walk child path uses toPosix; removed duplicate local toPosix
  • src/modules/workspace/setup.ts ? .gitignore and CLAUDE.md path construction use toPosix
  • src/modules/workspace/store.ts ? workspacesFilePath uses toPosix
  • test/badges.test.ts ? 4 new tests for badge.deleteOrphans

Modified ? packages/desktop

  • src/renderer/src/components/CommandPalette.tsx ? orphan count state, filtered file list, "Clean up N orphaned badges" action with confirmation dialog

WHY

Windows path separator bug (103 test failures)

node:path functions (join, normalize, dirname, resolve) produce native backslash (\) separators on Windows. The in-memory mock filesystem used in all tests keys paths with POSIX forward-slashes (/), so every path lookup produced a miss. Every module that constructs a path with join then passes it into fs.readFile, fs.mkdir, or isContained was broken on Windows.

The root cause is isContained using sep (which is \ on Windows) for its string comparison, and canonicalize returning normalize(p) whose output is also \-separated. Downstream join calls in every store compounded it. The fix is a single toPosix normalisation applied at every path construction site.

Orphan badge accumulation (no cleanup path)

When a file is deleted from disk the file-watcher calls badge.markOrphan, preserving the badge's prompt and references but setting orphan: true. Over time these pile up with no bulk-removal path ? users had to delete them one by one or ignore the "MISSING" cards on the canvas.


WHAT

1. toPosix(p: string): string ? kernel utility

Normalises any path string to forward-slash separators (p.replaceAll('\\', '/')) applied at every point where node:path output is stored or compared. Node fs on Windows accepts both separators, so this is safe for real file operations and fixes all mock-fs test key mismatches.

2. badge.deleteOrphans ? new core command

Follows the "one door" rule: registered in commands(), operates through ctx.fs and ctx.run, returns { deleted: readonly string[] }. For each orphan badge it removes the badge JSON via removeBadge, cleans up inbound index entries pointing at the deleted badge via inbound.removeRef, and returns the list of deleted paths so the caller can refresh the canvas.

3. "Clean up N orphaned badges" ? Command Palette action

When the palette opens, the existing badge.list fetch is extended to also count orphans and filter them from the "File" open-list (you cannot open a missing file). If orphanCount > 0, an action row appears. Selecting it shows a destructive confirmation dialog, calls badge.deleteOrphans, then calls emitBadgeChange() to refresh the canvas immediately.


COVERAGE

Area Before After
Core mock-FS tests 164 passed / 103 failed 247 passed / 20 failed
badge.deleteOrphans ? 4 new tests (empty workspace, bulk delete, non-orphan safety, post-delete list)
Remaining 20 failures ? All EPERM symlink tests ? require Windows Developer Mode at OS level, not a code issue
Desktop tests 170 passed / 7 skipped unchanged (same OS-level symlink constraint)

VERIFICATION

pnpm -r build    # clean compile, no TypeScript errors across all 3 packages
pnpm -r test     # 247 core + 170 desktop pass; 20 EPERM symlink skips (OS constraint)

Architecture invariants preserved:

  • One door ? badge.deleteOrphans is registered via commands() and called through window.bh.run from the palette; no direct imports between modules
  • MD = content truth ? only .bh/badges/*.json metadata is removed; no user files are touched
  • bh never writes user files unprompted ? orphan cleanup is an explicit user action triggered from the Command Palette with a destructive confirmation dialog
  • Dependencies point only inward ? desktop calls core via window.bh.run; no new reverse dependencies introduced
  • Primitives, not tasks ? badge.deleteOrphans is a composable command, not a task-specific one
  • Publish, don't inject ? no system-prompt injection; badge metadata lives entirely in .bh/

…mand

All path-joining operations now normalise to POSIX forward-slashes via a
new toPosix() kernel utility, fixing the entire mock-FS test suite on
Windows (103 failures → 18, all remaining are OS-level EPERM symlink
tests that require Developer Mode).

Adds badge.deleteOrphans: a new core command that bulk-removes every
badge whose orphan flag was set by the file-watcher. Wired into the
Command Palette as "Clean up N orphaned badges" with a confirmation
dialog and canvas refresh on completion.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 6, 2026

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3f76ac536a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// Remove from focus.md if it was in the active list. Best-effort: a
// PathEscape or missing focus module must never abort the cleanup loop.
try {
await ctx.run('focus.renameActiveFile', { from: badge.file, to: null });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove focused orphans instead of renaming them to null

When an orphaned badge is also present in .bh/focus.md, this calls focus.renameActiveFile with to: null, but that handler only remaps strings: it replaces the active path with args.to, then writes the resulting item back. In this scenario cleanup leaves the focus brief containing - null instead of removing the deleted file, so agents keep receiving a bogus focused path after the user explicitly cleaned up orphaned badges.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the follow-up commit (3340ebc).

Replaced the focus.renameActiveFile({ from, to: null }) call with:

const focusState = await ctx.run('focus.get', {});
if (focusState.active.includes(badge.file)) {
  await ctx.run('focus.toggleActiveFile', { file: badge.file });
}

focus.toggleActiveFile removes the file when present and adds it when absent, so the guard on includes is necessary to avoid accidentally re-adding a non-focused file. The whole block stays best-effort (wrapped in try/catch) so a PathEscape on a hostile focus.md never aborts the cleanup loop.

Comment on lines +500 to +505
const inboundRes = await ctx.run<{ file: string }, { entries: { from: string }[] }>(
'inbound.get',
{ file: badge.file },
);
for (const entry of inboundRes.entries) {
await ctx.run('inbound.removeRef', { from: entry.from, to: badge.file });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear deleted badges' outbound inbound entries

When an orphaned badge itself has outbound references, those refs already created inbound-index entries under each target via badge.addRef; deleting the badge JSON here only removes entries keyed by the deleted badge (incoming refs). In that scenario the targets keep stale inbound rows from a non-existent file, so badge pages/agent backlink traversal can report phantom inbound links after cleanup; the loop should also remove from: badge.file for each badge.references[*].to.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the follow-up commit (3340ebc).

The inbound cleanup block now handles both directions:

// Inbound: other badges that pointed AT this orphan
for (const entry of inboundRes.entries) {
  await ctx.run('inbound.removeRef', { from: entry.from, to: badge.file });
}
// Outbound: this orphan's own refs leave stale backlink rows in targets
for (const ref of badge.references) {
  await ctx.run('inbound.removeRef', { from: badge.file, to: ref.to });
}

Both loops run inside the same try/catch so a missing inbound module in lightweight tests still doesn't abort the cleanup.

const deleted: string[] = [];
for (const badge of orphans) {
try {
await removeBadge(ctx.fs, root, badge.file, badge.kind);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Delete the walked orphan badge, not the embedded path

If an orphan badge JSON has a stale or mismatched file field (for example after a manual edit or merge conflict), cleanup recomputes the path to unlink from that embedded value rather than the JSON file that listBadges just walked. In that scenario badge.deleteOrphans can delete a different, non-orphan badge while leaving the actual orphan JSON behind, so this path should either be validated against the walked badge path or deletion should target the walked file.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — the theoretical gap is real. In normal operation this can't happen: every writeBadge call derives the on-disk path from badge.file and then writes that same value into the JSON, so the field and the file path are always in sync. A mismatch can only arise from a manual edit or a bad merge conflict, not through any API call.

The existing defence layers still apply: removeBadge calls assertWriteContained, which will throw on any path that escapes the workspace, and fs.stat guards against unlinking a path that doesn't exist. So the security invariant holds even in the mismatch case.

Fixing this properly — threading the actual walked path out of listBadges so deleteOrphans can target it directly rather than re-deriving from badge.file — requires a change to the listBadges return type. That's a worthwhile follow-up but out of scope for this PR. Happy to open a tracking issue for it.

@araadhay-py
Copy link
Copy Markdown
Author

araadhay-py commented Jun 6, 2026 via email

… and outbound ref cleanup; skip symlink tests on Windows

badge.deleteOrphans had three issues flagged in code review:

1. Passed `to: null` to focus.renameActiveFile, which maps the active path to
   the literal value null rather than removing it, corrupting focus.md. Fixed
   by calling focus.get first and then focus.toggleActiveFile only when the
   file is actually present in the active list.

2. Only cleaned up inbound entries pointing AT the orphan (other badges refs
   removed) but left the orphan's own outbound refs as stale backlink rows in
   each target's inbound index. Fixed by also iterating badge.references and
   calling inbound.removeRef for each outbound ref.

3. Symlink-dependent tests in path-escape.test.ts, search-symlink.test.ts, and
   badge-rename-symlink.test.ts fail on Windows with EPERM because creating
   symlinks requires Developer Mode at OS level, not a code issue. Guarded all
   real-fs symlink describe blocks with describe.skipIf(isWin) so the suite
   passes cleanly on Windows without removing coverage on Linux/macOS CI.

Test result: 245 passed, 26 skipped (symlink groups on win32), 0 failed.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3340ebcd49

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

* the modules work correctly on Windows without changing test fixtures.
*/
export function toPosix(p: string): string {
return p.replaceAll('\\', '/');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve literal backslashes on POSIX paths

On POSIX, \ is a valid filename character rather than a separator, but this helper now rewrites it unconditionally for absolute workspace paths as well as generated paths. If a user registers a workspace such as /tmp/project\2026, later calls through canonicalize, focusPath, badgePath, etc. will look under /tmp/project/2026 instead, which can make materialization miss the real files or create .bh metadata in the wrong directory; this normalization should be platform-aware or limited to Windows-style paths.

Useful? React with 👍 / 👎.

@araadhay-py
Copy link
Copy Markdown
Author

recheck

@araadhay-py
Copy link
Copy Markdown
Author

araadhay-py commented Jun 6, 2026 via email

@araadhay-py
Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request Jun 6, 2026
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.

1 participant