cite-wide 0.0.1.2 Published

Type-Safety Pass — Eliminated All `any` Declarations, Retired Hand-Rolled YAML Parser, Gated ESLint Into Build

Five-commit refactor that brings the project from 14 explicit `any` declarations to zero, replaces a structurally broken regex-based YAML parser with Obsidian's native frontmatter APIs (metadataCache.getFileCache + fileManager.processFrontMatter), introduces a small set of boundary coercers for narrowing untrusted values, and wires an ESLint flat-config mirroring ObsidianReviewBot's rules into `pnpm build` so the rules that gate community-store submission now fire locally pre-push. Verified working in a live Obsidian vault.

Why Care?

Cite-Wide was rejected from the Obsidian community plugin store for "lack of type safety" — concretely, ObsidianReviewBot's @typescript-eslint/no-explicit-any fired on the project's 14 explicit any declarations. This refactor closes out that rejection criterion. The plugin now produces zero violations of the rules the bot actually enforces, and those same rules now run locally as part of pnpm build, so the next surprise on resubmission shouldn't be a type-safety one.

Two side benefits surfaced once the audit got specific about where the anys were:

  1. The hand-rolled YAML parser at citationFileService.ts:404 was structurally broken. It split key frontmatter lines on the first :, which mangled every URL value (every URL contains ://). The reads happened to work because the malformed values would later get unquoted by unescapeYamlValue and end up looking-vaguely-correct, but any frontmatter where a quoted URL had no surrounding quotes would silently corrupt. Replacing the parser with Obsidian's native APIs is both a type-safety fix and a real correctness fix.

  2. The Obsidian-API shim at src/types/obsidian.d.ts was actively lying rather than merely redundant — it tightened MarkdownView.file: TFile | null → TFile and PluginManifest.dir?: string → string, hiding null cases the upstream d.ts wants the caller to handle. Deleting the shim restores those constraints. The App.commands: any augmentation it also carried was never accessed by any code path; pure dead weight.

The reason this is recorded rather than left to commit messages: the strategic content of this work isn't "we removed 14 lines of any," it's "we adopted a boundary-coercion strategy" (untrusted values are typed unknown and narrowed via small coercer functions; types inside the codebase remain strict). That strategy is the load-bearing pattern for any future code touching frontmatter, JSON responses, or undocumented Obsidian APIs, and it lives in context-v/reminders/Obsidian-Type-Safety.md for posterity.

What Was Built

Commits in order

#CommitTitle
128b7b83add(utils): boundary coercers for unknown→typed conversion
2978984afix(types): remove easy any sites; delete redundant Obsidian shim
317193a2refactor(citations): retire hand-rolled YAML; coerce frontmatter at boundary
4b6a24dcfix(types): narrow Jina.ai Reader response with isRecord + coercers
50a5aa13add(lint): eslint flat-config mirroring Obsidian review-bot rules

src/utils/coerce.ts — the boundary coercers (commit 1)

Five small, non-throwing helpers that take unknown and return well-typed values:

  • isRecord(v): v is Record<string, unknown> — type predicate for objects

  • asString(v): string | undefined — strings pass through; numbers/booleans stringified

  • asNumber(v): number | undefined — numbers pass through; numeric strings parsed

  • asStringArray(v): string[] — arrays mapped through asString and filtered; scalars promoted to [scalar]

  • asDate(v): string | undefined — coerced to ISO string via Date, returns undefined on Invalid Date

Lossy on purpose: garbage input returns undefined / [] rather than throwing, so a content creator's malformed frontmatter never crashes the plugin. This is the load-bearing piece — every later commit consumes these.

"Easy" any removals (commit 2)

SiteBeforeAfter
main.ts:541constructor(app: any, existingCitation: any, ...)constructor(app: App, existingCitation: CitationMetadata, ...) — Obsidian's Modal constructor is already typed (app: App); the widening was gratuitous
src/utils/logger.ts ×6details?: any on LogEntry, addEntry, error, warn, info, debugdetails?: unknown everywhere; body already narrows via details instanceof Error, so no functional change
src/services/cleanReferencesSectionService.ts:40const editor = (window as any).activeEditor?.editor; (in processSelection())Deleted — processSelection() was never called from any code path; addColonSyntaxWhereNone is the only consumed export
src/types/obsidian.d.tsWhole fileDeleted (see Why Care? §2 above)

citationFileService.ts rewrite (commit 3)

The biggest single change. Net 218 inserts / 504 deletes because delegating to Obsidian's APIs collapses a lot of hand-rolled scaffolding.

ConcernWasNow
Singleton initnew CitationFileService(null as any) + later patch via (svc as any).app = appNo-arg constructor; private _app: App | null = null; private getter app that throws if uninitialized; public setApp(app) setter
Frontmatter readparseFrontmatter() regex parser splitting on first : per line, returning Partial<CitationMetadata> via (metadata as any)[key]app.metadataCache.getFileCache(file)?.frontmatter, narrowed via isRecord, each field run through the coercers
Frontmatter writecreateFrontmatterString() hand-emitter using a 30-char-blacklist for quoting decisions, plus regex replace of the --- block in file contentapp.fileManager.processFrontMatter(file, fn) for both new files and updates; framework handles parse/mutate/serialize atomically
updateCitationUsage~50 lines of read + parse + reconstruct + regex-replace + write~10 lines via processFrontMatter callback
Folder traversal (getAllCitationFiles)processFolder = (folder: any) => folder.children.forEach((child: any) => ...)Recursive walk using instanceof TFolder / instanceof TFile narrowing; no casts
citationData parameterscitationData: any on createCitationFileWithData and createFrontmatterFromDataTyped CitationData (the actual runtime shape, imported from urlCitationService.ts)
getCitationMetadata / findCitationByUrl / getCitationText / getAllCitationFilesAll declared async even though the underlying Obsidian APIs are syncNow sync; two await keywords dropped at call sites in main.ts

Jina.ai response narrowing (commit 4)

The final any in the project was parseReaderResponse(data: any, ...). Now data: unknown, narrowed via isRecord (handling both the { data: {...} } wrapper and flat-at-root variants the API actually returns), with every leaf field through asString. Side fix: the existing try/catch around date parsing was inert (new Date(badInput) yields an Invalid Date object, not an exception); replaced with Number.isNaN(dateObj.getTime()). Also extracted the duplicated fallback-citation construction into a private fallbackCitation() helper.

ESLint flat-config (commit 5)

Closes the local-enforcement gap surfaced in the audit: the project had @typescript-eslint/{eslint-plugin,parser} installed but no config, meaning the rules that gate community-store submission only ran on ObsidianReviewBot, post-PR.

eslint.config.mjs:

  • Type-aware via parserOptions.project: './tsconfig.json'

  • Mirrors the four bot rules: no-explicit-any, no-unnecessary-type-assertion, no-floating-promises, no-base-to-string

  • reportUnusedDisableDirectives: 'error' — suppression-via-disable fails the build, per the bot's posture

  • Ignores main.js, node_modules, examples, *.mjs

package.json:

  • New pnpm lint script for ad-hoc runs

  • pnpm build now runs tsc -noEmit -skipLibCheck && eslint . && esbuild production, so lint fires before any artifact is produced

The new rule flagged exactly one floating promise (CitationModal.ts:105, this.convertCitationGroup(group) in a click handler); fixed via the void operator per the bot-acceptable pattern. Stale .eslintignore file removed (flat config uses inline ignores; ESLint 9 emits a deprecation warning for the file).

Verification

pnpm build exits clean: tsc -noEmit -skipLibCheck ✓, eslint . ✓, esbuild production ✓. grep -r ': any\|<any>\|as any' src/ main.ts returns zero hits.

The plugin was reloaded in a live Obsidian vault and exercised end-to-end: extract-from-URL (Jina path), convert-numeric-footnotes-to-hex, duplicate-citation detection (the path that exercises the rewritten findCitationByUrl + getCitationMetadata). All worked.

What Changed in Approach (the meta-lesson)

Previous modeThis mode
Reach for any when external data shape is unclearType as unknown, narrow with isRecord + small coercer functions
Use as any to bypass private/initialization-order issuesSolve initialization order honestly with a lazy getter
Hand-roll YAML parsing, quoting, and emissionUse app.metadataCache.getFileCache (read) and app.fileManager.processFrontMatter (read+write), pipe through coercers
Augment Obsidian's d.ts whenever a private API is neededQuestion whether the private API is needed at all (most usage in this repo had documented public equivalents); when augmentation is genuinely required, type as unknown not any
Treat lint as a thing to set up "later"Wire lint into pnpm build from the start so the rules that gate community-store submission fire locally pre-push

Generalizing beyond cite-wide: any in a TypeScript codebase is almost always solving the wrong problem. The right problem is "I don't know the shape of this value." The right tool is unknown + narrowing, not any + hope. The 14 sites the audit found mapped 1:1 onto five recurring patterns (untyped framework callback param, untyped logger payload, JSON response, dynamic dictionary access, and singleton init), each of which has a textbook-clean alternative.

Open Items

  • Unused dependencies still flagged. fastify, @modelcontextprotocol/sdk, and zod remain in package.json with zero imports. The type-safety pass didn't introduce uses for them. Decision pending: confirm and remove (saves bundle size and removes the zod 3 → 4 deferred-major question entirely), or wire them into a feature.

  • Major dependency bumps still held back as of 2026-05-01: typescript 5.8 → 6, eslint 9 → 10, zod 3 → 4, @types/node 22 → 25 (the last intentionally pinned). See 2026-05-01_01.md. The eslint bump is now extra-relevant since we have a real config that depends on it.

  • Metadata-cache freshness on newly-created files. getCitationMetadata reads from app.metadataCache.getFileCache, which is populated asynchronously via the metadataCache:changed event. Right after creating a citation file, the cache may not yet reflect it, so a duplicate-detection check fired in the same tick could miss. Not observed during smoke testing, but worth flagging for any future rapid-fire workflow.

  • styles.css working-tree change from before this work began. Previously surfaced as a minified-but-equivalent version of the committed file. Untouched by this pass; resolution deferred until upcoming style work.

  • Resubmission to the Obsidian community store is unblocked by this pass, but not yet scheduled. The next concrete step would be running the actual ObsidianReviewBot checks against the current branch (it can be invoked via the obsidianmd/obsidian-releases PR flow) before opening a real submission, to catch any rules our local config doesn't yet mirror.

  • The bumped semantic version (0.0.1.1 → 0.0.1.2) is recorded in package.json only; manifest.json and versions.json were not resynced via pnpm version. If a release is intended, run that script before tagging.

Files Touched

cite-wide/
├── package.json                                     (modified: lint scripts; version 0.0.1.1 → 0.0.1.2)
├── eslint.config.mjs                                (created: flat-config mirroring bot rules)
├── .eslintignore                                    (deleted: superseded by flat-config inline ignores)
├── main.ts                                          (Modal constructor types; 2 redundant awaits dropped)
├── src/
│   ├── utils/
│   │   ├── coerce.ts                                (created: 5 boundary coercers)
│   │   └── logger.ts                                (any→unknown ×6 sites)
│   ├── services/
│   │   ├── citationFileService.ts                   (rewritten — singleton, YAML, folder walk; 218+ / 504−)
│   │   ├── urlCitationService.ts                    (Jina parse narrowed to unknown + isRecord + coercers)
│   │   └── cleanReferencesSectionService.ts         (deleted dead processSelection())
│   ├── modals/
│   │   └── CitationModal.ts                         (one floating promise → void operator)
│   └── types/
│       └── obsidian.d.ts                            (deleted entirely — partially redundant, partially lying)
└── changelog/                                       (originally created at context-v/changelogs/; relocated to repo-root changelog/ on 2026-05-17)
    └── 2026-05-01_02.md                             (created — this file)

Reference

  • Strategy doc this implements: context-v/reminders/Obsidian-Type-Safety.md

  • Audit reports that informed the work: captured in conversation; 14 any sites across 6 files, plus the hand-rolled-YAML and shim findings

  • Prior changelog (deps refresh): changelog/2026-05-01_01.md (file relocated 2026-05-17 from context-v/changelogs/ to the repo-standard changelog/ location)

  • ObsidianReviewBot rule sources: https://typescript-eslint.io/rules/no-explicit-any/, plus example PRs at https://github.com/obsidianmd/obsidian-releases/pull/8131, …/9166, …/10160, …/10723

  • Obsidian APIs adopted: App.metadataCache.getFileCache(file).frontmatter and App.fileManager.processFrontMatter(file, fn), both documented in obsidian.d.ts (lines 4272 and 2830 in obsidian@1.12.3)