diff --git a/cmd/context_evidence.go b/cmd/context_evidence.go
index 49b4b09..8001456 100644
--- a/cmd/context_evidence.go
+++ b/cmd/context_evidence.go
@@ -172,6 +172,12 @@ func normalizeContextInventoryPath(file string) string {
}
func normalizeContextPathWithVolumeGuard(file string, rejectVolume bool) string {
+ if file == "" || (rejectVolume && isContextVolumePath(file)) {
+ return ""
+ }
+ if isNormalizedContextRelativePath(file) {
+ return file
+ }
file = strings.ReplaceAll(file, `\`, "/")
volumePath := isContextVolumePath(file)
file = strings.TrimPrefix(pathpkg.Clean(file), "./")
@@ -181,6 +187,28 @@ func normalizeContextPathWithVolumeGuard(file string, rejectVolume bool) string
return file
}
+func isNormalizedContextRelativePath(file string) bool {
+ if file == "" || file[0] == '/' || file[len(file)-1] == '/' {
+ return false
+ }
+ segmentStart := 0
+ for index := 0; index < len(file); index++ {
+ if file[index] == '\\' {
+ return false
+ }
+ if file[index] != '/' {
+ continue
+ }
+ segment := file[segmentStart:index]
+ if segment == "" || segment == "." || segment == ".." {
+ return false
+ }
+ segmentStart = index + 1
+ }
+ segment := file[segmentStart:]
+ return segment != "." && segment != ".."
+}
+
func isContextVolumePath(file string) bool {
return strings.HasPrefix(file, "//") ||
(len(file) >= 2 && file[1] == ':' && ((file[0] >= 'a' && file[0] <= 'z') || (file[0] >= 'A' && file[0] <= 'Z')))
diff --git a/cmd/context_routing.go b/cmd/context_routing.go
index f1bf370..620827a 100644
--- a/cmd/context_routing.go
+++ b/cmd/context_routing.go
@@ -3,6 +3,7 @@ package cmd
import (
pathpkg "path"
"regexp"
+ "slices"
"sort"
"strings"
@@ -13,17 +14,20 @@ import (
var contextRoutingTokenPattern = regexp.MustCompile(`[A-Za-z0-9_@./\\-]*[A-Za-z0-9_@]`)
type contextFileIndex struct {
- caseInsensitive bool
- exact map[string][]string
- basenames map[string][]string
- sortedPaths []contextIndexedPath
- prefixPaths map[string][]string
+ caseInsensitive bool
+ files []scanner.FileInfo
+ sortedPaths []contextIndexedPath
+ useInventory bool
+ scanInventory bool
+ normalizeInventorySeparators bool
+ pathsReady bool
}
+type contextUniquePathIndex map[string]string
+
type contextIndexedPath struct {
- path string
- key string
- order int
+ path string
+ key string
}
type contextFileResolution struct {
@@ -64,13 +68,17 @@ func resolveContextFileResolutionWithCase(prompt string, files []scanner.FileInf
// Exact normalized repository-relative paths always win.
for _, token := range tokens {
normalized := normalizeContextPath(token)
- matches := index.exact[index.key(normalized)]
- if normalized != "" && len(matches) == 1 && add(matches[0], false) {
+ if normalized == "" {
+ continue
+ }
+ match, unique := index.uniqueExact(index.key(normalized))
+ if unique && add(match, false) {
return resolution
}
}
// Then accept a unique basename that includes its extension.
+ basenameKeys := make([]string, 0, len(tokens))
for _, token := range tokens {
normalized := normalizeContextPath(token)
if strings.Contains(normalized, "/") {
@@ -80,8 +88,12 @@ func resolveContextFileResolutionWithCase(prompt string, files []scanner.FileInf
if pathpkg.Ext(base) == "" {
continue
}
- matches := index.basenames[index.key(base)]
- if len(matches) == 1 && add(matches[0], false) {
+ basenameKeys = append(basenameKeys, index.key(base))
+ }
+ basenameMatches := index.uniqueBasenames(basenameKeys)
+ for _, baseKey := range basenameKeys {
+ match, unique := basenameMatches.unique(baseKey)
+ if unique && add(match, false) {
return resolution
}
}
@@ -100,7 +112,7 @@ func resolveContextFileResolutionWithCase(prompt string, files []scanner.FileInf
continue
}
seenPrefixes[prefixKey] = struct{}{}
- if index.forPrefix(prefixKey, func(path string) bool {
+ if index.forPrefix(prefixKey, topK, func(path string) bool {
return add(path, true)
}) {
return resolution
@@ -111,92 +123,436 @@ func resolveContextFileResolutionWithCase(prompt string, files []scanner.FileInf
}
func newContextFileIndex(files []scanner.FileInfo, caseInsensitive bool) contextFileIndex {
- index := contextFileIndex{
+ return contextFileIndex{
caseInsensitive: caseInsensitive,
- exact: make(map[string][]string, len(files)),
- basenames: make(map[string][]string),
- sortedPaths: make([]contextIndexedPath, 0, len(files)),
+ files: files,
}
- for _, file := range files {
- path := normalizeContextInventoryPath(file.Path)
- if path == "" {
+}
+
+func (i *contextFileIndex) uniqueBasenames(keys []string) contextUniquePathIndex {
+ if len(keys) == 0 {
+ return nil
+ }
+ matches := make(contextUniquePathIndex, len(keys))
+ i.preparePaths()
+ if len(keys) > 1 && !i.caseInsensitive {
+ requested := make(map[string]struct{}, len(keys))
+ for _, key := range keys {
+ requested[key] = struct{}{}
+ }
+ for index := 0; index < i.pathCount(); index++ {
+ key := i.basenameKeyAt(index)
+ if _, ok := requested[key]; ok {
+ matches.add(key, i.pathAt(index))
+ }
+ }
+ return matches
+ }
+ if len(keys) > 1 {
+ return i.uniqueCaseFoldedBasenames(keys, matches)
+ }
+ for index := 0; index < i.pathCount(); index++ {
+ if i.basenameMatches(index, keys[0]) {
+ matches.add(keys[0], i.pathAt(index))
+ }
+ }
+ return matches
+}
+
+func (i *contextFileIndex) uniqueCaseFoldedBasenames(keys []string, matches contextUniquePathIndex) contextUniquePathIndex {
+ requested := make(map[uint64]string, len(keys))
+ for _, key := range keys {
+ hash, _ := contextASCIIFoldHash(key)
+ // Exact comparisons below resolve the unlikely case of a hash collision.
+ if _, exists := requested[hash]; !exists {
+ requested[hash] = key
+ }
+ }
+ for index := 0; index < i.pathCount(); index++ {
+ base := i.basenameAt(index)
+ hash, ascii := contextASCIIFoldHash(base)
+ if !ascii {
+ base = i.key(base)
+ hash, _ = contextASCIIFoldHash(base)
+ }
+ candidate, found := requested[hash]
+ if !found {
continue
}
- pathKey := index.key(path)
- duplicate := false
- for _, existing := range index.exact[pathKey] {
- if existing == path {
- duplicate = true
- break
+ matchesKey := func(key string) bool {
+ if ascii {
+ return i.compareKeys(base, key) == 0
}
+ return base == key
}
- if duplicate {
+ if matchesKey(candidate) {
+ matches.add(candidate, i.pathAt(index))
continue
}
- index.exact[pathKey] = append(index.exact[pathKey], path)
- base := pathpkg.Base(path)
- index.basenames[index.key(base)] = append(index.basenames[index.key(base)], path)
- index.sortedPaths = append(index.sortedPaths, contextIndexedPath{path: path, key: pathKey})
- }
- if caseInsensitive {
- sort.Slice(index.sortedPaths, func(i, j int) bool {
- return index.sortedPaths[i].path < index.sortedPaths[j].path
- })
- for order := range index.sortedPaths {
- index.sortedPaths[order].order = order
- }
- index.prefixPaths = make(map[string][]string)
- for _, indexed := range index.sortedPaths {
- for prefix := indexed.path; prefix != "."; prefix = pathpkg.Dir(prefix) {
- prefixKey := index.key(prefix)
- index.prefixPaths[prefixKey] = append(index.prefixPaths[prefixKey], indexed.path)
- dir := pathpkg.Dir(prefix)
- if dir == prefix {
- break
- }
+ for _, key := range keys {
+ keyHash, _ := contextASCIIFoldHash(key)
+ if keyHash == hash && matchesKey(key) {
+ matches.add(key, i.pathAt(index))
+ break
}
}
}
- sort.Slice(index.sortedPaths, func(i, j int) bool {
- if index.sortedPaths[i].key == index.sortedPaths[j].key {
- return index.sortedPaths[i].path < index.sortedPaths[j].path
+ return matches
+}
+
+func (i contextFileIndex) basenameAt(index int) string {
+ if !i.useInventory && !i.scanInventory {
+ return pathpkg.Base(i.sortedPaths[index].key)
+ }
+ path := i.files[index].Path
+ if separator := strings.LastIndexAny(path, `/\`); separator >= 0 {
+ path = path[separator+1:]
+ }
+ return path
+}
+
+func (i contextFileIndex) basenameKeyAt(index int) string {
+ return i.key(i.basenameAt(index))
+}
+
+func (i contextUniquePathIndex) add(key, path string) {
+ existing, found := i[key]
+ if !found {
+ i[key] = path
+ return
+ }
+ if existing != path {
+ i[key] = ""
+ }
+}
+
+func (i contextUniquePathIndex) unique(key string) (string, bool) {
+ path, found := i[key]
+ return path, found && path != ""
+}
+
+func (i *contextFileIndex) uniqueExact(key string) (string, bool) {
+ i.preparePaths()
+ if i.scanInventory {
+ match := ""
+ for index, file := range i.files {
+ if i.compareKeys(file.Path, key) != 0 {
+ continue
+ }
+ path := i.pathAt(index)
+ if match != "" && match != path {
+ return "", false
+ }
+ match = path
}
- return index.sortedPaths[i].key < index.sortedPaths[j].key
+ return match, match != ""
+ }
+ start := sort.Search(i.pathCount(), func(index int) bool {
+ return i.compareKeyAt(index, key) >= 0
})
- return index
+ if start == i.pathCount() || i.compareKeyAt(start, key) != 0 {
+ return "", false
+ }
+ path := i.pathAt(start)
+ if start+1 < i.pathCount() && i.compareKeyAt(start+1, key) == 0 && i.pathAt(start+1) != path {
+ return "", false
+ }
+ return path, true
}
-func (i contextFileIndex) forPrefix(prefixKey string, visit func(string) bool) bool {
- if i.caseInsensitive {
- for _, path := range i.prefixPaths[prefixKey] {
- if visit(path) {
+func (i *contextFileIndex) preparePaths() {
+ if i.pathsReady {
+ return
+ }
+ // Binary-search ordered inventories; scan valid unordered inventories directly.
+ inventoryValid := true
+ inventoryOrdered := true
+ for index, file := range i.files {
+ valid, normalizeSeparators := contextInventoryPathState(file.Path)
+ i.normalizeInventorySeparators = i.normalizeInventorySeparators || normalizeSeparators
+ if !valid {
+ inventoryValid = false
+ break
+ }
+ if inventoryOrdered && index > 0 && i.comparePaths(i.files[index-1].Path, file.Path) >= 0 {
+ inventoryOrdered = false
+ }
+ }
+ i.useInventory = inventoryValid && inventoryOrdered
+ if i.useInventory {
+ i.pathsReady = true
+ return
+ }
+ if inventoryValid {
+ i.scanInventory = true
+ i.pathsReady = true
+ return
+ }
+
+ i.sortedPaths = make([]contextIndexedPath, 0, len(i.files))
+ for _, file := range i.files {
+ path := normalizeContextInventoryPath(file.Path)
+ if path != "" {
+ i.sortedPaths = append(i.sortedPaths, contextIndexedPath{path: path, key: i.key(path)})
+ }
+ }
+ compare := func(left, right contextIndexedPath) int {
+ if order := strings.Compare(left.key, right.key); order != 0 {
+ return order
+ }
+ return strings.Compare(left.path, right.path)
+ }
+ slices.SortFunc(i.sortedPaths, compare)
+ write := min(1, len(i.sortedPaths))
+ for read := 1; read < len(i.sortedPaths); read++ {
+ if i.sortedPaths[read] == i.sortedPaths[write-1] {
+ continue
+ }
+ if write != read {
+ i.sortedPaths[write] = i.sortedPaths[read]
+ }
+ write++
+ }
+ i.sortedPaths = i.sortedPaths[:write]
+ i.pathsReady = true
+}
+
+func (i *contextFileIndex) forPrefix(prefixKey string, limit int, visit func(string) bool) bool {
+ if limit <= 0 {
+ return false
+ }
+ i.preparePaths()
+ matches := make([]int, 0, min(limit, i.pathCount()))
+ consider := func(candidate int) {
+ position := sort.Search(len(matches), func(index int) bool {
+ return i.compareOutputPaths(matches[index], candidate) >= 0
+ })
+ if position < len(matches) && i.compareOutputPaths(matches[position], candidate) == 0 {
+ return
+ }
+ if len(matches) == limit && position == len(matches) {
+ return
+ }
+ if len(matches) < limit {
+ matches = append(matches, 0)
+ }
+ copy(matches[position+1:], matches[position:len(matches)-1])
+ matches[position] = candidate
+ }
+ visitMatches := func() bool {
+ for _, match := range matches {
+ if visit(i.pathAt(match)) {
return true
}
}
return false
}
-
- exactStart := sort.Search(len(i.sortedPaths), func(index int) bool {
- return i.sortedPaths[index].key >= prefixKey
+ descendantPrefix := prefixKey + "/"
+ if i.scanInventory {
+ for index, file := range i.files {
+ if i.compareKeys(file.Path, prefixKey) == 0 || contextPathPrefix(file.Path, descendantPrefix, i.caseInsensitive) {
+ consider(index)
+ }
+ }
+ return visitMatches()
+ }
+ exactStart := sort.Search(i.pathCount(), func(index int) bool {
+ return i.compareKeyAt(index, prefixKey) >= 0
})
- for index := exactStart; index < len(i.sortedPaths) && i.sortedPaths[index].key == prefixKey; index++ {
- if visit(i.sortedPaths[index].path) {
+ descendantStart := sort.Search(i.pathCount(), func(index int) bool {
+ return i.compareKeyAt(index, descendantPrefix) >= 0
+ })
+ if i.caseInsensitive {
+ for index := exactStart; index < i.pathCount() && i.compareKeyAt(index, prefixKey) == 0; index++ {
+ consider(index)
+ }
+ for index := descendantStart; index < i.pathCount() && i.keyHasPrefixAt(index, descendantPrefix); index++ {
+ consider(index)
+ }
+ return visitMatches()
+ }
+
+ visited := 0
+ for index := exactStart; index < i.pathCount() && i.compareKeyAt(index, prefixKey) == 0; index++ {
+ if visited == limit {
+ return false
+ }
+ visited++
+ if visit(i.pathAt(index)) {
return true
}
}
- descendantPrefix := prefixKey + "/"
- descendantStart := sort.Search(len(i.sortedPaths), func(index int) bool {
- return i.sortedPaths[index].key >= descendantPrefix
- })
- for index := descendantStart; index < len(i.sortedPaths) && strings.HasPrefix(i.sortedPaths[index].key, descendantPrefix); index++ {
- if visit(i.sortedPaths[index].path) {
+ for index := descendantStart; index < i.pathCount() && i.keyHasPrefixAt(index, descendantPrefix); index++ {
+ if visited == limit {
+ return false
+ }
+ visited++
+ if visit(i.pathAt(index)) {
return true
}
}
return false
}
+func (i contextFileIndex) pathCount() int {
+ if i.useInventory || i.scanInventory {
+ return len(i.files)
+ }
+ return len(i.sortedPaths)
+}
+
+func (i contextFileIndex) pathAt(index int) string {
+ if i.useInventory || i.scanInventory {
+ path := i.files[index].Path
+ if i.normalizeInventorySeparators {
+ return strings.ReplaceAll(path, `\`, "/")
+ }
+ return path
+ }
+ return i.sortedPaths[index].path
+}
+
+func (i contextFileIndex) compareOutputPaths(left, right int) int {
+ if i.useInventory || i.scanInventory {
+ if !i.normalizeInventorySeparators {
+ return strings.Compare(i.files[left].Path, i.files[right].Path)
+ }
+ return compareContextPaths(i.files[left].Path, i.files[right].Path, false)
+ }
+ return strings.Compare(i.sortedPaths[left].path, i.sortedPaths[right].path)
+}
+
+func (i contextFileIndex) compareKeyAt(index int, key string) int {
+ if i.useInventory {
+ return i.compareKeys(i.files[index].Path, key)
+ }
+ return strings.Compare(i.sortedPaths[index].key, key)
+}
+
+func (i contextFileIndex) keyHasPrefixAt(index int, prefix string) bool {
+ if i.useInventory {
+ if !i.normalizeInventorySeparators && !i.caseInsensitive {
+ return strings.HasPrefix(i.files[index].Path, prefix)
+ }
+ return contextPathPrefix(i.files[index].Path, prefix, i.caseInsensitive)
+ }
+ return strings.HasPrefix(i.sortedPaths[index].key, prefix)
+}
+
+func (i contextFileIndex) basenameMatches(index int, key string) bool {
+ if i.useInventory || i.scanInventory {
+ path := i.files[index].Path
+ if separator := strings.LastIndexAny(path, `/\`); separator >= 0 {
+ path = path[separator+1:]
+ }
+ return i.compareKeys(path, key) == 0
+ }
+ return pathpkg.Base(i.sortedPaths[index].key) == key
+}
+
+func (i contextFileIndex) comparePaths(left, right string) int {
+ if order := i.compareKeys(left, right); order != 0 {
+ return order
+ }
+ return compareContextPaths(left, right, false)
+}
+
+func (i contextFileIndex) compareKeys(left, right string) int {
+ if !i.normalizeInventorySeparators && !i.caseInsensitive {
+ return strings.Compare(left, right)
+ }
+ return compareContextPaths(left, right, i.caseInsensitive)
+}
+
+func compareContextPaths(left, right string, caseInsensitive bool) int {
+ limit := min(len(left), len(right))
+ for index := 0; index < limit; index++ {
+ leftByte, rightByte := left[index], right[index]
+ if leftByte == '\\' {
+ leftByte = '/'
+ }
+ if rightByte == '\\' {
+ rightByte = '/'
+ }
+ if leftByte >= 0x80 || rightByte >= 0x80 {
+ return strings.Compare(contextComparisonKey(left, caseInsensitive), contextComparisonKey(right, caseInsensitive))
+ }
+ if leftByte == rightByte {
+ continue
+ }
+ if caseInsensitive && leftByte >= 'A' && leftByte <= 'Z' {
+ leftByte += 'a' - 'A'
+ }
+ if caseInsensitive && rightByte >= 'A' && rightByte <= 'Z' {
+ rightByte += 'a' - 'A'
+ }
+ if leftByte < rightByte {
+ return -1
+ }
+ if leftByte > rightByte {
+ return 1
+ }
+ }
+ return len(left) - len(right)
+}
+
+func contextPathPrefix(path, prefix string, caseInsensitive bool) bool {
+ if len(path) < len(prefix) {
+ return strings.HasPrefix(contextComparisonKey(path, caseInsensitive), contextComparisonKey(prefix, caseInsensitive))
+ }
+ for index := range prefix {
+ pathByte, prefixByte := path[index], prefix[index]
+ if pathByte == '\\' {
+ pathByte = '/'
+ }
+ if prefixByte == '\\' {
+ prefixByte = '/'
+ }
+ if pathByte >= 0x80 || prefixByte >= 0x80 {
+ return strings.HasPrefix(contextComparisonKey(path, caseInsensitive), contextComparisonKey(prefix, caseInsensitive))
+ }
+ if caseInsensitive && pathByte >= 'A' && pathByte <= 'Z' {
+ pathByte += 'a' - 'A'
+ }
+ if caseInsensitive && prefixByte >= 'A' && prefixByte <= 'Z' {
+ prefixByte += 'a' - 'A'
+ }
+ if pathByte != prefixByte {
+ return false
+ }
+ }
+ return true
+}
+
+func contextComparisonKey(path string, caseInsensitive bool) string {
+ path = strings.ReplaceAll(path, `\`, "/")
+ if caseInsensitive {
+ path = strings.ToLower(path)
+ }
+ return path
+}
+
+func contextInventoryPathState(path string) (valid, normalizeSeparators bool) {
+ if path == "" || path[0] == '/' || path[0] == '\\' || path[len(path)-1] == '/' || path[len(path)-1] == '\\' {
+ return false, false
+ }
+ segmentStart := 0
+ for index := 0; index < len(path); index++ {
+ if path[index] != '/' && path[index] != '\\' {
+ continue
+ }
+ normalizeSeparators = normalizeSeparators || path[index] == '\\'
+ segment := path[segmentStart:index]
+ if segment == "" || segment == "." || segment == ".." {
+ return false, normalizeSeparators
+ }
+ segmentStart = index + 1
+ }
+ segment := path[segmentStart:]
+ return segment != "." && segment != "..", normalizeSeparators
+}
+
func (i contextFileIndex) key(value string) string {
if i.caseInsensitive {
return strings.ToLower(value)
@@ -204,6 +560,23 @@ func (i contextFileIndex) key(value string) string {
return value
}
+func contextASCIIFoldHash(value string) (uint64, bool) {
+ const offset64 = 14695981039346656037
+ const prime64 = 1099511628211
+ hash := uint64(offset64)
+ ascii := true
+ for index := 0; index < len(value); index++ {
+ char := value[index]
+ ascii = ascii && char < 0x80
+ if char >= 'A' && char <= 'Z' {
+ char += 'a' - 'A'
+ }
+ hash ^= uint64(char)
+ hash *= prime64
+ }
+ return hash, ascii
+}
+
func contextRoutingTokens(prompt string) []string {
matches := contextRoutingTokenPattern.FindAllString(strings.ReplaceAll(prompt, `\`, "/"), -1)
tokens := make([]string, 0, len(matches))
diff --git a/cmd/context_routing_benchmark_test.go b/cmd/context_routing_benchmark_test.go
new file mode 100644
index 0000000..5f8fb50
--- /dev/null
+++ b/cmd/context_routing_benchmark_test.go
@@ -0,0 +1,179 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+ "testing"
+
+ "codemap/analysis"
+ "codemap/scanner"
+)
+
+var benchmarkContextEnvelope ContextEnvelope
+var benchmarkContextMatches contextUniquePathIndex
+
+func benchmarkContextRoutingFiles(count int) []scanner.FileInfo {
+ files := make([]string, count)
+ for i := range files {
+ files[i] = fmt.Sprintf("Root/Area%02d/Feature%03d/Package%03d/file%05d.go", i%40, i%200, i%500, i)
+ }
+ sort.Strings(files)
+ return routingFiles(files...)
+}
+
+func BenchmarkContextFileIndexCaseInsensitive(b *testing.B) {
+ routing := benchmarkContextRoutingFiles(50_000)
+ for _, benchmark := range []struct {
+ name string
+ prefix string
+ }{
+ {name: "broad prefix", prefix: "root"},
+ {name: "narrow prefix", prefix: "root/area39/feature199"},
+ } {
+ b.Run(benchmark.name, func(b *testing.B) {
+ b.ReportAllocs()
+ for range b.N {
+ index := newContextFileIndex(routing, true)
+ index.forPrefix(benchmark.prefix, 3, func(string) bool { return false })
+ }
+ })
+ }
+ b.Run("exact", func(b *testing.B) {
+ b.ReportAllocs()
+ for range b.N {
+ index := newContextFileIndex(routing, true)
+ _, _ = index.uniqueExact(index.key("Root/Area39/Feature199/Package499/file49999.go"))
+ }
+ })
+ b.Run("basename", func(b *testing.B) {
+ b.ReportAllocs()
+ for range b.N {
+ index := newContextFileIndex(routing, true)
+ matches := index.uniqueBasenames([]string{index.key("file49999.go")})
+ _, _ = matches.unique(index.key("file49999.go"))
+ }
+ })
+}
+
+func BenchmarkContextFileIndexCaseInsensitiveMixedCase(b *testing.B) {
+ files := make([]string, 50_000)
+ for i := range files {
+ root := "Root"
+ if i%2 == 0 {
+ root = "alpha"
+ }
+ files[i] = fmt.Sprintf("%s/Area%02d/Feature%03d/file%05d.go", root, i%40, i%200, i)
+ }
+ sort.Strings(files)
+ routing := routingFiles(files...)
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ index := newContextFileIndex(routing, true)
+ index.forPrefix("root", 3, func(string) bool { return false })
+ }
+}
+
+func BenchmarkContextFileIndexCaseInsensitiveWindowsPaths(b *testing.B) {
+ routing := benchmarkContextRoutingFiles(50_000)
+ for i := range routing {
+ routing[i].Path = strings.ReplaceAll(routing[i].Path, "/", `\`)
+ }
+ probe := newContextFileIndex(routing, true)
+ probe.preparePaths()
+ if !probe.useInventory {
+ b.Fatal("ordered backslash-separated inventory used fallback index")
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ index := newContextFileIndex(routing, true)
+ index.forPrefix("root", 3, func(string) bool { return false })
+ }
+}
+
+func BenchmarkContextFileIndexCaseInsensitiveWindowsBasename(b *testing.B) {
+ routing := benchmarkContextRoutingFiles(50_000)
+ for i := range routing {
+ routing[i].Path = strings.ReplaceAll(routing[i].Path, "/", `\`)
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ index := newContextFileIndex(routing, true)
+ matches := index.uniqueBasenames([]string{"file49999.go"})
+ _, _ = matches.unique("file49999.go")
+ }
+}
+
+func BenchmarkContextFileIndexManyBasenames(b *testing.B) {
+ keys := make([]string, 128)
+ for index := range keys {
+ keys[index] = fmt.Sprintf("file%05d.go", index*317)
+ }
+ for _, benchmark := range []struct {
+ name string
+ caseInsensitive bool
+ }{
+ {name: "case sensitive"},
+ {name: "case folded", caseInsensitive: true},
+ } {
+ b.Run(benchmark.name, func(b *testing.B) {
+ routing := benchmarkContextRoutingFiles(50_000)
+ if benchmark.caseInsensitive {
+ for index := range routing {
+ routing[index].Path = strings.Replace(routing[index].Path, "/file", "/FILE", 1)
+ }
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ index := newContextFileIndex(routing, benchmark.caseInsensitive)
+ benchmarkContextMatches = index.uniqueBasenames(keys)
+ }
+ })
+ }
+}
+
+func BenchmarkContextFileIndexCaseSensitive(b *testing.B) {
+ routing := benchmarkContextRoutingFiles(50_000)
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ index := newContextFileIndex(routing, false)
+ index.forPrefix("Root", 3, func(string) bool { return false })
+ }
+}
+
+func BenchmarkBuildContextEnvelope(b *testing.B) {
+ files := benchmarkContextRoutingFiles(5_000)
+ imports := make(map[string][]string, len(files))
+ importers := make(map[string][]string, len(files))
+ for i := 1; i < len(files); i++ {
+ current := files[i].Path
+ previous := files[i-1].Path
+ imports[current] = []string{previous}
+ importers[previous] = []string{current}
+ }
+ graph := &scanner.FileGraph{
+ Imports: imports,
+ Importers: importers,
+ Coverage: scanner.GraphCoverage{Status: analysis.CoverageComplete},
+ }
+ deps := testContextEnvelopeDeps(files, graph)
+ root := b.TempDir()
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ benchmarkContextEnvelope = buildContextEnvelopeWithDeps(
+ context.Background(),
+ root,
+ "refactor Root/Area39/Feature199/Package499/file49999.go",
+ true,
+ deps,
+ )
+ }
+}
diff --git a/cmd/context_routing_test.go b/cmd/context_routing_test.go
index 14dc340..840879e 100644
--- a/cmd/context_routing_test.go
+++ b/cmd/context_routing_test.go
@@ -62,7 +62,7 @@ func TestContextLexicalRouting(t *testing.T) {
})
t.Run("normalized duplicates resolve once", func(t *testing.T) {
- files := routingFiles("./cmd/context.go", "cmd/context.go", "internal/context.go")
+ files := routingFiles("./cmd/context.go", "cmd//context.go", "cmd/context.go/", "cmd/context.go", "internal/context.go")
got := resolveContextFilesWithCase("inspect cmd/./context.go and context", files, config.ProjectConfig{}, 2, false)
if want := []string{"cmd/context.go"}; !reflect.DeepEqual(got, want) {
t.Fatalf("normalized files = %#v, want %#v", got, want)
@@ -95,7 +95,7 @@ func TestContextLexicalRouting(t *testing.T) {
})
t.Run("case-folded exact collisions stay unresolved", func(t *testing.T) {
- files := routingFiles("Cmd/Foo.go", "cmd/foo.go")
+ files := routingFiles("Cmd/Foo.go", "Root/a.go", "alpha/b.go", "cmd/foo.go")
got := resolveContextFilesWithCase(`inspect CMD\FOO.GO`, files, config.ProjectConfig{}, 2, true)
if len(got) != 0 {
t.Fatalf("case-collision files = %#v, want none", got)
@@ -146,6 +146,17 @@ func TestContextLexicalRouting(t *testing.T) {
}
})
+ t.Run("case-folded subsystem route fills after an explicit match", func(t *testing.T) {
+ files := routingFiles("src/build/c.go", "SRC/build/a.go", "Src/Build/b.go")
+ cfg := config.ProjectConfig{Routing: config.RoutingConfig{
+ Subsystems: []config.Subsystem{{ID: "build", Keywords: []string{"overdrive"}, Paths: []string{"src/build"}}},
+ }}
+ got := resolveContextFilesWithCase("inspect SRC/build/a.go during overdrive", files, cfg, 2, true)
+ if want := []string{"SRC/build/a.go", "Src/Build/b.go"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("files = %#v, want %#v", got, want)
+ }
+ })
+
t.Run("explicit basename drives intent risk", func(t *testing.T) {
files := routingFiles("src/final_build.rs", "a.rs", "b.rs", "c.rs")
graph := &scanner.FileGraph{
@@ -206,47 +217,154 @@ func routingFiles(paths ...string) []scanner.FileInfo {
func TestContextFileIndexPrefixes(t *testing.T) {
index := newContextFileIndex(routingFiles("src/build/z.go", "src/build/a.go", "src/other.go"), false)
var got []string
- index.forPrefix("src/build", func(path string) bool {
+ index.forPrefix("src/build", 1, func(path string) bool {
got = append(got, path)
return false
})
- if want := []string{"src/build/a.go", "src/build/z.go"}; !reflect.DeepEqual(got, want) {
+ if want := []string{"src/build/a.go"}; !reflect.DeepEqual(got, want) {
t.Fatalf("prefix files = %#v, want %#v", got, want)
}
}
+func TestContextFileIndexReusesOrderedInventory(t *testing.T) {
+ index := newContextFileIndex(routingFiles("Root/a.go", "Root/b.go"), true)
+ index.preparePaths()
+ if !index.useInventory {
+ t.Fatal("ordered inventory used fallback index")
+ }
+
+ index = newContextFileIndex(routingFiles("Root/a.go", "alpha/b.go"), true)
+ index.preparePaths()
+ if index.useInventory {
+ t.Fatal("unordered case-folded inventory used binary search")
+ }
+ if len(index.sortedPaths) != 0 {
+ t.Fatal("unordered case-folded inventory built a fallback index")
+ }
+ match, unique := index.uniqueExact("alpha/b.go")
+ if !unique || match != "alpha/b.go" {
+ t.Fatalf("direct-scan exact match = %q, %v", match, unique)
+ }
+
+ index = newContextFileIndex(routingFiles(`Root\a.go`, `Root\b.go`), true)
+ index.preparePaths()
+ if !index.useInventory {
+ t.Fatal("ordered backslash-separated inventory used fallback index")
+ }
+ match, unique = index.uniqueExact("root/a.go")
+ if !unique || match != "Root/a.go" {
+ t.Fatalf("backslash-separated exact match = %q, %v", match, unique)
+ }
+ var prefixed []string
+ index.forPrefix("root", 2, func(path string) bool {
+ prefixed = append(prefixed, path)
+ return false
+ })
+ if want := []string{"Root/a.go", "Root/b.go"}; !reflect.DeepEqual(prefixed, want) {
+ t.Fatalf("backslash-separated prefix matches = %#v, want %#v", prefixed, want)
+ }
+ basenames := index.uniqueBasenames([]string{"b.go"})
+ if match, unique = basenames.unique("b.go"); !unique || match != "Root/b.go" {
+ t.Fatalf("backslash-separated basename match = %q, %v", match, unique)
+ }
+
+ index = newContextFileIndex(routingFiles("Root/a.go", `Root\a.go`), true)
+ index.preparePaths()
+ if index.useInventory {
+ t.Fatal("separator-normalized duplicate reused inventory")
+ }
+}
+
func TestContextFileIndexPrefixesRespectBoundaries(t *testing.T) {
- index := newContextFileIndex(routingFiles("src/build.go", "src/build/a.go", "src/building/b.go"), false)
+ index := newContextFileIndex(routingFiles("src/build.go", "src/build", "src/build/a.go", "src/building/b.go"), false)
var got []string
- index.forPrefix("src/build", func(path string) bool {
+ index.forPrefix("src/build", 10, func(path string) bool {
got = append(got, path)
return false
})
- if want := []string{"src/build/a.go"}; !reflect.DeepEqual(got, want) {
+ if want := []string{"src/build", "src/build/a.go"}; !reflect.DeepEqual(got, want) {
t.Fatalf("boundary files = %#v, want %#v", got, want)
}
}
+func TestContextFileIndexPrefixLimitCountsUniquePaths(t *testing.T) {
+ index := newContextFileIndex(routingFiles("src/a.go", `src\a.go`, "src/b.go"), false)
+ var got []string
+ index.forPrefix("src", 2, func(path string) bool {
+ got = append(got, path)
+ return false
+ })
+ if want := []string{"src/a.go", "src/b.go"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("prefix files = %#v, want %#v", got, want)
+ }
+}
+
+func TestContextFileIndexBasenamesUseLargeKeySet(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ files []scanner.FileInfo
+ caseInsensitive bool
+ want string
+ }{
+ {name: "ordered", files: routingFiles("other/a.go", "pkg/a.go", "pkg/b.go", "pkg/c.go", "pkg/d.go", "pkg/e.go"), want: "pkg/e.go"},
+ {name: "unordered", files: routingFiles("pkg/e.go", "pkg/d.go", "pkg/c.go", "pkg/b.go", "pkg/a.go", "other/a.go"), want: "pkg/e.go"},
+ {name: "normalized fallback", files: routingFiles("./pkg/e.go", "pkg/d.go", "pkg/c.go", "pkg/b.go", "pkg/a.go", "other/a.go"), want: "pkg/e.go"},
+ {name: "case-folded separators", files: routingFiles(`Other\a.go`, `Pkg\a.go`, `Pkg\b.go`, `Pkg\c.go`, `Pkg\d.go`, `Pkg\e.go`), caseInsensitive: true, want: "Pkg/e.go"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ index := newContextFileIndex(test.files, test.caseInsensitive)
+ keys := []string{"a.go", "b.go", "c.go", "d.go", "e.go"}
+ for keyIndex := range keys {
+ keys[keyIndex] = index.key(keys[keyIndex])
+ }
+ matches := index.uniqueBasenames(keys)
+ if _, unique := matches.unique(index.key("a.go")); unique {
+ t.Fatal("ambiguous basename resolved")
+ }
+ if got, unique := matches.unique(index.key("e.go")); !unique || got != test.want {
+ t.Fatalf("unique basename = %q, %v, want %s, true", got, unique, test.want)
+ }
+ })
+ }
+
+ index := newContextFileIndex(routingFiles("pkg/Ä.go", "pkg/b.go", "pkg/c.go", "pkg/d.go", "pkg/e.go"), true)
+ keys := []string{index.key("ä.go"), "b.go", "c.go", "d.go", "e.go"}
+ matches := index.uniqueBasenames(keys)
+ if got, unique := matches.unique(index.key("ä.go")); !unique || got != "pkg/Ä.go" {
+ t.Fatalf("Unicode basename = %q, %v, want pkg/Ä.go, true", got, unique)
+ }
+}
+
func TestContextFileIndexPrefixesRespectCaseFolding(t *testing.T) {
- files := routingFiles("Src/Build/z.go", "src/build/a.go", "src/building/b.go")
+ files := routingFiles("src/Build/d.go", "Src/build/a.go", "SRC/BUILD/c.go", "src/build/b.go", "src/building/e.go")
index := newContextFileIndex(files, true)
- if want := []string{"Src/Build/z.go", "src/build/a.go"}; !reflect.DeepEqual(index.prefixPaths["src/build"], want) {
- t.Fatalf("case-folded prefix index = %#v, want %#v", index.prefixPaths["src/build"], want)
+ if index.pathsReady {
+ t.Fatal("case-folded inventory prepared eagerly")
}
var got []string
- index.forPrefix(index.key("src/build"), func(path string) bool {
+ index.forPrefix(index.key("src/build"), 2, func(path string) bool {
got = append(got, path)
return false
})
- if want := []string{"Src/Build/z.go", "src/build/a.go"}; !reflect.DeepEqual(got, want) {
+ if want := []string{"SRC/BUILD/c.go", "Src/build/a.go"}; !reflect.DeepEqual(got, want) {
t.Fatalf("case-folded files = %#v, want %#v", got, want)
}
+ if !index.pathsReady {
+ t.Fatal("case-folded inventory was not prepared on demand")
+ }
cfg := config.ProjectConfig{Routing: config.RoutingConfig{
Subsystems: []config.Subsystem{{ID: "build", Keywords: []string{"build"}, Paths: []string{"src/build"}}},
}}
- if got := resolveContextFilesWithCase("build", files, cfg, 1, true); !reflect.DeepEqual(got, []string{"Src/Build/z.go"}) {
+ if got := resolveContextFilesWithCase("build", files, cfg, 1, true); !reflect.DeepEqual(got, []string{"SRC/BUILD/c.go"}) {
t.Fatalf("case-folded top-k files = %#v, want first path", got)
}
+
+ index = newContextFileIndex(routingFiles("Ärea/a.go", "ärea/b.go"), true)
+ got = nil
+ index.forPrefix(index.key("ÄREA"), 2, func(path string) bool { got = append(got, path); return false })
+ if want := []string{"Ärea/a.go", "ärea/b.go"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("Unicode case-folded files = %#v, want %#v", got, want)
+ }
}
func TestContextSubsystemMatchesUsesSharedRouteScoring(t *testing.T) {
diff --git a/config/config.go b/config/config.go
index d3e6662..656c16b 100644
--- a/config/config.go
+++ b/config/config.go
@@ -234,7 +234,12 @@ func ConfigPath(root string) string {
// Returns zero-value ProjectConfig if the file is missing.
// Logs a warning to stderr and returns zero-value if JSON is malformed.
func Load(root string) ProjectConfig {
- data, err := os.ReadFile(ConfigPath(root))
+ return LoadFile(ConfigPath(root))
+}
+
+// LoadFile reads a previously resolved config path.
+func LoadFile(path string) ProjectConfig {
+ data, err := os.ReadFile(path)
if err != nil {
return ProjectConfig{}
}
diff --git a/config/config_test.go b/config/config_test.go
index b3a9bd7..7721c17 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -97,6 +97,18 @@ func TestLoad_ValidConfig(t *testing.T) {
}
}
+func TestLoadFileReadsResolvedConfigPath(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "policy.json")
+ if err := os.WriteFile(path, []byte(`{"only":["go"],"depth":2}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg := LoadFile(path)
+ if len(cfg.Only) != 1 || cfg.Only[0] != "go" || cfg.Depth != 2 {
+ t.Fatalf("LoadFile() = %+v", cfg)
+ }
+}
+
func TestLoad_PartialConfig(t *testing.T) {
dir := t.TempDir()
codemapDir := filepath.Join(dir, ".codemap")
@@ -198,7 +210,7 @@ func TestConfigPathAndLoadUseSelectedSetupRoot(t *testing.T) {
}
}
-func makeLinkedConfigWorktreeFixture(t *testing.T) (primary, linked string) {
+func makeLinkedConfigWorktreeFixture(t testing.TB) (primary, linked string) {
t.Helper()
primary = t.TempDir()
gitDir := filepath.Join(primary, ".git", "worktrees", "agent")
@@ -226,6 +238,27 @@ func makeLinkedConfigWorktreeFixture(t *testing.T) (primary, linked string) {
return primary, linked
}
+func BenchmarkLoadLinkedWorktreeConfig(b *testing.B) {
+ projectpath.ResetSetupRoot()
+ b.Cleanup(projectpath.ResetSetupRoot)
+ primary, linked := makeLinkedConfigWorktreeFixture(b)
+ path := filepath.Join(primary, ".codemap", "config.json")
+ if err := os.WriteFile(path, []byte(`{"only":["go"],"exclude":["vendor"]}`), 0o644); err != nil {
+ b.Fatal(err)
+ }
+
+ b.Run("resolve-root", func(b *testing.B) {
+ for b.Loop() {
+ _ = Load(linked)
+ }
+ })
+ b.Run("resolved-path", func(b *testing.B) {
+ for b.Loop() {
+ _ = LoadFile(path)
+ }
+ })
+}
+
func TestPolicyDefaultsAndClamps(t *testing.T) {
t.Run("defaults for empty config", func(t *testing.T) {
var cfg ProjectConfig
diff --git a/internal/runtimefile/runtimefile.go b/internal/runtimefile/runtimefile.go
index af5f58f..1fe63bb 100644
--- a/internal/runtimefile/runtimefile.go
+++ b/internal/runtimefile/runtimefile.go
@@ -2,12 +2,21 @@ package runtimefile
import (
"fmt"
+ "io"
"os"
"path/filepath"
)
// WriteAtomic replaces a regular runtime file without following its endpoint.
func WriteAtomic(path string, data []byte, mode os.FileMode) error {
+ return WriteAtomicWith(path, mode, func(w io.Writer) error {
+ _, err := w.Write(data)
+ return err
+ })
+}
+
+// WriteAtomicWith replaces a regular runtime file with streamed content.
+func WriteAtomicWith(path string, mode os.FileMode, write func(io.Writer) error) error {
if info, err := os.Lstat(path); err == nil && !info.Mode().IsRegular() {
return fmt.Errorf("unsafe runtime file %q", path)
} else if err != nil && !os.IsNotExist(err) {
@@ -23,7 +32,7 @@ func WriteAtomic(path string, data []byte, mode os.FileMode) error {
_ = tmp.Close()
return err
}
- if _, err := tmp.Write(data); err != nil {
+ if err := write(tmp); err != nil {
_ = tmp.Close()
return err
}
diff --git a/internal/runtimefile/runtimefile_test.go b/internal/runtimefile/runtimefile_test.go
new file mode 100644
index 0000000..f566d25
--- /dev/null
+++ b/internal/runtimefile/runtimefile_test.go
@@ -0,0 +1,59 @@
+package runtimefile
+
+import (
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestWriteAtomicWithPreservesDestinationAfterWriteFailure(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "state.json")
+ if err := os.WriteFile(path, []byte("old"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ wantErr := errors.New("encode failed")
+ err := WriteAtomicWith(path, 0o644, func(w io.Writer) error {
+ _, _ = w.Write([]byte("partial"))
+ return wantErr
+ })
+ if !errors.Is(err, wantErr) {
+ t.Fatalf("error = %v, want %v", err, wantErr)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != "old" {
+ t.Fatalf("destination = %q, want old", data)
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 1 {
+ t.Fatalf("directory entries = %d, want 1", len(entries))
+ }
+}
+
+func TestWriteAtomicWithReplacesDestination(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state.json")
+ if err := os.WriteFile(path, []byte("old"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := WriteAtomicWith(path, 0o644, func(w io.Writer) error {
+ _, err := io.WriteString(w, "new")
+ return err
+ }); err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != "new" {
+ t.Fatalf("content = %q, want new", data)
+ }
+}
diff --git a/mcp/main_more_test.go b/mcp/main_more_test.go
index 56cc6c6..199e3d2 100644
--- a/mcp/main_more_test.go
+++ b/mcp/main_more_test.go
@@ -451,7 +451,6 @@ func TestRustGraphContextHandlersDisclosePartialCoverage(t *testing.T) {
root := t.TempDir()
files := map[string]string{
- "Cargo.toml": "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
"src/lib.rs": "mod workspace;\n",
"src/workspace.rs": "pub fn run() {}\n",
"src/string_route.rs": "const COMMAND: &str = \"run\";\n",
diff --git a/render/hotpath_benchmark_test.go b/render/hotpath_benchmark_test.go
new file mode 100644
index 0000000..6dde02a
--- /dev/null
+++ b/render/hotpath_benchmark_test.go
@@ -0,0 +1,53 @@
+package render
+
+import (
+ "fmt"
+ "io"
+ "path/filepath"
+ "testing"
+
+ "codemap/scanner"
+)
+
+var benchmarkTree *treeNode
+
+func BenchmarkBuildTreeStructure(b *testing.B) {
+ files := benchmarkRenderFiles(50_000)
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ benchmarkTree = buildTreeStructure(files)
+ }
+}
+
+func BenchmarkTree(b *testing.B) {
+ project := scanner.Project{Root: "benchmark", Files: benchmarkRenderFiles(5_000)}
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ Tree(io.Discard, project)
+ }
+}
+
+func BenchmarkSkyline(b *testing.B) {
+ project := scanner.Project{Root: "benchmark", Files: benchmarkRenderFiles(50_000)}
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ Skyline(io.Discard, project, false)
+ }
+}
+
+func benchmarkRenderFiles(count int) []scanner.FileInfo {
+ files := make([]scanner.FileInfo, count)
+ extensions := []string{".go", ".ts", ".py", ".rs", ".java"}
+ for i := range files {
+ ext := extensions[i%len(extensions)]
+ files[i] = scanner.FileInfo{
+ Path: filepath.Join(fmt.Sprintf("area-%03d", i%100), fmt.Sprintf("feature-%03d", i%500), fmt.Sprintf("file-%05d%s", i, ext)),
+ Size: int64(64 + i%4096),
+ Ext: ext,
+ }
+ }
+ return files
+}
diff --git a/render/skyline.go b/render/skyline.go
index b21b011..8344b32 100644
--- a/render/skyline.go
+++ b/render/skyline.go
@@ -78,45 +78,70 @@ type extAgg struct {
count int
}
-// filterCodeFiles returns only source code files
-func filterCodeFiles(files []scanner.FileInfo) []scanner.FileInfo {
- var result []scanner.FileInfo
- for _, f := range files {
- if codeExtensions[strings.ToLower(f.Ext)] || codeFilenames[filepath.Base(f.Path)] {
- result = append(result, f)
- }
- }
- if len(result) == 0 {
- return files
- }
- return result
-}
-
// aggregateByExtension groups files by extension
func aggregateByExtension(files []scanner.FileInfo) []extAgg {
- groups := make(map[string]*extAgg)
+ groups := make(map[string]extAgg)
for _, f := range files {
ext := strings.ToLower(f.Ext)
if ext == "" {
ext = filepath.Base(f.Path)
}
- if groups[ext] == nil {
- groups[ext] = &extAgg{ext: ext}
- }
- groups[ext].size += f.Size
- groups[ext].count++
+ agg := groups[ext]
+ agg.ext = ext
+ agg.size += f.Size
+ agg.count++
+ groups[ext] = agg
}
var result []extAgg
for _, agg := range groups {
- result = append(result, *agg)
+ result = append(result, agg)
}
- sort.Slice(result, func(i, j int) bool {
- return result[i].size > result[j].size
- })
+ sortExtensionAggregates(result)
return result
}
+func aggregateSkylineFiles(files []scanner.FileInfo) ([]extAgg, int, int64) {
+ groups := make(map[string]extAgg)
+ count := 0
+ var size, allSize int64
+ for _, file := range files {
+ allSize += file.Size
+ ext := strings.ToLower(file.Ext)
+ if !codeExtensions[ext] && !codeFilenames[filepath.Base(file.Path)] {
+ continue
+ }
+ if ext == "" {
+ ext = filepath.Base(file.Path)
+ }
+ agg := groups[ext]
+ agg.ext = ext
+ agg.size += file.Size
+ agg.count++
+ groups[ext] = agg
+ count++
+ size += file.Size
+ }
+ if count == 0 {
+ return aggregateByExtension(files), len(files), allSize
+ }
+ result := make([]extAgg, 0, len(groups))
+ for _, agg := range groups {
+ result = append(result, agg)
+ }
+ sortExtensionAggregates(result)
+ return result, count, size
+}
+
+func sortExtensionAggregates(aggregates []extAgg) {
+ sort.Slice(aggregates, func(i, j int) bool {
+ if aggregates[i].size != aggregates[j].size {
+ return aggregates[i].size > aggregates[j].size
+ }
+ return aggregates[i].ext < aggregates[j].ext
+ })
+}
+
// getBuildingChar returns building texture character
func getBuildingChar(ext string) rune {
ext = strings.ToLower(ext)
@@ -220,8 +245,7 @@ func Skyline(w io.Writer, project scanner.Project, animate bool) {
width = 80
}
- codeFiles := filterCodeFiles(files)
- sorted := aggregateByExtension(codeFiles)
+ sorted, codeFileCount, codeSize := aggregateSkylineFiles(files)
arranged := createBuildings(sorted, width)
if len(arranged) == 0 {
@@ -242,15 +266,15 @@ func Skyline(w io.Writer, project scanner.Project, animate bool) {
// If writer is not os.Stdout, disable animation
if animate && w == os.Stdout {
- renderAnimated(w, arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFiles, projectName, sorted)
+ renderAnimated(w, arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFileCount, codeSize, projectName, sorted)
} else {
- renderStatic(w, arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFiles, projectName, sorted)
+ renderStatic(w, arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFileCount, codeSize, projectName, sorted)
}
}
// renderStatic renders static skyline to the given writer
func renderStatic(w io.Writer, arranged []building, width, leftMargin, sceneLeft, sceneRight, sceneWidth int,
- codeFiles []scanner.FileInfo, projectName string, sorted []extAgg) {
+ codeFileCount int, codeSize int64, projectName string, sorted []extAgg) {
// Build grid
grid := make([][]rune, skyHeight+maxHeight+1)
for i := range grid {
@@ -383,11 +407,7 @@ func renderStatic(w io.Writer, arranged []building, width, leftMargin, sceneLeft
title := fmt.Sprintf("─── %s ───", projectName)
fmt.Fprintf(w, "%s%s%s\n", BoldWhite, CenterString(title, width), Reset)
- var codeSize int64
- for _, f := range codeFiles {
- codeSize += f.Size
- }
- stats := fmt.Sprintf("%d languages · %d files · %s", len(sorted), len(codeFiles), formatSize(codeSize))
+ stats := fmt.Sprintf("%d languages · %d files · %s", len(sorted), codeFileCount, formatSize(codeSize))
fmt.Fprintf(w, "%s%s%s\n", Cyan, CenterString(stats, width), Reset)
fmt.Fprintln(w)
}
@@ -400,7 +420,6 @@ type animationModel struct {
sceneLeft int
sceneRight int
sceneWidth int
- codeFiles []scanner.FileInfo
projectName string
sorted []extAgg
starPositions [][2]int
@@ -602,7 +621,7 @@ func (m animationModel) View() string {
// renderAnimated renders animated skyline using bubbletea
func renderAnimated(w io.Writer, arranged []building, width, leftMargin, sceneLeft, sceneRight, sceneWidth int,
- codeFiles []scanner.FileInfo, projectName string, sorted []extAgg) {
+ codeFileCount int, codeSize int64, projectName string, sorted []extAgg) {
// Generate star positions
var starPositions [][2]int
for row := 0; row < skyHeight; row++ {
@@ -629,7 +648,6 @@ func renderAnimated(w io.Writer, arranged []building, width, leftMargin, sceneLe
sceneLeft: sceneLeft,
sceneRight: sceneRight,
sceneWidth: sceneWidth,
- codeFiles: codeFiles,
projectName: projectName,
sorted: sorted,
starPositions: starPositions,
@@ -643,7 +661,7 @@ func renderAnimated(w io.Writer, arranged []building, width, leftMargin, sceneLe
p.Run()
// After animation, print static final frame to main screen
- renderStatic(w, arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFiles, projectName, sorted)
+ renderStatic(w, arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFileCount, codeSize, projectName, sorted)
}
func max(a, b int) int {
diff --git a/render/skyline_test.go b/render/skyline_test.go
index 89980a8..94feffa 100644
--- a/render/skyline_test.go
+++ b/render/skyline_test.go
@@ -22,50 +22,42 @@ func resetSkylineRNG() {
rng = rand.New(rand.NewPCG(42, 0))
}
-func stripSkylineANSI(s string) string {
- return skylineANSIPattern.ReplaceAllString(s, "")
+func TestAggregateSkylineFilesFallsBackToAssets(t *testing.T) {
+ files := []scanner.FileInfo{
+ {Path: "images/one.png", Ext: ".png", Size: 10},
+ {Path: "images/two.jpg", Ext: ".jpg", Size: 20},
+ }
+ groups, count, size := aggregateSkylineFiles(files)
+ if count != 2 || size != 30 {
+ t.Fatalf("fallback totals = (%d, %d), want (2, 30)", count, size)
+ }
+ if len(groups) != 2 {
+ t.Fatalf("fallback groups = %d, want 2", len(groups))
+ }
}
-func TestSkylineFilterCodeFiles(t *testing.T) {
- tests := []struct {
- name string
- files []scanner.FileInfo
- expected int
- }{
- {
- name: "returns only code files when present",
- files: []scanner.FileInfo{
- {Path: "main.go", Ext: ".go"},
- {Path: "schema.cue", Ext: ".cue"},
- {Path: "photo.png", Ext: ".png"},
- {Path: "Dockerfile"},
- },
- expected: 3,
- },
- {
- name: "returns original files when no code files found",
- files: []scanner.FileInfo{
- {Path: "image.png", Ext: ".png"},
- {Path: "font.woff", Ext: ".woff"},
- },
- expected: 2,
- },
+func TestAggregateSkylineFilesExcludesAssetsFromCodeTotals(t *testing.T) {
+ files := []scanner.FileInfo{
+ {Path: "main.go", Ext: ".go", Size: 10},
+ {Path: "images/logo.png", Ext: ".png", Size: 100},
}
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := filterCodeFiles(tt.files)
- if len(got) != tt.expected {
- t.Fatalf("filterCodeFiles() len = %d, want %d", len(got), tt.expected)
- }
- })
+ groups, count, size := aggregateSkylineFiles(files)
+ if count != 1 || size != 10 {
+ t.Fatalf("code totals = (%d, %d), want (1, 10)", count, size)
+ }
+ if len(groups) != 1 || groups[0].ext != ".go" {
+ t.Fatalf("code groups = %#v, want only .go", groups)
}
}
+func stripSkylineANSI(s string) string {
+ return skylineANSIPattern.ReplaceAllString(s, "")
+}
+
func TestSkylineAggregateByExtension(t *testing.T) {
files := []scanner.FileInfo{
{Path: "a/main.go", Ext: ".go", Size: 100},
- {Path: "a/util.go", Ext: ".go", Size: 50},
+ {Path: "a/util.go", Ext: ".go", Size: 20},
{Path: "b/app.ts", Ext: ".ts", Size: 120},
{Path: "Makefile", Ext: "", Size: 80},
}
@@ -75,8 +67,8 @@ func TestSkylineAggregateByExtension(t *testing.T) {
t.Fatalf("aggregateByExtension() len = %d, want 3", len(agg))
}
- if agg[0].ext != ".go" || agg[0].size != 150 || agg[0].count != 2 {
- t.Fatalf("unexpected first aggregate: %+v", agg[0])
+ if agg[0].ext != ".go" || agg[0].size != 120 || agg[0].count != 2 || agg[1].ext != ".ts" {
+ t.Fatalf("first aggregates = %+v, want .go before .ts", agg[:2])
}
seenMakefile := false
@@ -209,7 +201,7 @@ func TestSkylineRenderStaticIncludesTitleAndStats(t *testing.T) {
sorted := []extAgg{{ext: ".go", size: 300, count: 1}}
var buf bytes.Buffer
- renderStatic(&buf, arranged, 40, 10, 8, 24, 16, codeFiles, "Demo", sorted)
+ renderStatic(&buf, arranged, 40, 10, 8, 24, 16, len(codeFiles), codeFiles[0].Size, "Demo", sorted)
out := buf.String()
checks := []string{"─── Demo ───", "1 languages", "1 files", "300.0B"}
diff --git a/render/tree.go b/render/tree.go
index 58268e8..0d56041 100644
--- a/render/tree.go
+++ b/render/tree.go
@@ -13,34 +13,43 @@ import (
// treeNode represents a node in the file tree
type treeNode struct {
- name string
- isFile bool
- file *scanner.FileInfo
- children map[string]*treeNode
+ name string
+ isFile bool
+ file *scanner.FileInfo
+ children map[string]*treeNode
+ fileCount int
+ totalSize int64
+ statsReady bool
}
// getTopLargeFiles returns paths of top 5 largest source code files
func getTopLargeFiles(files []scanner.FileInfo) map[string]bool {
// Filter out assets and binaries (no extension = likely binary)
- var sourceFiles []scanner.FileInfo
+ var largest []scanner.FileInfo
for _, f := range files {
ext := strings.ToLower(f.Ext)
// Skip if no extension (likely binary) or if it's an asset
if ext == "" || IsAssetExtension(ext) {
continue
}
- sourceFiles = append(sourceFiles, f)
+ position := sort.Search(len(largest), func(i int) bool {
+ return largest[i].Size < f.Size || largest[i].Size == f.Size && largest[i].Path > f.Path
+ })
+ if position >= 5 {
+ continue
+ }
+ largest = append(largest, scanner.FileInfo{})
+ copy(largest[position+1:], largest[position:])
+ largest[position] = f
+ if len(largest) > 5 {
+ largest = largest[:5]
+ }
}
- // Sort by size descending
- sort.Slice(sourceFiles, func(i, j int) bool {
- return sourceFiles[i].Size > sourceFiles[j].Size
- })
-
// Return top 5 as set
result := make(map[string]bool)
- for i := 0; i < len(sourceFiles) && i < 5; i++ {
- result[sourceFiles[i].Path] = true
+ for _, file := range largest {
+ result[file.Path] = true
}
return result
}
@@ -50,44 +59,61 @@ func getDirStats(node *treeNode) (int, int64) {
if node.isFile {
return 1, node.file.Size
}
+ if node.statsReady {
+ return node.fileCount, node.totalSize
+ }
count := 0
- var size int64 = 0
+ var size int64
for _, child := range node.children {
- c, s := getDirStats(child)
- count += c
- size += s
+ childCount, childSize := getDirStats(child)
+ count += childCount
+ size += childSize
}
return count, size
}
+func cacheTreeStats(node *treeNode) (int, int64) {
+ if node.isFile {
+ return 1, node.file.Size
+ }
+ node.fileCount, node.totalSize = 0, 0
+ for _, child := range node.children {
+ count, size := cacheTreeStats(child)
+ node.fileCount += count
+ node.totalSize += size
+ }
+ node.statsReady = true
+ return node.fileCount, node.totalSize
+}
+
// buildTreeStructure builds a nested tree from flat file list
func buildTreeStructure(files []scanner.FileInfo) *treeNode {
root := &treeNode{children: make(map[string]*treeNode)}
- for _, f := range files {
- parts := strings.Split(f.Path, string(os.PathSeparator))
+ for i := range files {
+ f := &files[i]
current := root
- for i, part := range parts {
- if i == len(parts)-1 {
+ remaining := f.Path
+ for {
+ separator := strings.IndexByte(remaining, os.PathSeparator)
+ if separator < 0 {
// File
- fileCopy := f
- current.children[part] = &treeNode{
- name: part,
+ current.children[remaining] = &treeNode{
+ name: remaining,
isFile: true,
- file: &fileCopy,
+ file: f,
}
- } else {
- // Directory
- if current.children[part] == nil {
- current.children[part] = &treeNode{
- name: part,
- children: make(map[string]*treeNode),
- }
- }
- current = current.children[part]
+ break
+ }
+ part := remaining[:separator]
+ if current.children[part] == nil {
+ current.children[part] = &treeNode{name: part, children: make(map[string]*treeNode)}
}
+ current = current.children[part]
+ remaining = remaining[separator+1:]
}
}
+ cacheTreeStats(root)
return root
}
diff --git a/render/tree_test.go b/render/tree_test.go
index 3271c90..991004d 100644
--- a/render/tree_test.go
+++ b/render/tree_test.go
@@ -4,7 +4,7 @@ import (
"bytes"
"context"
"math/rand/v2"
- "reflect"
+ "path/filepath"
"strings"
"testing"
@@ -235,6 +235,27 @@ func TestGetTopLargeFilesFewerThan5(t *testing.T) {
}
}
+func TestGetTopLargeFilesBreaksSizeTiesByPath(t *testing.T) {
+ files := []scanner.FileInfo{
+ {Path: "z.go", Size: 100, Ext: ".go"},
+ {Path: "f.go", Size: 100, Ext: ".go"},
+ {Path: "a.go", Size: 100, Ext: ".go"},
+ {Path: "b.go", Size: 100, Ext: ".go"},
+ {Path: "c.go", Size: 100, Ext: ".go"},
+ {Path: "d.go", Size: 100, Ext: ".go"},
+ {Path: "e.go", Size: 100, Ext: ".go"},
+ }
+ top := getTopLargeFiles(files)
+ for _, path := range []string{"a.go", "b.go", "c.go", "d.go", "e.go"} {
+ if !top[path] {
+ t.Fatalf("top files = %v, want %s", top, path)
+ }
+ }
+ if top["f.go"] || top["z.go"] {
+ t.Fatalf("top files include a larger tie-break path: %v", top)
+ }
+}
+
func TestTreeNodeStructure(t *testing.T) {
// Test treeNode creation
node := &treeNode{
@@ -260,6 +281,17 @@ func TestTreeNodeStructure(t *testing.T) {
}
}
+func TestBuildTreeStructureCountsDuplicatePathOnce(t *testing.T) {
+ root := buildTreeStructure([]scanner.FileInfo{
+ {Path: filepath.Join("src", "main.go"), Size: 10},
+ {Path: filepath.Join("src", "main.go"), Size: 20},
+ })
+ count, size := getDirStats(root.children["src"])
+ if count != 1 || size != 20 {
+ t.Fatalf("duplicate path stats = (%d, %d), want (1, 20)", count, size)
+ }
+}
+
func TestTitleCase(t *testing.T) {
tests := []struct {
name string
@@ -303,48 +335,6 @@ func TestGetSystemName(t *testing.T) {
}
}
-func TestFilterCodeFiles(t *testing.T) {
- tests := []struct {
- name string
- files []scanner.FileInfo
- want []scanner.FileInfo
- }{
- {
- name: "filters to code extensions and known code filenames",
- files: []scanner.FileInfo{
- {Path: "main.go", Ext: ".go"},
- {Path: "README.md", Ext: ".md"},
- {Path: "Dockerfile", Ext: ""},
- {Path: "assets/logo.png", Ext: ".png"},
- },
- want: []scanner.FileInfo{
- {Path: "main.go", Ext: ".go"},
- {Path: "Dockerfile", Ext: ""},
- },
- },
- {
- name: "returns original slice when no code files match",
- files: []scanner.FileInfo{
- {Path: "README.md", Ext: ".md"},
- {Path: "assets/logo.png", Ext: ".png"},
- },
- want: []scanner.FileInfo{
- {Path: "README.md", Ext: ".md"},
- {Path: "assets/logo.png", Ext: ".png"},
- },
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := filterCodeFiles(tt.files)
- if !reflect.DeepEqual(got, tt.want) {
- t.Errorf("filterCodeFiles() = %#v, want %#v", got, tt.want)
- }
- })
- }
-}
-
func TestAggregateByExtension(t *testing.T) {
files := []scanner.FileInfo{
{Path: "main.go", Ext: ".go", Size: 100},
diff --git a/scanner/cargofallback.go b/scanner/cargofallback.go
index 0888ffa..2f77372 100644
--- a/scanner/cargofallback.go
+++ b/scanner/cargofallback.go
@@ -29,7 +29,11 @@ func buildFileGraphFromOutcomeWithCargoMetadataAndFilters(ctx context.Context, r
break
}
}
- fg, err := buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx, root, outcome.Analyses, filters, loader, outcome.Sources...)
+ var inventory []FileInfo
+ if outcome.hasFileInventory {
+ inventory = outcome.files
+ }
+ fg, err := buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx, root, outcome.Analyses, filters, loader, outcome.Sources, inventory, outcome.hasFileInventory)
if err != nil {
return nil, err
}
@@ -98,7 +102,9 @@ func scanForGraphOutcomeWithFilters(ctx context.Context, root string, filters Fi
return ScanOutcome{}, false, err
}
fallback := ScanOutcome{
- Sources: []ScanSourceOutcome{incomplete.Outcome},
+ Sources: []ScanSourceOutcome{incomplete.Outcome},
+ files: files,
+ hasFileInventory: true,
}
recovered := false
if goFallback, fallbackErr := buildGoFallbackOutcome(ctx, root, files); fallbackErr == nil {
@@ -152,6 +158,10 @@ func mergeFallbackOutcome(dst *ScanOutcome, src ScanOutcome) {
dst.Analyses = append(dst.Analyses, src.Analyses...)
dst.Sources = append(dst.Sources, src.Sources...)
dst.precomputedEdges = append(dst.precomputedEdges, src.precomputedEdges...)
+ if src.hasFileInventory {
+ dst.files = src.files
+ dst.hasFileInventory = true
+ }
}
func buildCargoFallbackOutcome(ctx context.Context, root string, files []FileInfo, loader cargoMetadataLoader) (ScanOutcome, error) {
diff --git a/scanner/cue.go b/scanner/cue.go
index d3b1705..c333171 100644
--- a/scanner/cue.go
+++ b/scanner/cue.go
@@ -19,7 +19,10 @@ func scanCUEFiles(ctx context.Context, root string, filters Filters) (ScanOutcom
if err != nil {
return ScanOutcome{}, err
}
- return scanCUEFilesFromFiles(ctx, root, files)
+ outcome, err := scanCUEFilesFromFiles(ctx, root, files)
+ outcome.files = files
+ outcome.hasFileInventory = err == nil
+ return outcome, err
}
func scanCUEFilesFromFiles(ctx context.Context, root string, files []FileInfo) (ScanOutcome, error) {
diff --git a/scanner/deps_test.go b/scanner/deps_test.go
index 0f34e6c..7e1efe2 100644
--- a/scanner/deps_test.go
+++ b/scanner/deps_test.go
@@ -517,11 +517,11 @@ func TestDepsBuildFileIndex(t *testing.T) {
t.Fatalf("expected %q in byDir, got %v", handlerPath, got)
}
handlerNoExt := strings.TrimSuffix(handlerPath, filepath.Ext(handlerPath))
- if got := idx.byExact[handlerNoExt]; len(got) != 1 || got[0] != handlerPath {
- t.Fatalf("expected no-ext exact match for handler.go, got %v", got)
+ if got := tryExactMatch(handlerNoExt, idx, "go"); len(got) != 1 || got[0] != handlerPath {
+ t.Fatalf("expected extensionless import to resolve handler.go, got %v", got)
}
handlerSuffix := filepath.Join("service", "handler.go")
- if got := idx.bySuffix[handlerSuffix]; len(got) != 1 || got[0] != handlerPath {
+ if got := idx.suffixMatches(handlerSuffix); len(got) != 1 || got[0] != handlerPath {
t.Fatalf("expected suffix match for service/handler.go, got %v", got)
}
if got := idx.goPkgs["example.com/project/pkg/service"]; len(got) != 1 || got[0] != handlerPath {
diff --git a/scanner/filegraph.go b/scanner/filegraph.go
index 90f611a..7a5e07c 100644
--- a/scanner/filegraph.go
+++ b/scanner/filegraph.go
@@ -27,8 +27,8 @@ type FileGraph struct {
// fileIndex provides fast lookup of files by various import-like keys
type fileIndex struct {
- byExact map[string][]string // exact path -> files
- bySuffix map[string][]string // path suffix -> files (for nested packages)
+ byExact map[string]uint32 // exact path -> inventory count
+ bySuffix []string // paths ordered by suffix for nested lookup
byDir map[string][]string // directory -> files in it
goPkgs map[string][]string // Go package path -> files
cueModules []cueModuleInfo
@@ -61,7 +61,7 @@ func BuildFileGraphFromAnalyses(ctx context.Context, root string, analyses []Fil
if err != nil {
return nil, err
}
- return buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx, root, filtered, filters, loadCargoMetadata)
+ return buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx, root, filtered, filters, loadCargoMetadata, nil, nil, false)
}
func buildFileGraphFromAnalysesWithCargoMetadata(ctx context.Context, root string, analyses []FileAnalysis, loader cargoMetadataLoader) (*FileGraph, error) {
@@ -71,10 +71,10 @@ func buildFileGraphFromAnalysesWithCargoMetadata(ctx context.Context, root strin
if err != nil {
return nil, err
}
- return buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx, root, filtered, filters, loader)
+ return buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx, root, filtered, filters, loader, nil, nil, false)
}
-func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context, root string, analyses []FileAnalysis, filters Filters, loader cargoMetadataLoader, sources ...ScanSourceOutcome) (*FileGraph, error) {
+func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context, root string, analyses []FileAnalysis, filters Filters, loader cargoMetadataLoader, sources []ScanSourceOutcome, inventory []FileInfo, hasInventory bool) (*FileGraph, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
@@ -84,11 +84,10 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context,
}
fg := &FileGraph{
- Root: absRoot,
- Imports: make(map[string][]string),
- Importers: make(map[string][]string),
- Packages: make(map[string][]string),
- PathAliases: make(map[string][]string),
+ Root: absRoot,
+ Imports: make(map[string][]string),
+ Importers: make(map[string][]string),
+ Packages: make(map[string][]string),
}
hasCargoSource := false
for _, source := range sources {
@@ -101,19 +100,25 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context,
// Detect module name from go.mod (for Go import resolution)
fg.Module = detectModule(absRoot)
- // Detect path aliases from tsconfig.json (for TS/JS import resolution)
- fg.PathAliases, fg.BaseURL = detectPathAliases(absRoot)
-
- useJSWorkspace := needsJSWorkspaceResolver(analyses)
- useDartWorkspace := needsDartWorkspaceResolver(analyses)
+ analysisLanguages := inspectAnalysisLanguages(analyses)
+ useJSWorkspace := analysisLanguages.hasJS
+ useDartWorkspace := analysisLanguages.hasDart
+ if useJSWorkspace {
+ fg.PathAliases, fg.BaseURL = detectPathAliases(absRoot)
+ }
gitCache := NewGitIgnoreCache(root)
scanOnly := filters.Only
if useJSWorkspace || useDartWorkspace {
scanOnly = nil
}
- allFiles, err := ScanFiles(ctx, root, gitCache, scanOnly, filters.Exclude)
- if err != nil {
- return nil, err
+ var allFiles []FileInfo
+ if hasInventory && (len(filters.Only) == 0 || !useJSWorkspace && !useDartWorkspace) {
+ allFiles = inventory
+ } else {
+ allFiles, err = ScanFiles(ctx, root, gitCache, scanOnly, filters.Exclude)
+ if err != nil {
+ return nil, err
+ }
}
files := allFiles
if len(filters.Only) > 0 && (useJSWorkspace || useDartWorkspace) {
@@ -124,30 +129,30 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context,
}
}
}
- if !hasCUEAnalyses(analyses) {
- for _, file := range files {
- if !strings.EqualFold(filepath.Ext(file.Path), ".cue") {
- continue
- }
- cueOutcome, cueErr := scanCUEFilesFromFiles(ctx, absRoot, files)
- if cueErr != nil {
- return nil, cueErr
- }
- analyses = append(analyses, cueOutcome.Analyses...)
- for _, source := range cueOutcome.Sources {
- fg.Coverage.AddSource(source)
- }
- break
+ languages := inspectFileLanguages(files)
+ hasCUEAnalysis := analysisLanguages.hasCUE
+ if languages.hasCUE && !hasCUEAnalysis {
+ cueOutcome, cueErr := scanCUEFilesFromFiles(ctx, absRoot, files)
+ if cueErr != nil {
+ return nil, cueErr
+ }
+ analyses = append(analyses, cueOutcome.Analyses...)
+ for _, source := range cueOutcome.Sources {
+ fg.Coverage.AddSource(source)
}
}
- rustWorkspace, cargoOutcome, err := buildRustWorkspaceIndex(ctx, absRoot, analyses, files, loader)
- if err != nil {
- return nil, err
- }
- // The outcome already carries cargo provenance; keep exactly one
- // cargo-metadata source per graph.
- if cargoOutcome != nil && !hasCargoSource {
- fg.Coverage.AddSource(*cargoOutcome)
+ var rustWorkspace *rustWorkspaceIndex
+ if languages.hasRust || analysisLanguages.hasRust {
+ var cargoOutcome *ScanSourceOutcome
+ rustWorkspace, cargoOutcome, err = buildRustWorkspaceIndex(ctx, absRoot, analyses, files, loader)
+ if err != nil {
+ return nil, err
+ }
+ // The outcome already carries cargo provenance; keep exactly one
+ // cargo-metadata source per graph.
+ if cargoOutcome != nil && !hasCargoSource {
+ fg.Coverage.AddSource(*cargoOutcome)
+ }
}
// Build file index for fast fuzzy matching
@@ -155,40 +160,39 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context,
if err != nil {
return nil, err
}
- idx.cueModules = detectCUEModulesWithFiles(absRoot, files)
- idx.cuePackages = make(map[string]string)
- for _, file := range files {
- if DetectLanguage(file.Path) != "cue" {
- continue
- }
- path := filepath.ToSlash(filepath.Clean(file.Path))
- data, readErr := os.ReadFile(filepath.Join(absRoot, filepath.FromSlash(path)))
- if readErr == nil {
- idx.cuePackages[path], _ = cueHeader(data)
+ if languages.hasCUE || hasCUEAnalysis {
+ idx.cueModules = detectCUEModulesWithFiles(absRoot, files)
+ idx.cuePackages = make(map[string]string)
+ for _, file := range files {
+ if fileInfoLanguage(file) != "cue" {
+ continue
+ }
+ path := filepath.ToSlash(filepath.Clean(file.Path))
+ data, readErr := os.ReadFile(filepath.Join(absRoot, filepath.FromSlash(path)))
+ if readErr == nil {
+ idx.cuePackages[path], _ = cueHeader(data)
+ }
}
- }
- for _, analysis := range analyses {
- if analysis.Language == "cue" && analysis.Package != "" {
- idx.cuePackages[filepath.ToSlash(filepath.Clean(analysis.Path))] = analysis.Package
+ for _, analysis := range analyses {
+ if analysis.Language == "cue" && analysis.Package != "" {
+ idx.cuePackages[filepath.ToSlash(filepath.Clean(analysis.Path))] = analysis.Package
+ }
}
}
fg.Packages = idx.goPkgs
- for _, file := range files {
+ if languages.hasRust {
if err := ctx.Err(); err != nil {
return nil, err
}
- if strings.EqualFold(filepath.Ext(file.Path), ".rs") {
- if CoverageFromSources(fg.Coverage.Sources).Status != analysis.CoverageUnavailable {
- fg.Coverage.AddSource(ScanSourceOutcome{Name: "rust-cargo", Status: ScanSourceMixed, Detail: rustCoverageNote})
- }
- break
+ if CoverageFromSources(fg.Coverage.Sources).Status != analysis.CoverageUnavailable {
+ fg.Coverage.AddSource(ScanSourceOutcome{Name: "rust-cargo", Status: ScanSourceMixed, Detail: rustCoverageNote})
}
}
// Languages whose imports name modules rather than files cannot produce
// intra-project edges at all, so an empty graph over them is a blind spot
// rather than a finding. Recording it here is what keeps --importers and
// blast-radius honest too: both read this graph's provenance.
- fg.Coverage.AddSymbolLevelImportCoverage(files)
+ fg.Coverage.addSymbolLevelImportCoverage(languages.symbolLevel)
var jsResolver *jsWorkspaceResolver
if useJSWorkspace {
@@ -283,22 +287,30 @@ func applyPrecomputedFileEdges(fg *FileGraph, edges []fileEdge) {
}
}
-func needsJSWorkspaceResolver(analyses []FileAnalysis) bool {
- for _, analysis := range analyses {
- if isJavaScriptLanguage(DetectLanguage(analysis.Path)) {
- return true
- }
- }
- return false
+type analysisLanguageInventory struct {
+ hasJS bool
+ hasDart bool
+ hasRust bool
+ hasCUE bool
}
-func needsDartWorkspaceResolver(analyses []FileAnalysis) bool {
- for _, file := range analyses {
- if DetectLanguage(file.Path) == "dart" {
- return true
+func inspectAnalysisLanguages(analyses []FileAnalysis) analysisLanguageInventory {
+ var inventory analysisLanguageInventory
+ for _, analysis := range analyses {
+ language := DetectLanguage(analysis.Path)
+ if isJavaScriptLanguage(language) {
+ inventory.hasJS = true
+ } else if language == "dart" {
+ inventory.hasDart = true
+ }
+ switch analysis.Language {
+ case "rust":
+ inventory.hasRust = true
+ case "cue":
+ inventory.hasCUE = true
}
}
- return false
+ return inventory
}
// buildFileIndex creates a multi-key index for fast import resolution
@@ -308,12 +320,14 @@ func buildFileIndex(files []FileInfo, goModule string) *fileIndex {
}
func buildFileIndexContext(ctx context.Context, files []FileInfo, goModule string) (*fileIndex, error) {
+ directoryHint := min(len(files), 1024)
idx := &fileIndex{
- byExact: make(map[string][]string),
- bySuffix: make(map[string][]string),
- byDir: make(map[string][]string),
- goPkgs: make(map[string][]string),
+ 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),
}
+ goPackagePaths := make(map[string]string, directoryHint)
for _, f := range files {
if err := ctx.Err(); err != nil {
@@ -328,38 +342,30 @@ func buildFileIndexContext(ctx context.Context, files []FileInfo, goModule strin
// Index by directory
idx.byDir[dir] = append(idx.byDir[dir], path)
- // Index by exact path (without extension for fuzzy matching)
- idx.byExact[path] = append(idx.byExact[path], path)
- noExt := strings.TrimSuffix(path, filepath.Ext(path))
- idx.byExact[noExt] = append(idx.byExact[noExt], path)
-
- // Index by all path suffixes (for nested package resolution)
- // e.g., "llm-server/app/core/config.py" indexed as:
- // - "app/core/config.py"
- // - "core/config.py"
- // - "config.py"
- parts := strings.Split(path, string(filepath.Separator))
- for i := 1; i < len(parts); i++ {
- if err := ctx.Err(); err != nil {
- return nil, err
- }
- suffix := strings.Join(parts[i:], string(filepath.Separator))
- idx.bySuffix[suffix] = append(idx.bySuffix[suffix], path)
- // Also without extension
- noExt := strings.TrimSuffix(suffix, filepath.Ext(suffix))
- idx.bySuffix[noExt] = append(idx.bySuffix[noExt], path)
- }
+ idx.byExact[path]++
+ idx.bySuffix = append(idx.bySuffix, path)
// Go package index. Import paths always use forward slashes, so the
// key must be slash-normalized or lookups fail on Windows.
if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") && goModule != "" {
pkgPath := goModule
if dir != "" {
- pkgPath = goModule + "/" + filepath.ToSlash(dir)
+ var ok bool
+ pkgPath, ok = goPackagePaths[dir]
+ if !ok {
+ pkgPath = goModule + "/" + filepath.ToSlash(dir)
+ goPackagePaths[dir] = pkgPath
+ }
}
idx.goPkgs[pkgPath] = append(idx.goPkgs[pkgPath], path)
}
}
+ sort.Slice(idx.bySuffix, func(i, j int) bool {
+ if order := comparePathsReversed(idx.bySuffix[i], idx.bySuffix[j]); order != 0 {
+ return order < 0
+ }
+ return idx.bySuffix[i] < idx.bySuffix[j]
+ })
if err := ctx.Err(); err != nil {
return nil, err
@@ -367,6 +373,72 @@ func buildFileIndexContext(ctx context.Context, files []FileInfo, goModule strin
return idx, nil
}
+func comparePathsReversed(left, right string) int {
+ limit := min(len(left), len(right))
+ for i := 0; i < limit; i++ {
+ leftByte, rightByte := left[len(left)-1-i], right[len(right)-1-i]
+ if leftByte < rightByte {
+ return -1
+ }
+ if leftByte > rightByte {
+ return 1
+ }
+ }
+ if len(left) < len(right) {
+ return -1
+ }
+ if len(left) > len(right) {
+ return 1
+ }
+ return 0
+}
+
+func comparePathSuffix(path, suffix string) int {
+ prefixLen := len(suffix) + 1
+ limit := min(len(path), prefixLen)
+ for i := 0; i < limit; i++ {
+ left := path[len(path)-1-i]
+ right := byte(filepath.Separator)
+ if i < len(suffix) {
+ right = suffix[len(suffix)-1-i]
+ }
+ if left < right {
+ return -1
+ }
+ if left > right {
+ return 1
+ }
+ }
+ if len(path) < prefixLen {
+ return -1
+ }
+ if len(path) > prefixLen {
+ return 1
+ }
+ return 0
+}
+
+func hasPathSuffix(path, suffix string) bool {
+ return len(path) > len(suffix) && path[len(path)-len(suffix)-1] == byte(filepath.Separator) && strings.HasSuffix(path, suffix)
+}
+
+func (idx *fileIndex) suffixMatches(suffix string) []string {
+ if suffix == "" {
+ return nil
+ }
+ start := sort.Search(len(idx.bySuffix), func(i int) bool {
+ return comparePathSuffix(idx.bySuffix[i], suffix) >= 0
+ })
+ var matches []string
+ for i := start; i < len(idx.bySuffix) && hasPathSuffix(idx.bySuffix[i], suffix); i++ {
+ matches = append(matches, idx.bySuffix[i])
+ }
+ if len(matches) > 1 {
+ sort.Strings(matches)
+ }
+ return matches
+}
+
// fuzzyResolve converts an import path to compatible local file paths.
func fuzzyResolve(imp, fromFile string, idx *fileIndex, goModule string, pathAliases map[string][]string, baseURL string) []string {
return fuzzyResolveWithWorkspace(imp, fromFile, idx, goModule, pathAliases, baseURL, nil, nil)
@@ -474,15 +546,6 @@ func nearestCUEModule(fromFile string, modules []cueModuleInfo) (cueModuleInfo,
return cueModuleInfo{}, false
}
-func hasCUEAnalyses(analyses []FileAnalysis) bool {
- for _, analysis := range analyses {
- if analysis.Language == "cue" {
- return true
- }
- }
- return false
-}
-
func splitCUEImport(imp string) (string, string) {
separator := strings.LastIndex(imp, ":")
if separator <= strings.LastIndex(imp, "/") {
@@ -584,14 +647,13 @@ func resolveRelative(imp, fromDir string, idx *fileIndex, sourceLanguage string)
// tryExactMatch looks for exact path matches with common extensions.
// Extension list derived from the canonical scanner registry.
func tryExactMatch(path string, idx *fileIndex, sourceLanguage string) []string {
- extensions := ResolverExtensions()
-
- for _, ext := range extensions {
+ if idx.byExact[path] == 1 && languagesCompatible(sourceLanguage, DetectLanguage(path)) {
+ return []string{path}
+ }
+ for _, ext := range resolverExtensions[:len(resolverExtensions)-1] {
candidate := path + ext
- if files, ok := idx.byExact[candidate]; ok {
- if compatible := compatibleFiles(sourceLanguage, files); len(compatible) > 0 {
- return compatible
- }
+ if idx.byExact[candidate] == 1 && languagesCompatible(sourceLanguage, DetectLanguage(candidate)) {
+ return []string{candidate}
}
}
@@ -600,12 +662,9 @@ func tryExactMatch(path string, idx *fileIndex, sourceLanguage string) []string
// trySuffixMatch finds files where the path ends with the normalized import
func trySuffixMatch(normalized string, idx *fileIndex, sourceLanguage string) []string {
- // Extension list derived from the canonical scanner registry.
- extensions := ResolverExtensions()
-
- for _, ext := range extensions {
+ for _, ext := range resolverExtensions {
candidate := normalized + ext
- if files, ok := idx.bySuffix[candidate]; ok {
+ if files := idx.suffixMatches(candidate); len(files) > 0 {
files = compatibleFiles(sourceLanguage, files)
if len(files) == 0 {
continue
@@ -621,7 +680,7 @@ func trySuffixMatch(normalized string, idx *fileIndex, sourceLanguage string) []
// Also try __init__.py for Python packages
initCandidate := filepath.Join(normalized, "__init__.py")
- if files, ok := idx.bySuffix[initCandidate]; ok {
+ if files := idx.suffixMatches(initCandidate); len(files) > 0 {
return compatibleFiles(sourceLanguage, files)
}
diff --git a/scanner/filegraph_test.go b/scanner/filegraph_test.go
index 9f31c13..3524657 100644
--- a/scanner/filegraph_test.go
+++ b/scanner/filegraph_test.go
@@ -2,21 +2,17 @@ package scanner
import (
"context"
-
- "codemap/analysis"
"os"
"path/filepath"
"reflect"
"slices"
"sort"
"testing"
+
+ "codemap/analysis"
)
func TestRustWorkspaceImportersRespectCrateBoundaries(t *testing.T) {
- if !NewAstGrepAnalyzer().Available() {
- t.Skip("ast-grep not available")
- }
-
root := t.TempDir()
files := map[string]string{
"Cargo.toml": `[workspace]
@@ -57,9 +53,27 @@ but-api = { path = "../but-api" }
}
}
- graph, err := BuildFileGraph(context.Background(), root, ConfiguredFilters(root))
+ analyses := []FileAnalysis{
+ {Path: "crate-a/src/lib.rs", Language: "rust", References: []ImportReference{{Path: "workspace", Kind: "rust-module"}}},
+ {Path: "crate-a/src/workspace.rs", Language: "rust"},
+ {Path: "but-api/src/lib.rs", Language: "rust", References: []ImportReference{{Path: "workspace", Kind: "rust-module"}}},
+ {Path: "but-api/src/workspace.rs", Language: "rust"},
+ {Path: "consumer/src/lib.rs", Language: "rust", References: []ImportReference{{Path: "but_api::workspace::run", Kind: "rust-path"}}},
+ }
+ metadata := cargoMetadataJSON(t, root, []map[string]any{
+ cargoPackage(root, "crate-a", "crate-a", "crate_a", nil),
+ cargoPackage(root, "but-api", "but-api", "but_api", nil),
+ cargoPackage(root, "consumer", "consumer", "consumer", []map[string]any{{
+ "name": "but-api",
+ "path": filepath.Join(root, "but-api"),
+ }}),
+ })
+ graph, err := buildFileGraphFromAnalysesWithCargoMetadata(
+ context.Background(), root, analyses,
+ func(context.Context, string) ([]byte, error) { return metadata, nil },
+ )
if err != nil {
- t.Fatalf("BuildFileGraph() error: %v", err)
+ t.Fatalf("buildFileGraphFromAnalysesWithCargoMetadata() error: %v", err)
}
want := []string{"but-api/src/lib.rs", "consumer/src/lib.rs"}
@@ -185,8 +199,14 @@ func TestNormalizeImport(t *testing.T) {
func TestBuildFileIndex(t *testing.T) {
files := []FileInfo{
{Path: "main.go"},
+ {Path: "exact.go"},
+ {Path: "exact.go.go"},
{Path: filepath.Join("pkg", "util", "helpers.go")},
+ {Path: filepath.Join("src", "foobar", "config.py")},
+ {Path: filepath.Join("src", "bar", "config.py")},
{Path: filepath.Join("src", "app", "core", "config.py")},
+ {Path: filepath.Join("ba", "café", "config.py")},
+ {Path: filepath.Join("az", "café", "config.py")},
}
idx := buildFileIndex(files, "example.com/project")
@@ -198,14 +218,42 @@ func TestBuildFileIndex(t *testing.T) {
}{
{
name: "exact lookup without extension",
- got: idx.byExact[filepath.Join("pkg", "util", "helpers")],
+ got: tryExactMatch(filepath.Join("pkg", "util", "helpers"), idx, "go"),
want: []string{filepath.Join("pkg", "util", "helpers.go")},
},
+ {
+ name: "explicit extension wins before appended extensions",
+ got: tryExactMatch("exact.go", idx, "go"),
+ want: []string{"exact.go"},
+ },
{
name: "suffix lookup for nested path",
- got: idx.bySuffix[filepath.Join("app", "core", "config.py")],
+ got: idx.suffixMatches(filepath.Join("app", "core", "config.py")),
want: []string{filepath.Join("src", "app", "core", "config.py")},
},
+ {
+ name: "ambiguous Unicode suffix lookup",
+ got: idx.suffixMatches(filepath.Join("café", "config.py")),
+ want: []string{
+ filepath.Join("az", "café", "config.py"),
+ filepath.Join("ba", "café", "config.py"),
+ },
+ },
+ {
+ name: "suffix lookup requires a directory boundary",
+ got: idx.suffixMatches(filepath.Join("bar", "config.py")),
+ want: []string{filepath.Join("src", "bar", "config.py")},
+ },
+ {
+ name: "exact path is not a nested suffix match",
+ got: idx.suffixMatches(filepath.Join("src", "bar", "config.py")),
+ want: nil,
+ },
+ {
+ name: "empty suffix has no matches",
+ got: idx.suffixMatches(""),
+ want: nil,
+ },
{
name: "directory lookup",
got: idx.byDir[filepath.Join("pkg", "util")],
@@ -225,6 +273,11 @@ func TestBuildFileIndex(t *testing.T) {
}
})
}
+
+ duplicate := buildFileIndex([]FileInfo{{Path: "duplicate.go"}, {Path: "duplicate.go"}}, "")
+ if got := tryExactMatch("duplicate", duplicate, "go"); got != nil {
+ t.Fatalf("duplicate exact match = %#v, want ambiguous", got)
+ }
}
func TestResolveRelative(t *testing.T) {
@@ -313,6 +366,43 @@ func TestTrySuffixMatch(t *testing.T) {
}
}
+func TestSuffixMatchesMatchesLinearScan(t *testing.T) {
+ paths := []string{
+ filepath.Join("src", "alpha", "config.go"),
+ filepath.Join("src", "alphabet", "config.go"),
+ filepath.Join("vendor", "alpha", "config.go"),
+ filepath.Join("alpha", "nested", "config.go"),
+ filepath.Join("src", "café", "config.go"),
+ "config.go",
+ }
+ files := make([]FileInfo, len(paths))
+ for i, path := range paths {
+ files[i].Path = path
+ }
+ idx := buildFileIndex(files, "")
+
+ for _, suffix := range []string{
+ "",
+ "config.go",
+ filepath.Join("alpha", "config.go"),
+ filepath.Join("alphabet", "config.go"),
+ filepath.Join("nested", "config.go"),
+ filepath.Join("café", "config.go"),
+ filepath.Join("missing", "config.go"),
+ } {
+ var want []string
+ for _, path := range paths {
+ if suffix != "" && hasPathSuffix(path, suffix) {
+ want = append(want, path)
+ }
+ }
+ sort.Strings(want)
+ if got := idx.suffixMatches(suffix); !reflect.DeepEqual(got, want) {
+ t.Fatalf("suffixMatches(%q) = %#v, want %#v", suffix, got, want)
+ }
+ }
+}
+
func TestFuzzyResolve(t *testing.T) {
files := []FileInfo{
{Path: filepath.Join("pkg", "util", "helpers.go")},
diff --git a/scanner/hotpath_benchmark_test.go b/scanner/hotpath_benchmark_test.go
new file mode 100644
index 0000000..c99d44e
--- /dev/null
+++ b/scanner/hotpath_benchmark_test.go
@@ -0,0 +1,156 @@
+package scanner
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+var (
+ benchmarkScannedFiles []FileInfo
+ benchmarkFileIndex *fileIndex
+ benchmarkGraph *FileGraph
+ benchmarkResolved []string
+)
+
+func BenchmarkScanFiles(b *testing.B) {
+ root, _ := benchmarkScannerTree(b, 5_000)
+ cache := NewGitIgnoreCache(root)
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ var err error
+ benchmarkScannedFiles, err = ScanFiles(context.Background(), root, cache, nil, nil)
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkBuildFileGraphFromAnalyses(b *testing.B) {
+ root, files := benchmarkScannerTree(b, 5_000)
+ analyses := make([]FileAnalysis, 0, len(files))
+ for _, file := range files {
+ analyses = append(analyses, FileAnalysis{
+ Path: file.Path,
+ Language: "go",
+ Imports: []string{"example.com/bench/shared"},
+ })
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ var err error
+ benchmarkGraph, err = BuildFileGraphFromAnalyses(context.Background(), root, analyses, Filters{})
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkBuildFileGraphFromOutcome(b *testing.B) {
+ root, files := benchmarkScannerTree(b, 5_000)
+ analyses := make([]FileAnalysis, 0, len(files))
+ for _, file := range files {
+ analyses = append(analyses, FileAnalysis{
+ Path: file.Path,
+ Language: "go",
+ Imports: []string{"example.com/bench/shared"},
+ })
+ }
+ outcome := ScanOutcome{Analyses: analyses, files: files, hasFileInventory: true}
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ var err error
+ benchmarkGraph, err = BuildFileGraphFromOutcome(context.Background(), root, outcome, Filters{})
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkBuildFileIndex(b *testing.B) {
+ files := benchmarkFileInventory(50_000)
+ benchmarkBuildFileIndex(b, files)
+}
+
+func BenchmarkBuildFileIndexSparseDirectories(b *testing.B) {
+ files := make([]FileInfo, 50_000)
+ for i := range files {
+ files[i] = FileInfo{
+ Path: fmt.Sprintf("pkg/area-%05d/file.go", i),
+ Size: 128,
+ Ext: ".go",
+ }
+ }
+ benchmarkBuildFileIndex(b, files)
+}
+
+func benchmarkBuildFileIndex(b *testing.B, files []FileInfo) {
+ b.Helper()
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ var err error
+ benchmarkFileIndex, err = buildFileIndexContext(context.Background(), files, "example.com/bench")
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkTryExactMatchExplicitPath(b *testing.B) {
+ idx := buildFileIndex(benchmarkFileInventory(50_000), "")
+ path := "pkg/area-199/feature-499/file-49999.go"
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ benchmarkResolved = tryExactMatch(path, idx, "go")
+ }
+}
+
+func benchmarkScannerTree(b *testing.B, count int) (string, []FileInfo) {
+ b.Helper()
+ root := b.TempDir()
+ if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/bench\n"), 0o644); err != nil {
+ b.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(root, "shared"), 0o755); err != nil {
+ b.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "shared", "shared.go"), []byte("package shared\n"), 0o644); err != nil {
+ b.Fatal(err)
+ }
+ files := make([]FileInfo, 0, count)
+ for i := range count {
+ dir := filepath.Join(root, "pkg", fmt.Sprintf("area-%03d", i/100))
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ b.Fatal(err)
+ }
+ path := filepath.Join(dir, fmt.Sprintf("file-%05d.go", i))
+ if err := os.WriteFile(path, []byte("package bench\n"), 0o644); err != nil {
+ b.Fatal(err)
+ }
+ rel, err := filepath.Rel(root, path)
+ if err != nil {
+ b.Fatal(err)
+ }
+ files = append(files, FileInfo{Path: rel, Size: 14, Ext: ".go"})
+ }
+ return root, files
+}
+
+func benchmarkFileInventory(count int) []FileInfo {
+ files := make([]FileInfo, count)
+ for i := range files {
+ files[i] = FileInfo{
+ Path: fmt.Sprintf("pkg/area-%03d/feature-%03d/file-%05d.go", i%200, i%500, i),
+ Size: 128,
+ Ext: ".go",
+ }
+ }
+ return files
+}
diff --git a/scanner/importmodel.go b/scanner/importmodel.go
index 5e2a8a5..93fcc24 100644
--- a/scanner/importmodel.go
+++ b/scanner/importmodel.go
@@ -40,17 +40,44 @@ func ResolvesFileLevelImports(language string) bool {
return !symbolLevel
}
-// symbolLevelInventory counts scanned files per symbol-level language, keyed by
-// the language's display name.
-func symbolLevelInventory(files []FileInfo) map[string]int {
- counts := make(map[string]int)
+type fileLanguageInventory struct {
+ hasRust bool
+ hasCUE bool
+ symbolLevel map[string]int
+}
+
+func inspectFileLanguages(files []FileInfo) fileLanguageInventory {
+ var inventory fileLanguageInventory
for _, file := range files {
- display, symbolLevel := symbolLevelImportLanguages[DetectLanguage(file.Path)]
- if symbolLevel {
- counts[display]++
+ language := fileInfoLanguage(file)
+ switch language {
+ case "rust":
+ inventory.hasRust = true
+ case "cue":
+ inventory.hasCUE = true
+ }
+ if display, ok := symbolLevelImportLanguages[language]; ok {
+ if inventory.symbolLevel == nil {
+ inventory.symbolLevel = make(map[string]int)
+ }
+ inventory.symbolLevel[display]++
}
}
- return counts
+ return inventory
+}
+
+func fileInfoLanguage(file FileInfo) string {
+ ext := file.Ext
+ if ext == "" {
+ return DetectLanguage(file.Path)
+ }
+ return extToLang[strings.ToLower(ext)]
+}
+
+// symbolLevelInventory counts scanned files per symbol-level language, keyed by
+// the language's display name.
+func symbolLevelInventory(files []FileInfo) map[string]int {
+ return inspectFileLanguages(files).symbolLevel
}
// symbolLevelSources renders one source per symbol-level language present, so
@@ -104,7 +131,13 @@ func (c *GraphCoverage) AddSymbolLevelImportCoverage(files []FileInfo) {
if c == nil {
return
}
- counts := symbolLevelInventory(files)
+ c.addSymbolLevelImportCoverage(symbolLevelInventory(files))
+}
+
+func (c *GraphCoverage) addSymbolLevelImportCoverage(counts map[string]int) {
+ if c == nil {
+ return
+ }
sources := symbolLevelSources(counts)
if len(sources) == 0 {
return
diff --git a/scanner/importmodel_test.go b/scanner/importmodel_test.go
index 9e318e8..fc729cd 100644
--- a/scanner/importmodel_test.go
+++ b/scanner/importmodel_test.go
@@ -70,6 +70,22 @@ func TestResolvesFileLevelImports(t *testing.T) {
}
}
+func TestInspectFileLanguagesFindsGraphRequirements(t *testing.T) {
+ inventory := inspectFileLanguages([]FileInfo{
+ {Path: "src/lib.rs", Ext: ".rs"},
+ {Path: "cue/app.cue", Ext: ".cue"},
+ {Path: "src/Main.java", Ext: ".java"},
+ {Path: "src/Other.java", Ext: ".java"},
+ {Path: "main.go", Ext: ".go"},
+ })
+ if !inventory.hasRust || !inventory.hasCUE {
+ t.Fatalf("language requirements = %+v, want Rust and CUE", inventory)
+ }
+ if got := inventory.symbolLevel["Java"]; got != 2 {
+ t.Fatalf("Java file count = %d, want 2", got)
+ }
+}
+
// Coverage that already knows less must never be talked back up.
func TestApplySymbolLevelImportCoverageOnlyRemovesConfidence(t *testing.T) {
swift := []FileInfo{{Path: "App/Model.swift"}}
diff --git a/scanner/jsworkspace.go b/scanner/jsworkspace.go
index 414aeff..f33d3b7 100644
--- a/scanner/jsworkspace.go
+++ b/scanner/jsworkspace.go
@@ -415,10 +415,8 @@ func resolveManifestTarget(root, target string, idx *fileIndex, sourceLanguage s
if !ok {
return nil
}
- for _, candidate := range compatibleFiles(sourceLanguage, idx.byExact[localPath]) {
- if candidate == localPath {
- return []string{candidate}
- }
+ if idx.byExact[localPath] == 1 && languagesCompatible(sourceLanguage, DetectLanguage(localPath)) {
+ return []string{localPath}
}
return nil
}
diff --git a/scanner/jsworkspace_test.go b/scanner/jsworkspace_test.go
index 0d7045e..033352f 100644
--- a/scanner/jsworkspace_test.go
+++ b/scanner/jsworkspace_test.go
@@ -188,10 +188,10 @@ func TestJSWorkspaceResolverUsesExplicitFilters(t *testing.T) {
}
func TestJSWorkspaceResolverOnlyWidensJavaScriptScans(t *testing.T) {
- if needsJSWorkspaceResolver([]FileAnalysis{{Path: "main.go", Language: "go"}}) {
+ if inspectAnalysisLanguages([]FileAnalysis{{Path: "main.go", Language: "go"}}).hasJS {
t.Fatal("Go-only analysis should not widen the filtered file scan")
}
- if !needsJSWorkspaceResolver([]FileAnalysis{{Path: "app.ts", Language: "typescript"}}) {
+ if !inspectAnalysisLanguages([]FileAnalysis{{Path: "app.ts", Language: "typescript"}}).hasJS {
t.Fatal("TypeScript analysis should enable workspace manifest scanning")
}
}
diff --git a/scanner/outcome.go b/scanner/outcome.go
index 9a75657..3332289 100644
--- a/scanner/outcome.go
+++ b/scanner/outcome.go
@@ -32,6 +32,8 @@ type ScanOutcome struct {
Analyses []FileAnalysis `json:"analyses"`
Sources []analysis.Source `json:"sources,omitempty"`
precomputedEdges []fileEdge
+ files []FileInfo
+ hasFileInventory bool
}
// GraphCoverage describes graph blind spots and scanner provenance.
diff --git a/scanner/outcome_test.go b/scanner/outcome_test.go
index 9031dae..29d70b4 100644
--- a/scanner/outcome_test.go
+++ b/scanner/outcome_test.go
@@ -2,13 +2,13 @@ package scanner
import (
"context"
-
- "codemap/analysis"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
+
+ "codemap/analysis"
)
func requireSourceOutcome(t *testing.T, coverage GraphCoverage, source string) ScanSourceOutcome {
@@ -164,6 +164,70 @@ func TestNonCargoGraphHasNoCargoOutcome(t *testing.T) {
}
}
+func TestBuildFileGraphFromOutcomeReusesFileInventory(t *testing.T) {
+ root := t.TempDir()
+ outcome := ScanOutcome{
+ Analyses: []FileAnalysis{{Path: "app/main.py", Language: "python", Imports: []string{"pkg.util"}}},
+ Sources: []ScanSourceOutcome{{Name: "ast-grep", Status: ScanSourceAuthoritative}},
+ files: []FileInfo{
+ {Path: "app/main.py", Ext: ".py"},
+ {Path: filepath.FromSlash("src/pkg/util.py"), Ext: ".py"},
+ },
+ hasFileInventory: true,
+ }
+
+ graph, err := BuildFileGraphFromOutcome(context.Background(), root, outcome, Filters{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := filepath.FromSlash("src/pkg/util.py")
+ if got := graph.Imports["app/main.py"]; !reflect.DeepEqual(got, []string{want}) {
+ t.Fatalf("imports = %v, want [%s]", got, want)
+ }
+}
+
+func TestBuildFileGraphFromOutcomeReusesKnownEmptyInventory(t *testing.T) {
+ root := filepath.Join(t.TempDir(), "missing")
+ graph, err := BuildFileGraphFromOutcome(context.Background(), root, ScanOutcome{
+ Sources: []ScanSourceOutcome{{Name: "ast-grep", Status: ScanSourceAuthoritative}},
+ hasFileInventory: true,
+ }, Filters{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(graph.Imports) != 0 || len(graph.Importers) != 0 {
+ t.Fatalf("known empty inventory produced edges: %+v", graph)
+ }
+}
+
+func TestAppendCUEOutcomeReusesFallbackInventory(t *testing.T) {
+ root := t.TempDir()
+ if err := os.WriteFile(filepath.Join(root, "schema.cue"), []byte("package schema\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ outcome, err := appendCUEOutcome(context.Background(), root, Filters{}, ScanOutcome{
+ files: []FileInfo{{Path: "schema.cue", Ext: ".cue"}},
+ hasFileInventory: true,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(outcome.Analyses) != 1 || outcome.Analyses[0].Path != "schema.cue" || !outcome.hasFileInventory {
+ t.Fatalf("CUE analyses = %#v", outcome.Analyses)
+ }
+}
+
+func TestAppendCUEOutcomeReusesKnownEmptyInventory(t *testing.T) {
+ root := filepath.Join(t.TempDir(), "missing")
+ outcome, err := appendCUEOutcome(context.Background(), root, Filters{}, ScanOutcome{hasFileInventory: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(outcome.Analyses) != 0 || !outcome.hasFileInventory {
+ t.Fatalf("outcome = %#v", outcome)
+ }
+}
+
func TestCoverageFromSourcesMatrix(t *testing.T) {
tests := []struct {
name string
diff --git a/scanner/rustaskama_test.go b/scanner/rustaskama_test.go
index a1e6ad1..eec5950 100644
--- a/scanner/rustaskama_test.go
+++ b/scanner/rustaskama_test.go
@@ -137,9 +137,6 @@ func TestRustAskamaTemplateResolvesWithinCargoPackage(t *testing.T) {
}
func TestRustAskamaTemplateResolvesWithExtensionSibling(t *testing.T) {
- // idx.byExact also indexes files under their extension-stripped key, so
- // "app/templates/template.html.orig" appears under the same
- // "app/templates/template.html" key as the real target.
root := t.TempDir()
writeRustCargoFixture(t, root, map[string]string{
"Cargo.toml": "[package]\nname = \"app\"\nversion = \"0.1.0\"\n",
@@ -168,14 +165,14 @@ func TestRustAskamaTemplateResolvesWithExtensionSibling(t *testing.T) {
}
func TestRustAskamaTemplateRequiresAuthoritativeUnambiguousTarget(t *testing.T) {
- idx := &fileIndex{byExact: map[string][]string{
- filepath.FromSlash("app/templates/page.html"): {"app/templates/page.html", "app/templates/page.html"},
+ idx := &fileIndex{byExact: map[string]uint32{
+ filepath.FromSlash("app/templates/page.html"): 2,
}}
workspace := &rustWorkspaceIndex{packages: []rustPackage{{root: "app", authoritative: true}}}
if got := resolveRustAskamaTemplate(t.TempDir(), "app/src/lib.rs", `"page.html"`, idx, workspace); got != "" {
t.Fatalf("ambiguous target = %q, want unresolved", got)
}
- idx.byExact[filepath.FromSlash("app/templates/page.html")] = []string{"app/templates/page.html"}
+ idx.byExact[filepath.FromSlash("app/templates/page.html")] = 1
workspace.packages[0].authoritative = false
if got := resolveRustAskamaTemplate(t.TempDir(), "app/src/lib.rs", `"page.html"`, idx, workspace); got != "" {
t.Fatalf("fallback-owned target = %q, want unresolved", got)
diff --git a/scanner/rustbuildscript.go b/scanner/rustbuildscript.go
index 03b67ec..3a68569 100644
--- a/scanner/rustbuildscript.go
+++ b/scanner/rustbuildscript.go
@@ -41,15 +41,8 @@ func resolveRustBuildScriptInput(fromFile, input string, idx *fileIndex, workspa
if candidate == fromFile {
return ""
}
- // byExact also indexes files under their extension-stripped key, so
- // accept only when the target itself is indexed exactly once.
- exact := 0
- for _, file := range idx.byExact[candidate] {
- if file == candidate {
- exact++
- }
- }
- if exact != 1 {
+ // Duplicate inventory entries make ownership ambiguous.
+ if idx.byExact[candidate] != 1 {
return ""
}
return candidate
diff --git a/scanner/rustbuildscript_test.go b/scanner/rustbuildscript_test.go
index 2ab93d5..f17e2d1 100644
--- a/scanner/rustbuildscript_test.go
+++ b/scanner/rustbuildscript_test.go
@@ -125,9 +125,6 @@ func TestRustBuildScriptResolvesStaticCargoInputs(t *testing.T) {
}
func TestRustBuildScriptResolvesTargetWithExtensionSibling(t *testing.T) {
- // idx.byExact also indexes files under their extension-stripped key, so
- // "app/data.json.gz" appears under the same "app/data.json" key as the
- // real target. The real directive must still resolve.
root := t.TempDir()
writeRustCargoFixture(t, root, map[string]string{
"Cargo.toml": "[package]\nname = \"app\"\nversion = \"0.1.0\"\nbuild = \"build.rs\"\n",
diff --git a/scanner/rustgraph.go b/scanner/rustgraph.go
index 52561e6..fe422ac 100644
--- a/scanner/rustgraph.go
+++ b/scanner/rustgraph.go
@@ -510,20 +510,13 @@ func resolveRustExplicitModule(root, declaringFile, literal string) string {
}
// resolveRustInclude resolves include!(...) relative to the declaring file.
-// byExact also indexes files under their extension-stripped key, so accept
-// only when the target itself is indexed exactly once.
+// Duplicate inventory entries make ownership ambiguous.
func resolveRustInclude(root, declaringFile, literal string, idx *fileIndex) string {
target := resolveRustExplicitModule(root, declaringFile, literal)
if target == "" {
return ""
}
- exact := 0
- for _, file := range idx.byExact[target] {
- if file == target {
- exact++
- }
- }
- if exact != 1 {
+ if idx.byExact[target] != 1 {
return ""
}
return target
@@ -534,14 +527,7 @@ func resolveRustEmbeddedFile(root, declaringFile, literal string, idx *fileIndex
if target == "" {
return ""
}
- // Extensionless targets can be duplicated by the shared index; require one real path.
- exact := 0
- for _, file := range idx.byExact[target] {
- if file == target {
- exact++
- }
- }
- if exact != 1 {
+ if idx.byExact[target] != 1 {
return ""
}
return target
@@ -667,15 +653,8 @@ func resolveRustAskamaTemplate(root, fromFile, literal string, idx *fileIndex, w
if !pathWithin(target, templateRoot) {
return ""
}
- // byExact also indexes files under their extension-stripped key, so
- // accept only when the target itself is indexed exactly once.
- exact := 0
- for _, file := range idx.byExact[target] {
- if file == target {
- exact++
- }
- }
- if exact != 1 {
+ // Duplicate inventory entries make ownership ambiguous.
+ if idx.byExact[target] != 1 {
return ""
}
return target
@@ -744,8 +723,8 @@ func resolveRustModule(name, fromFile string, idx *fileIndex, workspace *rustWor
filepath.Join(dir, name+".rs"),
filepath.Join(dir, name, "mod.rs"),
} {
- if files := idx.byExact[candidate]; len(files) == 1 {
- return files[0]
+ if idx.byExact[candidate] == 1 {
+ return candidate
}
}
return ""
@@ -912,8 +891,8 @@ func resolveRustPathFromDirectory(base string, parts []string, idx *fileIndex) s
for i := len(parts); i > 0; i-- {
modulePath := filepath.Join(append([]string{base}, parts[:i]...)...)
for _, candidate := range []string{modulePath + ".rs", filepath.Join(modulePath, "mod.rs")} {
- if files := idx.byExact[candidate]; len(files) == 1 {
- return files[0]
+ if idx.byExact[candidate] == 1 {
+ return candidate
}
}
}
@@ -996,8 +975,7 @@ func (index *rustWorkspaceIndex) targetForFile(path string, idx *fileIndex) (rus
}
func rustTargetIndexed(target rustTarget, idx *fileIndex) bool {
- files := idx.byExact[target.rootFile]
- return len(files) == 1 && files[0] == target.rootFile
+ return idx.byExact[target.rootFile] == 1
}
func pathWithin(path, dir string) bool {
@@ -1044,11 +1022,11 @@ func (index *rustWorkspaceIndex) targetContainsFile(target rustTarget, path stri
modulePath := filepath.Join(append([]string{target.sourceDir}, parts[:i+1]...)...)
var next string
for _, candidate := range []string{modulePath + ".rs", filepath.Join(modulePath, "mod.rs")} {
- if files := idx.byExact[candidate]; len(files) == 1 {
+ if idx.byExact[candidate] == 1 {
if next != "" {
return false
}
- next = files[0]
+ next = candidate
}
}
if next == "" {
diff --git a/scanner/types.go b/scanner/types.go
index 42b9190..47b0ddd 100644
--- a/scanner/types.go
+++ b/scanner/types.go
@@ -258,11 +258,15 @@ func PromptExtensions() []string {
return exts
}
-// ResolverExtensions returns extensions used for import path resolution,
-// including index-file patterns for JS/TS/Python ecosystems.
-// Sorted by length descending so longer extensions match first (.tsx before .ts),
-// with empty string last as the final fallback.
+var resolverExtensions = buildResolverExtensions()
+
+// ResolverExtensions returns an isolated copy of the ordered extensions used
+// for import path resolution.
func ResolverExtensions() []string {
+ return slices.Clone(resolverExtensions)
+}
+
+func buildResolverExtensions() []string {
var exts []string
for ext := range extToLang {
exts = append(exts, ext)
diff --git a/scanner/types_test.go b/scanner/types_test.go
index 0a85feb..9164247 100644
--- a/scanner/types_test.go
+++ b/scanner/types_test.go
@@ -15,3 +15,14 @@ func TestNewDepsProjectWithCoverageAndFiltersClonesEffectiveFilters(t *testing.T
t.Fatalf("effective filters were not cloned: %+v", project.EffectiveFilters)
}
}
+
+func TestResolverExtensionsReturnsIsolatedOrder(t *testing.T) {
+ first := ResolverExtensions()
+ if len(first) == 0 || first[len(first)-1] != "" {
+ t.Fatalf("extensions = %#v, want bare-path fallback last", first)
+ }
+ first[0] = "changed"
+ if second := ResolverExtensions(); second[0] == "changed" {
+ t.Fatal("caller mutation changed resolver extension order")
+ }
+}
diff --git a/scanner/walker.go b/scanner/walker.go
index 0b17c0a..b10c7c0 100644
--- a/scanner/walker.go
+++ b/scanner/walker.go
@@ -262,19 +262,16 @@ func ScanFiles(ctx context.Context, root string, cache *GitIgnoreCache, only []s
return nil
}
- // Compute absolute path once for gitignore checks and relative path calculation
- absPath, _ := filepath.Abs(path)
-
// For directories: load any .gitignore, then check if dir itself should be skipped
if info.IsDir() {
if cache != nil {
- cache.tryLoadGitignore(absPath)
- if cache.ShouldIgnore(absPath) {
+ cache.tryLoadGitignore(path)
+ if cache.ShouldIgnore(path) {
return filepath.SkipDir
}
}
// Check if directory matches any exclude pattern
- relPath, _ := filepath.Rel(absRoot, absPath)
+ relPath, _ := filepath.Rel(absRoot, path)
if relPath != "." {
for _, pattern := range exclude {
pattern = strings.TrimSpace(pattern)
@@ -287,11 +284,11 @@ func ScanFiles(ctx context.Context, root string, cache *GitIgnoreCache, only []s
}
// For files: check gitignore
- if cache != nil && cache.ShouldIgnore(absPath) {
+ if cache != nil && cache.ShouldIgnore(path) {
return nil
}
- relPath, _ := filepath.Rel(absRoot, absPath)
+ relPath, _ := filepath.Rel(absRoot, path)
ext := filepath.Ext(path)
// Apply user filters (--only and --exclude)
@@ -329,11 +326,16 @@ func ConfiguredFilters(root string) Filters {
// ScanConfiguredFiles scans using the active setup root's project filters
// while honoring caller cancellation.
func ScanConfiguredFiles(ctx context.Context, root string, cache *GitIgnoreCache) ([]FileInfo, error) {
+ cfg := config.Load(root)
+ return ScanConfiguredFilesWithFilters(ctx, root, cache, Filters{Only: cfg.Only, Exclude: cfg.Exclude})
+}
+
+// ScanConfiguredFilesWithFilters scans files using already resolved filters.
+func ScanConfiguredFilesWithFilters(ctx context.Context, root string, cache *GitIgnoreCache, filters Filters) ([]FileInfo, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
- cfg := config.Load(root)
- files, err := ScanFiles(ctx, root, cache, cfg.Only, cfg.Exclude)
+ files, err := ScanFiles(ctx, root, cache, filters.Only, filters.Exclude)
if err != nil {
return nil, err
}
@@ -382,12 +384,26 @@ func ScanForDeps(ctx context.Context, root string, filters Filters) (ScanOutcome
if err != nil {
return outcome, err
}
- cueOutcome, err := scanCUEFiles(ctx, root, filters)
+ return appendCUEOutcome(ctx, root, filters, outcome)
+}
+
+func appendCUEOutcome(ctx context.Context, root string, filters Filters, outcome ScanOutcome) (ScanOutcome, error) {
+ var cueOutcome ScanOutcome
+ var err error
+ if outcome.hasFileInventory {
+ cueOutcome, err = scanCUEFilesFromFiles(ctx, root, outcome.files)
+ } else {
+ cueOutcome, err = scanCUEFiles(ctx, root, filters)
+ }
if err != nil {
return ScanOutcome{}, err
}
outcome.Analyses = append(outcome.Analyses, cueOutcome.Analyses...)
outcome.Sources = append(outcome.Sources, cueOutcome.Sources...)
+ if cueOutcome.hasFileInventory {
+ outcome.files = cueOutcome.files
+ outcome.hasFileInventory = true
+ }
return outcome, nil
}
diff --git a/scanner/walker_test.go b/scanner/walker_test.go
index e58f319..b45f025 100644
--- a/scanner/walker_test.go
+++ b/scanner/walker_test.go
@@ -825,6 +825,14 @@ func TestScanConfiguredFilesExcludesCodemapState(t *testing.T) {
if !reflect.DeepEqual(paths, want) {
t.Fatalf("configured files = %v, want %v (no .codemap entries)", paths, want)
}
+
+ files, err = ScanConfiguredFilesWithFilters(context.Background(), root, NewGitIgnoreCache(root), Filters{Only: []string{"go"}, Exclude: []string{"pkg"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(files) != 1 || filepath.ToSlash(files[0].Path) != "main.go" {
+ t.Fatalf("explicitly filtered files = %v, want main.go", files)
+ }
}
func TestFilterAnalysesContextBranches(t *testing.T) {
diff --git a/topology/cache.go b/topology/cache.go
index 0435f3b..d42394b 100644
--- a/topology/cache.go
+++ b/topology/cache.go
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"sort"
+ "strconv"
"strings"
"time"
@@ -42,6 +43,10 @@ func CachePathAt(cacheDir string) string {
}
func BuildCacheIdentity(root string, files []scanner.FileInfo, manifests []string, providers []Provider) (CacheIdentity, error) {
+ absRoot, err := filepath.Abs(root)
+ if err != nil {
+ return CacheIdentity{}, err
+ }
cfg := config.Load(root)
filterData, err := json.Marshal(cfg)
if err != nil {
@@ -52,28 +57,67 @@ func BuildCacheIdentity(root string, files []scanner.FileInfo, manifests []strin
sort.Strings(manifestPaths)
manifestHash := sha256.New()
for _, manifest := range manifestPaths {
- rel, err := normalizeRepoPath(root, manifest)
+ rel, err := normalizeRepoPathFromRoot(absRoot, manifest)
if err != nil {
return CacheIdentity{}, fmt.Errorf("manifest %q: %w", manifest, err)
}
- data, err := os.ReadFile(filepath.Join(root, rel))
+ data, err := os.ReadFile(filepath.Join(absRoot, rel))
if err != nil {
return CacheIdentity{}, err
}
writeHashPart(manifestHash, filepath.ToSlash(rel))
- writeHashPart(manifestHash, string(data))
+ writeHashBytes(manifestHash, data)
}
- fileParts := make([]string, 0, len(files))
- for _, file := range files {
- rel, err := normalizeRepoPath(root, file.Path)
+ type fileIdentity struct {
+ path string
+ index int
+ }
+ fileParts := make([]fileIdentity, 0, len(files))
+ for index, file := range files {
+ rel, err := normalizeRepoPathFromRoot(absRoot, file.Path)
if err != nil {
return CacheIdentity{}, fmt.Errorf("configured file %q: %w", file.Path, err)
}
- fileParts = append(fileParts, fmt.Sprintf("%s\x00%d\x00%s\x00%t\x00%d\x00%d",
- filepath.ToSlash(rel), file.Size, file.Ext, file.IsNew, file.Added, file.Removed))
+ fileParts = append(fileParts, fileIdentity{path: filepath.ToSlash(rel), index: index})
+ }
+ sort.Slice(fileParts, func(i, j int) bool {
+ if fileParts[i].path != fileParts[j].path {
+ return fileParts[i].path < fileParts[j].path
+ }
+ left, right := files[fileParts[i].index], files[fileParts[j].index]
+ if left.Size != right.Size {
+ return left.Size < right.Size
+ }
+ if left.Ext != right.Ext {
+ return left.Ext < right.Ext
+ }
+ if left.IsNew != right.IsNew {
+ return !left.IsNew
+ }
+ if left.Added != right.Added {
+ return left.Added < right.Added
+ }
+ return left.Removed < right.Removed
+ })
+ fileHash := sha256.New()
+ record := make([]byte, 0, 128)
+ for _, part := range fileParts {
+ file := files[part.index]
+ record = record[:0]
+ record = append(record, part.path...)
+ record = append(record, 0)
+ record = strconv.AppendInt(record, file.Size, 10)
+ record = append(record, 0)
+ record = append(record, file.Ext...)
+ record = append(record, 0)
+ record = strconv.AppendBool(record, file.IsNew)
+ record = append(record, 0)
+ record = strconv.AppendInt(record, int64(file.Added), 10)
+ record = append(record, 0)
+ record = strconv.AppendInt(record, int64(file.Removed), 10)
+ writeHashBytes(fileHash, record)
}
- sort.Strings(fileParts)
providerParts := make([]string, 0, len(providers))
for _, provider := range sortedProviders(providers) {
@@ -83,7 +127,7 @@ func BuildCacheIdentity(root string, files []scanner.FileInfo, manifests []strin
return CacheIdentity{
Filters: hashStrings(string(filterData)),
Manifests: hex.EncodeToString(manifestHash.Sum(nil)),
- ConfiguredFiles: hashStrings(fileParts...),
+ ConfiguredFiles: hex.EncodeToString(fileHash.Sum(nil)),
ProviderVersions: hashStrings(providerParts...),
}, nil
}
@@ -222,3 +266,8 @@ func writeHashPart(hash interface{ Write([]byte) (int, error) }, part string) {
_, _ = hash.Write([]byte(part))
_, _ = hash.Write([]byte{0})
}
+
+func writeHashBytes(hash interface{ Write([]byte) (int, error) }, part []byte) {
+ _, _ = hash.Write(part)
+ _, _ = hash.Write([]byte{0})
+}
diff --git a/topology/cache_test.go b/topology/cache_test.go
index 2d7821c..6018b9d 100644
--- a/topology/cache_test.go
+++ b/topology/cache_test.go
@@ -156,6 +156,23 @@ func TestCacheIdentityChangesForFiltersManifestsFilesAndProviderVersions(t *test
if err != nil {
t.Fatal(err)
}
+ reordered, err := BuildCacheIdentity(root, []scanner.FileInfo{
+ {Path: "other.go", Size: 42, Ext: ".go", IsNew: true, Added: 3, Removed: 1},
+ {Path: "main.go"},
+ }, []string{"go.mod"}, providers)
+ if err != nil {
+ t.Fatal(err)
+ }
+ reorderedAgain, err := BuildCacheIdentity(root, []scanner.FileInfo{
+ {Path: "main.go"},
+ {Path: "other.go", Size: 42, Ext: ".go", IsNew: true, Added: 3, Removed: 1},
+ }, []string{"go.mod"}, providers)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if reordered.ConfiguredFiles != reorderedAgain.ConfiguredFiles {
+ t.Fatal("configured file identity depends on inventory order")
+ }
writeTopologyFixture(t, root, ".codemap/config.json", `{"only":["go"],"exclude":["vendor"]}`)
filtered, err := BuildCacheIdentity(root, files, []string{"go.mod"}, providers)
diff --git a/topology/graph.go b/topology/graph.go
index 1f165d8..7320f7b 100644
--- a/topology/graph.go
+++ b/topology/graph.go
@@ -303,21 +303,22 @@ func validateNodePath(root string, node Node) error {
}
func normalizeRepoPath(root, path string) (string, error) {
- if path == "" || filepath.IsAbs(path) {
+ absRoot, err := filepath.Abs(root)
+ if err != nil {
+ return "", err
+ }
+ return normalizeRepoPathFromRoot(absRoot, path)
+}
+
+func normalizeRepoPathFromRoot(absRoot, path string) (string, error) {
+ if path == "" || filepath.IsAbs(path) || filepath.VolumeName(path) != "" {
return "", fmt.Errorf("path must be non-empty and repository-relative")
}
clean := filepath.Clean(path)
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes repository")
}
- absRoot, err := filepath.Abs(root)
- if err != nil {
- return "", err
- }
- joined, err := filepath.Abs(filepath.Join(absRoot, clean))
- if err != nil {
- return "", err
- }
+ joined := filepath.Join(absRoot, clean)
rel, err := filepath.Rel(absRoot, joined)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes repository")
diff --git a/topology/graph_test.go b/topology/graph_test.go
index 5d6dc6f..3b4b5e3 100644
--- a/topology/graph_test.go
+++ b/topology/graph_test.go
@@ -106,6 +106,18 @@ func TestMergeFragmentsRejectsEscapingPathsAndUnknownEndpoints(t *testing.T) {
}
}
+func TestNormalizeRepoPathRejectsAbsoluteAndEscapingPaths(t *testing.T) {
+ root := t.TempDir()
+ for _, path := range []string{"", filepath.Join("..", "outside"), filepath.Join(root, "inside")} {
+ if _, err := normalizeRepoPath(root, path); err == nil {
+ t.Fatalf("normalizeRepoPath(%q) succeeded", path)
+ }
+ }
+ if got, err := normalizeRepoPath(root, filepath.Join("nested", "file.go")); err != nil || got != filepath.Join("nested", "file.go") {
+ t.Fatalf("normalizeRepoPath(valid) = %q, %v", got, err)
+ }
+}
+
func TestMergeFragmentsRejectsMissingNodePaths(t *testing.T) {
root := t.TempDir()
missing := testNode("test:missing", "missing")
diff --git a/topology/graph_windows_test.go b/topology/graph_windows_test.go
new file mode 100644
index 0000000..e0c42a2
--- /dev/null
+++ b/topology/graph_windows_test.go
@@ -0,0 +1,11 @@
+//go:build windows
+
+package topology
+
+import "testing"
+
+func TestNormalizeRepoPathRejectsVolumePaths(t *testing.T) {
+ if _, err := normalizeRepoPath(`C:\repo`, `D:file.go`); err == nil {
+ t.Fatal("drive-relative path succeeded")
+ }
+}
diff --git a/topology/provider.go b/topology/provider.go
index 64ecbbb..7ebd190 100644
--- a/topology/provider.go
+++ b/topology/provider.go
@@ -17,6 +17,8 @@ import (
const maxManifestWalkEntries = 100_000
+var errManifestWalkLimit = errors.New("manifest walk limit exceeded")
+
type ManifestSelector struct {
Names []string
}
@@ -109,25 +111,20 @@ func BuildGraphWithProviders(ctx context.Context, root string, providers []Provi
return MergeFragments(root, nil), CacheIdentity{}, nil
}
- cache := scanner.NewGitIgnoreCache(root)
- inventoryFiles, err := scanner.ScanConfiguredFiles(ctx, root, cache)
+ inventoryFiles, manifests, err := discoverInventory(ctx, root, selected, cfg, true)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, CacheIdentity{}, err
}
- return unavailableGraph("inventory-failed", err.Error()), CacheIdentity{}, nil
+ code := "inventory-failed"
+ if errors.Is(err, errManifestWalkLimit) {
+ code = "manifest-discovery-failed"
+ }
+ return unavailableGraph(code, err.Error()), CacheIdentity{}, nil
}
if err := ctx.Err(); err != nil {
return nil, CacheIdentity{}, err
}
- inventoryFiles = filterInventoryFiles(inventoryFiles, selected)
- manifests, err := discoverManifests(ctx, root, selected, cfg)
- if err != nil {
- if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
- return nil, CacheIdentity{}, err
- }
- return unavailableGraph("manifest-discovery-failed", err.Error()), CacheIdentity{}, nil
- }
identity, err := BuildCacheIdentity(root, inventoryFiles, manifests, selected)
if err != nil {
return unavailableGraph("cache-identity-failed", err.Error()), CacheIdentity{}, nil
@@ -181,10 +178,20 @@ func BuildGraphWithProviders(ctx context.Context, root string, providers []Provi
}
func discoverManifests(ctx context.Context, root string, providers []Provider, cfg config.ProjectConfig) ([]string, error) {
+ _, manifests, err := discoverInventory(ctx, root, providers, cfg, false)
+ return manifests, err
+}
+
+func discoverInventory(ctx context.Context, root string, providers []Provider, cfg config.ProjectConfig, includeFiles bool) ([]scanner.FileInfo, []string, error) {
+ return discoverInventoryWithLimit(ctx, root, providers, cfg, includeFiles, maxManifestWalkEntries)
+}
+
+func discoverInventoryWithLimit(ctx context.Context, root string, providers []Provider, cfg config.ProjectConfig, includeFiles bool, maxEntries int) ([]scanner.FileInfo, []string, error) {
if err := ctx.Err(); err != nil {
- return nil, err
+ return nil, nil, err
}
names := make(map[string]bool)
+ languages := make(map[string]bool)
for _, provider := range providers {
for _, name := range provider.Manifests().Names {
name = strings.TrimSpace(name)
@@ -192,32 +199,44 @@ func discoverManifests(ctx context.Context, root string, providers []Provider, c
names[name] = true
}
}
+ if includeFiles {
+ for _, language := range provider.Languages() {
+ language = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(language)), ".")
+ if language != "" {
+ languages[language] = true
+ }
+ }
+ }
}
- if len(names) == 0 {
- return nil, nil
+ if len(names) == 0 && !includeFiles {
+ return nil, nil, nil
}
- absRoot, err := filepath.Abs(root)
- if err != nil {
- return nil, err
- }
+ absRoot := projectpath.CanonicalPath(root)
ignoreCache := scanner.NewGitIgnoreCache(absRoot)
entries := 0
+ var files []scanner.FileInfo
var manifests []string
- err = filepath.WalkDir(absRoot, func(path string, entry fs.DirEntry, walkErr error) error {
+ var sourceIgnoredRoot string
+ err := filepath.WalkDir(absRoot, func(path string, entry fs.DirEntry, walkErr error) error {
if err := ctx.Err(); err != nil {
return err
}
if walkErr != nil {
return walkErr
}
- entries++
- if entries > maxManifestWalkEntries {
- return fmt.Errorf("manifest walk exceeded %d entries", maxManifestWalkEntries)
+ if len(names) > 0 {
+ entries++
+ if entries > maxEntries {
+ return fmt.Errorf("%w: exceeded %d entries", errManifestWalkLimit, maxEntries)
+ }
}
if path == absRoot {
return nil
}
+ if sourceIgnoredRoot != "" && path != sourceIgnoredRoot && !strings.HasPrefix(path, sourceIgnoredRoot+string(filepath.Separator)) {
+ sourceIgnoredRoot = ""
+ }
rel, err := filepath.Rel(absRoot, path)
if err != nil {
return err
@@ -228,20 +247,42 @@ func discoverManifests(ctx context.Context, root string, providers []Provider, c
return filepath.SkipDir
}
ignoreCache.EnsureDir(path)
+ if sourceIgnoredRoot == "" && scanner.IgnoredDirs[entry.Name()] {
+ sourceIgnoredRoot = path
+ }
return nil
}
- if !names[entry.Name()] || ignoreCache.ShouldIgnore(path) ||
- !scanner.MatchesFilters(filepath.ToSlash(rel), filepath.Ext(rel), nil, cfg.Exclude) {
+ ext := filepath.Ext(rel)
+ relSlash := filepath.ToSlash(rel)
+ if ignoreCache.ShouldIgnore(path) || !scanner.MatchesFilters(relSlash, ext, nil, cfg.Exclude) {
return nil
}
- manifests = append(manifests, filepath.Clean(rel))
+ if names[entry.Name()] {
+ manifests = append(manifests, filepath.Clean(rel))
+ }
+ if !includeFiles || sourceIgnoredRoot != "" || scanner.IgnoredDirs[entry.Name()] ||
+ !scanner.MatchesFilters(relSlash, ext, cfg.Only, nil) {
+ return nil
+ }
+ language := strings.ToLower(scanner.DetectLanguage(relSlash))
+ if !languages[language] && !languages[strings.TrimPrefix(strings.ToLower(ext), ".")] {
+ return nil
+ }
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ files = append(files, scanner.FileInfo{Path: filepath.Clean(rel), Size: info.Size(), Ext: ext})
return nil
})
if err != nil {
- return nil, err
+ return nil, nil, err
}
sort.Strings(manifests)
- return manifests, nil
+ if err := ctx.Err(); err != nil {
+ return nil, nil, err
+ }
+ return files, manifests, nil
}
func enabledProviders(providers []Provider, only []string) []Provider {
@@ -268,27 +309,6 @@ func enabledProviders(providers []Provider, only []string) []Provider {
return enabled
}
-func filterInventoryFiles(files []scanner.FileInfo, providers []Provider) []scanner.FileInfo {
- languages := make(map[string]bool)
- for _, provider := range providers {
- for _, language := range provider.Languages() {
- language = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(language)), ".")
- if language != "" {
- languages[language] = true
- }
- }
- }
- filtered := make([]scanner.FileInfo, 0, len(files))
- for _, file := range files {
- language := strings.ToLower(scanner.DetectLanguage(file.Path))
- extension := strings.TrimPrefix(strings.ToLower(file.Ext), ".")
- if languages[language] || languages[extension] {
- filtered = append(filtered, file)
- }
- }
- return filtered
-}
-
func sortedProviders(providers []Provider) []Provider {
result := append([]Provider(nil), providers...)
sort.Slice(result, func(i, j int) bool {
diff --git a/topology/provider_benchmark_test.go b/topology/provider_benchmark_test.go
new file mode 100644
index 0000000..609fc94
--- /dev/null
+++ b/topology/provider_benchmark_test.go
@@ -0,0 +1,90 @@
+package topology
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "codemap/config"
+ "codemap/scanner"
+)
+
+var (
+ benchmarkManifests []string
+ benchmarkIdentity CacheIdentity
+ benchmarkGraph *Graph
+)
+
+func BenchmarkBuildGraphWithProviders(b *testing.B) {
+ root, _, provider := benchmarkTopologyTree(b, 5_000)
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ var err error
+ benchmarkGraph, benchmarkIdentity, err = BuildGraphWithProviders(context.Background(), root, []Provider{provider})
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkDiscoverManifests(b *testing.B) {
+ root, _, provider := benchmarkTopologyTree(b, 5_000)
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ var err error
+ benchmarkManifests, err = discoverManifests(context.Background(), root, []Provider{provider}, config.ProjectConfig{})
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkBuildCacheIdentity(b *testing.B) {
+ root, files, provider := benchmarkTopologyTree(b, 5_000)
+ manifests, err := discoverManifests(context.Background(), root, []Provider{provider}, config.ProjectConfig{})
+ if err != nil {
+ b.Fatal(err)
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ benchmarkIdentity, err = BuildCacheIdentity(root, files, manifests, []Provider{provider})
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func benchmarkTopologyTree(b *testing.B, count int) (string, []scanner.FileInfo, Provider) {
+ b.Helper()
+ root := b.TempDir()
+ files := make([]scanner.FileInfo, 0, count)
+ for i := range count {
+ dir := filepath.Join(root, fmt.Sprintf("module-%03d", i/100))
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ b.Fatal(err)
+ }
+ name := fmt.Sprintf("file-%05d.go", i)
+ if i%100 == 0 {
+ name = "bench.module"
+ }
+ path := filepath.Join(dir, name)
+ if err := os.WriteFile(path, []byte("module benchmark\n"), 0o644); err != nil {
+ b.Fatal(err)
+ }
+ if name == "bench.module" {
+ continue
+ }
+ rel, err := filepath.Rel(root, path)
+ if err != nil {
+ b.Fatal(err)
+ }
+ files = append(files, scanner.FileInfo{Path: rel, Size: 17, Ext: filepath.Ext(rel)})
+ }
+ provider := stubProvider{name: "benchmark", version: "1", languages: []string{"go"}, manifests: []string{"bench.module"}}
+ return root, files, provider
+}
diff --git a/topology/provider_test.go b/topology/provider_test.go
index 0afd260..cfdc6b2 100644
--- a/topology/provider_test.go
+++ b/topology/provider_test.go
@@ -259,6 +259,54 @@ func TestDiscoverManifestsHonorsGitignoreAndExclude(t *testing.T) {
}
}
+func TestDiscoverInventoryFindsManifestsBelowSourceIgnoredDirectories(t *testing.T) {
+ root := t.TempDir()
+ writeTopologyFixture(t, root, "Main.java", "class Main {}\n")
+ writeTopologyFixture(t, root, "main.go", "package main\n")
+ writeTopologyFixture(t, root, "pom.xml", "")
+ writeTopologyFixture(t, root, "vendor/pom.xml", "")
+ writeTopologyFixture(t, root, "vendor/generated.go", "package generated\n")
+ writeTopologyFixture(t, root, "vendor/.gitignore", "ignored/\n")
+ writeTopologyFixture(t, root, "vendor/ignored/pom.xml", "")
+ writeTopologyFixture(t, root, "build/pom.xml", "")
+ writeTopologyFixture(t, root, "build/generated.go", "package generated\n")
+
+ files, manifests, err := discoverInventory(context.Background(), root, []Provider{stubProvider{
+ name: "jvm",
+ version: "1",
+ languages: []string{"java"},
+ manifests: []string{"pom.xml"},
+ }}, config.ProjectConfig{}, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ wantFiles := []string{"Main.java"}
+ gotFiles := make([]string, len(files))
+ for i, file := range files {
+ gotFiles[i] = filepath.ToSlash(file.Path)
+ }
+ if !reflect.DeepEqual(gotFiles, wantFiles) {
+ t.Fatalf("files = %#v, want %#v", gotFiles, wantFiles)
+ }
+ wantManifests := []string{"build/pom.xml", "pom.xml", "vendor/pom.xml"}
+ for i := range manifests {
+ manifests[i] = filepath.ToSlash(manifests[i])
+ }
+ if !reflect.DeepEqual(manifests, wantManifests) {
+ t.Fatalf("manifests = %#v, want %#v", manifests, wantManifests)
+ }
+
+ filteredFiles, filteredManifests, err := discoverInventory(context.Background(), root, []Provider{stubProvider{
+ name: "jvm", version: "1", languages: []string{"java"}, manifests: []string{"pom.xml"},
+ }}, config.ProjectConfig{Only: []string{"java"}}, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(filteredFiles) != 1 || filepath.ToSlash(filteredFiles[0].Path) != "Main.java" || !reflect.DeepEqual(filteredManifests, manifests) {
+ t.Fatalf("filtered inventory = (%#v, %#v), want the Java file and unchanged manifests", filteredFiles, filteredManifests)
+ }
+}
+
func TestDiscoverManifestsStopsOnCancellation(t *testing.T) {
root := t.TempDir()
writeTopologyFixture(t, root, "pom.xml", "")
@@ -276,6 +324,18 @@ func TestDiscoverManifestsStopsOnCancellation(t *testing.T) {
}
}
+func TestDiscoverInventoryReportsManifestWalkLimit(t *testing.T) {
+ root := t.TempDir()
+ writeTopologyFixture(t, root, "pom.xml", "")
+
+ _, _, err := discoverInventoryWithLimit(context.Background(), root, []Provider{stubProvider{
+ name: "jvm", version: "1", languages: []string{"java"}, manifests: []string{"pom.xml"},
+ }}, config.ProjectConfig{}, false, 1)
+ if !errors.Is(err, errManifestWalkLimit) {
+ t.Fatalf("error = %v, want manifest walk limit", err)
+ }
+}
+
func TestRegisteredProvidersAreNameSorted(t *testing.T) {
providerRegistryMu.Lock()
original := append([]Provider(nil), providerRegistry...)
diff --git a/watch/daemon.go b/watch/daemon.go
index 12f83bd..18fcd98 100644
--- a/watch/daemon.go
+++ b/watch/daemon.go
@@ -30,6 +30,7 @@ var (
type Daemon struct {
root string
configDir string
+ configPath string
runtimeDir string
graph *Graph
watcher *fsnotify.Watcher
@@ -73,6 +74,16 @@ func (d *Daemon) runtimeStateDir() (string, error) {
return projectpath.CheckedRuntimeCodemapDir(d.root)
}
+func (d *Daemon) loadConfig() config.ProjectConfig {
+ if d.configPath != "" {
+ return config.LoadFile(d.configPath)
+ }
+ if d.configDir != "" {
+ return config.LoadFile(filepath.Join(d.configDir, "config.json"))
+ }
+ return config.Load(d.root)
+}
+
func (d *Daemon) ensurePublisher() error {
if d.publisher != nil {
return nil
@@ -113,6 +124,7 @@ func NewDaemon(root string, verbose bool) (*Daemon, error) {
d := &Daemon{
root: absRoot,
configDir: selection.PolicyDir,
+ configPath: filepath.Join(selection.PolicyDir, "config.json"),
runtimeDir: runtimeDir,
watcher: watcher,
gitCache: gitCache,
@@ -312,19 +324,24 @@ func (d *Daemon) WriteInitialState() {
// fullScan does a complete scan of the project
func (d *Daemon) fullScan() error {
start := time.Now()
+ cfg := d.loadConfig()
files, err := scanner.ScanFiles(context.Background(), d.root, d.gitCache, nil, nil)
if err != nil {
return err
}
- configuredFiles, err := scanner.ScanConfiguredFiles(context.Background(), d.root, d.gitCache)
- if err != nil {
- return err
+ configuredPaths := make([]string, 0)
+ for i := range files {
+ file := &files[i]
+ path := filepath.ToSlash(file.Path)
+ if path != ".codemap" && !strings.HasPrefix(path, ".codemap/") && scanner.MatchesFilters(file.Path, file.Ext, cfg.Only, cfg.Exclude) {
+ configuredPaths = append(configuredPaths, file.Path)
+ }
}
d.graph.mu.Lock()
d.graph.Files = make(map[string]*scanner.FileInfo)
- d.graph.ConfiguredFiles = make(map[string]struct{}, len(configuredFiles))
+ d.graph.ConfiguredFiles = make(map[string]struct{}, len(configuredPaths))
d.graph.State = make(map[string]*FileState)
for i := range files {
f := &files[i]
@@ -334,8 +351,8 @@ func (d *Daemon) fullScan() error {
d.graph.State[f.Path] = &FileState{Lines: lines, Size: f.Size}
}
}
- for _, file := range configuredFiles {
- d.graph.ConfiguredFiles[file.Path] = struct{}{}
+ for _, path := range configuredPaths {
+ d.graph.ConfiguredFiles[path] = struct{}{}
}
d.graph.LastScan = time.Now()
d.graph.mu.Unlock()
@@ -347,8 +364,7 @@ func (d *Daemon) fullScan() error {
return nil
}
-func (d *Daemon) isConfiguredFile(path string) bool {
- cfg := config.Load(d.root)
+func matchesConfiguredFile(path string, cfg config.ProjectConfig) bool {
return scanner.MatchesFilters(path, filepath.Ext(path), cfg.Only, cfg.Exclude)
}
@@ -360,7 +376,8 @@ func (d *Daemon) refreshConfiguredFiles(resetIgnoreCache bool) error {
gitCache = scanner.NewGitIgnoreCache(d.root)
d.gitCache = gitCache
}
- files, err := scanner.ScanConfiguredFiles(context.Background(), d.root, gitCache)
+ cfg := d.loadConfig()
+ files, err := scanner.ScanConfiguredFilesWithFilters(context.Background(), d.root, gitCache, scanner.Filters{Only: cfg.Only, Exclude: cfg.Exclude})
if err != nil {
return err
}
@@ -372,7 +389,7 @@ func (d *Daemon) refreshConfiguredFiles(resetIgnoreCache bool) error {
d.graph.ConfiguredFiles = configured
// Filters define dependency membership too, so the previous graph must not
// be published under a new configured-file count.
- d.markGraphLifecycleLocked(newGraphState(d.root, config.Load(d.root), graphLifecycleStale, time.Time{}, nil))
+ d.markGraphLifecycleLocked(newGraphState(d.root, cfg, graphLifecycleStale, time.Time{}, nil))
d.graph.mu.Unlock()
// Invalidation alone would leave the daemon serving no hub or importer
@@ -390,6 +407,7 @@ var daemonRefreshDependencies = (*Daemon).refreshDependencies
// refreshDependencies is called by eventLoop and owns the worker state flags.
func (d *Daemon) refreshDependencies() {
+ cfg := d.loadConfig()
d.graph.mu.RLock()
stale := d.graph.GraphState.Status == graphLifecycleStale
configuredCount := len(d.graph.ConfiguredFiles)
@@ -399,7 +417,7 @@ func (d *Daemon) refreshDependencies() {
}
snapshot := dependencyGraphSnapshot{
configured: configured,
- config: config.Load(d.root),
+ config: cfg,
generation: d.graph.graphGeneration,
}
d.graph.mu.RUnlock()
@@ -421,13 +439,13 @@ func (d *Daemon) refreshDependencies() {
// buildDependencyGraph converts a worker panic into the existing failed-build
// path so a background scan cannot terminate the daemon process.
-func buildDependencyGraph(ctx context.Context, root string, build func(context.Context, string, scanner.Filters) (*scanner.FileGraph, error)) (graph *scanner.FileGraph, err error) {
+func buildDependencyGraph(ctx context.Context, root string, filters scanner.Filters, build func(context.Context, string, scanner.Filters) (*scanner.FileGraph, error)) (graph *scanner.FileGraph, err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("dependency graph build panicked: %v", recovered)
}
}()
- return build(ctx, root, scanner.ConfiguredFilters(root))
+ return build(ctx, root, filters)
}
func (d *Daemon) startDependencyWorker() {
@@ -449,7 +467,8 @@ func (d *Daemon) startDependencyWorker() {
return
case snapshot := <-d.dependencyRequests:
started := time.Now()
- graph, err := buildDependencyGraph(ctx, d.root, buildFileGraph)
+ filters := scanner.Filters{Only: snapshot.config.Only, Exclude: snapshot.config.Exclude}
+ graph, err := buildDependencyGraph(ctx, d.root, filters, buildFileGraph)
result := dependencyGraphResult{snapshot: snapshot, graph: graph, err: err, started: started}
select {
case d.dependencyResults <- result:
@@ -469,9 +488,10 @@ func (d *Daemon) handleDependencyGraphResult(result dependencyGraphResult) {
retry := d.dependencyPending
d.dependencyPending = false
if retry {
+ cfg := d.loadConfig()
d.graph.mu.Lock()
if d.graph.GraphState.Status != graphLifecycleStale {
- d.markGraphLifecycleLocked(newGraphState(d.root, config.Load(d.root), graphLifecycleStale, time.Time{}, nil))
+ d.markGraphLifecycleLocked(newGraphState(d.root, cfg, graphLifecycleStale, time.Time{}, nil))
}
d.graph.mu.Unlock()
}
@@ -508,6 +528,7 @@ func (d *Daemon) computeDepsWithBeforePublish(build func(context.Context, string
}
func (d *Daemon) dependencyGraphSnapshot() dependencyGraphSnapshot {
+ cfg := d.loadConfig()
d.graph.mu.RLock()
defer d.graph.mu.RUnlock()
configured := make([]string, 0, len(d.graph.ConfiguredFiles))
@@ -516,7 +537,7 @@ func (d *Daemon) dependencyGraphSnapshot() dependencyGraphSnapshot {
}
return dependencyGraphSnapshot{
configured: configured,
- config: config.Load(d.root),
+ config: cfg,
generation: d.graph.graphGeneration,
}
}
@@ -530,13 +551,13 @@ func (d *Daemon) applyDependencyGraph(snapshot dependencyGraphSnapshot, fg *scan
return
}
+ currentConfig := d.loadConfig()
d.graph.mu.Lock()
defer d.graph.mu.Unlock()
configuredAfter := make([]string, 0, len(d.graph.ConfiguredFiles))
for file := range d.graph.ConfiguredFiles {
configuredAfter = append(configuredAfter, file)
}
- currentConfig := config.Load(d.root)
if d.graph.graphGeneration != snapshot.generation ||
ConfiguredInventoryFingerprint(snapshot.configured) != ConfiguredInventoryFingerprint(configuredAfter) ||
graphFilterFingerprint(snapshot.config) != graphFilterFingerprint(currentConfig) {
@@ -567,7 +588,7 @@ func (d *Daemon) applyDependencyGraph(snapshot dependencyGraphSnapshot, fg *scan
}
func (d *Daemon) markGraphLifecycle(status GraphLifecycle) {
- state := newGraphState(d.root, config.Load(d.root), status, time.Time{}, nil)
+ state := newGraphState(d.root, d.loadConfig(), status, time.Time{}, nil)
d.graph.mu.Lock()
defer d.graph.mu.Unlock()
d.markGraphLifecycleLocked(state)
diff --git a/watch/events.go b/watch/events.go
index d482bad..21c0c4f 100644
--- a/watch/events.go
+++ b/watch/events.go
@@ -11,7 +11,6 @@ import (
"strings"
"time"
- "codemap/config"
"codemap/internal/projectpath"
"codemap/internal/runtimefile"
"codemap/limits"
@@ -333,6 +332,7 @@ func (d *Daemon) filterControlEvent(path string) (resetIgnoreCache, control bool
func (d *Daemon) handleConfiguredMembershipEvent(event fsnotify.Event) bool {
event.Name = projectpath.CanonicalPath(event.Name)
+ cfg := d.loadConfig()
relPath, err := filepath.Rel(projectpath.CanonicalPath(d.root), event.Name)
if err != nil {
return false
@@ -343,7 +343,7 @@ func (d *Daemon) handleConfiguredMembershipEvent(event fsnotify.Event) bool {
present := event.Op&(fsnotify.Remove|fsnotify.Rename) == 0
if present {
info, err := os.Stat(event.Name)
- if err != nil || info.IsDir() || (d.gitCache != nil && d.gitCache.ShouldIgnore(event.Name)) || !d.isConfiguredFile(relPath) {
+ if err != nil || info.IsDir() || (d.gitCache != nil && d.gitCache.ShouldIgnore(event.Name)) || !matchesConfiguredFile(relPath, cfg) {
present = false
}
}
@@ -356,7 +356,7 @@ func (d *Daemon) handleConfiguredMembershipEvent(event fsnotify.Event) bool {
}
changed := present != existed
if changed {
- d.markGraphLifecycleLocked(newGraphState(d.root, config.Load(d.root), graphLifecycleStale, time.Time{}, nil))
+ d.markGraphLifecycleLocked(newGraphState(d.root, cfg, graphLifecycleStale, time.Time{}, nil))
}
d.graph.mu.Unlock()
if changed {
@@ -537,6 +537,7 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) bool {
default:
return false
}
+ cfg := d.loadConfig()
event := Event{
Time: time.Now(),
@@ -562,7 +563,7 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) bool {
delete(d.graph.ConfiguredFiles, relPath)
delete(d.graph.State, relPath)
if wasConfigured {
- state := newGraphState(d.root, config.Load(d.root), graphLifecycleStale, time.Time{}, nil)
+ state := newGraphState(d.root, cfg, graphLifecycleStale, time.Time{}, nil)
d.markGraphLifecycleLocked(state)
}
}
@@ -621,7 +622,7 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) bool {
if d.graph.ConfiguredFiles == nil {
d.graph.ConfiguredFiles = make(map[string]struct{})
}
- if d.isConfiguredFile(relPath) {
+ if matchesConfiguredFile(relPath, cfg) {
d.graph.ConfiguredFiles[relPath] = struct{}{}
isConfigured = true
} else {
@@ -641,7 +642,7 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) bool {
}
graphInvalidated := wasConfigured || isConfigured
if graphInvalidated {
- state := newGraphState(d.root, config.Load(d.root), graphLifecycleStale, time.Time{}, nil)
+ state := newGraphState(d.root, cfg, graphLifecycleStale, time.Time{}, nil)
d.markGraphLifecycleLocked(state)
}
diff --git a/watch/graph_state_test.go b/watch/graph_state_test.go
index b0200b4..235b421 100644
--- a/watch/graph_state_test.go
+++ b/watch/graph_state_test.go
@@ -220,7 +220,7 @@ func TestDependencyRefreshBuildsOffEventLoop(t *testing.T) {
}
func TestDependencyGraphBuildRecoversPanics(t *testing.T) {
- graph, err := buildDependencyGraph(context.Background(), t.TempDir(), func(context.Context, string, scanner.Filters) (*scanner.FileGraph, error) {
+ graph, err := buildDependencyGraph(context.Background(), t.TempDir(), scanner.Filters{}, func(context.Context, string, scanner.Filters) (*scanner.FileGraph, error) {
panic("test panic")
})
if graph != nil || err == nil || err.Error() != "dependency graph build panicked: test panic" {
diff --git a/watch/publication.go b/watch/publication.go
index 6ca8c79..04793d4 100644
--- a/watch/publication.go
+++ b/watch/publication.go
@@ -5,6 +5,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
+ "io"
"os"
"path/filepath"
"slices"
@@ -90,13 +91,12 @@ func (p *statePublisher) snapshot(generation uint64) State {
func (p *statePublisher) publish() error {
next := p.generation + 1
- data, err := json.MarshalIndent(p.snapshot(next), "", " ")
+ err := runtimefile.WriteAtomicWith(p.path, 0o644, func(w io.Writer) error {
+ encoder := json.NewEncoder(w)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(p.snapshot(next))
+ })
if err != nil {
- p.dirty = true
- p.deadline = time.Now().Add(publicationRetryDelay)
- return err
- }
- if err = runtimefile.WriteAtomic(p.path, data, 0o644); err != nil {
p.dirty = true
ackErr := p.failPending("publication_failed")
p.deadline = time.Now().Add(publicationRetryDelay)
diff --git a/watch/publication_benchmark_test.go b/watch/publication_benchmark_test.go
new file mode 100644
index 0000000..bd66b25
--- /dev/null
+++ b/watch/publication_benchmark_test.go
@@ -0,0 +1,67 @@
+package watch
+
+import (
+ "fmt"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "codemap/config"
+ "codemap/scanner"
+)
+
+var benchmarkPublishedState State
+
+func BenchmarkStatePublisherSnapshot(b *testing.B) {
+ publisher := benchmarkStatePublisher(b, 5_000)
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := range b.N {
+ benchmarkPublishedState = publisher.snapshot(uint64(i + 1))
+ }
+}
+
+func BenchmarkStatePublisherPublish(b *testing.B) {
+ publisher := benchmarkStatePublisher(b, 5_000)
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ publisher.markDirty(time.Now())
+ if err := publisher.publish(); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func benchmarkStatePublisher(b *testing.B, count int) *statePublisher {
+ b.Helper()
+ root := b.TempDir()
+ files := make(map[string]*scanner.FileInfo, count)
+ configured := make(map[string]struct{}, count)
+ imports := make(map[string][]string, count)
+ importers := make(map[string][]string, count)
+ for i := range count {
+ path := fmt.Sprintf("pkg/area-%03d/file-%05d.go", i%100, i)
+ files[path] = &scanner.FileInfo{Path: path, Size: 128, Ext: ".go"}
+ configured[path] = struct{}{}
+ if i > 0 {
+ previous := fmt.Sprintf("pkg/area-%03d/file-%05d.go", (i-1)%100, i-1)
+ imports[path] = []string{previous}
+ importers[previous] = append(importers[previous], path)
+ }
+ }
+ daemon := &Daemon{
+ root: root,
+ graph: &Graph{
+ Root: root,
+ Files: files,
+ ConfiguredFiles: configured,
+ FileGraph: &scanner.FileGraph{Root: root, Imports: imports, Importers: importers},
+ State: make(map[string]*FileState),
+ WorkingSet: NewWorkingSet(),
+ HasDeps: true,
+ GraphState: newGraphState(root, config.ProjectConfig{}, graphLifecycleAvailable, time.Now(), nil),
+ },
+ }
+ return newStatePublisher(daemon, filepath.Join(root, "state.json"), "benchmark-instance")
+}
diff --git a/watch/state_test.go b/watch/state_test.go
index 992b5d6..14d82f9 100644
--- a/watch/state_test.go
+++ b/watch/state_test.go
@@ -13,6 +13,8 @@ import (
"codemap/internal/projectpath"
"codemap/scanner"
+
+ "github.com/fsnotify/fsnotify"
)
func TestHelperWatchDaemonProcess(t *testing.T) {
@@ -434,6 +436,9 @@ func TestAutomaticLinkedWorktreeUsesLocalWatchStorage(t *testing.T) {
if err := os.MkdirAll(filepath.Join(primary, ".codemap"), 0o755); err != nil {
t.Fatal(err)
}
+ if err := os.WriteFile(filepath.Join(primary, ".codemap", "config.json"), []byte(`{"only":["go"]}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
if err := os.WriteFile(filepath.Join(gitDir, "commondir"), []byte("../..\n"), 0o644); err != nil {
t.Fatal(err)
}
@@ -471,6 +476,17 @@ func TestAutomaticLinkedWorktreeUsesLocalWatchStorage(t *testing.T) {
if _, err := os.Stat(filepath.Join(projectpath.ProjectRuntimeDir(primary), "state.json")); !os.IsNotExist(err) {
t.Fatalf("primary state unexpectedly created: %v", err)
}
+
+ if err := os.WriteFile(filepath.Join(linked, ".git"), []byte("invalid\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ textFile := filepath.Join(linked, "notes.txt")
+ if err := os.WriteFile(textFile, []byte("not configured\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if changed := d.handleConfiguredMembershipEvent(fsnotify.Event{Name: textFile, Op: fsnotify.Create}); changed {
+ t.Fatal("linked worktree stopped using its resolved primary policy")
+ }
}
func TestProcessAliveDetectsLiveAndDeadPIDs(t *testing.T) {