From 397b5762474d1f363a6f02be114beeb44b35b877 Mon Sep 17 00:00:00 2001 From: Juan Ezquerro LLanes Date: Wed, 28 Oct 2026 00:46:15 +0100 Subject: [PATCH 1/2] ci: add strict static analysis pipelines - Add CI workflow: gofmt, go vet, golangci-lint, race-enabled tests with coverage, SonarQube Cloud with blocking quality gate - Add CodeQL (go + actions) and Security workflow (govulncheck, dependency-review, zizmor) - Pin all actions by SHA, scope permissions per job, harden release.yml against template injection and cache poisoning, add cooldowns to Dependabot - Add .golangci.yml v2 configuration, fix all linter findings, and bump go directive to 1.26.10 to resolve stdlib vulnerabilities - Remove redundant .sonarcloud.properties and legacy sonarcloud workflow --- .github/dependabot.yml | 8 +++ .github/workflows/ci.yml | 95 ++++++++++++++++++++++++++++ .github/workflows/codeql.yml | 46 ++++++++++++++ .github/workflows/deploy.yml | 21 +++--- .github/workflows/release.yml | 30 +++++---- .github/workflows/security.yml | 55 ++++++++++++++++ .github/workflows/sonarcloud.yml | 37 ----------- .golangci.yml | 22 +++++++ .sonarcloud.properties | 7 -- cmd/init.go | 2 +- cmd/read.go | 4 +- cmd/search.go | 10 +-- cmd/setup.go | 2 +- cmd/update.go | 2 +- go.mod | 2 +- internal/engine/client.go | 2 +- internal/engine/finalize.go | 5 +- internal/engine/orchestrator.go | 6 +- internal/engine/orchestrator_test.go | 2 +- internal/engine/synthesize.go | 2 +- internal/engine/synthesize_test.go | 2 +- internal/engine/tree.go | 1 - internal/engine/tree_test.go | 12 +++- internal/security/security.go | 2 +- internal/security/security_test.go | 4 +- internal/tools/file_tools.go | 10 +-- sonar-project.properties | 1 + 27 files changed, 297 insertions(+), 95 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/security.yml delete mode 100644 .github/workflows/sonarcloud.yml create mode 100644 .golangci.yml delete mode 100644 .sonarcloud.properties diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8b77a1a..c612299 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,9 +5,17 @@ updates: directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 # Mantener actualizados los workflows de GitHub Actions - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..66850e8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint & format check + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: "go.mod" + + - name: Check formatting (gofmt) + run: | + UNFORMATTED=$(gofmt -l .) + if [ -n "$UNFORMATTED" ]; then + echo "The following files are not formatted with gofmt:" + echo "$UNFORMATTED" + exit 1 + fi + + - name: Run go vet + run: go vet ./... + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: latest + + tests: + name: Tests + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: "go.mod" + + - name: Run tests with race detector and coverage + run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... + + - name: Verify build + run: go build -v ./... + + - name: Upload coverage + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: coverage + path: coverage.out + + sonarcloud: + name: SonarQube Cloud + needs: tests + # Secrets are not exposed to pull requests from forks. + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: coverage + + - name: SonarQube Cloud Scan + uses: SonarSource/sonarqube-scan-action@d209202bc7d53ff1cc128f7f907dac145c9d6ae9 # v8.3.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..305956d --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,46 @@ +name: CodeQL + +on: + push: + branches: + - main + pull_request: + schedule: + - cron: "17 4 * * 1" + +permissions: {} + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - language: go + build-mode: autobuild + - language: actions + build-mode: none + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - if: matrix.language == 'go' + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: "go.mod" + + - uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-and-quality + + - uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0060c84..8ae6dda 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,10 +11,7 @@ on: - '.github/workflows/deploy.yml' workflow_dispatch: -permissions: - contents: read - pages: write - id-token: write +permissions: {} concurrency: group: "pages" @@ -26,15 +23,21 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Pages - uses: actions/configure-pages@v6 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Setup Python - uses: actions/setup-python@v7 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.13" @@ -48,10 +51,10 @@ jobs: touch ./site/.nojekyll - name: Upload artifact - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: ./site - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4ab838..2bb317a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,13 +4,14 @@ on: release: types: [published] -permissions: - contents: write +permissions: {} jobs: release: name: Build and Release Binaries runs-on: ubuntu-latest + permissions: + contents: write strategy: matrix: goos: [linux, windows, darwin] @@ -20,12 +21,15 @@ jobs: goarch: "386" steps: - name: Checkout Repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.26' + go-version-file: 'go.mod' + cache: false - name: Run Vet run: go vet ./... @@ -40,7 +44,7 @@ jobs: CGO_ENABLED: 0 run: | BINARY_NAME="code-reducer" - if [ "${{ matrix.goos }}" = "windows" ]; then + if [ "${GOOS}" = "windows" ]; then BINARY_NAME="code-reducer.exe" fi # Compile with optimizations and strip symbols @@ -48,19 +52,23 @@ jobs: - name: Package Binary id: package + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} run: | cd dist BINARY_NAME="code-reducer" - if [ "${{ matrix.goos }}" = "windows" ]; then + if [ "${GOOS}" = "windows" ]; then BINARY_NAME="code-reducer.exe" - zip "../code-reducer-${{ matrix.goos }}-${{ matrix.goarch }}.zip" "${BINARY_NAME}" - echo "asset_path=code-reducer-${{ matrix.goos }}-${{ matrix.goarch }}.zip" >> $GITHUB_OUTPUT + zip "../code-reducer-${GOOS}-${GOARCH}.zip" "${BINARY_NAME}" + echo "asset_path=code-reducer-${GOOS}-${GOARCH}.zip" >> "$GITHUB_OUTPUT" else - tar -czvf "../code-reducer-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" "${BINARY_NAME}" - echo "asset_path=code-reducer-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" >> $GITHUB_OUTPUT + tar -czvf "../code-reducer-${GOOS}-${GOARCH}.tar.gz" "${BINARY_NAME}" + echo "asset_path=code-reducer-${GOOS}-${GOARCH}.tar.gz" >> "$GITHUB_OUTPUT" fi - name: Upload to Release + # zizmor: ignore[superfluous-actions] uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: files: ${{ steps.package.outputs.asset_path }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..afa9a42 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,55 @@ +name: Security + +on: + push: + branches: + - main + pull_request: + schedule: + - cron: "41 5 * * *" + +permissions: {} + +jobs: + govulncheck: + name: Dependency vulnerabilities (govulncheck) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: "go.mod" + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Run govulncheck + run: govulncheck ./... + + dependency-review: + name: Dependency review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + + zizmor: + name: Workflow security (zizmor) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0 + with: + advanced-security: false diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml deleted file mode 100644 index 13eb96b..0000000 --- a/.github/workflows/sonarcloud.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: SonarQube Cloud Analysis - -on: - push: - branches: - - main - - master - pull_request: - types: [opened, synchronize, reopened] - -permissions: - contents: read - -jobs: - sonarcloud: - name: SonarQube Cloud Scan - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Setup Go - uses: actions/setup-go@v7 - with: - go-version-file: 'go.mod' - - - name: Run tests with coverage - run: | - go test -coverprofile=coverage.out -covermode=atomic ./... - - - name: SonarQube Cloud Scan - uses: SonarSource/sonarqube-scan-action@d209202bc7d53ff1cc128f7f907dac145c9d6ae9 # v8.3.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..edc13dd --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,22 @@ +version: "2" + +linters: + default: none + enable: + - bodyclose + - errcheck + - errorlint + - gocritic + - govet + - ineffassign + - misspell + - revive + - staticcheck + - unused + settings: + revive: + rules: + - name: exported + disabled: true + - name: package-comments + disabled: true diff --git a/.sonarcloud.properties b/.sonarcloud.properties deleted file mode 100644 index 44d77ed..0000000 --- a/.sonarcloud.properties +++ /dev/null @@ -1,7 +0,0 @@ -sonar.organization=arrase -sonar.projectKey=arrase_code-reducer -sonar.sources=. -sonar.exclusions=**/*_test.go,**/examples/** -sonar.tests=. -sonar.test.inclusions=**/*_test.go -sonar.go.coverage.reportPaths=coverage.out diff --git a/cmd/init.go b/cmd/init.go index 8532199..c92fc86 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -9,7 +9,7 @@ var initCmd = &cobra.Command{ Use: "init", Short: "Initialize project documentation", Long: `Scan the repository and build the initial set of wiki markdown pages.`, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { return executeCommand(engine.ModeInit) }, } diff --git a/cmd/read.go b/cmd/read.go index e269cab..d5ebe42 100644 --- a/cmd/read.go +++ b/cmd/read.go @@ -33,9 +33,9 @@ var readCmd = &cobra.Command{ out := cmd.OutOrStdout() if strings.HasSuffix(content, "\n") { - fmt.Fprint(out, content) + _, _ = fmt.Fprint(out, content) } else { - fmt.Fprintln(out, content) + _, _ = fmt.Fprintln(out, content) } return nil }, diff --git a/cmd/search.go b/cmd/search.go index e3dac5a..156542b 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -40,18 +40,18 @@ var searchCmd = &cobra.Command{ out := cmd.OutOrStdout() if len(results) == 0 { - fmt.Fprintf(out, "No results found for %q.\n", query) + _, _ = fmt.Fprintf(out, "No results found for %q.\n", query) return nil } for i, r := range results { - fmt.Fprintf(out, "[%d] %s (score: %.2f)\n", i+1, r.Target, r.Score) - fmt.Fprintf(out, " Heading: %s\n", r.Heading) + _, _ = fmt.Fprintf(out, "[%d] %s (score: %.2f)\n", i+1, r.Target, r.Score) + _, _ = fmt.Fprintf(out, " Heading: %s\n", r.Heading) if len(r.Sources) > 0 { - fmt.Fprintf(out, " Sources: %s\n", strings.Join(r.Sources, ", ")) + _, _ = fmt.Fprintf(out, " Sources: %s\n", strings.Join(r.Sources, ", ")) } if r.Snippet != "" { - fmt.Fprintf(out, " Snippet: %s\n", r.Snippet) + _, _ = fmt.Fprintf(out, " Snippet: %s\n", r.Snippet) } } return nil diff --git a/cmd/setup.go b/cmd/setup.go index 6e8c5be..6f9233e 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -16,7 +16,7 @@ var setupCmd = &cobra.Command{ Use: "setup", Short: "Configure the application", Long: `Run the interactive configuration setup to generate the .code-reducer.yaml file.`, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { repoRoot, err := os.Getwd() if err != nil { return fmt.Errorf("failed to get current working directory: %w", err) diff --git a/cmd/update.go b/cmd/update.go index 2f85328..f549609 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -9,7 +9,7 @@ var updateCmd = &cobra.Command{ Use: "update", Short: "Incrementally update documentation for modified files using cached SHA-256 hashes.", Long: `Scan changed files since the last documented commit and update the wiki pages.`, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { return executeCommand(engine.ModeUpdate) }, } diff --git a/go.mod b/go.mod index 3065e62..c51774c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/arrase/code-reducer -go 1.26 +go 1.26.10 require ( github.com/mattn/go-isatty v0.0.24 diff --git a/internal/engine/client.go b/internal/engine/client.go index 97391c6..d08964a 100644 --- a/internal/engine/client.go +++ b/internal/engine/client.go @@ -101,7 +101,7 @@ func (c *llmClient) CallLLM(ctx context.Context, systemPrompt, userPrompt string if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusOK { respData, err := io.ReadAll(resp.Body) diff --git a/internal/engine/finalize.go b/internal/engine/finalize.go index fb34134..fa67aa9 100644 --- a/internal/engine/finalize.go +++ b/internal/engine/finalize.go @@ -375,9 +375,10 @@ func findMatchingDelimiter(s string, openIdx int, openChar, closeChar byte) int if inQuotes { continue } - if ch == openChar { + switch ch { + case openChar: depth++ - } else if ch == closeChar { + case closeChar: depth-- if depth == 0 { return i diff --git a/internal/engine/orchestrator.go b/internal/engine/orchestrator.go index 2e9e147..48a3264 100644 --- a/internal/engine/orchestrator.go +++ b/internal/engine/orchestrator.go @@ -49,7 +49,7 @@ func Run(ctx context.Context, repoRoot string, cfg *config.Config, mode RunMode, if err != nil { return fmt.Errorf("failed to acquire repository lock: %w", err) } - defer lock.Unlock() + defer func() { _ = lock.Unlock() }() client := newLLMClient(cfg.ModelID, cfg.OllamaBaseURL, cfg.OllamaNumCtx, onStatus) orch := &orchestrator{client: client} @@ -111,9 +111,9 @@ func buildProjectContextItems(rootSum string, tree *DirNode, cache *MetadataCach copy(files, node.Files) sort.Strings(files) if len(files) > 0 { - mapBuilder.WriteString(fmt.Sprintf("- `%s` -> `%s` (files: %s)\n", dirPath, docPath, strings.Join(files, ", "))) + _, _ = fmt.Fprintf(&mapBuilder, "- `%s` -> `%s` (files: %s)\n", dirPath, docPath, strings.Join(files, ", ")) } else { - mapBuilder.WriteString(fmt.Sprintf("- `%s` -> `%s`\n", dirPath, docPath)) + _, _ = fmt.Fprintf(&mapBuilder, "- `%s` -> `%s`\n", dirPath, docPath) } } diff --git a/internal/engine/orchestrator_test.go b/internal/engine/orchestrator_test.go index f0419b8..9f2ce5f 100644 --- a/internal/engine/orchestrator_test.go +++ b/internal/engine/orchestrator_test.go @@ -251,7 +251,7 @@ func TestPrepareOllamaRequest(t *testing.T) { } func TestCallLLMDoneReasonLengthWarning(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { resp := ollamaResponse{ Message: Message{Role: "assistant", Content: "partial output"}, DoneReason: "length", diff --git a/internal/engine/synthesize.go b/internal/engine/synthesize.go index 4dc2f0c..855a4a2 100644 --- a/internal/engine/synthesize.go +++ b/internal/engine/synthesize.go @@ -101,7 +101,7 @@ func extractFileFacts(ctx context.Context, p *pipelineState, f string, siblingFi return "", err } if consolidatedFact != "" { - factsBuilder.WriteString(fmt.Sprintf("#### [%s]\n%s\n\n", step.Name, consolidatedFact)) + _, _ = fmt.Fprintf(&factsBuilder, "#### [%s]\n%s\n\n", step.Name, consolidatedFact) } } facts := strings.TrimSpace(factsBuilder.String()) diff --git a/internal/engine/synthesize_test.go b/internal/engine/synthesize_test.go index 6a02f88..128b13f 100644 --- a/internal/engine/synthesize_test.go +++ b/internal/engine/synthesize_test.go @@ -16,7 +16,7 @@ type mockLLMCaller struct { userPrompts []string } -func (m *mockLLMCaller) CallLLM(ctx context.Context, systemPrompt, userPrompt string) (string, error) { +func (m *mockLLMCaller) CallLLM(_ context.Context, systemPrompt, userPrompt string) (string, error) { m.systemPrompts = append(m.systemPrompts, systemPrompt) m.userPrompts = append(m.userPrompts, userPrompt) return "Mocked LLM summary response", nil diff --git a/internal/engine/tree.go b/internal/engine/tree.go index 88e8b44..0026cb7 100644 --- a/internal/engine/tree.go +++ b/internal/engine/tree.go @@ -144,4 +144,3 @@ func collectTreeNodes(tree *DirNode) map[string]*DirNode { walk(tree) return nodes } - diff --git a/internal/engine/tree_test.go b/internal/engine/tree_test.go index 10616da..054dcea 100644 --- a/internal/engine/tree_test.go +++ b/internal/engine/tree_test.go @@ -45,14 +45,20 @@ func TestBuildTree(t *testing.T) { func TestDetermineAndPropagateAffected(t *testing.T) { tempDir := t.TempDir() docsDir := filepath.Join(tempDir, "docs") - os.MkdirAll(filepath.Join(docsDir, "modules"), 0755) + if err := os.MkdirAll(filepath.Join(docsDir, "modules"), 0755); err != nil { + t.Fatal(err) + } // Create dummy module files so os.Stat doesn't fail for _, m := range []string{".", "cmd", "internal", "internal/app"} { safeName := toSafeMarkdownFilename(m) fullPath := filepath.Join(docsDir, "modules", safeName) - os.MkdirAll(filepath.Dir(fullPath), 0755) - os.WriteFile(fullPath, []byte("test"), 0644) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte("test"), 0644); err != nil { + t.Fatal(err) + } } tree := &DirNode{ diff --git a/internal/security/security.go b/internal/security/security.go index 595bf27..450d9f0 100644 --- a/internal/security/security.go +++ b/internal/security/security.go @@ -117,7 +117,7 @@ func AcquireLock(repoRoot string) (*SimpleLock, error) { return nil, fmt.Errorf("failed to acquire lock at %s: %w", lockPath, err) } - if _, err := f.Write([]byte(fmt.Sprintf("%d\n", os.Getpid()))); err != nil { + if _, err := fmt.Fprintf(f, "%d\n", os.Getpid()); err != nil { _ = f.Close() _ = os.Remove(lockPath) return nil, fmt.Errorf("failed to write pid to lockfile: %w", err) diff --git a/internal/security/security_test.go b/internal/security/security_test.go index 14c04ab..357c150 100644 --- a/internal/security/security_test.go +++ b/internal/security/security_test.go @@ -102,5 +102,7 @@ func TestAcquireLock(t *testing.T) { if err != nil { t.Fatalf("AcquireLock() failed after unlock: %v", err) } - lock2.Unlock() + if err := lock2.Unlock(); err != nil { + t.Errorf("Unlock() failed: %v", err) + } } diff --git a/internal/tools/file_tools.go b/internal/tools/file_tools.go index 58056f9..c7b1c70 100644 --- a/internal/tools/file_tools.go +++ b/internal/tools/file_tools.go @@ -109,8 +109,8 @@ func WriteFileAtomic(targetPath string, data []byte, perm os.FileMode) error { var success bool defer func() { if !success { - tmpFile.Close() - os.Remove(tmpName) + _ = tmpFile.Close() + _ = os.Remove(tmpName) } }() @@ -169,9 +169,9 @@ func EnsureGitignoreHasLockfile(repoRoot string) error { contentToAppend = "\n" + contentToAppend } - newData := append(data, []byte(contentToAppend)...) + data = append(data, []byte(contentToAppend)...) - return WriteFileAtomic(gitignorePath, newData, defaultFilePerm) + return WriteFileAtomic(gitignorePath, data, defaultFilePerm) } // LoadGitignore reads the .gitignore file from repoRoot and returns its active ignore patterns. @@ -235,7 +235,7 @@ func isBinaryFile(path string) bool { if err != nil { return false } - defer f.Close() + defer func() { _ = f.Close() }() var buf [512]byte n, _ := f.Read(buf[:]) diff --git a/sonar-project.properties b/sonar-project.properties index 44d77ed..ed32c92 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -5,3 +5,4 @@ sonar.exclusions=**/*_test.go,**/examples/** sonar.tests=. sonar.test.inclusions=**/*_test.go sonar.go.coverage.reportPaths=coverage.out +sonar.qualitygate.wait=true From 74c1646ca8b4d01ad7048587003ffae1b0e9c2c9 Mon Sep 17 00:00:00 2001 From: Juan Ezquerro LLanes Date: Wed, 28 Oct 2026 01:02:58 +0100 Subject: [PATCH 2/2] fix(ci): pin govulncheck version and exclude CLI entrypoints from coverage --- .github/workflows/security.yml | 2 +- .golangci.yml | 10 ++++++++++ cmd/read.go | 4 ++-- cmd/search.go | 10 +++++----- internal/engine/client.go | 2 +- internal/engine/orchestrator.go | 6 +++--- internal/engine/orchestrator_test.go | 6 ++++++ internal/engine/synthesize.go | 2 +- internal/tools/file_tools.go | 6 +++--- internal/tools/file_tools_test.go | 17 +++++++++++++++++ sonar-project.properties | 3 ++- 11 files changed, 51 insertions(+), 17 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index afa9a42..5952444 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -26,7 +26,7 @@ jobs: go-version-file: "go.mod" - name: Install govulncheck - run: go install golang.org/x/vuln/cmd/govulncheck@latest + run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 - name: Run govulncheck run: govulncheck ./... diff --git a/.golangci.yml b/.golangci.yml index edc13dd..7344214 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -14,6 +14,16 @@ linters: - staticcheck - unused settings: + errcheck: + exclude-functions: + - (*os.File).Close + - (io.Closer).Close + - (*github.com/arrase/code-reducer/internal/security.SimpleLock).Unlock + - os.Remove + - os.RemoveAll + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln revive: rules: - name: exported diff --git a/cmd/read.go b/cmd/read.go index d5ebe42..e269cab 100644 --- a/cmd/read.go +++ b/cmd/read.go @@ -33,9 +33,9 @@ var readCmd = &cobra.Command{ out := cmd.OutOrStdout() if strings.HasSuffix(content, "\n") { - _, _ = fmt.Fprint(out, content) + fmt.Fprint(out, content) } else { - _, _ = fmt.Fprintln(out, content) + fmt.Fprintln(out, content) } return nil }, diff --git a/cmd/search.go b/cmd/search.go index 156542b..e3dac5a 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -40,18 +40,18 @@ var searchCmd = &cobra.Command{ out := cmd.OutOrStdout() if len(results) == 0 { - _, _ = fmt.Fprintf(out, "No results found for %q.\n", query) + fmt.Fprintf(out, "No results found for %q.\n", query) return nil } for i, r := range results { - _, _ = fmt.Fprintf(out, "[%d] %s (score: %.2f)\n", i+1, r.Target, r.Score) - _, _ = fmt.Fprintf(out, " Heading: %s\n", r.Heading) + fmt.Fprintf(out, "[%d] %s (score: %.2f)\n", i+1, r.Target, r.Score) + fmt.Fprintf(out, " Heading: %s\n", r.Heading) if len(r.Sources) > 0 { - _, _ = fmt.Fprintf(out, " Sources: %s\n", strings.Join(r.Sources, ", ")) + fmt.Fprintf(out, " Sources: %s\n", strings.Join(r.Sources, ", ")) } if r.Snippet != "" { - _, _ = fmt.Fprintf(out, " Snippet: %s\n", r.Snippet) + fmt.Fprintf(out, " Snippet: %s\n", r.Snippet) } } return nil diff --git a/internal/engine/client.go b/internal/engine/client.go index d08964a..97391c6 100644 --- a/internal/engine/client.go +++ b/internal/engine/client.go @@ -101,7 +101,7 @@ func (c *llmClient) CallLLM(ctx context.Context, systemPrompt, userPrompt string if err != nil { return "", err } - defer func() { _ = resp.Body.Close() }() + defer resp.Body.Close() if resp.StatusCode == http.StatusOK { respData, err := io.ReadAll(resp.Body) diff --git a/internal/engine/orchestrator.go b/internal/engine/orchestrator.go index 48a3264..7d06e49 100644 --- a/internal/engine/orchestrator.go +++ b/internal/engine/orchestrator.go @@ -49,7 +49,7 @@ func Run(ctx context.Context, repoRoot string, cfg *config.Config, mode RunMode, if err != nil { return fmt.Errorf("failed to acquire repository lock: %w", err) } - defer func() { _ = lock.Unlock() }() + defer lock.Unlock() client := newLLMClient(cfg.ModelID, cfg.OllamaBaseURL, cfg.OllamaNumCtx, onStatus) orch := &orchestrator{client: client} @@ -111,9 +111,9 @@ func buildProjectContextItems(rootSum string, tree *DirNode, cache *MetadataCach copy(files, node.Files) sort.Strings(files) if len(files) > 0 { - _, _ = fmt.Fprintf(&mapBuilder, "- `%s` -> `%s` (files: %s)\n", dirPath, docPath, strings.Join(files, ", ")) + fmt.Fprintf(&mapBuilder, "- `%s` -> `%s` (files: %s)\n", dirPath, docPath, strings.Join(files, ", ")) } else { - _, _ = fmt.Fprintf(&mapBuilder, "- `%s` -> `%s`\n", dirPath, docPath) + fmt.Fprintf(&mapBuilder, "- `%s` -> `%s`\n", dirPath, docPath) } } diff --git a/internal/engine/orchestrator_test.go b/internal/engine/orchestrator_test.go index 9f2ce5f..e493d78 100644 --- a/internal/engine/orchestrator_test.go +++ b/internal/engine/orchestrator_test.go @@ -305,6 +305,12 @@ func TestBuildProjectContextItems(t *testing.T) { if strings.Contains(item0, "`modules/internal/README.md`") { t.Errorf("did not expect empty module 'internal' to be listed in Module Documentation Map, got:\n%s", item0) } + + cache.Modules["internal"] = "Internal umbrella summary" + itemsWithParent := buildProjectContextItems("Root summary", tree, cache) + if !strings.Contains(itemsWithParent[0], "- `internal` -> `modules/internal/README.md`\n") { + t.Errorf("expected parent module without direct files to be listed, got:\n%s", itemsWithParent[0]) + } } func TestRunUpdateIdenticalFactsShortCircuit(t *testing.T) { diff --git a/internal/engine/synthesize.go b/internal/engine/synthesize.go index 855a4a2..a514c19 100644 --- a/internal/engine/synthesize.go +++ b/internal/engine/synthesize.go @@ -101,7 +101,7 @@ func extractFileFacts(ctx context.Context, p *pipelineState, f string, siblingFi return "", err } if consolidatedFact != "" { - _, _ = fmt.Fprintf(&factsBuilder, "#### [%s]\n%s\n\n", step.Name, consolidatedFact) + fmt.Fprintf(&factsBuilder, "#### [%s]\n%s\n\n", step.Name, consolidatedFact) } } facts := strings.TrimSpace(factsBuilder.String()) diff --git a/internal/tools/file_tools.go b/internal/tools/file_tools.go index c7b1c70..e091c8b 100644 --- a/internal/tools/file_tools.go +++ b/internal/tools/file_tools.go @@ -109,8 +109,8 @@ func WriteFileAtomic(targetPath string, data []byte, perm os.FileMode) error { var success bool defer func() { if !success { - _ = tmpFile.Close() - _ = os.Remove(tmpName) + tmpFile.Close() + os.Remove(tmpName) } }() @@ -235,7 +235,7 @@ func isBinaryFile(path string) bool { if err != nil { return false } - defer func() { _ = f.Close() }() + defer f.Close() var buf [512]byte n, _ := f.Read(buf[:]) diff --git a/internal/tools/file_tools_test.go b/internal/tools/file_tools_test.go index 9a4f8ba..d6c7b14 100644 --- a/internal/tools/file_tools_test.go +++ b/internal/tools/file_tools_test.go @@ -187,6 +187,23 @@ func TestDiscoverCodeFiles(t *testing.T) { } } +func TestEnsureGitignoreHasLockfile(t *testing.T) { + repoRoot := t.TempDir() + + if err := os.WriteFile(filepath.Join(repoRoot, ".gitignore"), []byte("*.log"), 0644); err != nil { + t.Fatal(err) + } + + if err := tools.EnsureGitignoreHasLockfile(repoRoot); err != nil { + t.Fatalf("EnsureGitignoreHasLockfile() failed: %v", err) + } + + // Second call should be a no-op + if err := tools.EnsureGitignoreHasLockfile(repoRoot); err != nil { + t.Fatalf("EnsureGitignoreHasLockfile() second call failed: %v", err) + } +} + func TestVerifyGitRepo(t *testing.T) { nonGitDir := t.TempDir() if err := tools.VerifyGitRepo(nonGitDir); err == nil { diff --git a/sonar-project.properties b/sonar-project.properties index ed32c92..e605b6e 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,7 +1,8 @@ sonar.organization=arrase sonar.projectKey=arrase_code-reducer sonar.sources=. -sonar.exclusions=**/*_test.go,**/examples/** +sonar.exclusions=**/*_test.go,**/examples/**,.github/** +sonar.coverage.exclusions=cmd/**,main.go sonar.tests=. sonar.test.inclusions=**/*_test.go sonar.go.coverage.reportPaths=coverage.out