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
package/CHANGELOG.md CHANGED
@@ -6,6 +6,39 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.11.0] — local release candidate, pending release (2026-08-23)
10
+
11
+ ### Added
12
+
13
+ - Managed setup and `install-skill` now install manifested multi-file skill bundles atomically, verify every bundled digest, and admit exact npm-proven predecessor bytes without treating version text as ownership.
14
+ - `ast-tool upgrade --check` inspects supported direct npm and Volta installations without mutation; `ast-tool upgrade` performs the safe direct update with isolated package-manager state and then reconciles managed assets.
15
+ - CLI batch and doctor project selection now discovers the nearest `tsconfig.json` or `jsconfig.json` up to the Git or filesystem boundary, while rejecting same-level ambiguity and symlinked config identities.
16
+ - `ast-tool doctor` reports bounded, privacy-safe compiler, session, index, and queue state with executable continuations; unavailable registered-session evidence is reported as `not_run` instead of being fabricated.
17
+
18
+ ### Fixed
19
+
20
+ - Project watchers now ignore events owned by exact symbol-index cache artifacts while preserving legitimate source invalidations, including POSIX filenames containing a literal backslash.
21
+
22
+ ### Release status
23
+
24
+ - This local candidate is pending its exact-tree release matrix, CI, and the repository's separately authorized delivery sequence. It has not been published to npm, assigned to `next` or `latest`, tagged in Git, or released on GitHub; registry and GitHub readbacks remain authoritative.
25
+
26
+ ## [0.10.0] — published to `latest` and `next`, registry verified (2026-08-22)
27
+
28
+ ### Added
29
+
30
+ - Added deterministic atomic `ast_explore` clusters, categorized omission metadata, opt-in compiler-authoritative call spines, and registered-handler parity across MCP and the batch CLI.
31
+ - Added read-only `ast_find_test_candidates` across MCP and `ast-tool run`, with incoming compiler authority, fail-closed incomplete evidence, whole-candidate pagination, and lossless final JSON/TOON parity.
32
+
33
+ ### Fixed
34
+
35
+ - Oversized `ast_explore` call-spine aggregates are omitted atomically instead of failing valid requests.
36
+ - SQLite capability fallback now preserves fresh compiler-backed reads while retaining failed memory-backed index observability. Stable capability and invalid-root fallbacks retry only after restart or policy change; transient storage failures continue to recover automatically.
37
+
38
+ ### Release status
39
+
40
+ - npm published these immutable bytes under `next` with provenance and the expected Git commit. Public-registry consumer and release-preflight verification passed, `latest` and `next` both resolve to `0.10.0`, and the annotated `v0.10.0` tag plus GitHub Release resolve to the same release commit.
41
+
9
42
  ## [0.9.2] — local release candidate, pending release (2026-08-16)
10
43
 
11
44
  ### Fixed
package/README.md CHANGED
@@ -25,16 +25,17 @@ Coding agents often fall back to two generic operations: read files as plain tex
25
25
 
26
26
  This MCP server gives the agent structural code tools in addition to generic file reads and writes. Under the hood, `ts-morph` uses the TypeScript compiler project model, so the server can reason about declarations and references as code rather than undifferentiated text.
27
27
 
28
- | Need | Structural operation | Returned scope |
29
- | ------------------------------ | ------------------------- | ---------------------------------------------------------- |
30
- | Read a bounded file | `ast_get_file` | Exact selected source lines, hashes, and bounded freshness |
31
- | Explore bounded context | `ast_explore` | Ranked selectors plus optional source and references |
32
- | Understand a file | `ast_get_outline` | Signatures without implementation bodies |
33
- | Inspect one declaration | `ast_get_symbol_source` | Exact source for one function, method, class, or type |
34
- | Find usages across the project | `ast_find_references` | Compiler-resolved reference locations |
35
- | Understand symbol impact | `ast_get_impact` | Bounded direct/transitive compiler-backed relationships |
36
- | Rename a symbol everywhere | `ast_rename_symbol` | A reviewed project-wide rename plan |
37
- | Change one implementation | `ast_replace_symbol_body` | A body-only plan that preserves the declaration |
28
+ | Need | Structural operation | Returned scope |
29
+ | ------------------------------ | -------------------------- | ----------------------------------------------------------- |
30
+ | Read a bounded file | `ast_get_file` | Exact selected source lines, hashes, and bounded freshness |
31
+ | Explore bounded context | `ast_explore` | Ranked selectors plus optional source and references |
32
+ | Understand a file | `ast_get_outline` | Signatures without implementation bodies |
33
+ | Inspect one declaration | `ast_get_symbol_source` | Exact source for one function, method, class, or type |
34
+ | Find usages across the project | `ast_find_references` | Compiler-resolved reference locations |
35
+ | Understand symbol impact | `ast_get_impact` | Bounded direct/transitive compiler-backed relationships |
36
+ | Select affected tests | `ast_find_test_candidates` | Whole candidate proofs from incoming compiler relationships |
37
+ | Rename a symbol everywhere | `ast_rename_symbol` | A reviewed project-wide rename plan |
38
+ | Change one implementation | `ast_replace_symbol_body` | A body-only plan that preserves the declaration |
38
39
 
39
40
  Reads can start with a bounded file slice, a compact outline, or exact source only for the declaration that needs inspection. Mutations are prepared in memory first, compared against baseline diagnostics, and returned as immutable, hash-bound plans. Nothing is written until the caller reviews and explicitly applies the plan.
40
41
 
@@ -46,6 +47,7 @@ Reads can start with a bounded file slice, a compact outline, or exact source on
46
47
  - Use `ast_get_outline` for a compact body-free view of a known file without source lines.
47
48
  - Use `ast_get_symbol_source` when one declaration or implementation is the required evidence.
48
49
  - Use `ast_get_impact` when the exact symbol is known and bounded direct/transitive compiler relationships are needed; it is read-only evidence, not a mutation plan.
50
+ - Use `ast_find_test_candidates` when an exact symbol should map to conservative test candidates. It forces incoming compiler traversal, returns complete relationship paths, and never executes tests.
49
51
 
50
52
  `snapshot_state: "fresh"` means that the returned file bytes match the synchronized compiler snapshot. The separate `freshness` object describes the project/session state and preserves causes such as source changes or watcher failure. Neither field means that the project has zero TypeScript diagnostics; use `ast_get_diagnostics` for compiler errors and warnings.
51
53
 
@@ -62,7 +64,7 @@ The server exposes evidence labels instead of collapsing every result into an un
62
64
 
63
65
  Freshness is orthogonal to TypeScript diagnostics. `fresh` means the evidence matches the synchronized snapshot; `pending`, `rebuilding`, `stale`, or `degraded` means the response must not be presented as current compiler evidence. Read tools expose the state, causes (`source_change`, `config_change`, `index_failure`, `watcher_failure`, or `compiler_rebuild`), and bounded `checked_at` timestamp. `ast_get_impact` refuses non-fresh compiler relationships. `ast_explore` returns the state together with `completeness`, `unresolved`, `budget`, and `truncation` metadata rather than silently dropping evidence.
64
66
 
65
- All reads are budgeted. Callers control pagination and, where applicable, `max_bytes`, `reference_limit`, `max_depth`, `max_nodes`, and `max_edges`; responses report the effective limits and whether a record, byte, depth, edge, invocation, or serialization limit truncated the result. A truncated or unresolved result is incomplete evidence, not an empty negative result. The internal test-candidate resolver follows the same rule: it accepts only fresh, exact compiler-backed impact, emits direct/transitive evidence and bounded relationship IDs, and never executes tests or guesses from filenames alone.
67
+ All reads are budgeted. Callers control pagination and, where applicable, `max_bytes`, `reference_limit`, `max_depth`, `max_nodes`, and `max_edges`; responses report the effective limits and whether a record, byte, depth, edge, invocation, or serialization limit truncated the result. A truncated or unresolved result is incomplete evidence, not an empty negative result. `ast_find_test_candidates` follows the same rule: it accepts only fresh, exact compiler-backed impact, emits direct/transitive evidence and bounded relationship IDs, and never executes tests or guesses from filenames alone. Only a complete authoritative traversal may return `candidates: []` with `proven_empty: true`.
66
68
 
67
69
  ## Why this helps
68
70
 
@@ -82,11 +84,11 @@ The included batch benchmark records a 50% reduction in model round-trips and a
82
84
 
83
85
  ## Supported environment and trust boundary
84
86
 
85
- The local `0.9.2` release candidate requires Node.js `>=22.13.0`; its evidence matrix targets exact Node.js 22.13.0 and the current Node.js 24 line. Published v0.8.1 retains its immutable historical Node.js 22.5.0/24 evidence. Managed setup-file publication additionally requires GNU coreutils 9.7 `mv` supporting `--update=none-fail`, `--exchange`, `--no-copy`, and `--no-target-directory`, GNU coreutils `ln -L -T`, procfs descriptor paths at `/proc/self/fd`, and `O_DIRECTORY`/`O_NOFOLLOW`. Other Linux architectures or systems without those filesystem primitives, macOS, and Windows remain unverified.
87
+ The local `0.11.0` release candidate requires Node.js `>=22.13.0`; its evidence matrix targets exact Node.js 22.13.0 and the current Node.js 24 line. Published v0.10.0 retains its immutable historical Node.js 22.13.0/24 evidence. Managed setup-file publication additionally requires GNU coreutils 9.7 `mv` supporting `--update=none-fail`, `--exchange`, `--no-copy`, and `--no-target-directory`, GNU coreutils `ln -L -T`, procfs descriptor paths at `/proc/self/fd`, and `O_DIRECTORY`/`O_NOFOLLOW`. Other Linux architectures or systems without those filesystem primitives, macOS, and Windows remain unverified.
86
88
 
87
89
  This is a local stdio server. It runs with the invoking user's filesystem permissions, and clients may request any `project_root` that user can access. It does not provide HTTP authentication, sandboxing, tenant isolation, or a remote-service security boundary. Remote, untrusted, and multi-tenant operation is unsupported.
88
90
 
89
- In the local `0.9.2` release candidate, an absent `AST_SYMBOL_INDEX_PERSISTENCE` or explicit `enabled` selects the private SQLite symbol-index cache. `disabled` is the immediate memory-only rollback. `canary` requires an explicit absolute normalized `AST_SYMBOL_INDEX_CACHE_ROOT`. Invalid policy or storage fails closed to compiler-authoritative memory reads with bounded path-free status.
91
+ In published v0.10.0 and the local `0.11.0` release candidate, an absent `AST_SYMBOL_INDEX_PERSISTENCE` or explicit `enabled` selects the private SQLite symbol-index cache. `disabled` is the immediate memory-only rollback. `canary` requires an explicit absolute normalized `AST_SYMBOL_INDEX_CACHE_ROOT`. Invalid policy or storage fails closed to compiler-authoritative memory reads with bounded path-free status.
90
92
 
91
93
  The default cache root is selected from `AST_SYMBOL_INDEX_CACHE_ROOT`, then `XDG_CACHE_HOME`, then `HOME`. Inspect or clear only derived cache artifacts through the bounded CLI:
92
94
 
@@ -129,6 +131,29 @@ The package exposes two executables when installed:
129
131
  - `ast-mcp-server`: MCP stdio server.
130
132
  - `ast-tool`: batch, skill-installation, and agent-setup CLI.
131
133
 
134
+ ### Diagnose the active installation
135
+
136
+ ```bash
137
+ ast-tool doctor [--project <config-or-dir>]
138
+ ```
139
+
140
+ Doctor reuses CLI project discovery and existing runtime authorities without changing project,
141
+ agent, package, or skill state. It prints bounded JSON; exit `0` is healthy, `1` degraded, and `2`
142
+ failed. A healthy compiler remains usable when only the derived SQLite index is degraded.
143
+ Standalone diagnosis marks registered-session-only index and queue evidence as `not_run` rather
144
+ than fabricating healthy state.
145
+
146
+ ### Upgrade an installed package
147
+
148
+ Inspect the active global installation without writing, or update it immediately:
149
+
150
+ ```bash
151
+ ast-tool upgrade --check
152
+ ast-tool upgrade
153
+ ```
154
+
155
+ Upgrade supports only a direct package proven to belong to the active npm global prefix or to Volta. It rejects linked/source and ambiguous installations, never uses `sudo`, guesses from `PATH`, or accepts `--yes`. npm runs its proven `npm-cli.js` through the active Node with lifecycle scripts disabled and a disposable cache/config copy; cleanup is verified and blocks success if absence cannot be proven. Volta uses its native integration. The updated CLI then reconciles managed setup through that Node. Customized skill bytes remain untouched and produce `ast-tool setup --agents all --yes --force-skill`. Restart MCP clients after any package update; an already-running server does not change in place.
156
+
132
157
  ## Guided agent setup
133
158
 
134
159
  The installed package opens the interactive wizard with:
@@ -298,6 +323,7 @@ Project-scoped tools accept `project_root`, either the project directory or an e
298
323
  | `ast_search_symbols` | Paginated structural symbol discovery | No |
299
324
  | `ast_find_references` | Compiler-resolved references with bounded context | No |
300
325
  | `ast_get_impact` | Bounded incoming/outgoing compiler-backed impact evidence | No |
326
+ | `ast_find_test_candidates` | Paginated affected tests with atomic compiler relationship proofs | No |
301
327
  | `ast_get_diagnostics` | Project- or file-scoped TypeScript diagnostics | No |
302
328
  | `ast_rename_symbol` | Prepare a project-wide rename | No |
303
329
  | `ast_replace_symbol_body` | Prepare a body-only replacement while preserving the signature | No |
@@ -307,7 +333,9 @@ Project-scoped tools accept `project_root`, either the project directory or an e
307
333
 
308
334
  Read results use project-relative paths, deterministic ordering, structured MCP output, and pagination where result sets can grow with the project.
309
335
 
310
- `ast_explore` supports query, exact file, and exact symbol routes. Its default `summary` profile returns bounded reusable selectors; `context` adds selected source and `full` adds compiler references. Every response reports freshness, completeness, truncation, unresolved selectors, record limits, and a serialized byte budget. Use the primitive tools when a single exact operation is clearer or when preparing a mutation.
336
+ `ast_explore` supports query, exact file, and exact symbol routes. Its default `summary` profile returns bounded reusable selectors; `context` adds selected source and `full` adds compiler references. Whole symbol clusters are admitted under the caller's `max_bytes` ceiling, so source, reference records, and call paths are never sliced. `omissions` classifies withheld components as `budget`, `incomplete`, or `untrusted`, and any requested omission keeps completeness false.
337
+
338
+ Exact `file_path` plus `symbol_path` requests may opt into bounded static `call_spines`. Only fresh, exact, compiler-resolved invocation sites qualify; generic references, dynamic dispatch, and runtime behavior are not inferred. Absence of `call_spines` performs no call traversal. Every response still reports freshness, completeness, truncation, unresolved selectors, record limits, and canonical serialized-byte accounting. Use the primitive tools when a single exact operation is clearer or when preparing a mutation. See [ADR 0013](docs/adr/0013-ast-explore-presentation.md).
311
339
 
312
340
  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.
313
341
 
@@ -357,6 +385,10 @@ Example search-to-source pipeline:
357
385
 
358
386
  A `$ref` is an RFC 6901 JSON Pointer rooted at prior step results. References cannot point forward. If `emit` is omitted, only the final step result is returned; intermediate results remain inside the process.
359
387
 
388
+ For CLI batches only, omit `project_root` or provide a directory to select the nearest `tsconfig.json` or `jsconfig.json` from the invocation directory upward. An explicit config file always wins. Discovery stops at a `.git` or filesystem boundary and rejects same-level ambiguity or symlinked identities; MCP tool calls still require an explicit `project_root`.
389
+
390
+ `ast_find_test_candidates` and `ast_explore` are admitted as read steps. The batch runner injects the pipeline `project_root`, rejects a conflicting step root, and invokes the same registered MCP implementation. Candidate relationship proofs and exploration clusters remain whole; final JSON and TOON differ only in serialization, not logical evidence.
391
+
360
392
  ### Bounded foreach
361
393
 
362
394
  ```json
package/SECURITY.md CHANGED
@@ -11,7 +11,7 @@ Security fixes are provided for the exact version currently identified by the np
11
11
  | Earlier npm versions | No |
12
12
  | Unpublished release candidates | No |
13
13
 
14
- This project supports trusted single-user local stdio use; the supported operating-system and architecture boundary remains Linux x64 only. The local `0.9.2` release candidate requires Node.js `>=22.13.0`, with evidence targeted at exact Node.js 22.13.0 and the governed Node.js 24 major. Published v0.8.1 retains its immutable Node.js 22.5.0/24 release matrix. Managed setup mutation requires GNU coreutils 9.7 `mv --update=none-fail --exchange --no-copy --no-target-directory`, GNU coreutils `ln -L -T`, and procfs descriptor-relative paths at `/proc/self/fd` with `O_DIRECTORY`/`O_NOFOLLOW`. The candidate's default SQLite cache also relies on Linux ownership, mode, no-follow, link-count and inode-identity checks; it does not defend against a malicious same-UID process. Other Linux architectures and systems without those primitives are unverified, as are macOS and Windows. Remote, untrusted, and multi-tenant use is outside the supported security boundary.
14
+ This project supports trusted single-user local stdio use; the supported operating-system and architecture boundary remains Linux x64 only. The local `0.11.0` release candidate requires Node.js `>=22.13.0`, with evidence targeted at exact Node.js 22.13.0 and the governed Node.js 24 major. Published v0.10.0 retains its immutable Node.js 22.13.0/24 release matrix. Managed setup mutation requires GNU coreutils 9.7 `mv --update=none-fail --exchange --no-copy --no-target-directory`, GNU coreutils `ln -L -T`, and procfs descriptor-relative paths at `/proc/self/fd` with `O_DIRECTORY`/`O_NOFOLLOW`. The candidate's default SQLite cache also relies on Linux ownership, mode, no-follow, link-count and inode-identity checks; it does not defend against a malicious same-UID process. Other Linux architectures and systems without those primitives are unverified, as are macOS and Windows. Remote, untrusted, and multi-tenant use is outside the supported security boundary.
15
15
 
16
16
  ## Reporting a vulnerability
17
17
 
@@ -14,6 +14,8 @@ export const READ_BATCH_TOOLS = [
14
14
  "ast_get_symbol_source",
15
15
  "ast_search_symbols",
16
16
  "ast_find_references",
17
+ "ast_find_test_candidates",
18
+ "ast_explore",
17
19
  "ast_get_diagnostics",
18
20
  ];
19
21
  export const PREPARE_BATCH_TOOLS = [
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
3
4
  import process from "node:process";
4
5
  import { ZodError } from "zod";
5
6
  import { BatchExecutionError, parseBatchDocument, runBatchDocument } from "./batch/runner.js";
@@ -10,6 +11,12 @@ import { clearSymbolIndexCache, inspectSymbolIndexCache, SymbolIndexCacheError,
10
11
  import { AgentSetupError, detectInstalledAgents, resolveServerEntryPath, runAgentSetup, } from "./services/agent-setup.js";
11
12
  import { installBundledSkill, resolveBundledSkillAssets, } from "./services/skill-installer.js";
12
13
  import { parseAgentsArgument, promptForAgentSelection, resolveAgentSelection, } from "./services/setup-wizard.js";
14
+ import { runUpgradeLease, UpgradeCommandError } from "./services/upgrade-command.js";
15
+ import { reconcileUpgrade } from "./services/upgrade-reconcile.js";
16
+ import { createUpgradeRuntime, UpgradeCleanupError } from "./services/upgrade-runtime.js";
17
+ import { UpgradeError } from "./services/upgrade.js";
18
+ import { CliProjectDiscoveryError, resolveCliBatchProject, } from "./services/cli-project-discovery.js";
19
+ import { runDoctor } from "./services/doctor.js";
13
20
  class CliError extends Error {
14
21
  code;
15
22
  exitCode;
@@ -34,8 +41,10 @@ function usage() {
34
41
  " ast-tool apply <plan.astplan> --plan-hash <sha256>",
35
42
  " ast-tool cache inspect",
36
43
  " ast-tool cache clear --yes",
44
+ " ast-tool doctor [--project <config-or-dir>]",
37
45
  " ast-tool install-skill [claude|hermes|all] [--scope user|project] [--project-root <path>] [--force]",
38
46
  " ast-tool setup [--agents claude,hermes|all --yes] [--force-skill]",
47
+ " ast-tool upgrade [--check]",
39
48
  ].join("\n");
40
49
  }
41
50
  async function readStdinBounded() {
@@ -71,6 +80,13 @@ function parseApplyArgs(args) {
71
80
  }
72
81
  return { planFile: args[0], planHash: args[2] };
73
82
  }
83
+ function parseDoctorArgs(args) {
84
+ if (args.length === 0)
85
+ return {};
86
+ if (args.length === 2 && args[0] === "--project" && args[1] && !args[1].startsWith("--"))
87
+ return { project: args[1] };
88
+ throw new CliError(usage(), "USAGE", 2, "doctor");
89
+ }
74
90
  function parseRunArgs(args) {
75
91
  let source;
76
92
  let outputFormat = "json";
@@ -184,18 +200,37 @@ function parseSetupArgs(args) {
184
200
  }
185
201
  async function parseDocumentForCommand(source, command) {
186
202
  try {
187
- return parseBatchDocument(await readBatchInput(source));
203
+ const input = await readBatchInput(source);
204
+ return parseBatchDocument(await resolveCliBatchProject(input, process.cwd()));
188
205
  }
189
206
  catch (error) {
190
207
  if (error instanceof CliError) {
191
208
  throw new CliError(error.message, error.code, 2, command, error.stepId, { cause: error });
192
209
  }
210
+ if (error instanceof CliProjectDiscoveryError) {
211
+ const relativeSource = source === "-" ? source : path.relative(process.cwd(), path.resolve(source));
212
+ const shellSource = `'${relativeSource.replaceAll("'", `'\\''`)}'`;
213
+ throw new CliError(error.message, error.code, 2, command, undefined, undefined, {
214
+ continuation: `${error.continuation} Then run: ast-tool ${command} ${shellSource}`,
215
+ });
216
+ }
193
217
  const message = error instanceof ZodError ? error.issues.map((issue) => issue.message).join("; ") : error;
194
218
  throw new CliError(message instanceof Error ? message.message : String(message), "INVALID_BATCH", 2, command, undefined, { cause: error });
195
219
  }
196
220
  }
197
221
  export async function runCli(args) {
198
222
  const [command, ...commandArgs] = args;
223
+ if (command === "doctor") {
224
+ const executable = process.argv[1];
225
+ if (!executable)
226
+ throw new CliError(usage(), "USAGE", 2, command);
227
+ return runDoctor({
228
+ ...parseDoctorArgs(commandArgs),
229
+ cwd: process.cwd(),
230
+ executable,
231
+ environment: process.env,
232
+ });
233
+ }
199
234
  if (command === "run" || command === "validate") {
200
235
  const runArgs = command === "run"
201
236
  ? parseRunArgs(commandArgs)
@@ -320,6 +355,33 @@ export async function runCli(args) {
320
355
  throw new CliError(error instanceof Error ? error.message : String(error), "SKILL_INSTALL_ERROR", 1, command, undefined, { cause: error });
321
356
  }
322
357
  }
358
+ if (command === "upgrade") {
359
+ try {
360
+ const executable = process.argv[1];
361
+ if (!executable)
362
+ throw new Error("missing executable");
363
+ const lease = await createUpgradeRuntime(executable);
364
+ return await runUpgradeLease(commandArgs, lease, reconcileUpgrade);
365
+ }
366
+ catch (error) {
367
+ if (error instanceof UpgradeCommandError) {
368
+ throw new CliError(error.message, "USAGE", 2, command, undefined, { cause: error });
369
+ }
370
+ if (error instanceof UpgradeError) {
371
+ throw new CliError(error.message, error.code, 1, command, undefined, { cause: error }, {
372
+ continuation: error.continuation,
373
+ });
374
+ }
375
+ if (error instanceof UpgradeCleanupError) {
376
+ throw new CliError(error.message, error.code, 1, command, undefined, undefined, {
377
+ cleanup: "required",
378
+ ...(error.primaryCode ? { primary_code: error.primaryCode } : {}),
379
+ continuation: "ast-tool upgrade --check",
380
+ });
381
+ }
382
+ throw new CliError("Upgrade failed without exposing host details.", "UPGRADE_ERROR", 1, command, undefined, { cause: error });
383
+ }
384
+ }
323
385
  throw new CliError(usage(), "USAGE", 2, command ?? null);
324
386
  }
325
387
  function failure(error, command = null) {
@@ -363,6 +425,10 @@ async function main() {
363
425
  const result = await runCli(args);
364
426
  const outputFormat = args[0] === "run" ? parseRunArgs(args.slice(1)).outputFormat : "json";
365
427
  process.stdout.write(`${serializeCliSuccess(result, outputFormat)}\n`);
428
+ if (args[0] === "doctor") {
429
+ const status = result.status;
430
+ process.exitCode = status === "healthy" ? 0 : status === "degraded" ? 1 : 2;
431
+ }
366
432
  }
367
433
  catch (error) {
368
434
  const failed = failure(error, args[0] ?? null);
package/dist/server.js CHANGED
@@ -3,6 +3,7 @@ import { McpServer, } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { PublicOperationalError } from "./services/public-errors.js";
4
4
  import { registerApplyOperation } from "./tools/apply_operation.js";
5
5
  import { registerFindReferences } from "./tools/find_references.js";
6
+ import { registerFindTestCandidates } from "./tools/find_test_candidates.js";
6
7
  import { registerGetDiagnostics } from "./tools/get_diagnostics.js";
7
8
  import { registerGetFile } from "./tools/get_file.js";
8
9
  import { registerGetImpact } from "./tools/get_impact.js";
@@ -47,6 +48,7 @@ export function createServer(options = {}) {
47
48
  registerSearchSymbols(server);
48
49
  registerFindReferences(server);
49
50
  registerGetImpact(server);
51
+ registerFindTestCandidates(server);
50
52
  registerGetDiagnostics(server);
51
53
  registerGetFile(server);
52
54
  registerRenameSymbol(server);
@@ -5,7 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import process from "node:process";
7
7
  import { randomUUID } from "node:crypto";
8
- import { planBundledSkillInstallation, SkillConflictError, } from "./skill-installer.js";
8
+ import { applySkillInstallationPlan, planBundledSkillInstallation, SkillConflictError, } from "./skill-installer.js";
9
9
  import { planManagedGuidance, } from "./managed-guidance.js";
10
10
  import { applyManagedFilePlan, createManagedFileApplyContext, ManagedFileApplyError, ManagedFileRollbackError, revalidateManagedFilePlan, verifyManagedFilePostimage, } from "./managed-file.js";
11
11
  import { AGENT_IDS, classifyAgentVersion, getAgentTarget, MCP_SERVER_NAME, } from "./agent-targets.js";
@@ -29,14 +29,18 @@ export async function applyManagedAssetPlans(skillPlan, guidancePlan, context =
29
29
  const rollbackFailed = [];
30
30
  const authenticated = [];
31
31
  let activePlan;
32
+ let skillBundleApplying = true;
32
33
  try {
33
34
  for (const file of [...skillPlan.files, ...guidancePlan.files]) {
34
35
  await revalidateManagedFilePlan(file);
35
36
  }
36
- const plans = [
37
- ...skillPlan.files.map((file) => ({ file, asset: "skill" })),
38
- ...guidancePlan.files.map((file) => ({ file, asset: "guidance" })),
39
- ];
37
+ await applySkillInstallationPlan(skillPlan, (file) => {
38
+ authenticated.push(file);
39
+ if (file.status !== "unchanged")
40
+ physicalWrites.push({ path: file.path, asset: "skill", status: file.status });
41
+ }, context, hooks);
42
+ skillBundleApplying = false;
43
+ const plans = guidancePlan.files.map((file) => ({ file, asset: "guidance" }));
40
44
  for (const { file, asset } of plans) {
41
45
  activePlan = { file, asset };
42
46
  for (const current of authenticated) {
@@ -55,6 +59,8 @@ export async function applyManagedAssetPlans(skillPlan, guidancePlan, context =
55
59
  return physicalWrites;
56
60
  }
57
61
  catch (error) {
62
+ if (skillBundleApplying)
63
+ physicalWrites.length = 0;
58
64
  if (activePlan !== undefined && activePlan.file.status !== "unchanged") {
59
65
  const write = {
60
66
  path: activePlan.file.path,
@@ -5,6 +5,7 @@ const EXPECTED_TOOLS = [
5
5
  "ast_get_symbol_source",
6
6
  "ast_search_symbols",
7
7
  "ast_find_references",
8
+ "ast_find_test_candidates",
8
9
  "ast_get_diagnostics",
9
10
  "ast_rename_symbol",
10
11
  "ast_replace_symbol_body",
@@ -0,0 +1,101 @@
1
+ const endpointKey = (endpoint) => [endpoint.file, endpoint.symbol_path, endpoint.selector].join("\u0000");
2
+ const sequenceKey = (values) => JSON.stringify(values);
3
+ const exactCall = (edge) => edge.kind === "call" &&
4
+ edge.provenance === "compiler" &&
5
+ edge.confidence === "exact" &&
6
+ edge.resolution === "resolved" &&
7
+ edge.freshness.state === "fresh" &&
8
+ edge.compiler_authoritative;
9
+ function assertLimit(value, minimum, name) {
10
+ if (!Number.isSafeInteger(value) || value < minimum)
11
+ throw new Error(`Call spine ${name} is invalid.`);
12
+ return value;
13
+ }
14
+ export function planCallSpines(root, edges, options) {
15
+ const maxDepth = assertLimit(options.max_depth, 0, "max_depth");
16
+ const maxNodes = assertLimit(options.max_nodes, 1, "max_nodes");
17
+ const maxEdges = assertLimit(options.max_edges, 1, "max_edges");
18
+ const accepted = edges.filter(exactCall);
19
+ const freshness = options.freshness?.state ?? edges[0]?.freshness.state;
20
+ const authorityState = freshness !== "fresh" || accepted.length !== edges.length
21
+ ? "untrusted"
22
+ : options.discovery_complete === false
23
+ ? "incomplete"
24
+ : "authoritative";
25
+ const reasons = new Set();
26
+ const rootKey = endpointKey(root);
27
+ const initial = { endpoint: root, endpoints: [root], relationshipIds: [], depth: 0 };
28
+ const records = new Map([[rootKey, initial]]);
29
+ const observedEdges = new Set();
30
+ let layer = [initial];
31
+ while (layer.length > 0) {
32
+ const candidates = new Map();
33
+ for (const current of layer.sort((left, right) => endpointKey(left.endpoint).localeCompare(endpointKey(right.endpoint)))) {
34
+ const incident = accepted
35
+ .filter((edge) => options.direction === "outgoing"
36
+ ? endpointKey(edge.source) === endpointKey(current.endpoint)
37
+ : endpointKey(edge.target) === endpointKey(current.endpoint))
38
+ .sort((left, right) => left.relationship_id.localeCompare(right.relationship_id));
39
+ for (const edge of incident) {
40
+ if (!observedEdges.has(edge.relationship_id) && observedEdges.size >= maxEdges) {
41
+ reasons.add("edge_limit");
42
+ continue;
43
+ }
44
+ observedEdges.add(edge.relationship_id);
45
+ const next = options.direction === "outgoing" ? edge.target : edge.source;
46
+ const key = endpointKey(next);
47
+ if (current.endpoints.some((endpoint) => endpointKey(endpoint) === key) || records.has(key))
48
+ continue;
49
+ if (current.depth >= maxDepth) {
50
+ reasons.add("depth_limit");
51
+ continue;
52
+ }
53
+ const outgoing = options.direction === "outgoing";
54
+ const candidate = {
55
+ endpoint: next,
56
+ endpoints: outgoing ? [...current.endpoints, next] : [next, ...current.endpoints],
57
+ relationshipIds: outgoing
58
+ ? [...current.relationshipIds, edge.relationship_id]
59
+ : [edge.relationship_id, ...current.relationshipIds],
60
+ depth: current.depth + 1,
61
+ };
62
+ const previous = candidates.get(key);
63
+ if (!previous ||
64
+ sequenceKey(candidate.relationshipIds) < sequenceKey(previous.relationshipIds))
65
+ candidates.set(key, candidate);
66
+ }
67
+ }
68
+ const ordered = [...candidates.values()].sort((left, right) => endpointKey(left.endpoint).localeCompare(endpointKey(right.endpoint)));
69
+ const available = maxNodes - records.size;
70
+ if (ordered.length > available)
71
+ reasons.add("node_limit");
72
+ layer = ordered.slice(0, Math.max(0, available));
73
+ for (const record of layer)
74
+ records.set(endpointKey(record.endpoint), record);
75
+ }
76
+ const paths = [...records.values()]
77
+ .filter((record) => record.depth > 0)
78
+ .sort((left, right) => left.depth - right.depth ||
79
+ endpointKey(left.endpoint).localeCompare(endpointKey(right.endpoint)))
80
+ .map((record) => ({
81
+ endpoint: record.endpoint,
82
+ endpoints: record.endpoints,
83
+ relationship_ids: record.relationshipIds,
84
+ }));
85
+ const incomplete = authorityState !== "authoritative" || reasons.size > 0;
86
+ return {
87
+ root,
88
+ direction: options.direction,
89
+ paths,
90
+ visited: {
91
+ nodes: records.size,
92
+ edges: observedEdges.size,
93
+ max_depth: Math.max(0, ...paths.map((path) => path.relationship_ids.length)),
94
+ },
95
+ budget: { max_depth: maxDepth, max_nodes: maxNodes, max_edges: maxEdges },
96
+ incomplete,
97
+ truncation_reasons: ["depth_limit", "node_limit", "edge_limit"].filter((reason) => reasons.has(reason)),
98
+ authority_state: authorityState,
99
+ empty_proven: paths.length === 0 && !incomplete,
100
+ };
101
+ }
@@ -0,0 +1,97 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ const EXPLICIT = "Set project_root to a direct config file.";
4
+ export class CliProjectDiscoveryError extends Error {
5
+ code;
6
+ continuation;
7
+ constructor(code, message, continuation) {
8
+ super(message);
9
+ this.code = code;
10
+ this.continuation = continuation;
11
+ this.name = "CliProjectDiscoveryError";
12
+ }
13
+ }
14
+ function fail(code, message, continuation) {
15
+ throw new CliProjectDiscoveryError(code, message, continuation);
16
+ }
17
+ function same(left, right) {
18
+ return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
19
+ }
20
+ async function directPath(value) {
21
+ let info;
22
+ try {
23
+ info = await lstat(value);
24
+ }
25
+ catch (error) {
26
+ if (error.code !== "ENOENT")
27
+ fail("PROJECT_CONFIG_UNSAFE", "The requested CLI project location could not be inspected safely.", EXPLICIT);
28
+ fail("PROJECT_CONFIG_NOT_FOUND", "The requested CLI project location does not exist.", "Set project_root to an existing config file.");
29
+ }
30
+ if (info.isSymbolicLink())
31
+ fail("PROJECT_CONFIG_UNSAFE", "Symlinked CLI project locations are unsupported.", EXPLICIT);
32
+ const canonical = await realpath(value).catch(() => fail("PROJECT_CONFIG_UNSAFE", "CLI project identity could not be proven.", EXPLICIT));
33
+ if (!same(value, canonical))
34
+ fail("PROJECT_CONFIG_UNSAFE", "Symlinked CLI project ancestors are unsupported.", EXPLICIT);
35
+ return { path: canonical, file: info.isFile() };
36
+ }
37
+ async function configAt(directory, name) {
38
+ const candidate = path.join(directory, name);
39
+ try {
40
+ const direct = await directPath(candidate);
41
+ return direct.file ? direct.path : undefined;
42
+ }
43
+ catch (error) {
44
+ if (error instanceof CliProjectDiscoveryError && error.code === "PROJECT_CONFIG_NOT_FOUND")
45
+ return;
46
+ throw error;
47
+ }
48
+ }
49
+ async function hasGitBoundary(directory) {
50
+ return lstat(path.join(directory, ".git")).then(() => true, (error) => {
51
+ if (error.code === "ENOENT")
52
+ return false;
53
+ fail("PROJECT_CONFIG_UNSAFE", "The repository boundary could not be inspected safely.", EXPLICIT);
54
+ });
55
+ }
56
+ function relativeConfig(from, config) {
57
+ const relative = path.relative(from, config).split(path.sep).join("/");
58
+ return relative.startsWith(".") ? relative : `./${relative}`;
59
+ }
60
+ async function discover(start, relativeTo) {
61
+ const visited = new Set();
62
+ let directory = start;
63
+ while (!visited.has(directory)) {
64
+ visited.add(directory);
65
+ const [tsconfig, jsconfig] = await Promise.all([
66
+ configAt(directory, "tsconfig.json"),
67
+ configAt(directory, "jsconfig.json"),
68
+ ]);
69
+ if (tsconfig && jsconfig)
70
+ fail("PROJECT_CONFIG_AMBIGUOUS", "Both supported project configs exist at the nearest CLI project level.", `Set project_root to "${relativeConfig(relativeTo, tsconfig)}" or "${relativeConfig(relativeTo, jsconfig)}".`);
71
+ if (tsconfig || jsconfig)
72
+ return tsconfig ?? jsconfig;
73
+ if (await hasGitBoundary(directory))
74
+ break;
75
+ const parent = path.dirname(directory);
76
+ if (parent === directory)
77
+ break;
78
+ directory = parent;
79
+ }
80
+ fail("PROJECT_CONFIG_NOT_FOUND", "No supported project config was found within the CLI discovery boundary.", "Set project_root to a config file.");
81
+ }
82
+ export async function resolveCliBatchProject(input, cwd) {
83
+ if (typeof input !== "object" || input === null || Array.isArray(input))
84
+ return input;
85
+ const document = input;
86
+ const requested = document.project_root;
87
+ if (requested !== undefined && typeof requested !== "string")
88
+ return input;
89
+ const absolute = path.resolve(cwd, requested ?? cwd);
90
+ const direct = await directPath(absolute);
91
+ const projectRoot = direct.file
92
+ ? absolute.endsWith(".json")
93
+ ? direct.path
94
+ : fail("PROJECT_CONFIG_NOT_FOUND", "The explicit CLI project file is not a JSON config.", "Set project_root to a config file.")
95
+ : await discover(direct.path, cwd);
96
+ return { ...document, project_root: projectRoot };
97
+ }