ast-mcp-server 0.6.0 → 0.7.1
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/CHANGELOG.md +38 -0
- package/README.md +47 -15
- package/SECURITY.md +31 -0
- package/dist/cli.js +0 -0
- package/dist/index.js +129 -10
- package/dist/server.js +21 -2
- package/dist/services/agent-setup.js +36 -117
- package/dist/services/agent-targets.js +122 -0
- package/dist/services/context-builder.js +52 -24
- package/dist/services/diagnostics.js +10 -5
- package/dist/services/file-fingerprints.js +104 -0
- package/dist/services/file-snapshot.js +8 -1
- package/dist/services/impact.js +334 -0
- package/dist/services/operations.js +343 -254
- package/dist/services/outline.js +94 -21
- package/dist/services/pagination.js +10 -4
- package/dist/services/project-operation-scheduler.js +475 -0
- package/dist/services/project-status.js +134 -52
- package/dist/services/project-watcher.js +230 -0
- package/dist/services/project.js +646 -66
- package/dist/services/public-errors.js +255 -0
- package/dist/services/references.js +7 -1
- package/dist/services/relationships.js +1198 -0
- package/dist/services/request-context.js +42 -0
- package/dist/services/runtime-activity.js +65 -0
- package/dist/services/runtime-logger.js +50 -0
- package/dist/services/runtime-policy.js +47 -0
- package/dist/services/setup-wizard.js +4 -7
- package/dist/services/shutdown.js +93 -0
- package/dist/services/symbol-index-limits.js +5 -0
- package/dist/services/symbol-index-order.js +13 -0
- package/dist/services/symbol-index-policy.js +91 -0
- package/dist/services/symbol-index-sqlite.js +850 -0
- package/dist/services/symbol-index.js +447 -0
- package/dist/services/symbols.js +403 -79
- package/dist/services/test-candidates.js +209 -0
- package/dist/services/workspace.js +18 -7
- package/dist/tools/apply_operation.js +8 -4
- package/dist/tools/explore.js +8 -6
- package/dist/tools/find_references.js +12 -13
- package/dist/tools/get_diagnostics.js +10 -7
- package/dist/tools/get_file.js +23 -11
- package/dist/tools/get_impact.js +134 -0
- package/dist/tools/get_operation_preview.js +7 -4
- package/dist/tools/get_outline.js +8 -6
- package/dist/tools/get_project_status.js +77 -8
- package/dist/tools/get_symbol_source.js +8 -6
- package/dist/tools/list_files.js +11 -6
- package/dist/tools/rename_symbol.js +6 -4
- package/dist/tools/replace_symbol_body.js +6 -4
- package/dist/tools/result.js +27 -17
- package/dist/tools/scaffold_class.js +6 -4
- package/dist/tools/search_symbols.js +15 -14
- package/docs/support.md +80 -0
- package/package.json +16 -5
- package/skills/structural-code-editing/SKILL.md +37 -10
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export const MCP_SERVER_NAME = "ast";
|
|
2
|
+
const EXPECTED_HERMES_TOOLS = [
|
|
3
|
+
"ast_list_files",
|
|
4
|
+
"ast_get_outline",
|
|
5
|
+
"ast_get_symbol_source",
|
|
6
|
+
"ast_search_symbols",
|
|
7
|
+
"ast_find_references",
|
|
8
|
+
"ast_get_diagnostics",
|
|
9
|
+
"ast_rename_symbol",
|
|
10
|
+
"ast_replace_symbol_body",
|
|
11
|
+
"ast_scaffold_class",
|
|
12
|
+
"ast_get_operation_preview",
|
|
13
|
+
"ast_apply_operation",
|
|
14
|
+
];
|
|
15
|
+
function isMissingClaudeMcp(output) {
|
|
16
|
+
return (/\bno\s+mcp\s+server\b/i.test(output) &&
|
|
17
|
+
/(?:\bfound\b|\bnamed?\b)/i.test(output) &&
|
|
18
|
+
/\bast\b/i.test(output));
|
|
19
|
+
}
|
|
20
|
+
function inspectClaudeFields(stdout) {
|
|
21
|
+
const fields = new Map();
|
|
22
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
23
|
+
const match = line.match(/^\s*(Status|Type|Command|Args):\s*(.*)$/i);
|
|
24
|
+
if (match !== null)
|
|
25
|
+
fields.set(match[1].toLowerCase(), match[2].trim());
|
|
26
|
+
}
|
|
27
|
+
return fields;
|
|
28
|
+
}
|
|
29
|
+
const claudeMcp = {
|
|
30
|
+
async inspect(runtime, context) {
|
|
31
|
+
const result = await runtime.run(["mcp", "get", MCP_SERVER_NAME]);
|
|
32
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
33
|
+
if (result.exitCode !== 0) {
|
|
34
|
+
return isMissingClaudeMcp(output)
|
|
35
|
+
? { status: "missing" }
|
|
36
|
+
: { status: "error", operation: "MCP inspection", result };
|
|
37
|
+
}
|
|
38
|
+
const fields = inspectClaudeFields(result.stdout);
|
|
39
|
+
const current = /connected/i.test(fields.get("status") ?? "") &&
|
|
40
|
+
fields.get("type")?.toLowerCase() === "stdio" &&
|
|
41
|
+
fields.get("command") === context.nodeExecutable &&
|
|
42
|
+
fields.get("args") === context.serverEntryPath;
|
|
43
|
+
return current
|
|
44
|
+
? { status: "current" }
|
|
45
|
+
: {
|
|
46
|
+
status: "conflict",
|
|
47
|
+
detail: "The existing user-scoped 'ast' registration does not match this package.",
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
register(runtime, context) {
|
|
51
|
+
return runtime.run([
|
|
52
|
+
"mcp",
|
|
53
|
+
"add",
|
|
54
|
+
"--scope",
|
|
55
|
+
"user",
|
|
56
|
+
"--transport",
|
|
57
|
+
"stdio",
|
|
58
|
+
MCP_SERVER_NAME,
|
|
59
|
+
"--",
|
|
60
|
+
context.nodeExecutable,
|
|
61
|
+
context.serverEntryPath,
|
|
62
|
+
]);
|
|
63
|
+
},
|
|
64
|
+
registrationAccepted(result) {
|
|
65
|
+
return result.exitCode === 0;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
const hermesMcp = {
|
|
69
|
+
async inspect(runtime) {
|
|
70
|
+
const listed = await runtime.run(["mcp", "list"]);
|
|
71
|
+
if (listed.exitCode !== 0) {
|
|
72
|
+
return { status: "error", operation: "MCP list", result: listed };
|
|
73
|
+
}
|
|
74
|
+
if (!/^\s*ast(?:\s|$)/im.test(listed.stdout))
|
|
75
|
+
return { status: "missing" };
|
|
76
|
+
const tested = await runtime.run(["mcp", "test", MCP_SERVER_NAME]);
|
|
77
|
+
const current = tested.exitCode === 0 && EXPECTED_HERMES_TOOLS.every((tool) => tested.stdout.includes(tool));
|
|
78
|
+
return current
|
|
79
|
+
? { status: "current" }
|
|
80
|
+
: {
|
|
81
|
+
status: "conflict",
|
|
82
|
+
detail: "The existing 'ast' registration does not expose the expected structural tools.",
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
register(runtime, context) {
|
|
86
|
+
return runtime.run([
|
|
87
|
+
"mcp",
|
|
88
|
+
"add",
|
|
89
|
+
MCP_SERVER_NAME,
|
|
90
|
+
"--command",
|
|
91
|
+
context.nodeExecutable,
|
|
92
|
+
"--args",
|
|
93
|
+
context.serverEntryPath,
|
|
94
|
+
], "\n");
|
|
95
|
+
},
|
|
96
|
+
registrationAccepted(result) {
|
|
97
|
+
return result.exitCode === 0 && /saved\s+'ast'/i.test(result.stdout);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
export const AGENT_TARGETS = [
|
|
101
|
+
{
|
|
102
|
+
id: "claude",
|
|
103
|
+
label: "Claude Code",
|
|
104
|
+
command: "claude",
|
|
105
|
+
skillTarget: "claude",
|
|
106
|
+
mcp: claudeMcp,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: "hermes",
|
|
110
|
+
label: "Hermes",
|
|
111
|
+
command: "hermes",
|
|
112
|
+
skillTarget: "hermes",
|
|
113
|
+
mcp: hermesMcp,
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
export const AGENT_IDS = AGENT_TARGETS.map((target) => target.id);
|
|
117
|
+
export function getAgentTarget(id) {
|
|
118
|
+
const target = AGENT_TARGETS.find((candidate) => candidate.id === id);
|
|
119
|
+
if (target === undefined)
|
|
120
|
+
throw new Error(`Unsupported agent target: ${id}`);
|
|
121
|
+
return target;
|
|
122
|
+
}
|
|
@@ -2,9 +2,10 @@ import { Buffer } from "node:buffer";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { buildFileOutline, nodeSourceWithLocation } from "./outline.js";
|
|
4
4
|
import { paginate } from "./pagination.js";
|
|
5
|
-
import {
|
|
5
|
+
import { isCooperativeInterruption, NO_REQUEST_CONTEXT, } from "./request-context.js";
|
|
6
|
+
import { findDeclarationByName, getSourceFileOrThrow, reportSymbolIndexFailure, } from "./project.js";
|
|
6
7
|
import { collectSymbolReferences } from "./references.js";
|
|
7
|
-
import {
|
|
8
|
+
import { searchProjectSymbolsPage, searchProjectSymbolsPageWithIndex, sourceFileSymbols, symbolMatchRank, } from "./symbols.js";
|
|
8
9
|
export const EXPLORE_DEFAULT_LIMIT = 20;
|
|
9
10
|
export const EXPLORE_DEFAULT_REFERENCE_LIMIT = 20;
|
|
10
11
|
export const EXPLORE_DEFAULT_MAX_BYTES = 64 * 1024;
|
|
@@ -48,14 +49,14 @@ function recordFromOutlineSymbol(sourceFile, projectRoot, symbol) {
|
|
|
48
49
|
signature: symbol.signature,
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
|
-
function fileRecords(sourceFile, projectRoot) {
|
|
52
|
-
const outline = buildFileOutline(sourceFile);
|
|
52
|
+
function fileRecords(sourceFile, projectRoot, requestContext) {
|
|
53
|
+
const outline = buildFileOutline(sourceFile, requestContext);
|
|
53
54
|
if (outline.symbols.length > 0) {
|
|
54
55
|
return outline.symbols.map((symbol) => recordFromOutlineSymbol(sourceFile, projectRoot, symbol));
|
|
55
56
|
}
|
|
56
|
-
return sourceFileSymbols(sourceFile, projectRoot);
|
|
57
|
+
return sourceFileSymbols(sourceFile, projectRoot, requestContext);
|
|
57
58
|
}
|
|
58
|
-
function projectRecords(context, request) {
|
|
59
|
+
async function projectRecords(context, request, requestContext) {
|
|
59
60
|
const { project, projectRoot } = context;
|
|
60
61
|
if (request.filePath) {
|
|
61
62
|
const sourceFile = getSourceFileOrThrow(project, request.filePath);
|
|
@@ -63,7 +64,7 @@ function projectRecords(context, request) {
|
|
|
63
64
|
const requestedSymbolPath = request.symbolPath;
|
|
64
65
|
if (requestedSymbolPath) {
|
|
65
66
|
const node = findDeclarationByName(sourceFile, requestedSymbolPath);
|
|
66
|
-
const record = fileRecords(sourceFile, projectRoot).find((candidate) => candidate.symbol_path === requestedSymbolPath ||
|
|
67
|
+
const record = fileRecords(sourceFile, projectRoot, requestContext).find((candidate) => candidate.symbol_path === requestedSymbolPath ||
|
|
67
68
|
candidate.selector === requestedSymbolPath ||
|
|
68
69
|
(candidate.symbol_path === requestedSymbolPath.split("@")[0] &&
|
|
69
70
|
candidate.line === node.getStartLineNumber()));
|
|
@@ -71,29 +72,47 @@ function projectRecords(context, request) {
|
|
|
71
72
|
throw new Error(`Symbol "${requestedSymbolPath}" could not be projected from ${file}.`);
|
|
72
73
|
}
|
|
73
74
|
return {
|
|
75
|
+
context,
|
|
74
76
|
route: "symbol",
|
|
75
77
|
file,
|
|
76
78
|
symbol: record.selector,
|
|
77
79
|
records: [record],
|
|
78
80
|
};
|
|
79
81
|
}
|
|
80
|
-
const records = fileRecords(sourceFile, projectRoot)
|
|
82
|
+
const records = fileRecords(sourceFile, projectRoot, requestContext)
|
|
81
83
|
.filter((record) => !request.query || matchesQuery(record, request.query))
|
|
82
84
|
.filter((record) => !request.kinds || request.kinds.includes(record.kind));
|
|
83
|
-
return {
|
|
85
|
+
return {
|
|
86
|
+
context,
|
|
87
|
+
route: "file",
|
|
88
|
+
file,
|
|
89
|
+
symbol: null,
|
|
90
|
+
records: sortRecords(records, request.query),
|
|
91
|
+
};
|
|
84
92
|
}
|
|
85
93
|
if (!request.query) {
|
|
86
94
|
throw new Error("ast_explore requires query, file_path, or both file_path and symbol_path.");
|
|
87
95
|
}
|
|
96
|
+
let effectiveContext = context;
|
|
97
|
+
const indexedPage = await searchProjectSymbolsPageWithIndex(project, projectRoot, context.status.project, context.symbolIndex, context.symbolIndexReady, {
|
|
98
|
+
query: request.query,
|
|
99
|
+
kinds: request.kinds,
|
|
100
|
+
fileFilter: request.fileFilter,
|
|
101
|
+
}, request.offset, request.limit, async (reason) => {
|
|
102
|
+
effectiveContext = (await reportSymbolIndexFailure(projectRoot, reason)) ?? effectiveContext;
|
|
103
|
+
}, requestContext);
|
|
88
104
|
return {
|
|
105
|
+
context: effectiveContext,
|
|
89
106
|
route: "query",
|
|
90
107
|
file: null,
|
|
91
108
|
symbol: null,
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
109
|
+
page: indexedPage ??
|
|
110
|
+
searchProjectSymbolsPage(effectiveContext.project, effectiveContext.projectRoot, {
|
|
111
|
+
query: request.query,
|
|
112
|
+
kinds: request.kinds,
|
|
113
|
+
fileFilter: request.fileFilter,
|
|
114
|
+
}, request.offset, request.limit, requestContext),
|
|
115
|
+
records: [],
|
|
97
116
|
};
|
|
98
117
|
}
|
|
99
118
|
function projectSymbol(detail, record) {
|
|
@@ -120,16 +139,20 @@ function effectiveExpansion(request) {
|
|
|
120
139
|
references: request.includeReferences ?? request.detail === "full",
|
|
121
140
|
};
|
|
122
141
|
}
|
|
123
|
-
export function buildExploreContext(context, request) {
|
|
124
|
-
|
|
125
|
-
const
|
|
142
|
+
export async function buildExploreContext(context, request, requestContext = NO_REQUEST_CONTEXT) {
|
|
143
|
+
requestContext.checkpoint();
|
|
144
|
+
const routed = await projectRecords(context, request, requestContext);
|
|
145
|
+
requestContext.checkpoint();
|
|
146
|
+
const effectiveContext = routed.context;
|
|
147
|
+
const page = routed.page ?? paginate(routed.records, request.offset, request.limit);
|
|
126
148
|
const expansion = effectiveExpansion(request);
|
|
127
149
|
const unresolved = [];
|
|
128
150
|
const evidence = [];
|
|
129
151
|
for (const record of page.items) {
|
|
152
|
+
requestContext.checkpoint();
|
|
130
153
|
let sourceFile;
|
|
131
154
|
try {
|
|
132
|
-
sourceFile = getSourceFileOrThrow(
|
|
155
|
+
sourceFile = getSourceFileOrThrow(effectiveContext.project, record.file);
|
|
133
156
|
}
|
|
134
157
|
catch {
|
|
135
158
|
unresolved.push({ selector: record.selector, reason: "source_unresolved" });
|
|
@@ -145,13 +168,16 @@ export function buildExploreContext(context, request) {
|
|
|
145
168
|
}
|
|
146
169
|
let source;
|
|
147
170
|
let references;
|
|
148
|
-
if (expansion.source)
|
|
149
|
-
source = nodeSourceWithLocation(node,
|
|
171
|
+
if (expansion.source) {
|
|
172
|
+
source = nodeSourceWithLocation(node, effectiveContext.projectRoot, requestContext);
|
|
173
|
+
}
|
|
150
174
|
if (expansion.references) {
|
|
151
175
|
try {
|
|
152
|
-
references = collectSymbolReferences(node,
|
|
176
|
+
references = collectSymbolReferences(node, effectiveContext.projectRoot, record.selector, true, request.referenceDetail, 0, request.referenceLimit, requestContext);
|
|
153
177
|
}
|
|
154
|
-
catch {
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (isCooperativeInterruption(error))
|
|
180
|
+
throw error;
|
|
155
181
|
unresolved.push({ selector: record.selector, reason: "references_unresolved" });
|
|
156
182
|
}
|
|
157
183
|
}
|
|
@@ -167,9 +193,10 @@ export function buildExploreContext(context, request) {
|
|
|
167
193
|
let returnedSymbols = [...symbols];
|
|
168
194
|
let returnedEvidence = [...evidence];
|
|
169
195
|
let reason = page.has_more ? "record_limit" : null;
|
|
170
|
-
let result = createResult(
|
|
196
|
+
let result = createResult(effectiveContext, request, routed.route, routed.file, routed.symbol, returnedSymbols, returnedEvidence, page.total, unresolved, reason);
|
|
171
197
|
while (jsonBytes(result) > request.maxBytes &&
|
|
172
198
|
(returnedEvidence.length > 0 || returnedSymbols.length > 0)) {
|
|
199
|
+
requestContext.checkpoint();
|
|
173
200
|
reason = "byte_limit";
|
|
174
201
|
if (returnedEvidence.length > 0) {
|
|
175
202
|
returnedEvidence = returnedEvidence.slice(0, -1);
|
|
@@ -179,8 +206,9 @@ export function buildExploreContext(context, request) {
|
|
|
179
206
|
const selectors = new Set(returnedSymbols.map((symbol) => symbol.selector));
|
|
180
207
|
returnedEvidence = returnedEvidence.filter((item) => selectors.has(item.selector));
|
|
181
208
|
}
|
|
182
|
-
result = createResult(
|
|
209
|
+
result = createResult(effectiveContext, request, routed.route, routed.file, routed.symbol, returnedSymbols, returnedEvidence, page.total, unresolved, reason);
|
|
183
210
|
}
|
|
211
|
+
requestContext.checkpoint();
|
|
184
212
|
const usedBytes = jsonBytes(result);
|
|
185
213
|
return {
|
|
186
214
|
...result,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { DiagnosticCategory, ts } from "ts-morph";
|
|
3
|
-
|
|
3
|
+
import { NO_REQUEST_CONTEXT } from "./request-context.js";
|
|
4
|
+
export function normalizeDiagnostic(diagnostic, projectRoot, requestContext = NO_REQUEST_CONTEXT) {
|
|
5
|
+
requestContext.checkpoint();
|
|
4
6
|
const sourceFile = diagnostic.getSourceFile();
|
|
5
7
|
const start = diagnostic.getStart();
|
|
6
8
|
const location = sourceFile && start !== undefined ? sourceFile.getLineAndColumnAtPos(start) : undefined;
|
|
@@ -21,14 +23,16 @@ function identity(diagnostic) {
|
|
|
21
23
|
diagnostic.message,
|
|
22
24
|
]);
|
|
23
25
|
}
|
|
24
|
-
function subtract(candidates, baseline) {
|
|
26
|
+
function subtract(candidates, baseline, requestContext) {
|
|
25
27
|
const counts = new Map();
|
|
26
28
|
for (const diagnostic of baseline) {
|
|
29
|
+
requestContext.checkpoint();
|
|
27
30
|
const key = identity(diagnostic);
|
|
28
31
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
29
32
|
}
|
|
30
33
|
const difference = [];
|
|
31
34
|
for (const diagnostic of candidates) {
|
|
35
|
+
requestContext.checkpoint();
|
|
32
36
|
const key = identity(diagnostic);
|
|
33
37
|
const remaining = counts.get(key) ?? 0;
|
|
34
38
|
if (remaining > 0) {
|
|
@@ -40,9 +44,10 @@ function subtract(candidates, baseline) {
|
|
|
40
44
|
}
|
|
41
45
|
return difference;
|
|
42
46
|
}
|
|
43
|
-
export function compareDiagnostics(before, after) {
|
|
44
|
-
|
|
45
|
-
const
|
|
47
|
+
export function compareDiagnostics(before, after, requestContext = NO_REQUEST_CONTEXT) {
|
|
48
|
+
requestContext.checkpoint();
|
|
49
|
+
const added = subtract(after, before, requestContext);
|
|
50
|
+
const removed = subtract(before, after, requestContext);
|
|
46
51
|
return {
|
|
47
52
|
added,
|
|
48
53
|
removed,
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function isMissingFileError(error) {
|
|
5
|
+
return (typeof error === "object" &&
|
|
6
|
+
error !== null &&
|
|
7
|
+
"code" in error &&
|
|
8
|
+
(error.code === "ENOENT" || error.code === "ENOTDIR"));
|
|
9
|
+
}
|
|
10
|
+
function resolveCandidatePath(filePath) {
|
|
11
|
+
try {
|
|
12
|
+
return fs.realpathSync(filePath);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (!isMissingFileError(error))
|
|
16
|
+
throw error;
|
|
17
|
+
return path.resolve(filePath);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function assertRegularFile(filePath) {
|
|
21
|
+
const stats = fs.statSync(filePath, { bigint: true });
|
|
22
|
+
if (!stats.isFile()) {
|
|
23
|
+
throw new Error(`Cannot fingerprint non-file path: ${filePath}`);
|
|
24
|
+
}
|
|
25
|
+
return stats;
|
|
26
|
+
}
|
|
27
|
+
function metadataMatches(previous, current) {
|
|
28
|
+
return (previous.size_bytes === current.size_bytes &&
|
|
29
|
+
previous.mtime_ns === current.mtime_ns &&
|
|
30
|
+
previous.device === current.device &&
|
|
31
|
+
previous.inode === current.inode);
|
|
32
|
+
}
|
|
33
|
+
export function hashBytes(content) {
|
|
34
|
+
return createHash("sha256").update(content).digest("hex");
|
|
35
|
+
}
|
|
36
|
+
export function captureFileFingerprint(filePath, previous, options = {}) {
|
|
37
|
+
const canonicalPath = fs.realpathSync(filePath);
|
|
38
|
+
const stats = assertRegularFile(canonicalPath);
|
|
39
|
+
const metadata = {
|
|
40
|
+
size_bytes: Number(stats.size),
|
|
41
|
+
mtime_ns: stats.mtimeNs.toString(),
|
|
42
|
+
device: stats.dev.toString(),
|
|
43
|
+
inode: stats.ino.toString(),
|
|
44
|
+
};
|
|
45
|
+
if (!Number.isSafeInteger(metadata.size_bytes)) {
|
|
46
|
+
throw new Error(`File size is outside the safe integer range: ${canonicalPath}`);
|
|
47
|
+
}
|
|
48
|
+
if (previous && metadataMatches(previous, metadata) && !options.verifyContentHash) {
|
|
49
|
+
return {
|
|
50
|
+
...previous,
|
|
51
|
+
file_path: canonicalPath,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
file_path: canonicalPath,
|
|
56
|
+
...metadata,
|
|
57
|
+
content_hash: hashBytes(fs.readFileSync(canonicalPath)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function collectFileFingerprints(filePaths, previous = new Map(), options = {}) {
|
|
61
|
+
const files = new Map();
|
|
62
|
+
const missing = new Set();
|
|
63
|
+
for (const filePath of new Set(filePaths)) {
|
|
64
|
+
const candidatePath = resolveCandidatePath(filePath);
|
|
65
|
+
try {
|
|
66
|
+
const previousFingerprint = previous.get(candidatePath);
|
|
67
|
+
const fingerprint = captureFileFingerprint(candidatePath, previousFingerprint, options);
|
|
68
|
+
files.set(fingerprint.file_path, fingerprint);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (!isMissingFileError(error))
|
|
72
|
+
throw error;
|
|
73
|
+
missing.add(candidatePath);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
files,
|
|
78
|
+
missing: [...missing].sort(),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function compareFileFingerprints(previous, current) {
|
|
82
|
+
const added = [];
|
|
83
|
+
const changed = [];
|
|
84
|
+
const deleted = [];
|
|
85
|
+
const unchanged = [];
|
|
86
|
+
const paths = [...new Set([...previous.keys(), ...current.keys()])].sort();
|
|
87
|
+
for (const filePath of paths) {
|
|
88
|
+
const before = previous.get(filePath);
|
|
89
|
+
const after = current.get(filePath);
|
|
90
|
+
if (!before && after) {
|
|
91
|
+
added.push(filePath);
|
|
92
|
+
}
|
|
93
|
+
else if (before && !after) {
|
|
94
|
+
deleted.push(filePath);
|
|
95
|
+
}
|
|
96
|
+
else if (before && after && before.content_hash !== after.content_hash) {
|
|
97
|
+
changed.push(filePath);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
unchanged.push(filePath);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { added, changed, deleted, unchanged };
|
|
104
|
+
}
|
|
@@ -2,6 +2,7 @@ import { TextDecoder } from "node:util";
|
|
|
2
2
|
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { getSourceFileOrThrow } from "./project.js";
|
|
5
|
+
import { NO_REQUEST_CONTEXT } from "./request-context.js";
|
|
5
6
|
import { hashBytes } from "./workspace.js";
|
|
6
7
|
export const DEFAULT_FILE_LINE_LIMIT = 200;
|
|
7
8
|
export const MAX_FILE_LINE_LIMIT = 500;
|
|
@@ -58,27 +59,33 @@ function compilerSnapshotHashes(sourceText) {
|
|
|
58
59
|
}
|
|
59
60
|
return hashes;
|
|
60
61
|
}
|
|
61
|
-
export async function readFileSnapshot(project, projectRoot, filePath, options) {
|
|
62
|
+
export async function readFileSnapshot(project, projectRoot, filePath, options, requestContext = NO_REQUEST_CONTEXT) {
|
|
63
|
+
requestContext.checkpoint();
|
|
62
64
|
assertBoundedRange(options);
|
|
63
65
|
rejectTraversalPath(filePath);
|
|
64
66
|
const sourceFile = getSourceFileOrThrow(project, filePath);
|
|
67
|
+
requestContext.checkpoint();
|
|
65
68
|
const canonicalRoot = await realpath(projectRoot);
|
|
66
69
|
const sourcePath = sourceFile.getFilePath();
|
|
67
70
|
const canonicalFile = await realpath(sourcePath);
|
|
71
|
+
requestContext.checkpoint();
|
|
68
72
|
if (!isContained(canonicalRoot, canonicalFile)) {
|
|
69
73
|
throw new Error("Source file resolves outside the configured project root.");
|
|
70
74
|
}
|
|
71
75
|
const stat = await lstat(canonicalFile);
|
|
76
|
+
requestContext.checkpoint();
|
|
72
77
|
if (!stat.isFile()) {
|
|
73
78
|
throw new Error("Source file is not a regular file.");
|
|
74
79
|
}
|
|
75
80
|
const bytes = await readFile(canonicalFile);
|
|
81
|
+
requestContext.checkpoint();
|
|
76
82
|
const source = decodeUtf8(bytes, projectRelative(canonicalRoot, canonicalFile));
|
|
77
83
|
const fileHash = hashBytes(bytes);
|
|
78
84
|
const compilerHashes = compilerSnapshotHashes(sourceFile.getFullText());
|
|
79
85
|
const snapshotState = compilerHashes.includes(fileHash) ? "fresh" : "stale";
|
|
80
86
|
const allLines = splitLines(source);
|
|
81
87
|
const selectedLines = allLines.slice(options.offset, options.offset + options.limit);
|
|
88
|
+
requestContext.checkpoint();
|
|
82
89
|
return {
|
|
83
90
|
file: projectRelative(canonicalRoot, canonicalFile),
|
|
84
91
|
range: {
|