From b939b9699f40873777568164c0f57a1f09ea31e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 13:47:21 +0000 Subject: [PATCH 1/3] fix(scanner): Resolve tsconfig alias targets written as "./*" create-next-app writes "paths": {"@/*": ["./*"]}, and has for years, so this is the most common TypeScript layout in the wild. Substituting the wildcard produced "./lib/a1" while the file index stores repository-relative paths with no "./" prefix, so tryExactMatch and trySuffixMatch found nothing and a file imported everywhere reported no importers at all. The failure was silent: nothing marks an unresolved alias, so a whole Next.js project read as standalone files with a confident answer. Normalize the substituted target before matching. filepath.Join already cleaned it whenever a baseUrl was set, which is why this only ever bit projects without one, and why "@/*": ["*"] worked as a workaround. The same normalization applies to the no-wildcard exact-alias branch, which had the identical bug for targets like {"@app": ["./lib/a1"]}. Relates to #173, #172 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEUvjGsJemDFSV8nbxvxBo --- scanner/aliastarget_test.go | 78 +++++++++++++++++++ scanner/filegraph.go | 20 +++++ .../tsconfig-alias-dotslash/app/layout.tsx | 5 ++ testdata/tsconfig-alias-dotslash/app/page.tsx | 5 ++ testdata/tsconfig-alias-dotslash/lib/a1.ts | 3 + .../tsconfig-alias-dotslash/tsconfig.json | 7 ++ 6 files changed, 118 insertions(+) create mode 100644 scanner/aliastarget_test.go create mode 100644 testdata/tsconfig-alias-dotslash/app/layout.tsx create mode 100644 testdata/tsconfig-alias-dotslash/app/page.tsx create mode 100644 testdata/tsconfig-alias-dotslash/lib/a1.ts create mode 100644 testdata/tsconfig-alias-dotslash/tsconfig.json diff --git a/scanner/aliastarget_test.go b/scanner/aliastarget_test.go new file mode 100644 index 0000000..e7266ab --- /dev/null +++ b/scanner/aliastarget_test.go @@ -0,0 +1,78 @@ +package scanner + +import ( + "context" + "reflect" + "testing" +) + +// create-next-app has shipped "@/*": ["./*"] for years, so this is the most +// common TypeScript layout in the wild. The substituted target was "./lib/a1" +// while the file index holds "lib/a1", so nothing matched and a file imported +// everywhere reported no importers at all. +func TestTsconfigDotSlashAliasResolvesImporters(t *testing.T) { + graph, err := BuildFileGraph(context.Background(), "../testdata/tsconfig-alias-dotslash", Filters{}) + if err != nil { + t.Fatalf("build alias fixture graph: %v", err) + } + got := graph.Importers["lib/a1.ts"] + want := []string{"app/layout.tsx", "app/page.tsx"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("lib/a1.ts importers = %v, want exactly %v", got, want) + } +} + +func TestNormalizeAliasTarget(t *testing.T) { + for _, tc := range []struct { + name string + target string + want string + }{ + {"create-next-app default", "./lib/a1", "lib/a1"}, + {"bare wildcard target", "lib/a1", "lib/a1"}, + {"nested with dot slash", "./src/lib/s1", "src/lib/s1"}, + {"redundant separators", "./src//lib/../lib/s1", "src/lib/s1"}, + {"bare dot collapses to root", ".", ""}, + {"dot slash only", "./", ""}, + {"empty stays empty", "", ""}, + // A target escaping the project must not be silently rewritten into a + // path that could match an unrelated file. + {"parent traversal preserved", "../shared/x", "../shared/x"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := normalizeAliasTarget(tc.target); got != tc.want { + t.Fatalf("normalizeAliasTarget(%q) = %q, want %q", tc.target, got, tc.want) + } + }) + } +} + +// Every alias target shape has to keep resolving, so the "./" fix cannot +// regress the forms that already worked. +func TestPathAliasTargetShapes(t *testing.T) { + files := []FileInfo{{Path: "lib/a1.ts"}, {Path: "src/lib/s1.ts"}, {Path: "app/page.tsx"}} + idx := buildFileIndex(files, "") + + for _, tc := range []struct { + name string + imp string + aliases map[string][]string + baseURL string + want []string + }{ + {"dot slash wildcard", "@/lib/a1", map[string][]string{"@/*": {"./*"}}, "", []string{"lib/a1.ts"}}, + {"bare wildcard", "@/lib/a1", map[string][]string{"@/*": {"*"}}, "", []string{"lib/a1.ts"}}, + {"nested dot slash", "@/lib/s1", map[string][]string{"@/*": {"./src/*"}}, "", []string{"src/lib/s1.ts"}}, + {"nested bare", "@/lib/s1", map[string][]string{"@/*": {"src/*"}}, "", []string{"src/lib/s1.ts"}}, + {"base url with dot slash", "@/lib/a1", map[string][]string{"@/*": {"./*"}}, ".", []string{"lib/a1.ts"}}, + {"exact alias with dot slash", "@app", map[string][]string{"@app": {"./lib/a1"}}, "", []string{"lib/a1.ts"}}, + {"unmatched alias resolves to nothing", "@/nope", map[string][]string{"@/*": {"./*"}}, "", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + got := resolvePathAlias(tc.imp, tc.aliases, tc.baseURL, idx, "typescript") + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("resolvePathAlias(%q, %v, baseURL=%q) = %v, want %v", tc.imp, tc.aliases, tc.baseURL, got, tc.want) + } + }) + } +} diff --git a/scanner/filegraph.go b/scanner/filegraph.go index bb1acf0..73227c1 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -811,6 +811,23 @@ func readTSConfig(configPath, root string) (map[string][]string, string) { return paths, baseURL } +// normalizeAliasTarget puts a substituted tsconfig alias target into the same +// shape as the file index, which stores repository-relative paths with no "./" +// prefix. create-next-app has shipped "@/*": ["./*"] for years, so the +// substituted target is "./lib/a1" while the index holds "lib/a1" and nothing +// matches. filepath.Join already cleaned the target when a baseUrl was set, +// which is why this only ever bit projects without one. +func normalizeAliasTarget(target string) string { + if target == "" { + return target + } + cleaned := filepath.ToSlash(filepath.Clean(target)) + if cleaned == "." { + return "" + } + return cleaned +} + // resolvePathAlias attempts to resolve an import using TypeScript path aliases // e.g., "@modules/auth" with alias "@modules/*" -> ["src/modules/*"] becomes "src/modules/auth" func resolvePathAlias(imp string, pathAliases map[string][]string, baseURL string, idx *fileIndex, sourceLanguage string) []string { @@ -828,6 +845,7 @@ func resolvePathAlias(imp string, pathAliases map[string][]string, baseURL strin if baseURL != "" && !filepath.IsAbs(resolved) { resolved = filepath.Join(baseURL, resolved) } + resolved = normalizeAliasTarget(resolved) if files := tryExactMatch(resolved, idx, sourceLanguage); len(files) > 0 { return files } @@ -863,6 +881,8 @@ func resolvePathAlias(imp string, pathAliases map[string][]string, baseURL strin resolved = filepath.Join(baseURL, resolved) } + resolved = normalizeAliasTarget(resolved) + // Try to find matching files if files := tryExactMatch(resolved, idx, sourceLanguage); len(files) > 0 { return files diff --git a/testdata/tsconfig-alias-dotslash/app/layout.tsx b/testdata/tsconfig-alias-dotslash/app/layout.tsx new file mode 100644 index 0000000..65440b3 --- /dev/null +++ b/testdata/tsconfig-alias-dotslash/app/layout.tsx @@ -0,0 +1,5 @@ +import { a1 } from "@/lib/a1"; + +export function Layout() { + return a1(); +} diff --git a/testdata/tsconfig-alias-dotslash/app/page.tsx b/testdata/tsconfig-alias-dotslash/app/page.tsx new file mode 100644 index 0000000..a9b6e04 --- /dev/null +++ b/testdata/tsconfig-alias-dotslash/app/page.tsx @@ -0,0 +1,5 @@ +import { a1 } from "@/lib/a1"; + +export default function Page() { + return a1(); +} diff --git a/testdata/tsconfig-alias-dotslash/lib/a1.ts b/testdata/tsconfig-alias-dotslash/lib/a1.ts new file mode 100644 index 0000000..2783dfc --- /dev/null +++ b/testdata/tsconfig-alias-dotslash/lib/a1.ts @@ -0,0 +1,3 @@ +export function a1(): string { + return "a1"; +} diff --git a/testdata/tsconfig-alias-dotslash/tsconfig.json b/testdata/tsconfig-alias-dotslash/tsconfig.json new file mode 100644 index 0000000..2a2e4b3 --- /dev/null +++ b/testdata/tsconfig-alias-dotslash/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./*"] + } + } +} From 298dffd88d18ff5aa9fc33be0c48ef0b1687350d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 13:50:35 +0000 Subject: [PATCH 2/3] test(scanner): Compare fixture importers as a set The graph appends importers in analysis order, which is not stable across runs, so asserting a sequence made the alias fixture test fail on two CI legs while passing locally. The exactness that matters here is membership: neither a superset nor a subset of the expected importers. The underlying nondeterminism is pre-existing and unrelated to the alias fix; it is reported separately rather than papered over here. Relates to #173, #172 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEUvjGsJemDFSV8nbxvxBo --- scanner/aliastarget_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scanner/aliastarget_test.go b/scanner/aliastarget_test.go index e7266ab..930265f 100644 --- a/scanner/aliastarget_test.go +++ b/scanner/aliastarget_test.go @@ -3,6 +3,7 @@ package scanner import ( "context" "reflect" + "sort" "testing" ) @@ -15,7 +16,11 @@ func TestTsconfigDotSlashAliasResolvesImporters(t *testing.T) { if err != nil { t.Fatalf("build alias fixture graph: %v", err) } - got := graph.Importers["lib/a1.ts"] + // Compare as a set: the graph appends importers in analysis order, which + // is not deterministic across runs (see the note in the PR for #173). The + // exactness this asserts is membership, not sequence. + got := append([]string(nil), graph.Importers["lib/a1.ts"]...) + sort.Strings(got) want := []string{"app/layout.tsx", "app/page.tsx"} if !reflect.DeepEqual(got, want) { t.Fatalf("lib/a1.ts importers = %v, want exactly %v", got, want) From 7629e768fea5067ce38eb26ecc3aeec1a9bed529 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 14:08:23 +0000 Subject: [PATCH 3/3] fix(watch): Treat an unparseable readiness file as not-ready-yet Ported from #177, which I opened for this failure. Carrying it here so this PR has its best chance of going green rather than waiting on that one to merge; it becomes a no-op once main has it. waitWatchReadiness returned on the first successful read, so a readiness file caught mid-write failed json.Unmarshal and aborted the wait immediately. Measured against the real function, an empty file returns "reading daemon readiness: unexpected end of JSON input" after 0s, which is the string TestRunWatchStartWaitsForChildReadinessFailure reported on this PR's ubuntu-1.24 leg. I could not reproduce that CI failure locally, so this is the plausible cause rather than a proven one. The change is correct on its own terms either way: an unparseable file is a file still being written, not a daemon that failed. Relates to #173, #172 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEUvjGsJemDFSV8nbxvxBo --- main.go | 23 +++++++++----- watch_readiness_test.go | 66 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 watch_readiness_test.go diff --git a/main.go b/main.go index e14ae1b..27f8d0c 100644 --- a/main.go +++ b/main.go @@ -1153,22 +1153,31 @@ func publishWatchReadiness(path string, readinessErr error) error { func waitWatchReadiness(path string, timeout time.Duration) error { deadline := time.Now().Add(timeout) + // A readiness file that does not parse is a file still being written, not + // a daemon that failed: publishWatchReadiness renames its payload into + // place atomically, but nothing guarantees every writer does, and treating + // the first unparseable read as fatal reported a startup failure for a + // daemon that was starting fine. Keep the last parse error so a file that + // never becomes valid says why, rather than only that it timed out. + var lastParseErr error for { data, err := os.ReadFile(path) if err == nil { var status watchReadiness - if err := json.Unmarshal(data, &status); err != nil { - return fmt.Errorf("reading daemon readiness: %w", err) - } - if status.Error != "" { + if parseErr := json.Unmarshal(data, &status); parseErr != nil { + lastParseErr = parseErr + } else if status.Error != "" { return errors.New(status.Error) + } else { + return nil } - return nil - } - if !errors.Is(err, os.ErrNotExist) { + } else if !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("reading daemon readiness: %w", err) } if time.Now().After(deadline) { + if lastParseErr != nil { + return fmt.Errorf("reading daemon readiness: %w", lastParseErr) + } return fmt.Errorf("daemon readiness timed out after %s", timeout) } time.Sleep(10 * time.Millisecond) diff --git a/watch_readiness_test.go b/watch_readiness_test.go new file mode 100644 index 0000000..e2a10cb --- /dev/null +++ b/watch_readiness_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// A readiness file caught mid-write reads as zero or partial bytes. Treating +// that as fatal reported a startup failure for a daemon that was starting +// fine, and made TestRunWatchStartWaitsForChildReadinessFailure fail whenever +// the machine was loaded enough to land in the window. +func TestWaitWatchReadinessWaitsThroughPartialWrite(t *testing.T) { + path := filepath.Join(t.TempDir(), "ready.json") + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatal(err) + } + go func() { + time.Sleep(50 * time.Millisecond) + _ = os.WriteFile(path, []byte(`{"error":"claim rejected"}`), 0o644) + }() + + err := waitWatchReadiness(path, 5*time.Second) + if err == nil || !strings.Contains(err.Error(), "claim rejected") { + t.Fatalf("waitWatchReadiness() = %v, want the daemon's own error once the file is complete", err) + } +} + +func TestWaitWatchReadinessSucceedsAfterPartialWrite(t *testing.T) { + path := filepath.Join(t.TempDir(), "ready.json") + if err := os.WriteFile(path, []byte(`{"err`), 0o644); err != nil { + t.Fatal(err) + } + go func() { + time.Sleep(50 * time.Millisecond) + _ = os.WriteFile(path, []byte(`{}`), 0o644) + }() + + if err := waitWatchReadiness(path, 5*time.Second); err != nil { + t.Fatalf("waitWatchReadiness() = %v, want success once the file is complete", err) + } +} + +// A file that never becomes valid still has to say why, rather than reporting +// only that it timed out. +func TestWaitWatchReadinessReportsPersistentGarbage(t *testing.T) { + path := filepath.Join(t.TempDir(), "ready.json") + if err := os.WriteFile(path, []byte("not json at all"), 0o644); err != nil { + t.Fatal(err) + } + err := waitWatchReadiness(path, 200*time.Millisecond) + if err == nil || !strings.Contains(err.Error(), "reading daemon readiness") { + t.Fatalf("waitWatchReadiness() = %v, want the parse failure surfaced", err) + } +} + +// A missing file must still time out rather than hang. +func TestWaitWatchReadinessTimesOutWhenAbsent(t *testing.T) { + path := filepath.Join(t.TempDir(), "never-written.json") + err := waitWatchReadiness(path, 100*time.Millisecond) + if err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("waitWatchReadiness() = %v, want a timeout", err) + } +}