From 2de033e5d200cb823c6c791f8b37290448f9427d Mon Sep 17 00:00:00 2001 From: John Favret <64748847+johnfav03@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:51:49 -0700 Subject: [PATCH 1/2] switched isdircoveredbywatch for dirwatchset --- internal/execute/build/orchestrator.go | 38 ++--- internal/execute/watcher.go | 13 +- internal/execute/watchmanager/watchmanager.go | 50 ++++++- .../execute/watchmanager/watchmanager_test.go | 130 ++++++++++++++++++ 4 files changed, 193 insertions(+), 38 deletions(-) create mode 100644 internal/execute/watchmanager/watchmanager_test.go diff --git a/internal/execute/build/orchestrator.go b/internal/execute/build/orchestrator.go index 8adac69a991..da13e64bbff 100644 --- a/internal/execute/build/orchestrator.go +++ b/internal/execute/build/orchestrator.go @@ -417,7 +417,7 @@ func (o *Orchestrator) packageJsonLookupChanged(packageJson string, changedPaths } func (o *Orchestrator) computeDesiredWatches() map[string]bool { - desiredDirs := make(map[string]bool) + desiredDirs := watchmanager.NewDirWatchSet(o.comparePathsOptions) for i := range o.order { config := o.order[i] @@ -427,9 +427,7 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool { // Watch config file directory configDir := tspath.GetDirectoryPath(task.config) realConfigDir := o.host.FS().Realpath(configDir) - if _, has := desiredDirs[realConfigDir]; !has { - desiredDirs[realConfigDir] = false - } + desiredDirs.Set(realConfigDir, false) if task.resolved == nil { continue @@ -439,29 +437,21 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool { for _, cfgPath := range task.resolved.ExtendedSourceFiles() { realPath := o.host.FS().Realpath(cfgPath) dir := tspath.GetDirectoryPath(realPath) - if _, has := desiredDirs[dir]; !has { - desiredDirs[dir] = false - } + desiredDirs.Set(dir, false) } // Wildcard directories from tsconfig for dir, recursive := range task.resolved.WildcardDirectories() { realDir := o.host.FS().Realpath(dir) - if existing, has := desiredDirs[realDir]; has { - desiredDirs[realDir] = existing || recursive - } else { - desiredDirs[realDir] = recursive - } + desiredDirs.Set(realDir, recursive) } // Input file directories not already covered for _, fileName := range task.resolved.FileNames() { absPath := tspath.GetNormalizedAbsolutePath(fileName, o.opts.Sys.GetCurrentDirectory()) dir := tspath.GetDirectoryPath(absPath) - if !watchmanager.IsDirCoveredByWatch(desiredDirs, dir, o.comparePathsOptions) { - if watchmanager.CanWatchDirectory(dir) { - desiredDirs[dir] = false - } + if !desiredDirs.Covered(dir) && watchmanager.CanWatchDirectory(dir) { + desiredDirs.Set(dir, false) } } @@ -479,10 +469,8 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool { continue } dir := tspath.GetDirectoryPath(absPath) - if !watchmanager.IsDirCoveredByWatch(desiredDirs, dir, o.comparePathsOptions) { - if watchmanager.CanWatchDirectory(dir) { - desiredDirs[dir] = false - } + if !desiredDirs.Covered(dir) && watchmanager.CanWatchDirectory(dir) { + desiredDirs.Set(dir, false) } } for packageJson := range bi.buildInfo.GetPackageJsons(buildInfoDir) { @@ -497,16 +485,16 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool { } } - return o.wm.ResolveDesiredDirs(desiredDirs) + return o.wm.ResolveDesiredDirs(desiredDirs.Dirs()) } -func (o *Orchestrator) addWatchDir(desiredDirs map[string]bool, dir string) { - if !watchmanager.IsDirCoveredByWatch(desiredDirs, dir, o.comparePathsOptions) && watchmanager.CanWatchDirectory(dir) { - desiredDirs[dir] = false +func (o *Orchestrator) addWatchDir(desiredDirs *watchmanager.DirWatchSet, dir string) { + if !desiredDirs.Covered(dir) && watchmanager.CanWatchDirectory(dir) { + desiredDirs.Set(dir, false) } } -func (o *Orchestrator) addPackageJsonWatchDirs(desiredDirs map[string]bool, packageJson string) { +func (o *Orchestrator) addPackageJsonWatchDirs(desiredDirs *watchmanager.DirWatchSet, packageJson string) { dir := tspath.GetDirectoryPath(packageJson) dirs := []string{dir} foundNodeModules := false diff --git a/internal/execute/watcher.go b/internal/execute/watcher.go index 9b11c2a4dd4..d75d1b69ad6 100644 --- a/internal/execute/watcher.go +++ b/internal/execute/watcher.go @@ -184,18 +184,19 @@ func (w *Watcher) computeDesiredWatches(seenFilePaths []string) map[string]bool // Resolve ancestor fallbacks first so coverage checks use final dirs. resolvedDirs := w.wm.ResolveDesiredDirs(desiredDirs) - opts := w.comparePathsOptions() + coverage := watchmanager.NewDirWatchSet(w.comparePathsOptions()) + for dir, recursive := range resolvedDirs { + coverage.Set(dir, recursive) + } for _, filePath := range seenFilePaths { dir := tspath.GetDirectoryPath(filePath) - if !watchmanager.IsDirCoveredByWatch(resolvedDirs, dir, opts) { - if watchmanager.CanWatchDirectory(dir) { - resolvedDirs[dir] = false - } + if !coverage.Covered(dir) && watchmanager.CanWatchDirectory(dir) { + coverage.Set(dir, false) } } // Re-resolve in case newly added dirs don't exist - return w.wm.ResolveDesiredDirs(resolvedDirs) + return w.wm.ResolveDesiredDirs(coverage.Dirs()) } func (w *Watcher) reconcileWatches(seenFilePaths []string) error { diff --git a/internal/execute/watchmanager/watchmanager.go b/internal/execute/watchmanager/watchmanager.go index 46120ac76de..45d6dff63cc 100644 --- a/internal/execute/watchmanager/watchmanager.go +++ b/internal/execute/watchmanager/watchmanager.go @@ -306,19 +306,55 @@ func (wm *WatchManager) createDirWatches(updates []dirWatchUpdate) error { return nil } -func IsDirCoveredByWatch(dirs map[string]bool, dir string, opts tspath.ComparePathsOptions) bool { - for wdir, recursive := range dirs { - if recursive { - if tspath.ContainsPath(wdir, dir, opts) { - return true - } - } else if tspath.ComparePaths(dir, wdir, opts) == 0 { +// DirWatchSet accumulates the set of directories that should be watched while +// answering coverage queries efficiently. A directory is "covered" when it is +// already present in the set, or when it is contained within a recursive watch +// directory already in the set. +type DirWatchSet struct { + opts tspath.ComparePathsOptions + dirs map[string]bool + present map[string]struct{} + recursive []string +} + +func NewDirWatchSet(opts tspath.ComparePathsOptions) *DirWatchSet { + return &DirWatchSet{ + opts: opts, + dirs: make(map[string]bool), + present: make(map[string]struct{}), + } +} + +func (s *DirWatchSet) canonical(dir string) string { + return tspath.GetCanonicalFileName(dir, s.opts.UseCaseSensitiveFileNames) +} + +func (s *DirWatchSet) Set(dir string, recursive bool) { + existing, has := s.dirs[dir] + newRecursive := existing || recursive + s.dirs[dir] = newRecursive + s.present[s.canonical(dir)] = struct{}{} + if newRecursive && (!has || !existing) { + s.recursive = append(s.recursive, dir) + } +} + +func (s *DirWatchSet) Covered(dir string) bool { + if _, has := s.present[s.canonical(dir)]; has { + return true + } + for _, wdir := range s.recursive { + if tspath.ContainsPath(wdir, dir, s.opts) { return true } } return false } +func (s *DirWatchSet) Dirs() map[string]bool { + return s.dirs +} + func (wm *WatchManager) IsPathUnderWatch(path string, opts tspath.ComparePathsOptions) bool { for dir := range wm.watchedDirs { if tspath.ContainsPath(dir, path, opts) { diff --git a/internal/execute/watchmanager/watchmanager_test.go b/internal/execute/watchmanager/watchmanager_test.go new file mode 100644 index 00000000000..d84770a85c7 --- /dev/null +++ b/internal/execute/watchmanager/watchmanager_test.go @@ -0,0 +1,130 @@ +package watchmanager + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/tspath" + "gotest.tools/v3/assert" +) + +var ( + caseSensitiveOpts = tspath.ComparePathsOptions{UseCaseSensitiveFileNames: true, CurrentDirectory: "/repo"} + caseInsensitiveOpts = tspath.ComparePathsOptions{UseCaseSensitiveFileNames: false, CurrentDirectory: "/repo"} +) + +// TestDirWatchSetCoverage checks the core coverage rules: a recursive watch +// covers itself and all descendants, while a non-recursive watch covers only +// itself. Ancestors and unrelated paths are never covered. +func TestDirWatchSetCoverage(t *testing.T) { + t.Parallel() + + set := NewDirWatchSet(caseSensitiveOpts) + set.Set("/repo/src", true) // recursive + set.Set("/repo/config", false) // non-recursive + set.Set("/repo/node_modules/a", false) // non-recursive + + tests := []struct { + dir string + want bool + }{ + {"/repo/src", true}, // exact recursive + {"/repo/src/nested", true}, // descendant of recursive + {"/repo/src/nested/deep", true}, // deep descendant of recursive + {"/repo/config", true}, // exact non-recursive + {"/repo/config/nested", false}, // descendant of non-recursive: NOT covered + {"/repo/node_modules/a", true}, // exact non-recursive + {"/repo/node_modules/b", false}, // sibling, absent + {"/repo", false}, // ancestor of watched dirs: NOT covered + {"/other", false}, // unrelated + } + for _, tt := range tests { + assert.Equal(t, set.Covered(tt.dir), tt.want, "Covered(%q)", tt.dir) + } +} + +// TestDirWatchSetCaseSensitive verifies that on a case-sensitive filesystem a +// differently-cased directory is a distinct, uncovered directory. +func TestDirWatchSetCaseSensitive(t *testing.T) { + t.Parallel() + + set := NewDirWatchSet(caseSensitiveOpts) + set.Set("/repo/node_modules/a", false) + set.Set("/repo/Src", true) + + assert.Assert(t, set.Covered("/repo/node_modules/a")) + assert.Assert(t, !set.Covered("/repo/node_modules/A"), "case-sensitive FS must not cover differently-cased dir") + assert.Assert(t, set.Covered("/repo/Src/nested"), "recursive descendant with matching case is covered") + assert.Assert(t, !set.Covered("/repo/src/nested"), "case-sensitive FS must not cover differently-cased descendant") +} + +// TestDirWatchSetCaseInsensitive verifies that on a case-insensitive filesystem +// coverage ignores casing for both exact matches and recursive containment. +func TestDirWatchSetCaseInsensitive(t *testing.T) { + t.Parallel() + + set := NewDirWatchSet(caseInsensitiveOpts) + set.Set("/repo/node_modules/a", false) + set.Set("/repo/Src", true) + + assert.Assert(t, set.Covered("/repo/node_modules/A"), "exact match should be case-insensitive") + assert.Assert(t, set.Covered("/REPO/NODE_MODULES/a"), "exact match should be case-insensitive across components") + assert.Assert(t, set.Covered("/repo/src/nested/deep"), "recursive containment should be case-insensitive") +} + +// TestDirWatchSetPreservesCasing verifies Dirs returns original-cased keys so +// watch registration uses the real path even on case-insensitive filesystems. +func TestDirWatchSetPreservesCasing(t *testing.T) { + t.Parallel() + + set := NewDirWatchSet(caseInsensitiveOpts) + set.Set("/repo/Node_Modules/PkgName", false) + + _, ok := set.Dirs()["/repo/Node_Modules/PkgName"] + assert.Assert(t, ok, "Dirs must preserve original casing for watch registration") + _, lowered := set.Dirs()["/repo/node_modules/pkgname"] + assert.Assert(t, !lowered, "Dirs must not contain a canonicalized (lowercased) key") +} + +// TestDirWatchSetUpgradeToRecursive verifies that upgrading a directory from +// non-recursive to recursive begins covering its descendants. +func TestDirWatchSetUpgradeToRecursive(t *testing.T) { + t.Parallel() + + set := NewDirWatchSet(caseSensitiveOpts) + set.Set("/repo/src", false) + assert.Assert(t, set.Covered("/repo/src")) + assert.Assert(t, !set.Covered("/repo/src/nested"), "descendant not covered while non-recursive") + + set.Set("/repo/src", true) + assert.Assert(t, set.Covered("/repo/src/nested"), "descendant covered after upgrade to recursive") + assert.Equal(t, set.Dirs()["/repo/src"], true) +} + +// TestDirWatchSetNeverDowngrades verifies a recursive watch is not downgraded by +// a subsequent non-recursive Set of the same directory. +func TestDirWatchSetNeverDowngrades(t *testing.T) { + t.Parallel() + + set := NewDirWatchSet(caseSensitiveOpts) + set.Set("/repo/src", true) + set.Set("/repo/src", false) + + assert.Equal(t, set.Dirs()["/repo/src"], true) + assert.Assert(t, set.Covered("/repo/src/nested"), "recursive coverage retained after non-recursive Set") +} + +// TestDirWatchSetDirs verifies the emitted map reflects every added directory +// with the expected recursive flags. +func TestDirWatchSetDirs(t *testing.T) { + t.Parallel() + + set := NewDirWatchSet(caseSensitiveOpts) + set.Set("/repo/a", false) + set.Set("/repo/b", true) + set.Set("/repo/a", false) // duplicate non-recursive add is idempotent + + dirs := set.Dirs() + assert.Equal(t, len(dirs), 2) + assert.Equal(t, dirs["/repo/a"], false) + assert.Equal(t, dirs["/repo/b"], true) +} From 9378ad4f178a75021a61519aac2c0bdbfe370f44 Mon Sep 17 00:00:00 2001 From: John Favret <64748847+johnfav03@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:28:25 -0700 Subject: [PATCH 2/2] fixed fmt --- internal/execute/watchmanager/watchmanager.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/execute/watchmanager/watchmanager.go b/internal/execute/watchmanager/watchmanager.go index 45d6dff63cc..b9813780c25 100644 --- a/internal/execute/watchmanager/watchmanager.go +++ b/internal/execute/watchmanager/watchmanager.go @@ -311,9 +311,9 @@ func (wm *WatchManager) createDirWatches(updates []dirWatchUpdate) error { // already present in the set, or when it is contained within a recursive watch // directory already in the set. type DirWatchSet struct { - opts tspath.ComparePathsOptions - dirs map[string]bool - present map[string]struct{} + opts tspath.ComparePathsOptions + dirs map[string]bool + present map[string]struct{} recursive []string }