Added
Open configuration directory in system explorercommand — reveals the folder containing the activeinit.lua/.obsidian.vimrcin the OS file manager, with the configuration file itself selected. That folder is the onerequire()searches for alua/directory, so it is the folder to manage modules from. Obsidian already exposes this as the unofficial-but-typedApp.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)
- Plugin:
cursorlineoptaccepts Neovim's full grammar, includingscreenline— the option was a three-value enum (number/line/both). It now parses the real comma-separated list overline,screenline,numberandboth, in any order, withbothas Neovim's alias forline,numberand theline+screenlinecombination 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"storesboth).screenlinehighlights only the cursor's display row of a wrapped line, drawn as a measuredRectangleMarkerinside a CodeMirrorlayer()— aDecoration.linespans 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, andmigrateCursorlineoptSettingspins every existing vault to the previousnumber, so no installed configuration changes appearance. Stored values need no rewriting —number,lineandbothwere already valid Neovim spellings. The one undetectable case is a vault that has never writtendata.json, recorded inKNOWN_LIMITATIONS.md. - Plugin:
src/vim/cursorline-option.ts(new — grammar, normalization, canonical states),src/vim/cursorline.ts(screenlinelayer),src/settings.ts(type, default, both settings implementations),src/settings-migration.ts,src/main.ts,src/vimrc/loader.ts(normalizehook for string options),src/vim/options.ts,styles.css
- The default becomes Neovim's
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
deletebuflineare implemented. Interior-byte cursor writes deliberately normalize (D4); text/legacy-position/extmark repairs are described below, whilestrwidthand 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)
- Plugin:
- Remaining text, legacy-position and extmark coordinate seams — text get/set and legacy positions now use byte columns;
getcurposretains stickycurswantfrom 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 modeleddetails/virt_textcorrectly. 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)
- Plugin:
zzcenters wrapped lines on the cursor's display row, not the line's first row —scrollToCursormeasured the cursor's line withcharCoords(Pos(line, 0)), which on a wrapped line is the box of the first display row.zztherefore 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,smoothscrolloff),zzon the final character of a long line gives topline 12 at 3 display rows, topline 18 at 15, and topline 21 withskipcol1280 at 38 — Vim centers the whole buffer line, keeps the topline at a whole-line boundary, and scrolls within the line viaskipcolonly 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 tozt/z<CR>/zb/z-as well. Single-row lines are byte-identical to the previous formula, so unwrappedzzis unchanged. (#183)- Fork:
~/Repos/codemirror-vim/src/vim.js(scrollToCursor)
- Fork:
- Out-of-vault configurations open in the default editor —
Open configuration in default editorpassed the resolved path straight toApp.openWithDefaultApp(), which resolves its argument through the vault adapter:getFilePath()joins onto the vault base path. A configuration found outside the vault — viaglobalConfigSearchin Obsidian's userData directory, or an absoluteluaConfigPath/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'sshell.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, Electronshellaccess),src/main.ts(call site)
- Plugin:
just checktype-checked with whatever TypeScript was onPATH— the recipe called baretsc, so with TypeScript 7 installed globally it failed instantly ontsconfig.json(12,29): error TS5108: Option 'moduleResolution=node10' has been removed. The project pins^5.8.3, wheremoduleResolution: "node"is still valid, so the failure was entirely a function of the developer's global install; every other line in the recipe goes throughnpm runand picks upnode_modules/.bin.bumpandlinthad the same defect forprettier, 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 ranjust, 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)
- Build:
:changesopened nothing at all — the command was registered only insetupVimSubsystems(), butreloadFeatures()(reached from cold start viamain.ts) tears every registration down and then rebuilds only those registered through the centralregisterExCommands()path. Teardown did not remove an ex command;src/vim/registration.tsre-defined it asnoopEx, so:changesstayed recognized — the fork never reportedNot 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'sundefineEx(), which the fork has exported all along for exactly this, and:changesis registered in the rebuilt path alongside:jumps. An audit of thedefineAction/defineMotion/defineOperator/mapCommandregistrations found no other command reachable at cold start but absent from the rebuild, so:changeswas the only casualty.- Plugin:
src/vim/registration.ts(removeRegistration),src/workspace/commands.ts(registerExCommandstakes theChangeList),src/main.ts(both call sites; one-off registration removed)
- Plugin:
:edit!/:e!created a junk note named!instead of reverting the buffer — the fork'sparseInput_ends a command name at the first non-word character, so:edit!parses as commandeditwith argument!.matchCommand_resolvededitthroughcommandMap_['e']andcreateEditCommandcallednavigateWithJump(app, '!', ''). Observed in a live vault: the active file went fromWelcome.mdto!.mdand the buffer emptied. The separately registereddefineEx('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.:editnow detects the attached bang and delegates to the existing force handler; the unreachable registration is gone.- Plugin:
src/workspace/commands.ts(createEditCommand,registerExCommands)
- Plugin:
:foldwas only reachable by accident, andfois a contested abbreviation — the fork keys its ex dispatcher by abbreviation (commandMap_[shortName]), and two commands claimfo:fold(src/fold/commands.ts) andforward(src/workspace/commands.ts).forwardregisters later, socommandMap_['fo']names it, andmatchCommand_('fold')walks longest-prefix-to-shortest, findsfo, sees it namesforward, rejects it ('forward'.indexOf('fold') !== 0) and returns nothing. What had been keeping:foldalive was the old teardown:defineEx(name, '', noopEx)defaults its prefix to the full name, so removingfoldcreated acommandMap_['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,:foldwas already unreachable.VimRegistration.defineExnow creates that full-name anchor deliberately, so exact spellings resolve regardless of registration order, andundefineExstill removes every entry naming the command so teardown stays honest.fois the only contested abbreviation across alldefineExcall sites.- Surfaced by
foldopen-golden.e2e.ts, whosek/3jcases moved by raw buffer lines becausevim.cmd("4,6fold")was silently creating no fold at all. - Plugin:
src/vim/registration.ts(defineEx,removeRegistration)
- Surfaced by
:violations!never cleared anything — same parse, same cause: it resolved to plain:violationsand printed the list it was asked to discard. The bang is now handled inside theviolationshandler and the unreachabledefineEx('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
- Plugin:
vim.optwrote string options into settings without validating them — the__newindexhandler insrc/lua/api.tspassed the raw value straight toonSettingOverride, consulting neithervalidValuesnor the normalizer that the vimrcsetpath has always applied. Illegal values therefore landed in the settings object; legal ones only appeared correct because the subsequentsetOptioncall happened to write a normalized value over the top. Found by drivingcursorlineoptthrough the realvim.optpath —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.optstring-option write path)
- Plugin:
cursorlineopthad no observable effect, and the cursor line's number stayed highlighted withcursorlineoff — the gutter appliedvim-motions-line-num-currentto the cursor line whenever a line number was drawn, consulting neither option. Neovim gates this: "CursorLineNr Like LineNr when 'cursorline' is set and 'cursorlineopt' containsnumberor isboth, for the cursor line" (runtime/doc/syntax.txt), anddrawline.crequireswp->w_p_cul && (culopt_flags & kOptCuloptFlagNumber)—CursorLineNris never used while'cursorline'is off. BecausecreateCursorlineDecoration('number')correctly returns an empty extension, the default configuration (cursorline: true,cursorlineopt: 'number') meantcursorlinedid nothing observable at all: the highlight users saw came from the gutter unconditionally, and settingcursorlineopt=lineorcursorline=falsedid not remove it. Both renderers are fixed — the standalone line-number gutter and the unified statuscolumn build the class independently — and the relative-number0on the cursor line is still emitted, since only the highlight is gated, not the number. The three divergences this originally left open — noscreenline, anumberdefault where Neovim usesboth, 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)
- Plugin:
- Four hand-rolled copies of
getEditorView()—src/ui/hint-mode.ts,src/vim/table-cell-cursor-guard.ts,src/vim/table-debug-state.tsandsrc/vim/vim-api.tseach re-derivedview.editor.cm, three of them reproducing the sametry/catch. None was wrong, but they are the sites from which the.cm.cmdefect below is re-derived, so they now call the canonical accessor.src/editors/embeddable-editor.tsis untouched: itsthis.editoris the embeddable editor abstraction, not an ObsidianEditor. - Every gutter setting was a no-op until Obsidian restarted —
iterateEditorViews()readeditor.cm?.cmand required adispatchmethod on the result. ButEditor.cmis the CM6EditorView;editor.cm.cmis the CM5 compatibility adapter the vim engine attaches, and that adapter has nodispatch. The guard therefore rejected every leaf and the callback ran for no editor at all, soreconfigureLineNumberGutter,reconfigureSignColumnGutter,reconfigureFoldColumnGutter,reconfigureStatusColumnGutterandreconfigureCursorlineHighlightall dispatched their compartment reconfigure into nothing. Toggling number, relativenumber, signcolumn, foldcolumn, statuscolumn or cursorline — from settings, vimrc orvim.opt— stored the value and updated thevim-motions-line-numbers-activebody 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?.cminsrc/, against 49 uses of the canonicalgetEditorView()accessor thatAGENTS.mdalready mandates for exactly this property; the call site now uses it, with aninstanceof MarkdownViewnarrowing 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)
- Plugin:
Tests
- Source-derived documentation guard —
test/unit/lua/api-status-counts.test.tsreusesapi-inventory.tsto 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 intest/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 #184 —
test/specs/gutter-line-alignment.e2e.tsmeasures 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'sline-heightas 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 is0.00px. The requestedalign-items: centerwould 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 number72pxlow (row 4 of 7), and forcing the gutter element'sline-heightto24px— which needs!importantto beat Obsidian's inline style — drifts the heading to3.5px, both against a2pxtolerance. cursorlineoptis asserted through the realsetandvim.optpaths —test/specs/cursorlineopt-config.e2e.tsloads an actual.obsidian.vimrcand.obsidian.init.luarather than assigningplugin.settings, covering comma normalization, theculoptalias, reversed order and rejection on both paths. It found thevim.optvalidation bypass above on its first run. Worth recording precisely: the vimrcnormalizehook is load-bearing only for rejection — valid values are normalized downstream bysrc/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.0pxon Linux and2.5pxon Windows against a2pxtolerance. 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, ~16pxpadding-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 gutterline-heightbreaks the two heading cases (3.53). screenlineedge 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.tsadds a heading that is tall and wrapped, the one case where issue #184's two failure modes coincide, which fails at77.16pxunder 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 assertingnot.toBeNull(), which also acceptsDirection.LTRand so passed without ever entering RTL. The wrapped-block guard now measures against a real single-row line instead of a48pixel constant.cursorlineoptgrammar and migration are unit-tested,screenlinegeometrically —test/unit/cursorline-option.test.tscovers ordering, thebothalias, the rejectedline+screenlinepair, duplicates and whitespace;test/unit/settings-migration.test.tscovers the pin. Thescreenlinee2e 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 — aDecoration.linecannot satisfy it. Negative controls: deleting the mutual-exclusion and duplicate guards yieldsexpected 'line' to be nullandexpected 'number' to be null; making the migration a no-op yieldsexpected 'both' to be 'number', the exact surprise it exists to prevent; routingscreenlineto the line decoration makes the measured heightnullin bothscreenlinecases.CursorLineNrgating is covered in both gutters —test/specs/cursorline-number-highlight.e2e.tsruns the same four-case truth table (cursorlineopt=number/both/line, pluscursorline=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 DOM —
test/specs/gutter-runtime-reconfigure.e2e.tstogglesfoldcolumn,cursorline,signcolumnandstatuscolumnat 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 theiterateEditorViewsfix reverted, 4 of the 5 cases fail; the fifth passes correctly because it asserts an absence (cursorlineopt=numberinstalls 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-cmast-grep rule — flags$X.cm.cmand its optional-chaining forms acrosssrc/**(excluding vendored fengari). Shown to fire on the real defect atsrc/main.ts:5390and 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 Luanow asserts that it applied —getGutterState()had computedhasLineNumbersfrom 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 theiterateEditorViewsdefect 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 optionalget-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 intest/fixtures/neovim-coordinate-controls.mdwithout changing assertions. - mini.comment fixture pin — Phase 0 replaced the moving
mainref with27a29d6b949b9497f80a0a03421e89fed71d8c37intest/fixtures/test-plugins.jsonfor reproducible existing operation tests. - Configuration directory reveal — 12 further cases in
test/unit/util/open-path.test.tsplus 4 registration cases intest/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 revealingparentDirOf(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 thenulla missing command returns, andexecuteCommandByIdreturnsfalsefor an unknown id instead of throwing, so atoBeNull()error check could not see the absence either. Both now assert presence —not.toBeNull()andexecuted === true— and all four fail when the registration is removed. - The pre-existing
open-configurationexecution 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 reportsExpected: 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.
- The e2e control caught two of its own four cases passing with the command registration deleted:
- Out-of-vault config opening — 9 cases in
test/unit/util/open-path.test.tscovering vault-relative routing, absolute/tilde/Windows external routing, shell rejection, shell throw, and mobile. Verified red first by restoring the pre-fix unconditionalopenWithDefaultApp()call: 7 of 9 failed, with the absolute-path case reportingopenWithDefaultAppcalled 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
zzregression coverage — 3 cases intest/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 #143ztcase), 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'sbeforeSuiteguard that skipped the vim-mode toggle forSpike:-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 controls —
test/unit/vim-registration-ex.test.tsrunsVimRegistrationagainst 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 givesexpected '' to be 'UNKNOWN'; removing the full-name anchor givesexpected 'UNKNOWN' to be 'fold'. Either defect flips a test rather than passing quietly. solo-self-reported-successast-grep rule —.ast-grep/rules/gains a gate for the defect class above.vitest/expect-expectis syntactic and cannot see it: it confirms anexpect()exists and nothing more. The rule fires on anit()that assertstoHaveProperty('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:wcase 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!!overlayandchildren.length > 0. Syntax cannot tell a hardcoded flag from a measured one, so the rule stays pinned to thesuccessidiom. The 2 genuine finds among those 11 were fixed regardless.
- 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
- 57 e2e tests that could not fail have been given real assertions —
test/specs/**contained 190it()blocks assertingexpect(result).toHaveProperty('success', true), and in 72 of them that was the only assertion.successis set unconditionally by the test itself on the line after calling avoid-returning function, so the assertion proved only that no exception crossed theexecuteObsidianboundary. 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
Fixedentries 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.tswrote.obsidian.vimrcbeforereloadObsidian({ vault: 'test-vault' }); the reload replaced the launched vault and discarded the file. The observed default\leader was therefore correct:vimrcCommandCountwas 0,vimrcWatchPathwas null, and.obsidian.vimrcdid 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 fromLeaderRegistryand assert the real EasyMotion label count; replacing thewsuffix with unmappedxproduced 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'safterTestno longer force-removes modal DOM. It closes trackedModalinstances first, becauseel.remove()leaves the ObsidianScopeon the keymap stack to swallow the next test's first keystroke — the mechanism behind thegOfalse 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'safterTestforce-removes modal DOM without closing the instance, leaving the ObsidianScopeon the keymap stack to swallow the next test's first keystroke. Separately, onlyVimInfoModalrenders.vim-motions-info-modal-title— picker/SuggestModalsurfaces 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>Wand 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
executeObsidianblock that had been copy-pasted across 11 spec files:handleEx()reports what the fork actually did with an ex command — includingunknownCommand, set when the fork emitsNot an editor command, which is the control that makes a fabricated command name fail — plusdispatchedCommands,getWorkspaceSnapshot(),loadTwoFileWorkspace(),getVimMarkLetters()andgetInfoModalTitles(). - Save-type commands deliberately assert
dispatchedCommandsrather 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.mdin 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
- This sweep is what found the three
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.mdalso records the fork'sscrollToCursordisplay-row measurement and its Vimskipcolclamp.KNOWN_LIMITATIONS.md: D4/D5/D6 and extmark normalization deviations, remaining coordinate seams, quarantinedstrwidth, 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:changesand: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.:changeswas recorded asVimInfoModal.open()completing without producing DOM; in fact the closure never ran, because teardown had re-defined the command asnoopEx.:e!was recorded as "reverts to the last saved disk state, not thesetupEditorcontent"; 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 newtest/helpers.tsexports (handleEx,getWorkspaceSnapshot,loadTwoFileWorkspace,getVimMarkLetters,getInfoModalTitles), whyokalone is a vacuous assertion for avoid-returning ex handler, and the three traps that each produced a wrong diagnosis this cycle — theVimInfoModal-only title selector, modal instances that must be closed or theirScopeeats 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",:changesas "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: thecursorlineoptoption tables carry the five canonical values, the newbothdefault, that comma lists are accepted, and that existing vaults keepnumber.KNOWN_LIMITATIONS.md: the paragraph listingscreenline, thenumberdefault 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 writtendata.json.AGENTS.md,CONTRIBUTING.md: theeditor-view-double-cmast-grep rule, with the accessor to use for the CM6 view versus the CM5 adapter. Both rule lists also gainsolo-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.tsadded to thevim/tree, andcursorline.ts's entry corrected — it no longer describes "number/line/both modes" now thatscreenlineis drawn by a measured layer rather than a line decoration.README.md: the line-numbers feature bullet names the fullcursorlineoptgrammar 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 theiterateEditorViewsdefect, not a design constraint, so the#101record, thelinenumbermodenote and thestatuscolumnnote are all corrected.docs/configuration/settings.mdalready 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 onApp.openWithDefaultApp()/App.showInFolder(), thesrc/util/open-path.tsrouting that satisfies it, and the rule thatshowItemInFoldertakes the file rather thanparentDirOf(file).showInFolder()added to the list of typed unofficial APIs in use.CONTRIBUTING.md:src/util/open-path.tsadded to theutil/tree, andexternal-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 returnsvoid, 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 viaCapacitorAdapter.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 exploratorytest/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