ast-mcp-server 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +46 -14
  3. package/SECURITY.md +1 -1
  4. package/dist/batch/schema.js +2 -0
  5. package/dist/cli.js +67 -1
  6. package/dist/server.js +2 -0
  7. package/dist/services/agent-setup.js +11 -5
  8. package/dist/services/agent-targets.js +1 -0
  9. package/dist/services/call-spines.js +101 -0
  10. package/dist/services/cli-project-discovery.js +97 -0
  11. package/dist/services/context-builder.js +83 -24
  12. package/dist/services/doctor-continuation.js +37 -0
  13. package/dist/services/doctor-model.js +77 -0
  14. package/dist/services/doctor.js +111 -0
  15. package/dist/services/explore-presentation.js +160 -0
  16. package/dist/services/managed-bundle.js +145 -0
  17. package/dist/services/managed-file.js +37 -0
  18. package/dist/services/project.js +103 -24
  19. package/dist/services/public-errors.js +7 -1
  20. package/dist/services/relationships.js +104 -0
  21. package/dist/services/skill-installer.js +74 -56
  22. package/dist/services/test-candidates.js +15 -8
  23. package/dist/services/upgrade-command.js +77 -0
  24. package/dist/services/upgrade-reconcile.js +41 -0
  25. package/dist/services/upgrade-runtime.js +157 -0
  26. package/dist/services/upgrade.js +232 -0
  27. package/dist/tools/explore.js +73 -2
  28. package/dist/tools/find_test_candidates.js +124 -0
  29. package/dist/tools/get_impact.js +3 -30
  30. package/dist/tools/relationship-schema.js +31 -0
  31. package/docs/support.md +13 -13
  32. package/package.json +2 -2
  33. package/skills/structural-code-editing/SKILL.md +30 -158
  34. package/skills/structural-code-editing/references/operations.md +53 -0
  35. package/skills/structural-code-editing/releases.json +22 -3
@@ -2,7 +2,8 @@ import { readFile, realpath, stat } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
- import { applyManagedFilePlan, canonicalizeFuturePath, captureManagedFileSnapshot, createManagedFileApplyContext, hashBytes, verifyManagedFilePostimage, } from "./managed-file.js";
5
+ import { canonicalizeFuturePath, createManagedFileApplyContext, } from "./managed-file.js";
6
+ import { applyManagedBundle, ManagedBundleConflictError, planManagedBundle, } from "./managed-bundle.js";
6
7
  const SKILL_NAME = "structural-code-editing";
7
8
  const SHA256_PATTERN = /^[0-9a-f]{64}$/;
8
9
  const ReleaseRecordSchema = z
@@ -12,17 +13,24 @@ const ReleaseRecordSchema = z
12
13
  npm_versions: z.array(z.string().min(1)).min(1),
13
14
  })
14
15
  .strict();
15
- const SkillReleaseManifestSchema = z
16
+ const SkillFileSchema = z
17
+ .object({ path: z.string().min(1), sha256: z.string().regex(SHA256_PATTERN) })
18
+ .strict();
19
+ const BundleReleaseSchema = z
20
+ .object({
21
+ version: z.string().min(1),
22
+ files: z.array(SkillFileSchema).min(1),
23
+ })
24
+ .strict();
25
+ const BundleSkillReleaseManifestSchema = z
16
26
  .object({
17
- schema_version: z.literal(1),
27
+ schema_version: z.literal(2),
18
28
  algorithm: z.literal("sha256"),
19
- current: z
20
- .object({
21
- version: z.string().min(1),
22
- sha256: z.string().regex(SHA256_PATTERN),
23
- })
24
- .strict(),
25
- predecessors: z.array(ReleaseRecordSchema),
29
+ current: BundleReleaseSchema,
30
+ predecessors: z.array(z.union([
31
+ ReleaseRecordSchema,
32
+ BundleReleaseSchema.extend({ npm_versions: z.array(z.string().min(1)).min(1) }),
33
+ ])),
26
34
  })
27
35
  .strict();
28
36
  export class SkillConflictError extends Error {
@@ -101,77 +109,87 @@ async function readReleaseManifest(manifestPath) {
101
109
  catch (error) {
102
110
  throw new Error(`Skill release manifest is unreadable or invalid JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
103
111
  }
104
- const result = SkillReleaseManifestSchema.safeParse(parsed);
112
+ const result = BundleSkillReleaseManifestSchema.safeParse(parsed);
105
113
  if (!result.success) {
106
114
  throw new Error(`Skill release manifest is invalid: ${result.error.issues[0]?.message ?? "unknown error"}`);
107
115
  }
108
- const digests = [
109
- result.data.current.sha256,
110
- ...result.data.predecessors.map((item) => item.sha256),
111
- ];
112
- if (new Set(digests).size !== digests.length) {
113
- throw new Error("Skill release manifest contains a duplicate digest.");
116
+ const manifest = {
117
+ current: result.data.current,
118
+ predecessors: result.data.predecessors.map((item) => "files" in item
119
+ ? item
120
+ : {
121
+ version: item.version,
122
+ npm_versions: item.npm_versions,
123
+ files: [{ path: "SKILL.md", sha256: item.sha256 }],
124
+ }),
125
+ };
126
+ if (!manifest.current.files.some((file) => file.path === "SKILL.md")) {
127
+ throw new Error("Skill release manifest current bundle must include SKILL.md.");
114
128
  }
115
129
  const predecessorVersions = result.data.predecessors.flatMap((item) => item.npm_versions);
116
130
  if (new Set(predecessorVersions).size !== predecessorVersions.length) {
117
131
  throw new Error("Skill release manifest contains a duplicate npm version provenance entry.");
118
132
  }
119
- return result.data;
133
+ return manifest;
120
134
  }
121
135
  export async function planBundledSkillInstallation(options) {
122
136
  const sourcePath = path.resolve(options.sourceSkillPath);
123
- const source = await readFile(sourcePath);
124
- const sourceDigest = hashBytes(source);
125
- const manifestPath = path.resolve(options.releaseManifestPath ?? path.join(path.dirname(sourcePath), "releases.json"));
137
+ const sourceRoot = path.dirname(sourcePath);
138
+ const canonicalSourceRoot = await realpath(sourceRoot);
139
+ const manifestPath = path.resolve(options.releaseManifestPath ?? path.join(sourceRoot, "releases.json"));
126
140
  const manifest = await readReleaseManifest(manifestPath);
127
- if (sourceDigest !== manifest.current.sha256) {
128
- throw new Error(`Skill release manifest current digest does not match source skill bytes: ${sourcePath}`);
141
+ const sources = [];
142
+ for (const file of manifest.current.files) {
143
+ const assetPath = path.resolve(sourceRoot, file.path);
144
+ const assetRealPath = await realpath(assetPath).catch(() => undefined);
145
+ if (assetRealPath === undefined ||
146
+ (assetRealPath !== canonicalSourceRoot &&
147
+ !assetRealPath.startsWith(`${canonicalSourceRoot}${path.sep}`)) ||
148
+ !(await stat(assetPath)).isFile()) {
149
+ throw new Error(`Skill release manifest asset is missing or escapes the bundle: ${file.path}`);
150
+ }
151
+ sources.push({ ...file, content: await readFile(assetPath) });
129
152
  }
130
- const admittedPredecessors = new Set(manifest.predecessors.map((item) => item.sha256));
131
153
  const logical = await destinations(options);
132
154
  const filesByPath = new Map();
155
+ const statusesByRoot = new Map();
133
156
  for (const item of logical) {
134
- if (filesByPath.has(item.path))
157
+ const destinationRoot = path.dirname(item.path);
158
+ if (statusesByRoot.has(destinationRoot))
135
159
  continue;
136
- const snapshot = await captureManagedFileSnapshot(item.path);
137
- let status;
138
- if (!snapshot.exists)
139
- status = "installed";
140
- else if (snapshot.sha256 === sourceDigest)
141
- status = "unchanged";
142
- else if (admittedPredecessors.has(snapshot.sha256) || options.force === true)
143
- status = "updated";
144
- else
145
- throw new SkillConflictError(item.path);
146
- filesByPath.set(item.path, {
147
- path: item.path,
148
- snapshot,
149
- postimage: source,
150
- postimageSha256: sourceDigest,
151
- status,
152
- });
160
+ let plan;
161
+ try {
162
+ plan = await planManagedBundle({
163
+ destinationRoot,
164
+ current: { files: sources },
165
+ predecessors: manifest.predecessors,
166
+ force: options.force,
167
+ });
168
+ }
169
+ catch (error) {
170
+ if (error instanceof ManagedBundleConflictError)
171
+ throw new SkillConflictError(error.destination);
172
+ if (error instanceof Error && error.message.startsWith("Managed bundle")) {
173
+ throw new Error(error.message.replace("Managed bundle", "Skill release manifest"), {
174
+ cause: error,
175
+ });
176
+ }
177
+ throw error;
178
+ }
179
+ statusesByRoot.set(destinationRoot, plan.status);
180
+ for (const file of plan.files)
181
+ filesByPath.set(file.path, file);
153
182
  }
154
183
  return {
155
184
  installations: logical.map((item) => ({
156
185
  ...item,
157
- status: filesByPath.get(item.path).status,
186
+ status: statusesByRoot.get(path.dirname(item.path)),
158
187
  })),
159
188
  files: [...filesByPath.values()],
160
189
  };
161
190
  }
162
- export async function applySkillInstallationPlan(plan, onApplied, context = createManagedFileApplyContext()) {
163
- const authenticated = [];
164
- for (const file of plan.files) {
165
- for (const current of authenticated) {
166
- await verifyManagedFilePostimage(current, context);
167
- }
168
- await applyManagedFilePlan(file, context);
169
- authenticated.push(file);
170
- await onApplied?.(file);
171
- }
172
- for (const current of authenticated) {
173
- await verifyManagedFilePostimage(current, context);
174
- }
191
+ export async function applySkillInstallationPlan(plan, onApplied, context = createManagedFileApplyContext(), hooks = {}) {
192
+ await applyManagedBundle(plan, onApplied, context, hooks);
175
193
  }
176
194
  export async function installBundledSkill(options) {
177
195
  const plan = await planBundledSkillInstallation(options);
@@ -4,21 +4,22 @@ export const DEFAULT_TEST_DIRECTORIES = Object.freeze(["test", "tests", "__tests
4
4
  export const TEST_CANDIDATE_REASONS = Object.freeze([
5
5
  "direct_compiler_reference",
6
6
  "transitive_compiler_reference",
7
+ "convention_match",
7
8
  ]);
8
9
  export const TEST_CANDIDATE_CONFIDENCES = Object.freeze(["exact", "high"]);
9
- const MAX_CONVENTION_ITEMS = 32;
10
- const MAX_CONVENTION_LENGTH = 256;
10
+ export const MAX_TEST_CANDIDATE_CONVENTION_ITEMS = 32;
11
+ export const MAX_TEST_CANDIDATE_CONVENTION_LENGTH = 256;
11
12
  function normalizeConventionValues(values, defaults, label) {
12
13
  const selected = values ?? defaults;
13
- if (!Array.isArray(selected) || selected.length > MAX_CONVENTION_ITEMS) {
14
- throw new Error(`${label} must contain at most ${MAX_CONVENTION_ITEMS} entries.`);
14
+ if (!Array.isArray(selected) || selected.length > MAX_TEST_CANDIDATE_CONVENTION_ITEMS) {
15
+ throw new Error(`${label} must contain at most ${MAX_TEST_CANDIDATE_CONVENTION_ITEMS} entries.`);
15
16
  }
16
17
  const normalized = selected.map((value) => {
17
18
  if (typeof value !== "string" || value.trim().length === 0) {
18
19
  throw new Error(`${label} entries must be non-empty strings.`);
19
20
  }
20
- if (value.length > MAX_CONVENTION_LENGTH) {
21
- throw new Error(`${label} entries must not exceed ${MAX_CONVENTION_LENGTH} characters.`);
21
+ if (value.length > MAX_TEST_CANDIDATE_CONVENTION_LENGTH) {
22
+ throw new Error(`${label} entries must not exceed ${MAX_TEST_CANDIDATE_CONVENTION_LENGTH} characters.`);
22
23
  }
23
24
  return value.trim().replaceAll("\\", "/");
24
25
  });
@@ -112,7 +113,13 @@ function edgeOrder(left, right) {
112
113
  left.target.file.localeCompare(right.target.file) ||
113
114
  left.target.selector.localeCompare(right.target.selector));
114
115
  }
115
- function candidateReason(depth) {
116
+ function candidateReason(file, depth) {
117
+ if (!isTestFile(file, {
118
+ patterns: DEFAULT_TEST_FILE_PATTERNS,
119
+ directories: DEFAULT_TEST_DIRECTORIES,
120
+ })) {
121
+ return "convention_match";
122
+ }
116
123
  return depth === 1 ? "direct_compiler_reference" : "transitive_compiler_reference";
117
124
  }
118
125
  function candidateConfidence(depth) {
@@ -194,7 +201,7 @@ export function findTestCandidates(impact, conventions = {}) {
194
201
  const depth = candidate.depth;
195
202
  return {
196
203
  file,
197
- reason: candidateReason(depth),
204
+ reason: candidateReason(file, depth),
198
205
  confidence: candidateConfidence(depth),
199
206
  evidence: {
200
207
  depth,
@@ -0,0 +1,77 @@
1
+ import { inspectUpgrade, performUpgrade, UpgradeError } from "./upgrade.js";
2
+ import { ReconciliationError } from "./upgrade-reconcile.js";
3
+ import { UpgradeCleanupError } from "./upgrade-runtime.js";
4
+ export class UpgradeCommandError extends Error {
5
+ code;
6
+ constructor(code = "UPGRADE_USAGE") {
7
+ super("Usage: ast-tool upgrade [--check]");
8
+ this.code = code;
9
+ this.name = "UpgradeCommandError";
10
+ }
11
+ }
12
+ export async function runUpgradeCommand(args, runtime, reconcile) {
13
+ if (args.length > 1 || (args.length === 1 && args[0] !== "--check")) {
14
+ throw new UpgradeCommandError();
15
+ }
16
+ if (args[0] === "--check")
17
+ return inspectUpgrade(runtime);
18
+ const updated = await performUpgrade(runtime);
19
+ const { new_cli: newCli, ...result } = updated;
20
+ if (updated.status === "current")
21
+ return { ...result, reconciliation: "not_required" };
22
+ try {
23
+ await reconcile(newCli);
24
+ return { ...result, reconciliation: "completed" };
25
+ }
26
+ catch (error) {
27
+ const skillConflict = error instanceof ReconciliationError && error.code === "AGENT_SKILL_CONFLICT";
28
+ return {
29
+ ...result,
30
+ status: "partial",
31
+ package_status: "updated",
32
+ reconciliation: "required",
33
+ continuation: skillConflict
34
+ ? "ast-tool setup --agents all --yes --force-skill"
35
+ : "ast-tool setup --agents all --yes",
36
+ restart_required: true,
37
+ };
38
+ }
39
+ }
40
+ function primaryCode(error) {
41
+ return error instanceof UpgradeError || error instanceof UpgradeCommandError
42
+ ? error.code
43
+ : "UPGRADE_ERROR";
44
+ }
45
+ export async function runUpgradeLease(args, lease, reconcile) {
46
+ let result;
47
+ let primary;
48
+ try {
49
+ result = await runUpgradeCommand(args, lease.runtime, reconcile);
50
+ }
51
+ catch (error) {
52
+ primary = error;
53
+ }
54
+ try {
55
+ await lease.dispose();
56
+ }
57
+ catch {
58
+ if (primary)
59
+ throw new UpgradeCleanupError(primaryCode(primary));
60
+ const record = result;
61
+ if (record.status === "updated" || record.package_status === "updated") {
62
+ return {
63
+ ...record,
64
+ status: "partial",
65
+ package_status: "updated",
66
+ cleanup: "required",
67
+ cleanup_code: "UPGRADE_CLEANUP_FAILED",
68
+ continuation: record.continuation ?? "ast-tool upgrade --check",
69
+ restart_required: true,
70
+ };
71
+ }
72
+ throw new UpgradeCleanupError();
73
+ }
74
+ if (primary)
75
+ throw primary;
76
+ return result;
77
+ }
@@ -0,0 +1,41 @@
1
+ import { execFile } from "node:child_process";
2
+ import process from "node:process";
3
+ import { promisify } from "node:util";
4
+ const executeFile = promisify(execFile);
5
+ export class ReconciliationError extends Error {
6
+ code;
7
+ constructor(code) {
8
+ super("The updated package requires managed setup reconciliation.");
9
+ this.code = code;
10
+ this.name = "ReconciliationError";
11
+ }
12
+ }
13
+ export async function reconcileUpgrade(executable, options = {}) {
14
+ const execute = options.execute ?? executeFile;
15
+ try {
16
+ await execute(options.nodeExecutable ?? process.execPath, [executable, "setup", "--agents", "all", "--yes"], {
17
+ encoding: "utf8",
18
+ windowsHide: true,
19
+ env: options.environment ?? process.env,
20
+ });
21
+ }
22
+ catch (error) {
23
+ const stderr = typeof error === "object" && error !== null && "stderr" in error
24
+ ? String(error.stderr ?? "")
25
+ : "";
26
+ try {
27
+ const failure = JSON.parse(stderr);
28
+ if (typeof failure === "object" &&
29
+ failure !== null &&
30
+ "code" in failure &&
31
+ failure.code === "AGENT_SKILL_CONFLICT") {
32
+ throw new ReconciliationError("AGENT_SKILL_CONFLICT");
33
+ }
34
+ }
35
+ catch (parsed) {
36
+ if (parsed instanceof ReconciliationError)
37
+ throw parsed;
38
+ }
39
+ throw new ReconciliationError("UPGRADE_RECONCILIATION_FAILED");
40
+ }
41
+ }
@@ -0,0 +1,157 @@
1
+ import { execFile } from "node:child_process";
2
+ import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile, } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import { promisify } from "node:util";
7
+ export class UpgradeCleanupError extends Error {
8
+ primaryCode;
9
+ code = "UPGRADE_CLEANUP_FAILED";
10
+ constructor(primaryCode) {
11
+ super("Upgrade temporary files could not be removed safely.");
12
+ this.primaryCode = primaryCode;
13
+ this.name = "UpgradeCleanupError";
14
+ }
15
+ }
16
+ const executeFile = promisify(execFile);
17
+ async function npmCliFor(node, platform) {
18
+ const p = platform === "win32" ? path.win32 : path.posix;
19
+ const root = platform === "win32" ? p.dirname(node) : p.resolve(p.dirname(node), "..");
20
+ const candidates = platform === "win32"
21
+ ? [p.join(root, "node_modules", "npm", "bin", "npm-cli.js")]
22
+ : [
23
+ p.join(p.dirname(node), "npm"),
24
+ p.join(root, "lib", "node_modules", "npm", "bin", "npm-cli.js"),
25
+ ];
26
+ for (const candidate of candidates) {
27
+ try {
28
+ const resolved = await realpath(candidate);
29
+ if ((await lstat(resolved)).isFile())
30
+ return resolved;
31
+ }
32
+ catch {
33
+ // Try the next layout owned by this Node installation.
34
+ }
35
+ }
36
+ return;
37
+ }
38
+ async function copyUserConfig(environment, destination) {
39
+ const source = environment.npm_config_userconfig ??
40
+ environment.NPM_CONFIG_USERCONFIG ??
41
+ path.join(environment.HOME ?? os.homedir(), ".npmrc");
42
+ try {
43
+ await copyFile(source, destination);
44
+ await chmod(destination, 0o600);
45
+ }
46
+ catch {
47
+ await writeFile(destination, "", { encoding: "utf8", mode: 0o600 });
48
+ }
49
+ }
50
+ async function restoreOwnerAccess(value) {
51
+ const info = await lstat(value);
52
+ if (info.isSymbolicLink())
53
+ return;
54
+ await chmod(value, info.isDirectory() ? 0o700 : 0o600);
55
+ if (info.isDirectory())
56
+ for (const child of await readdir(value))
57
+ await restoreOwnerAccess(path.join(value, child));
58
+ }
59
+ async function cleanupBoundary(boundary, removeTree = (value) => rm(value, { recursive: true, force: true })) {
60
+ try {
61
+ await restoreOwnerAccess(boundary);
62
+ await removeTree(boundary);
63
+ }
64
+ catch {
65
+ throw new UpgradeCleanupError();
66
+ }
67
+ try {
68
+ await lstat(boundary);
69
+ }
70
+ catch (error) {
71
+ if (error.code === "ENOENT")
72
+ return;
73
+ throw new UpgradeCleanupError();
74
+ }
75
+ throw new UpgradeCleanupError();
76
+ }
77
+ export async function createUpgradeRuntime(executable, options = {}) {
78
+ const environment = options.environment ?? process.env;
79
+ const platform = options.platform ?? process.platform;
80
+ const nodeExecutable = options.nodeExecutable ?? process.execPath;
81
+ const execute = options.execute ?? executeFile;
82
+ const boundary = await mkdtemp(path.join(options.temporaryParent ?? os.tmpdir(), "ast-tool-upgrade-"));
83
+ try {
84
+ const cache = path.join(boundary, "cache");
85
+ const logs = path.join(boundary, "logs");
86
+ const userConfig = path.join(boundary, "npmrc");
87
+ await Promise.all([mkdir(cache), mkdir(logs), copyUserConfig(environment, userConfig)]);
88
+ const npmEnvironment = {
89
+ ...environment,
90
+ npm_config_cache: cache,
91
+ npm_config_logs_dir: logs,
92
+ npm_config_userconfig: userConfig,
93
+ npm_config_update_notifier: "false",
94
+ npm_config_audit: "false",
95
+ npm_config_fund: "false",
96
+ };
97
+ const run = async (file, args) => (await execute(file, args, { encoding: "utf8", windowsHide: true, env: npmEnvironment }))
98
+ .stdout;
99
+ const cliExecutable = await realpath(executable);
100
+ const packageRoot = path.resolve(path.dirname(cliExecutable), "..");
101
+ const npmCliExecutable = await npmCliFor(nodeExecutable, platform);
102
+ const p = platform === "win32" ? path.win32 : path.posix;
103
+ const voltaExecutable = environment.VOLTA_HOME
104
+ ? p.resolve(environment.VOLTA_HOME, "bin", platform === "win32" ? "volta.exe" : "volta")
105
+ : undefined;
106
+ const runtime = {
107
+ packageRoot,
108
+ cliExecutable,
109
+ nodeExecutable,
110
+ ...(npmCliExecutable ? { npmCliExecutable } : {}),
111
+ ...(voltaExecutable ? { voltaExecutable } : {}),
112
+ platform,
113
+ realpath,
114
+ directDirectory: async (value) => {
115
+ const info = await lstat(value).catch(() => undefined);
116
+ return info?.isDirectory() === true && info.isSymbolicLink() === false;
117
+ },
118
+ readPackage: async (root) => JSON.parse(await readFile(path.join(root, "package.json"), "utf8")),
119
+ latestVersion: async (provenance) => {
120
+ const stdout = provenance === "volta"
121
+ ? await run(voltaExecutable, [
122
+ "run",
123
+ "npm",
124
+ "view",
125
+ "ast-mcp-server@latest",
126
+ "version",
127
+ "--json",
128
+ ])
129
+ : await run(nodeExecutable, [
130
+ npmCliExecutable,
131
+ "view",
132
+ "ast-mcp-server@latest",
133
+ "version",
134
+ "--json",
135
+ ]);
136
+ const value = JSON.parse(stdout);
137
+ if (typeof value !== "string")
138
+ throw new Error("invalid registry response");
139
+ return value;
140
+ },
141
+ run,
142
+ };
143
+ return {
144
+ runtime,
145
+ dispose: () => cleanupBoundary(boundary, options.removeTree),
146
+ };
147
+ }
148
+ catch (error) {
149
+ try {
150
+ await cleanupBoundary(boundary, options.removeTree);
151
+ }
152
+ catch {
153
+ throw new UpgradeCleanupError("UPGRADE_RUNTIME_CREATION_FAILED");
154
+ }
155
+ throw error;
156
+ }
157
+ }