@oomkapwn/enquire-mcp 3.6.0 → 3.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
1
+ # L2 — Architecture (v3.6.0 audit)
2
+
3
+ **Scope**: module dependency graph; `package.json#exports` resolution; `TOOL_MANIFEST` ↔ `registerTool()` ↔ `kind`/gating reality; `registerPrompt()` ↔ README + STABILITY; CLI flag ↔ handler ↔ `docs/api.md` ↔ `ServeOptions` mapping.
4
+ **Auditor**: sub-agent C2.
5
+ **Date**: 2026-05-15.
6
+ **Baseline**: 30 `src/**/*.ts` modules; `npm run build` clean; `dist/` present; 713 tests pass; 44 `TOOL_MANIFEST` entries; 44 `registerTool()` calls; 19 `registerPrompt()` calls; 7 `package.json#exports` sub-paths (+ `.` and `./package.json`).
7
+
8
+ ## Summary
9
+
10
+ The architecture is in good shape. Module dependency graph is shallow and intentional (a `VERSION` hub via `index.ts` + a tightly-coupled `tools/*` peer cluster); both detected cycle classes are runtime-safe (cycled imports are only referenced inside function bodies). All 44 `TOOL_MANIFEST` entries match `registerTool()` calls exactly, and every tool's `kind` matches its registration context (read/write/fts/diagnostic). All 19 prompts are documented in both README and STABILITY. `package.json#exports` resolves cleanly to existing `dist/` files. `ServeOptions` ↔ CLI flag mapping is bidirectional and complete for stdio `serve`.
11
+
12
+ Findings cluster into 4 classes (1 medium, 2 low, 1 info):
13
+
14
+ 1. **L2-01 (Medium)** — `serve-http` is missing 8 retrieval-quality / PDF / HNSW / late-chunking flags that `serve` accepts and that `docs/api.md` claims work for both transports. Passing `--use-hnsw` (or any of the others) to `serve-http` is rejected by commander.
15
+ 2. **L2-02 (Low)** — drift class: `obsidian_full_text_search` is registered ONLY when both `--persistent-index` AND `--diagnostic-search-tools` are set (per `server.ts:402`), but four user-facing strings claim only `--persistent-index` is required (tool description in `tool-registry.ts:63`, `docs/api.md:3`, `docs/api.md:820–822`, `STABILITY.md:19`). The two places that get it right are `tool-manifest.ts:47` and `docs/api.md:55`.
16
+ 3. **L2-03 (Low)** — 7 circular dependencies in the module graph. None break runtime (cycled symbols are only referenced inside function bodies) and the cycles are intentional (`VERSION` re-export hub + `tools/*` peer cluster). No regression guard exists, so a future top-level use of a cycled symbol would break loading and not be caught by CI.
17
+ 4. **L2-04 (Info)** — `--reranker-model <alias>` and `--reranker-top-n <n>` are valid `serve` flags but appear nowhere in `docs/api.md` (the canonical API ref). Only `STABILITY.md:63` mentions `--reranker-model`. Real flags, real defaults, real behavior — just undocumented in the api ref.
18
+
19
+ No Critical / High findings. All 7 `package.json#exports` resolve to existing `dist/*.{js,d.ts}` files. The slim re-export surface at `src/index.ts` keeps the v3.5.x public API stable through the rc.2 split. All `tools/*` symbols imported by `tool-registry.ts` exist in `dist/tools/index.js`. The 19-prompt count is exact across `src/prompts.ts`, `README.md:154`, and `STABILITY.md:33`. The 44-tool kind/gating split is exact across `src/tool-manifest.ts` and the actual registration sites.
20
+
21
+ ---
22
+
23
+ ### Finding L2-01 (Medium)
24
+
25
+ **File**: `src/cli.ts:122–177` (the `serve-http` command definition), `docs/api.md:102,110` (the contradicting claim), `src/http-transport.ts:41–102` (`HttpServeOptions extends ServeOptions`).
26
+ **Class**: CLI surface drift between `serve` and `serve-http` (commander `.option()` chains for the same `HttpServeOptions extends ServeOptions` type are out of sync — flags were added to `serve` after v2.8.0 but never back-ported to `serve-http`).
27
+ **Description**: `serve-http` (the remote-MCP transport) is missing eight `.option()` declarations that `serve` (stdio) has and that `docs/api.md` says both transports share. Because commander uses `.option()` chains, not type-driven CLI generation, the typed `HttpServeOptions extends ServeOptions` interface is honored at compile time but irrelevant at parse time — commander parses positionally from `.option()` declarations alone. Result: a `serve-http --use-hnsw` invocation fails fast with `error: unknown option '--use-hnsw'` despite `HttpServeOptions` having a `useHnsw?: boolean` field via `ServeOptions`.
28
+
29
+ **Evidence** (commander runtime rejection):
30
+
31
+ ```bash
32
+ $ enquire-mcp serve-http --vault /tmp/foo --bearer-token <…32+ chars…> --use-hnsw
33
+ error: unknown option '--use-hnsw'
34
+ ```
35
+
36
+ Missing flags (each one is in `serve` at `src/cli.ts:42–117` but absent from `serve-http` at `src/cli.ts:122–177`):
37
+
38
+ | Flag | In `serve` | In `serve-http` | Effect in `ServeOptions` |
39
+ |---|---|---|---|
40
+ | `--include-pdfs` | `cli.ts:77` | (missing) | `includePdfs?: boolean` — PDF blend into hybrid search |
41
+ | `--enable-reranker` | `cli.ts:81` | (missing) | `enableReranker?: boolean` — BGE cross-encoder reranking |
42
+ | `--reranker-model <alias>` | `cli.ts:85` | (missing) | `rerankerModel?: string` |
43
+ | `--reranker-top-n <n>` | `cli.ts:89` | (missing) | `rerankerTopN?: string` |
44
+ | `--use-hnsw` | `cli.ts:93` | (missing) | `useHnsw?: boolean` |
45
+ | `--hnsw-ef <n>` | `cli.ts:97` | (missing) | `hnswEf?: string` |
46
+ | `--late-chunk-context <chars>` | `cli.ts:101` | (missing) | `lateChunkContext?: string` |
47
+ | `--no-hnsw-persist` | `cli.ts:105` | (missing) | `hnswPersist?: boolean` (negation) |
48
+
49
+ Conflicting documentation:
50
+
51
+ - `docs/api.md:102` — _"v2.13.0 — `serve` / `serve-http` flags: `--use-hnsw` builds an in-memory HNSW vector index on serve start … `--hnsw-ef <n>` tunes search-time accuracy."_ — claims both transports support `--use-hnsw` / `--hnsw-ef`.
52
+ - `docs/api.md:106` — _"v2.15.0 — `--late-chunk-context <chars>` on `serve` and `build-embeddings`."_ — at least concedes serve-http isn't included (so late-chunking is OK), but the v2.13.0 line above is wrong.
53
+ - `docs/api.md:108` — _"v2.16.0 — `--no-hnsw-persist`"_ — implies it applies to "when `--use-hnsw` is passed", which is documented as both transports.
54
+ - `docs/api.md:110` — _"v2.17.0 — `--quantize-embeddings <mode>` on `serve`, `serve-http`, `build-embeddings`, and `setup`."_ — `--quantize-embeddings` IS present on serve-http at `cli.ts:174–177`, so this one is correct. But it's the only late-feature flag that was actually back-ported.
55
+ - `src/http-transport.ts:38` — comment header: _"Extends ServeOptions so every stdio-mode flag (`--enable-write`, `--persistent-index`, `--watch`, etc.) is available over HTTP too."_ — overstates: HTTP transport's TYPE has every field, but the CLI surface drops 8 of them.
56
+
57
+ **Other instances** (grep cross-cutting):
58
+
59
+ - `README.md:62` — quickstart command is `enquire-mcp serve --vault <path> --persistent-index --enable-reranker --use-hnsw` — uses `serve`, doesn't claim it works for `serve-http`. Clean.
60
+ - `STABILITY.md:55–62` — lists CLI surface; doesn't claim serve-http parity beyond the shared serve flags. Clean.
61
+ - `docs/http-transport.md` — not inspected here (deferred to L6). If it claims `--use-hnsw` etc. work via serve-http, this same finding will surface there.
62
+
63
+ **Suggested class fix**: One of:
64
+ 1. **Back-port the 8 missing `.option()` calls to `serve-http`** in `src/cli.ts` (line ~177, right before the `.action()`). Mechanical edit — copy-paste the 8 lines from the serve block. Then add an invariant test in `tests/cli.test.ts` that does `program._findCommand('serve').options` ∩ `program._findCommand('serve-http').options` and asserts every shared option (everything except `--port` / `--host` / `--bearer-token` / `--bearer-token-env` / `--mcp-path` / `--rate-limit` / `--cors-origin` / `--health-path` / `--stateful` / `--session-idle-timeout-ms` / `--max-sessions`) is present in both. This prevents future drift.
65
+ 2. **Or factor the shared options into a helper** that takes a `Command` and chains the 23 `serve`/`serve-http` shared flags. Eliminates copy-paste drift entirely. `commander` v14 supports `Command.copyInheritedSettings` but not a clean shared-options pattern; a free function returning the chained command is the idiom. About a 30-line refactor of `cli.ts`.
66
+
67
+ **Suggested per-instance backfill**: After the class fix, no per-instance backfill needed — both transports converge. If the choice is option (1) without the invariant test, monitor over the next 2 releases for regression.
68
+
69
+ ---
70
+
71
+ ### Finding L2-02 (Low)
72
+
73
+ **File**: `src/tool-registry.ts:63`, `docs/api.md:3,820,822`, `STABILITY.md:19`. The truth-source is `src/server.ts:402` + `src/tool-manifest.ts:47`.
74
+ **Class**: Drift between user-visible documentation and runtime gating logic for `obsidian_full_text_search`. The actual gate is `if (deps.ftsIndex && opts.diagnosticSearchTools) registerFtsTools(...)` — needs BOTH `--persistent-index` (which builds the FTS5 index → makes `deps.ftsIndex` non-null) AND `--diagnostic-search-tools`. Four downstream strings drop the second flag.
75
+ **Description**: The single-ranker FTS5 search tool was demoted to diagnostic in v2.0.0-beta.3 (along with `obsidian_search_text`, `obsidian_semantic_search`, `obsidian_embeddings_search`). The umbrella `obsidian_search` became default. The manifest knows this (kind: `fts`, gating: `--persistent-index + --diagnostic-search-tools`) and `docs/api.md:55` knows this (table row reads `--persistent-index (+ --diagnostic-search-tools)`). But 4 other locations describe the gating as `--persistent-index` alone — including the description string the tool returns over MCP, which is the most user-visible. Users following `docs/api.md:822` who start `enquire-mcp serve --vault X --persistent-index` will be confused when `obsidian_full_text_search` does not appear in `tools/list`.
76
+
77
+ **Evidence** (truth-source — `src/server.ts:402`):
78
+
79
+ ```ts
80
+ if (deps.ftsIndex && opts.diagnosticSearchTools) registerFtsTools(server, deps.ftsIndex, deps.vault);
81
+ ```
82
+
83
+ Conflicting strings (each says or implies just `--persistent-index`):
84
+
85
+ ```ts
86
+ // src/tool-registry.ts:63 — returned to every MCP client in tools/list:
87
+ "… Use `obsidian_search_text` instead if the index isn't built yet — this tool is only registered when the server is started with `--persistent-index`."
88
+ ```
89
+
90
+ ```md
91
+ <!-- docs/api.md:3 — the intro paragraph: -->
92
+ … the 4 opt-ins are: 1 via `--persistent-index` (`obsidian_full_text_search`) + 3 via `--diagnostic-search-tools` …
93
+ ```
94
+
95
+ ```md
96
+ <!-- docs/api.md:820: -->
97
+ ## `obsidian_full_text_search` _(opt-in, requires `--persistent-index`)_
98
+
99
+ <!-- docs/api.md:822: -->
100
+ BM25-ranked full-text search over a SQLite FTS5 inverted index … Only registered when the server is started with `--persistent-index`; otherwise use `obsidian_search_text`.
101
+ ```
102
+
103
+ ```md
104
+ <!-- STABILITY.md:19: -->
105
+ **Read — opt-in via `--persistent-index` (1):** `obsidian_full_text_search`.
106
+ ```
107
+
108
+ Two sources get it right (`docs/api.md:55`, `tool-manifest.ts:47`):
109
+
110
+ ```md
111
+ | `obsidian_full_text_search` | read | `--persistent-index` (+ `--diagnostic-search-tools`) | BM25-ranked search … |
112
+ ```
113
+
114
+ ```ts
115
+ // tool-manifest.ts:45-48:
116
+ {
117
+ name: "obsidian_full_text_search",
118
+ kind: "fts",
119
+ gating: "--persistent-index + --diagnostic-search-tools",
120
+ summary: "BM25 full-text search backed by the SQLite FTS5 inverted index."
121
+ }
122
+ ```
123
+
124
+ **Other instances** (grep cross-cutting): the four conflicting strings above. No other doc / test / code path describes the gating.
125
+
126
+ **Suggested class fix**: Two options, not mutually exclusive:
127
+ 1. **Single source of truth, derived strings**: Have `src/tool-registry.ts` import the manifest entry for `obsidian_full_text_search` and use `TOOL_MANIFEST.find(t => t.name === '...').gating` to construct the description string. Then any future gating change updates the manifest once + the MCP description automatically. Same pattern works for `docs/api.md` — add a render step in `scripts/docs:api` (or in TypeDoc post-processing) that fills the gating column from the manifest.
128
+ 2. **Tighten the docs-consistency test**: `tests/docs-consistency.test.ts` already checks tool surface coverage. Extend it to assert (for every manifest entry whose `gating` includes `--diagnostic-search-tools`) that the docs sections describing that tool mention BOTH flags. About 20 lines of test code. Catches manual drift even without a render-time fix.
129
+
130
+ **Suggested per-instance backfill**: 4 string edits:
131
+ - `src/tool-registry.ts:63` — change "only registered when the server is started with `--persistent-index`" to "only registered when the server is started with `--persistent-index --diagnostic-search-tools`".
132
+ - `docs/api.md:3` — clarify the intro: "1 via `--persistent-index + --diagnostic-search-tools` …".
133
+ - `docs/api.md:820–822` — change `_(opt-in, requires `--persistent-index`)_` to `_(opt-in, requires `--persistent-index --diagnostic-search-tools`)_`; same fix in line 822 body text.
134
+ - `STABILITY.md:19` — change "opt-in via `--persistent-index` (1)" to "opt-in via `--persistent-index --diagnostic-search-tools` (1)" or restructure (move it to the diagnostic-search-tools section at line 23).
135
+
136
+ Estimated 15 minutes for the per-instance fix, 1 hour for the class fix (test extension).
137
+
138
+ ---
139
+
140
+ ### Finding L2-03 (Low)
141
+
142
+ **File**: 8 modules participate in the 7 detected cycles: `src/index.ts`, `src/cli.ts`, `src/server.ts`, `src/tool-registry.ts`, `src/http-transport.ts`, `src/tools/meta.ts`, `src/tools/read.ts`, `src/tools/search.ts`, `src/tools/write.ts`.
143
+ **Class**: Module dependency cycles. ESM tolerates them when cycled symbols are only referenced inside function bodies (the bindings get hoisted and bind by the time the cycled function is called), but they're a smell: they make module-load order brittle, complicate refactoring (extracting a symbol can break loading), and any future move from inside-function to top-level usage of a cycled symbol introduces a `ReferenceError: Cannot access '<symbol>' before initialization` at load time that no current test would catch.
144
+ **Description**: `npx madge --circular --extensions ts src/` reports 7 cycles. They fall into 2 classes:
145
+
146
+ **Class A — `VERSION` re-export hub** (4 cycles, all transitive through `src/index.ts`):
147
+ ```
148
+ 1. cli.ts → http-transport.ts → index.ts (→ cli.ts via index re-export of main)
149
+ 2. index.ts → server.ts (→ index.ts via server's `import { VERSION } from "./index.js"`)
150
+ 3. index.ts → server.ts → tool-registry.ts (→ index.ts via tool-registry's `import { VERSION }`)
151
+ 4. server.ts → tool-registry.ts (→ server.ts via tool-registry's `import type { ServerDeps }`)
152
+ ```
153
+
154
+ Class A root cause: `VERSION = "3.6.0"` lives in `src/index.ts:37` (single source of truth so `scripts/check-version-consistency.mjs` can grep one file), and three modules import it: `cli.ts:6`, `server.ts:7`, `tool-registry.ts:5`. Combined with the v3.6.0-rc.2 split (`src/index.ts:47–57` re-exports `main`, `buildEmbedText`, `buildMcpServer`, `formatReadyBanner`, `prepareServerDeps`, `ServeOptions`, `ServerDeps`, `startServer`, `parsePositiveInt`, `parseQuantizationMode` from `cli.ts`, `server.ts`, `tool-registry.ts` to keep the public surface stable), every cross-import creates a cycle through index.
155
+
156
+ **Class B — `tools/*` peer cluster** (3 cycles, all inside `src/tools/`):
157
+ ```
158
+ 5. tools/meta.ts → tools/read.ts (→ meta.ts via read's `import { findBestMatch, ... } from "./meta.js"`)
159
+ 6. tools/meta.ts → tools/read.ts → tools/search.ts (→ meta.ts via search's `import { findBestMatch, ... } from "./meta.js"`)
160
+ 7. tools/meta.ts → tools/read.ts → tools/search.ts → tools/write.ts (→ meta.ts via write's `import { findBestMatch, ... } from "./meta.js"`)
161
+ ```
162
+
163
+ Class B root cause: `tools/meta.ts` exports cross-tool helpers (`findBestMatch`, `stripMd`, `jaccard`, `intersectionSize`, `ngrams`, `normalizeTag`, `indexFor`) used by `read.ts`, `search.ts`, `write.ts`; and `meta.ts` calls into `read.ts` (`getRecentEdits`), `search.ts` (`searchHybrid`), `write.ts` (`resolveTarget`, `suggestSimilar`) for its higher-level tools (`contextPack`, `paperAudit`). So they import each other, and `tools/meta.ts` is both a "leaf" (helpers) and an "aggregator" (multi-tool composer).
164
+
165
+ **Evidence — confirmed runtime-safe** (no cycled symbol is referenced at module-load time):
166
+
167
+ ```bash
168
+ $ grep -nE "^(const|let|var|export const|export let|export var)\s+\w+\s*=\s*(findBestMatch|stripMd|jaccard|intersectionSize|ngrams|searchHybrid|resolveTarget|sliceSnippet|extractFrontmatterTagsLower|normalizeTag|listTags|getRecentEdits|getBacklinks|suggestSimilar|VERSION)" /Users/alex/Documents/Projects/obsidian-mcp/src/**/*.ts
169
+ # (empty)
170
+
171
+ $ node -e "import('./dist/index.js').then(m => console.log('VERSION:', m.VERSION))"
172
+ VERSION: 3.6.0
173
+ ```
174
+
175
+ All 7 cycles load cleanly. All 713 tests pass. So this is a smell, not a bug.
176
+
177
+ **Other instances** (grep cross-cutting): not applicable — this finding IS the cross-cutting view.
178
+
179
+ **Suggested class fix**: Two options:
180
+ 1. **Add a circular-dep invariant test**: `tests/no-circular-deps.test.ts` — invoke madge programmatically (`import madge from 'madge'; const result = await madge('src/', { fileExtensions: ['ts'] }); expect(result.circular()).toEqual([])` OR allow exactly the current 7 cycles and snapshot them). This catches NEW cycles introduced in PRs but tolerates the existing 2 classes. About 30 lines.
181
+ 2. **Eliminate Class A** by extracting `VERSION` to its own micro-module (`src/version.ts`) that nobody else imports from. `src/index.ts`, `cli.ts`, `server.ts`, `tool-registry.ts` would all import from `./version.js` directly. Removes 4 of the 7 cycles. Trade-off: `scripts/check-version-consistency.mjs` regex must be updated to grep `src/version.ts` instead of `src/index.ts`. About 10 lines of refactor + 1 script change.
182
+ 3. **Eliminate Class B** by extracting the cross-tool helpers (`findBestMatch`, `stripMd`, `jaccard`, `intersectionSize`, `ngrams`, `normalizeTag`, `indexFor`) from `tools/meta.ts` into a new `tools/_shared.ts`. Then `tools/meta.ts` becomes a pure aggregator (importing from peers but not exporting to them). Eliminates the 3 tools cycles. About a 50-line refactor.
183
+
184
+ Option (1) is the lowest-cost: keep the cycles, prevent new ones. Options (2) and (3) are nice-to-haves but not blocking.
185
+
186
+ **Suggested per-instance backfill**: not applicable — depends on which class fix is taken.
187
+
188
+ ---
189
+
190
+ ### Finding L2-04 (Info)
191
+
192
+ **File**: `docs/api.md` (canonical API reference) is missing two real CLI flags: `--reranker-model <alias>` and `--reranker-top-n <n>`.
193
+ **Class**: Doc completeness drift. The flags exist (`src/cli.ts:85–92` for `serve`, `src/cli.ts:580–581` for `eval`) and are honored at runtime (`src/server.ts` consumes `rerankerModel` + `rerankerTopN` from `ServeOptions`; `eval` consumes from its own opts). But they're not in the canonical CLI reference. A user reading `docs/api.md` end-to-end will not learn that the reranker model is configurable (`rerank-multilingual` is just one of 5 aliases — `rerank-bge`, `rerank-bge-large`, `rerank-jina-tiny`, `rerank-multilingual-large`) or how many candidates get reranked.
194
+ **Description**: `docs/api.md` describes `--enable-reranker` narratively in the line-3 intro and in section header "v2.9.0+ adds BGE cross-encoder reranking", but stops there. The serve-flag table at `docs/api.md:75–89` doesn't include any reranker flag (even `--enable-reranker` itself is missing from the table). The eval subcommand row at line 100 mentions `[--reranker]` but not `[--reranker-model]` / `[--reranker-top-n]`.
195
+
196
+ **Evidence**: `grep -nE "reranker-model|reranker-top-n" docs/api.md README.md` returns 0 matches. Only `STABILITY.md:63` mentions `--reranker-model` (it's the alias-stability promise). `docs/api-reference/` (TypeDoc-generated) has `rerankerModel` + `rerankerTopN` because they're `ServeOptions` fields — but that's not the place a CLI user looks.
197
+
198
+ **Other instances** (grep cross-cutting): same class as L2-01 (drift between `cli.ts` and `docs/api.md`), but L2-01 is about flag REJECTION (real bug); this is about flag UNDOCUMENTED (real gap). Other potentially undocumented flags worth a sweep when fixing this one: `--hnsw-ef` (described in narrative para at line 102 ✅), `--late-chunk-context` (line 106 ✅), `--no-hnsw-persist` (line 108 ✅), `--cors-origin` (mentioned in serve-http row at line 96 ✅), `--health-path` (line 96 ✅), `--mcp-path` (line 96 ✅). So this is JUST the 2 reranker tuning flags.
199
+
200
+ **Suggested class fix**: Add a docs-consistency invariant test that asserts every flag declared in `src/cli.ts` (regex `\.option\("(--[a-z][a-z-]+)`) appears at least once in `docs/api.md`. About 15 lines, similar shape to the existing tool-surface-coverage assertion in `tests/docs-consistency.test.ts`. Would catch future flag drift automatically.
201
+
202
+ **Suggested per-instance backfill**: Add a row (or a paragraph similar to the v2.13.0 narrative at line 102) covering `--reranker-model <alias>` and `--reranker-top-n <n>` in `docs/api.md`. Mention the 5 alias options + their size/quality trade-offs (already documented in `src/cli.ts:86–88` description string). About 5 lines.
203
+
204
+ ---
205
+
206
+ ## Files explicitly clean (no findings)
207
+
208
+ The following architecture surfaces were inspected and have no issues:
209
+
210
+ - **`package.json#exports`** — all 7 sub-paths (`.`, `./embed-db`, `./fts5`, `./vault`, `./hnsw`, `./bases`, `./communities`, `./package.json`) resolve to existing `dist/*.{js,d.ts}` files. Each subpath import loads cleanly (`embed-db` exports `EmbedDb` / `defaultEmbedDbFile` / `encodeInt8Vector` / `decodeInt8Vector`; `fts5` exports `FtsIndex` / `chunkContent` / `defaultIndexFile` / `safeFts5Query`; etc.). The slim `src/index.ts` re-export hub (`main`, `buildMcpServer`, `buildEmbedText`, `formatReadyBanner`, `prepareServerDeps`, `startServer`, `parsePositiveInt`, `parseQuantizationMode`, `ServeOptions`, `ServerDeps`, `VERSION`) preserves the v3.5.x public surface through the rc.2 module split.
211
+
212
+ - **`TOOL_MANIFEST` ↔ registration**: 44 manifest entries, 44 `server.registerTool()` calls, exact name-set equality (`Only in TOOL_MANIFEST (not in registry): [] / Only in registry (not in TOOL_MANIFEST): []`). Per-tool `kind` matches registration context: 1 fts (`registerFtsTools`), 33 read (`registerReadTools` outside the `if (diagnosticSearchTools)` block), 3 diagnostic (`registerReadTools` inside the `if (diagnosticSearchTools)` block), 7 write (`registerWriteTools`). Per-tool `gating` field exactly describes the runtime gate: `always` (33), `--diagnostic-search-tools` (3), `--persistent-index + --diagnostic-search-tools` (1), `--enable-write` (7) — see L2-02 for the one drift in the description strings.
213
+
214
+ - **`registerPrompt()` ↔ README + STABILITY**: 19 `server.registerPrompt()` calls in `src/prompts.ts`; 19 names in `README.md:154` (single `MCP prompts (...)` paragraph); 19 names in `STABILITY.md:33`. Set-equal across all three. (Detected names: `summarize_recent_edits`, `review_tag`, `find_orphans`, `weekly_review`, `extract_todos`, `process_inbox`, `consolidate_tags`, `find_duplicates`, `lint_wiki`, `monthly_review`, `search_with_query_expansion`, `vault_synth`, `vault_wiki_compile`, `vault_lint_extended`, `vault_capture`, `vault_persona_search`, `vault_automation_setup`, `vault_research`, `vault_synthesis_page`.)
215
+
216
+ - **`ServeOptions` ↔ CLI flag mapping** (bidirectional): 23 `ServeOptions` keys (`vault`, `enableWrite`, `maxFileBytes`, `cacheSize`, `persistentCache`, `cacheFile`, `persistentIndex`, `indexFile`, `tokenize`, `excludeGlob`, `readPaths`, `watch`, `disabledTools`, `enabledTools`, `diagnosticSearchTools`, `includePdfs`, `enableReranker`, `rerankerModel`, `rerankerTopN`, `useHnsw`, `hnswEf`, `lateChunkContext`, `hnswPersist`, `quantizeEmbeddings`) each map cleanly to a `serve`-command CLI flag (the negation `--no-hnsw-persist` → `hnswPersist` is the one camelCase exception, handled correctly by commander). Every `ServeOptions` key is referenced from at least 2 of `src/server.ts` / `src/cli.ts` / `src/http-transport.ts` / `src/tool-registry.ts` — none are dead.
217
+
218
+ - **CLI option handlers**: every `.option(...)` on every command (`serve`, `serve-http`, `gen-token`, `clear-cache`, `clear-index`, `index`, `install-model`, `build-embeddings`, `clear-embeddings`, `doctor`, `setup`, `eval`) is consumed by the corresponding `.action(opts => …)` handler. Spot-checked all 12 commands. No orphan flags.
219
+
220
+ - **Module graph shape**: shallow; max chain length ≤ 4 (`cli.ts → server.ts → tool-registry.ts → tools/index.ts → tools/*.ts`). `vault.ts` has the highest fan-in (14 importers) — it's the right hub (parsed-note cache + privacy filter + path resolution). `fts5.ts` (8 fan-in), `embed-db.ts` (4), `embeddings.ts` (4), `parser.ts` (4) are the right next-tier shared utilities. `tool-manifest.ts` has 0 fan-in inside `src/` by design — it's a spec file consumed only by `tests/docs-consistency.test.ts`. No suspicious orphans.
221
+
222
+ - **`tools/*` exports vs consumption**: 57 named exports from `src/tools/index.ts` (re-exports from `media.ts` + `meta.ts` + `read.ts` + `search.ts` + `write.ts`). 38 are imported by `src/tool-registry.ts` (the public tool handlers). The remaining 19 (`buildTfidfIndex`, `composeNote`, `extractFrontmatterTagsLower`, `findBestMatch`, `indexFor`, `intersectionSize`, `jaccard`, `ngrams`, `normalizeTag`, `pickEmbedTextForHyde`, `replaceStringOutsideCodeFences`, `resolvePeriodicAlias`, `resolveTarget`, `rewriteOutsideCodeFences`, `rewriteRawTarget`, `sliceSnippet`, `stripMd`, `suggestSimilar`, `tokenizeForTfidf`) are internal cross-module helpers used by sibling `tools/*` files — re-exported by `tools/index.ts` because `export * from` doesn't discriminate `@internal`. This intersects with L1-03 (`@internal` discipline) — adding `@internal` tags wouldn't hide them from JS consumers but would hide them from TypeDoc.
223
+
224
+ - **`tools/index.ts` cycle safety**: `export * from` chains through all 5 leaf files; despite cycles in the underlying graph, `import('./tools/index.js')` loads cleanly with 57 keys present.
225
+
226
+ - **`src/index.ts` CLI-entry guard**: realpath-comparison still functional; `if (isCliEntry) main().catch(...)` still gated. Module loads cleanly when imported as a library (no main() invoked); CLI-mode triggers correctly.
227
+
228
+ - **No dead code branches**: `grep -E "TODO|FIXME"` returns ~30 hits across `src/`, all of them descriptive context (e.g. "TODO: defer to v3.7" / "FIXME if multi-process needed") — none indicate broken / abandoned scaffolding. No `// @ts-expect-error` without explanation.
229
+
230
+ ## Verification commands
231
+
232
+ ```bash
233
+ cd /Users/alex/Documents/Projects/obsidian-mcp
234
+ npx madge --circular --extensions ts src/ # 7 cycles, all runtime-safe
235
+ npx madge --extensions ts --json src/ > /tmp/deps.json # dependency graph for analysis
236
+ node -e "import('./dist/index.js').then(m => console.log(Object.keys(m)))" # public surface
237
+ for sp in embed-db fts5 vault hnsw bases communities; do # all subpath exports
238
+ node -e "import('./dist/$sp.js').then(m => console.log('$sp:', Object.keys(m).length))"
239
+ done
240
+ node -e "import('./dist/tool-manifest.js').then(m => console.log(m.TOOL_MANIFEST.length))" # 44
241
+ grep -cE "server\.registerTool\(" src/tool-registry.ts # 44
242
+ grep -cE "server\.registerPrompt\(" src/prompts.ts # 19
243
+ enquire-mcp serve --help | grep -oE '^\s+--[a-z][a-z-]+' | sort -u | wc -l # 24 (serve flags, incl. --vault)
244
+ enquire-mcp serve-http --help | grep -oE '^\s+--[a-z][a-z-]+' | sort -u | wc -l # 27 (24 + 11 HTTP-only − 8 missing; see L2-01)
245
+ ```
@@ -0,0 +1,339 @@
1
+ # L3 — Tests & Coverage Audit (v3.6.0)
2
+
3
+ **Audit date**: 2026-05-15
4
+ **Branch**: `v3.6.0/post-stable-audit`
5
+ **Package version**: `3.6.0` (latest)
6
+ **Reference**: `docs/audits/v3.6.0-system-audit-plan.md` §L3
7
+
8
+ ## Summary
9
+
10
+ - Test count: **714** confirmed (713 passing + 1 documented env-gated skip). All 4 documented surfaces (README, package.json, social-preview.svg, CHANGELOG) agree.
11
+ - Per-file coverage: 6 files below 85% lines, 9 files below 75% branches, 6 files below 80% functions (excluding registration boilerplate and the barrel `tools/index.ts`). Most are external-dep or hard-to-reach error paths; one (`tools/index.ts` barrel) is a coverage-counter artifact.
12
+ - **Flake detection: 1 HIGH finding.** 3 npm-test runs produced 10, 11, 3 failures respectively; a 4th run with `--testTimeout=30000` produced 0 failures. Every "failure" is a 5000ms vitest default-timeout hit on `cli.test.ts` and `pdf.test.ts` and `ocr.test.ts` and `fts5.test.ts` tests that spawn child node processes or perform heavy disk/native work. **No deterministic test failures detected.**
13
+ - Snapshot integrity: no snapshot files exist (`tests/__snapshots__/` absent, no `toMatchSnapshot()` or `toMatchInlineSnapshot()` calls in any test file). NOT-APPLICABLE.
14
+ - Fixture freshness: `tests/fixtures/benchmark-queries.jsonl` (47 unique relevant paths) — every path exists in the synthetic vault built by `scripts/run-benchmarks.mjs`. Exact set match.
15
+ - Coverage thresholds: **branches threshold (74%) is THIN — actual 75.02%, margin +1.02pp.** Plan says ≥1pp is a flag-for-raise. Lines/statements/functions are safe.
16
+
17
+ ---
18
+
19
+ ## Finding L3-01 — npm-test 5000ms timeouts cause non-deterministic failures under load
20
+
21
+ - **Severity**: HIGH
22
+ - **Class**: flake-prone test pattern — tests that spawn child processes or perform cold-load of native deps rely on vitest's default 5000ms per-test timeout. Under parallel test contention (multiple worker processes + concurrent disk I/O from other clones running test:coverage on the same machine) these blow past 5s and report as failures.
23
+ - **Evidence**:
24
+ - Run 1 (no override): `Test Files 2 failed | 30 passed | 1 skipped (33); Tests 10 failed | 703 passed | 1 skipped (714); Duration 169.21s`. Tail visible at `/private/tmp/claude-501/.../tasks/btl7fp0xp.output` shows `Error: Test timed out in 5000ms.` for `tests/cli.test.ts:277:3` and `tests/ocr.test.ts:82:3`.
25
+ - Run 2 (no override): `Test Files 3 failed | 29 passed | 1 skipped (33); Tests 11 failed | 702 passed | 1 skipped (714); Duration 200.26s`. Failures at `/tmp/L3-test-run-2.log`:
26
+ - `tests/cli.test.ts` lines 112, 187, 193, 203, 214, 225, 236, 263, 277 (9 distinct `it()` blocks)
27
+ - `tests/fts5.test.ts:171` (`chunkContent > heading parser is linear-time on pathological input (no polynomial-redos)` — `AssertionError: expected 649 to be less than 500`)
28
+ - `tests/pdf.test.ts:33` (`extractPdfText > extracts text from a single-page PDF`)
29
+ - Run 3 (no override): `Test Files 1 failed | 31 passed | 1 skipped (33); Tests 3 failed | 710 passed | 1 skipped (714); Duration 31.15s`. Failures at `/tmp/L3-test-run-3.log`:
30
+ - `tests/cli.test.ts:236, 263, 277` (3 distinct `it()` blocks)
31
+ - Run 4 with `--testTimeout=30000` (control): `Test Files 32 passed | 1 skipped (33); Tests 713 passed | 1 skipped (714); Duration 67.92s`. Zero failures. Output at `/private/tmp/claude-501/.../tasks/b243wxd6u.output`.
32
+ - Root cause: 14 `execFileSync(process.execPath, ...)` calls in `tests/cli.test.ts` (lines 120, 133, 180, 189, 195, 206, 217, 229, 240, 249, 267, 270, 281, 287) all spawn child node processes synchronously. On cold start with parallel native-dep imports, single-spawn cost has been observed at 4-15s. Vitest's default `testTimeout: 5000` is too tight.
33
+ - Secondary: `tests/fts5.test.ts:171` asserts `expect(elapsedMs).toBeLessThan(500)` on a heading parser perf bound — under load this slipped to 649ms (run 2). This is a perf-threshold flake, not a timeout flake.
34
+ - **Cited file:line**:
35
+ - `/Users/alex/Documents/Projects/obsidian-mcp/tests/cli.test.ts:112,187,193,203,214,225,236,263,277` — 9 `it()` blocks with no per-test timeout override
36
+ - `/Users/alex/Documents/Projects/obsidian-mcp/tests/fts5.test.ts:171` — perf bound
37
+ - `/Users/alex/Documents/Projects/obsidian-mcp/tests/pdf.test.ts:33` — cold pdfjs-dist load
38
+ - `/Users/alex/Documents/Projects/obsidian-mcp/tests/ocr.test.ts:82` — cold tesseract.js + canvas + pdfjs load
39
+ - `/Users/alex/Documents/Projects/obsidian-mcp/vitest.config.ts` — no `testTimeout` set in `test` block (defaults to 5000ms)
40
+ - **Class fix**:
41
+ 1. Set a higher floor in `vitest.config.ts` for the suite-level `testTimeout` (e.g. `testTimeout: 15_000`). This is one line.
42
+ 2. For the perf bound at `fts5.test.ts:171`, either widen the threshold (500 → 1500ms) or skip the bound under `process.env.CI === 'true'` with a separate marker test. Pre-fix polynomial was 1-2s; 1.5s preserves the regression-detection.
43
+ 3. Document in `vitest.config.ts` comments why 15s is the floor — because of `execFileSync` cold-start and native-dep imports.
44
+ - **Per-instance backfill**: tests in `cli.test.ts` could also each set `, 30_000` as the test timeout argument (vitest 3rd param), but the suite-level config is simpler and refactor-resistant.
45
+ - **Recommended next action**: file as v3.6.1 patch. The flake is intermittent on dev laptops but worse on CI runners that share build hosts with other jobs — risk that 1-2 failures get rationalized as "macOS oddity" and ignored.
46
+
47
+ ---
48
+
49
+ ## Finding L3-02 — coverage branches threshold within 1pp safety margin
50
+
51
+ - **Severity**: MEDIUM
52
+ - **Class**: threshold-vs-actual safety margin too tight; one regression test deletion drops below CI gate.
53
+ - **Evidence**: from `/Users/alex/Documents/Projects/obsidian-mcp/coverage/coverage-summary.json`:
54
+ ```
55
+ Threshold vs Actual:
56
+ lines threshold=86 actual=89.20 margin=+3.20 [SAFE]
57
+ statements threshold=82 actual=85.79 margin=+3.79 [SAFE]
58
+ functions threshold=75 actual=82.15 margin=+7.15 [SAFE]
59
+ branches threshold=74 actual=75.02 margin=+1.02 [THIN]
60
+ ```
61
+ Branches margin is +1.02pp — within the L3 plan's "<1pp" warn zone is borderline. v3.5.9 dropped branches threshold from 73→72 because local was at 72.94% (knife-edge against CI); v3.6.0 raised it back to 74 after the coverage uplift moved local to 75.29% (+1.3pp margin); the latest measurement at 75.02% leaves only +1.02pp.
62
+ - **Cited file:line**: `/Users/alex/Documents/Projects/obsidian-mcp/vitest.config.ts:46-51` (thresholds block) and `coverage-summary.json` `total.branches.pct = 75.02`.
63
+ - **Class fix**: per the plan, raise the threshold by 2pp where actual is within 1pp. Recommendation: keep `branches: 74` BUT add 8-12 new test cases targeting the 22 uncovered branch lines in `src/communities.ts` and the 12 uncovered branches in `src/watcher.ts` (both lower-effort than re-covering external-dep code). This brings actual to ~77% and creates >2pp margin without changing the threshold.
64
+ - **Per-instance backfill**: add tests for `communities.ts` `L83, L101, L107-L108, L116, L148, L164-L170, L174, L183-L186, L194, L208, L212, L216, L244, L255, L274, L280-L281` (22 uniq lines, 22 br instances) and `watcher.ts` `L38, L52, L74-L77, L95, L100-L101, L109, L121, L126-L128, L137` (12 uniq lines, 17 br instances). Together this is ~35 branches; covering half (~17) lifts total branch pct by ~0.6pp.
65
+ - **Recommended next action**: v3.6.2 — batched with other coverage uplifts.
66
+
67
+ ---
68
+
69
+ ## Finding L3-03 — `src/embeddings.ts` 31.25% lines / 30.00% branches / 33.33% functions
70
+
71
+ - **Severity**: MEDIUM
72
+ - **Class**: external-dep code path — runtime functions require model download (~30-280 MB from HuggingFace) and are gated behind `ENQUIRE_LOAD_RERANKER_SMOKE=1`. The catalog/resolution layer IS covered (v3.5.11 added 40 tests); only the model-IO code is uncovered.
73
+ - **Evidence**: from `coverage-summary.json`:
74
+ ```
75
+ embeddings.ts: lines=31.25 (covered/total ratio same shape as 392 source lines), branches=30.00 (12/40), functions=33.33
76
+ ```
77
+ Uncovered branch lines (from `coverage/lcov.info`): L81, L87, L94, L121, L129, L141, L172, L179, L202, L359, L375.
78
+ Sample at `/Users/alex/Documents/Projects/obsidian-mcp/src/embeddings.ts:80-97`: `loadPipeline()` lazy dynamic import + clean-error fallback path — the success path requires `@huggingface/transformers` to actually load and run. L121-L143: `loadTransformersForRerank()` — same pattern. L358-L389: `score()` method that runs the reranker over batched pairs.
79
+ These are exercised by `tests/reranker-smoke.test.ts:36-85`, but that suite is `it.skip` unless `ENQUIRE_LOAD_RERANKER_SMOKE=1`.
80
+ - **Cited file:line**:
81
+ - `/Users/alex/Documents/Projects/obsidian-mcp/src/embeddings.ts:80-97` (loadPipeline)
82
+ - `/Users/alex/Documents/Projects/obsidian-mcp/src/embeddings.ts:117-144` (loadTransformersForRerank)
83
+ - `/Users/alex/Documents/Projects/obsidian-mcp/src/embeddings.ts:358-389` (score method)
84
+ - `/Users/alex/Documents/Projects/obsidian-mcp/tests/reranker-smoke.test.ts:36-85` (gated tests)
85
+ - **Classification**: (b) external-dep code paths — model download requires network + ~280 MB. Already documented in `reranker-smoke.test.ts:18-27` why this is gated.
86
+ - **Class fix**: not actionable unless CI gets a HuggingFace cache populated at build-image time. Existing approach (env-gated smoke + manual before major releases) is reasonable.
87
+ - **Recommended next action**: ACCEPT. Document the (intentional) low coverage in `vitest.config.ts` comments so a future auditor doesn't try to "fix" it. Optional: split `embeddings.ts` into `embeddings-runtime.ts` (uncoverable without model) + `embeddings-catalog.ts` (fully coverable) for cleaner per-file metrics.
88
+
89
+ ---
90
+
91
+ ## Finding L3-04 — `src/ocr.ts` 33.33% lines / 24.00% branches / 45.45% functions
92
+
93
+ - **Severity**: MEDIUM
94
+ - **Class**: external-dep code path — requires Tesseract.js trained-data download + native canvas + pdfjs. Same pattern as embeddings.ts.
95
+ - **Evidence**: `coverage-summary.json` shows `ocr.ts`: lines=33.33, branches=24.00 (6/25), functions=45.45.
96
+ Uncovered branch lines: L75, L88, L101, L168, L219-L220, L241, L259, L270.
97
+ Sample at `/Users/alex/Documents/Projects/obsidian-mcp/src/ocr.ts:71-94`: `loadTesseract()` and `loadCanvas()` lazy-load with clean-error fallback. L97-L107: `loadPdfjs()` same pattern.
98
+ Existing test `tests/ocr.test.ts:82` exercises real load when all 3 deps install — but that's the test that timed out in run 1 (5000ms isn't enough for cold load).
99
+ - **Cited file:line**:
100
+ - `/Users/alex/Documents/Projects/obsidian-mcp/src/ocr.ts:71-107` (3 lazy-load functions)
101
+ - `/Users/alex/Documents/Projects/obsidian-mcp/src/ocr.ts:165-275` (extraction pipeline)
102
+ - `/Users/alex/Documents/Projects/obsidian-mcp/tests/ocr.test.ts:82` (only real-load test, timeout-prone)
103
+ - **Classification**: (b) external-dep code paths.
104
+ - **Class fix**: same as L3-03 — accept low coverage; document why. Bumping `testTimeout` per L3-01 will make the existing real-load test run reliably.
105
+
106
+ ---
107
+
108
+ ## Finding L3-05 — `src/http-transport.ts` 79.91% lines / 66.86% branches / 58.97% functions
109
+
110
+ - **Severity**: LOW
111
+ - **Class**: hard-to-reach error paths — 500 internal server error handlers, session-id miss paths, max-sessions reached, transport.close() failure recovery.
112
+ - **Evidence**: `coverage-summary.json` shows `http-transport.ts`: lines=79.91, branches=66.86 (111/166), functions=58.97.
113
+ 32 unique uncovered branch lines: L130, L188, L246, L313-L316, L336, L430, L438, L455, L471-L473, L511, L518, L523, L531-L533, L557, L571-L572, L607-L608, L612, L618, L630-L633, L639, L665-L671.
114
+ Sample uncovered branches: `L430-L444` (GET with no `Mcp-Session-Id` header → 400; with unknown session → 404; SSE transport error catch), `L511-L526` (server.connect failure path), `L527-L536` (outer-block catch-all 500), `L607-L612` (transport.close fallback), `L665-L671` (cors/rate-limit label formatting when both are disabled).
115
+ - **Cited file:line**:
116
+ - `/Users/alex/Documents/Projects/obsidian-mcp/src/http-transport.ts:430-445` (GET without session-id)
117
+ - `/Users/alex/Documents/Projects/obsidian-mcp/src/http-transport.ts:511-526` (initialize error)
118
+ - `/Users/alex/Documents/Projects/obsidian-mcp/src/http-transport.ts:527-536` (final safety net)
119
+ - `/Users/alex/Documents/Projects/obsidian-mcp/src/http-transport.ts:660-672` (banner formatting)
120
+ - **Classification**: (a) hard-to-reach error paths. Most are defensive `try { ... } catch (err) { write_error_log }` recovery paths that would require fault injection (mock transport.handleRequest to throw). Reachable via test-double, but cost/benefit is marginal for a v3.6.x patch.
121
+ - **Class fix**: existing pattern (test E2E via real `spawn()` at `tests/http-transport.test.ts:225`) covers happy-path well. Adding fault-injection coverage is a v3.7 candidate.
122
+
123
+ ---
124
+
125
+ ## Finding L3-06 — `src/tools/search.ts` 80.89% lines / 69.75% branches / 70.00% functions
126
+
127
+ - **Severity**: LOW (single largest source file; ratio still acceptable)
128
+ - **Class**: genuinely undertested (c) — `search.ts` is 1565 lines, 52 unique uncovered branch lines spread across the file.
129
+ - **Evidence**: `coverage-summary.json` shows `tools/search.ts`: lines=80.89, branches=69.75 (196/281), functions=70.00.
130
+ Uncovered branch lines: L94, L118, L133, L239, L265, L447-L449, L457, L511, L522-L523, L528, L641, L652, L841, L845-L846, L857, L875, L880, L890, L895, L900, L925, L1156, L1182, L1208, L1213, L1237, L1254, L1269-L1271, L1295, L1320-L1325, L1354, L1362, L1375, L1380, L1384, L1411, L1424, L1450, L1475, L1483-L1488, L1505, L1515, L1557. (52 unique, 85 br instances.)
131
+ - **Cited file:line**: `/Users/alex/Documents/Projects/obsidian-mcp/src/tools/search.ts` — 52 line ranges as above.
132
+ - **Classification**: (c) genuinely undertested — `search.ts` houses the hybrid pipeline + reranker integration; some uncovered branches are around late-chunking flags, HyDE flags, reranker overrides, and result-merging edge cases (empty result set, single-source result, conflict resolution).
133
+ - **Class fix**: same as L3-02 — pair with v3.6.2 coverage uplift; target ~20 of the 52 uncovered lines for ~+0.7pp branches total.
134
+
135
+ ---
136
+
137
+ ## Finding L3-07 — `src/tools/meta.ts` 80.93% lines / 67.66% branches / 70.96% functions
138
+
139
+ - **Severity**: LOW
140
+ - **Class**: genuinely undertested (c) — `meta.ts` is 1425 lines, 65 unique uncovered branch lines.
141
+ - **Evidence**: `coverage-summary.json` shows `tools/meta.ts`: lines=80.93, branches=67.66 (203/300), functions=70.96.
142
+ Uncovered branch lines: L114, L124-L127, L137-L143, L177-L179, L184, L215, L392, L404, L422-L426, L437, L449, L453, L484, L590, L597, L615, L693, L699, L723-L725, L846, L851, L887, L892-L893, L897, L920-L922, L932, L1103-L1107, L1118, L1126, L1135, L1147, L1151-L1155, L1165, L1169, L1173-L1176, L1225, L1273, L1316, L1357, L1362, L1367-L1373, L1381. (65 unique, 97 br instances.)
143
+ - **Cited file:line**: `/Users/alex/Documents/Projects/obsidian-mcp/src/tools/meta.ts` — 65 line ranges as above.
144
+ - **Classification**: (c) genuinely undertested — `meta.ts` covers metadata-related tools (frontmatter, tags, links, communities, periodic-notes, base files). Uncovered branches cluster around `L120-L185` (frontmatter parsing edge cases), `L420-L490` (link manipulation error paths), `L1100-L1180` (Bases query argument validation).
145
+ - **Class fix**: pair with v3.6.2 coverage uplift. Highest-ROI targets: `L137-L143` (7-line block, likely a single switch fall-through) and `L1367-L1373` (similar shape).
146
+
147
+ ---
148
+
149
+ ## Finding L3-08 — `src/watcher.ts` 82.00% lines / 62.22% branches / 78.57% functions
150
+
151
+ - **Severity**: LOW
152
+ - **Class**: hard-to-reach error paths + concurrency races (a)+(c) — `watcher.ts` is only 142 lines but exercising chokidar event ordering deterministically is hard.
153
+ - **Evidence**: `coverage-summary.json` shows `watcher.ts`: lines=82.00, branches=62.22 (28/45), functions=78.57.
154
+ Uncovered branches: L38 (silent default), L52 (skip-dir match), L74-L77 (handle error catch), L95 (path safety), L100-L101 (no-ftsIndex branch), L109 (unlink branch), L121 (silent-flag), L126-L128 (read error catch), L137 (close idempotency).
155
+ - **Cited file:line**: `/Users/alex/Documents/Projects/obsidian-mcp/src/watcher.ts:38, 52, 74-77, 95, 100-101, 109, 121, 126-128, 137`.
156
+ - **Classification**: mostly (c). Many of these branches are reachable with a small additional test that pre-sets `silent: false` then triggers add/change/unlink events and asserts stderr output via `stderr` capture. ~30 lines of test code lifts branch pct from 62 → ~80.
157
+ - **Class fix**: targeted watcher.test.ts addition; recommended for v3.6.2.
158
+
159
+ ---
160
+
161
+ ## Finding L3-09 — `tools/index.ts` 0.00% coverage (barrel artifact)
162
+
163
+ - **Severity**: INFO
164
+ - **Class**: coverage-counter artifact — `src/tools/index.ts` is 5 lines of `export * from "./media.js"` etc. (pure barrel). v8 coverage reports 0/0 because the file has no executable statements outside the imports. The barrel itself is excluded only because of the `**/*.test.ts` exclude in vitest.config.ts — it should be added to `src/tools/index.ts` or the brace-glob exclude pattern should add it.
165
+ - **Evidence**: `coverage-summary.json`:
166
+ ```json
167
+ "tools/index": { "lines": {"total": 0, "covered": 0, "pct": 0}, ... }
168
+ ```
169
+ `/Users/alex/Documents/Projects/obsidian-mcp/src/tools/index.ts:1-5`:
170
+ ```
171
+ export * from "./media.js";
172
+ export * from "./meta.js";
173
+ export * from "./read.js";
174
+ export * from "./search.js";
175
+ export * from "./write.js";
176
+ ```
177
+ - **Cited file:line**: `/Users/alex/Documents/Projects/obsidian-mcp/vitest.config.ts:35-38` — the exclude block uses `src/{index,cli,server,tool-registry,prompts,tool-manifest}.ts` which does NOT include `src/tools/index.ts`. So the v8 coverage tool reports it, but because it's a pure re-export, the metric is 0/0 = NaN%, displayed as 0%.
178
+ - **Classification**: presentation artifact, not a real coverage gap.
179
+ - **Class fix**: add `src/tools/index.ts` to the exclude pattern. One-line change:
180
+ ```ts
181
+ exclude: [
182
+ "src/{index,cli,server,tool-registry,prompts,tool-manifest}.ts",
183
+ "src/tools/index.ts", // barrel re-export
184
+ "**/*.test.ts"
185
+ ]
186
+ ```
187
+ Or expand the glob: `"src/{index,cli,server,tool-registry,prompts,tool-manifest,tools/index}.ts"`.
188
+ - **Recommended next action**: include in v3.6.1 if patching anyway. Cosmetic but prevents future "tools/index has 0% coverage!" confusion.
189
+
190
+ ---
191
+
192
+ ## Finding L3-10 — `src/pdf.ts` 58.33% branches (89.18% lines)
193
+
194
+ - **Severity**: LOW
195
+ - **Class**: external-dep + (a) hard-to-reach metadata branches.
196
+ - **Evidence**: `coverage-summary.json` shows `pdf.ts`: lines=89.18, branches=58.33 (14/24).
197
+ Uncovered branches: L94 (lazy-load fallback), L139, L168, L172-L177 (6-line block of metadata `typeof info.X === "string"` checks).
198
+ `/Users/alex/Documents/Projects/obsidian-mcp/src/pdf.ts:172-177`: subject, keywords, creator, producer, creationDate, modDate — all `typeof info.X === "string"` checks where test PDFs (synthesised via `pdf-lib` in `tests/helpers/make-pdf.ts`) only set `title`/`author`, not the rest. So the truthy-string branch never fires for those 5 metadata fields.
199
+ - **Cited file:line**: `/Users/alex/Documents/Projects/obsidian-mcp/src/pdf.ts:172-177`, `/Users/alex/Documents/Projects/obsidian-mcp/tests/helpers/make-pdf.ts` (PDF builder).
200
+ - **Classification**: (a) — easily reachable, just need a test PDF with all 8 metadata fields populated.
201
+ - **Class fix**: extend `tests/helpers/make-pdf.ts` to optionally accept all 8 metadata fields, then add one test PDF with all set + assert all 8 fields land in `extractPdfText().metadata`. ~10 lines, lifts branches from 58 → ~95.
202
+ - **Recommended next action**: v3.6.2 coverage uplift.
203
+
204
+ ---
205
+
206
+ ## Finding L3-11 — fixture freshness verified (PASS)
207
+
208
+ - **Severity**: INFO
209
+ - **Evidence**:
210
+ - `/Users/alex/Documents/Projects/obsidian-mcp/tests/fixtures/benchmark-queries.jsonl` — 60 queries (q01-q60) reference 47 unique relevant paths.
211
+ - `/Users/alex/Documents/Projects/obsidian-mcp/scripts/run-benchmarks.mjs` — `VAULT_NOTES` object defines exactly the same 47 paths.
212
+ - `diff` between the two sorted unique-path sets returns 0.
213
+ - Path categories: `Reference/*.md` (32), `Projects/*.md` (6), `Inbox/*.md` (5), `Daily/*.md` (5), `INDEX.md` (1) — 47 total.
214
+ - **Status**: NO FINDING. Fixture is in sync with the synthetic-vault generator.
215
+ - **Notable**: the inline JSONL comment at line 6 explicitly states "When the vault layout changes the relevant-paths list here MUST change too — these are the binary ground-truth labels for NDCG / Recall / MRR." This invariant is currently held.
216
+
217
+ ---
218
+
219
+ ## Finding L3-12 — no snapshot files (NOT APPLICABLE)
220
+
221
+ - **Severity**: INFO
222
+ - **Evidence**:
223
+ - `find /Users/alex/Documents/Projects/obsidian-mcp/tests -name "__snapshots__"` → no results
224
+ - `find /Users/alex/Documents/Projects/obsidian-mcp/tests -name "*.snap"` → no results
225
+ - `grep -rn "toMatchSnapshot\|toMatchInlineSnapshot" tests/` → no matches
226
+ - **Status**: NO FINDING. Snapshot integrity check is moot because the project uses no snapshots — all assertions are explicit `expect().toEqual()`, `.toMatch()`, etc.
227
+
228
+ ---
229
+
230
+ ## Coverage table (full)
231
+
232
+ From `/Users/alex/Documents/Projects/obsidian-mcp/coverage/coverage-summary.json`:
233
+
234
+ | File | Lines | Branches | Funcs | Stmts | Flags |
235
+ |---|---:|---:|---:|---:|---|
236
+ | `tools/index` | 0.00 | 0.00 | 0.00 | 0.00 | barrel artifact (L3-09) |
237
+ | `embeddings` | 31.25 | 30.00 | 33.33 | 29.21 | external-dep (L3-03) |
238
+ | `ocr` | 33.33 | 24.00 | 45.45 | 30.30 | external-dep (L3-04) |
239
+ | `http-transport` | 79.91 | 66.86 | 58.97 | 78.57 | error paths (L3-05) |
240
+ | `tools/search` | 80.89 | 69.75 | 70.00 | 78.47 | (L3-06) |
241
+ | `tools/meta` | 80.93 | 67.66 | 70.96 | 76.88 | (L3-07) |
242
+ | `watcher` | 82.00 | 62.22 | 78.57 | 79.03 | (L3-08) |
243
+ | `pdf` | 89.18 | 58.33 | 100.00 | 90.00 | (L3-10) |
244
+ | `vault` | 92.63 | 80.00 | 75.38 | 83.52 | F<80 |
245
+ | `hnsw` | 94.25 | 75.00 | 100.00 | 91.57 | |
246
+ | `tools/media` | 94.30 | 67.93 | 92.30 | 91.11 | B<75 |
247
+ | `fts5` | 94.32 | 80.95 | 93.33 | 92.51 | |
248
+ | `doctor` | 94.54 | 66.35 | 100.00 | 92.50 | B<75 |
249
+ | `embed-db` | 95.56 | 81.30 | 88.00 | 93.78 | |
250
+ | `dql` | 95.95 | 86.12 | 89.65 | 90.28 | |
251
+ | `tools/read` | 96.11 | 85.43 | 91.17 | 93.97 | |
252
+ | `parser` | 98.00 | 84.61 | 100.00 | 96.42 | |
253
+ | `bases` | 98.21 | 73.17 | 91.30 | 92.27 | B<75 |
254
+ | `tools/write` | 98.51 | 84.83 | 96.15 | 95.30 | |
255
+ | `periodic` | 99.06 | 85.04 | 100.00 | 99.13 | |
256
+ | `communities` | 99.15 | 73.17 | 100.00 | 95.52 | B<75 |
257
+ | `cli-help` | 100.00 | 100.00 | 100.00 | 100.00 | |
258
+ | `eval` | 100.00 | 76.62 | 100.00 | 98.49 | |
259
+ | `rrf` | 100.00 | 93.33 | 100.00 | 96.66 | |
260
+ | **TOTAL** | **89.20** | **75.02** | **82.15** | **85.79** | branches THIN (L3-02) |
261
+
262
+ Thresholds: lines=86, statements=82, functions=75, branches=74. All passing; branches has +1.02pp margin (L3-02).
263
+
264
+ ---
265
+
266
+ ## Test count cross-surface verification (PASS)
267
+
268
+ | Surface | Path:line | Claimed |
269
+ |---|---|---|
270
+ | README | `/Users/alex/Documents/Projects/obsidian-mcp/README.md:13` (badge) | 714 passing |
271
+ | README | `/Users/alex/Documents/Projects/obsidian-mcp/README.md:32` (one-liner) | 714 unit tests |
272
+ | README | `/Users/alex/Documents/Projects/obsidian-mcp/README.md:98` (table) | 714 unit tests |
273
+ | README | `/Users/alex/Documents/Projects/obsidian-mcp/README.md:208` (code block) | 714 tests, ~5s |
274
+ | package.json | `/Users/alex/Documents/Projects/obsidian-mcp/package.json:5` (description) | 714 tests |
275
+ | social-preview.svg | `/Users/alex/Documents/Projects/obsidian-mcp/assets/social-preview.svg` | `<text>714</text>` |
276
+ | CHANGELOG | `/Users/alex/Documents/Projects/obsidian-mcp/CHANGELOG.md:70` (v3.6.0 entry) | 714 tests (713 passing + 1 env-gated smoke) |
277
+ | CHANGELOG | `/Users/alex/Documents/Projects/obsidian-mcp/CHANGELOG.md:167` (v3.6.0-rc.4) | 714 tests (713 passing + 1 skipped) |
278
+
279
+ **Actual measured**: `grep -rEh "^\s+(it|test)\(" tests/*.test.ts | wc -l` → **714**. Of those, 1 is `it.skip` at `tests/reranker-smoke.test.ts:38`. So **713 active + 1 skipped = 714 total**, matching every documented surface.
280
+
281
+ ---
282
+
283
+ ## Per-test-file count (sanity)
284
+
285
+ ```
286
+ tests/reranker-smoke.test.ts: 0 it() at indent 2 (1 it.skip)
287
+ tests/no-internal-imports.test.ts: 1
288
+ tests/chat-thread.test.ts: 7
289
+ tests/ocr.test.ts: 7
290
+ tests/watcher.test.ts: 7
291
+ tests/late-chunking.test.ts: 8
292
+ tests/canvas.test.ts: 10
293
+ tests/frontmatter-ops.test.ts: 11
294
+ tests/communities.test.ts: 13
295
+ tests/persistent-cache.test.ts: 13
296
+ tests/reranker.test.ts: 13
297
+ tests/rrf.test.ts: 13
298
+ tests/semantic.test.ts: 13
299
+ tests/lint.test.ts: 14
300
+ tests/v16.test.ts: 14
301
+ tests/doctor.test.ts: 15
302
+ tests/search-hybrid.test.ts: 15
303
+ tests/embeddings.test.ts: 17
304
+ tests/hnsw.test.ts: 17
305
+ tests/bases.test.ts: 21
306
+ tests/docs-consistency.test.ts: 21
307
+ tests/embed-db.test.ts: 22
308
+ tests/eval.test.ts: 25
309
+ tests/parser.test.ts: 25
310
+ tests/periodic.test.ts: 25
311
+ tests/pdf.test.ts: 26
312
+ tests/cli.test.ts: 31
313
+ tests/fts5.test.ts: 34
314
+ tests/security.test.ts: 36
315
+ tests/dql.test.ts: 43
316
+ tests/http-transport.test.ts: 49
317
+ tests/write.test.ts: 50
318
+ tests/tools.test.ts: 70
319
+ ```
320
+ Sum of `it()` calls in indented form: 686 (excluding nested describes counted differently). Including all `it()` and `test()` variations: 714. Includes 1 `it.skip` at `tests/reranker-smoke.test.ts:38`.
321
+
322
+ ---
323
+
324
+ ## Constraints honored
325
+
326
+ - Audit-only: no source files modified.
327
+ - Test + coverage runs executed (read-only on src/).
328
+ - Every claim cites specific `file:line` per L3 plan.
329
+
330
+ ## Recommendations summary
331
+
332
+ | Priority | Finding | Action |
333
+ |---|---|---|
334
+ | HIGH | L3-01 | Set `testTimeout: 15_000` in `vitest.config.ts`; widen `fts5.test.ts:171` perf bound to 1500ms. Ship as v3.6.1 patch. |
335
+ | MEDIUM | L3-02 | Add 8-12 test cases in `tests/communities.test.ts` + `tests/watcher.test.ts` to lift branches margin past 2pp; OR raise threshold to 75. |
336
+ | MEDIUM | L3-03, L3-04 | Document external-dep coverage gap in `vitest.config.ts` comments so future auditors don't try to "fix" it. |
337
+ | LOW | L3-05, L3-06, L3-07, L3-08, L3-10 | Batch into v3.6.2 coverage uplift; target the lowest-hanging branches. |
338
+ | INFO | L3-09 | Add `src/tools/index.ts` to vitest coverage exclude (one-line). |
339
+ | PASS | L3-11, L3-12, test count | No action — recorded for traceability. |