{"version":3,"file":"vault-indexer-C5i-OAyr.mjs","names":[],"sources":["../src/memory/vault/walk.ts","../src/memory/vault/deduplicate.ts","../src/memory/vault/parse-links.ts","../src/memory/vault/name-index.ts","../src/memory/vault/resolve.ts","../src/memory/vault/indexer.ts"],"sourcesContent":["/**\n * Vault directory walker — follows symlinks with cycle detection.\n */\n\nimport { statSync, readdirSync, existsSync } from \"node:fs\";\nimport { join, relative } from \"node:path\";\nimport type { VaultFile } from \"./types.js\";\n\n/** Maximum number of .md files to collect from a vault. */\nconst VAULT_MAX_FILES = 10_000;\n\n/** Maximum recursion depth for vault directory walks. */\nconst VAULT_MAX_DEPTH = 10;\n\n/**\n * Directories to always skip, at any depth, during vault walks.\n * Includes standard build/VCS noise plus Obsidian-specific directories.\n */\nconst VAULT_SKIP_DIRS = new Set([\n  // Version control\n  \".git\",\n  // Dependency directories (any language)\n  \"node_modules\",\n  \"vendor\",\n  \"Pods\",\n  // Build / compile output\n  \"dist\",\n  \"build\",\n  \"out\",\n  \"DerivedData\",\n  \".next\",\n  // Python virtual environments and caches\n  \".venv\",\n  \"venv\",\n  \"__pycache__\",\n  // General caches\n  \".cache\",\n  \".bun\",\n  // Obsidian internals\n  \".obsidian\",\n  \".trash\",\n]);\n\n/**\n * Recursively collect all .md files under a vault root, following symlinks.\n *\n * Symlink-following behaviour:\n *  - Symbolic links to files: followed if the target is a .md file\n *  - Symbolic links to directories: followed with cycle detection via inode\n *\n * Cycle detection is based on the real inode of each visited directory.\n * Using the real stat (not lstat) ensures that symlinked dirs resolve to\n * their actual inode, preventing infinite loops.\n *\n * @param vaultRoot  Absolute path to the vault root directory.\n * @param opts       Optional overrides for maxFiles and maxDepth.\n */\nexport function walkVaultMdFiles(\n  vaultRoot: string,\n  opts?: { maxFiles?: number; maxDepth?: number },\n): VaultFile[] {\n  const maxFiles = opts?.maxFiles ?? VAULT_MAX_FILES;\n  const maxDepth = opts?.maxDepth ?? VAULT_MAX_DEPTH;\n\n  const results: VaultFile[] = [];\n  const visitedDirs = new Set<string>();\n\n  function walk(dir: string, depth: number): void {\n    if (results.length >= maxFiles) return;\n    if (depth > maxDepth) return;\n\n    // Get the real inode of this directory (follows symlinks on the dir itself)\n    let dirStat: ReturnType<typeof statSync>;\n    try {\n      dirStat = statSync(dir);\n    } catch {\n      return; // Unreadable or broken symlink — skip\n    }\n\n    const dirKey = `${dirStat.dev}:${dirStat.ino}`;\n    if (visitedDirs.has(dirKey)) return; // Cycle detected\n    visitedDirs.add(dirKey);\n\n    let entries: import(\"node:fs\").Dirent<string>[];\n    try {\n      entries = readdirSync(dir, { withFileTypes: true, encoding: \"utf8\" });\n    } catch {\n      return; // Unreadable directory — skip\n    }\n\n    for (const entry of entries) {\n      if (results.length >= maxFiles) break;\n      if (VAULT_SKIP_DIRS.has(entry.name)) continue;\n\n      const full = join(dir, entry.name);\n\n      if (entry.isSymbolicLink()) {\n        // Follow the symlink — resolve to real target\n        let targetStat: ReturnType<typeof statSync>;\n        try {\n          targetStat = statSync(full); // statSync follows symlinks\n        } catch {\n          continue; // Broken symlink — skip\n        }\n\n        if (targetStat.isDirectory()) {\n          if (!VAULT_SKIP_DIRS.has(entry.name)) {\n            walk(full, depth + 1);\n          }\n        } else if (targetStat.isFile() && entry.name.endsWith(\".md\")) {\n          results.push({\n            absPath: full,\n            vaultRelPath: relative(vaultRoot, full),\n            inode: targetStat.ino,\n            device: targetStat.dev,\n          });\n        }\n      } else if (entry.isDirectory()) {\n        walk(full, depth + 1);\n      } else if (entry.isFile() && entry.name.endsWith(\".md\")) {\n        let fileStat: ReturnType<typeof statSync>;\n        try {\n          fileStat = statSync(full);\n        } catch {\n          continue;\n        }\n        results.push({\n          absPath: full,\n          vaultRelPath: relative(vaultRoot, full),\n          inode: fileStat.ino,\n          device: fileStat.dev,\n        });\n      }\n    }\n  }\n\n  if (existsSync(vaultRoot)) {\n    walk(vaultRoot, 0);\n  }\n\n  return results;\n}\n","/**\n * Inode-based deduplication for vault files.\n */\n\nimport type { VaultFile, InodeGroup } from \"./types.js\";\n\n/**\n * Group vault files by inode identity (device + inode).\n *\n * Within each group, the canonical file is chosen as the one with the\n * fewest path separators (shallowest), breaking ties by shortest string.\n * All other group members become aliases.\n */\nexport function deduplicateByInode(files: VaultFile[]): InodeGroup[] {\n  const groups = new Map<string, VaultFile[]>();\n\n  for (const file of files) {\n    const key = `${file.device}:${file.inode}`;\n    const existing = groups.get(key);\n    if (existing) {\n      existing.push(file);\n    } else {\n      groups.set(key, [file]);\n    }\n  }\n\n  const result: InodeGroup[] = [];\n\n  for (const group of groups.values()) {\n    if (group.length === 0) continue;\n\n    // Sort: fewest path separators first, then shortest string\n    const sorted = [...group].sort((a, b) => {\n      const aDepth = (a.vaultRelPath.match(/\\//g) ?? []).length;\n      const bDepth = (b.vaultRelPath.match(/\\//g) ?? []).length;\n      if (aDepth !== bDepth) return aDepth - bDepth;\n      return a.vaultRelPath.length - b.vaultRelPath.length;\n    });\n\n    const [canonical, ...aliases] = sorted as [VaultFile, ...VaultFile[]];\n    result.push({ canonical, aliases });\n  }\n\n  return result;\n}\n","/**\n * Markdown link parser for vault files.\n *\n * Handles wikilinks ([[target]]), markdown links ([text](path)),\n * embeds (![[target]]), and frontmatter wikilinks.\n */\n\nimport type { ParsedLink } from \"./types.js\";\n\n/**\n * Parse all links from markdown content.\n *\n * Handles:\n *  - Standard wikilinks: [[Target Note]]\n *  - Aliased wikilinks: [[Target Note|Display Text]]\n *  - Heading anchors: [[Target Note#Heading]] (stripped for resolution)\n *  - Embeds: ![[Target Note]]\n *  - Frontmatter wikilinks (YAML between --- delimiters)\n *  - Markdown links: [text](path/to/note.md)\n *  - Markdown embeds: ![alt](image.png)\n *\n * External URLs (http://, https://, mailto:, etc.) are excluded — only\n * relative paths are treated as vault links.\n *\n * @param content  Raw markdown file content.\n * @returns        Array of parsed links in document order.\n */\nexport function parseLinks(content: string): ParsedLink[] {\n  const links: ParsedLink[] = [];\n  const lines = content.split(\"\\n\");\n\n  // Determine frontmatter range (YAML between opening and closing ---)\n  let frontmatterEnd = 0;\n  if (content.startsWith(\"---\")) {\n    const closingIdx = content.indexOf(\"\\n---\", 3);\n    if (closingIdx !== -1) {\n      frontmatterEnd = content.slice(0, closingIdx + 4).split(\"\\n\").length - 1;\n    }\n  }\n\n  // Regex for [[wikilinks]] and ![[embeds]]\n  const wikilinkRe = /(!?)\\[\\[([^\\]]+?)\\]\\]/g;\n\n  // Regex for markdown links [text](target) and embeds ![alt](target)\n  const mdLinkRe = /(!)?\\[([^\\]]*)\\]\\(([^)]+)\\)/g;\n\n  for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {\n    const line = lines[lineIdx]!;\n    const lineNumber = lineIdx + 1; // 1-indexed\n    const isFrontmatter = lineIdx < frontmatterEnd;\n\n    // --- Wikilinks ---\n    wikilinkRe.lastIndex = 0;\n    let match: RegExpExecArray | null;\n    while ((match = wikilinkRe.exec(line)) !== null) {\n      const isEmbed = match[1] === \"!\";\n      const inner = match[2]!;\n\n      // Split on first | for alias\n      const pipeIdx = inner.indexOf(\"|\");\n      const beforePipe = pipeIdx === -1 ? inner : inner.slice(0, pipeIdx);\n      const alias = pipeIdx === -1 ? null : inner.slice(pipeIdx + 1);\n\n      // Strip heading anchor (everything after #)\n      const hashIdx = beforePipe.indexOf(\"#\");\n      const raw = hashIdx === -1 ? beforePipe.trim() : beforePipe.slice(0, hashIdx).trim();\n\n      if (!raw) continue; // Skip links with empty targets (e.g. [[#Heading]])\n\n      links.push({\n        raw,\n        alias: alias?.trim() ?? null,\n        lineNumber,\n        isEmbed: isEmbed && !isFrontmatter,\n        isMdLink: false,\n      });\n    }\n\n    // --- Markdown links --- (skip inside frontmatter)\n    if (!isFrontmatter) {\n      mdLinkRe.lastIndex = 0;\n      while ((match = mdLinkRe.exec(line)) !== null) {\n        const isEmbed = match[1] === \"!\";\n        const displayText = match[2]!;\n        let target = match[3]!.trim();\n\n        // Skip external URLs\n        if (/^[a-z][a-z0-9+.-]*:/i.test(target)) continue;\n\n        // Skip pure anchor links (#heading)\n        if (target.startsWith(\"#\")) continue;\n\n        // Strip heading anchor from target\n        const hashIdx = target.indexOf(\"#\");\n        if (hashIdx !== -1) target = target.slice(0, hashIdx);\n\n        // URL-decode (Obsidian encodes spaces as %20 in md links)\n        try {\n          target = decodeURIComponent(target);\n        } catch {\n          // Malformed encoding — use as-is\n        }\n\n        // Strip .md extension for resolution (resolveWikilink adds it back)\n        const raw = target.replace(/\\.md$/i, \"\").trim();\n        if (!raw) continue;\n\n        links.push({\n          raw,\n          alias: displayText || null,\n          lineNumber,\n          isEmbed,\n          isMdLink: true,\n        });\n      }\n    }\n  }\n\n  return links;\n}\n\n/** @deprecated Use {@link parseLinks} instead. */\nexport const parseWikilinks = parseLinks;\n","/**\n * Name index builder for Obsidian wikilink resolution.\n */\n\nimport { basename } from \"node:path\";\nimport type { VaultFile } from \"./types.js\";\n\n/**\n * Build a name lookup index for Obsidian wikilink resolution.\n *\n * Maps lowercase filename (without .md extension) to all vault-relative paths\n * that share that name. Includes both canonical paths and alias paths so that\n * wikilinks resolve regardless of which path the file is accessed through.\n */\nexport function buildNameIndex(files: VaultFile[]): Map<string, string[]> {\n  const index = new Map<string, string[]>();\n\n  for (const file of files) {\n    const name = basename(file.vaultRelPath, \".md\").toLowerCase();\n    const existing = index.get(name);\n    if (existing) {\n      existing.push(file.vaultRelPath);\n    } else {\n      index.set(name, [file.vaultRelPath]);\n    }\n  }\n\n  return index;\n}\n","/**\n * Wikilink resolver using Obsidian's shortest-match algorithm.\n */\n\nimport { normalize, basename, dirname } from \"node:path\";\n\n/**\n * Resolve a wikilink target to a vault-relative path using Obsidian's rules.\n *\n * Resolution algorithm:\n *  1. If raw contains \"/\", attempt exact path match (with and without .md).\n *  2. Normalize: lowercase the raw target, strip .md extension.\n *  3. Look up in the name index (all files with that basename).\n *  4. If exactly one match, return it.\n *  5. If multiple matches, pick the one closest to the source file\n *     (longest common directory prefix, then shortest overall path).\n *  6. If no matches, return null (dead link).\n *\n * @param raw         The raw link target (heading-stripped, pipe-stripped).\n * @param nameIndex   Map from lowercase basename-without-ext to vault paths.\n * @param sourcePath  Vault-relative path of the file containing the link.\n * @returns           Vault-relative path of the resolved target, or null.\n */\nexport function resolveWikilink(\n  raw: string,\n  nameIndex: Map<string, string[]>,\n  sourcePath: string,\n): string | null {\n  if (!raw) return null;\n\n  // Case 1: path contains \"/\" — try exact match with and without .md\n  if (raw.includes(\"/\")) {\n    const normalized = normalize(raw);\n    const normalizedMd = normalized.endsWith(\".md\") ? normalized : normalized + \".md\";\n\n    // Check if any indexed path matches (case-insensitive for macOS compatibility)\n    for (const [, paths] of nameIndex) {\n      for (const p of paths) {\n        if (p === normalizedMd || p === normalized) return p;\n        if (p.toLowerCase() === normalizedMd.toLowerCase()) return p;\n      }\n    }\n    // Fall through to name lookup in case the path prefix was wrong\n  }\n\n  // Normalize the raw target for name lookup.\n  // Use the basename only — Obsidian resolves by filename, not full path.\n  const rawBase = basename(raw)\n    .replace(/\\.md$/i, \"\")\n    .toLowerCase()\n    .trim();\n\n  if (!rawBase) return null;\n\n  const candidates = nameIndex.get(rawBase);\n\n  if (!candidates || candidates.length === 0) {\n    return null; // Dead link\n  }\n\n  if (candidates.length === 1) {\n    return candidates[0]!;\n  }\n\n  // Multiple matches — pick the one closest to the source file\n  const sourceDir = dirname(sourcePath);\n\n  let bestPath: string | null = null;\n  let bestPrefixLen = -1;\n  let bestPathLen = Infinity;\n\n  for (const candidate of candidates) {\n    const candidateDir = dirname(candidate);\n    const prefixLen = commonPrefixLength(sourceDir, candidateDir);\n    const pathLen = candidate.length;\n\n    if (\n      prefixLen > bestPrefixLen ||\n      (prefixLen === bestPrefixLen && pathLen < bestPathLen)\n    ) {\n      bestPrefixLen = prefixLen;\n      bestPathLen = pathLen;\n      bestPath = candidate;\n    }\n  }\n\n  return bestPath;\n}\n\n/**\n * Compute the length of the common prefix between two directory paths,\n * measured in path segments (not raw characters).\n *\n * Example: \"a/b/c\" and \"a/b/d\" → 2 (common: \"a\", \"b\")\n */\nfunction commonPrefixLength(a: string, b: string): number {\n  if (a === \".\" && b === \".\") return 0;\n  const aParts = a === \".\" ? [] : a.split(\"/\");\n  const bParts = b === \".\" ? [] : b.split(\"/\");\n  let count = 0;\n  const len = Math.min(aParts.length, bParts.length);\n  for (let i = 0; i < len; i++) {\n    if (aParts[i] === bParts[i]) {\n      count++;\n    } else {\n      break;\n    }\n  }\n  return count;\n}\n","/**\n * Main vault indexing orchestrator.\n *\n * Indexes an entire Obsidian vault (or any markdown knowledge base), following\n * symlinks, deduplicating files by inode, parsing wikilinks, and computing\n * per-file health metrics (orphan detection, dead links).\n *\n * Key differences from the project indexer (indexer/sync.ts):\n *  - Follows symbolic links (project indexer skips them)\n *  - Deduplicates files with the same inode (same content reachable via multiple paths)\n *  - Parses [[wikilinks]] and builds a directed link graph\n *  - Resolves wikilinks using Obsidian's shortest-match algorithm\n *  - Computes health metrics per file: inbound/outbound link counts, dead links, orphans\n */\n\nimport { readFileSync } from \"node:fs\";\nimport { basename } from \"node:path\";\nimport type {\n  StorageBackend,\n  VaultFileRow,\n  VaultAliasRow,\n  VaultLinkRow,\n  VaultHealthRow,\n  VaultNameEntry,\n  ChunkRow,\n} from \"../../storage/interface.js\";\nimport { chunkMarkdown } from \"../chunker.js\";\nimport { sha256File, chunkId, yieldToEventLoop } from \"../indexer/helpers.js\";\nimport { walkVaultMdFiles } from \"./walk.js\";\nimport { deduplicateByInode } from \"./deduplicate.js\";\nimport { parseLinks } from \"./parse-links.js\";\nimport { buildNameIndex } from \"./name-index.js\";\nimport { resolveWikilink } from \"./resolve.js\";\nimport type { VaultIndexResult } from \"./types.js\";\n\n/** Number of files to process before yielding to the event loop. */\nconst VAULT_YIELD_EVERY = 1;\n\n/**\n * Index an entire Obsidian vault (or markdown knowledge base) using the\n * async StorageBackend interface.\n *\n * Steps:\n *  1. Walk vault root, following symlinks.\n *  2. Deduplicate by inode — each unique file is indexed once.\n *  3. Build a name index for wikilink resolution.\n *  4. For each canonical file:\n *     a. SHA-256 hash for change detection — skip unchanged files.\n *     b. Read content, chunk with chunkMarkdown().\n *     c. Insert chunks into backend (memory_chunks and memory_fts).\n *     d. Upsert vault_files row.\n *  5. Record aliases in vault_aliases.\n *  6. Rebuild vault_name_index table.\n *  7. Rebuild vault_links:\n *     a. Parse [[wikilinks]] from each canonical file.\n *     b. Resolve each link with resolveWikilink().\n *     c. Insert into vault_links.\n *  8. Compute and upsert health metrics (vault_health).\n *  9. Return statistics.\n *\n * @param backend         StorageBackend to write to.\n * @param vaultProjectId  Registry project ID for the vault \"project\".\n * @param vaultRoot       Absolute path to the vault root directory.\n */\nexport async function indexVault(\n  backend: StorageBackend,\n  vaultProjectId: number,\n  vaultRoot: string,\n): Promise<VaultIndexResult> {\n  const startTime = Date.now();\n\n  const result: VaultIndexResult = {\n    filesIndexed: 0,\n    chunksCreated: 0,\n    filesSkipped: 0,\n    aliasesRecorded: 0,\n    linksExtracted: 0,\n    deadLinksFound: 0,\n    orphansFound: 0,\n    elapsed: 0,\n  };\n\n  // Step 1: Walk vault, collecting all .md files (follows symlinks)\n  const allFiles = walkVaultMdFiles(vaultRoot);\n\n  // Step 2: Deduplicate by inode\n  const inodeGroups = deduplicateByInode(allFiles);\n\n  // Step 3: Build name index (from all files including aliases, for resolution)\n  const nameIndex = buildNameIndex(allFiles);\n\n  // Step 4: Index each canonical file\n  await yieldToEventLoop();\n  let filesSinceYield = 0;\n\n  for (const group of inodeGroups) {\n    if (filesSinceYield >= VAULT_YIELD_EVERY) {\n      await yieldToEventLoop();\n      filesSinceYield = 0;\n    }\n    filesSinceYield++;\n\n    const { canonical } = group;\n\n    let content: string;\n    try {\n      content = readFileSync(canonical.absPath, \"utf8\");\n    } catch {\n      result.filesSkipped++;\n      continue;\n    }\n\n    const hash = sha256File(content);\n\n    // Change detection: skip if hash is unchanged\n    const existing = await backend.getVaultFile(canonical.vaultRelPath);\n    if (existing?.hash === hash) {\n      result.filesSkipped++;\n      continue;\n    }\n\n    // Delete old chunks for this vault path\n    await backend.deleteChunksForFile(vaultProjectId, canonical.vaultRelPath);\n\n    // Chunk the content\n    const chunks = chunkMarkdown(content);\n    const updatedAt = Date.now();\n\n    // Extract title from first H1 heading or filename\n    const titleMatch = /^#\\s+(.+)$/m.exec(content);\n    const title = titleMatch\n      ? titleMatch[1]!.trim()\n      : basename(canonical.vaultRelPath, \".md\");\n\n    // Build chunk rows\n    const chunkRows: ChunkRow[] = [];\n    for (let i = 0; i < chunks.length; i++) {\n      const chunk = chunks[i]!;\n      const id = chunkId(\n        vaultProjectId,\n        canonical.vaultRelPath,\n        i,\n        chunk.startLine,\n        chunk.endLine,\n      );\n      chunkRows.push({\n        id,\n        projectId: vaultProjectId,\n        source: \"vault\",\n        tier: \"topic\",\n        path: canonical.vaultRelPath,\n        startLine: chunk.startLine,\n        endLine: chunk.endLine,\n        hash: chunk.hash,\n        text: chunk.text,\n        updatedAt,\n      });\n    }\n\n    if (chunkRows.length > 0) {\n      await backend.insertChunks(chunkRows);\n    }\n\n    // Upsert vault file record\n    const vaultFileRow: VaultFileRow = {\n      vaultPath: canonical.vaultRelPath,\n      inode: canonical.inode,\n      device: canonical.device,\n      hash,\n      title,\n      indexedAt: updatedAt,\n    };\n    await backend.upsertVaultFile(vaultFileRow);\n\n    result.filesIndexed++;\n    result.chunksCreated += chunks.length;\n  }\n\n  // Step 5: Record aliases in vault_aliases\n  await yieldToEventLoop();\n\n  const allAliases: VaultAliasRow[] = [];\n  for (const group of inodeGroups) {\n    for (const alias of group.aliases) {\n      allAliases.push({\n        vaultPath: alias.vaultRelPath,\n        canonicalPath: group.canonical.vaultRelPath,\n        inode: alias.inode,\n        device: alias.device,\n      });\n      result.aliasesRecorded++;\n    }\n  }\n\n  const canonicalPaths = new Set(inodeGroups.map((g) => g.canonical.vaultRelPath));\n  for (const canonPath of canonicalPaths) {\n    await backend.deleteVaultAliases(canonPath);\n  }\n  if (allAliases.length > 0) {\n    await backend.upsertVaultAliases(allAliases);\n  }\n\n  // Step 6: Rebuild vault_name_index\n  await yieldToEventLoop();\n\n  const nameEntries: VaultNameEntry[] = [];\n  for (const [name, paths] of nameIndex) {\n    for (const path of paths) {\n      nameEntries.push({ name, vaultPath: path });\n    }\n  }\n  await backend.replaceNameIndex(nameEntries);\n\n  // Step 7: Rebuild vault_links\n  await yieldToEventLoop();\n\n  const linkRows: VaultLinkRow[] = [];\n  const allSourcePaths: string[] = [];\n\n  let linkParseYield = 0;\n  for (const group of inodeGroups) {\n    if (linkParseYield++ % VAULT_YIELD_EVERY === 0) {\n      await yieldToEventLoop();\n    }\n\n    const { canonical } = group;\n    allSourcePaths.push(canonical.vaultRelPath);\n\n    let content: string;\n    try {\n      content = readFileSync(canonical.absPath, \"utf8\");\n    } catch {\n      continue;\n    }\n\n    const parsedLinks = parseLinks(content);\n    for (const link of parsedLinks) {\n      const target = resolveWikilink(link.raw, nameIndex, canonical.vaultRelPath);\n      let linkType: string;\n      if (link.isMdLink) {\n        linkType = link.isEmbed ? \"md-embed\" : \"md-link\";\n      } else {\n        linkType = link.isEmbed ? \"embed\" : \"wikilink\";\n      }\n      linkRows.push({\n        sourcePath: canonical.vaultRelPath,\n        targetRaw: link.raw,\n        targetPath: target,\n        linkType,\n        lineNumber: link.lineNumber,\n        confidence: \"EXTRACTED\",\n      });\n    }\n  }\n\n  // Replace all links for all sources in batches of 500\n  const LINK_BATCH_SIZE = 500;\n  for (let i = 0; i < allSourcePaths.length; i += LINK_BATCH_SIZE) {\n    const batchSources = allSourcePaths.slice(i, i + LINK_BATCH_SIZE);\n    const batchLinks = linkRows.filter((r) => batchSources.includes(r.sourcePath));\n    await backend.replaceLinksForSources(batchSources, batchLinks);\n    await yieldToEventLoop();\n  }\n\n  result.linksExtracted = linkRows.length;\n  result.deadLinksFound = linkRows.filter((r) => r.targetPath === null).length;\n\n  // Step 8: Compute and upsert vault_health metrics\n  await yieldToEventLoop();\n\n  const outboundMap = new Map<string, number>();\n  const deadMap = new Map<string, number>();\n  const inboundMap = new Map<string, number>();\n\n  for (const row of linkRows) {\n    outboundMap.set(row.sourcePath, (outboundMap.get(row.sourcePath) ?? 0) + 1);\n    if (row.targetPath === null) {\n      deadMap.set(row.sourcePath, (deadMap.get(row.sourcePath) ?? 0) + 1);\n    } else {\n      inboundMap.set(row.targetPath, (inboundMap.get(row.targetPath) ?? 0) + 1);\n    }\n  }\n\n  const computedAt = Date.now();\n  let orphanCount = 0;\n\n  const HEALTH_BATCH_SIZE = 500;\n  for (let i = 0; i < inodeGroups.length; i += HEALTH_BATCH_SIZE) {\n    const batch = inodeGroups.slice(i, i + HEALTH_BATCH_SIZE);\n    const healthRows: VaultHealthRow[] = batch.map((group) => {\n      const path = group.canonical.vaultRelPath;\n      const inbound = inboundMap.get(path) ?? 0;\n      const outbound = outboundMap.get(path) ?? 0;\n      const dead = deadMap.get(path) ?? 0;\n      const isOrphan = inbound === 0;\n      if (isOrphan) orphanCount++;\n      return {\n        vaultPath: path,\n        inboundCount: inbound,\n        outboundCount: outbound,\n        deadLinkCount: dead,\n        isOrphan,\n        computedAt,\n      };\n    });\n    await backend.upsertVaultHealth(healthRows);\n    await yieldToEventLoop();\n  }\n\n  result.orphansFound = orphanCount;\n  result.elapsed = Date.now() - startTime;\n\n  return result;\n}\n"],"mappings":"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"}