feat: core/drift.ts + core/differ.ts — three-way merge engine (#11)#48
feat: core/drift.ts + core/differ.ts — three-way merge engine (#11)#48breferrari merged 20 commits intomainfrom
Conversation
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>
There was a problem hiding this comment.
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 intomanaged/modified/volatile/missing(withorphanedstubbed for v0.2). - Added
computeMergeAction()+threeWayMerge()usingnode-diff3merge 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). |
| const baseLines = base.split('\n'); | ||
| const theirsLines = theirs.split('\n'); | ||
| const oursLines = ours.split('\n'); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…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>
| const [classified, orphaned] = await Promise.all([ | ||
| Promise.all( | ||
| Object.entries(state.files).map(([relPath, file]) => | ||
| classifyFile(vaultRoot, relPath, file), | ||
| ), | ||
| ), | ||
| detectOrphans(vaultRoot, trackedPaths), | ||
| ]); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| // 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) }; | ||
| } |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
| /** | ||
| * 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(); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| // 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/), | ||
| ); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
… 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>
| 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; |
There was a problem hiding this comment.
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.).
There was a problem hiding this comment.
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.
|
|
||
| function getDefaultStringEnv(): nunjucks.Environment { | ||
| if (!defaultStringEnv) { | ||
| defaultStringEnv = new nunjucks.Environment(null, NUNJUCKS_OPTS); |
There was a problem hiding this comment.
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.
| defaultStringEnv = new nunjucks.Environment(null, NUNJUCKS_OPTS); | |
| defaultStringEnv = new nunjucks.Environment([], NUNJUCKS_OPTS); |
There was a problem hiding this comment.
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.
| // 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 */ | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
|
||
| export function errnoCode(err: unknown): string | undefined { | ||
| if (err !== null && typeof err === 'object' && 'code' in err) { | ||
| return (err as { code?: string }).code; |
There was a problem hiding this comment.
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.
| return (err as { code?: string }).code; | |
| const code = (err as { code?: unknown }).code; | |
| return typeof code === 'string' ? code : undefined; |
There was a problem hiding this comment.
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.
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>
| 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), | ||
| ]); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in df54469 — trackedPaths 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.
| // Intern every unique line to an integer-named token. See the block | ||
| // comment on LINE_SPLIT for why this is load-bearing. |
There was a problem hiding this comment.
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.
| // 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. |
There was a problem hiding this comment.
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.
| 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. | ||
|
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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>
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>
* 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>
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.ts—detectDrift()classifies every tracked file intomanaged / modified / volatile / missing / orphanedby sha256-comparing disk content againststate.json.rendered_hash. Non-recursive orphan scan: parent directory of any tracked file = tracked directory; files in a tracked dir not instate.files= orphans. Engine scaffolding (shard-values.yaml,.shardmind/) and third-party metadata (.git/,.obsidian/) are excluded at the source. Reads are bounded bymapConcurrent(items, 32, fn)so large vaults can't exhaust file descriptors.source/core/differ.ts—computeMergeAction()per spec §4.9: skip whenbase === ours, overwrite silently for managed files, three-way merge for modified files.threeWayMerge()usesnode-diff3'sdiff3MergeRegionsso stable regions withbuffer === 'a'|'b'can be classified as auto-merged vs'o'unchanged, which flatdiff3Mergecan't discriminate. ALineInternermaps each unique line to an integer-named token before diff3 sees it — this sidesteps a latent upstream crash where lines matchingObject.prototypemember 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— exposesrenderString()for in-memory template rendering (no disk I/O) via a lazy isolatednunjucks.Environment.renderFrontmatterSafely()auto-recovers when naive render produces YAML-hostile output (e.g.owner: {{ name }}withname = "Alice: AI researcher") by re-rendering with every string leaf JSON-encoded; non-string values keep their YAML type. AWeakMapseen-set guards the walker against circular references in values.source/runtime/errors.ts— typedErrorCodestring union (39 codes, grouped by domain).ShardMindError.codeis now strictly typed; typos surface at compile time. Exported fromshardmind/runtimefor hook consumers.source/runtime/errno.ts— sharederrnoCode/isEnoentwith runtimetypeof === 'string'guard. Collapsed 8 copies of theerr instanceof Error && 'code' in err ? ...pattern across core + runtime.threeWayMergesplits on/\r?\n/, so a user's Windows-saved file (CRLFtheirs) doesn't fight LFbase/oursrendered by the engine. Merged output is always LF.{ubuntu, windows, macos} × {node 22, node 24}withfail-fast: falseanddefaults.run.shell: bash. Windows caught the CRLF regression.<<<<<<< yours/=======/>>>>>>> shard update.Adversarial hardening
Deliberately pushed the engine against 16 attack categories. Four real bugs surfaced and were fixed:
node-diff3crash onObject.prototypeline content — fixed viaLineInterner(and filed upstream).WeakMapseen-set.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 test— 300 tests pass across 26 filesnpm run typecheck— strict mode clean, zeroany/as any/@ts-ignorenpm run build— runtime bundle 7.66 KB (well under the 30 KB target)detectDriftunit tests covering everyDriftReportbucket including 5 orphan scenariosthreeWayMergeunit tests pinning down stats accounting (caught a bug the fixture suite missed)node-diff3prototype crash)docs/ARCHITECTURE.md§17,docs/IMPLEMENTATION.md§4.8/§4.9) updated to match implementationCHANGELOG.md[Unreleased] section populatedTracking
LineInterneronce fix(LCS): use Object.create(null) so prototype members don't shadow lookups bhousel/node-diff3#87 ships).🤖 Generated with Claude Code