ast-mcp-server 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }