Skip to content

feat: core/drift.ts + core/differ.ts — three-way merge engine (#11)#48

Merged
breferrari merged 20 commits intomainfrom
feat/11-merge-engine
Apr 19, 2026
Merged

feat: core/drift.ts + core/differ.ts — three-way merge engine (#11)#48
breferrari merged 20 commits intomainfrom
feat/11-merge-engine

Conversation

@breferrari
Copy link
Copy Markdown
Owner

@breferrari breferrari commented Apr 19, 2026

Closes #11. Milestone 3 of the ROADMAP — the update engine that makes ShardMind different from every other "stamp-out-a-template" tool.

Summary

  • source/core/drift.tsdetectDrift() classifies every tracked file into managed / modified / volatile / missing / orphaned by sha256-comparing disk content against state.json.rendered_hash. Non-recursive orphan scan: parent directory of any tracked file = tracked directory; files in a tracked dir not in state.files = orphans. Engine scaffolding (shard-values.yaml, .shardmind/) and third-party metadata (.git/, .obsidian/) are excluded at the source. Reads are bounded by mapConcurrent(items, 32, fn) so large vaults can't exhaust file descriptors.
  • source/core/differ.tscomputeMergeAction() per spec §4.9: skip when base === ours, overwrite silently for managed files, three-way merge for modified files. threeWayMerge() uses node-diff3's diff3MergeRegions so stable regions with buffer === 'a'|'b' can be classified as auto-merged vs 'o' unchanged, which flat diff3Merge can't discriminate. A LineInterner maps each unique line to an integer-named token before diff3 sees it — this sidesteps a latent upstream crash where lines matching Object.prototype member names (constructor, __proto__, toString, …) collide with the library's internal {} hash map. Filed upstream as bhousel/node-diff3#86 with fix PR #87; removal of our workaround tracked in Drop LineInterner workaround once node-diff3 ships the prototype-lookup fix #49.
  • source/core/renderer.ts — exposes renderString() for in-memory template rendering (no disk I/O) via a lazy isolated nunjucks.Environment. renderFrontmatterSafely() auto-recovers when naive render produces YAML-hostile output (e.g. owner: {{ name }} with name = "Alice: AI researcher") by re-rendering with every string leaf JSON-encoded; non-string values keep their YAML type. A WeakMap seen-set guards the walker against circular references in values.
  • source/runtime/errors.ts — typed ErrorCode string union (39 codes, grouped by domain). ShardMindError.code is now strictly typed; typos surface at compile time. Exported from shardmind/runtime for hook consumers.
  • source/runtime/errno.ts — shared errnoCode / isEnoent with runtime typeof === 'string' guard. Collapsed 8 copies of the err instanceof Error && 'code' in err ? ... pattern across core + runtime.
  • CRLF tolerancethreeWayMerge splits on /\r?\n/, so a user's Windows-saved file (CRLF theirs) doesn't fight LF base/ours rendered by the engine. Merged output is always LF.
  • Cross-OS CI matrix{ubuntu, windows, macos} × {node 22, node 24} with fail-fast: false and defaults.run.shell: bash. Windows caught the CRLF regression.
  • Conflict markers use git vocabulary: <<<<<<< yours / ======= / >>>>>>> shard update.

Adversarial hardening

Deliberately pushed the engine against 16 attack categories. Four real bugs surfaced and were fixed:

  1. node-diff3 crash on Object.prototype line content — fixed via LineInterner (and filed upstream).
  2. YAML-hostile frontmatter values crashing render — fixed via auto-recovery.
  3. Circular references in values stack-overflowing the recovery walker — fixed with WeakMap seen-set.
  4. Stats over-counted by 1 on newline-terminated content — fixed by subtracting the trailing-empty token post-loop.

Twelve other categories held up: line endings (CRLF/LF/CR/mixed/no-trailing), conflict-marker injection (user content containing the marker strings), unicode/BOM/null bytes, size extremes (10K chars, 5K lines, many conflicts), idempotence & convergence, conflict boundaries (start/end/whole-file), drift races, exotic value types (Date/Function/Symbol), drift performance (500 files in <5s), token interning stress, whitespace-sensitive diffs, stats bookkeeping.

Test plan

  • npm test300 tests pass across 26 files
  • npm run typecheck — strict mode clean, zero any / as any / @ts-ignore
  • npm run build — runtime bundle 7.66 KB (well under the 30 KB target)
  • 20 fixture scenarios (17 spec + 3 edge cases: empty file, UTF-8 non-ASCII, frontmatter merge on modified ownership)
  • 12 direct detectDrift unit tests covering every DriftReport bucket including 5 orphan scenarios
  • 7 direct threeWayMerge unit tests pinning down stats accounting (caught a bug the fixture suite missed)
  • 6 fast-check property invariants × 200 cases = 1 200 generative scenarios (caught the node-diff3 prototype crash)
  • 60+ adversarial stress tests across 16 attack categories
  • 2 CRLF-robustness tests
  • Cross-OS CI matrix green on all 6 jobs
  • ROADMAP.md Milestone 3 fully checked; issue core/drift.ts — orphan detection (v0.2) #47 (orphan detection) pulled forward and closed
  • Spec docs (docs/ARCHITECTURE.md §17, docs/IMPLEMENTATION.md §4.8/§4.9) updated to match implementation
  • CHANGELOG.md [Unreleased] section populated
  • Two rounds of Copilot review addressed with disposition + commit refs on every thread

Tracking

🤖 Generated with Claude Code

breferrari and others added 3 commits April 19, 2026 17:47
Extracts the frontmatter-aware render pipeline into a public helper so the
merge engine can render an in-memory template string without writing it to
disk first. Uses a lazy isolated nunjucks.Environment — no pollution of
the global configure() state.

Covered by 5 new unit tests in renderer.test.ts (plain body, frontmatter
normalization, context substitution, syntax error, caller-supplied env).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements spec §4.8 (drift classification) and §4.9 (three-way merge
via node-diff3's Khanna–Myers algorithm). Unskips the fixture runner so
all 17 merge scenarios pass, with an orchestration dispatch branch that
routes volatile / new_file / removed / module-change scenarios away from
computeMergeAction (those are update-planner concerns, not merge concerns).

Highlights:
- detectDrift maps state.json ownership='user' onto DriftReport.volatile;
  hash-mismatched files become modified; absent files become missing.
  Orphan detection is deferred to v0.2 (see follow-up).
- computeMergeAction: skip when base===ours, overwrite for managed,
  three-way merge for modified. Conflict markers use git's yours/shard
  update vocabulary.
- threeWayMerge uses diff3MergeRegions (not diff3Merge) to classify
  stable vs auto-merged vs conflict lines accurately in MergeStats.
- ownershipForMergeInput now treats user_edited=true as 'modified'
  regardless of ownership_before — matches what drift would report at
  runtime (scenario 07 depends on this).

Tests: all 17 fixtures pass; 7 new drift classification unit tests
cover every DriftReport bucket independently of the merge fixtures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#47)

M3 three-way merge engine is done: drift.ts + differ.ts + 17 fixtures
green. Orphan detection (files under tracked paths but not in state)
is deferred to v0.2 as #47 — no v0.1 flow consumes it and the
iterator-exploded directory semantics need design first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 19, 2026 15:53
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Implements ShardMind’s Milestone 3 “merge engine” by adding drift detection, a three-way merge planner, and an in-memory rendering entrypoint needed by the differ to render cached/new templates without touching disk.

Changes:

  • Added detectDrift() to classify tracked files into managed/modified/volatile/missing (with orphaned stubbed for v0.2).
  • Added computeMergeAction() + threeWayMerge() using node-diff3 merge regions and Git-style conflict markers.
  • Exposed renderString() with a lazy, isolated Nunjucks environment and added/expanded unit tests for renderer + drift/differ behaviors.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/unit/renderer.test.ts Adds unit coverage for renderString (frontmatter normalization, error behavior, custom env).
tests/unit/drift.test.ts Unskips and updates fixture runner to dispatch orchestration-level cases and validate conflict markers.
tests/unit/drift-classification.test.ts New direct unit tests covering all detectDrift buckets independently of merge fixtures.
source/core/renderer.ts Adds renderString() and shared Nunjucks options with a lazily created isolated environment.
source/core/drift.ts Introduces drift classification via sha256 comparison + volatile/missing handling; orphaned deferred.
source/core/differ.ts Introduces merge-action computation and a region-based three-way merge with stats + conflict regions.
ROADMAP.md Marks Milestone 3 complete and adds deferred orphan detection item (#47).

Comment thread source/core/differ.ts Outdated
Comment on lines +113 to +115
const baseLines = base.split('\n');
const theirsLines = theirs.split('\n');
const oursLines = ours.split('\n');
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

threeWayMerge splits input on "\n" only. If the on-disk file uses CRLF ("\r\n"), each line in theirsLines will retain a trailing "\r", which can create spurious diffs/conflicts and inaccurate merge stats when compared against base/ours rendered with LF. Normalize line endings (e.g., convert CRLF→LF for all three inputs or split on /\r?\n/) before diff3 to make merges stable across platforms/editors.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good catch — valid concern, fixed in 31c9e04.

threeWayMerge now splits all three inputs on /\r?\n/, so a CRLF theirs on Windows no longer fights LF base / ours (renderer output is always LF). Merged output stays LF; a comment on the split documents the choice and flags that callers should convert to platform-native line endings at the write boundary if ever needed.

Covered by tests/unit/differ-line-endings.test.ts — one test for identity (CRLF theirs matching LF base/ours → no conflict) and one for a genuine conflict that only CRLF-normalization could reveal.

breferrari and others added 6 commits April 19, 2026 18:01
…actory

Code-review pass on the merge engine PR:

- New source/runtime/errno.ts with errnoCode + isEnoent replaces eight
  inline copies of the `err instanceof Error && 'code' in err ? (err as
  NodeJS.ErrnoException).code : undefined` pattern across core and
  runtime modules. Lives in runtime/ so both layers can import without
  crossing the one-way runtime → core boundary.
- detectDrift in source/core/drift.ts parallelizes per-file reads via
  Promise.all and split the inline body into volatileEntry /
  missingEntry / hashedEntry helpers. ~50× faster on large vaults.
- New tests/helpers/shard-state.ts centralizes ShardState construction.
  drift.test.ts and drift-classification.test.ts both use it; more
  callsites (state.test.ts, install-planner.test.ts) can migrate later.

All 214 tests remain green; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renderer output is always LF, but a user's file on disk may be CRLF
on Windows. Splitting on '\n' alone left trailing '\r' on every line
of `theirs`, producing spurious conflicts against LF `base`/`ours`.

Split on /\r?\n/ for all three inputs; merged output is LF. Callers
that need platform-native line endings should convert at the write
boundary.

New unit file tests/unit/differ-line-endings.test.ts covers identity
(CRLF theirs matching LF base/ours) and a real CRLF-theirs conflict.

Addresses Copilot review on PR #48.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
shardmind installs as a global CLI and runs wherever the user's vault
lives. Ubuntu-only coverage missed the CRLF-on-Windows merge regression
Copilot flagged on #48 — make all three OSes first-class. fail-fast is
off so a Windows-only fault doesn't mask a macOS one.

defaults.run.shell: bash makes the same step script work on all three
runners (Windows runners have git-bash pre-installed), and `rm -f`
tolerates the missing lockfile path on fresh checkouts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
.claude/scheduled_tasks.lock is session-local runtime state (Claude
Code writes it to coordinate its own wakeup timer). Ignore it alongside
.claude/memory/ — same category of "never commit, not user content".
.claude/ itself stays tracked since that's where shardmind installs
managed assets into a vault.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-review pass on the code this PR introduced. All 222 tests green.

source/core/differ.ts
- Hoist `ThreeWayMergeResult` to a named exported type, drop the
  `ReturnType<typeof ...>` workaround.
- Mark `ComputeMergeActionInput` fields readonly (it's an input contract).
- Pull conflict-handling into a pure `resolveUnstableRegion(region,
  mergedLengthBefore)` — no out-parameters, returns everything it
  produces. Easier to reason about and test.
- Pull `runMerge()` wrapper for the ShardMindError translation so the
  happy path in computeMergeAction stays linear.
- `arraysEqual` is now `length === && every()` over `readonly T[]` —
  idiomatic TS, callable with diff3's readonly arrays.
- Drop the `// ownership === 'modified'` narrative comment (the
  discriminated union already proves it).

FIX: Stats accounting was undercounting `linesAutoMerged`. A stable
region with `buffer === 'a'` or `'b'` means diff3 resolved to one
side's version without ambiguity — that's auto-merged, not unchanged.
Only `buffer === 'o'` is truly unchanged. Caught by the new direct
threeWayMerge tests; fixture suite didn't pin down the stats numbers.

source/core/drift.ts
- Import `FileState` from runtime/types.js instead of using
  `ShardState['files'][string]`. (Lazy, now fixed.)
- Extract `classifyFile` + `classifyByHash` helpers. The classifier
  now returns the target bucket directly, so the outer loop collapses
  to a single `byBucket[bucket].push(entry)` — no second discriminant
  check needed.

tests/helpers/
- Add `makeFileState` factory and a barrel `index.ts` so test files
  import from `../helpers` once the surface grows.
- Replace the magic `'x'.repeat(64)` with a named `PLACEHOLDER_HASH`.

tests/unit/drift.test.ts
- Classify each scenario up-front (`ScenarioKind`) and dispatch via a
  switch with `assertNever` exhaustiveness. Each branch is now a named
  assertion function (`assertVolatile`, `assertNewFile`, etc.) instead
  of an inline if-chain — easier to read and extend.

tests/unit/three-way-merge.test.ts (new)
- 6 direct unit tests for the merge primitive covering unchanged /
  auto-merge / conflict stats, false-conflict handling, and
  non-adjacent conflict regions with distinct line ranges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the free-form `code: string` on ShardMindError with a string
literal union listing all 39 codes the engine can emit (source/runtime/
errors.ts). TypeScript now refuses any typo at every callsite — no
runtime-only surprises, no code-grep archaeology to find what codes
exist.

The union is organized by domain (vault, manifest, schema, values,
state, registry/download, rendering, install, merge) with a header
comment on each group. Adding a new code is a one-line change in the
registry; the compiler then surfaces every site that needs it.

ErrorCode is exported from shardmind/runtime so hook scripts that
catch ShardMindError can narrow on code without hardcoding strings.

Intentional duplicates (VALUES_NOT_FOUND vs VALUES_MISSING,
VALUES_READ_FAILED vs VALUES_FILE_READ_FAILED) are documented in the
registry — runtime layer vs commands layer, different recovery hints.
Unification is a separate design concern.

All 222 tests green; typecheck clean on first pass (every existing
callsite happened to spell its code correctly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reversing the v0.2 deferral. obsidian-mind's core UX promise — "ShardMind
manages X, leaves Y alone" — depends on the manager/user boundary being
visible. Without orphan reporting, `shardmind` status has no way to show
what files are user territory vs shard territory. That's the value prop
of a package manager, not polish.

The "iterator-exploded directory semantics need design first" rationale
was also weaker than it looked. The rule is straightforward:

  parent directory of a tracked file = tracked directory
  any file in a tracked directory not in state.files = orphan
  non-recursive — subdirectories count only if they hold tracked files

Applied to obsidian-mind:
  skills/leadership.md tracked → skills/my-extra.md = orphan ✓
  CLAUDE.md tracked            → brain/daily/2026-04-19.md ≠ orphan
                                 (brain/daily/ isn't tracked) ✓

Implementation details:
- detectOrphans runs in parallel with the classification map via
  Promise.all. No added latency on the happy path.
- Engine scaffolding (`shard-values.yaml`) and third-party metadata
  (`.shardmind/`, `.git/`, `.obsidian/`) are explicitly excluded. The
  excluded list is a named ReadonlySet in drift.ts, not a sprinkle of
  magic strings.
- ENOENT on a scanned directory is treated as "nothing to report",
  not an error — handles the case where a tracked directory has been
  rm'd out from under us.

Tests: 5 new cases in drift-classification.test.ts covering orphan in
tracked dir, non-recursion into untracked subdirs, engine-reserved-file
exclusion, .shardmind/.git/.obsidian exclusion, and multi-dir aggregation.

ROADMAP: remove the v0.2 bullet for #47.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 23 out of 24 changed files in this pull request and generated 4 comments.

Comment thread source/core/drift.ts
Comment on lines +44 to +51
const [classified, orphaned] = await Promise.all([
Promise.all(
Object.entries(state.files).map(([relPath, file]) =>
classifyFile(vaultRoot, relPath, file),
),
),
detectOrphans(vaultRoot, trackedPaths),
]);
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

detectDrift() uses Promise.all over every entry in state.files (and over trackedDirs for orphan scanning). In large vaults this can trigger EMFILE/too-many-open-files errors or cause big I/O bursts. Consider processing files with a small concurrency limit (or sequentially) so drift detection remains reliable under high file counts.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

EMFILE risk is real at scale. Fixed in df54469: added a mapConcurrent(items, 32, fn) helper that caps in-flight file handles at 32 while preserving input order. 32 stays well under typical OS fd limits (macOS defaults to ~256) while still saturating disk on realistic vaults.

Comment thread source/core/drift.ts
Comment on lines +71 to +77
// FileState.ownership uses the literal 'user' for volatile files (what the
// install engine writes to state.json); DriftEntry.ownership reports it as
// 'volatile' because that is the reporting-layer vocabulary used by the
// update command. Intentional naming gap, not a bug.
if (file.ownership === 'user') {
return { bucket: 'volatile', entry: volatileEntry(relPath, file) };
}
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

FileState.ownership uses the literal 'user' (see runtime/types.ts), while docs/IMPLEMENTATION.md §4.8 describes 'volatile', and this module maps 'user'→reported 'volatile'. This naming split is easy to miss for future contributors; consider aligning the persisted state value with the reporting vocabulary (or updating the spec/docs to explicitly call out the 'user' persisted value and why).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Already addressed — the 'user' → 'volatile' naming gap is documented both in the inline comment at drift.ts:72-75 and in docs/IMPLEMENTATION.md §4.8 (updated in cabe217). Leaving both names as-is: 'user' in the persisted state because that's what the install engine writes; 'volatile' in the reporting layer because that's what the update command displays. Renaming either breaks backwards compatibility with existing state.json files.

Comment thread source/core/drift.ts
Comment on lines +125 to +153
/**
* A file is orphaned when it sits in a directory that contains at least one
* state-tracked file, yet isn't itself in state.files. Non-recursive: a
* subdirectory only counts as tracked if it directly holds a tracked file.
*
* Example: if `skills/leadership.md` is tracked, then `skills/` is a tracked
* directory; a user-created `skills/my-extra.md` is an orphan. But
* `brain/daily/2026-04-19.md` (under an untracked sub-directory) is user
* content that ShardMind never claimed — not an orphan.
*
* Engine scaffolding (`.shardmind/`, `shard-values.yaml`) and third-party
* metadata (`.git/`, `.obsidian/`) are excluded.
*/
async function detectOrphans(
vaultRoot: string,
trackedPaths: ReadonlySet<string>,
): Promise<string[]> {
const trackedDirs = new Set<string>();
for (const relPath of trackedPaths) {
const dir = path.posix.dirname(toPosixPath(relPath));
trackedDirs.add(dir === '.' ? '' : dir);
}

const perDir = await Promise.all(
[...trackedDirs].map(relDir => scanDirForOrphans(vaultRoot, relDir, trackedPaths)),
);

return perDir.flat().sort();
}
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

This PR implements orphan detection (detectOrphans/scanDirForOrphans) and adds orphan-focused unit tests, but the PR description + linked issue #47 state orphan detection is deferred and detectDrift returns orphaned: []. Please reconcile the intended scope by either updating the PR description/issues/docs to reflect the implemented behavior, or reverting orphan detection to the deferred stub if v0.1 shouldn’t surface orphans yet.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Stale — orphan detection was pulled into v0.1 during review and the scope was reconciled. PR body was refreshed, ROADMAP.md Milestone 3 bullet tracks the v0.1 landing, and issue #47 is closed with the rationale (e5304dd). detectDrift now returns a populated orphaned array, which matches what the tests exercise.

Comment thread source/core/differ.ts Outdated
Comment on lines +116 to +125
// Normalize line endings to LF before splitting. `base` and `ours` are
// renderer output (always LF), but `theirs` comes from disk and may be
// CRLF on Windows. Without this, every line in theirs would trail with
// '\r', producing spurious conflicts. Merged output is LF; callers that
// need platform-native line endings convert at the write boundary.
const regions: IRegion<string>[] = diff3MergeRegions(
theirs.split(/\r?\n/),
base.split(/\r?\n/),
ours.split(/\r?\n/),
);
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

threeWayMerge() splits on /\r?\n/, which will produce a trailing "" element when the input ends with a newline. That extra sentinel line gets counted into stats and affects ConflictRegion lineStart/lineEnd, so line accounting can be off-by-one (and tests currently allow this via >= checks). Consider normalizing to a line array that strips the final empty element while separately preserving whether the original had a trailing newline, then re-append the newline when joining the merged output.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good catch on the stats inflation. Fixed in df54469 — when all three inputs end with \n, split(/\r?\n/) produces a trailing "" that diff3 emits as a stable region and inflates linesUnchanged by 1. After the merge loop, we now detect this and subtract one from linesUnchanged.

Kept the trailing "" flowing through diff3 itself rather than stripping it — a newline-status difference between theirs and ours is a legitimate auto-merge concern, not a pre-merge normalization. My first attempt stripped + re-appended based on ours' style, which broke the property test "theirs-only-changed ⇒ output equals theirs". The stats-only correction preserves merge semantics and fixes the off-by-one in reporting.

breferrari and others added 4 commits April 19, 2026 18:27
… helper)

Addresses findings from the second /simplify review pass.

- source/runtime/vault-paths.ts: add GIT_DIR and OBSIDIAN_DIR
  constants. drift.ts's UNSCANNED_DIRS set now imports them instead of
  hardcoding '.git' / '.obsidian' — matches the existing
  SHARDMIND_DIR / VALUES_FILE centralization.

- source/core/drift.ts: inline the single-use `toPosixPath` helper.
  It collided in name with `toPosix(from, to)` in fs-utils.ts (which
  has a different signature and purpose); the one-call-site slash
  normalization doesn't pay for a shared function.

- source/core/differ.ts: extract `LINE_SPLIT = /\r?\n/` to a
  module-level constant; threeWayMerge used the literal three times on
  consecutive lines. Comment moved to the constant so the CRLF
  rationale has a single home.

- tests/helpers/shard-state.ts: document makeStateWithFiles as a
  shorthand, which is exactly what it is — 13 drift-test callsites
  stay concise, which is why the helper earns its keep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four follow-through fixes on findings I initially waved away:

1. Remove makeStateWithFiles helper — it was just makeShardState({
   files }) with extra sugar. Two helpers for the same operation is a
   lookup tax on every reader; one helper is cleaner. Updated 13
   callsites across drift.test.ts + drift-classification.test.ts.

2. Hoist `import type { Dirent } from 'node:fs'` in drift.ts so the
   scanner signature reads cleanly instead of inline
   import('node:fs').Dirent[] expression.

3. Extract MergeStatsWithConflicts as a named type in runtime/types.ts.
   ThreeWayMergeResult and MergeResult.stats both reference it now,
   instead of `MergeResult['stats']` indexed access + inline shape.
   Extends MergeStats so the auto_merge-only stats and the conflict-
   aware stats are clearly related.

4. `trackedPaths` scoping — re-inspected; correctly scoped, one-time
   Set construction shared between detectOrphans and the classifier.
   No change needed.

All 227 tests green; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last unchecked bullet on ROADMAP Milestone 3 and the
"Edge case fixtures pass" line on #11's acceptance criteria. I had
cut this corner in the first pass and only caught it on audit.

Three new fixtures extend the corpus from 17 to 20:

- 18-empty-file: base template is empty, new template adds content.
  Ownership managed → overwrite. Guards against any future regression
  where empty-string rendering gets special-cased incorrectly.

- 19-utf8-non-ascii: emoji, accented Latin, CJK, combining marks.
  Auto-merge path — shard and user both edit, disjoint. Ensures no
  Buffer/string conversion silently mangles multi-byte characters in
  a way ASCII-only fixtures wouldn't reveal.

- 20-frontmatter-modified-merge: modified ownership, user added a
  frontmatter tag while shard updated the body. Covers frontmatter +
  body interleaving via diff3 that scenario 12 (managed, frontmatter-
  only, overwrite) never exercises.

Hash-identical / binary-identical: not added as a new fixture. The
`sha256(base) === sha256(ours)` shortcut is already exercised by
scenario 01 (no-change → skip) and scenario 05 (modified-no-upstream
→ skip). A redundant fixture would test the same code path.

The runner's "discovers N scenarios" assertion is now parameterized
by a named constant so future additions are a one-line bump.

230 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final audit pass on PR #48. Two doc drifts introduced by the
implementation that the spec didn't catch up with:

§4.9 (differ): algorithm step 6a said `diff3Merge(...)`. Implementation
uses `diff3MergeRegions(...)` because the flat `diff3Merge` MergeRegion
shape can't distinguish stable-unchanged lines (buffer === 'o') from
stable-auto-merged lines (buffer === 'a' | 'b'), and the acceptance
criteria require accurate stats. Step 6a now documents the regions
algorithm, CRLF-tolerant split, and the stable/unstable classification
rules. MergeStatsWithConflicts is called out as its own named type
(matches runtime/types.ts).

§4.8 (drift): algorithm was silent on the orphan scan because the
original design deferred it. Orphan detection is now part of v0.1 (see
#47, closed). Algorithm documents the parallel scan via Promise.all,
the tracked-directory derivation (parent dir of each tracked file), the
non-recursive scan rule, and the engine-reserved / never-scanned
exclusions. Includes a short rationale block on why we don't recurse.

PR #48 body refreshed (separate from this commit) to reflect the final
state: 230 tests, 20 fixtures, orphan detection landed, ErrorCode
union, CRLF fix, cross-OS CI matrix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 44 out of 47 changed files in this pull request and generated 4 comments.

Comment thread source/core/drift.ts
Comment on lines +174 to +180
const orphans: string[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
if (UNSCANNED_DIRS.has(entry.name)) continue;
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
if (trackedPaths.has(rel)) continue;
if (ENGINE_RESERVED_FILES.has(rel)) continue;
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

In scanDirForOrphans, UNSCANNED_DIRS is meant to prevent scanning directories like .shardmind/, .git/, .obsidian/, but the check is currently UNSCANNED_DIRS.has(entry.name) after entry.isFile(), so it never matches (those are directories, and entry.name is just the filename). This also means a tracked directory that is one of these (e.g. if state ever contains a path under .git/) would still be scanned, contradicting the doc comment/spec.

Suggested fix: remove the entry.name check and instead short-circuit at the directory level (e.g. in scanDirForOrphans, if (UNSCANNED_DIRS.has(relDir.split('/')[0] ?? relDir)) return []; or check relDir === '.git' / startsWith('.git/') etc.).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Correct — the UNSCANNED_DIRS.has(entry.name) check ran after entry.isFile() filtered out directories, so it was effectively dead code.

Fixed in df54469 by moving the check to detectOrphans where trackedDirs is derived: any directory whose top segment is .shardmind, .git, or .obsidian is dropped from the set before any readdir is issued. If a shard somehow tracks .git/HEAD, that whole directory is skipped at the source. Renamed the constant to UNSCANNED_DIR_NAMES to reflect the new role.

Comment thread source/core/renderer.ts Outdated

function getDefaultStringEnv(): nunjucks.Environment {
if (!defaultStringEnv) {
defaultStringEnv = new nunjucks.Environment(null, NUNJUCKS_OPTS);
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

getDefaultStringEnv constructs new nunjucks.Environment(null, ...). Passing null as the loader list is risky because Environment expects undefined, a loader, or an array of loaders; null can behave differently across Nunjucks versions and may throw if internal code assumes an array/object.

Suggested fix: pass undefined (omit the first argument) or [] as the loaders argument to explicitly represent “no loaders”, keeping the env truly string-only and avoiding potential runtime incompatibilities.

Suggested change
defaultStringEnv = new nunjucks.Environment(null, NUNJUCKS_OPTS);
defaultStringEnv = new nunjucks.Environment([], NUNJUCKS_OPTS);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fair — swapped to new nunjucks.Environment([], NUNJUCKS_OPTS) in df54469. Added a comment noting the empty loader array explicitly represents 'no filesystem resolution', which is the intent for in-memory string rendering.

Comment thread tests/unit/drift.test.ts
Comment on lines +111 to 118
// expected-output.md is optional — conflict scenarios omit it because
// the marker format is implementation-defined.
let expectedOutput: string | null = null;
try {
expectedOutput = await fsp.readFile(path.join(base, 'expected-output.md'), 'utf-8');
} catch {
// Some scenarios (e.g. conflicts) deliberately omit expected-output.md
// because the merge markers depend on implementation details.
/* no-op */
}
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

loadFiles treats expected-output.md as optional, but the catch { /* no-op */ } currently swallows all read errors (permissions, path mistakes, etc.), which can mask real fixture/test setup problems.

Suggested fix: only ignore ENOENT (file missing) and rethrow other errors so the test fails loudly when the fixtures directory is misconfigured.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Right — the blanket catch was swallowing permissions/path errors too. Narrowed to ENOENT in df54469; any other read failure now throws so fixture-setup problems surface loudly.

Comment thread source/runtime/errno.ts Outdated

export function errnoCode(err: unknown): string | undefined {
if (err !== null && typeof err === 'object' && 'code' in err) {
return (err as { code?: string }).code;
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

errnoCode is typed to return string | undefined, but Node’s ErrnoException.code can be string | number. The current implementation returns (err as { code?: string }).code without verifying the runtime type, so it can return a number while claiming it’s a string, which is an unsound contract and can lead to confusing error messages/comparisons.

Suggested fix: either (a) widen the return type to string | number | undefined, or (b) keep the string | undefined API but explicitly guard typeof code === 'string' before returning.

Suggested change
return (err as { code?: string }).code;
const code = (err as { code?: unknown }).code;
return typeof code === 'string' ? code : undefined;

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good defensive catch. Fixed in df54469 with the suggested typeof === 'string' guard. Node's types document code as string, but a non-string on a custom thrown object would otherwise silently violate our return type.

breferrari and others added 2 commits April 19, 2026 19:09
Showcase-pass: this is the cutting-edge rigor the merge engine deserves.

## The bug

Property testing via fast-check surfaced a real crash in node-diff3
that no fixture caught: its LCS implementation uses `{}` as a map and
iterates on string keys, which collides with Object.prototype members
when any line equals 'constructor', '__proto__', 'toString',
'hasOwnProperty', etc.:

    TypeError: equivalenceClasses[item].push is not a function

For a vault of markdown/code content, a line containing the literal
word `constructor` is routine — this would crash obsidian-mind on
random user files.

## The fix

threeWayMerge now prefixes every line with U+0001 (START OF HEADING,
never a valid printable character) before handing arrays to
diff3MergeRegions. No prototype member name starts with U+0001, so
the collision can't happen. Prefixes are stripped from all outputs
(merged content, conflict region snapshots) before they leave the
module — consumers never see them.

Explicit regression test in three-way-merge.test.ts covers the
'constructor' / '__proto__' / 'toString' / 'hasOwnProperty' /
'valueOf' case.

## Property tests (tests/unit/three-way-merge-properties.test.ts)

Six invariants pinned down across 200 random cases each (= 1200
generated scenarios):

1. Identity: merge(x, x, x) is x with no conflicts.
2. Theirs only: base === ours ⇒ merged content equals theirs.
3. Ours only:  base === theirs ⇒ merged content equals ours.
4. False conflict: theirs === ours ⇒ merged content equals theirs.
5. CRLF invariance: CRLF theirs produces the same conflict count
   as LF theirs of the same content.
6. Stats bookkeeping: linesUnchanged + linesAutoMerged +
   linesConflicted ≤ total emitted lines.

Dev dep added: fast-check.

## Docs

- docs/ARCHITECTURE.md §17.3 table now reflects all 20 scenarios.
- §17.4 code sample shows diff3MergeRegions (not diff3Merge) and
  explains why the regions variant is load-bearing for accurate stats.
- §17.5 corrects the frontmatter-merge decision — we line-merge the
  rendered document (parse → stringify via yaml, then diff3), we do
  NOT deep-merge YAML objects. Fixture 20 proves this works.
- §17.6 test-build-order checkboxes updated to reflect what shipped.
- CHANGELOG.md gains an [Unreleased] section documenting the merge
  engine, ErrorCode union, CRLF fix, cross-OS CI, and 20 fixtures.

237 tests green; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ran an adversarial test pass deliberately trying to break the engine.
Wrote ~60 stress tests across 11 categories; 3 categories failed and
surfaced genuine bugs, each fixed in this commit.

## Bug 1 — Prototype pollution via user content (found earlier, already fixed)

Covered in 03248fe. Documented here for narrative continuity.

## Bug 2 — Sentinel corruption of user content

The original fix for the node-diff3 prototype crash prefixed every line
with U+0001 and global-stripped U+0001 from output. This corrupts user
content that legitimately contains U+0001 (unlikely in markdown but
possible, and a determined adversary could construct it).

Fix: replace the sentinel-prefix approach with a `LineInterner` that
maps every unique line to an integer-named token ("0", "1", "2", ...).
Integer-named keys can never collide with Object.prototype members,
so diff3's hash-map crash is impossible by construction. Tokens are
mapped back to original lines on output — user content is never
touched, the encoding is foolproof.

See source/core/differ.ts: LineInterner class replaces prefixLines /
stripPrefixes / stripPrefix.

## Bug 3 — YAML-hostile frontmatter values crash the renderer

Template `owner: {{ name }}` with `name = "Alice: AI researcher"`
rendered to `owner: Alice: AI researcher` which is invalid YAML, and
the renderer threw RENDER_FRONTMATTER_ERROR. In a user-facing product
this means any value containing a colon, pipe, or quote would crash
the entire update flow. Not acceptable for a showcase.

Fix: `renderFrontmatterSafely` attempts the naive render first. If
parse fails, it retries with every string leaf in the context
JSON-encoded (which is always a valid YAML double-quoted scalar).
Non-string values (numbers, booleans, arrays of primitives) are left
untouched so their YAML type is preserved. Only if recovery also fails
do we throw — meaning the template itself has a structural problem
independent of the values.

## Bug 4 — Circular references in values crash the recovery walker

Surfaced by the test I added for bug 3: a value with a self-reference
(possible through hooks or computed defaults) sent encodeStringLeaves
into infinite recursion and stack-overflow.

Fix: encodeStringLeaves takes a WeakMap<object, unknown> `seen`
parameter. On re-entry into an already-visited object, returns the
cached stand-in. Cycles terminate; the encoded output is a valid
acyclic JSON-compatible tree.

## What now passes

- 60+ adversarial tests across: prototype pollution vectors, sentinel
  corruption, line-ending edge cases (CRLF, CR, mixed, no trailing
  newline), conflict marker injection (user content containing
  `<<<<<<< yours` etc.), unicode/BOM/null bytes, size extremes (10K
  chars, 5K lines, many conflicts), idempotence/convergence, conflict
  boundaries (start/end/entire file), drift races (file deleted
  mid-scan, directory deletion, Windows path separators), exotic
  value types (Date, function, Symbol), renderer context hardening
  (circular refs, deep nesting), token interning stress (10K lines,
  all duplicates, all unique), whitespace-sensitive diffs, stats
  bookkeeping under stress.

## Test count

230 → 300 tests. Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 49 out of 53 changed files in this pull request and generated 3 comments.

Comment thread source/core/drift.ts Outdated
Comment on lines +43 to +52
const trackedPaths = new Set(Object.keys(state.files));

const [classified, orphaned] = await Promise.all([
Promise.all(
Object.entries(state.files).map(([relPath, file]) =>
classifyFile(vaultRoot, relPath, file),
),
),
detectOrphans(vaultRoot, trackedPaths),
]);
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

detectOrphans() normalizes tracked directories to posix separators, but trackedPaths itself is left unnormalized. If state.files ever contains Windows-style keys (e.g. nested\\file.md), scanDirForOrphans() will generate nested/file.md and fail the trackedPaths.has(rel) check, incorrectly reporting a tracked file as an orphan. Normalize trackedPaths to a consistent separator (or maintain a parallel normalized Set for orphan detection) so tracked files never appear in orphaned even with mixed separators.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in df54469trackedPaths is now normalized to forward slashes on construction, so the trackedPaths.has(rel) check inside scanDirForOrphans works whether state.files was written with posix or native separators. install-executor already emits posix, but the defensive normalization costs nothing and removes a silent-failure mode.

Comment thread source/core/differ.ts Outdated
Comment on lines +132 to +133
// Intern every unique line to an integer-named token. See the block
// comment on LINE_SPLIT for why this is load-bearing.
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

The comment says “See the block comment on LINE_SPLIT for why this is load-bearing”, but the load-bearing rationale is the prototype-collision workaround (line interning), not the line-splitting regex. This is misleading when maintaining the code; consider updating the reference to point at the interning/prototype-collision comment instead.

Suggested change
// Intern every unique line to an integer-named token. See the block
// comment on LINE_SPLIT for why this is load-bearing.
// Intern every unique line to an integer-named token. This is
// load-bearing because the interning/prototype-collision workaround
// depends on diffing tokens rather than raw line strings.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Stale reference fixed in df54469. Updated the comment to point at the LineInterner/prototype-collision rationale (which is the load-bearing part); LINE_SPLIT is just about CRLF tolerance.

Comment thread tests/unit/merge-adversarial.test.ts Outdated
Comment on lines +28 to +33
describe('merge adversarial — sentinel corruption vectors', () => {
// differ.ts prefixes every line with U+0001 to sidestep the node-diff3
// prototype-pollution crash. If the strip pass is implemented naively
// (global replace), legitimate user content containing U+0001 would be
// mangled. Probe for it.

Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

The comment claims differ.ts prefixes every line with U+0001 and then strips it, but the current implementation uses integer token interning (no sentinel prefix). This makes the motivation for these tests inaccurate and harder to trust. Update the comment to match the actual interning approach (or drop the sentinel rationale) while keeping the round-trip assertions.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Rewrote the describe block in df54469 to describe the current LineInterner-based approach. Kept the round-trip tests as a regression guard — they prove user content containing U+0001 survives the merge, which an earlier sentinel-prefix implementation would have mangled. Also dropped two .not.toContain('\u0001') assertions elsewhere that were sentinel-era leftovers.

Opened a fork of node-diff3 for issue #86 / PR #87 upstream and
cloned it into this workspace for iteration. Keep it out of
shardmind's working tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
breferrari and others added 3 commits April 19, 2026 19:47
The node-diff3 clone doesn't belong next to this repo; removed the
directory and the matching ignore line. The upstream PR (#87 on
bhousel/node-diff3) stays — this just cleans up the local workspace.

This reverts commit 45337c0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Once bhousel/node-diff3#87 is merged and released upstream, the
LineInterner class in differ.ts can be removed in favor of passing
raw string arrays to diff3MergeRegions. Cross-linked three places
so the workaround doesn't get forgotten:

- Issue #49 documents the removal steps.
- source/core/differ.ts comment above LineInterner references #49
  and the upstream PR.
- ROADMAP.md v0.2 Engine polish bullet points to both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot flagged 12 issues on PR #48 post-hardening. Triaged: 9 real,
3 stale. This commit addresses all 9.

source/core/drift.ts
- Bounded concurrency on per-file reads. `detectDrift` previously
  issued unlimited `Promise.all` over every tracked file, which can
  trip EMFILE/too-many-open-files on large vaults. New
  `mapConcurrent(items, 32, fn)` helper caps in-flight handles at 32
  while preserving input order. (3107133175)
- `trackedPaths` now normalized to forward slashes on construction.
  `scanDirForOrphans` builds relative paths with `/`, and if any
  state.files key slipped through with native separators the
  `trackedPaths.has(rel)` check would miss and falsely orphan a
  tracked file. (3107243197)
- `UNSCANNED_DIRS` was dead code — the check at `entry.name` ran
  *after* `entry.isFile()`, so it could never match a directory and
  its stated purpose (preventing scans into `.shardmind/`, `.git/`,
  `.obsidian/`) was moot. Moved the check to `detectOrphans` where
  tracked directories are derived; if a shard somehow tracks a file
  under one of those namespaces, that directory is skipped at the
  source. Renamed to `UNSCANNED_DIR_NAMES` to reflect the new role.
  (3107192187)

source/core/differ.ts
- Trailing-newline stats correction. `split(/\r?\n/)` produces a
  trailing "" for newline-terminated content; diff3 emits it as part
  of a stable region, which padded `linesUnchanged` by 1. When all
  three agreed on the trailing "", subtract one from `linesUnchanged`
  so stats match user-visible line counts. The merge itself is
  unchanged — trailing newlines are legitimate document content that
  should flow through diff3 like any other line. (3107133188)
- Updated the comment above `diff3MergeRegions` to point at the
  LineInterner/prototype-collision rationale, not `LINE_SPLIT`.
  LINE_SPLIT is about CRLF tolerance; the load-bearing bit is the
  interning. (3107243211)

source/core/renderer.ts
- `new nunjucks.Environment([], ...)` instead of `(null, ...)`. null
  isn't a documented loader value; an empty loader array explicitly
  represents "no filesystem resolution", which is the intent.
  (3107192203)

source/runtime/errno.ts
- Runtime `typeof === 'string'` guard on the extracted `code`. Node's
  types say `code?: string`, but a stray non-string on a custom error
  would have silently violated our return type. Now unsound values
  return `undefined`. (3107192220)

tests/unit/drift.test.ts
- `loadFiles` narrowed the `expected-output.md` read's catch to ENOENT
  only. Other errors (permissions, etc.) now throw and surface the
  real fixture-setup problem instead of being swallowed. (3107192214)

tests/unit/merge-adversarial.test.ts
- Updated stale "sentinel prefix" describe block to describe the
  current `LineInterner` approach. (3107243218)
- Removed two leftover `.not.toContain('\u0001')` assertions.
  Sentinel encoding is gone — those checks would incorrectly fail
  if user content ever contained `\u0001`, which is exactly what the
  top-of-file tests verify is now preserved.

Stale (replied, not changed):
- CRLF concern (3107097177) — already fixed in 31c9e04.
- Naming-gap docs (3107133181) — already documented in
  docs/IMPLEMENTATION.md §4.8 in cabe217.
- Orphan scope reconciliation (3107133183) — PR body refreshed mid-
  session; ROADMAP reflects the v0.1 landing.

300 tests green; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@breferrari breferrari merged commit 13b8e6c into main Apr 19, 2026
6 checks passed
@breferrari breferrari deleted the feat/11-merge-engine branch April 19, 2026 18:01
breferrari added a commit that referenced this pull request Apr 20, 2026
Aimed at the #48 bar. Four real bugs were hiding under the first pass,
all caught by a deliberate adversarial audit and the component-test
precedent install already set.

## Bugs fixed

- **Data loss in migrator** (`core/migrator.ts`) — `rename: a → b` silently
  overwrote an existing value at `b`. Now refuses the overwrite, warns,
  and keeps both keys so the user can untangle manually. Regression test
  in `migrator.test.ts`.
- **Concurrent backup-dir collision** (`core/update-executor.ts`) — the
  ISO timestamp stripped fractional seconds, so two same-second updates
  allocated the same `.shardmind/backups/update-<ts>/` directory and
  clobbered each other's rollback snapshots. Timestamp now retains ms
  and a numeric suffix is probed on EEXIST. Regression in
  `update-adversarial.test.ts`.
- **DiffView CRLF corruption** (`components/DiffView.tsx`) — the view
  split on `\n` only, leaving `\r` on each line. Ink treated those as
  carriage returns and corrupted the terminal when rendering Windows-
  saved user files. Now splits on `/\r?\n/` everywhere to match
  `differ.ts`.
- **keep_as_user left files tracked** (`core/update-executor.ts`) — the
  "keep my edits (untrack)" decision kept the file in `state.files`;
  next update still considered it managed. Now drops the entry on apply.
  Regression in `update.test.ts`.

## Also

- SIGINT now always runs the shard tempdir cleanup — cancelling during
  the prompt/wizard phase used to leak the extracted tarball.
- `DiffView`'s choice handler uses a `Set<DiffAction>` allowlist instead
  of a bare cast, so the disabled "Open in editor" option can never
  bypass the v0.2 stub.
- `useSigintRollback` API cleaned up: drops the redundant `enabled`
  param (caller folds the condition into `isActive`) and adds a
  `cleanup` callback that runs on every Ctrl-C.
- `conflictFromDirect` now produces a cache-relative `templateKey`
  (previously passed `''` for tempDir and emitted an absolute path
  that would break future updates if the fallback ever fired).
- `NewValuesPrompt` + `RemovedFilesReview` use a `submittedRef` +
  per-file `key` to prevent the re-entrant double-submit that the
  new component tests caught.

## Tests added (62 new)

- `tests/unit/migrator-adversarial.test.ts` — 18 tests incl. 4
  `fast-check` properties × 200 runs = 800 generative scenarios.
  Covers prototype-key pollution, non-Error throws, unserializable
  transforms, BOM + null bytes, cyclic values, pre-release + build-
  metadata semver, chained renames, idempotence, transitivity.
- `tests/unit/update-adversarial.test.ts` — 11 tests covering missing
  cached templates (→ `conflictFromDirect` fallback), iterator-shrink
  cleanup, CRLF user files + LF templates, Unicode/emoji filenames,
  inconsistent drift + state, concurrent `createBackupDir`, rollback
  idempotency, render determinism (property).
- `tests/component/` — 6 new files for `DiffView`, `CommandFrame`,
  `NewValuesPrompt`, `NewModulesReview`, `RemovedFilesReview`,
  `UpdateSummary`, matching the 9-file precedent set by install.
  DiffView alone covers header rendering, context lines, singular/
  plural region labels, each DiffAction, disabled-option filtering,
  and CRLF robustness.
- `tests/integration/update.test.ts` — 2 new cases: `--dryRun` does
  not mutate the vault; corrupted template cache surfaces the file
  as a full-file conflict via `conflictFromDirect`.

341 → 405 tests passing.

## Docs

- `docs/IMPLEMENTATION.md` §3 — update data flow diagram rewritten to
  match the actual `useUpdateMachine` phase progression (three
  discrete prompt phases, explicit rollback branch).
- `docs/IMPLEMENTATION.md` §4.10 — corrected migration filter rule
  (`currentVersion < from_version ≤ targetVersion`), documented the
  rename-never-clobbers invariant and `MIGRATION_INVALID_VERSION`.
- `docs/IMPLEMENTATION.md` §4.11 / §4.12 / §4.13 — new module specs
  for `update-planner.ts`, `update-executor.ts`, `values-io.ts` in
  the same §4.x style as the existing engine modules.
- `docs/IMPLEMENTATION.md` §6.5 — DiffView prop spec updated to
  reflect `index` / `total` / `result` / `DiffAction` (was out of
  date) and the CRLF + Set-allowlist invariants.
- `docs/IMPLEMENTATION.md` §7 — error handling table adds the six
  new update + migration error codes.
- `docs/ARCHITECTURE.md` §10.5 — update flow covers `--yes` /
  `--verbose` / `--dry-run`, removed-files decision prompt, snapshot
  guarantee; links to IMPLEMENTATION §4.11 / §4.12.
- `CLAUDE.md` — module table + source tree diagram include the new
  core modules, components, and `commands/hooks/` directory.
- `CHANGELOG.md` — comprehensive Unreleased entry covering every
  user-visible surface, following the #48 tone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
breferrari added a commit that referenced this pull request Apr 20, 2026
* feat: core/migrator.ts — apply schema migrations to values (#12)

Adds the fourth and final engine pipeline module: applyMigrations walks
rename/added/removed/type_changed changes whose from_version falls in
(currentVersion, targetVersion], preserving values on best-effort
anomalies (missing keys, throwing transforms) so a single bad migration
can't block the upgrade. Zod validation against the new schema still
catches real breakage after the pass.

16 unit tests cover each change type, version-range selection, warn-only
semantics, and a multi-version chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: core/update-planner.ts — pure plan engine for updates (#12)

planUpdate maps the DriftReport + schema diff + new-shard render into a
list of per-file UpdateActions: overwrite managed, three-way merge
modified, add new, delete/keep-as-user removed, restore missing, skip
volatile. Pending conflicts are surfaced as a separate list so the
state machine can drive DiffView between planner and executor.

Also exports three small helpers the state machine needs before
planning: computeSchemaAdditions (new required keys + offered modules),
mergeModuleSelections (carry-over + opt-in), and
removedFilesNeedingDecision (only modified removals need a prompt).

17 unit tests cover each action branch against a synthetic temp vault.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: core/update-executor.ts — apply plan + rollback (#12)

runUpdate applies an UpdatePlan against a real vault: snapshot every
path it will touch (files + .shardmind cache) into
.shardmind/backups/update-<ts>/, write/merge/delete per action, re-cache
manifest + schema + templates, write new state.json, run the
post-update hook. Any failure between snapshot and state write triggers
rollbackUpdate, which restores the snapshot tree and removes files
added mid-flight. Dry-run path skips all writes and the snapshot.

Adds runPostUpdateHook alongside runPostInstallHook — same deferred
execution contract (#30) covers both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: components/DiffView + update-flow UI (#12)

Six new components for the update flow:

- DiffView — one-conflict-at-a-time viewer with ±3 lines of context,
  color-coded yours/shard regions, and per-file action select
  (accept new / keep mine / skip; editor integration stubbed for v0.2).
- NewValuesPrompt — asks only for value keys that migrations couldn't
  fill (thin wrapper over ValueInput).
- NewModulesReview — multiselect for modules present in the new shard
  but absent from the prior install.
- RemovedFilesReview — per-file keep-or-delete decision for modified
  files dropped from the new shard; managed removals auto-delete.
- UpdateProgress — progress bar + verbose history (mirrors
  InstallProgress).
- UpdateSummary — from→to line, action counts, conflict resolution
  breakdown, migration warnings, hook output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: commands/update.tsx — update command wiring (#12)

Sibling of commands/install.tsx. useUpdateMachine owns every
side-effecting transition behind a hook interface so update.tsx stays
thin presentation:

  booting → loading → (no-install | up-to-date | migrating →
    prompt-new-values → prompt-new-modules → prompt-removed-files →
    resolving-conflicts → writing → summary)

SIGINT handler reads the executor's backup dir to roll back mid-write.
--yes auto-keeps conflicts and opts into every new optional module so
CI upgrades are unattended-friendly.

Adds commands/update to tsup.config entry points and refreshes the
install machine's now-inaccurate "update not yet available" hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: end-to-end update pipeline integration test (#12)

Six tests against examples/minimal-shard that drive install → modify →
detectDrift → applyMigrations → planUpdate → runUpdate against real
filesystem state (no network):

- managed file silently overwritten on version bump with template change
- modified file with non-overlapping edits three-way merges cleanly
- modified file with overlapping edits surfaces a pending conflict,
  keep_mine preserves the user's version and marks it 'modified' in
  state.json
- up-to-date precondition (same version + same tarball sha)
- new required-value detection via computeSchemaAdditions
- removedFilesNeedingDecision flags modified files dropped from the new
  shard

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(roadmap): tick M4 update command, defer editor + cache to v0.2

#12 shipped. Status (#13) is a separate PR. Full CLI E2E comes with M6
(#15 — publish) where we already planned it.

Two v0.2 follow-ups surfaced while scoping #12:
- #50 — $EDITOR integration for DiffView (v0.1 stubbed the option).
- #51 — 24h update-check cache shared between status (#13) and update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: share install+update chrome, cache render pass, parallelize I/O

Cleanup pass after #12 lands. Same behavior, less duplication, faster
planning and rollback prep.

Deduplication:
- Extract `CommandFrame` — dry-run banner + keyboard legend — used by
  both install and update commands.
- Rename `InstallProgress` → `CommandProgress` and delete the identical
  `UpdateProgress`. One component now.
- Extract `summarizeHook` + `useSigintRollback` to `hooks/shared.ts`.
  Both machines drop their copies.
- Move `mapConcurrent` from drift.ts to fs-utils.ts so planner +
  executor can share it.

Perf:
- Render the new shard once per update: planner accepts a precomputed
  `NewFilePlan`, the state machine threads the one rendered at the
  removed-files check straight through. Previously: two full render
  passes per update.
- `renderNewShard` now runs `render` + `copy` passes in parallel with
  bounded concurrency.
- `planUpdate` runs the per-modified-file merge loop in parallel
  instead of awaiting each `readFile` + `computeMergeAction` in turn.
- `snapshotForRollback` copies snapshot files with bounded concurrency
  instead of a serial `for` loop.

Bug fix:
- `keep_as_user` now deletes the entry from `state.files`. Previously
  the file stayed on disk (correct) but was still tracked as the old
  managed path (wrong — the user chose "untrack"). Covered by a new
  integration test.

Smaller cleanups:
- Conflict actions carry `theirsHash` from the planner so the executor
  skips the re-read + re-hash on keep_mine/skip.
- Write actions derive "newly introduced" from `action.kind` instead
  of a pre-write `pathExists` stat.
- Remove unused `oldRenderContext` field (planner rendered from
  `newRenderContext` only).
- Simplify `NewValuesPrompt` + `RemovedFilesReview`: call the
  completion callback inline on the threshold-crossing submit instead
  of a `done` boolean + `useEffect`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: round-two cleanups — dedup YAML load, group planner input, fix conflictFromDirect

Second pass after pushback on the first /simplify — several "not worth"
dismissals turned out to be worth doing.

1. `conflictFromDirect` had a dead branch and a real bug. `target.entry
   .sourcePath` is typed `string` — never null — so half the `||` was
   unreachable. More importantly, the function called
   `toTemplateKey('', target.entry.sourcePath)` which silently produced
   an absolute path instead of a cache-relative key. If the fallback
   ever fired, the next update's `loadOldTemplate` lookup would fail.
   Now threaded through `newTempDir`, matching the other branches.

2. Extract `source/core/values-io.ts :: loadValuesYaml` — one helper
   for the two shard-values.yaml readers (install's --values prefill
   and update's canonical load). Same read → parse → validate-mapping
   flow; the only real difference was schema-based key filtering, now
   a parameter. Kills ~30 lines of near-duplicate error-handling code.

3. Restructure `PlanUpdateInput` from 11 flat fields into grouped
   sub-objects (`vault`, `values`, `newShard`, `removedFileDecisions`).
   The newShard cluster (schema + selections + tempDir + renderContext
   + filePlan) always travels together — grouping prevents callers
   from accidentally mixing fields from two different shards. 14 test
   sites migrated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix+test: round-three hardening audit (adversarial + property-based)

Aimed at the #48 bar. Four real bugs were hiding under the first pass,
all caught by a deliberate adversarial audit and the component-test
precedent install already set.

## Bugs fixed

- **Data loss in migrator** (`core/migrator.ts`) — `rename: a → b` silently
  overwrote an existing value at `b`. Now refuses the overwrite, warns,
  and keeps both keys so the user can untangle manually. Regression test
  in `migrator.test.ts`.
- **Concurrent backup-dir collision** (`core/update-executor.ts`) — the
  ISO timestamp stripped fractional seconds, so two same-second updates
  allocated the same `.shardmind/backups/update-<ts>/` directory and
  clobbered each other's rollback snapshots. Timestamp now retains ms
  and a numeric suffix is probed on EEXIST. Regression in
  `update-adversarial.test.ts`.
- **DiffView CRLF corruption** (`components/DiffView.tsx`) — the view
  split on `\n` only, leaving `\r` on each line. Ink treated those as
  carriage returns and corrupted the terminal when rendering Windows-
  saved user files. Now splits on `/\r?\n/` everywhere to match
  `differ.ts`.
- **keep_as_user left files tracked** (`core/update-executor.ts`) — the
  "keep my edits (untrack)" decision kept the file in `state.files`;
  next update still considered it managed. Now drops the entry on apply.
  Regression in `update.test.ts`.

## Also

- SIGINT now always runs the shard tempdir cleanup — cancelling during
  the prompt/wizard phase used to leak the extracted tarball.
- `DiffView`'s choice handler uses a `Set<DiffAction>` allowlist instead
  of a bare cast, so the disabled "Open in editor" option can never
  bypass the v0.2 stub.
- `useSigintRollback` API cleaned up: drops the redundant `enabled`
  param (caller folds the condition into `isActive`) and adds a
  `cleanup` callback that runs on every Ctrl-C.
- `conflictFromDirect` now produces a cache-relative `templateKey`
  (previously passed `''` for tempDir and emitted an absolute path
  that would break future updates if the fallback ever fired).
- `NewValuesPrompt` + `RemovedFilesReview` use a `submittedRef` +
  per-file `key` to prevent the re-entrant double-submit that the
  new component tests caught.

## Tests added (62 new)

- `tests/unit/migrator-adversarial.test.ts` — 18 tests incl. 4
  `fast-check` properties × 200 runs = 800 generative scenarios.
  Covers prototype-key pollution, non-Error throws, unserializable
  transforms, BOM + null bytes, cyclic values, pre-release + build-
  metadata semver, chained renames, idempotence, transitivity.
- `tests/unit/update-adversarial.test.ts` — 11 tests covering missing
  cached templates (→ `conflictFromDirect` fallback), iterator-shrink
  cleanup, CRLF user files + LF templates, Unicode/emoji filenames,
  inconsistent drift + state, concurrent `createBackupDir`, rollback
  idempotency, render determinism (property).
- `tests/component/` — 6 new files for `DiffView`, `CommandFrame`,
  `NewValuesPrompt`, `NewModulesReview`, `RemovedFilesReview`,
  `UpdateSummary`, matching the 9-file precedent set by install.
  DiffView alone covers header rendering, context lines, singular/
  plural region labels, each DiffAction, disabled-option filtering,
  and CRLF robustness.
- `tests/integration/update.test.ts` — 2 new cases: `--dryRun` does
  not mutate the vault; corrupted template cache surfaces the file
  as a full-file conflict via `conflictFromDirect`.

341 → 405 tests passing.

## Docs

- `docs/IMPLEMENTATION.md` §3 — update data flow diagram rewritten to
  match the actual `useUpdateMachine` phase progression (three
  discrete prompt phases, explicit rollback branch).
- `docs/IMPLEMENTATION.md` §4.10 — corrected migration filter rule
  (`currentVersion < from_version ≤ targetVersion`), documented the
  rename-never-clobbers invariant and `MIGRATION_INVALID_VERSION`.
- `docs/IMPLEMENTATION.md` §4.11 / §4.12 / §4.13 — new module specs
  for `update-planner.ts`, `update-executor.ts`, `values-io.ts` in
  the same §4.x style as the existing engine modules.
- `docs/IMPLEMENTATION.md` §6.5 — DiffView prop spec updated to
  reflect `index` / `total` / `result` / `DiffAction` (was out of
  date) and the CRLF + Set-allowlist invariants.
- `docs/IMPLEMENTATION.md` §7 — error handling table adds the six
  new update + migration error codes.
- `docs/ARCHITECTURE.md` §10.5 — update flow covers `--yes` /
  `--verbose` / `--dry-run`, removed-files decision prompt, snapshot
  guarantee; links to IMPLEMENTATION §4.11 / §4.12.
- `CLAUDE.md` — module table + source tree diagram include the new
  core modules, components, and `commands/hooks/` directory.
- `CHANGELOG.md` — comprehensive Unreleased entry covering every
  user-visible surface, following the #48 tone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* cleanup: drop dead planner fields, guard DiffView against double-submit

Audit follow-ups while doing the fresh-eyes pass:

- `NewFilePlan.entriesByPath` was populated but never consumed. Drop
  it and introduce a named `RenderedFileEntry` type so internals that
  previously indexed `NewFilePlan['outputs'][number]` read cleanly.
- `PendingConflict.index` was always `array.indexOf(self)` — dead
  weight. Removed from the type and the construction site.
- `DiffView` now guards `onChoice` with a `firedRef` (same pattern
  used by `NewValuesPrompt` + `RemovedFilesReview`), and keys the
  underlying `Select` on `filePath` so each conflict gets a fresh
  focus context. Prevents Ink re-fires from resolving a subsequent
  file's conflict with the same keypress. Regression test added.

406 tests passing (was 405).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix+test: round-four /harden audit

Fresh-eyes pass found four real issues the prior rounds missed.

- **useSigintRollback** re-registered the SIGINT handler on every
  render because its deps were inline arrows from the caller. Pins
  the callbacks in refs and registers once on mount.
- **rollbackUpdate** now returns a list of per-file failures instead
  of void; runUpdate attaches them to the thrown error's message so
  the command layer can surface "update failed AND rollback was
  partial" — silent swallowing was telling users the vault was back
  to pre-update state when it wasn't.
- **Hook path traversal**: a shard manifest declaring
  `hooks.post-update: "../../etc/shadow"` could probe arbitrary
  filesystem paths via the existence detection. `lookupHook` now
  rejects traversal and absolute paths; resolved hook path must live
  under the shard's temp directory. Six unit tests lock the guard.
- **UPDATE_CACHE_MISSING** hint now names the exact
  `shardmind install <source>` command to recover, and includes the
  original parse error.

Also: swept the diff for stopgap-ish phrasing ("v0.1 doesn't X yet",
"v0.2 stub") and rewrote to describe the permanent invariants
instead. Temporal framing in code + tests rots as the roadmap
advances; reserved it to docs sections that are explicitly about
phase-gated decisions.

413 passing (was 406, +7 for hook tests + rollback-failure test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix+test: address Copilot review round

Six real findings from Copilot. Two were stale (already fixed in
earlier rounds). One was a doc overpromise. The other four were
correctness bugs worth fixing before merge.

- **SIGINT rollback unreachable** (`use-update-machine.ts` +
  `update-executor.ts`). `backupDirRef.current` was only assigned
  after `runUpdate` resolved, but Ctrl-C fires during the run — so
  the handler always saw `null` and skipped rollback. Executor now
  exposes `onBackupReady` (fires after snapshot, before any write)
  and `onFileTouched` (fires per write with `introduced: boolean`).
  The machine populates its refs eagerly via these callbacks.
  Integration test locks the ordering (backup callback fires before
  any file write).
- **Progress bar could overshoot 100%** (`update-executor.ts`).
  `total` was `writable.length` which excludes keep_mine/skip
  conflicts, but the loop emitted progress events for them anyway —
  so `index` could exceed `total`. New `actionEmitsProgress` helper
  computes the progress total correctly; `actionWrites` retired.
- **`add` could silently overwrite an untracked user file with no
  rollback restore** (`update-executor.ts`). Snapshot pass now
  includes `add` paths too — if a user has a pre-existing file at
  the same path (an orphan, or an untracked collision), it's in the
  backup and rollback restores it. `addedPaths` only tracks paths
  this run actually introduced, so rollback erases the right set.
- **Copy-origin binary assets corrupted by UTF-8 round-trip**
  (`update-planner.ts` + `update-executor.ts`). `renderNewShard`
  now carries a `copyFromSourcePath` pointer on copy-origin
  entries; planner threads it onto `add` / `overwrite` /
  `restore_missing` actions; executor's new `writeAction` prefers
  `fsp.copyFile` over `fsp.writeFile(..., 'utf-8')` when that
  pointer is present. Bytes survive the round trip. Test locks
  binary-hash equality.
- **Stale "atomically" comment** on `writeValuesFile` — clarified
  that atomicity comes from the snapshot-based rollback, not from
  the write itself.

**Stale findings (already fixed earlier, noted in response):**
- `drift.missing` "leave alone" comment mismatch — comment was
  already gone by round 2.
- `conflictFromDirect` absolute `templateKey` — fixed in commit
  21a900b (round 2).

415 passing (was 413, +2 tests: binary-bytes preservation, SIGINT-
reachability ordering).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

core/drift.ts + core/differ.ts — three-way merge engine

2 participants