Introduces src/utils/yaml-bridge.ts as the single YAML parse/serialize seam
for the Bases subsystem, and a reusable .base corpus + differential tests
that run every fixture through both js-yaml (still resolvable transitively as
the behavioural oracle) and the new yaml-backed bridge, asserting
equivalence. The one intentional difference — js-yaml coerced bare/ISO dates
to Date, yaml keeps strings — is asserted explicitly and proven downstream-
safe (expression-evaluator already does new Date(value) on non-Date input).
Corpus is structured for ADR-201/#180 reuse.
Closes#175. Records the decision to replace the arbitrary-JS new Function
in expression-evaluator.ts with a vetted no-eval expression library — driven
by the security reality (.base files are syncable/shareable, so the current
path is arbitrary code execution from untrusted files in a live plugin), not
the (non-gating) scanner finding.
Hard constraints recorded: no hand-rolled parser (its own risk class), and a
mandatory differential test corpus (new == old). Implementation tracked in
#180; rejected alternatives (accept-and-document, hand-rolled parser, heavy
sandbox) documented with rationale.
Closes a repo-health/hygiene gap surfaced by the Obsidian community
trust/health signal. Canonical Covenant 2.1 text; reporting contact set to
the maintainer email already public in git history.
Plugin is live in the community directory (2026-05-16). Capture settled
knowledge so it isn't re-investigated:
- README: link to the public Obsidian listing; fix stale '(coming soon)'
install section (it's live) and the search term to the real plugin name.
- CLAUDE.md: document the portal=source-of-truth -> bot-mirrors-to
community-plugins.json -> in-app browser reads the mirror architecture,
and the resulting post-approval propagation lag (normal, not a bug).
- CLAUDE.md: broaden 'accepted scorecard cautions' to 'accepted review
findings' covering the full submit review — fs access, vault enumeration,
clipboard, and the README placeholder false positive — each with its
justification, so they aren't re-litigated. Refresh the issue tracker line.
Closes#176.
Obsidian CSS-lint flags !important. Replace with element-qualified selectors
that win on specificity instead:
- button.mcp-hidden out-specifies framework button display (was the reason
for display:none !important)
- input.mcp-api-key-input out-specifies container user-select:none, keeping
the API key selectable/copyable
Behavior-preserving by design; needs a visual confirm (see PR).
Obsidian source-code review flags builtin-modules as a package to replace.
node:module has exposed builtinModules natively since Node 6.x; esbuild only
used it to externalize Node builtins from the bundle. Behavior-equivalent
swap, devDependency removed.
Follow-up to #164. Obsidian's portal flags every installable release asset
lacking a build-provenance attestation. #164 attested main.js + the .mcpb
bundles but missed styles.css and manifest.json, leaving a standing
'styles.css release asset does not have a GitHub artifact attestation'
recommendation. Attest the full installable set.
_update-versions-json wrote MIN_OBSIDIAN into versions.json, but it was
stale at 0.15.0 while manifest.json minAppVersion is 1.6.6 (raised in the
0.11.21 release, where versions.json was hand-corrected). Left as-is, the
next make release-* would write an inconsistent versions.json entry
(version -> 0.15.0) that disagrees with the shipped manifest. Align the
Makefile to the actual floor so the automation is correct without manual
patching.
The portal long 'About' matches nothing in the repo because Obsidian seeded
it portal-side during the community.obsidian.md migration; it's decoupled
from package.json/manifest and only editable in the authenticated dev portal.
Document so future work doesn't try to change it via the repo.
package.json is now the single source of truth for the plugin description
(as it already effectively is for version). sync-version.mjs propagates it
into manifest.json; mcpb/manifest.json keeps its Claude-Desktop-specific
description and is intentionally not synced.
Adds scripts/set-description.mjs + 'make set-description DESC=...' so neither
JSON file is hand-edited (250-char Obsidian limit enforced).
Rewrites the word-salad description to reality-based copy naming the actual
client and mechanism:
'Give Claude Desktop and other AI assistants semantic access to your notes
through a built-in Model Context Protocol (MCP) server.'
Closes#164. Adds actions/attest-build-provenance@v2 (gated like Create
Release) plus id-token/attestations write permissions, attesting main.js and
both .mcpb bundles. Clears the scorecard 'release assets missing a GitHub
artifact attestation' finding without changing what ships.
Closes#163. The literal only existed in DEFAULT_SETTINGS and was passed to
the inbound https.createServer, which never sets requestCert — so Node ignored
it. certificate-manager already defaults the effective value to true via
`!== false`, so removing the default is behavior-preserving and removes the
exact token Obsidian's scorecard pattern-matches as 'disables SSL verification'.
Adds tests/security/tls-cert-verification.test.ts as a regression guard:
fails if the flagged literal returns or the secure default flips.
Add scripts/scorecard.mjs + 'make scorecard'. The community portal renders a
deterministic Health/Review scan; the page is server-rendered (summary in DOM,
findings in the Next.js RSC payload) so we can pull it without a browser.
- Computes a freshness delta (portal version vs manifest.json) since a fresh
scan only triggers from the authenticated dev portal — STALE means a
logged-in re-scan is still needed.
- Scraper drift guard: missing critical anchors / empty findings on a
non-clean Review exits non-zero with a SCRAPER DRIFT banner so the parser
rots loudly, not silently. Scorecard body never gates.
- --json mode for diffing findings across releases.
CLAUDE.md: document make scorecard, the freshness caveat, the drift guard,
and the known accepted cautions (.mcpb additional-files is ADR-102 by design;
'scan not available' disclosures are neutral). Actionable findings tracked in
#163 (rejectUnauthorized), #164 (attestation); CI-gate in #165.
Obsidian moved community-plugin submission from the obsidian-releases PR
process to the community.obsidian.md developer portal. Replace the now-defunct
'I'm Not Dead Yet' PR-refresh procedure in CLAUDE.md with the portal-based
distribution reality (stable Latest release matching manifest, no v-prefix,
versions.json, make promote).
ADR-300 records the decision to keep a single main branch with the existing
prerelease/promote flow rather than add a dedicated Obsidian release branch:
for 'same code, slower cadence' the prerelease/promote gap already provides
the decoupling, and a release branch would only add permanent version-file
divergence. Rejected alternatives documented.
The plugin code already calls APIs introduced in newer Obsidian versions
(FileManager.trashFile in 1.6.6, Workspace.getLeaf in 0.16.0,
Vault.createFolder in 1.4.0, ToggleComponent.setDisabled in 1.2.3,
AbstractTextComponent.setDisabled in 1.2.3, ButtonComponent.setTooltip
in 1.1.0). Declaring minAppVersion 0.15.0 was misleading — anyone
between 0.15 and 1.5 would have the plugin load and then crash at
runtime. Set the floor to 1.6.6 to match the highest API actually used.
The 0.3.0 release adds typed rules (no-plugin-as-component, no-view-references-in-plugin,
no-unsupported-api, prefer-file-manager-trash-file, prefer-instanceof) and wires
them into the hybrid recommended config without a **/*.ts files scope. As a
result they also try to run against package.json — which uses ESLint's JSON
language and has no @typescript-eslint parser services — and ESLint aborts
the whole run with "Error while loading rule 'obsidianmd/no-plugin-as-component'".
- eslint.config.mts: disable those five typed rules on JSON files, and
ignore the data files the recommended config doesn't wire a JSON parser
for (tsconfig.json, package-lock.json, manifest.json, versions.json,
src/config/**/*.json, .claude/**).
- Keep the autofix for `setTimeout` → `window.setTimeout` and
`globalThis` → `window` in code paths that only run inside Obsidian
(main.ts, main-complex.ts, worker-manager.ts, connection-pool.ts,
tools/fetch.ts) — that's the popout-window-compat behaviour the rule
exists to enforce.
- Revert the autofix in code paths imported by Jest tests where `window`
is undefined: session-manager.ts and obsidian-api.ts background timers,
semantic/router.ts active-file timeout, debug.ts console reference.
Each retains a `// eslint-disable-next-line` with the reason
(background timer / Node test compat).
Closes#115. Closes#123.
The Dataview API returns Luxon DateTime objects (which expose toISO(), not
toISOString()) for file.ctime/mtime, so listPages() and getPageMetadata()
crashed with "toISOString is not a function" the moment any real Dataview
result reached them. The existing tests passed only because the mocks used
native Date, which exposes toISOString().
The query path had a separate envelope bug: executeQuery() hard-coded
success: true whenever dataviewAPI.query() didn't throw, while Dataview
itself returns { successful: false, error: "..." } for malformed queries
without throwing. The formatter then read response.successful (undefined),
hit the failure branch, and rendered every result as "❌ Query failed:
Unknown error" — even when the query had succeeded.
- Add toIsoOptional() that prefers Luxon's toISO() and falls back to
Date.toISOString(); apply at every page.file.ctime/mtime serialization
site and inside convertDataviewValue().
- Propagate result.successful and result.error from the inner Dataview
response to the outer tool envelope.
- Add a dataview.query normalizer case in the formatter dispatcher that
flattens result.{type,values,headers} to the top level and maps
success → successful, matching the formatter's contract.
- Regression tests for both bugs, including a Luxon-shaped mock distinct
from the existing Date-shaped one.
Pagination was silently a no-op for the MCP-facing surface. MCP
clients send numeric arguments (page, pageSize, snippetLength) as
JSON numbers; paramStr returns undefined for non-string values, so
parseInt(paramStr(...) ?? '1') always collapsed to defaults.
Caught while live-verifying the #158 pagination work shipped in
0.11.19: a call with page=2 pageSize=50 returned page 1 pageSize 20.
The router was discarding the user's values before the call ever
reached listFilesPaginated.
Three call sites fixed in src/semantic/router.ts:
- vault.list (line 174-175) — the immediate verifier
- vault.search (line 286-287)
- vault.search snippet length (line 306)
paramNum already existed at line 44; this commit just wires it in.
Code-reviewer caught a real bug: listFiles (recursive flat) sorts by
full path, but listFilesPaginated in recursive mode was sorting
folders-first-then-by-basename. So the agent's "use page=2 pageSize=50"
hint anchored to a path-sorted universe, but page 2 returned a
basename-sorted universe — overlaps and gaps for any caller trying to
walk the listing.
Recursive paginated mode now sorts by full path, matching listFiles.
Level-only mode (the default for internal callers — cp recursion,
isDirectory probe) keeps the folder-first-then-basename order it
needs for tree navigation.
New test (`paged universe matches the flat listFiles universe`)
concatenates every page and asserts equality with the flat call —
exactly the agent-facing contract the formatter's next-page hint
promises.
Router comment also clarifies why root paginated stays recursive=false
(getAllLoadedFiles is already recursive — the path collapses to the
same answer either way).
Refs PR #158 review.
When the formatter truncated a directory listing with "... and N more",
the agent had no usable next call. \`page=2 pageSize=50\` would have
returned a fundamentally different shape (level-only folder entries
from listFilesPaginated, not the next 50 files from the recursive
listFiles), so any hint to "paginate" would have led the agent
somewhere broken.
This change makes pagination semantically consistent and gives the
agent an explicit, working escape hatch:
- listFilesPaginated gains a recursive flag (default false to
preserve existing internal callers — cp at router.ts:1049 and the
isDirectory probe at router.ts:1021 still get level-only).
- vault.list passes recursive=true when paginating with a non-root
directory. Page N of vault.list('foo') now returns the Nth slice
of the same flat universe vault.list('foo') (unpaginated) would
return — no shape switch.
- The flat-list formatter, when it truncates at 50, now tells the
agent exactly what to call next: page=2 pageSize=50, or raw:true
for the full list in one response.
- The structured-response formatter shows "Page X of Y" and, when
there are more pages, surfaces the next-page call with the
current pageSize baked in.
- normalizeResponse gains a vault.list case translating the API
response's \`type: 'file'|'folder'\` to the formatter's \`isFolder\`
boolean. Previously the structured branch's filter on f.isFolder
always failed silently, lumping every entry into "Files" even when
half were folders.
Two new tests in tests/list-files-recursive.test.ts:
- recursive paginated mode produces the expected slice with no
cross-page overlap
- recursive=false (default) preserves the level-only behavior the
internal callers depend on
Refs #154 (extends the agent-facing fix).
vault.list(directory) returned an empty array whenever the target
folder's direct children were all subfolders — listFiles used
folder.children and then filtered to TFile, dropping every
descendant. Meanwhile the root case used vault.getAllLoadedFiles()
which is recursive, so root and directory listings diverged.
listFiles now walks the folder tree when a directory is provided,
matching the recursive semantics of the root case. Callers that
were already iterating result paths get the same shape — full
vault-rooted paths to .md files — they just no longer hit the
empty-list cliff for folder-of-folders inputs.
The synchronous throw on missing directory is preserved; existing
try/catch sites at router.ts:538 and :1030 use it as a "does this
exist as a directory" probe.
Adds tests/list-files-recursive.test.ts with regression coverage:
- recursing through a folder of subfolders returns all descendant files
- recurses through arbitrary nesting depth
- still throws on a missing directory
- root call (no directory) still returns the full vault
Test fixtures: tests/__mocks__/obsidian.ts gained a TFolder class
and a stat field on TFile.
Closes#154.
After merging the dependabot batch, npm audit still flagged 5
transitive vulns (4 high, 1 moderate). Running npm audit fix lifts
deep deps to patched ranges and resolves all of them — except it
tries to land yaml@2.9.0 which was published 2026-05-11, inside the
7-day hold window.
Added a package.json override to pin yaml to ~2.8.4 (published
2026-05-02, 13 days aged). 2.8.4 already fixes the CVE-flagged
"Stack Overflow via deeply nested YAML collections" (range
2.0.0-2.8.2), and we get the safety of an aged release.
Final state: 0 vulnerabilities, 127/127 tests still green, all
upgrades respect the 7-day rule.