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 +42 -0
- package/README.md +14 -6
- package/benchmark/README.md +18 -7
- package/dist/batch/runner.js +2 -2
- package/dist/batch/schema.js +5 -1
- package/dist/server.js +8 -1
- package/dist/services/agent-setup.js +1 -0
- package/dist/services/operation-plan-file.js +1 -1
- package/dist/services/operations.js +172 -17
- package/dist/services/pagination.js +16 -10
- package/dist/services/project-status.js +736 -0
- package/dist/services/project.js +87 -13
- package/dist/services/read-contracts.js +159 -0
- package/dist/services/scaffold.js +241 -0
- package/dist/services/symbols.js +16 -0
- package/dist/services/workspace.js +12 -6
- package/dist/tools/apply_operation.js +1 -1
- package/dist/tools/find_references.js +30 -7
- package/dist/tools/get_project_status.js +68 -0
- package/dist/tools/operation-schema.js +1 -1
- package/dist/tools/scaffold_class.js +145 -0
- package/dist/tools/search_symbols.js +77 -16
- package/package.json +2 -1
- package/skills/structural-code-editing/SKILL.md +15 -5
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
|
|
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/
|
|
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
|
|
320
|
+
Rename, body replacement, and class scaffold never write directly during preparation:
|
|
318
321
|
|
|
319
|
-
1. Call `ast_rename_symbol` or `
|
|
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
|
|
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
|
|
402
|
+
- No arbitrary signature migration, general file creation/deletion, cross-language refactors, or general-purpose scripting language.
|
package/benchmark/README.md
CHANGED
|
@@ -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 |
|
|
105
|
-
| Batch | 1 | 2 |
|
|
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
|
|
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,
|
|
114
|
-
| References | 71 | 3,
|
|
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,
|
|
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
|
|
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.
|
package/dist/batch/runner.js
CHANGED
|
@@ -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:
|
|
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 };
|
package/dist/batch/schema.js
CHANGED
|
@@ -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 = [
|
|
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:
|
|
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;
|
|
@@ -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
|
-
|
|
506
|
+
if (isCreatedFile(file)) {
|
|
507
|
+
assertSafeCreationState(operation.project_root, file.absolutePath, file.file);
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
|
|
511
|
+
}
|
|
384
512
|
}
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
if (currentWorkspace.digest === operation.post_workspace_hash) {
|
|
513
|
+
const workspaceHash = await currentWorkspaceHash(operation);
|
|
514
|
+
if (workspaceHash === operation.post_workspace_hash) {
|
|
388
515
|
for (const file of operation.files) {
|
|
516
|
+
assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
|
|
389
517
|
const currentBytes = await readFile(file.absolutePath);
|
|
390
518
|
if (hashBytes(currentBytes) !== file.updatedHash) {
|
|
391
519
|
throw new Error(`Postimage recovery conflict: ${file.file} does not match the reviewed postimage.`);
|
|
@@ -397,10 +525,12 @@ export async function applyOperation(operationId, planHash) {
|
|
|
397
525
|
await persistReceipt();
|
|
398
526
|
return appliedResult(operation, true);
|
|
399
527
|
}
|
|
400
|
-
if (
|
|
528
|
+
if (workspaceHash !== operation.workspace_hash) {
|
|
401
529
|
throw new Error("Conflict: the project source/config workspace changed after preparation. No files were written.");
|
|
402
530
|
}
|
|
403
531
|
for (const file of operation.files) {
|
|
532
|
+
if (isCreatedFile(file))
|
|
533
|
+
continue;
|
|
404
534
|
const currentBytes = await readFile(file.absolutePath);
|
|
405
535
|
if (hashBytes(currentBytes) !== file.originalHash) {
|
|
406
536
|
throw new Error(`Conflict: ${file.file} changed after the operation was prepared. No files were written.`);
|
|
@@ -413,6 +543,10 @@ export async function applyOperation(operationId, planHash) {
|
|
|
413
543
|
staged.set(file, await stageFile(file, operationId));
|
|
414
544
|
}
|
|
415
545
|
for (const file of operation.files) {
|
|
546
|
+
if (isCreatedFile(file)) {
|
|
547
|
+
assertSafeNewOperationFile(operation.project_root, file.absolutePath, file.file);
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
416
550
|
const currentBytes = await readFile(file.absolutePath);
|
|
417
551
|
if (hashBytes(currentBytes) !== file.originalHash) {
|
|
418
552
|
throw new Error(`Conflict: ${file.file} changed while the operation was being staged. No files were written.`);
|
|
@@ -420,9 +554,18 @@ export async function applyOperation(operationId, planHash) {
|
|
|
420
554
|
}
|
|
421
555
|
for (const [index, file] of operation.files.entries()) {
|
|
422
556
|
await operationTestHooks.beforeReplace?.(file.file, index);
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
557
|
+
if (isCreatedFile(file)) {
|
|
558
|
+
await link(staged.get(file), file.absolutePath);
|
|
559
|
+
applied.push(file);
|
|
560
|
+
await safeUnlink(staged.get(file));
|
|
561
|
+
staged.delete(file);
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
await rename(staged.get(file), file.absolutePath);
|
|
565
|
+
staged.delete(file);
|
|
566
|
+
applied.push(file);
|
|
567
|
+
}
|
|
568
|
+
await operationTestHooks.afterReplace?.(file.file, index);
|
|
426
569
|
const writtenBytes = await readFile(file.absolutePath);
|
|
427
570
|
if (hashBytes(writtenBytes) !== file.updatedHash) {
|
|
428
571
|
throw new Error(`Post-write verification failed for ${file.file}.`);
|
|
@@ -439,7 +582,13 @@ export async function applyOperation(operationId, planHash) {
|
|
|
439
582
|
rollbackErrors.push(`${file.file}: current bytes no longer match this operation's postimage`);
|
|
440
583
|
continue;
|
|
441
584
|
}
|
|
442
|
-
|
|
585
|
+
if (isCreatedFile(file)) {
|
|
586
|
+
assertSafeOperationFile(operation.project_root, file.absolutePath, file.file);
|
|
587
|
+
await safeUnlink(file.absolutePath);
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
await restoreFile(file, operationId);
|
|
591
|
+
}
|
|
443
592
|
}
|
|
444
593
|
catch (rollbackError) {
|
|
445
594
|
rollbackErrors.push(`${file.file}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
|
|
@@ -497,7 +646,6 @@ function containedOperationPath(projectRoot, file) {
|
|
|
497
646
|
if (relative !== file.split(path.posix.sep).join(path.sep)) {
|
|
498
647
|
throw new Error(`Persisted operation file path is not normalized: ${file}`);
|
|
499
648
|
}
|
|
500
|
-
assertSafeOperationFile(projectRoot, absolutePath, file);
|
|
501
649
|
return absolutePath;
|
|
502
650
|
}
|
|
503
651
|
export function exportOperationRecord(operationId) {
|
|
@@ -539,12 +687,19 @@ export function importOperationRecord(persisted, expectedPlanHash) {
|
|
|
539
687
|
const absolutePath = containedOperationPath(context.projectRoot, file.file);
|
|
540
688
|
const originalBytes = decodePersistedBytes(file.original_bytes_base64, `${file.file} original bytes`);
|
|
541
689
|
const updatedBytes = decodePersistedBytes(file.updated_bytes_base64, `${file.file} updated bytes`);
|
|
542
|
-
|
|
690
|
+
const createsFile = file.original_hash === ABSENT_FILE_HASH;
|
|
691
|
+
if (createsFile ? originalBytes.length !== 0 : hashBytes(originalBytes) !== file.original_hash) {
|
|
543
692
|
throw new Error(`Persisted original hash mismatch for ${file.file}.`);
|
|
544
693
|
}
|
|
545
694
|
if (hashBytes(updatedBytes) !== file.updated_hash) {
|
|
546
695
|
throw new Error(`Persisted updated hash mismatch for ${file.file}.`);
|
|
547
696
|
}
|
|
697
|
+
if (createsFile) {
|
|
698
|
+
assertSafeCreationState(context.projectRoot, absolutePath, file.file);
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
assertSafeOperationFile(context.projectRoot, absolutePath, file.file);
|
|
702
|
+
}
|
|
548
703
|
const originalText = decodeSource(originalBytes, absolutePath);
|
|
549
704
|
const updatedText = decodeSource(updatedBytes, absolutePath);
|
|
550
705
|
return {
|
|
@@ -556,7 +711,7 @@ export function importOperationRecord(persisted, expectedPlanHash) {
|
|
|
556
711
|
updatedText,
|
|
557
712
|
originalBytes,
|
|
558
713
|
updatedBytes,
|
|
559
|
-
diff: createTwoFilesPatch(file.file, file.file, originalText, updatedText, "before", "after", { context: 3 }),
|
|
714
|
+
diff: createTwoFilesPatch(createsFile ? "/dev/null" : file.file, file.file, originalText, updatedText, "before", "after", { context: 3 }),
|
|
560
715
|
mode: file.mode,
|
|
561
716
|
};
|
|
562
717
|
});
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export const DEFAULT_PAGE_LIMIT = 100;
|
|
3
3
|
export const MAX_PAGE_LIMIT = 500;
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
.min(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
export function createPaginationInputSchema(defaultLimit = DEFAULT_PAGE_LIMIT) {
|
|
5
|
+
if (!Number.isInteger(defaultLimit) || defaultLimit < 1 || defaultLimit > MAX_PAGE_LIMIT) {
|
|
6
|
+
throw new Error(`Default page limit must be an integer from 1 to ${MAX_PAGE_LIMIT}.`);
|
|
7
|
+
}
|
|
8
|
+
return {
|
|
9
|
+
offset: z.number().int().min(0).default(0).describe("Zero-based result offset."),
|
|
10
|
+
limit: z
|
|
11
|
+
.number()
|
|
12
|
+
.int()
|
|
13
|
+
.min(1)
|
|
14
|
+
.max(MAX_PAGE_LIMIT)
|
|
15
|
+
.default(defaultLimit)
|
|
16
|
+
.describe(`Maximum results to return (1-${MAX_PAGE_LIMIT}).`),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export const PaginationInputSchema = createPaginationInputSchema();
|
|
14
20
|
export const PaginationOutputSchema = {
|
|
15
21
|
offset: z.number().int().min(0),
|
|
16
22
|
limit: z.number().int().min(1),
|