diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index c35d8a87..cb00916d 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -44,6 +44,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l - (#1737) Clarified that changing the task tag does not automatically update existing Base view filters. Thanks to @goldorak00 for reporting this and @Domcoppinger for the first-time setup feedback. - (#2005) Excluded archived task notes and archived project-note references from Task & Project Statistics. Thanks to @Xiarno for suggesting this. - Added a local review-type lint check that catches dependency-resolution warnings from Obsidian's online review before submission. +- Improved dependency and project relationship cache updates so ordinary note edits avoid unnecessary relationship recalculation, while blocked and blocking state stays current when dependency status changes. ## Fixed diff --git a/src/utils/DependencyCache.ts b/src/utils/DependencyCache.ts index 76c5a292..c2958561 100644 --- a/src/utils/DependencyCache.ts +++ b/src/utils/DependencyCache.ts @@ -33,10 +33,14 @@ export class DependencyCache extends Events { // Dependency indexes private dependencySources: Map> = new Map(); // task path -> blocking task paths private dependencyTargets: Map> = new Map(); // task path -> tasks blocked by this task + private activeDependencySources: Map> = new Map(); // task path -> incomplete blocking task paths + private activeDependencyTargets: Map> = new Map(); // task path -> tasks actively blocked by this task // Project references index private projectReferences: Map> = new Map(); // project path -> Set private projectReferenceSources: Map> = new Map(); // task path -> Set + private relationshipFingerprints: Map = new Map(); // task path -> normalized dependency/project fields + private completedStatusByPath: Map = new Map(); // file path -> completion state for status-aware dependency lookups // Initialization state private initialized = false; @@ -142,15 +146,27 @@ export class DependencyCache extends Events { return; } - const metadata = this.app.metadataCache.getFileCache(file); - if (!metadata?.frontmatter) { - this.clearForwardDependencies(file.path); + const frontmatter = this.getFrontmatterFromCache(cache) ?? this.getFrontmatterForFile(file); + this.updateCompletionState(file.path, frontmatter); + + if (!frontmatter) { + if (this.hasForwardRelationships(file.path)) { + this.clearForwardDependencies(file.path); + } this.triggerIfFileRelationshipsChanged(file.path, before); return; } - if (!this.isTaskFileCallback(metadata.frontmatter)) { - this.clearForwardDependencies(file.path); + if (!this.isTaskFileCallback(frontmatter)) { + if (this.hasForwardRelationships(file.path)) { + this.clearForwardDependencies(file.path); + } + this.triggerIfFileRelationshipsChanged(file.path, before); + return; + } + + const nextFingerprint = this.buildRelationshipFingerprint(frontmatter); + if (this.relationshipFingerprints.get(file.path) === nextFingerprint) { this.triggerIfFileRelationshipsChanged(file.path, before); return; } @@ -159,7 +175,7 @@ export class DependencyCache extends Events { // Only clear the forward dependencies (tasks this task depends on) // Keep reverse dependencies intact - they'll be updated when other tasks change this.clearForwardDependencies(file.path); - this.indexTaskFile(file.path, metadata.frontmatter); + this.indexTaskFile(file.path, frontmatter); this.triggerIfFileRelationshipsChanged(file.path, before); } @@ -171,7 +187,7 @@ export class DependencyCache extends Events { private getFileRelationshipSignature(path: string): string { const blockingTasks = this.sortedSetValues(this.dependencySources.get(path)); - const blockedTasks = this.sortedSetValues(this.dependencyTargets.get(path)); + const blockedTasks = this.sortedSetValues(this.activeDependencyTargets.get(path)); const referencedProjects = this.sortedSetValues(this.projectReferenceSources.get(path)); const projectTasks = this.sortedSetValues(this.projectReferences.get(path)); @@ -200,22 +216,36 @@ export class DependencyCache extends Events { */ private handleFileRenamed(file: TFile, oldPath: string): void { // Get metadata for new path - const metadata = this.app.metadataCache.getFileCache(file); + const frontmatter = this.getFrontmatterForFile(file); // Clear old path this.clearFileFromIndexes(oldPath); // Index new path if it's a task - if ( - this.isValidFile(file.path) && - metadata?.frontmatter && - this.isTaskFileCallback(metadata.frontmatter) - ) { - this.indexTaskFile(file.path, metadata.frontmatter); + if (this.isValidFile(file.path) && frontmatter && this.isTaskFileCallback(frontmatter)) { + this.indexTaskFile(file.path, frontmatter); } this.trigger(EVENT_DEPENDENCY_CACHE_CHANGED); } + private getFrontmatterForFile(file: TFile): Record | null { + const metadata = this.app.metadataCache.getFileCache(file); + return this.getFrontmatterFromCache(metadata); + } + + private getFrontmatterFromCache(cache: unknown): Record | null { + if (!cache || typeof cache !== "object" || !("frontmatter" in cache)) { + return null; + } + + const frontmatter = (cache as { frontmatter?: unknown }).frontmatter; + if (!frontmatter || typeof frontmatter !== "object" || Array.isArray(frontmatter)) { + return null; + } + + return frontmatter as Record; + } + /** * Resolve a project reference string to a file path */ @@ -242,6 +272,9 @@ export class DependencyCache extends Events { return; } + this.relationshipFingerprints.set(path, this.buildRelationshipFingerprint(frontmatter)); + this.completedStatusByPath.set(path, this.isCompletedFrontmatter(frontmatter)); + const dependenciesField = this.fieldMapper?.toUserField("blockedBy") || "blockedBy"; const projectField = this.fieldMapper?.toUserField("projects") || "project"; @@ -255,13 +288,7 @@ export class DependencyCache extends Events { for (const dep of normalized) { const resolved = resolveDependencyEntry(this.app, path, dep); if (resolved?.path && this.isValidFile(resolved.path)) { - blockingTasks.add(resolved.path); - - // Add to targets (reverse mapping) - if (!this.dependencyTargets.has(resolved.path)) { - this.dependencyTargets.set(resolved.path, new Set()); - } - this.dependencyTargets.get(resolved.path)!.add(path); + this.addDependencyLink(path, resolved.path, blockingTasks); } } @@ -296,6 +323,166 @@ export class DependencyCache extends Events { } } + private addDependencyLink( + dependentPath: string, + blockingPath: string, + blockingTasks: Set + ): void { + blockingTasks.add(blockingPath); + + if (!this.dependencyTargets.has(blockingPath)) { + this.dependencyTargets.set(blockingPath, new Set()); + } + this.dependencyTargets.get(blockingPath)!.add(dependentPath); + + if (!this.isCompletedPath(blockingPath)) { + this.addActiveDependencyLink(dependentPath, blockingPath); + } + } + + private addActiveDependencyLink(dependentPath: string, blockingPath: string): void { + if (!this.activeDependencySources.has(dependentPath)) { + this.activeDependencySources.set(dependentPath, new Set()); + } + this.activeDependencySources.get(dependentPath)!.add(blockingPath); + + if (!this.activeDependencyTargets.has(blockingPath)) { + this.activeDependencyTargets.set(blockingPath, new Set()); + } + this.activeDependencyTargets.get(blockingPath)!.add(dependentPath); + } + + private removeActiveDependencyLink(dependentPath: string, blockingPath: string): void { + const activeSources = this.activeDependencySources.get(dependentPath); + if (activeSources) { + activeSources.delete(blockingPath); + if (activeSources.size === 0) { + this.activeDependencySources.delete(dependentPath); + } + } + + const activeTargets = this.activeDependencyTargets.get(blockingPath); + if (activeTargets) { + activeTargets.delete(dependentPath); + if (activeTargets.size === 0) { + this.activeDependencyTargets.delete(blockingPath); + } + } + } + + private rebuildActiveLinksForBlocker(blockingPath: string): void { + const blockedTasks = this.dependencyTargets.get(blockingPath); + this.activeDependencyTargets.delete(blockingPath); + + if (!blockedTasks) { + return; + } + + for (const dependentPath of blockedTasks) { + const activeSources = this.activeDependencySources.get(dependentPath); + if (activeSources) { + activeSources.delete(blockingPath); + if (activeSources.size === 0) { + this.activeDependencySources.delete(dependentPath); + } + } + } + + if (this.isCompletedPath(blockingPath)) { + return; + } + + for (const dependentPath of blockedTasks) { + this.addActiveDependencyLink(dependentPath, blockingPath); + } + } + + private buildRelationshipFingerprint(frontmatter: Record): string { + const dependenciesField = this.fieldMapper?.toUserField("blockedBy") || "blockedBy"; + const projectField = this.fieldMapper?.toUserField("projects") || "project"; + + const dependencies = (normalizeDependencyList(frontmatter[dependenciesField]) ?? []) + .map((dependency) => dependency.uid) + .filter((uid) => uid.length > 0) + .sort(); + const projects = this.normalizeProjectFingerprintValues(frontmatter[projectField]); + + return JSON.stringify({ dependencies, projects }); + } + + private normalizeProjectFingerprintValues(value: unknown): string[] { + const projects = Array.isArray(value) ? value : value ? [value] : []; + const normalized = new Set(); + + for (const project of projects) { + if (typeof project !== "string") { + continue; + } + + const trimmed = project.trim(); + if (trimmed) { + normalized.add(trimmed); + } + } + + return Array.from(normalized).sort(); + } + + private hasForwardRelationships(path: string): boolean { + return ( + this.relationshipFingerprints.has(path) || + this.dependencySources.has(path) || + this.projectReferenceSources.has(path) + ); + } + + private updateCompletionState(path: string, frontmatter: Record | null): void { + const oldCompleted = this.completedStatusByPath.get(path) ?? false; + const newCompleted = frontmatter ? this.isCompletedFrontmatter(frontmatter) : false; + this.completedStatusByPath.set(path, newCompleted); + + if (oldCompleted !== newCompleted) { + this.rebuildActiveLinksForBlocker(path); + } + } + + private isCompletedPath(path: string): boolean { + const cached = this.completedStatusByPath.get(path); + if (cached !== undefined) { + return cached; + } + + const file = this.app.vault.getAbstractFileByPath(path); + if (!(file instanceof TFile)) { + this.completedStatusByPath.set(path, false); + return false; + } + + const frontmatter = this.getFrontmatterForFile(file); + const completed = frontmatter ? this.isCompletedFrontmatter(frontmatter) : false; + this.completedStatusByPath.set(path, completed); + return completed; + } + + private isCompletedFrontmatter(frontmatter: Record): boolean { + const statusField = this.fieldMapper?.toUserField("status") || "status"; + const status = frontmatter[statusField]; + const statusText = this.stringifyStatusValue(status); + return Boolean(statusText && this.statusManager.isCompletedStatus(statusText)); + } + + private stringifyStatusValue(status: unknown): string | null { + if ( + typeof status === "string" || + typeof status === "number" || + typeof status === "boolean" + ) { + return String(status); + } + + return null; + } + /** * Clear only forward dependencies (tasks this task depends on) * Used when a task is modified - we rebuild forward deps from frontmatter @@ -314,9 +501,11 @@ export class DependencyCache extends Events { this.dependencyTargets.delete(blockingTask); } } + this.removeActiveDependencyLink(path, blockingTask); } this.dependencySources.delete(path); } + this.activeDependencySources.delete(path); // Also clear project references since those are stored in this task's frontmatter const referencedProjects = this.projectReferenceSources.get(path); @@ -332,6 +521,7 @@ export class DependencyCache extends Events { } this.projectReferenceSources.delete(path); } + this.relationshipFingerprints.delete(path); } /** @@ -351,9 +541,11 @@ export class DependencyCache extends Events { this.dependencyTargets.delete(blockingTask); } } + this.removeActiveDependencyLink(path, blockingTask); } this.dependencySources.delete(path); } + this.activeDependencySources.delete(path); // Clear from dependency targets const blockedTasks = this.dependencyTargets.get(path); @@ -367,9 +559,11 @@ export class DependencyCache extends Events { this.dependencySources.delete(blockedTask); } } + this.removeActiveDependencyLink(blockedTask, path); } this.dependencyTargets.delete(path); } + this.activeDependencyTargets.delete(path); // Clear project references declared by this file const referencedProjects = this.projectReferenceSources.get(path); @@ -400,6 +594,8 @@ export class DependencyCache extends Events { } this.projectReferences.delete(path); } + this.relationshipFingerprints.delete(path); + this.completedStatusByPath.delete(path); } /** @@ -438,11 +634,7 @@ export class DependencyCache extends Events { this.buildIndexesSync(); } - if (this.isCompletedTask(taskPath)) { - return []; - } - - const blocked = this.dependencyTargets.get(taskPath); + const blocked = this.activeDependencyTargets.get(taskPath); return blocked ? Array.from(blocked) : []; } @@ -451,53 +643,10 @@ export class DependencyCache extends Events { * Only returns true if the task has blocking dependencies that are NOT completed */ isTaskBlocked(taskPath: string): boolean { - const blockingPaths = this.getBlockingTaskPaths(taskPath); - if (blockingPaths.length === 0) { - return false; + if (!this.indexesBuilt) { + this.buildIndexesSync(); } - - const statusField = this.fieldMapper?.toUserField("status") || "status"; - - // Check if any blocking task is incomplete - for (const blockingPath of blockingPaths) { - const file = this.app.vault.getAbstractFileByPath(blockingPath); - if (!(file instanceof TFile)) { - // If we can't find the blocking task file, consider it as blocking - // (conservative approach - better to show blocked than hide it) - continue; - } - - const metadata = this.app.metadataCache.getFileCache(file); - if (!metadata?.frontmatter) { - // No metadata means we can't determine status, assume blocking - continue; - } - - const status = metadata.frontmatter[statusField]; - if (!status || !this.statusManager.isCompletedStatus(status)) { - // Found at least one incomplete blocking task - return true; - } - } - - // All blocking tasks are completed - return false; - } - - private isCompletedTask(taskPath: string): boolean { - const file = this.app.vault.getAbstractFileByPath(taskPath); - if (!(file instanceof TFile)) { - return false; - } - - const metadata = this.app.metadataCache.getFileCache(file); - if (!metadata?.frontmatter) { - return false; - } - - const statusField = this.fieldMapper?.toUserField("status") || "status"; - const status = metadata.frontmatter[statusField]; - return Boolean(status && this.statusManager.isCompletedStatus(status)); + return (this.activeDependencySources.get(taskPath)?.size ?? 0) > 0; } /** @@ -576,8 +725,12 @@ export class DependencyCache extends Events { private clearIndexes(): void { this.dependencySources.clear(); this.dependencyTargets.clear(); + this.activeDependencySources.clear(); + this.activeDependencyTargets.clear(); this.projectReferences.clear(); this.projectReferenceSources.clear(); + this.relationshipFingerprints.clear(); + this.completedStatusByPath.clear(); } /** diff --git a/tests/performance/dependencyCache.performance.test.ts b/tests/performance/dependencyCache.performance.test.ts new file mode 100644 index 00000000..d20bc045 --- /dev/null +++ b/tests/performance/dependencyCache.performance.test.ts @@ -0,0 +1,223 @@ +import { TFile, type App } from "obsidian"; +import { FieldMapper } from "../../src/services/FieldMapper"; +import { DEFAULT_FIELD_MAPPING, DEFAULT_SETTINGS } from "../../src/settings/defaults"; +import { DependencyCache, EVENT_DEPENDENCY_CACHE_CHANGED } from "../../src/utils/DependencyCache"; + +jest.mock("obsidian"); + +type MetadataChangedHandler = (file: TFile, data: unknown, cache: unknown) => void; + +type MockApp = App & { + __files: Map; + __metadata: Map }>; + __metadataChangedHandlers: MetadataChangedHandler[]; + __linkResolutionCalls: number; +}; + +interface LargeGraphFixture { + app: MockApp; + cache: DependencyCache; + blockerPaths: string[]; + dependentPaths: string[]; + changeHandler: jest.Mock; +} + +function createMockApp(): MockApp { + const files = new Map(); + const metadata = new Map }>(); + const metadataChangedHandlers: MetadataChangedHandler[] = []; + + const app = { + __files: files, + __metadata: metadata, + __metadataChangedHandlers: metadataChangedHandlers, + __linkResolutionCalls: 0, + vault: { + getMarkdownFiles: jest.fn(() => Array.from(files.values())), + getAbstractFileByPath: jest.fn((path: string) => files.get(path) ?? null), + on: jest.fn(() => ({})), + }, + metadataCache: { + getFileCache: jest.fn((file: TFile) => metadata.get(file.path) ?? null), + getFirstLinkpathDest: jest.fn((linkpath: string) => { + app.__linkResolutionCalls++; + return files.get(linkpath) ?? files.get(`${linkpath}.md`) ?? null; + }), + on: jest.fn((eventName: string, handler: MetadataChangedHandler) => { + if (eventName === "changed") { + metadataChangedHandlers.push(handler); + } + return {}; + }), + offref: jest.fn(), + }, + } as unknown as MockApp; + + return app; +} + +function createFile(app: MockApp, path: string, frontmatter: Record): TFile { + const file = new TFile(path); + app.__files.set(path, file); + app.__metadata.set(path, { frontmatter }); + return file; +} + +function createDependencyCache(app: MockApp): DependencyCache { + return new DependencyCache( + app, + { + ...DEFAULT_SETTINGS, + taskIdentificationMethod: "tag", + taskTag: "task", + }, + new FieldMapper(DEFAULT_FIELD_MAPPING), + { isCompletedStatus: jest.fn((status: string) => status === "done") } as never, + (frontmatter) => Array.isArray((frontmatter as { tags?: unknown }).tags) + ); +} + +function createLargeGraph(blockerCount = 250, dependentCount = 2500): LargeGraphFixture { + const app = createMockApp(); + const blockerPaths: string[] = []; + const dependentPaths: string[] = []; + + for (let i = 0; i < blockerCount; i++) { + const path = `Tasks/Blocker-${i}.md`; + blockerPaths.push(path); + createFile(app, path, { + title: `Blocker ${i}`, + status: "open", + tags: ["task"], + }); + } + + for (let i = 0; i < dependentCount; i++) { + const blockerPath = blockerPaths[i % blockerPaths.length]; + const path = `Tasks/Dependent-${i}.md`; + dependentPaths.push(path); + createFile(app, path, { + title: `Dependent ${i}`, + status: "open", + tags: ["task"], + blockedBy: [{ uid: `[[${blockerPath}]]`, reltype: "FINISHTOSTART" }], + projects: [`[[Projects/Project-${i % 20}.md]]`], + }); + } + + for (let i = 0; i < 20; i++) { + createFile(app, `Projects/Project-${i}.md`, { + title: `Project ${i}`, + }); + } + + const cache = createDependencyCache(app); + const changeHandler = jest.fn(); + cache.on(EVENT_DEPENDENCY_CACHE_CHANGED, changeHandler); + cache.initialize(); + + return { app, cache, blockerPaths, dependentPaths, changeHandler }; +} + +function invokeMetadataChanged(app: MockApp, path: string): void { + const file = app.__files.get(path); + if (!file) { + throw new Error(`Missing file ${path}`); + } + const cache = app.__metadata.get(path) ?? null; + for (const handler of app.__metadataChangedHandlers) { + handler(file, null, cache); + } +} + +function logMetric(label: string, metric: Record): void { + if (process.env.TN_PERF_LOG === "1") { + process.stderr.write(`[dependency-cache-perf] ${label} ${JSON.stringify(metric)}\n`); + } +} + +describe("DependencyCache performance smoke", () => { + it("measures large-graph build, edit, and read-heavy paths", async () => { + const { app, cache, blockerPaths, dependentPaths, changeHandler } = createLargeGraph(); + + let start = performance.now(); + await cache.buildIndexes(); + const buildDurationMs = performance.now() - start; + const buildLinkResolutionCalls = app.__linkResolutionCalls; + expect(cache.isTaskBlocked(dependentPaths[0])).toBe(true); + expect(cache.getBlockedTaskPaths(blockerPaths[0]).length).toBeGreaterThan(0); + + app.__linkResolutionCalls = 0; + changeHandler.mockClear(); + start = performance.now(); + for (const path of dependentPaths) { + const entry = app.__metadata.get(path); + if (!entry) continue; + app.__metadata.set(path, { + frontmatter: { + ...entry.frontmatter, + details: "body-like metadata churn that does not affect relationships", + }, + }); + invokeMetadataChanged(app, path); + } + const nonRelationshipEditDurationMs = performance.now() - start; + const nonRelationshipEditLinkResolutionCalls = app.__linkResolutionCalls; + const nonRelationshipEditEvents = changeHandler.mock.calls.length; + + app.__linkResolutionCalls = 0; + changeHandler.mockClear(); + start = performance.now(); + const blockerEntry = app.__metadata.get(blockerPaths[0]); + if (!blockerEntry) { + throw new Error("Missing blocker metadata"); + } + app.__metadata.set(blockerPaths[0], { + frontmatter: { + ...blockerEntry.frontmatter, + status: "done", + }, + }); + invokeMetadataChanged(app, blockerPaths[0]); + const statusChangeDurationMs = performance.now() - start; + const statusChangeLinkResolutionCalls = app.__linkResolutionCalls; + const statusChangeEvents = changeHandler.mock.calls.length; + expect(cache.isTaskBlocked(dependentPaths[0])).toBe(false); + + app.__linkResolutionCalls = 0; + (app.metadataCache.getFileCache as jest.Mock).mockClear(); + start = performance.now(); + for (const path of dependentPaths) { + cache.isTaskBlocked(path); + } + for (const path of blockerPaths) { + cache.getBlockedTaskPaths(path); + } + const readHeavyDurationMs = performance.now() - start; + const readHeavyLinkResolutionCalls = app.__linkResolutionCalls; + const readHeavyMetadataReads = (app.metadataCache.getFileCache as jest.Mock).mock.calls + .length; + + logMetric("large-graph", { + buildDurationMs, + buildLinkResolutionCalls, + nonRelationshipEditDurationMs, + nonRelationshipEditLinkResolutionCalls, + nonRelationshipEditEvents, + statusChangeDurationMs, + statusChangeLinkResolutionCalls, + statusChangeEvents, + readHeavyDurationMs, + readHeavyLinkResolutionCalls, + readHeavyMetadataReads, + }); + + expect(buildLinkResolutionCalls).toBeGreaterThan(0); + expect(nonRelationshipEditLinkResolutionCalls).toBe(0); + expect(nonRelationshipEditEvents).toBe(0); + expect(statusChangeLinkResolutionCalls).toBe(0); + expect(statusChangeEvents).toBe(1); + expect(readHeavyLinkResolutionCalls).toBe(0); + expect(readHeavyMetadataReads).toBe(0); + }); +}); diff --git a/tests/unit/utils/DependencyCache.optimization.test.ts b/tests/unit/utils/DependencyCache.optimization.test.ts new file mode 100644 index 00000000..60a6b8fc --- /dev/null +++ b/tests/unit/utils/DependencyCache.optimization.test.ts @@ -0,0 +1,169 @@ +import { TFile, type App } from "obsidian"; +import { FieldMapper } from "../../../src/services/FieldMapper"; +import { DEFAULT_FIELD_MAPPING, DEFAULT_SETTINGS } from "../../../src/settings/defaults"; +import { + DependencyCache, + EVENT_DEPENDENCY_CACHE_CHANGED, +} from "../../../src/utils/DependencyCache"; + +jest.mock("obsidian"); + +type MetadataChangedHandler = (file: TFile, data: unknown, cache: unknown) => void; + +type MockApp = App & { + __files: Map; + __metadata: Map }>; + __metadataChangedHandlers: MetadataChangedHandler[]; + __linkResolutionCalls: number; +}; + +function createMockApp(): MockApp { + const files = new Map(); + const metadata = new Map }>(); + const metadataChangedHandlers: MetadataChangedHandler[] = []; + + const app = { + __files: files, + __metadata: metadata, + __metadataChangedHandlers: metadataChangedHandlers, + __linkResolutionCalls: 0, + vault: { + getMarkdownFiles: jest.fn(() => Array.from(files.values())), + getAbstractFileByPath: jest.fn((path: string) => files.get(path) ?? null), + on: jest.fn(() => ({})), + }, + metadataCache: { + getFileCache: jest.fn((file: TFile) => metadata.get(file.path) ?? null), + getFirstLinkpathDest: jest.fn((linkpath: string) => { + app.__linkResolutionCalls++; + return files.get(linkpath) ?? files.get(`${linkpath}.md`) ?? null; + }), + on: jest.fn((eventName: string, handler: MetadataChangedHandler) => { + if (eventName === "changed") { + metadataChangedHandlers.push(handler); + } + return {}; + }), + offref: jest.fn(), + }, + } as unknown as MockApp; + + return app; +} + +function createFile(app: MockApp, path: string, frontmatter: Record): TFile { + const file = new TFile(path); + app.__files.set(path, file); + app.__metadata.set(path, { frontmatter }); + return file; +} + +function createDependencyCache(app: MockApp): DependencyCache { + return new DependencyCache( + app, + { + ...DEFAULT_SETTINGS, + taskIdentificationMethod: "tag", + taskTag: "task", + }, + new FieldMapper(DEFAULT_FIELD_MAPPING), + { isCompletedStatus: jest.fn((status: string) => status === "done") } as never, + (frontmatter) => Array.isArray((frontmatter as { tags?: unknown }).tags) + ); +} + +function invokeMetadataChanged(app: MockApp, path: string): void { + const file = app.__files.get(path); + if (!file) { + throw new Error(`Missing file ${path}`); + } + const cache = app.__metadata.get(path) ?? null; + for (const handler of app.__metadataChangedHandlers) { + handler(file, null, cache); + } +} + +describe("DependencyCache optimized relationship updates", () => { + it("skips link resolution when task metadata changes without relationship changes", async () => { + const app = createMockApp(); + createFile(app, "Tasks/Blocker.md", { + title: "Blocker", + status: "open", + tags: ["task"], + }); + createFile(app, "Projects/Alpha.md", { + title: "Alpha", + }); + createFile(app, "Tasks/Dependent.md", { + title: "Dependent", + status: "open", + tags: ["task"], + blockedBy: [{ uid: "[[Tasks/Blocker.md]]", reltype: "FINISHTOSTART" }], + projects: ["[[Projects/Alpha.md]]"], + }); + + const cache = createDependencyCache(app); + const changeHandler = jest.fn(); + cache.on(EVENT_DEPENDENCY_CACHE_CHANGED, changeHandler); + cache.initialize(); + await cache.buildIndexes(); + changeHandler.mockClear(); + app.__linkResolutionCalls = 0; + + app.__metadata.set("Tasks/Dependent.md", { + frontmatter: { + title: "Dependent renamed", + status: "open", + tags: ["task"], + blockedBy: [{ uid: "[[Tasks/Blocker.md]]", reltype: "FINISHTOSTART" }], + projects: ["[[Projects/Alpha.md]]"], + }, + }); + invokeMetadataChanged(app, "Tasks/Dependent.md"); + + expect(app.__linkResolutionCalls).toBe(0); + expect(changeHandler).not.toHaveBeenCalled(); + expect(cache.isTaskBlocked("Tasks/Dependent.md")).toBe(true); + expect(cache.isFileUsedAsProject("Projects/Alpha.md")).toBe(true); + }); + + it("updates status-aware active relationships without re-resolving dependency links", async () => { + const app = createMockApp(); + createFile(app, "Tasks/Blocker.md", { + title: "Blocker", + status: "open", + tags: ["task"], + }); + createFile(app, "Tasks/Dependent.md", { + title: "Dependent", + status: "open", + tags: ["task"], + blockedBy: [{ uid: "[[Tasks/Blocker.md]]", reltype: "FINISHTOSTART" }], + }); + + const cache = createDependencyCache(app); + const changeHandler = jest.fn(); + cache.on(EVENT_DEPENDENCY_CACHE_CHANGED, changeHandler); + cache.initialize(); + await cache.buildIndexes(); + changeHandler.mockClear(); + app.__linkResolutionCalls = 0; + + expect(cache.isTaskBlocked("Tasks/Dependent.md")).toBe(true); + expect(cache.getBlockedTaskPaths("Tasks/Blocker.md")).toEqual(["Tasks/Dependent.md"]); + + app.__metadata.set("Tasks/Blocker.md", { + frontmatter: { + title: "Blocker", + status: "done", + tags: ["task"], + }, + }); + invokeMetadataChanged(app, "Tasks/Blocker.md"); + + expect(app.__linkResolutionCalls).toBe(0); + expect(changeHandler).toHaveBeenCalledTimes(1); + expect(cache.isTaskBlocked("Tasks/Dependent.md")).toBe(false); + expect(cache.getBlockedTaskPaths("Tasks/Blocker.md")).toEqual([]); + }); +});