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.
@@ -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
- assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
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 currentContext = createFreshProject(operation.tsConfigFilePath);
386
- const currentWorkspace = createWorkspaceSnapshot(currentContext);
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 (currentWorkspace.digest !== operation.workspace_hash) {
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
- await rename(staged.get(file), file.absolutePath);
424
- staged.delete(file);
425
- applied.push(file);
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
- await restoreFile(file, operationId);
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
- if (hashBytes(originalBytes) !== file.original_hash) {
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 const PaginationInputSchema = {
5
- offset: z.number().int().min(0).default(0).describe("Zero-based result offset."),
6
- limit: z
7
- .number()
8
- .int()
9
- .min(1)
10
- .max(MAX_PAGE_LIMIT)
11
- .default(DEFAULT_PAGE_LIMIT)
12
- .describe(`Maximum results to return (1-${MAX_PAGE_LIMIT}).`),
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
+ }
@@ -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()),