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.
- package/README.md +173 -120
- package/dist/index.js +724 -134
- package/package.json +2 -2
- package/src/cli/commands.ts +12 -6
- package/src/core/engine.ts +53 -3
- package/src/core/ghost-mode.ts +53 -0
- package/src/core/project-manager.ts +5 -1
- package/src/indexing/ast.ts +356 -51
- package/src/indexing/indexer.ts +25 -0
- package/src/server/mcp.ts +1 -1
- package/src/server/tools.ts +129 -0
- package/src/storage/tier2.ts +212 -4
- package/real-benchmark.mjs +0 -322
package/src/indexing/indexer.ts
CHANGED
|
@@ -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
package/src/server/tools.ts
CHANGED
|
@@ -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
|
|
package/src/storage/tier2.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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 =>
|
|
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
|
}
|