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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.5.1] - 2026-08-05
8
+
9
+ ### Added
10
+
11
+ - Bounded JSON-safe project status contracts and the read-only `ast_get_project_status` tool.
12
+ - Session freshness metadata with separate source, config, and canonical fingerprints.
13
+ - Serialized operation-queue accounting and status integration without changing reviewed mutation semantics.
14
+
15
+ ### Changed
16
+
17
+ - Source synchronization verifies stability across refresh, snapshot, refresh, and verification before reporting fresh state.
18
+ - Phase 1 keeps the symbol index disabled while exposing explicit stale/degraded recovery state.
19
+
20
+ ### Security
21
+
22
+ - Status projections redact identities, absolute paths, credentials, and multi-token `Authorization` values at both transition and projection boundaries.
23
+ - Noncanonical external fingerprints are converted to bounded opaque SHA-256 digests before projection.
24
+
25
+ ## [0.5.0] - 2026-08-03
26
+
27
+ ### Added
28
+
29
+ - Relevance-ranked symbol search with `selectors`, `summary`, and backward-compatible `full` detail profiles.
30
+ - Progressive semantic references with location-only defaults and opt-in bounded source context.
31
+ - `ast_scaffold_class`, a strict prepare-only class scaffold that generates explicit throwing method placeholders and uses the existing preview/apply protocol.
32
+ - Hash-bound absent-file plans with atomic no-clobber creation, conservative rollback, persisted replay, and idempotent receipt recovery.
33
+ - A checked result-shaping workflow benchmark covering ranking, selector chaining, multi-file references, JSON/TOON payloads, call counts, evidence preservation, and complete tool metadata.
34
+
35
+ ### Changed
36
+
37
+ - Symbol search now defaults to ranked `summary` records with a 20-result page. Use `detail: "full", limit: 100` for the v0.4.0 result profile.
38
+ - Reference search now defaults to `locations` without source lines. Use `detail: "context"` for the v0.4.0 record profile.
39
+ - MCP and batch client identity now derives from the shipped package version instead of a separately maintained literal.
40
+
41
+ ### Security
42
+
43
+ - Scaffold inputs are strict and bounded; identifiers, duplicates, raw TypeScript fragments, paths, parent directories, and target absence are validated before plan creation.
44
+ - File creation uses same-directory exclusive staging plus an atomic hard-link commit and never falls back to overwrite-capable rename.
45
+ - Existing rename/body-replacement plan hashes and persisted modification plans remain compatible.
46
+
7
47
  ## [0.4.0] - 2026-08-03
8
48
 
9
49
  ### Added
@@ -44,5 +84,7 @@ Initial public release.
44
84
  - Apply rejects stale workspaces, changed configs/sources, mismatched plan hashes, unsafe plan files, conflicting MCP registrations, and conflicting skill content.
45
85
  - Inputs, outputs, operation stores, subprocesses, batch fan-out, plan lifetime, and filesystem access are bounded.
46
86
 
87
+ [0.5.1]: https://github.com/yailPeralta/ast-mcp-server/releases/tag/v0.5.1
88
+ [0.5.0]: https://github.com/yailPeralta/ast-mcp-server/releases/tag/v0.5.0
47
89
  [0.4.0]: https://github.com/yailPeralta/ast-mcp-server/releases/tag/v0.4.0
48
90
  [0.3.0]: https://github.com/yailPeralta/ast-mcp-server/releases/tag/v0.3.0
package/README.md CHANGED
@@ -39,7 +39,7 @@ Reads can start with a compact outline and fetch exact source only when needed.
39
39
 
40
40
  AST-aware editing is not a proof that a change is semantically correct. The safety comes from combining structural selection with diagnostics, exact previews, reviewed hashes, freshness checks, and fail-closed apply semantics.
41
41
 
42
- The included batch benchmark records a 50% reduction in model round-trips and a 95.21% reduction in serialized context for its search-to-source scenario. The format benchmark records an `o200k_base` estimate of 25.72% fewer model-facing tokens for a 100-row symbol search and 26.56% across the eligible collection corpus. These are reproducible scenario measurements, not universal token, billing, cache, or latency claims.
42
+ The included batch benchmark records a 50% reduction in model round-trips and a 94.67% reduction in serialized context for its search-to-source scenario. The result-shaping corpus records a 68.80% reduction in aggregate model-facing TOON tokens while preserving declared selectors/reference coordinates with the same six logical calls. The separate format benchmark records 25.87% across its eligible collection corpus. These are reproducible local `o200k_base` estimates, not universal token, billing, cache, or latency claims.
43
43
 
44
44
  ## Requirements
45
45
 
@@ -224,11 +224,14 @@ Project-scoped tools accept `project_root`, either the project directory or an e
224
224
  | `ast_get_diagnostics` | Project- or file-scoped TypeScript diagnostics | No |
225
225
  | `ast_rename_symbol` | Prepare a project-wide rename | No |
226
226
  | `ast_replace_symbol_body` | Prepare a body-only replacement while preserving the signature | No |
227
+ | `ast_scaffold_class` | Prepare one new class file with explicit placeholder methods | No |
227
228
  | `ast_get_operation_preview` | Retrieve the complete retained diff for a prepared plan | No |
228
229
  | `ast_apply_operation` | Apply one reviewed, hash-bound plan | Yes |
229
230
 
230
231
  Read results use project-relative paths, deterministic ordering, structured MCP output, and pagination where result sets can grow with the project.
231
232
 
233
+ Symbol search is relevance-ranked and defaults to at most 20 `summary` records containing `file`, a directly reusable `selector`, `kind`, and body-free `signature`. Request `detail: "selectors"` for routing coordinates only, or `detail: "full", limit: 100` for the v0.4.0 fields/page. References default to `detail: "locations"`; request `detail: "context"` only when the bounded source line is needed.
234
+
232
235
  ### Optional TOON results
233
236
 
234
237
  `ast_search_symbols`, `ast_find_references`, and `ast_get_diagnostics` accept `output_format: "toon"` for collection-heavy results consumed directly by a model. JSON remains the default and preserves the canonical structured object.
@@ -265,7 +268,7 @@ Example search-to-source pipeline:
265
268
  "tool": "ast_get_symbol_source",
266
269
  "input": {
267
270
  "file_path": { "$ref": "#/steps/search/symbols/0/file" },
268
- "symbol_path": { "$ref": "#/steps/search/symbols/0/symbol_path" }
271
+ "symbol_path": { "$ref": "#/steps/search/symbols/0/selector" }
269
272
  }
270
273
  }
271
274
  ],
@@ -314,15 +317,17 @@ Success is one compact JSON value on stdout by default. `ast-tool run --output-f
314
317
 
315
318
  ### MCP process
316
319
 
317
- Rename and body replacement never write directly:
320
+ Rename, body replacement, and class scaffold never write directly during preparation:
318
321
 
319
- 1. Call `ast_rename_symbol` or `ast_replace_symbol_body`.
322
+ 1. Call `ast_rename_symbol`, `ast_replace_symbol_body`, or `ast_scaffold_class`.
320
323
  2. Review the diagnostic delta, affected files, `blocked`, and `plan_hash`.
321
324
  3. Fetch complete diffs with `ast_get_operation_preview` when needed.
322
325
  4. Call `ast_apply_operation` with both `operation_id` and `plan_hash`.
323
326
 
324
327
  MCP plans live in a bounded in-memory store and do not survive a server restart.
325
328
 
329
+ `ast_scaffold_class` accepts structured imports, heritage, decorators, constructor parameter properties, initialized properties, and one or more method signatures. It creates an in-memory preview for one absent project-relative `.ts`/`.tsx` target. Each generated method initially contains only `throw new Error("Not implemented: Class.method")`. Review the `/dev/null` creation diff and diagnostics, apply the scaffold, then replace each pending method body with `ast_replace_symbol_body`. Existing targets and symbolic/traversing parents fail closed.
330
+
326
331
  ### CLI process boundary
327
332
 
328
333
  A batch may contain at most one prepare operation. It must be the final step and cannot use foreach. `ast_apply_operation` and arbitrary preview calls are forbidden inside batch documents.
@@ -380,15 +385,18 @@ yarn benchmark /absolute/project --sample 20 --output benchmark/results/project.
380
385
  yarn benchmark:corpus benchmark/task-corpus.json --output benchmark/results/self-corpus.json
381
386
  yarn benchmark:batch --iterations 5 --output benchmark/results/self-batch.json
382
387
  yarn benchmark:formats
388
+ yarn benchmark:shapes
383
389
  ```
384
390
 
385
391
  The batch benchmark compares two separate client calls with one batch invocation in fresh Node processes, recording model round-trips, actual tool invocations, wall time, maximum RSS, and serialized character counts. Character counts are not model-specific token estimates.
386
392
 
387
393
  The format benchmark runs real tools against this repository plus deterministic reference/diagnostic fixtures. It checks JSON→TOON→value equality, UTF-8 bytes, `gpt-tokenizer` `o200k_base` estimates, encode/decode latency, the actual MCP envelope, tool metadata, and negative controls. Its checked result is `benchmark/results/self-formats.json`; local tokenizer estimates do not establish provider-side billing or cache savings. See `benchmark/README.md` for methodology and limitations.
388
394
 
395
+ The result-shaping benchmark compares the v0.4.0-compatible `full/100/context` profiles with the new public defaults across exact-name, exact-path, prefix, broad-substring, and multi-file-reference tasks. It fails on missing evidence, extra logical calls, fewer than 11 tools, or less than 35% aggregate TOON token reduction. Its checked result is `benchmark/results/self-result-shapes.json`.
396
+
389
397
  ## Scope
390
398
 
391
399
  - TypeScript and JavaScript projects understood by the TypeScript compiler.
392
- - Structural rename and callable-body replacement.
400
+ - Structural rename, callable-body replacement, and reviewed creation of one class scaffold.
393
401
  - Declarative DAG-like pipelines with prior-result references and bounded foreach.
394
- - No arbitrary signature migration, file creation/deletion plans, cross-language refactors, or general-purpose scripting language.
402
+ - No arbitrary signature migration, general file creation/deletion, cross-language refactors, or general-purpose scripting language.
@@ -101,18 +101,29 @@ The checked broad search-to-source batch result used five fresh-process samples:
101
101
 
102
102
  | Mode | Model round-trips | Tool invocations | Context chars | Median wall time | Median max RSS |
103
103
  | -------- | ----------------: | ---------------: | ------------: | ---------------: | -------------: |
104
- | Separate | 2 | 2 | 11,514 | 536.19 ms | 377.54 MB |
105
- | Batch | 1 | 2 | 552 | 535.24 ms | 378.35 MB |
104
+ | Separate | 2 | 2 | 7,818 | 587.03 ms | 380.68 MB |
105
+ | Batch | 1 | 2 | 417 | 607.95 ms | 379.15 MB |
106
106
 
107
- That run reduced model round-trips by 50% and serialized context by 95.21%. Local execution latency decreased by 0.18% and RSS increased by 0.21%, both small enough to treat as noise rather than a performance claim. The batch value is orchestration/context reduction; it does not make TypeScript analysis free.
107
+ That run reduced model round-trips by 50% and serialized context by 94.67%. Local execution latency increased by 3.56% and RSS decreased by 0.40%, both small enough to treat as noise rather than a performance claim. The batch value is orchestration/context reduction; it does not make TypeScript analysis free.
108
108
 
109
109
  The checked model-facing format corpus used the complete MCP envelope for TOON:
110
110
 
111
111
  | Payload | Records | JSON tokens | MCP TOON tokens | Reduction |
112
112
  | ------------------- | ------: | ----------: | --------------: | --------: |
113
- | Broad symbol search | 100 | 5,225 | 3,881 | 25.72% |
114
- | References | 71 | 3,086 | 2,150 | 30.33% |
113
+ | Broad symbol search | 100 | 5,382 | 4,061 | 24.54% |
114
+ | References | 71 | 3,094 | 2,159 | 30.22% |
115
115
  | Diagnostics | 30 | 1,309 | 1,034 | 21.01% |
116
- | Eligible aggregate | 201 | 9,620 | 7,065 | 26.56% |
116
+ | Eligible aggregate | 201 | 9,785 | 7,254 | 25.87% |
117
117
 
118
- All seven positive and negative payloads round-tripped exactly. File list, outline, source, and prepare envelopes ranged from 5.50% to 23.40% worse in estimated tokens, which is why they remain JSON-only. Current serialized tool metadata is 2,451 characters smaller than the v0.3.0 baseline despite the three new input fields, because multi-shape tools no longer advertise a single incompatible output schema.
118
+ All seven positive and negative payloads round-tripped exactly. File list, outline, source, and prepare envelopes were 5.13% to 13.56% worse in estimated tokens, which is why they remain JSON-only. The complete eleven-tool metadata is 3,372 serialized characters larger than the retained v0.3.0 baseline; this static protocol cost is reported separately from dynamic result savings.
119
+
120
+ The checked result-shaping corpus compares the v0.4.0-compatible `full/100/context` profiles with the new public defaults across exact-name, exact-path, prefix, broad-substring, and multi-file-reference workflows:
121
+
122
+ | Profile | Logical calls | MCP TOON tokens |
123
+ | --------- | ------------: | --------------: |
124
+ | Baseline | 6 | 3,910 |
125
+ | Candidate | 6 | 1,220 |
126
+
127
+ Every declared selector and reference coordinate remained present, and the aggregate reduction was 68.80%, above the checked 35% gate. `duration_ms` is omitted from both measured representations to make token counts deterministic; real JSON/TOON outputs are still decode-compared before measurement. These are local `o200k_base` estimates, not provider billing, cache, or latency evidence.
128
+
129
+ The complete v0.5.0 `tools/list` metadata is 22,473 serialized characters versus the retained v0.4.0 value of 16,650 (`+5,823`). Removing only `ast_scaffold_class` from the current list reduces it by 5,411 characters and 1,295 local `o200k_base` tokens. No v0.4.0 metadata token count was retained, so the report intentionally leaves the historical token delta unset.
@@ -1,7 +1,7 @@
1
1
  import { performance } from "node:perf_hooks";
2
2
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
3
  import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
4
- import { createServer } from "../server.js";
4
+ import { createServer, PACKAGE_VERSION } from "../server.js";
5
5
  import { MAX_BATCH_CONTEXT_BYTES, MAX_BATCH_INVOCATIONS, MAX_BATCH_OUTPUT_BYTES, MAX_FOREACH_ITEMS, isItemTemplate, isPrepareBatchTool, isReadBatchTool, isReferenceTemplate, parseBatchDocument as parseDocument, } from "./schema.js";
6
6
  export { parseDocument as parseBatchDocument };
7
7
  export class BatchExecutionError extends Error {
@@ -76,7 +76,7 @@ function injectProjectRoot(resolved, projectRoot, step) {
76
76
  }
77
77
  async function connectBatchClient() {
78
78
  const server = createServer();
79
- const client = new Client({ name: "ast-tool-batch", version: "0.3.0" });
79
+ const client = new Client({ name: "ast-tool-batch", version: PACKAGE_VERSION });
80
80
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
81
81
  await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
82
82
  return { client, server };
@@ -16,7 +16,11 @@ export const READ_BATCH_TOOLS = [
16
16
  "ast_find_references",
17
17
  "ast_get_diagnostics",
18
18
  ];
19
- export const PREPARE_BATCH_TOOLS = ["ast_rename_symbol", "ast_replace_symbol_body"];
19
+ export const PREPARE_BATCH_TOOLS = [
20
+ "ast_rename_symbol",
21
+ "ast_replace_symbol_body",
22
+ "ast_scaffold_class",
23
+ ];
20
24
  export const BATCH_TOOLS = [...READ_BATCH_TOOLS, ...PREPARE_BATCH_TOOLS];
21
25
  const BatchToolSchema = z.enum(BATCH_TOOLS);
22
26
  const BatchStepSchema = z
package/dist/server.js CHANGED
@@ -1,20 +1,26 @@
1
+ import { createRequire } from "node:module";
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { registerApplyOperation } from "./tools/apply_operation.js";
3
4
  import { registerFindReferences } from "./tools/find_references.js";
4
5
  import { registerGetDiagnostics } from "./tools/get_diagnostics.js";
5
6
  import { registerGetOperationPreview } from "./tools/get_operation_preview.js";
6
7
  import { registerGetOutline } from "./tools/get_outline.js";
8
+ import { registerGetProjectStatus } from "./tools/get_project_status.js";
7
9
  import { registerGetSymbolSource } from "./tools/get_symbol_source.js";
8
10
  import { registerListFiles } from "./tools/list_files.js";
9
11
  import { registerRenameSymbol } from "./tools/rename_symbol.js";
10
12
  import { registerReplaceSymbolBody } from "./tools/replace_symbol_body.js";
13
+ import { registerScaffoldClass } from "./tools/scaffold_class.js";
11
14
  import { registerSearchSymbols } from "./tools/search_symbols.js";
15
+ const packageMetadata = createRequire(import.meta.url)("../package.json");
16
+ export const PACKAGE_VERSION = packageMetadata.version;
12
17
  export function createServer() {
13
18
  const server = new McpServer({
14
19
  name: "ast-mcp-server",
15
- version: "0.3.0",
20
+ version: PACKAGE_VERSION,
16
21
  });
17
22
  registerListFiles(server);
23
+ registerGetProjectStatus(server);
18
24
  registerGetOutline(server);
19
25
  registerGetSymbolSource(server);
20
26
  registerSearchSymbols(server);
@@ -22,6 +28,7 @@ export function createServer() {
22
28
  registerGetDiagnostics(server);
23
29
  registerRenameSymbol(server);
24
30
  registerReplaceSymbolBody(server);
31
+ registerScaffoldClass(server);
25
32
  registerGetOperationPreview(server);
26
33
  registerApplyOperation(server);
27
34
  return server;
@@ -17,6 +17,7 @@ const EXPECTED_HERMES_TOOLS = [
17
17
  "ast_get_diagnostics",
18
18
  "ast_rename_symbol",
19
19
  "ast_replace_symbol_body",
20
+ "ast_scaffold_class",
20
21
  "ast_get_operation_preview",
21
22
  "ast_apply_operation",
22
23
  ];
@@ -42,7 +42,7 @@ const PersistedOperationSchema = z
42
42
  .object({
43
43
  operation_id: z.string().uuid(),
44
44
  plan_hash: HashSchema,
45
- kind: z.enum(["rename_symbol", "replace_symbol_body"]),
45
+ kind: z.enum(["rename_symbol", "replace_symbol_body", "scaffold_class"]),
46
46
  status: z.enum(["prepared", "applied"]),
47
47
  project_root: z.string().min(1),
48
48
  created_at: IsoDateSchema,
@@ -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),