From ba1a8521db55b4304e83612e49aa0ffff6eea814 Mon Sep 17 00:00:00 2001 From: David Ahmann Date: Wed, 18 Feb 2026 07:11:25 -0500 Subject: [PATCH 1/3] parser: report full import cycle chain (#16544) --- pkg/parser/import_processor.go | 74 +++++++++++++++++++++++++-- pkg/parser/import_topological_test.go | 46 +++++++++++++++++ 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/pkg/parser/import_processor.go b/pkg/parser/import_processor.go index 6bb72569962..409e446d9ae 100644 --- a/pkg/parser/import_processor.go +++ b/pkg/parser/import_processor.go @@ -771,7 +771,10 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a log.Printf("Completed BFS traversal. Processed %d imports in total", len(processedOrder)) // Sort imports in topological order (roots first, dependencies before dependents) - topologicalOrder := topologicalSortImports(processedOrder, baseDir, cache) + topologicalOrder, err := topologicalSortImports(processedOrder, baseDir, cache) + if err != nil { + return nil, err + } log.Printf("Sorted imports in topological order: %v", topologicalOrder) return &ImportsResult{ @@ -809,7 +812,7 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a // topologicalSortImports sorts imports in topological order using Kahn's algorithm // Returns imports sorted such that roots (files with no imports) come first, // and each import has all its dependencies listed before it -func topologicalSortImports(imports []string, baseDir string, cache *ImportCache) []string { +func topologicalSortImports(imports []string, baseDir string, cache *ImportCache) ([]string, error) { importLog.Printf("Starting topological sort of %d imports", len(imports)) // Build dependency graph: map each import to its list of nested imports @@ -932,7 +935,72 @@ func topologicalSortImports(imports []string, baseDir string, cache *ImportCache } importLog.Printf("Topological sort complete: %v", result) - return result + if len(result) != len(imports) { + if cycle := detectImportCycleChain(dependencies, allImportsSet); len(cycle) > 0 { + return nil, fmt.Errorf("import cycle detected: %s", strings.Join(cycle, " -> ")) + } + return nil, fmt.Errorf("import cycle detected") + } + return result, nil +} + +// detectImportCycleChain returns a deterministic cycle chain where the last +// node points back to the first back-edge target. Example: +// A -> B -> C -> D -> B +func detectImportCycleChain(dependencies map[string][]string, allImportsSet map[string]bool) []string { + visited := make(map[string]bool) + inStack := make(map[string]bool) + path := []string{} + pathIndex := make(map[string]int) + + nodes := make([]string, 0, len(dependencies)) + for node := range dependencies { + nodes = append(nodes, node) + } + sort.Strings(nodes) + + var visit func(string) []string + visit = func(node string) []string { + visited[node] = true + inStack[node] = true + pathIndex[node] = len(path) + path = append(path, node) + + deps := append([]string{}, dependencies[node]...) + sort.Strings(deps) + for _, dep := range deps { + if !allImportsSet[dep] { + continue + } + if !visited[dep] { + if cycle := visit(dep); len(cycle) > 0 { + return cycle + } + continue + } + if inStack[dep] { + start := pathIndex[dep] + cycle := append([]string{}, path[start:]...) + cycle = append(cycle, dep) + return cycle + } + } + + delete(inStack, node) + delete(pathIndex, node) + path = path[:len(path)-1] + return nil + } + + for _, node := range nodes { + if visited[node] { + continue + } + if cycle := visit(node); len(cycle) > 0 { + return cycle + } + } + return nil } // extractImportPaths extracts just the import paths from frontmatter diff --git a/pkg/parser/import_topological_test.go b/pkg/parser/import_topological_test.go index c38c0b9fb2b..a23b5656466 100644 --- a/pkg/parser/import_topological_test.go +++ b/pkg/parser/import_topological_test.go @@ -417,3 +417,49 @@ tools: assert.Equal(t, "m-root.md", result.ImportedFiles[1]) assert.Equal(t, "z-root.md", result.ImportedFiles[2]) } + +func TestImportTopologicalSortReportsFullCycleChain(t *testing.T) { + tempDir := testutil.TempDir(t, "import-topo-cycle-*") + + files := map[string]string{ + "a.md": `--- +imports: + - b.md +tools: + tool-a: {} +---`, + "b.md": `--- +imports: + - c.md +tools: + tool-b: {} +---`, + "c.md": `--- +imports: + - d.md +tools: + tool-c: {} +---`, + "d.md": `--- +imports: + - b.md +tools: + tool-d: {} +---`, + } + + for filename, content := range files { + filePath := filepath.Join(tempDir, filename) + err := os.WriteFile(filePath, []byte(content), 0644) + require.NoError(t, err) + } + + frontmatter := map[string]any{ + "imports": []string{"a.md"}, + } + + _, err := parser.ProcessImportsFromFrontmatterWithManifest(frontmatter, tempDir, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "import cycle detected") + assert.Contains(t, err.Error(), "b.md -> c.md -> d.md -> b.md") +} From 3b1ec30775fe40e6babcfbd633828edfd22b0482 Mon Sep 17 00:00:00 2001 From: David Ahmann Date: Wed, 18 Feb 2026 07:20:58 -0500 Subject: [PATCH 2/3] build: pin tool installs in make tools --- Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 890a1aa702d..151eec6af2e 100644 --- a/Makefile +++ b/Makefile @@ -377,7 +377,10 @@ check-node-version: .PHONY: tools tools: ## Install build-time tools from tools.go @echo "Installing build tools..." - @grep _ tools.go | awk -F'"' '{print $$2}' | xargs -tI % go install % + @go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.11 + @go install github.com/securego/gosec/v2/cmd/gosec@v2.23.0 + @go install golang.org/x/tools/gopls@v0.21.1 + @go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 @echo "✓ Tools installed successfully" # Install golangci-lint binary (avoiding GPL dependencies in go.mod) From dc9dba3d5bb507db6f1c318ceccfb23d90311df7 Mon Sep 17 00:00:00 2001 From: David Ahmann Date: Wed, 18 Feb 2026 07:21:16 -0500 Subject: [PATCH 3/3] Revert "build: pin tool installs in make tools" This reverts commit 3b1ec30775fe40e6babcfbd633828edfd22b0482. --- Makefile | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 151eec6af2e..890a1aa702d 100644 --- a/Makefile +++ b/Makefile @@ -377,10 +377,7 @@ check-node-version: .PHONY: tools tools: ## Install build-time tools from tools.go @echo "Installing build tools..." - @go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.11 - @go install github.com/securego/gosec/v2/cmd/gosec@v2.23.0 - @go install golang.org/x/tools/gopls@v0.21.1 - @go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 + @grep _ tools.go | awk -F'"' '{print $$2}' | xargs -tI % go install % @echo "✓ Tools installed successfully" # Install golangci-lint binary (avoiding GPL dependencies in go.mod)