ttsc
Version:
General-purpose TypeScript-Go compiler, runtime, plugin host, and LSP host.
988 lines • 105 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.WatchTopology = void 0;
exports.reloadInputsForFailedTopologyRefresh = reloadInputsForFailedTopologyRefresh;
exports.syncWatchers = syncWatchers;
exports.literalGlobRoot = literalGlobRoot;
exports.projectInputWatchDirectories = projectInputWatchDirectories;
exports.projectInputActiveWatchDirectories = projectInputActiveWatchDirectories;
exports.projectInputAvailableWatchDirectory = projectInputAvailableWatchDirectory;
exports.projectInputReplacementStrandsWatchers = projectInputReplacementStrandsWatchers;
exports.projectInputTopologyMayAffect = projectInputTopologyMayAffect;
exports.projectInputEventShouldNotify = projectInputEventShouldNotify;
exports.projectInputReloadEventShouldNotify = projectInputReloadEventShouldNotify;
exports.projectInputMembershipInvalidatesProgram = projectInputMembershipInvalidatesProgram;
exports.planCompilerDirectoryWatchEvent = planCompilerDirectoryWatchEvent;
const node_crypto_1 = __importDefault(require("node:crypto"));
const node_fs_1 = __importDefault(require("node:fs"));
const node_path_1 = __importDefault(require("node:path"));
const readConfigJson_1 = require("../../compiler/internal/project/readConfigJson");
const readProjectConfig_1 = require("../../compiler/internal/project/readProjectConfig");
const resolveTsgo_1 = require("../../compiler/internal/resolveTsgo");
const spawnNative_1 = require("../../compiler/internal/spawnNative");
const schema_1 = require("../../flags/schema");
const projectInputPathIdentity_1 = require("../../internal/projectInputPathIdentity");
const singleFileOutput_1 = require("./singleFileOutput");
/**
* Keeps the launcher watch set aligned with the compiler's current program.
*
* TypeScript-Go's `--listFilesOnly` output is the authority for source and
* declaration inputs. Configuration files, project-reference roots, and the
* source trees of selected native plugins supplement that list, while compiler
* outputs are filtered before any watcher is installed.
*/
class WatchTopology {
options;
callbacks;
analysisOnly = false;
closed = false;
compilerPostRegistrationMembershipRefresh = false;
compilerPostRegistrationReconciliationScheduled = false;
compilerPostRegistrationSkipUnobservedProjectInputWatchRoots = true;
directories = new Map();
directoryWatchers = new Map();
extraInputs = [];
extraWatchers = new Map();
compilerFileSnapshots = new Map();
files = new Map();
fileWatchers = new Map();
observedDirectories = new Map();
outputFiles = new Map();
outputs = new Map();
projectInputFingerprints = new Map();
projectInputMatches = new Map();
projectInputs = {
files: [],
globs: [],
reloadDirectories: [],
reloadFiles: [],
root: "",
};
declaredProjectInputs = {
files: [],
globs: [],
reloadDirectories: [],
reloadFiles: [],
root: "",
};
projectInputRecoveryScheduled = false;
projectInputPostRegistrationReconciliationScheduled = false;
projectInputRejectedWatchRoots = new Set();
projectInputRequiredWatchRoots = new Map();
projectInputUnobservedWatchRoots = new Map();
projectInputWatchRoots = new Map();
projectInputWatchers = new Map();
projectInputLinkWatchers = new Map();
projectInputCompilerOutputOverlaps = new WeakMap();
projectInputCompilerAcknowledgements = new Map();
reloadFiles = new Map();
constructor(options, callbacks) {
this.options = options;
this.callbacks = callbacks;
}
/** Re-resolve compiler inputs and notify only when their membership changed. */
refresh(notify) {
this.refreshCompilerInputs(notify, false);
}
refreshCompilerInputs(notify, skipUnobservedProjectInputWatchRoots) {
const next = resolveWatchTopology(this.options, this.extraInputs);
const compilerProgramMembershipChange = next.analysisOnly &&
mapsEqual(this.reloadFiles, next.reloadFiles) &&
mapsEqual(this.outputFiles, next.outputFiles) &&
mapsEqual(this.outputs, next.outputs)
? compilerMembershipChange(this.files, next.files)
: [];
const projectInputProgramOverlap = projectInputCompilerMembershipChange(this.projectInputs, compilerProgramMembershipChange);
const projectInputProgramChange = compilerProgramMembershipChange.length !== 0 &&
projectInputProgramOverlap.length ===
compilerProgramMembershipChange.length
? projectInputProgramOverlap
: undefined;
const changed = this.analysisOnly !== next.analysisOnly ||
mapsEqual(this.files, next.files) === false ||
mapsEqual(this.directories, next.directories) === false ||
mapsEqual(this.outputFiles, next.outputFiles) === false ||
mapsEqual(this.outputs, next.outputs) === false ||
mapsEqual(this.reloadFiles, next.reloadFiles) === false;
this.analysisOnly = next.analysisOnly;
this.files = next.files;
// Stamp the tracked set as it is resolved, so the first event that cannot
// name what changed compares against the state the compiler just saw rather
// than against nothing, which would make it nominate everything once.
for (const key of [...this.compilerFileSnapshots.keys()]) {
if (!next.files.has(key))
this.compilerFileSnapshots.delete(key);
}
for (const [key, file] of next.files) {
// Only a file with no stamp yet is seeded. Restamping one that already
// has a baseline would advance it past a change nobody reported, and the
// next unnamed event would then read that change as no change at all.
if (!this.compilerFileSnapshots.has(key)) {
this.compilerFileSnapshots.set(key, compilerFileSnapshot(file));
}
}
this.directories = next.directories;
this.outputFiles = next.outputFiles;
this.outputs = next.outputs;
this.reloadFiles = next.reloadFiles;
for (const key of this.projectInputCompilerAcknowledgements.keys()) {
if (!next.files.has(key)) {
this.projectInputCompilerAcknowledgements.delete(key);
}
}
const projectInputProgramReload = projectInputProgramOverlap.length === 0
? false
: this.acknowledgeProjectInputCompilerMembership(projectInputProgramOverlap);
const fileWatcherRegistered = this.syncFileWatchers();
const directoryWatcherRegistered = this.syncDirectoryWatchers();
this.syncExtraWatchers();
this.syncProjectInputWatchers(skipUnobservedProjectInputWatchRoots);
if (fileWatcherRegistered || directoryWatcherRegistered) {
this.scheduleCompilerPostRegistrationReconciliation(directoryWatcherRegistered, skipUnobservedProjectInputWatchRoots);
}
if (notify && changed) {
if (projectInputProgramChange !== undefined) {
const changedPath = projectInputProgramChange.length === 1
? projectInputProgramChange[0]
: undefined;
this.callbacks.onInputChange(projectInputProgramReload
? { kind: "config", path: changedPath }
: {
invalidate: true,
kind: "project",
path: changedPath,
});
}
else {
this.callbacks.onTopologyChange();
}
}
}
/**
* Hand one Program-membership transition from the compiler lane to the
* overlapping project-input lane.
*
* Windows can deliver the compiler membership refresh before the recursive
* project watcher names the same creation. The rebuild scheduled here already
* consumes the current project bytes, so publishing their strong fingerprints
* keeps the later parent event from rediscovering the same population delta.
* A newly tracked compiler file also remembers that fingerprint until its
* first named content delivery; identical bytes are the delayed creation,
* while different bytes are a real later edit and remain observable even
* inside filesystem timestamp resolution.
*/
acknowledgeProjectInputCompilerMembership(changed) {
const matches = this.collectProjectInputMatches();
const fingerprints = fingerprintProjectInputMatches(matches);
const identities = (0, projectInputPathIdentity_1.createProjectInputPathIdentityContext)();
const changedInputs = projectInputChangedPaths({
next: matches,
nextFingerprints: fingerprints,
previous: this.projectInputMatches,
previousFingerprints: this.projectInputFingerprints,
});
const population = this.projectInputPopulation();
const causedBy = projectInputCompilerMembershipProjectChanges(changed, population.globs);
const reload = projectInputReloadEventShouldNotify({
causedBy,
changed: causedBy.length === 1 ? causedBy[0] : undefined,
changedInputs,
globs: population.globs,
reloadDirectories: population.reloadDirectories,
reloadFiles: population.reloadFiles ?? [],
});
// The callback below consumes the complete population observed by this
// scan. Crucially, reload classification runs against the old baseline
// first, so a concurrent selection delta becomes one cold transition
// instead of disappearing behind the warm compiler-membership handoff.
this.projectInputMatches = matches;
this.projectInputFingerprints = fingerprints;
for (const location of changed) {
const compilerKey = pathKey(location);
if (!this.files.has(compilerKey))
continue;
const fingerprint = fingerprints.get(identities.resolve(location).key);
if (fingerprint !== undefined && fingerprint !== "") {
this.projectInputCompilerAcknowledgements.set(compilerKey, fingerprint);
}
}
return reload;
}
/** Add Go plugin source trees discovered by the real build lane. */
setExtraInputs(inputs) {
const next = uniqueExistingPaths(inputs);
if (arraysEqual(this.extraInputs, next))
return;
this.extraInputs = next;
this.refresh(false);
}
/**
* Reconcile project-rule dependencies, retaining absent files and empty glob
* populations as live topology.
*/
setProjectInputs(inputs) {
const next = normalizeProjectInputSnapshot(inputs);
// The declared spellings are recorded even when the normalized snapshot did
// not move, because a republication can carry a new alias for identities
// that already matched, and anchoring the spelling that was retired would
// leave the live one unwatched.
this.declaredProjectInputs =
inputs.declared === undefined
? inputs
: { ...inputs.declared, root: inputs.root };
if (projectInputSnapshotsEqual(this.projectInputs, next)) {
this.syncProjectInputWatchers();
return;
}
this.projectInputs = next;
this.projectInputRejectedWatchRoots.clear();
this.pruneProjectInputWatchRoots([
next,
inputs,
{ ...(inputs.declared ?? inputs), root: inputs.root },
]);
this.projectInputMatches = this.collectProjectInputMatches();
this.projectInputFingerprints = fingerprintProjectInputMatches(this.projectInputMatches);
this.syncProjectInputWatchers();
}
/** Close every watcher so SIGINT/SIGTERM can drain the event loop. */
close() {
this.closed = true;
closeWatchers(this.fileWatchers);
closeWatchers(this.directoryWatchers);
closeWatchers(this.extraWatchers);
closeWatchers(this.projectInputWatchers);
closeWatchers(this.projectInputLinkWatchers);
}
syncFileWatchers(skipMissing = false) {
const previous = new Map(this.fileWatchers);
const files = process.platform === "win32"
? new Map()
: skipMissing
? new Map([...this.files].filter(([, location]) => node_fs_1.default.existsSync(location)))
: this.files;
syncWatchers(this.fileWatchers, files, (location) => node_fs_1.default.watch(watcherRegistrationPath(location), { persistent: true }, () => {
// A per-file watcher fires on any filesystem attention its target
// receives, and it carries no filename to distinguish an edit from
// a touch. It answers the same question the unnamed directory event
// answers, so it answers it the same way: from the bytes.
const movement = this.compilerFileMovement(location);
if (movement.owner)
this.rearmFileWatchers([location], true);
if (!movement.content)
return;
this.callbacks.onInputChange({
kind: this.classifyCompilerInput(location),
path: location,
});
}), (location, error) => this.callbacks.onError(location, error), () => this.closed === false);
return [...this.fileWatchers].some(([key, watcher]) => previous.get(key) !== watcher);
}
/** Compare a tracked file's content and physical owner with its snapshot. */
compilerFileMovement(location) {
const key = pathKey(location);
const previous = this.compilerFileSnapshots.get(key);
const next = compilerFileSnapshot(location);
this.compilerFileSnapshots.set(key, next);
return {
content: previous?.content !== next.content,
owner: previous?.owner !== next.owner,
};
}
syncDirectoryWatchers() {
const previous = new Map(this.directoryWatchers);
const desired = new Map(this.directories);
for (const [key, location] of this.observedDirectories) {
if (isDirectory(location) === false ||
this.isCompilerOutputDirectory(location) ||
this.isProjectInputDirectory(location)) {
this.observedDirectories.delete(key);
continue;
}
desired.set(key, location);
}
if (process.platform === "win32") {
for (const [key, location] of desired) {
if ([...desired].some(([candidateKey, candidate]) => candidateKey !== key && isPathWithin(candidate, location))) {
desired.delete(key);
}
}
}
syncWatchers(this.directoryWatchers, desired, (location) => node_fs_1.default.watch(watcherRegistrationPath(location), {
persistent: true,
recursive: process.platform === "win32",
}, (event, filename) => {
const changed = filename === null
? undefined
: node_path_1.default.resolve(location, filename.toString());
const pluginInput = changed ?? location;
if (this.isPluginInput(pluginInput)) {
this.callbacks.onInputChange({
kind: "plugin",
path: pluginInput,
});
return;
}
const plan = planCompilerDirectoryWatchEvent({
changed,
event,
exists: node_fs_1.default.existsSync,
location,
platform: process.platform,
trackedFiles: this.files,
});
this.rearmFileWatchers(plan.rearm);
for (const file of this.compilerChangesToReport(plan.changes, changed, event)) {
this.callbacks.onInputChange({
kind: this.classifyCompilerInput(file),
path: file,
});
}
if (plan.refresh)
this.refreshFromDirectory(location, changed);
}), (location, error) => this.callbacks.onError(location, error), () => this.closed === false);
return [...this.directoryWatchers].some(([key, watcher]) => previous.get(key) !== watcher);
}
/**
* Reconcile tracked compiler files after a newly registered watcher returns.
*
* A file or directory watcher can be returned before its backend is ready to
* deliver the first event. The compiler-file stamps were captured before
* registration, so one coalesced microtask can recover a change in that
* handoff window. A real event updates the same stamp first and makes this
* bounded scan a no-op.
*/
scheduleCompilerPostRegistrationReconciliation(refreshMembership, skipUnobservedProjectInputWatchRoots) {
if (this.closed)
return;
if (refreshMembership) {
this.compilerPostRegistrationMembershipRefresh = true;
this.compilerPostRegistrationSkipUnobservedProjectInputWatchRoots =
this.compilerPostRegistrationSkipUnobservedProjectInputWatchRoots &&
skipUnobservedProjectInputWatchRoots;
}
if (this.compilerPostRegistrationReconciliationScheduled)
return;
this.compilerPostRegistrationReconciliationScheduled = true;
queueMicrotask(() => {
this.compilerPostRegistrationReconciliationScheduled = false;
if (this.closed)
return;
const refreshCompilerMembership = this.compilerPostRegistrationMembershipRefresh;
const skipUnobservedProjectInputWatchRoots = this.compilerPostRegistrationSkipUnobservedProjectInputWatchRoots;
this.compilerPostRegistrationMembershipRefresh = false;
this.compilerPostRegistrationSkipUnobservedProjectInputWatchRoots = true;
const changed = [];
const rearm = [];
for (const file of this.files.values()) {
const movement = this.compilerFileMovement(file);
if (movement.content)
changed.push(file);
if (movement.owner)
rearm.push(file);
}
// A replacement can move the path to a new inode without changing its
// cheap content stamp or topology key. Rebind its physical owner without
// inventing a content notification. Missing entries remain covered by
// their parent directory and are retried when recreation is observed.
this.rearmFileWatchers(rearm, true);
for (const file of changed) {
this.callbacks.onInputChange({
kind: this.classifyCompilerInput(file),
path: file,
});
}
if (!refreshCompilerMembership)
return;
try {
// Directory watchers own files not present in the current Program.
// Re-resolve even when every tracked stamp is unchanged so a swallowed
// startup event cannot strand a newly included source.
this.refreshCompilerInputs(true, skipUnobservedProjectInputWatchRoots);
}
catch (error) {
const reported = new Set(changed.map(pathKey));
const reconciledChange = changed.length === 1 ? changed[0] : undefined;
for (const reload of reloadInputsForFailedTopologyRefresh(this.reloadFiles.values(), reconciledChange)) {
if (reported.has(pathKey(reload)))
continue;
this.callbacks.onInputChange({ kind: "config", path: reload });
}
this.callbacks.onError(reconciledChange === undefined
? (this.options.projectRoot ?? this.options.cwd)
: node_path_1.default.dirname(reconciledChange), error);
}
});
}
/**
* Narrow a plan's changes to the tracked files that actually moved.
*
* A backend that cannot name what changed forces the plan to nominate every
* tracked file under the watched directory, which is the only safe answer it
* can give from an event carrying no filename. macOS delivers such events for
* ordinary activity elsewhere in the project, so the compiler lane would wake
* for sources nobody touched. Only a content notification passes through: it
* is the one event that claims the bytes moved. A rename claims the directory
* entry was rewritten and an unnamed event claims nothing, so both are
* decided from the bytes, which is the question neither of them answered.
*/
compilerChangesToReport(changes, changed, event) {
// A content notification is taken at its word: the backend is telling us
// these bytes changed, and second-guessing it would lose an edit that
// landed inside the clock's resolution. A rename says the directory entry
// was rewritten, which is a different claim — a file can be moved back, or
// replaced by an identical copy, without its content moving at all — and an
// event that cannot name anything makes no claim about content either.
// Those two are decided from the bytes, and the rearm they drive is
// unaffected, because rebinding is about the inode and not the content.
if (changed !== undefined && event !== "rename") {
return changes.filter((file) => {
const key = pathKey(file);
const acknowledged = this.projectInputCompilerAcknowledgements.get(key);
this.projectInputCompilerAcknowledgements.delete(key);
this.recordCompilerFileSnapshot(file);
return (acknowledged === undefined ||
acknowledged !== fingerprintProjectInputFile(file));
});
}
return changes.filter((file) => this.compilerFileMovement(file).content);
}
recordCompilerFileSnapshot(file) {
this.compilerFileSnapshots.set(pathKey(file), compilerFileSnapshot(file));
}
rearmFileWatchers(files, skipMissing = false) {
for (const file of files) {
const key = pathKey(file);
this.fileWatchers.get(key)?.close();
this.fileWatchers.delete(key);
}
if (this.syncFileWatchers(skipMissing)) {
this.scheduleCompilerPostRegistrationReconciliation(false, true);
}
}
syncExtraWatchers() {
const directories = new Map();
for (const input of this.extraInputs) {
for (const directory of collectInputDirectories(input)) {
directories.set(pathKey(directory), directory);
}
}
syncWatchers(this.extraWatchers, directories, (location) => node_fs_1.default.watch(watcherRegistrationPath(location), { persistent: true }, (_event, filename) => {
const changed = filename === null
? undefined
: node_path_1.default.resolve(location, filename.toString());
this.callbacks.onInputChange({
kind: "plugin",
path: changed ?? location,
});
}), (location, error) => this.callbacks.onError(location, error), () => this.closed === false);
}
syncProjectInputWatchers(skipUnobservedProjectInputWatchRoots = false) {
if (this.closed)
return;
const previous = new Map(this.projectInputWatchers);
const previousLinks = new Map(this.projectInputLinkWatchers);
const identities = (0, projectInputPathIdentity_1.createProjectInputPathIdentityContext)();
const desired = new Map();
const required = new Map();
for (const file of this.projectInputDeclarations("file")) {
if (this.isProjectInputCompilerOutput(file, identities))
continue;
const location = this.projectInputWatchRoot("file", file, node_path_1.default.dirname(file));
this.retainProjectInputWatchRoot(required, desired, identities, location, node_path_1.default.dirname(file), skipUnobservedProjectInputWatchRoots);
}
for (const glob of this.projectInputDeclarations("glob")) {
const root = literalGlobRoot(glob);
if (this.isProjectInputCompilerOutputDirectory(root, identities)) {
continue;
}
const location = this.projectInputWatchRoot("glob", glob, root);
this.retainProjectInputWatchRoot(required, desired, identities, location, root, skipUnobservedProjectInputWatchRoots);
}
for (const file of this.projectInputDeclarations("reload")) {
if (this.isProjectInputCompilerOutput(file, identities))
continue;
const location = this.projectInputWatchRoot("reload", file, node_path_1.default.dirname(file));
this.retainProjectInputWatchRoot(required, desired, identities, location, node_path_1.default.dirname(file), skipUnobservedProjectInputWatchRoots);
}
for (const directory of this.projectInputDeclarations("reload-directory")) {
if (this.isProjectInputCompilerOutputDirectory(directory, identities)) {
continue;
}
const location = this.projectInputWatchRoot("reload-directory", directory, directory);
this.retainProjectInputWatchRoot(required, desired, identities, location, directory, skipUnobservedProjectInputWatchRoots);
}
const active = new Map();
for (const location of projectInputActiveWatchDirectories(desired.values(), identities)) {
const identity = identities.resolve(location);
active.set(identity.key, identity.path);
}
this.projectInputRequiredWatchRoots = required;
syncWatchers(this.projectInputWatchers, active, (location) => node_fs_1.default.watch(watcherRegistrationPath(location), { persistent: true, recursive: true }, (_event, filename) => {
const changed = filename === null
? undefined
: node_path_1.default.resolve(location, filename.toString());
this.refreshProjectInputs(location, changed);
}), (location, error) => {
const key = identities.resolve(location).key;
const firstFailure = !this.projectInputRejectedWatchRoots.has(key);
this.projectInputRejectedWatchRoots.add(key);
this.callbacks.onError(location, error);
if (firstFailure && !this.closed) {
this.scheduleProjectInputWatcherRecovery();
}
}, () => this.closed === false);
if (this.closed)
return;
if (!this.projectInputRecoveryScheduled) {
this.reportUnobservedProjectInputWatchRoots(required);
}
this.syncProjectInputLinkWatchers(identities);
const watcherRegistered = [...this.projectInputWatchers].some(([key, watcher]) => previous.get(key) !== watcher) ||
[...this.projectInputLinkWatchers].some(([key, watcher]) => previousLinks.get(key) !== watcher);
if (watcherRegistered) {
this.scheduleProjectInputPostRegistrationReconciliation();
}
this.callbacks.onProjectInputWatchRoots?.([...this.projectInputWatchers.keys()]
.map((key) => active.get(key) ?? identities.resolve(key).path)
.sort());
}
/**
* Watch the directory that holds a declaration which is itself a link.
*
* A recursive watcher cannot report the link being replaced. The backend that
* keys its handles by path skips an entry it already knows, and the handle it
* put on the entry followed the link to the target's inode, which unlinking
* and recreating the link never touches. A plain directory watch has neither
* property: it reports the entry by name the moment it moves. These are kept
* apart from the recursive roots because they are not roots — they observe
* one directory, they are never reported as watch roots, and an ancestor
* covering them does not make them redundant.
*/
syncProjectInputLinkWatchers(identities) {
const desired = new Map();
for (const kind of ["file", "reload"]) {
for (const declaration of this.projectInputDeclarations(kind)) {
const declared = node_path_1.default.resolve(declaration);
// The test is whether the declaration is itself a link, not whether its
// spelling is canonical. Comparing against the resolved identity would
// admit every declaration whose ancestor is aliased — which on macOS is
// every declaration under the system temporary directory — and it would
// still miss the retarget, because the watcher goes below the link.
if (!isSymbolicLink(declared))
continue;
if (this.isProjectInputCompilerOutput(declared, identities))
continue;
const parent = nearestExistingDirectory(node_path_1.default.dirname(declared));
if (parent === undefined)
continue;
desired.set(identities.resolve(parent).key, parent);
}
}
syncWatchers(this.projectInputLinkWatchers, desired, (location) => node_fs_1.default.watch(watcherRegistrationPath(location), { persistent: true }, (_event, filename) => {
const changed = filename === null
? undefined
: node_path_1.default.resolve(location, filename.toString());
this.refreshProjectInputs(location, changed);
}), (location, error) => this.callbacks.onError(location, error), () => this.closed === false);
}
/** Drop the watcher that just reported a directory replacement. */
retireProjectInputWatcher(location, identities) {
const key = identities.resolve(location).key;
// A plain directory watcher binds an inode, so a replacement strands it
// exactly as it strands a recursive root. Both maps are keyed the same way,
// so both are retired together and the next sync reinstalls whichever the
// declarations still call for.
for (const watchers of [
this.projectInputWatchers,
this.projectInputLinkWatchers,
]) {
const watcher = watchers.get(key);
if (watcher === undefined)
continue;
watcher.close();
watchers.delete(key);
}
}
retainProjectInputWatchRoot(required, desired, identities, location, target, skipUnobservedProjectInputWatchRoots) {
const requiredIdentity = identities.resolve(location ?? target);
required.set(requiredIdentity.key, requiredIdentity.path);
if (skipUnobservedProjectInputWatchRoots) {
const retainedActiveRoot = [...this.projectInputWatchers.keys()].find((root) => (0, projectInputPathIdentity_1.isProjectInputPathIdentityWithin)(root, requiredIdentity.key));
if (retainedActiveRoot !== undefined) {
desired.set(retainedActiveRoot, retainedActiveRoot);
return;
}
}
if (location === undefined ||
(skipUnobservedProjectInputWatchRoots &&
this.projectInputUnobservedWatchRoots.has(requiredIdentity.key))) {
return;
}
const available = projectInputAvailableWatchDirectory(location, this.projectInputRejectedWatchRoots, identities, this.projectInputs.root);
if (available === undefined)
return;
const identity = identities.resolve(available);
desired.set(identity.key, identity.path);
}
/**
* Retry a failed root on the next reconciliation instead of retiring it for
* the session.
*
* This immediate recovery pass still honors the rejected root so it can
* install a safe ancestor where one exists. Only the recovery fixpoint
* reports a genuinely uncovered lane; transient gaps between fallback
* candidates are not user-visible. The rejection then expires. A later
* compiler refresh or an unchanged project-input republication can retry the
* original root, while a permanently failing backend costs at most one
* attempt per sync.
*/
scheduleProjectInputWatcherRecovery() {
if (this.projectInputRecoveryScheduled)
return;
this.projectInputRecoveryScheduled = true;
queueMicrotask(() => {
try {
let previousRejectionCount = -1;
while (this.closed === false &&
previousRejectionCount !== this.projectInputRejectedWatchRoots.size) {
previousRejectionCount = this.projectInputRejectedWatchRoots.size;
this.syncProjectInputWatchers();
}
}
finally {
this.projectInputRejectedWatchRoots.clear();
this.projectInputRecoveryScheduled = false;
if (!this.closed) {
this.reportUnobservedProjectInputWatchRoots(this.projectInputRequiredWatchRoots);
}
}
});
}
/**
* Reconcile the snapshot-to-watcher handoff after the caller's current turn.
*
* A recursive watcher can return before its backend is ready to deliver the
* first event. The publication baseline is necessarily captured before that
* watcher exists, so an input materialized synchronously after
* `setProjectInputs()` would otherwise depend entirely on that startup event.
* The ordinary fingerprint update makes this scan and a real backend event
* race safely: whichever arrives first records the new population and the
* other becomes a no-op.
*/
scheduleProjectInputPostRegistrationReconciliation() {
if (this.closed ||
this.projectInputPostRegistrationReconciliationScheduled) {
return;
}
this.projectInputPostRegistrationReconciliationScheduled = true;
queueMicrotask(() => {
this.projectInputPostRegistrationReconciliationScheduled = false;
if (this.closed)
return;
this.refreshPublishedProjectInputIdentities();
this.refreshProjectInputs(this.projectInputs.root, undefined, true);
});
}
/**
* Re-resolve declarations after watcher registration.
*
* A missing path can become a symlink before the handoff scan. The retained
* normalized snapshot still names the pre-link spelling in that case, so a
* scan can find the first target file without installing the physical owner
* that must observe later target changes.
*/
refreshPublishedProjectInputIdentities() {
const next = normalizeProjectInputSnapshot(this.declaredProjectInputs);
if (projectInputSnapshotsEqual(this.projectInputs, next))
return;
this.projectInputs = next;
this.pruneProjectInputWatchRoots([next, this.declaredProjectInputs]);
}
/** Drop retained owner choices for declarations no longer published. */
pruneProjectInputWatchRoots(snapshots) {
const declarations = new Set(snapshots.flatMap((snapshot) => [
...snapshot.files.map((file) => projectInputDeclarationKey("file", file)),
...snapshot.globs.map((glob) => projectInputDeclarationKey("glob", glob)),
...(snapshot.reloadFiles ?? []).map((file) => projectInputDeclarationKey("reload", file)),
...(snapshot.reloadDirectories ?? []).map((directory) => projectInputDeclarationKey("reload-directory", directory)),
]));
for (const key of this.projectInputWatchRoots.keys()) {
if (!declarations.has(key))
this.projectInputWatchRoots.delete(key);
}
}
/** Report only newly uncovered project-input roots as an observation loss. */
reportUnobservedProjectInputWatchRoots(required) {
const active = [...this.projectInputWatchers.keys()];
const unavailable = new Map([...required].filter(([key]) => active.every((root) => !(0, projectInputPathIdentity_1.isProjectInputPathIdentityWithin)(root, key))));
const newlyUnavailable = [...unavailable]
.filter(([key]) => !this.projectInputUnobservedWatchRoots.has(key))
.map(([, location]) => location)
.sort();
this.projectInputUnobservedWatchRoots = unavailable;
if (newlyUnavailable.length !== 0) {
this.callbacks.onProjectInputWatchUnavailable?.(newlyUnavailable);
}
}
/**
* One snapshot holding every spelling of every declaration.
*
* Consumers that decide from a population rather than from a single path have
* to see both, or half of them answer from the file a link pointed at when
* the snapshot was published while the event they are judging resolved to the
* file it points at now.
*/
projectInputPopulation() {
return {
files: this.projectInputDeclarations("file"),
globs: this.projectInputDeclarations("glob"),
reloadDirectories: this.projectInputDeclarations("reload-directory"),
reloadFiles: this.projectInputDeclarations("reload"),
root: this.projectInputs.root,
};
}
/**
* Every spelling of one declaration that has to be anchored separately.
*
* The retained snapshot is normalized to physical identities, which is what
* every comparison needs but not what every watcher needs: a declaration
* reached through a symlink resolves to its target's directory, so anchoring
* the normalized form alone watches the bytes and never the link. Retargeting
* or replacing the link then goes unobserved, even though it is exactly what
* decides which bytes the declaration names next. Both spellings are planned
* through the same root selection, so the project-root hoist and the
* nearest-existing-ancestor boundary still bound each of them, and the active
* set drops one again whenever they coincide or share an ancestor.
*/
projectInputDeclarations(kind) {
const select = (snapshot) => kind === "file"
? snapshot.files
: kind === "glob"
? snapshot.globs
: kind === "reload"
? (snapshot.reloadFiles ?? [])
: (snapshot.reloadDirectories ?? []);
const seen = new Set();
const declarations = [];
for (const entry of [
...select(this.projectInputs),
...select(this.declaredProjectInputs),
]) {
const key = (0, projectInputPathIdentity_1.resolveProjectInputPath)(entry);
if (seen.has(key))
continue;
seen.add(key);
declarations.push(entry);
}
return declarations;
}
projectInputWatchRoot(kind, declaration, target) {
const key = projectInputDeclarationKey(kind, declaration);
const retained = this.projectInputWatchRoots.get(key);
if (retained !== undefined && isDirectory(retained))
return retained;
const resolved = projectInputRecursiveWatchRoot(target, this.projectInputs.root);
if (resolved !== undefined)
this.projectInputWatchRoots.set(key, resolved);
return resolved;
}
refreshProjectInputs(location, changed, skipUnobservedProjectInputWatchRoots = false) {
try {
const previous = this.projectInputMatches;
const identities = (0, projectInputPathIdentity_1.createProjectInputPathIdentityContext)();
// One population for the whole decision. Every question below is asked of
// the same declarations, and rebuilding it per question would both cost
// more and let two answers disagree about what was declared.
const population = this.projectInputPopulation();
const directlyMatched = changed !== undefined &&
(previous.has(identities.resolve(changed).key) ||
matchesProjectInput(population, changed, identities));
const topologyMatched = changed !== undefined &&
projectInputTopologyMayAffect(population, changed, previous, identities);
if (changed !== undefined &&
(this.isProjectInputCompilerOutput(changed, identities) ||
(directlyMatched === false && topologyMatched === false))) {
return;
}
// Rearm before snapshotting. A watcher that has to be replaced stops
// delivering the moment it is closed, so a scan taken first would become
// the baseline for a window in which nothing was watched, and anything
// written there would never be announced again. Reinstalling first makes
// the scan below observe whatever the gap swallowed.
if (changed !== undefined &&
projectInputReplacementStrandsWatchers(population, changed, identities)) {
this.retireProjectInputWatcher(location, identities);
this.syncProjectInputWatchers();
}
const next = this.collectProjectInputMatches();
const membershipChanged = mapsEqual(previous, next) === false;
const nextFingerprints = changed === undefined ||
membershipChanged ||
directlyMatched ||
topologyMatched
? fingerprintProjectInputMatches(next)
: this.projectInputFingerprints;
const contentChanged = mapsEqual(this.projectInputFingerprints, nextFingerprints) === false;
const changedInputs = projectInputChangedPaths({
next,
nextFingerprints,
previous,
previousFingerprints: this.projectInputFingerprints,
});
const reconciledChange = changed ?? (changedInputs.length === 1 ? changedInputs[0] : undefined);
// Both spellings classify the event. The normalized form names the file a
// link pointed at when the snapshot was published, so after a retarget it
// names the wrong one; only the declared form resolves to what the link
// points at now, which is the selection this lane exists to protect.
const reload = projectInputReloadEventShouldNotify({
changed: reconciledChange,
changedInputs,
globs: population.globs,
reloadDirectories: population.reloadDirectories ?? [],
reloadFiles: population.reloadFiles ?? [],
});
const invalidate = projectInputMembershipInvalidatesProgram({
changed: reconciledChange,
changedInputs,
contentChanged,
next,
previous,
});
this.projectInputMatches = next;
this.projectInputFingerprints = nextFingerprints;
this.syncProjectInputWatchers(skipUnobservedProjectInputWatchRoots);
// A JSON/TS/JS project-input member can simultaneously enter or leave
// the compiler Program. Reconcile the compiler watch snapshot before
// scheduling its resident invalidation, so runWatch's post-cycle refresh
// does not rediscover the same delta as a broader execution reload.
if (invalidate) {
this.refreshCompilerInputs(false, skipUnobservedProjectInputWatchRoots);
}
if (projectInputEventShouldNotify({
contentChanged,
directlyMatched,
membershipChanged,
}) &&
(reconciledChange === undefined ||
this.isProjectInputCompilerOutput(reconciledChange, identities) ===
false)) {
this.callbacks.onInputChange(reload
? { kind: "config", path: reconciledChange }
: {
...(invalidate ? { invalidate: true } : {}),
kind: "project",
path: reconciledChange,
});
}
}
catch (error) {
// A rename can invalidate the old filesystem object before the
// replacement is readable. Rebind ancestor ownership even when the
// population scan races that transient gap, so a later create cannot be
// stranded without a watcher.
this.syncProjectInputWatchers(skipUnobservedProjectInputWatchRoots);
this.callbacks.onError(location, error);
}
}
collectProjectInputMatches() {
const identities = (0, projectInputPathIdentity_1.createProjectInputPathIdentityContext)();
const matches = new Map();
// Both spellings are scanned, and each is resolved here rather than when
// the snapshot arrived. A declaration reached through a symlink otherwise
// keeps the identity it had when it was published, so retargeting the link
// moves no key, changes no fingerprint, and the cycle never learns that the
// bytes it depends on are now a different file.
for (const file of this.projectInputDeclarations("file")) {
if (node_fs_1.default.existsSync(file) &&
this.isProjectInputCompilerOutput(file, identities) === false) {
const identity = identities.resolve(file);
matches.set(identity.key, identity.path);
}
}
for (const file of this.projectInputDeclarations("reload")) {
if (node_fs_1.default.existsSync(file) &&
this.isProjectInputCompilerOutput(file, identities) === false) {
const identity = identities.resolve(file);
matches.set(identity.key, identity.path);
}
}
for (const directory of this.projectInputDeclarations("reload-directory")) {
if (isDirectory(directory) &&
this.isProjectInputCompilerOutputDirectory(directory, identities) ===
false) {
const identity = identities.resolve(directory);
matches.set(identity.key, identity.path);
}
}
for (const glob of this.projectInputDeclarations("glob")) {
const root = literalGlobRoot(glob);
if (isDirectory(root) === false ||
this.isProjectInputCompilerOutputDirectory(root, identities)) {
continue;
}
const stack = [root];
while (stack.length !== 0) {
const current = stack.pop();
let entries;
try {
entries = node_fs_1.default.readdirSync(current, { withFileTypes: true });
}
catch (error) {
if (isVanishedFilesystemEntry(error))
continue;
throw error;
}
for (const entry of entries) {
const location = node_path_1.default.join(current, entry.name);
if (this.isProjectInputCompilerOutput(location, identities)) {
continue;
}
if (entry.isDirectory()) {
stack.push(location);
}
else if (entry.isFile() &&
matchesProjectInputGlob(glob, location, identities)) {
const identity = identities.resolve(location);
matches.set(identity.key, identity.path);
}
}
}
}
return matches;
}
refreshFromDirectory(location, changed) {
if (changed !== undefined &&
isDirectory(changed) &&
this.isCompilerOutputDirectory(changed) === false &&
this.isProjectInputDirectory(changed) === false) {
this.observedDirectories.set(pathKey(changed), changed);
}
try {
this.refresh(true);
}
catch (error) {
for (const reload of reloadInputsForFailedTopologyRefresh(this.reloadFiles.values(), changed)) {
this.callbacks.onInputChange({ kind: "config", path: reload });
}
this.callbacks.onError(location, error);
}
}
isCompilerOutputDirectory(location) {
return [...this.outputs.values()].some((output) => isPathWithin(output, location));
}
isCompilerOutput(location) {
return (this.outputFiles.has(pathKey(location)) ||
this.isCompilerOutputDirectory(location));
}
isProjectInputCompilerOutputDirectory(location, identities) {
const root = this.projectInputs.root;
let overlaps = this.projectInputCompilerOutputOverlaps.get(identities);
if (overlaps === undefined) {
overlaps = new Map();
this.projectInputCompilerOutputOverlaps.set(identities, overlaps);
}
return [...