ast-mcp-server 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.5.1] - 2026-08-05
8
+
9
+ ### Added
10
+
11
+ - Bounded JSON-safe project status contracts and the read-only `ast_get_project_status` tool.
12
+ - Session freshness metadata with separate source, config, and canonical fingerprints.
13
+ - Serialized operation-queue accounting and status integration without changing reviewed mutation semantics.
14
+
15
+ ### Changed
16
+
17
+ - Source synchronization verifies stability across refresh, snapshot, refresh, and verification before reporting fresh state.
18
+ - Phase 1 keeps the symbol index disabled while exposing explicit stale/degraded recovery state.
19
+
20
+ ### Security
21
+
22
+ - Status projections redact identities, absolute paths, credentials, and multi-token `Authorization` values at both transition and projection boundaries.
23
+ - Noncanonical external fingerprints are converted to bounded opaque SHA-256 digests before projection.
24
+
7
25
  ## [0.5.0] - 2026-08-03
8
26
 
9
27
  ### Added
@@ -66,6 +84,7 @@ Initial public release.
66
84
  - Apply rejects stale workspaces, changed configs/sources, mismatched plan hashes, unsafe plan files, conflicting MCP registrations, and conflicting skill content.
67
85
  - Inputs, outputs, operation stores, subprocesses, batch fan-out, plan lifetime, and filesystem access are bounded.
68
86
 
87
+ [0.5.1]: https://github.com/yailPeralta/ast-mcp-server/releases/tag/v0.5.1
69
88
  [0.5.0]: https://github.com/yailPeralta/ast-mcp-server/releases/tag/v0.5.0
70
89
  [0.4.0]: https://github.com/yailPeralta/ast-mcp-server/releases/tag/v0.4.0
71
90
  [0.3.0]: https://github.com/yailPeralta/ast-mcp-server/releases/tag/v0.3.0
@@ -0,0 +1,129 @@
1
+ # Benchmark methodology
2
+
3
+ The benchmark suite measures structural payload reduction, batch orchestration, and model-facing result formats. The first two benchmarks deliberately avoid converting characters into tokens. The format benchmark pins and names one tokenizer so its estimates are reproducible rather than universal.
4
+
5
+ ## Project payload and latency
6
+
7
+ `scripts/benchmark.mjs`:
8
+
9
+ 1. Loads a real TypeScript project from disk.
10
+ 2. Selects the lexicographically first non-declaration source files after an optional filter.
11
+ 3. Serializes the default compact outline payload (`file` plus `outline`).
12
+ 4. Also records the larger opt-in payload containing detailed symbol metadata.
13
+ 5. Reports fresh project load, cold cached-session, warm cached-session, and per-file outline latency.
14
+
15
+ Run:
16
+
17
+ ```bash
18
+ yarn benchmark /absolute/project/path --sample 20 --output benchmark/results/project.json
19
+ ```
20
+
21
+ The selection rule is deterministic but not statistically representative of every repository. Use `--filter` and multiple samples when comparing particular modules.
22
+
23
+ ## Compact-first task corpus
24
+
25
+ `task-corpus.json` defines twelve questions over this repository. Each task declares:
26
+
27
+ - files a full-file workflow would load;
28
+ - declarations a compact-first workflow would request after inspecting outlines;
29
+ - evidence strings necessary to answer that task.
30
+
31
+ `scripts/benchmark-corpus.mjs` compares:
32
+
33
+ - baseline: complete text of every declared file;
34
+ - compact: default outline payloads plus only the selected declaration sources.
35
+
36
+ It fails unless every predefined evidence string is present in the compact payload. This makes the check reproducible, but it does not prove that every possible reviewer would reach an equivalent conclusion.
37
+
38
+ Run:
39
+
40
+ ```bash
41
+ yarn benchmark:corpus benchmark/task-corpus.json --output benchmark/results/self-corpus.json
42
+ ```
43
+
44
+ ## Interpreting results
45
+
46
+ - Character reduction measures serialized payload size, not billed model tokens.
47
+ - Negative reduction on a tiny file is valid: protocol structure can cost more than reading that file directly.
48
+ - Project-load latency and warm-session latency should be considered separately.
49
+ - Detailed symbol metadata is opt-in because it repeats signature text and can dominate small files.
50
+ - Benchmark scripts record absolute local paths and timestamps. Checked reports replace local paths with descriptive placeholders; regenerate them when comparing another machine or revision.
51
+
52
+ ## Batch orchestration
53
+
54
+ `scripts/benchmark-batch.mjs` compares a two-operation search-to-source workflow in fresh Node processes:
55
+
56
+ - separate: the client receives the broad symbol-search payload, selects the first match, then requests its source;
57
+ - batch: `ast-tool` performs the same two MCP tool invocations in process and emits only the final source result.
58
+
59
+ Run:
60
+
61
+ ```bash
62
+ yarn benchmark:batch --iterations 5 --output benchmark/results/self-batch.json
63
+ ```
64
+
65
+ The report records logical model round-trips, actual tool invocations, median wall time, process maximum RSS, final-result characters, and total characters exposed to the model. It does not include model inference latency, so the practical wall-time benefit of removing a model round-trip will be larger than this local process-only comparison. Maximum RSS includes the Node runtime and TypeScript project in each fresh worker.
66
+
67
+ ## Model-facing JSON and TOON
68
+
69
+ `scripts/benchmark-formats.mjs` compares compact JSON with TOON for actual MCP logical results. It uses this repository for broad symbol search and deterministic temporary TypeScript fixtures for repeated references and non-empty diagnostics. File lists, outlines, exact source, and a prepared rename are retained as negative controls.
70
+
71
+ For each payload it records:
72
+
73
+ - exact JSON→TOON→value round-trip equality;
74
+ - UTF-8 bytes;
75
+ - `gpt-tokenizer@3.4.0` `o200k_base` token estimates;
76
+ - plain TOON for CLI output and the real MCP `{format,data}` envelope;
77
+ - median encode/decode time over 50 local iterations;
78
+ - serialized `tools/list` metadata against the checked v0.3.0 baseline.
79
+
80
+ Run:
81
+
82
+ ```bash
83
+ yarn benchmark:formats
84
+ ```
85
+
86
+ The script fails unless every round trip is exact, the broad-search MCP envelope reduces estimated tokens by at least 20%, and the aggregate eligible corpus reduces them by at least 15%. Tokenizer estimates are not provider usage records and do not establish billed-token or prompt-cache savings.
87
+
88
+ ## Checked results
89
+
90
+ Results generated on 2026-08-03 with Node.js v24.16.0:
91
+
92
+ | Workload | Sample | Full source chars | Compact chars | Reduction | Fresh load | Warm session |
93
+ | --------------- | -------: | ----------------: | ------------: | --------: | ---------: | -----------: |
94
+ | This repository | 20 files | 77,982 | 12,512 | 83.96% | 543 ms | 1.86 ms |
95
+ | `x-scraper` | 20 files | 178,110 | 17,036 | 90.44% | 3,951 ms | 180.16 ms |
96
+ | Task corpus | 12 tasks | 83,289 | 28,372 | 65.94% | n/a | n/a |
97
+
98
+ The task corpus passed all 12 predefined evidence checks. Its compact count includes the selected declaration bodies, not just outlines. Exact raw reports are under `benchmark/results/`.
99
+
100
+ The checked broad search-to-source batch result used five fresh-process samples:
101
+
102
+ | Mode | Model round-trips | Tool invocations | Context chars | Median wall time | Median max RSS |
103
+ | -------- | ----------------: | ---------------: | ------------: | ---------------: | -------------: |
104
+ | Separate | 2 | 2 | 7,818 | 587.03 ms | 380.68 MB |
105
+ | Batch | 1 | 2 | 417 | 607.95 ms | 379.15 MB |
106
+
107
+ That run reduced model round-trips by 50% and serialized context by 94.67%. Local execution latency increased by 3.56% and RSS decreased by 0.40%, both small enough to treat as noise rather than a performance claim. The batch value is orchestration/context reduction; it does not make TypeScript analysis free.
108
+
109
+ The checked model-facing format corpus used the complete MCP envelope for TOON:
110
+
111
+ | Payload | Records | JSON tokens | MCP TOON tokens | Reduction |
112
+ | ------------------- | ------: | ----------: | --------------: | --------: |
113
+ | Broad symbol search | 100 | 5,382 | 4,061 | 24.54% |
114
+ | References | 71 | 3,094 | 2,159 | 30.22% |
115
+ | Diagnostics | 30 | 1,309 | 1,034 | 21.01% |
116
+ | Eligible aggregate | 201 | 9,785 | 7,254 | 25.87% |
117
+
118
+ All seven positive and negative payloads round-tripped exactly. File list, outline, source, and prepare envelopes were 5.13% to 13.56% worse in estimated tokens, which is why they remain JSON-only. The complete eleven-tool metadata is 3,372 serialized characters larger than the retained v0.3.0 baseline; this static protocol cost is reported separately from dynamic result savings.
119
+
120
+ The checked result-shaping corpus compares the v0.4.0-compatible `full/100/context` profiles with the new public defaults across exact-name, exact-path, prefix, broad-substring, and multi-file-reference workflows:
121
+
122
+ | Profile | Logical calls | MCP TOON tokens |
123
+ | --------- | ------------: | --------------: |
124
+ | Baseline | 6 | 3,910 |
125
+ | Candidate | 6 | 1,220 |
126
+
127
+ Every declared selector and reference coordinate remained present, and the aggregate reduction was 68.80%, above the checked 35% gate. `duration_ms` is omitted from both measured representations to make token counts deterministic; real JSON/TOON outputs are still decode-compared before measurement. These are local `o200k_base` estimates, not provider billing, cache, or latency evidence.
128
+
129
+ The complete v0.5.0 `tools/list` metadata is 22,473 serialized characters versus the retained v0.4.0 value of 16,650 (`+5,823`). Removing only `ast_scaffold_class` from the current list reduces it by 5,411 characters and 1,295 local `o200k_base` tokens. No v0.4.0 metadata token count was retained, so the report intentionally leaves the historical token delta unset.
package/dist/server.js CHANGED
@@ -5,6 +5,7 @@ import { registerFindReferences } from "./tools/find_references.js";
5
5
  import { registerGetDiagnostics } from "./tools/get_diagnostics.js";
6
6
  import { registerGetOperationPreview } from "./tools/get_operation_preview.js";
7
7
  import { registerGetOutline } from "./tools/get_outline.js";
8
+ import { registerGetProjectStatus } from "./tools/get_project_status.js";
8
9
  import { registerGetSymbolSource } from "./tools/get_symbol_source.js";
9
10
  import { registerListFiles } from "./tools/list_files.js";
10
11
  import { registerRenameSymbol } from "./tools/rename_symbol.js";
@@ -19,6 +20,7 @@ export function createServer() {
19
20
  version: PACKAGE_VERSION,
20
21
  });
21
22
  registerListFiles(server);
23
+ registerGetProjectStatus(server);
22
24
  registerGetOutline(server);
23
25
  registerGetSymbolSource(server);
24
26
  registerSearchSymbols(server);
@@ -0,0 +1,736 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createTruncationMetadata, isFreshnessCause, isSnapshotState, isTruncationReason, } from "./read-contracts.js";
3
+ export const MAX_PENDING_FILES = 64;
4
+ export const MAX_PENDING_FILENAME_LENGTH = 256;
5
+ export const MAX_DEGRADED_ERRORS = 8;
6
+ export const MAX_STATUS_TEXT_LENGTH = 512;
7
+ const TRUNCATION_SUFFIX = "... [truncated]";
8
+ const MAX_STATUS_CAUSES = 5;
9
+ const MAX_PENDING_INPUT_SCAN = MAX_PENDING_FILES * 4;
10
+ const MAX_ERROR_INPUT_SCAN = MAX_DEGRADED_ERRORS * 4;
11
+ function isCompilerStatusState(value) {
12
+ return value === "ready" || value === "rebuilding" || value === "failed";
13
+ }
14
+ function isIndexStatusState(value) {
15
+ return value === "disabled" || value === "ready" || value === "rebuilding" || value === "failed";
16
+ }
17
+ function isWatcherStatusState(value) {
18
+ return value === "disabled" || value === "ready" || value === "failed";
19
+ }
20
+ function isSynchronizationCause(value) {
21
+ return value === "source_change" || value === "config_change" || value === "compiler_rebuild";
22
+ }
23
+ function canonicalIdentityPath(value) {
24
+ const normalized = value.trim().replaceAll("\\", "/");
25
+ const hasLeadingSlash = normalized.startsWith("/");
26
+ const windowsDrive = /^[A-Za-z]:\//.test(normalized);
27
+ const segments = normalized.split("/");
28
+ const result = [];
29
+ for (const segment of segments) {
30
+ if (!segment || segment === ".")
31
+ continue;
32
+ if (segment === ".." && result.length > 0 && result[result.length - 1] !== "..") {
33
+ result.pop();
34
+ continue;
35
+ }
36
+ result.push(segment);
37
+ }
38
+ const joined = result.join("/");
39
+ if (hasLeadingSlash)
40
+ return `/${joined}`;
41
+ if (windowsDrive)
42
+ return joined;
43
+ return joined || ".";
44
+ }
45
+ function digest(prefix, value) {
46
+ return `${prefix}_${createHash("sha256").update(value, "utf8").digest("hex").slice(0, 20)}`;
47
+ }
48
+ function fingerprintDigest(prefix, value) {
49
+ return `${prefix}_${createHash("sha256").update(value, "utf8").digest("hex")}`;
50
+ }
51
+ function normalizeIdentityToken(value, prefix) {
52
+ const expected = new RegExp(`^${prefix}_[0-9a-f]{20}$`);
53
+ return typeof value === "string" && expected.test(value) ? value : digest(prefix, "[redacted]");
54
+ }
55
+ function normalizeProjectIdentity(value) {
56
+ const record = typeof value === "object" && value !== null
57
+ ? value
58
+ : {};
59
+ return {
60
+ project_id: normalizeIdentityToken(record.project_id, "project"),
61
+ config_id: record.config_id === null || record.config_id === undefined
62
+ ? null
63
+ : normalizeIdentityToken(record.config_id, "config"),
64
+ };
65
+ }
66
+ export function createProjectIdentity(input) {
67
+ if (typeof input !== "object" ||
68
+ input === null ||
69
+ typeof input.projectRoot !== "string" ||
70
+ (input.configPath !== undefined && typeof input.configPath !== "string")) {
71
+ throw new Error("Project identity input must contain string paths.");
72
+ }
73
+ const projectRoot = canonicalIdentityPath(input.projectRoot);
74
+ const configPath = input.configPath === undefined ? null : canonicalIdentityPath(input.configPath);
75
+ return {
76
+ project_id: digest("project", projectRoot),
77
+ config_id: configPath === null ? null : digest("config", configPath),
78
+ };
79
+ }
80
+ function isFiniteCount(value) {
81
+ return (typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0);
82
+ }
83
+ function boundedCount(value) {
84
+ if (!isFiniteCount(value))
85
+ return 0;
86
+ return value;
87
+ }
88
+ function redactAbsolutePaths(value) {
89
+ const traversalMarker = "__TRAVERSAL_PATH_REDACTED__";
90
+ const quotedTraversalPath = /(?<=["'`])(?:[^"'`\n\r]*[\\/])?\.\.[\\/][^"'`\n\r]*?(?=["'`])/g;
91
+ const labelledTraversalPath = /([=:]\s+)([^"'`:\n\r;,]*[ \t]+[^"'`:\n\r;,/\\]+[\\/]\.\.[\\/][^"'`:\n\r]*?)(?=\s*[:;,\n\r"'`)\]}]|(?:\.(?=\s|$))|$)/g;
92
+ const diagnosticTraversalPath = /(\b(?:read|open|resolve|watch|parse)\s+)([^"'`:\n\r;,]*[\\/]\.\.[\\/][^"'`:\n\r]*?)(?=\s*[:;,\n\r"'`)\]}]|(?:\.(?=\s|$))|$)/gi;
93
+ const spacedTraversalPath = /(?<=\s)([^"'`:\n\r;,]*[ \t]+[^"'`:\n\r;,/\\]+[\\/]\.\.[\\/][^"'`:\n\r]*?)(?=\s*[:;,\n\r"'`)\]}]|(?:\.(?=\s|$))|$)/g;
94
+ const relativeTraversalPath = /(?:[^\s"'`:/${}\\]+[\\/])+\.\.[\\/][^"'`:\n\r]*?(?=\s*[:;,\n\r"'`)\]}]|(?:\.(?=\s|$))|$)/g;
95
+ const directTraversalPath = /(?:^|(?<=[\s(]))\.\.[\\/][^"'`:\n\r]*?(?=\s*[:;,\n\r"'`)\]}]|(?:\.(?=\s|$))|$)/g;
96
+ const uncPath = /(?:\\\\|\/{2})[^"'`\n\r]*?(?=\s*(?::(?=\s|$)|[;,\n\r"'`)\]}])|(?:\.(?=\s|$))|$)/g;
97
+ const drivePath = /\b[A-Za-z]:[^"'`\n\r]*?(?=\s*(?::(?=\s|$)|[;,\n\r"'`)\]}])|(?:\.(?=\s|$))|$)/g;
98
+ const posixPath = /(?<![A-Za-z0-9_./-])\/(?!\/)[^"'`\n\r]*?(?=\s*(?::(?=\s|$)|[;,\n\r"'`)\]}])|(?:\.(?=\s|$))|$)/g;
99
+ const redacted = value
100
+ .replace(quotedTraversalPath, traversalMarker)
101
+ .replace(labelledTraversalPath, `$1${traversalMarker}`)
102
+ .replace(diagnosticTraversalPath, `$1${traversalMarker}`)
103
+ .replace(spacedTraversalPath, traversalMarker)
104
+ .replace(relativeTraversalPath, traversalMarker)
105
+ .replace(directTraversalPath, traversalMarker);
106
+ return redacted
107
+ .replace(drivePath, "[path-redacted]")
108
+ .replace(uncPath, "[path-redacted]")
109
+ .replace(posixPath, "[path-redacted]")
110
+ .replaceAll(traversalMarker, "[path-redacted]");
111
+ }
112
+ function redactSensitiveText(value) {
113
+ return value
114
+ .replace(/(?!(?:\bauthorization[ \t]*[:=])[ \t]*\[REDACTED\])(\bauthorization[ \t]*[:=])([ \t]*)[^\r\n;]+/gi, "$1$2[REDACTED]")
115
+ .replace(/(?!(?:\bbearer)[ \t]+\[REDACTED\])\bbearer[ \t]+[^\r\n;]+/gi, "Bearer [REDACTED]")
116
+ .replace(/\b(api[-_ ]?key|access[-_ ]?token|refresh[-_ ]?token|token|password|secret|credential|connection[-_ ]?string)\b\s*[:=]\s*(?!\[REDACTED\])(?:"[^"]*"|'[^']*'|[^\s,;}\]]+)/gi, "$1=[REDACTED]")
117
+ .replace(/\b([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^\s"'`]+@/g, "$1[REDACTED]@")
118
+ .replace(/\b(?:sk|ghp|github_pat|xox[baprs]-|AIza)[A-Za-z0-9_-]{8,}\b/g, "[REDACTED]");
119
+ }
120
+ function boundedText(value) {
121
+ const text = typeof value === "string" ? value : "[invalid-error]";
122
+ const redacted = redactSensitiveText(redactAbsolutePaths(text));
123
+ return redacted.length <= MAX_STATUS_TEXT_LENGTH
124
+ ? { value: redacted, truncated: false }
125
+ : {
126
+ value: `${redacted.slice(0, MAX_STATUS_TEXT_LENGTH - TRUNCATION_SUFFIX.length)}${TRUNCATION_SUFFIX}`,
127
+ truncated: true,
128
+ };
129
+ }
130
+ function projectRelativePath(value) {
131
+ let normalized = value.trim().replaceAll("\\", "/");
132
+ const quote = normalized[0];
133
+ if (normalized.length >= 2 &&
134
+ (quote === '"' || quote === "'" || quote === "`") &&
135
+ normalized.endsWith(quote)) {
136
+ normalized = normalized.slice(1, -1).trim();
137
+ }
138
+ if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)) {
139
+ return "[absolute-path-redacted]";
140
+ }
141
+ const segments = normalized.split("/");
142
+ if (segments.some((segment) => segment === "..")) {
143
+ return "[path-redacted]";
144
+ }
145
+ return segments.filter((segment) => segment.length > 0 && segment !== ".").join("/") || ".";
146
+ }
147
+ function validateArrayShape(value, limit) {
148
+ if (!Array.isArray(value))
149
+ return { valid: false, truncated: false };
150
+ if (Object.getPrototypeOf(value) !== Array.prototype)
151
+ return { valid: false, truncated: false };
152
+ if (Object.getOwnPropertySymbols(value).length > 0)
153
+ return { valid: false, truncated: false };
154
+ const end = Math.min(value.length, limit);
155
+ for (let index = 0; index < end; index += 1) {
156
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
157
+ if (descriptor === undefined ||
158
+ !("value" in descriptor) ||
159
+ typeof descriptor.value !== "string") {
160
+ return { valid: false, truncated: value.length > end };
161
+ }
162
+ }
163
+ return { valid: true, truncated: value.length > end };
164
+ }
165
+ function collectStringPrefix(value, limit) {
166
+ const shape = validateArrayShape(value, limit);
167
+ if (!Array.isArray(value))
168
+ return { values: [], ...shape };
169
+ const end = Math.min(value.length, limit);
170
+ const values = [];
171
+ for (let index = 0; index < end; index += 1) {
172
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
173
+ if (descriptor !== undefined && "value" in descriptor && typeof descriptor.value === "string") {
174
+ values.push(descriptor.value);
175
+ }
176
+ }
177
+ return { values, ...shape };
178
+ }
179
+ function boundedPendingFilename(value) {
180
+ if (value.length <= MAX_PENDING_FILENAME_LENGTH) {
181
+ return { value, truncated: false };
182
+ }
183
+ return {
184
+ value: `${value.slice(0, MAX_PENDING_FILENAME_LENGTH - TRUNCATION_SUFFIX.length)}${TRUNCATION_SUFFIX}`,
185
+ truncated: true,
186
+ };
187
+ }
188
+ function boundedPendingFiles(files) {
189
+ const collected = collectStringPrefix(files, MAX_PENDING_INPUT_SCAN);
190
+ const unique = [];
191
+ const seen = new Set();
192
+ for (const file of collected.values) {
193
+ const normalized = projectRelativePath(file);
194
+ if (seen.has(normalized))
195
+ continue;
196
+ seen.add(normalized);
197
+ unique.push(normalized);
198
+ if (unique.length > MAX_PENDING_FILES)
199
+ break;
200
+ }
201
+ const bounded = unique.slice(0, MAX_PENDING_FILES).map(boundedPendingFilename);
202
+ const recordTruncated = collected.truncated || unique.length > MAX_PENDING_FILES;
203
+ const filenameTruncated = bounded.some((file) => file.truncated);
204
+ return {
205
+ files: bounded.slice(0, MAX_PENDING_FILES).map((file) => file.value),
206
+ recordTruncation: createTruncationMetadata(recordTruncated, recordTruncated ? "record_limit" : null),
207
+ filenameTruncation: createTruncationMetadata(filenameTruncated, filenameTruncated ? "serialization_limit" : null),
208
+ };
209
+ }
210
+ function mergePendingFiles(current, incoming) {
211
+ const currentPrefix = collectStringPrefix(current, MAX_PENDING_INPUT_SCAN);
212
+ const incomingPrefix = collectStringPrefix(incoming, MAX_PENDING_INPUT_SCAN);
213
+ const merged = boundedPendingFiles([...currentPrefix.values, ...incomingPrefix.values]);
214
+ const recordTruncated = merged.recordTruncation.truncated || currentPrefix.truncated || incomingPrefix.truncated;
215
+ return {
216
+ ...merged,
217
+ recordTruncation: createTruncationMetadata(recordTruncated, recordTruncated ? "record_limit" : null),
218
+ };
219
+ }
220
+ function appendCause(current, cause) {
221
+ if (current.includes(cause))
222
+ return current;
223
+ if (current.length >= MAX_STATUS_CAUSES)
224
+ return current;
225
+ return [...current, cause];
226
+ }
227
+ function removeCause(current, cause) {
228
+ return current.filter((currentCause) => currentCause !== cause);
229
+ }
230
+ function appendError(current, error) {
231
+ const bounded = boundedText(error);
232
+ const currentErrors = collectStringPrefix(current, MAX_DEGRADED_ERRORS).values;
233
+ const next = currentErrors.includes(bounded.value)
234
+ ? currentErrors
235
+ : [...currentErrors, bounded.value];
236
+ return {
237
+ errors: next.slice(-MAX_DEGRADED_ERRORS),
238
+ countTruncated: next.length > MAX_DEGRADED_ERRORS,
239
+ textTruncated: bounded.truncated,
240
+ };
241
+ }
242
+ function boundedErrors(errors) {
243
+ let current = [];
244
+ let countTruncated = false;
245
+ let textTruncated = false;
246
+ const collected = collectStringPrefix(errors, MAX_ERROR_INPUT_SCAN);
247
+ for (const error of collected.values) {
248
+ const appended = appendError(current, error);
249
+ current = appended.errors;
250
+ countTruncated ||= appended.countTruncated;
251
+ textTruncated ||= appended.textTruncated;
252
+ }
253
+ countTruncated ||= collected.truncated;
254
+ return {
255
+ errors: current,
256
+ collectionValid: collected.valid,
257
+ countTruncated,
258
+ textTruncated,
259
+ };
260
+ }
261
+ function emptyTruncationMetadata() {
262
+ return createTruncationMetadata(false);
263
+ }
264
+ function mergeTruncation(current, incoming, reason) {
265
+ const truncated = current.truncated || incoming.truncated;
266
+ return createTruncationMetadata(truncated, truncated ? reason : null);
267
+ }
268
+ function errorTruncation(countTruncated, textTruncated) {
269
+ if (countTruncated)
270
+ return createTruncationMetadata(true, "record_limit");
271
+ if (textTruncated)
272
+ return createTruncationMetadata(true, "serialization_limit");
273
+ return emptyTruncationMetadata();
274
+ }
275
+ function nonEmptyFingerprint(value, prefix) {
276
+ if (typeof value !== "string")
277
+ return null;
278
+ const normalized = value.trim();
279
+ if (normalized.length === 0)
280
+ return null;
281
+ const canonical = new RegExp(`^${prefix}_[0-9a-f]{64}$`);
282
+ return canonical.test(value) ? value : fingerprintDigest(prefix, value);
283
+ }
284
+ function normalizedTimestamp(value) {
285
+ if (typeof value !== "string")
286
+ return null;
287
+ const normalized = value.trim();
288
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?Z$/.exec(normalized);
289
+ if (match === null)
290
+ return null;
291
+ const year = Number(match[1]);
292
+ const month = Number(match[2]);
293
+ const day = Number(match[3]);
294
+ const hour = Number(match[4]);
295
+ const minute = Number(match[5]);
296
+ const second = Number(match[6]);
297
+ const millisecond = match[7] === undefined ? 0 : Number(match[7]);
298
+ const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
299
+ const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
300
+ return month >= 1 &&
301
+ month <= 12 &&
302
+ day >= 1 &&
303
+ day <= daysInMonth[month - 1] &&
304
+ hour <= 23 &&
305
+ minute <= 59 &&
306
+ second <= 59 &&
307
+ millisecond <= 999
308
+ ? normalized
309
+ : null;
310
+ }
311
+ function statusWith(status, changes) {
312
+ return { ...status, ...changes };
313
+ }
314
+ function normalizeTruncation(current, derived, fallbackReason, acceptedReasons = [fallbackReason]) {
315
+ const record = typeof current === "object" && current !== null
316
+ ? current
317
+ : {};
318
+ const currentTruncated = record.truncated === true;
319
+ const currentReason = isTruncationReason(record.reason) && acceptedReasons.includes(record.reason)
320
+ ? record.reason
321
+ : null;
322
+ const truncated = currentTruncated || derived.truncated;
323
+ if (!truncated)
324
+ return emptyTruncationMetadata();
325
+ return createTruncationMetadata(true, currentReason ?? derived.reason ?? fallbackReason);
326
+ }
327
+ function isValidTruncationMetadata(value) {
328
+ if (typeof value !== "object" || value === null || Array.isArray(value))
329
+ return false;
330
+ const record = value;
331
+ if (typeof record.truncated !== "boolean")
332
+ return false;
333
+ return record.truncated ? isTruncationReason(record.reason) : record.reason === null;
334
+ }
335
+ function invalidProjectStatus() {
336
+ return {
337
+ project: normalizeProjectIdentity(null),
338
+ state: "degraded",
339
+ causes: [],
340
+ sourceCount: 0,
341
+ indexedCount: 0,
342
+ pendingFiles: [],
343
+ pendingFilesTruncated: false,
344
+ pendingFilesTruncation: emptyTruncationMetadata(),
345
+ pendingFilesFilenameTruncation: emptyTruncationMetadata(),
346
+ compiler: { state: "failed" },
347
+ index: { state: "disabled" },
348
+ watcher: { state: "disabled" },
349
+ lastSuccessfulSyncAt: null,
350
+ lastSuccessfulIndexAt: null,
351
+ sourceSnapshotFingerprint: null,
352
+ configSnapshotFingerprint: null,
353
+ canonicalSnapshotFingerprint: null,
354
+ degradedErrors: ["[invalid-status]"],
355
+ degradedErrorsTruncation: emptyTruncationMetadata(),
356
+ degradedErrorsTextTruncation: emptyTruncationMetadata(),
357
+ boundaryInvalid: true,
358
+ };
359
+ }
360
+ function normalizeProjectStatus(status) {
361
+ if (typeof status !== "object" || status === null || Array.isArray(status)) {
362
+ return invalidProjectStatus();
363
+ }
364
+ const causeCollection = collectStringPrefix(status.causes, MAX_STATUS_CAUSES * 4);
365
+ const causesFromInput = causeCollection.values
366
+ .filter(isFreshnessCause)
367
+ .filter((cause, index, causes) => causes.indexOf(cause) === index);
368
+ const hasMalformedCause = !causeCollection.valid ||
369
+ causeCollection.truncated ||
370
+ causeCollection.values.some((cause) => !isFreshnessCause(cause));
371
+ const rawIndexState = status.index?.state;
372
+ const rawWatcherState = status.watcher?.state;
373
+ const rawCompilerState = status.compiler?.state;
374
+ const malformedCount = !isFiniteCount(status.sourceCount) || !isFiniteCount(status.indexedCount);
375
+ const indexFailed = !isIndexStatusState(rawIndexState) || rawIndexState === "failed";
376
+ const watcherFailed = !isWatcherStatusState(rawWatcherState) || rawWatcherState === "failed";
377
+ const malformedComponentState = !isIndexStatusState(rawIndexState) || !isWatcherStatusState(rawWatcherState);
378
+ const compilerValid = isCompilerStatusState(rawCompilerState);
379
+ const compilerFailed = rawCompilerState === "failed";
380
+ const compilerRebuilding = rawCompilerState === "rebuilding";
381
+ const sourceSnapshotFingerprint = nonEmptyFingerprint(status.sourceSnapshotFingerprint, "source");
382
+ const configSnapshotFingerprint = nonEmptyFingerprint(status.configSnapshotFingerprint, "config");
383
+ const canonicalSnapshotFingerprint = nonEmptyFingerprint(status.canonicalSnapshotFingerprint, "snapshot");
384
+ const lastSuccessfulSyncAt = normalizedTimestamp(status.lastSuccessfulSyncAt);
385
+ const hasCompleteSnapshotEvidence = canonicalSnapshotFingerprint !== null ||
386
+ (sourceSnapshotFingerprint !== null && configSnapshotFingerprint !== null);
387
+ const hasActiveInvalidationCause = causesFromInput.some((cause) => cause === "source_change" || cause === "config_change" || cause === "compiler_rebuild");
388
+ let causes = causesFromInput;
389
+ if (indexFailed)
390
+ causes = appendCause(causes, "index_failure");
391
+ if (watcherFailed)
392
+ causes = appendCause(causes, "watcher_failure");
393
+ const hasComponentFailure = causes.includes("index_failure") ||
394
+ causes.includes("watcher_failure") ||
395
+ indexFailed ||
396
+ watcherFailed ||
397
+ (compilerFailed && status.state === "fresh");
398
+ const pendingCollection = collectStringPrefix(status.pendingFiles, MAX_PENDING_INPUT_SCAN);
399
+ const pending = boundedPendingFiles(status.pendingFiles);
400
+ const bounded = boundedErrors(status.degradedErrors);
401
+ const malformedTruncation = [
402
+ status.pendingFilesTruncation,
403
+ status.pendingFilesFilenameTruncation,
404
+ status.degradedErrorsTruncation,
405
+ status.degradedErrorsTextTruncation,
406
+ ].some((metadata) => !isValidTruncationMetadata(metadata));
407
+ const boundaryInvalid = status.boundaryInvalid === true ||
408
+ hasMalformedCause ||
409
+ malformedCount ||
410
+ malformedComponentState ||
411
+ !pendingCollection.valid ||
412
+ !bounded.collectionValid ||
413
+ malformedTruncation ||
414
+ !isSnapshotState(status.state) ||
415
+ !compilerValid;
416
+ const pendingFilesTruncation = normalizeTruncation(status.pendingFilesTruncation, pending.recordTruncation, "record_limit");
417
+ const pendingFilesFilenameTruncation = normalizeTruncation(status.pendingFilesFilenameTruncation, pending.filenameTruncation, "serialization_limit");
418
+ const degradedErrorsTruncation = normalizeTruncation(status.degradedErrorsTruncation, errorTruncation(bounded.countTruncated, bounded.textTruncated), "record_limit", ["record_limit", "serialization_limit"]);
419
+ const degradedErrorsTextTruncation = normalizeTruncation(status.degradedErrorsTextTruncation, createTruncationMetadata(bounded.textTruncated, bounded.textTruncated ? "serialization_limit" : null), "serialization_limit");
420
+ const hasPendingEvidence = pending.files.length > 0 ||
421
+ pendingFilesTruncation.truncated ||
422
+ pendingFilesFilenameTruncation.truncated;
423
+ return {
424
+ ...status,
425
+ project: normalizeProjectIdentity(status.project),
426
+ state: hasComponentFailure || boundaryInvalid
427
+ ? "degraded"
428
+ : status.state === "fresh" &&
429
+ (!hasCompleteSnapshotEvidence ||
430
+ lastSuccessfulSyncAt === null ||
431
+ hasActiveInvalidationCause ||
432
+ hasPendingEvidence ||
433
+ compilerRebuilding ||
434
+ bounded.errors.length > 0)
435
+ ? "pending"
436
+ : status.state,
437
+ causes,
438
+ boundaryInvalid,
439
+ sourceCount: boundedCount(status.sourceCount),
440
+ indexedCount: 0,
441
+ index: { state: "disabled" },
442
+ compiler: { state: compilerValid ? rawCompilerState : "failed" },
443
+ watcher: { state: watcherFailed ? "failed" : rawWatcherState },
444
+ lastSuccessfulSyncAt,
445
+ lastSuccessfulIndexAt: null,
446
+ sourceSnapshotFingerprint,
447
+ configSnapshotFingerprint,
448
+ canonicalSnapshotFingerprint,
449
+ pendingFiles: pending.files,
450
+ pendingFilesTruncated: pendingFilesTruncation.truncated || pendingFilesFilenameTruncation.truncated,
451
+ pendingFilesTruncation,
452
+ pendingFilesFilenameTruncation,
453
+ degradedErrors: bounded.errors,
454
+ degradedErrorsTruncation,
455
+ degradedErrorsTextTruncation,
456
+ };
457
+ }
458
+ function hasActiveIndexFailure(status) {
459
+ return status.causes.includes("index_failure");
460
+ }
461
+ function hasActiveWatcherFailure(status) {
462
+ return status.causes.includes("watcher_failure");
463
+ }
464
+ function hasActiveComponentFailure(status) {
465
+ return (hasActiveIndexFailure(status) ||
466
+ hasActiveWatcherFailure(status) ||
467
+ status.boundaryInvalid === true ||
468
+ status.index.state === "failed" ||
469
+ status.watcher.state === "failed");
470
+ }
471
+ function stateAfterRecovery(status) {
472
+ return hasActiveComponentFailure(status) ? "degraded" : "pending";
473
+ }
474
+ function markInvalidTransition(status, error) {
475
+ const appended = appendError(status.degradedErrors, error);
476
+ const textTruncated = status.degradedErrorsTextTruncation.truncated || appended.textTruncated;
477
+ const countTruncated = status.degradedErrorsTruncation.reason === "record_limit" || appended.countTruncated;
478
+ return statusWith(status, {
479
+ state: "degraded",
480
+ boundaryInvalid: true,
481
+ degradedErrors: appended.errors,
482
+ degradedErrorsTruncation: errorTruncation(countTruncated, textTruncated),
483
+ degradedErrorsTextTruncation: createTruncationMetadata(textTruncated, textTruncated ? "serialization_limit" : null),
484
+ });
485
+ }
486
+ export function createInitialProjectStatus(project, options = {}) {
487
+ const pendingInput = options.pendingFiles === undefined ? [] : options.pendingFiles;
488
+ const pendingInputCollection = collectStringPrefix(pendingInput, MAX_PENDING_INPUT_SCAN);
489
+ const pending = boundedPendingFiles(pendingInput);
490
+ const watcherState = options.watcherState === undefined ? "disabled" : options.watcherState;
491
+ const initial = {
492
+ project: { ...project },
493
+ state: "pending",
494
+ causes: pending.files.length > 0 ? ["source_change"] : [],
495
+ sourceCount: boundedCount(options.sourceCount),
496
+ indexedCount: 0,
497
+ pendingFiles: pending.files,
498
+ pendingFilesTruncated: pending.recordTruncation.truncated || pending.filenameTruncation.truncated,
499
+ pendingFilesTruncation: pending.recordTruncation,
500
+ pendingFilesFilenameTruncation: pending.filenameTruncation,
501
+ compiler: { state: "ready" },
502
+ index: { state: "disabled" },
503
+ watcher: { state: watcherState },
504
+ lastSuccessfulSyncAt: null,
505
+ lastSuccessfulIndexAt: null,
506
+ sourceSnapshotFingerprint: null,
507
+ configSnapshotFingerprint: null,
508
+ canonicalSnapshotFingerprint: null,
509
+ degradedErrors: [],
510
+ degradedErrorsTruncation: emptyTruncationMetadata(),
511
+ degradedErrorsTextTruncation: emptyTruncationMetadata(),
512
+ boundaryInvalid: !pendingInputCollection.valid ||
513
+ (options.sourceCount !== undefined && !isFiniteCount(options.sourceCount)) ||
514
+ (options.indexedCount !== undefined && !isFiniteCount(options.indexedCount)),
515
+ };
516
+ return normalizeProjectStatus(initial);
517
+ }
518
+ export function transitionProjectStatus(inputStatus, event) {
519
+ const status = normalizeProjectStatus(inputStatus);
520
+ const runtimeEvent = event;
521
+ if (typeof runtimeEvent !== "object" ||
522
+ runtimeEvent === null ||
523
+ typeof runtimeEvent.type !== "string") {
524
+ return markInvalidTransition(status, "[invalid-event]");
525
+ }
526
+ switch (event.type) {
527
+ case "source_changed": {
528
+ const eventFiles = collectStringPrefix(event.files, MAX_PENDING_INPUT_SCAN);
529
+ if (!eventFiles.valid)
530
+ return markInvalidTransition(status, "[invalid-files]");
531
+ const pending = mergePendingFiles(status.pendingFiles, event.files);
532
+ const recordTruncation = mergeTruncation(status.pendingFilesTruncation, pending.recordTruncation, "record_limit");
533
+ const filenameTruncation = mergeTruncation(status.pendingFilesFilenameTruncation, pending.filenameTruncation, "serialization_limit");
534
+ return statusWith(status, {
535
+ state: status.state === "degraded" ? "degraded" : "pending",
536
+ causes: appendCause(status.causes, "source_change"),
537
+ pendingFiles: pending.files,
538
+ pendingFilesTruncated: recordTruncation.truncated || filenameTruncation.truncated,
539
+ pendingFilesTruncation: recordTruncation,
540
+ pendingFilesFilenameTruncation: filenameTruncation,
541
+ });
542
+ }
543
+ case "config_changed":
544
+ return statusWith(status, {
545
+ state: status.state === "degraded" ? "degraded" : "pending",
546
+ causes: appendCause(status.causes, "config_change"),
547
+ });
548
+ case "compiler_rebuild_started":
549
+ return statusWith(status, {
550
+ state: status.state === "degraded" ? "degraded" : "rebuilding",
551
+ causes: appendCause(status.causes, "compiler_rebuild"),
552
+ compiler: { state: "rebuilding" },
553
+ index: {
554
+ state: hasActiveIndexFailure(status) || status.index.state === "disabled"
555
+ ? "disabled"
556
+ : "rebuilding",
557
+ },
558
+ });
559
+ case "sync_succeeded": {
560
+ if (!isFiniteCount(event.sourceCount) || !isFiniteCount(event.indexedCount)) {
561
+ return markInvalidTransition(status, "[invalid-count]");
562
+ }
563
+ const hasFailure = hasActiveComponentFailure(status);
564
+ const indexDisabled = hasActiveIndexFailure(status) || status.index.state === "disabled";
565
+ const sourceSnapshotFingerprint = nonEmptyFingerprint(event.sourceSnapshotFingerprint, "source");
566
+ const configSnapshotFingerprint = nonEmptyFingerprint(event.configSnapshotFingerprint, "config");
567
+ const canonicalSnapshotFingerprint = nonEmptyFingerprint(event.canonicalSnapshotFingerprint, "snapshot");
568
+ const hasSnapshotEvidence = sourceSnapshotFingerprint !== null ||
569
+ configSnapshotFingerprint !== null ||
570
+ canonicalSnapshotFingerprint !== null;
571
+ const hasCompleteSnapshotEvidence = canonicalSnapshotFingerprint !== null ||
572
+ (sourceSnapshotFingerprint !== null && configSnapshotFingerprint !== null);
573
+ const eventSyncAt = event.at === undefined ? status.lastSuccessfulSyncAt : normalizedTimestamp(event.at);
574
+ const nextSyncAt = event.at === undefined
575
+ ? status.lastSuccessfulSyncAt
576
+ : (normalizedTimestamp(event.at) ?? status.lastSuccessfulSyncAt);
577
+ const hasFreshEvidence = hasCompleteSnapshotEvidence && eventSyncAt !== null;
578
+ const canClearInvalidation = !hasFailure && hasFreshEvidence;
579
+ const pending = canClearInvalidation
580
+ ? boundedPendingFiles([])
581
+ : {
582
+ files: status.pendingFiles,
583
+ recordTruncation: status.pendingFilesTruncation,
584
+ filenameTruncation: status.pendingFilesFilenameTruncation,
585
+ };
586
+ const nextState = hasFailure ? "degraded" : canClearInvalidation ? "fresh" : "pending";
587
+ return statusWith(status, {
588
+ state: nextState,
589
+ causes: canClearInvalidation ? [] : status.causes,
590
+ sourceCount: boundedCount(event.sourceCount),
591
+ indexedCount: indexDisabled ? 0 : boundedCount(event.indexedCount),
592
+ pendingFiles: pending.files,
593
+ pendingFilesTruncated: pending.recordTruncation.truncated || pending.filenameTruncation.truncated,
594
+ pendingFilesTruncation: pending.recordTruncation,
595
+ pendingFilesFilenameTruncation: pending.filenameTruncation,
596
+ compiler: { state: "ready" },
597
+ index: {
598
+ state: indexDisabled
599
+ ? "disabled"
600
+ : hasFailure && status.index.state === "failed"
601
+ ? "failed"
602
+ : "ready",
603
+ },
604
+ lastSuccessfulSyncAt: canClearInvalidation ? nextSyncAt : status.lastSuccessfulSyncAt,
605
+ lastSuccessfulIndexAt: !hasFreshEvidence || indexDisabled || (hasFailure && status.index.state === "failed")
606
+ ? status.lastSuccessfulIndexAt
607
+ : (nextSyncAt ?? status.lastSuccessfulIndexAt),
608
+ sourceSnapshotFingerprint: hasSnapshotEvidence
609
+ ? (sourceSnapshotFingerprint ?? status.sourceSnapshotFingerprint)
610
+ : status.sourceSnapshotFingerprint,
611
+ configSnapshotFingerprint: hasSnapshotEvidence
612
+ ? (configSnapshotFingerprint ?? status.configSnapshotFingerprint)
613
+ : status.configSnapshotFingerprint,
614
+ canonicalSnapshotFingerprint: hasSnapshotEvidence
615
+ ? (canonicalSnapshotFingerprint ?? status.canonicalSnapshotFingerprint)
616
+ : status.canonicalSnapshotFingerprint,
617
+ degradedErrors: canClearInvalidation ? [] : status.degradedErrors,
618
+ degradedErrorsTruncation: canClearInvalidation
619
+ ? emptyTruncationMetadata()
620
+ : status.degradedErrorsTruncation,
621
+ degradedErrorsTextTruncation: canClearInvalidation
622
+ ? emptyTruncationMetadata()
623
+ : status.degradedErrorsTextTruncation,
624
+ });
625
+ }
626
+ case "sync_failed":
627
+ if (!isSynchronizationCause(event.cause)) {
628
+ return markInvalidTransition(status, event.error);
629
+ }
630
+ return statusWith(status, {
631
+ state: hasActiveComponentFailure(status) ? "degraded" : "stale",
632
+ causes: appendCause(status.causes, event.cause),
633
+ compiler: { state: "failed" },
634
+ });
635
+ case "stale_detected": {
636
+ const cause = event.cause === undefined ? "source_change" : event.cause;
637
+ const eventFiles = event.files === undefined
638
+ ? { valid: true }
639
+ : collectStringPrefix(event.files, MAX_PENDING_INPUT_SCAN);
640
+ if (!eventFiles.valid)
641
+ return markInvalidTransition(status, "[invalid-files]");
642
+ const pending = mergePendingFiles(status.pendingFiles, event.files);
643
+ const recordTruncation = mergeTruncation(status.pendingFilesTruncation, pending.recordTruncation, "record_limit");
644
+ const filenameTruncation = mergeTruncation(status.pendingFilesFilenameTruncation, pending.filenameTruncation, "serialization_limit");
645
+ const next = statusWith(status, {
646
+ state: hasActiveComponentFailure(status) ? "degraded" : "stale",
647
+ causes: isSynchronizationCause(cause) ? appendCause(status.causes, cause) : status.causes,
648
+ pendingFiles: pending.files,
649
+ pendingFilesTruncated: recordTruncation.truncated || filenameTruncation.truncated,
650
+ pendingFilesTruncation: recordTruncation,
651
+ pendingFilesFilenameTruncation: filenameTruncation,
652
+ });
653
+ return isSynchronizationCause(cause) ? next : markInvalidTransition(next, "[invalid-cause]");
654
+ }
655
+ case "index_failed": {
656
+ const appended = appendError(status.degradedErrors, event.error);
657
+ const textTruncated = status.degradedErrorsTextTruncation.truncated || appended.textTruncated;
658
+ const countTruncated = status.degradedErrorsTruncation.reason === "record_limit" || appended.countTruncated;
659
+ return statusWith(status, {
660
+ state: "degraded",
661
+ causes: appendCause(status.causes, "index_failure"),
662
+ index: { state: "disabled" },
663
+ degradedErrors: appended.errors,
664
+ degradedErrorsTruncation: errorTruncation(countTruncated, textTruncated),
665
+ degradedErrorsTextTruncation: createTruncationMetadata(textTruncated, textTruncated ? "serialization_limit" : null),
666
+ });
667
+ }
668
+ case "watcher_failed": {
669
+ const appended = appendError(status.degradedErrors, event.error);
670
+ const textTruncated = status.degradedErrorsTextTruncation.truncated || appended.textTruncated;
671
+ const countTruncated = status.degradedErrorsTruncation.reason === "record_limit" || appended.countTruncated;
672
+ return statusWith(status, {
673
+ state: "degraded",
674
+ causes: appendCause(status.causes, "watcher_failure"),
675
+ watcher: { state: "failed" },
676
+ degradedErrors: appended.errors,
677
+ degradedErrorsTruncation: errorTruncation(countTruncated, textTruncated),
678
+ degradedErrorsTextTruncation: createTruncationMetadata(textTruncated, textTruncated ? "serialization_limit" : null),
679
+ });
680
+ }
681
+ case "component_recovered":
682
+ if (event.component !== "index" && event.component !== "watcher") {
683
+ return markInvalidTransition(status, "[invalid-component]");
684
+ }
685
+ return recoverComponent(status, event.component);
686
+ case "index_recovered":
687
+ return recoverComponent(status, "index");
688
+ case "watcher_recovered":
689
+ return recoverComponent(status, "watcher");
690
+ default:
691
+ return markInvalidTransition(status, "[invalid-event]");
692
+ }
693
+ }
694
+ function recoverComponent(status, component) {
695
+ const recovered = component === "index"
696
+ ? statusWith(status, {
697
+ index: { state: "disabled" },
698
+ causes: removeCause(status.causes, "index_failure"),
699
+ })
700
+ : statusWith(status, {
701
+ watcher: { state: "ready" },
702
+ causes: removeCause(status.causes, "watcher_failure"),
703
+ });
704
+ const state = stateAfterRecovery(recovered);
705
+ return statusWith(recovered, {
706
+ state,
707
+ degradedErrors: state === "degraded" ? recovered.degradedErrors : [],
708
+ degradedErrorsTruncation: state === "degraded" ? recovered.degradedErrorsTruncation : emptyTruncationMetadata(),
709
+ degradedErrorsTextTruncation: state === "degraded" ? recovered.degradedErrorsTextTruncation : emptyTruncationMetadata(),
710
+ });
711
+ }
712
+ export function projectStatusToProjection(status) {
713
+ const normalized = normalizeProjectStatus(status);
714
+ return {
715
+ project: { ...normalized.project },
716
+ state: normalized.state,
717
+ causes: [...normalized.causes],
718
+ source_count: normalized.sourceCount,
719
+ indexed_count: 0,
720
+ pending_files: [...normalized.pendingFiles],
721
+ pending_files_truncated: normalized.pendingFilesTruncated,
722
+ pending_files_truncation: { ...normalized.pendingFilesTruncation },
723
+ pending_files_filename_truncation: { ...normalized.pendingFilesFilenameTruncation },
724
+ compiler: { ...normalized.compiler },
725
+ index: { state: "disabled" },
726
+ watcher: { ...normalized.watcher },
727
+ last_successful_sync_at: normalized.lastSuccessfulSyncAt,
728
+ last_successful_index_at: null,
729
+ source_snapshot_fingerprint: normalized.sourceSnapshotFingerprint,
730
+ config_snapshot_fingerprint: normalized.configSnapshotFingerprint,
731
+ canonical_snapshot_fingerprint: normalized.canonicalSnapshotFingerprint,
732
+ degraded_errors: [...normalized.degradedErrors],
733
+ degraded_errors_truncation: { ...normalized.degradedErrorsTruncation },
734
+ degraded_errors_text_truncation: { ...normalized.degradedErrorsTextTruncation },
735
+ };
736
+ }
@@ -2,7 +2,8 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { Project } from "ts-morph";
4
4
  import { findDeclaration } from "./symbols.js";
5
- import { createConfigSnapshot } from "./workspace.js";
5
+ import { createConfigSnapshot, createWorkspaceSnapshot } from "./workspace.js";
6
+ import { createInitialProjectStatus, createProjectIdentity, projectStatusToProjection, transitionProjectStatus, } from "./project-status.js";
6
7
  const MAX_PROJECT_SESSIONS = 8;
7
8
  const projectSessions = new Map();
8
9
  const projectRoots = new WeakMap();
@@ -25,7 +26,8 @@ function buildProjectContext(tsConfigFilePath) {
25
26
  });
26
27
  const projectRoot = path.dirname(tsConfigFilePath);
27
28
  projectRoots.set(project, projectRoot);
28
- return { project, projectRoot, tsConfigFilePath };
29
+ const status = createInitialProjectStatus(createProjectIdentity({ projectRoot, configPath: tsConfigFilePath }), { sourceCount: project.getSourceFiles().length });
30
+ return { project, projectRoot, tsConfigFilePath, status };
29
31
  }
30
32
  export function createFreshProject(projectRoot) {
31
33
  return buildProjectContext(resolveTsConfigPath(projectRoot));
@@ -50,41 +52,113 @@ function getOrCreateSession(projectRoot) {
50
52
  configDigest: createConfigSnapshot(tsConfigFilePath).digest,
51
53
  queue: Promise.resolve(),
52
54
  activeOperations: 0,
55
+ runningOperations: 0,
56
+ queuedOperations: 0,
53
57
  lastAccessedAt: Date.now(),
54
58
  };
55
59
  projectSessions.set(tsConfigFilePath, session);
56
60
  return session;
57
61
  }
62
+ function getQueueStatus(session) {
63
+ return {
64
+ state: session.runningOperations > 0 ? "running" : session.queuedOperations > 0 ? "queued" : "idle",
65
+ active_operations: session.runningOperations,
66
+ queued_operations: session.queuedOperations,
67
+ };
68
+ }
58
69
  async function synchronizeSession(session) {
59
- const currentConfigDigest = createConfigSnapshot(session.context.tsConfigFilePath).digest;
60
- if (currentConfigDigest !== session.configDigest) {
61
- session.context = buildProjectContext(session.context.tsConfigFilePath);
62
- session.configDigest = currentConfigDigest;
63
- return;
70
+ let syncFailureCause = "compiler_rebuild";
71
+ let currentConfigDigest;
72
+ try {
73
+ currentConfigDigest = createConfigSnapshot(session.context.tsConfigFilePath).digest;
74
+ if (currentConfigDigest !== session.configDigest) {
75
+ let status = transitionProjectStatus(session.context.status, { type: "config_changed" });
76
+ status = transitionProjectStatus(status, { type: "compiler_rebuild_started" });
77
+ const rebuilt = buildProjectContext(session.context.tsConfigFilePath);
78
+ session.context = { ...rebuilt, status };
79
+ }
80
+ const { project, tsConfigFilePath } = session.context;
81
+ syncFailureCause = "source_change";
82
+ const refreshSourceFiles = async () => {
83
+ project.addSourceFilesFromTsConfig(tsConfigFilePath);
84
+ await Promise.all(project.getSourceFiles().map((sourceFile) => sourceFile.refreshFromFileSystem()));
85
+ };
86
+ await refreshSourceFiles();
87
+ const snapshot = createWorkspaceSnapshot(session.context);
88
+ if (snapshot.configDigest !== currentConfigDigest) {
89
+ syncFailureCause = "config_change";
90
+ throw new Error("Project configuration changed during synchronization. Retry the operation.");
91
+ }
92
+ await refreshSourceFiles();
93
+ const verificationSnapshot = createWorkspaceSnapshot(session.context);
94
+ if (verificationSnapshot.configDigest !== currentConfigDigest) {
95
+ syncFailureCause = "config_change";
96
+ throw new Error("Project configuration changed during synchronization. Retry the operation.");
97
+ }
98
+ if (verificationSnapshot.sourceDigest !== snapshot.sourceDigest) {
99
+ syncFailureCause = "source_change";
100
+ throw new Error("Project source changed during synchronization. Retry the operation.");
101
+ }
102
+ const status = transitionProjectStatus(session.context.status, {
103
+ type: "sync_succeeded",
104
+ sourceCount: verificationSnapshot.sourceFileCount,
105
+ indexedCount: 0,
106
+ sourceSnapshotFingerprint: `source_${verificationSnapshot.sourceDigest}`,
107
+ configSnapshotFingerprint: `config_${verificationSnapshot.configDigest}`,
108
+ canonicalSnapshotFingerprint: `snapshot_${verificationSnapshot.digest}`,
109
+ at: new Date().toISOString(),
110
+ });
111
+ session.context = { ...session.context, status };
112
+ session.configDigest = verificationSnapshot.configDigest;
113
+ }
114
+ catch (error) {
115
+ const status = transitionProjectStatus(session.context.status, {
116
+ type: "sync_failed",
117
+ cause: syncFailureCause,
118
+ error: error instanceof Error ? error.message : String(error),
119
+ });
120
+ session.context = { ...session.context, status };
121
+ throw error;
64
122
  }
65
- const { project, tsConfigFilePath } = session.context;
66
- project.addSourceFilesFromTsConfig(tsConfigFilePath);
67
- await Promise.all(project.getSourceFiles().map((sourceFile) => sourceFile.refreshFromFileSystem()));
68
123
  }
69
- export async function withProject(projectRoot, operation) {
70
- const session = getOrCreateSession(projectRoot);
124
+ async function runSessionWithSyncPolicy(session, operation, allowSynchronizationFailure) {
71
125
  session.activeOperations += 1;
126
+ session.queuedOperations += 1;
72
127
  let release;
73
128
  const previous = session.queue;
74
129
  session.queue = new Promise((resolve) => {
75
130
  release = resolve;
76
131
  });
77
132
  await previous;
133
+ session.queuedOperations -= 1;
134
+ session.runningOperations += 1;
78
135
  try {
79
- await synchronizeSession(session);
136
+ try {
137
+ await synchronizeSession(session);
138
+ }
139
+ catch (error) {
140
+ if (!allowSynchronizationFailure)
141
+ throw error;
142
+ }
80
143
  session.lastAccessedAt = Date.now();
81
144
  return await operation(session.context);
82
145
  }
83
146
  finally {
147
+ session.runningOperations -= 1;
84
148
  session.activeOperations -= 1;
85
149
  release?.();
86
150
  }
87
151
  }
152
+ export async function withProject(projectRoot, operation) {
153
+ return runSessionWithSyncPolicy(getOrCreateSession(projectRoot), operation, false);
154
+ }
155
+ export async function getProjectStatus(projectRoot) {
156
+ const session = getOrCreateSession(projectRoot);
157
+ return runSessionWithSyncPolicy(session, (context) => ({
158
+ ...projectStatusToProjection(context.status),
159
+ operation_queue: getQueueStatus(session),
160
+ }), true);
161
+ }
88
162
  // Compatibility API for internal scripts. MCP tools should use withProject().
89
163
  export function getProject(projectRoot) {
90
164
  return createFreshProject(projectRoot).project;
@@ -0,0 +1,159 @@
1
+ export const SNAPSHOT_STATES = Object.freeze([
2
+ "fresh",
3
+ "pending",
4
+ "stale",
5
+ "rebuilding",
6
+ "degraded",
7
+ ]);
8
+ export function isSnapshotState(value) {
9
+ return SNAPSHOT_STATES.some((state) => state === value);
10
+ }
11
+ export const FRESHNESS_CAUSES = Object.freeze([
12
+ "source_change",
13
+ "config_change",
14
+ "index_failure",
15
+ "watcher_failure",
16
+ "compiler_rebuild",
17
+ ]);
18
+ export function isFreshnessCause(value) {
19
+ return FRESHNESS_CAUSES.some((cause) => cause === value);
20
+ }
21
+ function assertIntegerAtLeast(value, minimum, name) {
22
+ if (!Number.isInteger(value) || value < minimum) {
23
+ throw new Error(`${name} must be an integer greater than or equal to ${minimum}.`);
24
+ }
25
+ }
26
+ export function createFileRange(offset, limit, totalLines) {
27
+ assertIntegerAtLeast(offset, 0, "File range offset");
28
+ assertIntegerAtLeast(limit, 1, "File range limit");
29
+ assertIntegerAtLeast(totalLines, 0, "File range total_lines");
30
+ return { offset, limit, total_lines: totalLines };
31
+ }
32
+ function projectRelativePath(file) {
33
+ let unquoted = file.trim();
34
+ const quote = unquoted[0];
35
+ if (unquoted.length >= 2 &&
36
+ (quote === '"' || quote === "'" || quote === "`") &&
37
+ unquoted.endsWith(quote)) {
38
+ unquoted = unquoted.slice(1, -1).trim();
39
+ }
40
+ const normalized = unquoted.replaceAll("\\", "/");
41
+ const relative = normalized.startsWith("./") ? normalized.slice(2) : normalized;
42
+ const segments = relative.split("/");
43
+ const isWindowsAbsolute = /^[A-Za-z]:/.test(relative);
44
+ if (relative.length === 0 ||
45
+ relative.startsWith("/") ||
46
+ isWindowsAbsolute ||
47
+ segments.some((segment) => segment.length === 0 || segment === ".." || segment === ".")) {
48
+ throw new Error("Source location file must be project-relative.");
49
+ }
50
+ return relative;
51
+ }
52
+ export function createSourceLocation(file, range) {
53
+ if (typeof file !== "string" || typeof range !== "object" || range === null) {
54
+ throw new Error("Source location input is invalid.");
55
+ }
56
+ assertIntegerAtLeast(range.start_line, 1, "Source location start_line");
57
+ if (range.start_column !== undefined) {
58
+ assertIntegerAtLeast(range.start_column, 1, "Source location start_column");
59
+ }
60
+ if (range.end_line !== undefined) {
61
+ assertIntegerAtLeast(range.end_line, range.start_line, "Source location end_line");
62
+ }
63
+ if (range.end_column !== undefined) {
64
+ assertIntegerAtLeast(range.end_column, 1, "Source location end_column");
65
+ }
66
+ if (range.end_line === range.start_line &&
67
+ range.start_column !== undefined &&
68
+ range.end_column !== undefined &&
69
+ range.end_column < range.start_column) {
70
+ throw new Error("Source location end_column must not precede start_column.");
71
+ }
72
+ return {
73
+ file: projectRelativePath(file),
74
+ start_line: range.start_line,
75
+ ...(range.start_column === undefined ? {} : { start_column: range.start_column }),
76
+ ...(range.end_line === undefined ? {} : { end_line: range.end_line }),
77
+ ...(range.end_column === undefined ? {} : { end_column: range.end_column }),
78
+ };
79
+ }
80
+ export const TRUNCATION_REASONS = Object.freeze([
81
+ "record_limit",
82
+ "byte_limit",
83
+ "depth_limit",
84
+ "edge_limit",
85
+ "invocation_limit",
86
+ "serialization_limit",
87
+ ]);
88
+ export function isTruncationReason(value) {
89
+ return TRUNCATION_REASONS.some((reason) => reason === value);
90
+ }
91
+ export function createTruncationMetadata(truncated, reason = null) {
92
+ if (reason !== null && !isTruncationReason(reason)) {
93
+ throw new Error(`Unknown truncation reason: ${String(reason)}.`);
94
+ }
95
+ if (truncated !== (reason !== null)) {
96
+ throw new Error("A truncated result requires a machine-readable reason, and a complete result does not.");
97
+ }
98
+ return { truncated, reason };
99
+ }
100
+ export function isJsonValue(value) {
101
+ const ancestors = new Set();
102
+ function visit(candidate) {
103
+ if (candidate === null)
104
+ return true;
105
+ if (typeof candidate === "string" || typeof candidate === "boolean")
106
+ return true;
107
+ if (typeof candidate === "number") {
108
+ return Number.isFinite(candidate) && !Object.is(candidate, -0);
109
+ }
110
+ if (typeof candidate !== "object")
111
+ return false;
112
+ if (ancestors.has(candidate))
113
+ return false;
114
+ ancestors.add(candidate);
115
+ try {
116
+ if ("toJSON" in candidate)
117
+ return false;
118
+ if (Array.isArray(candidate)) {
119
+ if (Object.getPrototypeOf(candidate) !== Array.prototype)
120
+ return false;
121
+ if (Object.getOwnPropertySymbols(candidate).length > 0)
122
+ return false;
123
+ for (let index = 0; index < candidate.length; index += 1) {
124
+ if (!Object.hasOwn(candidate, index))
125
+ return false;
126
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index));
127
+ if (descriptor === undefined || !("value" in descriptor) || !visit(descriptor.value)) {
128
+ return false;
129
+ }
130
+ }
131
+ return Object.getOwnPropertyNames(candidate).every((key) => {
132
+ if (key === "length")
133
+ return true;
134
+ const index = Number(key);
135
+ return (Number.isInteger(index) &&
136
+ index >= 0 &&
137
+ index < candidate.length &&
138
+ String(index) === key);
139
+ });
140
+ }
141
+ const prototype = Object.getPrototypeOf(candidate);
142
+ if (prototype !== Object.prototype && prototype !== null)
143
+ return false;
144
+ if (Object.getOwnPropertySymbols(candidate).length > 0)
145
+ return false;
146
+ return Object.getOwnPropertyNames(candidate).every((key) => {
147
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
148
+ return (descriptor !== undefined &&
149
+ descriptor.enumerable &&
150
+ "value" in descriptor &&
151
+ visit(descriptor.value));
152
+ });
153
+ }
154
+ finally {
155
+ ancestors.delete(candidate);
156
+ }
157
+ }
158
+ return visit(value);
159
+ }
@@ -65,12 +65,15 @@ export function createConfigSnapshot(rootConfigPath) {
65
65
  export function createWorkspaceSnapshot(context) {
66
66
  const sourcePaths = context.project
67
67
  .getSourceFiles()
68
- .map((sourceFile) => sourceFile.getFilePath());
69
- const paths = [...sourcePaths, ...createConfigSnapshot(context.tsConfigFilePath).files]
70
- .map(canonicalPath)
71
- .sort();
72
- const files = new Map();
73
- for (const filePath of paths) {
68
+ .map((sourceFile) => canonicalPath(sourceFile.getFilePath()));
69
+ const configSnapshot = createConfigSnapshot(context.tsConfigFilePath);
70
+ const sourceFiles = new Map();
71
+ for (const filePath of [...new Set(sourcePaths)].sort()) {
72
+ const digest = hashBytes(fs.readFileSync(filePath));
73
+ sourceFiles.set(filePath, digest);
74
+ }
75
+ const files = new Map(sourceFiles);
76
+ for (const filePath of configSnapshot.files) {
74
77
  const digest = hashBytes(fs.readFileSync(filePath));
75
78
  const previous = files.get(filePath);
76
79
  if (previous && previous !== digest) {
@@ -80,6 +83,9 @@ export function createWorkspaceSnapshot(context) {
80
83
  }
81
84
  return {
82
85
  digest: hashWorkspaceFiles(files),
86
+ sourceDigest: hashWorkspaceFiles(sourceFiles),
87
+ configDigest: configSnapshot.digest,
88
+ sourceFileCount: sourceFiles.size,
83
89
  fileCount: files.size,
84
90
  files,
85
91
  };
@@ -0,0 +1,68 @@
1
+ import { z } from "zod";
2
+ import { FRESHNESS_CAUSES, SNAPSHOT_STATES, TRUNCATION_REASONS, } from "../services/read-contracts.js";
3
+ import { getProjectStatus } from "../services/project.js";
4
+ import { errorResult, structuredResult } from "./result.js";
5
+ const AstGetProjectStatusInputSchema = z.object({
6
+ project_root: z
7
+ .string()
8
+ .describe("Absolute project directory containing tsconfig.json, or the config path."),
9
+ });
10
+ const TruncationMetadataSchema = z.object({
11
+ truncated: z.boolean(),
12
+ reason: z.enum(TRUNCATION_REASONS).nullable(),
13
+ });
14
+ const ProjectStatusOutputSchema = z.object({
15
+ project: z.object({
16
+ project_id: z.string().regex(/^project_[0-9a-f]{20}$/),
17
+ config_id: z
18
+ .string()
19
+ .regex(/^config_[0-9a-f]{20}$/)
20
+ .nullable(),
21
+ }),
22
+ state: z.enum(SNAPSHOT_STATES),
23
+ causes: z.array(z.enum(FRESHNESS_CAUSES)),
24
+ source_count: z.number().int().min(0),
25
+ indexed_count: z.number().int().min(0),
26
+ pending_files: z.array(z.string()),
27
+ pending_files_truncated: z.boolean(),
28
+ pending_files_truncation: TruncationMetadataSchema,
29
+ pending_files_filename_truncation: TruncationMetadataSchema,
30
+ compiler: z.object({ state: z.enum(["ready", "rebuilding", "failed"]) }),
31
+ index: z.object({ state: z.literal("disabled") }),
32
+ operation_queue: z.object({
33
+ state: z.enum(["idle", "queued", "running"]),
34
+ active_operations: z.number().int().min(0),
35
+ queued_operations: z.number().int().min(0),
36
+ }),
37
+ watcher: z.object({ state: z.enum(["disabled", "ready", "failed"]) }),
38
+ last_successful_sync_at: z.string().nullable(),
39
+ last_successful_index_at: z.null(),
40
+ source_snapshot_fingerprint: z.string().nullable(),
41
+ config_snapshot_fingerprint: z.string().nullable(),
42
+ canonical_snapshot_fingerprint: z.string().nullable(),
43
+ degraded_errors: z.array(z.string()),
44
+ degraded_errors_truncation: TruncationMetadataSchema,
45
+ degraded_errors_text_truncation: TruncationMetadataSchema,
46
+ });
47
+ export function registerGetProjectStatus(server) {
48
+ server.registerTool("ast_get_project_status", {
49
+ title: "Get project freshness status",
50
+ description: "Returns bounded, read-only compiler freshness and project status without exposing filesystem identities or credentials.",
51
+ inputSchema: AstGetProjectStatusInputSchema,
52
+ outputSchema: ProjectStatusOutputSchema,
53
+ annotations: {
54
+ readOnlyHint: true,
55
+ destructiveHint: false,
56
+ idempotentHint: true,
57
+ openWorldHint: false,
58
+ },
59
+ }, async ({ project_root }) => {
60
+ try {
61
+ const output = await getProjectStatus(project_root);
62
+ return structuredResult({ ...output });
63
+ }
64
+ catch (error) {
65
+ return errorResult(error);
66
+ }
67
+ });
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ast-mcp-server",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Structural code MCP server and batch CLI for compact TypeScript/JavaScript reads and hash-bound AST edits.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -82,4 +82,4 @@
82
82
  "typescript-eslint": "^8.65.0",
83
83
  "vitest": "^4.1.10"
84
84
  }
85
- }
85
+ }