ast-mcp-server 0.3.0 → 0.5.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/CHANGELOG.md +44 -0
- package/README.md +28 -8
- package/dist/batch/runner.js +10 -2
- package/dist/batch/schema.js +8 -1
- package/dist/cli-output.js +28 -0
- package/dist/cli.js +57 -9
- package/dist/server.js +6 -1
- package/dist/services/agent-setup.js +1 -0
- package/dist/services/operation-plan-file.js +1 -1
- package/dist/services/operations.js +172 -17
- package/dist/services/pagination.js +16 -10
- package/dist/services/scaffold.js +241 -0
- package/dist/services/symbols.js +16 -0
- package/dist/tools/apply_operation.js +1 -1
- package/dist/tools/find_references.js +32 -9
- package/dist/tools/get_diagnostics.js +4 -4
- package/dist/tools/operation-schema.js +1 -1
- package/dist/tools/result.js +45 -1
- package/dist/tools/scaffold_class.js +145 -0
- package/dist/tools/search_symbols.js +79 -18
- package/package.json +9 -2
- package/skills/structural-code-editing/SKILL.md +30 -6
- package/benchmark/README.md +0 -86
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { lstatSync, realpathSync } from "node:fs";
|
|
3
|
-
import { open, readFile, rename, stat, unlink } from "node:fs/promises";
|
|
3
|
+
import { link, open, readFile, rename, stat, unlink } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { createTwoFilesPatch } from "diff";
|
|
6
6
|
import { LanguageVariant, Node, ScriptTarget, SyntaxKind, ts } from "ts-morph";
|
|
7
7
|
import { compareDiagnostics, normalizeDiagnostic, } from "./diagnostics.js";
|
|
8
8
|
import { createFreshProject, findDeclarationByName, getSourceFileOrThrow, invalidateProject, } from "./project.js";
|
|
9
9
|
import { executableDeclaration } from "./symbols.js";
|
|
10
|
+
import { buildClassScaffold } from "./scaffold.js";
|
|
10
11
|
import { withWorkspaceFileLock } from "./runtime-state.js";
|
|
11
12
|
import { createWorkspaceSnapshot, hashBytes, hashWorkspaceFiles } from "./workspace.js";
|
|
12
13
|
const DEFAULT_OPERATION_TTL_MS = 15 * 60 * 1000;
|
|
13
14
|
const DEFAULT_MAX_OPERATIONS = 100;
|
|
14
15
|
const MAX_INLINE_PREVIEW_CHARS = 100_000;
|
|
16
|
+
const ABSENT_FILE_HASH = "0".repeat(64);
|
|
17
|
+
const DEFAULT_NEW_FILE_MODE = 0o644;
|
|
15
18
|
const operations = new Map();
|
|
16
19
|
const writeQueues = new Map();
|
|
17
20
|
let operationTestHooks = {};
|
|
@@ -104,6 +107,53 @@ function assertSafeOperationFile(projectRoot, absolutePath, displayPath) {
|
|
|
104
107
|
throw new Error(`Operation target escapes the project root: ${displayPath}`);
|
|
105
108
|
}
|
|
106
109
|
}
|
|
110
|
+
function isCreatedFile(file) {
|
|
111
|
+
return file.originalHash === ABSENT_FILE_HASH;
|
|
112
|
+
}
|
|
113
|
+
function assertSafeNewOperationFile(projectRoot, absolutePath, displayPath) {
|
|
114
|
+
const relative = path.relative(projectRoot, absolutePath);
|
|
115
|
+
if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`)) {
|
|
116
|
+
throw new Error(`Operation target escapes the project root: ${displayPath}`);
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
lstatSync(absolutePath);
|
|
120
|
+
throw new Error(`Operation target already exists: ${displayPath}`);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error.code !== "ENOENT")
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
const parentPath = path.dirname(absolutePath);
|
|
127
|
+
const parentStat = lstatSync(parentPath);
|
|
128
|
+
if (parentStat.isSymbolicLink()) {
|
|
129
|
+
throw new Error(`Operation target parent must not be a symbolic link: ${displayPath}`);
|
|
130
|
+
}
|
|
131
|
+
if (!parentStat.isDirectory()) {
|
|
132
|
+
throw new Error(`Operation target parent must be a real directory: ${displayPath}`);
|
|
133
|
+
}
|
|
134
|
+
const canonicalParent = realpathSync(parentPath);
|
|
135
|
+
const sameParent = process.platform === "win32"
|
|
136
|
+
? canonicalParent.toLowerCase() === parentPath.toLowerCase()
|
|
137
|
+
: canonicalParent === parentPath;
|
|
138
|
+
if (!sameParent) {
|
|
139
|
+
throw new Error(`Operation target parent traverses a symbolic link: ${displayPath}`);
|
|
140
|
+
}
|
|
141
|
+
const uid = process.getuid?.();
|
|
142
|
+
if (uid !== undefined && parentStat.uid !== uid) {
|
|
143
|
+
throw new Error(`Operation target parent is not owned by the current user: ${displayPath}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function assertSafeCreationState(projectRoot, absolutePath, displayPath) {
|
|
147
|
+
try {
|
|
148
|
+
lstatSync(absolutePath);
|
|
149
|
+
assertSafeOperationFile(projectRoot, absolutePath, displayPath);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (error.code !== "ENOENT")
|
|
153
|
+
throw error;
|
|
154
|
+
assertSafeNewOperationFile(projectRoot, absolutePath, displayPath);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
107
157
|
async function createPlan(projectRootInput, kind, allowNewErrors, mutate) {
|
|
108
158
|
pruneOperations();
|
|
109
159
|
const context = createFreshProject(projectRootInput);
|
|
@@ -126,9 +176,28 @@ async function createPlan(projectRootInput, kind, allowNewErrors, mutate) {
|
|
|
126
176
|
for (const sourceFile of context.project.getSourceFiles()) {
|
|
127
177
|
const absolutePath = sourceFile.getFilePath();
|
|
128
178
|
const originalText = originals.get(absolutePath);
|
|
129
|
-
if (originalText === undefined)
|
|
130
|
-
continue;
|
|
131
179
|
const updatedText = sourceFile.getFullText();
|
|
180
|
+
if (originalText === undefined) {
|
|
181
|
+
const file = path.relative(context.projectRoot, absolutePath);
|
|
182
|
+
assertSafeNewOperationFile(context.projectRoot, absolutePath, file);
|
|
183
|
+
const originalBytes = Buffer.alloc(0);
|
|
184
|
+
const updatedBytes = Buffer.from(updatedText, "utf8");
|
|
185
|
+
files.push({
|
|
186
|
+
absolutePath,
|
|
187
|
+
file,
|
|
188
|
+
originalHash: ABSENT_FILE_HASH,
|
|
189
|
+
updatedHash: hashBytes(updatedBytes),
|
|
190
|
+
originalText: "",
|
|
191
|
+
updatedText,
|
|
192
|
+
originalBytes,
|
|
193
|
+
updatedBytes,
|
|
194
|
+
diff: createTwoFilesPatch("/dev/null", file, "", updatedText, "before", "after", {
|
|
195
|
+
context: 3,
|
|
196
|
+
}),
|
|
197
|
+
mode: DEFAULT_NEW_FILE_MODE,
|
|
198
|
+
});
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
132
201
|
if (updatedText === originalText)
|
|
133
202
|
continue;
|
|
134
203
|
const originalBytes = await readFile(absolutePath);
|
|
@@ -159,13 +228,13 @@ async function createPlan(projectRootInput, kind, allowNewErrors, mutate) {
|
|
|
159
228
|
}
|
|
160
229
|
const postWorkspaceFiles = new Map(workspaceBefore.files);
|
|
161
230
|
for (const file of files) {
|
|
162
|
-
if (!postWorkspaceFiles.has(file.absolutePath)) {
|
|
231
|
+
if (!isCreatedFile(file) && !postWorkspaceFiles.has(file.absolutePath)) {
|
|
163
232
|
throw new Error(`Affected file is missing from the workspace snapshot: ${file.file}`);
|
|
164
233
|
}
|
|
165
234
|
postWorkspaceFiles.set(file.absolutePath, file.updatedHash);
|
|
166
235
|
}
|
|
167
236
|
const postWorkspaceHash = hashWorkspaceFiles(postWorkspaceFiles);
|
|
168
|
-
const workspaceAfter = createWorkspaceSnapshot(context);
|
|
237
|
+
const workspaceAfter = createWorkspaceSnapshot(createFreshProject(context.tsConfigFilePath));
|
|
169
238
|
if (workspaceAfter.digest !== workspaceBefore.digest) {
|
|
170
239
|
throw new Error("Workspace changed while the operation was being prepared. Retry preparation.");
|
|
171
240
|
}
|
|
@@ -261,6 +330,41 @@ export async function prepareReplaceBody(args) {
|
|
|
261
330
|
return 0;
|
|
262
331
|
});
|
|
263
332
|
}
|
|
333
|
+
export async function prepareScaffoldClass(args) {
|
|
334
|
+
if (path.isAbsolute(args.filePath)) {
|
|
335
|
+
throw new Error("Scaffold file_path must be project-relative.");
|
|
336
|
+
}
|
|
337
|
+
const normalizedFile = path.normalize(args.filePath);
|
|
338
|
+
if (normalizedFile === "." ||
|
|
339
|
+
normalizedFile === ".." ||
|
|
340
|
+
normalizedFile.startsWith(`..${path.sep}`) ||
|
|
341
|
+
normalizedFile !== args.filePath.split(path.posix.sep).join(path.sep)) {
|
|
342
|
+
throw new Error("Scaffold file_path must be normalized and remain inside the project root.");
|
|
343
|
+
}
|
|
344
|
+
if (!/\.(?:ts|tsx)$/.test(normalizedFile) || normalizedFile.endsWith(".d.ts")) {
|
|
345
|
+
throw new Error("Scaffold file_path must target a .ts or .tsx implementation file.");
|
|
346
|
+
}
|
|
347
|
+
let metadata;
|
|
348
|
+
const operation = await createPlan(args.projectRoot, "scaffold_class", args.allowNewErrors ?? false, (project, projectRoot) => {
|
|
349
|
+
const absolutePath = path.resolve(projectRoot, normalizedFile);
|
|
350
|
+
assertSafeNewOperationFile(projectRoot, absolutePath, normalizedFile);
|
|
351
|
+
const scaffold = buildClassScaffold(project, absolutePath, args.spec);
|
|
352
|
+
metadata = {
|
|
353
|
+
outline: scaffold.outline,
|
|
354
|
+
pendingMethods: scaffold.pendingMethods,
|
|
355
|
+
};
|
|
356
|
+
return 0;
|
|
357
|
+
});
|
|
358
|
+
if (!metadata)
|
|
359
|
+
throw new Error("Scaffold generation did not produce metadata.");
|
|
360
|
+
return {
|
|
361
|
+
operation,
|
|
362
|
+
file: normalizedFile.split(path.sep).join(path.posix.sep),
|
|
363
|
+
className: args.spec.className,
|
|
364
|
+
outline: metadata.outline,
|
|
365
|
+
pendingMethods: metadata.pendingMethods,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
264
368
|
async function withWriteLock(key, callback) {
|
|
265
369
|
const previous = writeQueues.get(key) ?? Promise.resolve();
|
|
266
370
|
let release;
|
|
@@ -325,6 +429,24 @@ async function syncDirectories(files) {
|
|
|
325
429
|
}
|
|
326
430
|
}
|
|
327
431
|
}
|
|
432
|
+
async function currentWorkspaceHash(operation) {
|
|
433
|
+
const context = createFreshProject(operation.tsConfigFilePath);
|
|
434
|
+
const snapshot = createWorkspaceSnapshot(context);
|
|
435
|
+
const files = new Map(snapshot.files);
|
|
436
|
+
for (const file of operation.files) {
|
|
437
|
+
if (!isCreatedFile(file))
|
|
438
|
+
continue;
|
|
439
|
+
try {
|
|
440
|
+
files.set(file.absolutePath, hashBytes(await readFile(file.absolutePath)));
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
if (error.code !== "ENOENT")
|
|
444
|
+
throw error;
|
|
445
|
+
files.delete(file.absolutePath);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return hashWorkspaceFiles(files);
|
|
449
|
+
}
|
|
328
450
|
function appliedResult(operation, idempotentReplay) {
|
|
329
451
|
return {
|
|
330
452
|
operation_id: operation.operation_id,
|
|
@@ -364,6 +486,7 @@ export async function applyOperation(operationId, planHash) {
|
|
|
364
486
|
return withWriteLock(operation.tsConfigFilePath, async () => withWorkspaceFileLock(operation.tsConfigFilePath, { stateDirectory: operation.lockStateDirectory }, async () => {
|
|
365
487
|
if (operation.status === "applied") {
|
|
366
488
|
for (const file of operation.files) {
|
|
489
|
+
assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
|
|
367
490
|
const currentBytes = await readFile(file.absolutePath);
|
|
368
491
|
if (hashBytes(currentBytes) !== file.updatedHash) {
|
|
369
492
|
throw new Error(`Applied receipt conflict: ${file.file} no longer matches the recorded postimage.`);
|
|
@@ -380,12 +503,17 @@ export async function applyOperation(operationId, planHash) {
|
|
|
380
503
|
throw new Error(operation.block_reason ?? "The operation is blocked by validation errors.");
|
|
381
504
|
}
|
|
382
505
|
for (const file of operation.files) {
|
|
383
|
-
|
|
506
|
+
if (isCreatedFile(file)) {
|
|
507
|
+
assertSafeCreationState(operation.project_root, file.absolutePath, file.file);
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
|
|
511
|
+
}
|
|
384
512
|
}
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
if (currentWorkspace.digest === operation.post_workspace_hash) {
|
|
513
|
+
const workspaceHash = await currentWorkspaceHash(operation);
|
|
514
|
+
if (workspaceHash === operation.post_workspace_hash) {
|
|
388
515
|
for (const file of operation.files) {
|
|
516
|
+
assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
|
|
389
517
|
const currentBytes = await readFile(file.absolutePath);
|
|
390
518
|
if (hashBytes(currentBytes) !== file.updatedHash) {
|
|
391
519
|
throw new Error(`Postimage recovery conflict: ${file.file} does not match the reviewed postimage.`);
|
|
@@ -397,10 +525,12 @@ export async function applyOperation(operationId, planHash) {
|
|
|
397
525
|
await persistReceipt();
|
|
398
526
|
return appliedResult(operation, true);
|
|
399
527
|
}
|
|
400
|
-
if (
|
|
528
|
+
if (workspaceHash !== operation.workspace_hash) {
|
|
401
529
|
throw new Error("Conflict: the project source/config workspace changed after preparation. No files were written.");
|
|
402
530
|
}
|
|
403
531
|
for (const file of operation.files) {
|
|
532
|
+
if (isCreatedFile(file))
|
|
533
|
+
continue;
|
|
404
534
|
const currentBytes = await readFile(file.absolutePath);
|
|
405
535
|
if (hashBytes(currentBytes) !== file.originalHash) {
|
|
406
536
|
throw new Error(`Conflict: ${file.file} changed after the operation was prepared. No files were written.`);
|
|
@@ -413,6 +543,10 @@ export async function applyOperation(operationId, planHash) {
|
|
|
413
543
|
staged.set(file, await stageFile(file, operationId));
|
|
414
544
|
}
|
|
415
545
|
for (const file of operation.files) {
|
|
546
|
+
if (isCreatedFile(file)) {
|
|
547
|
+
assertSafeNewOperationFile(operation.project_root, file.absolutePath, file.file);
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
416
550
|
const currentBytes = await readFile(file.absolutePath);
|
|
417
551
|
if (hashBytes(currentBytes) !== file.originalHash) {
|
|
418
552
|
throw new Error(`Conflict: ${file.file} changed while the operation was being staged. No files were written.`);
|
|
@@ -420,9 +554,18 @@ export async function applyOperation(operationId, planHash) {
|
|
|
420
554
|
}
|
|
421
555
|
for (const [index, file] of operation.files.entries()) {
|
|
422
556
|
await operationTestHooks.beforeReplace?.(file.file, index);
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
557
|
+
if (isCreatedFile(file)) {
|
|
558
|
+
await link(staged.get(file), file.absolutePath);
|
|
559
|
+
applied.push(file);
|
|
560
|
+
await safeUnlink(staged.get(file));
|
|
561
|
+
staged.delete(file);
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
await rename(staged.get(file), file.absolutePath);
|
|
565
|
+
staged.delete(file);
|
|
566
|
+
applied.push(file);
|
|
567
|
+
}
|
|
568
|
+
await operationTestHooks.afterReplace?.(file.file, index);
|
|
426
569
|
const writtenBytes = await readFile(file.absolutePath);
|
|
427
570
|
if (hashBytes(writtenBytes) !== file.updatedHash) {
|
|
428
571
|
throw new Error(`Post-write verification failed for ${file.file}.`);
|
|
@@ -439,7 +582,13 @@ export async function applyOperation(operationId, planHash) {
|
|
|
439
582
|
rollbackErrors.push(`${file.file}: current bytes no longer match this operation's postimage`);
|
|
440
583
|
continue;
|
|
441
584
|
}
|
|
442
|
-
|
|
585
|
+
if (isCreatedFile(file)) {
|
|
586
|
+
assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
|
|
587
|
+
await safeUnlink(file.absolutePath);
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
await restoreFile(file, operationId);
|
|
591
|
+
}
|
|
443
592
|
}
|
|
444
593
|
catch (rollbackError) {
|
|
445
594
|
rollbackErrors.push(`${file.file}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
|
|
@@ -497,7 +646,6 @@ function containedOperationPath(projectRoot, file) {
|
|
|
497
646
|
if (relative !== file.split(path.posix.sep).join(path.sep)) {
|
|
498
647
|
throw new Error(`Persisted operation file path is not normalized: ${file}`);
|
|
499
648
|
}
|
|
500
|
-
assertSafeOperationFile(projectRoot, absolutePath, file);
|
|
501
649
|
return absolutePath;
|
|
502
650
|
}
|
|
503
651
|
export function exportOperationRecord(operationId) {
|
|
@@ -539,12 +687,19 @@ export function importOperationRecord(persisted, expectedPlanHash) {
|
|
|
539
687
|
const absolutePath = containedOperationPath(context.projectRoot, file.file);
|
|
540
688
|
const originalBytes = decodePersistedBytes(file.original_bytes_base64, `${file.file} original bytes`);
|
|
541
689
|
const updatedBytes = decodePersistedBytes(file.updated_bytes_base64, `${file.file} updated bytes`);
|
|
542
|
-
|
|
690
|
+
const createsFile = file.original_hash === ABSENT_FILE_HASH;
|
|
691
|
+
if (createsFile ? originalBytes.length !== 0 : hashBytes(originalBytes) !== file.original_hash) {
|
|
543
692
|
throw new Error(`Persisted original hash mismatch for ${file.file}.`);
|
|
544
693
|
}
|
|
545
694
|
if (hashBytes(updatedBytes) !== file.updated_hash) {
|
|
546
695
|
throw new Error(`Persisted updated hash mismatch for ${file.file}.`);
|
|
547
696
|
}
|
|
697
|
+
if (createsFile) {
|
|
698
|
+
assertSafeCreationState(context.projectRoot, absolutePath, file.file);
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
assertSafeOperationFile(context.projectRoot, absolutePath, file.file);
|
|
702
|
+
}
|
|
548
703
|
const originalText = decodeSource(originalBytes, absolutePath);
|
|
549
704
|
const updatedText = decodeSource(updatedBytes, absolutePath);
|
|
550
705
|
return {
|
|
@@ -556,7 +711,7 @@ export function importOperationRecord(persisted, expectedPlanHash) {
|
|
|
556
711
|
updatedText,
|
|
557
712
|
originalBytes,
|
|
558
713
|
updatedBytes,
|
|
559
|
-
diff: createTwoFilesPatch(file.file, file.file, originalText, updatedText, "before", "after", { context: 3 }),
|
|
714
|
+
diff: createTwoFilesPatch(createsFile ? "/dev/null" : file.file, file.file, originalText, updatedText, "before", "after", { context: 3 }),
|
|
560
715
|
mode: file.mode,
|
|
561
716
|
};
|
|
562
717
|
});
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export const DEFAULT_PAGE_LIMIT = 100;
|
|
3
3
|
export const MAX_PAGE_LIMIT = 500;
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
.min(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
export function createPaginationInputSchema(defaultLimit = DEFAULT_PAGE_LIMIT) {
|
|
5
|
+
if (!Number.isInteger(defaultLimit) || defaultLimit < 1 || defaultLimit > MAX_PAGE_LIMIT) {
|
|
6
|
+
throw new Error(`Default page limit must be an integer from 1 to ${MAX_PAGE_LIMIT}.`);
|
|
7
|
+
}
|
|
8
|
+
return {
|
|
9
|
+
offset: z.number().int().min(0).default(0).describe("Zero-based result offset."),
|
|
10
|
+
limit: z
|
|
11
|
+
.number()
|
|
12
|
+
.int()
|
|
13
|
+
.min(1)
|
|
14
|
+
.max(MAX_PAGE_LIMIT)
|
|
15
|
+
.default(defaultLimit)
|
|
16
|
+
.describe(`Maximum results to return (1-${MAX_PAGE_LIMIT}).`),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export const PaginationInputSchema = createPaginationInputSchema();
|
|
14
20
|
export const PaginationOutputSchema = {
|
|
15
21
|
offset: z.number().int().min(0),
|
|
16
22
|
limit: z.number().int().min(1),
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { LanguageVariant, Node, Scope, ScriptTarget, SyntaxKind, ts, } from "ts-morph";
|
|
2
|
+
import { buildFileOutline } from "./outline.js";
|
|
3
|
+
function assertIdentifier(value, label) {
|
|
4
|
+
const scanner = ts.createScanner(ScriptTarget.Latest, false, LanguageVariant.Standard, value);
|
|
5
|
+
const valid = scanner.scan() === SyntaxKind.Identifier && scanner.scan() === SyntaxKind.EndOfFileToken;
|
|
6
|
+
if (!valid)
|
|
7
|
+
throw new Error(`${label} must be a valid TypeScript identifier.`);
|
|
8
|
+
}
|
|
9
|
+
function assertBoundedText(value, label, maximum = 10_000) {
|
|
10
|
+
if (value.trim().length === 0)
|
|
11
|
+
throw new Error(`${label} must not be empty.`);
|
|
12
|
+
if (value.length > maximum)
|
|
13
|
+
throw new Error(`${label} exceeds ${maximum} characters.`);
|
|
14
|
+
}
|
|
15
|
+
function parseFragment(sourceText, label) {
|
|
16
|
+
const sourceFile = ts.createSourceFile("__ast_scaffold_fragment.ts", sourceText, ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
17
|
+
const diagnostics = sourceFile.parseDiagnostics;
|
|
18
|
+
if (diagnostics.length > 0) {
|
|
19
|
+
throw new Error(`${label} is not valid TypeScript in its declaration position.`);
|
|
20
|
+
}
|
|
21
|
+
return sourceFile;
|
|
22
|
+
}
|
|
23
|
+
function assertTypeFragment(value, label) {
|
|
24
|
+
assertBoundedText(value, label);
|
|
25
|
+
const sourceFile = parseFragment(`type __ScaffoldType = ${value};`, label);
|
|
26
|
+
const declaration = sourceFile.statements[0];
|
|
27
|
+
if (sourceFile.statements.length !== 1 ||
|
|
28
|
+
!declaration ||
|
|
29
|
+
!ts.isTypeAliasDeclaration(declaration) ||
|
|
30
|
+
declaration.type.getText(sourceFile) !== value.trim()) {
|
|
31
|
+
throw new Error(`${label} must contain exactly one TypeScript type.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function assertHeritageFragment(value, label, token) {
|
|
35
|
+
assertBoundedText(value, label);
|
|
36
|
+
const keyword = token === SyntaxKind.ExtendsKeyword ? "extends" : "implements";
|
|
37
|
+
const sourceFile = parseFragment(`class __Scaffold ${keyword} ${value} {}`, label);
|
|
38
|
+
const declaration = sourceFile.statements[0];
|
|
39
|
+
const clause = declaration && ts.isClassDeclaration(declaration)
|
|
40
|
+
? declaration.heritageClauses?.find((item) => item.token === token)
|
|
41
|
+
: undefined;
|
|
42
|
+
if (sourceFile.statements.length !== 1 ||
|
|
43
|
+
!clause ||
|
|
44
|
+
clause.types.length !== 1 ||
|
|
45
|
+
clause.types[0]?.getText(sourceFile) !== value.trim()) {
|
|
46
|
+
throw new Error(`${label} must contain exactly one TypeScript heritage expression.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function assertInitializerFragment(value, label) {
|
|
50
|
+
assertBoundedText(value, label);
|
|
51
|
+
const sourceFile = parseFragment(`class __Scaffold { value = ${value}; }`, label);
|
|
52
|
+
const declaration = sourceFile.statements[0];
|
|
53
|
+
const members = declaration && ts.isClassDeclaration(declaration) ? declaration.members : [];
|
|
54
|
+
const property = members[0];
|
|
55
|
+
if (sourceFile.statements.length !== 1 ||
|
|
56
|
+
members.length !== 1 ||
|
|
57
|
+
!property ||
|
|
58
|
+
!ts.isPropertyDeclaration(property) ||
|
|
59
|
+
property.initializer?.getText(sourceFile) !== value.trim()) {
|
|
60
|
+
throw new Error(`${label} must contain exactly one TypeScript expression.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function scopeStructure(modifier) {
|
|
64
|
+
switch (modifier) {
|
|
65
|
+
case "public":
|
|
66
|
+
return { scope: Scope.Public };
|
|
67
|
+
case "public readonly":
|
|
68
|
+
return { scope: Scope.Public, isReadonly: true };
|
|
69
|
+
case "protected":
|
|
70
|
+
return { scope: Scope.Protected };
|
|
71
|
+
case "protected readonly":
|
|
72
|
+
return { scope: Scope.Protected, isReadonly: true };
|
|
73
|
+
case "private":
|
|
74
|
+
return { scope: Scope.Private };
|
|
75
|
+
case "private readonly":
|
|
76
|
+
return { scope: Scope.Private, isReadonly: true };
|
|
77
|
+
default:
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function decoratorStructure(value, label) {
|
|
82
|
+
assertBoundedText(value, label);
|
|
83
|
+
const sourceFile = parseFragment(`${value.trim()}\nclass __Scaffold {}`, label);
|
|
84
|
+
const declaration = sourceFile.statements[0];
|
|
85
|
+
const decorators = declaration && ts.isClassDeclaration(declaration) && ts.canHaveDecorators(declaration)
|
|
86
|
+
? ts.getDecorators(declaration)
|
|
87
|
+
: undefined;
|
|
88
|
+
if (sourceFile.statements.length !== 1 ||
|
|
89
|
+
decorators?.length !== 1 ||
|
|
90
|
+
decorators[0]?.getText(sourceFile) !== value.trim()) {
|
|
91
|
+
throw new Error(`${label} must contain exactly one TypeScript decorator.`);
|
|
92
|
+
}
|
|
93
|
+
const match = /^@([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)(?:\(([\s\S]*)\))?$/.exec(value.trim());
|
|
94
|
+
if (!match) {
|
|
95
|
+
throw new Error(`${label} must use @Name or @Name(arguments) syntax.`);
|
|
96
|
+
}
|
|
97
|
+
const argumentText = match[2]?.trim();
|
|
98
|
+
return {
|
|
99
|
+
name: match[1],
|
|
100
|
+
arguments: argumentText ? [argumentText] : [],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function validateSpec(spec) {
|
|
104
|
+
assertIdentifier(spec.className, "class_name");
|
|
105
|
+
if (spec.methods.length === 0)
|
|
106
|
+
throw new Error("methods must contain at least one method.");
|
|
107
|
+
const methodNames = new Set();
|
|
108
|
+
for (const [index, method] of spec.methods.entries()) {
|
|
109
|
+
assertIdentifier(method.name, `methods[${index}].name`);
|
|
110
|
+
if (method.name === "constructor") {
|
|
111
|
+
throw new Error('Method name "constructor" is reserved for the generated constructor.');
|
|
112
|
+
}
|
|
113
|
+
if (methodNames.has(method.name)) {
|
|
114
|
+
throw new Error("Method names must be unique so pending method selectors are unambiguous.");
|
|
115
|
+
}
|
|
116
|
+
methodNames.add(method.name);
|
|
117
|
+
assertTypeFragment(method.returnType, `methods[${index}].return_type`);
|
|
118
|
+
const parameterNames = new Set();
|
|
119
|
+
for (const [paramIndex, parameter] of method.params.entries()) {
|
|
120
|
+
assertIdentifier(parameter.name, `methods[${index}].params[${paramIndex}].name`);
|
|
121
|
+
if (parameterNames.has(parameter.name)) {
|
|
122
|
+
throw new Error(`methods[${index}] parameter names must be unique.`);
|
|
123
|
+
}
|
|
124
|
+
parameterNames.add(parameter.name);
|
|
125
|
+
assertTypeFragment(parameter.type, `methods[${index}].params[${paramIndex}].type`);
|
|
126
|
+
}
|
|
127
|
+
for (const [docsIndex, docs] of (method.docs ?? []).entries()) {
|
|
128
|
+
assertBoundedText(docs, `methods[${index}].docs[${docsIndex}]`);
|
|
129
|
+
if (docs.includes("*/")) {
|
|
130
|
+
throw new Error(`methods[${index}].docs[${docsIndex}] must not close its doc comment.`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const constructorParameterNames = new Set();
|
|
135
|
+
for (const [index, parameter] of spec.constructorParams.entries()) {
|
|
136
|
+
assertIdentifier(parameter.name, `constructor_params[${index}].name`);
|
|
137
|
+
if (constructorParameterNames.has(parameter.name)) {
|
|
138
|
+
throw new Error("Constructor parameter names must be unique.");
|
|
139
|
+
}
|
|
140
|
+
constructorParameterNames.add(parameter.name);
|
|
141
|
+
assertTypeFragment(parameter.type, `constructor_params[${index}].type`);
|
|
142
|
+
}
|
|
143
|
+
const propertyNames = new Set();
|
|
144
|
+
for (const [index, property] of spec.properties.entries()) {
|
|
145
|
+
assertIdentifier(property.name, `properties[${index}].name`);
|
|
146
|
+
if (propertyNames.has(property.name))
|
|
147
|
+
throw new Error("Property names must be unique.");
|
|
148
|
+
if (methodNames.has(property.name)) {
|
|
149
|
+
throw new Error("Property names must not collide with generated method names.");
|
|
150
|
+
}
|
|
151
|
+
propertyNames.add(property.name);
|
|
152
|
+
assertTypeFragment(property.type, `properties[${index}].type`);
|
|
153
|
+
if (property.initializer === undefined) {
|
|
154
|
+
throw new Error(`properties[${index}] requires an initializer.`);
|
|
155
|
+
}
|
|
156
|
+
assertInitializerFragment(property.initializer, `properties[${index}].initializer`);
|
|
157
|
+
}
|
|
158
|
+
for (const [index, declaration] of spec.imports.entries()) {
|
|
159
|
+
assertBoundedText(declaration.from, `imports[${index}].from`, 500);
|
|
160
|
+
if ([...declaration.from].some((character) => {
|
|
161
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
162
|
+
return codePoint <= 31 || codePoint === 127;
|
|
163
|
+
})) {
|
|
164
|
+
throw new Error(`imports[${index}].from must not contain control characters.`);
|
|
165
|
+
}
|
|
166
|
+
if (declaration.default !== undefined) {
|
|
167
|
+
assertIdentifier(declaration.default, `imports[${index}].default`);
|
|
168
|
+
}
|
|
169
|
+
for (const [nameIndex, name] of (declaration.named ?? []).entries()) {
|
|
170
|
+
assertIdentifier(name, `imports[${index}].named[${nameIndex}]`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
spec.decorators.forEach((decorator, index) => decoratorStructure(decorator, `decorators[${index}]`));
|
|
174
|
+
spec.methods.forEach((method, methodIndex) => (method.decorators ?? []).forEach((decorator, decoratorIndex) => decoratorStructure(decorator, `methods[${methodIndex}].decorators[${decoratorIndex}]`)));
|
|
175
|
+
if (spec.extends !== undefined) {
|
|
176
|
+
assertHeritageFragment(spec.extends, "extends", SyntaxKind.ExtendsKeyword);
|
|
177
|
+
}
|
|
178
|
+
spec.implements.forEach((value, index) => assertHeritageFragment(value, `implements[${index}]`, SyntaxKind.ImplementsKeyword));
|
|
179
|
+
}
|
|
180
|
+
export function buildClassScaffold(project, filePath, spec) {
|
|
181
|
+
validateSpec(spec);
|
|
182
|
+
const sourceFile = project.createSourceFile(filePath, "", { overwrite: false });
|
|
183
|
+
sourceFile.addImportDeclarations(spec.imports.map((declaration) => ({
|
|
184
|
+
moduleSpecifier: declaration.from,
|
|
185
|
+
...(declaration.default ? { defaultImport: declaration.default } : {}),
|
|
186
|
+
...(declaration.named?.length ? { namedImports: declaration.named } : {}),
|
|
187
|
+
})));
|
|
188
|
+
sourceFile.addClass({
|
|
189
|
+
name: spec.className,
|
|
190
|
+
isExported: true,
|
|
191
|
+
...(spec.extends ? { extends: spec.extends } : {}),
|
|
192
|
+
...(spec.implements.length > 0 ? { implements: spec.implements } : {}),
|
|
193
|
+
decorators: spec.decorators.map((decorator, index) => decoratorStructure(decorator, `decorators[${index}]`)),
|
|
194
|
+
ctors: spec.constructorParams.length > 0
|
|
195
|
+
? [
|
|
196
|
+
{
|
|
197
|
+
parameters: spec.constructorParams.map((parameter) => ({
|
|
198
|
+
name: parameter.name,
|
|
199
|
+
type: parameter.type,
|
|
200
|
+
...scopeStructure(parameter.accessModifier),
|
|
201
|
+
})),
|
|
202
|
+
statements: [],
|
|
203
|
+
},
|
|
204
|
+
]
|
|
205
|
+
: [],
|
|
206
|
+
properties: spec.properties.map((property) => ({
|
|
207
|
+
name: property.name,
|
|
208
|
+
type: property.type,
|
|
209
|
+
...scopeStructure(property.accessModifier),
|
|
210
|
+
...(property.initializer !== undefined ? { initializer: property.initializer } : {}),
|
|
211
|
+
})),
|
|
212
|
+
methods: spec.methods.map((method, methodIndex) => ({
|
|
213
|
+
name: method.name,
|
|
214
|
+
isAsync: method.isAsync,
|
|
215
|
+
returnType: method.returnType,
|
|
216
|
+
...scopeStructure(method.accessModifier),
|
|
217
|
+
parameters: method.params.map((parameter) => ({
|
|
218
|
+
name: parameter.name,
|
|
219
|
+
type: parameter.type,
|
|
220
|
+
})),
|
|
221
|
+
decorators: (method.decorators ?? []).map((decorator, decoratorIndex) => decoratorStructure(decorator, `methods[${methodIndex}].decorators[${decoratorIndex}]`)),
|
|
222
|
+
docs: method.docs ?? [],
|
|
223
|
+
statements: [`throw new Error("Not implemented: ${spec.className}.${method.name}");`],
|
|
224
|
+
})),
|
|
225
|
+
});
|
|
226
|
+
const classes = sourceFile.getClasses();
|
|
227
|
+
const allowedStatements = sourceFile
|
|
228
|
+
.getStatements()
|
|
229
|
+
.every((statement) => Node.isImportDeclaration(statement) || Node.isClassDeclaration(statement));
|
|
230
|
+
if (!allowedStatements ||
|
|
231
|
+
classes.length !== 1 ||
|
|
232
|
+
classes[0]?.getName() !== spec.className ||
|
|
233
|
+
classes[0].getMethods().length !== spec.methods.length) {
|
|
234
|
+
throw new Error("Generated scaffold failed structural postcondition validation.");
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
sourceFile,
|
|
238
|
+
pendingMethods: spec.methods.map((method) => `${spec.className}.${method.name}`),
|
|
239
|
+
outline: buildFileOutline(sourceFile).text,
|
|
240
|
+
};
|
|
241
|
+
}
|
package/dist/services/symbols.js
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
import { Node, } from "ts-morph";
|
|
2
|
+
export function symbolMatchRank(query, symbol) {
|
|
3
|
+
const normalizedQuery = query.toLowerCase();
|
|
4
|
+
const normalizedPath = symbol.symbolPath.toLowerCase();
|
|
5
|
+
const normalizedName = symbol.name.toLowerCase();
|
|
6
|
+
const normalizedSelector = `${normalizedPath}@${symbol.line}`;
|
|
7
|
+
if (normalizedSelector === normalizedQuery)
|
|
8
|
+
return 0;
|
|
9
|
+
if (normalizedPath === normalizedQuery)
|
|
10
|
+
return 1;
|
|
11
|
+
if (normalizedName === normalizedQuery)
|
|
12
|
+
return 2;
|
|
13
|
+
if (normalizedPath.startsWith(normalizedQuery) || normalizedName.startsWith(normalizedQuery)) {
|
|
14
|
+
return 3;
|
|
15
|
+
}
|
|
16
|
+
return 4;
|
|
17
|
+
}
|
|
2
18
|
function namedNode(node) {
|
|
3
19
|
if (Node.isConstructorDeclaration(node))
|
|
4
20
|
return "constructor";
|
|
@@ -7,7 +7,7 @@ const AstApplyOperationInputSchema = z.object({
|
|
|
7
7
|
});
|
|
8
8
|
const AstApplyOperationOutputSchema = z.object({
|
|
9
9
|
operation_id: z.string().uuid(),
|
|
10
|
-
kind: z.enum(["rename_symbol", "replace_symbol_body"]),
|
|
10
|
+
kind: z.enum(["rename_symbol", "replace_symbol_body", "scaffold_class"]),
|
|
11
11
|
status: z.literal("applied"),
|
|
12
12
|
applied_at: z.string(),
|
|
13
13
|
affected_files: z.array(z.string()),
|