diff --git a/CLAUDE.md b/CLAUDE.md index 466c682..5a5eefd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ Two things make `src/` runnable under Vitest even though it targets the Obsidian - Entry: [src/main.ts](src/main.ts) → bundled to `./main.js` at repo root (the file Obsidian loads). Kept minimal: settings load, ribbon icon, two commands, settings tab, plus a single `activeQuickRecord` ref so the Quick Record command can toggle. - Bundler: [esbuild.config.mjs](esbuild.config.mjs). `obsidian`, `electron`, all `@codemirror/*`, all `@lezer/*`, and Node built-ins are marked `external`. Never import other runtime deps without bundling them in. - Release artifacts are `main.js`, `manifest.json`, and `styles.css` at the repo root. Do not commit the generated `main.js`. -- TypeScript config ([tsconfig.json](tsconfig.json)) is strict: `noImplicitAny`, `strictNullChecks`, `noImplicitReturns`, `noUncheckedIndexedAccess`, `useUnknownInCatchVariables`, `baseUrl: "src"`. Target ES6, module ESNext, lib DOM + ES5/6/7 only. No Node lib, so don't reach for Node APIs in plugin code. +- TypeScript config ([tsconfig.json](tsconfig.json)) is strict: `noImplicitAny`, `strictNullChecks`, `noImplicitReturns`, `noUncheckedIndexedAccess`, `useUnknownInCatchVariables`, `baseUrl: "src"`. Target ES6, module ESNext, lib DOM + ES2019, `types: []`, `skipLibCheck`. `lib` must stay matched to the runtime APIs the code actually uses (ES2019 covers `Object.entries`/`values`/`fromEntries`, `padStart`, `Promise.finally`; raise it deliberately if you adopt a newer API — Obsidian's WebViews support far newer). `types: []` is load-bearing: without it tsc auto-includes the test suite's `@types/node`, which silently supplied the ES2017+ types the old ES7 lib lacked, and the mismatch surfaced only as `no-unsafe-*` warnings from the Obsidian review bot (see the "Review-bot type environment" gotcha). No Node lib or types, so don't reach for Node APIs in plugin code. - ESLint ([eslint.config.mts](eslint.config.mts)) layers `eslint-plugin-obsidianmd`'s recommended rules on top of `typescript-eslint`. These rules encode Obsidian-specific correctness checks; respect them rather than disabling. The config is deliberately tuned to **mirror the Obsidian community-review bot** for the classes that once slipped through to submission (see [docs/RELEASING.md](docs/RELEASING.md)): it enables the type-checked `@typescript-eslint` rules (`no-deprecated`, `no-unsafe-*`, `no-unnecessary-type-assertion`) scoped to `src/`; cherry-picks `no-unsupported-api` from a `0.4.1` alias of the plugin (`obsidianmd-latest`, since the pinned `0.1.9` base predates that rule) so an Obsidian API newer than `manifest.json`'s `minAppVersion` fails lint locally; and sets `noInlineConfig` so an `eslint-disable` comment can never silence a rule (the bot forbids disables). The base plugin stays pinned at `0.1.9` because `0.4.x`'s recommended `ui/sentence-case` is over-aggressive and diverges from what the bot enforces; only the single `no-unsupported-api` rule is taken from the alias. To reach an API outside the typed/deprecated surface, use a local structural type-alias (e.g. `ScriptProcessorNodeLike` in [src/realtime/pcm.ts](src/realtime/pcm.ts)), never a disable comment. ## Source layout @@ -97,6 +97,7 @@ src/ ├── audio-transcode.ts # WebAudio decode + resample to 16 kHz mono PCM WAV (shared by whisper-local and mistral-voxtral) ├── pipeline.ts # transcribe → cleanup → insert orchestrator ├── insert.ts # cursor/newFile/append + {{date}}/{{time}} expansion +├── time.ts # formatMoment: Obsidian's bundled moment behind a narrow structural alias (moment's types don't resolve in the review bot's environment; never call moment directly) ├── ingest.ts # Auto-ingest batch runner: scan rule folders, pipeline each audio file, move on success ├── whisper-host.ts # Spawns/stops a user-supplied whisper-server child process (desktop only; Phase B auto-start + idle-stop) ├── templates-folder.ts # Load templates from a vault folder + populate it with the 10 defaults @@ -148,7 +149,7 @@ src/ 1. **Persist audio** (`audio` source only): writes the raw `Blob` to the vault via [src/audio-persist.ts](src/audio-persist.ts) before transcription, so the user keeps the recording even if later stages fail. Path resolution: when `settings.attachmentsFolderPath` is set, the file goes under that folder with manual de-collision (`-1`, `-2`, ...); when empty, the path comes from `app.fileManager.getAvailablePathForAttachment(filename)`, which respects Obsidian's own attachments setting. Filename is `ReWrite-YYYY-MM-DD-HHmmss.` with the extension derived from the blob's mime type (`webm` / `m4a` / `ogg` / `wav` / `mp3`, default `webm`). Failure is non-fatal: a Notice fires and transcription proceeds. The resolved path is later prepended to the cleaned output as `![[]]\n\n` before insertion. 2. **Transcribe**: `audio` → `createTranscriptionProvider(profile.transcriptionProvider).transcribe(blob, config)`. Skipped when the source is `paste` or `text` (input passes through). Just before dispatching, `validateRecording(blobSize, durationMs, providerId)` from [src/transcription/limits.ts](src/transcription/limits.ts) throws a friendly per-provider error if the recording exceeds the provider's documented byte or duration cap. Because validation runs after `persist-audio`, the user keeps the saved file and can switch providers + reprocess from the vault. 3. **Cleanup**: `createLLMProvider(profile.llmProvider).complete(systemPrompt, transcript, config)`. The system prompt is the template prompt, optionally augmented with an `## Ad-hoc instructions` block when the wake-name scan ([src/wake-name.ts](src/wake-name.ts)) extracts directives from the transcript, a `## Context` block when `params.contextHint` is non-empty (see Context hint below), a `## Known nouns` block when `plugin.knownNouns` is non-empty (see Assistant prompt and Known nouns sections below), and a `## Note properties` block when the template declares `noteProperties` (see Note properties below). `cleanupTranscript` returns `{ body, properties, title? }`: when the template declares properties OR sets `titleFromContent`, the LLM is asked to emit a leading ```` ```yaml ```` block which `extractFromBlock` parses off the front (the rest is the body); otherwise `properties` is `{}`, `title` is undefined, and `body` is the full output. On error, the provider error propagates unchanged; nothing is written to the clipboard (the earlier clipboard fallback was removed as a sensitive-content exposure, and for `audio` sources the persisted recording is the recovery path). -4. **Insert**: `src/insert.ts` routes to `cursor` / `newFile` / `append` per the template. `cursor` falls back to `append` when no editor is active; `append` falls back to `newFile` when no markdown file exists. `{{date}}` / `{{time}}` in filename templates expand via Obsidian's `moment`, and `{{title}}` expands to the LLM-generated title (empty when no title). The modal's per-invocation Destination control overrides `insertMode` / `newFileFolder` / `newFileNameTemplate` via `PipelineParams.destinationOverride`; the override is shallow-merged onto a copy of the template before the insert call, so the template file on disk is never mutated. Extracted `properties` are threaded via `InsertParams.properties` and written into the note's frontmatter via `app.fileManager.processFrontMatter` in `newFile` mode only (see Note properties below). An extracted `title` (from `titleFromContent`) is threaded via `InsertParams.title` and shapes the `newFile` filename via the `{{title}}` token / whole-name replacement (see Note title below). +4. **Insert**: `src/insert.ts` routes to `cursor` / `newFile` / `append` per the template. `cursor` falls back to `append` when no editor is active; `append` falls back to `newFile` when no markdown file exists. `{{date}}` / `{{time}}` in filename templates expand via Obsidian's `moment` (reached through `formatMoment` in [src/time.ts](src/time.ts)), and `{{title}}` expands to the LLM-generated title (empty when no title). The modal's per-invocation Destination control overrides `insertMode` / `newFileFolder` / `newFileNameTemplate` via `PipelineParams.destinationOverride`; the override is shallow-merged onto a copy of the template before the insert call, so the template file on disk is never mutated. Extracted `properties` are threaded via `InsertParams.properties` and written into the note's frontmatter via `app.fileManager.processFrontMatter` in `newFile` mode only (see Note properties below). An extracted `title` (from `titleFromContent`) is threaded via `InsertParams.title` and shapes the `newFile` filename via the `{{title}}` token / whole-name replacement (see Note title below). The pipeline accepts an `AbortSignal` (forwarded to providers) and is consumed by [src/ui/modal.ts](src/ui/modal.ts), [src/ui/quick-record.ts](src/ui/quick-record.ts), and [src/ui/text-source.ts](src/ui/text-source.ts) (the `runTextPipeline` helper for command + editor-menu entry points). Every caller passes the plugin itself as `host: PipelineHost`; `PipelineHost` is a narrow interface ({ `assistantPrompt`, `knownNouns` }) so `cleanupTranscript` can read the loaded vault content without importing `ReWritePlugin` (which would form a circular dep through the UI layer). @@ -348,10 +349,18 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma - **Keep [src/main.ts](src/main.ts) minimal**: only plugin lifecycle, command registration, settings tab registration. Feature logic belongs in dedicated modules. - **Defer heavy work**: no long tasks in `onload`. Providers/recorders lazy-init when first used. - **Network policy**: provider calls go to user-configured endpoints with user-provided keys. No telemetry, no auto-update of plugin code, no `fetch`+`eval`. -- **Releases**: GitHub release tag must exactly match `manifest.json`'s `version` (no leading `v`). Attach `main.js`, `manifest.json`, `styles.css` as individual binary assets (not zipped). This is automated by [.github/workflows/release.yml](.github/workflows/release.yml): pushing a version tag builds the bundle, runs `actions/attest-build-provenance` (GitHub artifact attestations for provenance), and publishes the assets via `softprops/action-gh-release`. To cut a release, push a tag named exactly the version; do not hand-upload assets (that loses attestation). **Before publishing any release, follow [docs/RELEASING.md](docs/RELEASING.md)** for the full step-by-step (version bump, tag, push, verify) and the guideline-conflict checklist that keeps the Obsidian community review green. +- **Releases**: GitHub release tag must exactly match `manifest.json`'s `version` (no leading `v`). Attach `main.js`, `manifest.json`, `styles.css` as individual binary assets (not zipped). This is automated by [.github/workflows/release.yml](.github/workflows/release.yml): pushing a version tag builds the bundle, runs `actions/attest-build-provenance` (GitHub artifact attestations for provenance), and publishes the assets via `softprops/action-gh-release`. To cut a release, push a tag named exactly the version; do not hand-upload assets (that loses attestation). A tag carrying a pre-release suffix (any `-`, e.g. `1.3.0-alpha`/`1.3.0-beta`) is instead published as a GitHub **prerelease** for manual/BRAT smoke-testing: the workflow stamps the tag's version into the *published* `manifest.json` only (master's copy is untouched, so the official updater and community review never see it). See the "Pre-release (alpha/beta) builds" section of RELEASING.md. **Before publishing any release, follow [docs/RELEASING.md](docs/RELEASING.md)** for the full step-by-step (version bump, tag, push, verify) and the guideline-conflict checklist that keeps the Obsidian community review green. ## Gotchas +### Review-bot type environment (lint parity) + +The Obsidian community-review bot runs the same typed ESLint rules our config mirrors, but in a **different type environment**: it uses this repo's `tsconfig.json` while lacking parts of our `node_modules` (`@types/node`, `moment`'s typings) and possibly running an older TypeScript. Anything error-typed there trips `no-unsafe-*` at every use, even though local lint is clean — all ~30 of the 1.2.1 review warnings were this (see [docs/DEVCONFLICTS.md](docs/DEVCONFLICTS.md) finding 10 and the parity section of [docs/RELEASING.md](docs/RELEASING.md)). Three standing invariants: + +- **Keep `tsconfig.json`'s `lib` matched to the APIs the code uses; `types: []` enforces it.** With `types: []`, an API newer than the declared lib fails `npm run build` locally instead of being silently typed by the test suite's auto-included `@types/node` (which is exactly how ES2017+ usage slipped past an ES2016 lib before 1.2.1). +- **Never call `moment` (re-exported by `obsidian`) directly; go through `formatMoment` in [src/time.ts](src/time.ts).** `obsidian` itself types fine in the bot's environment, but its `moment` export's type comes from the `moment` package, which does not resolve there, so direct calls are error-typed. The wrapper reaches it through a narrow structural alias (same pattern as `ScriptProcessorNodeLike`). +- **Avoid TS-version-dependent type assertions.** On TS 5.7+ a value declared `Uint8Array` is `ArrayBufferLike`-backed and needs `as BufferSource` for WebCrypto params; on older TS that same assertion trips `no-unnecessary-type-assertion`. Write code that needs no assertion on any version: let byte-helper return types be inferred (a `new Uint8Array(...)` infers `ArrayBuffer`-backed on 5.7+), type params that only feed WebCrypto as `BufferSource`, and copy a foreign library's bytes (`new Uint8Array(raw)`) rather than asserting ([src/secrets.ts](src/secrets.ts) does all three). Never write the generic `Uint8Array` syntax — it is a hard error before TS 5.7. Prefer a type-predicate narrow (`isRecord`) over an `as Record` assertion where either would do. + ### HTTP and provider requests - **`requestUrl` multipart bodies are hand-built.** `requestUrl` does not accept `FormData`. [src/http.ts](src/http.ts) exports `buildMultipart(parts)` which produces a `Uint8Array` with a random boundary; transcription adapters (Whisper, Rev.ai) call into it. If you add a multipart-LLM provider, reuse this rather than reaching for `FormData`. diff --git a/docs/DEVCONFLICTS.md b/docs/DEVCONFLICTS.md index f265ed8..38aea01 100644 --- a/docs/DEVCONFLICTS.md +++ b/docs/DEVCONFLICTS.md @@ -89,6 +89,15 @@ Guideline: "Use `editorCallback` or `editorCheckCallback` for commands requiring ### 9. `app.vault.getFiles()` full-vault scan [src/ui/audio-source.ts:18](../src/ui/audio-source.ts#L18) iterates all vault files to filter audio by extension. The guideline's "don't iterate all files" advice is specifically about *finding a file by path* (use `getFileByPath`), which this is not — there is no extension index in the API, so a scan is reasonable. Listed only for completeness; not a real conflict. +### 10. 1.2.1 automated-review `no-unsafe-*` warnings (type-environment mismatch) +The 1.2.1 submission drew ~30 warnings (`no-unsafe-assignment/call/member-access/argument/return`, `no-unnecessary-type-assertion`) that local `npm run lint` did not reproduce even after the rule mirror ([eslint.config.mts](../eslint.config.mts)) was in place. Investigation showed none of the flagged code was genuinely unsafe; every warning came from the bot's **type environment** differing from local. The bot's typed lint uses the repo's own `tsconfig.json` but not the repo's full `node_modules`, so a value that types cleanly locally can be error-typed there (and an error-typed value trips `no-unsafe-*` at every use). Three sub-causes: + +1. **Declared `lib` was lower than the APIs the code uses.** `tsconfig.json` declared `lib` ES2016 while the source uses `Object.entries`/`values`/`fromEntries`, `String.padStart`, and `Promise.finally` (ES2017–ES2019). Locally, tsc auto-included the test suite's `@types/node`, which silently supplied those types; in the bot's environment they were error-typed (~20 of the warnings). +2. **`moment`'s types don't resolve in the bot's environment.** `obsidian` itself types fine there, but its `moment` re-export's type comes from the `moment` package (a transitive dependency), which the bot doesn't have — every direct `moment(...)` call was error-typed (5 warnings across [src/audio-persist.ts](../src/audio-persist.ts), [src/insert.ts](../src/insert.ts), [src/template-guide.ts](../src/template-guide.ts)). +3. **TS-version-dependent assertions.** On TS 5.7+ (local, 5.8) a value declared `Uint8Array` is `ArrayBufferLike`-backed and needs `as BufferSource` to satisfy WebCrypto; on the bot's older TS the same assertion is "unnecessary" ([src/secrets.ts](../src/secrets.ts)). + +> **Resolution: Fixed (all three).** `tsconfig.json` now declares `lib: ["DOM", "ES2019"]` with `types: []` (so the `@types/node` masking can never recur — a lib/API mismatch now fails `npm run build` locally) and `skipLibCheck`. All `moment` calls go through `formatMoment` in [src/time.ts](../src/time.ts), a narrow structural alias over Obsidian's bundled moment (same pattern as `ScriptProcessorNodeLike`). The secrets byte helpers were restructured to need no `BufferSource` assertion under any TS version (inferred `ArrayBuffer`-backed return types, a `BufferSource` param, one 32-byte defensive copy of the hash-wasm output), and the two `as Record` narrows the bot flagged were replaced with an `isRecord` type predicate. + --- ## Checked and clean (no conflict found) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 059e860..4458672 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -30,7 +30,7 @@ The tag name must equal `manifest.json`'s `version` exactly. `.npmrc` already pi - **No `v` in the tag.** The release tag must match `manifest.json` `version` character-for-character: `1.0.1`, never `v1.0.1`. - **Three loose asset files.** `main.js`, `manifest.json`, `styles.css` attached as individual binary assets, never zipped. The workflow does this; do not hand-upload. - **A new release needs a new version number.** Obsidian's automated review only registers a change when the version increments. Re-pushing the same version does not count as a new submission. Bump the patch/minor/major rather than overwriting a published version. (To address review feedback, update the repo and publish a new GitHub release with an incremented version.) -- **Version format is `x.y.z` only.** Semantic Versioning, no pre-release suffixes (no `1.0.0-beta`, no build metadata). The initial release is `1.0.0`. +- **Stable version format is `x.y.z` only.** Semantic Versioning, no build metadata. The initial release is `1.0.0`. Pre-release suffixes (`1.3.0-alpha`, `1.3.0-beta.1`) are allowed **only** for the alpha/beta channel below, which is deliberately invisible to the official review/updater; a stable tag never carries a suffix. - **The directory reads `manifest.json` at the HEAD of your default branch** (`master`), not just the release asset. Keep master's `manifest.json` correct and in sync with the released version. - **`minAppVersion` must be >= the highest `@since` of every Obsidian API you call directly** (anything not behind a runtime feature-detect). Check `node_modules/obsidian/obsidian.d.ts` for the `@since` of new APIs. Example from this project: `FileManager.trashFile` is `@since 1.6.6`, which is why `minAppVersion` is `1.6.6` (raised from 1.4.4, which `FileManager.processFrontMatter` `@since 1.4.4` had driven). The `obsidianmd/no-unsupported-api` lint rule flags a direct call newer than the declared floor. Feature-detected APIs (like `app.secretStorage`) do not raise the floor. - **`versions.json` maps plugin version -> minAppVersion.** Our [version-bump.mjs](../version-bump.mjs) only adds a new line when the `minAppVersion` value is not already present (i.e. when the floor actually changes). That is valid: Obsidian reads the latest version straight from the release `manifest.json`, and consults `versions.json` only to find the newest plugin version compatible with an older app. If you raise `minAppVersion`, confirm a new `versions.json` entry was written; if you keep it, no new line is expected. @@ -50,6 +50,8 @@ These are the recurring findings; clear them before tagging. Most are also why t **Local lint now mirrors the bot for the classes that previously slipped through** (the 1.2.0 submission failed on three the local lint did not catch). [eslint.config.mts](../eslint.config.mts) enables: the type-checked `@typescript-eslint` rules (`no-deprecated`, the `no-unsafe-*` family, `no-unnecessary-type-assertion`); `no-unsupported-api` (cherry-picked from a `0.4.1` alias of `eslint-plugin-obsidianmd` since our pinned `0.1.9` base lacks it) so a direct Obsidian API newer than `minAppVersion` is caught locally; and `noInlineConfig` so an `eslint-disable` can never silence a rule. So `npm run lint` failing on these now is the point. When the review bot adds a new rule class we do not catch, add it here the same way (prefer cherry-picking one rule from the alias over adopting `0.4.x`'s recommended config wholesale, whose `ui/sentence-case` is over-aggressive and diverges from the bot). +**The bot's TYPE environment also differs from local, and mirroring the rules alone does not cover that** (the 1.2.1 submission drew ~30 `no-unsafe-*` warnings local lint could not reproduce; see [DEVCONFLICTS.md](DEVCONFLICTS.md) finding 10). The bot lints with the repo's own `tsconfig.json` but not the repo's full `node_modules`: `@types/node` and `moment`'s typings are absent there, and its TypeScript version may lag ours. Anything that is error-typed in that environment trips `no-unsafe-*` at every use. The standing guards: `tsconfig.json` declares `lib` matched to the APIs the code actually uses plus `types: []`, so an API newer than the declared lib fails `npm run build` locally instead of being silently typed by the test suite's `@types/node`; `moment` is only reached through `formatMoment` in [src/time.ts](../src/time.ts) (never called directly from `obsidian`'s re-export); and type assertions whose necessity depends on the TS version (`as BufferSource`) are avoided in favor of code that needs no assertion on any version. If the bot ever flags a `no-unsafe-*` or `no-unnecessary-type-assertion` warning that local lint does not show, suspect a type-resolution difference first: check what supplies the type locally (`npx tsc -noEmit --explainFiles`), rather than assuming the warning is spurious. + - **Plugin `id`**: lowercase letters and hyphens only, must not end in `plugin`, must not contain `obsidian`. Locked once published; do not change it. (Ours is `rewrite-voice-notes`.) - **No newer-than-minAppVersion APIs**: see the `minAppVersion` rule above. Enforced locally by `obsidianmd-latest/no-unsupported-api`. - **No `eslint-disable` directives.** The bot rejects disabling its rules, and `noInlineConfig` makes them inert locally (an attempt to suppress an error just leaves the error). Reach APIs outside the typed/deprecated surface through local type-aliases instead (see [src/realtime/pcm.ts](../src/realtime/pcm.ts)'s `ScriptProcessorNodeLike`). If a string trips `ui/sentence-case` (e.g. a random example), pass it through a variable instead of a string literal; the rule only inspects literals. @@ -91,6 +93,27 @@ gh attestation verify /tmp/rel/main.js --repo WiseGuru/ReWrite-Voice-Notes # mus Also confirm the release page shows the bare tag (`1.0.1`, no `v`) and all three assets. +## Pre-release (alpha/beta) builds + +For a manual smoke test through Obsidian before committing to a stable release, cut a pre-release. It builds and publishes the same three attested assets as a normal release, but as a GitHub **prerelease**, so it is invisible to the official channel: GitHub's "latest release" (what Obsidian's in-app updater reads) excludes prereleases, and the community submission review reads `manifest.json` at **master's HEAD**, which this flow never touches. + +Convention: pre-release the version you are *about to* ship. If the next release is `1.3.0`, tag `1.3.0-alpha` (then `-beta`, `-rc`, etc.) while master's `manifest.json` still reads the last stable version (`1.2.1`). Because `1.3.0-alpha` < `1.3.0` in SemVer, a BRAT beta tester is auto-upgraded when the real `1.3.0` lands. + +Any tag containing a `-` is treated as a pre-release by [release.yml](../.github/workflows/release.yml): it stamps the tag's version into the **published** `manifest.json` at build time (master's copy is left alone) and sets `prerelease: true` on the GitHub release. + +```bash +# On master, clean tree, everything you want to test committed. +# Do NOT bump manifest.json / package.json / versions.json and do NOT commit a bump. +npm run build && npm run lint # must both pass +git tag -a 1.3.0-alpha -m "Pre-release 1.3.0-alpha" +git push origin 1.3.0-alpha +# CI publishes a prerelease with a manifest.json version of 1.3.0-alpha. +``` + +Iterate by pushing further suffixed tags (`1.3.0-alpha.2`, `1.3.0-beta`, ...). To install for review, either download the three assets from the prerelease page and copy them into `/.obsidian/plugins/rewrite-voice-notes/`, or add the repo in **BRAT** with "beta versions" enabled (BRAT reads the newest prerelease). When satisfied, cut the stable `1.3.0` the normal way (TL;DR above): the real version bump + bare tag. Pre-release tags and their GitHub releases can be left in place (they stay marked prerelease) or deleted; they never affect the stable channel. + +Note: `versions.json` is not updated for pre-releases (no bump runs), which is correct. Never point a stable tag at a suffixed name, and never commit a `-alpha`/`-beta` version into master's `manifest.json` (it would corrupt what the directory reads at HEAD). + ## Re-releasing the same version (rare) Only for fixing a botched release that nobody has consumed, and never once the version is accepted/depended on. Move the tag to the new commit and force-push to re-trigger CI: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5a7c5fc..a1937a4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -32,6 +32,10 @@ The auto-start and idle-stop lifecycle toggles shipped (see Unreleased). The rem Merged to `master`, not yet tagged. These become the next version's release notes. +### Fix: resolve the 1.2.1 automated-review `no-unsafe-*` warnings at the root (type-environment parity) + +All ~30 warnings the Obsidian review bot raised on 1.2.1 traced to its lint environment resolving types differently from local, not to genuinely unsafe code (full analysis: [DEVCONFLICTS.md](DEVCONFLICTS.md) finding 10). Fixed at the root so the class cannot recur: `tsconfig.json` now declares `lib: ["DOM", "ES2019"]` (matching the ES2017–ES2019 APIs the code actually uses) with `types: []` so the test suite's `@types/node` can no longer mask a lib/API mismatch locally; all `moment` calls go through the new `formatMoment` structural wrapper in `src/time.ts` (moment's typings don't resolve in the bot's environment); `src/secrets.ts` was restructured to need no `as BufferSource` assertions under any TypeScript version; and two `as Record` narrows became an `isRecord` type predicate. Runtime behavior is unchanged. Guardrails documented in CLAUDE.md's "Review-bot type environment" gotcha and RELEASING.md's parity section. + --- ## Released diff --git a/src/audio-persist.ts b/src/audio-persist.ts index 5086302..b69d6cb 100644 --- a/src/audio-persist.ts +++ b/src/audio-persist.ts @@ -1,4 +1,5 @@ -import { App, moment, normalizePath } from 'obsidian'; +import { App, normalizePath } from 'obsidian'; +import { formatMoment } from 'time'; import { GlobalSettings } from './types'; interface AttachmentFileManager { @@ -50,7 +51,7 @@ export function mimeToExtension(mime: string): string { } export function buildAudioFilename(now: Date, ext: string): string { - const stamp = moment(now).format('YYYY-MM-DD-HHmmss'); + const stamp = formatMoment('YYYY-MM-DD-HHmmss', now); return `ReWrite-${stamp}.${ext}`; } diff --git a/src/insert.ts b/src/insert.ts index 9f43086..fb7a5d8 100644 --- a/src/insert.ts +++ b/src/insert.ts @@ -1,4 +1,5 @@ -import { App, MarkdownView, Modal, moment, normalizePath, Notice, Setting, TFile } from 'obsidian'; +import { App, MarkdownView, Modal, normalizePath, Notice, Setting, TFile } from 'obsidian'; +import { formatMoment } from 'time'; import { NewFileCollisionMode, NoteTemplate } from './types'; import { guardReservedName, sanitizeFilename } from './templates-folder'; @@ -207,11 +208,11 @@ class RenamePromptModal extends Modal { } function expandFilenameTemplate(template: string, title?: string): string { - const now = moment(); + const now = new Date(); const safeTitle = title ? titleToFilename(title) : ''; return template - .replace(/\{\{date\}\}/g, now.format('YYYY-MM-DD')) - .replace(/\{\{time\}\}/g, now.format('HHmmss')) + .replace(/\{\{date\}\}/g, formatMoment('YYYY-MM-DD', now)) + .replace(/\{\{time\}\}/g, formatMoment('HHmmss', now)) .replace(/\{\{title\}\}/g, safeTitle); } diff --git a/src/pipeline.ts b/src/pipeline.ts index ac531b1..5a0a704 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -234,9 +234,9 @@ export function extractFromBlock( let usedStrict = false; try { const parsed: unknown = parseYaml(block); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + if (isRecord(parsed)) { usedStrict = true; - for (const [key, value] of Object.entries(parsed as Record)) { + for (const [key, value] of Object.entries(parsed)) { // Values are scalars. Take strings as-is, coerce numbers/booleans, // and ignore null/undefined or nested objects/arrays. let coerced: string | null = null; @@ -280,6 +280,10 @@ export function extractFromBlock( return { body, properties, title: title || undefined }; } +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + function stripQuotes(value: string): string { let v = value.trim(); // Peel matched leading/trailing quote pairs. diff --git a/src/secrets.ts b/src/secrets.ts index ab7ecea..444b736 100644 --- a/src/secrets.ts +++ b/src/secrets.ts @@ -404,14 +404,19 @@ function bytesToBase64(bytes: Uint8Array): string { return btoa(s); } -function base64ToBytes(b64: string): Uint8Array { +// Return types are deliberately inferred (not annotated `Uint8Array`): on TS 5.7+ +// the inferred type is the ArrayBuffer-backed Uint8Array that WebCrypto's +// BufferSource params accept directly, while an explicit `Uint8Array` annotation +// widens to an ArrayBufferLike backing and forces `as BufferSource` assertions +// that older TS (the community-review bot's) then flags as unnecessary. +function base64ToBytes(b64: string) { const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes; } -function randomBytes(n: number): Uint8Array { +function randomBytes(n: number) { const out = new Uint8Array(n); crypto.getRandomValues(out); return out; @@ -428,7 +433,7 @@ function isAllocationFailure(e: unknown): boolean { // ---------- key derivation ---------- -async function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array, iterations: number): Promise { +async function deriveKeyFromPassphrase(passphrase: string, salt: BufferSource, iterations: number): Promise { const passBytes = new TextEncoder().encode(passphrase); const baseKey = await crypto.subtle.importKey( 'raw', @@ -438,7 +443,7 @@ async function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array, ite ['deriveKey'], ); return crypto.subtle.deriveKey( - { name: 'PBKDF2', hash: 'SHA-256', salt: salt as BufferSource, iterations }, + { name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, baseKey, { name: 'AES-GCM', length: 256 }, false, @@ -464,7 +469,10 @@ async function deriveArgon2idKey( }); return crypto.subtle.importKey( 'raw', - raw as BufferSource, + // hash-wasm types the binary output as a bare Uint8Array (ArrayBufferLike + // backing on TS 5.7+); copying into a fresh array satisfies BufferSource on + // every TS version without an assertion. 32 bytes, once per derivation. + new Uint8Array(raw), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'], @@ -516,7 +524,7 @@ async function buildPassphraseKdfAndKey(passphrase: string): Promise<{ kdf: Pass async function aesGcmEncrypt(key: CryptoKey, plaintext: string): Promise { const iv = randomBytes(AES_IV_BYTES); const ct = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv: iv as BufferSource }, + { name: 'AES-GCM', iv }, key, new TextEncoder().encode(plaintext), ); @@ -529,9 +537,9 @@ async function aesGcmDecrypt(key: CryptoKey, payload: string): Promise { const iv = base64ToBytes(payload.slice(0, sepIdx)); const ct = base64ToBytes(payload.slice(sepIdx + 1)); const pt = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv: iv as BufferSource }, + { name: 'AES-GCM', iv }, key, - ct as BufferSource, + ct, ); return new TextDecoder().decode(pt); } diff --git a/src/template-guide.ts b/src/template-guide.ts index eb93f8f..8140c9d 100644 --- a/src/template-guide.ts +++ b/src/template-guide.ts @@ -1,4 +1,5 @@ -import { App, moment, normalizePath, TFile } from 'obsidian'; +import { App, normalizePath, TFile } from 'obsidian'; +import { formatMoment } from 'time'; import type { TemplateUpdateConflict, TemplateUpdateEntry, UpdateResult } from './templates-folder'; // The Update button's worklist. Lives next to the templates folder (in its @@ -120,7 +121,7 @@ function renderUpdateReport(result: UpdateResult): string { lines.push('# ReWrite: template update report'); lines.push(''); const failNote = result.parseFailed ? `, ${result.parseFailed} could not be parsed` : ''; - lines.push(`Generated ${moment().format('YYYY-MM-DD HH:mm')}. ${result.updated} updated, ${result.conflicts} need manual review, ${result.created} created, ${result.unchanged} unchanged${failNote}.`); + lines.push(`Generated ${formatMoment('YYYY-MM-DD HH:mm')}. ${result.updated} updated, ${result.conflicts} need manual review, ${result.created} created, ${result.unchanged} unchanged${failNote}.`); lines.push(''); const actionable = result.entries.filter((e) => e.status !== 'unchanged'); diff --git a/src/templates-folder.ts b/src/templates-folder.ts index 669eee0..58670aa 100644 --- a/src/templates-folder.ts +++ b/src/templates-folder.ts @@ -421,6 +421,10 @@ async function parseTemplateFile(app: App, file: TFile): Promise { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + // Split out from parseTemplateFile so callers that already hold the file content (the update // walk previously read each candidate file up to three times: once for the id, once for the // full parse, once more to diff against the rendered output) can parse without a redundant read. @@ -482,8 +486,8 @@ export function parseTemplateContent(file: TFile, content: string): NoteTemplate // blank keys are skipped; a missing/non-string instruction becomes "". const noteProperties: NotePropertySpec[] = []; const rawProps = obj.noteProperties; - if (rawProps && typeof rawProps === 'object' && !Array.isArray(rawProps)) { - for (const [name, instruction] of Object.entries(rawProps as Record)) { + if (isRecord(rawProps)) { + for (const [name, instruction] of Object.entries(rawProps)) { const key = name.trim(); if (!key) continue; noteProperties.push({ diff --git a/src/time.ts b/src/time.ts new file mode 100644 index 0000000..a2adcb8 --- /dev/null +++ b/src/time.ts @@ -0,0 +1,19 @@ +import { moment } from 'obsidian'; + +// Obsidian re-exports its bundled moment, but the export's TYPE comes from the +// `moment` package (a transitive dependency of `obsidian`), which the community +// review bot's lint environment does not resolve: `obsidian` itself types fine +// there, `moment` does not, so every direct `moment(...)` call is error-typed and +// trips the bot's no-unsafe-* rules. All timestamp formatting therefore goes +// through this narrow structural alias (the same pattern as ScriptProcessorNodeLike +// and WakeLockLike), which needs nothing from moment's own typings. Runtime +// behavior is unchanged: it is still Obsidian's bundled moment doing the work. +interface MomentLike { + format(fmt: string): string; +} + +type MomentFactory = (input?: Date) => MomentLike; + +export function formatMoment(fmt: string, input?: Date): string { + return (moment as unknown as MomentFactory)(input).format(fmt); +} diff --git a/tsconfig.json b/tsconfig.json index 222535d..d09dd90 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,11 +17,11 @@ "strictBindCallApply": true, "allowSyntheticDefaultImports": true, "useUnknownInCatchVariables": true, + "types": [], + "skipLibCheck": true, "lib": [ "DOM", - "ES5", - "ES6", - "ES7" + "ES2019" ] }, "include": [