mindnet_obsidian/src/analysis/chainInspector.ts

755 lines
26 KiB
TypeScript
Raw Normal View History

/**
* Chain Inspector v0: analyzes relationships, chains, gaps, and backward paths.
*/
import type { App } from "obsidian";
import { TFile } from "obsidian";
import type { SectionContext } from "./sectionContext";
import type { IndexedEdge, SectionNode } from "./graphIndex";
import { buildNoteIndex, loadNeighborNote } from "./graphIndex";
import type { ChainRolesConfig } from "../dictionary/types";
import { splitIntoSections } from "../mapping/sectionParser";
import { normalizeLinkTarget } from "../unresolvedLink/linkHelpers";
export interface InspectorOptions {
includeNoteLinks: boolean;
includeCandidates: boolean;
maxDepth: number;
direction: "forward" | "backward" | "both";
}
export interface Finding {
code: string;
severity: "info" | "warn" | "error";
message: string;
evidence?: {
file: string;
sectionHeading: string | null;
};
}
export interface NeighborEdge {
rawEdgeType: string;
target: { file: string; heading: string | null };
scope: "section" | "note" | "candidate";
evidence: {
file: string;
sectionHeading: string | null;
lineRange?: { start: number; end: number };
};
}
export interface Path {
nodes: Array<{ file: string; heading: string | null }>;
edges: Array<{ rawEdgeType: string; from: string; to: string }>;
}
export interface ChainInspectorReport {
context: {
file: string;
heading: string | null;
zoneKind: string;
};
settings: InspectorOptions;
neighbors: {
incoming: NeighborEdge[];
outgoing: NeighborEdge[];
};
paths: {
forward: Path[];
backward: Path[];
};
findings: Finding[];
}
const MIN_TEXT_LENGTH_FOR_EDGE_CHECK = 200;
const CAUSAL_ROLE_NAMES = ["causal", "influences", "enables_constraints"];
/**
* Filter edges based on options.
*/
function filterEdges(
edges: IndexedEdge[],
options: InspectorOptions
): IndexedEdge[] {
return edges.filter((edge) => {
if (edge.scope === "candidate" && !options.includeCandidates) {
return false;
}
if (edge.scope === "note" && !options.includeNoteLinks) {
return false;
}
return true;
});
}
/**
* Get neighbors (incoming/outgoing) for current section context.
*/
function getNeighbors(
edges: IndexedEdge[],
context: SectionContext,
options: InspectorOptions
): { incoming: NeighborEdge[]; outgoing: NeighborEdge[] } {
const filtered = filterEdges(edges, options);
const currentSection: { file: string; heading: string | null } = {
file: context.file,
heading: context.heading,
};
const incoming: NeighborEdge[] = [];
const outgoing: NeighborEdge[] = [];
// Helper: check if edge target matches current file (by path or basename)
const currentFileBasename = context.file.split("/").pop()?.replace(/\.md$/, "") || "";
const matchesCurrentFile = (targetFile: string): boolean => {
if (targetFile === currentSection.file) return true;
if (targetFile === currentFileBasename) return true;
if (targetFile === `${currentFileBasename}.md`) return true;
// Check if targetFile is basename of currentSection.file
const currentBasename = currentSection.file.split("/").pop()?.replace(/\.md$/, "") || "";
return targetFile === currentBasename;
};
for (const edge of filtered) {
// Check if edge targets current section
// Match exact section (file + heading) OR note-level link (file only, heading null)
const targetsCurrentSection =
matchesCurrentFile(edge.target.file) &&
(edge.target.heading === currentSection.heading ||
(edge.target.heading === null && currentSection.heading !== null));
if (targetsCurrentSection) {
// Incoming edge
incoming.push({
rawEdgeType: edge.rawEdgeType,
target: {
file:
"sectionHeading" in edge.source
? edge.source.file
: edge.source.file,
heading:
"sectionHeading" in edge.source
? edge.source.sectionHeading
: null,
},
scope: edge.scope,
evidence: edge.evidence,
});
}
// Check if edge originates from current section
const sourceMatches =
("sectionHeading" in edge.source
? edge.source.sectionHeading === currentSection.heading &&
edge.source.file === currentSection.file
: edge.scope === "note" && edge.source.file === currentSection.file) &&
edge.source.file === currentSection.file;
if (sourceMatches) {
// Outgoing edge
outgoing.push({
rawEdgeType: edge.rawEdgeType,
target: edge.target,
scope: edge.scope,
evidence: edge.evidence,
});
}
}
// Sort for deterministic output
const sortEdges = (a: NeighborEdge, b: NeighborEdge) => {
if (a.rawEdgeType !== b.rawEdgeType) {
return a.rawEdgeType.localeCompare(b.rawEdgeType);
}
if (a.target.file !== b.target.file) {
return a.target.file.localeCompare(b.target.file);
}
const aHeading = a.target.heading || "";
const bHeading = b.target.heading || "";
return aHeading.localeCompare(bHeading);
};
incoming.sort(sortEdges);
outgoing.sort(sortEdges);
return { incoming, outgoing };
}
/**
* Traverse paths from current node.
*/
function traversePaths(
edges: IndexedEdge[],
context: SectionContext,
options: InspectorOptions
): { forward: Path[]; backward: Path[] } {
const filtered = filterEdges(edges, options);
const currentSection: { file: string; heading: string | null } = {
file: context.file,
heading: context.heading,
};
const forward: Path[] = [];
const backward: Path[] = [];
if (options.direction === "forward" || options.direction === "both") {
forward.push(...traverseForward(filtered, currentSection, options.maxDepth));
}
if (options.direction === "backward" || options.direction === "both") {
backward.push(...traverseBackward(filtered, currentSection, options.maxDepth));
}
return { forward, backward };
}
function traverseForward(
edges: IndexedEdge[],
start: { file: string; heading: string | null },
maxDepth: number
): Path[] {
const paths: Path[] = [];
const visited = new Set<string>();
function visit(
current: { file: string; heading: string | null },
path: Path,
depth: number
) {
if (depth > maxDepth) return;
const nodeKey = `${current.file}:${current.heading || ""}`;
if (visited.has(nodeKey)) return;
visited.add(nodeKey);
// Find outgoing edges
for (const edge of edges) {
const sourceMatches =
("sectionHeading" in edge.source
? edge.source.sectionHeading === current.heading &&
edge.source.file === current.file
: edge.scope === "note" && edge.source.file === current.file) &&
edge.source.file === current.file;
if (sourceMatches) {
const newPath: Path = {
nodes: [...path.nodes, edge.target],
edges: [
...path.edges,
{
rawEdgeType: edge.rawEdgeType,
from: nodeKey,
to: `${edge.target.file}:${edge.target.heading || ""}`,
},
],
};
if (depth < maxDepth) {
visit(edge.target, newPath, depth + 1);
} else {
paths.push(newPath);
}
}
}
if (path.nodes.length > 1) {
paths.push(path);
}
}
visit(start, { nodes: [start], edges: [] }, 0);
return paths;
}
function traverseBackward(
edges: IndexedEdge[],
start: { file: string; heading: string | null },
maxDepth: number
): Path[] {
const paths: Path[] = [];
const visited = new Set<string>();
function visit(
current: { file: string; heading: string | null },
path: Path,
depth: number
) {
if (depth > maxDepth) return;
const nodeKey = `${current.file}:${current.heading || ""}`;
if (visited.has(nodeKey)) return;
visited.add(nodeKey);
// Find incoming edges (edges that target current node)
// Match exact section OR note-level link (heading null matches any section in that file)
// Also match by basename (since edges might use basename instead of full path)
const currentFileBasename = current.file.split("/").pop()?.replace(/\.md$/, "") || "";
const matchesCurrentFile = (targetFile: string): boolean => {
if (targetFile === current.file) return true;
if (targetFile === currentFileBasename) return true;
if (targetFile === `${currentFileBasename}.md`) return true;
const currentBasename = current.file.split("/").pop()?.replace(/\.md$/, "") || "";
return targetFile === currentBasename;
};
for (const edge of edges) {
const targetsCurrentNode =
matchesCurrentFile(edge.target.file) &&
(edge.target.heading === current.heading ||
(edge.target.heading === null && current.heading !== null));
if (targetsCurrentNode) {
const sourceNode: { file: string; heading: string | null } =
"sectionHeading" in edge.source
? {
file: edge.source.file,
heading: edge.source.sectionHeading,
}
: { file: edge.source.file, heading: null };
const sourceKey = `${sourceNode.file}:${sourceNode.heading || ""}`;
const newPath: Path = {
nodes: [sourceNode, ...path.nodes],
edges: [
{
rawEdgeType: edge.rawEdgeType,
from: sourceKey,
to: nodeKey,
},
...path.edges,
],
};
if (depth < maxDepth) {
visit(sourceNode, newPath, depth + 1);
} else {
paths.push(newPath);
}
}
}
if (path.nodes.length > 1) {
paths.push(path);
}
}
visit(start, { nodes: [start], edges: [] }, 0);
return paths;
}
/**
* Compute gap heuristics findings.
*/
function computeFindings(
allEdges: IndexedEdge[], // All edges (including neighbor notes) for incoming edge detection
currentEdges: IndexedEdge[], // Current note edges only for outgoing edge detection
context: SectionContext,
sections: SectionNode[],
sectionContent: string,
chainRoles: ChainRolesConfig | null,
options: InspectorOptions
): Finding[] {
const findings: Finding[] = [];
// Find current section content
const currentSection = sections.find(
(s) => s.file === context.file && s.heading === context.heading
);
if (!currentSection) {
return findings;
}
// Filter edges for current section (outgoing edges only - from current note)
const sectionEdges = filterEdges(currentEdges, options).filter((edge) => {
if (edge.scope === "candidate") return false; // Exclude candidates for gap checks
if (edge.scope === "note") return false; // Exclude note-level for section checks
const sourceMatches =
"sectionHeading" in edge.source
? edge.source.sectionHeading === context.heading &&
edge.source.file === context.file
: false;
return sourceMatches;
});
// Check: missing_edges
const textWithoutHeadings = sectionContent
.split("\n")
.filter((line) => !line.match(/^#{1,6}\s/))
.join("\n");
const textWithoutEdgeBlocks = textWithoutHeadings.replace(
/>\s*\[!edge\][\s\S]*?(?=\n\n|\n>|$)/g,
""
);
const textLength = textWithoutEdgeBlocks.trim().length;
if (textLength > MIN_TEXT_LENGTH_FOR_EDGE_CHECK && sectionEdges.length === 0) {
findings.push({
code: "missing_edges",
severity: "warn",
message: `Section has ${textLength} characters of content but no explicit edges`,
evidence: {
file: context.file,
sectionHeading: context.heading,
},
});
}
// Check: one_sided_connectivity
// Use same matching logic as getNeighbors for consistency
const currentFileBasename = context.file.split("/").pop()?.replace(/\.md$/, "") || "";
const matchesCurrentFile = (targetFile: string): boolean => {
if (targetFile === context.file) return true; // Full path match
if (targetFile === currentFileBasename) return true; // Basename match
if (targetFile === `${currentFileBasename}.md`) return true; // Basename with .md
// Check if targetFile is basename of context.file (redundant but consistent with getNeighbors)
const currentBasename = context.file.split("/").pop()?.replace(/\.md$/, "") || "";
return targetFile === currentBasename;
};
// Count incoming edges (edges targeting current section)
const filteredAllEdges = filterEdges(allEdges, options);
const incoming = filteredAllEdges.filter((edge) => {
if (edge.scope === "candidate") return false; // Exclude candidates
const fileMatches = matchesCurrentFile(edge.target.file);
const headingMatches = edge.target.heading === context.heading ||
(edge.target.heading === null && context.heading !== null);
return fileMatches && headingMatches;
});
// Count outgoing edges (edges originating from current section)
const outgoing = sectionEdges.filter((edge) => {
const sourceMatches =
"sectionHeading" in edge.source
? edge.source.sectionHeading === context.heading &&
edge.source.file === context.file
: false;
return sourceMatches;
});
// Debug logging for findings
console.log(`[Chain Inspector] computeFindings: incoming=${incoming.length}, outgoing=${outgoing.length}, allEdges=${allEdges.length}, filteredAllEdges=${filteredAllEdges.length}`);
if (incoming.length > 0 && outgoing.length === 0) {
findings.push({
code: "one_sided_connectivity",
severity: "info",
message: "Section has only incoming edges, no outgoing edges",
evidence: {
file: context.file,
sectionHeading: context.heading,
},
});
} else if (outgoing.length > 0 && incoming.length === 0) {
findings.push({
code: "one_sided_connectivity",
severity: "info",
message: "Section has only outgoing edges, no incoming edges",
evidence: {
file: context.file,
sectionHeading: context.heading,
},
});
}
// Check: only_candidates
const candidateEdges = currentEdges.filter(
(edge) =>
edge.scope === "candidate" &&
("sectionHeading" in edge.source
? edge.source.sectionHeading === context.heading &&
edge.source.file === context.file
: false)
);
if (candidateEdges.length > 0 && sectionEdges.length === 0) {
findings.push({
code: "only_candidates",
severity: "info",
message: "Section has only candidate edges, no explicit edges",
evidence: {
file: context.file,
sectionHeading: context.heading,
},
});
}
// Check: dangling_target
const allTargets = new Set(
sectionEdges.map((e) => `${e.target.file}:${e.target.heading || ""}`)
);
// Note: We can't fully check if targets exist without loading all files,
// so we skip this for now or mark as "potential" if we have access to vault
// Check: no_causal_roles (if chainRoles available)
if (chainRoles) {
const hasCausalRole = sectionEdges.some((edge) => {
for (const [roleName, role] of Object.entries(chainRoles.roles)) {
if (CAUSAL_ROLE_NAMES.includes(roleName)) {
if (role.edge_types.includes(edge.rawEdgeType)) {
return true;
}
}
}
return false;
});
if (sectionEdges.length > 0 && !hasCausalRole) {
findings.push({
code: "no_causal_roles",
severity: "info",
message: "Section has edges but none match causal roles",
evidence: {
file: context.file,
sectionHeading: context.heading,
},
});
}
}
// Sort findings: severity desc, code asc
findings.sort((a, b) => {
const severityOrder = { error: 3, warn: 2, info: 1 };
const severityDiff =
(severityOrder[b.severity] || 0) - (severityOrder[a.severity] || 0);
if (severityDiff !== 0) return severityDiff;
return a.code.localeCompare(b.code);
});
return findings;
}
/**
* Inspect chains for current section context.
*/
export async function inspectChains(
app: App,
context: SectionContext,
options: InspectorOptions,
chainRoles: ChainRolesConfig | null
): Promise<ChainInspectorReport> {
// Build index for current note
const currentFile = app.vault.getAbstractFileByPath(context.file);
if (!currentFile || !("path" in currentFile)) {
throw new Error(`File not found: ${context.file}`);
}
// Type guard: check if it's a TFile (has extension property)
if (!("extension" in currentFile) || currentFile.extension !== "md") {
throw new Error(`File not found or not a markdown file: ${context.file}`);
}
const { edges: currentEdges, sections } = await buildNoteIndex(
app,
currentFile as TFile
);
// Collect all outgoing targets to load neighbor notes
const outgoingTargets = new Set<string>();
for (const edge of currentEdges) {
if (
("sectionHeading" in edge.source
? edge.source.sectionHeading === context.heading &&
edge.source.file === context.file
: edge.scope === "note" && edge.source.file === context.file) &&
edge.source.file === context.file
) {
outgoingTargets.add(edge.target.file);
}
}
// Find notes that link to current note (for incoming edges)
// Use Obsidian's metadataCache.getBacklinksForFile() for efficient lookup
// This is much faster than scanning all files manually
const notesLinkingToCurrent = new Set<string>();
try {
// @ts-ignore - getBacklinksForFile exists but may not be in TS definitions
const backlinks = app.metadataCache.getBacklinksForFile(currentFile as TFile);
if (backlinks) {
// backlinks is a Map-like structure: source file path -> array of references
for (const sourcePath of backlinks.keys()) {
if (sourcePath === context.file) continue; // Skip self
notesLinkingToCurrent.add(sourcePath);
}
console.log(`[Chain Inspector] Found ${notesLinkingToCurrent.size} notes linking to current note via getBacklinksForFile`);
} else {
console.log("[Chain Inspector] getBacklinksForFile returned null/undefined");
}
} catch (e) {
// Fallback: if getBacklinksForFile is not available, use manual scan
// This should rarely happen, but provides compatibility
console.warn("getBacklinksForFile not available, falling back to manual scan", e);
const currentNoteBasename = (currentFile as TFile).basename;
const currentNotePath = context.file;
const currentNotePathWithoutExt = currentNotePath.replace(/\.md$/, "");
const allMarkdownFiles = app.vault.getMarkdownFiles();
for (const file of allMarkdownFiles) {
if (file.path === currentNotePath) continue;
try {
const content = await app.vault.cachedRead(file);
const wikilinkRegex = /\[\[([^\]]+?)\]\]/g;
let match: RegExpExecArray | null;
while ((match = wikilinkRegex.exec(content)) !== null) {
if (!match[1]) continue;
const normalizedLink = normalizeLinkTarget(match[1].trim());
if (!normalizedLink) continue;
const resolvedFile = app.metadataCache.getFirstLinkpathDest(
normalizedLink,
file.path
);
if (resolvedFile && resolvedFile.path === currentNotePath) {
notesLinkingToCurrent.add(file.path);
break;
}
// Fallback string matching
if (
normalizedLink === currentNoteBasename ||
normalizedLink === currentNotePath ||
normalizedLink === currentNotePathWithoutExt ||
normalizedLink.replace(/\.md$/, "") === currentNotePathWithoutExt
) {
notesLinkingToCurrent.add(file.path);
break;
}
}
} catch {
continue;
}
}
}
// Load neighbor notes lazily to find incoming edges
const allEdges = [...currentEdges];
// Load outgoing targets (for forward paths)
for (const targetFile of outgoingTargets) {
if (targetFile === context.file) continue; // Skip self
const neighborFile = await loadNeighborNote(app, targetFile);
if (neighborFile) {
const { edges: neighborEdges } = await buildNoteIndex(app, neighborFile);
allEdges.push(...neighborEdges);
}
}
// Load notes that link to current note (for incoming edges and backward paths)
console.log(`[Chain Inspector] Loading ${notesLinkingToCurrent.size} notes that link to current note`);
for (const sourceFile of notesLinkingToCurrent) {
if (sourceFile === context.file) continue; // Skip self
const sourceNoteFile = await loadNeighborNote(app, sourceFile);
if (sourceNoteFile) {
const { edges: sourceEdges } = await buildNoteIndex(app, sourceNoteFile);
console.log(`[Chain Inspector] Loaded ${sourceEdges.length} edges from ${sourceFile}`);
// Debug: Show all edges from this file (first 5) to understand what we're working with
if (sourceEdges.length > 0) {
console.log(`[Chain Inspector] Sample edges from ${sourceFile} (showing first 5):`);
for (const edge of sourceEdges.slice(0, 5)) {
const sourceInfo = "sectionHeading" in edge.source
? `${edge.source.file}#${edge.source.sectionHeading || "null"}`
: `${edge.source.file} (note-level)`;
console.log(` - ${edge.rawEdgeType} from ${sourceInfo} -> ${edge.target.file}#${edge.target.heading || "null"}`);
}
}
// Debug: Log ALL edges that target current note (any section)
// Match by full path OR basename (since edges might use basename only)
const currentFileBasename = (currentFile as TFile).basename;
const edgesTargetingCurrentNote = sourceEdges.filter((e) => {
// Match full path
if (e.target.file === context.file) return true;
// Match basename (e.g., "Krebserkrankung von Sushi" matches "03_Experiences/Events/Krebserkrankung von Sushi.md")
if (e.target.file === currentFileBasename) return true;
// Match basename without extension
if (e.target.file === currentFileBasename.replace(/\.md$/, "")) return true;
return false;
});
if (edgesTargetingCurrentNote.length > 0) {
console.log(`[Chain Inspector] ✓ Found ${edgesTargetingCurrentNote.length} edges from ${sourceFile} targeting current note (${context.file}):`);
for (const edge of edgesTargetingCurrentNote) {
const sourceInfo = "sectionHeading" in edge.source
? `${edge.source.file}#${edge.source.sectionHeading || "null"}`
: `${edge.source.file} (note-level)`;
console.log(` - ${edge.rawEdgeType} from ${sourceInfo} -> ${edge.target.file}#${edge.target.heading || "null"} [scope: ${edge.scope}]`);
}
// Check why they don't match current section
const currentSectionKey = `${context.file}:${context.heading || "null"}`;
console.log(`[Chain Inspector] Current section: ${currentSectionKey}`);
// Use same matching logic as getNeighbors
const debugFileBasename = context.file.split("/").pop()?.replace(/\.md$/, "") || "";
const matchesCurrentFileDebug = (targetFile: string): boolean => {
if (targetFile === context.file) return true;
if (targetFile === debugFileBasename) return true;
if (targetFile === `${debugFileBasename}.md`) return true;
// Also check against currentFileBasename (from TFile.basename)
if (targetFile === currentFileBasename) return true;
if (targetFile === currentFileBasename.replace(/\.md$/, "")) return true;
return false;
};
for (const edge of edgesTargetingCurrentNote) {
const targetKey = `${edge.target.file}:${edge.target.heading || "null"}`;
const fileMatches = matchesCurrentFileDebug(edge.target.file);
const headingMatches = edge.target.heading === context.heading ||
(edge.target.heading === null && context.heading !== null);
const matches = fileMatches && headingMatches;
console.log(` - Edge target: ${targetKey}, file matches: ${fileMatches ? "YES" : "NO"}, heading matches: ${headingMatches ? "YES" : "NO"}, should match: ${matches ? "YES" : "NO"}`);
}
} else {
console.log(`[Chain Inspector] ✗ No edges from ${sourceFile} target current note (${context.file})`);
console.log(` - Edges in this file target: ${[...new Set(sourceEdges.map(e => e.target.file))].slice(0, 3).join(", ")}...`);
}
allEdges.push(...sourceEdges);
} else {
console.log(`[Chain Inspector] Could not load neighbor note: ${sourceFile}`);
}
}
// Get section content for gap analysis
const content = await app.vault.read(currentFile as TFile);
const sectionsWithContent = splitIntoSections(content);
const currentSectionContent =
sectionsWithContent[context.sectionIndex]?.content || "";
// Get neighbors (now includes edges from neighbor notes)
console.log(`[Chain Inspector] Total edges after loading neighbors: ${allEdges.length} (current: ${currentEdges.length}, neighbors: ${allEdges.length - currentEdges.length})`);
const neighbors = getNeighbors(allEdges, context, options);
console.log(`[Chain Inspector] Neighbors found: ${neighbors.incoming.length} incoming, ${neighbors.outgoing.length} outgoing`);
// Traverse paths (now includes edges from neighbor notes)
const paths = traversePaths(allEdges, context, options);
// Compute findings (use allEdges for incoming checks, currentEdges for outgoing checks)
const findings = computeFindings(
allEdges, // Use allEdges so we can detect incoming edges from neighbor notes
currentEdges, // Use currentEdges for outgoing edge checks (only current note can have outgoing edges)
context,
sections,
currentSectionContent,
chainRoles,
options
);
return {
context: {
file: context.file,
heading: context.heading,
zoneKind: context.zoneKind,
},
settings: options,
neighbors,
paths,
findings,
};
}