ast-mcp-server 0.4.0 → 0.5.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.
@@ -2,7 +2,8 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { Project } from "ts-morph";
4
4
  import { findDeclaration } from "./symbols.js";
5
- import { createConfigSnapshot } from "./workspace.js";
5
+ import { createConfigSnapshot, createWorkspaceSnapshot } from "./workspace.js";
6
+ import { createInitialProjectStatus, createProjectIdentity, projectStatusToProjection, transitionProjectStatus, } from "./project-status.js";
6
7
  const MAX_PROJECT_SESSIONS = 8;
7
8
  const projectSessions = new Map();
8
9
  const projectRoots = new WeakMap();
@@ -25,7 +26,8 @@ function buildProjectContext(tsConfigFilePath) {
25
26
  });
26
27
  const projectRoot = path.dirname(tsConfigFilePath);
27
28
  projectRoots.set(project, projectRoot);
28
- return { project, projectRoot, tsConfigFilePath };
29
+ const status = createInitialProjectStatus(createProjectIdentity({ projectRoot, configPath: tsConfigFilePath }), { sourceCount: project.getSourceFiles().length });
30
+ return { project, projectRoot, tsConfigFilePath, status };
29
31
  }
30
32
  export function createFreshProject(projectRoot) {
31
33
  return buildProjectContext(resolveTsConfigPath(projectRoot));
@@ -50,41 +52,113 @@ function getOrCreateSession(projectRoot) {
50
52
  configDigest: createConfigSnapshot(tsConfigFilePath).digest,
51
53
  queue: Promise.resolve(),
52
54
  activeOperations: 0,
55
+ runningOperations: 0,
56
+ queuedOperations: 0,
53
57
  lastAccessedAt: Date.now(),
54
58
  };
55
59
  projectSessions.set(tsConfigFilePath, session);
56
60
  return session;
57
61
  }
62
+ function getQueueStatus(session) {
63
+ return {
64
+ state: session.runningOperations > 0 ? "running" : session.queuedOperations > 0 ? "queued" : "idle",
65
+ active_operations: session.runningOperations,
66
+ queued_operations: session.queuedOperations,
67
+ };
68
+ }
58
69
  async function synchronizeSession(session) {
59
- const currentConfigDigest = createConfigSnapshot(session.context.tsConfigFilePath).digest;
60
- if (currentConfigDigest !== session.configDigest) {
61
- session.context = buildProjectContext(session.context.tsConfigFilePath);
62
- session.configDigest = currentConfigDigest;
63
- return;
70
+ let syncFailureCause = "compiler_rebuild";
71
+ let currentConfigDigest;
72
+ try {
73
+ currentConfigDigest = createConfigSnapshot(session.context.tsConfigFilePath).digest;
74
+ if (currentConfigDigest !== session.configDigest) {
75
+ let status = transitionProjectStatus(session.context.status, { type: "config_changed" });
76
+ status = transitionProjectStatus(status, { type: "compiler_rebuild_started" });
77
+ const rebuilt = buildProjectContext(session.context.tsConfigFilePath);
78
+ session.context = { ...rebuilt, status };
79
+ }
80
+ const { project, tsConfigFilePath } = session.context;
81
+ syncFailureCause = "source_change";
82
+ const refreshSourceFiles = async () => {
83
+ project.addSourceFilesFromTsConfig(tsConfigFilePath);
84
+ await Promise.all(project.getSourceFiles().map((sourceFile) => sourceFile.refreshFromFileSystem()));
85
+ };
86
+ await refreshSourceFiles();
87
+ const snapshot = createWorkspaceSnapshot(session.context);
88
+ if (snapshot.configDigest !== currentConfigDigest) {
89
+ syncFailureCause = "config_change";
90
+ throw new Error("Project configuration changed during synchronization. Retry the operation.");
91
+ }
92
+ await refreshSourceFiles();
93
+ const verificationSnapshot = createWorkspaceSnapshot(session.context);
94
+ if (verificationSnapshot.configDigest !== currentConfigDigest) {
95
+ syncFailureCause = "config_change";
96
+ throw new Error("Project configuration changed during synchronization. Retry the operation.");
97
+ }
98
+ if (verificationSnapshot.sourceDigest !== snapshot.sourceDigest) {
99
+ syncFailureCause = "source_change";
100
+ throw new Error("Project source changed during synchronization. Retry the operation.");
101
+ }
102
+ const status = transitionProjectStatus(session.context.status, {
103
+ type: "sync_succeeded",
104
+ sourceCount: verificationSnapshot.sourceFileCount,
105
+ indexedCount: 0,
106
+ sourceSnapshotFingerprint: `source_${verificationSnapshot.sourceDigest}`,
107
+ configSnapshotFingerprint: `config_${verificationSnapshot.configDigest}`,
108
+ canonicalSnapshotFingerprint: `snapshot_${verificationSnapshot.digest}`,
109
+ at: new Date().toISOString(),
110
+ });
111
+ session.context = { ...session.context, status };
112
+ session.configDigest = verificationSnapshot.configDigest;
113
+ }
114
+ catch (error) {
115
+ const status = transitionProjectStatus(session.context.status, {
116
+ type: "sync_failed",
117
+ cause: syncFailureCause,
118
+ error: error instanceof Error ? error.message : String(error),
119
+ });
120
+ session.context = { ...session.context, status };
121
+ throw error;
64
122
  }
65
- const { project, tsConfigFilePath } = session.context;
66
- project.addSourceFilesFromTsConfig(tsConfigFilePath);
67
- await Promise.all(project.getSourceFiles().map((sourceFile) => sourceFile.refreshFromFileSystem()));
68
123
  }
69
- export async function withProject(projectRoot, operation) {
70
- const session = getOrCreateSession(projectRoot);
124
+ async function runSessionWithSyncPolicy(session, operation, allowSynchronizationFailure) {
71
125
  session.activeOperations += 1;
126
+ session.queuedOperations += 1;
72
127
  let release;
73
128
  const previous = session.queue;
74
129
  session.queue = new Promise((resolve) => {
75
130
  release = resolve;
76
131
  });
77
132
  await previous;
133
+ session.queuedOperations -= 1;
134
+ session.runningOperations += 1;
78
135
  try {
79
- await synchronizeSession(session);
136
+ try {
137
+ await synchronizeSession(session);
138
+ }
139
+ catch (error) {
140
+ if (!allowSynchronizationFailure)
141
+ throw error;
142
+ }
80
143
  session.lastAccessedAt = Date.now();
81
144
  return await operation(session.context);
82
145
  }
83
146
  finally {
147
+ session.runningOperations -= 1;
84
148
  session.activeOperations -= 1;
85
149
  release?.();
86
150
  }
87
151
  }
152
+ export async function withProject(projectRoot, operation) {
153
+ return runSessionWithSyncPolicy(getOrCreateSession(projectRoot), operation, false);
154
+ }
155
+ export async function getProjectStatus(projectRoot) {
156
+ const session = getOrCreateSession(projectRoot);
157
+ return runSessionWithSyncPolicy(session, (context) => ({
158
+ ...projectStatusToProjection(context.status),
159
+ operation_queue: getQueueStatus(session),
160
+ }), true);
161
+ }
88
162
  // Compatibility API for internal scripts. MCP tools should use withProject().
89
163
  export function getProject(projectRoot) {
90
164
  return createFreshProject(projectRoot).project;
@@ -0,0 +1,159 @@
1
+ export const SNAPSHOT_STATES = Object.freeze([
2
+ "fresh",
3
+ "pending",
4
+ "stale",
5
+ "rebuilding",
6
+ "degraded",
7
+ ]);
8
+ export function isSnapshotState(value) {
9
+ return SNAPSHOT_STATES.some((state) => state === value);
10
+ }
11
+ export const FRESHNESS_CAUSES = Object.freeze([
12
+ "source_change",
13
+ "config_change",
14
+ "index_failure",
15
+ "watcher_failure",
16
+ "compiler_rebuild",
17
+ ]);
18
+ export function isFreshnessCause(value) {
19
+ return FRESHNESS_CAUSES.some((cause) => cause === value);
20
+ }
21
+ function assertIntegerAtLeast(value, minimum, name) {
22
+ if (!Number.isInteger(value) || value < minimum) {
23
+ throw new Error(`${name} must be an integer greater than or equal to ${minimum}.`);
24
+ }
25
+ }
26
+ export function createFileRange(offset, limit, totalLines) {
27
+ assertIntegerAtLeast(offset, 0, "File range offset");
28
+ assertIntegerAtLeast(limit, 1, "File range limit");
29
+ assertIntegerAtLeast(totalLines, 0, "File range total_lines");
30
+ return { offset, limit, total_lines: totalLines };
31
+ }
32
+ function projectRelativePath(file) {
33
+ let unquoted = file.trim();
34
+ const quote = unquoted[0];
35
+ if (unquoted.length >= 2 &&
36
+ (quote === '"' || quote === "'" || quote === "`") &&
37
+ unquoted.endsWith(quote)) {
38
+ unquoted = unquoted.slice(1, -1).trim();
39
+ }
40
+ const normalized = unquoted.replaceAll("\\", "/");
41
+ const relative = normalized.startsWith("./") ? normalized.slice(2) : normalized;
42
+ const segments = relative.split("/");
43
+ const isWindowsAbsolute = /^[A-Za-z]:/.test(relative);
44
+ if (relative.length === 0 ||
45
+ relative.startsWith("/") ||
46
+ isWindowsAbsolute ||
47
+ segments.some((segment) => segment.length === 0 || segment === ".." || segment === ".")) {
48
+ throw new Error("Source location file must be project-relative.");
49
+ }
50
+ return relative;
51
+ }
52
+ export function createSourceLocation(file, range) {
53
+ if (typeof file !== "string" || typeof range !== "object" || range === null) {
54
+ throw new Error("Source location input is invalid.");
55
+ }
56
+ assertIntegerAtLeast(range.start_line, 1, "Source location start_line");
57
+ if (range.start_column !== undefined) {
58
+ assertIntegerAtLeast(range.start_column, 1, "Source location start_column");
59
+ }
60
+ if (range.end_line !== undefined) {
61
+ assertIntegerAtLeast(range.end_line, range.start_line, "Source location end_line");
62
+ }
63
+ if (range.end_column !== undefined) {
64
+ assertIntegerAtLeast(range.end_column, 1, "Source location end_column");
65
+ }
66
+ if (range.end_line === range.start_line &&
67
+ range.start_column !== undefined &&
68
+ range.end_column !== undefined &&
69
+ range.end_column < range.start_column) {
70
+ throw new Error("Source location end_column must not precede start_column.");
71
+ }
72
+ return {
73
+ file: projectRelativePath(file),
74
+ start_line: range.start_line,
75
+ ...(range.start_column === undefined ? {} : { start_column: range.start_column }),
76
+ ...(range.end_line === undefined ? {} : { end_line: range.end_line }),
77
+ ...(range.end_column === undefined ? {} : { end_column: range.end_column }),
78
+ };
79
+ }
80
+ export const TRUNCATION_REASONS = Object.freeze([
81
+ "record_limit",
82
+ "byte_limit",
83
+ "depth_limit",
84
+ "edge_limit",
85
+ "invocation_limit",
86
+ "serialization_limit",
87
+ ]);
88
+ export function isTruncationReason(value) {
89
+ return TRUNCATION_REASONS.some((reason) => reason === value);
90
+ }
91
+ export function createTruncationMetadata(truncated, reason = null) {
92
+ if (reason !== null && !isTruncationReason(reason)) {
93
+ throw new Error(`Unknown truncation reason: ${String(reason)}.`);
94
+ }
95
+ if (truncated !== (reason !== null)) {
96
+ throw new Error("A truncated result requires a machine-readable reason, and a complete result does not.");
97
+ }
98
+ return { truncated, reason };
99
+ }
100
+ export function isJsonValue(value) {
101
+ const ancestors = new Set();
102
+ function visit(candidate) {
103
+ if (candidate === null)
104
+ return true;
105
+ if (typeof candidate === "string" || typeof candidate === "boolean")
106
+ return true;
107
+ if (typeof candidate === "number") {
108
+ return Number.isFinite(candidate) && !Object.is(candidate, -0);
109
+ }
110
+ if (typeof candidate !== "object")
111
+ return false;
112
+ if (ancestors.has(candidate))
113
+ return false;
114
+ ancestors.add(candidate);
115
+ try {
116
+ if ("toJSON" in candidate)
117
+ return false;
118
+ if (Array.isArray(candidate)) {
119
+ if (Object.getPrototypeOf(candidate) !== Array.prototype)
120
+ return false;
121
+ if (Object.getOwnPropertySymbols(candidate).length > 0)
122
+ return false;
123
+ for (let index = 0; index < candidate.length; index += 1) {
124
+ if (!Object.hasOwn(candidate, index))
125
+ return false;
126
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index));
127
+ if (descriptor === undefined || !("value" in descriptor) || !visit(descriptor.value)) {
128
+ return false;
129
+ }
130
+ }
131
+ return Object.getOwnPropertyNames(candidate).every((key) => {
132
+ if (key === "length")
133
+ return true;
134
+ const index = Number(key);
135
+ return (Number.isInteger(index) &&
136
+ index >= 0 &&
137
+ index < candidate.length &&
138
+ String(index) === key);
139
+ });
140
+ }
141
+ const prototype = Object.getPrototypeOf(candidate);
142
+ if (prototype !== Object.prototype && prototype !== null)
143
+ return false;
144
+ if (Object.getOwnPropertySymbols(candidate).length > 0)
145
+ return false;
146
+ return Object.getOwnPropertyNames(candidate).every((key) => {
147
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
148
+ return (descriptor !== undefined &&
149
+ descriptor.enumerable &&
150
+ "value" in descriptor &&
151
+ visit(descriptor.value));
152
+ });
153
+ }
154
+ finally {
155
+ ancestors.delete(candidate);
156
+ }
157
+ }
158
+ return visit(value);
159
+ }
@@ -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";
@@ -65,12 +65,15 @@ export function createConfigSnapshot(rootConfigPath) {
65
65
  export function createWorkspaceSnapshot(context) {
66
66
  const sourcePaths = context.project
67
67
  .getSourceFiles()
68
- .map((sourceFile) => sourceFile.getFilePath());
69
- const paths = [...sourcePaths, ...createConfigSnapshot(context.tsConfigFilePath).files]
70
- .map(canonicalPath)
71
- .sort();
72
- const files = new Map();
73
- for (const filePath of paths) {
68
+ .map((sourceFile) => canonicalPath(sourceFile.getFilePath()));
69
+ const configSnapshot = createConfigSnapshot(context.tsConfigFilePath);
70
+ const sourceFiles = new Map();
71
+ for (const filePath of [...new Set(sourcePaths)].sort()) {
72
+ const digest = hashBytes(fs.readFileSync(filePath));
73
+ sourceFiles.set(filePath, digest);
74
+ }
75
+ const files = new Map(sourceFiles);
76
+ for (const filePath of configSnapshot.files) {
74
77
  const digest = hashBytes(fs.readFileSync(filePath));
75
78
  const previous = files.get(filePath);
76
79
  if (previous && previous !== digest) {
@@ -80,6 +83,9 @@ export function createWorkspaceSnapshot(context) {
80
83
  }
81
84
  return {
82
85
  digest: hashWorkspaceFiles(files),
86
+ sourceDigest: hashWorkspaceFiles(sourceFiles),
87
+ configDigest: configSnapshot.digest,
88
+ sourceFileCount: sourceFiles.size,
83
89
  fileCount: files.size,
84
90
  files,
85
91
  };
@@ -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()),