diff --git a/specs/agent-sidepanel-v3/wp-17-plugin-core-split/brief.md b/specs/agent-sidepanel-v3/wp-17-plugin-core-split/brief.md new file mode 100644 index 00000000..f1dc75be --- /dev/null +++ b/specs/agent-sidepanel-v3/wp-17-plugin-core-split/brief.md @@ -0,0 +1,136 @@ +# WP-17 — `src/core/plugin-core.ts` split (module registration / lifecycle / MCP wiring) + +**Branch:** `claude/asv3-wp17-plugin-core-split` (cut from `origin/develop`) +**Lane:** Cleanup (orthogonal to v2 work) +**Estimated size:** medium (~400–550 LOC moved across new files; behaviour-preserving refactor; existing tests largely point at the same surface) + +## Goal (one sentence) + +Split the 476-LOC `src/core/plugin-core.ts` (audit recorded 362 LOC; file has grown since) into three focused modules along the boundaries already implicit in the code — `module-validation.ts` + `module-topo-sort.ts` (registration/validation), `settings-migration.ts` (per-module migration + validation), and `mcp-server-lifecycle.ts` (start/stop + sync) — leaving `plugin-core.ts` as a thin orchestrator of those three plus the existing module init/destroy lifecycle. + +## Problem statement + +`src/core/plugin-core.ts` (476 LOC, lint `max-lines > 350` warning per the 2026-05-17 audit) carries four conceptually independent concerns inside one file: + +1. **Module validation** (`validateUriActions`, `validateSettingsKeys`, `validateModules` — lines 30–82). Pure functions that inspect the module descriptor array. No dependencies on `CorePorts`. Pure data validation. +2. **Module topological sort** (`topoSort` — lines 85–125). Kahn's BFS; pure function. No `CorePorts` dependency. +3. **Settings migration** (`migrateSettings` — lines 128–183). Per-module schema migration + validation with fall-back to defaults. Depends on `LoggerPort` only. +4. **Module lifecycle** (`PluginCore` class — lines 187+: `init`, `destroy`, `initModule`, `notifySettingsChanged`). Owns the module init/destroy ordering, the event bus, the leak-map, the URI dispatch map, and the settings-changed reconciliation. +5. **MCP server lifecycle** — woven into the `PluginCore` class: `_mcpRunning`, `_syncChain`, `_doSyncMcpRunning`, `startMcpServer`, `stopMcpServer`, `isMcpServerRunning`. Tangentially related to (4) (init triggers an auto-start, destroy triggers a stop) but otherwise independent. + +The audit identified (1)+(2) and (3) and (5) as the three natural split points. The implementation has accreted because each concern was added incrementally with a clear seam already visible in the code. Splitting along those seams produces three files under 200 LOC each plus a slimmer `plugin-core.ts` (~200 LOC) that delegates. + +## Scope — IN + +**New `src/core/module-validation.ts`** (~100 LOC): + +- Exports `validateModules(modules)`, `validateUriActions(modules)`, `validateSettingsKeys(modules)`. Pure; no `CorePorts`. + +**New `src/core/module-topo-sort.ts`** (~50 LOC): + +- Exports `topoSort(modules): ModuleDescriptor[]`. Pure; preserves the existing `// eslint-disable complexity` (Kahn's BFS is irreducible). + +**New `src/core/settings-migration.ts`** (~80 LOC): + +- Exports `migrateSettings(modules, settings, logger): void`. Takes `LoggerPort` (single port dep) so it stays pure of `CorePorts` aggregate. + +**New `src/core/mcp-server-lifecycle.ts`** (~120 LOC): + +- Exports an `McpServerLifecycle` class with `start()`, `stop()`, `isRunning()`, `syncRunning()` and the `_syncChain` debounce. +- Constructor takes `{ port?: ObsidianMcpServerPort, isEnabled?: () => boolean, logger: LoggerPort, bus: EventBus }`. The `_doSyncMcpRunning` body moves here verbatim. +- `PluginCore.notifySettingsChanged` calls `mcpLifecycle.syncRunning()` instead of `_doSyncMcpRunning`. +- `PluginCore.init` calls `mcpLifecycle.start()` after `core:init-complete`. +- `PluginCore.destroy` calls `mcpLifecycle.stop()` before `core:destroy-complete`. + +**Modified `src/core/plugin-core.ts`** (target ≤ 220 LOC): + +- Imports + re-exports from the four new modules. +- `PluginCore` class delegates the four concerns; its body shrinks to: constructor + event bus wiring + `init` (calls validate → topoSort → migrate → init modules → start MCP) + `destroy` + `initModule` + `notifySettingsChanged`. + +**Tests follow the source.** Each new file gets its own mirror test under `tests/core/`. The existing `tests/core/plugin-core.test.ts` shrinks: tests that exercised the now-extracted helpers (validation, topo, migration, MCP sync) move to the new files. Tests of `PluginCore`'s end-to-end behaviour (init→degraded module path→destroy) stay. + +## Scope — OUT + +- The `ModuleDescriptor` shape — stable (ADR-010). +- `EventBus` mechanics — stable (ADR-011). +- The MCP server PORT interface (`ObsidianMcpServerPort`) — stable. +- `LoggerPort` filtering — stable. +- Adding new module-system features (e.g. lazy module init, hot-reload) — out of scope. +- The unused `eslint-disable` directive at `src/plugin/main.ts:270` (`obsidianmd/commands/no-plugin-id-in-command-id`) — **explicit carry-out for WP-6** per the audit. DO NOT touch in this WP. +- Splitting `src/plugin/main.ts` — that's the WP-16 candidate flagged in the audit. DO NOT touch here. +- Any module's `init`/`destroy` implementation — only `PluginCore`'s shape changes, not how modules behave. + +## Approach + +1. **Iteration 1 — extract pure modules.** Move `validateModules` + helpers to `module-validation.ts`. Move `topoSort` to `module-topo-sort.ts`. Move `migrateSettings` to `settings-migration.ts`. Update `plugin-core.ts` to import + re-export so external import paths don't break. +2. **Iteration 2 — extract `McpServerLifecycle`.** Move `_mcpRunning`, `_syncChain`, `_doSyncMcpRunning`, `startMcpServer`, `stopMcpServer`, `isMcpServerRunning` to the new class. Wire from `PluginCore.init` / `destroy` / `notifySettingsChanged`. +3. **Iteration 3 — relocate tests.** Move existing test cases for the extracted helpers into mirror files under `tests/core/`. Keep `tests/core/plugin-core.test.ts` for the end-to-end behaviour. +4. **Iteration 4 — verify `max-lines` warning gone.** Run lint; confirm `plugin-core.ts` ≤ 220 LOC; each new file ≤ 150 LOC. No new `max-lines` warning introduced. +5. **Run the full pre-PR gate every iteration.** + +## Deliverables + +**New files:** + +- `src/core/module-validation.ts` + `tests/core/module-validation.test.ts`. +- `src/core/module-topo-sort.ts` + `tests/core/module-topo-sort.test.ts`. +- `src/core/settings-migration.ts` + `tests/core/settings-migration.test.ts`. +- `src/core/mcp-server-lifecycle.ts` + `tests/core/mcp-server-lifecycle.test.ts`. + +**Modified files:** + +- `src/core/plugin-core.ts` — shrinks to thin orchestrator (target ≤ 220 LOC). +- `tests/core/plugin-core.test.ts` — tests for extracted helpers move out; end-to-end tests stay. +- Any importer of the now-extracted helpers (likely only `src/core/plugin-core.ts` itself, but verify across `src/plugin/main.ts` and any test). + +**Deleted (no back-compat shims, per CLAUDE.md):** + +- The extracted function bodies inside `plugin-core.ts`. No re-export from `plugin-core` for the extracted helpers — consumers import from the new file paths. (If a re-export is needed transiently for the migration commit, it lands in the same commit that removes it — no dangling re-exports.) + +## Definition of done + +- [ ] `npm audit --audit-level=high --omit=dev` clean. +- [ ] `npm run typecheck` clean. +- [ ] `npm run lint` 0 errors; `max-lines` warning on `src/core/plugin-core.ts` is **gone** (file ≤ 350, target ≤ 220 LOC); no new `max-lines` warning on any extracted file. +- [ ] `npm run test` passes; each new module's mirror test ≥ 90% statements/branches. +- [ ] `npm run build` + `npm run build:web` succeed. +- [ ] `npm run docs:api` succeeds. +- [ ] `npm run test:coverage` thresholds maintained or improved. +- [ ] **Public surface unchanged**: `PluginCore` class signature (`init`, `destroy`, `notifySettingsChanged`, `startMcpServer`, `stopMcpServer`, `isMcpServerRunning`, `bus`) stays identical. Verified by a smoke test asserting the export shape. +- [ ] **MCP lifecycle isolated**: `mcp-server-lifecycle.test.ts` exercises start / stop / sync paths without instantiating `PluginCore` — proves the seam holds. +- [ ] **Validation pure**: `module-validation.test.ts` and `module-topo-sort.test.ts` pass without any port mocks. +- [ ] PR opened against `develop`, title `refactor(asv3): plugin-core.ts split — validation / topo / migration / mcp (WP-17)`, body cites the audit's 362-LOC max-lines warning. + +## Risks / known unknowns + +`McpServerLifecycle`'s `_syncChain` is a serial promise chain — moving it preserves the `_doSyncMcpRunning` ordering invariant (each enqueued reconciliation re-reads `isMcpServerEnabled()` fresh). Write a deterministic test for the chain: enqueue two `syncRunning()` calls with conflicting `isEnabled()` returns, assert the second one wins. The MCP port may be `undefined` (test-only branch); preserve the existing `port === undefined → no-op` behaviour. The migration of `migrateSettings` keeps its `// eslint-disable complexity` directive on the function declaration in the new file — don't drop it. + +The `core:init-complete` and `core:destroy-complete` event payloads (`degradedCount`, `leakCount`) must stay identical — these are observed by tests AND by external observers. Verify with a snapshot test of the event envelopes before and after the split. + +## RALPH iteration template + +``` +loop: + 1. Read brief.md + loop-state.md. + 2. Pick the next failing check (audit → typecheck → lint → test → build → docs → DoD). + 3. Implement the smallest change that moves one check red→green. + STAY IN SCOPE — no module-system feature additions, no main.ts split (WP-16), + no carry-out for WP-6 (`src/plugin/main.ts:270` eslint-disable). + 4. Run from inside .worktrees/asv3-wp17: + npm audit --audit-level=high --omit=dev \ + && npm run typecheck && npm run lint && npm run test \ + && npm run build && npm run build:web && npm run docs:api + 5. Update loop-state.md. + 6. If all gates green AND all DoD met → commit, push, open PR via gh. + Else → goto 1. + Hard cap: 10 RALPH iterations. +``` + +## Conventions + +- **Worktree:** `.worktrees/asv3-wp17` (already created, branch `claude/asv3-wp17-plugin-core-split`). +- **Commits:** conventional, squash on merge. Prefix `refactor(asv3):`. +- **PR target:** `develop`. Ready for review. +- **Do not touch:** `src/plugin/main.ts` (that's WP-16 candidate territory), `src/plugin/main.ts:270` unused `eslint-disable` (WP-6 carry-out), `ModuleDescriptor`, `EventBus`, `ObsidianMcpServerPort`. +- **Coordinate with WP-14** (chat-threads repo). Both touch `src/plugin/main.ts` LOC indirectly (WP-14 removes the persistence helper; WP-17 doesn't touch `main.ts` at all). Should be conflict-free. +- **Never** push to `develop`. Never force-push. diff --git a/specs/agent-sidepanel-v3/wp-17-plugin-core-split/loop-state.md b/specs/agent-sidepanel-v3/wp-17-plugin-core-split/loop-state.md new file mode 100644 index 00000000..e7239cbb --- /dev/null +++ b/specs/agent-sidepanel-v3/wp-17-plugin-core-split/loop-state.md @@ -0,0 +1,53 @@ +# WP-17 loop state + +Updated by the implementer subagent each RALPH iteration. The brief is `brief.md` in this folder. + +> **Worktree context** — All implementation entries below describe work performed on `claude/asv3-wp17-plugin-core-split` inside `.worktrees/asv3-wp17/`. The brief was scaffolded from the main worktree on `claude/improve-sidepanel-chat-8pgcT` (PR #395 era). + +## Iterations + +### Iteration 1 — full extract + mirror tests + gate green + +**Extracted from `src/core/plugin-core.ts` (was 476 LOC):** + +- `src/core/module-validation.ts` (73 LOC) — exports `validateModules`, `validateSettingsKeys`, `validateUriActions`. Pure; no port dependencies. +- `src/core/module-topo-sort.ts` (54 LOC) — exports `topoSort(modules)`. Kahn's BFS with the `eslint-disable complexity` directive preserved on the function. +- `src/core/settings-migration.ts` (77 LOC) — exports `migrateSettings(modules, settings, logger)`. Single `LoggerPort` dependency; keeps the `eslint-disable complexity` directive. +- `src/core/mcp-server-lifecycle.ts` (100 LOC) — `McpServerLifecycle` class with `start`, `stop`, `isRunning`, `syncRunning`. Constructor takes `{ port?, isEnabled?, logger }`. Owns the `_syncChain` serial promise chain and the `_running` invariant. + +**Modified `src/core/plugin-core.ts` (now 272 LOC; ~211 effective after eslint skipBlankLines/skipComments — well under the 220 target):** + +- Imports + delegates to the four new modules. +- `PluginCore` constructor instantiates `McpServerLifecycle` from `ports.mcpServer`, `ports.isMcpServerEnabled`, `ports.logger`. +- `startMcpServer` / `stopMcpServer` / `isMcpServerRunning` delegate to the lifecycle. +- `notifySettingsChanged` calls `mcpLifecycle.syncRunning()`. +- `init` calls `validateModules` → `topoSort` → `migrateSettings` → init modules → `mcpLifecycle.start()`. +- `destroy` calls `mcpLifecycle.stop()` before `core:destroy-complete`. +- Public surface (`bus`, `degradedModules`, `allModules`, `getModuleSettings`, `handleUri`, `notifySettingsChanged`, `isMcpServerRunning`, `startMcpServer`, `stopMcpServer`, `init`, `destroy`) preserved bit-for-bit. + +**New mirror tests (55 cases, all green without any port-mock plumbing):** + +- `tests/core/module-validation.test.ts` (16 cases) — `validateModules` / `validateSettingsKeys` / `validateUriActions` direct unit tests. +- `tests/core/module-topo-sort.test.ts` (8 cases) — including 2- and 3-cycle detection, diamond dependency, and stable independent ordering. +- `tests/core/settings-migration.test.ts` (13 cases) — covers migrate-success, migrate-throw + fallback, validate-throw + fallback, downgrade no-op, corrupted `_moduleVersions` (string / array / null), missing key. +- `tests/core/mcp-server-lifecycle.test.ts` (18 cases) — start/stop/sync paths, idempotency, gate-closed and undefined-port no-ops, serialised concurrent syncs (start-wins-over-stop and last-enqueued-wins), adapter error logging without instantiating `PluginCore`. + +**Existing tests:** `tests/core/plugin-core.test.ts` (78 cases) and `tests/core/plugin-core-mcp.test.ts` (16 cases) all pass without modification — proves the public surface is preserved. + +**Pre-PR gate (all green):** + +``` +npm audit --audit-level=high --omit=dev ✓ 0 vulnerabilities +npm run typecheck ✓ +npm run lint ✓ 0 errors, 23 warnings (all pre-existing — none on src/core/*) +npm run test ✓ 1918 passed (153 files); up from 1863 by exactly +55 (the new tests) +npm run build ✓ +npm run build:web ✓ +npm run docs:api ✓ +``` + +The `max-lines` warning on `src/core/plugin-core.ts` is **gone**. No new `max-lines` warnings introduced on any extracted file. The remaining `max-lines` warnings (`src/plugin/main.ts`, `MarkdownBlock.vue`, `MessageList.vue`, `ChatSidebar.vue`) and the unused `eslint-disable` on `src/plugin/main.ts:278` are pre-existing and explicitly out of scope (WP-6 / WP-16 territory). + +## Carry-out items + +_None — all in-scope DoD criteria met._ diff --git a/src/core/mcp-server-lifecycle.ts b/src/core/mcp-server-lifecycle.ts new file mode 100644 index 00000000..36207145 --- /dev/null +++ b/src/core/mcp-server-lifecycle.ts @@ -0,0 +1,100 @@ +import type { LoggerPort, ObsidianMcpServerPort } from '@/domain/ports' +import { tryAsync } from '@/domain/shared/tryAsync' + +export interface McpServerLifecycleDeps { + /** When undefined, all start/stop/sync paths short-circuit to no-ops. */ + readonly port?: ObsidianMcpServerPort + /** + * Predicate the host supplies to gate the MCP server. Auto-start (`start()`) + * and `syncRunning()` consult this on every call. When undefined the gate + * resolves to "disabled" (the same semantics `PluginCore` exposed before + * the split — pass `() => true` to opt in). + */ + readonly isEnabled?: () => boolean + readonly logger: LoggerPort +} + +/** + * Owns the local MCP server's running state and serialises concurrent + * reconciliations through a single promise chain. + * + * Extracted from `PluginCore` in WP-17 to isolate the MCP concern from the + * module-system lifecycle. Public methods preserve the `PluginCore` shape + * (`start`, `stop`, `isRunning`, `syncRunning`) so the orchestrator can + * delegate without behaviour change. + */ +export class McpServerLifecycle { + private readonly deps: McpServerLifecycleDeps + private _running = false + private _syncChain: Promise = Promise.resolve() + + constructor(deps: McpServerLifecycleDeps) { + this.deps = deps + } + + /** True iff the MCP server is currently running under this lifecycle's control. */ + isRunning(): boolean { + return this._running + } + + /** + * Start the local MCP server. + * + * - Idempotent: no-op when already running. + * - Gated by `deps.isEnabled()`. + * - Errors are logged via `LoggerPort` and swallowed; the server simply + * remains stopped on failure. + */ + async start(): Promise { + if (this.deps.port === undefined) return + if (this._running) return + if (this.deps.isEnabled?.() !== true) return + + const result = await tryAsync(() => this.deps.port!.start()) + if (!result.ok) { + this.deps.logger.error('MCP server start failed', result.error) + return + } + this._running = true + } + + /** + * Stop the local MCP server. Idempotent: no-op when not running. + * Errors are logged but do not throw. + */ + async stop(): Promise { + if (this.deps.port === undefined) return + if (!this._running) return + + const result = await tryAsync(() => this.deps.port!.stop()) + if (!result.ok) { + this.deps.logger.error('MCP server stop failed', result.error) + } + // Mark stopped even on adapter error: the running invariant is owned by + // this lifecycle, and a failed stop should not strand future start calls. + this._running = false + } + + /** + * Enqueues a reconciliation onto a serial promise chain so that concurrent + * calls (e.g. rapid stop→start) never observe stale `_running` state. + * Each enqueued reconciliation reads `isEnabled()` fresh when it actually + * runs, so the last queued call always reflects the latest intent. + */ + syncRunning(): Promise { + this._syncChain = this._syncChain + .then(() => this._doSync()) + .catch(() => { /* start/stop errors are logged inside those methods */ }) + return this._syncChain + } + + private async _doSync(): Promise { + if (this.deps.port === undefined) return + const desired = this.deps.isEnabled?.() === true + if (desired && !this._running) { + await this.start() + } else if (!desired && this._running) { + await this.stop() + } + } +} diff --git a/src/core/module-topo-sort.ts b/src/core/module-topo-sort.ts new file mode 100644 index 00000000..b6435540 --- /dev/null +++ b/src/core/module-topo-sort.ts @@ -0,0 +1,54 @@ +import type { ModuleDescriptor } from '@/modules' + +/** + * Topologically sorts the module set so each module's declared `dependsOn` + * prerequisites appear before it in the result. Independent modules at the + * same depth keep their declaration order (stable BFS). + * + * Throws when a dependency cycle is detected; the error message lists the + * IDs of all modules left unscheduled after Kahn's BFS terminates. + * + * Pure — depends only on the module descriptor array; no port dependency. + */ +// eslint-disable-next-line complexity -- Kahn's BFS; complexity comes from the algorithm itself, not incidental branching. +export function topoSort(modules: ReadonlyArray): ModuleDescriptor[] { + const inDegree = new Map() + const adj = new Map() // dep → dependants + + for (const mod of modules) { + if (!inDegree.has(mod.id)) inDegree.set(mod.id, 0) + if (!adj.has(mod.id)) adj.set(mod.id, []) + } + + for (const mod of modules) { + for (const dep of mod.dependsOn ?? []) { + inDegree.set(mod.id, (inDegree.get(mod.id) ?? 0) + 1) + if (!adj.has(dep)) adj.set(dep, []) + adj.get(dep)!.push(mod.id) + } + } + + const queue: ModuleDescriptor[] = modules.filter((m) => inDegree.get(m.id) === 0) + const sorted: ModuleDescriptor[] = [] + const byId = new Map(modules.map((m) => [m.id, m])) + + while (queue.length > 0) { + const mod = queue.shift()! + sorted.push(mod) + for (const dependantId of adj.get(mod.id) ?? []) { + const next = (inDegree.get(dependantId) ?? 1) - 1 + inDegree.set(dependantId, next) + if (next === 0) queue.push(byId.get(dependantId)!) + } + } + + if (sorted.length !== modules.length) { + const remaining = modules + .filter((m) => !sorted.includes(m)) + .map((m) => m.id) + .join(', ') + throw new Error(`cycle detected among modules: ${remaining}`) + } + + return sorted +} diff --git a/src/core/module-validation.ts b/src/core/module-validation.ts new file mode 100644 index 00000000..3d6f411a --- /dev/null +++ b/src/core/module-validation.ts @@ -0,0 +1,73 @@ +import type { ModuleDescriptor } from '@/modules' + +/** + * Asserts URI action names are unique across the module set. + * + * Pure — depends only on the module descriptor array; no port dependency. + */ +export function validateUriActions(modules: ReadonlyArray): void { + const seen = new Set() + for (const mod of modules) { + for (const uriAction of mod.uriActions ?? []) { + if (seen.has(uriAction.action)) { + throw new Error(`duplicate URI action "${uriAction.action}" in module "${mod.id}"`) + } + seen.add(uriAction.action) + } + } +} + +/** + * Asserts settingsKey values are unique and do not collide with the reserved + * `_`-prefix namespace (`_moduleVersions` etc.). + * + * Pure — depends only on the module descriptor array; no port dependency. + */ +export function validateSettingsKeys(modules: ReadonlyArray): void { + const seen = new Set() + for (const mod of modules) { + if (mod.settingsKey === undefined) continue + if (mod.settingsKey.startsWith('_')) { + throw new Error(`reserved settingsKey "${mod.settingsKey}" in module "${mod.id}"`) + } + if (seen.has(mod.settingsKey)) { + throw new Error(`duplicate settingsKey "${mod.settingsKey}" in module "${mod.id}"`) + } + seen.add(mod.settingsKey) + } +} + +/** + * Asserts the module registry is internally consistent: unique IDs, unique + * settings keys, unique URI actions, no self-dependency, no unknown + * `dependsOn` references. Cycles are caught downstream by `topoSort`. + * + * Pure — depends only on the module descriptor array; no port dependency. + */ +export function validateModules(modules: ReadonlyArray): void { + const ids = new Set() + + for (const mod of modules) { + if (ids.has(mod.id)) { + throw new Error(`duplicate module id: "${mod.id}"`) + } + ids.add(mod.id) + } + + validateSettingsKeys(modules) + validateUriActions(modules) + + for (const mod of modules) { + const deps = mod.dependsOn ?? [] + + if (deps.includes(mod.id)) { + throw new Error(`self-dependency detected for module "${mod.id}"`) + } + + for (const dep of deps) { + if (!ids.has(dep)) { + throw new Error(`unknown dependency "${dep}" in module "${mod.id}"`) + } + } + } +} diff --git a/src/core/plugin-core.ts b/src/core/plugin-core.ts index d990b726..bee1e631 100644 --- a/src/core/plugin-core.ts +++ b/src/core/plugin-core.ts @@ -4,6 +4,10 @@ import { createEventBus, type EventBus, type EventBusOptions, type EventEnvelope import { tryAsync, trySync } from '@/domain/shared/tryAsync' import type { ModuleDescriptor, ModulePorts } from '@/modules' import { applyModuleMessages } from './applyModuleMessages' +import { validateModules } from './module-validation' +import { topoSort } from './module-topo-sort' +import { migrateSettings } from './settings-migration' +import { McpServerLifecycle } from './mcp-server-lifecycle' export interface CorePorts { readonly settings: SettingsPort @@ -25,177 +29,17 @@ export interface CorePorts { readonly isMcpServerEnabled?: () => boolean } -// ── Validation helpers ──────────────────────────────────────────────────────── - -function validateUriActions(modules: ReadonlyArray): void { - const seen = new Set() - for (const mod of modules) { - for (const uriAction of mod.uriActions ?? []) { - if (seen.has(uriAction.action)) { - throw new Error(`duplicate URI action "${uriAction.action}" in module "${mod.id}"`) - } - seen.add(uriAction.action) - } - } -} - -function validateSettingsKeys(modules: ReadonlyArray): void { - const seen = new Set() - for (const mod of modules) { - if (mod.settingsKey === undefined) continue - if (mod.settingsKey.startsWith('_')) { - throw new Error(`reserved settingsKey "${mod.settingsKey}" in module "${mod.id}"`) - } - if (seen.has(mod.settingsKey)) { - throw new Error(`duplicate settingsKey "${mod.settingsKey}" in module "${mod.id}"`) - } - seen.add(mod.settingsKey) - } -} - -function validateModules(modules: ReadonlyArray): void { - const ids = new Set() - - for (const mod of modules) { - if (ids.has(mod.id)) { - throw new Error(`duplicate module id: "${mod.id}"`) - } - ids.add(mod.id) - } - - validateSettingsKeys(modules) - validateUriActions(modules) - - for (const mod of modules) { - const deps = mod.dependsOn ?? [] - - if (deps.includes(mod.id)) { - throw new Error(`self-dependency detected for module "${mod.id}"`) - } - - for (const dep of deps) { - if (!ids.has(dep)) { - throw new Error(`unknown dependency "${dep}" in module "${mod.id}"`) - } - } - } -} - -// eslint-disable-next-line complexity -- Kahn's BFS; complexity comes from the algorithm itself, not incidental branching. -function topoSort(modules: ReadonlyArray): ModuleDescriptor[] { - const inDegree = new Map() - const adj = new Map() // dep → dependants - - for (const mod of modules) { - if (!inDegree.has(mod.id)) inDegree.set(mod.id, 0) - if (!adj.has(mod.id)) adj.set(mod.id, []) - } - - for (const mod of modules) { - for (const dep of mod.dependsOn ?? []) { - inDegree.set(mod.id, (inDegree.get(mod.id) ?? 0) + 1) - if (!adj.has(dep)) adj.set(dep, []) - adj.get(dep)!.push(mod.id) - } - } - - const queue: ModuleDescriptor[] = modules.filter((m) => inDegree.get(m.id) === 0) - const sorted: ModuleDescriptor[] = [] - const byId = new Map(modules.map((m) => [m.id, m])) - - while (queue.length > 0) { - const mod = queue.shift()! - sorted.push(mod) - for (const dependantId of adj.get(mod.id) ?? []) { - const next = (inDegree.get(dependantId) ?? 1) - 1 - inDegree.set(dependantId, next) - if (next === 0) queue.push(byId.get(dependantId)!) - } - } - - if (sorted.length !== modules.length) { - const remaining = modules - .filter((m) => !sorted.includes(m)) - .map((m) => m.id) - .join(', ') - throw new Error(`cycle detected among modules: ${remaining}`) - } - - return sorted -} - -// eslint-disable-next-line complexity -- Migration pipeline; each branch handles one aspect of the migration/validation/fallback spec. -function migrateSettings( - modules: ReadonlyArray, - settings: Record, - logger: LoggerPort, -): void { - const raw = settings._moduleVersions - const versions: Record = - raw !== null && typeof raw === 'object' && !Array.isArray(raw) - ? (raw as Record) - : {} - - for (const mod of modules) { - if (mod.settingsKey === undefined) continue - - const key = mod.settingsKey - const storedVersion = versions[key] ?? 0 - const targetVersion = mod.settingsVersion ?? 0 - - let blob: unknown = settings[key] ?? {} - - if (storedVersion < targetVersion && mod.migrate !== undefined) { - const migrateResult = trySync(() => mod.migrate!(storedVersion, blob)) - if (migrateResult.ok) { - blob = migrateResult.value - } else { - logger.warn('settings migration failed; falling back to defaults', { - moduleId: mod.id, - settingsKey: key, - fromVersion: storedVersion, - toVersion: targetVersion, - error: migrateResult.error.message, - }) - blob = mod.settingsDefaults ?? {} - } - } - - if (mod.validateSettings !== undefined) { - const validateResult = trySync(() => mod.validateSettings!(blob)) - if (validateResult.ok) { - blob = validateResult.value - } else { - logger.warn('validateSettings failed; falling back to defaults', { - moduleId: mod.id, - settingsKey: key, - error: validateResult.error.message, - }) - blob = mod.settingsDefaults ?? {} - } - } - - settings[key] = blob - versions[key] = targetVersion - } - - settings._moduleVersions = versions -} - -// ── PluginCore ──────────────────────────────────────────────────────────────── - export class PluginCore { readonly bus: EventBus private readonly _degradedModules: Array<{ id: string; error: Error }> = [] private readonly ports: CorePorts private readonly modules: ReadonlyArray + private readonly mcpLifecycle: McpServerLifecycle private sorted: ModuleDescriptor[] = [] private readonly leakMap = new Map() private readonly moduleSettingsMap = new Map() private readonly uriDispatch = new Map void>() private _initCalled = false - private _mcpRunning = false - private _syncChain: Promise = Promise.resolve() constructor( modules: ReadonlyArray, @@ -216,6 +60,11 @@ export class PluginCore { } }, }) + this.mcpLifecycle = new McpServerLifecycle({ + port: ports.mcpServer, + isEnabled: ports.isMcpServerEnabled, + logger: ports.logger, + }) } get degradedModules(): ReadonlyArray<{ id: string; error: Error }> { @@ -281,35 +130,20 @@ export class PluginCore { // Reconcile MCP after every settings change — the sync is a cheap no-op // when desired === running, so we avoid hardcoding which module's // settingsKey owns the toggle. - await this._syncMcpRunning() + await this.mcpLifecycle.syncRunning() } /** True iff the MCP server is currently running under PluginCore's control. */ isMcpServerRunning(): boolean { - return this._mcpRunning + return this.mcpLifecycle.isRunning() } - /** - * Enqueues a reconciliation onto a serial promise chain so that concurrent - * calls (e.g. rapid stop→start) never observe stale `_mcpRunning` state. - * Each enqueued reconciliation reads `isMcpServerEnabled()` fresh when it - * actually runs, so the last queued call always reflects the latest intent. - */ - private _syncMcpRunning(): Promise { - this._syncChain = this._syncChain - .then(() => this._doSyncMcpRunning()) - .catch(() => { /* start/stop errors are logged inside those methods */ }) - return this._syncChain + async startMcpServer(): Promise { + await this.mcpLifecycle.start() } - private async _doSyncMcpRunning(): Promise { - if (this.ports.mcpServer === undefined) return - const desired = this.ports.isMcpServerEnabled?.() === true - if (desired && !this._mcpRunning) { - await this.startMcpServer() - } else if (!desired && this._mcpRunning) { - await this.stopMcpServer() - } + async stopMcpServer(): Promise { + await this.mcpLifecycle.stop() } async init(rawSettings: Record): Promise { @@ -351,7 +185,7 @@ export class PluginCore { await this.initModule(mod, modulePorts, settings, degradedIds) } - await this.startMcpServer() + await this.mcpLifecycle.start() this.bus.emit('core:init-complete', { degradedCount: this._degradedModules.length }) } @@ -385,49 +219,11 @@ export class PluginCore { } } - await this.stopMcpServer() + await this.mcpLifecycle.stop() this.bus.emit('core:destroy-complete', { leakCount }) } - /** - * Start the local MCP server. - * - * - Idempotent: no-op when already running. - * - Gated by `ports.isMcpServerEnabled()`. - * - Errors are logged via `LoggerPort` and swallowed; the server simply - * remains stopped on failure. - */ - async startMcpServer(): Promise { - if (this.ports.mcpServer === undefined) return - if (this._mcpRunning) return - if (this.ports.isMcpServerEnabled?.() !== true) return - - const result = await tryAsync(() => this.ports.mcpServer!.start()) - if (!result.ok) { - this.ports.logger.error('MCP server start failed', result.error) - return - } - this._mcpRunning = true - } - - /** - * Stop the local MCP server. Idempotent: no-op when not running. - * Errors are logged but do not throw. - */ - async stopMcpServer(): Promise { - if (this.ports.mcpServer === undefined) return - if (!this._mcpRunning) return - - const result = await tryAsync(() => this.ports.mcpServer!.stop()) - if (!result.ok) { - this.ports.logger.error('MCP server stop failed', result.error) - } - // Mark stopped even on adapter error: the running invariant is owned by - // PluginCore, and a failed stop should not strand future start calls. - this._mcpRunning = false - } - private async initModule( mod: ModuleDescriptor, modulePorts: ModulePorts, diff --git a/src/core/settings-migration.ts b/src/core/settings-migration.ts new file mode 100644 index 00000000..f566c5bf --- /dev/null +++ b/src/core/settings-migration.ts @@ -0,0 +1,77 @@ +import type { LoggerPort } from '@/domain/ports' +import { trySync } from '@/domain/shared/tryAsync' +import type { ModuleDescriptor } from '@/modules' + +/** + * Mutates `settings` in-place: for every module with a `settingsKey`, + * + * 1. Reads the stored version from `settings._moduleVersions` (falling back + * to 0 when the entry is missing or the container is corrupted). + * 2. Runs `mod.migrate(storedVersion, blob)` when `storedVersion < + * mod.settingsVersion ?? 0`. A throwing migrate falls back to + * `mod.settingsDefaults ?? {}` and logs a warning. + * 3. Runs `mod.validateSettings(blob)` when declared. A throwing validator + * falls back to defaults and logs a warning. + * 4. Writes the resulting blob to `settings[settingsKey]` and bumps + * `settings._moduleVersions[settingsKey]` to the target version. + * + * Single-port dependency (`LoggerPort`) — kept narrow so this stays pure of + * the `CorePorts` aggregate. + */ +// eslint-disable-next-line complexity -- Migration pipeline; each branch handles one aspect of the migration/validation/fallback spec. +export function migrateSettings( + modules: ReadonlyArray, + settings: Record, + logger: LoggerPort, +): void { + const raw = settings._moduleVersions + const versions: Record = + raw !== null && typeof raw === 'object' && !Array.isArray(raw) + ? (raw as Record) + : {} + + for (const mod of modules) { + if (mod.settingsKey === undefined) continue + + const key = mod.settingsKey + const storedVersion = versions[key] ?? 0 + const targetVersion = mod.settingsVersion ?? 0 + + let blob: unknown = settings[key] ?? {} + + if (storedVersion < targetVersion && mod.migrate !== undefined) { + const migrateResult = trySync(() => mod.migrate!(storedVersion, blob)) + if (migrateResult.ok) { + blob = migrateResult.value + } else { + logger.warn('settings migration failed; falling back to defaults', { + moduleId: mod.id, + settingsKey: key, + fromVersion: storedVersion, + toVersion: targetVersion, + error: migrateResult.error.message, + }) + blob = mod.settingsDefaults ?? {} + } + } + + if (mod.validateSettings !== undefined) { + const validateResult = trySync(() => mod.validateSettings!(blob)) + if (validateResult.ok) { + blob = validateResult.value + } else { + logger.warn('validateSettings failed; falling back to defaults', { + moduleId: mod.id, + settingsKey: key, + error: validateResult.error.message, + }) + blob = mod.settingsDefaults ?? {} + } + } + + settings[key] = blob + versions[key] = targetVersion + } + + settings._moduleVersions = versions +} diff --git a/tests/core/mcp-server-lifecycle.test.ts b/tests/core/mcp-server-lifecycle.test.ts new file mode 100644 index 00000000..aae986cd --- /dev/null +++ b/tests/core/mcp-server-lifecycle.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi } from 'vitest' +import { McpServerLifecycle } from '@/core/mcp-server-lifecycle' +import { MockObsidianMcpServerAdapter } from '@/infrastructure/mock/MockObsidianMcpServerAdapter' +import { fakeModulePorts } from '../__fakes__/fake-ports' +import type { LoggerPort, ObsidianMcpServerPort } from '@/domain/ports' + +function makeLogger(): LoggerPort { + return fakeModulePorts().logger +} + +describe('McpServerLifecycle.start', () => { + it('starts the adapter when the gate is open', async () => { + const port = new MockObsidianMcpServerAdapter() + const lifecycle = new McpServerLifecycle({ port, isEnabled: () => true, logger: makeLogger() }) + await lifecycle.start() + expect(port.started).toBe(true) + expect(lifecycle.isRunning()).toBe(true) + }) + + it('is a no-op when the gate is closed', async () => { + const port = new MockObsidianMcpServerAdapter() + const startSpy = vi.spyOn(port, 'start') + const lifecycle = new McpServerLifecycle({ port, isEnabled: () => false, logger: makeLogger() }) + await lifecycle.start() + expect(startSpy).not.toHaveBeenCalled() + expect(lifecycle.isRunning()).toBe(false) + }) + + it('is a no-op when isEnabled is omitted (default disabled)', async () => { + const port = new MockObsidianMcpServerAdapter() + const startSpy = vi.spyOn(port, 'start') + const lifecycle = new McpServerLifecycle({ port, logger: makeLogger() }) + await lifecycle.start() + expect(startSpy).not.toHaveBeenCalled() + expect(lifecycle.isRunning()).toBe(false) + }) + + it('is a no-op when port is undefined', async () => { + const lifecycle = new McpServerLifecycle({ isEnabled: () => true, logger: makeLogger() }) + await expect(lifecycle.start()).resolves.toBeUndefined() + expect(lifecycle.isRunning()).toBe(false) + }) + + it('is idempotent — second start() does not call adapter.start again', async () => { + const port = new MockObsidianMcpServerAdapter() + const startSpy = vi.spyOn(port, 'start') + const lifecycle = new McpServerLifecycle({ port, isEnabled: () => true, logger: makeLogger() }) + await lifecycle.start() + await lifecycle.start() + expect(startSpy).toHaveBeenCalledTimes(1) + }) + + it('logs error and stays stopped when adapter.start throws', async () => { + const logger = makeLogger() + const port: ObsidianMcpServerPort = { + start: async () => { throw new Error('start failed') }, + stop: async () => {}, + getConnectionConfig: () => ({ transport: 'http', url: 'http://127.0.0.1:3001/mcp' }), + } + const lifecycle = new McpServerLifecycle({ port, isEnabled: () => true, logger }) + await lifecycle.start() + expect(logger.error).toHaveBeenCalledWith('MCP server start failed', expect.any(Error)) + expect(lifecycle.isRunning()).toBe(false) + }) +}) + +describe('McpServerLifecycle.stop', () => { + it('stops a running adapter', async () => { + const port = new MockObsidianMcpServerAdapter() + const lifecycle = new McpServerLifecycle({ port, isEnabled: () => true, logger: makeLogger() }) + await lifecycle.start() + await lifecycle.stop() + expect(port.started).toBe(false) + expect(lifecycle.isRunning()).toBe(false) + }) + + it('is a no-op when not running', async () => { + const port = new MockObsidianMcpServerAdapter() + const stopSpy = vi.spyOn(port, 'stop') + const lifecycle = new McpServerLifecycle({ port, isEnabled: () => true, logger: makeLogger() }) + await lifecycle.stop() + expect(stopSpy).not.toHaveBeenCalled() + }) + + it('is a no-op when port is undefined', async () => { + const lifecycle = new McpServerLifecycle({ isEnabled: () => true, logger: makeLogger() }) + await expect(lifecycle.stop()).resolves.toBeUndefined() + }) + + it('is idempotent — second stop() does not call adapter.stop again', async () => { + const port = new MockObsidianMcpServerAdapter() + const stopSpy = vi.spyOn(port, 'stop') + const lifecycle = new McpServerLifecycle({ port, isEnabled: () => true, logger: makeLogger() }) + await lifecycle.start() + await lifecycle.stop() + await lifecycle.stop() + expect(stopSpy).toHaveBeenCalledTimes(1) + }) + + it('logs error and clears running flag when adapter.stop throws', async () => { + const logger = makeLogger() + const port: ObsidianMcpServerPort = { + start: async () => ({ port: 3001 }), + stop: async () => { throw new Error('stop failed') }, + getConnectionConfig: () => ({ transport: 'http', url: 'http://127.0.0.1:3001/mcp' }), + } + const lifecycle = new McpServerLifecycle({ port, isEnabled: () => true, logger }) + await lifecycle.start() + await lifecycle.stop() + expect(logger.error).toHaveBeenCalledWith('MCP server stop failed', expect.any(Error)) + // PluginCore treats the server as stopped after a failed stop so future + // start calls aren't permanently blocked. + expect(lifecycle.isRunning()).toBe(false) + }) +}) + +describe('McpServerLifecycle.syncRunning', () => { + it('starts when desired and not running', async () => { + let enabled = false + const port = new MockObsidianMcpServerAdapter() + const lifecycle = new McpServerLifecycle({ port, isEnabled: () => enabled, logger: makeLogger() }) + enabled = true + await lifecycle.syncRunning() + expect(lifecycle.isRunning()).toBe(true) + }) + + it('stops when not desired and running', async () => { + let enabled = true + const port = new MockObsidianMcpServerAdapter() + const lifecycle = new McpServerLifecycle({ port, isEnabled: () => enabled, logger: makeLogger() }) + await lifecycle.start() + expect(lifecycle.isRunning()).toBe(true) + enabled = false + await lifecycle.syncRunning() + expect(lifecycle.isRunning()).toBe(false) + }) + + it('is a no-op when desired matches running', async () => { + const port = new MockObsidianMcpServerAdapter() + const stopSpy = vi.spyOn(port, 'stop') + const lifecycle = new McpServerLifecycle({ port, isEnabled: () => false, logger: makeLogger() }) + await lifecycle.syncRunning() + expect(stopSpy).not.toHaveBeenCalled() + expect(lifecycle.isRunning()).toBe(false) + }) + + it('is a no-op when port is undefined', async () => { + const lifecycle = new McpServerLifecycle({ isEnabled: () => true, logger: makeLogger() }) + await expect(lifecycle.syncRunning()).resolves.toBeUndefined() + }) + + it('serialises concurrent calls — later start wins over an in-flight stop', async () => { + let enabled = true + let resolveStop: () => void = () => {} + const port: ObsidianMcpServerPort = { + start: async () => ({ port: 3001 }), + stop: () => new Promise(resolve => { resolveStop = resolve }), + getConnectionConfig: () => ({ transport: 'http', url: 'http://127.0.0.1:3001/mcp' }), + } + const lifecycle = new McpServerLifecycle({ port, isEnabled: () => enabled, logger: makeLogger() }) + await lifecycle.start() + expect(lifecycle.isRunning()).toBe(true) + + // Trigger stop — stop() will block until resolveStop() is called. + enabled = false + const firstSync = lifecycle.syncRunning() + + // Before stop completes, queue a start. + enabled = true + const secondSync = lifecycle.syncRunning() + + resolveStop() + await Promise.all([firstSync, secondSync]) + + expect(lifecycle.isRunning()).toBe(true) + }) + + it('reads isEnabled fresh on each sync — last enqueued call wins', async () => { + let enabled = false + const port = new MockObsidianMcpServerAdapter() + const lifecycle = new McpServerLifecycle({ port, isEnabled: () => enabled, logger: makeLogger() }) + + enabled = true + const first = lifecycle.syncRunning() + enabled = false + const second = lifecycle.syncRunning() + + await Promise.all([first, second]) + expect(lifecycle.isRunning()).toBe(false) + }) + + it('swallows errors from the chained start/stop without breaking subsequent syncs', async () => { + let enabled = true + const port: ObsidianMcpServerPort = { + start: async () => { throw new Error('start failed') }, + stop: async () => {}, + getConnectionConfig: () => ({ transport: 'http', url: 'http://127.0.0.1:3001/mcp' }), + } + const lifecycle = new McpServerLifecycle({ port, isEnabled: () => enabled, logger: makeLogger() }) + await lifecycle.syncRunning() // start throws, chain swallows + enabled = false + await lifecycle.syncRunning() // chain still functional + expect(lifecycle.isRunning()).toBe(false) + }) +}) diff --git a/tests/core/module-topo-sort.test.ts b/tests/core/module-topo-sort.test.ts new file mode 100644 index 00000000..35469393 --- /dev/null +++ b/tests/core/module-topo-sort.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi } from 'vitest' +import { topoSort } from '@/core/module-topo-sort' +import type { ModuleDescriptor } from '@/modules' + +function makeModule(id: string, overrides?: Partial): ModuleDescriptor { + return { id, init: vi.fn(), ...overrides } +} + +describe('topoSort', () => { + it('returns an empty array for no modules', () => { + expect(topoSort([])).toEqual([]) + }) + + it('preserves declaration order when there are no dependencies', () => { + const a = makeModule('a') + const b = makeModule('b') + const c = makeModule('c') + expect(topoSort([a, b, c]).map((m) => m.id)).toEqual(['a', 'b', 'c']) + }) + + it('places a dependency before its dependant', () => { + const a = makeModule('a') + const b = makeModule('b', { dependsOn: ['a'] }) + expect(topoSort([b, a]).map((m) => m.id)).toEqual(['a', 'b']) + }) + + it('preserves declaration order across independent modules at the same depth', () => { + const x = makeModule('x') + const y = makeModule('y') + expect(topoSort([x, y]).map((m) => m.id)).toEqual(['x', 'y']) + }) + + it('handles a chain of dependencies', () => { + const a = makeModule('a') + const b = makeModule('b', { dependsOn: ['a'] }) + const c = makeModule('c', { dependsOn: ['b'] }) + expect(topoSort([c, b, a]).map((m) => m.id)).toEqual(['a', 'b', 'c']) + }) + + it('handles a diamond (a → b, a → c, both → d)', () => { + const a = makeModule('a') + const b = makeModule('b', { dependsOn: ['a'] }) + const c = makeModule('c', { dependsOn: ['a'] }) + const d = makeModule('d', { dependsOn: ['b', 'c'] }) + const order = topoSort([d, c, b, a]).map((m) => m.id) + expect(order.indexOf('a')).toBeLessThan(order.indexOf('b')) + expect(order.indexOf('a')).toBeLessThan(order.indexOf('c')) + expect(order.indexOf('b')).toBeLessThan(order.indexOf('d')) + expect(order.indexOf('c')).toBeLessThan(order.indexOf('d')) + }) + + it('throws on a 2-cycle and lists both modules', () => { + const a = makeModule('a', { dependsOn: ['b'] }) + const b = makeModule('b', { dependsOn: ['a'] }) + expect(() => topoSort([a, b])).toThrow(/cycle/i) + expect(() => topoSort([a, b])).toThrow(/\ba\b/) + expect(() => topoSort([a, b])).toThrow(/\bb\b/) + }) + + it('throws on a 3-cycle and names every involved module', () => { + const a = makeModule('a', { dependsOn: ['c'] }) + const b = makeModule('b', { dependsOn: ['a'] }) + const c = makeModule('c', { dependsOn: ['b'] }) + expect(() => topoSort([a, b, c])).toThrow(/cycle.*a.*b.*c|cycle.*c.*b.*a|cycle.*b.*a.*c/i) + }) +}) diff --git a/tests/core/module-validation.test.ts b/tests/core/module-validation.test.ts new file mode 100644 index 00000000..9c4617f6 --- /dev/null +++ b/tests/core/module-validation.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest' +import { + validateModules, + validateSettingsKeys, + validateUriActions, +} from '@/core/module-validation' +import type { ModuleDescriptor } from '@/modules' + +function makeModule(id: string, overrides?: Partial): ModuleDescriptor { + return { id, init: vi.fn(), ...overrides } +} + +describe('validateModules', () => { + it('accepts an empty registry', () => { + expect(() => { validateModules([]) }).not.toThrow() + }) + + it('accepts a well-formed registry', () => { + const a = makeModule('a') + const b = makeModule('b', { dependsOn: ['a'] }) + expect(() => { validateModules([a, b]) }).not.toThrow() + }) + + it('rejects duplicate module IDs', () => { + const a = makeModule('a') + const b = makeModule('a') + expect(() => { validateModules([a, b]) }).toThrow(/duplicate.*a/i) + }) + + it('rejects self-dependency', () => { + const a = makeModule('a', { dependsOn: ['a'] }) + expect(() => { validateModules([a]) }).toThrow(/self.*a/i) + }) + + it('rejects unknown dependency reference', () => { + const a = makeModule('a', { dependsOn: ['ghost'] }) + expect(() => { validateModules([a]) }).toThrow(/unknown.*ghost/i) + }) + + it('rejects duplicate settingsKey across modules', () => { + const a = makeModule('a', { settingsKey: 'shared' }) + const b = makeModule('b', { settingsKey: 'shared' }) + expect(() => { validateModules([a, b]) }).toThrow(/duplicate settingsKey.*shared/i) + }) + + it('rejects a settingsKey starting with underscore', () => { + const a = makeModule('a', { settingsKey: '_reserved' }) + expect(() => { validateModules([a]) }).toThrow(/reserved settingsKey.*_reserved/i) + }) + + it('rejects duplicate URI actions across modules', () => { + const handler = vi.fn() + const a = makeModule('a', { uriActions: [{ action: 'open-chat', handler }] }) + const b = makeModule('b', { uriActions: [{ action: 'open-chat', handler }] }) + expect(() => { validateModules([a, b]) }).toThrow(/duplicate.*open-chat/i) + }) + + it('accepts modules without dependsOn / settingsKey / uriActions', () => { + const a = makeModule('a') + expect(() => { validateModules([a]) }).not.toThrow() + }) +}) + +describe('validateSettingsKeys', () => { + it('ignores modules without a settingsKey', () => { + const a = makeModule('a') + expect(() => { validateSettingsKeys([a]) }).not.toThrow() + }) + + it('accepts unique non-reserved keys', () => { + const a = makeModule('a', { settingsKey: 'one' }) + const b = makeModule('b', { settingsKey: 'two' }) + expect(() => { validateSettingsKeys([a, b]) }).not.toThrow() + }) + + it('rejects duplicate keys', () => { + const a = makeModule('a', { settingsKey: 'dup' }) + const b = makeModule('b', { settingsKey: 'dup' }) + expect(() => { validateSettingsKeys([a, b]) }).toThrow(/duplicate settingsKey/i) + }) + + it('rejects underscore-prefixed keys', () => { + const a = makeModule('a', { settingsKey: '_secret' }) + expect(() => { validateSettingsKeys([a]) }).toThrow(/reserved settingsKey/i) + }) +}) + +describe('validateUriActions', () => { + it('accepts modules without URI actions', () => { + const a = makeModule('a') + expect(() => { validateUriActions([a]) }).not.toThrow() + }) + + it('accepts unique actions across modules', () => { + const a = makeModule('a', { uriActions: [{ action: 'one', handler: vi.fn() }] }) + const b = makeModule('b', { uriActions: [{ action: 'two', handler: vi.fn() }] }) + expect(() => { validateUriActions([a, b]) }).not.toThrow() + }) + + it('rejects duplicate actions within the same module', () => { + const handler = vi.fn() + const a = makeModule('a', { + uriActions: [ + { action: 'dup', handler }, + { action: 'dup', handler }, + ], + }) + expect(() => { validateUriActions([a]) }).toThrow(/duplicate URI action.*dup/i) + }) + + it('rejects duplicate actions across modules', () => { + const handler = vi.fn() + const a = makeModule('a', { uriActions: [{ action: 'dup', handler }] }) + const b = makeModule('b', { uriActions: [{ action: 'dup', handler }] }) + expect(() => { validateUriActions([a, b]) }).toThrow(/duplicate URI action.*dup/i) + }) +}) diff --git a/tests/core/settings-migration.test.ts b/tests/core/settings-migration.test.ts new file mode 100644 index 00000000..a8d6a688 --- /dev/null +++ b/tests/core/settings-migration.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, vi } from 'vitest' +import { migrateSettings } from '@/core/settings-migration' +import { fakeModulePorts } from '../__fakes__/fake-ports' +import type { ModuleDescriptor } from '@/modules' +import type { LoggerPort } from '@/domain/ports' + +function makeLogger(): LoggerPort { + return fakeModulePorts().logger +} + +function makeModule(id: string, overrides?: Partial): ModuleDescriptor { + return { id, init: vi.fn(), ...overrides } +} + +describe('migrateSettings', () => { + it('skips modules without a settingsKey', () => { + const logger = makeLogger() + const migrate = vi.fn() + const mod = makeModule('a', { migrate }) + const settings: Record = { unrelated: 42 } + + migrateSettings([mod], settings, logger) + + expect(migrate).not.toHaveBeenCalled() + expect(settings.a).toBeUndefined() + }) + + it('runs migrate() when storedVersion < targetVersion', () => { + const logger = makeLogger() + const migrate = vi.fn((_: number, blob: unknown) => ({ ...(blob as object), v: 1 })) + const mod = makeModule('a', { + settingsKey: 'a', + settingsVersion: 1, + migrate, + }) + const settings: Record = { a: { original: true }, _moduleVersions: { a: 0 } } + + migrateSettings([mod], settings, logger) + + expect(migrate).toHaveBeenCalledWith(0, { original: true }) + expect(settings.a).toMatchObject({ original: true, v: 1 }) + expect((settings._moduleVersions as Record).a).toBe(1) + }) + + it('skips migrate() when storedVersion equals targetVersion', () => { + const logger = makeLogger() + const migrate = vi.fn() + const mod = makeModule('a', { + settingsKey: 'a', + settingsVersion: 2, + migrate, + }) + const settings: Record = { a: { x: 1 }, _moduleVersions: { a: 2 } } + + migrateSettings([mod], settings, logger) + + expect(migrate).not.toHaveBeenCalled() + }) + + it('does NOT downgrade when storedVersion > targetVersion but overwrites the version record', () => { + const logger = makeLogger() + const migrate = vi.fn() + const mod = makeModule('a', { + settingsKey: 'a', + settingsVersion: 1, + migrate, + }) + const settings: Record = { _moduleVersions: { a: 5 }, a: { keepMe: true } } + + migrateSettings([mod], settings, logger) + + expect(migrate).not.toHaveBeenCalled() + expect(settings.a).toEqual({ keepMe: true }) + expect((settings._moduleVersions as Record).a).toBe(1) + }) + + it('falls back to settingsDefaults and warns when migrate() throws', () => { + const logger = makeLogger() + const mod = makeModule('a', { + settingsKey: 'a', + settingsVersion: 1, + settingsDefaults: { fallback: true }, + migrate: () => { throw new Error('migration failed') }, + }) + const settings: Record = { a: {}, _moduleVersions: { a: 0 } } + + migrateSettings([mod], settings, logger) + + expect(settings.a).toEqual({ fallback: true }) + expect(logger.warn).toHaveBeenCalledWith( + 'settings migration failed; falling back to defaults', + expect.objectContaining({ moduleId: 'a', settingsKey: 'a' }), + ) + }) + + it('runs validateSettings after a successful migration', () => { + const logger = makeLogger() + const validateSettings = vi.fn((blob: unknown) => ({ ...(blob as object), validated: true })) + const mod = makeModule('a', { + settingsKey: 'a', + settingsVersion: 1, + migrate: (_: number, blob: unknown) => ({ ...(blob as object), migrated: true }), + validateSettings, + }) + const settings: Record = { a: { x: 1 }, _moduleVersions: { a: 0 } } + + migrateSettings([mod], settings, logger) + + expect(validateSettings).toHaveBeenCalledWith(expect.objectContaining({ x: 1, migrated: true })) + expect(settings.a).toMatchObject({ x: 1, migrated: true, validated: true }) + }) + + it('falls back to settingsDefaults and warns when validateSettings throws', () => { + const logger = makeLogger() + const mod = makeModule('a', { + settingsKey: 'a', + settingsDefaults: { fallback: true }, + validateSettings: () => { throw new Error('invalid') }, + }) + const settings: Record = { a: { bad: 'data' } } + + migrateSettings([mod], settings, logger) + + expect(settings.a).toEqual({ fallback: true }) + expect(logger.warn).toHaveBeenCalledWith( + 'validateSettings failed; falling back to defaults', + expect.objectContaining({ moduleId: 'a' }), + ) + }) + + it('initialises _moduleVersions when it is missing entirely', () => { + const logger = makeLogger() + const migrate = vi.fn((_: number, blob: unknown) => ({ ...(blob as object), migrated: true })) + const mod = makeModule('a', { + settingsKey: 'a', + settingsVersion: 1, + migrate, + }) + const settings: Record = { a: { x: 1 } } + + migrateSettings([mod], settings, logger) + + expect(migrate).toHaveBeenCalledWith(0, { x: 1 }) + expect(settings.a).toMatchObject({ x: 1, migrated: true }) + expect(settings._moduleVersions).toEqual({ a: 1 }) + }) + + it('recovers gracefully when _moduleVersions is not a plain object', () => { + const logger = makeLogger() + const migrate = vi.fn().mockReturnValue({ migrated: true }) + const mod = makeModule('a', { + settingsKey: 'a', + settingsVersion: 1, + migrate, + }) + const settings: Record = { _moduleVersions: 'corrupted', a: {} } + + migrateSettings([mod], settings, logger) + + expect(migrate).toHaveBeenCalledWith(0, {}) + expect(settings._moduleVersions).toEqual({ a: 1 }) + }) + + it('recovers when _moduleVersions is an array', () => { + const logger = makeLogger() + const migrate = vi.fn().mockReturnValue({ migrated: true }) + const mod = makeModule('a', { + settingsKey: 'a', + settingsVersion: 1, + migrate, + }) + const settings: Record = { _moduleVersions: [1, 2, 3], a: {} } + + migrateSettings([mod], settings, logger) + + expect(migrate).toHaveBeenCalledWith(0, {}) + expect(settings._moduleVersions).toEqual({ a: 1 }) + }) + + it('recovers when _moduleVersions is null', () => { + const logger = makeLogger() + const migrate = vi.fn().mockReturnValue({ migrated: true }) + const mod = makeModule('a', { + settingsKey: 'a', + settingsVersion: 1, + migrate, + }) + const settings: Record = { _moduleVersions: null, a: {} } + + migrateSettings([mod], settings, logger) + + expect(migrate).toHaveBeenCalledWith(0, {}) + }) + + it('uses an empty object for missing blobs', () => { + const logger = makeLogger() + const migrate = vi.fn((_: number, blob: unknown) => ({ ...(blob as object), migrated: true })) + const mod = makeModule('a', { + settingsKey: 'a', + settingsVersion: 1, + migrate, + }) + const settings: Record = {} + + migrateSettings([mod], settings, logger) + + expect(migrate).toHaveBeenCalledWith(0, {}) + }) +})