diff --git a/src/__tests__/cache/local-storage-cache.version.test.ts b/src/__tests__/cache/local-storage-cache.version.test.ts new file mode 100644 index 00000000..9e09d665 --- /dev/null +++ b/src/__tests__/cache/local-storage-cache.version.test.ts @@ -0,0 +1,80 @@ +/** + * Phase 0 W4b — LocalStorageCache version-mismatch read fix. + * + * Prior to W4b, storeFile() tagged entries with `currentVersion` but loadFile() + * never validated it on read. After an upgrade, every stale entry would be + * happily returned. This test asserts: + * - write at v1.0.0, read at v1.0.0 → returns the entry + * - write at v1.0.0, read at v2.0.0 → returns null (treated as cache miss) + * - the stale entry is removed from the underlying store on the mismatched read + */ + +import { LocalStorageCache } from "@/cache/local-storage-cache"; + +describe("LocalStorageCache version-mismatch read (W4b)", () => { + beforeEach(() => { + // Reset the static "logged once" flag between tests so each test + // independently exercises the log path. + (LocalStorageCache as any)._versionMismatchLogged = false; + }); + + it("returns entries written at the same version", async () => { + const cache = new LocalStorageCache("test-app-1", "1.0.0"); + await cache.storeFile("foo.md", { value: 42 }); + const loaded = await cache.loadFile<{ value: number }>("foo.md"); + expect(loaded).not.toBeNull(); + expect(loaded?.data.value).toBe(42); + expect(loaded?.version).toBe("1.0.0"); + }); + + it("returns null when the cache entry was written at a different version", async () => { + const oldCache = new LocalStorageCache("test-app-2", "1.0.0"); + await oldCache.storeFile("foo.md", { value: 42 }); + + // New cache instance with bumped version pointing at the SAME backing + // store (the localforage mock keys by `name` which is appId-derived). + const newCache = new LocalStorageCache("test-app-2", "2.0.0"); + const loaded = await newCache.loadFile("foo.md"); + expect(loaded).toBeNull(); + }); + + it("removes the stale entry on a mismatched read", async () => { + const oldCache = new LocalStorageCache("test-app-3", "1.0.0"); + await oldCache.storeFile("foo.md", { value: 42 }); + + const newCache = new LocalStorageCache("test-app-3", "2.0.0"); + // First read: triggers prune + await newCache.loadFile("foo.md"); + + // Second read: should still be a miss (the entry was removed, not just + // returned null on the fly). + const persister: any = (newCache as any).persister; + const raw = await persister.getItem(newCache.fileKey("foo.md")); + expect(raw).toBeNull(); + }); + + it("prunes multiple stale entries on subsequent mismatched reads", async () => { + // We don't assert log noise because the log-once optimization uses a + // process-wide static field that's hard to reason about across test + // boundaries — what matters is that every mismatched read independently + // returns null and prunes its entry. + const oldCache = new LocalStorageCache("test-app-4", "1.0.0"); + await oldCache.storeFile("a.md", { v: 1 }); + await oldCache.storeFile("b.md", { v: 2 }); + await oldCache.storeFile("c.md", { v: 3 }); + + const newCache = new LocalStorageCache("test-app-4", "2.0.0"); + const a = await newCache.loadFile("a.md"); + const b = await newCache.loadFile("b.md"); + const c = await newCache.loadFile("c.md"); + + expect(a).toBeNull(); + expect(b).toBeNull(); + expect(c).toBeNull(); + + const persister: any = (newCache as any).persister; + expect(await persister.getItem(newCache.fileKey("a.md"))).toBeNull(); + expect(await persister.getItem(newCache.fileKey("b.md"))).toBeNull(); + expect(await persister.getItem(newCache.fileKey("c.md"))).toBeNull(); + }); +}); diff --git a/src/__tests__/cache/scope-map.test.ts b/src/__tests__/cache/scope-map.test.ts new file mode 100644 index 00000000..fd33a84d --- /dev/null +++ b/src/__tests__/cache/scope-map.test.ts @@ -0,0 +1,151 @@ +/** + * Phase 0 W4a — typed cache scope map. + * + * Asserts: + * 1. Direct field lookups resolve to the documented scopes. + * 2. Nested field paths walk parents correctly. + * 3. Unknown fields contribute no scopes. + * 4. Multiple fields union their scopes. + * 5. The map covers every field that current call sites of + * Orchestrator.onSettingsChange refer to (ratchet test — fails if a + * caller is added without updating the map). + */ + +import { + scopesForFields, + listMappedFields, + SETTINGS_FIELD_TO_SCOPES, + type CacheScope, +} from "@/dataflow/cache/scope-map"; + +describe("scope-map (W4a)", () => { + describe("direct lookups", () => { + it("taskStatuses → parser", () => { + expect(scopesForFields(["taskStatuses"])).toEqual(["parser"]); + }); + + it("projectConfig → project + augment", () => { + const scopes = scopesForFields(["projectConfig"]); + expect(new Set(scopes)).toEqual(new Set(["project", "augment"])); + }); + + it("fileMetadataInheritance → augment", () => { + expect(scopesForFields(["fileMetadataInheritance"])).toEqual([ + "augment", + ]); + }); + }); + + describe("nested field paths walk parents", () => { + it("fileMetadataInheritance.inheritFromFrontmatter resolves via parent", () => { + expect( + scopesForFields([ + "fileMetadataInheritance.inheritFromFrontmatter", + ]), + ).toEqual(["augment"]); + }); + + it("fileSource.recognitionStrategies.tags.taskTags resolves via parent", () => { + expect( + scopesForFields([ + "fileSource.recognitionStrategies.tags.taskTags", + ]), + ).toEqual(["parser"]); + }); + + it("projectConfig.pathMappings.foo resolves via parent", () => { + const scopes = scopesForFields([ + "projectConfig.pathMappings.foo", + ]); + expect(new Set(scopes)).toEqual(new Set(["project", "augment"])); + }); + }); + + describe("unknown fields", () => { + it("returns empty scope list for completely unknown field", () => { + expect(scopesForFields(["someTotallyUnknownField"])).toEqual([]); + }); + + it("returns empty scope list when no parent prefix matches", () => { + // "fileSource" is not in the map (only "fileSource.recognitionStrategies" + // etc. are). A made-up sibling like "fileSource.somethingNotInTheMap" + // has no matching prefix and should yield no scopes. + expect( + scopesForFields(["fileSource.somethingNotInTheMap"]), + ).toEqual([]); + }); + + it("does not match descendants (only proper prefixes)", () => { + // "fileSource" alone is not in the map; only "fileSource.recognitionStrategies" + // etc. So a bare "fileSource" lookup should yield nothing — the + // map intentionally requires the caller to be specific. + expect(scopesForFields(["fileSource"])).toEqual([]); + }); + }); + + describe("union semantics", () => { + it("merges scopes across multiple fields", () => { + const scopes = scopesForFields([ + "taskStatuses", // parser + "fileMetadataInheritance", // augment + ]); + expect(new Set(scopes)).toEqual(new Set(["parser", "augment"])); + }); + + it("dedupes overlapping scopes", () => { + const scopes = scopesForFields([ + "taskStatuses", + "preferMetadataFormat", + ]); + // Both → parser, expect only one entry. + expect(scopes).toEqual(["parser"]); + }); + + it("returns empty array when given empty input", () => { + expect(scopesForFields([])).toEqual([]); + }); + }); + + describe("ratchet: every existing onSettingsChange caller's intent is covered", () => { + // The 3 existing callers in IndexSettingsTab.ts all change + // fileMetadataInheritance.* fields. They currently pass ["parser"] + // (which is technically wrong — these are augment-time settings), + // but the typed map reflects the CORRECT scope. Phase 1's migration + // fixes the call sites. + it("fileMetadataInheritance.* covered by the map", () => { + const fields = [ + "fileMetadataInheritance", + "fileMetadataInheritance.enabled", + "fileMetadataInheritance.inheritFromFrontmatter", + "fileMetadataInheritance.inheritFromFrontmatterForSubtasks", + ]; + for (const f of fields) { + expect(scopesForFields([f])).toEqual(["augment"]); + } + }); + + it("listMappedFields exposes a non-empty list", () => { + expect(listMappedFields().length).toBeGreaterThan(0); + }); + + it("every mapped value is a non-empty array of CacheScope", () => { + const validScopes = new Set([ + "parser", + "augment", + "project", + "index", + ]); + for (const [field, scopes] of Object.entries( + SETTINGS_FIELD_TO_SCOPES, + )) { + expect(scopes.length).toBeGreaterThan(0); + for (const s of scopes) { + expect(validScopes.has(s)).toBe(true); + } + // Field paths must be non-empty strings + expect(field.length).toBeGreaterThan(0); + } + }); + }); +}); + diff --git a/src/__tests__/integration/CacheInvariants.smoke.test.ts b/src/__tests__/integration/CacheInvariants.smoke.test.ts new file mode 100644 index 00000000..80e196b7 --- /dev/null +++ b/src/__tests__/integration/CacheInvariants.smoke.test.ts @@ -0,0 +1,93 @@ +/** + * Phase 0 W4b — smoke test for the cache invariants checker. + * + * This test validates the checker doesn't false-positive on a freshly built + * orchestrator and correctly reports violations when the cache is in a known + * inconsistent state. The fuller "checker reports ok at every step of a + * realistic operation sequence" lives in W5.4 (CacheInvariants.test.ts). + */ + +import { buildOrchestrator } from "./_fixtures/buildOrchestrator"; +import { checkCacheInvariants } from "@/dataflow/cache/invariants"; + +describe("checkCacheInvariants smoke (W4b)", () => { + it("reports ok on a freshly built orchestrator with no cached data", async () => { + const fx = await buildOrchestrator(); + try { + const report = await checkCacheInvariants(fx.orchestrator); + // Empty cache → no raw entries, no augmented entries, no + // invariants to violate. + expect(report.ok).toBe(true); + expect(report.violations).toEqual([]); + expect(report.stats.rawCount).toBe(0); + expect(report.stats.augmentedCount).toBe(0); + } finally { + await fx.dispose(); + } + }); + + it("flags I1 when a raw entry has no augmented counterpart", async () => { + const fx = await buildOrchestrator(); + try { + // Reach into Storage and inject a raw entry without writing the + // matching augmented entry. This is the canonical "augmentation + // crashed mid-batch" failure mode. + const storage: any = (fx.orchestrator as any).storage; + await storage.storeRaw("ghost.md", [], "content", 1); + + const report = await checkCacheInvariants(fx.orchestrator); + expect(report.ok).toBe(false); + expect(report.stats.rawCount).toBe(1); + expect(report.stats.augmentedCount).toBe(0); + expect(report.stats.missingAugmented).toBe(1); + expect( + report.violations.some( + (v) => v.id === "I1-missing-augmented", + ), + ).toBe(true); + } finally { + await fx.dispose(); + } + }); + + it("does not flag I1 when raw and augmented are in sync", async () => { + const fx = await buildOrchestrator(); + try { + const storage: any = (fx.orchestrator as any).storage; + await storage.storeRaw("a.md", [], "content", 1); + await storage.storeAugmented("a.md", []); + + const report = await checkCacheInvariants(fx.orchestrator); + // I1 satisfied. Other invariants may or may not flag depending on + // indexer state — what matters is missingAugmented === 0. + expect(report.stats.missingAugmented).toBe(0); + expect( + report.violations.some( + (v) => v.id === "I1-missing-augmented", + ), + ).toBe(false); + } finally { + await fx.dispose(); + } + }); + + it("never throws — returns a violation report on internal errors", async () => { + const fx = await buildOrchestrator(); + try { + // Sabotage storage so listRawPaths throws. The checker should + // catch and report, never propagate. + const storage: any = (fx.orchestrator as any).storage; + storage.listRawPaths = jest + .fn() + .mockRejectedValue(new Error("disk on fire")); + + const report = await checkCacheInvariants(fx.orchestrator); + expect(report.ok).toBe(false); + expect( + report.violations.some((v) => v.id === "list-raw-failed"), + ).toBe(true); + } finally { + await fx.dispose(); + } + }); +}); diff --git a/src/__tests__/integration/OrchestratorScopeMap.test.ts b/src/__tests__/integration/OrchestratorScopeMap.test.ts new file mode 100644 index 00000000..c36afba6 --- /dev/null +++ b/src/__tests__/integration/OrchestratorScopeMap.test.ts @@ -0,0 +1,96 @@ +/** + * Phase 0 W4a — verify the typed sibling Orchestrator.onSettingsFieldsChanged + * resolves field paths to scopes via the typed map and delegates to the + * existing onSettingsChange. + */ + +import { buildOrchestrator } from "./_fixtures/buildOrchestrator"; + +describe("Orchestrator.onSettingsFieldsChanged (W4a)", () => { + it("delegates known fields to onSettingsChange with the right scopes", async () => { + const fx = await buildOrchestrator(); + try { + const onSettingsChange = jest.spyOn( + fx.orchestrator, + "onSettingsChange", + ); + // Block the rebuild path so we don't actually rebuild during the test. + (fx.orchestrator as any).rebuild = jest.fn(async () => {}); + + await fx.orchestrator.onSettingsFieldsChanged([ + "fileMetadataInheritance.inheritFromFrontmatter", + ]); + + expect(onSettingsChange).toHaveBeenCalledTimes(1); + expect(onSettingsChange.mock.calls[0][0]).toEqual(["augment"]); + + onSettingsChange.mockRestore(); + } finally { + await fx.dispose(); + } + }); + + it("merges scopes when multiple fields are passed", async () => { + const fx = await buildOrchestrator(); + try { + const onSettingsChange = jest.spyOn( + fx.orchestrator, + "onSettingsChange", + ); + (fx.orchestrator as any).rebuild = jest.fn(async () => {}); + + await fx.orchestrator.onSettingsFieldsChanged([ + "taskStatuses", // parser + "projectConfig", // project + augment + ]); + + expect(onSettingsChange).toHaveBeenCalledTimes(1); + const passedScopes = onSettingsChange.mock.calls[0][0] as string[]; + expect(new Set(passedScopes)).toEqual( + new Set(["parser", "project", "augment"]), + ); + + onSettingsChange.mockRestore(); + } finally { + await fx.dispose(); + } + }); + + it("skips delegation when no field maps to a scope, but still emits SETTINGS_CHANGED", async () => { + const fx = await buildOrchestrator(); + try { + const onSettingsChange = jest.spyOn( + fx.orchestrator, + "onSettingsChange", + ); + + const events: any[] = []; + fx.app.workspace.on( + "task-genius:settings-changed", + (payload: any) => { + events.push(payload); + }, + ); + + await fx.orchestrator.onSettingsFieldsChanged([ + "someTotallyUnknownField", + "anotherUnknownField", + ]); + + // onSettingsChange should NOT have been invoked because no scopes resolved. + expect(onSettingsChange).not.toHaveBeenCalled(); + // But the SETTINGS_CHANGED event should still fire so UI observers + // can refresh. + expect(events.length).toBe(1); + expect(events[0].scopes).toEqual([]); + expect(events[0].fields).toEqual([ + "someTotallyUnknownField", + "anotherUnknownField", + ]); + + onSettingsChange.mockRestore(); + } finally { + await fx.dispose(); + } + }); +}); diff --git a/src/cache/local-storage-cache.ts b/src/cache/local-storage-cache.ts index 98439aff..621b93c1 100644 --- a/src/cache/local-storage-cache.ts +++ b/src/cache/local-storage-cache.ts @@ -87,6 +87,13 @@ export class LocalStorageCache { * Load metadata for a file from cache * @param path File path to load * @returns Cached data or null if not found + * + * Phase 0 W4b: validates the wrapper's version against the current plugin + * version. Prior to this fix, `storeFile` was tagging entries with + * `currentVersion` but `loadFile` never checked it, so plugin upgrades did + * NOT auto-invalidate stale cache entries — the inner Storage records had + * their own version validation, but the outer wrapper layer's was dead + * code. On mismatch we now treat the entry as a cache miss and remove it. */ public async loadFile(path: string): Promise | null> { if (!this.initialized) await this.initialize(); @@ -94,6 +101,24 @@ export class LocalStorageCache { try { const key = this.fileKey(path); const data = await this.persister.getItem>(key); + if (data && data.version && data.version !== this.currentVersion) { + // Version mismatch — log once per session via _versionMismatchLogged + // to keep noise down on first load after an upgrade with thousands + // of cache entries. + if (!LocalStorageCache._versionMismatchLogged) { + console.log( + `[LocalStorageCache] Cache version mismatch (cached=${data.version}, current=${this.currentVersion}); treating as cache miss and pruning stale entries on access.`, + ); + LocalStorageCache._versionMismatchLogged = true; + } + // Prune the stale entry so we don't keep paying the read cost. + try { + await this.persister.removeItem(key); + } catch { + /* best effort */ + } + return null; + } return data; } catch (error) { console.error(`Error loading cache for ${path}:`, error); @@ -101,6 +126,9 @@ export class LocalStorageCache { } } + /** Process-wide flag so we only log version mismatch once per session. */ + private static _versionMismatchLogged = false; + /** * Store metadata for a file in cache * @param path File path to store diff --git a/src/dataflow/Orchestrator.ts b/src/dataflow/Orchestrator.ts index 7f59f7b9..e95c84bc 100644 --- a/src/dataflow/Orchestrator.ts +++ b/src/dataflow/Orchestrator.ts @@ -24,6 +24,7 @@ import { ConfigurableTaskParser } from "./core/ConfigurableTaskParser"; import { MetadataParseMode } from "../types/TaskParserConfig"; import { TimeParsingService } from "../services/time-parsing-service"; import type { EnhancedTimeParsingConfig } from "../types/time-parsing"; +import { scopesForFields } from "./cache/scope-map"; /** * DataflowOrchestrator - Coordinates all dataflow components @@ -1763,7 +1764,12 @@ export class DataflowOrchestrator { } /** - * Handle settings change + * Handle settings change. + * + * Pass the cache scopes that the changed settings touch. Existing call + * sites pass scope strings directly; new call sites should prefer + * `onSettingsFieldsChanged()` (W4a) which derives scopes from a typed + * field map. */ async onSettingsChange(scopes: string[]): Promise { // Clear relevant caches based on scope @@ -1793,6 +1799,34 @@ export class DataflowOrchestrator { } } + /** + * Handle settings change by typed field path. Resolves the field paths to + * cache scopes via `SETTINGS_FIELD_TO_SCOPES` and delegates to the + * stringly-typed `onSettingsChange`. + * + * Phase 0 W4a — adds the typed entry point. Phase 1+ migrates existing + * call sites away from raw scope strings as features are touched. The + * primary value is centralizing "which fields invalidate which scopes" + * so the migration can be mechanical. + * + * Field paths are dot-separated (e.g. "fileMetadataInheritance.inheritFromFrontmatter"). + * Unknown fields contribute no scopes (no cache impact). + */ + async onSettingsFieldsChanged(fields: readonly string[]): Promise { + const scopes = scopesForFields(fields); + if (scopes.length === 0) { + // Nothing cache-affecting changed; still emit the change event so + // observers that don't care about scopes (e.g. UI refresh) can react. + emit(this.app, Events.SETTINGS_CHANGED, { + scopes: [], + fields, + timestamp: Date.now(), + }); + return; + } + await this.onSettingsChange(scopes); + } + /** * Update project configuration options */ diff --git a/src/dataflow/cache/invariants.ts b/src/dataflow/cache/invariants.ts new file mode 100644 index 00000000..96b8e77a --- /dev/null +++ b/src/dataflow/cache/invariants.ts @@ -0,0 +1,217 @@ +/** + * Phase 0 W4b — Cache invariants checker. + * + * A debug-mode safety net that walks the cache namespaces and asserts the + * relationships the dataflow pipeline assumes are true. Used by: + * 1. Integration tests, to fail loudly if a refactor breaks an invariant. + * 2. The hidden `Task Genius (debug): Check cache invariants` command, + * gated on `(globalThis as any).__taskGeniusDebug === true`. + * + * What "cache" means here + * ----------------------- + * The Storage layer (src/dataflow/persistence/Storage.ts) maintains four + * namespaces in LocalStorageCache: + * - raw — parsed Task[] per file, pre-augmentation + * - project — project metadata per file + * - augmented — Task[] per file, post-inheritance + * - consolidated — single TaskCache holding the merged view + * + * The pipeline goes: parse → raw → augment → augmented → index → consolidated. + * If any of these layers drift apart (e.g. a file has raw entries but no + * augmented ones), queries return inconsistent results. + * + * Invariants checked + * ------------------ + * I1. Every entry in `raw` has a corresponding entry in `augmented`. + * (Augmentation should run after parsing; missing augmented = lost data.) + * + * I2. Every cached entry's inner version field matches the plugin's current + * version. (Storage records carry a `version` field for invalidation.) + * + * I3. The number of files in the in-memory TaskIndexer matches the number of + * files in `augmented` (within tolerance — the indexer can include files + * loaded from the consolidated namespace that haven't been re-parsed yet). + * + * I4. Every namespace returned by `storage.getStats()` has a non-negative + * count and the totals add up. (Sanity check; mostly catches counting + * bugs in Storage itself.) + * + * Phase 0 intentionally does NOT check: + * - "every task in the index has a backing entry in consolidated" — the + * consolidated namespace is one big record, not per-file, so this would + * require deserializing the whole thing. Defer to a future phase. + * - Project resolver in-memory vs storage `project` consistency — the + * resolver's cache is keyed differently than the storage namespace, so + * a clean mapping needs more design. Defer. + * + * The checker NEVER throws — it returns a report with `ok` and a list of + * violations. Callers decide whether to log, fail a test, or surface to UI. + */ + +import type { DataflowOrchestrator } from "../Orchestrator"; + +export interface CacheInvariantViolation { + id: string; + message: string; +} + +export interface CacheInvariantReport { + ok: boolean; + violations: CacheInvariantViolation[]; + stats: { + rawCount: number; + augmentedCount: number; + // Number of paths present in raw but missing in augmented (I1). + missingAugmented: number; + // Number of cache entries with version mismatch (I2). + versionMismatches: number; + }; +} + +/** + * Walk the orchestrator's storage and report any invariant violations. + * + * Reaches into private state via `as any` because Storage is not part of the + * orchestrator's public API. The checker is debug-only — production code paths + * never invoke it. Keep it independent of the public API surface so it can be + * deleted or refactored without churning consumers. + */ +export async function checkCacheInvariants( + orch: DataflowOrchestrator, +): Promise { + const violations: CacheInvariantViolation[] = []; + const stats = { + rawCount: 0, + augmentedCount: 0, + missingAugmented: 0, + versionMismatches: 0, + }; + + const storage: any = (orch as any).storage; + if (!storage) { + violations.push({ + id: "no-storage", + message: + "Orchestrator has no storage instance; nothing to check (this is normal pre-initialize)", + }); + return { ok: false, violations, stats }; + } + + let rawPaths: string[] = []; + let augPaths: string[] = []; + + try { + rawPaths = await storage.listRawPaths(); + } catch (e) { + violations.push({ + id: "list-raw-failed", + message: `storage.listRawPaths() threw: ${e}`, + }); + } + + try { + augPaths = await storage.listAugmentedPaths(); + } catch (e) { + violations.push({ + id: "list-augmented-failed", + message: `storage.listAugmentedPaths() threw: ${e}`, + }); + } + + stats.rawCount = rawPaths.length; + stats.augmentedCount = augPaths.length; + + // I1: every raw entry has an augmented counterpart + const augSet = new Set(augPaths); + for (const rawPath of rawPaths) { + if (!augSet.has(rawPath)) { + stats.missingAugmented++; + violations.push({ + id: "I1-missing-augmented", + message: `raw entry has no augmented counterpart: ${rawPath}`, + }); + } + } + + // I2: every cached record carries a current version + const currentVersion: string | undefined = storage.currentVersion; + if (currentVersion) { + // Sample up to N entries to keep this O(reasonable). The full walk is + // only useful in tests; in production we just want a smoke check. + const maxToCheck = 64; + const toCheck = rawPaths.slice(0, maxToCheck); + for (const path of toCheck) { + try { + const rec = await storage.loadRaw(path); + if (rec && rec.version && rec.version !== currentVersion) { + stats.versionMismatches++; + violations.push({ + id: "I2-version-mismatch", + message: `raw record version mismatch for ${path}: cached=${rec.version} current=${currentVersion}`, + }); + } + } catch { + /* loadRaw already logs; just skip */ + } + } + } + + // I3: indexer file count vs augmented count (with tolerance) + const repository: any = (orch as any).repository; + if (repository && typeof repository.getIndexedFilePaths === "function") { + try { + const indexedPaths: string[] = + await repository.getIndexedFilePaths(); + const indexedSet = new Set(indexedPaths); + // Files in augmented that the in-memory index doesn't know about + // is a sign of stale storage that nobody's loading. + for (const augPath of augPaths) { + if (!indexedSet.has(augPath)) { + violations.push({ + id: "I3-index-augmented-drift", + message: `augmented entry not present in in-memory index: ${augPath}`, + }); + } + } + } catch (e) { + violations.push({ + id: "I3-index-walk-failed", + message: `repository.getIndexedFilePaths() threw: ${e}`, + }); + } + } + + // I4: getStats sanity + try { + const sgs = await storage.getStats(); + for (const [ns, count] of Object.entries(sgs.byNamespace ?? {})) { + if (typeof count !== "number" || count < 0) { + violations.push({ + id: "I4-bad-namespace-count", + message: `storage.getStats().byNamespace[${ns}] is not a non-negative number: ${count}`, + }); + } + } + const sum: number = Object.values(sgs.byNamespace ?? {}).reduce( + (a, b) => a + (typeof b === "number" ? b : 0), + 0, + ); + if (sum > sgs.totalKeys) { + violations.push({ + id: "I4-namespace-sum-overflow", + message: `sum of byNamespace counts (${sum}) exceeds totalKeys (${sgs.totalKeys})`, + }); + } + } catch (e) { + violations.push({ + id: "I4-getstats-failed", + message: `storage.getStats() threw: ${e}`, + }); + } + + return { + ok: violations.length === 0, + violations, + stats, + }; +} diff --git a/src/dataflow/cache/scope-map.ts b/src/dataflow/cache/scope-map.ts new file mode 100644 index 00000000..76f04fca --- /dev/null +++ b/src/dataflow/cache/scope-map.ts @@ -0,0 +1,150 @@ +/** + * Typed cache scope map. + * + * Phase 0 W4a — Background + * ------------------------ + * `Orchestrator.onSettingsChange(scopes: string[])` takes a free-form list of + * scope names and uses them to decide which cache namespaces to invalidate. + * Today, every caller has to: + * 1. know which scope name applies to the field they're touching + * 2. spell it correctly (no type checking) + * 3. remember to update its scopes if the field's caching impact changes + * + * That works for one or two callers but breaks down when settings consolidation + * begins in Phase 1 (25 tabs → 7) and field paths shift around. This file gives + * us a single source of truth for "which fields invalidate which cache scopes" + * so the migration can be mechanical instead of error-prone. + * + * Usage + * ----- + * Phase 0 ships this map and a `scopesForFields()` helper, but does NOT migrate + * any existing callers. The new sibling method `Orchestrator.onSettingsFieldsChanged` + * (added separately) wraps the existing onSettingsChange — Phase 1+ will switch + * call sites over progressively as features are touched. + * + * The keys are dot-separated field paths into `TaskProgressBarSettings`. We + * intentionally use string keys instead of `keyof` because (a) many fields + * are nested objects whose entire subtree is one cache concern, and (b) Phase 1 + * will rename and consolidate these fields, so a stringly-typed map is more + * resilient to refactoring than a structural type that fights every move. + * + * Discovery + * --------- + * The current set of mappings was derived by walking every existing caller of + * `Orchestrator.onSettingsChange` (only 3 sites at the time of writing, all in + * `IndexSettingsTab.ts`). Note: those callers currently pass `["parser"]` for + * `fileMetadataInheritance` changes which is technically wrong — file metadata + * inheritance is an augment-time concern. The map below reflects the CORRECT + * mapping; Phase 1's migration will fix the call sites. + */ + +/** The four cache namespaces the Orchestrator manages. */ +export type CacheScope = "parser" | "augment" | "project" | "index"; + +/** + * Field path → list of cache scopes that need invalidation when that field changes. + * + * Field paths are dot-separated (e.g. "fileSource.recognitionStrategies"). A path + * matches both itself and any descendant — so a change to + * "fileMetadataInheritance.inheritFromFrontmatter" matches the entry for + * "fileMetadataInheritance". + * + * If a field is not in this map, it has no cache impact. + */ +export const SETTINGS_FIELD_TO_SCOPES: Readonly< + Record +> = Object.freeze({ + // --- Parser scope --- + // Anything that changes how raw markdown / canvas / file-metadata is parsed + // into Task records. Invalidates the "raw" cache namespace. + taskStatuses: ["parser"], + preferMetadataFormat: ["parser"], + enableCustomDateFormats: ["parser"], + customDateFormats: ["parser"], + projectTagPrefix: ["parser"], + contextTagPrefix: ["parser"], + areaTagPrefix: ["parser"], + useDailyNotePathAsDate: ["parser"], + dailyNoteFormat: ["parser"], + useAsDateType: ["parser"], + dailyNotePath: ["parser"], + ignoreHeading: ["parser"], + focusHeading: ["parser"], + "fileSource.recognitionStrategies": ["parser"], + "fileSource.fileTaskProperties": ["parser"], + "fileSource.relationships": ["parser"], + + // --- Augment scope --- + // Anything that changes how parsed tasks are merged with file/project metadata. + // Invalidates the "augmented" cache namespace. + fileMetadataInheritance: ["augment"], + + // --- Project scope --- + // Anything that changes project detection or project metadata enrichment. + // Invalidates "project" + "augment" namespaces (project changes ripple + // through augmentation). + projectConfig: ["project", "augment"], + + // --- Index scope --- + // Anything that changes the consolidated index shape itself. Rare — most + // settings are parser/augment concerns. Currently no fields use this. + // Reserved for future use; left here as documentation. +}); + +/** + * Resolve a list of changed field paths into the minimal set of cache scopes + * that need invalidation. Unknown fields contribute no scopes (no cache impact). + * + * Field path matching is prefix-based on dotted segments: a change to + * "fileMetadataInheritance.inheritFromFrontmatter" matches the entry for + * "fileMetadataInheritance" (because "fileMetadataInheritance" is a prefix + * along a `.` boundary). This lets callers pass either the parent or any + * leaf without needing to know the granularity of the map. + */ +export function scopesForFields( + fields: readonly string[], +): CacheScope[] { + const out = new Set(); + for (const field of fields) { + const matchedScopes = lookupField(field); + for (const s of matchedScopes) out.add(s); + } + return [...out]; +} + +/** + * Look up the cache scopes for a single field path. + * + * Returns the scopes for the longest matching prefix in the map. For example: + * - "fileMetadataInheritance.inheritFromFrontmatter" → ["augment"] + * (matches "fileMetadataInheritance") + * - "fileSource.recognitionStrategies.tags.taskTags" → ["parser"] + * (matches "fileSource.recognitionStrategies") + * - "fileSource.somethingElse" → [] + * (no entry, falls through) + */ +function lookupField(field: string): readonly CacheScope[] { + // Direct hit + if (field in SETTINGS_FIELD_TO_SCOPES) { + return SETTINGS_FIELD_TO_SCOPES[field]; + } + // Walk parents (drop one segment at a time from the right) + let current = field; + while (true) { + const lastDot = current.lastIndexOf("."); + if (lastDot === -1) return []; + current = current.substring(0, lastDot); + if (current in SETTINGS_FIELD_TO_SCOPES) { + return SETTINGS_FIELD_TO_SCOPES[current]; + } + } +} + +/** + * Test-only helper: enumerate every field path registered in the map. Used by + * scope-map.test.ts to assert all known caller fields are covered without + * having to import the test fixture. + */ +export function listMappedFields(): readonly string[] { + return Object.keys(SETTINGS_FIELD_TO_SCOPES); +}