neuronlayer 0.1.6 → 0.1.8

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.

Potentially problematic release.


This version of neuronlayer might be problematic. Click here for more details.

@@ -149,6 +149,17 @@ export class Indexer extends EventEmitter {
149
149
  const exportsWithFileId = parsed.exports.map(e => ({ ...e, fileId }));
150
150
  this.tier2.insertExports(exportsWithFileId);
151
151
  }
152
+
153
+ // Build dependency edges from imports
154
+ if (parsed.imports.length > 0) {
155
+ this.tier2.clearDependencies(fileId);
156
+ for (const imp of parsed.imports) {
157
+ const targetFile = this.tier2.resolveImportToFile(relativePath, imp.importedFrom);
158
+ if (targetFile) {
159
+ this.tier2.addDependency(fileId, targetFile.id, 'imports');
160
+ }
161
+ }
162
+ }
152
163
  }
153
164
  } catch (astError) {
154
165
  // AST parsing is optional, don't fail the whole index
@@ -156,6 +167,20 @@ export class Indexer extends EventEmitter {
156
167
  }
157
168
 
158
169
  this.emit('fileIndexed', relativePath);
170
+
171
+ // Emit impact warning for changed files (not during initial indexing)
172
+ if (!this.isIndexing) {
173
+ const dependents = this.tier2.getFileDependents(relativePath);
174
+ if (dependents.length > 0) {
175
+ this.emit('fileImpact', {
176
+ file: relativePath,
177
+ affectedFiles: dependents.map(d => d.file),
178
+ affectedCount: dependents.length,
179
+ imports: dependents.map(d => ({ file: d.file, symbols: d.imports }))
180
+ });
181
+ }
182
+ }
183
+
159
184
  return true; // Actually indexed
160
185
  } catch (error) {
161
186
  console.error(`Error indexing ${absolutePath}:`, error);
package/src/server/mcp.ts CHANGED
@@ -25,7 +25,7 @@ export class MCPServer {
25
25
 
26
26
  this.server = new Server(
27
27
  {
28
- name: 'memorylayer',
28
+ name: 'neuronlayer',
29
29
  version: '0.1.0'
30
30
  },
31
31
  {
@@ -138,6 +138,37 @@ export const toolDefinitions: ToolDefinition[] = [
138
138
  required: ['path']
139
139
  }
140
140
  },
141
+ {
142
+ name: 'find_circular_deps',
143
+ description: 'Find circular dependencies in the project. Circular imports cause subtle bugs, make refactoring dangerous, and can lead to undefined values at runtime. Use this to detect and fix import cycles.',
144
+ inputSchema: {
145
+ type: 'object',
146
+ properties: {},
147
+ required: []
148
+ }
149
+ },
150
+ {
151
+ name: 'get_impact_analysis',
152
+ description: 'Analyze the full impact of changing a file. Shows all directly and indirectly affected files, affected tests, and circular dependencies. Use this BEFORE making changes to understand the blast radius and risk level.',
153
+ inputSchema: {
154
+ type: 'object',
155
+ properties: {
156
+ file: {
157
+ type: 'string',
158
+ description: 'File path to analyze impact for'
159
+ },
160
+ depth: {
161
+ type: 'number',
162
+ description: 'How many hops to follow in the dependency graph (default: 3)'
163
+ },
164
+ include_tests: {
165
+ type: 'boolean',
166
+ description: 'Include affected tests in the analysis (default: true)'
167
+ }
168
+ },
169
+ required: ['file']
170
+ }
171
+ },
141
172
  {
142
173
  name: 'get_file_summary',
143
174
  description: 'Get a compressed summary of a file (10x smaller than full content). Use this for quick overview without reading full file.',
@@ -1057,6 +1088,104 @@ export async function handleToolCall(
1057
1088
  };
1058
1089
  }
1059
1090
 
1091
+ case 'find_circular_deps': {
1092
+ const cycles = engine.findCircularDependencies();
1093
+
1094
+ if (cycles.length === 0) {
1095
+ return {
1096
+ status: 'clean',
1097
+ message: 'No circular dependencies found',
1098
+ cycles: []
1099
+ };
1100
+ }
1101
+
1102
+ return {
1103
+ status: 'cycles_found',
1104
+ message: `Found ${cycles.length} circular dependency chain(s)`,
1105
+ cycles: cycles.map((cycle, i) => ({
1106
+ id: i + 1,
1107
+ files: cycle,
1108
+ length: cycle.length - 1, // -1 because last element repeats first
1109
+ description: cycle.join(' → ')
1110
+ })),
1111
+ recommendation: 'Break cycles by extracting shared code into a separate module, using dependency injection, or restructuring imports.'
1112
+ };
1113
+ }
1114
+
1115
+ case 'get_impact_analysis': {
1116
+ const filePath = args.file as string;
1117
+ const depth = (args.depth as number) || 3;
1118
+ const includeTests = args.include_tests !== false;
1119
+
1120
+ // Get transitive dependents (files affected by changing this file)
1121
+ const affected = engine.getTransitiveDependents(filePath, depth);
1122
+
1123
+ // Get what this file imports
1124
+ const deps = engine.getFileDependencies(filePath);
1125
+
1126
+ // Get affected tests if requested
1127
+ let affectedTests: Array<{ name: string; file: string }> = [];
1128
+ if (includeTests) {
1129
+ const allAffectedFiles = [filePath, ...affected.map(a => a.file)];
1130
+ const testSet = new Set<string>();
1131
+
1132
+ for (const f of allAffectedFiles) {
1133
+ const tests = engine.getTestsForFile(f);
1134
+ for (const t of tests) {
1135
+ if (!testSet.has(t.id)) {
1136
+ testSet.add(t.id);
1137
+ affectedTests.push({ name: t.name, file: t.file });
1138
+ }
1139
+ }
1140
+ }
1141
+ }
1142
+
1143
+ // Check for circular dependencies involving this file
1144
+ const allCycles = engine.findCircularDependencies();
1145
+ const relevantCycles = allCycles.filter(cycle => cycle.includes(filePath));
1146
+
1147
+ // Calculate risk level
1148
+ const totalAffected = affected.length;
1149
+ const riskLevel = totalAffected > 10 ? 'HIGH' : totalAffected > 5 ? 'MEDIUM' : 'LOW';
1150
+
1151
+ // Build risk factors
1152
+ const riskFactors: string[] = [];
1153
+ if (totalAffected > 10) riskFactors.push(`${totalAffected} files depend on this`);
1154
+ if (relevantCycles.length > 0) riskFactors.push(`Involved in ${relevantCycles.length} circular dependency chain(s)`);
1155
+ if (affectedTests.length > 5) riskFactors.push(`${affectedTests.length} tests may need updates`);
1156
+ if (deps.imports.length > 10) riskFactors.push(`File has ${deps.imports.length} dependencies`);
1157
+
1158
+ return {
1159
+ file: filePath,
1160
+ risk_level: riskLevel,
1161
+ risk_factors: riskFactors.length > 0 ? riskFactors : ['No significant risk factors detected'],
1162
+ summary: {
1163
+ total_affected_files: totalAffected,
1164
+ direct_dependents: affected.filter(a => a.depth === 1).length,
1165
+ indirect_dependents: affected.filter(a => a.depth > 1).length,
1166
+ affected_tests: affectedTests.length,
1167
+ circular_dependencies: relevantCycles.length
1168
+ },
1169
+ direct_dependents: affected.filter(a => a.depth === 1).map(a => ({
1170
+ file: a.file,
1171
+ imports: a.imports
1172
+ })),
1173
+ indirect_dependents: affected.filter(a => a.depth > 1).map(a => ({
1174
+ file: a.file,
1175
+ depth: a.depth,
1176
+ imports: a.imports
1177
+ })),
1178
+ this_file_imports: deps.imports.map(i => i.file),
1179
+ affected_tests: affectedTests,
1180
+ circular_dependencies: relevantCycles.map(cycle => cycle.join(' → ')),
1181
+ recommendation: riskLevel === 'HIGH'
1182
+ ? 'High-impact change. Consider breaking it into smaller changes and testing incrementally.'
1183
+ : riskLevel === 'MEDIUM'
1184
+ ? 'Moderate impact. Review affected files and ensure tests cover the changes.'
1185
+ : 'Low-risk change. Standard review and testing should suffice.'
1186
+ };
1187
+ }
1188
+
1060
1189
  case 'get_file_summary': {
1061
1190
  const path = args.path as string;
1062
1191
 
@@ -664,13 +664,23 @@ export class Tier2Storage {
664
664
  }
665
665
 
666
666
  getFilesImporting(modulePath: string): Array<{ fileId: number; filePath: string }> {
667
+ // Use exact matching with common import path patterns to avoid false positives
668
+ // e.g., "user" should not match "super-user-service"
667
669
  const stmt = this.db.prepare(`
668
670
  SELECT DISTINCT i.file_id as fileId, f.path as filePath
669
671
  FROM imports i
670
672
  JOIN files f ON i.file_id = f.id
671
- WHERE i.imported_from LIKE ?
673
+ WHERE i.imported_from = ?
674
+ OR i.imported_from LIKE ?
675
+ OR i.imported_from LIKE ?
676
+ OR i.imported_from LIKE ?
672
677
  `);
673
- return stmt.all(`%${modulePath}%`) as Array<{ fileId: number; filePath: string }>;
678
+ return stmt.all(
679
+ modulePath,
680
+ `%/${modulePath}`, // ends with /modulePath
681
+ `./${modulePath}`, // relative ./modulePath
682
+ `../${modulePath}` // parent ../modulePath
683
+ ) as Array<{ fileId: number; filePath: string }>;
674
684
  }
675
685
 
676
686
  // Phase 2: Export operations
@@ -742,7 +752,6 @@ export class Tier2Storage {
742
752
 
743
753
  getFileDependents(filePath: string): Array<{ file: string; imports: string[] }> {
744
754
  // Find files that import this file
745
- // This is a simplified version - in reality we'd need to resolve module paths
746
755
  const fileName = filePath.split(/[/\\]/).pop()?.replace(/\.[^.]+$/, '') || '';
747
756
  const importers = this.getFilesImporting(fileName);
748
757
 
@@ -750,7 +759,10 @@ export class Tier2Storage {
750
759
 
751
760
  for (const importer of importers) {
752
761
  const imports = this.getImportsByFile(importer.fileId);
753
- const relevantImport = imports.find(i => i.importedFrom.includes(fileName));
762
+ const relevantImport = imports.find(i => {
763
+ const importedName = i.importedFrom.split(/[/\\]/).pop()?.replace(/\.[^.]+$/, '') || '';
764
+ return importedName === fileName || i.importedFrom.endsWith(`/${fileName}`) || i.importedFrom.endsWith(`./${fileName}`);
765
+ });
754
766
  if (relevantImport) {
755
767
  deps.push({
756
768
  file: importer.filePath,
@@ -761,4 +773,200 @@ export class Tier2Storage {
761
773
 
762
774
  return deps;
763
775
  }
776
+
777
+ /**
778
+ * Get ALL files affected by a change, walking the dependency graph.
779
+ * depth=1 is direct importers only. depth=3 catches ripple effects.
780
+ */
781
+ getTransitiveDependents(
782
+ filePath: string,
783
+ maxDepth: number = 3
784
+ ): Array<{ file: string; depth: number; imports: string[] }> {
785
+ const visited = new Map<string, { depth: number; imports: string[] }>();
786
+ const queue: Array<{ path: string; depth: number }> = [{ path: filePath, depth: 0 }];
787
+
788
+ while (queue.length > 0) {
789
+ const current = queue.shift()!;
790
+ if (current.depth >= maxDepth) continue;
791
+ if (visited.has(current.path) && visited.get(current.path)!.depth <= current.depth) continue;
792
+
793
+ const dependents = this.getFileDependents(current.path);
794
+ for (const dep of dependents) {
795
+ const existingDepth = visited.get(dep.file)?.depth ?? Infinity;
796
+ const newDepth = current.depth + 1;
797
+
798
+ if (newDepth < existingDepth) {
799
+ visited.set(dep.file, { depth: newDepth, imports: dep.imports });
800
+ queue.push({ path: dep.file, depth: newDepth });
801
+ }
802
+ }
803
+ }
804
+
805
+ visited.delete(filePath); // don't include the original file
806
+ return Array.from(visited.entries())
807
+ .map(([file, info]) => ({ file, ...info }))
808
+ .sort((a, b) => a.depth - b.depth);
809
+ }
810
+
811
+ /**
812
+ * Get the full import graph as an adjacency list.
813
+ * Returns { file → [files it imports] } for the whole project.
814
+ */
815
+ getFullDependencyGraph(): Map<string, string[]> {
816
+ const stmt = this.db.prepare(`
817
+ SELECT f.path as filePath, i.imported_from as importedFrom
818
+ FROM imports i
819
+ JOIN files f ON i.file_id = f.id
820
+ `);
821
+ const rows = stmt.all() as Array<{ filePath: string; importedFrom: string }>;
822
+
823
+ const graph = new Map<string, string[]>();
824
+ for (const row of rows) {
825
+ if (!graph.has(row.filePath)) graph.set(row.filePath, []);
826
+ graph.get(row.filePath)!.push(row.importedFrom);
827
+ }
828
+ return graph;
829
+ }
830
+
831
+ /**
832
+ * Find circular dependencies in the project.
833
+ * Returns arrays of file paths that form cycles.
834
+ */
835
+ findCircularDependencies(): Array<string[]> {
836
+ const graph = this.getFullDependencyGraph();
837
+ const cycles: Array<string[]> = [];
838
+ const visited = new Set<string>();
839
+ const stack = new Set<string>();
840
+
841
+ const dfs = (node: string, path: string[]) => {
842
+ if (stack.has(node)) {
843
+ // Found cycle
844
+ const cycleStart = path.indexOf(node);
845
+ if (cycleStart >= 0) {
846
+ cycles.push(path.slice(cycleStart).concat(node));
847
+ }
848
+ return;
849
+ }
850
+ if (visited.has(node)) return;
851
+
852
+ visited.add(node);
853
+ stack.add(node);
854
+ path.push(node);
855
+
856
+ const deps = graph.get(node) || [];
857
+ for (const dep of deps) {
858
+ // Resolve relative imports to file paths
859
+ const resolved = this.resolveImportPath(node, dep);
860
+ if (resolved) dfs(resolved, [...path]);
861
+ }
862
+
863
+ stack.delete(node);
864
+ };
865
+
866
+ for (const file of graph.keys()) {
867
+ dfs(file, []);
868
+ }
869
+
870
+ // Deduplicate cycles (same cycle can be found from different starting points)
871
+ const uniqueCycles: Array<string[]> = [];
872
+ const seen = new Set<string>();
873
+ for (const cycle of cycles) {
874
+ const normalized = [...cycle].sort().join('|');
875
+ if (!seen.has(normalized)) {
876
+ seen.add(normalized);
877
+ uniqueCycles.push(cycle);
878
+ }
879
+ }
880
+
881
+ return uniqueCycles;
882
+ }
883
+
884
+ /**
885
+ * Resolve a relative import path to an actual file path in the database.
886
+ */
887
+ resolveImportPath(fromFile: string, importPath: string): string | null {
888
+ // Skip external packages (node_modules)
889
+ if (!importPath.startsWith('.') && !importPath.startsWith('/')) return null;
890
+
891
+ const dir = fromFile.split(/[/\\]/).slice(0, -1).join('/');
892
+
893
+ // Normalize the import path
894
+ let resolved = importPath;
895
+ if (importPath.startsWith('./')) {
896
+ resolved = dir + '/' + importPath.slice(2);
897
+ } else if (importPath.startsWith('../')) {
898
+ const parts = dir.split('/');
899
+ let impParts = importPath.split('/');
900
+ while (impParts[0] === '..') {
901
+ parts.pop();
902
+ impParts.shift();
903
+ }
904
+ resolved = parts.join('/') + '/' + impParts.join('/');
905
+ }
906
+
907
+ // Remove extension if present
908
+ const baseName = resolved.replace(/\.(ts|tsx|js|jsx|mjs|cjs)$/, '');
909
+
910
+ // Try to find a matching file in the database
911
+ const stmt = this.db.prepare(`
912
+ SELECT path FROM files
913
+ WHERE path = ? OR path = ? OR path = ? OR path = ?
914
+ OR path = ? OR path = ?
915
+ LIMIT 1
916
+ `);
917
+ const result = stmt.get(
918
+ `${baseName}.ts`, `${baseName}.tsx`,
919
+ `${baseName}.js`, `${baseName}.jsx`,
920
+ `${baseName}/index.ts`, `${baseName}/index.js`
921
+ ) as { path: string } | undefined;
922
+
923
+ return result?.path || null;
924
+ }
925
+
926
+ /**
927
+ * Resolve an import path to a file record in the database.
928
+ * Used by indexer to build the dependencies table.
929
+ */
930
+ resolveImportToFile(
931
+ sourceFilePath: string,
932
+ importPath: string
933
+ ): { id: number; path: string } | null {
934
+ // Skip external packages
935
+ if (!importPath.startsWith('.') && !importPath.startsWith('/')) return null;
936
+
937
+ const sourceDir = sourceFilePath.split(/[/\\]/).slice(0, -1).join('/');
938
+
939
+ // Normalize the import path
940
+ let resolved = importPath;
941
+ if (importPath.startsWith('./')) {
942
+ resolved = sourceDir + '/' + importPath.slice(2);
943
+ } else if (importPath.startsWith('../')) {
944
+ const parts = sourceDir.split('/');
945
+ let impParts = importPath.split('/');
946
+ while (impParts[0] === '..') {
947
+ parts.pop();
948
+ impParts.shift();
949
+ }
950
+ resolved = parts.join('/') + '/' + impParts.join('/');
951
+ }
952
+
953
+ // Remove extension if present
954
+ resolved = resolved.replace(/\.(ts|tsx|js|jsx|mjs|cjs)$/, '');
955
+
956
+ // Try exact matches with common extensions
957
+ const stmt = this.db.prepare(`
958
+ SELECT id, path FROM files
959
+ WHERE path = ? OR path = ? OR path = ? OR path = ?
960
+ OR path = ? OR path = ?
961
+ LIMIT 1
962
+ `);
963
+
964
+ const result = stmt.get(
965
+ `${resolved}.ts`, `${resolved}.tsx`,
966
+ `${resolved}.js`, `${resolved}.jsx`,
967
+ `${resolved}/index.ts`, `${resolved}/index.js`
968
+ ) as { id: number; path: string } | undefined;
969
+
970
+ return result || null;
971
+ }
764
972
  }