diff --git a/.golangci.yml b/.golangci.yml index 7f0e07ee..f4faaab1 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -90,25 +90,6 @@ linters: - LICENSES - third_party$ rules: - - path: _test\.go$ - linters: - - funlen - - gocognit - - dupl - - errcheck - - noctx - - contextcheck - - gosec - - errname - - errorlint - - bodyclose - - cyclop - - nilnil - - perfsprint - - prealloc - - revive - - staticcheck - - unconvert # These boundaries intentionally create/close context-owning services; # contextcheck cannot model their lifecycle contracts. - path: ^cmd/gitcontribute/main\.go$|^internal/app/(app|discovery|hydration|jobs)\.go$ @@ -133,3 +114,104 @@ linters: - path: ^internal/corpus/(frontier|jobs|tracking)\.go$ linters: - sqlclosecheck + # These tests intentionally model raw RoundTripper responses and use + # repeated status-specific fixtures; the production transport owns the + # response lifecycle and the duplicated fixtures keep each rate-limit + # contract readable. + - path: ^internal/github/(retry|retry_replay)_test\.go$ + linters: + - bodyclose + - dupl + - noctx + # MCP subprocess fixtures own their service lifetime inside the test + # process and deliberately use the convenience constructor. + - path: ^internal/app/mcp_stdio_e2e_test\.go$ + linters: + - contextcheck + # These test helpers intentionally use the project convenience + # constructor and close services at the test boundary. + - path: ^internal/app/read_boundary_test\.go$ + linters: + - contextcheck + # These are subprocess and filesystem boundary harnesses. Their command + # paths and permissions are test-controlled, while the production + # adapters remain subject to the security linters. + - path: ^internal/app/mcp_stdio_e2e_test\.go$|^internal/app/tui_capture_test\.go$|^internal/app/app_test\.go$|^internal/acquire/acquire_test\.go$|^internal/codeindex/codeindex_test\.go$|^internal/workspace/workspace_test\.go$|^internal/corpus/lifecycle_test\.go$|^internal/app/setup_verification_test\.go$|^internal/managedbinary/install_test\.go$|^internal/tui/snapshot_test\.go$ + linters: + - gosec + - noctx + # These compact fakes and HTTP fixtures intentionally omit unrelated + # arguments or return zero values for capabilities under test elsewhere. + - path: ^internal/app/hydration_test\.go$|^internal/app/job_executor_reconciliation_test\.go$|^internal/discovery/gharchive_fetcher_test\.go$|^internal/discovery/search_test\.go$|^internal/github/client_test\.go$|^internal/tui/tui_test\.go$ + linters: + - revive + - errcheck + - dupl + - path: ^internal/app/job_executor_test\.go$|^internal/github/retry_test\.go$ + linters: + - revive + - path: ^internal/app/job_executor_test\.go$|^internal/cli/cli_test\.go$ + linters: + - nilnil + # These local test servers and teardown-only writes are intentionally + # best effort; their assertions cover the observable operation. + - path: ^internal/app/app_test\.go$|^internal/app/control_test\.go$|^internal/app/corpus_lifecycle_test\.go$|^internal/app/discovery_test\.go$|^internal/app/mcp_stdio_e2e_test\.go$|^internal/app/setup_verification_test\.go$|^internal/buflimit/buflimit_test\.go$|^internal/cli/setup_prompt_internal_test\.go$|^internal/corpus/lifecycle_test\.go$|^internal/corpus/tracking_test\.go$|^internal/discovery/gharchive_fetcher_test\.go$|^internal/discovery/gharchive_test\.go$|^internal/github/pull_request_workflows_test\.go$|^internal/log/log_test\.go$ + linters: + - errcheck + # These test handlers and nested JSON assertions are fixture plumbing; + # their surrounding behavior is asserted by the test cases. + - path: ^internal/app/guidance_test\.go$|^internal/app/mcp_github_acquisition_test\.go$|^internal/app/read_boundary_test\.go$|^internal/app/setup_test\.go$|^internal/tracking/sanitize_test\.go$ + linters: + - errcheck + - path: ^internal/app/commitplan_test\.go$|^internal/app/control_test\.go$|^internal/tui/snapshot_test\.go$ + linters: + - gosec + - path: ^internal/app/setup_test\.go$|^internal/buflimit/buflimit_test\.go$|^internal/cli/surfaces_test\.go$|^internal/discovery/gharchive_fetcher_test\.go$|^internal/log/log_test\.go$ + linters: + - gosec + - path: ^internal/app/tui_test\.go$|^internal/tui/actions_test\.go$ + linters: + - errcheck + - path: ^internal/tui/snapshot_test\.go$ + linters: + - errcheck + - path: ^internal/cli/extended_test\.go$ + linters: + - revive + - path: ^internal/app/sync_metadata_test\.go$ + linters: + - revive + # These final fixture-only writes use executable or world-readable + # permissions to emulate installed artifacts and serialized outputs. + - path: ^internal/app/discovery_test\.go$|^internal/app/surfaces_test\.go$|^internal/app/upgrade_activation_test\.go$|^internal/app/upgrade_registration_test\.go$|^internal/github/client_test\.go$ + linters: + - gosec + # Type assertions in these protocol fixtures are the assertions under + # test; failure is reported by the surrounding test assertion. + - path: ^cmd/gitcontribute/main_test\.go$|^internal/mcpserver/server_test\.go$|^internal/setup/setup_test\.go$ + linters: + - errcheck + - path: ^internal/app/guidance_test\.go$|^internal/app/upgrade_setup_test\.go$|^internal/app/upgrade_test\.go$|^internal/corpus/tracking_test\.go$ + linters: + - gosec + - path: ^internal/cli/surfaces_test\.go$ + linters: + - revive + - path: ^internal/app/mcp_pr_workflows_test\.go$|^internal/app/mcp_thread_facets_test\.go$ + linters: + - errcheck + - path: ^internal/app/mcp_github_acquisition_test\.go$ + linters: + - gosec + - path: ^internal/cli/cli_test\.go$ + linters: + - revive + - path: ^internal/app/surfaces_test\.go$ + linters: + - errcheck + - path: ^internal/discovery/gharchive_test\.go$ + linters: + - gosec + - path: ^internal/app/upgrade_test\.go$|^internal/evidence/mcp_runner_test\.go$ + linters: + - noctx diff --git a/internal/acquire/acquire_test.go b/internal/acquire/acquire_test.go index a32c9e86..a1a9b1aa 100644 --- a/internal/acquire/acquire_test.go +++ b/internal/acquire/acquire_test.go @@ -218,7 +218,7 @@ func TestWriteMetadataAtomicallyWithPrivatePermissions(t *testing.T) { func runGit(t *testing.T, dir string, args ...string) string { t.Helper() - cmd := exec.Command("git", append([]string{"--no-pager"}, args...)...) + cmd := exec.CommandContext(context.Background(), "git", append([]string{"--no-pager"}, args...)...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", @@ -295,7 +295,7 @@ func TestAcquireMirrorLockCancelsWhileHeld(t *testing.T) { if err != nil || !ok { t.Fatalf("failed to hold test lock: ok=%v err=%v", ok, err) } - defer fl.Close() + defer func() { _ = fl.Close() }() mgr, err := NewManager(root, nil) if err != nil { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 00d896b9..e3a8ca99 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -690,7 +690,7 @@ func setupAppGitRemote(t *testing.T) (remoteURL, baseSHA, candidateSHA string) { func runGitApp(t *testing.T, dir string, args ...string) string { t.Helper() - cmd := exec.Command("git", append([]string{"--no-pager"}, args...)...) + cmd := exec.CommandContext(context.Background(), "git", append([]string{"--no-pager"}, args...)...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", diff --git a/internal/app/control_test.go b/internal/app/control_test.go index ea19f864..b9eee614 100644 --- a/internal/app/control_test.go +++ b/internal/app/control_test.go @@ -345,6 +345,7 @@ func TestDoctorReportsOlderRegistrationWhenNewerPrivateRuntimeIsInstalled(t *tes if err != nil { t.Fatal(err) } + var registeredPath string for _, version := range []string{"0.15.0", "0.16.0"} { path, err := managedbinary.Destination(dataDir, version) if err != nil { @@ -356,8 +357,11 @@ func TestDoctorReportsOlderRegistrationWhenNewerPrivateRuntimeIsInstalled(t *tes if err := os.WriteFile(path, []byte(version), 0o755); err != nil { t.Fatal(err) } + if version == "0.15.0" { + registeredPath = path + } } - writeCodexConfig(t, home, filepath.Join(dataDir, "bin", "0.15.0", "gitcontribute")) + writeCodexConfig(t, home, registeredPath) result, err := svc.Doctor(context.Background()) if err != nil { diff --git a/internal/app/corpus_lifecycle_test.go b/internal/app/corpus_lifecycle_test.go index e348c0bf..1d450d60 100644 --- a/internal/app/corpus_lifecycle_test.go +++ b/internal/app/corpus_lifecycle_test.go @@ -94,10 +94,10 @@ func TestSetupFailsFastForUnmarkedCorpusInDryRunAndRealModes(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := db.Exec("CREATE TABLE goose_db_version (id INTEGER PRIMARY KEY, version_id INTEGER)"); err != nil { + if _, err := db.ExecContext(context.Background(), "CREATE TABLE goose_db_version (id INTEGER PRIMARY KEY, version_id INTEGER)"); err != nil { t.Fatal(err) } - if _, err := db.Exec("INSERT INTO goose_db_version (id, version_id) VALUES (1, 9999)"); err != nil { + if _, err := db.ExecContext(context.Background(), "INSERT INTO goose_db_version (id, version_id) VALUES (1, 9999)"); err != nil { t.Fatal(err) } if err := db.Close(); err != nil { diff --git a/internal/app/hydration_test.go b/internal/app/hydration_test.go index 1bb5b473..2fa14a2c 100644 --- a/internal/app/hydration_test.go +++ b/internal/app/hydration_test.go @@ -41,15 +41,15 @@ func (f *fakeHydrationReader) ListIssueTimeline(_ context.Context, _, _ string, return github.ListResult[github.IssueTimelineEvent]{Items: f.issueTimelinePages[idx], Page: page}, nil } -func (f *fakeHydrationReader) GetRepository(ctx context.Context, owner, name string) (github.Repository, github.RateInfo, error) { +func (f *fakeHydrationReader) GetRepository(_ context.Context, owner, name string) (github.Repository, github.RateInfo, error) { return github.Repository{Owner: owner, Name: name, NodeID: "R_1", UpdatedAt: time.Now()}, github.RateInfo{}, nil } -func (f *fakeHydrationReader) ListIssues(ctx context.Context, owner, name string, opts github.ListIssueOptions) (github.ListResult[github.Issue], error) { +func (f *fakeHydrationReader) ListIssues(_ context.Context, owner, name string, opts github.ListIssueOptions) (github.ListResult[github.Issue], error) { return github.ListResult[github.Issue]{}, nil } -func (f *fakeHydrationReader) ListIssueComments(ctx context.Context, owner, name string, issueNumber int, opts github.PageOptions) (github.ListResult[github.IssueComment], error) { +func (f *fakeHydrationReader) ListIssueComments(_ context.Context, owner, name string, issueNumber int, opts github.PageOptions) (github.ListResult[github.IssueComment], error) { if f.failWith != nil && f.issueCommentsCalls >= f.failAfterIssueCalls { return github.ListResult[github.IssueComment]{}, f.failWith } @@ -66,14 +66,14 @@ func (f *fakeHydrationReader) ListIssueComments(ctx context.Context, owner, name return github.ListResult[github.IssueComment]{Items: f.issueCommentsPages[idx], Page: page}, nil } -func (f *fakeHydrationReader) GetPullRequestDetails(ctx context.Context, owner, name string, number int) (github.PullRequestDetails, github.RateInfo, error) { +func (f *fakeHydrationReader) GetPullRequestDetails(_ context.Context, owner, name string, number int) (github.PullRequestDetails, github.RateInfo, error) { if f.failWith != nil { return github.PullRequestDetails{}, github.RateInfo{}, f.failWith } return f.prDetails, github.RateInfo{}, nil } -func (f *fakeHydrationReader) ListPullRequestReviews(ctx context.Context, owner, name string, number int, opts github.PageOptions) (github.ListResult[github.Review], error) { +func (f *fakeHydrationReader) ListPullRequestReviews(_ context.Context, owner, name string, number int, opts github.PageOptions) (github.ListResult[github.Review], error) { if f.failWith != nil && f.prReviewsCalls >= f.failAfterIssueCalls { return github.ListResult[github.Review]{}, f.failWith } @@ -90,7 +90,7 @@ func (f *fakeHydrationReader) ListPullRequestReviews(ctx context.Context, owner, return github.ListResult[github.Review]{Items: f.prReviewsPages[idx], Page: page}, nil } -func (f *fakeHydrationReader) ListPullRequestComments(ctx context.Context, owner, name string, number int, opts github.PageOptions) (github.ListResult[github.ReviewComment], error) { +func (f *fakeHydrationReader) ListPullRequestComments(_ context.Context, owner, name string, number int, opts github.PageOptions) (github.ListResult[github.ReviewComment], error) { if f.failWith != nil && f.prReviewCommentsCalls >= f.failAfterIssueCalls { return github.ListResult[github.ReviewComment]{}, f.failWith } @@ -365,7 +365,7 @@ func TestHydrateBoundsPagination(t *testing.T) { repo, thread := seedRepoAndThread(t, svc, corpus.ThreadKindIssue, 1) pages := make([][]github.IssueComment, 10) for i := range pages { - pages[i] = []github.IssueComment{{ID: int64(i + 1), UpdatedAt: time.Date(2024, 1, 1, 0, 0, int(i), 0, time.UTC)}} + pages[i] = []github.IssueComment{{ID: int64(i + 1), UpdatedAt: time.Date(2024, 1, 1, 0, 0, i, 0, time.UTC)}} } reader := &fakeHydrationReader{issueCommentsPages: pages} svc.SetGitHubReader(reader) diff --git a/internal/app/job_executor_lifecycle_test.go b/internal/app/job_executor_lifecycle_test.go index ca9aa781..9368bbc5 100644 --- a/internal/app/job_executor_lifecycle_test.go +++ b/internal/app/job_executor_lifecycle_test.go @@ -35,7 +35,7 @@ func TestJobExecutorCloseCancelsAndWaits(t *testing.T) { } started := make(chan struct{}) - id, err := jobs.Submit(ctx, "block", nil, func(ctx context.Context, report func(progress, statistics string) error) (any, error) { + id, err := jobs.Submit(ctx, "block", nil, func(ctx context.Context, _ func(progress, statistics string) error) (any, error) { close(started) <-ctx.Done() return nil, ctx.Err() diff --git a/internal/app/job_executor_test.go b/internal/app/job_executor_test.go index 708f25c1..44de89d7 100644 --- a/internal/app/job_executor_test.go +++ b/internal/app/job_executor_test.go @@ -271,7 +271,7 @@ func TestJobCancellation(t *testing.T) { } blocked := make(chan struct{}) - id, err := jobs.Submit(ctx, "block", nil, func(ctx context.Context, report func(progress, statistics string) error) (any, error) { + id, err := jobs.Submit(ctx, "block", nil, func(ctx context.Context, _ func(progress, statistics string) error) (any, error) { close(blocked) <-ctx.Done() return nil, ctx.Err() @@ -309,7 +309,7 @@ func TestCancelQueuedJob(t *testing.T) { } // Delayed function that will never be started before cancel. - id, err := jobs.Submit(ctx, "never", nil, func(ctx context.Context, report func(progress, statistics string) error) (any, error) { + id, err := jobs.Submit(ctx, "never", nil, func(ctx context.Context, _ func(progress, statistics string) error) (any, error) { select { case <-ctx.Done(): return nil, ctx.Err() @@ -365,7 +365,7 @@ func TestJobExecutorBoundsRunningAndPendingJobs(t *testing.T) { queuedRan := make(chan struct{}, 1) third, err := jobs.Submit(ctx, "third", nil, func(context.Context, func(string, string) error) (any, error) { queuedRan <- struct{}{} - return nil, nil + return struct{}{}, nil }) if err != nil { t.Fatalf("submit third: %v", err) @@ -539,7 +539,7 @@ func TestRemoteCancellationReleasesQueuedAdmission(t *testing.T) { queuedRan := make(chan struct{}, 1) queued, err := jobs.Submit(ctx, "queued", nil, func(context.Context, func(string, string) error) (any, error) { queuedRan <- struct{}{} - return nil, nil + return struct{}{}, nil }) if err != nil { t.Fatalf("submit queued: %v", err) diff --git a/internal/app/mcp_stdio_e2e_test.go b/internal/app/mcp_stdio_e2e_test.go index 9d6a0dc8..e96a2c8a 100644 --- a/internal/app/mcp_stdio_e2e_test.go +++ b/internal/app/mcp_stdio_e2e_test.go @@ -33,7 +33,7 @@ func TestMCPStdioHelper(t *testing.T) { if home == "" { t.Skip("stdio helper subprocess only") } - svc, err := New(config.NewPaths(&config.Env{Home: home}), "e2e", nil) + svc, err := NewWithContext(context.Background(), config.NewPaths(&config.Env{Home: home}), "e2e", nil) if err != nil { t.Fatal(err) } @@ -181,9 +181,12 @@ func TestMCPStdioPullRequestPortfolioFlow(t *testing.T) { t.Fatalf("portfolio = %+v, sync job = %+v", portfolio, syncResult) } pr := portfolio.PullRequests[0] - if pr.Ref != "lab/project#7" || pr.Attention != "approved" || pr.ReviewDecision != "approved" || pr.Mergeable == nil || !*pr.Mergeable { + if pr.Ref != "lab/project#7" || pr.ReviewDecision != "approved" || pr.Mergeable == nil || !*pr.Mergeable { t.Fatalf("portfolio PR = %+v", pr) } + if pr.Attention != "stale" { + t.Fatalf("portfolio attention = %q, want stale independently of approval and mergeability", pr.Attention) + } if pr.HeadSHA != "head123" || pr.BaseSHA != "base123" || pr.StatusCoverage != "complete" { t.Fatalf("portfolio status coverage = %+v", pr) } @@ -408,7 +411,7 @@ func replayMCPRecoveryAction(t *testing.T, action mcpcontract.ToolCall) (string, func seedMCPStdioCorpus(ctx context.Context, t *testing.T, home string) { t.Helper() - svc, err := New(config.NewPaths(&config.Env{Home: home}), "e2e", nil) + svc, err := NewWithContext(ctx, config.NewPaths(&config.Env{Home: home}), "e2e", nil) if err != nil { t.Fatal(err) } @@ -441,7 +444,7 @@ func seedMCPStdioCorpus(ctx context.Context, t *testing.T, home string) { func seedMCPStdioEmptyCorpus(ctx context.Context, t *testing.T, home string) { t.Helper() - svc, err := New(config.NewPaths(&config.Env{Home: home}), "e2e", nil) + svc, err := NewWithContext(ctx, config.NewPaths(&config.Env{Home: home}), "e2e", nil) if err != nil { t.Fatal(err) } diff --git a/internal/app/read_boundary_test.go b/internal/app/read_boundary_test.go index 887a11e3..5e6d5d87 100644 --- a/internal/app/read_boundary_test.go +++ b/internal/app/read_boundary_test.go @@ -98,7 +98,7 @@ func TestPublicCorpusReadsDoNotCreateDatabase(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - svc, err := New(config.NewPaths(&config.Env{Home: t.TempDir()}), "test", nil) + svc, err := NewWithContext(ctx, config.NewPaths(&config.Env{Home: t.TempDir()}), "test", nil) if err != nil { t.Fatal(err) } diff --git a/internal/app/upgrade_activation_test.go b/internal/app/upgrade_activation_test.go index a7014749..71a2f039 100644 --- a/internal/app/upgrade_activation_test.go +++ b/internal/app/upgrade_activation_test.go @@ -173,6 +173,8 @@ func TestUpgradeRejectsDestinationRuntimeContractDisagreementBeforeRegistration( } func TestUpgradeRejectsMismatchedPostInstallNPMVersion(t *testing.T) { + t.Setenv("npm_command", "") + t.Setenv("npm_lifecycle_event", "") originalCmd := upgradeCommand originalExec := osExecutable originalGOOS := upgradeGOOS diff --git a/internal/app/upgrade_setup_test.go b/internal/app/upgrade_setup_test.go index b2a17ee8..737ade29 100644 --- a/internal/app/upgrade_setup_test.go +++ b/internal/app/upgrade_setup_test.go @@ -49,8 +49,9 @@ func TestUpgradeActivatesPrivateMCPRuntimeFromTargetRelease(t *testing.T) { } func TestUpgradeNpxActivatesPrivateMCPRuntimeFromLatestRelease(t *testing.T) { - t.Setenv("npm_command", "exec") home, _, _, _, svc := setupUpgradeActivationTest(t, "1.2.3", "1.2.4", "1.2.4") + t.Setenv("npm_command", "exec") + t.Setenv("npm_lifecycle_event", "npx") setRuntimeContract(t, "1.2.4", 1) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) @@ -121,8 +122,9 @@ func TestUpgradeActivatesAlreadyInstalledTargetRuntime(t *testing.T) { } func TestUpgradeNpxStaleBootstrapReportsExplicitLatestRecovery(t *testing.T) { - t.Setenv("npm_command", "exec") _, _, configPath, want, svc := setupUpgradeActivationTest(t, "1.2.3", "1.2.4", "1.2.3") + t.Setenv("npm_command", "exec") + t.Setenv("npm_lifecycle_event", "npx") setRuntimeContract(t, "1.2.3", 1) report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) diff --git a/internal/app/upgrade_test.go b/internal/app/upgrade_test.go index 1a4c653b..8d9dacfa 100644 --- a/internal/app/upgrade_test.go +++ b/internal/app/upgrade_test.go @@ -28,6 +28,7 @@ func TestUpgradeNpxDoesNotInstallGlobalPackage(t *testing.T) { return []byte("1.2.4\n"), nil } t.Setenv("npm_command", "exec") + t.Setenv("npm_lifecycle_event", "npx") svc := &Service{version: "1.2.3", paths: config.NewPaths(&config.Env{Home: t.TempDir()})} report, err := svc.Upgrade(context.Background(), contracts.UpgradeOptions{Yes: true}) if err != nil { @@ -133,6 +134,8 @@ func TestClassifyNPMExecutableDistinguishesProjectAndGlobalInstalls(t *testing.T } func TestUpgradeReportsInspectableStagesForGlobalNPM(t *testing.T) { + t.Setenv("npm_command", "") + t.Setenv("npm_lifecycle_event", "") originalCmd := upgradeCommand originalExec := osExecutable t.Cleanup(func() { @@ -190,6 +193,8 @@ func TestUpgradeReportsInspectableStagesForGlobalNPM(t *testing.T) { } func TestUpgradeGlobalNPMInstallsLatest(t *testing.T) { + t.Setenv("npm_command", "") + t.Setenv("npm_lifecycle_event", "") originalCmd := upgradeCommand originalExec := osExecutable originalGOOS := upgradeGOOS @@ -249,6 +254,8 @@ func TestUpgradeGlobalNPMInstallsLatest(t *testing.T) { } func TestUpgradeWindowsGlobalNPMDoesNotInstall(t *testing.T) { + t.Setenv("npm_command", "") + t.Setenv("npm_lifecycle_event", "") originalCmd := upgradeCommand originalExec := osExecutable originalGOOS := upgradeGOOS @@ -304,6 +311,8 @@ func TestUpgradeWindowsGlobalNPMDoesNotInstall(t *testing.T) { } func TestUpgradeProjectNPMReportsManualUpdate(t *testing.T) { + t.Setenv("npm_command", "") + t.Setenv("npm_lifecycle_event", "") originalCmd := upgradeCommand originalExec := osExecutable t.Cleanup(func() { @@ -419,6 +428,8 @@ func TestUpgradeConfiguredRuntimeOutdated(t *testing.T) { } func TestUpgradeCombinedInstallActivatesPrivateRuntimeFromInstalledPackage(t *testing.T) { + t.Setenv("npm_command", "") + t.Setenv("npm_lifecycle_event", "") originalCmd := upgradeCommand originalExec := osExecutable originalGOOS := upgradeGOOS @@ -512,7 +523,7 @@ func TestUpgradeCorpusSchemaMigrationRequired(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := db.Exec("DELETE FROM goose_db_version"); err != nil { + if _, err := db.ExecContext(context.Background(), "DELETE FROM goose_db_version"); err != nil { t.Fatal(err) } if _, err := db.Exec("INSERT INTO goose_db_version (id, version_id, is_applied, tstamp) VALUES (1, ?, 1, CURRENT_TIMESTAMP)", target-1); err != nil { @@ -673,6 +684,8 @@ func setRuntimeContractOutput(t *testing.T, output string) { func setupUpgradeActivationTest(t *testing.T, currentVersion, targetVersion, candidateVersion string) (home, source, configPath string, want []byte, svc *Service) { t.Helper() + t.Setenv("npm_command", "") + t.Setenv("npm_lifecycle_event", "") originalCmd := upgradeCommand originalExec := osExecutable t.Cleanup(func() { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 215eb928..6537a4d8 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -510,106 +510,7 @@ func (c *CLI) Run(ctx context.Context, args []string) error { ) } - switch cmd { - case "setup": - return c.runSetupCommand(ctx, &cli.Setup) - case "remove": - return c.runRemoveCommand(ctx, &cli.Remove) - case "upgrade": - return c.runUpgrade(ctx, &cli.Upgrade) - case "runtime-contract": - return c.runRuntimeContract(ctx) - case "init": - return c.runInit(ctx, &cli.Init) - case "corpus": - return c.runCorpus(ctx, command, &cli.Corpus) - case "configure": - return c.runConfigure(ctx, &cli.Configure) - case "metadata": - return c.runMetadata(ctx, &cli.Metadata) - case "status": - return c.runStatus(ctx, &cli.Status) - case "doctor": - return c.runDoctor(ctx, &cli.Doctor) - case "health": - return c.runHealth(ctx, &cli.Health) - case "radar": - return c.runRadar(ctx, &cli.Radar) - case "search": - return c.runSearch(ctx, command, &cli.Search) - case "dossier": - return c.runDossier(ctx, command, &cli.Dossier) - case "research": - return c.runResearch(ctx, command, &cli.Research) - case "seeds": - return c.runSeeds(ctx, &cli.Seeds) - case "index": - return c.runIndex(ctx, &cli.Index) - case "acquire": - return c.runAcquire(ctx, &cli.Acquire) - case "source": - return c.runSource(ctx, command, &cli.Source) - case "crawl": - return c.runCrawl(ctx, &cli.Crawl) - case "tail": - return c.runTail(ctx, &cli.Tail) - case "investigation": - return c.runInvestigation(ctx, command, &cli.Investigation) - case "hypothesis": - return c.runHypothesis(ctx, command, &cli.Hypothesis) - case "duplicates": - return c.runCheck(ctx, command, "duplicates", &cli.Duplicates) - case "collisions": - return c.runCheck(ctx, command, "collisions", &cli.Collisions) - case "opportunity": - return c.runOpportunity(ctx, command, &cli.Opportunity) - case "concern": - return c.runConcern(ctx, command, &cli.Concern) - case "workspace": - return c.runWorkspace(ctx, command, &cli.Workspace) - case "diff": - return c.runDiff(ctx, &cli.Diff) - case "validation": - return c.runValidation(ctx, command, &cli.Validation) - case "evidence": - return c.runEvidence(ctx, command, &cli.Evidence) - case "readiness": - return c.runReadiness(ctx, command, &cli.Readiness) - case "prepare": - return c.runPrepare(ctx, command, &cli.Prepare) - case "archive": - return c.runArchive(ctx, command, &cli.Archive) - case "coverage": - return c.runCoverage(ctx, &cli.Coverage) - case "runs": - return c.runRuns(ctx, &cli.Runs) - case "jobs": - return c.runJobs(ctx, command, &cli.Jobs) - case "neighbors": - return c.runNeighbors(ctx, &cli.Neighbors) - case "export": - return c.runExport(ctx, command, &cli.Export) - case "clusters": - return c.runClusters(ctx, command, &cli.Clusters) - case "cluster": - return c.runCluster(ctx, command, &cli.Cluster) - case "lens": - return c.runLens(ctx, command, &cli.Lens) - case "collection": - return c.runCollection(ctx, command, &cli.Collection) - case "triage": - return c.runTriage(ctx, command, &cli.Triage) - case "contribution": - return c.runContribution(ctx, command, &cli.Contribution) - case "tracking": - return c.runTracking(ctx, command, &cli.Tracking) - case "mcp": - return c.runMCP(ctx, &cli.MCP) - case "tui": - return c.runTUI(ctx, &cli.TUI) - default: - return NewCLIError(ExitUsage, fmt.Errorf("unknown command: %s", cmd)) - } + return c.dispatchCommand(ctx, cmd, command, &cli) } func (c *CLI) setupService() (contracts.SetupService, error) { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 80ecba9a..2db74b8f 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -120,12 +120,14 @@ type fakeService struct { type coreOnlyService struct{} -func (coreOnlyService) Init(context.Context) (*contracts.InitResult, error) { return nil, nil } +func (coreOnlyService) Init(context.Context) (*contracts.InitResult, error) { + return &contracts.InitResult{}, nil +} func (coreOnlyService) Status(context.Context) (*contracts.StatusResult, error) { - return nil, nil + return &contracts.StatusResult{}, nil } func (coreOnlyService) Search(context.Context, string, contracts.SearchOptions) (*contracts.SearchResult, error) { - return nil, nil + return &contracts.SearchResult{}, nil } func (coreOnlyService) Dossier(context.Context, contracts.RepoRef) (*contracts.DossierResult, error) { return nil, nil diff --git a/internal/cli/control_jobs_tui_test.go b/internal/cli/control_jobs_tui_test.go index cc12df6f..43ce185c 100644 --- a/internal/cli/control_jobs_tui_test.go +++ b/internal/cli/control_jobs_tui_test.go @@ -56,7 +56,7 @@ func TestControlCommands(t *testing.T) { t.Fatalf("metadata output=%q", stdout.String()) } - c, stdout, _ = newTestCLI(svc, nil) + c, _, _ = newTestCLI(svc, nil) requireNoErr(t, c.Run(context.Background(), []string{"configure", "--crawl-budget", "25", "--dry-run", "--json"})) if svc.configureOpts.CrawlBudget == nil || *svc.configureOpts.CrawlBudget != 25 || !svc.configureOpts.DryRun { t.Fatalf("configure opts=%+v", svc.configureOpts) diff --git a/internal/cli/corpus_commands_test.go b/internal/cli/corpus_commands_test.go index 37fd1902..e7134138 100644 --- a/internal/cli/corpus_commands_test.go +++ b/internal/cli/corpus_commands_test.go @@ -157,7 +157,7 @@ func TestCorpusDestructiveCommandsRequireConsent(t *testing.T) { if err != nil { t.Fatal(err) } - defer input.Close() + defer func() { _ = input.Close() }() c.SetInput(input) for _, args := range [][]string{ {"corpus", "migrate"}, diff --git a/internal/cli/dispatch.go b/internal/cli/dispatch.go new file mode 100644 index 00000000..52fb74cd --- /dev/null +++ b/internal/cli/dispatch.go @@ -0,0 +1,67 @@ +package cli + +import ( + "context" + "fmt" +) + +// dispatchCommand is the narrow routing layer between Kong's parsed command +// tree and the capability-specific handlers. Keeping the table here means the +// top-level CLI owns parsing, logging, and error boundaries while each command +// group owns its own behavior. +func (c *CLI) dispatchCommand(ctx context.Context, cmd, command string, parsed *rootCmd) error { + handlers := map[string]func() error{ + "setup": func() error { return c.runSetupCommand(ctx, &parsed.Setup) }, + "remove": func() error { return c.runRemoveCommand(ctx, &parsed.Remove) }, + "upgrade": func() error { return c.runUpgrade(ctx, &parsed.Upgrade) }, + "runtime-contract": func() error { return c.runRuntimeContract(ctx) }, + "init": func() error { return c.runInit(ctx, &parsed.Init) }, + "corpus": func() error { return c.runCorpus(ctx, command, &parsed.Corpus) }, + "configure": func() error { return c.runConfigure(ctx, &parsed.Configure) }, + "metadata": func() error { return c.runMetadata(ctx, &parsed.Metadata) }, + "status": func() error { return c.runStatus(ctx, &parsed.Status) }, + "doctor": func() error { return c.runDoctor(ctx, &parsed.Doctor) }, + "health": func() error { return c.runHealth(ctx, &parsed.Health) }, + "radar": func() error { return c.runRadar(ctx, &parsed.Radar) }, + "search": func() error { return c.runSearch(ctx, command, &parsed.Search) }, + "dossier": func() error { return c.runDossier(ctx, command, &parsed.Dossier) }, + "research": func() error { return c.runResearch(ctx, command, &parsed.Research) }, + "seeds": func() error { return c.runSeeds(ctx, &parsed.Seeds) }, + "index": func() error { return c.runIndex(ctx, &parsed.Index) }, + "acquire": func() error { return c.runAcquire(ctx, &parsed.Acquire) }, + "source": func() error { return c.runSource(ctx, command, &parsed.Source) }, + "crawl": func() error { return c.runCrawl(ctx, &parsed.Crawl) }, + "tail": func() error { return c.runTail(ctx, &parsed.Tail) }, + "investigation": func() error { return c.runInvestigation(ctx, command, &parsed.Investigation) }, + "hypothesis": func() error { return c.runHypothesis(ctx, command, &parsed.Hypothesis) }, + "duplicates": func() error { return c.runCheck(ctx, command, "duplicates", &parsed.Duplicates) }, + "collisions": func() error { return c.runCheck(ctx, command, "collisions", &parsed.Collisions) }, + "opportunity": func() error { return c.runOpportunity(ctx, command, &parsed.Opportunity) }, + "concern": func() error { return c.runConcern(ctx, command, &parsed.Concern) }, + "workspace": func() error { return c.runWorkspace(ctx, command, &parsed.Workspace) }, + "diff": func() error { return c.runDiff(ctx, &parsed.Diff) }, + "validation": func() error { return c.runValidation(ctx, command, &parsed.Validation) }, + "evidence": func() error { return c.runEvidence(ctx, command, &parsed.Evidence) }, + "readiness": func() error { return c.runReadiness(ctx, command, &parsed.Readiness) }, + "prepare": func() error { return c.runPrepare(ctx, command, &parsed.Prepare) }, + "archive": func() error { return c.runArchive(ctx, command, &parsed.Archive) }, + "coverage": func() error { return c.runCoverage(ctx, &parsed.Coverage) }, + "runs": func() error { return c.runRuns(ctx, &parsed.Runs) }, + "jobs": func() error { return c.runJobs(ctx, command, &parsed.Jobs) }, + "neighbors": func() error { return c.runNeighbors(ctx, &parsed.Neighbors) }, + "export": func() error { return c.runExport(ctx, command, &parsed.Export) }, + "clusters": func() error { return c.runClusters(ctx, command, &parsed.Clusters) }, + "cluster": func() error { return c.runCluster(ctx, command, &parsed.Cluster) }, + "lens": func() error { return c.runLens(ctx, command, &parsed.Lens) }, + "collection": func() error { return c.runCollection(ctx, command, &parsed.Collection) }, + "triage": func() error { return c.runTriage(ctx, command, &parsed.Triage) }, + "contribution": func() error { return c.runContribution(ctx, command, &parsed.Contribution) }, + "tracking": func() error { return c.runTracking(ctx, command, &parsed.Tracking) }, + "mcp": func() error { return c.runMCP(ctx, &parsed.MCP) }, + "tui": func() error { return c.runTUI(ctx, &parsed.TUI) }, + } + if handler, ok := handlers[cmd]; ok { + return handler() + } + return NewCLIError(ExitUsage, fmt.Errorf("unknown command: %s", cmd)) +} diff --git a/internal/cli/setup_cli_test.go b/internal/cli/setup_cli_test.go index 85babf92..16c67b6c 100644 --- a/internal/cli/setup_cli_test.go +++ b/internal/cli/setup_cli_test.go @@ -234,7 +234,7 @@ func TestSetupDoesNotStartWizardWhenPromptOutputIsRedirected(t *testing.T) { if err != nil { t.Fatal(err) } - defer redirected.Close() + defer func() { _ = redirected.Close() }() var stdout bytes.Buffer c := cli.New(&fakeService{}, &fakeMCPRunner{}, &stdout, redirected) @@ -251,7 +251,7 @@ func TestSetupDoesNotAskForConsentWhenPlanOutputIsRedirected(t *testing.T) { if err != nil { t.Fatal(err) } - defer redirected.Close() + defer func() { _ = redirected.Close() }() var stderr bytes.Buffer c := cli.New(&fakeService{}, &fakeMCPRunner{}, redirected, &stderr) diff --git a/internal/cli/surfaces_test.go b/internal/cli/surfaces_test.go index b36ed048..68502c4c 100644 --- a/internal/cli/surfaces_test.go +++ b/internal/cli/surfaces_test.go @@ -355,13 +355,13 @@ func TestLensExplainRequiresRef(t *testing.T) { func TestCollectionCreateAddList(t *testing.T) { t.Parallel() svc := &fakeSurfacesService{fakeService: &fakeService{}} - c, stdout, _ := newSurfacesCLI(svc) + c, _, _ := newSurfacesCLI(svc) requireNoErr(t, c.Run(context.Background(), []string{"collection", "create", "favorites"})) if !svc.createColCalled || svc.lastCreateColName != "favorites" { t.Fatalf("create collection not called: called=%v name=%q", svc.createColCalled, svc.lastCreateColName) } - c2, stdout, _ := newSurfacesCLI(svc) + c2, _, _ := newSurfacesCLI(svc) requireNoErr(t, c2.Run(context.Background(), []string{"collection", "add", "favorites", "repo:o/r", "issue:o/r#1", "pr:o/r#2"})) if !svc.addColCalled || svc.lastAddColName != "favorites" || len(svc.lastAddColMembers) != 3 { t.Fatalf("add collection not called: called=%v name=%q members=%+v", svc.addColCalled, svc.lastAddColName, svc.lastAddColMembers) diff --git a/internal/clusterprojection/contracts_test.go b/internal/clusterprojection/contracts_test.go new file mode 100644 index 00000000..94eaea48 --- /dev/null +++ b/internal/clusterprojection/contracts_test.go @@ -0,0 +1,35 @@ +package clusterprojection + +import ( + "strings" + "testing" + + "github.com/morluto/gitcontribute/internal/similarity" +) + +func TestIdentityMatchesAllProjectionInputs(t *testing.T) { + identity := Identity{SourceRevision: "source-1", GovernanceRevision: 3, RuleVersion: similarity.RuleVersion("duplicate-v1")} + if !identity.Matches("source-1", 3, similarity.RuleVersion("duplicate-v1")) { + t.Fatal("identity did not match equal inputs") + } + for name, args := range map[string]struct { + source string + governance uint64 + rule similarity.RuleVersion + }{ + "source": {source: "source-2", governance: 3, rule: identity.RuleVersion}, + "governance": {source: identity.SourceRevision, governance: 4, rule: identity.RuleVersion}, + "rule": {source: identity.SourceRevision, governance: 3, rule: similarity.RuleVersion("duplicate-v2")}, + } { + if identity.Matches(args.source, args.governance, args.rule) { + t.Errorf("identity matched changed %s input", name) + } + } +} + +func TestStaleInputErrorDescribesBothRevisionChanges(t *testing.T) { + err := (&StaleInputError{ExpectedSource: "old", ActualSource: "new", ExpectedGovernance: 1, ActualGovernance: 2}).Error() + if !strings.Contains(err, `source "old" -> "new"`) || !strings.Contains(err, "governance 1 -> 2") { + t.Fatalf("stale error = %q", err) + } +} diff --git a/internal/codeindex/codeindex_test.go b/internal/codeindex/codeindex_test.go index 7d4f6b81..e2572059 100644 --- a/internal/codeindex/codeindex_test.go +++ b/internal/codeindex/codeindex_test.go @@ -26,7 +26,7 @@ func newRepo(t *testing.T) string { func testGit(t *testing.T, dir string, args ...string) string { t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd := exec.CommandContext(context.Background(), "git", append([]string{"-C", dir}, args...)...) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v failed: %v\n%s", args, err, out) diff --git a/internal/corpus/change_watch_test.go b/internal/corpus/change_watch_test.go index ff9e52eb..be5e5d84 100644 --- a/internal/corpus/change_watch_test.go +++ b/internal/corpus/change_watch_test.go @@ -14,7 +14,7 @@ func TestChangeWatchDetectsCommitFromCorpusConnection(t *testing.T) { if err != nil { t.Fatal(err) } - defer watch.Close() + defer func() { _ = watch.Close() }() if unchanged, err := watch.Unchanged(ctx); err != nil || !unchanged { t.Fatalf("new watch = (%v, %v)", unchanged, err) } diff --git a/internal/corpus/corpus_fixture_test.go b/internal/corpus/corpus_fixture_test.go new file mode 100644 index 00000000..fde10ef2 --- /dev/null +++ b/internal/corpus/corpus_fixture_test.go @@ -0,0 +1,102 @@ +package corpus + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "sync" + "testing" +) + +var ( + testCorpusTemplateOnce sync.Once + testCorpusTemplatePath string + errTestCorpusTemplate error +) + +func openTestCorpus(t *testing.T) (*Corpus, string) { + t.Helper() + ctx := context.Background() + path := filepath.Join(t.TempDir(), "corpus.db") + if err := copyTestDatabase(testCorpusTemplate(t), path); err != nil { + t.Fatalf("copy corpus template: %v", err) + } + c, err := Open(ctx, path) + if err != nil { + t.Fatalf("open corpus: %v", err) + } + t.Cleanup(func() { _ = c.Close() }) + return c, path +} + +func testCorpusTemplate(t *testing.T) string { + t.Helper() + testCorpusTemplateOnce.Do(func() { + dir, err := os.MkdirTemp("", "gitcontribute-corpus-template-") + if err != nil { + errTestCorpusTemplate = err + return + } + testCorpusTemplatePath = filepath.Join(dir, "corpus.db") + c, err := Open(context.Background(), testCorpusTemplatePath) + if err != nil { + errTestCorpusTemplate = err + return + } + if _, err := c.db.ExecContext(context.Background(), "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { + errTestCorpusTemplate = errors.Join(err, c.Close()) + return + } + errTestCorpusTemplate = c.Close() + if errTestCorpusTemplate != nil { + return + } + for _, suffix := range []string{"-wal", "-shm"} { + if err := os.Remove(testCorpusTemplatePath + suffix); err != nil && !errors.Is(err, os.ErrNotExist) { + errTestCorpusTemplate = err + return + } + } + }) + if errTestCorpusTemplate != nil { + t.Fatalf("initialize corpus template: %v", errTestCorpusTemplate) + } + return testCorpusTemplatePath +} + +func copyTestDatabase(source, destination string) error { + in, err := os.Open(source) + if err != nil { + return err + } + defer func() { _ = in.Close() }() + out, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + return errors.Join(err, out.Close()) + } + return out.Close() +} + +func TestTestCorpusTemplateIsCurrentAndStandalone(t *testing.T) { + t.Parallel() + c, path := openTestCorpus(t) + if _, err := os.Stat(testCorpusTemplate(t) + "-wal"); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("template retained a WAL sidecar: %v", err) + } + _, target, err := c.SchemaVersions(context.Background()) + if err != nil { + t.Fatalf("schema versions: %v", err) + } + current, exists, err := InspectSchemaVersion(context.Background(), path) + if err != nil { + t.Fatalf("inspect copied schema: %v", err) + } + if !exists || current != target { + t.Fatalf("copied schema version = %d (exists=%t), want current %d", current, exists, target) + } +} diff --git a/internal/corpus/corpus_test.go b/internal/corpus/corpus_test.go index 2fa80f8d..fdcbd555 100644 --- a/internal/corpus/corpus_test.go +++ b/internal/corpus/corpus_test.go @@ -15,18 +15,6 @@ import ( "github.com/google/go-cmp/cmp" ) -func openTestCorpus(t *testing.T) (*Corpus, string) { - t.Helper() - ctx := context.Background() - path := filepath.Join(t.TempDir(), "corpus.db") - c, err := Open(ctx, path) - if err != nil { - t.Fatalf("open corpus: %v", err) - } - t.Cleanup(func() { _ = c.Close() }) - return c, path -} - func TestMigrationLoggerFatalfRecordsError(t *testing.T) { t.Parallel() logger := &migrationLogger{} @@ -95,7 +83,7 @@ func TestOpenReopensPathAfterInitializationLeaseHandoff(t *testing.T) { if err != nil { t.Fatal(err) } - defer c.Close() + defer func() { _ = c.Close() }() repo, err := c.GetRepository(ctx, "owner", "replacement") if err != nil { t.Fatal(err) @@ -240,7 +228,7 @@ func TestOpenRejectsEmptyDatabaseOwnedByAnotherApplication(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { _ = db.Close() }() var applicationID int if err := db.QueryRowContext(ctx, `PRAGMA application_id`).Scan(&applicationID); err != nil { t.Fatal(err) @@ -274,7 +262,7 @@ func TestOpenRejectsUnmarkedEmptyMigrationTable(t *testing.T) { if err != nil { t.Fatal(err) } - defer db.Close() + defer func() { _ = db.Close() }() var tableCount int if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'repositories'`).Scan(&tableCount); err != nil { t.Fatal(err) @@ -306,7 +294,7 @@ func TestOpenRetriesMarkedInterruptedInitialization(t *testing.T) { if err != nil { t.Fatalf("retry Open: %v", err) } - defer c.Close() + defer func() { _ = c.Close() }() current, target, err := c.SchemaVersions(ctx) if err != nil { t.Fatal(err) @@ -330,7 +318,7 @@ func TestCheckWriteAccessReportsContentionImmediatelyAndRestoresTimeout(t *testi if err != nil { t.Fatal(err) } - defer conn.Close() + defer func() { _ = conn.Close() }() if _, err := conn.ExecContext(ctx, `BEGIN IMMEDIATE`); err != nil { t.Fatal(err) } diff --git a/internal/corpus/dossiers_test.go b/internal/corpus/dossiers_test.go index 8913f573..0cefc491 100644 --- a/internal/corpus/dossiers_test.go +++ b/internal/corpus/dossiers_test.go @@ -18,7 +18,7 @@ func TestDossiersMigration(t *testing.T) { if err != nil { t.Fatalf("query tables: %v", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() var names []string for rows.Next() { diff --git a/internal/corpus/jobs_test.go b/internal/corpus/jobs_test.go index d0340b41..72081ad2 100644 --- a/internal/corpus/jobs_test.go +++ b/internal/corpus/jobs_test.go @@ -347,7 +347,7 @@ func TestBeginReconcileTransactionPreservesConnectionBusyTimeout(t *testing.T) { if err != nil { t.Fatal(err) } - defer conn.Close() + defer func() { _ = conn.Close() }() var before int if err := conn.QueryRowContext(ctx, `PRAGMA busy_timeout`).Scan(&before); err != nil { @@ -375,13 +375,13 @@ func TestBeginReconcileTransactionRetriesBusyWriter(t *testing.T) { if err != nil { t.Fatal(err) } - defer other.Close() + defer func() { _ = other.Close() }() blocker, err := c.db.Conn(ctx) if err != nil { t.Fatal(err) } - defer blocker.Close() + defer func() { _ = blocker.Close() }() if _, err := blocker.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil { t.Fatalf("begin blocker transaction: %v", err) } @@ -390,7 +390,7 @@ func TestBeginReconcileTransactionRetriesBusyWriter(t *testing.T) { if err != nil { t.Fatal(err) } - defer conn.Close() + defer func() { _ = conn.Close() }() if _, err := conn.ExecContext(ctx, "PRAGMA busy_timeout = 1"); err != nil { t.Fatalf("set short busy timeout: %v", err) } diff --git a/internal/corpus/schema_inspect_test.go b/internal/corpus/schema_inspect_test.go index 3316f3dc..9bcca108 100644 --- a/internal/corpus/schema_inspect_test.go +++ b/internal/corpus/schema_inspect_test.go @@ -40,7 +40,7 @@ func TestOpenReadOnlyReadsCurrentCorpus(t *testing.T) { if err != nil { t.Fatal(err) } - defer readOnly.Close() + defer func() { _ = readOnly.Close() }() if _, err := readOnly.db.ExecContext(ctx, `INSERT INTO repositories (owner, name, created_at, updated_at) VALUES ('owner', 'repo', 1, 1)`); err == nil { t.Fatal("read-only corpus accepted a write") } diff --git a/internal/discovery/gharchive_test.go b/internal/discovery/gharchive_test.go index 4fc9d148..fd9d82c9 100644 --- a/internal/discovery/gharchive_test.go +++ b/internal/discovery/gharchive_test.go @@ -109,7 +109,7 @@ func TestArchiveReaderContextCancellation(t *testing.T) { cancel() reader := NewArchiveReader(nil, nil) - err := reader.Read(ctx, time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), bytes.NewReader(gzipLines(eventLine("PushEvent", map[string]any{}))), func(s Signal) error { + err := reader.Read(ctx, time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), bytes.NewReader(gzipLines(eventLine("PushEvent", map[string]any{}))), func(_ Signal) error { return nil }) if !errors.Is(err, context.Canceled) { diff --git a/internal/evidence/flameox_receipt_fixture_test.go b/internal/evidence/flameox_receipt_fixture_test.go index 890990ce..5d70f68c 100644 --- a/internal/evidence/flameox_receipt_fixture_test.go +++ b/internal/evidence/flameox_receipt_fixture_test.go @@ -2,6 +2,7 @@ package evidence import ( "bytes" + "context" _ "embed" "encoding/hex" "encoding/json" @@ -73,7 +74,7 @@ func TestFlameoxProfilerReceiptFixtureRejectsMalformedAndUnknownFields(t *testin if _, err := DigestExternalReceipt(receipt); err != nil { t.Fatal(err) } - if _, err := (&Service{}).AttachExternalReceipt(nil, receipt); err == nil { + if _, err := (&Service{}).AttachExternalReceipt(context.Background(), receipt); err == nil { t.Fatal("wrong artifact digest was accepted") } } diff --git a/internal/failure/errors_test.go b/internal/failure/errors_test.go new file mode 100644 index 00000000..fe38e054 --- /dev/null +++ b/internal/failure/errors_test.go @@ -0,0 +1,25 @@ +package failure + +import ( + "errors" + "strings" + "testing" +) + +func TestNotFoundPreservesCauseAndKind(t *testing.T) { + cause := errors.New("missing repository") + err := NotFound(cause) + if !Is(err, KindNotFound) || !errors.Is(err, cause) { + t.Fatalf("classified error = %v", err) + } + if got := Format(err); !strings.HasPrefix(got, "not_found: missing repository") { + t.Fatalf("formatted error = %q", got) + } +} + +func TestNotFoundWithoutCauseHasStableMessage(t *testing.T) { + err := NotFound(nil) + if !Is(err, KindNotFound) || err.Error() != "requested object not found" { + t.Fatalf("not found error = %v", err) + } +} diff --git a/internal/github/client_guidance_test.go b/internal/github/client_guidance_test.go index 3a1f3c9c..4b3699c5 100644 --- a/internal/github/client_guidance_test.go +++ b/internal/github/client_guidance_test.go @@ -3,6 +3,7 @@ package github import ( "context" "encoding/base64" + "errors" "net/http" "net/http/httptest" "testing" @@ -54,7 +55,8 @@ func TestGetRepositoryFileClassifiesMissingPath(t *testing.T) { client := newTestClient(t, srv, StaticTokenSource("")) _, _, err := client.GetRepositoryFile(context.Background(), testOwner, testRepo, "CONTRIBUTING.md") - if _, ok := err.(*NotFoundError); !ok { + notFoundError := &NotFoundError{} + if !errors.As(err, ¬FoundError) { t.Fatalf("error = %T %v, want *NotFoundError", err, err) } } diff --git a/internal/github/client_test.go b/internal/github/client_test.go index 61aa951e..87fd9ac3 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -21,14 +21,14 @@ const testRepo = "hello-world" type noopLimiter struct{} -func (noopLimiter) WaitN(ctx context.Context, n int) error { return nil } +func (noopLimiter) WaitN(_ context.Context, n int) error { return nil } type countingLimiter struct { calls int err error } -func (l *countingLimiter) WaitN(ctx context.Context, n int) error { +func (l *countingLimiter) WaitN(_ context.Context, n int) error { l.calls++ return l.err } @@ -509,7 +509,7 @@ func TestPermanentAccessErrorsAreTyped(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(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) { w.WriteHeader(tt.status) writeJSON(w, map[string]any{"message": tt.name}) })) @@ -673,7 +673,7 @@ type fakeRunner struct { err error } -func (f fakeRunner) Run(ctx context.Context, name string, args ...string) (string, error) { +func (f fakeRunner) Run(_ context.Context, name string, args ...string) (string, error) { return f.out, f.err } diff --git a/internal/github/retry_replay_test.go b/internal/github/retry_replay_test.go index d05f4014..36a68183 100644 --- a/internal/github/retry_replay_test.go +++ b/internal/github/retry_replay_test.go @@ -2,6 +2,7 @@ package github import ( "bytes" + "context" "io" "net/http" "strings" @@ -23,7 +24,7 @@ func TestRetryTransportRetriesExplicitGraphQLReadAndRecreatesBody(t *testing.T) Sleeper: (&fakeSleeper{}).Sleep, }, } - req, err := http.NewRequest(http.MethodPost, "http://example.com/graphql", bytes.NewBufferString(`{"query":"query ReadOnly { viewer { login } }"}`)) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://example.com/graphql", bytes.NewBufferString(`{"query":"query ReadOnly { viewer { login } }"}`)) if err != nil { t.Fatal(err) } @@ -32,6 +33,7 @@ func TestRetryTransportRetriesExplicitGraphQLReadAndRecreatesBody(t *testing.T) if err != nil { t.Fatal(err) } + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK || ft.index != 2 { t.Fatalf("GraphQL retry status=%d attempts=%d", resp.StatusCode, ft.index) } diff --git a/internal/github/retry_requests_test.go b/internal/github/retry_requests_test.go new file mode 100644 index 00000000..c65c92b8 --- /dev/null +++ b/internal/github/retry_requests_test.go @@ -0,0 +1,16 @@ +package github + +import ( + "context" + "net/http" + "testing" +) + +func newGetRequest(t *testing.T, rawurl string) *http.Request { + t.Helper() + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawurl, nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + return req +} diff --git a/internal/github/retry_test.go b/internal/github/retry_test.go index 4c78964e..d066dbf9 100644 --- a/internal/github/retry_test.go +++ b/internal/github/retry_test.go @@ -93,15 +93,6 @@ func (f *fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) { }, nil } -func newGetRequest(t *testing.T, rawurl string) *http.Request { - t.Helper() - req, err := http.NewRequest(http.MethodGet, rawurl, nil) - if err != nil { - t.Fatalf("new request: %v", err) - } - return req -} - func TestRetryTransportRetries5xx(t *testing.T) { ft := &fakeTransport{ results: []fakeResult{ @@ -126,6 +117,7 @@ func TestRetryTransportRetries5xx(t *testing.T) { if err != nil { t.Fatalf("RoundTrip: %v", err) } + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } @@ -165,6 +157,7 @@ func TestRetryTransportNoRetryOnTerminal4xx(t *testing.T) { if err != nil { t.Fatalf("RoundTrip: %v", err) } + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNotFound { t.Fatalf("status = %d, want 404", resp.StatusCode) } @@ -887,7 +880,7 @@ func TestRetryClientPreservesRateLimiterBehavior(t *testing.T) { } func TestRetryClientContextCancel(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { t.Error("handler should not be called for cancelled context") })) defer srv.Close() diff --git a/internal/health/health_test.go b/internal/health/health_test.go index a7560bc2..3d9ccaa2 100644 --- a/internal/health/health_test.go +++ b/internal/health/health_test.go @@ -24,6 +24,7 @@ func openTestCorpus(t *testing.T) *corpus.Corpus { return c } +//nolint:revive // test helpers keep *testing.T first for readable failure locations. func upsertThread(t *testing.T, ctx context.Context, c *corpus.Corpus, repoID int64, thread corpus.Thread, authorAssoc string) *corpus.Thread { t.Helper() payload, err := json.Marshal(github.Issue{Author: thread.Author, AuthorAssociation: authorAssoc}) @@ -39,6 +40,7 @@ func upsertThread(t *testing.T, ctx context.Context, c *corpus.Corpus, repoID in return out } +//nolint:revive // test helpers keep *testing.T first for readable failure locations. func applyFacet(t *testing.T, ctx context.Context, c *corpus.Corpus, repoID, threadID int64, facet string, sourceUpdated time.Time, payload any, complete bool) { t.Helper() var pages []corpus.FacetObservationInput diff --git a/internal/mcpadapter/runner_test.go b/internal/mcpadapter/runner_test.go new file mode 100644 index 00000000..aa5defc0 --- /dev/null +++ b/internal/mcpadapter/runner_test.go @@ -0,0 +1,16 @@ +package mcpadapter + +import ( + "context" + "strings" + "testing" + + "github.com/morluto/gitcontribute/internal/contracts" +) + +func TestRunnerRejectsUnsupportedTransportBeforeUsingService(t *testing.T) { + err := New(nil, "test").Run(context.Background(), contracts.MCPOptions{Transport: "http"}) + if err == nil || !strings.Contains(err.Error(), `unsupported mcp transport "http"`) { + t.Fatalf("run error = %v", err) + } +} diff --git a/internal/mcpserver/catalog_test.go b/internal/mcpserver/catalog_test.go index 10b9047b..ffffc8f4 100644 --- a/internal/mcpserver/catalog_test.go +++ b/internal/mcpserver/catalog_test.go @@ -3,6 +3,7 @@ package mcpserver import ( "context" "encoding/json" + "errors" "fmt" "sort" "strconv" @@ -123,7 +124,8 @@ func TestStructuredCancellationIsNotRetryable(t *testing.T) { return nil, struct{}{}, context.Canceled }) _, _, err := handler(context.Background(), nil, struct{}{}) - toolErr, ok := err.(*mcpcontract.ToolError) + toolErr := &mcpcontract.ToolError{} + ok := errors.As(err, &toolErr) if !ok || toolErr.Code != "cancelled" || toolErr.Retryable { t.Fatalf("cancellation error = %#v", err) } @@ -140,12 +142,12 @@ func TestReadOnlyModeFiltersEverySideEffectingTool(t *testing.T) { if err != nil { t.Fatal(err) } - defer serverSession.Close() + defer func() { _ = serverSession.Close() }() clientSession, err := client.Connect(context.Background(), t2, nil) if err != nil { t.Fatal(err) } - defer clientSession.Close() + defer func() { _ = clientSession.Close() }() for tool, err := range clientSession.Tools(context.Background(), nil) { if err != nil { t.Fatal(err) @@ -168,12 +170,12 @@ func TestUnsupportedOptionalCapabilitiesAreNotAdvertised(t *testing.T) { if err != nil { t.Fatal(err) } - defer serverSession.Close() + defer func() { _ = serverSession.Close() }() clientSession, err := client.Connect(context.Background(), t2, nil) if err != nil { t.Fatal(err) } - defer clientSession.Close() + defer func() { _ = clientSession.Close() }() names := map[string]bool{} for tool, err := range clientSession.Tools(context.Background(), nil) { if err != nil { diff --git a/internal/mcpserver/schemas.go b/internal/mcpserver/schemas.go index dc23a16e..bc80d34d 100644 --- a/internal/mcpserver/schemas.go +++ b/internal/mcpserver/schemas.go @@ -3,7 +3,9 @@ package mcpserver import ( "encoding/json" "fmt" + "maps" "reflect" + "sync" "github.com/google/jsonschema-go/jsonschema" "github.com/morluto/gitcontribute/internal/mcpcontract" @@ -14,99 +16,121 @@ type schemaDefinition struct { err error } +type schemaCacheEntry struct { + once sync.Once + definition schemaDefinition +} + type schemaBuilder struct { schema *jsonschema.Schema err *error } +// schemaCache caches the reflection-based schema for each Go type so +// repeated tool registration does not re-run jsonschema.For on every call. +// The Once also makes concurrent construction single-flight and retains +// reflection errors, rather than allowing every caller to repeat the failed +// computation. Callers that customize a schema must clone before mutating it. +var schemaCache sync.Map // map[reflect.Type]*schemaCacheEntry + func inferredSchema[T any]() schemaDefinition { - schema, err := jsonschema.For[T](&jsonschema.ForOptions{ - TypeSchemas: map[reflect.Type]*jsonschema.Schema{ - reflect.TypeFor[mcpcontract.Probability](): { - Type: "number", - Description: "Numeric confidence from 0 to 1.", - Minimum: jsonschema.Ptr(0.0), - Maximum: jsonschema.Ptr(1.0), - }, - reflect.TypeFor[mcpcontract.SimilarityScore](): { - Type: "number", - Description: "Normalized similarity score from 0 to 1.", - Minimum: jsonschema.Ptr(0.0), - Maximum: jsonschema.Ptr(1.0), - }, - reflect.TypeFor[mcpcontract.RadarScore](): { - Type: "integer", - Description: "Deterministic Contribution Radar score from 0 to 100.", - Minimum: jsonschema.Ptr(0.0), - Maximum: jsonschema.Ptr(100.0), - }, - reflect.TypeFor[mcpcontract.ProgressPercent](): { - Type: "integer", - Description: "Integer completion percentage from 0 to 100.", - Minimum: jsonschema.Ptr(0.0), - Maximum: jsonschema.Ptr(100.0), - }, - reflect.TypeFor[mcpcontract.NonNegativeInt](): { - Type: "integer", - Description: "Non-negative integer count or delay.", - Minimum: jsonschema.Ptr(0.0), - }, - reflect.TypeFor[mcpcontract.BatchItemStatus](): { - Type: "string", - Description: "Per-item batch outcome.", - Enum: []any{"complete", "retryable", "unavailable", "failed"}, - }, - reflect.TypeFor[mcpcontract.SourceFileStatus](): { - Type: "string", - Description: "Bounded source-file outcome.", - Enum: []any{"complete", "not_found", "too_large", "retryable", "unavailable", "failed"}, - }, - reflect.TypeFor[mcpcontract.JobStatus](): { - Type: "string", - Description: "Durable job lifecycle status.", - Enum: []any{"queued", "running", "succeeded", "failed", "cancelled"}, - }, - reflect.TypeFor[mcpcontract.JobExecutionState](): { - Type: "string", - Description: "Whether a durable job is queued, running, or terminal.", - Enum: []any{"queued", "running", "terminal"}, - }, - reflect.TypeFor[mcpcontract.JobOutcome](): { - Type: "string", - Description: "Terminal job outcome; omitted until execution is terminal.", - Enum: []any{"succeeded", "partial", "failed", "cancelled"}, - }, - reflect.TypeFor[mcpcontract.FixPatternOutcome](): { - Type: "string", - Description: "Pull-request outcome; merged state comes from GitHub and superseded requires an explicit replacement relationship.", - Enum: []any{"merged", "closed_unmerged", "superseded", "open", "unknown"}, - }, - reflect.TypeFor[mcpcontract.FixPatternRelationship](): { - Type: "string", - Description: "Evidence connecting a pull request to an issue.", - Enum: []any{"closes", "references", "explicit_replacement", "similarity_only"}, - }, - reflect.TypeFor[mcpcontract.FixPatternReportStatus](): { - Type: "string", - Description: "Whether the bounded report is complete or retains coverage limits or failures.", - Enum: []any{"complete", "partial"}, - }, - reflect.TypeFor[mcpcontract.FixPatternProofStyle](): { - Type: "string", - Description: "Evidence style detected in stored pull-request text.", - Enum: []any{"regression_test", "reproduction", "benchmark", "before_after", "screenshot"}, - }, - reflect.TypeFor[mcpcontract.FixPatternRelatedKind](): { - Type: "string", - Description: "Stored thread kind of a related target.", - Enum: []any{"issue", "pull_request"}, + key := reflect.TypeFor[T]() + entry, _ := schemaCache.LoadOrStore(key, &schemaCacheEntry{}) + cached, ok := entry.(*schemaCacheEntry) + if !ok { + panic("MCP schema cache contains an invalid entry") + } + cached.once.Do(func() { + schema, err := jsonschema.For[T](&jsonschema.ForOptions{ + TypeSchemas: map[reflect.Type]*jsonschema.Schema{ + reflect.TypeFor[mcpcontract.Probability](): { + Type: "number", + Description: "Numeric confidence from 0 to 1.", + Minimum: jsonschema.Ptr(0.0), + Maximum: jsonschema.Ptr(1.0), + }, + reflect.TypeFor[mcpcontract.SimilarityScore](): { + Type: "number", + Description: "Normalized similarity score from 0 to 1.", + Minimum: jsonschema.Ptr(0.0), + Maximum: jsonschema.Ptr(1.0), + }, + reflect.TypeFor[mcpcontract.RadarScore](): { + Type: "integer", + Description: "Deterministic Contribution Radar score from 0 to 100.", + Minimum: jsonschema.Ptr(0.0), + Maximum: jsonschema.Ptr(100.0), + }, + reflect.TypeFor[mcpcontract.ProgressPercent](): { + Type: "integer", + Description: "Integer completion percentage from 0 to 100.", + Minimum: jsonschema.Ptr(0.0), + Maximum: jsonschema.Ptr(100.0), + }, + reflect.TypeFor[mcpcontract.NonNegativeInt](): { + Type: "integer", + Description: "Non-negative integer count or delay.", + Minimum: jsonschema.Ptr(0.0), + }, + reflect.TypeFor[mcpcontract.BatchItemStatus](): { + Type: "string", + Description: "Per-item batch outcome.", + Enum: []any{"complete", "retryable", "unavailable", "failed"}, + }, + reflect.TypeFor[mcpcontract.SourceFileStatus](): { + Type: "string", + Description: "Bounded source-file outcome.", + Enum: []any{"complete", "not_found", "too_large", "retryable", "unavailable", "failed"}, + }, + reflect.TypeFor[mcpcontract.JobStatus](): { + Type: "string", + Description: "Durable job lifecycle status.", + Enum: []any{"queued", "running", "succeeded", "failed", "cancelled"}, + }, + reflect.TypeFor[mcpcontract.JobExecutionState](): { + Type: "string", + Description: "Whether a durable job is queued, running, or terminal.", + Enum: []any{"queued", "running", "terminal"}, + }, + reflect.TypeFor[mcpcontract.JobOutcome](): { + Type: "string", + Description: "Terminal job outcome; omitted until execution is terminal.", + Enum: []any{"succeeded", "partial", "failed", "cancelled"}, + }, + reflect.TypeFor[mcpcontract.FixPatternOutcome](): { + Type: "string", + Description: "Pull-request outcome; merged state comes from GitHub and superseded requires an explicit replacement relationship.", + Enum: []any{"merged", "closed_unmerged", "superseded", "open", "unknown"}, + }, + reflect.TypeFor[mcpcontract.FixPatternRelationship](): { + Type: "string", + Description: "Evidence connecting a pull request to an issue.", + Enum: []any{"closes", "references", "explicit_replacement", "similarity_only"}, + }, + reflect.TypeFor[mcpcontract.FixPatternReportStatus](): { + Type: "string", + Description: "Whether the bounded report is complete or retains coverage limits or failures.", + Enum: []any{"complete", "partial"}, + }, + reflect.TypeFor[mcpcontract.FixPatternProofStyle](): { + Type: "string", + Description: "Evidence style detected in stored pull-request text.", + Enum: []any{"regression_test", "reproduction", "benchmark", "before_after", "screenshot"}, + }, + reflect.TypeFor[mcpcontract.FixPatternRelatedKind](): { + Type: "string", + Description: "Stored thread kind of a related target.", + Enum: []any{"issue", "pull_request"}, + }, }, - }, + }) + if err != nil { + cached.definition.err = fmt.Errorf("infer MCP schema: %w", err) + return + } + cached.definition.schema = schema }) - if err != nil { - return schemaDefinition{err: fmt.Errorf("infer MCP schema: %w", err)} - } - return schemaDefinition{schema: schema} + return cached.definition } func inputSchema[T any](customize func(*schemaBuilder)) schemaDefinition { @@ -114,21 +138,104 @@ func inputSchema[T any](customize func(*schemaBuilder)) schemaDefinition { if definition.err != nil { return definition } + // Clone the cached schema before mutating so the cached instance + // is not corrupted for other callers. The customize function + // mutates properties on the schema (ranges, defaults, enums). + clone := cloneSchemaTree(definition.schema) var buildErr error - builder := &schemaBuilder{schema: definition.schema, err: &buildErr} + builder := &schemaBuilder{schema: clone, err: &buildErr} if customize != nil { customize(builder) } - definition.err = buildErr - return definition + return schemaDefinition{schema: clone, err: buildErr} } func outputSchema[T any](description string) schemaDefinition { definition := inferredSchema[T]() - if definition.err == nil { - definition.schema.Description = description + if definition.err != nil { + return definition + } + // Clone the cached schema before mutating Description so the + // cached instance is not corrupted for other callers. + clone := cloneSchemaTree(definition.schema) + clone.Description = description + return schemaDefinition{schema: clone} +} + +// cloneSchemaTree copies the complete mutable schema tree. jsonschema's +// CloneSchemas copies nested schemas, but intentionally leaves non-schema +// maps and slices shallow. Customizers update maps such as +// DependentRequired, so those values must not be shared between registrations. +func cloneSchemaTree(schema *jsonschema.Schema) *jsonschema.Schema { + clone := schema.CloneSchemas() + seen := make(map[*jsonschema.Schema]bool) + var copyMutable func(*jsonschema.Schema) + copyMutable = func(current *jsonschema.Schema) { + if current == nil || seen[current] { + return + } + seen[current] = true + current.Types = append([]string(nil), current.Types...) + current.Enum = append([]any(nil), current.Enum...) + current.Examples = append([]any(nil), current.Examples...) + current.Default = append([]byte(nil), current.Default...) + current.Required = append([]string(nil), current.Required...) + current.PropertyOrder = append([]string(nil), current.PropertyOrder...) + if current.Vocabulary != nil { + current.Vocabulary = maps.Clone(current.Vocabulary) + } + if current.DependencyStrings != nil { + current.DependencyStrings = cloneStringSlices(current.DependencyStrings) + } + if current.DependentRequired != nil { + current.DependentRequired = cloneStringSlices(current.DependentRequired) + } + for _, child := range schemaChildren(current) { + copyMutable(child) + } + } + copyMutable(clone) + return clone +} + +func cloneStringSlices(values map[string][]string) map[string][]string { + clone := make(map[string][]string, len(values)) + for key, value := range values { + clone[key] = append([]string(nil), value...) + } + return clone +} + +func schemaChildren(schema *jsonschema.Schema) []*jsonschema.Schema { + children := make([]*jsonschema.Schema, 0) + for _, child := range schema.Defs { + children = append(children, child) + } + for _, child := range schema.Definitions { + children = append(children, child) + } + for _, child := range schema.DependencySchemas { + children = append(children, child) + } + children = append(children, schema.PrefixItems...) + children = append(children, schema.Items, schema.AdditionalItems, schema.Contains, schema.UnevaluatedItems) + for _, child := range schema.Properties { + children = append(children, child) + } + for _, child := range schema.PatternProperties { + children = append(children, child) + } + children = append(children, schema.AdditionalProperties, schema.PropertyNames, schema.UnevaluatedProperties) + children = append(children, schema.AllOf...) + children = append(children, schema.AnyOf...) + children = append(children, schema.OneOf...) + children = append(children, schema.Not, schema.If, schema.Then, schema.Else) + for _, child := range schema.DependentSchemas { + children = append(children, child) } - return definition + children = append(children, schema.ContentSchema) + children = append(children, schema.ItemsArray...) + return children } func property(builder *schemaBuilder, name string) *jsonschema.Schema { diff --git a/internal/mcpserver/schemas_test.go b/internal/mcpserver/schemas_test.go new file mode 100644 index 00000000..cceb20b3 --- /dev/null +++ b/internal/mcpserver/schemas_test.go @@ -0,0 +1,93 @@ +package mcpserver + +import ( + "sync" + "testing" + + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +func TestInferredSchemaIsSharedByType(t *testing.T) { + first := inferredSchema[mcpcontract.RepoInput]() + second := inferredSchema[mcpcontract.RepoInput]() + if first.err != nil || second.err != nil || first.schema != second.schema { + t.Fatalf("cached schema definitions differ: first=%p/%v second=%p/%v", first.schema, first.err, second.schema, second.err) + } +} + +func TestCustomizedSchemasDoNotShareMutableState(t *testing.T) { + first := inputSchema[mcpcontract.SearchCodeInput](func(schema *schemaBuilder) { + setRange(schema, "limit", 1, 7) + requireTogether(schema, "owner", "repo") + }) + second := inputSchema[mcpcontract.SearchCodeInput](func(schema *schemaBuilder) { + setRange(schema, "limit", 1, 99) + }) + if first.err != nil || second.err != nil { + t.Fatalf("schema customization failed: %v / %v", first.err, second.err) + } + if got := *first.schema.Properties["limit"].Maximum; got != 7 { + t.Fatalf("first maximum = %v, want 7", got) + } + if got := *second.schema.Properties["limit"].Maximum; got != 99 { + t.Fatalf("second maximum = %v, want 99", got) + } + if len(second.schema.DependentRequired) != 0 { + t.Fatalf("dependent required state leaked into second schema: %#v", second.schema.DependentRequired) + } +} + +func TestNestedDefinitionsAndArrayItemsRemainCustomizable(t *testing.T) { + definition := inputSchema[mcpcontract.GetCoverageInput](func(schema *schemaBuilder) { + setArrayBounds(schema, "targets", 1, 7) + configureCoverageTargetFields(schema) + }) + if definition.err != nil { + t.Fatal(definition.err) + } + targets := definition.schema.Properties["targets"] + if targets == nil || targets.Items == nil || targets.MaxItems == nil || *targets.MaxItems != 7 { + t.Fatalf("targets schema lost array customization: %#v", targets) + } + target := definition.schema.Defs["CoverageTarget"] + if target == nil { + target = targets.Items + } + if target == nil || len(target.Properties["type"].Enum) != 2 { + t.Fatalf("nested target definition lost enum customization: %#v", target) + } +} + +func TestConcurrentServerConstructionProducesIdenticalCatalogs(t *testing.T) { + const count = 12 + fingerprints := make(chan string, count) + errs := make(chan error, count) + var group sync.WaitGroup + for range count { + group.Go(func() { + server, err := New(&fakeReader{}, "test") + if err != nil { + errs <- err + return + } + fingerprints <- server.catalogFingerprint() + }) + } + group.Wait() + close(fingerprints) + close(errs) + for err := range errs { + t.Fatal(err) + } + var want string + for fingerprint := range fingerprints { + if want == "" { + want = fingerprint + } else if fingerprint != want { + t.Fatalf("catalog fingerprint = %q, want %q", fingerprint, want) + } + } + if want == "" { + t.Fatal("no catalog fingerprints recorded") + } +} diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index e268279a..49439857 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -65,7 +65,7 @@ func (*fakeReader) SearchPullRequestFeedback(context.Context, mcpcontract.Search return mcpcontract.SearchPullRequestFeedbackOutput{Status: "complete"}, nil } -func (*fakeReader) GetThreadFacets(_ context.Context, in mcpcontract.GetThreadFacetsInput) (mcpcontract.GetThreadFacetsOutput, error) { +func (*fakeReader) GetThreadFacets(_ context.Context, _ mcpcontract.GetThreadFacetsInput) (mcpcontract.GetThreadFacetsOutput, error) { return mcpcontract.GetThreadFacetsOutput{Status: "complete"}, nil } @@ -391,7 +391,7 @@ func (*fakeReader) Draft(_ context.Context, in mcpcontract.DraftInput) (mcpcontr return mcpcontract.DraftOutput{ID: in.ID, Revision: in.Revision, OpportunityID: "opp-1", Kind: "issue", Title: "draft", Body: "body"}, nil } -func (f *fakeReader) AttachValidationReceipt(_ context.Context, in mcpcontract.AttachValidationReceiptInput) (mcpcontract.ExternalValidationReceiptOutput, error) { +func (f *fakeReader) AttachValidationReceipt(_ context.Context, _ mcpcontract.AttachValidationReceiptInput) (mcpcontract.ExternalValidationReceiptOutput, error) { f.recordCall("attach_validation_receipt") return mcpcontract.ExternalValidationReceiptOutput{RunID: "external-run", ReceiptSHA256: "digest"}, nil } @@ -401,7 +401,7 @@ func (f *fakeReader) VerifyPublishedDraft(_ context.Context, in mcpcontract.Veri return mcpcontract.PublishedDraftVerificationOutput{Status: "exact_match", DraftID: in.DraftID, Revision: in.Revision}, nil } -func (*fakeReader) ExportManifest(_ context.Context, in mcpcontract.ExportManifestInput) (mcpcontract.ManifestOutput, error) { +func (*fakeReader) ExportManifest(_ context.Context, _ mcpcontract.ExportManifestInput) (mcpcontract.ManifestOutput, error) { return mcpcontract.ManifestOutput{ManifestID: "sha256:test", ContentSHA256: "test", SchemaVersion: "contribution-evidence.v1", Status: "incomplete"}, nil } diff --git a/internal/precedent/models_test.go b/internal/precedent/models_test.go new file mode 100644 index 00000000..f22adc38 --- /dev/null +++ b/internal/precedent/models_test.go @@ -0,0 +1,14 @@ +package precedent + +import ( + "testing" + + "github.com/morluto/gitcontribute/internal/domain" +) + +func TestRepositoryKeyNormalizesOwnerAndRepository(t *testing.T) { + got := RepositoryKey(domain.RepoRef{Owner: "Morluto", Repo: "GitContribute"}) + if got != "morluto/gitcontribute" { + t.Fatalf("repository key = %q", got) + } +} diff --git a/internal/redaction/redaction_test.go b/internal/redaction/redaction_test.go new file mode 100644 index 00000000..c3cb565d --- /dev/null +++ b/internal/redaction/redaction_test.go @@ -0,0 +1,25 @@ +package redaction + +import "testing" + +func TestStringRedactsSupportedCredentialForms(t *testing.T) { + input := `token="secret-value" Authorization: Bearer abc123 ghp_abcdefghijklmnopqrstuvwxyz1234567890` + got := String(input) + for _, secret := range []string{"secret-value", "abc123", "ghp_abcdefghijklmnopqrstuvwxyz1234567890"} { + if contains(got, secret) { + t.Errorf("redacted output contains %q: %q", secret, got) + } + } + if got == input { + t.Fatal("credential input was unchanged") + } +} + +func contains(value, needle string) bool { + for i := 0; i+len(needle) <= len(value); i++ { + if value[i:i+len(needle)] == needle { + return true + } + } + return false +} diff --git a/internal/repositorycontext/policy_test.go b/internal/repositorycontext/policy_test.go new file mode 100644 index 00000000..8d91404d --- /dev/null +++ b/internal/repositorycontext/policy_test.go @@ -0,0 +1,15 @@ +package repositorycontext + +import "testing" + +func TestGuidancePathsReturnsIndependentPolicyCopy(t *testing.T) { + paths := GuidancePaths() + if len(paths) == 0 || RequestCost() != len(paths)+2 { + t.Fatalf("paths=%v request cost=%d", paths, RequestCost()) + } + original := paths[0] + paths[0] = "changed" + if GuidancePaths()[0] != original { + t.Fatal("guidance path policy leaked mutable storage") + } +} diff --git a/internal/terminalinstall/npm_test.go b/internal/terminalinstall/npm_test.go new file mode 100644 index 00000000..ffdf884e --- /dev/null +++ b/internal/terminalinstall/npm_test.go @@ -0,0 +1,22 @@ +package terminalinstall + +import ( + "errors" + "strings" + "testing" +) + +func TestCommandFailureIncludesOutputWithoutDroppingCause(t *testing.T) { + cause := errors.New("exit status 1") + err := commandFailure("install persistent CLI", []byte("permission denied\n"), cause) + if !errors.Is(err, cause) || !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("command failure = %v", err) + } +} + +func TestCommandFailureOmitsEmptyOutput(t *testing.T) { + err := commandFailure("resolve prefix", nil, errors.New("failed")) + if err.Error() != "resolve prefix: failed" { + t.Fatalf("command failure = %q", err) + } +} diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index be43b155..9dc7c3a6 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -12,7 +12,7 @@ import ( func runGit(t *testing.T, dir string, args ...string) string { t.Helper() - cmd := exec.Command("git", append([]string{"--no-pager"}, args...)...) + cmd := exec.CommandContext(context.Background(), "git", append([]string{"--no-pager"}, args...)...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index ee3f00d1..b0fe1028 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -24,7 +24,7 @@ func TestInitAndStatus(t *testing.T) { if err != nil { t.Fatalf("failed to create application service: %v", err) } - defer svc.Close() + defer func() { _ = svc.Close() }() result, err := svc.Init(context.Background()) if err != nil { @@ -53,7 +53,7 @@ func TestMetadataConfirmsVersion(t *testing.T) { if err != nil { t.Fatalf("failed to create application service: %v", err) } - defer svc.Close() + defer func() { _ = svc.Close() }() metadata, err := svc.Metadata(context.Background()) if err != nil { @@ -77,7 +77,7 @@ func TestDoctorWithoutCorpus(t *testing.T) { if err != nil { t.Fatalf("failed to create application service: %v", err) } - defer svc.Close() + defer func() { _ = svc.Close() }() result, err := svc.Doctor(context.Background()) if err != nil {