From 78647f1b6bad7501308771f4508c5c3d56712792 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Fri, 10 Jul 2026 17:18:00 +0200 Subject: [PATCH 1/5] Add more fragmentation tests. --- fragmentation/fragmentation_test.go | 348 ++++++++++++++++++ fragmentation/internal_test.go | 133 +++++++ .../org/example/EmptyLaterPartitions.java | 7 + 3 files changed, 488 insertions(+) create mode 100644 fragmentation/internal_test.go create mode 100644 test/resources/code/java/org/example/EmptyLaterPartitions.java diff --git a/fragmentation/fragmentation_test.go b/fragmentation/fragmentation_test.go index 6820c935..a1bb295e 100644 --- a/fragmentation/fragmentation_test.go +++ b/fragmentation/fragmentation_test.go @@ -30,9 +30,11 @@ import ( "embed-code/embed-code-go/configuration" "embed-code/embed-code-go/fragmentation" _type "embed-code/embed-code-go/type" + "errors" "fmt" "os" "path/filepath" + "strings" "testing" . "github.com/onsi/ginkgo/v2" @@ -46,6 +48,7 @@ const ( complexFragmentsFileName = "Complex.java" twoFragmentsFileName = "TwoFragments.java" overlappingFragmentsFileName = "OverlappingFragments.java" + emptyLaterPartitionsFileName = "EmptyLaterPartitions.java" emptyFileName = "Empty.java" indent = " " ) @@ -154,6 +157,23 @@ var _ = Describe("Fragmentation", func() { ContainSubstring("Example.java"), ContainSubstring("uses unsupported encoding; expected UTF-8"), ))) + Expect(errors.Unwrap(err)).Should(MatchError("unsupported source encoding: expected UTF-8")) + }) + + It("should report unsupported source encoding while resolving code file references", func() { + sourceRoot := GinkgoT().TempDir() + fileName := "Example.java" + Expect(os.WriteFile(filepath.Join(sourceRoot, fileName), []byte{0xff}, 0600)). + To(Succeed()) + config.CodeRoots = _type.NamedPathList{_type.NamedPath{Path: sourceRoot}} + + reference, err := resolver.ResolveCodeFileReference(fileName, config) + + Expect(reference).Should(BeEmpty()) + Expect(err).Should(MatchError(And( + ContainSubstring("Example.java"), + ContainSubstring("uses unsupported encoding; expected UTF-8"), + ))) }) It("should isolate cached source content between resolvers", func() { @@ -213,6 +233,143 @@ var _ = Describe("Fragmentation", func() { Expect(reloadedB).Should(Equal([]string{"class B { String version = \"second\"; }"})) }) + It("should resolve content and references from a named code root", func() { + sourceRoot := GinkgoT().TempDir() + otherRoot := GinkgoT().TempDir() + config.CodeRoots = _type.NamedPathList{ + _type.NamedPath{Name: "library", Path: sourceRoot}, + _type.NamedPath{Name: "other", Path: otherRoot}, + } + writeSourceFile(sourceRoot, "Example.java", "class LibraryExample {}") + writeSourceFile(otherRoot, "Example.java", "class OtherExample {}") + + content, err := resolver.ResolveContent( + "$library/Example.java", + fragmentation.DefaultFragmentName, + config, + ) + Expect(err).ShouldNot(HaveOccurred()) + reference, err := resolver.ResolveCodeFileReference("$library/Example.java", config) + Expect(err).ShouldNot(HaveOccurred()) + missingReference, err := resolver.ResolveCodeFileReference("$library/Missing.java", config) + Expect(err).ShouldNot(HaveOccurred()) + + Expect(content).Should(Equal([]string{"class LibraryExample {}"})) + Expect(reference).Should(And( + HavePrefix("file://"), + ContainSubstring("Example.java"), + )) + Expect(missingReference).Should(And( + ContainSubstring("$library/Missing.java"), + ContainSubstring("file://"), + ContainSubstring("Missing.java"), + )) + }) + + It("should report unresolved source references", func() { + sourceRoot := GinkgoT().TempDir() + config.CodeRoots = _type.NamedPathList{_type.NamedPath{Path: sourceRoot}} + + defaultContent, defaultErr := resolver.ResolveContent( + "Missing.java", + fragmentation.DefaultFragmentName, + config, + ) + fragmentContent, fragmentErr := resolver.ResolveContent("Missing.java", "custom", config) + + Expect(defaultContent).Should(BeNil()) + Expect(defaultErr).Should(MatchError(And( + ContainSubstring("code file"), + ContainSubstring("file://"), + ContainSubstring("Missing.java"), + ContainSubstring("not found"), + ))) + Expect(fragmentContent).Should(BeNil()) + Expect(fragmentErr).Should(MatchError(And( + ContainSubstring("fragment `custom`"), + ContainSubstring("file://"), + ContainSubstring("Missing.java"), + ContainSubstring("not found"), + ))) + }) + + It("should report unresolved source references across multiple unnamed roots", func() { + config.CodeRoots = _type.NamedPathList{ + _type.NamedPath{Path: GinkgoT().TempDir()}, + _type.NamedPath{Path: GinkgoT().TempDir()}, + } + + content, err := resolver.ResolveContent( + "Missing.java", + fragmentation.DefaultFragmentName, + config, + ) + + Expect(content).Should(BeNil()) + Expect(err).Should(MatchError("code file `Missing.java` not found")) + }) + + It("should report directories where source files are expected", func() { + sourceRoot := GinkgoT().TempDir() + config.CodeRoots = _type.NamedPathList{_type.NamedPath{Path: sourceRoot}} + + content, err := resolver.ResolveContent( + ".", + fragmentation.DefaultFragmentName, + config, + ) + + Expect(content).Should(BeNil()) + Expect(err).Should(MatchError(And( + ContainSubstring(sourceRoot), + ContainSubstring("is a directory, the file was expected"), + ))) + }) + + It("should report missing named code roots", func() { + config.CodeRoots = _type.NamedPathList{ + _type.NamedPath{Name: "library", Path: GinkgoT().TempDir()}, + } + + reference, err := resolver.ResolveCodeFileReference("$missing/Example.java", config) + + Expect(reference).Should(BeEmpty()) + Expect(err).Should(MatchError( + "code root with name `missing` not found for path `$missing/Example.java`", + )) + }) + + It("should report missing fragments from resolved source files", func() { + sourceRoot := GinkgoT().TempDir() + config.CodeRoots = _type.NamedPathList{_type.NamedPath{Path: sourceRoot}} + writeSourceFile(sourceRoot, "Example.java", "class Example {}") + + content, err := resolver.ResolveContent("Example.java", "missing", config) + + Expect(content).Should(BeNil()) + Expect(err).Should(MatchError(And( + ContainSubstring("fragment `missing`"), + ContainSubstring("file://"), + ContainSubstring("Example.java"), + ContainSubstring("not found"), + ))) + }) + + It("should resolve empty source files as empty content", func() { + sourceRoot := GinkgoT().TempDir() + config.CodeRoots = _type.NamedPathList{_type.NamedPath{Path: sourceRoot}} + writeSourceFile(sourceRoot, "Empty.java", "") + + content, err := resolver.ResolveContent( + "Empty.java", + "", + config, + ) + + Expect(err).ShouldNot(HaveOccurred()) + Expect(content).Should(BeEmpty()) + }) + It("should reject cache limits below one", func() { resolver, err := fragmentation.NewResolver(0) @@ -220,6 +377,48 @@ var _ = Describe("Fragmentation", func() { Expect(err).Should(HaveOccurred()) }) + It("should report source read failures during fragmentation", func() { + frag, err := fragmentation.NewFragmentation( + filepath.Join(GinkgoT().TempDir(), "Missing.java"), + ) + Expect(err).ShouldNot(HaveOccurred()) + + lines, fragments, err := frag.DoFragmentation() + + Expect(lines).Should(BeNil()) + Expect(fragments).Should(BeNil()) + Expect(err).Should(HaveOccurred()) + }) + + It("should report unsupported source encoding during fragmentation", func() { + sourceRoot := GinkgoT().TempDir() + sourcePath := filepath.Join(sourceRoot, "Invalid.java") + Expect(os.WriteFile(sourcePath, []byte{0xff}, 0600)).To(Succeed()) + frag, err := fragmentation.NewFragmentation(sourcePath) + Expect(err).ShouldNot(HaveOccurred()) + + lines, fragments, err := frag.DoFragmentation() + + Expect(lines).Should(BeNil()) + Expect(fragments).Should(BeNil()) + Expect(err).Should(MatchError("unsupported source encoding: expected UTF-8")) + }) + + It("should report scanner failures during fragmentation", func() { + sourceRoot := GinkgoT().TempDir() + sourcePath := filepath.Join(sourceRoot, "LongLine.java") + Expect(os.WriteFile(sourcePath, []byte(strings.Repeat("x", 64*1024+1)), 0600)). + To(Succeed()) + frag, err := fragmentation.NewFragmentation(sourcePath) + Expect(err).ShouldNot(HaveOccurred()) + + lines, fragments, err := frag.DoFragmentation() + + Expect(lines).Should(BeNil()) + Expect(fragments).Should(BeNil()) + Expect(err).Should(MatchError("bufio.Scanner: token too long")) + }) + It("should fail on an unopened fragment", func() { frag := buildTestFragmentation(unopenedFragmentFileName, config) @@ -228,6 +427,126 @@ var _ = Describe("Fragmentation", func() { Expect(err).Should(HaveOccurred()) }) + It("should report malformed fragment markers with source line context", func() { + sourceRoot := GinkgoT().TempDir() + sourcePath := filepath.Join(sourceRoot, "Malformed.java") + Expect(os.WriteFile(sourcePath, []byte("// #docfragment"), 0600)).To(Succeed()) + frag, err := fragmentation.NewFragmentation(sourcePath) + Expect(err).ShouldNot(HaveOccurred()) + + lines, fragments, err := frag.DoFragmentation() + + Expect(lines).Should(BeNil()) + Expect(fragments).Should(BeNil()) + Expect(err).Should(MatchError(And( + ContainSubstring("failed to do fragmentation"), + ContainSubstring("file://"), + ContainSubstring("Malformed.java:1"), + ContainSubstring("without any name"), + ))) + }) + + It("should report malformed end fragment markers with source line context", func() { + sourceRoot := GinkgoT().TempDir() + sourcePath := filepath.Join(sourceRoot, "MalformedEnd.java") + Expect(os.WriteFile(sourcePath, []byte("// #enddocfragment"), 0600)).To(Succeed()) + frag, err := fragmentation.NewFragmentation(sourcePath) + Expect(err).ShouldNot(HaveOccurred()) + + lines, fragments, err := frag.DoFragmentation() + + Expect(lines).Should(BeNil()) + Expect(fragments).Should(BeNil()) + Expect(err).Should(MatchError(And( + ContainSubstring("failed to do fragmentation"), + ContainSubstring("file://"), + ContainSubstring("MalformedEnd.java:1"), + ContainSubstring("without any name"), + ))) + }) + + It("should reject a repeated fragment start before an end marker", func() { + sourceRoot := GinkgoT().TempDir() + sourcePath := filepath.Join(sourceRoot, "RepeatedStart.java") + Expect(os.WriteFile(sourcePath, []byte(`// #docfragment "main" +// #docfragment "main"`), 0600)).To(Succeed()) + frag, err := fragmentation.NewFragmentation(sourcePath) + Expect(err).ShouldNot(HaveOccurred()) + + lines, fragments, err := frag.DoFragmentation() + + Expect(lines).Should(BeNil()) + Expect(fragments).Should(BeNil()) + Expect(err).Should(MatchError(And( + ContainSubstring("RepeatedStart.java:2"), + ContainSubstring("last added partition has no end position"), + ))) + }) + + It("should reject a repeated fragment end marker", func() { + sourceRoot := GinkgoT().TempDir() + sourcePath := filepath.Join(sourceRoot, "RepeatedEnd.java") + Expect(os.WriteFile(sourcePath, []byte(`// #docfragment "main" +line +// #enddocfragment "main" +// #enddocfragment "main"`), 0600)).To(Succeed()) + frag, err := fragmentation.NewFragmentation(sourcePath) + Expect(err).ShouldNot(HaveOccurred()) + + lines, fragments, err := frag.DoFragmentation() + + Expect(lines).Should(BeNil()) + Expect(fragments).Should(BeNil()) + Expect(err).Should(MatchError(And( + ContainSubstring("RepeatedEnd.java:4"), + ContainSubstring("unexpected #enddocfragment statement"), + ))) + }) + + Describe("FragmentBuilder", func() { + It("should reject an end position before a start position", func() { + builder := fragmentation.FragmentBuilder{ + CodeFilePath: "Example.java", + Name: "main", + } + + err := builder.AddEndPosition(0) + + Expect(err).Should(MatchError("the list of partitions is empty")) + }) + + It("should reject a new start while the latest partition is still open", func() { + builder := fragmentation.FragmentBuilder{ + CodeFilePath: "Example.java", + Name: "main", + } + + Expect(builder.AddStartPosition(0)).To(Succeed()) + err := builder.AddStartPosition(1) + + Expect(err).Should(MatchError(And( + ContainSubstring("fragment \"main\""), + ContainSubstring("last added partition has no end position"), + ))) + }) + + It("should reject a duplicate end position", func() { + builder := fragmentation.FragmentBuilder{ + CodeFilePath: "Example.java", + Name: "main", + } + + Expect(builder.AddStartPosition(0)).To(Succeed()) + Expect(builder.AddEndPosition(0)).To(Succeed()) + err := builder.AddEndPosition(1) + + Expect(err).Should(MatchError(And( + ContainSubstring("unexpected #enddocfragment statement"), + ContainSubstring("Example.java:0"), + ))) + }) + }) + Describe("Partition.Select", func() { It("should reject a start position before source lines", func() { partition := fragmentation.Partition{ @@ -325,6 +644,35 @@ var _ = Describe("Fragmentation", func() { endings, _ := fragmentation.FindDocFragments(endDocFragment) Expect(endings).Should(BeEmpty()) }) + + It("should report a fragment marker without a name", func() { + openings, err := fragmentation.FindDocFragments("// #docfragment") + + Expect(openings).Should(BeEmpty()) + Expect(err).Should(MatchError( + "found `#docfragment` pefix without any name", + )) + }) + + It("should report an unquoted fragment name", func() { + openings, err := fragmentation.FindDocFragments("// #docfragment main") + + Expect(openings).Should(BeEmpty()) + Expect(err).Should(MatchError(And( + ContainSubstring("failed to unquote name `main`"), + ContainSubstring("invalid syntax"), + ))) + }) + }) + + It("should render empty later partitions with an unindented separator", func() { + content := resolveTestFragment(resolver, emptyLaterPartitionsFileName, "piece", config) + + Expect(content).Should(Equal([]string{ + "call();", + config.Separator, + "", + })) }) It("should correctly parse file into many partitions", func() { diff --git a/fragmentation/internal_test.go b/fragmentation/internal_test.go new file mode 100644 index 00000000..41f0fee2 --- /dev/null +++ b/fragmentation/internal_test.go @@ -0,0 +1,133 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package fragmentation + +import ( + "embed-code/embed-code-go/configuration" + _type "embed-code/embed-code-go/type" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Fragmentation internals", func() { + Describe("cache", func() { + It("should replace an already stored key without adding a duplicate entry", func() { + cache := newCache[int, string](2, func(_ int) (string, error) { + return "loaded", nil + }) + + cache.storeLoaded(1, "first") + cache.storeLoaded(1, "second") + + Expect(cache.values).Should(HaveKeyWithValue(1, "second")) + Expect(cache.entries).Should(HaveLen(1)) + Expect(cache.order.Len()).Should(Equal(1)) + }) + + It("should ignore eviction when the usage order is empty", func() { + cache := newCache[int, string](1, func(_ int) (string, error) { + return "loaded", nil + }) + + cache.evictOldest() + + Expect(cache.values).Should(BeEmpty()) + Expect(cache.entries).Should(BeEmpty()) + Expect(cache.order.Len()).Should(Equal(0)) + }) + + It("should discard an invalid usage-order entry", func() { + cache := newCache[int, string](1, func(_ int) (string, error) { + return "loaded", nil + }) + cache.order.PushBack("not an int key") + + cache.evictOldest() + + Expect(cache.values).Should(BeEmpty()) + Expect(cache.entries).Should(BeEmpty()) + Expect(cache.order.Len()).Should(Equal(0)) + }) + }) + + It("should propagate partition selection errors while rendering fragments", func() { + fragment := Fragment{ + Name: "broken", + Partitions: []Partition{ + {StartPosition: 1, EndPosition: 1}, + }, + } + + text, err := fragment.text([]string{"only line"}, "...") + + Expect(text).Should(BeEmpty()) + Expect(err).Should(MatchError( + "fragment partition start position 1 is outside source lines", + )) + }) + + It("should propagate fragment rendering errors through fragment line selection", func() { + fragment := Fragment{ + Name: "broken", + Partitions: []Partition{ + {StartPosition: 1, EndPosition: 1}, + }, + } + + lines, err := fragmentLines(fragment, []string{"only line"}, "...") + + Expect(lines).Should(BeNil()) + Expect(err).Should(MatchError( + "fragment partition start position 1 is outside source lines", + )) + }) + + It("should propagate reference errors from unresolved source errors", func() { + config := configuration.Configuration{ + CodeRoots: _type.NamedPathList{ + _type.NamedPath{Name: "library", Path: "/tmp"}, + }, + } + + err := unresolvedSourceError("$missing/Example.java", DefaultFragmentName, config) + + Expect(err).Should(MatchError( + "code root with name `missing` not found for path `$missing/Example.java`", + )) + }) + + It("should describe missing default fragments as source load failures", func() { + message := missingFragmentLogMessage(DefaultFragmentName, "/tmp/Source.java") + + Expect(message).Should(And( + ContainSubstring("Could not load source file"), + ContainSubstring("file://"), + ContainSubstring("Source.java"), + )) + }) +}) diff --git a/test/resources/code/java/org/example/EmptyLaterPartitions.java b/test/resources/code/java/org/example/EmptyLaterPartitions.java new file mode 100644 index 00000000..88888f81 --- /dev/null +++ b/test/resources/code/java/org/example/EmptyLaterPartitions.java @@ -0,0 +1,7 @@ +class EmptyLaterPartitions { + // #docfragment "piece" + call(); + // #enddocfragment "piece" + // #docfragment "piece" + // #enddocfragment "piece" +} From 1124b1e809b9de6d8fa591a14208074ee90be581 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Sun, 12 Jul 2026 12:46:28 +0200 Subject: [PATCH 2/5] Add negative tests. --- fragmentation/fragmentation.go | 10 +++- fragmentation/internal_test.go | 93 ++++++++++++++++++++++++++++++++++ fragmentation/resolver.go | 2 +- 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/fragmentation/fragmentation.go b/fragmentation/fragmentation.go index f8350eaa..07c327c2 100644 --- a/fragmentation/fragmentation.go +++ b/fragmentation/fragmentation.go @@ -54,6 +54,14 @@ import ( // NamedPathPrefix is the prefix before a named code source. const NamedPathPrefix = "$" +// makeAbsolutePath resolves paths to absolute paths. +// +// It points to filepath.Abs in production. +// Tests replace it to exercise error propagation from absolute +// path resolution, which filepath.Abs does not fail reliably +// across supported environments. +var makeAbsolutePath = filepath.Abs + // Fragmentation splits the given file into fragments. type Fragmentation struct { // codeFile is the absolute path of the source file being fragmented. @@ -72,7 +80,7 @@ type Fragmentation struct { // Fragmentation - source file fragmentation context. // error - when codeFile cannot be made absolute. func NewFragmentation(codeFile string) (Fragmentation, error) { - absoluteCodeFile, err := filepath.Abs(codeFile) + absoluteCodeFile, err := makeAbsolutePath(codeFile) if err != nil { return Fragmentation{}, err } diff --git a/fragmentation/internal_test.go b/fragmentation/internal_test.go index 41f0fee2..5af66201 100644 --- a/fragmentation/internal_test.go +++ b/fragmentation/internal_test.go @@ -29,6 +29,9 @@ package fragmentation import ( "embed-code/embed-code-go/configuration" _type "embed-code/embed-code-go/type" + "errors" + "os" + "path/filepath" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -130,4 +133,94 @@ var _ = Describe("Fragmentation internals", func() { ContainSubstring("Source.java"), )) }) + + Describe("resolver error propagation", func() { + It("should propagate cached source reload errors while resolving content", func() { + sourceRoot := GinkgoT().TempDir() + sourcePath := filepath.Join(sourceRoot, "Example.java") + Expect(os.WriteFile(sourcePath, []byte("class Example {}"), 0600)).To(Succeed()) + loads := 0 + resolver := Resolver{ + cache: newCache[absolutePath, fragmentedFile](0, + func(_ absolutePath) (fragmentedFile, error) { + loads++ + if loads > 1 { + return fragmentedFile{}, errors.New("source reload failed") + } + + return fragmentedFile{ + lines: []string{"class Example {}"}, + fragments: map[string]Fragment{ + DefaultFragmentName: CreateDefaultFragment(), + }, + }, nil + }, + ), + } + config := configuration.Configuration{ + CodeRoots: _type.NamedPathList{ + _type.NamedPath{Path: sourceRoot}, + }, + } + + content, err := resolver.ResolveContent("Example.java", DefaultFragmentName, config) + + Expect(content).Should(BeNil()) + Expect(err).Should(MatchError("source reload failed")) + Expect(loads).Should(Equal(2)) + }) + + It("should propagate absolute path errors while resolving a source in a root", func() { + resolver, err := NewResolver(DefaultResolverCacheLimit) + Expect(err).ShouldNot(HaveOccurred()) + + withAbsolutePathError(func() { + source, found, err := resolver.resolveSourceInRoot( + _type.NamedPath{Path: "relative-root"}, + "Example.java", + ) + + Expect(source).Should(BeEmpty()) + Expect(found).Should(BeFalse()) + Expect(err).Should(MatchError("absolute path failed")) + }) + }) + + It("should propagate absolute path errors while loading source fragments", func() { + withAbsolutePathError(func() { + content, err := loadSourceFragments("Example.java") + + Expect(content).Should(Equal(fragmentedFile{})) + Expect(err).Should(MatchError("absolute path failed")) + }) + }) + + It("should propagate absolute path errors while building code file references", func() { + config := configuration.Configuration{ + CodeRoots: _type.NamedPathList{ + _type.NamedPath{Path: "relative-root"}, + }, + } + + withAbsolutePathError(func() { + reference, err := codeFileReference("Example.java", config) + + Expect(reference).Should(BeEmpty()) + Expect(err).Should(MatchError("absolute path failed")) + }) + }) + }) }) + +// withAbsolutePathError replaces absolute path resolution with a deterministic failure. +func withAbsolutePathError(action func()) { + originalMakeAbsolutePath := makeAbsolutePath + makeAbsolutePath = func(_ string) (string, error) { + return "", errors.New("absolute path failed") + } + defer func() { + makeAbsolutePath = originalMakeAbsolutePath + }() + + action() +} diff --git a/fragmentation/resolver.go b/fragmentation/resolver.go index 11914fbf..d47fe069 100644 --- a/fragmentation/resolver.go +++ b/fragmentation/resolver.go @@ -273,7 +273,7 @@ func splitNamedPath(codePath string) (string, string, bool) { // sourceFromRoot builds an absolute source path from a code root and a relative path. func sourceFromRoot(root _type.NamedPath, relativePath string) (absolutePath, error) { - rootAbs, err := filepath.Abs(root.Path) + rootAbs, err := makeAbsolutePath(root.Path) if err != nil { return "", err } From 92a02fe3d328744e58136e6d40f9045aed3550e9 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Sun, 12 Jul 2026 13:03:48 +0200 Subject: [PATCH 3/5] Improve `files` tests. --- files/files.go | 9 +- files/files_private_test.go | 83 +++++++++++++++++++ files/files_test.go | 24 ++++++ ..._test.go => fragmentation_private_test.go} | 1 + 4 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 files/files_private_test.go rename fragmentation/{internal_test.go => fragmentation_private_test.go} (98%) diff --git a/files/files.go b/files/files.go index ef29b383..553dd2c0 100644 --- a/files/files.go +++ b/files/files.go @@ -41,6 +41,13 @@ const ( WritePermission uint32 = 0600 ) +// statPath inspects filesystem paths. +// +// It points to os.Stat in production. Tests replace it to exercise error +// handling after glob resolution, which otherwise depends on filesystem races +// between finding a path and inspecting it. +var statPath = os.Stat + // IsFileExist reports whether the given path exists as a file. // // Parameters: @@ -103,7 +110,7 @@ func validatePathExists(path string) (bool, os.FileInfo, error) { } firstMatch := matches[0] - info, err := os.Stat(firstMatch) + info, err := statPath(firstMatch) if err != nil { if os.IsNotExist(err) { diff --git a/files/files_private_test.go b/files/files_private_test.go new file mode 100644 index 00000000..01330274 --- /dev/null +++ b/files/files_private_test.go @@ -0,0 +1,83 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +//nolint:testpackage // Covers package-private filesystem hooks used for deterministic errors. +package files + +import ( + "errors" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Files internals", func() { + It("should treat a path removed after glob resolution as missing", func() { + filePath := writeTemporaryFile() + + withStatError(os.ErrNotExist, func() { + exists, err := IsFileExist(filePath) + + Expect(err).ShouldNot(HaveOccurred()) + Expect(exists).Should(BeFalse()) + }) + }) + + It("should report stat errors after glob resolution", func() { + filePath := writeTemporaryFile() + statErr := errors.New("stat failed") + + withStatError(statErr, func() { + exists, err := IsFileExist(filePath) + + Expect(exists).Should(BeFalse()) + Expect(err).Should(MatchError(statErr)) + }) + }) +}) + +// withStatError replaces path inspection with a deterministic error. +func withStatError(statErr error, action func()) { + originalStatPath := statPath + statPath = func(_ string) (os.FileInfo, error) { + return nil, statErr + } + defer func() { + statPath = originalStatPath + }() + + action() +} + +// writeTemporaryFile creates an existing path for glob resolution. +func writeTemporaryFile() string { + filePath := filepath.Join(GinkgoT().TempDir(), "file.txt") + Expect(os.WriteFile(filePath, []byte("content"), os.FileMode(WritePermission))).To(Succeed()) + + return filePath +} diff --git a/files/files_test.go b/files/files_test.go index 84797680..76d46b88 100644 --- a/files/files_test.go +++ b/files/files_test.go @@ -68,6 +68,13 @@ var _ = Describe("Files actions", func() { Expect(files.IsDirExist(filePath)).Should(BeFalse()) }) + It("should return error as the referenced path has an invalid glob pattern", func() { + exists, err := files.IsDirExist("[") + + Expect(exists).Should(BeFalse()) + Expect(err).Should(HaveOccurred()) + }) + It("should return error as the referenced path is a file", func() { currentDir, _ := os.Getwd() path := filepath.Dir(currentDir) + "/test/resources/config_files/correct_config.yml" @@ -103,6 +110,23 @@ var _ = Describe("Files actions", func() { Expect(files.IsFileExist(filePath)).Should(BeFalse()) }) + It("should return error as the referenced file has an invalid glob pattern", func() { + exists, err := files.IsFileExist("[") + + Expect(exists).Should(BeFalse()) + Expect(err).Should(HaveOccurred()) + }) + + It("should return false as the referenced file is a broken symlink", func() { + linkPath := filepath.Join(GinkgoT().TempDir(), "broken-link") + Expect(os.Symlink("missing-target", linkPath)).To(Succeed()) + + exists, err := files.IsFileExist(linkPath) + + Expect(err).ShouldNot(HaveOccurred()) + Expect(exists).Should(BeFalse()) + }) + It("should return false as the referenced path point to directory", func() { currentDir, _ := os.Getwd() diff --git a/fragmentation/internal_test.go b/fragmentation/fragmentation_private_test.go similarity index 98% rename from fragmentation/internal_test.go rename to fragmentation/fragmentation_private_test.go index 5af66201..2af4f183 100644 --- a/fragmentation/internal_test.go +++ b/fragmentation/fragmentation_private_test.go @@ -24,6 +24,7 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +//nolint:testpackage // Covers package-private cache and resolver branches. package fragmentation import ( From d91bef5df2c436a3030832bc2f7a3422e04239ab Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Mon, 13 Jul 2026 10:48:10 +0200 Subject: [PATCH 4/5] Fix typo. --- fragmentation/fragmentation_test.go | 2 +- fragmentation/lookup.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fragmentation/fragmentation_test.go b/fragmentation/fragmentation_test.go index a1bb295e..20113ab5 100644 --- a/fragmentation/fragmentation_test.go +++ b/fragmentation/fragmentation_test.go @@ -650,7 +650,7 @@ line Expect(openings).Should(BeEmpty()) Expect(err).Should(MatchError( - "found `#docfragment` pefix without any name", + "found `#docfragment` prefix without any name", )) }) diff --git a/fragmentation/lookup.go b/fragmentation/lookup.go index c1268868..ae8b7616 100644 --- a/fragmentation/lookup.go +++ b/fragmentation/lookup.go @@ -92,7 +92,7 @@ func lookup(line string, prefix string) ([]string, error) { fragmentsStart := strings.Index(line, prefix) + len(prefix) + 1 if len(line) < fragmentsStart { return unquotedNames, fmt.Errorf( - "found `%s` pefix without any name", prefix, + "found `%s` prefix without any name", prefix, ) } for _, fragmentName := range strings.Split(line[fragmentsStart:], ",") { From 5e62e7e86fb7a9af130ba0affa003ed38d1fccd8 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Mon, 13 Jul 2026 12:30:59 +0200 Subject: [PATCH 5/5] Simplify cache logic. --- fragmentation/cache.go | 51 ++++++++------------- fragmentation/fragmentation_private_test.go | 27 +---------- 2 files changed, 20 insertions(+), 58 deletions(-) diff --git a/fragmentation/cache.go b/fragmentation/cache.go index b2ae72d4..45e2aada 100644 --- a/fragmentation/cache.go +++ b/fragmentation/cache.go @@ -26,10 +26,7 @@ package fragmentation -import ( - "container/list" - "sync" -) +import "sync" // cache is a limited collection of recently used values by key. type cache[K comparable, V any] struct { @@ -45,21 +42,16 @@ type cache[K comparable, V any] struct { // values contains cached values indexed by key. values map[K]V - // entries maps keys to their positions in the usage order. - entries map[K]*list.Element - // order tracks keys from least to most recently used. - order *list.List + order []K } // newCache creates a cache with a loader and least-recently-used eviction. func newCache[K comparable, V any](limit int, loader func(K) (V, error)) *cache[K, V] { return &cache[K, V]{ - limit: limit, - loader: loader, - values: make(map[K]V), - entries: make(map[K]*list.Element), - order: list.New(), + limit: limit, + loader: loader, + values: make(map[K]V), } } @@ -91,14 +83,15 @@ func (c *cache[K, V]) get(key K) (V, error) { // storeLoaded stores a loaded value and evicts the least recently used value when needed. func (c *cache[K, V]) storeLoaded(key K, value V) { - c.values[key] = value - if entry, found := c.entries[key]; found { - c.order.MoveToBack(entry) + if _, found := c.values[key]; found { + c.values[key] = value + c.markUsed(key) return } - c.entries[key] = c.order.PushBack(key) + c.values[key] = value + c.order = append(c.order, key) if len(c.values) <= c.limit { return } @@ -108,25 +101,19 @@ func (c *cache[K, V]) storeLoaded(key K, value V) { // markUsed moves a cache key to the most recently used position. func (c *cache[K, V]) markUsed(key K) { - if entry, found := c.entries[key]; found { - c.order.MoveToBack(entry) + for index, existingKey := range c.order { + if existingKey == key { + c.order = append(c.order[:index], c.order[index+1:]...) + c.order = append(c.order, key) + + return + } } } // evictOldest removes the least recently used cache entry. func (c *cache[K, V]) evictOldest() { - oldestEntry := c.order.Front() - if oldestEntry == nil { - return - } - - oldestKey, isKey := oldestEntry.Value.(K) - if !isKey { - c.order.Remove(oldestEntry) - - return - } - c.order.Remove(oldestEntry) - delete(c.entries, oldestKey) + oldestKey := c.order[0] + c.order = c.order[1:] delete(c.values, oldestKey) } diff --git a/fragmentation/fragmentation_private_test.go b/fragmentation/fragmentation_private_test.go index 2af4f183..95e3f1ea 100644 --- a/fragmentation/fragmentation_private_test.go +++ b/fragmentation/fragmentation_private_test.go @@ -49,34 +49,9 @@ var _ = Describe("Fragmentation internals", func() { cache.storeLoaded(1, "second") Expect(cache.values).Should(HaveKeyWithValue(1, "second")) - Expect(cache.entries).Should(HaveLen(1)) - Expect(cache.order.Len()).Should(Equal(1)) + Expect(cache.order).Should(Equal([]int{1})) }) - It("should ignore eviction when the usage order is empty", func() { - cache := newCache[int, string](1, func(_ int) (string, error) { - return "loaded", nil - }) - - cache.evictOldest() - - Expect(cache.values).Should(BeEmpty()) - Expect(cache.entries).Should(BeEmpty()) - Expect(cache.order.Len()).Should(Equal(0)) - }) - - It("should discard an invalid usage-order entry", func() { - cache := newCache[int, string](1, func(_ int) (string, error) { - return "loaded", nil - }) - cache.order.PushBack("not an int key") - - cache.evictOldest() - - Expect(cache.values).Should(BeEmpty()) - Expect(cache.entries).Should(BeEmpty()) - Expect(cache.order.Len()).Should(Equal(0)) - }) }) It("should propagate partition selection errors while rendering fragments", func() {