mindnet_obsidian/src/analysis/chainInspector.ts
Lars 74cacdd41d
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run
Update documentation and enhance chain inspection logic
- Revamped the README to provide comprehensive documentation for the Mindnet Causal Assistant plugin, including user, administrator, developer, and architect guides.
- Added specialized documentation sections for installation, deployment, and troubleshooting.
- Enhanced the chain inspection logic to determine effective required links based on template definitions, profiles, and defaults, improving the accuracy of findings related to link completeness.
2026-01-20 11:34:58 +01:00

1157 lines
42 KiB
TypeScript

/**
* 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, ChainTemplatesConfig } from "../dictionary/types";
import { splitIntoSections } from "../mapping/sectionParser";
import { normalizeLinkTarget } from "../unresolvedLink/linkHelpers";
import type { EdgeVocabulary } from "../vocab/types";
import { parseEdgeVocabulary } from "../vocab/parseEdgeVocabulary";
import { VocabularyLoader } from "../vocab/VocabularyLoader";
import { applySeverityPolicy } from "./severityPolicy";
export interface InspectorOptions {
includeNoteLinks: boolean;
includeCandidates: boolean;
maxDepth: number;
direction: "forward" | "backward" | "both";
maxTemplateMatches?: number; // Optional: limit number of template matches (default: 3)
}
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 TemplateMatch {
templateName: string;
score: number;
slotAssignments: {
[slotId: string]: {
nodeKey: string;
file: string;
heading?: string | null;
noteType: string;
};
};
missingSlots: string[];
satisfiedLinks: number;
requiredLinks: number;
roleEvidence?: Array<{
from: string;
to: string;
edgeRole: string;
rawEdgeType: string;
}>;
slotsComplete: boolean;
linksComplete: boolean;
confidence: "confirmed" | "plausible" | "weak";
}
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[];
analysisMeta?: {
edgesTotal: number;
edgesWithCanonical: number;
edgesUnmapped: number;
roleMatches: { [roleName: string]: number };
topNUsed?: number;
};
templateMatches?: TemplateMatch[];
templatesSource?: {
path: string;
status: "loaded" | "error" | "using-last-known-good";
loadedAt: number | null;
templateCount: number;
};
templateMatchingProfileUsed?: {
name: string;
resolvedFrom: "settings" | "default";
profileConfig?: {
required_links?: boolean;
min_slots_filled_for_gap_findings?: number;
min_score_for_gap_findings?: number;
};
};
}
const MIN_TEXT_LENGTH_FOR_EDGE_CHECK = 200;
const CAUSAL_ROLE_NAMES = ["causal", "influences", "enables_constraints"];
/**
* Resolve canonical edge type from raw edge type using edge vocabulary.
*/
export function resolveCanonicalEdgeType(
rawEdgeType: string,
edgeVocabulary: EdgeVocabulary | null
): { canonical?: string; matchedBy: "canonical" | "alias" | "none" } {
if (!edgeVocabulary) {
return { matchedBy: "none" };
}
// Check if raw type is already canonical
if (edgeVocabulary?.byCanonical.has(rawEdgeType)) {
return { canonical: rawEdgeType, matchedBy: "canonical" };
}
// Check if raw type is an alias (case-insensitive lookup)
const lowerRaw = rawEdgeType.toLowerCase();
const canonical = edgeVocabulary.aliasToCanonical.get(lowerRaw);
if (canonical) {
return { canonical, matchedBy: "alias" };
}
return { matchedBy: "none" };
}
/**
* 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,
edgeVocabulary: EdgeVocabulary | null,
app: App,
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)
// Use filterEdges to respect includeCandidates option (consistent with getNeighbors)
const filteredAllEdges = filterEdges(allEdges, options);
const incoming = filteredAllEdges.filter((edge) => {
// Don't manually filter candidates here - filterEdges already did that based on options.includeCandidates
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)
// Use filterEdges to respect includeCandidates option (consistent with getNeighbors)
// Note: sectionEdges already filters out candidates/note-level for missing_edges check,
// but for one_sided_connectivity we need to use the same filtering as getNeighbors
const filteredCurrentEdges = filterEdges(currentEdges, options);
const outgoing = filteredCurrentEdges.filter((edge) => {
const sourceMatches =
"sectionHeading" in edge.source
? edge.source.sectionHeading === context.heading &&
edge.source.file === context.file
: edge.scope === "note" && edge.source.file === context.file;
return sourceMatches;
});
// Debug logging for findings (use effective counts that match report.neighbors)
console.log(`[Chain Inspector] computeFindings: incomingEffective=${incoming.length}, outgoingEffective=${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
// Check outgoing edges from current section for missing files/headings
for (const edge of sectionEdges) {
const targetFile = edge.target.file;
const targetHeading = edge.target.heading;
// Try to resolve target file
const resolvedFile = app.metadataCache.getFirstLinkpathDest(
normalizeLinkTarget(targetFile),
context.file
);
if (!resolvedFile) {
// File does not exist
const sourceHeading = "sectionHeading" in edge.source ? edge.source.sectionHeading : null;
findings.push({
code: "dangling_target",
severity: "error",
message: `Target file does not exist: ${targetFile}`,
evidence: {
file: context.file,
sectionHeading: sourceHeading,
},
});
continue;
}
// If heading is specified, check if it exists in the file
if (targetHeading !== null) {
const targetContent = app.metadataCache.getFileCache(resolvedFile);
if (targetContent) {
// Use file cache to check headings
const headings = targetContent.headings || [];
const headingExists = headings.some(
(h) => h.heading === targetHeading
);
if (!headingExists) {
const sourceHeading = "sectionHeading" in edge.source ? edge.source.sectionHeading : null;
findings.push({
code: "dangling_target_heading",
severity: "warn",
message: `Target heading not found in ${targetFile}: ${targetHeading}`,
evidence: {
file: context.file,
sectionHeading: sourceHeading,
},
});
}
} else {
// File cache not available - metadataCache might not have processed the file yet
// Skip heading check in this case (file exists but cache not ready)
// This is acceptable as metadataCache will eventually update
}
}
}
// Check: no_causal_roles (if chainRoles available)
// Use canonical types for role matching if edgeVocabulary is available
if (chainRoles) {
const hasCausalRole = sectionEdges.some((edge) => {
// First try canonical type if available
const { canonical } = resolveCanonicalEdgeType(edge.rawEdgeType, edgeVocabulary);
const edgeTypeToCheck = canonical || edge.rawEdgeType;
for (const [roleName, role] of Object.entries(chainRoles?.roles || {})) {
if (CAUSAL_ROLE_NAMES.includes(roleName)) {
// Check both canonical and raw type (permissive)
if (role.edge_types.includes(edgeTypeToCheck) || 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,
edgeVocabularyPath?: string,
chainTemplates?: ChainTemplatesConfig | null,
templatesLoadResult?: { path: string; status: string; loadedAt: number | null; templateCount: number },
templateMatchingProfileName?: string
): 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
// Respect includeNoteLinks and includeCandidates toggles
const outgoingTargets = new Set<string>();
for (const edge of currentEdges) {
// Skip candidates if not included
if (edge.scope === "candidate" && !options.includeCandidates) continue;
// Skip note-level links if not included
if (edge.scope === "note" && !options.includeNoteLinks) continue;
// Only consider edges from current context
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];
// Resolve and deduplicate outgoing neighbor files
const outgoingNeighborFiles = new Set<string>(); // Use Set to deduplicate by resolved path
const outgoingNeighborFileMap = new Map<string, TFile>(); // original target -> resolved TFile
// Resolve outgoing targets (may be basenames without folder)
console.log(`[Chain Inspector] Loading outgoing neighbor notes: ${outgoingTargets.size}`);
for (const targetFile of outgoingTargets) {
if (targetFile === context.file) continue; // Skip self
const neighborFile = await loadNeighborNote(app, targetFile, context.file);
if (neighborFile) {
const resolvedPath = neighborFile.path;
outgoingNeighborFiles.add(resolvedPath);
outgoingNeighborFileMap.set(targetFile, neighborFile);
}
}
// Load edges from outgoing neighbors
for (const neighborPath of outgoingNeighborFiles) {
const neighborFile = app.vault.getAbstractFileByPath(neighborPath);
if (neighborFile && "extension" in neighborFile && neighborFile.extension === "md") {
const { edges: neighborEdges } = await buildNoteIndex(app, neighborFile as TFile);
allEdges.push(...neighborEdges);
console.log(`[Chain Inspector] Loaded ${neighborEdges.length} edges from ${neighborPath} (outgoing neighbor)`);
}
}
// Load notes that link to current note (for incoming edges and backward paths)
// Deduplicate with outgoing neighbors (same file might be both incoming and outgoing)
const allNeighborFiles = new Set<string>([...outgoingNeighborFiles]);
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
if (allNeighborFiles.has(sourceFile)) continue; // Skip if already loaded as outgoing neighbor
const sourceNoteFile = await loadNeighborNote(app, sourceFile, context.file);
if (sourceNoteFile) {
allNeighborFiles.add(sourceNoteFile.path);
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);
// Load edge vocabulary if path provided
let edgeVocabulary: EdgeVocabulary | null = null;
if (edgeVocabularyPath) {
try {
const vocabText = await VocabularyLoader.loadText(app, edgeVocabularyPath);
edgeVocabulary = parseEdgeVocabulary(vocabText);
} catch (error) {
console.warn(`[Chain Inspector] Could not load edge vocabulary from ${edgeVocabularyPath}:`, error);
}
}
// Compute findings (use allEdges for incoming checks, currentEdges for outgoing checks)
// Note: computeFindings will use filterEdges internally to respect includeCandidates,
// ensuring consistency with report.neighbors
const effectiveIncomingCount = neighbors.incoming.length;
const effectiveOutgoingCount = neighbors.outgoing.length;
const effectiveFilteredEdges = filterEdges(allEdges, options);
console.log(`[Chain Inspector] Before computeFindings: effectiveIncoming=${effectiveIncomingCount}, effectiveOutgoing=${effectiveOutgoingCount}, effectiveFilteredEdges=${effectiveFilteredEdges.length}`);
let 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,
edgeVocabulary,
app,
options
);
// Compute analysisMeta
const filteredEdges = filterEdges(allEdges, options);
let edgesWithCanonical = 0;
let edgesUnmapped = 0;
const roleMatches: { [roleName: string]: number } = {};
for (const edge of filteredEdges) {
const { canonical, matchedBy } = resolveCanonicalEdgeType(edge.rawEdgeType, edgeVocabulary);
if (matchedBy !== "none") {
edgesWithCanonical++;
} else {
edgesUnmapped++;
}
// Count role matches (using canonical if available)
if (chainRoles && canonical) {
for (const [roleName, role] of Object.entries(chainRoles?.roles || {})) {
if (role.edge_types.includes(canonical)) {
roleMatches[roleName] = (roleMatches[roleName] || 0) + 1;
}
}
}
}
// Sort roleMatches keys for deterministic output
const sortedRoleMatches: { [roleName: string]: number } = {};
const sortedRoleNames = Object.keys(roleMatches).sort();
for (const roleName of sortedRoleNames) {
const count = roleMatches[roleName];
if (count !== undefined) {
sortedRoleMatches[roleName] = count;
}
}
let analysisMeta: ChainInspectorReport["analysisMeta"] = {
edgesTotal: filteredEdges.length,
edgesWithCanonical,
edgesUnmapped,
roleMatches: sortedRoleMatches,
};
// Resolve profile early (even if no templates) for logging
const profileName = templateMatchingProfileName || "discovery";
let profile: import("../dictionary/types").TemplateMatchingProfile | undefined;
let resolvedFrom: "settings" | "default" = "default";
if (chainTemplates?.defaults?.profiles) {
if (profileName === "discovery" && chainTemplates.defaults.profiles.discovery) {
profile = chainTemplates.defaults.profiles.discovery;
resolvedFrom = templateMatchingProfileName ? "settings" : "default";
} else if (profileName === "decisioning" && chainTemplates.defaults.profiles.decisioning) {
profile = chainTemplates.defaults.profiles.decisioning;
resolvedFrom = templateMatchingProfileName ? "settings" : "default";
}
}
// Log start-of-run header with resolved profile and settings
const requiredLinks = profile?.required_links ?? false;
console.log(
`[Chain Inspector] Run: profile=${profileName} (resolvedFrom=${resolvedFrom}) required_links=${requiredLinks} includeCandidates=${options.includeCandidates} maxDepth=${options.maxDepth} direction=${options.direction}`
);
// Template matching
let templateMatches: TemplateMatch[] = [];
let templatesSource: ChainInspectorReport["templatesSource"] = undefined;
let templateMatchingProfileUsed: ChainInspectorReport["templateMatchingProfileUsed"] = undefined;
if (chainTemplates && templatesLoadResult) {
templatesSource = {
path: templatesLoadResult.path,
status: templatesLoadResult.status as "loaded" | "error" | "using-last-known-good",
loadedAt: templatesLoadResult.loadedAt,
templateCount: templatesLoadResult.templateCount,
};
// Set profile used info
templateMatchingProfileUsed = {
name: profileName,
resolvedFrom,
profileConfig: profile ? {
required_links: profile.required_links,
min_slots_filled_for_gap_findings: profile.min_slots_filled_for_gap_findings,
min_score_for_gap_findings: profile.min_score_for_gap_findings,
} : undefined,
};
try {
const { matchTemplates } = await import("./templateMatching");
const allTemplateMatches = await matchTemplates(
app,
{ file: context.file, heading: context.heading },
allEdges,
chainTemplates,
chainRoles,
edgeVocabulary,
options,
profile
);
// Sort all matches: confidence rank (confirmed > plausible > weak), then score desc, then templateName asc
const confidenceRank = (c: "confirmed" | "plausible" | "weak"): number => {
if (c === "confirmed") return 3;
if (c === "plausible") return 2;
return 1; // weak
};
const sortedMatches = [...allTemplateMatches].sort((a, b) => {
// First by confidence rank (desc)
const rankDiff = confidenceRank(b.confidence) - confidenceRank(a.confidence);
if (rankDiff !== 0) return rankDiff;
// Then by score (desc)
if (b.score !== a.score) return b.score - a.score;
// Finally by templateName (asc)
return a.templateName.localeCompare(b.templateName);
});
// Limit to topN (default: 3, configurable via options.maxTemplateMatches)
const topN = options.maxTemplateMatches ?? 3;
templateMatches = sortedMatches.slice(0, topN);
// Store topNUsed in analysisMeta
if (analysisMeta) {
analysisMeta.topNUsed = topN;
}
// Add template-based findings with profile thresholds
for (const match of templateMatches) {
// Find the template definition to check for template-level required_links override
const templateDef = chainTemplates?.templates?.find(t => t.name === match.templateName);
// Determine effective required_links: template.matching > profile > defaults.matching > false
const effectiveRequiredLinks = templateDef?.matching?.required_links ??
profile?.required_links ??
chainTemplates?.defaults?.matching?.required_links ??
false;
// missing_slot_<slotId> findings (with profile thresholds)
if (match.missingSlots.length > 0) {
const slotsFilled = Object.keys(match.slotAssignments).length;
const minSlotsFilled = profile?.min_slots_filled_for_gap_findings ?? 2;
const minScore = profile?.min_score_for_gap_findings ?? 0;
// Only emit if thresholds are met
if (slotsFilled >= minSlotsFilled && match.score >= minScore) {
for (const slotId of match.missingSlots) {
findings.push({
code: `missing_slot_${slotId}`,
severity: applySeverityPolicy(profileName as "discovery" | "decisioning" | undefined, `missing_slot_${slotId}`, "warn"),
message: `Template ${match.templateName}: missing slot ${slotId} near current section`,
evidence: {
file: context.file,
sectionHeading: context.heading,
},
});
}
}
}
// missing_link_constraints finding
// Only emit if required_links=true (strict mode)
// In soft mode (required_links=false), suppress this finding even if links are incomplete
if (effectiveRequiredLinks && match.slotsComplete && match.requiredLinks > 0 && !match.linksComplete) {
findings.push({
code: "missing_link_constraints",
severity: applySeverityPolicy(profileName as "discovery" | "decisioning" | undefined, "missing_link_constraints", "info"),
message: `Template ${match.templateName}: slots complete but link constraints missing (${match.satisfiedLinks}/${match.requiredLinks} satisfied)`,
evidence: {
file: context.file,
sectionHeading: context.heading,
},
});
}
// weak_chain_roles finding
if (match.roleEvidence && match.roleEvidence.length > 0) {
const hasCausalRole = match.roleEvidence.some((ev) =>
CAUSAL_ROLE_NAMES.includes(ev.edgeRole)
);
if (!hasCausalRole && match.satisfiedLinks > 0) {
findings.push({
code: "weak_chain_roles",
severity: applySeverityPolicy(profileName as "discovery" | "decisioning" | undefined, "weak_chain_roles", "info"),
message: `Template ${match.templateName}: links satisfied but only by non-causal roles`,
evidence: {
file: context.file,
sectionHeading: context.heading,
},
});
}
}
}
} catch (e) {
console.error("[Chain Inspector] Template matching failed:", e);
}
}
// Apply severity policy to all findings
findings = findings.map((finding) => ({
...finding,
severity: applySeverityPolicy(profileName as "discovery" | "decisioning" | undefined, finding.code, finding.severity),
}));
return {
context: {
file: context.file,
heading: context.heading,
zoneKind: context.zoneKind,
},
settings: options,
neighbors,
paths,
findings,
analysisMeta,
templateMatches: templateMatches.length > 0 ? templateMatches : undefined,
templatesSource,
templateMatchingProfileUsed,
};
}