Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions blast_radius.go
Original file line number Diff line number Diff line change
Expand Up @@ -1367,6 +1367,16 @@ func renderImportersReportString(report scanner.ImportersReport) string {
// this command and deserves an explanation when the answer is "none".
func renderImportersReportCLI(w io.Writer, report scanner.ImportersReport) {
if len(report.Importers) == 0 && len(report.HubImports) == 0 {
if report.NotIndexed {
// Distinct from "scanned and found zero importers": codemap never
// looked at this file, so a bare "No files import X" here would be
// indistinguishable from a genuine negative and read as a
// confident answer instead of a blind spot.
fmt.Fprintf(w, "%s: not indexed (under a hidden or excluded folder, or the path doesn't exist).\n", report.File)
fmt.Fprintln(w, " codemap never scanned this file, so this is not a confirmed zero-importers result.")
renderCoverage(w, report.CoverageStatus, report.CoverageNotes)
return
}
fmt.Fprintf(w, "No files import %s.\n", report.File)
if strings.EqualFold(filepath.Ext(report.File), ".go") {
fmt.Fprintln(w, " Note: files in the same package never import each other (Go resolves")
Expand Down Expand Up @@ -1448,6 +1458,15 @@ func buildImportersReportFromGraph(root, file string, fg *scanner.FileGraph) (sc
importers := append([]string(nil), fg.Importers[file]...)
imports := append([]string(nil), fg.Imports[file]...)

// NotIndexed distinguishes "codemap scanned this file and it genuinely
// has zero importers" from "codemap never looked at this file at all"
// (e.g. it lives under a hidden or excluded directory, or the path
// doesn't exist). CoverageStatus/CoverageNotes below still describe the
// *scan as a whole* (complete/partial/unavailable) and are left as-is:
// an authoritative, complete scan can still miss one particular file
// simply because that file was outside its scanned set, which is
// exactly the gap NotIndexed exists to surface without redefining what
// "complete" means for the project overall.
report := scanner.ImportersReport{
Root: root,
Mode: "importers",
Expand All @@ -1456,6 +1475,7 @@ func buildImportersReportFromGraph(root, file string, fg *scanner.FileGraph) (sc
Imports: imports,
ImporterCount: len(importers),
IsHub: fg.IsHub(file),
NotIndexed: !fg.Indexed(file),
CoverageStatus: string(fg.Coverage.EffectiveStatus()),
CoverageNotes: append([]string(nil), fg.Coverage.Notes...),
}
Expand Down
93 changes: 93 additions & 0 deletions blast_radius_fixes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,54 @@ func TestBuildImportersReportFromGraphCarriesCoverage(t *testing.T) {
}
}

// TestBuildImportersReportFromGraphMarksNotIndexedFile is the regression for
// the "false complete coverage" bug: a file that was never part of the
// scanned inventory (KnownFiles) must be reported as not indexed rather than
// as a confirmed zero-importers result, and that must be visible both to
// JSON/machine consumers (NotIndexed) and to a human running --importers
// (renderImportersReportCLI's message), while a file that genuinely was
// scanned and has zero importers must keep reading as a plain negative.
func TestBuildImportersReportFromGraphMarksNotIndexedFile(t *testing.T) {
fg := &scanner.FileGraph{
Imports: map[string][]string{},
Importers: map[string][]string{},
KnownFiles: map[string]bool{"lonely.js": true},
}

unindexed, err := buildImportersReportFromGraph("/repo", "node_modules/dep.js", fg)
if err != nil {
t.Fatalf("buildImportersReportFromGraph() error: %v", err)
}
if !unindexed.NotIndexed {
t.Fatalf("report.NotIndexed = false, want true for a file absent from KnownFiles")
}
if got := renderImportersReportString(unindexed); strings.Contains(got, "No files import") {
t.Fatalf("bundle renderer must not claim a confirmed zero-importers result for an unindexed file:\n%s", got)
}
var buf strings.Builder
renderImportersReportCLI(&buf, unindexed)
cliOut := buf.String()
if !strings.Contains(cliOut, "not indexed") {
t.Fatalf("CLI output should say the file was not indexed, got:\n%s", cliOut)
}
if strings.Contains(cliOut, "No files import") {
t.Fatalf("CLI output must not read as a confirmed zero-importers result:\n%s", cliOut)
}

indexed, err := buildImportersReportFromGraph("/repo", "lonely.js", fg)
if err != nil {
t.Fatalf("buildImportersReportFromGraph() error: %v", err)
}
if indexed.NotIndexed {
t.Fatalf("report.NotIndexed = true, want false for a file present in KnownFiles")
}
buf.Reset()
renderImportersReportCLI(&buf, indexed)
if !strings.Contains(buf.String(), "No files import lonely.js") {
t.Fatalf("a genuinely scanned, zero-importer file should read as a plain negative, got:\n%s", buf.String())
}
}

func TestBuildImportersReportFromGraphRejectsFileOutsideRoot(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
Expand Down Expand Up @@ -437,3 +485,48 @@ func TestCoverageNotesFromSources(t *testing.T) {
t.Fatalf("coverageNotesFromSources() = %#v, want %#v", notes, want)
}
}

// TestImportersDistinguishesNotIndexedFromZeroImporters is the end-to-end
// regression for issue #2 in the bug report: codemap answered "No files
// import X. Coverage: complete" for a file it had never actually indexed
// (here, one under node_modules/, which the walker has always excluded),
// indistinguishable from a genuine zero-importers result. It must now say
// the file was not indexed, while a real, scanned, zero-importer file keeps
// reading as a plain negative.
func TestImportersDistinguishesNotIndexedFromZeroImporters(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "node_modules"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "node_modules", "dep.js"), []byte("module.exports = {};\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "lonely.js"), []byte("module.exports = {};\n"), 0o644); err != nil {
t.Fatal(err)
}

notIndexedOut, err := runRootOptionsBinary(root, "--importers", "node_modules/dep.js")
if err != nil {
t.Fatalf("codemap --importers node_modules/dep.js failed: %v\n%s", err, notIndexedOut)
}
if !strings.Contains(notIndexedOut, "not indexed") {
t.Fatalf("expected a not-indexed answer for an excluded file, got:\n%s", notIndexedOut)
}
if strings.Contains(notIndexedOut, "No files import") {
t.Fatalf("an unindexed file must not be reported as a confirmed zero-importers result:\n%s", notIndexedOut)
}

zeroImportersOut, err := runRootOptionsBinary(root, "--importers", "lonely.js")
if err != nil {
t.Fatalf("codemap --importers lonely.js failed: %v\n%s", err, zeroImportersOut)
}
if !strings.Contains(zeroImportersOut, "No files import lonely.js") {
t.Fatalf("a genuinely scanned, zero-importer file should read as a plain negative, got:\n%s", zeroImportersOut)
}
if strings.Contains(zeroImportersOut, "not indexed") {
t.Fatalf("a genuinely scanned file must not be reported as not indexed:\n%s", zeroImportersOut)
}
}
110 changes: 106 additions & 4 deletions scanner/filegraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ type FileGraph struct {
PathAliases map[string][]string // TS/JS path aliases from tsconfig.json (e.g., "@modules/*" -> ["src/modules/*"])
BaseURL string // TS/JS baseUrl from tsconfig.json
Coverage GraphCoverage
// KnownFiles is every file this graph actually scanned (post filters),
// keyed by slash-separated path relative to Root. A path absent here was
// never indexed at all — e.g. it lives under an excluded or hidden
// directory — which is a distinct, worse state than "indexed and found
// to have zero importers": see Indexed.
KnownFiles map[string]bool
}

// Indexed reports whether path (slash-separated, relative to fg.Root) was
// part of this graph's scanned inventory. A false result means codemap never
// looked at the file at all, so an empty Importers/Imports entry for it is a
// coverage gap, not a confirmed zero.
func (fg *FileGraph) Indexed(path string) bool {
if fg == nil {
return false
}
return fg.KnownFiles[path]
}

// fileIndex provides fast lookup of files by various import-like keys
Expand All @@ -36,6 +53,17 @@ type fileIndex struct {
goPkgs map[string][]string // Go package path -> files
cueModules []cueModuleInfo
cuePackages map[string]string
// absRoot and filters back a narrow fallback in tryExactMatch: a file
// excluded from the scanned inventory purely by .gitignore can still be
// the exact, unambiguous target of a plain relative import from a file
// that *was* scanned. absRoot empty disables the fallback entirely
// (e.g. tests that build a fileIndex from bare FileInfo values with no
// real directory behind them). filters is still enforced in that
// fallback so it recovers gitignore's blind spot without also
// resurrecting a file the project or caller explicitly excluded via
// --only/--exclude or config.json, which must stay absolutely excluded.
absRoot string
filters Filters
}

// BuildFileGraph scans a project with explicit filters and builds its file
Expand Down Expand Up @@ -132,6 +160,10 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context,
}
}
}
fg.KnownFiles = make(map[string]bool, len(files))
for _, file := range files {
fg.KnownFiles[filepath.ToSlash(file.Path)] = true
}
languages := inspectFileLanguages(files)
hasCUEAnalysis := analysisLanguages.hasCUE
if languages.hasCUE && !hasCUEAnalysis {
Expand Down Expand Up @@ -159,7 +191,7 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context,
}

// Build file index for fast fuzzy matching
idx, err := buildFileIndexContext(ctx, files, fg.Module)
idx, err := buildFileIndexContext(ctx, files, fg.Module, absRoot, filters)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -261,6 +293,17 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context,
resolved[symbolLevelImportLanguages["swift"]] = true
}
fg.Coverage.addSymbolLevelImportCoverage(languages.symbolLevel, resolved)
// A file resolved via tryExactMatch's on-disk fallback (above) is, by
// construction, real and present — it just wasn't part of the scanned
// inventory KnownFiles was built from (e.g. gitignored). Now that it has
// a proven importer, it should read as indexed rather than "not
// indexed": the whole point of the fallback is to turn a dropped edge
// into a correct one, and leaving KnownFiles stale would have the
// importers report claim the edge while simultaneously claiming the
// target was never looked at.
for imported := range fg.Importers {
fg.KnownFiles[filepath.ToSlash(imported)] = true
}
fg.sortEdges()
return fg, nil
}
Expand Down Expand Up @@ -384,19 +427,24 @@ func inspectAnalysisLanguages(analyses []FileAnalysis) analysisLanguageInventory
return inventory
}

// buildFileIndex creates a multi-key index for fast import resolution
// buildFileIndex creates a multi-key index for fast import resolution. It has
// no project root behind it (callers pass bare FileInfo values, often in
// tests), so the on-disk fallback in tryExactMatch stays disabled; use
// buildFileIndexContext directly to enable it.
func buildFileIndex(files []FileInfo, goModule string) *fileIndex {
idx, _ := buildFileIndexContext(context.Background(), files, goModule)
idx, _ := buildFileIndexContext(context.Background(), files, goModule, "", Filters{})
return idx
}

func buildFileIndexContext(ctx context.Context, files []FileInfo, goModule string) (*fileIndex, error) {
func buildFileIndexContext(ctx context.Context, files []FileInfo, goModule string, absRoot string, filters Filters) (*fileIndex, error) {
directoryHint := min(len(files), 1024)
idx := &fileIndex{
byExact: make(map[string]uint32, len(files)),
bySuffix: make([]string, 0, len(files)),
byDir: make(map[string][]string, directoryHint),
goPkgs: make(map[string][]string, directoryHint),
absRoot: absRoot,
filters: filters,
}
goPackagePaths := make(map[string]string, directoryHint)

Expand Down Expand Up @@ -793,9 +841,63 @@ func tryExactMatch(path string, idx *fileIndex, sourceLanguage string) []string
}
}

// A candidate absent from the scanned inventory (count 0, as opposed to
// the ambiguous count >= 2 case above, which is deliberately left
// unresolved) may still be a real, readable file that the scan excluded
// for an orthogonal reason — most commonly .gitignore, since ScanFiles
// applies the project's gitignore rules to build the inventory this
// index is drawn from. A plain relative require('./x') naming an exact,
// unambiguous path is confident evidence the file is genuinely part of
// the dependency graph even though it isn't part of the scanned file
// set, so this on-disk check recovers exactly that edge without
// widening the fuzzy/suffix resolution strategies to gitignored files.
if idx.absRoot != "" {
if idx.byExact[path] == 0 && languagesCompatible(sourceLanguage, DetectLanguage(path)) && idx.gitignoreOnlyRecoverable(path) {
return []string{path}
}
for _, candidate := range typescriptSourceCandidates(path, sourceLanguage) {
if idx.byExact[candidate] == 0 && languagesCompatible(sourceLanguage, DetectLanguage(candidate)) && idx.gitignoreOnlyRecoverable(candidate) {
return []string{candidate}
}
}
for _, ext := range resolverExtensions[:len(resolverExtensions)-1] {
candidate := path + ext
if idx.byExact[candidate] == 0 && languagesCompatible(sourceLanguage, DetectLanguage(candidate)) && idx.gitignoreOnlyRecoverable(candidate) {
return []string{candidate}
}
}
}

return nil
}

// gitignoreOnlyRecoverable reports whether relPath is missing from the
// scanned inventory *solely* because of .gitignore, as opposed to an
// explicit --only/--exclude filter, a config.json exclude, or one of the
// scanner's own never-source directory names (node_modules, vendor,
// testdata, ...) — all of which must stay absolutely excluded, unlike
// .gitignore's blind spot. ScanFiles' only exclusion mechanisms are those
// three plus .gitignore, so a real, regular file that clears the other two
// and is still absent can only be missing because of .gitignore.
func (idx *fileIndex) gitignoreOnlyRecoverable(relPath string) bool {
if !MatchesFilters(filepath.ToSlash(relPath), filepath.Ext(relPath), idx.filters.Only, idx.filters.Exclude) {
return false
}
for dir := filepath.ToSlash(filepath.Dir(relPath)); dir != "." && dir != "/" && dir != ""; dir = pathpkg.Dir(dir) {
if IgnoredDirs[pathpkg.Base(dir)] {
return false
}
}
return isRegularFile(idx.absRoot, relPath)
}

// isRegularFile reports whether root-relative path exists on disk under
// absRoot as a regular (not directory, not symlink-to-directory) file.
func isRegularFile(absRoot, relPath string) bool {
info, err := os.Stat(filepath.Join(absRoot, filepath.FromSlash(relPath)))
return err == nil && info.Mode().IsRegular()
}

// typescriptEmitExtensions maps an emitted JavaScript extension to the
// TypeScript sources that produce it, in the order the compiler prefers.
//
Expand Down
Loading
Loading