devsmind-mcp 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -381,7 +381,7 @@ class DevMindDatabase {
381
381
  /* istanbul ignore next -- every call site here is a `SELECT COUNT(*) AS c FROM ...`,
382
382
  which always returns exactly one row; `row` can only be undefined if this helper is
383
383
  ever repurposed for a query that can return zero rows. Kept as a real guard, not
384
- because today's three call sites can hit it. */
384
+ because today's call sites can hit it. */
385
385
  return row ? row.c : 0;
386
386
  }
387
387
  catch {
@@ -392,6 +392,8 @@ class DevMindDatabase {
392
392
  nodes: one('SELECT COUNT(*) AS c FROM nodes WHERE deprecated = 0'),
393
393
  connections: one('SELECT COUNT(*) AS c FROM node_connections'),
394
394
  history: one('SELECT COUNT(*) AS c FROM history'),
395
+ vectors: one('SELECT COUNT(*) AS c FROM node_vectors'),
396
+ workflows: one('SELECT COUNT(*) AS c FROM workflows'),
395
397
  };
396
398
  }
397
399
  vacuum() {
@@ -2797,9 +2799,21 @@ class DevMindDatabase {
2797
2799
  if (node.file_path) {
2798
2800
  const paths = node.file_path.split(',').map(p => p.trim()).filter(Boolean);
2799
2801
  if (paths.length > 0) {
2802
+ // "Missing" also covers a file_path that resolves to a DIRECTORY rather than a real
2803
+ // file — plain fs.existsSync() alone says a directory "exists", so a node corrupted
2804
+ // this way (its file_path IS a repo or workspace root, from some earlier bug) sailed
2805
+ // through this check indefinitely; it only ever surfaced as an EISDIR crash when
2806
+ // writeGraphToDisk/writeVectorsToDisk later tried to write a JSON file at that same
2807
+ // path. statSync().isFile() catches both "doesn't exist" and "exists but isn't a file"
2808
+ // in one check — a node deprecated for either reason has no real source to hold anyway.
2800
2809
  const allMissing = paths.every(p => {
2801
2810
  const resolvedPath = path.isAbsolute(p) ? p : path.resolve(workspaceRoot, p);
2802
- return !fs.existsSync(resolvedPath);
2811
+ try {
2812
+ return !fs.statSync(resolvedPath).isFile();
2813
+ }
2814
+ catch {
2815
+ return true;
2816
+ }
2803
2817
  });
2804
2818
  if (allMissing)
2805
2819
  missingFile.push(node);
@@ -2808,6 +2822,58 @@ class DevMindDatabase {
2808
2822
  }
2809
2823
  return { spurious, missingFile };
2810
2824
  }
2825
+ /**
2826
+ * Finds `.devmind/graph`/`.devmind/vectors` JSON output paths that a live node's file_path
2827
+ * still maps to, but that are occupied by a stray DIRECTORY instead of a file — the case
2828
+ * `isDegenerateDiskJsonPath`'s existsSync+isDirectory branch refuses to write over. Left
2829
+ * behind by some earlier node whose file_path once collapsed onto that exact path (see that
2830
+ * method's comment for how); once the directory exists, `writeGraphToDisk`/`writeVectorsToDisk`
2831
+ * can only skip-and-warn forever, since neither one ever deletes anything. This is what
2832
+ * `devsmind analyze --fix` uses to actually remove the blocker and re-trigger the write, so a
2833
+ * perfectly healthy node's file stops being warned about on every future sync.
2834
+ *
2835
+ * Deliberately mirrors ONLY the third of `isDegenerateDiskJsonPath`'s three checks (never the
2836
+ * first two — an empty/trailing-slash/`..`-collapsing diskRelPath means the node's OWN
2837
+ * file_path is degenerate, already covered by `missingFile` above and fixed by deprecating the
2838
+ * node, not by deleting anything on disk). Skipping those here is required for safety: a
2839
+ * collapsed diskRelPath would make `targetPath` resolve to `graphDir`/`vectorsDir` itself (or
2840
+ * an ancestor of it), and blindly reporting that as "stray" would recommend deleting the whole
2841
+ * output tree.
2842
+ */
2843
+ findStrayOutputDirs() {
2844
+ const rows = this.db.prepare('SELECT DISTINCT file_path FROM nodes').all();
2845
+ const filePaths = new Set();
2846
+ for (const row of rows) {
2847
+ if (row.file_path) {
2848
+ for (const p of row.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
2849
+ filePaths.add(p);
2850
+ }
2851
+ }
2852
+ }
2853
+ const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
2854
+ const dirsByKind = [
2855
+ { dir: path.join(workspaceRoot, 'graph'), kind: 'graph' },
2856
+ { dir: path.join(workspaceRoot, 'vectors'), kind: 'vectors' },
2857
+ ];
2858
+ const results = [];
2859
+ for (const filePath of filePaths) {
2860
+ const absPath = (0, config_1.canonicalizePath)(filePath);
2861
+ const repoRelPath = this.toRepoRelativePath(absPath);
2862
+ const diskRelPath = repoRelPath.replace(/^\{([^}]+)\}/, '$1').replace(/\.[^/.]+$/, '.json');
2863
+ if (!diskRelPath || diskRelPath.endsWith('/') || diskRelPath.endsWith('\\'))
2864
+ continue;
2865
+ for (const { dir, kind } of dirsByKind) {
2866
+ const targetPath = path.join(dir, diskRelPath);
2867
+ const relToContainer = path.relative(dir, targetPath);
2868
+ if (relToContainer === '' || relToContainer.startsWith('..'))
2869
+ continue;
2870
+ if (fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()) {
2871
+ results.push({ file_path: filePath, target: targetPath, kind });
2872
+ }
2873
+ }
2874
+ }
2875
+ return results;
2876
+ }
2811
2877
  pruneSpuriousNodes(workspaceRoot) {
2812
2878
  const { spurious, missingFile } = this.findSpuriousAndMissingFileNodes(workspaceRoot);
2813
2879
  const candidates = [...spurious, ...missingFile];
@@ -3351,6 +3417,56 @@ class DevMindDatabase {
3351
3417
  likeEscape(s) {
3352
3418
  return s.replace(/[\\%_]/g, ch => '\\' + ch);
3353
3419
  }
3420
+ /**
3421
+ * True when `targetPath` (a computed `graph/`/`vectors/` JSON path) does NOT land strictly
3422
+ * inside `containerDir` as a real file — i.e. writing there would be writing to a directory,
3423
+ * not a file inside one. Guards `writeGraphToDisk`/`writeVectorsToDisk` against a malformed
3424
+ * node whose `file_path` IS a directory (the workspace root, or a repo root itself): observed
3425
+ * in production as an EISDIR crash on `devsmind sync`, because `toRepoRelativePath` collapses
3426
+ * to `''` (workspace root) or `'{repo}/'` (a repo root) for those, which `diskRelPath` then
3427
+ * turns into `''`/a trailing-slash string/`'..'` depending on which branch collapsed — every
3428
+ * one of which makes `path.join(containerDir, diskRelPath)` resolve to `containerDir` itself
3429
+ * or some directory already inside it, never a fresh `.json` file. Multiple checks on purpose:
3430
+ * the empty/trailing-slash/`..`-relative cases catch it structurally (works even on a brand
3431
+ * new brain where nothing exists on disk yet, where an EISDIR would instead be a silent
3432
+ * file-where-directory-belongs corruption); the existsSync+isDirectory check is a catch-all
3433
+ * for any other collapse shape not foreseen above (existsSync is checked first specifically so
3434
+ * a normal not-yet-written target — the common case — never reaches statSync at all). Callers
3435
+ * already wrap their whole body in a try/catch that logs and returns, so nothing extra is
3436
+ * needed here for a statSync that throws for some other reason (a permission error, a race).
3437
+ * `devsmind analyze --fix` deprecates the node actually causing this; this only ever refuses
3438
+ * the write, never touches the node.
3439
+ */
3440
+ isDegenerateDiskJsonPath(containerDir, diskRelPath, targetPath) {
3441
+ if (!diskRelPath || diskRelPath.endsWith('/') || diskRelPath.endsWith('\\'))
3442
+ return true;
3443
+ const relToContainer = path.relative(containerDir, targetPath);
3444
+ if (relToContainer === '' || relToContainer.startsWith('..'))
3445
+ return true;
3446
+ return fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory();
3447
+ }
3448
+ /**
3449
+ * True when `filePath` ITSELF can never resolve to a valid `graph/`/`vectors/` JSON target —
3450
+ * i.e. `isDegenerateDiskJsonPath`'s first two checks (empty/trailing-slash/`..`-collapsing
3451
+ * `diskRelPath`), never its third (existsSync+isDirectory, which is about a stray directory
3452
+ * left on disk, not about the file_path being bad — that case is exactly what
3453
+ * `findStrayOutputDirs` fixes, and can legitimately happen for a perfectly healthy file_path,
3454
+ * so it must NOT be treated as permanent here). Deliberately does no filesystem I/O — pure
3455
+ * string logic, cheap enough to call for every distinct file_path a full `syncToDisk` touches.
3456
+ * Used to recognize a file_path that's structurally unfixable no matter how many times a write
3457
+ * is retried, as opposed to one that's merely blocked by removable disk cruft.
3458
+ */
3459
+ isFilePathStructurallyDegenerate(filePath) {
3460
+ const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
3461
+ const absPath = (0, config_1.canonicalizePath)(filePath);
3462
+ const repoRelPath = this.toRepoRelativePath(absPath);
3463
+ const diskRelPath = repoRelPath.replace(/^\{([^}]+)\}/, '$1').replace(/\.[^/.]+$/, '.json');
3464
+ if (!diskRelPath || diskRelPath.endsWith('/') || diskRelPath.endsWith('\\'))
3465
+ return true;
3466
+ const graphDir = path.join(workspaceRoot, 'graph');
3467
+ const relToContainer = path.relative(graphDir, path.join(graphDir, diskRelPath));
3468
+ return relToContainer === '' || relToContainer.startsWith('..');
3469
+ }
3354
3470
  writeGraphToDisk(filePath) {
3355
3471
  try {
3356
3472
  if (!filePath)
@@ -3361,7 +3477,12 @@ class DevMindDatabase {
3361
3477
  const repoRelPath = this.toRepoRelativePath(absPath);
3362
3478
  // E.g., "{harrir-web}/app/page.tsx" -> "graph/harrir-web/app/page.json"
3363
3479
  const diskRelPath = repoRelPath.replace(/^\{([^}]+)\}/, '$1').replace(/\.[^/.]+$/, '.json');
3364
- const graphJsonPath = path.join(workspaceRoot, 'graph', diskRelPath);
3480
+ const graphDir = path.join(workspaceRoot, 'graph');
3481
+ const graphJsonPath = path.join(graphDir, diskRelPath);
3482
+ if (this.isDegenerateDiskJsonPath(graphDir, diskRelPath, graphJsonPath)) {
3483
+ console.warn(`⚠️ Skipped writing graph JSON: a node's file_path resolves to a directory, not a file (${absPath}) — run \`devsmind analyze --fix\` to clean it up.`);
3484
+ return;
3485
+ }
3365
3486
  // Get all nodes in this file (active AND deprecated). A node's file_path is either
3366
3487
  // exactly this absolute path, or (for the rare node spanning multiple files) a ", "-joined
3367
3488
  // list containing it. We anchor on the FULL absolute path with ", " boundaries and escape
@@ -3442,7 +3563,14 @@ class DevMindDatabase {
3442
3563
  const absPath = (0, config_1.canonicalizePath)(filePath);
3443
3564
  const repoRelPath = this.toRepoRelativePath(absPath);
3444
3565
  const diskRelPath = repoRelPath.replace(/^\{([^}]+)\}/, '$1').replace(/\.[^/.]+$/, '.json');
3445
- const vectorsJsonPath = path.join(workspaceRoot, 'vectors', diskRelPath);
3566
+ const vectorsDir = path.join(workspaceRoot, 'vectors');
3567
+ const vectorsJsonPath = path.join(vectorsDir, diskRelPath);
3568
+ // Same guard as writeGraphToDisk — see isDegenerateDiskJsonPath's comment for why this
3569
+ // happens and what it means.
3570
+ if (this.isDegenerateDiskJsonPath(vectorsDir, diskRelPath, vectorsJsonPath)) {
3571
+ console.warn(`⚠️ Skipped writing vectors JSON: a node's file_path resolves to a directory, not a file (${absPath}) — run \`devsmind analyze --fix\` to clean it up.`);
3572
+ return;
3573
+ }
3446
3574
  const absLower = absPath.toLowerCase();
3447
3575
  const absEscLower = this.likeEscape(absPath).toLowerCase();
3448
3576
  const stmt = this.db.prepare(`
@@ -3474,20 +3602,50 @@ class DevMindDatabase {
3474
3602
  console.warn('⚠️ SQLite warning: Failed to write vectors JSON to disk:', err);
3475
3603
  }
3476
3604
  }
3477
- /** Force-syncs all database nodes and workflows to disk JSON files. */
3605
+ /**
3606
+ * Force-syncs all database nodes, vectors, and workflows to disk JSON files. This is the
3607
+ * write-back half of `devsmind sync` (paired with `syncFromDisk`, which reads `vectors/*.json`
3608
+ * into `node_vectors` — see its comment). Vectors are re-written here for the same reason
3609
+ * graph JSON is: normal operation already keeps `vectors/` current (`writeVectorsToDisk` runs
3610
+ * immediately alongside every embedding write), but `sync` exists precisely for the abnormal
3611
+ * case — recovering a `.devmind` after `vectors/*.json` was deleted/corrupted independently of
3612
+ * the DB, or after a past write silently failed (e.g. the directory-typed file_path bug
3613
+ * `isDegenerateDiskJsonPath` now catches) — so leaving vectors out of the force-resync
3614
+ * defeated half the point of running it.
3615
+ *
3616
+ * A file_path shared by at least one ACTIVE node is always re-attempted, deprecated or not —
3617
+ * a deprecated node sharing a real file with a live one still needs its `deprecated:1` flag
3618
+ * kept current on disk. A file_path held ONLY by deprecated nodes is skipped when it's also
3619
+ * `isFilePathStructurallyDegenerate` (a directory rather than a file — `deprecateNode` already
3620
+ * wrote it once, at the moment it was deprecated, so nothing new would come of retrying):
3621
+ * without this, a node deprecated for exactly this reason produced the identical "Skipped
3622
+ * writing graph JSON" warning on every single future `sync`, forever, since deprecating a node
3623
+ * never clears its (already-garbage) file_path — indistinguishable from the bug never having
3624
+ * been fixed at all.
3625
+ */
3478
3626
  syncToDisk() {
3479
3627
  try {
3480
- const rows = this.db.prepare('SELECT DISTINCT file_path FROM nodes').all();
3481
- const filePaths = new Set();
3628
+ const rows = this.db.prepare('SELECT DISTINCT file_path, deprecated FROM nodes').all();
3629
+ const activeFilePaths = new Set();
3630
+ const allFilePaths = new Set();
3482
3631
  for (const row of rows) {
3483
- if (row.file_path) {
3484
- for (const p of row.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
3485
- filePaths.add(p);
3486
- }
3632
+ if (!row.file_path)
3633
+ continue;
3634
+ for (const p of row.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
3635
+ allFilePaths.add(p);
3636
+ if (!row.deprecated)
3637
+ activeFilePaths.add(p);
3638
+ }
3639
+ }
3640
+ const filePaths = new Set();
3641
+ for (const p of allFilePaths) {
3642
+ if (activeFilePaths.has(p) || !this.isFilePathStructurallyDegenerate(p)) {
3643
+ filePaths.add(p);
3487
3644
  }
3488
3645
  }
3489
3646
  for (const filePath of filePaths) {
3490
3647
  this.writeGraphToDisk(filePath);
3648
+ this.writeVectorsToDisk(filePath);
3491
3649
  }
3492
3650
  const workflowRows = this.db.prepare('SELECT id FROM workflows').all();
3493
3651
  for (const row of workflowRows) {