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.
Files changed (56) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +47 -15
  3. package/SECURITY.md +31 -0
  4. package/dist/cli.js +0 -0
  5. package/dist/index.js +129 -10
  6. package/dist/server.js +21 -2
  7. package/dist/services/agent-setup.js +36 -117
  8. package/dist/services/agent-targets.js +122 -0
  9. package/dist/services/context-builder.js +52 -24
  10. package/dist/services/diagnostics.js +10 -5
  11. package/dist/services/file-fingerprints.js +104 -0
  12. package/dist/services/file-snapshot.js +8 -1
  13. package/dist/services/impact.js +334 -0
  14. package/dist/services/operations.js +343 -254
  15. package/dist/services/outline.js +94 -21
  16. package/dist/services/pagination.js +10 -4
  17. package/dist/services/project-operation-scheduler.js +475 -0
  18. package/dist/services/project-status.js +134 -52
  19. package/dist/services/project-watcher.js +230 -0
  20. package/dist/services/project.js +646 -66
  21. package/dist/services/public-errors.js +255 -0
  22. package/dist/services/references.js +7 -1
  23. package/dist/services/relationships.js +1198 -0
  24. package/dist/services/request-context.js +42 -0
  25. package/dist/services/runtime-activity.js +65 -0
  26. package/dist/services/runtime-logger.js +50 -0
  27. package/dist/services/runtime-policy.js +47 -0
  28. package/dist/services/setup-wizard.js +4 -7
  29. package/dist/services/shutdown.js +93 -0
  30. package/dist/services/symbol-index-limits.js +5 -0
  31. package/dist/services/symbol-index-order.js +13 -0
  32. package/dist/services/symbol-index-policy.js +91 -0
  33. package/dist/services/symbol-index-sqlite.js +850 -0
  34. package/dist/services/symbol-index.js +447 -0
  35. package/dist/services/symbols.js +403 -79
  36. package/dist/services/test-candidates.js +209 -0
  37. package/dist/services/workspace.js +18 -7
  38. package/dist/tools/apply_operation.js +8 -4
  39. package/dist/tools/explore.js +8 -6
  40. package/dist/tools/find_references.js +12 -13
  41. package/dist/tools/get_diagnostics.js +10 -7
  42. package/dist/tools/get_file.js +23 -11
  43. package/dist/tools/get_impact.js +134 -0
  44. package/dist/tools/get_operation_preview.js +7 -4
  45. package/dist/tools/get_outline.js +8 -6
  46. package/dist/tools/get_project_status.js +77 -8
  47. package/dist/tools/get_symbol_source.js +8 -6
  48. package/dist/tools/list_files.js +11 -6
  49. package/dist/tools/rename_symbol.js +6 -4
  50. package/dist/tools/replace_symbol_body.js +6 -4
  51. package/dist/tools/result.js +27 -17
  52. package/dist/tools/scaffold_class.js +6 -4
  53. package/dist/tools/search_symbols.js +15 -14
  54. package/docs/support.md +80 -0
  55. package/package.json +16 -5
  56. 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
- 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
- }
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 redacted = redactSensitiveText(redactAbsolutePaths(text));
123
- return redacted.length <= MAX_STATUS_TEXT_LENGTH
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: "disabled" },
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: hasActiveIndexFailure(status) || status.index.state === "disabled"
555
- ? "disabled"
556
- : "rebuilding",
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 indexDisabled = hasActiveIndexFailure(status) || status.index.state === "disabled";
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: indexDisabled ? 0 : boundedCount(event.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: indexDisabled
599
- ? "disabled"
600
- : hasFailure && status.index.state === "failed"
601
- ? "failed"
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 || indexDisabled || (hasFailure && status.index.state === "failed")
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: "disabled" },
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: "disabled" },
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: 0,
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: { state: "disabled" },
711
+ index: { ...normalized.index },
726
712
  watcher: { ...normalized.watcher },
727
713
  last_successful_sync_at: normalized.lastSuccessfulSyncAt,
728
- last_successful_index_at: null,
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
+ }