devsmind-mcp 2.3.0 → 3.0.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.
- package/README.md +326 -234
- package/dist/cli/activity.d.ts +9 -0
- package/dist/cli/activity.js +69 -0
- package/dist/cli/activity.js.map +1 -0
- package/dist/cli/describe.d.ts +31 -0
- package/dist/cli/describe.js +201 -0
- package/dist/cli/describe.js.map +1 -0
- package/dist/cli/diff.d.ts +14 -0
- package/dist/cli/diff.js +161 -0
- package/dist/cli/diff.js.map +1 -0
- package/dist/cli/embed.d.ts +6 -0
- package/dist/cli/embed.js +109 -0
- package/dist/cli/embed.js.map +1 -0
- package/dist/cli/extract-agent.d.ts +66 -0
- package/dist/cli/extract-agent.js +192 -0
- package/dist/cli/extract-agent.js.map +1 -0
- package/dist/cli/feedback.d.ts +11 -0
- package/dist/cli/feedback.js +90 -0
- package/dist/cli/feedback.js.map +1 -0
- package/dist/cli/index.js +128 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.d.ts +30 -0
- package/dist/cli/init.js +98 -18
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/integrations/memory-topics.d.ts +44 -0
- package/dist/cli/integrations/memory-topics.js +295 -0
- package/dist/cli/integrations/memory-topics.js.map +1 -0
- package/dist/cli/integrations/memory.d.ts +11 -5
- package/dist/cli/integrations/memory.js +132 -36
- package/dist/cli/integrations/memory.js.map +1 -1
- package/dist/cli/integrations/prompt.d.ts +8 -0
- package/dist/cli/integrations/prompt.js +24 -3
- package/dist/cli/integrations/prompt.js.map +1 -1
- package/dist/cli/integrations/registry.d.ts +19 -5
- package/dist/cli/integrations/registry.js +23 -13
- package/dist/cli/integrations/registry.js.map +1 -1
- package/dist/cli/llm-client.d.ts +116 -0
- package/dist/cli/llm-client.js +322 -0
- package/dist/cli/llm-client.js.map +1 -0
- package/dist/cli/prune.js +4 -3
- package/dist/cli/prune.js.map +1 -1
- package/dist/cli/rule.d.ts +22 -2
- package/dist/cli/rule.js +116 -56
- package/dist/cli/rule.js.map +1 -1
- package/dist/cli/runner.d.ts +21 -0
- package/dist/cli/runner.js +94 -276
- package/dist/cli/runner.js.map +1 -1
- package/dist/cli/sync-progress.d.ts +9 -0
- package/dist/cli/sync-progress.js +41 -0
- package/dist/cli/sync-progress.js.map +1 -0
- package/dist/cli/sync.js +5 -2
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/view.js +3 -2
- package/dist/cli/view.js.map +1 -1
- package/dist/cli/workflow.js +26 -20
- package/dist/cli/workflow.js.map +1 -1
- package/dist/db/activity.d.ts +200 -0
- package/dist/db/activity.js +347 -0
- package/dist/db/activity.js.map +1 -0
- package/dist/db/analyze.js +25 -9
- package/dist/db/analyze.js.map +1 -1
- package/dist/db/database.d.ts +607 -79
- package/dist/db/database.js +1936 -488
- package/dist/db/database.js.map +1 -1
- package/dist/db/edges.d.ts +24 -0
- package/dist/db/edges.js +84 -0
- package/dist/db/edges.js.map +1 -1
- package/dist/db/embedder.d.ts +39 -0
- package/dist/db/embedder.js +0 -0
- package/dist/db/embedder.js.map +1 -0
- package/dist/db/feedback.d.ts +128 -0
- package/dist/db/feedback.js +182 -0
- package/dist/db/feedback.js.map +1 -0
- package/dist/db/file-diff.d.ts +32 -0
- package/dist/db/file-diff.js +110 -0
- package/dist/db/file-diff.js.map +1 -0
- package/dist/db/grep.d.ts +78 -0
- package/dist/db/grep.js +475 -0
- package/dist/db/grep.js.map +1 -0
- package/dist/db/message-revert.d.ts +63 -0
- package/dist/db/message-revert.js +258 -0
- package/dist/db/message-revert.js.map +1 -0
- package/dist/db/revert.d.ts +31 -0
- package/dist/db/revert.js +108 -0
- package/dist/db/revert.js.map +1 -0
- package/dist/db/schema.d.ts +49 -4
- package/dist/db/schema.js +125 -73
- package/dist/db/schema.js.map +1 -1
- package/dist/db/search-index.d.ts +65 -0
- package/dist/db/search-index.js +74 -0
- package/dist/db/search-index.js.map +1 -0
- package/dist/db/staging.d.ts +90 -5
- package/dist/db/staging.js +148 -22
- package/dist/db/staging.js.map +1 -1
- package/dist/mcp/server.d.ts +29 -7
- package/dist/mcp/server.js +2755 -745
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/vendor/3d-force-graph.min.js +5 -0
- package/dist/mcp/vendor/force-graph.min.js +5 -0
- package/dist/mcp/vendor/model/model_int8.onnx +0 -0
- package/dist/mcp/vendor/model/vocab.txt +30522 -0
- package/dist/mcp/vendor/three.min.js +7 -0
- package/dist/mcp/view.css +419 -0
- package/dist/mcp/view.html +161 -0
- package/dist/mcp/view.js +245 -0
- package/dist/mcp/view_chat.js +382 -0
- package/dist/mcp/view_graph.js +576 -0
- package/dist/mcp/visualizer.d.ts +25 -2
- package/dist/mcp/visualizer.js +31 -4
- package/dist/mcp/visualizer.js.map +1 -1
- package/dist/utils/ast.d.ts +187 -0
- package/dist/utils/ast.js +640 -20
- package/dist/utils/ast.js.map +1 -1
- package/dist/utils/diff.d.ts +44 -0
- package/dist/utils/diff.js +78 -0
- package/dist/utils/diff.js.map +1 -0
- package/dist/utils/edit.d.ts +47 -0
- package/dist/utils/edit.js +196 -0
- package/dist/utils/edit.js.map +1 -0
- package/dist/utils/scanner.js +12 -4
- package/dist/utils/scanner.js.map +1 -1
- package/dist/utils/tokenize.d.ts +45 -0
- package/dist/utils/tokenize.js +129 -0
- package/dist/utils/tokenize.js.map +1 -0
- package/dist/utils/version.d.ts +14 -0
- package/dist/utils/version.js +61 -0
- package/dist/utils/version.js.map +1 -0
- package/package.json +18 -5
- package/dist/mcp/visualizer_2d.html +0 -635
- package/dist/mcp/visualizer_3d.html +0 -613
package/dist/utils/ast.js
CHANGED
|
@@ -33,13 +33,84 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.AST_PARSEABLE_EXTENSIONS = void 0;
|
|
37
|
+
exports.isAstParseable = isAstParseable;
|
|
38
|
+
exports.normalizeFsPath = normalizeFsPath;
|
|
39
|
+
exports.invalidateParsedFile = invalidateParsedFile;
|
|
36
40
|
exports.parseNodeId = parseNodeId;
|
|
41
|
+
exports.detectRtkEndpointAliases = detectRtkEndpointAliases;
|
|
42
|
+
exports.detectRtkEndpointNodes = detectRtkEndpointNodes;
|
|
37
43
|
exports.resolveConnectionsLocally = resolveConnectionsLocally;
|
|
44
|
+
exports.locateNodeInFile = locateNodeInFile;
|
|
38
45
|
exports.extractNodeFromFile = extractNodeFromFile;
|
|
46
|
+
exports.enumerateFileCandidates = enumerateFileCandidates;
|
|
47
|
+
exports.outlineFile = outlineFile;
|
|
48
|
+
exports.listFileImports = listFileImports;
|
|
49
|
+
exports.findTouchedSymbols = findTouchedSymbols;
|
|
39
50
|
const fs = __importStar(require("fs"));
|
|
40
51
|
const path = __importStar(require("path"));
|
|
41
52
|
const ts = __importStar(require("typescript"));
|
|
42
53
|
const config_1 = require("./config");
|
|
54
|
+
/**
|
|
55
|
+
* Extensions the TypeScript parser can read, and therefore the only ones where a symbol's exact
|
|
56
|
+
* span is knowable — live code extraction, AST edge resolution, and in-place editing are all
|
|
57
|
+
* limited to these. Every other indexed language falls back to regex reference matching.
|
|
58
|
+
*
|
|
59
|
+
* `.mjs`/`.cjs` are plain JavaScript and parse fine (TS understands both script kinds); they are
|
|
60
|
+
* listed for the same reason `.js` is. Template-based formats (`.vue`, `.svelte`) are NOT here —
|
|
61
|
+
* their files aren't valid JS, so the parser would choke on the markup.
|
|
62
|
+
*/
|
|
63
|
+
exports.AST_PARSEABLE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte']);
|
|
64
|
+
/** Single-file-component formats: real JS, but only inside their `<script>` block. */
|
|
65
|
+
const SFC_EXTENSIONS = new Set(['.vue', '.svelte']);
|
|
66
|
+
/**
|
|
67
|
+
* For a single-file component, blank every character outside its `<script>` block(s) while
|
|
68
|
+
* preserving the file's exact length and line breaks. The parser then sees only JavaScript,
|
|
69
|
+
* yet every offset it reports still indexes the REAL file — which is what lets the rest of
|
|
70
|
+
* this module treat `.vue`/`.svelte` like any other source file. Other extensions pass through.
|
|
71
|
+
*/
|
|
72
|
+
function maskNonScript(text, filePath) {
|
|
73
|
+
if (!SFC_EXTENSIONS.has(path.extname(filePath).toLowerCase()))
|
|
74
|
+
return text;
|
|
75
|
+
const masked = text.replace(/[^\n]/g, ' ').split('');
|
|
76
|
+
const re = /<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi;
|
|
77
|
+
for (let m = re.exec(text); m !== null; m = re.exec(text)) {
|
|
78
|
+
const innerStart = m.index + m[0].indexOf('>') + 1;
|
|
79
|
+
for (let i = 0; i < m[1].length; i++)
|
|
80
|
+
masked[innerStart + i] = text[innerStart + i];
|
|
81
|
+
}
|
|
82
|
+
return masked.join('');
|
|
83
|
+
}
|
|
84
|
+
/** Parse `text` as the JS/TS belonging to `filePath`, masking SFC markup first. */
|
|
85
|
+
function parseText(filePath, text) {
|
|
86
|
+
const isSfc = SFC_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
|
87
|
+
return ts.createSourceFile(filePath, maskNonScript(text, filePath), ts.ScriptTarget.Latest, true,
|
|
88
|
+
// An SFC's extension tells TS nothing; its <script> may be either language, and TS is a
|
|
89
|
+
// superset of JS, so parsing as TS reads both.
|
|
90
|
+
isSfc ? ts.ScriptKind.TS : undefined);
|
|
91
|
+
}
|
|
92
|
+
/** True when `filePath` can be parsed for exact symbol spans. */
|
|
93
|
+
function isAstParseable(filePath) {
|
|
94
|
+
return exports.AST_PARSEABLE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Canonical form for COMPARING two filesystem paths — absolute, forward slashes, lower case.
|
|
98
|
+
*
|
|
99
|
+
* Windows reaches the same file through several spellings (`c:\x` vs `C:/x`), so any exact
|
|
100
|
+
* match against a stored path silently misses unless both sides are folded first. Comparison
|
|
101
|
+
* only: never write this back to disk or store it as a node's file_path.
|
|
102
|
+
*/
|
|
103
|
+
function normalizeFsPath(p) {
|
|
104
|
+
return path.resolve(p).replace(/\\/g, '/').toLowerCase();
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Drop a file's cached AST. The cache is mtime-keyed, but mtime resolution is coarse enough
|
|
108
|
+
* that a write followed immediately by a read can still be served the pre-write tree, so a
|
|
109
|
+
* writer must invalidate explicitly rather than trust the timestamp to have moved.
|
|
110
|
+
*/
|
|
111
|
+
function invalidateParsedFile(filePath) {
|
|
112
|
+
sourceFileCache.delete(filePath);
|
|
113
|
+
}
|
|
43
114
|
/**
|
|
44
115
|
* Parses a DevsMind node ID into constituent parts
|
|
45
116
|
*/
|
|
@@ -124,6 +195,8 @@ function getFileImports(sourceFile) {
|
|
|
124
195
|
*/
|
|
125
196
|
function isDefinitionName(node) {
|
|
126
197
|
const parent = node.parent;
|
|
198
|
+
/* istanbul ignore if -- defensive: every node from a tree parsed with setParentNodes=true
|
|
199
|
+
(see parseText) has a parent except the SourceFile itself, which is never an Identifier. */
|
|
127
200
|
if (!parent)
|
|
128
201
|
return false;
|
|
129
202
|
// The declared name of a declaration (function foo, class Foo, const foo, param foo, foo() {} …)
|
|
@@ -343,6 +416,128 @@ function findRouteCall(sourceFile, method, arg) {
|
|
|
343
416
|
ts.forEachChild(sourceFile, visit);
|
|
344
417
|
return found;
|
|
345
418
|
}
|
|
419
|
+
/**
|
|
420
|
+
* Walks a source file for RTK Query endpoint definitions. Endpoint keys live inside
|
|
421
|
+
* `endpoints: (builder) => ({ ... })` — an ARROW FUNCTION BODY — so they are structurally
|
|
422
|
+
* invisible to the general declaration walk ({@link declarationsOverlapping}/`findNodeInAst`
|
|
423
|
+
* both explicitly stop at function-body boundaries, since a local const or a helper defined
|
|
424
|
+
* inside a function body is never a graph entity). RTK endpoints are the one exception to that
|
|
425
|
+
* rule this codebase knows about, which is why they need their own dedicated walk rather than
|
|
426
|
+
* being reachable by the general "find a declaration by name" machinery.
|
|
427
|
+
*/
|
|
428
|
+
function findRtkEndpointMatches(sourceFile) {
|
|
429
|
+
const results = [];
|
|
430
|
+
function findEndpointsObject(fnBody) {
|
|
431
|
+
const unwrap = (e) => ts.isParenthesizedExpression(e) ? unwrap(e.expression) : e;
|
|
432
|
+
if (ts.isBlock(fnBody)) {
|
|
433
|
+
for (const stmt of fnBody.statements) {
|
|
434
|
+
if (ts.isReturnStatement(stmt) && stmt.expression) {
|
|
435
|
+
const expr = unwrap(stmt.expression);
|
|
436
|
+
if (ts.isObjectLiteralExpression(expr))
|
|
437
|
+
return expr;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
const expr = unwrap(fnBody);
|
|
443
|
+
return ts.isObjectLiteralExpression(expr) ? expr : null;
|
|
444
|
+
}
|
|
445
|
+
function visit(node) {
|
|
446
|
+
if (ts.isCallExpression(node) &&
|
|
447
|
+
ts.isIdentifier(node.expression) &&
|
|
448
|
+
(node.expression.text === 'createApi' || node.expression.text === 'injectEndpoints') &&
|
|
449
|
+
node.arguments.length > 0 &&
|
|
450
|
+
ts.isObjectLiteralExpression(node.arguments[0])) {
|
|
451
|
+
const configObj = node.arguments[0];
|
|
452
|
+
for (const prop of configObj.properties) {
|
|
453
|
+
if (!ts.isPropertyAssignment(prop) ||
|
|
454
|
+
!ts.isIdentifier(prop.name) ||
|
|
455
|
+
prop.name.text !== 'endpoints' ||
|
|
456
|
+
!(ts.isArrowFunction(prop.initializer) || ts.isFunctionExpression(prop.initializer)))
|
|
457
|
+
continue;
|
|
458
|
+
const endpointsObj = findEndpointsObject(prop.initializer.body);
|
|
459
|
+
if (!endpointsObj)
|
|
460
|
+
continue;
|
|
461
|
+
for (const epProp of endpointsObj.properties) {
|
|
462
|
+
if (!(ts.isPropertyAssignment(epProp) || ts.isMethodDeclaration(epProp)) ||
|
|
463
|
+
!(ts.isIdentifier(epProp.name) || ts.isStringLiteral(epProp.name)))
|
|
464
|
+
continue;
|
|
465
|
+
const epName = epProp.name.text;
|
|
466
|
+
// `.query(...)` / `.mutation(...)` may appear either as `key: builder.query(...)`
|
|
467
|
+
// (property assignment) or `key(builder) { return builder.query(...) }` (method) —
|
|
468
|
+
// only the property-assignment shape is RTK Query's documented form, so that's all we
|
|
469
|
+
// match; a method-shorthand endpoint falls through and simply isn't found.
|
|
470
|
+
if (!ts.isPropertyAssignment(epProp) || !ts.isCallExpression(epProp.initializer))
|
|
471
|
+
continue;
|
|
472
|
+
const callExpr = epProp.initializer;
|
|
473
|
+
if (!ts.isPropertyAccessExpression(callExpr.expression))
|
|
474
|
+
continue;
|
|
475
|
+
const method = callExpr.expression.name.text;
|
|
476
|
+
if (method !== 'query' && method !== 'mutation')
|
|
477
|
+
continue;
|
|
478
|
+
results.push({ propNode: epProp, epName, method });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
ts.forEachChild(node, visit);
|
|
483
|
+
}
|
|
484
|
+
visit(sourceFile);
|
|
485
|
+
return results;
|
|
486
|
+
}
|
|
487
|
+
function detectRtkEndpointAliases(filePath) {
|
|
488
|
+
if (!isAstParseable(filePath))
|
|
489
|
+
return [];
|
|
490
|
+
let sourceFile;
|
|
491
|
+
try {
|
|
492
|
+
sourceFile = getSourceFile(filePath);
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
return [];
|
|
496
|
+
}
|
|
497
|
+
const toPascal = (name) => (name.length ? name[0].toUpperCase() + name.slice(1) : name);
|
|
498
|
+
return findRtkEndpointMatches(sourceFile).map(({ epName, method }) => {
|
|
499
|
+
const pascal = toPascal(epName);
|
|
500
|
+
const aliases = method === 'query'
|
|
501
|
+
? [`use${pascal}Query`, `useLazy${pascal}Query`]
|
|
502
|
+
: [`use${pascal}Mutation`];
|
|
503
|
+
return { endpointName: epName, aliases };
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* The candidate-enumeration counterpart to {@link detectRtkEndpointAliases}: produces full
|
|
508
|
+
* {@link ExtractionCandidate}s for each RTK endpoint, so {@link enumerateFileCandidates} doesn't
|
|
509
|
+
* silently skip them (see {@link findRtkEndpointMatches} for why the general declaration walk
|
|
510
|
+
* can't see them). Always `isExported:true` — an endpoint reached only through its generated hook
|
|
511
|
+
* is, for every practical purpose, part of the file's export surface; nothing about whether it
|
|
512
|
+
* "counts" as a node is a judgment call, so this feeds the SAME auto-accept path as a normal
|
|
513
|
+
* exported declaration.
|
|
514
|
+
*/
|
|
515
|
+
function detectRtkEndpointNodes(filePath) {
|
|
516
|
+
if (!isAstParseable(filePath))
|
|
517
|
+
return [];
|
|
518
|
+
let sourceFile;
|
|
519
|
+
try {
|
|
520
|
+
sourceFile = getSourceFile(filePath);
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return [];
|
|
524
|
+
}
|
|
525
|
+
return findRtkEndpointMatches(sourceFile).map(({ propNode, epName }) => {
|
|
526
|
+
const start = propNode.getStart(sourceFile);
|
|
527
|
+
const end = propNode.getEnd();
|
|
528
|
+
const code = propNode.getText(sourceFile);
|
|
529
|
+
return {
|
|
530
|
+
qualified: epName,
|
|
531
|
+
name: epName,
|
|
532
|
+
type: 'rtk_endpoint',
|
|
533
|
+
signature: code.split('\n')[0].slice(0, 200),
|
|
534
|
+
codeSnapshot: code,
|
|
535
|
+
startLine: sourceFile.getLineAndCharacterOfPosition(start).line + 1,
|
|
536
|
+
endLine: sourceFile.getLineAndCharacterOfPosition(end).line + 1,
|
|
537
|
+
isExported: true
|
|
538
|
+
};
|
|
539
|
+
});
|
|
540
|
+
}
|
|
346
541
|
/** Navigate a dotted path through nested object-literal properties. */
|
|
347
542
|
function navigateObjectPath(obj, segments) {
|
|
348
543
|
let current = obj;
|
|
@@ -362,6 +557,10 @@ function navigateObjectPath(obj, segments) {
|
|
|
362
557
|
current = null;
|
|
363
558
|
}
|
|
364
559
|
}
|
|
560
|
+
/* istanbul ignore next -- unreachable: every iteration of the loop above returns (either
|
|
561
|
+
`null` via a missing prop, or `prop` on the last segment), so control can only fall past
|
|
562
|
+
the loop when `segments` is empty. The sole caller (findInFrameworkContainer) already
|
|
563
|
+
guards `segments.length === 0` before ever calling this function. */
|
|
365
564
|
return null;
|
|
366
565
|
}
|
|
367
566
|
/**
|
|
@@ -372,6 +571,9 @@ function navigateObjectPath(obj, segments) {
|
|
|
372
571
|
* factory call's object-literal argument, so we isolate just that method.
|
|
373
572
|
*/
|
|
374
573
|
function findInFrameworkContainer(sourceFile, segments) {
|
|
574
|
+
/* istanbul ignore if -- defensive: the sole caller (findNodeInAst) only reaches here when
|
|
575
|
+
`symbolName.includes('.')`, so `segments` (symbolName.split('.').slice(1)) always has at
|
|
576
|
+
least one element. */
|
|
375
577
|
if (segments.length === 0)
|
|
376
578
|
return null;
|
|
377
579
|
const objArgs = [];
|
|
@@ -472,6 +674,10 @@ function findNodeInAst(sourceFile, className, symbolName) {
|
|
|
472
674
|
// Fallback: className resolved to a non-class declaration (object literal, factory call,
|
|
473
675
|
// etc). Search inside it for a property/method matching the member name, at any depth.
|
|
474
676
|
if (!foundNode && className && containerCandidate) {
|
|
677
|
+
/* istanbul ignore next -- defensive: every current caller sets `className` from a symbolName
|
|
678
|
+
that included a '.' in the first place (locateNodeInFile/codeOfSymbol split on 2 parts,
|
|
679
|
+
resolveConnectionsLocally's className comes from parseNodeId, which only sets it alongside
|
|
680
|
+
a dotted symbolName), so the ": symbolName" (no-dot) fallback is never exercised. */
|
|
475
681
|
const memberName = symbolName.includes('.') ? symbolName.split('.').pop() : symbolName;
|
|
476
682
|
foundNode = findPropertyInContainer(containerCandidate, memberName);
|
|
477
683
|
}
|
|
@@ -527,7 +733,7 @@ function getSourceFile(filePath, content) {
|
|
|
527
733
|
if (cached && cached.mtimeMs === mtimeMs && content === undefined)
|
|
528
734
|
return cached.sf;
|
|
529
735
|
const text = content ?? fs.readFileSync(filePath, 'utf-8');
|
|
530
|
-
const sf =
|
|
736
|
+
const sf = parseText(filePath, text);
|
|
531
737
|
sourceFileCache.set(filePath, { mtimeMs, sf });
|
|
532
738
|
return sf;
|
|
533
739
|
}
|
|
@@ -615,6 +821,9 @@ function resolveToExistingFile(basePaths) {
|
|
|
615
821
|
return cached;
|
|
616
822
|
let result = null;
|
|
617
823
|
outer: for (const base of basePaths) {
|
|
824
|
+
/* istanbul ignore if -- defensive: every entry basePaths can contain comes from
|
|
825
|
+
resolveImportToPaths, which only ever pushes path.resolve(...) results (always a
|
|
826
|
+
non-empty string) or pushes nothing at all — never a falsy element. */
|
|
618
827
|
if (!base)
|
|
619
828
|
continue;
|
|
620
829
|
for (const ext of ['.ts', '.tsx', '.js', '.jsx']) {
|
|
@@ -659,6 +868,9 @@ function getBarrelReexports(resolvedImportPath) {
|
|
|
659
868
|
barrelMissCache.add(resolvedImportPath);
|
|
660
869
|
return [];
|
|
661
870
|
}
|
|
871
|
+
/* istanbul ignore next -- defensive TOCTOU guard: `indexPath` was confirmed to exist one line
|
|
872
|
+
above via fs.existsSync; the `?? -1` only matters if the file is deleted in the instant
|
|
873
|
+
between that check and this statSync, a race not worth simulating in a unit test. */
|
|
662
874
|
const mtimeMs = statMtime(indexPath) ?? -1;
|
|
663
875
|
const cached = barrelCache.get(indexPath);
|
|
664
876
|
if (cached && cached.mtimeMs === mtimeMs)
|
|
@@ -698,11 +910,30 @@ function getBarrelReexports(resolvedImportPath) {
|
|
|
698
910
|
// import alias by (case-insensitive) node name,
|
|
699
911
|
// - null when there is no default export at all.
|
|
700
912
|
const ANON_DEFAULT = 'anon';
|
|
913
|
+
/**
|
|
914
|
+
* The name for a non-identifier `export default <expr>` (an ExportAssignment): a named class or
|
|
915
|
+
* function expression's own name, or the ANON_DEFAULT marker for anything else.
|
|
916
|
+
*/
|
|
917
|
+
function anonOrNamedExpressionDefaultName(expr) {
|
|
918
|
+
/* istanbul ignore next -- unreachable: `export default class Foo {}` / `export default
|
|
919
|
+
function foo() {}`, named or anonymous, always parse as a ClassDeclaration/FunctionDeclaration
|
|
920
|
+
(getDefaultExportName's other, `stmt.name ? ... : ANON_DEFAULT`, branch), never as this
|
|
921
|
+
ExportAssignment's `expression`. The only way a ClassExpression/FunctionExpression can reach
|
|
922
|
+
here at all is wrapped (`export default (class Foo {})`), and then `expr` is the
|
|
923
|
+
ParenthesizedExpression, not the class/function expression itself — so this never matches in
|
|
924
|
+
practice, verified directly against the TS parser. */
|
|
925
|
+
if ((ts.isClassExpression(expr) || ts.isFunctionExpression(expr)) && expr.name)
|
|
926
|
+
return expr.name.text;
|
|
927
|
+
return ANON_DEFAULT; // `export default Joi.object({...})`, `{...}`, `() => …`
|
|
928
|
+
}
|
|
701
929
|
const defaultExportCache = new Map();
|
|
702
930
|
function getDefaultExportName(filePath) {
|
|
703
931
|
const ext = path.extname(filePath).toLowerCase();
|
|
704
932
|
if (!['.ts', '.tsx', '.js', '.jsx'].includes(ext))
|
|
705
933
|
return null;
|
|
934
|
+
/* istanbul ignore next -- defensive TOCTOU guard: every caller passes a `targetNode.file_path`
|
|
935
|
+
it already has from a live candidate-node list, i.e. a file that exists; the `?? -1` only
|
|
936
|
+
matters if it's deleted in the instant before this statSync. */
|
|
706
937
|
const mtimeMs = statMtime(filePath) ?? -1;
|
|
707
938
|
const cached = defaultExportCache.get(filePath);
|
|
708
939
|
if (cached && cached.mtimeMs === mtimeMs)
|
|
@@ -714,12 +945,7 @@ function getDefaultExportName(filePath) {
|
|
|
714
945
|
// `export default <expr>`
|
|
715
946
|
if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
|
|
716
947
|
const expr = stmt.expression;
|
|
717
|
-
|
|
718
|
-
name = expr.text;
|
|
719
|
-
else if ((ts.isClassExpression(expr) || ts.isFunctionExpression(expr)) && expr.name)
|
|
720
|
-
name = expr.name.text;
|
|
721
|
-
else
|
|
722
|
-
name = ANON_DEFAULT; // `export default Joi.object({...})`, `{...}`, `() => …`
|
|
948
|
+
name = ts.isIdentifier(expr) ? expr.text : anonOrNamedExpressionDefaultName(expr);
|
|
723
949
|
break;
|
|
724
950
|
}
|
|
725
951
|
// `export default class X {}` / `export default function X() {}`
|
|
@@ -770,8 +996,7 @@ function resolveConnectionsLocally(sourceNodeId, sourceFilePath, candidateNodes,
|
|
|
770
996
|
return [];
|
|
771
997
|
}
|
|
772
998
|
const fileContent = fs.readFileSync(sourceFilePath, 'utf-8');
|
|
773
|
-
const
|
|
774
|
-
const isTsOrJs = ['.ts', '.tsx', '.js', '.jsx'].includes(ext);
|
|
999
|
+
const isTsOrJs = isAstParseable(sourceFilePath);
|
|
775
1000
|
const tsPaths = loadTsPaths(repoRoot);
|
|
776
1001
|
let referencedNames = new Set();
|
|
777
1002
|
let thisMembers = new Set();
|
|
@@ -822,6 +1047,8 @@ function resolveConnectionsLocally(sourceNodeId, sourceFilePath, candidateNodes,
|
|
|
822
1047
|
const paths = resolveImportToPaths(imp.moduleSpecifier, sourceDir, repoRoot, tsPaths);
|
|
823
1048
|
const barrels = [];
|
|
824
1049
|
for (const p of paths) {
|
|
1050
|
+
/* istanbul ignore if -- defensive: see the matching guard in resolveToExistingFile —
|
|
1051
|
+
resolveImportToPaths never pushes a falsy path. */
|
|
825
1052
|
if (!p)
|
|
826
1053
|
continue;
|
|
827
1054
|
const rx = getBarrelReexports(p);
|
|
@@ -889,8 +1116,11 @@ function resolveConnectionsLocally(sourceNodeId, sourceFilePath, candidateNodes,
|
|
|
889
1116
|
connections.add(targetNode.id);
|
|
890
1117
|
continue;
|
|
891
1118
|
}
|
|
1119
|
+
// Check the target's own name AND any alias it's exported under (e.g. an RTK-generated
|
|
1120
|
+
// hook name for its endpoint) — one implementation, several referenceable handles.
|
|
892
1121
|
const nameToCheck = memberName || symbolName;
|
|
893
|
-
|
|
1122
|
+
const targetNames = [nameToCheck, ...(targetNode.aliases ?? [])];
|
|
1123
|
+
if (targetNames.some(n => referencedNames.has(n))) {
|
|
894
1124
|
connections.add(targetNode.id);
|
|
895
1125
|
}
|
|
896
1126
|
continue;
|
|
@@ -973,8 +1203,13 @@ function resolveConnectionsLocally(sourceNodeId, sourceFilePath, candidateNodes,
|
|
|
973
1203
|
// matching a `Utils.formatDate` method) — a pure false positive.
|
|
974
1204
|
}
|
|
975
1205
|
else {
|
|
976
|
-
// Top-level function/variable imported & referenced
|
|
977
|
-
|
|
1206
|
+
// Top-level function/variable imported & referenced — check the target's own declared
|
|
1207
|
+
// name AND any alias it's exported under. This is the RTK fix: a caller importing
|
|
1208
|
+
// `useGetAdminOrdersQuery` (generated by createApi, never a name that appears in the
|
|
1209
|
+
// endpoint's own declaration) still resolves to the `getAdminOrders` endpoint node, as
|
|
1210
|
+
// long as that alias was attached to it (see the RTK detector / Phase C).
|
|
1211
|
+
const targetNames = [symbolName, ...(targetNode.aliases ?? [])];
|
|
1212
|
+
if (targetNames.some(n => importedAsNames.includes(n) && referencedNames.has(n))) {
|
|
978
1213
|
connections.add(targetNode.id);
|
|
979
1214
|
continue;
|
|
980
1215
|
}
|
|
@@ -1007,10 +1242,15 @@ function resolveConnectionsLocally(sourceNodeId, sourceFilePath, candidateNodes,
|
|
|
1007
1242
|
// default-imported and its alias is referenced here, and the file is small enough that
|
|
1008
1243
|
// its default export is unambiguous, link it. The node-count gate keeps this from
|
|
1009
1244
|
// re-exploding on large files (whose default export is virtually always named anyway).
|
|
1245
|
+
// `?? 99` is defensive only: `nodesPerFile` is built from `candidateNodes`, which always
|
|
1246
|
+
// includes `targetNode` itself, so its own file is always present with count >= 1; the
|
|
1247
|
+
// `?? 99` fallback can never actually be taken.
|
|
1248
|
+
/* istanbul ignore next */
|
|
1249
|
+
const targetFileNodeCount = nodesPerFile.get(normFile(targetNode.file_path)) ?? 99;
|
|
1010
1250
|
if (isImported &&
|
|
1011
1251
|
importedAsDefaultNames.some(alias => referencedNames.has(alias)) &&
|
|
1012
1252
|
getDefaultExportName(targetNode.file_path) === ANON_DEFAULT &&
|
|
1013
|
-
|
|
1253
|
+
targetFileNodeCount <= 3) {
|
|
1014
1254
|
connections.add(targetNode.id);
|
|
1015
1255
|
continue;
|
|
1016
1256
|
}
|
|
@@ -1049,13 +1289,13 @@ function resolveConnectionsLocally(sourceNodeId, sourceFilePath, candidateNodes,
|
|
|
1049
1289
|
return Array.from(connections);
|
|
1050
1290
|
}
|
|
1051
1291
|
/**
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1054
|
-
*
|
|
1292
|
+
* Locate a symbol's span in a file via the AST. This is the write-side counterpart to
|
|
1293
|
+
* `extractNodeFromFile`: same lookup, but it also surfaces the offsets, so a caller can
|
|
1294
|
+
* replace the symbol without the caller ever having to read the file or reproduce its
|
|
1295
|
+
* text byte-exactly.
|
|
1055
1296
|
*/
|
|
1056
|
-
function
|
|
1057
|
-
|
|
1058
|
-
if (!['.ts', '.tsx', '.js', '.jsx'].includes(ext))
|
|
1297
|
+
function locateNodeInFile(filePath, symbolName) {
|
|
1298
|
+
if (!isAstParseable(filePath))
|
|
1059
1299
|
return null;
|
|
1060
1300
|
try {
|
|
1061
1301
|
const sf = getSourceFile(filePath);
|
|
@@ -1064,16 +1304,396 @@ function extractNodeFromFile(filePath, symbolName) {
|
|
|
1064
1304
|
const node = findNodeInAst(sf, className, symbolName);
|
|
1065
1305
|
if (!node)
|
|
1066
1306
|
return null;
|
|
1307
|
+
const start = node.getStart(sf); // skips leading trivia → a JSDoc block above stays put
|
|
1308
|
+
const end = node.getEnd();
|
|
1067
1309
|
const code = node.getText(sf);
|
|
1310
|
+
const full = sf.getFullText();
|
|
1311
|
+
// Indent is only meaningful when nothing but whitespace precedes the symbol on its line;
|
|
1312
|
+
// for an inline symbol (`const x = function () {}`) there is no indent to re-apply.
|
|
1313
|
+
const lineStart = full.lastIndexOf('\n', start - 1) + 1;
|
|
1314
|
+
const prefix = full.slice(lineStart, start);
|
|
1068
1315
|
return {
|
|
1069
1316
|
name: parts[parts.length - 1] || symbolName,
|
|
1070
1317
|
type: astBaseType(node),
|
|
1071
1318
|
signature: code.split('\n')[0].slice(0, 200),
|
|
1072
|
-
codeSnapshot: code
|
|
1319
|
+
codeSnapshot: code,
|
|
1320
|
+
start,
|
|
1321
|
+
end,
|
|
1322
|
+
startLine: sf.getLineAndCharacterOfPosition(start).line + 1,
|
|
1323
|
+
endLine: sf.getLineAndCharacterOfPosition(end).line + 1,
|
|
1324
|
+
indent: /^[ \t]*$/.test(prefix) ? prefix : ''
|
|
1073
1325
|
};
|
|
1074
1326
|
}
|
|
1075
1327
|
catch {
|
|
1076
1328
|
return null;
|
|
1077
1329
|
}
|
|
1078
1330
|
}
|
|
1331
|
+
function extractNodeFromFile(filePath, symbolName) {
|
|
1332
|
+
const loc = locateNodeInFile(filePath, symbolName);
|
|
1333
|
+
if (!loc)
|
|
1334
|
+
return null;
|
|
1335
|
+
const { name, type, signature, codeSnapshot } = loc;
|
|
1336
|
+
return { name, type, signature, codeSnapshot };
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* The name a declaration contributes to a node id, or null if it isn't one the graph models.
|
|
1340
|
+
*
|
|
1341
|
+
* Mirrors the shapes `findNodeInAst` resolves in the opposite direction, including the
|
|
1342
|
+
* object-literal factory form (`Page({ onShopLook() {} })` → `Page.onShopLook`) used by
|
|
1343
|
+
* mini-program style frameworks.
|
|
1344
|
+
*/
|
|
1345
|
+
function declarationNameOf(node) {
|
|
1346
|
+
const plain = (n) => n.name && (ts.isIdentifier(n.name) || ts.isStringLiteral(n.name)) ? n.name.text : null;
|
|
1347
|
+
// Nothing declared inside a function body is a graph entity: a local `const`, a helper
|
|
1348
|
+
// function, an object literal built in a return statement. Only declarations reachable from
|
|
1349
|
+
// the file without passing through a function qualify — which still admits class members and
|
|
1350
|
+
// factory-call members, since a class or a call is not a function body.
|
|
1351
|
+
for (let p = node.parent; p; p = p.parent) {
|
|
1352
|
+
if (ts.isSourceFile(p))
|
|
1353
|
+
break;
|
|
1354
|
+
if (isFunctionLikeScope(p))
|
|
1355
|
+
return null;
|
|
1356
|
+
}
|
|
1357
|
+
if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) ||
|
|
1358
|
+
ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) ||
|
|
1359
|
+
ts.isEnumDeclaration(node)) {
|
|
1360
|
+
const n = plain(node);
|
|
1361
|
+
return n ? { name: n, qualified: n } : null;
|
|
1362
|
+
}
|
|
1363
|
+
// Class members carry their class name. A method may also live in an object literal rather
|
|
1364
|
+
// than a class, so membership is decided by the parent, not by the node kind.
|
|
1365
|
+
if (ts.isMethodDeclaration(node) || ts.isPropertyDeclaration(node) ||
|
|
1366
|
+
ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node) ||
|
|
1367
|
+
ts.isConstructorDeclaration(node) || ts.isPropertyAssignment(node) ||
|
|
1368
|
+
ts.isShorthandPropertyAssignment(node)) {
|
|
1369
|
+
const member = ts.isConstructorDeclaration(node) ? 'constructor' : plain(node);
|
|
1370
|
+
if (!member)
|
|
1371
|
+
return null;
|
|
1372
|
+
if (node.parent && ts.isClassLike(node.parent) && node.parent.name) {
|
|
1373
|
+
return { name: member, qualified: `${node.parent.name.text}.${member}` };
|
|
1374
|
+
}
|
|
1375
|
+
// A plain key is only a symbol when its object is itself named — `Page({ data: {...} })`
|
|
1376
|
+
// or `const api = { timeout: 30 }`. Keys nested deeper (`data: { n: 1 }` → `n`) are that
|
|
1377
|
+
// object's CONTENTS, not entities of their own: the graph models `Page.data`, never
|
|
1378
|
+
// `Page.n`. Methods are exempt — a function nested at any depth is still a real symbol.
|
|
1379
|
+
if (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) {
|
|
1380
|
+
/* istanbul ignore next -- defensive: by grammar, a PropertyAssignment/
|
|
1381
|
+
ShorthandPropertyAssignment's parent is always its containing ObjectLiteralExpression, so
|
|
1382
|
+
the `: undefined` (not-an-object-literal-parent) side of this ternary is unreachable. */
|
|
1383
|
+
const owner = node.parent && ts.isObjectLiteralExpression(node.parent) ? node.parent.parent : undefined;
|
|
1384
|
+
const namedObject = owner && (ts.isCallExpression(owner) || ts.isVariableDeclaration(owner));
|
|
1385
|
+
if (!namedObject)
|
|
1386
|
+
return null;
|
|
1387
|
+
}
|
|
1388
|
+
// Otherwise it sits inside an object literal. Walk out to whatever names that object:
|
|
1389
|
+
// a factory call (`Page({...})` → `Page.onShopLook`) or a variable (`const api = {...}`).
|
|
1390
|
+
for (let cur = node.parent; cur; cur = cur.parent) {
|
|
1391
|
+
if (ts.isCallExpression(cur) && ts.isIdentifier(cur.expression)) {
|
|
1392
|
+
return { name: member, qualified: `${cur.expression.text}.${member}` };
|
|
1393
|
+
}
|
|
1394
|
+
if (ts.isVariableDeclaration(cur) && cur.name && ts.isIdentifier(cur.name)) {
|
|
1395
|
+
return { name: member, qualified: `${cur.name.text}.${member}` };
|
|
1396
|
+
}
|
|
1397
|
+
if (ts.isClassLike(cur) && cur.name) {
|
|
1398
|
+
return { name: member, qualified: `${cur.name.text}.${member}` };
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
return { name: member, qualified: member };
|
|
1402
|
+
}
|
|
1403
|
+
if (ts.isVariableDeclaration(node)) {
|
|
1404
|
+
const n = plain(node);
|
|
1405
|
+
return n ? { name: n, qualified: n } : null;
|
|
1406
|
+
}
|
|
1407
|
+
return null;
|
|
1408
|
+
}
|
|
1409
|
+
/** Every declaration whose span overlaps one of `ranges`, innermost-first ordering not implied. */
|
|
1410
|
+
function declarationsOverlapping(sf, ranges) {
|
|
1411
|
+
// A zero-width range (a pure deletion) still has to intersect something, so give it width 1.
|
|
1412
|
+
const spans = ranges.map(r => ({ start: r.start, end: Math.max(r.end, r.start + 1) }));
|
|
1413
|
+
const hits = [];
|
|
1414
|
+
const visit = (n) => {
|
|
1415
|
+
const start = n.getStart(sf);
|
|
1416
|
+
const end = n.getEnd();
|
|
1417
|
+
if (!spans.some(s => start < s.end && s.start < end))
|
|
1418
|
+
return; // disjoint — skip its subtree
|
|
1419
|
+
const named = declarationNameOf(n);
|
|
1420
|
+
if (named)
|
|
1421
|
+
hits.push({ node: n, name: named.name, qualified: named.qualified, start, end });
|
|
1422
|
+
ts.forEachChild(n, visit);
|
|
1423
|
+
};
|
|
1424
|
+
ts.forEachChild(sf, visit);
|
|
1425
|
+
return hits;
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Whether a declaration is part of the file's export surface — directly (`export function foo`,
|
|
1429
|
+
* `export const foo = ...`), via a separate `export { foo }` statement, or transitively (a method
|
|
1430
|
+
* of an exported class, a member of an object literal assigned to an exported variable). This is
|
|
1431
|
+
* the auto-accept signal for {@link enumerateFileCandidates}: an exported, named declaration is
|
|
1432
|
+
* unambiguously a real entity — nothing about its EXISTENCE is a judgment call, only an
|
|
1433
|
+
* unexported/anonymous one needs curation.
|
|
1434
|
+
*/
|
|
1435
|
+
function isNodeExported(node, sourceFile) {
|
|
1436
|
+
// Class/object-literal members inherit their container's export status — a method isn't
|
|
1437
|
+
// separately "exported", it's reachable (or not) through whatever names its class/object.
|
|
1438
|
+
if (ts.isMethodDeclaration(node) || ts.isPropertyDeclaration(node) || ts.isGetAccessorDeclaration(node) ||
|
|
1439
|
+
ts.isSetAccessorDeclaration(node) || ts.isConstructorDeclaration(node) ||
|
|
1440
|
+
ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) {
|
|
1441
|
+
if (node.parent && ts.isClassLike(node.parent))
|
|
1442
|
+
return isNodeExported(node.parent, sourceFile);
|
|
1443
|
+
for (let cur = node.parent; cur; cur = cur.parent) {
|
|
1444
|
+
if (ts.isVariableStatement(cur))
|
|
1445
|
+
return isNodeExported(cur, sourceFile);
|
|
1446
|
+
if (ts.isSourceFile(cur))
|
|
1447
|
+
break;
|
|
1448
|
+
}
|
|
1449
|
+
return false;
|
|
1450
|
+
}
|
|
1451
|
+
const hasExportModifier = (n) => !!n.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
1452
|
+
// `export const foo = ...`: the `export` modifier sits on the enclosing VariableStatement, not
|
|
1453
|
+
// on the VariableDeclaration itself.
|
|
1454
|
+
const modifierHost = ts.isVariableDeclaration(node) && node.parent && ts.isVariableDeclarationList(node.parent)
|
|
1455
|
+
? node.parent.parent
|
|
1456
|
+
: node;
|
|
1457
|
+
if (hasExportModifier(modifierHost))
|
|
1458
|
+
return true;
|
|
1459
|
+
// `export { foo, bar }` as its own statement elsewhere in the file, not a modifier at all.
|
|
1460
|
+
const declaredName = (ts.isVariableDeclaration(node) && node.name && ts.isIdentifier(node.name)) ? node.name.text :
|
|
1461
|
+
(node.name && ts.isIdentifier(node.name))
|
|
1462
|
+
? node.name.text
|
|
1463
|
+
: null;
|
|
1464
|
+
if (!declaredName)
|
|
1465
|
+
return false;
|
|
1466
|
+
for (const stmt of sourceFile.statements) {
|
|
1467
|
+
if (ts.isExportDeclaration(stmt) && !stmt.moduleSpecifier &&
|
|
1468
|
+
stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
|
|
1469
|
+
if (stmt.exportClause.elements.some(el => el.name.text === declaredName || el.propertyName?.text === declaredName)) {
|
|
1470
|
+
return true;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
return false;
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Enumerates every graph-eligible declaration in a file, deterministically — no LLM, so nothing
|
|
1478
|
+
* gets silently missed the way the old whole-file-to-an-LLM extraction could. A named, EXPORTED
|
|
1479
|
+
* declaration (`isExported:true`) is unambiguous: whether it "counts" as a node requires no
|
|
1480
|
+
* judgment, so the caller can auto-accept it with zero LLM turns. Everything else — unexported
|
|
1481
|
+
* helpers, anonymous default exports, tiny inline callbacks, object-literal factory members whose
|
|
1482
|
+
* significance is genuinely a judgment call — is still enumerated (nothing is dropped silently)
|
|
1483
|
+
* but flagged `isExported:false`, so an agentic curation pass only has to spend turns on THOSE.
|
|
1484
|
+
*/
|
|
1485
|
+
function enumerateFileCandidates(filePath) {
|
|
1486
|
+
if (!isAstParseable(filePath))
|
|
1487
|
+
return [];
|
|
1488
|
+
let sourceFile;
|
|
1489
|
+
try {
|
|
1490
|
+
sourceFile = getSourceFile(filePath);
|
|
1491
|
+
}
|
|
1492
|
+
catch {
|
|
1493
|
+
return [];
|
|
1494
|
+
}
|
|
1495
|
+
const fullText = sourceFile.getFullText();
|
|
1496
|
+
const hits = declarationsOverlapping(sourceFile, [{ start: 0, end: fullText.length }]);
|
|
1497
|
+
// A single declaration can surface more than once under different matching rules (e.g. a
|
|
1498
|
+
// property assignment's own span, and — if it were itself a match — an initializer nested
|
|
1499
|
+
// inside it). Keep the FIRST hit per qualified name: `declarationsOverlapping` walks
|
|
1500
|
+
// parent-before-child, so the first hit is always the outermost, correct one.
|
|
1501
|
+
const byQualified = new Map();
|
|
1502
|
+
for (const h of hits) {
|
|
1503
|
+
if (!byQualified.has(h.qualified))
|
|
1504
|
+
byQualified.set(h.qualified, h);
|
|
1505
|
+
}
|
|
1506
|
+
const results = [];
|
|
1507
|
+
for (const h of byQualified.values()) {
|
|
1508
|
+
const code = h.node.getText(sourceFile);
|
|
1509
|
+
results.push({
|
|
1510
|
+
qualified: h.qualified,
|
|
1511
|
+
name: h.name,
|
|
1512
|
+
type: astBaseType(h.node),
|
|
1513
|
+
signature: code.split('\n')[0].slice(0, 200),
|
|
1514
|
+
codeSnapshot: code,
|
|
1515
|
+
startLine: sourceFile.getLineAndCharacterOfPosition(h.start).line + 1,
|
|
1516
|
+
endLine: sourceFile.getLineAndCharacterOfPosition(h.end).line + 1,
|
|
1517
|
+
isExported: isNodeExported(h.node, sourceFile)
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
// RTK Query endpoints live inside a function body (`endpoints: (builder) => ({...})`), so the
|
|
1521
|
+
// general declaration walk above structurally cannot see them — see `findRtkEndpointMatches`.
|
|
1522
|
+
// Folded in here (not called separately by every consumer) so `enumerateFileCandidates` really
|
|
1523
|
+
// is the complete candidate list for a file, RTK or not.
|
|
1524
|
+
for (const rtkNode of detectRtkEndpointNodes(filePath)) {
|
|
1525
|
+
if (!byQualified.has(rtkNode.qualified))
|
|
1526
|
+
results.push(rtkNode);
|
|
1527
|
+
}
|
|
1528
|
+
return results;
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Every OTHER top-level declaration in a file — functions, classes, consts, types, interfaces —
|
|
1532
|
+
* the "what else is here" a raw file read used to be the only way to answer. Reuses
|
|
1533
|
+
* {@link enumerateFileCandidates}'s own declaration walk (`declarationsOverlapping` /
|
|
1534
|
+
* `astBaseType` / `isNodeExported`), but deliberately does NOT call `node.getText()` per hit the
|
|
1535
|
+
* way that function does: `enumerateFileCandidates` materializes each declaration's FULL source
|
|
1536
|
+
* just to build a one-line signature and then discards it, which is an extra copy of potentially
|
|
1537
|
+
* the whole file in string allocations — on every single `get_node_code` call, this is squarely
|
|
1538
|
+
* the wrong trade. A short slice of the already-loaded full text plus a line split gets the same
|
|
1539
|
+
* signature far cheaper. The parse itself is free either way: `getSourceFile` is mtime-cached
|
|
1540
|
+
* ({@link getSourceFile}), and by the time `get_node_code` calls this the file has usually already
|
|
1541
|
+
* been parsed once this same request via `getLiveCode`/`extractLiveCode`.
|
|
1542
|
+
*/
|
|
1543
|
+
function outlineFile(filePath) {
|
|
1544
|
+
if (!isAstParseable(filePath))
|
|
1545
|
+
return [];
|
|
1546
|
+
let sourceFile;
|
|
1547
|
+
try {
|
|
1548
|
+
sourceFile = getSourceFile(filePath);
|
|
1549
|
+
}
|
|
1550
|
+
catch {
|
|
1551
|
+
return [];
|
|
1552
|
+
}
|
|
1553
|
+
const fullText = sourceFile.getFullText();
|
|
1554
|
+
const hits = declarationsOverlapping(sourceFile, [{ start: 0, end: fullText.length }]);
|
|
1555
|
+
// Same "keep the first (outermost) hit per qualified name" rule as enumerateFileCandidates —
|
|
1556
|
+
// declarationsOverlapping walks parent-before-child, so the first hit is always correct.
|
|
1557
|
+
const byQualified = new Map();
|
|
1558
|
+
for (const h of hits) {
|
|
1559
|
+
if (!byQualified.has(h.qualified))
|
|
1560
|
+
byQualified.set(h.qualified, h);
|
|
1561
|
+
}
|
|
1562
|
+
const results = [];
|
|
1563
|
+
for (const h of byQualified.values()) {
|
|
1564
|
+
/* istanbul ignore next -- the `|| null` arm is unreachable: `declarationsOverlapping` takes
|
|
1565
|
+
each hit's start from `node.getStart(sf)`, which skips leading trivia, so the slice always
|
|
1566
|
+
begins at a real token character and its first line is never the empty string. Kept as a
|
|
1567
|
+
type-level guarantee that `signature` is `string | null` rather than `string | undefined`. */
|
|
1568
|
+
const signature = fullText.slice(h.start, h.start + 200).split('\n')[0] || null;
|
|
1569
|
+
results.push({
|
|
1570
|
+
qualified: h.qualified,
|
|
1571
|
+
name: h.name,
|
|
1572
|
+
type: astBaseType(h.node),
|
|
1573
|
+
signature,
|
|
1574
|
+
start_line: sourceFile.getLineAndCharacterOfPosition(h.start).line + 1,
|
|
1575
|
+
end_line: sourceFile.getLineAndCharacterOfPosition(h.end).line + 1,
|
|
1576
|
+
exported: isNodeExported(h.node, sourceFile)
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
// RTK Query endpoints live inside a function body and the general walk above structurally
|
|
1580
|
+
// cannot see them (see detectRtkEndpointNodes) — folded in here so the outline is genuinely
|
|
1581
|
+
// complete, RTK or not, same as enumerateFileCandidates. Its own codeSnapshot is dropped; these
|
|
1582
|
+
// are individual endpoint bodies (small), not a whole-file cost, but the outline never carries
|
|
1583
|
+
// code regardless of source.
|
|
1584
|
+
for (const rtk of detectRtkEndpointNodes(filePath)) {
|
|
1585
|
+
if (byQualified.has(rtk.qualified))
|
|
1586
|
+
continue;
|
|
1587
|
+
results.push({
|
|
1588
|
+
qualified: rtk.qualified,
|
|
1589
|
+
name: rtk.name,
|
|
1590
|
+
type: rtk.type,
|
|
1591
|
+
signature: rtk.signature,
|
|
1592
|
+
start_line: rtk.startLine,
|
|
1593
|
+
end_line: rtk.endLine,
|
|
1594
|
+
exported: rtk.isExported
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
return results;
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* A file's own import list — file-path entry point for {@link getFileImports} (which needs an
|
|
1601
|
+
* already-parsed `ts.SourceFile`). Used by the Phase D curation agent's `get_file_imports` tool:
|
|
1602
|
+
* whether an ambiguous candidate is worth keeping is often decided by whether it's ever imported
|
|
1603
|
+
* anywhere, and the file's OWN imports are the cheap first signal (e.g. a candidate that mirrors
|
|
1604
|
+
* a re-exported name, or that only makes sense alongside a specific imported dependency).
|
|
1605
|
+
*/
|
|
1606
|
+
function listFileImports(filePath) {
|
|
1607
|
+
if (!isAstParseable(filePath))
|
|
1608
|
+
return [];
|
|
1609
|
+
try {
|
|
1610
|
+
return getFileImports(getSourceFile(filePath));
|
|
1611
|
+
}
|
|
1612
|
+
catch {
|
|
1613
|
+
return [];
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
/** A symbol's current source in `sf`, or null when it isn't there. */
|
|
1617
|
+
function codeOfSymbol(sf, symbolName) {
|
|
1618
|
+
const parts = symbolName.split('.');
|
|
1619
|
+
const className = parts.length === 2 ? parts[0] : undefined;
|
|
1620
|
+
const node = findNodeInAst(sf, className, symbolName);
|
|
1621
|
+
return node ? node.getText(sf) : null;
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* Work out which symbols an edit actually changed, from where the edit landed.
|
|
1625
|
+
*
|
|
1626
|
+
* Position beats name matching: a span is unambiguous where a name is not (fifty classes can
|
|
1627
|
+
* each have a `run`), it survives the symbol being renamed by the very edit being traced, and
|
|
1628
|
+
* it finds code that did not exist until this write.
|
|
1629
|
+
*
|
|
1630
|
+
* Three passes, each removing a specific kind of wrong answer:
|
|
1631
|
+
* 1. OVERLAP — every declaration intersecting the written span. A point would not do: text
|
|
1632
|
+
* appended after an anchor (`}` → `}\n\nfunction added() {}`) begins inside the PREVIOUS
|
|
1633
|
+
* function, so a point attributes the new function to its neighbour.
|
|
1634
|
+
* 2. INNERMOST — drop any declaration that contains another hit, so inserting a method
|
|
1635
|
+
* reports the method and not its whole class.
|
|
1636
|
+
* 3. CHANGED — compare each survivor against `beforeContent` and keep only what actually
|
|
1637
|
+
* differs. Overlap alone over-reports: the anchor's own function is intersected by an
|
|
1638
|
+
* append yet is untouched by it, and re-recording it would invent history for a change
|
|
1639
|
+
* that never happened. A symbol absent from `beforeContent` is new.
|
|
1640
|
+
*
|
|
1641
|
+
* Symbols already in the graph are matched by span against their RECORDED ids, never by
|
|
1642
|
+
* re-deriving a name — a container name may have been chosen at index time and be absent from
|
|
1643
|
+
* the source (`Component({...})` recorded as `ProductImageGalleryComponent`).
|
|
1644
|
+
*
|
|
1645
|
+
* `knownSymbols` should be the graph's nodes for this file; empty is fine, everything is then
|
|
1646
|
+
* reported as new. An edit touching no symbol at all (an import, markup, a config value) yields
|
|
1647
|
+
* nothing — a normal outcome, not an error.
|
|
1648
|
+
*/
|
|
1649
|
+
function findTouchedSymbols(filePath, ranges, knownSymbols = [], beforeContent) {
|
|
1650
|
+
if (!isAstParseable(filePath) || !ranges.length)
|
|
1651
|
+
return [];
|
|
1652
|
+
try {
|
|
1653
|
+
const sf = getSourceFile(filePath);
|
|
1654
|
+
const before = beforeContent === undefined ? null : parseText(filePath, beforeContent);
|
|
1655
|
+
// 1. overlap
|
|
1656
|
+
const hits = declarationsOverlapping(sf, ranges);
|
|
1657
|
+
if (!hits.length)
|
|
1658
|
+
return [];
|
|
1659
|
+
// Prefer the graph's own id wherever a hit is the same span as a node it already knows.
|
|
1660
|
+
const byId = new Map();
|
|
1661
|
+
for (const k of knownSymbols) {
|
|
1662
|
+
const loc = locateNodeInFile(filePath, k.symbolName);
|
|
1663
|
+
if (loc)
|
|
1664
|
+
byId.set(`${loc.start}-${loc.end}`, k);
|
|
1665
|
+
}
|
|
1666
|
+
// 2. innermost: discard anything that strictly contains another hit
|
|
1667
|
+
const innermost = hits.filter(h => !hits.some(o => o !== h && o.start >= h.start && o.end <= h.end && (o.end - o.start) < (h.end - h.start)));
|
|
1668
|
+
// 3. changed-only
|
|
1669
|
+
const out = new Map();
|
|
1670
|
+
for (const h of innermost) {
|
|
1671
|
+
const known = byId.get(`${h.start}-${h.end}`);
|
|
1672
|
+
const symbolName = known ? known.symbolName : h.qualified;
|
|
1673
|
+
const newCode = h.node.getText(sf);
|
|
1674
|
+
const oldCode = before ? codeOfSymbol(before, symbolName) : null;
|
|
1675
|
+
if (oldCode !== null && oldCode === newCode)
|
|
1676
|
+
continue; // intersected but untouched
|
|
1677
|
+
const key = known ? known.id : symbolName;
|
|
1678
|
+
if (out.has(key))
|
|
1679
|
+
continue;
|
|
1680
|
+
out.set(key, {
|
|
1681
|
+
node_id: known?.id,
|
|
1682
|
+
symbolName,
|
|
1683
|
+
name: h.name,
|
|
1684
|
+
type: astBaseType(h.node),
|
|
1685
|
+
signature: newCode.split('\n')[0].slice(0, 200),
|
|
1686
|
+
codeSnapshot: newCode,
|
|
1687
|
+
codeBefore: oldCode,
|
|
1688
|
+
startLine: sf.getLineAndCharacterOfPosition(h.start).line + 1,
|
|
1689
|
+
endLine: sf.getLineAndCharacterOfPosition(h.end).line + 1,
|
|
1690
|
+
isNew: !known
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
return Array.from(out.values());
|
|
1694
|
+
}
|
|
1695
|
+
catch {
|
|
1696
|
+
return [];
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1079
1699
|
//# sourceMappingURL=ast.js.map
|