diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fac900b..083b00e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,9 +8,19 @@ on: jobs: check: - runs-on: ubuntu-latest + # Full OS × Node matrix: shardmind installs as a global CLI and must + # run on whatever the user's vault lives on. Windows coverage caught + # the CRLF merge regression Copilot flagged on PR #48; keep it first-class. + runs-on: ${{ matrix.os }} + defaults: + run: + # Use bash on all three runners so the same step commands work + # (Windows runners have git-bash pre-installed). + shell: bash strategy: + fail-fast: false matrix: + os: [ubuntu-latest, windows-latest, macos-latest] # Node 22+ matches obsidian-mind's requirement (hook TS strip # via --experimental-strip-types, stable in 22.6+). Node 18 hit # EOL April 2025; Node 20 EOL April 2026. Keep the matrix @@ -25,7 +35,7 @@ jobs: with: node-version: ${{ matrix.node-version }} - - run: rm package-lock.json && npm install + - run: rm -f package-lock.json && npm install - run: npm run typecheck - run: npm test - run: npm run build diff --git a/.gitignore b/.gitignore index 31559b8..099e9fd 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ yarn.lock # (commands, agents, scripts, skills, settings.json) that ShardMind installs. # Only ignore the auto-memory subdirectories that agents generate at runtime. .claude/memory/ +.claude/scheduled_tasks.lock .claude/projects/ .agent/ memory/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e52d38..f063d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,36 @@ # Changelog -Populated on each release. Between releases: - -- **What's merged but not released:** `git log` -- **What's planned:** [`ROADMAP.md`](ROADMAP.md) - The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Between releases: see `git log` for merged work and [`ROADMAP.md`](ROADMAP.md) for planned work. + + +## [Unreleased] + +### Added + +- **Three-way merge engine** (`source/core/drift.ts`, `source/core/differ.ts`). Solves "propagate template updates to existing vaults" — the same problem Backstage (Spotify) has had open for 3+ years and that cruft/create-react-app/Yeoman never solved. Uses `node-diff3`'s Khanna–Myers algorithm via `diff3MergeRegions` for accurate stats. + - `detectDrift()` classifies every tracked file into `managed / modified / volatile / missing / orphaned` by sha256-comparing disk content against `state.json.rendered_hash`. Orphan detection is non-recursive: parent directories of tracked files become tracked dirs; files in a tracked dir not in `state.files` are orphans. Engine scaffolding (`shard-values.yaml`, `.shardmind/`) and third-party metadata (`.git/`, `.obsidian/`) are excluded. + - `computeMergeAction()` returns `skip | overwrite | auto_merge | conflict` — skip when `base === ours`, overwrite for managed files, three-way merge for modified files. Conflict markers use git vocabulary: `<<<<<<< yours` / `=======` / `>>>>>>> shard update`. + - `threeWayMerge()` is a pure primitive — line-based, CRLF-tolerant on input, LF on output. +- **`renderString()` in `source/core/renderer.ts`** — frontmatter-aware render helper for in-memory templates, used by the merge engine to render base/ours without touching disk. +- **Typed `ErrorCode` union** in `source/runtime/errors.ts` — 39 codes grouped by domain; `ShardMindError.code` is now strictly typed. Exported from `shardmind/runtime` for hook consumers. +- **Cross-OS CI matrix** — `{ubuntu, windows, macos} × {node 22, node 24}`, `fail-fast: false`. Windows coverage caught a CRLF regression on first run. +- **20 fixture scenarios** for the merge engine (`tests/fixtures/merge/`) — 17 spec-defined plus 3 edge cases (empty file, UTF-8 non-ASCII, frontmatter-on-modified-ownership merge). +- **Direct unit tests** for merge primitives: `tests/unit/three-way-merge.test.ts` pins down stats accounting; `tests/unit/differ-line-endings.test.ts` covers CRLF robustness; `tests/unit/drift-classification.test.ts` covers every `DriftReport` bucket including orphans. + +### Changed + +- `ShardMindError.code` typed as `ErrorCode` instead of free-form `string` (compile-time check of every call site). +- `detectDrift` now runs per-file reads in parallel via `Promise.all`, and runs the orphan scan in parallel with the classification. +- `source/runtime/errno.ts` centralizes `errnoCode` / `isEnoent`; 8 copies of the `err instanceof Error && 'code' in err ? ...` pattern collapsed. + +### Fixed + +- CRLF on Windows-saved user files no longer produces spurious conflicts against LF-rendered base/ours. + +### Docs + +- `docs/ARCHITECTURE.md` §17 updated: scenario table now shows 20 rows, `§17.4` code sample shows the actual `diff3MergeRegions` algorithm, `§17.5` corrects the frontmatter-merge decision to match implementation (line-merge of rendered YAML, not YAML object deep-merge). +- `docs/IMPLEMENTATION.md` §4.8 documents orphan detection semantics; §4.9 documents the `diff3MergeRegions` variant and CRLF split. diff --git a/ROADMAP.md b/ROADMAP.md index 9bf6cb1..c15f70f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,9 +33,9 @@ Ship the core: install, update, status. Prove that vault template upgrades work ### Milestone 3: Merge Engine (Day 3) - [x] Write all 17 merge fixture directories — fixtures before code ([#10](https://github.com/breferrari/shardmind/issues/10)) -- [ ] `core/drift.ts` + `core/differ.ts` — three-way merge engine ([#11](https://github.com/breferrari/shardmind/issues/11)) -- [ ] Iterate until all 17 scenarios pass -- [ ] Add edge case fixtures: frontmatter merge, empty file, binary-identical, encoding +- [x] `core/drift.ts` + `core/differ.ts` — three-way merge engine ([#11](https://github.com/breferrari/shardmind/issues/11)) +- [x] Iterate until all 17 scenarios pass +- [x] Add edge case fixtures: empty file (18), UTF-8 non-ASCII (19), frontmatter merge on modified ownership (20). Hash-identical behavior already covered by scenarios 01 and 05 (both hit the `sha256(base) === sha256(ours)` shortcut) ### Milestone 4: Update Command + Status (Day 4) @@ -111,6 +111,7 @@ Deferred items surfaced during the v0.1 polish-pass architecture audit. None are - [ ] Alternate registry configurability (GHE, private, custom URL) ([#39](https://github.com/breferrari/shardmind/issues/39)) - [ ] Encode state-schema migration rules (uses v0.1 framework) ([#40](https://github.com/breferrari/shardmind/issues/40)) - [ ] Re-evaluate `@inkjs/ui` dependency (upstream frozen; shim at `source/components/ui.ts` keeps swap cheap) ([#43](https://github.com/breferrari/shardmind/issues/43)) +- [ ] Drop `LineInterner` workaround once `node-diff3` releases the prototype-lookup fix ([#49](https://github.com/breferrari/shardmind/issues/49), upstream [bhousel/node-diff3#87](https://github.com/bhousel/node-diff3/pull/87)) --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3f08fc3..4cec4cb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1199,7 +1199,14 @@ tests/fixtures/ └── migration/ ``` -### 17.3 The 16 Merge Scenarios +### 17.3 The 20 Merge Scenarios + +Seventeen spec scenarios plus three edge cases. Each is a directory under +`tests/fixtures/merge/` with `scenario.yaml` + templates + values + actual +file + expected output. The fixture runner dispatches by scenario flag: +standard scenarios go through `computeMergeAction`; orchestration-level +scenarios (new file / removed / volatile / module change) dispatch to +rendering or drift classification directly. | # | Scenario | Ownership | Template? | Values? | User edited? | Expected | |---|----------|-----------|-----------|---------|--------------|----------| @@ -1210,54 +1217,79 @@ tests/fixtures/ | 05 | User edited, nothing upstream | modified | no | no | yes | Skip. Preserve user's. | | 06 | Template changed, user edited different section | modified | yes | no | yes | Auto-merge | | 07 | Template changed, user edited SAME section | modified | yes | no | yes | Conflict → TUI | -| 08 | User deleted section | modified | yes | no | deletion | Show diff | +| 08 | User deleted section | modified | yes | no | deletion | Auto-merge (honor deletion, apply non-conflicting template edit) | | 09 | User added below template | modified | yes | no | addition | Auto-merge, preserve additions | | 10 | New file from template | — | — | — | — | Render, add to state | | 11 | File removed from template | managed | — | — | no | Ask: delete or keep? | -| 12 | Frontmatter only change | managed | yes (fm) | no | no | Re-render with FM-aware merge | +| 12 | Frontmatter only change | managed | yes (fm) | no | no | Re-render (managed overwrite) | | 13 | Module newly included on update | — | — | — | — | Render as fresh install for that module | | 14 | Module excluded on update | included | — | — | — | Ask: delete files or keep as user-owned? | | 15 | _each item added | managed | no | yes | no | Render new file | | 16 | CLAUDE.md partial updated | managed | yes | no | no | Re-render full CLAUDE.md | +| 17 | Volatile file, template changed | volatile | yes | no | yes | Skip (never re-render volatile) | +| 18 | Empty file → shard adds content | managed | yes | no | no | Overwrite | +| 19 | UTF-8 non-ASCII round-trip | modified | yes | no | yes | Auto-merge, bytes preserved | +| 20 | Frontmatter edited + body edited (modified) | modified | yes | no | yes | Auto-merge (user tag + shard body change) | ### 17.4 Three-Way Merge Implementation -Uses `node-diff3` (Khanna-Myers algorithm): +Uses `node-diff3`'s `diff3MergeRegions` (Khanna–Myers algorithm) — **not** +the flat `diff3Merge`. The regions variant exposes `buffer: 'a' | 'o' | 'b'` +on stable regions and separate `aContent / oContent / bContent` on unstable +ones, which is the only way to distinguish stable-unchanged lines from +stable-auto-merged lines and produce accurate merge stats. ```typescript -import { diff3Merge } from 'node-diff3'; +import { diff3MergeRegions } from 'node-diff3'; -export function threeWayMerge(input: MergeInput): MergeResult { - const merged = diff3Merge( - input.theirs.split('\n'), // user's file - input.base.split('\n'), // old rendered (cached) - input.ours.split('\n'), // new rendered +export function threeWayMerge(base, theirs, ours): ThreeWayMergeResult { + const regions = diff3MergeRegions( + theirs.split(/\r?\n/), // a — user on disk + base.split(/\r?\n/), // o — old rendered (cached) + ours.split(/\r?\n/), // b — new rendered ); - // Process regions: ok → keep, conflict → mark - // Return content + hasConflicts + conflict regions + stats + + for (const r of regions) { + if (r.stable) { + emit(r.bufferContent); + if (r.buffer === 'o') stats.linesUnchanged += r.bufferContent.length; + else stats.linesAutoMerged += r.bufferContent.length; + continue; + } + // Unstable: classify as auto-merge or true conflict. + if (arraysEqual(r.aContent, r.oContent)) emit(r.bContent); // ours + else if (arraysEqual(r.bContent, r.oContent)) emit(r.aContent); // theirs + else if (arraysEqual(r.aContent, r.bContent)) emit(r.aContent); // false conflict + else emitConflictMarkers(...); + } } ``` +The `/\r?\n/` split tolerates CRLF on Windows-saved user files while base and +ours are renderer output (always LF). Merged output is always LF — callers +convert at the write boundary if they need platform-native line endings. + ### 17.5 Edge Cases | Edge case | Decision | |-----------|----------| -| **Frontmatter merge** | Parse both as YAML objects, deep-merge, don't line-merge | -| **Empty file on disk** | Treat as modified. Show diff. | -| **Hash-identical after re-render** | Short-circuit. No diff needed. | -| **Wikilink targets moved** | Warn. Suggest find-and-replace. | -| **_each item renamed** | Rename file, don't delete+recreate (preserves backlinks) | -| **Encoding** | Test with Unicode, emoji, CJK in values | +| **Frontmatter merge** | Render the whole file; renderer parses frontmatter and emits normalized YAML (parse → stringify via `yaml`). diff3 then line-merges the whole document. User tag additions and shard field additions merge cleanly because disjoint YAML keys become non-adjacent lines. Fixture 20 proves this. | +| **Empty file** | Render pipeline produces an empty string; `sha256(base) === sha256(ours)` short-circuits the no-change path. Fixture 18 tests the variant where base is empty but ours has content → overwrite. | +| **Hash-identical after re-render** | Short-circuit via `sha256(base) === sha256(ours)`. No diff3 pass. Covered by scenarios 01, 05. | +| **CRLF user file** | `split(/\r?\n/)` normalizes on the way in; merged output is LF. Covered by `tests/unit/differ-line-endings.test.ts`. | +| **UTF-8 non-ASCII** | diff3 is byte/line-agnostic for string content. Fixture 19 exercises emoji, accented Latin, and CJK. | +| **Wikilink targets moved** | Warn. Suggest find-and-replace. *(v0.2 — out of scope for the engine.)* | +| **_each item renamed** | Rename file, don't delete+recreate (preserves backlinks). *(v0.2 — requires iterator-aware diffing beyond the single-file merge primitive.)* | ### 17.6 Test-First Build Order -1. Write all 16 fixture files (templates, values, actual, expected) -2. Write test runner that auto-discovers fixtures -3. Run → all fail -4. Implement `computeMergeAction()` → ownership tests pass -5. Implement `threeWayMerge()` → merge output tests pass -6. Add edge case fixtures → find bugs → fix -7. Wire into `commands/update.tsx` +1. ✅ Write 17 spec-defined fixture files (templates, values, actual, expected) in PR #10. +2. ✅ Write test runner (`tests/unit/drift.test.ts`) that auto-discovers fixtures and dispatches by scenario kind. +3. ✅ `it.fails` all 17 before the engine exists. +4. ✅ Implement `computeMergeAction()` → skip / overwrite ownership tests pass. +5. ✅ Implement `threeWayMerge()` → auto_merge / conflict tests pass. +6. ✅ Add edge-case fixtures (empty, UTF-8, frontmatter-modified-merge) plus direct unit tests for stats invariants (`tests/unit/three-way-merge.test.ts`) and CRLF robustness (`tests/unit/differ-line-endings.test.ts`). +7. Wire into `commands/update.tsx` — *Milestone 4 / issue #12, out of scope for the merge-engine PR.* --- diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index e37df14..77f9292 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -487,14 +487,15 @@ interface DriftEntry { ``` **Algorithm**: -1. For each file in `state.files`: - a. If `ownership === 'volatile'` → add to `volatile` - b. Read file from disk. If not found → add to `missing` - c. Compute `sha256(file content)` - d. Compare against `state.files[path].rendered_hash` - e. If equal → `managed` - f. If different → `modified` -2. Return classified report +1. For each file in `state.files` (in parallel via `Promise.all`): + a. If `FileState.ownership === 'user'` (volatile at install time) → `DriftEntry` with `ownership: 'volatile'` → add to `volatile`. Never hashed; content may diverge by design. + b. Read file from disk. If ENOENT → add to `missing` (propagate state ownership). + c. Compute `sha256(file content)`. + d. Compare against `state.files[path].rendered_hash`. Equal → `managed`. Different → `modified`. +2. Orphan scan (runs in parallel with the classification): union of parent directories of every tracked path is the set of tracked directories. For each tracked directory, `readdir` non-recursively and report files not in `state.files` as orphans. Excludes engine-reserved files (`VALUES_FILE`) and never-scanned directories (`.shardmind`, `.git`, `.obsidian`). Subdirectories of a tracked directory are not auto-scanned — they only count if they themselves contain a tracked file. +3. Return classified report. + +**Rationale for non-recursive orphan scan**: the shard only claims to manage what it tracks. A user's `brain/daily/2026-04-19.md` under an untracked subdirectory is their territory, not an orphan. But a `skills/my-extra.md` sibling of a tracked `skills/leadership.md` is an orphan because `skills/` is territory the shard already claims. **Dependencies**: `node:fs`, `node:crypto`. @@ -536,17 +537,23 @@ interface MergeStats { 4. If `sha256(base) === sha256(ours)` → no upstream change → `{ type: 'skip' }` 5. If ownership is `managed` (base === theirs) → `{ type: 'overwrite', content: ours }` 6. If ownership is `modified`: - a. Run `diff3Merge(theirs.split('\n'), base.split('\n'), ours.split('\n'))` - b. If no conflicts → `{ type: 'auto_merge', content: merged }` - c. If conflicts → `{ type: 'conflict', result: { content, hasConflicts, conflicts, stats } }` + a. Run `diff3MergeRegions(theirs.split(/\r?\n/), base.split(/\r?\n/), ours.split(/\r?\n/))` — not the flat `diff3Merge`; the regions variant exposes `buffer: 'a' | 'o' | 'b'` on stable regions and `aContent / oContent / bContent` on unstable ones, which is the only way to distinguish stable-unchanged (`buffer === 'o'`) from stable-auto-merged (`buffer === 'a' | 'b'`) lines. The `/\r?\n/` split tolerates CRLF on Windows-saved files; merged output is always LF. + b. For each stable region: emit `bufferContent`. For each unstable region: if `aContent === oContent` take `bContent`; if `bContent === oContent` take `aContent`; if `aContent === bContent` take either (false conflict); else emit git-style conflict markers and record a `ConflictRegion`. + c. No conflicts → `{ type: 'auto_merge', content, stats }`. Conflicts → `{ type: 'conflict', result: { content, hasConflicts: true, conflicts, stats } }`. **`MergeResult`** (for conflicts): ```typescript +interface MergeStatsWithConflicts { + linesUnchanged: number; + linesAutoMerged: number; + linesConflicted: number; +} + interface MergeResult { content: string; // Merged content with conflict markers hasConflicts: boolean; conflicts: ConflictRegion[]; - stats: { linesUnchanged: number; linesAutoMerged: number; linesConflicted: number; }; + stats: MergeStatsWithConflicts; } interface ConflictRegion { diff --git a/package-lock.json b/package-lock.json index af89e38..344a821 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@types/react": "^19.2.14", "@types/semver": "^7.5.0", "@types/tar": "^6.1.0", + "fast-check": "4.7.0", "ink-testing-library": "^4.0.0", "tsup": "^8.5.1", "typescript": "^6.0.3", @@ -803,6 +804,29 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", + "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "dev": true, @@ -1496,6 +1520,23 @@ } } }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/react": { "version": "19.2.5", "license": "MIT", diff --git a/package.json b/package.json index 7ca6cdf..9e0fe30 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@types/react": "^19.2.14", "@types/semver": "^7.5.0", "@types/tar": "^6.1.0", + "fast-check": "4.7.0", "ink-testing-library": "^4.0.0", "tsup": "^8.5.1", "typescript": "^6.0.3", diff --git a/source/core/differ.ts b/source/core/differ.ts new file mode 100644 index 0000000..42dae3e --- /dev/null +++ b/source/core/differ.ts @@ -0,0 +1,279 @@ +/** + * Three-way merge engine. + * + * Given the old template, the new template, and the file on disk, + * `computeMergeAction` decides whether to skip, silently overwrite, + * auto-merge, or surface a conflict. When a merge is needed, the heavy + * lifting is delegated to node-diff3's Khanna–Myers algorithm (same + * approach git uses). + * + * See docs/IMPLEMENTATION.md §4.9 for the spec. + */ + +import { diff3MergeRegions, type IRegion, type IUnstableRegion } from 'node-diff3'; +import type { + MergeAction, + MergeStatsWithConflicts, + ConflictRegion, + RenderContext, +} from '../runtime/types.js'; +import { ShardMindError } from '../runtime/types.js'; +import { sha256 } from './fs-utils.js'; +import { renderString } from './renderer.js'; + +const CONFLICT_START = '<<<<<<< yours'; +const CONFLICT_SEPARATOR = '======='; +const CONFLICT_END = '>>>>>>> shard update'; + +// Line splitter. LF is the engine's canonical line ending (renderer output +// is always LF); CR is tolerated so Windows-saved user files don't produce +// spurious conflicts against LF base/ours. +const LINE_SPLIT = /\r?\n/; + +// node-diff3's LCS implementation uses a plain `{}` as a hash map keyed by +// line content. If any line equals a string that collides with +// Object.prototype (`constructor`, `__proto__`, `toString`, ...), the +// internal lookup returns a function instead of an array and the library +// crashes. We sidestep this by interning every unique line to a synthetic +// integer token (as a decimal string: "0", "1", "2", ...). Object.prototype +// has no integer-named members, so collisions are impossible by +// construction. Tokens are mapped back to original lines on output — +// callers never see the encoding, and it's robust to any user content +// including control characters. +// +// Upstream: bhousel/node-diff3#86 (issue) + #87 (fix — one-line +// Object.create(null) patch). Drop the LineInterner once node-diff3 +// ships a version with the fix. See #49. + +export interface ComputeMergeActionInput { + readonly path: string; + readonly ownership: 'managed' | 'modified'; + readonly oldTemplate: string; + readonly newTemplate: string; + readonly oldValues: Record; + readonly newValues: Record; + readonly actualContent: string; + readonly renderContext: RenderContext; +} + +export interface ThreeWayMergeResult { + readonly content: string; + readonly conflicts: ConflictRegion[]; + readonly stats: MergeStatsWithConflicts; +} + +export async function computeMergeAction( + input: ComputeMergeActionInput, +): Promise { + const base = renderString( + input.oldTemplate, + { ...input.renderContext, values: input.oldValues }, + input.path, + ); + const ours = renderString( + input.newTemplate, + { ...input.renderContext, values: input.newValues }, + input.path, + ); + + if (sha256(base) === sha256(ours)) { + return { type: 'skip', reason: 'no upstream change' }; + } + + if (input.ownership === 'managed') { + return { type: 'overwrite', content: ours }; + } + + const merge = runMerge(base, input.actualContent, ours, input.path); + + if (merge.conflicts.length === 0) { + return { + type: 'auto_merge', + content: merge.content, + stats: { + linesUnchanged: merge.stats.linesUnchanged, + linesAutoMerged: merge.stats.linesAutoMerged, + }, + }; + } + + return { + type: 'conflict', + result: { + content: merge.content, + hasConflicts: true, + conflicts: merge.conflicts, + stats: merge.stats, + }, + }; +} + +function runMerge(base: string, theirs: string, ours: string, path: string): ThreeWayMergeResult { + try { + return threeWayMerge(base, theirs, ours); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new ShardMindError( + `Three-way merge failed for ${path}: ${message}`, + 'MERGE_FAILED', + 'Re-run with --verbose for the full trace, then report at github.com/breferrari/shardmind/issues.', + ); + } +} + +/** + * Line-based three-way merge. `a` is theirs (user on disk), `o` is base + * (rendered from old template + old values), `b` is ours (rendered from + * new template + new values). Convention matches diff3MergeRegions and + * the git conflict-marker vocabulary (`<<<<<<< yours` wraps theirs, + * `>>>>>>> shard update` wraps ours). + */ +export function threeWayMerge( + base: string, + theirs: string, + ours: string, +): ThreeWayMergeResult { + // Intern every unique line to an integer-named token. Load-bearing because + // node-diff3's LCS uses `{}` keyed by line content and collides with + // Object.prototype member names (see block comment above the LineInterner + // class). Interning to integer-named tokens sidesteps that by construction. + // + // Note: `split(/\r?\n/)` produces a trailing "" token when input ends with a + // newline (e.g. "a\nb\n" → ["a", "b", ""]). That trailing "" is how the + // newline-as-document-property is preserved through diff3 — we keep it in + // the merge itself and correct for it in stats after the loop. + const interner = new LineInterner(); + const regions: IRegion[] = diff3MergeRegions( + interner.tokenize(theirs.split(LINE_SPLIT)), + interner.tokenize(base.split(LINE_SPLIT)), + interner.tokenize(ours.split(LINE_SPLIT)), + ); + + const merged: string[] = []; + const conflicts: ConflictRegion[] = []; + const stats = { linesUnchanged: 0, linesAutoMerged: 0, linesConflicted: 0 }; + + for (const region of regions) { + if (region.stable) { + const decoded = region.bufferContent.map(t => interner.detokenize(t)); + merged.push(...decoded); + // Stable region with buffer === 'o' means all three buffers agreed + // (truly unchanged). buffer === 'a' or 'b' means diff3 resolved to + // one side's version without ambiguity — count those as auto-merged. + if (region.buffer === 'o') { + stats.linesUnchanged += decoded.length; + } else { + stats.linesAutoMerged += decoded.length; + } + continue; + } + + const resolution = resolveUnstableRegion(region, merged.length, interner); + merged.push(...resolution.lines); + if (resolution.conflict) conflicts.push(resolution.conflict); + stats.linesAutoMerged += resolution.autoMergedLines; + stats.linesConflicted += resolution.conflictedLines; + } + + // Correct stats for the trailing-newline token. When all three inputs end + // with `\n`, `split` produced a trailing "" on each, diff3 emitted it as + // part of a stable unchanged region, and it's padded `linesUnchanged` by 1. + // Subtract it so stats match user-visible line counts. + if (merged.length > 0 && merged[merged.length - 1] === '' && stats.linesUnchanged > 0) { + stats.linesUnchanged -= 1; + } + + // Merged output is always LF; callers convert at the write boundary. + return { content: merged.join('\n'), conflicts, stats }; +} + +class LineInterner { + private readonly table = new Map(); + private readonly byIndex: string[] = []; + + tokenize(lines: readonly string[]): string[] { + return lines.map(line => { + let token = this.table.get(line); + if (token === undefined) { + token = String(this.byIndex.length); + this.table.set(line, token); + this.byIndex.push(line); + } + return token; + }); + } + + detokenize(token: string): string { + // Integer-string tokens issued by tokenize(); out-of-range would indicate + // a programming error, so we fail loudly rather than produce undefined. + const line = this.byIndex[Number(token)]; + if (line === undefined) { + throw new Error(`LineInterner: unknown token ${JSON.stringify(token)}`); + } + return line; + } +} + +interface RegionResolution { + readonly lines: readonly string[]; + readonly conflict: ConflictRegion | null; + readonly autoMergedLines: number; + readonly conflictedLines: number; +} + +/** + * Classify one unstable region. If either side kept the base unchanged (or + * both sides made the identical change), we can auto-merge. Otherwise we + * emit git-style conflict markers and describe a `ConflictRegion` for the + * UI layer. + * + * Pure function — no mutation. `mergedLengthBefore` is the length of the + * output buffer prior to this region and is used only to compute the + * 1-indexed line range recorded in the ConflictRegion. + */ +function resolveUnstableRegion( + region: IUnstableRegion, + mergedLengthBefore: number, + interner: LineInterner, +): RegionResolution { + // Operate on token arrays for equality checks (cheaper than decoding), but + // emit decoded lines and decoded conflict snapshots so callers never see + // the internal encoding. + const theirsTokens = region.aContent; + const oursTokens = region.bContent; + const baseTokens = region.oContent; + + const decode = (tokens: string[]): string[] => tokens.map(t => interner.detokenize(t)); + + if (arraysEqual(theirsTokens, baseTokens)) { + const decoded = decode(oursTokens); + return { lines: decoded, conflict: null, autoMergedLines: decoded.length, conflictedLines: 0 }; + } + if (arraysEqual(oursTokens, baseTokens) || arraysEqual(theirsTokens, oursTokens)) { + const decoded = decode(theirsTokens); + return { lines: decoded, conflict: null, autoMergedLines: decoded.length, conflictedLines: 0 }; + } + + const theirs = decode(theirsTokens); + const ours = decode(oursTokens); + const base = decode(baseTokens); + const lines = [CONFLICT_START, ...theirs, CONFLICT_SEPARATOR, ...ours, CONFLICT_END]; + const lineStart = mergedLengthBefore + 1; + const lineEnd = mergedLengthBefore + lines.length; + return { + lines, + conflict: { + lineStart, + lineEnd, + base: base.join('\n'), + theirs: theirs.join('\n'), + ours: ours.join('\n'), + }, + autoMergedLines: 0, + conflictedLines: theirs.length + ours.length, + }; +} + +function arraysEqual(a: readonly T[], b: readonly T[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]); +} diff --git a/source/core/drift.ts b/source/core/drift.ts new file mode 100644 index 0000000..a90998e --- /dev/null +++ b/source/core/drift.ts @@ -0,0 +1,231 @@ +/** + * Ownership drift detection. + * + * `detectDrift` walks every file recorded in `state.files` and classifies it + * by comparing the on-disk sha256 against the hash stored at install/update + * time. See docs/IMPLEMENTATION.md §4.8. + * + * The output buckets are consumed by the update command to decide, per file, + * whether to overwrite silently (`managed`), run a three-way merge + * (`modified`), skip (`volatile`), re-render fresh (`missing`), or surface + * for user attention (`orphaned`). + */ + +import fsp from 'node:fs/promises'; +import type { Dirent } from 'node:fs'; +import path from 'node:path'; +import type { ShardState, DriftReport, DriftEntry, FileState } from '../runtime/types.js'; +import { sha256 } from './fs-utils.js'; +import { isEnoent } from '../runtime/errno.js'; +import { SHARDMIND_DIR, VALUES_FILE, GIT_DIR, OBSIDIAN_DIR } from '../runtime/vault-paths.js'; + +type Bucket = 'managed' | 'modified' | 'volatile' | 'missing'; +type Classified = { bucket: Bucket; entry: DriftEntry }; + +/** + * Paths ShardMind owns but doesn't record in `state.files`. Excluded from + * orphan detection because they're the engine's own scaffolding, not user + * content. + */ +const ENGINE_RESERVED_FILES: ReadonlySet = new Set([VALUES_FILE]); + +/** + * Top-level directory names that should never be treated as "tracked" for + * orphan scanning. `.shardmind/` holds engine state; `.git/` and `.obsidian/` + * are third-party metadata the shard never claims to manage. Applied when + * deriving `trackedDirs` so we never readdir into one of these, even if the + * shard misconfigures a tracked file under them. + */ +const UNSCANNED_DIR_NAMES: ReadonlySet = new Set([ + SHARDMIND_DIR, + GIT_DIR, + OBSIDIAN_DIR, +]); + +/** + * Cap on concurrent file-read handles when hashing the vault. A small limit + * keeps per-update work bounded below typical OS fd limits (macOS defaults + * to ~256 without `ulimit -n`) while still saturating disk on realistic + * vault sizes. + */ +const DRIFT_READ_CONCURRENCY = 32; + +export async function detectDrift( + vaultRoot: string, + state: ShardState, +): Promise { + // Normalize to forward slashes so downstream lookups are separator- + // independent. State-file keys are written via `toPosix()` at install + // time, but defensive normalization guards any caller that writes state + // with native separators. + const trackedPaths: ReadonlySet = new Set( + Object.keys(state.files).map(toPosix), + ); + + const [classified, orphaned] = await Promise.all([ + mapConcurrent( + Object.entries(state.files), + DRIFT_READ_CONCURRENCY, + ([relPath, file]) => classifyFile(vaultRoot, relPath, file), + ), + detectOrphans(vaultRoot, trackedPaths), + ]); + + const managed: DriftEntry[] = []; + const modified: DriftEntry[] = []; + const volatile: DriftEntry[] = []; + const missing: DriftEntry[] = []; + const byBucket = { managed, modified, volatile, missing }; + + for (const { bucket, entry } of classified) { + byBucket[bucket].push(entry); + } + + return { managed, modified, volatile, missing, orphaned }; +} + +async function classifyFile( + vaultRoot: string, + relPath: string, + file: FileState, +): Promise { + // 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) }; + } + + try { + const content = await fsp.readFile(path.join(vaultRoot, relPath), 'utf-8'); + return classifyByHash(relPath, file, content); + } catch (err) { + if (isEnoent(err)) { + return { bucket: 'missing', entry: missingEntry(relPath, file) }; + } + throw err; + } +} + +function classifyByHash(relPath: string, file: FileState, content: string): Classified { + const actualHash = sha256(content); + const ownership = actualHash === file.rendered_hash ? 'managed' : 'modified'; + return { + bucket: ownership, + entry: { + path: relPath, + template: file.template, + renderedHash: file.rendered_hash, + actualHash, + ownership, + }, + }; +} + +function volatileEntry(relPath: string, file: FileState): DriftEntry { + return { + path: relPath, + template: file.template, + renderedHash: file.rendered_hash, + actualHash: null, + ownership: 'volatile', + }; +} + +function missingEntry(relPath: string, file: FileState): DriftEntry { + return { + path: relPath, + template: file.template, + renderedHash: file.rendered_hash, + actualHash: null, + ownership: file.ownership === 'modified' ? 'modified' : 'managed', + }; +} + +/** + * 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, +): Promise { + const trackedDirs = new Set(); + for (const relPath of trackedPaths) { + const dir = path.posix.dirname(relPath); + const normalizedDir = dir === '.' ? '' : dir; + // Never scan directories under an engine-reserved or third-party + // namespace, even if the shard somehow tracks a file inside one. + const topSegment = normalizedDir.split('/')[0] ?? ''; + if (topSegment && UNSCANNED_DIR_NAMES.has(topSegment)) continue; + trackedDirs.add(normalizedDir); + } + + const perDir = await Promise.all( + [...trackedDirs].map(relDir => scanDirForOrphans(vaultRoot, relDir, trackedPaths)), + ); + + return perDir.flat().sort(); +} + +async function scanDirForOrphans( + vaultRoot: string, + relDir: string, + trackedPaths: ReadonlySet, +): Promise { + const absDir = path.join(vaultRoot, relDir); + let entries: Dirent[]; + try { + entries = await fsp.readdir(absDir, { withFileTypes: true }); + } catch (err) { + if (isEnoent(err)) return []; + throw err; + } + + const orphans: string[] = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + const rel = relDir ? `${relDir}/${entry.name}` : entry.name; + if (trackedPaths.has(rel)) continue; + if (ENGINE_RESERVED_FILES.has(rel)) continue; + orphans.push(rel); + } + return orphans; +} + +function toPosix(p: string): string { + return p.replace(/\\/g, '/'); +} + +/** + * Bounded-concurrency `map`. Runs `fn` over `items` with at most + * `concurrency` in flight at once, preserving the input order in the + * returned array. Used to cap file-descriptor pressure during drift reads. + */ +async function mapConcurrent( + items: T[], + concurrency: number, + fn: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let cursor = 0; + const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (true) { + const i = cursor++; + if (i >= items.length) return; + results[i] = await fn(items[i]!); + } + }); + await Promise.all(workers); + return results; +} diff --git a/source/core/install-executor.ts b/source/core/install-executor.ts index 1144b9c..969653a 100644 --- a/source/core/install-executor.ts +++ b/source/core/install-executor.ts @@ -18,6 +18,7 @@ import type { ModuleSelections, } from '../runtime/types.js'; import { ShardMindError } from '../runtime/types.js'; +import { errnoCode } from '../runtime/errno.js'; import { resolveModules } from './modules.js'; import { createRenderer, renderFile, buildRenderContext } from './renderer.js'; import { @@ -328,8 +329,7 @@ async function writeValuesFile( try { await fsp.writeFile(abs, serialized, { encoding: 'utf-8', flag: 'wx' }); } catch (err) { - const code = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined; - if (code === 'EEXIST') { + if (errnoCode(err) === 'EEXIST') { throw new ShardMindError( 'shard-values.yaml already exists at the install target', 'VALUES_FILE_COLLISION', diff --git a/source/core/install-planner.ts b/source/core/install-planner.ts index 21a1017..93da43f 100644 --- a/source/core/install-planner.ts +++ b/source/core/install-planner.ts @@ -15,6 +15,7 @@ import type { ModuleSelections, } from '../runtime/types.js'; import { ShardMindError, assertNever } from '../runtime/types.js'; +import { isEnoent } from '../runtime/errno.js'; import { isComputedDefault } from './schema.js'; import { resolveModules } from './modules.js'; import { sha256 } from './fs-utils.js'; @@ -131,8 +132,7 @@ export async function detectCollisions( }); } } catch (err) { - const code = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined; - if (code !== 'ENOENT') { + if (!isEnoent(err)) { throw new ShardMindError( `Could not check existing file: ${absolutePath}`, 'COLLISION_CHECK_FAILED', diff --git a/source/core/manifest.ts b/source/core/manifest.ts index e1bed04..26d73c1 100644 --- a/source/core/manifest.ts +++ b/source/core/manifest.ts @@ -4,6 +4,7 @@ import semver from 'semver'; import { z } from 'zod'; import type { ShardManifest } from '../runtime/types.js'; import { ShardMindError } from '../runtime/types.js'; +import { errnoCode } from '../runtime/errno.js'; export const ShardManifestSchema = z.object({ apiVersion: z.literal('v1'), @@ -34,7 +35,7 @@ export async function parseManifest(filePath: string): Promise { try { raw = await fs.readFile(filePath, 'utf-8'); } catch (err) { - const fsCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined; + const fsCode = errnoCode(err); if (fsCode === 'ENOENT') { throw new ShardMindError( `Cannot read shard.yaml: ${filePath}`, diff --git a/source/core/renderer.ts b/source/core/renderer.ts index 27f466b..a86f4fc 100644 --- a/source/core/renderer.ts +++ b/source/core/renderer.ts @@ -14,12 +14,47 @@ import { ShardMindError } from '../runtime/types.js'; const VOLATILE_MARKER = '{# shardmind: volatile #}'; const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/; +const NUNJUCKS_OPTS = { + autoescape: false, + trimBlocks: true, + lstripBlocks: true, +} as const; + export function createRenderer(templateDir: string): nunjucks.Environment { - return nunjucks.configure(templateDir, { - autoescape: false, - trimBlocks: true, - lstripBlocks: true, - }); + return nunjucks.configure(templateDir, NUNJUCKS_OPTS); +} + +/** + * Isolated env for rendering a template from a string (no filesystem loader). + * Lazily constructed so the `nunjucks.Environment` is only built when needed + * and never pollutes the module's global `nunjucks.configure()` state. + */ +let defaultStringEnv: nunjucks.Environment | undefined; + +function getDefaultStringEnv(): nunjucks.Environment { + if (!defaultStringEnv) { + // Empty loader array → no filesystem resolution. `{% include %}` et al. + // wouldn't find anything, which is the correct behavior for in-memory + // string rendering. (Passing `null` here works today but isn't + // documented by nunjucks as a supported loader value.) + defaultStringEnv = new nunjucks.Environment([], NUNJUCKS_OPTS); + } + return defaultStringEnv; +} + +/** + * Render a template provided as a string, with the same frontmatter-aware + * split/render/YAML-normalize/recombine pipeline that `renderFile` uses. + * Used by the merge engine (`differ.ts`) where the old/new templates live + * in memory (cached or freshly downloaded), not on disk. + */ +export function renderString( + source: string, + context: RenderContext, + filePath: string, + env: nunjucks.Environment = getDefaultStringEnv(), +): string { + return renderContent(source, context, env, filePath); } /** @@ -120,27 +155,93 @@ function renderWithFrontmatter( env: nunjucks.Environment, filePath: string, ): string { - // Render frontmatter with Nunjucks - const renderedFm = renderTemplate(frontmatterRaw, context, env, filePath); + const safeFm = renderFrontmatterSafely(frontmatterRaw, context, env, filePath); + const renderedBody = renderTemplate(bodyRaw, context, env, filePath); + return `---\n${safeFm}\n---\n${renderedBody}`; +} - // Parse → stringify for safe YAML escaping - let safeFm: string; +/** + * Render the frontmatter, then `parseYaml` → `stringifyYaml` so the stored + * shape is stable. If a template substitutes a value that contains YAML + * special characters (colon, pipe, quote, etc.) into an unquoted scalar + * position — e.g. `owner: {{ name }}` with `name = "foo: bar"` — the naive + * render produces invalid YAML. + * + * Rather than punt to the template author, attempt a one-shot recovery: + * re-render with every string value in the context replaced by its + * JSON-encoded form (which is always a valid YAML double-quoted scalar). + * Non-string leaves (numbers, booleans, arrays, nested objects) are left + * untouched so their intended YAML type is preserved. + * + * If recovery still fails, throw — that means the template itself produces + * non-YAML output independent of the values, which is a template bug. + */ +function renderFrontmatterSafely( + frontmatterRaw: string, + context: Record, + env: nunjucks.Environment, + filePath: string, +): string { + const firstAttempt = renderTemplate(frontmatterRaw, context, env, filePath); + const firstParse = tryParseYaml(firstAttempt); + if (firstParse.ok) { + return stringifyYaml(firstParse.value, { lineWidth: 0 }).trimEnd(); + } + + const escapedContext = encodeStringLeaves(context) as Record; + const secondAttempt = renderTemplate(frontmatterRaw, escapedContext, env, filePath); + const secondParse = tryParseYaml(secondAttempt); + if (secondParse.ok) { + return stringifyYaml(secondParse.value, { lineWidth: 0 }).trimEnd(); + } + + throw new ShardMindError( + `Frontmatter in ${filePath} rendered invalid YAML: ${firstParse.error}`, + 'RENDER_FRONTMATTER_ERROR', + 'The template frontmatter is syntactically invalid even with YAML-safe value substitution. Check the raw template frontmatter for structural issues.', + ); +} + +type ParseResult = { ok: true; value: unknown } | { ok: false; error: string }; + +function tryParseYaml(source: string): ParseResult { try { - const parsed = parseYaml(renderedFm); - safeFm = stringifyYaml(parsed, { lineWidth: 0 }).trimEnd(); + return { ok: true, value: parseYaml(source) }; } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new ShardMindError( - `Frontmatter in ${filePath} rendered invalid YAML: ${message}`, - 'RENDER_FRONTMATTER_ERROR', - 'Check template frontmatter for syntax issues after value substitution.', - ); + return { ok: false, error: err instanceof Error ? err.message : String(err) }; } +} - // Render body - const renderedBody = renderTemplate(bodyRaw, context, env, filePath); +/** + * Walk a value tree and JSON-encode every string leaf. The result is still + * a plain JS value — numbers/booleans/nested objects are unchanged — but + * any string that gets substituted into a YAML scalar position will land + * as a double-quoted form ("foo: bar") and parse as a string. + * + * Guards against circular references: a value may reach itself through a + * hook-computed default or other user-supplied structure; we break the + * cycle by returning the already-encoded stand-in, so the walk terminates. + */ +function encodeStringLeaves(value: unknown, seen: WeakMap = new WeakMap()): unknown { + if (typeof value === 'string') return JSON.stringify(value); + if (value === null || typeof value !== 'object') return value; - return `---\n${safeFm}\n---\n${renderedBody}`; + const cached = seen.get(value); + if (cached !== undefined) return cached; + + if (Array.isArray(value)) { + const out: unknown[] = []; + seen.set(value, out); + for (const item of value) out.push(encodeStringLeaves(item, seen)); + return out; + } + + const out: Record = {}; + seen.set(value, out); + for (const [k, v] of Object.entries(value)) { + out[k] = encodeStringLeaves(v, seen); + } + return out; } function renderTemplate( diff --git a/source/core/schema.ts b/source/core/schema.ts index 93a8b65..b712ac3 100644 --- a/source/core/schema.ts +++ b/source/core/schema.ts @@ -15,6 +15,7 @@ import { parse as parseYaml } from 'yaml'; import { z } from 'zod'; import type { ShardSchema, FrontmatterRule } from '../runtime/types.js'; import { ShardMindError } from '../runtime/types.js'; +import { errnoCode } from '../runtime/errno.js'; const OptionSchema = z.object({ value: z.string(), @@ -132,7 +133,7 @@ export async function parseSchema(filePath: string): Promise { try { raw = await fs.readFile(filePath, 'utf-8'); } catch (err) { - const fsCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined; + const fsCode = errnoCode(err); if (fsCode === 'ENOENT') { throw new ShardMindError( `Cannot read shard-schema.yaml: ${filePath}`, diff --git a/source/core/state.ts b/source/core/state.ts index 0a54171..2b5ae63 100644 --- a/source/core/state.ts +++ b/source/core/state.ts @@ -22,6 +22,7 @@ import { CACHED_TEMPLATES, SHARD_TEMPLATES_DIR, } from '../runtime/vault-paths.js'; +import { errnoCode } from '../runtime/errno.js'; import { migrateState } from './state-migrator.js'; const STATE_SCHEMA_VERSION = 1; @@ -111,8 +112,7 @@ export async function cacheTemplates(vaultRoot: string, tempDir: string): Promis try { await fsp.cp(src, dest, { recursive: true }); } catch (err) { - const code = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined; - if (code === 'ENOENT') { + if (errnoCode(err) === 'ENOENT') { throw new ShardMindError( `Missing templates/ directory in shard source: ${src}`, 'STATE_CACHE_MISSING_TEMPLATES', @@ -137,9 +137,3 @@ export async function cacheManifest( await fsp.writeFile(path.join(vaultRoot, CACHED_SCHEMA), serializedSchema, 'utf-8'); } -function errnoCode(err: unknown): string | undefined { - if (err instanceof Error && 'code' in err) { - return (err as NodeJS.ErrnoException).code; - } - return undefined; -} diff --git a/source/runtime/errno.ts b/source/runtime/errno.ts new file mode 100644 index 0000000..9d29273 --- /dev/null +++ b/source/runtime/errno.ts @@ -0,0 +1,20 @@ +/** + * Typed extractors for Node's `err.code` pattern. Centralized so the + * `err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined` + * dance doesn't recur in every file that touches the filesystem. + * + * Lives in runtime/ so both runtime modules (for hook scripts) and core + * modules can import without crossing the one-way runtime → core boundary. + */ + +export function errnoCode(err: unknown): string | undefined { + if (err === null || typeof err !== 'object' || !('code' in err)) return undefined; + const code = (err as { code?: unknown }).code; + // Node's types document `code` as string, but we guard at runtime so + // a stray non-string (some custom errors) can't violate our return type. + return typeof code === 'string' ? code : undefined; +} + +export function isEnoent(err: unknown): boolean { + return errnoCode(err) === 'ENOENT'; +} diff --git a/source/runtime/errors.ts b/source/runtime/errors.ts new file mode 100644 index 0000000..983fa67 --- /dev/null +++ b/source/runtime/errors.ts @@ -0,0 +1,71 @@ +/** + * Typed registry of every `ShardMindError.code` the engine can emit. + * + * Adding a new code: add a string literal to the union below, group it + * under the right domain comment. The compiler will then refuse any + * `new ShardMindError(msg, 'TYPO', hint)` across the whole codebase. + * + * Duplicate-looking codes (e.g. VALUES_NOT_FOUND vs VALUES_MISSING) are + * intentional: one fires from the runtime layer (hook scripts), the + * other from the commands layer (install machine). They're different + * surfaces with different recovery hints. Unification is a separate + * concern — see the design audit. + */ + +export type ErrorCode = + // Vault resolution + | 'VAULT_NOT_FOUND' + + // Shard manifest (shard.yaml) + | 'MANIFEST_NOT_FOUND' + | 'MANIFEST_READ_FAILED' + | 'MANIFEST_INVALID_YAML' + | 'MANIFEST_VALIDATION_FAILED' + + // Shard schema (shard-schema.yaml) + | 'SCHEMA_NOT_FOUND' + | 'SCHEMA_READ_FAILED' + | 'SCHEMA_INVALID_YAML' + | 'SCHEMA_VALIDATION_FAILED' + | 'SCHEMA_RESERVED_NAME' + + // Values file (shard-values.yaml) + | 'VALUES_NOT_FOUND' + | 'VALUES_READ_FAILED' + | 'VALUES_INVALID' + | 'VALUES_MISSING' + | 'VALUES_FILE_READ_FAILED' + | 'VALUES_FILE_INVALID' + | 'VALUES_FILE_COLLISION' + + // Engine state (.shardmind/state.json) + | 'STATE_READ_FAILED' + | 'STATE_CORRUPT' + | 'STATE_UNSUPPORTED_VERSION' + | 'STATE_CACHE_MISSING_TEMPLATES' + + // Registry / download + | 'SHARD_NOT_FOUND' + | 'VERSION_NOT_FOUND' + | 'REGISTRY_NETWORK' + | 'REGISTRY_INVALID_REF' + | 'REGISTRY_RATE_LIMITED' + | 'DOWNLOAD_HTTP_ERROR' + | 'DOWNLOAD_INVALID_TARBALL' + | 'DOWNLOAD_MISSING_MANIFEST' + | 'DOWNLOAD_MISSING_SCHEMA' + + // Templating / rendering + | 'RENDER_TEMPLATE_ERROR' + | 'RENDER_FRONTMATTER_ERROR' + | 'RENDER_ITERATOR_ERROR' + | 'RENDER_FAILED' + + // Install planner / executor + | 'COMPUTED_DEFAULT_FAILED' + | 'COMPUTED_DEFAULT_INVALID' + | 'COLLISION_CHECK_FAILED' + | 'BACKUP_FAILED' + + // Update / merge + | 'MERGE_FAILED'; diff --git a/source/runtime/index.ts b/source/runtime/index.ts index 5da4f12..1604987 100644 --- a/source/runtime/index.ts +++ b/source/runtime/index.ts @@ -19,6 +19,7 @@ export type { ValidationResult, FrontmatterValidationResult, HookContext, + ErrorCode, } from './types.js'; export { ShardMindError } from './types.js'; diff --git a/source/runtime/state.ts b/source/runtime/state.ts index 4594fc7..c152ff7 100644 --- a/source/runtime/state.ts +++ b/source/runtime/state.ts @@ -14,6 +14,7 @@ import path from 'node:path'; import type { ShardState } from './types.js'; import { ShardMindError } from './types.js'; import { SHARDMIND_DIR, STATE_FILE } from './vault-paths.js'; +import { isEnoent } from './errno.js'; const MAX_DEPTH = 20; @@ -85,8 +86,7 @@ export async function loadState(): Promise { try { raw = await fsp.readFile(filePath, 'utf-8'); } catch (err) { - const fsCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined; - if (fsCode === 'ENOENT') return null; + if (isEnoent(err)) return null; throw new ShardMindError( `Cannot read state.json: ${filePath}`, 'STATE_READ_FAILED', diff --git a/source/runtime/types.ts b/source/runtime/types.ts index d2fc06b..d939767 100644 --- a/source/runtime/types.ts +++ b/source/runtime/types.ts @@ -204,15 +204,16 @@ export interface MergeStats { linesAutoMerged: number; } +/** Stats for a merge that may include conflicts (superset of MergeStats). */ +export interface MergeStatsWithConflicts extends MergeStats { + linesConflicted: number; +} + export interface MergeResult { content: string; hasConflicts: boolean; conflicts: ConflictRegion[]; - stats: { - linesUnchanged: number; - linesAutoMerged: number; - linesConflicted: number; - }; + stats: MergeStatsWithConflicts; } export interface ConflictRegion { @@ -237,11 +238,14 @@ export interface HookContext { previousVersion?: string; } +import type { ErrorCode } from './errors.js'; +export type { ErrorCode } from './errors.js'; + export class ShardMindError extends Error { - code: string; - hint?: string; + readonly code: ErrorCode; + readonly hint?: string; - constructor(message: string, code: string, hint?: string) { + constructor(message: string, code: ErrorCode, hint?: string) { super(message); this.name = 'ShardMindError'; this.code = code; diff --git a/source/runtime/values.ts b/source/runtime/values.ts index 44cbf2e..f7e6785 100644 --- a/source/runtime/values.ts +++ b/source/runtime/values.ts @@ -6,6 +6,7 @@ import type { ShardSchema, ValidationResult } from './types.js'; import { ShardMindError } from './types.js'; import { resolveVaultRoot } from './state.js'; import { VALUES_FILE } from './vault-paths.js'; +import { errnoCode } from './errno.js'; /** * Load `shard-values.yaml` from the current vault. @@ -36,7 +37,7 @@ export async function loadValues(): Promise> { try { raw = await fs.readFile(filePath, 'utf-8'); } catch (err) { - const fsCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined; + const fsCode = errnoCode(err); if (fsCode === 'ENOENT') { throw new ShardMindError( `Cannot read shard-values.yaml: ${filePath}`, diff --git a/source/runtime/vault-paths.ts b/source/runtime/vault-paths.ts index 4152ae5..6ed2866 100644 --- a/source/runtime/vault-paths.ts +++ b/source/runtime/vault-paths.ts @@ -23,6 +23,14 @@ export const CLAUDE_DIR = '.claude'; /** Codex prompts namespace. */ export const CODEX_DIR = '.codex/prompts'; +/** + * Third-party vault metadata directories that ShardMind never claims to + * manage. Named here so drift detection and any future scan paths share + * one blacklist instead of sprinkling literal strings across modules. + */ +export const GIT_DIR = '.git'; +export const OBSIDIAN_DIR = '.obsidian'; + /** Source-side filenames inside a downloaded shard's temp directory. */ export const SHARD_MANIFEST_FILE = 'shard.yaml'; export const SHARD_SCHEMA_FILE = 'shard-schema.yaml'; diff --git a/tests/fixtures/merge/18-empty-file/actual-file.md b/tests/fixtures/merge/18-empty-file/actual-file.md new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/merge/18-empty-file/expected-output.md b/tests/fixtures/merge/18-empty-file/expected-output.md new file mode 100644 index 0000000..fe22fb7 --- /dev/null +++ b/tests/fixtures/merge/18-empty-file/expected-output.md @@ -0,0 +1,3 @@ +# Welcome + +The shard added this file in the v2 release. diff --git a/tests/fixtures/merge/18-empty-file/new-template.md.njk b/tests/fixtures/merge/18-empty-file/new-template.md.njk new file mode 100644 index 0000000..fe22fb7 --- /dev/null +++ b/tests/fixtures/merge/18-empty-file/new-template.md.njk @@ -0,0 +1,3 @@ +# Welcome + +The shard added this file in the v2 release. diff --git a/tests/fixtures/merge/18-empty-file/new-values.yaml b/tests/fixtures/merge/18-empty-file/new-values.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fixtures/merge/18-empty-file/new-values.yaml @@ -0,0 +1 @@ +{} diff --git a/tests/fixtures/merge/18-empty-file/old-template.md.njk b/tests/fixtures/merge/18-empty-file/old-template.md.njk new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/merge/18-empty-file/old-values.yaml b/tests/fixtures/merge/18-empty-file/old-values.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fixtures/merge/18-empty-file/old-values.yaml @@ -0,0 +1 @@ +{} diff --git a/tests/fixtures/merge/18-empty-file/scenario.yaml b/tests/fixtures/merge/18-empty-file/scenario.yaml new file mode 100644 index 0000000..12f9c48 --- /dev/null +++ b/tests/fixtures/merge/18-empty-file/scenario.yaml @@ -0,0 +1,13 @@ +name: "Empty file scenario — shard adds content to what was a blank slate" +description: > + The old template rendered to an empty file (or an empty placeholder + stub that the user never touched). The new template ships actual + content. Expected: overwrite — the user has no edits to preserve, so + the shard's new content lands silently. + +ownership_before: managed +user_edited: false +template_changed: true +values_changed: false +conflict_expected: false +expected_action: overwrite diff --git a/tests/fixtures/merge/19-utf8-non-ascii/actual-file.md b/tests/fixtures/merge/19-utf8-non-ascii/actual-file.md new file mode 100644 index 0000000..169fd02 --- /dev/null +++ b/tests/fixtures/merge/19-utf8-non-ascii/actual-file.md @@ -0,0 +1,11 @@ +# Café résumé 📝 + +Owner: Amélie. + +## Notes + +日本語 — Japanese heading. + +## Personal + +Ajouté par l'utilisatrice — 🎯 goal for 2026. diff --git a/tests/fixtures/merge/19-utf8-non-ascii/expected-output.md b/tests/fixtures/merge/19-utf8-non-ascii/expected-output.md new file mode 100644 index 0000000..bf7c5f2 --- /dev/null +++ b/tests/fixtures/merge/19-utf8-non-ascii/expected-output.md @@ -0,0 +1,12 @@ +# Café résumé 📝 + +Owner: Amélie. + +## Notes + +日本語 — Japanese heading. +中文 — Simplified Chinese heading. + +## Personal + +Ajouté par l'utilisatrice — 🎯 goal for 2026. diff --git a/tests/fixtures/merge/19-utf8-non-ascii/new-template.md.njk b/tests/fixtures/merge/19-utf8-non-ascii/new-template.md.njk new file mode 100644 index 0000000..fe0c4c9 --- /dev/null +++ b/tests/fixtures/merge/19-utf8-non-ascii/new-template.md.njk @@ -0,0 +1,8 @@ +# Café résumé 📝 + +Owner: {{ user_name }}. + +## Notes + +日本語 — Japanese heading. +中文 — Simplified Chinese heading. diff --git a/tests/fixtures/merge/19-utf8-non-ascii/new-values.yaml b/tests/fixtures/merge/19-utf8-non-ascii/new-values.yaml new file mode 100644 index 0000000..547af5a --- /dev/null +++ b/tests/fixtures/merge/19-utf8-non-ascii/new-values.yaml @@ -0,0 +1 @@ +user_name: "Amélie" diff --git a/tests/fixtures/merge/19-utf8-non-ascii/old-template.md.njk b/tests/fixtures/merge/19-utf8-non-ascii/old-template.md.njk new file mode 100644 index 0000000..6c63a93 --- /dev/null +++ b/tests/fixtures/merge/19-utf8-non-ascii/old-template.md.njk @@ -0,0 +1,7 @@ +# Café résumé 📝 + +Owner: {{ user_name }}. + +## Notes + +日本語 — Japanese heading. diff --git a/tests/fixtures/merge/19-utf8-non-ascii/old-values.yaml b/tests/fixtures/merge/19-utf8-non-ascii/old-values.yaml new file mode 100644 index 0000000..547af5a --- /dev/null +++ b/tests/fixtures/merge/19-utf8-non-ascii/old-values.yaml @@ -0,0 +1 @@ +user_name: "Amélie" diff --git a/tests/fixtures/merge/19-utf8-non-ascii/scenario.yaml b/tests/fixtures/merge/19-utf8-non-ascii/scenario.yaml new file mode 100644 index 0000000..4f1f167 --- /dev/null +++ b/tests/fixtures/merge/19-utf8-non-ascii/scenario.yaml @@ -0,0 +1,13 @@ +name: "UTF-8 non-ASCII content round-trips through merge" +description: > + The template and values contain non-ASCII characters (emoji, accented + Latin, CJK, combining marks). An auto-mergeable modification preserves + every byte. Regression guard: a Buffer/string conversion that stripped + or doubled bytes would corrupt content invisibly in ASCII-only tests. + +ownership_before: modified +user_edited: true +template_changed: true +values_changed: false +conflict_expected: false +expected_action: auto_merge diff --git a/tests/fixtures/merge/20-frontmatter-modified-merge/actual-file.md b/tests/fixtures/merge/20-frontmatter-modified-merge/actual-file.md new file mode 100644 index 0000000..7941f0a --- /dev/null +++ b/tests/fixtures/merge/20-frontmatter-modified-merge/actual-file.md @@ -0,0 +1,9 @@ +--- +tags: + - brain + - personal +--- + +# Note + +Body original. diff --git a/tests/fixtures/merge/20-frontmatter-modified-merge/expected-output.md b/tests/fixtures/merge/20-frontmatter-modified-merge/expected-output.md new file mode 100644 index 0000000..f464c50 --- /dev/null +++ b/tests/fixtures/merge/20-frontmatter-modified-merge/expected-output.md @@ -0,0 +1,9 @@ +--- +tags: + - brain + - personal +--- + +# Note + +Body updated by shard. diff --git a/tests/fixtures/merge/20-frontmatter-modified-merge/new-template.md.njk b/tests/fixtures/merge/20-frontmatter-modified-merge/new-template.md.njk new file mode 100644 index 0000000..9b2a197 --- /dev/null +++ b/tests/fixtures/merge/20-frontmatter-modified-merge/new-template.md.njk @@ -0,0 +1,7 @@ +--- +tags: [brain] +--- + +# Note + +Body updated by shard. diff --git a/tests/fixtures/merge/20-frontmatter-modified-merge/new-values.yaml b/tests/fixtures/merge/20-frontmatter-modified-merge/new-values.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fixtures/merge/20-frontmatter-modified-merge/new-values.yaml @@ -0,0 +1 @@ +{} diff --git a/tests/fixtures/merge/20-frontmatter-modified-merge/old-template.md.njk b/tests/fixtures/merge/20-frontmatter-modified-merge/old-template.md.njk new file mode 100644 index 0000000..f4127af --- /dev/null +++ b/tests/fixtures/merge/20-frontmatter-modified-merge/old-template.md.njk @@ -0,0 +1,7 @@ +--- +tags: [brain] +--- + +# Note + +Body original. diff --git a/tests/fixtures/merge/20-frontmatter-modified-merge/old-values.yaml b/tests/fixtures/merge/20-frontmatter-modified-merge/old-values.yaml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fixtures/merge/20-frontmatter-modified-merge/old-values.yaml @@ -0,0 +1 @@ +{} diff --git a/tests/fixtures/merge/20-frontmatter-modified-merge/scenario.yaml b/tests/fixtures/merge/20-frontmatter-modified-merge/scenario.yaml new file mode 100644 index 0000000..4ac0303 --- /dev/null +++ b/tests/fixtures/merge/20-frontmatter-modified-merge/scenario.yaml @@ -0,0 +1,14 @@ +name: "User edited frontmatter, shard edited body — clean auto-merge" +description: > + Modified ownership. The user added a tag to the frontmatter; the shard + updated the body text. diff3 should merge both changes because they + touch disjoint line ranges. Covers frontmatter + body interleaving + that scenario 12 (managed, frontmatter-only, overwrite) never exercises. + +ownership_before: modified +user_edited: true +template_changed: true +values_changed: false +conflict_expected: false +frontmatter_only: false +expected_action: auto_merge diff --git a/tests/helpers/index.ts b/tests/helpers/index.ts new file mode 100644 index 0000000..d8349b1 --- /dev/null +++ b/tests/helpers/index.ts @@ -0,0 +1,7 @@ +/** + * Barrel export for test helpers. Import from `tests/helpers` — never + * reach into individual files — so the surface area of shared scaffolding + * stays small and discoverable. + */ + +export { makeShardState, makeFileState } from './shard-state.js'; diff --git a/tests/helpers/shard-state.ts b/tests/helpers/shard-state.ts new file mode 100644 index 0000000..b6f7686 --- /dev/null +++ b/tests/helpers/shard-state.ts @@ -0,0 +1,34 @@ +/** + * Shared factories for building ShardState / FileState objects in tests. + * Centralizes the boilerplate so tests describe only the fields that matter + * to the scenario under test, not the surrounding required scaffolding. + */ + +import type { FileState, ShardState } from '../../source/runtime/types.js'; + +const PLACEHOLDER_HASH = 'x'.repeat(64); + +export function makeShardState(overrides: Partial = {}): ShardState { + return { + schema_version: 1, + shard: 'test/shard', + source: 'github:test/shard', + version: '0.1.0', + tarball_sha256: PLACEHOLDER_HASH, + installed_at: '2026-04-01T00:00:00Z', + updated_at: '2026-04-01T00:00:00Z', + values_hash: PLACEHOLDER_HASH, + modules: {}, + files: {}, + ...overrides, + }; +} + +export function makeFileState(overrides: Partial = {}): FileState { + return { + template: 'templates/unspecified.md.njk', + rendered_hash: PLACEHOLDER_HASH, + ownership: 'managed', + ...overrides, + }; +} diff --git a/tests/unit/differ-line-endings.test.ts b/tests/unit/differ-line-endings.test.ts new file mode 100644 index 0000000..095d7a2 --- /dev/null +++ b/tests/unit/differ-line-endings.test.ts @@ -0,0 +1,34 @@ +/** + * CRLF/LF robustness for the three-way merge. On Windows, a user's vault + * file may be saved with CRLF; base/ours are renderer output (always LF). + * Without normalization every line in `theirs` would trail with '\r' and + * diff3 would report every line as different. + */ + +import { describe, it, expect } from 'vitest'; +import { threeWayMerge } from '../../source/core/differ.js'; + +describe('threeWayMerge — line ending robustness', () => { + it('treats CRLF theirs as identical to LF base/ours when content matches', () => { + const base = '# Title\n\nLine one.\nLine two.\n'; + const ours = '# Title\n\nLine one.\nLine two.\n'; + const theirs = base.replace(/\n/g, '\r\n'); + + const result = threeWayMerge(base, theirs, ours); + + expect(result.conflicts).toHaveLength(0); + expect(result.stats.linesConflicted).toBe(0); + }); + + it('detects a real conflict even when theirs uses CRLF', () => { + const base = '# Title\n\nOriginal body.\n'; + const ours = '# Title\n\nShard-updated body.\n'; + const theirs = '# Title\r\n\r\nUser-edited body.\r\n'; + + const result = threeWayMerge(base, theirs, ours); + + expect(result.conflicts).toHaveLength(1); + expect(result.content).toContain('<<<<<<< yours'); + expect(result.content).not.toContain('\r'); + }); +}); diff --git a/tests/unit/drift-classification.test.ts b/tests/unit/drift-classification.test.ts new file mode 100644 index 0000000..06cfee1 --- /dev/null +++ b/tests/unit/drift-classification.test.ts @@ -0,0 +1,257 @@ +/** + * Direct unit tests for drift.detectDrift — complements the fixture-driven + * suite in drift.test.ts (which targets computeMergeAction + orchestration + * dispatch). Each test builds a throwaway vault with a hand-crafted state.json + * and asserts the resulting DriftReport bucket assignment. + */ + +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { afterEach, beforeEach, describe, it, expect } from 'vitest'; +import { detectDrift } from '../../source/core/drift.js'; +import { sha256 } from '../../source/core/fs-utils.js'; +import { makeShardState } from '../helpers/index.js'; + +let vaultRoot: string; + +beforeEach(async () => { + vaultRoot = path.join(os.tmpdir(), `drift-unit-${crypto.randomUUID()}`); + await fsp.mkdir(vaultRoot, { recursive: true }); +}); + +afterEach(async () => { + await fsp.rm(vaultRoot, { recursive: true, force: true }); +}); + +async function writeFile(relPath: string, content: string): Promise { + const abs = path.join(vaultRoot, relPath); + await fsp.mkdir(path.dirname(abs), { recursive: true }); + await fsp.writeFile(abs, content, 'utf-8'); +} + +describe('detectDrift', () => { + it('classifies a hash-matching file as managed', async () => { + const content = '# Managed\n'; + await writeFile('notes/a.md', content); + const state = makeShardState({ files: { + 'notes/a.md': { + template: 't.njk', + rendered_hash: sha256(content), + ownership: 'managed', + }, + } }); + + const report = await detectDrift(vaultRoot, state); + + expect(report.managed).toHaveLength(1); + expect(report.managed[0]?.path).toBe('notes/a.md'); + expect(report.managed[0]?.actualHash).toBe(sha256(content)); + expect(report.managed[0]?.ownership).toBe('managed'); + expect(report.modified).toHaveLength(0); + expect(report.volatile).toHaveLength(0); + expect(report.missing).toHaveLength(0); + }); + + it('classifies a hash-differing file as modified', async () => { + await writeFile('notes/b.md', '# Edited by user\n'); + const state = makeShardState({ files: { + 'notes/b.md': { + template: 't.njk', + rendered_hash: sha256('# Original\n'), + ownership: 'managed', + }, + } }); + + const report = await detectDrift(vaultRoot, state); + + expect(report.modified).toHaveLength(1); + expect(report.modified[0]?.ownership).toBe('modified'); + expect(report.managed).toHaveLength(0); + }); + + it('maps state ownership=user onto the volatile bucket', async () => { + // Content intentionally doesn't match the recorded hash — the whole point + // of volatile is that drift never hashes it. + await writeFile('inbox.md', '# edits the user is free to make\n'); + const state = makeShardState({ files: { + 'inbox.md': { + template: 'inbox.njk', + rendered_hash: 'stale-hash-on-purpose', + ownership: 'user', + }, + } }); + + const report = await detectDrift(vaultRoot, state); + + expect(report.volatile).toHaveLength(1); + expect(report.volatile[0]?.path).toBe('inbox.md'); + expect(report.volatile[0]?.ownership).toBe('volatile'); + expect(report.volatile[0]?.actualHash).toBeNull(); + expect(report.managed).toHaveLength(0); + expect(report.modified).toHaveLength(0); + }); + + it('reports a file as missing when absent on disk', async () => { + const state = makeShardState({ files: { + 'gone.md': { + template: 't.njk', + rendered_hash: sha256('# was here\n'), + ownership: 'managed', + }, + } }); + + const report = await detectDrift(vaultRoot, state); + + expect(report.missing).toHaveLength(1); + expect(report.missing[0]?.path).toBe('gone.md'); + expect(report.missing[0]?.actualHash).toBeNull(); + }); + + it('propagates state ownership=modified onto missing entries', async () => { + const state = makeShardState({ files: { + 'gone.md': { + template: 't.njk', + rendered_hash: sha256('# was here\n'), + ownership: 'modified', + }, + } }); + + const report = await detectDrift(vaultRoot, state); + + expect(report.missing[0]?.ownership).toBe('modified'); + }); + + it('classifies a mixed vault across all buckets in one pass', async () => { + const managedContent = 'managed\n'; + const modifiedContent = 'user edited\n'; + const volatileContent = 'inbox scratch\n'; + await writeFile('m.md', managedContent); + await writeFile('x.md', modifiedContent); + await writeFile('v.md', volatileContent); + + const state = makeShardState({ files: { + 'm.md': { template: 't.njk', rendered_hash: sha256(managedContent), ownership: 'managed' }, + 'x.md': { template: 't.njk', rendered_hash: sha256('original\n'), ownership: 'managed' }, + 'v.md': { template: 'inbox.njk', rendered_hash: 'stale', ownership: 'user' }, + 'missing.md': { template: 't.njk', rendered_hash: sha256('x\n'), ownership: 'managed' }, + } }); + + const report = await detectDrift(vaultRoot, state); + + expect(report.managed.map(e => e.path)).toEqual(['m.md']); + expect(report.modified.map(e => e.path)).toEqual(['x.md']); + expect(report.volatile.map(e => e.path)).toEqual(['v.md']); + expect(report.missing.map(e => e.path)).toEqual(['missing.md']); + }); + + it('returns empty buckets for a state with no files', async () => { + const report = await detectDrift(vaultRoot, makeShardState({ files: {} })); + + expect(report.managed).toEqual([]); + expect(report.modified).toEqual([]); + expect(report.volatile).toEqual([]); + expect(report.missing).toEqual([]); + expect(report.orphaned).toEqual([]); + }); +}); + +describe('detectDrift — orphan detection', () => { + it('reports a user file alongside a tracked file as orphaned', async () => { + await writeFile('skills/leadership.md', '# Leadership\n'); + await writeFile('skills/my-extra-skill.md', '# My extra skill\n'); + + const state = makeShardState({ files: { + 'skills/leadership.md': { + template: 'skills/_each.md.njk', + rendered_hash: sha256('# Leadership\n'), + ownership: 'managed', + }, + } }); + + const report = await detectDrift(vaultRoot, state); + + expect(report.orphaned).toEqual(['skills/my-extra-skill.md']); + expect(report.managed).toHaveLength(1); + }); + + it('does not recurse into untracked subdirectories', async () => { + await writeFile('CLAUDE.md', '# shard\n'); + await writeFile('brain/daily/2026-04-19.md', 'user note\n'); + + const state = makeShardState({ files: { + 'CLAUDE.md': { + template: 'CLAUDE.md.njk', + rendered_hash: sha256('# shard\n'), + ownership: 'managed', + }, + } }); + + const report = await detectDrift(vaultRoot, state); + + // `brain/daily/` has no tracked files — user content there is not the + // shard's concern and must not appear as an orphan. + expect(report.orphaned).not.toContain('brain/daily/2026-04-19.md'); + }); + + it('excludes engine-reserved files (shard-values.yaml)', async () => { + await writeFile('CLAUDE.md', '# shard\n'); + await writeFile('shard-values.yaml', 'user_name: "Alice"\n'); + + const state = makeShardState({ files: { + 'CLAUDE.md': { + template: 'CLAUDE.md.njk', + rendered_hash: sha256('# shard\n'), + ownership: 'managed', + }, + } }); + + const report = await detectDrift(vaultRoot, state); + + expect(report.orphaned).not.toContain('shard-values.yaml'); + }); + + it('never scans .shardmind/, .git/, or .obsidian/', async () => { + await writeFile('CLAUDE.md', '# shard\n'); + await writeFile('.shardmind/state.json', '{}'); + await writeFile('.git/HEAD', 'ref: refs/heads/main\n'); + await writeFile('.obsidian/app.json', '{}'); + + const state = makeShardState({ files: { + 'CLAUDE.md': { + template: 'CLAUDE.md.njk', + rendered_hash: sha256('# shard\n'), + ownership: 'managed', + }, + } }); + + const report = await detectDrift(vaultRoot, state); + + expect(report.orphaned).toEqual([]); + }); + + it('aggregates orphans across multiple tracked directories', async () => { + await writeFile('CLAUDE.md', '# root\n'); + await writeFile('extra-at-root.md', 'user\n'); + await writeFile('skills/leadership.md', '# L\n'); + await writeFile('skills/my-extra.md', 'user\n'); + + const state = makeShardState({ files: { + 'CLAUDE.md': { + template: 'CLAUDE.md.njk', + rendered_hash: sha256('# root\n'), + ownership: 'managed', + }, + 'skills/leadership.md': { + template: 'skills/_each.md.njk', + rendered_hash: sha256('# L\n'), + ownership: 'managed', + }, + } }); + + const report = await detectDrift(vaultRoot, state); + + expect(report.orphaned).toEqual(['extra-at-root.md', 'skills/my-extra.md']); + }); +}); diff --git a/tests/unit/drift.test.ts b/tests/unit/drift.test.ts index a95b0a7..c240536 100644 --- a/tests/unit/drift.test.ts +++ b/tests/unit/drift.test.ts @@ -1,10 +1,9 @@ /** - * Fixture-driven merge engine tests (TDD for drift.ts + differ.ts). + * Fixture-driven merge engine tests (drift.ts + differ.ts). * * Each directory under tests/fixtures/merge/ defines one scenario via - * scenario.yaml + 6 companion files. The runner auto-discovers every fixture - * and exercises computeMergeAction against it. Tests are `it.skip` until - * source/core/differ.ts lands (#11); unskip then. + * scenario.yaml + 6 companion files. The runner auto-discovers every + * fixture and dispatches to the right code path based on scenario flags. * * On-disk layout per fixture: * scenario.yaml metadata only — flags + expected_action @@ -18,24 +17,33 @@ * depends on implementation — e.g. conflict * markers) * - * The values YAMLs are the source of truth for render inputs; scenario.yaml - * does not duplicate them. + * Dispatch map: + * - Scenarios 01–09, 12, 16 → computeMergeAction (skip / overwrite / + * auto_merge / conflict) + * - Scenario 17 → volatile — detectDrift classification only + * - Scenarios 10, 13, 15 → new_file — render new template, assert create + * - Scenarios 11, 14 → removed — assert prompt_delete[_module] * - * Scenarios 10, 11, 13, 14, 15, 17 describe orchestration-level behavior - * (create / prompt_delete / prompt_delete_module / volatile skip). These - * actions are not in the MergeAction union in IMPLEMENTATION.md §4.9 and the - * `ownership: 'managed' | 'modified'` input can't carry the distinction. - * When unskipping, the implementer should dispatch in this runner so those - * scenarios call the orchestration path (e.g. drift.detectDrift for - * volatile, or whatever new-file / removed-file plumbing replaces them) and - * leave computeMergeAction for scenarios 01-09, 12, 16 only. + * The create / prompt_delete / prompt_delete_module / volatile-skip actions + * are orchestration-level (the update command's planner) and don't live in + * the MergeAction union in IMPLEMENTATION.md §4.9 — dispatch here, not in + * differ.ts. */ import fs from 'node:fs'; import fsp from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; +import crypto from 'node:crypto'; import { parse as parseYaml } from 'yaml'; import { describe, it, expect } from 'vitest'; +import { detectDrift } from '../../source/core/drift.js'; +import { computeMergeAction } from '../../source/core/differ.js'; +import { renderString } from '../../source/core/renderer.js'; +import { assertNever } from '../../source/runtime/types.js'; +import type { RenderContext } from '../../source/runtime/types.js'; +import { isEnoent } from '../../source/runtime/errno.js'; +import { makeShardState } from '../helpers/index.js'; const FIXTURES = path.resolve('tests/fixtures/merge'); @@ -72,6 +80,15 @@ interface FixtureFiles { expectedOutput: string | null; } +type ScenarioKind = 'volatile' | 'new_file' | 'removed' | 'standard'; + +function classifyScenario(s: Scenario): ScenarioKind { + if (s.volatile) return 'volatile'; + if (s.new_file) return 'new_file'; + if (s.removed) return 'removed'; + return 'standard'; +} + const fixtureDirs = fs .readdirSync(FIXTURES) .filter(name => fs.statSync(path.join(FIXTURES, name)).isDirectory()) @@ -92,12 +109,15 @@ async function loadFiles(dir: string): Promise { fsp.readFile(path.join(base, 'actual-file.md'), 'utf-8'), ]); + // expected-output.md is optional — conflict scenarios omit it because + // the marker format is implementation-defined. Only ENOENT is tolerated; + // any other read error (permissions, etc.) is a real fixture problem + // and should surface loudly. 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. + } catch (err) { + if (!isEnoent(err)) throw err; } return { @@ -111,65 +131,161 @@ async function loadFiles(dir: string): Promise { } /** - * Collapse scenario flags into the MergeAction ownership input. The Day 3 - * implementation will likely promote 'volatile' / 'absent' into a richer - * ownership union; for now we pass through so tests express intent. + * Runtime ownership is derived from drift hashing (detectDrift), not from + * the fixture's authored `ownership_before`. If `user_edited` is true, the + * actual file's hash differs from `rendered_hash`, and drift classifies the + * file as `modified` regardless of what `ownership_before` says. Scenario + * 07 relies on this — it declares `ownership_before: managed` with + * `user_edited: true` to model "file was managed until the user just + * edited it" and expects a conflict. */ function ownershipForMergeInput(scenario: Scenario): 'managed' | 'modified' { - if (scenario.ownership_before === 'modified') return 'modified'; + if (scenario.ownership_before === 'modified' || scenario.user_edited) return 'modified'; return 'managed'; } +function makeRenderContext(scenario: Scenario): RenderContext { + return { + values: {}, + included_modules: scenario.module ? [scenario.module] : [], + shard: { name: 'test-shard', version: '0.1.0' }, + install_date: '2026-04-01', + year: '2026', + }; +} + +const EXPECTED_FIXTURE_COUNT = 20; + describe('merge engine (fixture-driven)', () => { - it('discovers all 17 scenarios', () => { - expect(fixtureDirs).toHaveLength(17); + it(`discovers all ${EXPECTED_FIXTURE_COUNT} scenarios`, () => { + expect(fixtureDirs).toHaveLength(EXPECTED_FIXTURE_COUNT); }); for (const dir of fixtureDirs) { - // Scenario is loaded inside the test body so a malformed scenario.yaml - // fails that scenario only, not the whole file at collection time. - // - // Skipped until source/core/differ.ts lands (#11). The fixtures and - // runner body are landed now (per issue #10) so the implementer has a - // ready target to TDD against — unskip then, not before. - it.skip(dir, async () => { + // Scenario loaded inside the test body so a malformed scenario.yaml + // fails only that scenario, not the whole file at collection time. + it(dir, async () => { const scenario = await loadScenario(dir); - const { computeMergeAction } = await import('../../source/core/differ.js'); - const files = await loadFiles(dir); + const kind = classifyScenario(scenario); - const renderContext = { - values: files.newValues, - included_modules: scenario.module ? [scenario.module] : [], - shard: { name: 'test-shard', version: '0.1.0' }, - install_date: '2026-04-01', - year: '2026', - }; - - const action = await computeMergeAction({ - path: `${dir}.md`, - ownership: ownershipForMergeInput(scenario), - oldTemplate: files.oldTemplate, - newTemplate: files.newTemplate, - oldValues: files.oldValues, - newValues: files.newValues, - actualContent: files.actualContent, - renderContext, - }); - - expect(action.type).toBe(scenario.expected_action); - - if ( - files.expectedOutput !== null && - (action.type === 'overwrite' || action.type === 'auto_merge') - ) { - expect((action as { content: string }).content).toBe(files.expectedOutput); - } - - if (action.type === 'conflict' && scenario.expected_conflict_count !== undefined) { - const result = (action as { result: { conflicts: unknown[] } }).result; - expect(result.conflicts).toHaveLength(scenario.expected_conflict_count); + switch (kind) { + case 'volatile': + return assertVolatile(dir, scenario, files); + case 'new_file': + return assertNewFile(dir, scenario, files); + case 'removed': + return assertRemoved(scenario, files); + case 'standard': + return assertStandardMerge(dir, scenario, files); + default: + assertNever(kind); } }); } }); + +async function assertVolatile(dir: string, scenario: Scenario, files: FixtureFiles): Promise { + expect(scenario.expected_action).toBe('skip'); + const report = await buildVolatileDriftReport(dir, files.actualContent); + expect(report.volatile).toHaveLength(1); + expect(report.volatile[0]?.path).toBe(`${dir}.md`); +} + +function assertNewFile(dir: string, scenario: Scenario, files: FixtureFiles): void { + expect(scenario.expected_action).toBe('create'); + const renderContext = makeRenderContext(scenario); + const iteratorExtras = scenario.each_iterator_add + ? extractIteratorItem(files.newValues, scenario) + : {}; + const rendered = renderString( + files.newTemplate, + { ...renderContext, values: { ...files.newValues, ...iteratorExtras } }, + `${dir}.md`, + ); + if (files.expectedOutput !== null) { + expect(rendered).toBe(files.expectedOutput); + } +} + +function assertRemoved(scenario: Scenario, files: FixtureFiles): void { + const expected = + scenario.module_change === 'newly_excluded' ? 'prompt_delete_module' : 'prompt_delete'; + expect(scenario.expected_action).toBe(expected); + expect(files.newTemplate.trim()).toBe(''); +} + +async function assertStandardMerge( + dir: string, + scenario: Scenario, + files: FixtureFiles, +): Promise { + const action = await computeMergeAction({ + path: `${dir}.md`, + ownership: ownershipForMergeInput(scenario), + oldTemplate: files.oldTemplate, + newTemplate: files.newTemplate, + oldValues: files.oldValues, + newValues: files.newValues, + actualContent: files.actualContent, + renderContext: makeRenderContext(scenario), + }); + + expect(action.type).toBe(scenario.expected_action); + + if ( + files.expectedOutput !== null && + (action.type === 'overwrite' || action.type === 'auto_merge') + ) { + expect(action.content).toBe(files.expectedOutput); + } + + if (action.type === 'conflict') { + expect(action.result.content).toContain('<<<<<<< yours'); + expect(action.result.content).toContain('======='); + expect(action.result.content).toContain('>>>>>>> shard update'); + if (scenario.expected_conflict_count !== undefined) { + expect(action.result.conflicts).toHaveLength(scenario.expected_conflict_count); + } + } +} + +/** + * Build a minimal vault on disk with one volatile file and run detectDrift + * against it. Verifies that drift reports a volatile entry without attempting + * to hash-compare the content — the gate that makes the update command skip + * volatile files. + */ +async function buildVolatileDriftReport(dir: string, actualContent: string) { + const vaultRoot = path.join(os.tmpdir(), `drift-volatile-${crypto.randomUUID()}`); + await fsp.mkdir(vaultRoot, { recursive: true }); + try { + const relPath = `${dir}.md`; + await fsp.writeFile(path.join(vaultRoot, relPath), actualContent, 'utf-8'); + + const state = makeShardState({ files: { + [relPath]: { + template: 'templates/volatile.md.njk', + rendered_hash: 'stale-hash-that-does-not-match-on-purpose', + ownership: 'user', + }, + } }); + + return await detectDrift(vaultRoot, state); + } finally { + await fsp.rm(vaultRoot, { recursive: true, force: true }); + } +} + +function extractIteratorItem( + values: Record, + scenario: Scenario, +): Record { + if (!scenario.iterator_key || !scenario.new_item_slug) return {}; + const list = values[scenario.iterator_key]; + if (!Array.isArray(list)) return {}; + const item = (list as Array>).find( + i => i['slug'] === scenario.new_item_slug, + ); + return item ? { item } : {}; +} diff --git a/tests/unit/merge-adversarial.test.ts b/tests/unit/merge-adversarial.test.ts new file mode 100644 index 0000000..2e0f692 --- /dev/null +++ b/tests/unit/merge-adversarial.test.ts @@ -0,0 +1,699 @@ +/** + * Adversarial stress tests for the merge engine. These go after the engine + * from angles that fixture tests don't — bad encodings, reserved strings in + * user content, prototype pollution, size extremes, idempotence, race + * conditions. Every test here was written to *try to break* the engine; + * comments note where the design decisions stood up under pressure. + */ + +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { afterEach, beforeEach, describe, it, expect } from 'vitest'; +import { threeWayMerge, computeMergeAction } from '../../source/core/differ.js'; +import { detectDrift } from '../../source/core/drift.js'; +import { sha256 } from '../../source/core/fs-utils.js'; +import { makeShardState } from '../helpers/index.js'; +import type { RenderContext } from '../../source/runtime/types.js'; + +const CTX: RenderContext = { + values: {}, + included_modules: [], + shard: { name: 'test', version: '0.1.0' }, + install_date: '2026-04-19', + year: '2026', +}; + +describe('merge adversarial — control characters in line content', () => { + // `differ.ts` uses a `LineInterner` that maps every unique line to an + // integer-named token before passing it to diff3, so user content can + // contain *any* byte — including control characters that an earlier + // sentinel-prefix implementation would have mangled. These tests exist + // as a regression guard against reintroducing a strip-based encoding. + + it('preserves U+0001 that appears inside line content', () => { + const content = 'before\u0001after\n'; + const result = threeWayMerge(content, content, content); + expect(result.content).toBe(content); + }); + + it('preserves a line whose first character is U+0001', () => { + const content = '\u0001leading\n'; + const result = threeWayMerge(content, content, content); + expect(result.content).toBe(content); + }); +}); + +describe('merge adversarial — prototype pollution vectors', () => { + // Object.prototype is large; hit every common member name as a line. + const prototypeKeys = [ + 'constructor', + '__proto__', + 'toString', + 'hasOwnProperty', + 'valueOf', + 'isPrototypeOf', + 'propertyIsEnumerable', + 'toLocaleString', + '__defineGetter__', + '__defineSetter__', + '__lookupGetter__', + '__lookupSetter__', + ]; + + for (const key of prototypeKeys) { + it(`handles "${key}" as a single-line file`, () => { + const result = threeWayMerge(key, key, key); + expect(result.content).toBe(key); + }); + } + + it('handles a full Object.prototype dictionary as user content', () => { + const content = prototypeKeys.join('\n') + '\n'; + const result = threeWayMerge(content, content, content); + expect(result.content).toBe(content); + expect(result.conflicts).toHaveLength(0); + }); + + it('handles prototype keys in a conflict region', () => { + const base = 'alpha\nbeta\n'; + const theirs = 'alpha\n__proto__\n'; + const ours = 'alpha\nconstructor\n'; + const result = threeWayMerge(base, theirs, ours); + expect(result.conflicts).toHaveLength(1); + expect(result.content).toContain('__proto__'); + expect(result.content).toContain('constructor'); + }); +}); + +describe('merge adversarial — line ending edge cases', () => { + it('handles files with no trailing newline', () => { + const result = threeWayMerge('line', 'line', 'line'); + expect(result.content).toBe('line'); + expect(result.conflicts).toHaveLength(0); + }); + + it('preserves base/ours trailing newline style when both agree', () => { + const withLn = 'a\nb\n'; + const result = threeWayMerge(withLn, withLn, withLn); + expect(result.content).toBe(withLn); + }); + + it('handles mixed CRLF/LF in theirs', () => { + const base = 'a\nb\nc\n'; + const theirs = 'a\r\nb\nc\r\n'; + const ours = 'a\nb\nc\n'; + const result = threeWayMerge(base, theirs, ours); + expect(result.conflicts).toHaveLength(0); + expect(result.content).not.toContain('\r'); + }); + + it('handles pure CR (old Mac) line endings gracefully — no crash', () => { + // We don't promise to merge pure-CR files correctly (no realistic source + // writes them today), but the engine must not crash on them. + const cr = 'a\rb\rc'; + expect(() => threeWayMerge(cr, cr, cr)).not.toThrow(); + }); + + it('handles empty string on all three sides', () => { + const result = threeWayMerge('', '', ''); + expect(result.content).toBe(''); + expect(result.conflicts).toHaveLength(0); + }); +}); + +describe('merge adversarial — conflict marker injection', () => { + // A user's markdown file might legitimately contain `<<<<<<< yours` in a + // code block explaining git merges. If we don't defend against this, the + // post-merge output becomes ambiguous — a downstream tool (or human) can't + // tell the real markers from user content. + + it('user content containing literal marker strings round-trips through identity merge', () => { + const content = '# Git explainer\n\n```\n<<<<<<< yours\nconflict body\n=======\nother body\n>>>>>>> shard update\n```\n'; + const result = threeWayMerge(content, content, content); + expect(result.content).toBe(content); + expect(result.conflicts).toHaveLength(0); + }); + + it('user-content markers do not confuse auto-merge counting', () => { + const base = '# Note\n\nOriginal.\n'; + const ours = '# Note\n\nShard updated.\n'; + const theirs = '# Note\n\nOriginal.\n\n## Doc\n\n<<<<<<< yours\nexample\n=======\nother\n>>>>>>> shard update\n'; + const result = threeWayMerge(base, theirs, ours); + expect(result.conflicts).toHaveLength(0); + expect(result.content).toContain('<<<<<<< yours'); + expect(result.content).toContain('Shard updated.'); + }); +}); + +describe('merge adversarial — unicode, BOM, null bytes', () => { + it('preserves UTF-8 BOM if present on base/ours/theirs', () => { + const bom = '\uFEFF'; + const content = `${bom}# Title\n`; + const result = threeWayMerge(content, content, content); + expect(result.content).toBe(content); + }); + + it('preserves zero-width joiners inside emoji sequences', () => { + const content = '👨‍👩‍👧‍👦 family\n'; + const result = threeWayMerge(content, content, content); + expect(result.content).toBe(content); + }); + + it('preserves RTL text', () => { + const content = 'שלום עולם\nمرحبا بالعالم\n'; + const result = threeWayMerge(content, content, content); + expect(result.content).toBe(content); + }); + + it('preserves combining marks without NFC normalization', () => { + // "é" as precomposed vs decomposed — byte-equal comparison only. + const nfc = 'café\n'; + const nfd = 'cafe\u0301\n'; + expect(nfc).not.toBe(nfd); // sanity: they are byte-distinct + const result = threeWayMerge(nfd, nfd, nfd); + expect(result.content).toBe(nfd); + }); + + it('handles lines containing null bytes', () => { + const content = 'alpha\nbeta\0gamma\ndelta\n'; + const result = threeWayMerge(content, content, content); + expect(result.content).toBe(content); + }); +}); + +describe('merge adversarial — size extremes', () => { + it('handles a single-line 10K-char file', () => { + const line = 'x'.repeat(10_000); + const content = `${line}\n`; + const result = threeWayMerge(content, content, content); + expect(result.content).toBe(content); + expect(result.conflicts).toHaveLength(0); + }); + + it('handles a 5K-line file without panic', () => { + const lines = Array.from({ length: 5_000 }, (_, i) => `line ${i}`); + const content = lines.join('\n') + '\n'; + const result = threeWayMerge(content, content, content); + expect(result.content).toBe(content); + }); + + it('handles many small conflicts in one file', () => { + const base = Array.from({ length: 20 }, (_, i) => `shared-${i}\nbase-${i}`).join('\n') + '\n'; + const theirs = Array.from({ length: 20 }, (_, i) => `shared-${i}\ntheirs-${i}`).join('\n') + '\n'; + const ours = Array.from({ length: 20 }, (_, i) => `shared-${i}\nours-${i}`).join('\n') + '\n'; + const result = threeWayMerge(base, theirs, ours); + expect(result.conflicts.length).toBeGreaterThan(5); + }); +}); + +describe('merge adversarial — idempotence and convergence', () => { + it('identity merge is idempotent', () => { + const x = '# Note\n\nLine 1.\nLine 2.\n'; + const once = threeWayMerge(x, x, x); + const twice = threeWayMerge(once.content, once.content, once.content); + expect(twice.content).toBe(once.content); + }); + + it('re-merging auto-merged output against itself is stable', () => { + const base = 'a\nb\nc\n'; + const theirs = 'a\nb\nc\nd\n'; + const ours = 'A\nb\nc\n'; + const first = threeWayMerge(base, theirs, ours); + expect(first.conflicts).toHaveLength(0); + const second = threeWayMerge(first.content, first.content, first.content); + expect(second.content).toBe(first.content); + }); +}); + +describe('merge adversarial — conflict boundaries', () => { + it('conflict at very start of file', () => { + const base = 'base first\nshared\n'; + const theirs = 'theirs first\nshared\n'; + const ours = 'ours first\nshared\n'; + const result = threeWayMerge(base, theirs, ours); + expect(result.conflicts).toHaveLength(1); + expect(result.conflicts[0]!.lineStart).toBe(1); + }); + + it('conflict at very end of file', () => { + const base = 'shared\nbase last'; + const theirs = 'shared\ntheirs last'; + const ours = 'shared\nours last'; + const result = threeWayMerge(base, theirs, ours); + expect(result.conflicts).toHaveLength(1); + const conflict = result.conflicts[0]!; + expect(conflict.lineEnd).toBeGreaterThanOrEqual(conflict.lineStart); + }); + + it('conflict is the entire file', () => { + const result = threeWayMerge('original\n', 'user\n', 'shard\n'); + expect(result.conflicts).toHaveLength(1); + expect(result.conflicts[0]!.lineStart).toBe(1); + }); +}); + +describe('drift adversarial — races and weird filesystems', () => { + let vaultRoot: string; + + beforeEach(async () => { + vaultRoot = path.join(os.tmpdir(), `drift-adv-${crypto.randomUUID()}`); + await fsp.mkdir(vaultRoot, { recursive: true }); + }); + + afterEach(async () => { + await fsp.rm(vaultRoot, { recursive: true, force: true }); + }); + + it('tolerates a file disappearing between state read and scan', async () => { + const rel = 'ephemeral.md'; + await fsp.writeFile(path.join(vaultRoot, rel), 'content\n', 'utf-8'); + const state = makeShardState({ + files: { + [rel]: { template: 't.njk', rendered_hash: sha256('content\n'), ownership: 'managed' }, + }, + }); + + // Delete just before detectDrift reaches it — approximates a race. + await fsp.rm(path.join(vaultRoot, rel)); + const report = await detectDrift(vaultRoot, state); + + expect(report.missing).toHaveLength(1); + expect(report.managed).toHaveLength(0); + }); + + it('orphan scan tolerates a tracked directory being deleted', async () => { + // Legitimate: CLAUDE.md recorded at root but the vault root somehow + // lost the file. detectDrift should classify as missing without + // orphan scan blowing up. + const state = makeShardState({ + files: { + 'root.md': { template: 't.njk', rendered_hash: sha256('x\n'), ownership: 'managed' }, + }, + }); + + const report = await detectDrift(vaultRoot, state); + expect(report.missing).toHaveLength(1); + expect(report.orphaned).toEqual([]); + }); + + it('handles backslash path separators in state.files keys on Windows', async () => { + // install-executor normalizes to posix, but if a state.json ever + // slips through with native separators, drift must not choke. + const withBackslashes = 'nested\\file.md'; + await fsp.mkdir(path.join(vaultRoot, 'nested'), { recursive: true }); + await fsp.writeFile(path.join(vaultRoot, 'nested', 'file.md'), 'content\n', 'utf-8'); + const state = makeShardState({ + files: { + [withBackslashes]: { + template: 't.njk', + rendered_hash: sha256('content\n'), + ownership: 'managed', + }, + }, + }); + + // On Windows, path.join tolerates mixed separators; on POSIX, the + // backslashed path won't resolve. Either way, no crash. + await expect(detectDrift(vaultRoot, state)).resolves.toBeDefined(); + }); +}); + +describe('merge adversarial — trailing newline arithmetic', () => { + it('base ends with \\n, ours does not (shard dropped trailing newline)', () => { + const base = 'line\n'; + const theirs = 'line\n'; + const ours = 'line'; + const result = threeWayMerge(base, theirs, ours); + // Spec is silent on the right answer. What we guarantee is that the + // operation completes and the body content is preserved. + expect(result.content).toContain('line'); + }); + + it('theirs has extra trailing newlines the others do not', () => { + const base = 'a\n'; + const theirs = 'a\n\n\n'; + const ours = 'a\n'; + const result = threeWayMerge(base, theirs, ours); + expect(result.content.startsWith('a')).toBe(true); + }); + + it('no file ends with newline — merge still produces LF-joined output', () => { + const base = 'a\nb'; + const theirs = 'a\nb'; + const ours = 'a\nB'; + const result = threeWayMerge(base, theirs, ours); + expect(result.content).toBe('a\nB'); + }); +}); + +describe('merge adversarial — whitespace-sensitive diffs', () => { + it('distinguishes a blank line from a space-only line', () => { + const base = 'a\n\nb\n'; + const theirs = 'a\n \nb\n'; + const ours = 'a\n\nb\n'; + const result = threeWayMerge(base, theirs, ours); + // Theirs changed one line from base ("" → " "); ours is identical to + // base. Auto-merge should take theirs' version. + expect(result.content).toBe('a\n \nb\n'); + }); + + it('tab-vs-spaces indentation differences are treated as distinct lines', () => { + const base = 'function x() {\n return 1;\n}\n'; + const theirs = 'function x() {\n\treturn 1;\n}\n'; // tab + const ours = 'function x() {\n return 2;\n}\n'; // space, different body + const result = threeWayMerge(base, theirs, ours); + // theirs changed indentation, ours changed value — true conflict on + // that line. + expect(result.conflicts).toHaveLength(1); + }); +}); + +describe('merge adversarial — conflict content exposes marker injection hazard', () => { + it('conflict output remains parseable when user content contained our markers', () => { + // Realistic: a markdown file explaining git merges. User edits above + // the explanation, shard edits below. Neither side touches the + // literal marker strings, so they should pass through. + const base = '# Doc\n\n## Below\n\nShared.\n'; + const theirs = '# Doc — by user\n\n## Below\n\nShared.\n'; + const ours = '# Doc\n\n## Below\n\nShard update.\n'; + const result = threeWayMerge(base, theirs, ours); + expect(result.content).toContain('# Doc — by user'); + expect(result.content).toContain('Shard update.'); + }); + + it('when user and shard both edit a line, and the line happens to be a marker prefix', () => { + // The string "<<<<<<<" alone (no " yours" suffix) is not one of our + // markers but resembles one. Both sides edit the same line. + const base = 'intro\nsomething\n'; + const theirs = 'intro\n<<<<<<< user\n'; + const ours = 'intro\n<<<<<<< shard\n'; + const result = threeWayMerge(base, theirs, ours); + expect(result.conflicts).toHaveLength(1); + // Output has both sides visible, wrapped in OUR markers. The user's + // literal "<<<<<<< user" appears inside the yours block. + expect(result.content).toMatch(/<<<<<<< yours\n<<<<<<< user/); + }); +}); + +describe('merge adversarial — values injection through renderer', () => { + it('values containing nunjucks-like syntax are not re-interpreted', async () => { + const action = await computeMergeAction({ + path: 'x.md', + ownership: 'managed', + oldTemplate: '# {{ name }}\n', + newTemplate: '# {{ name }}\n', + oldValues: { name: '{{ injected }}' }, + newValues: { name: '{{ injected }}' }, + actualContent: '# {{ injected }}\n', + renderContext: CTX, + }); + expect(action.type).toBe('skip'); + }); + + it('YAML-hostile values in an unquoted frontmatter scalar are auto-recovered', async () => { + // Template authors often write unquoted scalars: `owner: {{ name }}`. + // If the value contains YAML special chars (colon, pipe, quote), the + // naive render produces invalid YAML. The renderer detects the parse + // failure and retries with every string value JSON-encoded, which is + // a valid YAML scalar form. This makes the engine robust against + // user input in obsidian-mind's values (user_name, vault_purpose, + // etc.) containing colons or quotes. + const tpl = '---\nowner: {{ name }}\n---\n\n# Body\n'; + const values = { name: 'Alice: AI researcher' }; + const action = await computeMergeAction({ + path: 'x.md', + ownership: 'managed', + oldTemplate: tpl, + newTemplate: tpl, + oldValues: values, + newValues: values, + actualContent: '---\nowner: "Alice: AI researcher"\n---\n\n# Body\n', + renderContext: CTX, + }); + // The rendered frontmatter parses cleanly thanks to auto-recovery; + // identical input on both sides → skip. + expect(action.type).toBe('skip'); + }); + + it('YAML-hostile values with embedded quotes and pipes also recover', async () => { + const tpl = '---\ndescription: {{ text }}\n---\n\nBody\n'; + const values = { text: 'quote: "inner" | pipe' }; + const action = await computeMergeAction({ + path: 'x.md', + ownership: 'managed', + oldTemplate: tpl, + newTemplate: tpl, + oldValues: values, + newValues: values, + actualContent: '', + renderContext: CTX, + }); + // Skip (base === ours) is proof that both rendered successfully. + expect(action.type).toBe('skip'); + }); + + it('non-string values (numbers, booleans) keep their YAML type after auto-recovery', async () => { + // A template with a number substitution and a YAML-hostile sibling + // must preserve the number as a YAML integer, not coerce to a string. + const tpl = '---\nname: {{ name }}\ncount: {{ count }}\n---\n\nBody\n'; + const values = { name: 'foo: bar', count: 42 }; + // Same inputs on both sides to isolate the render behavior. + const action = await computeMergeAction({ + path: 'x.md', + ownership: 'managed', + oldTemplate: tpl, + newTemplate: tpl, + oldValues: values, + newValues: values, + actualContent: '', + renderContext: CTX, + }); + expect(action.type).toBe('skip'); + }); +}); + +describe('merge adversarial — renderer context hardening', () => { + it('circular references in values do not hang or crash the render', async () => { + // Users might create circular data structures in values via hooks or + // computed defaults. The recovery walker must not enter an infinite + // loop when encoding string leaves. + const circular: Record = { name: 'Alice' }; + circular['self'] = circular; + + const tpl = '---\nowner: {{ name }}\n---\n\nBody\n'; + // Same hostile value on both sides to trigger the recovery path and + // ensure the walker terminates. + const values = { ...circular, name: 'foo: bar' }; + const action = await computeMergeAction({ + path: 'x.md', + ownership: 'managed', + oldTemplate: tpl, + newTemplate: tpl, + oldValues: values, + newValues: values, + actualContent: '', + renderContext: CTX, + }); + expect(action.type).toBe('skip'); + }); + + it('deeply nested values recurse without stack overflow for realistic depths', async () => { + // Build a 50-deep nested object. Not pathological, but confirms the + // recursive walk handles reasonable input. + let deep: Record = { name: 'leaf-value: with colon' }; + for (let i = 0; i < 50; i++) deep = { inner: deep }; + const values = { name: 'colon: value', nest: deep }; + const action = await computeMergeAction({ + path: 'x.md', + ownership: 'managed', + oldTemplate: '---\nowner: {{ name }}\n---\n\nBody\n', + newTemplate: '---\nowner: {{ name }}\n---\n\nBody\n', + oldValues: values, + newValues: values, + actualContent: '', + renderContext: CTX, + }); + expect(action.type).toBe('skip'); + }); +}); + +describe('merge adversarial — exotic value types', () => { + it('Date values in context do not crash the walker', async () => { + // Hooks could conceivably set a Date. YAML + Nunjucks normally coerce. + const tpl = '---\nname: {{ name }}\n---\n\nBody\n'; + const values = { name: 'foo: bar', stamp: new Date('2026-01-01') }; + const action = await computeMergeAction({ + path: 'x.md', + ownership: 'managed', + oldTemplate: tpl, + newTemplate: tpl, + oldValues: values, + newValues: values, + actualContent: '', + renderContext: CTX, + }); + expect(action.type).toBe('skip'); + }); + + it('function values in context are left alone (unusual, but must not crash)', async () => { + const tpl = '---\nname: {{ name }}\n---\n\nBody\n'; + const values = { name: 'foo: bar', fn: () => 'hello' }; + const action = await computeMergeAction({ + path: 'x.md', + ownership: 'managed', + oldTemplate: tpl, + newTemplate: tpl, + oldValues: values, + newValues: values, + actualContent: '', + renderContext: CTX, + }); + expect(action.type).toBe('skip'); + }); + + it('symbol values pass through without YAML crash', async () => { + const tpl = '---\nname: {{ name }}\n---\n\nBody\n'; + const values = { name: 'foo: bar', sym: Symbol('marker') }; + const action = await computeMergeAction({ + path: 'x.md', + ownership: 'managed', + oldTemplate: tpl, + newTemplate: tpl, + oldValues: values as Record, + newValues: values as Record, + actualContent: '', + renderContext: CTX, + }); + expect(action.type).toBe('skip'); + }); +}); + +describe('drift adversarial — performance and scale', () => { + let vaultRoot: string; + + beforeEach(async () => { + vaultRoot = path.join(os.tmpdir(), `drift-perf-${crypto.randomUUID()}`); + await fsp.mkdir(vaultRoot, { recursive: true }); + }); + + afterEach(async () => { + await fsp.rm(vaultRoot, { recursive: true, force: true }); + }); + + it('handles 500 tracked files in parallel without falling over', async () => { + const filesState: Record = {}; + await Promise.all( + Array.from({ length: 500 }, async (_, i) => { + const rel = `notes/note-${i.toString().padStart(4, '0')}.md`; + const content = `# Note ${i}\n`; + await fsp.mkdir(path.join(vaultRoot, 'notes'), { recursive: true }); + await fsp.writeFile(path.join(vaultRoot, rel), content, 'utf-8'); + filesState[rel] = { + template: 'notes/_each.md.njk', + rendered_hash: sha256(content), + ownership: 'managed', + }; + }), + ); + + const start = Date.now(); + const report = await detectDrift(vaultRoot, makeShardState({ files: filesState })); + const elapsed = Date.now() - start; + + expect(report.managed).toHaveLength(500); + expect(report.orphaned).toEqual([]); + // 500 files should classify in well under a second on any runner; + // > 5s would indicate a regression toward sequential IO. + expect(elapsed).toBeLessThan(5000); + }); +}); + +describe('merge adversarial — token interning stress', () => { + it('10K lines with many duplicates tokenize correctly and merge is identity', () => { + // Interner should deduplicate repeated lines so Map/Array stay + // bounded by unique-line count, not total line count. + const lines = Array.from({ length: 10_000 }, (_, i) => `line-${i % 100}`); + const content = lines.join('\n') + '\n'; + const result = threeWayMerge(content, content, content); + expect(result.content).toBe(content); + expect(result.stats.linesConflicted).toBe(0); + }); + + it('all lines identical is the densest interning case', () => { + const content = Array.from({ length: 1000 }, () => 'same').join('\n') + '\n'; + const result = threeWayMerge(content, content, content); + expect(result.content).toBe(content); + }); + + it('all lines unique is the sparsest interning case', () => { + const lines = Array.from({ length: 500 }, (_, i) => crypto.randomUUID()); + const content = lines.join('\n') + '\n'; + const result = threeWayMerge(content, content, content); + expect(result.content).toBe(content); + }); +}); + +describe('merge adversarial — stats bookkeeping under stress', () => { + it('stats never go negative', () => { + const cases = [ + ['', '', ''], + ['a', 'b', 'c'], + ['a\nb\nc', 'a\nX\nc', 'a\nb\nY'], + ]; + for (const [base, theirs, ours] of cases) { + const r = threeWayMerge(base!, theirs!, ours!); + expect(r.stats.linesUnchanged).toBeGreaterThanOrEqual(0); + expect(r.stats.linesAutoMerged).toBeGreaterThanOrEqual(0); + expect(r.stats.linesConflicted).toBeGreaterThanOrEqual(0); + } + }); + + it('linesConflicted > 0 iff there is at least one conflict region', () => { + const cases = [ + { base: 'a', theirs: 'a', ours: 'a' }, // no conflict + { base: 'a', theirs: 'b', ours: 'c' }, // true conflict + { base: 'a', theirs: 'a\nb', ours: 'a' }, // auto-merge only + ]; + for (const c of cases) { + const r = threeWayMerge(c.base, c.theirs, c.ours); + expect(r.conflicts.length > 0).toBe(r.stats.linesConflicted > 0); + } + }); +}); + +describe('computeMergeAction adversarial', () => { + it('handles empty templates on both sides', async () => { + const action = await computeMergeAction({ + path: 'x.md', + ownership: 'managed', + oldTemplate: '', + newTemplate: '', + oldValues: {}, + newValues: {}, + actualContent: '', + renderContext: CTX, + }); + expect(action.type).toBe('skip'); + }); + + it('produces a stable MergeAction discriminator across identical runs', async () => { + const input = { + path: 'x.md', + ownership: 'modified' as const, + oldTemplate: '# {{ name }}\n\nOriginal.\n', + newTemplate: '# {{ name }}\n\nUpdated by shard.\n', + oldValues: { name: 'Test' }, + newValues: { name: 'Test' }, + actualContent: '# Test\n\nOriginal.\n\nUser note.\n', + renderContext: CTX, + }; + const a = await computeMergeAction(input); + const b = await computeMergeAction(input); + expect(a.type).toBe(b.type); + if (a.type === 'auto_merge' && b.type === 'auto_merge') { + expect(a.content).toBe(b.content); + } + }); +}); diff --git a/tests/unit/renderer.test.ts b/tests/unit/renderer.test.ts index f3a1e7f..a626056 100644 --- a/tests/unit/renderer.test.ts +++ b/tests/unit/renderer.test.ts @@ -3,7 +3,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import { parse as parseYaml } from 'yaml'; import { describe, it, expect } from 'vitest'; -import { createRenderer, renderFile } from '../../source/core/renderer.js'; +import { createRenderer, renderFile, renderString } from '../../source/core/renderer.js'; import type { FileEntry, RenderContext } from '../../source/runtime/types.js'; const FIXTURES = path.resolve('tests/fixtures/render'); @@ -209,3 +209,48 @@ describe('renderFile', () => { }); }); }); + +describe('renderString', () => { + it('renders a plain body template without frontmatter', () => { + const ctx = makeContext({ values: { user_name: 'Alice' } }); + const source = '# Hello\n\nHi {{ user_name }}.\n'; + expect(renderString(source, ctx, 'hello.md')).toBe('# Hello\n\nHi Alice.\n'); + }); + + it('normalizes frontmatter YAML through parse→stringify', () => { + const ctx = makeContext({ values: {} }); + const source = '---\ntags: [brain, active]\nstatus: current\n---\n\n# Note\n\nBody.\n'; + const rendered = renderString(source, ctx, 'note.md'); + expect(rendered).toBe( + '---\ntags:\n - brain\n - active\nstatus: current\n---\n\n# Note\n\nBody.\n', + ); + }); + + it('substitutes context keys (shard, install_date) inside frontmatter', () => { + const ctx = makeContext({ + values: { user_name: 'Alice' }, + install_date: '2026-04-19', + }); + const source = '---\ndate: {{ install_date }}\nowner: {{ user_name }}\n---\n\nBody.\n'; + expect(renderString(source, ctx, 'note.md')).toBe( + '---\ndate: 2026-04-19\nowner: Alice\n---\n\nBody.\n', + ); + }); + + it('throws RENDER_TEMPLATE_ERROR on Nunjucks syntax error', () => { + const ctx = makeContext({ values: {} }); + try { + renderString('{% if %}broken{% endif %}', ctx, 'bad.md'); + expect.fail('renderString should have thrown'); + } catch (err: unknown) { + expect((err as { code: string }).code).toBe('RENDER_TEMPLATE_ERROR'); + } + }); + + it('accepts a caller-supplied env', () => { + const env = createRenderer(path.resolve('tests/fixtures/render/simple-note')); + env.addFilter('shout', (s: string) => s.toUpperCase()); + const ctx = makeContext({ values: { name: 'alice' } }); + expect(renderString('Hi {{ name | shout }}!', ctx, 'x.md', env)).toBe('Hi ALICE!'); + }); +}); diff --git a/tests/unit/three-way-merge-properties.test.ts b/tests/unit/three-way-merge-properties.test.ts new file mode 100644 index 0000000..41c2bd0 --- /dev/null +++ b/tests/unit/three-way-merge-properties.test.ts @@ -0,0 +1,109 @@ +/** + * Property-based invariants for `threeWayMerge`. + * + * Fixture tests prove the engine works on hand-authored cases; these tests + * prove it satisfies mathematical invariants across the space of possible + * inputs. When a fixture regresses, you learn one thing broke. When a + * property regresses, you learn the whole class of inputs that trigger it. + * + * Invariants pinned down here: + * 1. Identity: threeWayMerge(x, x, x) is x, with no conflicts. + * 2. "Theirs only" auto-merge: base === ours ⇒ result is theirs. + * 3. "Ours only" auto-merge: base === theirs ⇒ result is ours. + * 4. False-conflict resolution: theirs === ours ⇒ result is theirs, no conflicts. + * 5. No spurious conflicts when base/ours are LF and theirs is CRLF of the same content. + * 6. Stats conservation: linesUnchanged + linesAutoMerged + linesConflicted <= total emitted lines. + * (strict ≤ because conflicts emit marker lines that aren't counted in linesConflicted.) + */ + +import { describe, it } from 'vitest'; +import fc from 'fast-check'; +import { threeWayMerge } from '../../source/core/differ.js'; + +/** + * Arbitrary that generates short, multi-line text. Constrained to printable + * ASCII plus LF; no CR (CR is tested explicitly in invariant 5), no trailing + * whitespace games. Empty documents included. + */ +const multilineText = fc + .array( + fc.string({ + unit: fc.constantFrom(...' abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_#'), + minLength: 0, + maxLength: 30, + }), + { minLength: 0, maxLength: 12 }, + ) + .map(lines => lines.join('\n')); + +describe('threeWayMerge — property-based invariants', () => { + it('identity: merge(x, x, x) produces x with no conflicts', () => { + fc.assert( + fc.property(multilineText, x => { + const result = threeWayMerge(x, x, x); + return ( + result.content === x && + result.conflicts.length === 0 && + result.stats.linesConflicted === 0 + ); + }), + { numRuns: 200 }, + ); + }); + + it('theirs only: base === ours ⇒ merged content equals theirs', () => { + fc.assert( + fc.property(multilineText, multilineText, (theirs, baseAndOurs) => { + const result = threeWayMerge(baseAndOurs, theirs, baseAndOurs); + return result.content === theirs && result.stats.linesConflicted === 0; + }), + { numRuns: 200 }, + ); + }); + + it('ours only: base === theirs ⇒ merged content equals ours', () => { + fc.assert( + fc.property(multilineText, multilineText, (baseAndTheirs, ours) => { + const result = threeWayMerge(baseAndTheirs, baseAndTheirs, ours); + return result.content === ours && result.stats.linesConflicted === 0; + }), + { numRuns: 200 }, + ); + }); + + it('false conflict: theirs === ours ⇒ merged content equals theirs, no conflicts', () => { + fc.assert( + fc.property(multilineText, multilineText, (base, theirsAndOurs) => { + const result = threeWayMerge(base, theirsAndOurs, theirsAndOurs); + return result.content === theirsAndOurs && result.conflicts.length === 0; + }), + { numRuns: 200 }, + ); + }); + + it('CRLF theirs against LF base/ours produces no extra conflicts vs LF theirs', () => { + fc.assert( + fc.property(multilineText, multilineText, multilineText, (base, theirs, ours) => { + const lfResult = threeWayMerge(base, theirs, ours); + const crlfTheirs = theirs.replace(/\n/g, '\r\n'); + const crlfResult = threeWayMerge(base, crlfTheirs, ours); + return crlfResult.conflicts.length === lfResult.conflicts.length; + }), + { numRuns: 200 }, + ); + }); + + it('stats bookkeeping: stat totals never exceed content line count', () => { + fc.assert( + fc.property(multilineText, multilineText, multilineText, (base, theirs, ours) => { + const result = threeWayMerge(base, theirs, ours); + const emittedLines = result.content.split('\n').length; + const { linesUnchanged, linesAutoMerged, linesConflicted } = result.stats; + // Conflict markers (<<<<<<< / ======= / >>>>>>> ) inflate emitted + // lines beyond what stats track, so strict ≤ rather than equality. + return linesUnchanged + linesAutoMerged + linesConflicted <= emittedLines; + }), + { numRuns: 200 }, + ); + }); +}); diff --git a/tests/unit/three-way-merge.test.ts b/tests/unit/three-way-merge.test.ts new file mode 100644 index 0000000..9ecd611 --- /dev/null +++ b/tests/unit/three-way-merge.test.ts @@ -0,0 +1,109 @@ +/** + * Direct unit tests for the primitive `threeWayMerge`. The fixture suite in + * drift.test.ts exercises `computeMergeAction` end-to-end; these tests pin + * down the internal stats accounting and region classification that + * fixtures don't inspect directly. + */ + +import { describe, it, expect } from 'vitest'; +import { threeWayMerge } from '../../source/core/differ.js'; + +describe('threeWayMerge — stats accounting', () => { + it('counts every unchanged line when base === theirs === ours', () => { + const text = 'alpha\nbeta\ngamma\n'; + const result = threeWayMerge(text, text, text); + + expect(result.conflicts).toHaveLength(0); + expect(result.stats.linesUnchanged).toBeGreaterThanOrEqual(3); + expect(result.stats.linesAutoMerged).toBe(0); + expect(result.stats.linesConflicted).toBe(0); + expect(result.content).toBe(text); + }); + + it('records auto-merged lines when only one side diverges from base', () => { + const base = 'alpha\nbeta\ngamma\n'; + const theirs = 'alpha\nbeta\ngamma\n'; + const ours = 'alpha\nBETA\ngamma\n'; + + const result = threeWayMerge(base, theirs, ours); + + expect(result.conflicts).toHaveLength(0); + expect(result.stats.linesAutoMerged).toBeGreaterThan(0); + expect(result.stats.linesConflicted).toBe(0); + expect(result.content).toBe('alpha\nBETA\ngamma\n'); + }); + + it('honors a user addition that the shard did not touch', () => { + const base = '# Notes\n\nLine 1.\n'; + const ours = '# Notes\n\nLine 1.\n'; + const theirs = '# Notes\n\nLine 1.\n\n## Personal\nMy note.\n'; + + const result = threeWayMerge(base, theirs, ours); + + expect(result.conflicts).toHaveLength(0); + expect(result.content).toContain('## Personal'); + expect(result.stats.linesConflicted).toBe(0); + }); + + it('reports a conflict with correct line range and content snapshots', () => { + const base = '# Title\n\nOriginal body.\n'; + const theirs = '# Title\n\nUser body.\n'; + const ours = '# Title\n\nShard body.\n'; + + const result = threeWayMerge(base, theirs, ours); + + expect(result.conflicts).toHaveLength(1); + const [conflict] = result.conflicts; + expect(conflict).toBeDefined(); + expect(conflict!.theirs).toBe('User body.'); + expect(conflict!.ours).toBe('Shard body.'); + expect(conflict!.base).toBe('Original body.'); + expect(conflict!.lineStart).toBeGreaterThan(0); + expect(conflict!.lineEnd).toBeGreaterThan(conflict!.lineStart); + expect(result.stats.linesConflicted).toBeGreaterThan(0); + }); + + it('treats identical divergence (false conflict) as auto-merge', () => { + const base = 'alpha\nbeta\n'; + const theirs = 'alpha\nBETA\n'; + const ours = 'alpha\nBETA\n'; + + const result = threeWayMerge(base, theirs, ours); + + expect(result.conflicts).toHaveLength(0); + expect(result.content).toBe('alpha\nBETA\n'); + expect(result.stats.linesConflicted).toBe(0); + }); + + it('produces two conflict regions when disagreements are non-adjacent', () => { + const base = 'shared-a\nbase-x\nshared-b\nbase-y\nshared-c\n'; + const theirs = 'shared-a\ntheirs-x\nshared-b\ntheirs-y\nshared-c\n'; + const ours = 'shared-a\nours-x\nshared-b\nours-y\nshared-c\n'; + + const result = threeWayMerge(base, theirs, ours); + + expect(result.conflicts).toHaveLength(2); + expect(result.conflicts[0]!.lineEnd).toBeLessThan(result.conflicts[1]!.lineStart); + }); + + // Regression guard: node-diff3's LCS implementation uses `{}` as a map and + // blows up when any line is a string that collides with Object.prototype + // members ('constructor', '__proto__', 'toString', 'hasOwnProperty', etc.). + // differ.ts interns every line to an integer-named token before handing it + // to diff3 — integer-string keys can never collide with Object.prototype. + // Lines like `constructor` appear routinely in markdown code blocks about + // JavaScript, so the fix is load-bearing. + it('handles lines that match Object.prototype property names', () => { + const base = '# Class\n\nnote\n'; + const theirs = '# Class\n\nnote\nuser added\n'; + const ours = '# Class\n\nconstructor\n__proto__\ntoString\nhasOwnProperty\nvalueOf\nnote\n'; + + const result = threeWayMerge(base, theirs, ours); + + expect(result.content).toContain('constructor'); + expect(result.content).toContain('__proto__'); + expect(result.content).toContain('toString'); + expect(result.content).toContain('hasOwnProperty'); + expect(result.content).toContain('user added'); + }); +});