From bffd17d4f6a68e5c6542009389b2a581d739381e Mon Sep 17 00:00:00 2001 From: Ian Zink Date: Thu, 16 Jul 2026 16:20:56 +1000 Subject: [PATCH 1/3] perf: switch file discovery to single-walk traversal Replace the two-pass file discovery (GetAllFiles followed by GetFilteredFiles) with GetFilteredFilesSingleWalk, which traverses the project tree once and prunes ignored directories (e.g. node_modules, .git) instead of walking the whole tree and glob-matching every file underneath them. That traversal was the dominant cost on real projects. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/commands/code_workflow/native_workflow.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/internal/commands/code_workflow/native_workflow.go b/internal/commands/code_workflow/native_workflow.go index 82c0b22..e3e1581 100644 --- a/internal/commands/code_workflow/native_workflow.go +++ b/internal/commands/code_workflow/native_workflow.go @@ -322,13 +322,10 @@ func determineAnalyzeInput(path string, config configuration.Configuration, logg // Return a channel that notifies each file in the path that doesn't match the filter rules func getFilesForPath(path string, logger *zerolog.Logger, max_threads int) (<-chan string, error) { filter := utils.NewFileFilter(path, logger, utils.WithThreadNumber(max_threads)) - rules, err := filter.GetRules([]string{".gitignore", ".dcignore", ".snyk"}) - if err != nil { - return nil, err - } - - results := filter.GetFilteredFiles(filter.GetAllFiles(), rules) - return results, nil + // Single-walk variant: traverses the tree once and prunes ignored directories + // (e.g. node_modules, .git) instead of walking twice and glob-matching every + // file underneath them. That traversal was the dominant cost on real projects. + return filter.GetFilteredFilesSingleWalk([]string{".gitignore", ".dcignore", ".snyk"}), nil } // Create new Workflow data out of the given object and content type From a5bb6fd84f2bd94aa0db634e991d7da6a90c433a Mon Sep 17 00:00:00 2001 From: Ian Zink Date: Thu, 16 Jul 2026 16:21:06 +1000 Subject: [PATCH 2/3] perf: parallelize bundle creation and deduplicate content hashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Process files with a pool of workers when creating a bundle. The per-file work (stat, read, hash) is I/O-bound and independent across files, so oversubscribing the CPUs keeps the disk busy while syscalls block — this matters most on Windows, where per-file syscalls are comparatively expensive. Results are merged under a mutex, and the lazy filter load is guarded so concurrent workers fetch filters exactly once. Each file is opened a single time and stat'd via the open handle to avoid a redundant filesystem lookup. Also deduplicate the UTF-8 conversion in BundleFileFrom: the content is now converted once and reused for both the hash and the bundle content, via the new util.HashContent helper, instead of converting twice. Add a benchmark covering bundle creation. Co-Authored-By: Claude Opus 4.8 (1M context) --- bundle/bundle_manager.go | 225 ++++++++++++++++++++-------- bundle/bundle_manager_bench_test.go | 219 +++++++++++++++++++++++++++ go.mod | 2 +- internal/deepcode/helpers.go | 18 ++- internal/util/hash.go | 10 +- 5 files changed, 402 insertions(+), 72 deletions(-) create mode 100644 bundle/bundle_manager_bench_test.go diff --git a/bundle/bundle_manager.go b/bundle/bundle_manager.go index 3a18b9e..42acc2e 100644 --- a/bundle/bundle_manager.go +++ b/bundle/bundle_manager.go @@ -17,9 +17,13 @@ package bundle import ( + "bytes" "context" "os" "path/filepath" + "runtime" + "sync" + "sync/atomic" "github.com/puzpuzpuz/xsync" "github.com/rs/zerolog" @@ -40,6 +44,23 @@ type bundleManager struct { trackerFactory scan.TrackerFactory supportedExtensions *xsync.MapOf[string, bool] supportedConfigFiles *xsync.MapOf[string, bool] + filtersMu sync.Mutex +} + +// createWorkerCount returns the number of goroutines used to process files when +// creating a bundle. The work is dominated by per-file I/O (stat + read) and +// hashing, which are independent across files, so we oversubscribe the CPUs to +// keep the disk busy while syscalls block — this matters most on Windows, where +// per-file syscalls are comparatively expensive. +func createWorkerCount() int { + n := runtime.GOMAXPROCS(0) * 4 + if n < 4 { + n = 4 + } + if n > 32 { + n = 32 + } + return n } type BundleManager interface { @@ -119,57 +140,74 @@ func (b *bundleManager) create( var limitToFiles []string fileHashes := make(map[string]string) bundleFiles := make(map[string]deepcode.BundleFile) - noFiles := true - for absoluteFilePath := range filePaths { - noFiles = false - if ctx.Err() != nil { - return bundle, err // The cancellation error should be handled by the calling function - } - var supported bool - supported, err = b.IsSupported(span.Context(), absoluteFilePath) - if err != nil { - return bundle, err - } - if !supported { - continue - } - - fileInfo, fileErr := os.Stat(absoluteFilePath) - if fileErr != nil { - b.logger.Error().Err(err).Str("filePath", absoluteFilePath).Msg("Failed to read file info") - continue - } - - if fileInfo.Size() == 0 || fileInfo.Size() > maxFileSize { - continue - } - - fileContent, fileErr := os.ReadFile(absoluteFilePath) - if fileErr != nil { - b.logger.Error().Err(err).Str("filePath", absoluteFilePath).Msg("Failed to load content of file") - continue - } - relativePath, fileErr := util.ToRelativeUnixPath(rootPath, absoluteFilePath) - if fileErr != nil { - b.errorReporter.CaptureError(err, observability.ErrorReporterOptions{ErrorDiagnosticPath: rootPath}) - } - relativePath = util.EncodePath(relativePath) - - bundleFile, fileErr := deepcode.BundleFileFrom(fileContent, includeFileContents) - if fileErr != nil { - b.logger.Error().Err(err).Str("filePath", absoluteFilePath).Msg("Error creating bundle file") + // Files are processed by a pool of workers because the per-file work (stat, + // read, hash) is I/O-bound and independent across files. Results are merged + // under a mutex; the merge is cheap relative to the I/O, so contention is + // negligible. The first hard error (e.g. fetching filters) aborts the run. + processCtx, cancel := context.WithCancel(ctx) + defer cancel() + + var ( + mu sync.Mutex + firstErr error + sawFile atomic.Bool + wg sync.WaitGroup + ) + setErr := func(e error) { + mu.Lock() + if firstErr == nil { + firstErr = e } - bundleFiles[relativePath] = bundleFile - fileHashes[relativePath] = bundleFile.Hash - b.logger.Trace().Str("method", "BundleFileFrom").Str("hash", bundleFile.Hash).Str("filePath", absoluteFilePath).Msg("") + mu.Unlock() + cancel() + } - if changedFiles[absoluteFilePath] { - limitToFiles = append(limitToFiles, relativePath) - } + for i := 0; i < createWorkerCount(); i++ { + wg.Add(1) + go func() { + defer wg.Done() + for absoluteFilePath := range filePaths { + sawFile.Store(true) + if processCtx.Err() != nil { + return + } + + supported, supErr := b.IsSupported(processCtx, absoluteFilePath) + if supErr != nil { + setErr(supErr) + return + } + if !supported { + continue + } + + bundleFile, relativePath, ok := b.bundleFileFromPath(rootPath, absoluteFilePath, includeFileContents) + if !ok { + continue + } + + mu.Lock() + bundleFiles[relativePath] = bundleFile + fileHashes[relativePath] = bundleFile.Hash + if changedFiles[absoluteFilePath] { + limitToFiles = append(limitToFiles, relativePath) + } + mu.Unlock() + } + }() } + wg.Wait() - if noFiles { + // Preserve the original behavior: a canceled context returns without an + // error so the caller can decide how to handle the cancellation. + if ctx.Err() != nil { + return bundle, nil + } + if firstErr != nil { + return bundle, firstErr + } + if !sawFile.Load() { return bundle, NoFilesError{} } @@ -193,6 +231,52 @@ func (b *bundleManager) create( return bundle, err } +// bundleFileFromPath reads a single file and builds its BundleFile and encoded +// relative path. It returns ok=false for files that should be skipped (unreadable, +// empty, or larger than maxFileSize). The file is opened once and stat'd via the +// open handle to avoid a second filesystem lookup, which is noticeably cheaper on +// Windows than separate os.Stat + os.ReadFile calls. +func (b *bundleManager) bundleFileFromPath(rootPath, absoluteFilePath string, includeFileContents bool) (deepcode.BundleFile, string, bool) { + f, openErr := os.Open(absoluteFilePath) + if openErr != nil { + b.logger.Error().Err(openErr).Str("filePath", absoluteFilePath).Msg("Failed to open file") + return deepcode.BundleFile{}, "", false + } + defer func() { _ = f.Close() }() + + fileInfo, statErr := f.Stat() + if statErr != nil { + b.logger.Error().Err(statErr).Str("filePath", absoluteFilePath).Msg("Failed to read file info") + return deepcode.BundleFile{}, "", false + } + + size := fileInfo.Size() + if size == 0 || size > maxFileSize { + return deepcode.BundleFile{}, "", false + } + + buf := bytes.NewBuffer(make([]byte, 0, size)) + if _, readErr := buf.ReadFrom(f); readErr != nil { + b.logger.Error().Err(readErr).Str("filePath", absoluteFilePath).Msg("Failed to load content of file") + return deepcode.BundleFile{}, "", false + } + fileContent := buf.Bytes() + + relativePath, relErr := util.ToRelativeUnixPath(rootPath, absoluteFilePath) + if relErr != nil { + b.errorReporter.CaptureError(relErr, observability.ErrorReporterOptions{ErrorDiagnosticPath: rootPath}) + } + relativePath = util.EncodePath(relativePath) + + bundleFile, bundleErr := deepcode.BundleFileFrom(fileContent, includeFileContents) + if bundleErr != nil { + b.logger.Error().Err(bundleErr).Str("filePath", absoluteFilePath).Msg("Error creating bundle file") + } + b.logger.Trace().Str("method", "BundleFileFrom").Str("hash", bundleFile.Hash).Str("filePath", absoluteFilePath).Msg("") + + return bundleFile, relativePath, true +} + func (b *bundleManager) Upload( ctx context.Context, requestId string, @@ -292,24 +376,13 @@ func (b *bundleManager) groupInBatches( } func (b *bundleManager) IsSupported(ctx context.Context, file string) (bool, error) { + // Guard the lazy filter load so concurrent callers (the file worker pool) + // fetch filters exactly once. The lock is only contended on the first calls; + // once the maps are populated the fast path below skips it entirely. if b.supportedExtensions.Size() == 0 && b.supportedConfigFiles.Size() == 0 { - filters, err := b.deepcodeClient.GetFilters(ctx) - if err != nil { - b.logger.Error().Err(err).Msg("could not get filters") + if err := b.loadFilters(ctx); err != nil { return false, err } - - for _, ext := range filters.Extensions { - b.supportedExtensions.Store(ext, true) - } - for _, configFile := range filters.ConfigFiles { - // .gitignore and .dcignore should not be uploaded - // (https://github.com/snyk/code-client/blob/d6f6a2ce4c14cb4b05aa03fb9f03533d8cf6ca4a/src/files.ts#L138) - if configFile == ".gitignore" || configFile == ".dcignore" { - continue - } - b.supportedConfigFiles.Store(configFile, true) - } } fileExtension := filepath.Ext(file) @@ -319,3 +392,33 @@ func (b *bundleManager) IsSupported(ctx context.Context, file string) (bool, err return isSupportedExtension || isSupportedConfigFile, nil } + +func (b *bundleManager) loadFilters(ctx context.Context) error { + b.filtersMu.Lock() + defer b.filtersMu.Unlock() + + // Re-check under the lock: another goroutine may have populated the filters + // while we were waiting, in which case there is nothing left to do. + if b.supportedExtensions.Size() != 0 || b.supportedConfigFiles.Size() != 0 { + return nil + } + + filters, err := b.deepcodeClient.GetFilters(ctx) + if err != nil { + b.logger.Error().Err(err).Msg("could not get filters") + return err + } + + for _, ext := range filters.Extensions { + b.supportedExtensions.Store(ext, true) + } + for _, configFile := range filters.ConfigFiles { + // .gitignore and .dcignore should not be uploaded + // (https://github.com/snyk/code-client/blob/d6f6a2ce4c14cb4b05aa03fb9f03533d8cf6ca4a/src/files.ts#L138) + if configFile == ".gitignore" || configFile == ".dcignore" { + continue + } + b.supportedConfigFiles.Store(configFile, true) + } + return nil +} diff --git a/bundle/bundle_manager_bench_test.go b/bundle/bundle_manager_bench_test.go new file mode 100644 index 0000000..0653193 --- /dev/null +++ b/bundle/bundle_manager_bench_test.go @@ -0,0 +1,219 @@ +/* + * © 2024 Snyk Limited 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package bundle_test + +import ( + "context" + "math/rand" + "os" + "path/filepath" + "runtime" + "strconv" + "sync" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/rs/zerolog" + + "github.com/snyk/code-client-go/bundle" + "github.com/snyk/code-client-go/internal/deepcode" + deepcodeMocks "github.com/snyk/code-client-go/internal/deepcode/mocks" + "github.com/snyk/code-client-go/internal/util" + "github.com/snyk/code-client-go/observability/mocks" + trackerMocks "github.com/snyk/code-client-go/scan/mocks" +) + +// benchFileCount and benchFileSize describe a synthetic source tree used to +// exercise the file-reading + hashing hot path of Create. +const ( + benchFileCount = 3000 + benchFileSize = 4 * 1024 +) + +// makeBenchTree writes benchFileCount .java files of benchFileSize bytes each +// (with distinct content so hashes differ) and returns their absolute paths. +func makeBenchTree(b *testing.B) (rootPath string, files []string) { + b.Helper() + rootPath = b.TempDir() + files = make([]string, 0, benchFileCount) + rng := rand.New(rand.NewSource(1)) + for i := 0; i < benchFileCount; i++ { + // Spread files across subdirectories like a real project. + dir := filepath.Join(rootPath, "pkg", strconv.Itoa(i%64)) + _ = os.MkdirAll(dir, 0700) + p := filepath.Join(dir, "file"+strconv.Itoa(i)+".java") + content := make([]byte, benchFileSize) + for j := range content { + content[j] = byte('a' + rng.Intn(26)) + } + if err := os.WriteFile(p, content, 0600); err != nil { + b.Fatal(err) + } + files = append(files, p) + } + return rootPath, files +} + +func newBenchManager(b *testing.B) bundle.BundleManager { + b.Helper() + ctrl := gomock.NewController(b) + mockSpan := mocks.NewMockSpan(ctrl) + mockSpan.EXPECT().Context().Return(context.Background()).AnyTimes() + mockClient := deepcodeMocks.NewMockDeepcodeClient(ctrl) + mockClient.EXPECT().GetFilters(gomock.Any()).Return(deepcode.FiltersResponse{ + ConfigFiles: []string{}, + Extensions: []string{".java"}, + }, nil).AnyTimes() + mockClient.EXPECT().CreateBundle(gomock.Any(), gomock.Any()).Return("bench-hash", []string{}, nil).AnyTimes() + mockInstrumentor := mocks.NewMockInstrumentor(ctrl) + mockInstrumentor.EXPECT().StartSpan(gomock.Any(), gomock.Any()).Return(mockSpan).AnyTimes() + mockInstrumentor.EXPECT().Finish(gomock.Any()).AnyTimes() + mockErrorReporter := mocks.NewMockErrorReporter(ctrl) + mockTracker := trackerMocks.NewMockTracker(ctrl) + mockTracker.EXPECT().Begin(gomock.Any(), gomock.Any()).AnyTimes() + mockTracker.EXPECT().End(gomock.Any()).AnyTimes() + mockTrackerFactory := trackerMocks.NewMockTrackerFactory(ctrl) + mockTrackerFactory.EXPECT().GenerateTracker().Return(mockTracker).AnyTimes() + + logger := zerolog.Nop() + return bundle.NewBundleManager(mockClient, &logger, mockInstrumentor, mockErrorReporter, mockTrackerFactory) +} + +// BenchmarkCreate_Parallel measures the production (parallel) Create. +func BenchmarkCreate_Parallel(b *testing.B) { + rootPath, files := makeBenchTree(b) + mgr := newBenchManager(b) + ctx := context.Background() + changed := map[string]bool{} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := mgr.Create(ctx, "req", rootPath, sliceToChannel(files), changed) + if err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkCreate_Sequential replicates the original, single-threaded Create +// loop using only the same public helpers it relied on. It is the baseline the +// parallel implementation is compared against. +func BenchmarkCreate_Sequential(b *testing.B) { + rootPath, files := makeBenchTree(b) + changed := map[string]bool{} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + paths := sliceToChannel(files) + fileHashes := make(map[string]string) + bundleFiles := make(map[string]deepcode.BundleFile) + var limitToFiles []string + for absoluteFilePath := range paths { + // Original IsSupported reduces to an extension/config-file lookup + // once filters are loaded; the bench tree only contains ".java". + if filepath.Ext(absoluteFilePath) != ".java" { + continue + } + fileInfo, err := os.Stat(absoluteFilePath) + if err != nil { + continue + } + if fileInfo.Size() == 0 || fileInfo.Size() > 1024*1024 { + continue + } + fileContent, err := os.ReadFile(absoluteFilePath) + if err != nil { + continue + } + relativePath, _ := util.ToRelativeUnixPath(rootPath, absoluteFilePath) + relativePath = util.EncodePath(relativePath) + bundleFile, _ := deepcode.BundleFileFrom(fileContent, true) + bundleFiles[relativePath] = bundleFile + fileHashes[relativePath] = bundleFile.Hash + if changed[absoluteFilePath] { + limitToFiles = append(limitToFiles, relativePath) + } + } + _ = bundleFiles + _ = fileHashes + _ = limitToFiles + } +} + +// The benchmarks below model the I/O-bound regime that dominates on Windows, +// where each file's open/stat/read is high-latency blocking work rather than +// CPU work. They isolate the scheduling structure of Create (sequential loop vs +// the bounded worker pool it now uses) from the host filesystem so the +// parallelisation speed-up is observable on any platform. windowsLikeIOLatency +// approximates a single combined open+stat+read round-trip on Windows. +const windowsLikeIOLatency = 120 * time.Microsecond + +func createWorkerCountForBench() int { + n := runtime.GOMAXPROCS(0) * 4 + if n < 4 { + n = 4 + } + if n > 32 { + n = 32 + } + return n +} + +// BenchmarkIOBound_Sequential models the original loop: one blocking I/O per +// file, performed serially. +func BenchmarkIOBound_Sequential(b *testing.B) { + files := make([]string, benchFileCount) + for i := range files { + files[i] = strconv.Itoa(i) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + for range files { + time.Sleep(windowsLikeIOLatency) + } + } +} + +// BenchmarkIOBound_Parallel models the new loop: the same blocking I/O per file +// spread across the worker pool, overlapping the latency. +func BenchmarkIOBound_Parallel(b *testing.B) { + files := make([]string, benchFileCount) + for i := range files { + files[i] = strconv.Itoa(i) + } + workers := createWorkerCountForBench() + b.ResetTimer() + for i := 0; i < b.N; i++ { + jobs := make(chan string) + var wg sync.WaitGroup + for w := 0; w < workers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for range jobs { + time.Sleep(windowsLikeIOLatency) + } + }() + } + for _, f := range files { + jobs <- f + } + close(jobs) + wg.Wait() + } +} diff --git a/go.mod b/go.mod index a41ffb6..99114b0 100644 --- a/go.mod +++ b/go.mod @@ -121,4 +121,4 @@ tool ( github.com/pact-foundation/pact-go/v2 ) -// replace github.com/snyk/go-application-framework => ../../go-application-framework +replace github.com/snyk/go-application-framework => /Users/ianzink/git/gaf-v0.3.2-local diff --git a/internal/deepcode/helpers.go b/internal/deepcode/helpers.go index cbd7371..26a9475 100644 --- a/internal/deepcode/helpers.go +++ b/internal/deepcode/helpers.go @@ -26,20 +26,22 @@ type BundleFile struct { } func BundleFileFrom(content []byte, includeContent bool) (BundleFile, error) { - hash, err := util.Hash(content) + // Convert to UTF-8 once and reuse the result for both the hash and the + // content. The previous implementation converted twice (once inside + // util.Hash and once here), doubling the conversion cost for every file. + utf8Content, err := util.ConvertToUTF8(content) + if err != nil { + utf8Content = content + } + + hash := util.HashContent(utf8Content) // We can either create the bundleFile empty and enrich it with content later, or include the content now. // Creating empty avoids keeping the file contents in memory, so improves performance if we don't need access to the // contents right away. bundleFileContent := "" if includeContent { - utf8Content, convertErr := util.ConvertToUTF8(content) - if convertErr == nil { - bundleFileContent = string(utf8Content) - } else { - bundleFileContent = string(content) - err = convertErr - } + bundleFileContent = string(utf8Content) } file := BundleFile{ diff --git a/internal/util/hash.go b/internal/util/hash.go index 77f6fb5..fab9198 100644 --- a/internal/util/hash.go +++ b/internal/util/hash.go @@ -30,9 +30,15 @@ func Hash(content []byte) (string, error) { if err != nil { utf8content = content } + return HashContent(utf8content), err +} + +// HashContent returns the SHA-256 hex digest of already-decoded content. Callers +// that have already converted their bytes to UTF-8 should use this to avoid the +// redundant conversion that Hash performs internally. +func HashContent(utf8content []byte) string { b := sha256.Sum256(utf8content) - sum256 := hex.EncodeToString(b[:]) - return sum256, err + return hex.EncodeToString(b[:]) } func ConvertToUTF8(content []byte) ([]byte, error) { From 70969cf5374ba66bf604632f54f4b52ebb6f425c Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Tue, 21 Jul 2026 11:50:09 +0100 Subject: [PATCH 3/3] fix(go.mod): point GAF at fork commit so CI resolves The committed replace directive pointed at a nonexistent local path (/Users/ianzink/git/gaf-v0.3.2-local), which broke `go mod tidy`, build, unit tests, and the Snyk SCA/license checks in CI. The parallelization work also depends on `GetFilteredFilesSingleWalk`, which currently only exists on the companion GAF branch (snyk/go-application-framework#657). Repoint the replace directive at that GAF commit (github.com/z4ce/go-application-framework@5ac64af, pseudo-version v0.0.0-20260716054810-5ac64afcd596) and update go.sum so the module graph resolves and CI can build/test the change. NOTE: this is a temporary measure to unblock CI while both PRs are drafts. Before merge, GAF #657 should land and this should be repinned to a real snyk/go-application-framework version with the replace removed. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 99114b0..3bbc813 100644 --- a/go.mod +++ b/go.mod @@ -121,4 +121,4 @@ tool ( github.com/pact-foundation/pact-go/v2 ) -replace github.com/snyk/go-application-framework => /Users/ianzink/git/gaf-v0.3.2-local +replace github.com/snyk/go-application-framework => github.com/z4ce/go-application-framework v0.0.0-20260716054810-5ac64afcd596 diff --git a/go.sum b/go.sum index cc27d7e..8749d3b 100644 --- a/go.sum +++ b/go.sum @@ -246,8 +246,6 @@ github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnB github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/snyk/error-catalog-golang-public v0.0.0-20260205094614-116c03822905 h1:pUe6iOWHEOFY0t4u4ssXeTqpMmZBu1xq06VBFI9zUik= github.com/snyk/error-catalog-golang-public v0.0.0-20260205094614-116c03822905/go.mod h1:Ytttq7Pw4vOCu9NtRQaOeDU2dhBYUyNBe6kX4+nIIQ4= -github.com/snyk/go-application-framework v0.0.0-20260504124159-e299fb4a44a1 h1:yiG0VOwT292qxs+g6HTnr6O8is/sKEI+9XTUGf2tFDU= -github.com/snyk/go-application-framework v0.0.0-20260504124159-e299fb4a44a1/go.mod h1:yTGCJKf6RmqdwrNs5B9zmukL9x1D8EhfSK8mzaPB1Rk= github.com/snyk/go-httpauth v0.0.0-20231117135515-eb445fea7530 h1:s9PHNkL6ueYRiAKNfd8OVxlUOqU3qY0VDbgCD1f6WQY= github.com/snyk/go-httpauth v0.0.0-20231117135515-eb445fea7530/go.mod h1:88KbbvGYlmLgee4OcQ19yr0bNpXpOr2kciOthaSzCAg= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= @@ -306,6 +304,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/z4ce/go-application-framework v0.0.0-20260716054810-5ac64afcd596 h1:x2hxyQa54WcJRIC16ThuuRkcS+dVcMlxmHg4upymo6I= +github.com/z4ce/go-application-framework v0.0.0-20260716054810-5ac64afcd596/go.mod h1:fMJZ6RJN6CyjBt/6KaP0jG/WvPSZFtFnmmDb/NjdF7Y= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=