github saberzero1/motions 0.150.0

4 hours ago

Added

  • Open configuration directory in system explorer command — reveals the folder containing the active init.lua / .obsidian.vimrc in the OS file manager, with the configuration file itself selected. That folder is the one require() searches for a lua/ directory, so it is the folder to manage modules from. Obsidian already exposes this as the unofficial-but-typed App.showInFolder(); no new Electron dependency is introduced for vault-relative configurations, and out-of-vault ones reuse the routing added below. Desktop only, and the command is hidden on mobile. When a Lua and a vimrc configuration share a folder, it is revealed once rather than twice. (#182)
    • Plugin: src/main.ts (open-configuration-directory), src/util/open-path.ts (revealPathInSystemExplorer, parentDirOf), src/util/external-fs.ts (revealExternalPath)
  • cursorlineopt accepts Neovim's full grammar, including screenline — the option was a three-value enum (number/line/both). It now parses the real comma-separated list over line, screenline, number and both, in any order, with both as Neovim's alias for line,number and the line+screenline combination rejected as Neovim rejects it; duplicates are rejected too, matching Neovim's own check. The grammar collapses to five reachable states, which is what the settings dropdown offers and what is stored, while vimrc and Lua accept any legal spelling and normalize into it (vim.opt.cursorlineopt = "line,number" stores both). screenline highlights only the cursor's display row of a wrapped line, drawn as a measured RectangleMarker inside a CodeMirror layer() — a Decoration.line spans the whole wrapped block and a mark decoration would stop at the last glyph instead of filling to the content edge, so neither can express it.
    • The default becomes Neovim's both, and migrateCursorlineoptSettings pins every existing vault to the previous number, so no installed configuration changes appearance. Stored values need no rewriting — number, line and both were already valid Neovim spellings. The one undetectable case is a vault that has never written data.json, recorded in KNOWN_LIMITATIONS.md.
    • Plugin: src/vim/cursorline-option.ts (new — grammar, normalization, canonical states), src/vim/cursorline.ts (screenline layer), src/settings.ts (type, default, both settings implementations), src/settings-migration.ts, src/main.ts, src/vimrc/loader.ts (normalize hook for string options), src/vim/options.ts, styles.css

Fixed

  • Enumerated Neovim coordinate boundaries — byte offsets, cursor/mark reads, character/display-column queries and five string-coordinate helpers use a single typed adapter. Synthetic current-window APIs and deletebufline are implemented. Interior-byte cursor writes deliberately normalize (D4); text/legacy-position/extmark repairs are described below, while strwidth and other unenumerated seams remain deferred, not parity claims.
    • Plugin: src/lua/coordinates.ts, src/lua/coordinate-wire.ts (adapter/marshalling), src/lua/api.ts, src/lua/fn.ts, src/lua/stdlib.ts (handlers), src/lua/loader.ts, src/lua/window-info.ts (resolved options and geometry), src/lua/obsidian-api.ts (explicit host-unit boundary)
  • Remaining text, legacy-position and extmark coordinate seams — text get/set and legacy positions now use byte columns; getcurpos retains sticky curswant from the fork with a positional fallback (D6). Text reads preserve exact UTF-8 slices; writes normalize interior start-down/end-up (D5). Extmarks follow that normalization, reject out-of-range columns, and serialize modeled details/virt_text correctly. These are scoped repairs with documented deviations, not whole-shim byte parity; already-real registration counts are unchanged.
    • Plugin: src/lua/coordinates.ts, src/lua/coordinate-wire.ts (conversion and raw-byte marshalling), src/lua/api.ts, src/lua/fn.ts (handler boundaries), src/lua/extmarks.ts (virtual-text chunk serialization), src/types/vim-api.d.ts (fork goal state)
  • zz centers wrapped lines on the cursor's display row, not the line's first rowscrollToCursor measured the cursor's line with charCoords(Pos(line, 0)), which on a wrapped line is the box of the first display row. zz therefore centered that row and pushed everything below it down; on a line wrapping past the window height the cursor left the viewport entirely. Measured against Neovim 0.12.5 (nvim -u NONE, 80x22, wrap, scrolloff=0, smoothscroll off), zz on the final character of a long line gives topline 12 at 3 display rows, topline 18 at 15, and topline 21 with skipcol 1280 at 38 — Vim centers the whole buffer line, keeps the topline at a whole-line boundary, and scrolls within the line via skipcol only as far as needed to keep the cursor on screen. The fork now reproduces all three: it averages the first and last display rows' boxes, clamps to the line's first row once the line is taller than the viewport, and applies the cursor-visibility clamp to zt/z<CR>/zb/z- as well. Single-row lines are byte-identical to the previous formula, so unwrapped zz is unchanged. (#183)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (scrollToCursor)
  • Out-of-vault configurations open in the default editorOpen configuration in default editor passed the resolved path straight to App.openWithDefaultApp(), which resolves its argument through the vault adapter: getFilePath() joins onto the vault base path. A configuration found outside the vault — via globalConfigSearch in Obsidian's userData directory, or an absolute luaConfigPath/vimrcPath — therefore became <vault>/home/…/init.lua, a path that does not exist, and the command silently did nothing. Absolute paths now route through Electron's shell.openPath() instead, and a failure raises a notice rather than appearing to succeed. Found while implementing #182.
    • Plugin: src/util/open-path.ts (new vault-relative/external routing), src/util/external-fs.ts (openExternalPath, Electron shell access), src/main.ts (call site)
  • just check type-checked with whatever TypeScript was on PATH — the recipe called bare tsc, so with TypeScript 7 installed globally it failed instantly on tsconfig.json(12,29): error TS5108: Option 'moduleResolution=node10' has been removed. The project pins ^5.8.3, where moduleResolution: "node" is still valid, so the failure was entirely a function of the developer's global install; every other line in the recipe goes through npm run and picks up node_modules/.bin. bump and lint had the same defect for prettier, latent only because the global copy happened to match the pinned 3.9.6 — a profile update would have silently reformatted the repo mid-release. CI never ran just, so this was a local-only false failure. This is not a TypeScript 7 compatibility fix: moduleResolution: "node" really is removed there, and choosing its replacement is a separate decision.
    • Build: justfile (check, bump, lint — no bare tool invocations remain)
  • :changes opened nothing at all — the command was registered only in setupVimSubsystems(), but reloadFeatures() (reached from cold start via main.ts) tears every registration down and then rebuilds only those registered through the central registerExCommands() path. Teardown did not remove an ex command; src/vim/registration.ts re-defined it as noopEx, so :changes stayed recognized — the fork never reported Not an editor command, and nothing was thrown — while doing nothing whatsoever. That is the worst possible failure mode for a diagnostic: indistinguishable from an empty change list. Ex teardown now calls the fork's undefineEx(), which the fork has exported all along for exactly this, and :changes is registered in the rebuilt path alongside :jumps. An audit of the defineAction/defineMotion/defineOperator/mapCommand registrations found no other command reachable at cold start but absent from the rebuild, so :changes was the only casualty.
    • Plugin: src/vim/registration.ts (removeRegistration), src/workspace/commands.ts (registerExCommands takes the ChangeList), src/main.ts (both call sites; one-off registration removed)
  • :edit! / :e! created a junk note named ! instead of reverting the buffer — the fork's parseInput_ ends a command name at the first non-word character, so :edit! parses as command edit with argument !. matchCommand_ resolved edit through commandMap_['e'] and createEditCommand called navigateWithJump(app, '!', ''). Observed in a live vault: the active file went from Welcome.md to !.md and the buffer emptied. The separately registered defineEx('edit!', …) handler was unreachable dead code and had been since it shipped — Vim's standard "discard my changes and re-read the file" instead littered the vault. :edit now detects the attached bang and delegates to the existing force handler; the unreachable registration is gone.
    • Plugin: src/workspace/commands.ts (createEditCommand, registerExCommands)
  • :fold was only reachable by accident, and fo is a contested abbreviation — the fork keys its ex dispatcher by abbreviation (commandMap_[shortName]), and two commands claim fo: fold (src/fold/commands.ts) and forward (src/workspace/commands.ts). forward registers later, so commandMap_['fo'] names it, and matchCommand_('fold') walks longest-prefix-to-shortest, finds fo, sees it names forward, rejects it ('forward'.indexOf('fold') !== 0) and returns nothing. What had been keeping :fold alive was the old teardown: defineEx(name, '', noopEx) defaults its prefix to the full name, so removing fold created a commandMap_['fold'] entry that no rebuild ever cleaned up, and later registrations refreshed the handler behind it. The command was reachable only because of an entry created by its own removal — meaning on any path where that teardown had not yet run, :fold was already unreachable. VimRegistration.defineEx now creates that full-name anchor deliberately, so exact spellings resolve regardless of registration order, and undefineEx still removes every entry naming the command so teardown stays honest. fo is the only contested abbreviation across all defineEx call sites.
    • Surfaced by foldopen-golden.e2e.ts, whose k/3j cases moved by raw buffer lines because vim.cmd("4,6fold") was silently creating no fold at all.
    • Plugin: src/vim/registration.ts (defineEx, removeRegistration)
  • :violations! never cleared anything — same parse, same cause: it resolved to plain :violations and printed the list it was asked to discard. The bang is now handled inside the violations handler and the unreachable defineEx('violations!', …) registration is removed. :w!, :q!, :wq! and :xall! were audited and are unaffected — they resolve to their base handlers with ! as an ignored argument and create no files.
    • Plugin: src/workspace/commands.ts
  • vim.opt wrote string options into settings without validating them — the __newindex handler in src/lua/api.ts passed the raw value straight to onSettingOverride, consulting neither validValues nor the normalizer that the vimrc set path has always applied. Illegal values therefore landed in the settings object; legal ones only appeared correct because the subsequent setOption call happened to write a normalized value over the top. Found by driving cursorlineopt through the real vim.opt path — vim.opt.cursorlineopt = "both,screenline", which Neovim rejects, was stored verbatim — but the bypass was never specific to that option: it applied to every string option reachable from Lua. The Lua path now validates exactly as the vimrc path does.
    • Plugin: src/lua/api.ts (vim.opt string-option write path)
  • cursorlineopt had no observable effect, and the cursor line's number stayed highlighted with cursorline off — the gutter applied vim-motions-line-num-current to the cursor line whenever a line number was drawn, consulting neither option. Neovim gates this: "CursorLineNr Like LineNr when 'cursorline' is set and 'cursorlineopt' contains number or is both, for the cursor line" (runtime/doc/syntax.txt), and drawline.c requires wp->w_p_cul && (culopt_flags & kOptCuloptFlagNumber)CursorLineNr is never used while 'cursorline' is off. Because createCursorlineDecoration('number') correctly returns an empty extension, the default configuration (cursorline: true, cursorlineopt: 'number') meant cursorline did nothing observable at all: the highlight users saw came from the gutter unconditionally, and setting cursorlineopt=line or cursorline=false did not remove it. Both renderers are fixed — the standalone line-number gutter and the unified statuscolumn build the class independently — and the relative-number 0 on the cursor line is still emitted, since only the highlight is gated, not the number. The three divergences this originally left open — no screenline, a number default where Neovim uses both, and rejected comma lists — are closed by the grammar entry above.
    • Plugin: src/vim/cursorline.ts (setCursorlineNumberHighlight, isCursorlineNumberHighlight), src/vim/line-number-gutter.ts, src/vim/statuscolumn.ts (both number segments), src/main.ts (sets the flag at setup and rebuilds the active gutter on change)
  • Four hand-rolled copies of getEditorView()src/ui/hint-mode.ts, src/vim/table-cell-cursor-guard.ts, src/vim/table-debug-state.ts and src/vim/vim-api.ts each re-derived view.editor.cm, three of them reproducing the same try/catch. None was wrong, but they are the sites from which the .cm.cm defect below is re-derived, so they now call the canonical accessor. src/editors/embeddable-editor.ts is untouched: its this.editor is the embeddable editor abstraction, not an Obsidian Editor.
  • Every gutter setting was a no-op until Obsidian restartediterateEditorViews() read editor.cm?.cm and required a dispatch method on the result. But Editor.cm is the CM6 EditorView; editor.cm.cm is the CM5 compatibility adapter the vim engine attaches, and that adapter has no dispatch. The guard therefore rejected every leaf and the callback ran for no editor at all, so reconfigureLineNumberGutter, reconfigureSignColumnGutter, reconfigureFoldColumnGutter, reconfigureStatusColumnGutter and reconfigureCursorlineHighlight all dispatched their compartment reconfigure into nothing. Toggling number, relativenumber, signcolumn, foldcolumn, statuscolumn or cursorline — from settings, vimrc or vim.opt — stored the value and updated the vim-motions-line-numbers-active body class while the gutter itself never appeared or disappeared. The body class updating on its own is what made this look like a rendering bug rather than a dead code path. This was the sole .cm?.cm in src/, against 49 uses of the canonical getEditorView() accessor that AGENTS.md already mandates for exactly this property; the call site now uses it, with an instanceof MarkdownView narrowing in place of the unchecked cast. Found while investigating #184, whose reproduction could not be set up until gutters could be toggled at runtime.
    • Plugin: src/main.ts (iterateEditorViews)

Tests

  • Source-derived documentation guardtest/unit/lua/api-status-counts.test.ts reuses api-inventory.ts to enforce source/handler/status membership, dispatch and authoritative totals, public per-name subtotals, full/no-runner inventories, duplicate-stub accounting and historical provenance. Negative controls record actual mismatches in test/fixtures/neovim-coordinate-controls.md.
  • Coordinate and audit evidence — manifest-generated conformance, independent native oracle, directional type checks, structural boundary rule and Phase 5/5b demand audit remain the regression gates. mini.surround and mini.splitjoin are both BLOCKED; Phases 6/7 were cancelled, not executed as successful integration suites.
  • Gutter vertical alignment is pinned against #184test/specs/gutter-line-alignment.e2e.ts measures the gutter number's glyph centre against the line's first text glyph, as a delta relative to a normal single-row line so theme font metrics cancel out. Obsidian sets each line's line-height as an inline style on its .cm-gutterElement (31.07px on a heading against 24px on a body line), so number and heading glyph already share one line box: the measured heading delta is 0.00px. The requested align-items: center would centre against the taller block instead, which is not where a heading's text sits, and would move a wrapped line's number off its first display row. Negative controls: the issue's own CSS puts a 7-row wrapped line's number 72px low (row 4 of 7), and forcing the gutter element's line-height to 24px — which needs !important to beat Obsidian's inline style — drifts the heading to 3.5px, both against a 2px tolerance.
  • cursorlineopt is asserted through the real set and vim.opt pathstest/specs/cursorlineopt-config.e2e.ts loads an actual .obsidian.vimrc and .obsidian.init.lua rather than assigning plugin.settings, covering comma normalization, the culopt alias, reversed order and rejection on both paths. It found the vim.opt validation bypass above on its first run. Worth recording precisely: the vimrc normalize hook is load-bearing only for rejection — valid values are normalized downstream by src/vim/options.ts, so the two "normalizes" cases pass with the hook removed and only the rejection cases discriminate it.
  • Gutter alignment is measured against the display row, not the text glyph (Windows) — the assertion compared the number's glyph centre with the heading's glyph centre, i.e. two font content areas at different sizes. A content area is centred on its line box only when that font governs the line's baseline; on a heading the baseline comes from the strut, so a different platform font stack shifts the heading's centre while the gutter number does not move. Identical, correct rendering measured 0.0px on Linux and 2.5px on Windows against a 2px tolerance. The comparison is now the number's centre against the centre of the line's first display row, which is font-metric-free on the row side and is the invariant the plugin actually controls — where the glyph sits inside its own row is Obsidian's typography. Deriving the row also corrected a wrong assumption: a heading's leading space sits above its text (measured 47.06px block, 31.07px row, ~16px padding-top), so the first row starts at the end of that space rather than at the top of the border box. Both negative controls still fire and are now complementary — the CSS issue #184 proposed breaks the two wrapped cases (77.12, 72), while breaking Obsidian's per-line gutter line-height breaks the two heading cases (3.53).
  • screenline edge cases and the combined heading case — the screenline suite adds an empty line, a closed fold (a replace decoration adjacent to the cursor) and a genuine right-to-left editor; gutter-line-alignment.e2e.ts adds a heading that is tall and wrapped, the one case where issue #184's two failure modes coincide, which fails at 77.16px under the CSS that issue proposed. Two of these were caught being vacuous before they shipped: a Live Preview table case where the table never rendered as a widget at all (removed — the fold case already covers a replacement widget), and an RTL case asserting not.toBeNull(), which also accepts Direction.LTR and so passed without ever entering RTL. The wrapped-block guard now measures against a real single-row line instead of a 48 pixel constant.
  • cursorlineopt grammar and migration are unit-tested, screenline geometricallytest/unit/cursorline-option.test.ts covers ordering, the both alias, the rejected line+screenline pair, duplicates and whitespace; test/unit/settings-migration.test.ts covers the pin. The screenline e2e asserts the highlight is SHORTER than the wrapped block it sits in, which is the only assertion that distinguishes a display row from a logical line — a Decoration.line cannot satisfy it. Negative controls: deleting the mutual-exclusion and duplicate guards yields expected 'line' to be null and expected 'number' to be null; making the migration a no-op yields expected 'both' to be 'number', the exact surprise it exists to prevent; routing screenline to the line decoration makes the measured height null in both screenline cases.
  • CursorLineNr gating is covered in both gutterstest/specs/cursorline-number-highlight.e2e.ts runs the same four-case truth table (cursorlineopt = number/both/line, plus cursorline=false) against the standalone line-number gutter and the unified statuscolumn, asserting the number highlight and the line decoration independently. Red first: before the fix the two "does not highlight" cases failed in both gutters, 4 of 8, while the two positive cases passed.
  • All five gutter reconfigure paths are covered at the DOMtest/specs/gutter-runtime-reconfigure.e2e.ts toggles foldcolumn, cursorline, signcolumn and statuscolumn at runtime and asserts the editor DOM in both directions, including that the statuscolumn hides the individual line-number and sign gutters and restores them when cleared. One-directional assertions would not have caught a dead teardown branch. Negative control: with the iterateEditorViews fix reverted, 4 of the 5 cases fail; the fifth passes correctly because it asserts an absence (cursorlineopt=number installs no line decoration, the extension being deliberately empty for that option). Only the line-number path had DOM coverage before, which is why the defect survived 108 releases.
  • editor-view-double-cm ast-grep rule — flags $X.cm.cm and its optional-chaining forms across src/** (excluding vendored fengari). Shown to fire on the real defect at src/main.ts:5390 and to stay silent on the fixed tree, per the repo rule that a new pattern rule must be demonstrated against a defect that actually shipped.
  • should store and apply number=true via Lua now asserts that it appliedgetGutterState() had computed hasLineNumbers from the editor DOM since it was written, but no test ever read it; the test checked only the stored setting and the body class, both of which are set before any editor is touched. That is what let the iterateEditorViews defect above ship and survive: with the fix reverted the gutter is absent and the five other cases in the file stay green.
  • Remaining-seams re-audit — 23 manifest APIs; core blockers removed exactly set-text-bytes, getpos-bytes, extmark-columns, plus optional get-text-bytes. Remaining non-coordinate blockers keep both behavior suites gated. The unchanged source-count guard still rejects a deliberately wrong documented figure; observed mismatch/restoration is recorded in test/fixtures/neovim-coordinate-controls.md without changing assertions.
  • mini.comment fixture pin — Phase 0 replaced the moving main ref with 27a29d6b949b9497f80a0a03421e89fed71d8c37 in test/fixtures/test-plugins.json for reproducible existing operation tests.
  • Configuration directory reveal — 12 further cases in test/unit/util/open-path.test.ts plus 4 registration cases in test/specs/config-management.e2e.ts. Two independent unit sabotages were used, because one alone could not distinguish the two ways to get this wrong: dropping the absolute-path branch failed 6 of 21 (the out-of-vault reveals only), while revealing parentDirOf(path) instead of the file failed a different 5, reporting ".obsidian" where ".obsidian/.init.lua" was expected. The vault-relative cases pass under the first sabotage and fail under the second, so neither set is vacuous.
    • The e2e control caught two of its own four cases passing with the command registration deleted: not.toBe(fileName) was satisfied by the null a missing command returns, and executeCommandById returns false for an unknown id instead of throwing, so a toBeNull() error check could not see the absence either. Both now assert presence — not.toBeNull() and executed === true — and all four fail when the registration is removed.
    • The pre-existing open-configuration execution case had the same weak shape and has been given the same treatment, since it could not distinguish a working command from a deleted one. Controlled by removing its own registration: it now reports Expected: true, Received: false, where before it passed. This is the older of the two commands, so the gap had been open since the command shipped.
  • Out-of-vault config opening — 9 cases in test/unit/util/open-path.test.ts covering vault-relative routing, absolute/tilde/Windows external routing, shell rejection, shell throw, and mobile. Verified red first by restoring the pre-fix unconditional openWithDefaultApp() call: 7 of 9 failed, with the absolute-path case reporting openWithDefaultApp called once with /home/testuser/.config/obsidian/init.lua. The 2 that passed are the vault-relative cases, which were already correct — so the suite distinguishes the two routes rather than passing wholesale.
  • Wrapped-line zz regression coverage — 3 cases in test/specs/vim-builtin/z-commands.e2e.ts (#183), measuring the cursor's display row and the full extent of its line block rather than the logical line number. Verified red first: on a line three viewports tall the cursor sat 4437 px down a 1301 px viewport, and the gap above/below the block differed by 389.9 px against a 36 px tolerance. Each case was then sabotaged individually — halving the centering term fails the centering and unwrapped-line cases (and the pre-existing #143 zt case), disabling the visibility clamp fails only the tall-line case — so none of the three passes vacuously.
  • Exploratory suite retirement — durable coverage from test/specs/spikes/ moved into feature- and issue-named specs. Vacuous instrumentation and cases already covered by stronger normal-suite assertions were deleted; the exploratory directory is gone. 65 files and ~400 tests became 33 relocated tests; the suite went from 232 spec files to 177. wdio.conf.mts's beforeSuite guard that skipped the vim-mode toggle for Spike:-titled suites is removed as dead code — which also means the relocated tests now run with that toggle, as every normal spec does.
  • Ex-command lifetime controlstest/unit/vim-registration-ex.test.ts runs VimRegistration against a fake that mirrors the fork's abbreviation keying and prefix walk, and pins both halves of the invariant: a torn-down command must become genuinely unknown, and two commands sharing an abbreviation must both stay reachable by exact name across teardown/rebuild. Reverting teardown to the no-op gives expected '' to be 'UNKNOWN'; removing the full-name anchor gives expected 'UNKNOWN' to be 'fold'. Either defect flips a test rather than passing quietly.
  • solo-self-reported-success ast-grep rule.ast-grep/rules/ gains a gate for the defect class above. vitest/expect-expect is syntactic and cannot see it: it confirms an expect() exists and nothing more. The rule fires on an it() that asserts toHaveProperty('success', true) and asserts nothing else, which is exactly the shape that cannot fail. Pairing the crash guard with a real assertion does not match.
    • Adopted only after being measured against ground truth. On the pre-sweep tree it fires 76 times and catches all 72 known-vacuous blocks with zero false positives against the 118 legitimately-paired ones; on the current tree it fires 0. The 4 it found beyond the 72 were real — blocks with two self-reported flags, which a "exactly one expect()" text survey had skipped. .ast-grep/rules/ requires a new rule be shown to fire on a defect that actually shipped before it is trusted; workspace.e2e.ts's :w case is that defect.
    • A generalized variant matching any toHaveProperty($PROP, true) was built and rejected: it flagged 11 further blocks of which 9 were legitimate — properties computed from real DOM queries such as !!overlay and children.length > 0. Syntax cannot tell a hardcoded flag from a measured one, so the rule stays pinned to the success idiom. The 2 genuine finds among those 11 were fixed regardless.
  • 57 e2e tests that could not fail have been given real assertionstest/specs/** contained 190 it() blocks asserting expect(result).toHaveProperty('success', true), and in 72 of them that was the only assertion. success is set unconditionally by the test itself on the line after calling a void-returning function, so the assertion proved only that no exception crossed the executeObsidian boundary. Confirmed rather than theorised: replacing the ex-command strings with names that do not exist — 'w''wNOSUCHCOMMAND', 'ob''obNOSUCHCOMMAND' — left both tests passing. All surviving tests now assert state the feature is responsible for producing; the former spike-only remainder was either absorbed into feature specs or deleted when it proved vacuous or redundant. The 118 blocks that already paired the crash guard with a real assertion were left alone. Every rewrite was proven red by substituting a nonexistent command or key and observing the specific failure, then restored.
    • This sweep is what found the three Fixed entries above. :changes, :edit! and :violations! had passing tests the entire time they were broken, and :edit!'s test was named "should revert file without error" while the command was creating !.md.
    • The apparent fourth finding was a test bug, not a product bug. vimrc.e2e.ts wrote .obsidian.vimrc before reloadObsidian({ vault: 'test-vault' }); the reload replaced the launched vault and discarded the file. The observed default \ leader was therefore correct: vimrcCommandCount was 0, vimrcWatchPath was null, and .obsidian.vimrc did not exist in the running vault. The helper now reloads first, writes the config, and invokes the real configuration reload path. Both space and comma cases read the configured value from LeaderRegistry and assert the real EasyMotion label count; replacing the w suffix with unmapped x produced 0 labels in each case, expected >0. The documented Fixed status is accurate, and no plugin or fork change was required. (#6)
    • wdio.conf.mts's afterTest no longer force-removes modal DOM. It closes tracked Modal instances first, because el.remove() leaves the Obsidian Scope on the keymap stack to swallow the next test's first keystroke — the mechanism behind the gO false alarm.
    • Two apparent product defects turned out to be test defects, and both are worth carrying forward. gO "never opened the outline modal" only when it ran after :contextactions: WDIO's afterTest force-removes modal DOM without closing the instance, leaving the Obsidian Scope on the keymap stack to swallow the next test's first keystroke. Separately, only VimInfoModal renders .vim-motions-info-modal-title — picker/SuggestModal surfaces render no title span, so asserting on it there manufactures a false failure.
    • Three tests were deleted rather than rewritten — plain-text <C-]>, single-pane <C-w>W and single-pane <C-w>p. Each asserts a no-op, and fabricated keys produce byte-identical state, so no assertion can distinguish the feature working from the feature being absent. Per .agents/skills/negative-control/SKILL.md, a test that cannot fail is worse than none.
    • Shared helpers replace the 40-line executeObsidian block that had been copy-pasted across 11 spec files: handleEx() reports what the fork actually did with an ex command — including unknownCommand, set when the fork emits Not an editor command, which is the control that makes a fabricated command name fail — plus dispatchedCommands, getWorkspaceSnapshot(), loadTwoFileWorkspace(), getVimMarkLetters() and getInfoModalTitles().
    • Save-type commands deliberately assert dispatchedCommands rather than on-disk content: Obsidian's idle autosave reaches the same end state within ~2 s and would mask a completely broken :w. This was verified — Welcome.md in the test vault had already been rewritten by autosave during an earlier run.
    • Tests: test/helpers.ts, test/specs/workspace.e2e.ts, test/specs/workspace-extended.e2e.ts, test/specs/ex-commands-extended.e2e.ts, test/specs/easymotion-interaction.e2e.ts, test/specs/oil-poc.e2e.ts, test/specs/oil-ex-guard.e2e.ts, test/specs/picker.e2e.ts, test/specs/vim-builtin/ex-commands-expanded.e2e.ts, test/specs/vim-builtin/link-nav-window-cycle.e2e.ts, test/specs/vim-builtin/new-commands.e2e.ts, test/specs/vim-builtin/ex-move-copy-normal.e2e.ts

Documentation

  • NEOVIM_API_STATUS.md: unchanged source-guarded counts and their semantic blind spot, closed seams and repaired serialization, exact before/after blocker lists, seven silent placeholders and remaining coordinate limitations.
  • AGENTS.md, CONTRIBUTING.md: coordinate ownership, module trees, inventory/oracle/test contracts and negative-control evidence. AGENTS.md also records the fork's scrollToCursor display-row measurement and its Vim skipcol clamp.
  • KNOWN_LIMITATIONS.md: D4/D5/D6 and extmark normalization deviations, remaining coordinate seams, quarantined strwidth, blocked plugin suites and the resolved mini.comment moving-fixture risk. mini.splitjoin's Vimscript-dependent string expression mapping is an architectural constraint, not a missing-function to-do.
  • README.md, docs/configuration/lua-config.md: current qualified API counts, coordinate forms/units/errors and current-window/string-helper references; no blanket plugin compatibility claim.
  • test/fixtures/neovim-coordinate-controls.md: observed documentation-guard controls and restoration evidence.
  • KNOWN_LIMITATIONS.md: the :changes and :e! entries under "Remaining weaknesses" are struck through as fixed, and both records are corrected — each had been diagnosed as a test problem and was actually a product bug. :changes was recorded as VimInfoModal.open() completing without producing DOM; in fact the closure never ran, because teardown had re-defined the command as noopEx. :e! was recorded as "reverts to the last saved disk state, not the setupEditor content"; in fact it reverted nothing and created a note named !.md. The crash-guard assertions are what made both look like test-harness faults, which is the clearest argument in the repo for why this defect class matters.
  • CONTRIBUTING.md, AGENTS.md: the new test/helpers.ts exports (handleEx, getWorkspaceSnapshot, loadTwoFileWorkspace, getVimMarkLetters, getInfoModalTitles), why ok alone is a vacuous assertion for a void-returning ex handler, and the three traps that each produced a wrong diagnosis this cycle — the VimInfoModal-only title selector, modal instances that must be closed or their Scope eats the next test's first keystroke, and autosave masking on-disk save assertions.
  • docs/reference/keybindings.md, docs/features/ex-commands.md: checked and deliberately unchanged. Both already described :e! as "Revert current file to saved version", :changes as "Show change list in modal" and :violations! as "Clear all recorded violations". The documentation was correct the whole time; the implementation was not.
  • docs/configuration/settings.md, docs/configuration/vimrc.md, docs/configuration/lua-config.md: the cursorlineopt option tables carry the five canonical values, the new both default, that comma lists are accepted, and that existing vaults keep number.
  • KNOWN_LIMITATIONS.md: the paragraph listing screenline, the number default and rejected comma lists as permanent divergences is replaced by the implemented grammar, and states the one case the migration cannot detect — a vault that has never written data.json.
  • AGENTS.md, CONTRIBUTING.md: the editor-view-double-cm ast-grep rule, with the accessor to use for the CM6 view versus the CM5 adapter. Both rule lists also gain solo-self-reported-success, which has been a live gate since it shipped but was documented in neither file — a gate nobody can find is one somebody deletes. AGENTS.md's Tier 2 spec categories now name gutter reconfiguration and cursor-line highlighting.
  • CONTRIBUTING.md: src/vim/cursorline-option.ts added to the vim/ tree, and cursorline.ts's entry corrected — it no longer describes "number/line/both modes" now that screenline is drawn by a measured layer rather than a line decoration.
  • README.md: the line-numbers feature bullet names the full cursorlineopt grammar instead of the bare option.
  • KNOWN_LIMITATIONS.md, docs/configuration/vimrc.md: the "gutter settings require one restart" rule is removed — it was a symptom of the iterateEditorViews defect, not a design constraint, so the #101 record, the linenumbermode note and the statuscolumn note are all corrected. docs/configuration/settings.md already promised that changes "take effect immediately without restarting"; the two pages had contradicted each other, and the one that was wrong is the one that has now been fixed in code.
  • AGENTS.md: the vault-relative constraint on App.openWithDefaultApp()/App.showInFolder(), the src/util/open-path.ts routing that satisfies it, and the rule that showItemInFolder takes the file rather than parentDirOf(file). showInFolder() added to the list of typed unofficial APIs in use.
  • CONTRIBUTING.md: src/util/open-path.ts added to the util/ tree, and external-fs.ts's entry extended with its two new OS-reaching helpers.
  • docs/reference/keybindings.md: the new command in the command table.
  • docs/configuration/lua-config.md, docs/configuration/vimrc.md: the new command alongside the existing external-editor one, that it selects the configuration file inside the revealed folder, that a shared folder is revealed once, and that both commands handle out-of-vault configurations.
  • KNOWN_LIMITATIONS.md: why both configuration commands are desktop only, separating the hard platform limit from the plugin's own choice. App.showInFolder() has no mobile branch at all and returns void, so a mobile call is an undetectable no-op and there is no Capacitor reveal API to fall back to; App.openWithDefaultApp() does have a mobile branch via CapacitorAdapter.open(), so that command's gate is a deliberate decision about out-of-vault configurations rather than a platform constraint.
  • CHANGELOG.md: this phase's work is recorded only under Unreleased; released blocks are unchanged.
  • AGENTS.md, CONTRIBUTING.md, KNOWN_LIMITATIONS.md: retired the exploratory test/specs/spikes/ directory, documented that durable behavior belongs in the normal tiers, and updated active regression-test paths.

Full Changelog: 0.149.0...0.150.0

Don't miss a new motions release

NewReleases is sending notifications on new releases.