ast-mcp-server 0.10.0 → 0.11.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 +30 -2
- package/README.md +27 -2
- package/SECURITY.md +1 -1
- package/dist/cli.js +67 -1
- package/dist/services/agent-setup.js +11 -5
- package/dist/services/cli-project-discovery.js +97 -0
- package/dist/services/doctor-continuation.js +37 -0
- package/dist/services/doctor-model.js +77 -0
- package/dist/services/doctor.js +111 -0
- package/dist/services/managed-bundle.js +145 -0
- package/dist/services/managed-file.js +37 -0
- package/dist/services/project.js +98 -22
- package/dist/services/skill-installer.js +74 -56
- package/dist/services/upgrade-command.js +77 -0
- package/dist/services/upgrade-reconcile.js +41 -0
- package/dist/services/upgrade-runtime.js +157 -0
- package/dist/services/upgrade.js +232 -0
- package/docs/support.md +13 -13
- package/package.json +2 -2
- package/skills/structural-code-editing/SKILL.md +30 -160
- package/skills/structural-code-editing/references/operations.md +53 -0
- package/skills/structural-code-editing/releases.json +17 -3
package/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
-
## [0.
|
|
9
|
+
## [0.11.1] — local recovery candidate, pending release (2026-08-23)
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Public- and local-registry consumer verification now share one dependency-free managed-skill validator that derives the expected release version from the bundled skill metadata, compares installed copies byte-for-byte, and rejects embedded managed guidance.
|
|
14
|
+
- The exact-tree release matrix now runs `test:local-registry` under both supported Node.js lanes, preventing managed-asset verifier drift from reaching publication again.
|
|
15
|
+
|
|
16
|
+
### Release status
|
|
17
|
+
|
|
18
|
+
- This local recovery candidate succeeds the immutable npm `0.11.0` publication, whose public-registry verification failed because two consumer validators still required skill bundle `4.4.0` while the published package correctly contained `4.5.0`. Version `0.11.0` remains under `next` only and must not be promoted, tagged, or released. Version `0.11.1` remains pending its exact-tree matrix, CI, and separately authorized delivery sequence; it has not been published or assigned to any dist-tag.
|
|
19
|
+
|
|
20
|
+
## [0.11.0] — published to `next`, verification failed, not promoted (2026-08-23)
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- 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.
|
|
25
|
+
- `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.
|
|
26
|
+
- 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.
|
|
27
|
+
- `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.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Project watchers now ignore events owned by exact symbol-index cache artifacts while preserving legitimate source invalidations, including POSIX filenames containing a literal backslash.
|
|
32
|
+
|
|
33
|
+
### Release status
|
|
34
|
+
|
|
35
|
+
- npm published these immutable bytes under `next` with provenance and the expected Git commit. Public-registry verification then failed because two consumer validators still required skill bundle `4.4.0` while the package correctly contained `4.5.0`; `0.11.0` was never promoted to `latest`, tagged in Git, or released on GitHub.
|
|
36
|
+
|
|
37
|
+
## [0.10.0] — published to `latest` and `next`, registry verified (2026-08-22)
|
|
10
38
|
|
|
11
39
|
### Added
|
|
12
40
|
|
|
@@ -20,7 +48,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
20
48
|
|
|
21
49
|
### Release status
|
|
22
50
|
|
|
23
|
-
-
|
|
51
|
+
- 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.
|
|
24
52
|
|
|
25
53
|
## [0.9.2] — local release candidate, pending release (2026-08-16)
|
|
26
54
|
|
package/README.md
CHANGED
|
@@ -84,11 +84,11 @@ The included batch benchmark records a 50% reduction in model round-trips and a
|
|
|
84
84
|
|
|
85
85
|
## Supported environment and trust boundary
|
|
86
86
|
|
|
87
|
-
The local `0.
|
|
87
|
+
The local `0.11.1` recovery 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.
|
|
88
88
|
|
|
89
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.
|
|
90
90
|
|
|
91
|
-
In the local `0.
|
|
91
|
+
In published v0.10.0 and the local `0.11.1` recovery 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.
|
|
92
92
|
|
|
93
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:
|
|
94
94
|
|
|
@@ -131,6 +131,29 @@ The package exposes two executables when installed:
|
|
|
131
131
|
- `ast-mcp-server`: MCP stdio server.
|
|
132
132
|
- `ast-tool`: batch, skill-installation, and agent-setup CLI.
|
|
133
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
|
+
|
|
134
157
|
## Guided agent setup
|
|
135
158
|
|
|
136
159
|
The installed package opens the interactive wizard with:
|
|
@@ -362,6 +385,8 @@ Example search-to-source pipeline:
|
|
|
362
385
|
|
|
363
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.
|
|
364
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
|
+
|
|
365
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.
|
|
366
391
|
|
|
367
392
|
### Bounded foreach
|
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.
|
|
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.1` recovery 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
|
|
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
|
-
|
|
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);
|
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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,
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
function shellQuote(value) {
|
|
4
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
5
|
+
}
|
|
6
|
+
function relativeArgument(project, cwd) {
|
|
7
|
+
const requested = project ?? ".";
|
|
8
|
+
if (/[\0\r\n]/u.test(requested))
|
|
9
|
+
return ".";
|
|
10
|
+
const relative = path.relative(cwd, path.resolve(cwd, requested)).split(path.sep).join("/") || ".";
|
|
11
|
+
return relative.startsWith(".") ? relative : `./${relative}`;
|
|
12
|
+
}
|
|
13
|
+
function ambiguousArgument(project, cwd) {
|
|
14
|
+
let directory = path.resolve(cwd, project ?? ".");
|
|
15
|
+
while (true) {
|
|
16
|
+
const candidate = path.join(directory, "tsconfig.json");
|
|
17
|
+
if (existsSync(candidate) && existsSync(path.join(directory, "jsconfig.json")))
|
|
18
|
+
return relativeArgument(candidate, cwd);
|
|
19
|
+
if (existsSync(path.join(directory, ".git")))
|
|
20
|
+
break;
|
|
21
|
+
const parent = path.dirname(directory);
|
|
22
|
+
if (parent === directory)
|
|
23
|
+
break;
|
|
24
|
+
directory = parent;
|
|
25
|
+
}
|
|
26
|
+
return relativeArgument(path.join(project ?? ".", "tsconfig.json"), cwd);
|
|
27
|
+
}
|
|
28
|
+
export function buildDoctorConfigContinuation(state, discoveryContinuation, project, cwd) {
|
|
29
|
+
void discoveryContinuation;
|
|
30
|
+
const requested = state === "ambiguous" ? ambiguousArgument(project, cwd) : relativeArgument(project, cwd);
|
|
31
|
+
const command = `ast-tool doctor --project ${shellQuote(requested)}`;
|
|
32
|
+
if (state === "ambiguous")
|
|
33
|
+
return `Run: ${command}`;
|
|
34
|
+
return state === "unsafe"
|
|
35
|
+
? `Replace the requested project location with a direct config, then run: ${command}`
|
|
36
|
+
: `Create the requested project location and a supported config, then run: ${command}`;
|
|
37
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { sanitizePublicText } from "./public-errors.js";
|
|
2
|
+
function item(code, status, summary, reasonCode, continuation, continuationTrusted = false) {
|
|
3
|
+
return {
|
|
4
|
+
code,
|
|
5
|
+
status,
|
|
6
|
+
summary: sanitizePublicText(summary),
|
|
7
|
+
reason_code: reasonCode,
|
|
8
|
+
...(continuation
|
|
9
|
+
? { continuation: continuationTrusted ? continuation : sanitizePublicText(continuation) }
|
|
10
|
+
: {}),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function overall(checks) {
|
|
14
|
+
if (checks.some((check) => check.status === "failed"))
|
|
15
|
+
return "failed";
|
|
16
|
+
return checks.some((check) => check.status === "degraded") ? "degraded" : "healthy";
|
|
17
|
+
}
|
|
18
|
+
export function evaluateDoctor(evidence) {
|
|
19
|
+
const checks = [];
|
|
20
|
+
checks.push(evidence.runtime_invalid
|
|
21
|
+
? item("runtime_policy", "degraded", "Runtime limits contain invalid values and defaults were applied.", "invalid_runtime_policy", "Fix AST runtime limit values, then run: ast-tool doctor")
|
|
22
|
+
: item("runtime_policy", "healthy", "Runtime limits are admitted.", "runtime_policy_ready"));
|
|
23
|
+
checks.push(evidence.config.state === "ready"
|
|
24
|
+
? item("project_config", "healthy", "A unique project config was resolved.", "config_ready")
|
|
25
|
+
: item("project_config", "failed", "Project config discovery did not produce a safe unique identity.", `config_${evidence.config.state}`, evidence.config.continuation, evidence.config.continuation_trusted));
|
|
26
|
+
if (evidence.project) {
|
|
27
|
+
const { compiler, watcher, index, operation_queue: queue } = evidence.project;
|
|
28
|
+
checks.push(compiler.state === "ready"
|
|
29
|
+
? item("compiler", "healthy", "Compiler synchronization is ready.", "compiler_ready")
|
|
30
|
+
: item("compiler", compiler.state === "failed" ? "failed" : "degraded", "Compiler synchronization is not ready.", `compiler_${compiler.state}`, "Run: ast-tool doctor"));
|
|
31
|
+
checks.push(watcher.state === "ready"
|
|
32
|
+
? item("watcher", "healthy", "Project watcher is ready.", "watcher_ready")
|
|
33
|
+
: item("watcher", "degraded", "Project watcher is unavailable; compiler reads may require resynchronization.", `watcher_${watcher.state}`, "Restart MCP clients, then run: ast-tool doctor"));
|
|
34
|
+
checks.push(evidence.project_authority === "isolated" && index.state === "disabled"
|
|
35
|
+
? item("derived_index", "not_run", "Persistent derived-index inspection is disabled for an isolated diagnosis.", "index_not_run_diagnostic_policy")
|
|
36
|
+
: index.state === "ready" || index.state === "disabled"
|
|
37
|
+
? item("derived_index", "healthy", index.state === "ready"
|
|
38
|
+
? "Derived symbol index is ready."
|
|
39
|
+
: "Derived symbol index is disabled by admitted policy.", `index_${index.state}`)
|
|
40
|
+
: item("derived_index", "degraded", compiler.state === "ready"
|
|
41
|
+
? "Derived index is unavailable; compiler-backed file operations remain available."
|
|
42
|
+
: "Derived index is unavailable while compiler synchronization is not ready.", compiler.state === "ready" ? "index_degraded_compiler_ready" : `index_${index.state}`, "Run: ast-tool cache inspect"));
|
|
43
|
+
const queueDegraded = evidence.runtime_admission !== "open" ||
|
|
44
|
+
queue.admission !== "open" ||
|
|
45
|
+
queue.rejected_operations > 0 ||
|
|
46
|
+
queue.queue_timeout_operations > 0 ||
|
|
47
|
+
queue.deadline_exceeded_operations > 0;
|
|
48
|
+
checks.push(evidence.project_authority === "isolated" && evidence.runtime_admission === "open"
|
|
49
|
+
? item("operation_queue", "not_run", "No registered project queue exists to inspect.", "queue_not_run_no_registered_session")
|
|
50
|
+
: queueDegraded
|
|
51
|
+
? item("operation_queue", "degraded", "Operation queue reports admission or timeout pressure.", evidence.runtime_admission === "closed"
|
|
52
|
+
? "runtime_admission_closed"
|
|
53
|
+
: "queue_pressure", "Run: ast-tool doctor")
|
|
54
|
+
: item("operation_queue", "healthy", "Operation queue is accepting work.", "queue_ready"));
|
|
55
|
+
}
|
|
56
|
+
else if (evidence.config.state === "ready" && evidence.project_authority === "unavailable") {
|
|
57
|
+
checks.push(item("compiler", "not_run", "Compiler diagnosis is unavailable while runtime admission is closed.", "compiler_not_run_runtime_closed"), item("watcher", "not_run", "Watcher check requires compiler diagnosis.", "watcher_not_run"), item("derived_index", "not_run", "Derived index check requires compiler diagnosis.", "index_not_run"), item("operation_queue", "degraded", "Project runtime admission is closed.", "runtime_admission_closed", "Restart MCP clients, then run: ast-tool doctor"));
|
|
58
|
+
}
|
|
59
|
+
else if (evidence.config.state === "ready") {
|
|
60
|
+
checks.push(item("compiler", "failed", "Compiler creation failed.", "compiler_creation_failed", "Run: ast-tool doctor"), item("watcher", "not_run", "Watcher check requires a ready compiler.", "watcher_not_run"), item("derived_index", "not_run", "Derived index check requires a ready compiler.", "index_not_run"), item("operation_queue", "not_run", "Queue check requires a diagnostic project.", "queue_not_run"));
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
checks.push(item("compiler", "not_run", "Compiler check requires a project config.", "compiler_not_run"), item("watcher", "not_run", "Watcher check requires a project config.", "watcher_not_run"), item("derived_index", "not_run", "Derived index check requires a project config.", "index_not_run"), item("operation_queue", "not_run", "Queue check requires a project config.", "queue_not_run"));
|
|
64
|
+
}
|
|
65
|
+
checks.push(evidence.cache.state === "failed" || evidence.cache.unsafe_entry_count > 0
|
|
66
|
+
? item("index_cache", "degraded", "Index cache inspection found unavailable or unsafe state.", evidence.cache.unsafe_entry_count > 0 ? "cache_unsafe" : "cache_failed", "Run: ast-tool cache inspect")
|
|
67
|
+
: item("index_cache", "healthy", `Index cache is ${evidence.cache.state}.`, `cache_${evidence.cache.state}`));
|
|
68
|
+
checks.push(evidence.package.state === "ready"
|
|
69
|
+
? item("package", "healthy", `Package version ${evidence.package.version ?? "unknown"} is locally admitted.`, "package_ready")
|
|
70
|
+
: item("package", "degraded", "Running package provenance could not be admitted locally.", `package_${evidence.package.state}`, "Run: ast-tool upgrade --check"));
|
|
71
|
+
checks.push(evidence.setup.state === "ready"
|
|
72
|
+
? item("client_setup", "healthy", "Detected client skills are current.", "setup_ready")
|
|
73
|
+
: item("client_setup", "degraded", "Detected client skill setup requires attention.", `setup_${evidence.setup.state}`, evidence.setup.state === "conflict"
|
|
74
|
+
? "Run: ast-tool setup --agents all --yes --force-skill"
|
|
75
|
+
: "Run: ast-tool setup --agents all --yes"));
|
|
76
|
+
return { schema_version: 1, status: overall(checks), checks: checks.slice(0, 9) };
|
|
77
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { detectInstalledAgents } from "./agent-setup.js";
|
|
4
|
+
import { resolveCliBatchProject, CliProjectDiscoveryError } from "./cli-project-discovery.js";
|
|
5
|
+
import { buildDoctorConfigContinuation } from "./doctor-continuation.js";
|
|
6
|
+
import { evaluateDoctor } from "./doctor-model.js";
|
|
7
|
+
import { getDiagnosticProjectStatus } from "./project.js";
|
|
8
|
+
import { readRuntimePolicy } from "./runtime-policy.js";
|
|
9
|
+
import { planBundledSkillInstallation, resolveBundledSkillAssets, SkillConflictError, } from "./skill-installer.js";
|
|
10
|
+
import { inspectSymbolIndexCache, } from "./symbol-index-cache.js";
|
|
11
|
+
async function resolveProject(project, cwd) {
|
|
12
|
+
const resolved = (await resolveCliBatchProject({ ...(project ? { project_root: project } : {}) }, cwd));
|
|
13
|
+
return resolved.project_root;
|
|
14
|
+
}
|
|
15
|
+
async function inspectPackage(executable) {
|
|
16
|
+
const cli = await realpath(executable);
|
|
17
|
+
const root = path.resolve(path.dirname(cli), "..");
|
|
18
|
+
const metadata = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
|
19
|
+
const bins = metadata.bin;
|
|
20
|
+
if (metadata.name !== "ast-mcp-server" ||
|
|
21
|
+
typeof metadata.version !== "string" ||
|
|
22
|
+
typeof bins?.["ast-tool"] !== "string" ||
|
|
23
|
+
(await realpath(path.resolve(root, bins["ast-tool"]))) !== cli)
|
|
24
|
+
return { state: "unsupported" };
|
|
25
|
+
return { state: "ready", version: metadata.version };
|
|
26
|
+
}
|
|
27
|
+
async function inspectSetup(executable, cwd, environment) {
|
|
28
|
+
const detections = await detectInstalledAgents({ environment, commandTimeoutMs: 5000 });
|
|
29
|
+
const installed = detections.filter((agent) => agent.installed);
|
|
30
|
+
if (installed.length === 0)
|
|
31
|
+
return { state: "ready" };
|
|
32
|
+
if (installed.some((agent) => agent.compatibility?.status !== "compatible"))
|
|
33
|
+
return { state: "outdated" };
|
|
34
|
+
const assets = await resolveBundledSkillAssets(executable);
|
|
35
|
+
try {
|
|
36
|
+
const plan = await planBundledSkillInstallation({
|
|
37
|
+
target: installed.map((agent) => agent.id),
|
|
38
|
+
scope: "user",
|
|
39
|
+
sourceSkillPath: assets.skillPath,
|
|
40
|
+
releaseManifestPath: assets.releasesPath,
|
|
41
|
+
environment,
|
|
42
|
+
...(environment.HOME ? { homeDirectory: environment.HOME } : {}),
|
|
43
|
+
workingDirectory: cwd,
|
|
44
|
+
});
|
|
45
|
+
if (plan.installations.every((item) => item.status === "unchanged"))
|
|
46
|
+
return { state: "ready" };
|
|
47
|
+
return {
|
|
48
|
+
state: plan.installations.some((item) => item.status === "updated") ? "outdated" : "missing",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error instanceof SkillConflictError)
|
|
53
|
+
return { state: "conflict" };
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const defaults = {
|
|
58
|
+
resolveProject,
|
|
59
|
+
projectStatus: getDiagnosticProjectStatus,
|
|
60
|
+
cache: (environment) => inspectSymbolIndexCache({ environment }),
|
|
61
|
+
package: inspectPackage,
|
|
62
|
+
setup: inspectSetup,
|
|
63
|
+
runtime: readRuntimePolicy,
|
|
64
|
+
};
|
|
65
|
+
export async function runDoctor(options, overrides = {}) {
|
|
66
|
+
const dependencies = { ...defaults, ...overrides };
|
|
67
|
+
const environment = options.environment ?? process.env;
|
|
68
|
+
const policy = dependencies.runtime(environment);
|
|
69
|
+
const runtimeInvalid = Object.values(policy.reasons).some((reason) => reason === "invalid_integer" || reason === "out_of_range");
|
|
70
|
+
let config = { state: "ready" };
|
|
71
|
+
let projectRoot;
|
|
72
|
+
try {
|
|
73
|
+
projectRoot = await dependencies.resolveProject(options.project, options.cwd);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
const discovery = error instanceof CliProjectDiscoveryError ? error : undefined;
|
|
77
|
+
const state = discovery?.code.endsWith("AMBIGUOUS")
|
|
78
|
+
? "ambiguous"
|
|
79
|
+
: discovery?.code.endsWith("UNSAFE")
|
|
80
|
+
? "unsafe"
|
|
81
|
+
: "missing";
|
|
82
|
+
config = {
|
|
83
|
+
state,
|
|
84
|
+
continuation: buildDoctorConfigContinuation(state, discovery?.continuation, options.project, options.cwd),
|
|
85
|
+
continuation_trusted: true,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
let diagnostic;
|
|
89
|
+
if (projectRoot)
|
|
90
|
+
diagnostic = await dependencies.projectStatus(projectRoot).catch(() => undefined);
|
|
91
|
+
const [cache, packageEvidence, setup] = await Promise.all([
|
|
92
|
+
dependencies
|
|
93
|
+
.cache(environment)
|
|
94
|
+
.then((result) => ({ state: result.state, unsafe_entry_count: result.unsafe_entry_count }))
|
|
95
|
+
.catch(() => ({ state: "failed", unsafe_entry_count: 0 })),
|
|
96
|
+
dependencies.package(options.executable).catch(() => ({ state: "failed" })),
|
|
97
|
+
dependencies
|
|
98
|
+
.setup(options.executable, options.cwd, environment)
|
|
99
|
+
.catch(() => ({ state: "failed" })),
|
|
100
|
+
]);
|
|
101
|
+
return evaluateDoctor({
|
|
102
|
+
runtime_invalid: runtimeInvalid,
|
|
103
|
+
project_authority: diagnostic?.authority ?? (projectRoot ? "failed" : "unavailable"),
|
|
104
|
+
runtime_admission: diagnostic?.runtime_admission ?? "open",
|
|
105
|
+
config,
|
|
106
|
+
...(diagnostic?.status ? { project: diagnostic.status } : {}),
|
|
107
|
+
cache,
|
|
108
|
+
package: packageEvidence,
|
|
109
|
+
setup,
|
|
110
|
+
});
|
|
111
|
+
}
|