ast-mcp-server 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +47 -15
- package/SECURITY.md +31 -0
- package/dist/cli.js +0 -0
- package/dist/index.js +129 -10
- package/dist/server.js +21 -2
- package/dist/services/agent-setup.js +36 -117
- package/dist/services/agent-targets.js +122 -0
- package/dist/services/context-builder.js +52 -24
- package/dist/services/diagnostics.js +10 -5
- package/dist/services/file-fingerprints.js +104 -0
- package/dist/services/file-snapshot.js +8 -1
- package/dist/services/impact.js +334 -0
- package/dist/services/operations.js +343 -254
- package/dist/services/outline.js +94 -21
- package/dist/services/pagination.js +10 -4
- package/dist/services/project-operation-scheduler.js +475 -0
- package/dist/services/project-status.js +134 -52
- package/dist/services/project-watcher.js +230 -0
- package/dist/services/project.js +646 -66
- package/dist/services/public-errors.js +255 -0
- package/dist/services/references.js +7 -1
- package/dist/services/relationships.js +1198 -0
- package/dist/services/request-context.js +42 -0
- package/dist/services/runtime-activity.js +65 -0
- package/dist/services/runtime-logger.js +50 -0
- package/dist/services/runtime-policy.js +47 -0
- package/dist/services/setup-wizard.js +4 -7
- package/dist/services/shutdown.js +93 -0
- package/dist/services/symbol-index-limits.js +5 -0
- package/dist/services/symbol-index-order.js +13 -0
- package/dist/services/symbol-index-policy.js +91 -0
- package/dist/services/symbol-index-sqlite.js +850 -0
- package/dist/services/symbol-index.js +447 -0
- package/dist/services/symbols.js +403 -79
- package/dist/services/test-candidates.js +209 -0
- package/dist/services/workspace.js +18 -7
- package/dist/tools/apply_operation.js +8 -4
- package/dist/tools/explore.js +8 -6
- package/dist/tools/find_references.js +12 -13
- package/dist/tools/get_diagnostics.js +10 -7
- package/dist/tools/get_file.js +23 -11
- package/dist/tools/get_impact.js +134 -0
- package/dist/tools/get_operation_preview.js +7 -4
- package/dist/tools/get_outline.js +8 -6
- package/dist/tools/get_project_status.js +77 -8
- package/dist/tools/get_symbol_source.js +8 -6
- package/dist/tools/list_files.js +11 -6
- package/dist/tools/rename_symbol.js +6 -4
- package/dist/tools/replace_symbol_body.js +6 -4
- package/dist/tools/result.js +27 -17
- package/dist/tools/scaffold_class.js +6 -4
- package/dist/tools/search_symbols.js +15 -14
- package/docs/support.md +80 -0
- package/package.json +16 -5
- package/skills/structural-code-editing/SKILL.md +37 -10
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { types as utilTypes } from "node:util";
|
|
3
|
+
import { MAX_PROJECT_OPERATION_COUNTER, MAX_PROJECT_OPERATION_DURATION_MS, } from "./project-operation-scheduler.js";
|
|
4
|
+
import { sanitizeSensitiveText } from "./public-errors.js";
|
|
2
5
|
import { createTruncationMetadata, isFreshnessCause, isSnapshotState, isTruncationReason, } from "./read-contracts.js";
|
|
3
6
|
export const MAX_PENDING_FILES = 64;
|
|
4
7
|
export const MAX_PENDING_FILENAME_LENGTH = 256;
|
|
@@ -85,42 +88,12 @@ function boundedCount(value) {
|
|
|
85
88
|
return 0;
|
|
86
89
|
return value;
|
|
87
90
|
}
|
|
88
|
-
|
|
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
|
-
}
|
|
91
|
+
const MAX_STATUS_TEXT_INPUT_CHARS = 8192;
|
|
120
92
|
function boundedText(value) {
|
|
121
93
|
const text = typeof value === "string" ? value : "[invalid-error]";
|
|
122
|
-
const
|
|
123
|
-
|
|
94
|
+
const inputTruncated = text.length > MAX_STATUS_TEXT_INPUT_CHARS;
|
|
95
|
+
const redacted = sanitizeSensitiveText(text.slice(0, MAX_STATUS_TEXT_INPUT_CHARS));
|
|
96
|
+
return !inputTruncated && redacted.length <= MAX_STATUS_TEXT_LENGTH
|
|
124
97
|
? { value: redacted, truncated: false }
|
|
125
98
|
: {
|
|
126
99
|
value: `${redacted.slice(0, MAX_STATUS_TEXT_LENGTH - TRUNCATION_SUFFIX.length)}${TRUNCATION_SUFFIX}`,
|
|
@@ -437,12 +410,12 @@ function normalizeProjectStatus(status) {
|
|
|
437
410
|
causes,
|
|
438
411
|
boundaryInvalid,
|
|
439
412
|
sourceCount: boundedCount(status.sourceCount),
|
|
440
|
-
indexedCount: 0,
|
|
441
|
-
index: { state: "
|
|
413
|
+
indexedCount: rawIndexState === "disabled" ? 0 : boundedCount(status.indexedCount),
|
|
414
|
+
index: { state: isIndexStatusState(rawIndexState) ? rawIndexState : "failed" },
|
|
442
415
|
compiler: { state: compilerValid ? rawCompilerState : "failed" },
|
|
443
416
|
watcher: { state: watcherFailed ? "failed" : rawWatcherState },
|
|
444
417
|
lastSuccessfulSyncAt,
|
|
445
|
-
lastSuccessfulIndexAt: null,
|
|
418
|
+
lastSuccessfulIndexAt: rawIndexState === "disabled" ? null : normalizedTimestamp(status.lastSuccessfulIndexAt),
|
|
446
419
|
sourceSnapshotFingerprint,
|
|
447
420
|
configSnapshotFingerprint,
|
|
448
421
|
canonicalSnapshotFingerprint,
|
|
@@ -551,9 +524,11 @@ export function transitionProjectStatus(inputStatus, event) {
|
|
|
551
524
|
causes: appendCause(status.causes, "compiler_rebuild"),
|
|
552
525
|
compiler: { state: "rebuilding" },
|
|
553
526
|
index: {
|
|
554
|
-
state:
|
|
555
|
-
? "
|
|
556
|
-
: "
|
|
527
|
+
state: status.index.state === "failed"
|
|
528
|
+
? "failed"
|
|
529
|
+
: status.index.state === "disabled"
|
|
530
|
+
? "disabled"
|
|
531
|
+
: "rebuilding",
|
|
557
532
|
},
|
|
558
533
|
});
|
|
559
534
|
case "sync_succeeded": {
|
|
@@ -561,7 +536,9 @@ export function transitionProjectStatus(inputStatus, event) {
|
|
|
561
536
|
return markInvalidTransition(status, "[invalid-count]");
|
|
562
537
|
}
|
|
563
538
|
const hasFailure = hasActiveComponentFailure(status);
|
|
564
|
-
const
|
|
539
|
+
const indexUnavailable = hasActiveIndexFailure(status) ||
|
|
540
|
+
status.index.state === "disabled" ||
|
|
541
|
+
status.index.state === "failed";
|
|
565
542
|
const sourceSnapshotFingerprint = nonEmptyFingerprint(event.sourceSnapshotFingerprint, "source");
|
|
566
543
|
const configSnapshotFingerprint = nonEmptyFingerprint(event.configSnapshotFingerprint, "config");
|
|
567
544
|
const canonicalSnapshotFingerprint = nonEmptyFingerprint(event.canonicalSnapshotFingerprint, "snapshot");
|
|
@@ -588,21 +565,21 @@ export function transitionProjectStatus(inputStatus, event) {
|
|
|
588
565
|
state: nextState,
|
|
589
566
|
causes: canClearInvalidation ? [] : status.causes,
|
|
590
567
|
sourceCount: boundedCount(event.sourceCount),
|
|
591
|
-
indexedCount:
|
|
568
|
+
indexedCount: indexUnavailable ? 0 : boundedCount(event.indexedCount),
|
|
592
569
|
pendingFiles: pending.files,
|
|
593
570
|
pendingFilesTruncated: pending.recordTruncation.truncated || pending.filenameTruncation.truncated,
|
|
594
571
|
pendingFilesTruncation: pending.recordTruncation,
|
|
595
572
|
pendingFilesFilenameTruncation: pending.filenameTruncation,
|
|
596
573
|
compiler: { state: "ready" },
|
|
597
574
|
index: {
|
|
598
|
-
state:
|
|
599
|
-
? "
|
|
600
|
-
:
|
|
601
|
-
? "
|
|
575
|
+
state: hasFailure && status.index.state === "failed"
|
|
576
|
+
? "failed"
|
|
577
|
+
: indexUnavailable
|
|
578
|
+
? "disabled"
|
|
602
579
|
: "ready",
|
|
603
580
|
},
|
|
604
581
|
lastSuccessfulSyncAt: canClearInvalidation ? nextSyncAt : status.lastSuccessfulSyncAt,
|
|
605
|
-
lastSuccessfulIndexAt: !hasFreshEvidence ||
|
|
582
|
+
lastSuccessfulIndexAt: !hasFreshEvidence || indexUnavailable
|
|
606
583
|
? status.lastSuccessfulIndexAt
|
|
607
584
|
: (nextSyncAt ?? status.lastSuccessfulIndexAt),
|
|
608
585
|
sourceSnapshotFingerprint: hasSnapshotEvidence
|
|
@@ -659,7 +636,7 @@ export function transitionProjectStatus(inputStatus, event) {
|
|
|
659
636
|
return statusWith(status, {
|
|
660
637
|
state: "degraded",
|
|
661
638
|
causes: appendCause(status.causes, "index_failure"),
|
|
662
|
-
index: { state: "
|
|
639
|
+
index: { state: "failed" },
|
|
663
640
|
degradedErrors: appended.errors,
|
|
664
641
|
degradedErrorsTruncation: errorTruncation(countTruncated, textTruncated),
|
|
665
642
|
degradedErrorsTextTruncation: createTruncationMetadata(textTruncated, textTruncated ? "serialization_limit" : null),
|
|
@@ -685,6 +662,15 @@ export function transitionProjectStatus(inputStatus, event) {
|
|
|
685
662
|
return recoverComponent(status, event.component);
|
|
686
663
|
case "index_recovered":
|
|
687
664
|
return recoverComponent(status, "index");
|
|
665
|
+
case "index_ready":
|
|
666
|
+
return statusWith(status, { index: { state: "ready" } });
|
|
667
|
+
case "index_disabled":
|
|
668
|
+
return statusWith(status, {
|
|
669
|
+
index: { state: "disabled" },
|
|
670
|
+
indexedCount: 0,
|
|
671
|
+
lastSuccessfulIndexAt: null,
|
|
672
|
+
causes: removeCause(status.causes, "index_failure"),
|
|
673
|
+
});
|
|
688
674
|
case "watcher_recovered":
|
|
689
675
|
return recoverComponent(status, "watcher");
|
|
690
676
|
default:
|
|
@@ -694,7 +680,7 @@ export function transitionProjectStatus(inputStatus, event) {
|
|
|
694
680
|
function recoverComponent(status, component) {
|
|
695
681
|
const recovered = component === "index"
|
|
696
682
|
? statusWith(status, {
|
|
697
|
-
index: { state: "
|
|
683
|
+
index: { state: status.index.state === "failed" ? "ready" : status.index.state },
|
|
698
684
|
causes: removeCause(status.causes, "index_failure"),
|
|
699
685
|
})
|
|
700
686
|
: statusWith(status, {
|
|
@@ -716,16 +702,16 @@ export function projectStatusToProjection(status) {
|
|
|
716
702
|
state: normalized.state,
|
|
717
703
|
causes: [...normalized.causes],
|
|
718
704
|
source_count: normalized.sourceCount,
|
|
719
|
-
indexed_count:
|
|
705
|
+
indexed_count: normalized.indexedCount,
|
|
720
706
|
pending_files: [...normalized.pendingFiles],
|
|
721
707
|
pending_files_truncated: normalized.pendingFilesTruncated,
|
|
722
708
|
pending_files_truncation: { ...normalized.pendingFilesTruncation },
|
|
723
709
|
pending_files_filename_truncation: { ...normalized.pendingFilesFilenameTruncation },
|
|
724
710
|
compiler: { ...normalized.compiler },
|
|
725
|
-
index: {
|
|
711
|
+
index: { ...normalized.index },
|
|
726
712
|
watcher: { ...normalized.watcher },
|
|
727
713
|
last_successful_sync_at: normalized.lastSuccessfulSyncAt,
|
|
728
|
-
last_successful_index_at:
|
|
714
|
+
last_successful_index_at: normalized.lastSuccessfulIndexAt,
|
|
729
715
|
source_snapshot_fingerprint: normalized.sourceSnapshotFingerprint,
|
|
730
716
|
config_snapshot_fingerprint: normalized.configSnapshotFingerprint,
|
|
731
717
|
canonical_snapshot_fingerprint: normalized.canonicalSnapshotFingerprint,
|
|
@@ -734,3 +720,99 @@ export function projectStatusToProjection(status) {
|
|
|
734
720
|
degraded_errors_text_truncation: { ...normalized.degradedErrorsTextTruncation },
|
|
735
721
|
};
|
|
736
722
|
}
|
|
723
|
+
const OPERATION_QUEUE_FIELDS = Object.freeze([
|
|
724
|
+
"admission",
|
|
725
|
+
"queue_capacity",
|
|
726
|
+
"active_operations",
|
|
727
|
+
"queued_operations",
|
|
728
|
+
"rejected_operations",
|
|
729
|
+
"cancelled_operations",
|
|
730
|
+
"queue_timeout_operations",
|
|
731
|
+
"deadline_exceeded_operations",
|
|
732
|
+
"last_outcome",
|
|
733
|
+
"max_queue_wait_ms",
|
|
734
|
+
"max_execution_ms",
|
|
735
|
+
]);
|
|
736
|
+
function operationQueueRecord(value) {
|
|
737
|
+
if (typeof value !== "object" || value === null)
|
|
738
|
+
return {};
|
|
739
|
+
try {
|
|
740
|
+
if (utilTypes.isProxy(value) || Array.isArray(value))
|
|
741
|
+
return {};
|
|
742
|
+
const prototype = Object.getPrototypeOf(value);
|
|
743
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
744
|
+
return {};
|
|
745
|
+
const ownKeys = Reflect.ownKeys(value);
|
|
746
|
+
if (ownKeys.some((key) => typeof key === "symbol"))
|
|
747
|
+
return {};
|
|
748
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
749
|
+
for (const key of ownKeys) {
|
|
750
|
+
const descriptor = descriptors[key];
|
|
751
|
+
if (!descriptor?.enumerable || !("value" in descriptor))
|
|
752
|
+
return {};
|
|
753
|
+
}
|
|
754
|
+
const record = Object.create(null);
|
|
755
|
+
for (const field of OPERATION_QUEUE_FIELDS) {
|
|
756
|
+
const descriptor = descriptors[field];
|
|
757
|
+
if (descriptor && "value" in descriptor)
|
|
758
|
+
record[field] = descriptor.value;
|
|
759
|
+
}
|
|
760
|
+
return record;
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
return {};
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
function normalizeQueueCapacity(value, configuredCapacity) {
|
|
767
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 1 && value <= 256) {
|
|
768
|
+
return value;
|
|
769
|
+
}
|
|
770
|
+
if (typeof configuredCapacity === "number" &&
|
|
771
|
+
Number.isSafeInteger(configuredCapacity) &&
|
|
772
|
+
configuredCapacity >= 1 &&
|
|
773
|
+
configuredCapacity <= 256) {
|
|
774
|
+
return configuredCapacity;
|
|
775
|
+
}
|
|
776
|
+
return 32;
|
|
777
|
+
}
|
|
778
|
+
function normalizeRuntimeMetric(value, maximum) {
|
|
779
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value))
|
|
780
|
+
return 0;
|
|
781
|
+
if (value < 0)
|
|
782
|
+
return 0;
|
|
783
|
+
return Math.min(value, maximum);
|
|
784
|
+
}
|
|
785
|
+
function normalizeOperationOutcome(value) {
|
|
786
|
+
if (value === "none" ||
|
|
787
|
+
value === "succeeded" ||
|
|
788
|
+
value === "failed" ||
|
|
789
|
+
value === "rejected" ||
|
|
790
|
+
value === "cancelled" ||
|
|
791
|
+
value === "queue_timeout" ||
|
|
792
|
+
value === "deadline_exceeded" ||
|
|
793
|
+
value === "internal_error") {
|
|
794
|
+
return value;
|
|
795
|
+
}
|
|
796
|
+
return "internal_error";
|
|
797
|
+
}
|
|
798
|
+
export function projectOperationQueueToProjection(input, configuredCapacity) {
|
|
799
|
+
const record = operationQueueRecord(input);
|
|
800
|
+
const queueCapacity = normalizeQueueCapacity(record.queue_capacity, configuredCapacity);
|
|
801
|
+
const activeOperations = normalizeRuntimeMetric(record.active_operations, 1);
|
|
802
|
+
const queuedOperations = normalizeRuntimeMetric(record.queued_operations, queueCapacity);
|
|
803
|
+
const state = activeOperations > 0 ? "running" : queuedOperations > 0 ? "queued" : "idle";
|
|
804
|
+
return Object.freeze({
|
|
805
|
+
state,
|
|
806
|
+
admission: record.admission === "open" ? "open" : "closed",
|
|
807
|
+
queue_capacity: queueCapacity,
|
|
808
|
+
active_operations: activeOperations,
|
|
809
|
+
queued_operations: queuedOperations,
|
|
810
|
+
rejected_operations: normalizeRuntimeMetric(record.rejected_operations, MAX_PROJECT_OPERATION_COUNTER),
|
|
811
|
+
cancelled_operations: normalizeRuntimeMetric(record.cancelled_operations, MAX_PROJECT_OPERATION_COUNTER),
|
|
812
|
+
queue_timeout_operations: normalizeRuntimeMetric(record.queue_timeout_operations, MAX_PROJECT_OPERATION_COUNTER),
|
|
813
|
+
deadline_exceeded_operations: normalizeRuntimeMetric(record.deadline_exceeded_operations, MAX_PROJECT_OPERATION_COUNTER),
|
|
814
|
+
last_outcome: normalizeOperationOutcome(record.last_outcome),
|
|
815
|
+
max_queue_wait_ms: normalizeRuntimeMetric(record.max_queue_wait_ms, MAX_PROJECT_OPERATION_DURATION_MS),
|
|
816
|
+
max_execution_ms: normalizeRuntimeMetric(record.max_execution_ms, MAX_PROJECT_OPERATION_DURATION_MS),
|
|
817
|
+
});
|
|
818
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const DEFAULT_WATCHER_DEBOUNCE_MS = 100;
|
|
4
|
+
export const DEFAULT_MAX_PENDING_PATHS = 65;
|
|
5
|
+
export const DEFAULT_MAX_WATCHED_DIRECTORIES = 512;
|
|
6
|
+
const IGNORED_DIRECTORY_NAMES = new Set([".git", ".yarn", "coverage", "dist", "node_modules"]);
|
|
7
|
+
function defaultWatchFactory(directory, onEvent, onError) {
|
|
8
|
+
const watcher = fs.watch(directory, { persistent: false }, onEvent);
|
|
9
|
+
watcher.on("error", onError);
|
|
10
|
+
return watcher;
|
|
11
|
+
}
|
|
12
|
+
function boundedInteger(value, fallback, minimum) {
|
|
13
|
+
if (value === undefined)
|
|
14
|
+
return fallback;
|
|
15
|
+
if (!Number.isInteger(value) || value < minimum) {
|
|
16
|
+
throw new Error(`Watcher numeric options must be integers >= ${minimum}.`);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function normalizeDirectory(projectRoot, directory) {
|
|
21
|
+
const normalizedRoot = fs.realpathSync(path.resolve(projectRoot));
|
|
22
|
+
const normalizedDirectory = fs.realpathSync(path.resolve(directory));
|
|
23
|
+
if (normalizedDirectory !== normalizedRoot &&
|
|
24
|
+
!normalizedDirectory.startsWith(`${normalizedRoot}${path.sep}`)) {
|
|
25
|
+
throw new Error(`Watcher directory must stay inside the project root: ${directory}.`);
|
|
26
|
+
}
|
|
27
|
+
return normalizedDirectory;
|
|
28
|
+
}
|
|
29
|
+
export function selectProjectWatchDirectories(projectRoot, sourceFilePaths) {
|
|
30
|
+
const normalizedRoot = fs.realpathSync(path.resolve(projectRoot));
|
|
31
|
+
const directories = new Set([normalizedRoot]);
|
|
32
|
+
for (const sourceFilePath of sourceFilePaths) {
|
|
33
|
+
let directory = path.dirname(path.resolve(sourceFilePath));
|
|
34
|
+
while (directory === normalizedRoot || directory.startsWith(`${normalizedRoot}${path.sep}`)) {
|
|
35
|
+
const relativeParts = path.relative(normalizedRoot, directory).split(path.sep);
|
|
36
|
+
if (relativeParts.some((part) => IGNORED_DIRECTORY_NAMES.has(part)))
|
|
37
|
+
break;
|
|
38
|
+
let physicalDirectory;
|
|
39
|
+
try {
|
|
40
|
+
physicalDirectory = fs.realpathSync(directory);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
if (physicalDirectory !== normalizedRoot &&
|
|
46
|
+
!physicalDirectory.startsWith(`${normalizedRoot}${path.sep}`)) {
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
directories.add(physicalDirectory);
|
|
50
|
+
if (directory === normalizedRoot)
|
|
51
|
+
break;
|
|
52
|
+
directory = path.dirname(directory);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return [...directories].sort((left, right) => left.split(path.sep).length - right.split(path.sep).length || left.localeCompare(right));
|
|
56
|
+
}
|
|
57
|
+
function discoverDirectories(projectRoot, maxDirectories) {
|
|
58
|
+
const directories = [projectRoot];
|
|
59
|
+
for (let index = 0; index < directories.length; index += 1) {
|
|
60
|
+
const directory = directories[index];
|
|
61
|
+
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (!entry.isDirectory() ||
|
|
64
|
+
entry.isSymbolicLink() ||
|
|
65
|
+
IGNORED_DIRECTORY_NAMES.has(entry.name)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
directories.push(path.join(directory, entry.name));
|
|
69
|
+
if (directories.length > maxDirectories) {
|
|
70
|
+
throw new Error(`Watcher directory limit exceeded (${maxDirectories}).`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return directories;
|
|
75
|
+
}
|
|
76
|
+
function normalizeFilename(projectRoot, watchedDirectory, filename) {
|
|
77
|
+
const filenameText = filename === null ? "" : filename.toString();
|
|
78
|
+
const absolutePath = filenameText
|
|
79
|
+
? path.resolve(watchedDirectory, filenameText)
|
|
80
|
+
: watchedDirectory;
|
|
81
|
+
const relativePath = path.relative(projectRoot, absolutePath).split(path.sep).join("/");
|
|
82
|
+
if (relativePath === "" ||
|
|
83
|
+
relativePath === "." ||
|
|
84
|
+
relativePath.startsWith("../") ||
|
|
85
|
+
path.isAbsolute(relativePath)) {
|
|
86
|
+
return ".";
|
|
87
|
+
}
|
|
88
|
+
return relativePath;
|
|
89
|
+
}
|
|
90
|
+
function createWatcher(options) {
|
|
91
|
+
if (typeof options.onChange !== "function" || typeof options.onError !== "function") {
|
|
92
|
+
throw new Error("Watcher callbacks must be functions.");
|
|
93
|
+
}
|
|
94
|
+
const projectRoot = fs.realpathSync(path.resolve(options.projectRoot));
|
|
95
|
+
const debounceMs = boundedInteger(options.debounceMs, DEFAULT_WATCHER_DEBOUNCE_MS, 0);
|
|
96
|
+
const maxPendingPaths = boundedInteger(options.maxPendingPaths, DEFAULT_MAX_PENDING_PATHS, 1);
|
|
97
|
+
const maxWatchedDirectories = boundedInteger(options.maxWatchedDirectories, DEFAULT_MAX_WATCHED_DIRECTORIES, 1);
|
|
98
|
+
const watchFactory = options.watchFactory ?? defaultWatchFactory;
|
|
99
|
+
const configuredDirectories = options.directories === undefined
|
|
100
|
+
? undefined
|
|
101
|
+
: [
|
|
102
|
+
...new Set(options.directories.map((directory) => normalizeDirectory(projectRoot, directory))),
|
|
103
|
+
];
|
|
104
|
+
let state = "disabled";
|
|
105
|
+
let handles = [];
|
|
106
|
+
let pendingPaths = new Map();
|
|
107
|
+
let pendingPathsTruncated = false;
|
|
108
|
+
let timer;
|
|
109
|
+
const clearPendingTimer = () => {
|
|
110
|
+
if (timer === undefined)
|
|
111
|
+
return;
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
timer = undefined;
|
|
114
|
+
};
|
|
115
|
+
const closeHandles = () => {
|
|
116
|
+
const currentHandles = handles;
|
|
117
|
+
handles = [];
|
|
118
|
+
for (const handle of currentHandles) {
|
|
119
|
+
try {
|
|
120
|
+
handle.close();
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Closing an already-failed native watcher is best effort.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const notifyError = (error) => {
|
|
128
|
+
try {
|
|
129
|
+
options.onError(error);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Observer errors must not escape the filesystem callback.
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const markFailed = (error) => {
|
|
136
|
+
if (state === "disabled" && handles.length === 0)
|
|
137
|
+
return;
|
|
138
|
+
clearPendingTimer();
|
|
139
|
+
pendingPaths = new Map();
|
|
140
|
+
pendingPathsTruncated = false;
|
|
141
|
+
closeHandles();
|
|
142
|
+
state = "failed";
|
|
143
|
+
notifyError(error);
|
|
144
|
+
};
|
|
145
|
+
const flush = () => {
|
|
146
|
+
timer = undefined;
|
|
147
|
+
if (state !== "ready" || pendingPaths.size === 0)
|
|
148
|
+
return;
|
|
149
|
+
const files = [...pendingPaths.keys()];
|
|
150
|
+
pendingPaths = new Map();
|
|
151
|
+
pendingPathsTruncated = false;
|
|
152
|
+
try {
|
|
153
|
+
options.onChange(files);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
markFailed(error);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const scheduleFlush = () => {
|
|
160
|
+
clearPendingTimer();
|
|
161
|
+
timer = setTimeout(flush, debounceMs);
|
|
162
|
+
};
|
|
163
|
+
const handleEvent = (watchedDirectory, _eventType, filename) => {
|
|
164
|
+
if (state !== "ready")
|
|
165
|
+
return;
|
|
166
|
+
const relativePath = normalizeFilename(projectRoot, watchedDirectory, filename);
|
|
167
|
+
if (!pendingPaths.has(relativePath)) {
|
|
168
|
+
if (pendingPaths.size >= maxPendingPaths) {
|
|
169
|
+
pendingPathsTruncated = true;
|
|
170
|
+
markFailed(new Error(`Watcher event limit exceeded (${maxPendingPaths} paths).`));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
pendingPaths.set(relativePath, true);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
scheduleFlush();
|
|
178
|
+
};
|
|
179
|
+
return {
|
|
180
|
+
start() {
|
|
181
|
+
if (state === "ready")
|
|
182
|
+
return;
|
|
183
|
+
clearPendingTimer();
|
|
184
|
+
pendingPaths = new Map();
|
|
185
|
+
pendingPathsTruncated = false;
|
|
186
|
+
const nextHandles = [];
|
|
187
|
+
try {
|
|
188
|
+
const directories = configuredDirectories ?? discoverDirectories(projectRoot, maxWatchedDirectories);
|
|
189
|
+
if (directories.length > maxWatchedDirectories) {
|
|
190
|
+
throw new Error(`Watcher directory limit exceeded (${maxWatchedDirectories}).`);
|
|
191
|
+
}
|
|
192
|
+
for (const directory of directories) {
|
|
193
|
+
nextHandles.push(watchFactory(directory, (eventType, filename) => handleEvent(directory, eventType, filename), markFailed));
|
|
194
|
+
}
|
|
195
|
+
handles = nextHandles;
|
|
196
|
+
state = "ready";
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
for (const handle of nextHandles) {
|
|
200
|
+
try {
|
|
201
|
+
handle.close();
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// Partial startup cleanup is best effort.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
state = "failed";
|
|
208
|
+
notifyError(error);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
close() {
|
|
212
|
+
clearPendingTimer();
|
|
213
|
+
pendingPaths = new Map();
|
|
214
|
+
pendingPathsTruncated = false;
|
|
215
|
+
closeHandles();
|
|
216
|
+
state = "disabled";
|
|
217
|
+
},
|
|
218
|
+
snapshot() {
|
|
219
|
+
return {
|
|
220
|
+
state,
|
|
221
|
+
watched_directories: handles.length,
|
|
222
|
+
pending_paths: [...pendingPaths.keys()],
|
|
223
|
+
pending_paths_truncated: pendingPathsTruncated,
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
export function createProjectWatcher(options) {
|
|
229
|
+
return createWatcher(options);
|
|
230
|
+
}
|