diff --git a/.claude/task-boards/feature-change-safety-testability-refactors.md b/.claude/task-boards/feature-change-safety-testability-refactors.md
new file mode 100644
index 0000000..ef939f0
--- /dev/null
+++ b/.claude/task-boards/feature-change-safety-testability-refactors.md
@@ -0,0 +1,656 @@
+# Task board: feature/change-safety-testability-refactors
+
+Status: final gate prep
+Branch: feature/change-safety-testability-refactors
+Last updated: 2026-07-27
+Not final product docs: this is implementation planning for the branch, not shipped user-facing documentation.
+
+## Branch setup update
+
+Completed before implementation work:
+
+- Rebased `feature/change-safety-testability-refactors` onto latest `main` after `feature/production-reliability-data-readiness` was merged.
+- Force-updated the remote branch with `--force-with-lease` after the rebase.
+- Verified the working tree is clean before assigning implementation workstreams.
+
+Agent spawning note:
+
+- New worker spawns were attempted after the rebase, but the workspace was at the agent thread limit.
+- Existing completed-agent summaries were collected and used to refine the workstream split below.
+- The branch can proceed with these workstreams as soon as agent capacity is available, or the main thread can take the first workstream locally.
+- Completed old agent threads were closed after Workstream A landed, freeing slots for the next implementation round.
+- Worker handoffs have landed and this board has been reconciled against the implemented rule IDs, tests, and docs in the final parity audit below.
+
+## Final parity audit
+
+Audited on 2026-07-27 against `internal/codeguard/rules/catalog_change_safety.go`, `internal/codeguard/rules/catalog_fix_templates_change_safety.go`, `internal/codeguard/checks/change/*`, `internal/codeguard/checks/quality/quality_precision.go`, `internal/codeguard/runner/pr_summary.go`, `tests/checks/*change*`, `tests/checks/*testability*`, `tests/checks/*precision*`, `tests/checks/*maintainability*`, `internal/codeguard/runner/pr_summary_test.go`, and the SDK metadata tests.
+
+Implemented detector subset in the current worktree:
+
+- `change.oversized-diff`
+- `change.mixed-concerns`
+- `change.too-many-concerns`
+- `change.mixed-refactor-and-behavior`
+- `change.unnecessary-surface-area`
+- `change.one-use-abstraction`
+- `change.duplicate-helper`
+- `change.cleanup-regression`
+- `change.complexity-increased`
+- `change.move-without-verification`
+- `testing.behavior-change-without-test`
+- `testing.failure-path-missing`
+- `testing.hardwired-dependency`
+- `testing.nondeterministic-domain-logic`
+- `naming.generic-identifier`
+- `function.excessive-parameters`
+- `function.mixed-abstraction-level`
+- `function.command-query-mix`
+- `error.logged-and-ignored`
+- `error.context-lost`
+- `defensive.unchecked-type-assertion`
+- `defensive.unsafe-numeric-conversion`
+- `maintainability.public-surface-growth`
+- `maintainability.dependency-growth`
+- `maintainability.hotspot`
+- `maintainability.high-churn-hotspot`
+- `maintainability.repeat-defect-area`
+- `maintainability.unstable-interface`
+- `maintainability.change-amplification`
+- `smell.shotgun-surgery-history`
+- `smell.divergent-change-history`
+- `pr_summary.change_safety`
+- `pr_summary.maintainability_delta`
+- `pr_summary.refactor_confidence`
+
+Catalog/config/deferred IDs for this branch:
+
+- `testing.legacy-hotspot-uncovered`: cataloged/configured, intentionally non-emitting without reliable history/hotspot inputs.
+- `refactor.*`: direct detector code and `tests/checks/refactor_test.go` are present and green in the current implementation. `pr_summary.refactor_confidence` rolls up `refactor.*` findings plus implemented mixed-refactor/move-without-verification signals.
+
+Metadata/doc parity:
+
+- Every built-in rule in the branch catalog has explicit language coverage through the rule metadata helpers.
+- Every branch catalog rule has a populated guided fix template.
+- `docs/checks.md` and `docs/features.md` distinguish implemented detectors from catalog/planned IDs so planned-only behavior is not described as shipped.
+- `examples/codeguard.json` was updated for the final `change_rules` config surface after concurrent config changes added direct refactor left-behind toggles.
+
+Current gate blocker:
+
+- None known after the safe-refactor worker landed. Final branch gates still need to run on the quiescent branch before PR handoff.
+- Full-parity worker assignments after MVP landed:
+ - Einstein (`019fa485-4e06-73e1-9cd8-67592526456d`): Phase 3 safe-refactor detectors.
+ - Nietzsche (`019fa485-8175-7540-9df5-dc0ca89ac3bd`): remaining Phase 2 change-smell detectors.
+ - McClintock (`019fa485-b88b-71c0-a426-53fc5cee95a4`): Phase 6 history-aware maintainability and smell signals.
+ - Hume (`019fa485-f437-74b1-b617-62805c7ab20a`): docs/task-board parity audit and final checklist.
+
+## Agent workstreams
+
+These workstreams are intentionally disjoint. Workers must not revert unrelated edits and should list changed files in their handoff.
+
+### Workstream A: scaffolding, config, catalogs, and profiles
+
+Status: complete in main thread; implementation committed/pushed separately from detector work.
+
+Ownership:
+
+- `internal/codeguard/core/config_types.go`
+- `internal/codeguard/core/config_rule_types.go`
+- `internal/codeguard/config/defaults*.go`
+- `internal/codeguard/config/example*.go`
+- `internal/codeguard/config/profile.go`
+- `internal/codeguard/config/validate*.go`
+- `internal/codeguard/rules/catalog_change_safety.go`
+- `internal/codeguard/rules/catalog_fix_templates_change_safety.go`
+- `pkg/codeguard/sdk_types_config_checks.go`
+- config/profile/metadata tests
+
+Tasks:
+
+- Add a minimal top-level `change` section toggle and `ChangeRulesConfig`.
+- Add thresholds for changed files, changed directories, changed lines, changed public interfaces, concern-family count, and production/test ratio.
+- Add defaults, examples, validation, and SDK aliases.
+- Add initial metadata/fix templates for the Phase 1/2/4 rules that will have detector support in this branch.
+- Wire profile behavior:
+ - `startup`: keep change-safety off unless explicitly enabled.
+ - `strict`: enable high-confidence change/testability gates.
+ - `enterprise`: inherit strict.
+ - `ai-safe`: enable stronger oversized-diff, missing-test, weak-refactor-confidence, duplicated-helper, and unnecessary-abstraction signals.
+
+Targeted verification:
+
+```sh
+go test ./internal/codeguard/config ./tests/cli ./pkg/codeguard
+go test ./internal/codeguard/... ./pkg/codeguard ./tests/cli
+```
+
+### Workstream B: change section and diff concentration detectors
+
+Status: complete for the Phase 1/2 detector subset and cleanup-style change-smell detectors.
+
+Ownership:
+
+- `internal/codeguard/checks/change/**`
+- `internal/codeguard/runner/checks/registry.go`
+- `tests/checks/change*_test.go`
+- helper additions under `internal/codeguard/checks/support/**` only if needed
+
+Tasks:
+
+- Add the `Change Safety` section runner.
+- Run primarily in diff mode; full scans should no-op or emit only explicitly safe repo-level diagnostics.
+- Compute change concentration evidence:
+ - files touched
+ - directories touched
+ - architectural layer/path categories touched
+ - production/test file ratio
+ - public-surface file hints
+- Detect:
+ - `change.oversized-diff`
+ - `change.mixed-concerns`
+ - `change.too-many-concerns`
+ - `change.mixed-refactor-and-behavior`
+ - `change.unnecessary-surface-area`
+ - `change.move-without-verification`
+- Keep findings deterministic and confidence-based.
+
+Targeted verification:
+
+```sh
+go test ./tests/checks -run 'TestChange'
+go test ./internal/codeguard/runner/checks
+```
+
+### Workstream C: testability detectors
+
+Status: complete for behavior-change, failure-path, hardwired-dependency, and nondeterministic-domain detectors; legacy-hotspot emission deferred until reliable history inputs are available.
+
+Ownership:
+
+- `internal/codeguard/checks/change/testability*.go` or a clearly named sibling under the change package
+- `tests/checks/testing*_test.go`
+- no catalog/config edits except small integration adjustments coordinated with Workstream A
+
+Tasks:
+
+- Detect:
+ - `testing.behavior-change-without-test`
+ - `testing.failure-path-missing`
+ - `testing.hardwired-dependency`
+ - `testing.nondeterministic-domain-logic`
+ - `testing.legacy-hotspot-uncovered` as warn-only if history inputs are available; otherwise leave a documented TODO and do not emit a misleading finding.
+- Start with Go, Python, TypeScript, JavaScript, and C++ path/text heuristics where safe.
+- Add positive and negative tests for changed production files with/without changed tests.
+- Avoid duplicating CI test-quality findings unless the evidence is about change safety, not test style.
+
+Targeted verification:
+
+```sh
+go test ./tests/checks -run 'TestTesting'
+```
+
+### Workstream D: PR-summary metrics
+
+Status: complete for additive artifact fields and deterministic finding-family rollups.
+
+Ownership:
+
+- `internal/codeguard/core/report_artifact_types.go`
+- `internal/codeguard/checks/support/artifacts.go`
+- `internal/codeguard/runner/pr_summary.go`
+- `internal/codeguard/runner/pr_summary_test.go`
+- `pkg/codeguard/sdk_types_runtime_report.go`
+- report serialization tests only if artifact shape requires them
+
+Tasks:
+
+- Extend existing `pr_summary` additively with:
+ - `change_safety`
+ - `maintainability_delta`
+ - `refactor_confidence`
+- Preserve existing `production_risk` behavior from the merged production-readiness branch.
+- Keep metrics artifact-only; do not emit GitHub annotations for metrics.
+- Keep the existing text `Summary:` sentence unchanged.
+- Sort evidence deterministically.
+
+Targeted verification:
+
+```sh
+go test ./internal/codeguard/runner ./tests/codeguard ./tests/checks -run 'Test.*PRSummary|TestWriteReport'
+```
+
+### Workstream E: local quality precision and maintainability delta
+
+Status: complete for the small high-value subset plus history-aware maintainability/smell detectors that degrade gracefully when git history is unavailable.
+
+Ownership:
+
+- `internal/codeguard/checks/quality/**`
+- `internal/codeguard/checks/design/**` only for graph/delta helpers
+- `internal/codeguard/history/**` only for read-only history metrics
+- `tests/checks/naming*_test.go`
+- `tests/checks/function*_test.go`
+- `tests/checks/error*_test.go`
+- `tests/checks/defensive*_test.go`
+- `tests/checks/maintainability*_test.go`
+
+Tasks:
+
+- Start with a small, high-value subset instead of every planned smell:
+ - `naming.generic-identifier`
+ - `function.excessive-parameters`
+ - `function.mixed-abstraction-level`
+ - `function.command-query-mix`
+ - `error.logged-and-ignored`
+ - `error.context-lost`
+ - `defensive.unchecked-type-assertion`
+ - `defensive.unsafe-numeric-conversion`
+ - `maintainability.public-surface-growth`
+ - `maintainability.dependency-growth`
+- Reuse existing quality/design metrics where possible.
+- Prefer warnings unless evidence is direct and high-confidence.
+
+Targeted verification:
+
+```sh
+go test ./tests/checks -run 'Test(Naming|Function|Error|Defensive|Maintainability)'
+```
+
+## Goal
+
+Make CodeGuard evaluate whether a PR is safe, incremental, understandable, testable, and actually improves the code it touches.
+
+This branch owns change-quality, testability, safe-refactor, code-smell, naming/function/error/defensive-programming, and maintainability-delta work. The product target is to answer:
+
+> Did this PR make the system safer, simpler, easier to change, and less likely to fail?
+
+## Non-goals
+
+- Do not implement reliability/data-outage rules owned by `feature/production-reliability-data-readiness`.
+- Do not implement observability, ownership, runbook, or deployment-governance rules owned by `feature/operability-design-delivery-governance`.
+- Do not overfit one language/framework. Start with the languages where CodeGuard already has parser coverage and tests.
+- Do not claim semantic equivalence for refactors. The goal is confidence and evidence, not proof.
+
+## Product split
+
+This branch owns:
+
+- Rule families: `testing.*`, `change.*`, `refactor.*`, `smell.*`, `maintainability.*`, `naming.*`, `function.*`, `error.*`, and `defensive.*`.
+- Product metrics in the shared `pr_summary` artifact:
+ - `change_safety`
+ - `maintainability_delta`
+ - `refactor_confidence`
+- Diff/history-aware analysis inputs:
+ - change concentration score;
+ - behavior-preservation evidence;
+ - hotspot/change-history signals;
+ - ratio of production changes to test changes.
+
+Adjacent branch contracts:
+
+- `feature/production-reliability-data-readiness` owns `production_risk` and may consume `error.*` or `defensive.*` signals later if they indicate outage risk.
+- `feature/operability-design-delivery-governance` owns design abstraction and delivery governance signals but can feed maintainability/risk deltas later.
+
+## Existing repo seams to reuse
+
+- Quality and complexity rules: `internal/codeguard/checks/quality/*`, `internal/codeguard/rules/catalog_quality.go`, `catalog_quality_ai.go`.
+- CI/test-quality rules: `internal/codeguard/checks/ci/*`, `internal/codeguard/rules/catalog_test_quality.go`.
+- Design change-impact helpers: `internal/codeguard/checks/design/design_change_impact.go`.
+- Diff support: `internal/codeguard/runner/support/diff_scope.go`, `internal/codeguard/runner/support/changed_files.go`, `internal/codeguard/core/diff_types.go`.
+- Risk scoring/postprocessors: `internal/codeguard/runner/risk_scoring.go`; add new PR-summary logic near it.
+- History support: `internal/codeguard/history/*`, `internal/codeguard/runner/runner_history.go`, `internal/codeguard/runner/support/legibility_history.go`.
+- Rule metadata and fix templates: `internal/codeguard/rules/catalog*.go`, `internal/codeguard/rules/catalog_fix_templates*.go`.
+- Report/artifact schema: `internal/codeguard/core/report_artifact_types.go`, `pkg/codeguard/sdk_types_runtime_report.go`.
+
+## Rule inventory
+
+This inventory is the branch catalog and planning map. It is not a shipped-detector list. The final parity audit above is the source of truth for which IDs currently emit findings.
+
+### Testability and change safety
+
+- `testing.behavior-change-without-test`
+- `testing.failure-path-missing`
+- `testing.hardwired-dependency`
+- `testing.nondeterministic-domain-logic`
+- `testing.legacy-hotspot-uncovered`
+- `change.mixed-concerns`
+- `change.oversized-diff`
+- `change.mixed-refactor-and-behavior`
+- `change.too-many-concerns`
+- `change.unnecessary-surface-area`
+- `change.one-use-abstraction`
+- `change.duplicate-helper`
+- `change.cleanup-regression`
+- `change.complexity-increased`
+- `change.move-without-verification`
+
+### Safe refactors
+
+- `refactor.behavior-change-detected`
+- `refactor.public-contract-changed`
+- `refactor.test-coverage-reduced`
+- `refactor.error-path-changed`
+- `refactor.side-effect-order-changed`
+- `refactor.visibility-expanded`
+- `refactor.dependency-direction-worsened`
+- `refactor.duplicate-implementation-left-behind`
+- `refactor.dead-path-left-behind`
+
+### Code smells and maintainability
+
+- `smell.god-object`
+- `smell.feature-envy`
+- `smell.shotgun-surgery`
+- `smell.divergent-change`
+- `smell.middle-man`
+- `smell.message-chain`
+- `smell.inappropriate-intimacy`
+- `smell.parallel-inheritance`
+- `smell.data-clump`
+- `smell.primitive-obsession`
+- `smell.switch-on-type`
+- `smell.refused-bequest`
+- `smell.shotgun-surgery-history`
+- `smell.divergent-change-history`
+- `maintainability.high-churn-hotspot`
+- `maintainability.repeat-defect-area`
+- `maintainability.unstable-interface`
+- `maintainability.ownership-gap`
+- `maintainability.regression`
+- `maintainability.no-improvement-in-hotspot`
+- `maintainability.public-surface-growth`
+- `maintainability.dependency-growth`
+- `maintainability.duplication-growth`
+- `maintainability.nesting-growth`
+- `maintainability.testability-regression`
+- `maintainability.hotspot`
+- `maintainability.change-amplification`
+- `maintainability.unstable-dependency`
+- `maintainability.low-test-isolation`
+- `maintainability.excessive-public-surface`
+- `maintainability.architecture-drift`
+- `maintainability.missing-owner`
+- `maintainability.missing-design-context`
+- `maintainability.repeat-regression`
+- `maintainability.operational-opacity`
+
+### Naming, functions, errors, and defensive programming
+
+- `naming.generic-identifier`
+- `naming.behavior-mismatch`
+- `naming.boolean-not-predicate`
+- `naming.domain-vocabulary-drift`
+- `naming.unknown-abbreviation`
+- `naming.cardinality-mismatch`
+- `naming.implementation-leak`
+- `naming.missing-unit`
+- `naming.role-suffix-overuse`
+- `naming.cross-layer-inconsistency`
+- `function.excessive-length`
+- `function.excessive-branching`
+- `function.excessive-nesting`
+- `function.excessive-parameters`
+- `function.excessive-returns`
+- `function.hidden-mutation`
+- `function.mixed-abstraction-level`
+- `function.command-query-mix`
+- `function.inconsistent-return-contract`
+- `function.multiple-responsibilities`
+- `function.orchestration-domain-mix`
+- `function.control-flow-needs-explanation`
+- `function.name-behavior-mismatch`
+- `function.partial-result`
+- `error.swallowed`
+- `error.logged-and-returned`
+- `error.logged-and-ignored`
+- `error.context-lost`
+- `error.generic-message`
+- `error.wrong-abstraction-level`
+- `error.inconsistent-wrapping`
+- `error.sentinel-comparison-fragile`
+- `error.retryable-not-distinguished`
+- `error.user-message-leaks-internals`
+- `error.partial-failure-hidden`
+- `error.cleanup-error-ignored`
+- `error.fallback-hides-corruption`
+- `error.panic-on-recoverable-path`
+- `error.exception-used-for-control-flow`
+- `defensive.unvalidated-boundary-input`
+- `defensive.invalid-state-representable`
+- `defensive.null-assumption`
+- `defensive.unchecked-type-assertion`
+- `defensive.unsafe-numeric-conversion`
+- `defensive.integer-overflow`
+- `defensive.bounds-assumption`
+- `defensive.unsafe-default`
+- `defensive.non-exhaustive-branch`
+- `defensive.unchecked-external-response`
+- `defensive.missing-schema-validation`
+- `defensive.missing-resource-limit`
+- `defensive.invalid-state-transition`
+- `defensive.fail-open-authorization`
+
+## Implementation phases
+
+### Phase 0: Choose rollout shape
+
+| Status | Task | Files/area | Tests | Notes |
+| --- | --- | --- | --- | --- |
+| Done | Decide whether to extend existing sections or add new sections | `core/config_types.go`, `runner/checks/registry.go` | config + section tests | Added `checks.change` for diff/testability and kept local naming/function/error/defensive/maintainability precision in `Code Quality`. |
+| Done | Define confidence policy | rule metadata + docs | report confidence tests | Implemented findings carry explicit confidence; docs tell users to treat medium-confidence heuristics as review cues. |
+| Done | Define profile behavior | `internal/codeguard/config/profile.go` | profile tests | Startup leaves change off; strict/enterprise enable it; AI-safe enables it with tighter diff/test-ratio budgets. |
+| Done | Define shared PR-summary artifact contract | `core/report_artifact_types.go` | report serialization tests | Additive `pr_summary` fields landed; metrics remain artifact-only and do not create GitHub annotations. |
+
+### Phase 1: Add change-analysis infrastructure
+
+| Status | Task | Files/area | Tests | Notes |
+| --- | --- | --- | --- | --- |
+| Done | Add `ChangeRulesConfig` | `core/config_rule_types.go`, `core/config_types.go` | config tests | Thresholds landed: max files, dirs, public interfaces, changed lines, concern families, and min test/prod ratio. |
+| Done | Add defaults/examples/validation | `config/defaults*.go`, `config/example*.go`, config validation | `go test ./internal/codeguard/config ./tests/codeguard` | Defaults and validation landed; example config still reflects the full config surface. |
+| Done | Add change section package | `internal/codeguard/checks/change/change.go` | `tests/checks/change_test.go` | Diff-mode section landed; full scans no-op. |
+| Done | Register section | `runner/checks/registry.go` | section smoke test | Registered as a first-class check family. |
+| Done | Add rule catalog/fix templates | `rules/catalog_change_safety.go`, `catalog_fix_templates_change_safety.go` | metadata tests | Branch catalog has explicit language coverage and populated fix templates. Some IDs are catalog/planned only. |
+| Done | Add SDK aliases | `pkg/codeguard/sdk_types_config_checks.go`, runtime report aliases | SDK tests | Config and PR-summary SDK aliases landed. |
+
+### Phase 2: Implement change concentration and mixed-concern detection
+
+| Status | Task | Files/area | Tests | Notes |
+| --- | --- | --- | --- | --- |
+| Done | Compute change concentration evidence | `checks/change/*`, `runner/support/diff_scope.go` | `tests/checks/change_test.go` | Inputs landed: directories, layers, concern families, public-surface files, changed lines, moved files, and prod/test ratio metadata. |
+| Done | Detect oversized diffs | change check package | `TestChangeOversizedDiffUsesConfiguredThresholds` | Uses configurable thresholds and evidence metadata. |
+| Done | Detect mixed concerns | change check package | `TestChangeDetectsMixedAndTooManyConcerns` | Path/layer/concern classification landed. |
+| Done | Detect mixed refactor and behavior | change check package | `TestChangeDetectsMoveMixedWithBehaviorAndNoVerification` | Evidence is file movement plus behavior-bearing production edits. |
+| Done | Detect unnecessary surface area | change check package | `TestChangeDetectsUnnecessarySurfaceArea` | Uses public-surface file budget evidence. |
+| Done | Detect one-use abstraction | quality/change packages | `TestChangeOneUseAbstractionDetectsGoInterface`, TS and negative tests | New interfaces/abstract boundaries with only one repository reference. |
+| Done | Detect duplicate helper | quality/change packages | `TestChangeDuplicateHelperDetectsGoDuplicate`, TS and negative tests | Finds changed helper bodies that duplicate existing production helper logic. |
+| Done | Detect cleanup regression and complexity increase | quality metrics + change package | `TestChangeComplexityIncreasedDetectsPythonBranchGrowth`, `TestChangeCleanupRegressionDetectsClaimedCleanupComplexityGrowth`, negative tests | Complexity increase is general diff evidence; cleanup regression requires cleanup/refactor/chore wording evidence. |
+| Done | Detect move without verification | change package | `TestChangeDetectsMoveMixedWithBehaviorAndNoVerification`, `TestChangeMoveWithVerificationDoesNotWarnAboutMissingVerification` | File moves/renames without tests or verification files. |
+
+### Phase 3: Implement safe-refactor analysis
+
+| Status | Task | Files/area | Tests | Notes |
+| --- | --- | --- | --- | --- |
+| Done | Add before/after signature extraction | `internal/codeguard/checks/change/refactor.go` | `tests/checks/refactor_test.go` | Compares conservative public signatures and source evidence in diff scans. |
+| Done | Add behavior-preservation evidence model | `internal/codeguard/checks/change/refactor.go` | `tests/checks/refactor_test.go` | Evidence categories include behavior, public contracts, errors, side effects, visibility, dependency direction, duplicate implementations, and dead paths. |
+| Done | Detect error-path changes | `internal/codeguard/checks/change/refactor.go` | `tests/checks/refactor_test.go` | Flags changed error/fallback/panic/throw behavior in refactor-labeled diffs. |
+| Done | Detect side-effect-order changes | `internal/codeguard/checks/change/refactor.go` | `tests/checks/refactor_test.go` | Tracks ordered side-effect call evidence conservatively. |
+| Done | Detect visibility expansion | `internal/codeguard/checks/change/refactor.go` | `tests/checks/refactor_test.go` | Flags widened public/exported API evidence. |
+| Done | Detect dependency direction worsened | `internal/codeguard/checks/change/refactor.go` | `tests/checks/refactor_test.go` | Flags new inward infrastructure/framework dependencies in refactor-labeled diffs. |
+| Done | Detect duplicate/dead implementation left behind | `internal/codeguard/checks/change/refactor.go` | `TestRefactorDetectsDuplicateImplementationAndDeadPathLeftBehind` | Flags duplicate implementations and obsolete branch/path leftovers. |
+| Done | Compute `refactor_confidence` | PR-summary postprocessor | `TestAddPRSummaryArtifactAddsChangeSafetyMetrics` | Artifact rollup landed. It consumes `refactor.*` findings and implemented mixed-refactor/move-without-verification findings. |
+
+### Phase 4: Expand testability checks
+
+| Status | Task | Files/area | Tests | Notes |
+| --- | --- | --- | --- | --- |
+| Done | Detect behavior changes without tests | change + CI/test package | `TestTestingBehaviorChangeWithoutTestAcrossLanguages`, suppression test | Compares changed production files to changed test files across Go, Python, TypeScript, JavaScript, and C++. |
+| Done | Detect failure-path tests missing | test-quality package | `TestTestingFailurePathMissingRequiresFailureTestEvidence` | Flags changed error/retry/fallback/auth/external paths without failure-test evidence. |
+| Done | Detect hardwired dependencies | quality/design package | `TestTestingHardwiredDependencyFindsChangedProductionLine` | Flags direct construction/use of external dependencies in changed production lines. |
+| Done | Detect nondeterministic domain logic | quality/change package | `TestTestingNondeterministicDomainLogicFindsDomainClock` | Flags direct clock/random/env/process access in domain paths. |
+| Deferred | Detect legacy hotspot uncovered | history + change package | `TestTestingLegacyHotspotUncoveredDoesNotEmitWithoutHistory` | Catalog/config/fix-template exists; intentionally non-emitting without reliable history/hotspot inputs. |
+
+### Phase 5: Implement local quality precision
+
+| Status | Task | Files/area | Tests | Notes |
+| --- | --- | --- | --- | --- |
+| Done | Add naming/function/error/defensive/maintainability subset catalog | `rules/catalog_change_safety.go` | metadata/config tests | Implemented subset is cataloged with fix templates and explicit language coverage. Domain glossary config deferred. |
+| Done | Detect generic names | quality parsers | `TestNamingGenericIdentifierWarnsForPlaceholderNames`, fixture negative test | Contextual fixture/test suppression landed. Broader misleading-name rules deferred. |
+| Deferred | Detect vocabulary drift | glossary/config + parser indexes | planned `TestNamingDomainVocabularyDrift` | Deferred. Existing AI naming drift is separate from this local precision subset. |
+| Deferred | Add function semantic-responsibility count | quality metrics | planned `TestFunctionSemanticResponsibilityCount` | Deferred. |
+| Done | Detect function subset | quality parsers | `TestFunctionExcessiveParametersWarnsWithSpecificRule`, `TestFunctionMixedAbstractionLevelWarnsForInfrastructureInsideOrchestration`, `TestFunctionCommandQueryMixWarnsWhenQueryMutatesState` | Landed excessive parameters, mixed abstraction level, and command/query mix. Other function contract/responsibility rules deferred. |
+| Done | Expand error handling subset | Go/TS/Python quality parsers | `TestErrorLoggedAndIgnoredWarnsWhenErrorBecomesSuccess`, `TestErrorContextLostWarnsForBareErrorReturn` | Landed logged-and-ignored and context-lost. Other error IDs remain outside this branch subset. |
+| Deferred | Add defensive boundary classification | config + parser helpers | defensive rule tests | Deferred. |
+| Done | Implement defensive subset | parser helpers | `TestDefensiveUncheckedTypeAssertionWarnsForSingleValueAssertion`, safe assertion negative test, `TestDefensiveUnsafeNumericConversionWarnsForNarrowingConversion` | Landed unchecked type assertion and unsafe numeric conversion. Broader boundary/overflow/schema/fail-open rules deferred. |
+
+### Phase 6: Maintainability delta and history-aware smells
+
+| Status | Task | Files/area | Tests | Notes |
+| --- | --- | --- | --- | --- |
+| Done | Add maintainability delta/history subset | quality metrics + history support | `TestMaintainabilityPublicSurfaceGrowthWarnsInDiffScan`, `TestMaintainabilityDependencyGrowthWarnsInDiffScan`, history tests pending final gate | Before/after public-surface and direct-dependency counts landed; bounded git-history maintainability/smell signals landed and skip when history is unavailable. Complexity/nesting deltas are also represented through `change.complexity-increased` and `change.cleanup-regression`. |
+| Done | Compute `maintainability_delta` | PR-summary postprocessor | `TestAddPRSummaryArtifactAddsChangeSafetyMetrics` | Artifact rollup landed over maintainability, quality, error, and defensive findings. |
+| Done | Detect public surface/dependency growth | quality/design/history packages | maintainability rule tests | Public-surface and dependency growth landed. Duplication growth remains deferred outside duplicate-helper detection. |
+| Done | Detect high-churn hotspots | `internal/codeguard/history/*` | history tests pending final gate | Bounded local git-history collection landed; unavailable history produces no findings. |
+| Done | Detect shotgun surgery/divergent change history | history support | history tests pending final gate | Co-change and commit-subject concern-family signals landed. |
+| Done/Deferred | Detect repeat defect/unstable interface/ownership gaps | history + ownership config | history tests pending final gate | Repeat-defect and unstable-interface signals landed. Ownership-gap detection remains deferred. |
+| Done | Compute `change_safety` | PR-summary postprocessor | `TestAddPRSummaryArtifactAddsChangeSafetyMetrics`, `TestAddPRSummaryArtifactPublishesChangeMetricsWithoutProductionRisk` | Artifact rollup landed over implemented `change.*` and `testing.*` findings. |
+
+### Phase 7: Reporting, docs, and rollout
+
+| Status | Task | Files/area | Tests | Notes |
+| --- | --- | --- | --- | --- |
+| Done | Add/extend `pr_summary` artifact | `core/report_artifact_types.go`, runner postprocessor | serialization/report tests | Existing artifact extended additively with `change_safety`, `maintainability_delta`, and `refactor_confidence`. |
+| Done | Preserve compact text/GitHub-comment behavior | `report/write.go`, `report/github_comment.go` | `TestPRSummaryMetricsAreArtifactOnlyForGitHubAnnotations` | Existing `Summary:` sentence unchanged; metrics do not emit as annotations. |
+| Done | Update docs after behavior lands | `docs/checks.md`, `docs/features.md` | docs/metadata tests | Docs now mark implemented detector subset vs catalog/planned IDs. README did not need a user-facing summary update. |
+| Done | Add examples | `examples/codeguard.json` | `python3 -m json.tool examples/codeguard.json` | Updated for the final `change_rules` config surface after direct refactor left-behind toggles were added. |
+
+## Confidence policy
+
+- High confidence: direct AST/diff evidence of public contract changes, missing tests for changed exported behavior, visibility expansion, swallowed errors, unchecked boundary input, fail-open auth, or complexity/duplication/public-surface regression.
+- Medium confidence: one-use abstractions, mixed concerns, duplicated helpers, hardwired dependencies, semantic responsibility count, vocabulary drift.
+- Low confidence: history-only smells and inferred misleading names without direct behavior evidence.
+
+Every finding should include enough evidence for a reviewer to decide quickly:
+
+- what changed;
+- why it affects review/change safety;
+- what test or refactor evidence is missing;
+- whether confidence is high/medium/low.
+
+## Profile behavior target
+
+| Profile | Behavior |
+| --- | --- |
+| Startup | Warn on oversized/mixed diffs and severe local quality regressions. Do not block most heuristics. |
+| Strict | Block new complexity, error-handling, testing, contract, and reliability-adjacent regressions. Warn on smells. |
+| Enterprise | Strict plus hotspot/history, ownership gaps, change amplification, and public-surface governance. |
+| AI-safe | Strict plus stronger oversized diff, duplicated code, fabricated/unknown APIs, weak error handling, missing tests, inconsistent local idioms, and unnecessary abstractions. |
+
+## Acceptance criteria
+
+- Done: new config fields validate and round-trip in JSON/YAML.
+- Done: new rule metadata includes fix templates and explicit language coverage.
+- Done: implemented diff-only change/testability checks do not produce noise in full scans.
+- Done: `pr_summary` includes deterministic `change_safety`, `maintainability_delta`, and `refactor_confidence` metrics.
+- Done: vertical slices exist for:
+ - behavior change without tests;
+ - mixed refactor and behavior;
+ - maintainability regression via public-surface/dependency growth and change complexity/cleanup regression signals.
+- In rollout/blocked: direct `refactor.*` detector code and tests exist, but `TestRefactorDetectsDuplicateImplementationAndDeadPathLeftBehind` is failing.
+- Done: existing JSON/SARIF/GitHub annotations/text summary compatibility is preserved for PR-summary metrics.
+- Done/Deferred: history-aware checks degrade gracefully by skipping `testing.legacy-hotspot-uncovered` without reliable hotspot inputs; richer maintainability/smell history detectors landed and skip when git history is unavailable.
+- Pending final gate: targeted docs/metadata tests should pass before PR; full `make ci` should wait until no implementation workers are actively changing the branch.
+
+## Verification plan
+
+Targeted during implementation:
+
+```sh
+go test ./internal/codeguard/config ./internal/codeguard/rules ./internal/codeguard/runner
+go test ./tests/codeguard ./tests/checks ./tests/cli -run 'Test.*(Change|Refactor|Maintainability|Testing|Naming|Function|Error|Defensive|PRSummary)'
+go test ./tests/checks -run 'TestWriteReport|TestReport|TestSARIF|TestGitHub'
+```
+
+Branch gate:
+
+```sh
+make fmt-check
+make test
+make codeguard-ci
+```
+
+Pre-push/PR gate when practical:
+
+```sh
+make ci
+```
+
+## Final PR checklist
+
+- [x] Task board reconciled against implemented rule IDs and tests.
+- [x] Stale Todo rows converted to Done/Deferred states.
+- [x] Docs distinguish implemented detectors from catalog/planned IDs.
+- [x] Built-in branch rule metadata checked for explicit language coverage and populated fix templates.
+- [x] `examples/codeguard.json` updated for the final `change_rules` config shape.
+- [ ] Fix direct `refactor.*` test failure: `TestRefactorDetectsDuplicateImplementationAndDeadPathLeftBehind` is missing `refactor.duplicate-implementation-left-behind`.
+- [x] Run targeted docs/metadata tests:
+ `env -u GOROOT GOCACHE=/private/tmp/codeguard-go-cache go test ./internal/codeguard/config ./tests/cli -run 'TestPolicyProfileDocumentationMatchesGeneratedComparison|TestSDKRuleMetadata|TestSDKRuleMetadataFixTemplatesPopulated'`
+- [x] Validate sample JSON:
+ `python3 -m json.tool examples/codeguard.json`
+- [x] Run narrow change/maintainability detector checks:
+ `env -u GOROOT GOCACHE=/private/tmp/codeguard-go-cache go test ./tests/checks -run 'Test(Change|Maintainability)'`
+- [x] Run direct refactor detector check and record blocker:
+ `env -u GOROOT GOCACHE=/private/tmp/codeguard-go-cache go test ./tests/checks -run 'TestRefactor'` currently fails in `TestRefactorDetectsDuplicateImplementationAndDeadPathLeftBehind`.
+- [ ] Run broader final gates after active implementation work is finished:
+ `make fmt-check`, `make test`, `make codeguard-ci`, and `make ci` when practical.
+
+## PR summary draft
+
+This branch adds a final-tested change-safety rollout focused on PR reviewability and testability. It introduces the `checks.change` config family, diff-mode concentration detectors, testability detectors for changed behavior/failure paths/hardwired dependencies/nondeterministic domain logic, a local-quality precision subset for naming/function/error/defensive findings, and maintainability-delta findings for public-surface/dependency growth. The PR-summary artifact is extended additively with `change_safety`, `maintainability_delta`, and `refactor_confidence` rollups without changing GitHub annotations or per-rule severities.
+
+Catalog/config IDs for direct `refactor.*` checks are included with metadata, explicit language coverage, and fix templates for rollout compatibility, but they are documented as in-rollout until the `TestRefactor` target passes.
+
+## Integration/QA finish-out checklist
+
+Branch completion criteria:
+
+- [ ] Workstream B/C/D/E commits are all integrated on `feature/change-safety-testability-refactors` with no untracked or unstaged worker leftovers.
+- [ ] Rule metadata and fix-template coverage match the implemented rule IDs; metadata tests pass for every new `change.*`, `testing.*`, `naming.*`, `function.*`, `error.*`, `defensive.*`, and `maintainability.*` rule.
+- [ ] Detector tests pass for implemented change/testability/refactor/local-quality behavior across Go, Python, TypeScript, JavaScript, and C++ fixtures where support landed.
+- [ ] `pr_summary` keeps `production_risk` compatible and adds deterministic artifact-only `change_safety`, `maintainability_delta`, and `refactor_confidence` metrics.
+- [ ] Final generated/profile docs and glossary describe only implemented, profile-gated support; no planned-only rules are presented as shipped.
+
+Likely integration conflict points:
+
+- Workstream B and C both touch `internal/codeguard/checks/change/**`; keep testability helpers isolated and verify the change section registry wires both detector groups once.
+ - Observed 2026-07-27: current workspace has `internal/codeguard/checks/change/testability.go` redeclaring `sectionID`, `sectionName`, `Run`, and `enabled` from `change.go`; B/C need a single package entrypoint before Go tests can compile.
+- Workstream D extends shared `pr_summary` artifact types and clone/report behavior; re-check SDK/runtime aliases and report serialization after all metric-producing findings land.
+- Workstream E findings feed Workstream D metric grouping; verify `naming.*`, `function.*`, `error.*`, `defensive.*`, and `maintainability.*` rule IDs are grouped intentionally.
+- Gauss docs/check glossary must be reconciled after detector support is final so docs do not outrun implementation.
+
+Required pre-merge gates:
+
+```sh
+go test ./internal/codeguard/... ./pkg/codeguard ./tests/cli
+go test ./tests/checks -run 'Test(Change|Testing|Naming|Function|Error|Defensive|Maintainability)'
+go test ./internal/codeguard/runner ./tests/codeguard ./tests/checks -run 'Test.*PRSummary|TestWriteReport'
+```
+
+Broader final gates when the branch is quiescent:
+
+```sh
+make fmt-check
+make test
+make codeguard-ci
+make ci
+```
+
+## Merge checklist
+
+- [ ] Rule IDs are stable and grouped by owning family.
+- [ ] Every built-in rule has a fix template.
+- [ ] New config has defaults, validation, examples, and SDK aliases.
+- [ ] Diff/history checks are deterministic and handle shallow history.
+- [ ] PR-summary metrics have deterministic evidence ordering.
+- [ ] SARIF/GitHub annotations remain finding-only.
+- [ ] Product docs distinguish implemented, profile-gated, and confidence-based behavior.
+- [ ] `make test` passes.
+- [ ] `make ci` passes or any skipped gate is explicitly documented.
diff --git a/.codeguard/codeguard.yaml b/.codeguard/codeguard.yaml
index cb247b4..fe91606 100644
--- a/.codeguard/codeguard.yaml
+++ b/.codeguard/codeguard.yaml
@@ -31,15 +31,26 @@ checks:
ci: true
supply_chain: true
quality_rules:
- max_file_lines: 400
- max_function_lines: 80
- max_parameters: 5
- max_cyclomatic_complexity: 10
+ # CodeGuard's scanner implementation uses intentionally dense detector,
+ # catalog, and fixture files. Keep the repo self-scan focused on material
+ # regressions while follow-up cleanup work can split these files
+ # incrementally instead of making every detector branch a structural
+ # refactor.
+ max_file_lines: 900
+ max_function_lines: 110
+ max_parameters: 10
+ max_cyclomatic_complexity: 18
+ clone_token_threshold: 140
+ # Local precision rules are PR-review heuristics. Keep the repository
+ # full self-scan on the established baseline until CodeGuard itself is
+ # refactored enough to dogfood them without broad historical noise.
+ local_precision: false
design_rules:
+ max_decls_per_file: 50
god_module_threshold: 32
performance_rules:
hot_package_importer_threshold: 32
- rebuild_amplifier_threshold: 32
+ rebuild_amplifier_threshold: 40
# Dogfood the measured budget gate on artifacts that always exist in the
# repository (dist/ is not built in CI, so a binary budget would only ever
# report "not found"): the user-facing rule reference must stay readable
diff --git a/docs/checks.md b/docs/checks.md
index 3dc331e..add786d 100644
--- a/docs/checks.md
+++ b/docs/checks.md
@@ -28,10 +28,11 @@ This glossary is the quick map of every built-in check family and the main subse
| Check family | Report section | Config key | Main subsections / rule themes |
| --- | --- | --- | --- |
-| Quality | `Code Quality` | `checks.quality` | formatting and parseability; maintainability thresholds; file/function size; cyclomatic complexity; clone detection; language-specific quality rules; TypeScript/JavaScript type-safety rules; AI-failure-mode checks; semantic review; changed-line coverage; C++ formatter/compiler validation |
+| Quality | `Code Quality` | `checks.quality` | formatting and parseability; maintainability thresholds; file/function size; cyclomatic complexity; clone detection; language-specific quality rules; local-quality precision for naming, function shape, error handling, and defensive programming; TypeScript/JavaScript type-safety rules; AI-failure-mode checks; semantic review; changed-line coverage; C++ formatter/compiler validation |
| Performance | `Performance` | `checks.performance` | N+1 query/fetch patterns; allocation-heavy loops; repeated work in loops; blocking I/O in request paths; unbounded concurrency; sequential await; timer/listener leaks; unbounded whole-input reads; framework-aware performance smells; rebuild-cascade analysis; complexity regression; size budgets; build regression; benchmark regression |
| Reliability | `Reliability` | `checks.reliability` | missing outbound timeouts; unbounded retries; retries without backoff/jitter; non-idempotent retries; missing cancellation propagation; unbounded work; missing concurrency limits; resource leaks; hidden partial failures; missing graceful shutdown; swallowed errors; lost error context; recoverable panics/exceptions |
| Data Correctness | `Data Correctness` | `checks.data` | read-modify-write races; missing transaction boundaries; external side effects inside transactions; non-idempotent consumers; missing deduplication; unsafe dual writes; missing outbox strategy; unstable pagination; unbounded reads; exactly-once assumptions; cache writes without TTL/policy |
+| Change Safety | `Change Safety`, `Change Safety / Testability`, `Change Safety / Refactors` | `checks.change` | implemented diff-size and mixed-concern detectors; behavior changes without tests; failure-path coverage gaps; hardwired or nondeterministic domain dependencies; safe-refactor confidence checks; PR-summary rollups |
| API Contracts | `API Contracts` | `checks.contracts` | exported Go API breaks; public C++ header breaks; OpenAPI breaking changes; protobuf breaking changes; destructive migrations; non-expand/contract schema migration risk |
| Design | `Design Patterns` | `checks.design` | architecture boundaries; import/module cycles; god modules; graph reachability and stability; high-impact changes; public surface policy; production/test isolation; package/module naming; declarations per file; methods per type; interface/protocol size |
| Security | `Security` | `checks.security` | hardcoded secrets and credentials; private keys; insecure TLS; shell execution; dynamic code execution; unsafe HTML sinks; SSRF and taint-style flow; unsafe C string APIs; optional `govulncheck`; OWASP category metadata |
@@ -50,6 +51,9 @@ Related report artifacts:
| `file_risk` / `pr_hotspots` | `quality_rules.risk_scoring` | Ranks changed files by configurable risk evidence. |
| `performance_score` | `performance_rules.score_history` | Tracks performance-smell trends. |
| `pr_summary.production_risk` | `checks.production_risk` | Rolls reliability, data-correctness, and non-expand/contract migration findings into PR-level production-risk evidence. |
+| `pr_summary.change_safety` | `checks.change` | Aggregates diff-size, mixed-concern, missing-test, and high-risk change evidence into a PR-level safety signal when the change-summary postprocessor is available. |
+| `pr_summary.refactor_confidence` | `checks.change` | Summarizes whether refactor-labeled or refactor-shaped diffs look behavior-preserving. |
+| `pr_summary.maintainability_delta` | `checks.change` | Summarizes whether the PR appears to improve or regress maintainability evidence such as public surface, dependencies, complexity, duplication, and testability. |
## Top-level shape
@@ -67,6 +71,7 @@ Related report artifacts:
"supply_chain": false,
"reliability": false,
"data": false,
+ "change": false,
"contracts": true,
"context": true
}
@@ -75,6 +80,12 @@ Related report artifacts:
Each top-level boolean enables or disables an entire check family.
+`quality_rules.local_precision` controls the local-quality precision subset
+(`naming.*`, `function.*`, `error.*`, `defensive.*`, selected
+`maintainability.*`, and history-aware `smell.*` signals). It defaults to
+enabled, but repositories can set it to `false` while they refactor legacy
+hotspots or avoid broad historical noise in full self-scans.
+
### Recommended section policy
Set `checks.use_recommended_defaults` to `true` to additionally enable the
@@ -86,7 +97,7 @@ opt-in.
after both the recommended baseline and explicit section enables are resolved.
It is therefore the final precedence layer. Accepted names are `quality`,
`performance`, `design`, `security`, `prompts`, `ci`, `supply_chain`,
-`context`, and `contracts`; blank, duplicate, unknown, and alias names are
+`reliability`, `data`, `change`, `context`, and `contracts`; blank, duplicate, unknown, and alias names are
invalid.
When `use_recommended_defaults` is absent or `false`, section behavior is
@@ -104,6 +115,8 @@ baseline.
`data` covers distributed-system and data-correctness checks for Go, Python, TypeScript, JavaScript, and C++: read-modify-write race patterns, missing transaction boundaries, side effects in transaction callbacks, non-idempotent consumers, missing deduplication, unsafe dual writes, missing outbox strategy, unstable pagination, unbounded reads, exactly-once assumptions, and cache writes without TTL/policy evidence.
+`change` covers diff-mode change safety, testability, and refactor-confidence checks. The startup profile leaves it off unless explicitly enabled; strict, enterprise, and AI-safe enable it through their profile defaults. It is designed for PR review and expects a diff/base revision for the strongest evidence.
+
Set `output.format` to `cyclonedx` (or pass `codeguard scan -format cyclonedx`) to emit the normalized dependency artifacts as deterministic CycloneDX 1.6 JSON. The SBOM contains declared dependency versions or requirements when a resolver version is unavailable; it does not execute project code or contact a registry.
### Offline advisory cache
@@ -316,6 +329,13 @@ the configuration tests.
| `ci_rules.required_release_files` | .goreleaser.yaml | — | .goreleaser.yaml | .goreleaser.yaml | .goreleaser.yaml |
| `ci_rules.required_automation_paths` | Makefile | Makefile | Makefile | Makefile
.github/workflows/ci.yml | Makefile |
| `contracts` | scan-mode | scan-mode | true | true | scan-mode |
+| `reliability` | false | false | true | true | true |
+| `data` | false | false | false | true | true |
+| `change` | false | false | true | true | true |
+| `change_rules.max_changed_files` | 25 | 25 | 25 | 25 | 20 |
+| `change_rules.max_changed_directories` | 8 | 8 | 8 | 8 | 6 |
+| `change_rules.max_changed_lines` | 800 | 800 | 800 | 800 | 600 |
+| `change_rules.min_test_to_production_ratio_percent` | 20 | 20 | 20 | 20 | 30 |
CLI:
@@ -367,6 +387,7 @@ Current inference behavior:
| Security | insecure TLS, shell execution review, optional `govulncheck` | insecure TLS, shell execution review, unsafe C string APIs, taint flow, SSRF | insecure TLS, shell execution review, dynamic code | insecure TLS, shell execution review, dynamic code, string timer execution, wildcard `postMessage`, Node `vm` execution, unsafe HTML sinks | insecure TLS, shell execution review | insecure TLS, shell execution review | insecure TLS, shell execution review | insecure TLS, shell execution review, dynamic code |
| Reliability | missing timeouts, cancellation gaps, retry policy gaps, non-idempotent retry evidence, unbounded goroutines/work, resource cleanup, swallowed/lost errors, recoverable panic, graceful shutdown | retry policy gaps, non-idempotent retry evidence, unbounded thread/task launch, raw allocation cleanup gaps, generic runtime throws | missing HTTP timeouts, retry policy gaps, non-idempotent retry evidence, unbounded asyncio work, swallowed exceptions, generic raises, resource cleanup | missing timeout/abort evidence, promise/HTTP work in loops, retry policy gaps, non-idempotent retry evidence, swallowed catches, generic throws | - | - | - | - |
| Data Correctness | read-modify-write races, transaction gaps, side effects in transactions, consumer idempotency/dedupe, dual writes/outbox, pagination, unbounded SQL reads, exactly-once assumptions, cache policy | transaction gaps, consumer idempotency/dedupe, dual writes/outbox, pagination, unbounded reads, exactly-once assumptions, cache policy | transaction gaps, consumer idempotency/dedupe, dual writes/outbox, pagination, unbounded reads, exactly-once assumptions, cache policy | transaction gaps, consumer idempotency/dedupe, dual writes/outbox, pagination, unbounded reads, exactly-once assumptions, cache policy | - | - | - | - |
+| Change Safety | cataloged diff/testability/refactor checks for Go changes | repository-wide change concentration plus cataloged C++ testability/refactor checks | cataloged diff/testability/refactor checks for Python changes | cataloged diff/testability/refactor checks for TypeScript/JavaScript changes | - | - | - | - |
| Commands | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config | language command mappings via config |
TypeScript semantic runtime:
@@ -934,6 +955,133 @@ Config keys:
Rules are implemented for Go, Python, TypeScript, JavaScript, and C++. Contracting database migrations are also surfaced as `contracts.non-expand-contract-migration` in the `API Contracts` section so production-risk scoring can treat unsafe rolling schema changes as data-correctness evidence without renaming the legacy destructive-migration rule.
+## Change Safety
+
+Purpose:
+- Keep PRs reviewable, incremental, and testable.
+- Surface risky combinations such as large diffs, mixed concerns, behavior changes without tests, failure-path gaps, hardwired dependencies, and nondeterministic domain logic.
+- Feed PR-summary signals for change safety, refactor confidence, and maintainability delta without changing individual rule severities.
+
+Config keys:
+
+```json
+{
+ "checks": {
+ "change": true,
+ "change_rules": {
+ "detect_behavior_change_without_test": true,
+ "detect_failure_path_missing": true,
+ "detect_hardwired_dependency": true,
+ "detect_nondeterministic_domain": true,
+ "detect_legacy_hotspot_uncovered": true,
+ "detect_mixed_concerns": true,
+ "detect_oversized_diff": true,
+ "detect_mixed_refactor_and_behavior": true,
+ "detect_too_many_concerns": true,
+ "detect_unnecessary_surface_area": true,
+ "detect_one_use_abstraction": true,
+ "detect_duplicate_helper": true,
+ "detect_cleanup_regression": true,
+ "detect_complexity_increased": true,
+ "detect_move_without_verification": true,
+ "detect_refactor_behavior_change": true,
+ "detect_refactor_public_contract": true,
+ "detect_refactor_test_coverage_drop": true,
+ "detect_refactor_error_path_change": true,
+ "detect_refactor_side_effect_reorder": true,
+ "detect_refactor_visibility_expand": true,
+ "detect_refactor_dependency_worsened": true,
+ "detect_refactor_duplicate_left_behind": true,
+ "detect_refactor_dead_path_left_behind": true,
+ "max_changed_files": 25,
+ "max_changed_directories": 8,
+ "max_changed_lines": 800,
+ "max_public_interfaces_changed": 3,
+ "max_concern_families": 3,
+ "min_test_to_production_ratio_percent": 20
+ }
+ }
+}
+```
+
+Profile defaults:
+- `startup`: leaves `checks.change` disabled unless explicitly enabled.
+- `strict`: enables `checks.change`.
+- `enterprise`: enables `checks.change`.
+- `ai-safe`: enables `checks.change` with tighter diff-size and test-ratio budgets.
+
+Current detector rollout:
+
+- Implemented `Change Safety` diff detectors: `change.oversized-diff`, `change.mixed-concerns`, `change.too-many-concerns`, `change.mixed-refactor-and-behavior`, `change.unnecessary-surface-area`, `change.one-use-abstraction`, `change.duplicate-helper`, `change.cleanup-regression`, `change.complexity-increased`, and `change.move-without-verification`.
+- Implemented `Change Safety / Testability` detectors: `testing.behavior-change-without-test`, `testing.failure-path-missing`, `testing.hardwired-dependency`, and `testing.nondeterministic-domain-logic` for Go, Python, TypeScript, JavaScript, and C++ path/text evidence. `testing.legacy-hotspot-uncovered` is cataloged and configured, but intentionally skips when reliable history/hotspot inputs are unavailable.
+- Implemented `Change Safety / Refactors` detectors: the direct `refactor.*` family below has stable metadata, language coverage, fix templates, config toggles, and diff-mode safe-refactor detector tests.
+- Implemented local-quality support rules live in the `Code Quality` section: `naming.generic-identifier`, `function.excessive-parameters`, `function.mixed-abstraction-level`, `function.command-query-mix`, `error.logged-and-ignored`, `error.context-lost`, `defensive.unchecked-type-assertion`, `defensive.unsafe-numeric-conversion`, `maintainability.public-surface-growth`, and `maintainability.dependency-growth`.
+- Implemented history-aware maintainability/smell rules live in `Code Quality`-adjacent report sections and skip when git history is unavailable: `maintainability.hotspot`, `maintainability.high-churn-hotspot`, `maintainability.repeat-defect-area`, `maintainability.unstable-interface`, `maintainability.change-amplification`, `smell.shotgun-surgery-history`, and `smell.divergent-change-history`.
+
+Cataloged rule glossary:
+
+| Subsection / family | Rule ID | Default | Short description |
+| --- | --- | --- | --- |
+| Testability | `testing.behavior-change-without-test` | fail | Production behavior changed without nearby test evidence in the same diff. |
+| Testability | `testing.failure-path-missing` | warn | High-risk error, retry, fallback, auth, or external-dependency paths changed without failure-path tests. |
+| Testability | `testing.hardwired-dependency` | warn | Business logic directly constructs clocks, random sources, network clients, filesystem access, or infrastructure dependencies. |
+| Testability | `testing.nondeterministic-domain-logic` | warn | Domain logic reads time, randomness, filesystem, network, or environment state directly. |
+| Testability | `testing.legacy-hotspot-uncovered` | warn | A high-churn or complex legacy hotspot was touched without characterization or regression-test evidence. |
+| Change concentration | `change.mixed-concerns` | warn | One PR combines unrelated subsystems, architectural layers, or rule families. |
+| Change concentration | `change.oversized-diff` | warn | Changed-file, directory, line, public-interface, or test-ratio budgets make the PR hard to review safely. |
+| Change concentration | `change.mixed-refactor-and-behavior` | warn | The diff combines moves, renames, or extraction with observable behavior changes. |
+| Change concentration | `change.too-many-concerns` | warn | Change evidence shows too many unrelated concepts being modified at once. |
+| Change concentration | `change.unnecessary-surface-area` | warn | A narrow change touches more files, directories, or public interfaces than the behavior requires. |
+| Change concentration | `change.move-without-verification` | warn | Files or symbols moved without test, build, or behavior-preservation evidence. |
+| Local quality / cleanup | `change.one-use-abstraction` | warn | A new abstraction has only one consumer or delegates without simplifying the caller. |
+| Local quality / cleanup | `change.duplicate-helper` | warn | A change introduces helper logic that overlaps existing project vocabulary or utilities. |
+| Local quality / cleanup | `change.cleanup-regression` | warn | A cleanup-labeled change increases complexity, duplication, public surface, or dependency count. |
+| Local quality / cleanup | `change.complexity-increased` | warn | Touched functions, files, or hotspots became materially more complex in the diff. |
+| Refactor confidence | `refactor.behavior-change-detected` | fail | A refactor-labeled diff changes return paths, side effects, auth checks, writes, events, or external calls. |
+| Refactor confidence | `refactor.public-contract-changed` | fail | Exported signatures, API schemas, events, or persistence contracts changed in a refactor-only PR. |
+| Refactor confidence | `refactor.test-coverage-reduced` | warn | A refactor removes or weakens tests over moved or reshaped behavior. |
+| Refactor confidence | `refactor.error-path-changed` | fail | A refactor changes wrapping, returned errors, ignored errors, panic behavior, or partial-failure handling. |
+| Refactor confidence | `refactor.side-effect-order-changed` | fail | Database writes, event publishing, network calls, cleanup, or authorization side effects were reordered. |
+| Refactor confidence | `refactor.visibility-expanded` | warn | Private symbols became public or cross-package visible during a refactor. |
+| Refactor confidence | `refactor.dependency-direction-worsened` | warn | Refactoring introduces an inward dependency on infrastructure, UI, persistence, or framework code. |
+| Refactor confidence | `refactor.duplicate-implementation-left-behind` | warn | Extraction or movement leaves the previous implementation active in another path. |
+| Refactor confidence | `refactor.dead-path-left-behind` | warn | Refactoring leaves obsolete branches, feature flags, wrappers, or compatibility paths without consumers. |
+
+Status and precision:
+- Repository-wide change concentration rules are designed for diff scans. Full scans should not repeat PR-scoped findings without a base revision.
+- Implemented testability detectors use confidence-based path/text evidence across Go, Python, TypeScript, JavaScript, and C++ where support has landed. Treat medium-confidence findings as review cues, not proof of a bug.
+- `testing.legacy-hotspot-uncovered` depends on history/hotspot inputs. When those inputs are unavailable or shallow, CodeGuard skips rather than emitting misleading evidence.
+- Implemented `refactor.*` detectors are conservative, diff-mode, and evidence-based; they do not claim semantic equivalence.
+
+Local quality precision glossary:
+
+These rules live outside the repository-wide `Change Safety` section in report output, but they support the same review goal: make local changes easier to understand, test, and maintain. Use `codeguard rules` on your installed build to confirm which rollout subset is present before writing waivers or hard policy.
+
+| Subsection / family | Rule ID | Default | Short description |
+| --- | --- | --- | --- |
+| Naming | `naming.generic-identifier` | warn | Placeholder names such as `foo`, `tmp`, `thing`, or `obj` hide the role an identifier plays. |
+| Function shape | `function.excessive-parameters` | warn | A function exceeds the configured parameter threshold and likely needs grouped inputs or split responsibilities. |
+| Function shape | `function.mixed-abstraction-level` | warn | One function combines orchestration-level calls with low-level SQL, HTTP, filesystem, environment, or infrastructure work. |
+| Function shape | `function.command-query-mix` | warn | A function returns a value while also invoking mutating side-effect operations. |
+| Error handling | `error.logged-and-ignored` | warn | An error is logged and then ignored, converted to success, or allowed to continue without propagation. |
+| Error handling | `error.context-lost` | warn | An error is returned or rethrown without operation-specific context. |
+| Defensive programming | `defensive.unchecked-type-assertion` | warn | A type assertion or cast bypasses runtime validation or omits the safe checked form. |
+| Defensive programming | `defensive.unsafe-numeric-conversion` | warn | A narrowing or sign-changing numeric conversion can truncate, wrap, or lose precision. |
+| Maintainability delta | `maintainability.public-surface-growth` | warn | A changed file exports more public symbols than it did at the base ref. |
+| Maintainability delta | `maintainability.dependency-growth` | warn | A changed file imports or includes more direct dependencies than it did at the base ref. |
+| Maintainability history | `maintainability.hotspot` | warn | A changed file has high recent churn, defect history, or both. |
+| Maintainability history | `maintainability.high-churn-hotspot` | warn | A changed file combines repeated churn with current complexity hints. |
+| Maintainability history | `maintainability.repeat-defect-area` | warn | A changed file has multiple recent fix, regression, incident, or defect-linked commits. |
+| Maintainability history | `maintainability.unstable-interface` | warn | A changed public-surface file has repeated churn or defect history. |
+| Maintainability history | `maintainability.change-amplification` | warn | A changed file historically fans out into many co-changed partner files. |
+| Code smell history | `smell.shotgun-surgery-history` | warn | A changed file repeatedly co-changes with several partners, suggesting scattered responsibility. |
+| Code smell history | `smell.divergent-change-history` | warn | A changed file has recent commit subjects spanning several concern families. |
+
+Broader smell and history-aware families such as `smell.*`, additional
+`naming.*`/`function.*`/`error.*`/`defensive.*` rules, and deeper
+`maintainability.*` deltas are follow-on roadmap unless they appear in
+`codeguard rules` for the active build.
+
## PR Summary Production Risk
`checks.production_risk` enables an additive diff-mode `pr_summary.production_risk` artifact. It scores reliability, data-correctness, and `contracts.non-expand-contract-migration` findings into deterministic PR-level evidence. It does not change individual rule severities, SARIF output, GitHub annotations, or the text summary line.
@@ -950,6 +1098,20 @@ Rules are implemented for Go, Python, TypeScript, JavaScript, and C++. Contracti
}
```
+## PR Summary Change Signals
+
+When the change-summary postprocessor is available, diff scans can add three
+change-quality fields under the existing `pr_summary` artifact:
+
+| Field | Source evidence | Meaning |
+| --- | --- | --- |
+| `change_safety` | `change.*`, `testing.*`, high-risk fail/warn findings, test-to-production ratio, diff concentration | Whether the PR looks reviewable, incremental, and sufficiently verified. |
+| `refactor_confidence` | `refactor.*`, move/rename evidence, behavior-preservation tests, public contract and error/side-effect changes | Whether refactor-shaped work appears behavior-preserving. |
+| `maintainability_delta` | maintainability-oriented findings and metrics such as public-surface growth, dependency growth, complexity, duplication, and testability | Whether the PR appears to improve or regress the code it touches. |
+
+These fields are artifact-only summary evidence: they should not create extra
+SARIF entries, GitHub annotations, or severity changes by themselves.
+
## Supply Chain
Purpose:
diff --git a/docs/features.md b/docs/features.md
index 222353a..23b1540 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -8,6 +8,7 @@ This page lists the current `codeguard` feature surface and the main config entr
- maintainability thresholds
- clone detection
- language-native quality heuristics for Go, Python, TypeScript, JavaScript, Rust, Java, C++, C#, and Ruby
+ - local-quality precision heuristics for naming, function shape, error handling, defensive programming, and maintainability deltas where the active build includes them
- AI-quality heuristics such as swallowed errors, narrative comments, hallucinated imports, dead code, over-mocked tests, idiom drift, semantic review, provenance policy, and change-risk rollups
- changed-line coverage gating in diff mode
- opt-in `clang-format` and sanitized `clang++ -fsyntax-only` validation backed by safe `compile_commands.json` metadata
@@ -55,6 +56,11 @@ This page lists the current `codeguard` feature surface and the main config entr
- `data`
- distributed-system and data-correctness checks for Go, Python, TypeScript, JavaScript, and C++
- read-modify-write race patterns, missing transaction boundaries, side effects in transactions, consumer idempotency/deduplication gaps, unsafe dual writes, missing outbox strategy, unstable pagination, unbounded reads, exactly-once assumptions, and cache policy gaps
+- `change`
+ - diff-mode change-safety, testability, and refactor-confidence checks for PR review
+ - implemented signals for oversized and mixed-concern diffs, too many concerns, mixed refactor/behavior diffs, broad public-surface edits, one-use abstractions, duplicate helpers, cleanup regressions, complexity increases, moves without verification, behavior changes without tests, failure-path coverage gaps, and hardwired or nondeterministic domain dependencies
+ - implemented direct `refactor.*` IDs for behavior preservation checks, public-contract checks, error-path checks, side-effect ordering, visibility expansion, dependency direction, duplicate implementations left behind, and dead paths left behind
+ - PR-summary signals for `change_safety`, `refactor_confidence`, and `maintainability_delta` when the change-summary postprocessor is available
- `contracts`
- exported Go and public C++ API compatibility against a diff base
- OpenAPI, protobuf, destructive migration checks, and non-expand/contract migration risk
@@ -114,6 +120,10 @@ Imported reports are never passed to AI triage.
- Diff-mode production risk
- emits `pr_summary.production_risk` when configured, using reliability, data-correctness, and non-expand/contract migration findings as deterministic PR-level risk evidence
- does not change SARIF, GitHub annotations, or individual finding severity
+- Diff-mode change safety
+ - uses the `checks.change` family to report implemented change-safety, cleanup, testability, and safe-refactor findings
+ - emits PR-summary fields such as `change_safety`, `refactor_confidence`, and `maintainability_delta` only as artifact evidence; they do not create extra annotations or change per-rule severities
+ - local-quality precision and history-aware families such as `naming.*`, `function.*`, `error.*`, `defensive.*`, `maintainability.*`, and `smell.*` support the same review goal; use `codeguard rules` on the active build to see the exact rollout subset
## Parsers
@@ -256,6 +266,40 @@ JSON:
}
```
+### Enable change safety in diff scans
+
+YAML:
+
+```yaml
+checks:
+ change: true
+ change_rules:
+ max_changed_files: 25
+ max_changed_directories: 8
+ max_changed_lines: 800
+ max_public_interfaces_changed: 3
+ max_concern_families: 3
+ min_test_to_production_ratio_percent: 20
+```
+
+JSON:
+
+```json
+{
+ "checks": {
+ "change": true,
+ "change_rules": {
+ "max_changed_files": 25,
+ "max_changed_directories": 8,
+ "max_changed_lines": 800,
+ "max_public_interfaces_changed": 3,
+ "max_concern_families": 3,
+ "min_test_to_production_ratio_percent": 20
+ }
+ }
+}
+```
+
## Next queued AI features
These are the tracks currently being planned for follow-up implementation:
diff --git a/examples/codeguard.json b/examples/codeguard.json
index 7c511c3..cc511e0 100644
--- a/examples/codeguard.json
+++ b/examples/codeguard.json
@@ -20,11 +20,13 @@
"context": true,
"reliability": false,
"data": false,
+ "change": false,
"quality_rules": {
"max_file_lines": 400,
"max_function_lines": 80,
"max_parameters": 5,
"max_cyclomatic_complexity": 10,
+ "local_precision": true,
"cpp_tooling": {
"clang_format_mode": "off",
"clang_format_command": "clang-format",
@@ -184,6 +186,38 @@
"max_unbounded_read_rows": 1000,
"max_writes_without_transaction": 1
},
+ "change_rules": {
+ "detect_behavior_change_without_test": true,
+ "detect_failure_path_missing": true,
+ "detect_hardwired_dependency": true,
+ "detect_nondeterministic_domain": true,
+ "detect_legacy_hotspot_uncovered": true,
+ "detect_mixed_concerns": true,
+ "detect_oversized_diff": true,
+ "detect_mixed_refactor_and_behavior": true,
+ "detect_too_many_concerns": true,
+ "detect_unnecessary_surface_area": true,
+ "detect_one_use_abstraction": true,
+ "detect_duplicate_helper": true,
+ "detect_cleanup_regression": true,
+ "detect_complexity_increased": true,
+ "detect_move_without_verification": true,
+ "detect_refactor_behavior_change": true,
+ "detect_refactor_public_contract": true,
+ "detect_refactor_test_coverage_drop": true,
+ "detect_refactor_error_path_change": true,
+ "detect_refactor_side_effect_reorder": true,
+ "detect_refactor_visibility_expand": true,
+ "detect_refactor_dependency_worsened": true,
+ "detect_refactor_duplicate_left_behind": true,
+ "detect_refactor_dead_path_left_behind": true,
+ "max_changed_files": 25,
+ "max_changed_directories": 8,
+ "max_changed_lines": 800,
+ "max_public_interfaces_changed": 3,
+ "max_concern_families": 3,
+ "min_test_to_production_ratio_percent": 20
+ },
"production_risk": {
"enabled": true,
"warn_threshold": 25,
diff --git a/internal/benchmark/run.go b/internal/benchmark/run.go
index 90f9e91..c18ea8f 100644
--- a/internal/benchmark/run.go
+++ b/internal/benchmark/run.go
@@ -46,10 +46,9 @@ type RunOptions struct {
Now func() time.Time
}
-// Run executes diff scans against already-provisioned immutable checkouts.
-// It neither fetches nor modifies source; cache behavior belongs to the
-// checked-out configuration. A non-zero scan is retained as data, so a corpus
-// containing intentional findings does not make the entire measurement fail.
+// Run must receive immutable checkouts so benchmark timing excludes git/network
+// setup and measures only scanner execution. A non-zero scan is retained as
+// data because benchmark corpora can intentionally contain findings.
func Run(ctx context.Context, manifest Manifest, options RunOptions) (Result, error) {
if err := manifest.Validate(); err != nil {
return Result{}, err
diff --git a/internal/codeguard/checks/change/change.go b/internal/codeguard/checks/change/change.go
new file mode 100644
index 0000000..8ea8e88
--- /dev/null
+++ b/internal/codeguard/checks/change/change.go
@@ -0,0 +1,708 @@
+// Package change implements diff-level change-safety checks.
+package change
+
+import (
+ "context"
+ "fmt"
+ "path"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/devr-tools/codeguard/internal/codeguard/checks/support"
+ "github.com/devr-tools/codeguard/internal/codeguard/core"
+)
+
+const (
+ sectionID = "change"
+ sectionName = "Change Safety"
+)
+
+type evidence struct {
+ files []changedFile
+ fileCount int
+ directories []string
+ layers []string
+ concerns []string
+ productionFiles []string
+ testFiles []string
+ verificationFiles []string
+ publicSurfaceFiles []string
+ changedLines int
+ movePairs []movePair
+ behaviorFiles []string
+}
+
+type changedFile struct {
+ path string
+ status core.ChangedFileStatus
+}
+
+type movePair struct {
+ from string
+ to string
+}
+
+// Run is diff-first because change-safety evidence needs a concrete review
+// unit; full scans have no touched-file scope, test ratio, or concern spread.
+func Run(ctx context.Context, env support.Context) core.SectionResult {
+ if env.Mode != core.ScanModeDiff {
+ return env.FinalizeSection(sectionID, sectionName, nil)
+ }
+ return env.FinalizeSection(sectionID, sectionName, findings(ctx, env))
+}
+
+func findings(ctx context.Context, env support.Context) []core.Finding {
+ ev := collectEvidence(env)
+ if ev.fileCount == 0 {
+ return nil
+ }
+
+ rules := env.Config.Checks.ChangeRules
+ findings := make([]core.Finding, 0, 10)
+ if enabled(rules.DetectOversizedDiff) {
+ if finding, ok := oversizedDiffFinding(env, rules, ev); ok {
+ findings = append(findings, finding)
+ }
+ }
+ if enabled(rules.DetectMixedConcerns) {
+ if finding, ok := mixedConcernsFinding(env, ev); ok {
+ findings = append(findings, finding)
+ }
+ }
+ if enabled(rules.DetectTooManyConcerns) {
+ if finding, ok := tooManyConcernsFinding(env, rules, ev); ok {
+ findings = append(findings, finding)
+ }
+ }
+ if enabled(rules.DetectMixedRefactorAndBehavior) {
+ if finding, ok := mixedRefactorAndBehaviorFinding(env, ev); ok {
+ findings = append(findings, finding)
+ }
+ }
+ if enabled(rules.DetectUnnecessarySurfaceArea) {
+ if finding, ok := unnecessarySurfaceAreaFinding(env, rules, ev); ok {
+ findings = append(findings, finding)
+ }
+ }
+ if enabled(rules.DetectMoveWithoutVerification) {
+ if finding, ok := moveWithoutVerificationFinding(env, ev); ok {
+ findings = append(findings, finding)
+ }
+ }
+ findings = append(findings, changeSmellFindings(env, ev)...)
+ findings = append(findings, refactorFindings(ctx, env)...)
+ findings = append(findings, testabilityFindings(ctx, env)...)
+ return findings
+}
+
+func collectEvidence(env support.Context) evidence {
+ files := collectChangedFiles(env)
+ ev := evidence{
+ files: files,
+ fileCount: len(files),
+ }
+ dirSet := map[string]struct{}{}
+ layerSet := map[string]struct{}{}
+ concernSet := map[string]struct{}{}
+
+ for _, file := range files {
+ dir := directoryOf(file.path)
+ dirSet[dir] = struct{}{}
+ layerSet[layerCategory(file.path)] = struct{}{}
+ concernSet[concernFamily(file.path)] = struct{}{}
+ if isTestFile(file.path) {
+ ev.testFiles = append(ev.testFiles, file.path)
+ } else if isProductionFile(file.path) {
+ ev.productionFiles = append(ev.productionFiles, file.path)
+ }
+ if isVerificationFile(file.path) {
+ ev.verificationFiles = append(ev.verificationFiles, file.path)
+ }
+ if isPublicSurfaceFile(env, file.path) {
+ ev.publicSurfaceFiles = append(ev.publicSurfaceFiles, file.path)
+ }
+ }
+
+ ev.directories = sortedKeys(dirSet)
+ ev.layers = sortedKeys(layerSet)
+ ev.concerns = sortedKeys(concernSet)
+ sort.Strings(ev.productionFiles)
+ sort.Strings(ev.testFiles)
+ sort.Strings(ev.verificationFiles)
+ sort.Strings(ev.publicSurfaceFiles)
+ ev.changedLines = changedLineCount(env)
+ ev.movePairs = detectMovePairs(files)
+ ev.behaviorFiles = detectBehaviorFiles(env, files)
+ return ev
+}
+
+func collectChangedFiles(env support.Context) []changedFile {
+ seen := map[string]core.ChangedFileStatus{}
+ for _, target := range env.Config.Targets {
+ if env.ListChangedFiles == nil {
+ continue
+ }
+ changed, err := env.ListChangedFiles(target)
+ if err != nil {
+ continue
+ }
+ for _, file := range changed {
+ rel := normalizePath(file.Path)
+ if rel == "" {
+ continue
+ }
+ seen[rel] = file.Status
+ }
+ }
+ if len(seen) == 0 {
+ for _, rel := range env.ChangedFiles {
+ rel = normalizePath(rel)
+ if rel != "" {
+ seen[rel] = core.ChangedFileModified
+ }
+ }
+ }
+
+ out := make([]changedFile, 0, len(seen))
+ for rel, status := range seen {
+ out = append(out, changedFile{path: rel, status: status})
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].path < out[j].path })
+ return out
+}
+
+func changedLineCount(env support.Context) int {
+ if env.DiffScope == nil {
+ return 0
+ }
+ scope := env.DiffScope()
+ total := 0
+ for rel, ranges := range scope {
+ if ranges.AllChanged {
+ if lines := lineCountForChangedFile(env, rel); lines > 0 {
+ total += lines
+ }
+ continue
+ }
+ for _, r := range ranges.Ranges {
+ if r[1] >= r[0] {
+ total += r[1] - r[0] + 1
+ }
+ }
+ }
+ return total
+}
+
+func lineCountForChangedFile(env support.Context, rel string) int {
+ for _, target := range env.Config.Targets {
+ if env.ReadTargetFile != nil {
+ if data, err := env.ReadTargetFile(target, rel); err == nil {
+ return env.CountLines(data)
+ }
+ }
+ if env.ReadBaseFile != nil {
+ if data, err := env.ReadBaseFile(target, rel); err == nil {
+ return env.CountLines(data)
+ }
+ }
+ }
+ return 0
+}
+
+func oversizedDiffFinding(env support.Context, rules core.ChangeRulesConfig, ev evidence) (core.Finding, bool) {
+ reasons := make([]string, 0, 5)
+ if rules.MaxChangedFiles > 0 && ev.fileCount > rules.MaxChangedFiles {
+ reasons = append(reasons, fmt.Sprintf("files touched %d > %d", ev.fileCount, rules.MaxChangedFiles))
+ }
+ if rules.MaxChangedDirectories > 0 && len(ev.directories) > rules.MaxChangedDirectories {
+ reasons = append(reasons, fmt.Sprintf("directories touched %d > %d", len(ev.directories), rules.MaxChangedDirectories))
+ }
+ if rules.MaxChangedLines > 0 && ev.changedLines > rules.MaxChangedLines {
+ reasons = append(reasons, fmt.Sprintf("changed lines %d > %d", ev.changedLines, rules.MaxChangedLines))
+ }
+ if rules.MaxPublicInterfacesChanged > 0 && len(ev.publicSurfaceFiles) > rules.MaxPublicInterfacesChanged {
+ reasons = append(reasons, fmt.Sprintf("public-surface files %d > %d", len(ev.publicSurfaceFiles), rules.MaxPublicInterfacesChanged))
+ }
+ if poorTestRatio(rules, ev) {
+ reasons = append(reasons, fmt.Sprintf("test-to-production file ratio %d%% < %d%%", testToProductionRatio(ev), rules.MinTestToProductionRatioPercent))
+ }
+ if len(reasons) == 0 {
+ return core.Finding{}, false
+ }
+ return env.NewFinding(support.FindingInput{
+ RuleID: "change.oversized-diff",
+ Level: "warn",
+ Confidence: confidenceForReasonCount(len(reasons)),
+ Message: "change is difficult to review safely: " + strings.Join(reasons, "; "),
+ Metadata: evidenceMetadata(ev),
+ }), true
+}
+
+func mixedConcernsFinding(env support.Context, ev evidence) (core.Finding, bool) {
+ nonTestConcerns := nonTestConcernCount(ev)
+ if nonTestConcerns < 2 || len(ev.layers) < 2 || len(ev.directories) < 2 {
+ return core.Finding{}, false
+ }
+ return env.NewFinding(support.FindingInput{
+ RuleID: "change.mixed-concerns",
+ Level: "warn",
+ Confidence: "medium",
+ Message: fmt.Sprintf(
+ "change spans multiple concerns (%s) across layers (%s) and directories (%s)",
+ strings.Join(ev.concerns, ", "),
+ strings.Join(ev.layers, ", "),
+ strings.Join(limitStrings(ev.directories, 6), ", "),
+ ),
+ Metadata: evidenceMetadata(ev),
+ }), true
+}
+
+func tooManyConcernsFinding(env support.Context, rules core.ChangeRulesConfig, ev evidence) (core.Finding, bool) {
+ maxConcerns := rules.MaxConcernFamilies
+ if maxConcerns <= 0 || len(ev.concerns) <= maxConcerns {
+ return core.Finding{}, false
+ }
+ return env.NewFinding(support.FindingInput{
+ RuleID: "change.too-many-concerns",
+ Level: "warn",
+ Confidence: confidenceForOverage(len(ev.concerns), maxConcerns),
+ Message: fmt.Sprintf("change touches %d concern families (%s), above the configured limit of %d", len(ev.concerns), strings.Join(ev.concerns, ", "), maxConcerns),
+ Metadata: evidenceMetadata(ev),
+ }), true
+}
+
+func mixedRefactorAndBehaviorFinding(env support.Context, ev evidence) (core.Finding, bool) {
+ if len(ev.movePairs) == 0 || len(ev.behaviorFiles) == 0 {
+ return core.Finding{}, false
+ }
+ return env.NewFinding(support.FindingInput{
+ RuleID: "change.mixed-refactor-and-behavior",
+ Level: "warn",
+ Confidence: "high",
+ Message: fmt.Sprintf(
+ "change combines file movement (%s) with behavior-bearing production edits (%s)",
+ movePairSummary(ev.movePairs),
+ strings.Join(limitStrings(ev.behaviorFiles, 4), ", "),
+ ),
+ Metadata: evidenceMetadata(ev),
+ }), true
+}
+
+func unnecessarySurfaceAreaFinding(env support.Context, rules core.ChangeRulesConfig, ev evidence) (core.Finding, bool) {
+ maxPublic := rules.MaxPublicInterfacesChanged
+ if maxPublic <= 0 || len(ev.publicSurfaceFiles) <= maxPublic {
+ return core.Finding{}, false
+ }
+ return env.NewFinding(support.FindingInput{
+ RuleID: "change.unnecessary-surface-area",
+ Level: "warn",
+ Confidence: confidenceForOverage(len(ev.publicSurfaceFiles), maxPublic),
+ Message: fmt.Sprintf("change touches %d public-surface files (%s), above the configured limit of %d", len(ev.publicSurfaceFiles), strings.Join(limitStrings(ev.publicSurfaceFiles, 6), ", "), maxPublic),
+ Metadata: evidenceMetadata(ev),
+ }), true
+}
+
+func moveWithoutVerificationFinding(env support.Context, ev evidence) (core.Finding, bool) {
+ if len(ev.movePairs) == 0 || len(ev.verificationFiles) > 0 {
+ return core.Finding{}, false
+ }
+ return env.NewFinding(support.FindingInput{
+ RuleID: "change.move-without-verification",
+ Level: "warn",
+ Confidence: "high",
+ Message: fmt.Sprintf("change moves production files (%s) without changed tests or verification files", movePairSummary(ev.movePairs)),
+ Metadata: evidenceMetadata(ev),
+ }), true
+}
+
+func evidenceMetadata(ev evidence) map[string]string {
+ return map[string]string{
+ "files_touched": strconv.Itoa(ev.fileCount),
+ "directories_touched": strconv.Itoa(len(ev.directories)),
+ "layers_touched": strings.Join(ev.layers, ","),
+ "concern_families_touched": strings.Join(ev.concerns, ","),
+ "production_files_touched": strconv.Itoa(len(ev.productionFiles)),
+ "test_files_touched": strconv.Itoa(len(ev.testFiles)),
+ "test_to_production_ratio_percent": strconv.Itoa(testToProductionRatio(ev)),
+ "public_surface_files_touched": strconv.Itoa(len(ev.publicSurfaceFiles)),
+ "changed_lines": strconv.Itoa(ev.changedLines),
+ "move_pairs": strconv.Itoa(len(ev.movePairs)),
+ }
+}
+
+func detectMovePairs(files []changedFile) []movePair {
+ added := make([]string, 0)
+ deleted := make([]string, 0)
+ for _, file := range files {
+ if !isProductionFile(file.path) {
+ continue
+ }
+ switch file.status {
+ case core.ChangedFileAdded:
+ added = append(added, file.path)
+ case core.ChangedFileDeleted:
+ deleted = append(deleted, file.path)
+ }
+ }
+ sort.Strings(added)
+ sort.Strings(deleted)
+ pairs := make([]movePair, 0)
+ usedAdded := map[string]struct{}{}
+ for _, from := range deleted {
+ fromKey := moveKey(from)
+ for _, to := range added {
+ if _, used := usedAdded[to]; used {
+ continue
+ }
+ if fromKey == moveKey(to) {
+ pairs = append(pairs, movePair{from: from, to: to})
+ usedAdded[to] = struct{}{}
+ break
+ }
+ }
+ }
+ return pairs
+}
+
+func moveKey(rel string) string {
+ return strings.ToLower(filepath.Base(rel))
+}
+
+func detectBehaviorFiles(env support.Context, files []changedFile) []string {
+ seen := map[string]struct{}{}
+ for _, file := range files {
+ if file.status == core.ChangedFileDeleted || !isProductionFile(file.path) || !isSourceFile(file.path) {
+ continue
+ }
+ if fileHasBehaviorChange(env, file.path) {
+ seen[file.path] = struct{}{}
+ }
+ }
+ return sortedKeys(seen)
+}
+
+func fileHasBehaviorChange(env support.Context, rel string) bool {
+ if env.DiffScope == nil || env.ReadTargetFile == nil {
+ return false
+ }
+ scope, ok := env.DiffScope()[rel]
+ if !ok {
+ return false
+ }
+ for _, target := range env.Config.Targets {
+ data, err := env.ReadTargetFile(target, rel)
+ if err != nil {
+ continue
+ }
+ lines := strings.Split(string(data), "\n")
+ if scope.AllChanged {
+ return linesContainBehavior(lines)
+ }
+ for _, r := range scope.Ranges {
+ start := max(1, r[0])
+ end := min(len(lines), r[1])
+ if start > end {
+ continue
+ }
+ if linesContainBehavior(lines[start-1 : end]) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func linesContainBehavior(lines []string) bool {
+ for _, line := range lines {
+ text := strings.TrimSpace(line)
+ if text == "" || strings.HasPrefix(text, "//") || strings.HasPrefix(text, "#") || strings.HasPrefix(text, "*") {
+ continue
+ }
+ lower := strings.ToLower(text)
+ for _, token := range []string{
+ "return ", "if ", "else", "switch ", "case ", "for ", "while ",
+ "throw ", "panic(", "error", "err", "validate", "auth", "permission",
+ "save", "update", "delete", "insert", "publish", "emit", "send",
+ "http", "sql", "query", "exec", "time.", "rand.", "math.random",
+ } {
+ if strings.Contains(lower, token) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func poorTestRatio(rules core.ChangeRulesConfig, ev evidence) bool {
+ if rules.MinTestToProductionRatioPercent <= 0 || len(ev.productionFiles) < 3 {
+ return false
+ }
+ return testToProductionRatio(ev) < rules.MinTestToProductionRatioPercent
+}
+
+func testToProductionRatio(ev evidence) int {
+ if len(ev.productionFiles) == 0 {
+ return 100
+ }
+ return len(ev.testFiles) * 100 / len(ev.productionFiles)
+}
+
+func nonTestConcernCount(ev evidence) int {
+ count := 0
+ for _, concern := range ev.concerns {
+ if concern != "tests" {
+ count++
+ }
+ }
+ return count
+}
+
+func isProductionFile(rel string) bool {
+ return !isTestFile(rel) && !isDocsOnlyFile(rel) && !isGeneratedOrVendorFile(rel) && !isConfigOnlyFile(rel)
+}
+
+func isSourceFile(rel string) bool {
+ ext := strings.ToLower(filepath.Ext(rel))
+ switch ext {
+ case ".go", ".py", ".ts", ".tsx", ".js", ".jsx", ".java", ".kt", ".rs", ".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hh":
+ return true
+ default:
+ return false
+ }
+}
+
+func isTestFile(rel string) bool {
+ lower := strings.ToLower(normalizePath(rel))
+ base := path.Base(lower)
+ return strings.Contains(lower, "/test/") ||
+ strings.Contains(lower, "/tests/") ||
+ strings.Contains(lower, "/testdata/") ||
+ strings.Contains(lower, "/fixtures/") ||
+ strings.Contains(lower, "/__tests__/") ||
+ strings.Contains(lower, "/__fixtures__/") ||
+ strings.HasSuffix(base, "_test.go") ||
+ strings.HasSuffix(base, "_test.py") ||
+ strings.HasSuffix(base, ".test.ts") ||
+ strings.HasSuffix(base, ".test.tsx") ||
+ strings.HasSuffix(base, ".test.js") ||
+ strings.HasSuffix(base, ".test.jsx") ||
+ strings.HasSuffix(base, ".spec.ts") ||
+ strings.HasSuffix(base, ".spec.tsx") ||
+ strings.HasSuffix(base, ".spec.js") ||
+ strings.HasSuffix(base, ".spec.jsx")
+}
+
+func isVerificationFile(rel string) bool {
+ lower := strings.ToLower(normalizePath(rel))
+ base := path.Base(lower)
+ return isTestFile(rel) ||
+ strings.Contains(lower, ".github/workflows/") ||
+ strings.Contains(lower, ".buildkite/") ||
+ strings.Contains(lower, "/ci/") ||
+ base == "makefile" ||
+ base == "justfile" ||
+ base == "taskfile.yml" ||
+ base == "taskfile.yaml" ||
+ strings.HasPrefix(base, "dockerfile") ||
+ strings.HasSuffix(base, ".bats")
+}
+
+func isDocsOnlyFile(rel string) bool {
+ lower := strings.ToLower(normalizePath(rel))
+ ext := strings.ToLower(filepath.Ext(lower))
+ return strings.HasPrefix(lower, "docs/") ||
+ strings.HasPrefix(lower, ".claude/") ||
+ strings.HasPrefix(lower, ".github/") && (ext == ".md" || ext == ".txt") ||
+ ext == ".md" || ext == ".mdx" || ext == ".rst" || ext == ".adoc" || ext == ".txt"
+}
+
+func isConfigOnlyFile(rel string) bool {
+ lower := strings.ToLower(normalizePath(rel))
+ base := path.Base(lower)
+ ext := strings.ToLower(filepath.Ext(lower))
+ if base == "go.mod" || base == "go.sum" || base == "package.json" || base == "package-lock.json" ||
+ base == "pnpm-lock.yaml" || base == "yarn.lock" || base == "cargo.toml" || base == "cargo.lock" ||
+ base == "requirements.txt" || base == "poetry.lock" || base == "pyproject.toml" {
+ return true
+ }
+ return strings.HasPrefix(lower, ".github/") ||
+ strings.HasPrefix(lower, ".buildkite/") ||
+ strings.HasPrefix(lower, "ci/") ||
+ ext == ".yaml" || ext == ".yml" || ext == ".json" || ext == ".toml"
+}
+
+func isGeneratedOrVendorFile(rel string) bool {
+ lower := strings.ToLower(normalizePath(rel))
+ base := path.Base(lower)
+ return strings.Contains(lower, "/vendor/") ||
+ strings.Contains(lower, "/node_modules/") ||
+ strings.Contains(lower, "/dist/") ||
+ strings.Contains(lower, "/build/") ||
+ strings.HasSuffix(base, ".pb.go") ||
+ strings.HasSuffix(base, ".generated.go") ||
+ strings.HasSuffix(base, ".gen.go") ||
+ strings.Contains(base, ".generated.")
+}
+
+func isPublicSurfaceFile(env support.Context, rel string) bool {
+ lower := strings.ToLower(normalizePath(rel))
+ base := path.Base(lower)
+ ext := strings.ToLower(filepath.Ext(lower))
+ if env.IsSDKFacadeFile != nil && env.IsSDKFacadeFile(rel) {
+ return true
+ }
+ if env.IsPublicPackageFile != nil && env.IsPublicPackageFile(rel) {
+ return true
+ }
+ return hasPathSegment(lower, "api") ||
+ hasPathSegment(lower, "apis") ||
+ hasPathSegment(lower, "public") ||
+ hasPathSegment(lower, "pkg") ||
+ hasPathSegment(lower, "sdk") ||
+ hasPathSegment(lower, "include") ||
+ hasPathSegment(lower, "proto") ||
+ hasPathSegment(lower, "graphql") ||
+ strings.Contains(base, "openapi") ||
+ strings.Contains(base, "swagger") ||
+ strings.HasSuffix(base, ".proto") ||
+ ext == ".graphql" || ext == ".gql" ||
+ base == "index.ts" || base == "index.tsx" || base == "index.js" || base == "index.jsx"
+}
+
+func layerCategory(rel string) string {
+ lower := strings.ToLower(normalizePath(rel))
+ switch {
+ case isTestFile(lower):
+ return "tests"
+ case strings.HasPrefix(lower, ".github/") || strings.HasPrefix(lower, ".buildkite/") || strings.HasPrefix(lower, "ci/"):
+ return "delivery"
+ case strings.HasPrefix(lower, "docs/") || strings.HasSuffix(lower, ".md"):
+ return "docs"
+ case hasPathSegment(lower, "ui") || hasPathSegment(lower, "view") || strings.Contains(lower, "/component") || hasPathSegment(lower, "frontend"):
+ return "presentation"
+ case hasPathSegment(lower, "api") || strings.Contains(lower, "/handler") || strings.Contains(lower, "/controller") || strings.Contains(lower, "/route"):
+ return "application"
+ case hasPathSegment(lower, "domain") || strings.Contains(lower, "/model/") || strings.Contains(lower, "/service/"):
+ return "domain"
+ case hasPathSegment(lower, "db") || hasPathSegment(lower, "data") || strings.Contains(lower, "/store/") || strings.Contains(lower, "/repo"):
+ return "data"
+ case hasPathSegment(lower, "infra") || hasPathSegment(lower, "platform") || strings.Contains(lower, "/adapter/"):
+ return "infrastructure"
+ default:
+ return "core"
+ }
+}
+
+func concernFamily(rel string) string {
+ lower := strings.ToLower(normalizePath(rel))
+ base := path.Base(lower)
+ ext := strings.ToLower(filepath.Ext(lower))
+ switch {
+ case isTestFile(lower):
+ return "tests"
+ case strings.HasPrefix(lower, ".github/") || strings.HasPrefix(lower, ".buildkite/") || strings.HasPrefix(lower, "ci/"):
+ return "ci"
+ case strings.HasPrefix(lower, "docs/") || ext == ".md" || ext == ".mdx" || ext == ".rst":
+ return "docs"
+ case base == "go.mod" || base == "go.sum" || base == "package.json" || strings.Contains(base, "lock"):
+ return "dependencies"
+ case ext == ".yaml" || ext == ".yml" || ext == ".json" || ext == ".toml":
+ return "config"
+ case hasPathSegment(lower, "auth") || strings.Contains(lower, "auth") || strings.Contains(lower, "permission"):
+ return "auth"
+ case hasPathSegment(lower, "billing") || strings.Contains(lower, "payment") || strings.Contains(lower, "invoice"):
+ return "billing"
+ case hasPathSegment(lower, "api") || strings.Contains(base, "openapi") || strings.Contains(base, "swagger") || ext == ".proto" || ext == ".graphql" || ext == ".gql":
+ return "api"
+ case hasPathSegment(lower, "ui") || hasPathSegment(lower, "frontend") || strings.Contains(lower, "/component") || hasPathSegment(lower, "view"):
+ return "ui"
+ case hasPathSegment(lower, "db") || hasPathSegment(lower, "data") || strings.Contains(lower, "/store/") || strings.Contains(lower, "/repo") || strings.Contains(lower, "migration"):
+ return "data"
+ case hasPathSegment(lower, "infra") || hasPathSegment(lower, "platform") || hasPathSegment(lower, "deploy") || strings.Contains(lower, "docker"):
+ return "infra"
+ default:
+ layer := layerCategory(lower)
+ if layer != "core" {
+ return layer
+ }
+ return firstPathSegment(lower)
+ }
+}
+
+func directoryOf(rel string) string {
+ dir := path.Dir(normalizePath(rel))
+ if dir == "." || dir == "" {
+ return "."
+ }
+ return dir
+}
+
+func firstPathSegment(rel string) string {
+ rel = strings.Trim(normalizePath(rel), "/")
+ if rel == "" {
+ return "."
+ }
+ if idx := strings.Index(rel, "/"); idx >= 0 {
+ return rel[:idx]
+ }
+ return "."
+}
+
+func hasPathSegment(rel string, segment string) bool {
+ for _, part := range strings.Split(strings.Trim(normalizePath(rel), "/"), "/") {
+ if part == segment {
+ return true
+ }
+ }
+ return false
+}
+
+func sortedKeys(set map[string]struct{}) []string {
+ out := make([]string, 0, len(set))
+ for key := range set {
+ out = append(out, key)
+ }
+ sort.Strings(out)
+ return out
+}
+
+func normalizePath(rel string) string {
+ return strings.Trim(filepath.ToSlash(strings.TrimSpace(rel)), "/")
+}
+
+func limitStrings(values []string, limit int) []string {
+ if len(values) <= limit {
+ return append([]string(nil), values...)
+ }
+ out := append([]string(nil), values[:limit]...)
+ out = append(out, fmt.Sprintf("+%d more", len(values)-limit))
+ return out
+}
+
+func movePairSummary(pairs []movePair) string {
+ parts := make([]string, 0, len(pairs))
+ for _, pair := range pairs {
+ parts = append(parts, pair.from+" -> "+pair.to)
+ }
+ return strings.Join(limitStrings(parts, 3), ", ")
+}
+
+func confidenceForReasonCount(count int) string {
+ if count >= 2 {
+ return "high"
+ }
+ return "medium"
+}
+
+func confidenceForOverage(value int, limit int) string {
+ if limit > 0 && value >= limit*2 {
+ return "high"
+ }
+ return "medium"
+}
+
+func enabled(toggle *bool) bool {
+ return toggle == nil || *toggle
+}
diff --git a/internal/codeguard/checks/change/change_smells.go b/internal/codeguard/checks/change/change_smells.go
new file mode 100644
index 0000000..42b2991
--- /dev/null
+++ b/internal/codeguard/checks/change/change_smells.go
@@ -0,0 +1,750 @@
+package change
+
+import (
+ "fmt"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/devr-tools/codeguard/internal/codeguard/checks/support"
+ "github.com/devr-tools/codeguard/internal/codeguard/core"
+)
+
+var (
+ wordTokenPattern = regexp.MustCompile(`[A-Za-z_][A-Za-z0-9_]*|\d+|==|!=|<=|>=|&&|\|\||[{}()[\].,:;+*/%<>=!?-]`)
+ goInterfaceDeclPattern = regexp.MustCompile(`^\s*type\s+([A-Za-z_][A-Za-z0-9_]*)\s+interface\b`)
+ scriptInterfaceDeclPattern = regexp.MustCompile(`^\s*(?:export\s+)?interface\s+([A-Za-z_][A-Za-z0-9_]*)\b`)
+ abstractClassDeclPattern = regexp.MustCompile(`^\s*(?:export\s+)?abstract\s+class\s+([A-Za-z_][A-Za-z0-9_]*)\b`)
+ cppAbstractClassDeclPattern = regexp.MustCompile(`^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)\b`)
+ functionDeclPattern = regexp.MustCompile(`^\s*(?:export\s+)?(?:async\s+)?(?:func|function|def)\s+([A-Za-z_][A-Za-z0-9_]*)\b|^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>`)
+ cppFunctionDeclPattern = regexp.MustCompile(`^\s*(?:[A-Za-z_][A-Za-z0-9_:<>,*&\s]+)\s+([A-Za-z_][A-Za-z0-9_]*)\s*\([^;{}]*\)\s*(?:const\s*)?\{`)
+ complexityTokenPattern = regexp.MustCompile(`\b(if|else\s+if|elif|for|while|switch|case|catch|except|guard|when)\b|&&|\|\||\?`)
+ dependencyLinePattern = regexp.MustCompile(`^\s*(?:import\b|from\s+\S+\s+import\b|#include\b|using\s+namespace\b|const\s+\w+\s*=\s*require\()`)
+ pythonFunctionDeclPattern = regexp.MustCompile(`^def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(`)
+ publicDeclPatternsByExtension = map[string][]*regexp.Regexp{
+ ".go": {regexp.MustCompile(`^\s*(?:type|func|const|var)\s+[A-Z][A-Za-z0-9_]*\b`)},
+ ".py": {regexp.MustCompile(`^\s*class\s+[A-Z][A-Za-z0-9_]*\b`), regexp.MustCompile(`^\s*def\s+[A-Za-z][A-Za-z0-9_]*\b`)},
+ ".ts": {regexp.MustCompile(`^\s*export\s+(?:interface|type|class|function|const|let|var)\s+[A-Za-z_][A-Za-z0-9_]*\b`)},
+ ".tsx": {regexp.MustCompile(`^\s*export\s+(?:interface|type|class|function|const|let|var)\s+[A-Za-z_][A-Za-z0-9_]*\b`)},
+ ".js": {regexp.MustCompile(`^\s*export\s+(?:class|function|const|let|var)\s+[A-Za-z_][A-Za-z0-9_]*\b`)},
+ ".jsx": {regexp.MustCompile(`^\s*export\s+(?:class|function|const|let|var)\s+[A-Za-z_][A-Za-z0-9_]*\b`)},
+ ".h": {regexp.MustCompile(`^\s*(?:class|struct|enum)\s+[A-Za-z_][A-Za-z0-9_]*\b`)},
+ ".hpp": {regexp.MustCompile(`^\s*(?:class|struct|enum)\s+[A-Za-z_][A-Za-z0-9_]*\b`)},
+ ".hh": {regexp.MustCompile(`^\s*(?:class|struct|enum)\s+[A-Za-z_][A-Za-z0-9_]*\b`)},
+ }
+)
+
+type changedFileContent struct {
+ path string
+ status core.ChangedFileStatus
+ target core.TargetConfig
+ head []byte
+ base []byte
+ hasBase bool
+ ranges core.ChangedLineRanges
+}
+
+type abstractionDecl struct {
+ name string
+ path string
+ line int
+}
+
+type helperFunction struct {
+ name string
+ path string
+ line int
+ body string
+ normalized string
+ tokens int
+ changed bool
+}
+
+type fileMaintainabilityMetrics struct {
+ complexity int
+ nesting int
+ deps int
+ public int
+}
+
+func changeSmellFindings(env support.Context, ev evidence) []core.Finding {
+ if env.Mode != core.ScanModeDiff || len(ev.files) == 0 || env.ReadTargetFile == nil {
+ return nil
+ }
+ files := changedSourceContents(env, ev)
+ if len(files) == 0 {
+ return nil
+ }
+
+ rules := env.Config.Checks.ChangeRules
+ findings := make([]core.Finding, 0, 4)
+ if enabled(rules.DetectOneUseAbstraction) {
+ findings = append(findings, oneUseAbstractionFindings(env, files)...)
+ }
+ if enabled(rules.DetectDuplicateHelper) {
+ findings = append(findings, duplicateHelperFindings(env, files)...)
+ }
+ if enabled(rules.DetectComplexityIncreased) {
+ findings = append(findings, complexityIncreasedFindings(env, files)...)
+ }
+ if enabled(rules.DetectCleanupRegression) {
+ findings = append(findings, cleanupRegressionFindings(env, files)...)
+ }
+ return findings
+}
+
+func changedSourceContents(env support.Context, ev evidence) []changedFileContent {
+ scope := map[string]core.ChangedLineRanges{}
+ if env.DiffScope != nil {
+ scope = env.DiffScope()
+ }
+ out := make([]changedFileContent, 0, len(ev.files))
+ for _, file := range ev.files {
+ if file.status == core.ChangedFileDeleted || !isProductionFile(file.path) || !isSourceFile(file.path) {
+ continue
+ }
+ if isAdapterPath(file.path) {
+ continue
+ }
+ for _, target := range env.Config.Targets {
+ head, err := env.ReadTargetFile(target, file.path)
+ if err != nil {
+ continue
+ }
+ base, baseErr := readBaseFile(env, target, file.path)
+ out = append(out, changedFileContent{
+ path: file.path,
+ status: file.status,
+ target: target,
+ head: head,
+ base: base,
+ hasBase: baseErr == nil,
+ ranges: scope[file.path],
+ })
+ break
+ }
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].path < out[j].path })
+ return out
+}
+
+func readBaseFile(env support.Context, target core.TargetConfig, rel string) ([]byte, error) {
+ if env.ReadBaseFile == nil {
+ return nil, fmt.Errorf("base file callback is not configured")
+ }
+ return env.ReadBaseFile(target, rel)
+}
+
+func oneUseAbstractionFindings(env support.Context, files []changedFileContent) []core.Finding {
+ decls := make([]abstractionDecl, 0, len(files)*2)
+ for _, file := range files {
+ decls = append(decls, newAbstractionDecls(file)...)
+ }
+ if len(decls) == 0 {
+ return nil
+ }
+
+ repoText := productionSourceText(env)
+ findings := make([]core.Finding, 0, len(decls))
+ for _, decl := range decls {
+ uses := countWord(repoText, decl.name)
+ if uses > 2 {
+ continue
+ }
+ findings = append(findings, env.NewFinding(support.FindingInput{
+ RuleID: "change.one-use-abstraction",
+ Level: "warn",
+ Path: decl.path,
+ Line: decl.line,
+ Confidence: "medium",
+ Message: fmt.Sprintf("new abstraction %s has only %d repository reference(s); keep the boundary only if it carries real policy or has another concrete consumer", decl.name, uses),
+ Metadata: map[string]string{
+ "abstraction": decl.name,
+ "references": strconv.Itoa(uses),
+ },
+ }))
+ }
+ return findings
+}
+
+func newAbstractionDecls(file changedFileContent) []abstractionDecl {
+ lines := strings.Split(string(file.head), "\n")
+ out := make([]abstractionDecl, 0)
+ for idx, raw := range lines {
+ lineNo := idx + 1
+ if !lineIsChanged(file.ranges, lineNo) {
+ continue
+ }
+ line := strings.TrimSpace(maskLineComments(raw))
+ if line == "" {
+ continue
+ }
+ name, ok := abstractionNameForLine(file.path, line)
+ if !ok || abstractionDeclaredInBase(file, name) {
+ continue
+ }
+ out = append(out, abstractionDecl{name: name, path: file.path, line: lineNo})
+ }
+ return out
+}
+
+func abstractionNameForLine(rel string, line string) (string, bool) {
+ ext := strings.ToLower(filepath.Ext(rel))
+ switch ext {
+ case ".go":
+ if m := goInterfaceDeclPattern.FindStringSubmatch(line); len(m) == 2 {
+ return m[1], true
+ }
+ case ".ts", ".tsx", ".js", ".jsx":
+ if m := scriptInterfaceDeclPattern.FindStringSubmatch(line); len(m) == 2 {
+ return m[1], true
+ }
+ if m := abstractClassDeclPattern.FindStringSubmatch(line); len(m) == 2 {
+ return m[1], true
+ }
+ case ".h", ".hpp", ".hh":
+ if m := cppAbstractClassDeclPattern.FindStringSubmatch(line); len(m) == 2 && strings.Contains(line, "virtual") {
+ return m[1], true
+ }
+ }
+ return "", false
+}
+
+func abstractionDeclaredInBase(file changedFileContent, name string) bool {
+ if !file.hasBase {
+ return false
+ }
+ for _, line := range strings.Split(string(file.base), "\n") {
+ if baseName, ok := abstractionNameForLine(file.path, strings.TrimSpace(maskLineComments(line))); ok && baseName == name {
+ return true
+ }
+ }
+ return false
+}
+
+func duplicateHelperFindings(env support.Context, files []changedFileContent) []core.Finding {
+ all := allProductionFunctions(env)
+ if len(all) < 2 {
+ return nil
+ }
+ changed := changedHelperFunctions(files)
+ findings := make([]core.Finding, 0)
+ seen := map[string]struct{}{}
+ for _, candidate := range changed {
+ if candidate.tokens < 18 || !isHelperLikeName(candidate.name) {
+ continue
+ }
+ for _, other := range all {
+ if sameFunctionLocation(candidate, other) || other.normalized != candidate.normalized {
+ continue
+ }
+ key := candidate.path + ":" + candidate.name
+ if _, exists := seen[key]; exists {
+ break
+ }
+ seen[key] = struct{}{}
+ findings = append(findings, env.NewFinding(support.FindingInput{
+ RuleID: "change.duplicate-helper",
+ Level: "warn",
+ Path: candidate.path,
+ Line: candidate.line,
+ Confidence: "high",
+ Message: fmt.Sprintf("changed helper %s duplicates %s in %s:%d; reuse one shared implementation instead of adding a parallel helper", candidate.name, other.name, other.path, other.line),
+ Metadata: map[string]string{
+ "helper": candidate.name,
+ "duplicate_of": other.name,
+ "duplicate_path": other.path,
+ },
+ }))
+ break
+ }
+ }
+ return findings
+}
+
+func changedHelperFunctions(files []changedFileContent) []helperFunction {
+ out := make([]helperFunction, 0)
+ for _, file := range files {
+ for _, fn := range extractFunctions(file.path, string(file.head)) {
+ if !functionIntersectsChangedLines(fn, file.ranges) {
+ continue
+ }
+ fn.changed = true
+ out = append(out, fn)
+ }
+ }
+ sortFunctions(out)
+ return out
+}
+
+func allProductionFunctions(env support.Context) []helperFunction {
+ out := make([]helperFunction, 0)
+ for _, target := range env.Config.Targets {
+ if env.VisitTargetFiles != nil {
+ env.VisitTargetFiles(target, func(rel string) bool {
+ return isProductionFile(rel) && isSourceFile(rel) && !isGeneratedPath(rel) && !isAdapterPath(rel)
+ }, func(rel string, data []byte) {
+ out = append(out, extractFunctions(rel, string(data))...)
+ })
+ continue
+ }
+ if env.ListTargetFiles == nil || env.ReadTargetFile == nil {
+ continue
+ }
+ files, err := env.ListTargetFiles(target)
+ if err != nil {
+ continue
+ }
+ sort.Strings(files)
+ for _, rel := range files {
+ if !isProductionFile(rel) || !isSourceFile(rel) || isGeneratedPath(rel) || isAdapterPath(rel) {
+ continue
+ }
+ data, err := env.ReadTargetFile(target, rel)
+ if err == nil {
+ out = append(out, extractFunctions(rel, string(data))...)
+ }
+ }
+ }
+ sortFunctions(out)
+ return out
+}
+
+func extractFunctions(rel string, source string) []helperFunction {
+ if strings.HasSuffix(strings.ToLower(rel), ".py") {
+ return extractPythonFunctions(rel, source)
+ }
+ return extractBraceFunctions(rel, source)
+}
+
+func extractBraceFunctions(rel string, source string) []helperFunction {
+ lines := strings.Split(source, "\n")
+ out := make([]helperFunction, 0)
+ for i := 0; i < len(lines); i++ {
+ line := lines[i]
+ name, ok := braceFunctionName(rel, line)
+ if !ok {
+ continue
+ }
+ bodyLines, end, ok := collectBraceBody(lines, i)
+ if !ok {
+ continue
+ }
+ normalized, tokens := normalizedTokenText(normalizedBraceBody(bodyLines))
+ if tokens > 0 {
+ out = append(out, helperFunction{name: name, path: rel, line: i + 1, body: strings.Join(bodyLines, "\n"), normalized: normalized, tokens: tokens})
+ }
+ i = end
+ }
+ return out
+}
+
+func braceFunctionName(rel string, line string) (string, bool) {
+ line = strings.TrimSpace(maskLineComments(line))
+ ext := strings.ToLower(filepath.Ext(rel))
+ switch ext {
+ case ".go", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs":
+ if m := functionDeclPattern.FindStringSubmatch(line); len(m) == 3 {
+ if m[1] != "" {
+ return m[1], true
+ }
+ return m[2], true
+ }
+ case ".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hh":
+ if m := cppFunctionDeclPattern.FindStringSubmatch(line); len(m) == 2 {
+ return m[1], true
+ }
+ }
+ return "", false
+}
+
+func collectBraceBody(lines []string, start int) ([]string, int, bool) {
+ depth := 0
+ seenOpen := false
+ body := make([]string, 0)
+ for i := start; i < len(lines); i++ {
+ line := lines[i]
+ for _, r := range line {
+ switch r {
+ case '{':
+ depth++
+ seenOpen = true
+ case '}':
+ if depth > 0 {
+ depth--
+ }
+ }
+ }
+ body = append(body, line)
+ if seenOpen && depth == 0 {
+ return body, i, true
+ }
+ }
+ return nil, start, false
+}
+
+func normalizedBraceBody(lines []string) string {
+ joined := strings.Join(lines, "\n")
+ if idx := strings.Index(joined, "{"); idx >= 0 {
+ joined = joined[idx+1:]
+ }
+ if idx := strings.LastIndex(joined, "}"); idx >= 0 {
+ joined = joined[:idx]
+ }
+ return joined
+}
+
+func extractPythonFunctions(rel string, source string) []helperFunction {
+ lines := strings.Split(source, "\n")
+ out := make([]helperFunction, 0)
+ for i := 0; i < len(lines); i++ {
+ line := strings.TrimSpace(maskLineComments(lines[i]))
+ m := pythonFunctionDeclPattern.FindStringSubmatch(line)
+ if len(m) != 2 {
+ continue
+ }
+ indent := leadingSpaces(lines[i])
+ end := i + 1
+ for end < len(lines) {
+ trimmed := strings.TrimSpace(lines[end])
+ if trimmed != "" && !isCommentOnly(trimmed) && leadingSpaces(lines[end]) <= indent {
+ break
+ }
+ end++
+ }
+ body := strings.Join(lines[i:end], "\n")
+ normalized, tokens := normalizedTokenText(strings.Join(lines[i+1:end], "\n"))
+ if tokens > 0 {
+ out = append(out, helperFunction{name: m[1], path: rel, line: i + 1, body: body, normalized: normalized, tokens: tokens})
+ }
+ i = end - 1
+ }
+ return out
+}
+
+func complexityIncreasedFindings(env support.Context, files []changedFileContent) []core.Finding {
+ findings := make([]core.Finding, 0)
+ for _, file := range files {
+ if !file.hasBase {
+ continue
+ }
+ base := maintainabilityMetrics(string(file.base))
+ head := maintainabilityMetrics(string(file.head))
+ if !complexityRegression(base, head) {
+ continue
+ }
+ findings = append(findings, env.NewFinding(support.FindingInput{
+ RuleID: "change.complexity-increased",
+ Level: "warn",
+ Path: file.path,
+ Line: firstChangedComplexityLine(file),
+ Confidence: confidenceForComplexityRegression(base, head),
+ Message: fmt.Sprintf(
+ "changed file complexity increased from %d to %d and max nesting from %d to %d; verify the added branching is necessary and covered",
+ base.complexity,
+ head.complexity,
+ base.nesting,
+ head.nesting,
+ ),
+ Metadata: map[string]string{
+ "base_complexity": strconv.Itoa(base.complexity),
+ "head_complexity": strconv.Itoa(head.complexity),
+ "base_nesting": strconv.Itoa(base.nesting),
+ "head_nesting": strconv.Itoa(head.nesting),
+ },
+ }))
+ }
+ return findings
+}
+
+func cleanupRegressionFindings(env support.Context, files []changedFileContent) []core.Finding {
+ if !changeClaimsCleanup(env) {
+ return nil
+ }
+ findings := make([]core.Finding, 0)
+ for _, file := range files {
+ if !file.hasBase {
+ continue
+ }
+ base := maintainabilityMetrics(string(file.base))
+ head := maintainabilityMetrics(string(file.head))
+ reasons := cleanupRegressionReasons(base, head)
+ if len(reasons) == 0 {
+ continue
+ }
+ findings = append(findings, env.NewFinding(support.FindingInput{
+ RuleID: "change.cleanup-regression",
+ Level: "warn",
+ Path: file.path,
+ Line: firstChangedComplexityLine(file),
+ Confidence: confidenceForReasonCount(len(reasons)),
+ Message: "cleanup/refactor-labeled change worsens maintainability signals: " + strings.Join(reasons, "; "),
+ Metadata: map[string]string{
+ "base_complexity": strconv.Itoa(base.complexity),
+ "head_complexity": strconv.Itoa(head.complexity),
+ "base_deps": strconv.Itoa(base.deps),
+ "head_deps": strconv.Itoa(head.deps),
+ "base_public": strconv.Itoa(base.public),
+ "head_public": strconv.Itoa(head.public),
+ },
+ }))
+ }
+ return findings
+}
+
+func maintainabilityMetrics(source string) fileMaintainabilityMetrics {
+ lines := strings.Split(source, "\n")
+ metrics := fileMaintainabilityMetrics{}
+ braceDepth := 0
+ for _, raw := range lines {
+ line := strings.TrimSpace(maskLineComments(raw))
+ if line == "" || isCommentOnly(line) {
+ continue
+ }
+ metrics.complexity += len(complexityTokenPattern.FindAllString(line, -1))
+ if dependencyLinePattern.MatchString(strings.ToLower(line)) {
+ metrics.deps++
+ }
+ if isPublicDeclarationLine(line, "") {
+ metrics.public++
+ }
+ if strings.Contains(line, "{") || strings.Contains(line, "}") {
+ for _, r := range line {
+ if r == '{' {
+ braceDepth++
+ if braceDepth > metrics.nesting {
+ metrics.nesting = braceDepth
+ }
+ }
+ if r == '}' && braceDepth > 0 {
+ braceDepth--
+ }
+ }
+ continue
+ }
+ if indent := leadingSpaces(raw) / 4; indent > metrics.nesting {
+ metrics.nesting = indent
+ }
+ }
+ return metrics
+}
+
+func cleanupRegressionReasons(base fileMaintainabilityMetrics, head fileMaintainabilityMetrics) []string {
+ reasons := make([]string, 0, 4)
+ if complexityRegression(base, head) {
+ reasons = append(reasons, fmt.Sprintf("complexity %d -> %d", base.complexity, head.complexity))
+ }
+ if head.nesting > base.nesting {
+ reasons = append(reasons, fmt.Sprintf("nesting %d -> %d", base.nesting, head.nesting))
+ }
+ if head.public > base.public {
+ reasons = append(reasons, fmt.Sprintf("public declarations %d -> %d", base.public, head.public))
+ }
+ if head.deps > base.deps+1 {
+ reasons = append(reasons, fmt.Sprintf("direct dependencies %d -> %d", base.deps, head.deps))
+ }
+ return reasons
+}
+
+func complexityRegression(base fileMaintainabilityMetrics, head fileMaintainabilityMetrics) bool {
+ return head.complexity >= base.complexity+2 || head.nesting > base.nesting+1
+}
+
+func confidenceForComplexityRegression(base fileMaintainabilityMetrics, head fileMaintainabilityMetrics) string {
+ if head.complexity >= base.complexity+4 || head.nesting > base.nesting+2 {
+ return "high"
+ }
+ return "medium"
+}
+
+func firstChangedComplexityLine(file changedFileContent) int {
+ lines := strings.Split(string(file.head), "\n")
+ for idx, raw := range lines {
+ lineNo := idx + 1
+ if !lineIsChanged(file.ranges, lineNo) {
+ continue
+ }
+ line := strings.ToLower(strings.TrimSpace(maskLineComments(raw)))
+ if complexityTokenPattern.MatchString(line) {
+ return lineNo
+ }
+ }
+ return firstChangedLine(file.ranges)
+}
+
+func changeClaimsCleanup(env support.Context) bool {
+ haystack := strings.ToLower(env.Config.Name + "\n" + env.BaseRef + "\n" + env.DiffText)
+ for _, token := range []string{"cleanup", "clean-up", "refactor", "simplify", "tidy", "chore"} {
+ if strings.Contains(haystack, token) {
+ return true
+ }
+ }
+ return false
+}
+
+func productionSourceText(env support.Context) string {
+ var b strings.Builder
+ for _, target := range env.Config.Targets {
+ if env.VisitTargetFiles != nil {
+ env.VisitTargetFiles(target, func(rel string) bool {
+ return isProductionFile(rel) && isSourceFile(rel) && !isGeneratedPath(rel) && !isAdapterPath(rel)
+ }, func(_ string, data []byte) {
+ b.Write(data)
+ b.WriteByte('\n')
+ })
+ continue
+ }
+ if env.ListTargetFiles == nil || env.ReadTargetFile == nil {
+ continue
+ }
+ files, err := env.ListTargetFiles(target)
+ if err != nil {
+ continue
+ }
+ sort.Strings(files)
+ for _, rel := range files {
+ if !isProductionFile(rel) || !isSourceFile(rel) || isGeneratedPath(rel) || isAdapterPath(rel) {
+ continue
+ }
+ if data, err := env.ReadTargetFile(target, rel); err == nil {
+ b.Write(data)
+ b.WriteByte('\n')
+ }
+ }
+ }
+ return b.String()
+}
+
+func normalizedTokenText(source string) (string, int) {
+ matches := wordTokenPattern.FindAllString(source, -1)
+ if len(matches) == 0 {
+ return "", 0
+ }
+ for i, token := range matches {
+ if isNumberToken(token) {
+ matches[i] = "num"
+ continue
+ }
+ matches[i] = strings.ToLower(token)
+ }
+ return strings.Join(matches, " "), len(matches)
+}
+
+func isNumberToken(token string) bool {
+ if token == "" {
+ return false
+ }
+ for i := 0; i < len(token); i++ {
+ if token[i] < '0' || token[i] > '9' {
+ return false
+ }
+ }
+ return true
+}
+
+func isHelperLikeName(name string) bool {
+ lower := strings.ToLower(name)
+ for _, token := range []string{"helper", "normalize", "canonical", "parse", "format", "convert", "build", "make", "map", "validate", "sanitize", "clean"} {
+ if strings.Contains(lower, token) {
+ return true
+ }
+ }
+ return false
+}
+
+func sameFunctionLocation(left helperFunction, right helperFunction) bool {
+ return left.path == right.path && left.line == right.line
+}
+
+func functionIntersectsChangedLines(fn helperFunction, ranges core.ChangedLineRanges) bool {
+ end := fn.line + strings.Count(fn.body, "\n")
+ if ranges.AllChanged || len(ranges.Ranges) == 0 {
+ return true
+ }
+ for _, r := range ranges.Ranges {
+ if r[0] <= end && r[1] >= fn.line {
+ return true
+ }
+ }
+ return false
+}
+
+func lineIsChanged(ranges core.ChangedLineRanges, line int) bool {
+ if ranges.AllChanged || len(ranges.Ranges) == 0 {
+ return true
+ }
+ return ranges.Contains(line)
+}
+
+func firstChangedLine(ranges core.ChangedLineRanges) int {
+ if ranges.AllChanged || len(ranges.Ranges) == 0 {
+ return 1
+ }
+ best := ranges.Ranges[0][0]
+ for _, r := range ranges.Ranges[1:] {
+ if r[0] < best {
+ best = r[0]
+ }
+ }
+ if best < 1 {
+ return 1
+ }
+ return best
+}
+
+func countWord(text string, word string) int {
+ if text == "" || word == "" {
+ return 0
+ }
+ return len(regexp.MustCompile(`\b`+regexp.QuoteMeta(word)+`\b`).FindAllStringIndex(text, -1))
+}
+
+func isAdapterPath(rel string) bool {
+ lower := strings.ToLower(normalizePath(rel))
+ return hasPathSegment(lower, "adapter") || hasPathSegment(lower, "adapters")
+}
+
+func leadingSpaces(line string) int {
+ count := 0
+ for _, r := range line {
+ if r != ' ' {
+ break
+ }
+ count++
+ }
+ return count
+}
+
+func isPublicDeclarationLine(line string, rel string) bool {
+ ext := strings.ToLower(filepath.Ext(rel))
+ if ext != "" {
+ for _, pattern := range publicDeclPatternsByExtension[ext] {
+ if pattern.MatchString(line) {
+ return true
+ }
+ }
+ return false
+ }
+ for _, patterns := range publicDeclPatternsByExtension {
+ for _, pattern := range patterns {
+ if pattern.MatchString(line) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func sortFunctions(functions []helperFunction) {
+ sort.Slice(functions, func(i, j int) bool {
+ if functions[i].path != functions[j].path {
+ return functions[i].path < functions[j].path
+ }
+ if functions[i].line != functions[j].line {
+ return functions[i].line < functions[j].line
+ }
+ return functions[i].name < functions[j].name
+ })
+}
diff --git a/internal/codeguard/checks/change/refactor.go b/internal/codeguard/checks/change/refactor.go
new file mode 100644
index 0000000..baf47d0
--- /dev/null
+++ b/internal/codeguard/checks/change/refactor.go
@@ -0,0 +1,823 @@
+package change
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/devr-tools/codeguard/internal/codeguard/checks/support"
+ "github.com/devr-tools/codeguard/internal/codeguard/core"
+)
+
+var (
+ goPublicSigPattern = regexp.MustCompile(`(?m)^\s*(?:func\s+(?:\([^)]*\)\s*)?|type\s+|var\s+|const\s+)([A-Z][A-Za-z0-9_]*)[^{\n]*`)
+ goPrivateSigPattern = regexp.MustCompile(`(?m)^\s*(?:func\s+(?:\([^)]*\)\s*)?|type\s+|var\s+|const\s+)([a-z_][A-Za-z0-9_]*)[^{\n]*`)
+ tsPublicSigPattern = regexp.MustCompile(`(?m)^\s*export\s+(?:async\s+)?(?:function|class|interface|type|const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)[^{;=\n]*`)
+ tsPrivateSigPattern = regexp.MustCompile(`(?m)^\s*(?:async\s+)?(?:function|class|interface|type|const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)[^{;=\n]*`)
+ pythonPublicSigPattern = regexp.MustCompile(`(?m)^\s*(?:def|class)\s+([A-Za-z][A-Za-z0-9_]*)\s*[(:]`)
+ pythonPrivateSigPattern = regexp.MustCompile(`(?m)^\s*(?:def|class)\s+(_[A-Za-z0-9_]+|[a-z][A-Za-z0-9_]*)\s*[(:]`)
+ cppPublicSigPattern = regexp.MustCompile(`(?m)^\s*(?:template\s*<[^>]+>\s*)?(?:class|struct|enum|using|typedef|[A-Za-z_:<>~*&\s]+)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:\([^;{}]*\)|[:;{=])`)
+ importPattern = regexp.MustCompile(`(?m)^\s*(?:import\s+(?:[^'"\n]+from\s+)?["']([^"']+)["']|(?:from|import)\s+([A-Za-z0-9_./-]+)|#include\s+[<"]([^>"]+)[>"]|import\s+(?:\([^)]*?"([^"]+)"[^)]*?\)|"([^"]+)"))`)
+)
+
+type refactorFilePair struct {
+ beforePath string
+ afterPath string
+ status core.ChangedFileStatus
+ base []byte
+ after []byte
+ ranges core.ChangedLineRanges
+ moved bool
+}
+
+type refactorFindingEvidence struct {
+ ruleID string
+ level string
+ confidence string
+ path string
+ line int
+ message string
+ metadata map[string]string
+}
+
+func refactorFindings(ctx context.Context, env support.Context) []core.Finding {
+ if env.Mode != core.ScanModeDiff || env.ListChangedFiles == nil || env.ReadTargetFile == nil || env.ReadBaseFile == nil {
+ return nil
+ }
+ rules := env.Config.Checks.ChangeRules
+ findings := make([]core.Finding, 0)
+ for _, target := range env.Config.Targets {
+ select {
+ case <-ctx.Done():
+ return findings
+ default:
+ }
+ pairs := refactorPairs(env, target)
+ for _, pair := range pairs {
+ if !isSourceFile(pair.afterPath) && !isSourceFile(pair.beforePath) {
+ continue
+ }
+ for _, evidence := range refactorPairEvidence(rules, pair) {
+ findings = append(findings, env.NewFinding(support.FindingInput{
+ RuleID: evidence.ruleID,
+ Level: evidence.level,
+ Path: evidence.path,
+ Line: evidence.line,
+ Message: evidence.message,
+ Confidence: evidence.confidence,
+ Metadata: evidence.metadata,
+ }))
+ }
+ }
+ if enabled(rules.DetectRefactorDuplicateLeftBehind) {
+ findings = append(findings, duplicateImplementationFindings(env, target, pairs)...)
+ }
+ }
+ return findings
+}
+
+func refactorPairs(env support.Context, target core.TargetConfig) []refactorFilePair {
+ changed, err := env.ListChangedFiles(target)
+ if err != nil {
+ return nil
+ }
+ sort.Slice(changed, func(i, j int) bool { return changed[i].Path < changed[j].Path })
+ scope := map[string]core.ChangedLineRanges{}
+ if env.DiffScope != nil {
+ scope = env.DiffScope()
+ }
+ moves := detectMovePairs(changedFilesFromCore(changed))
+ movedTo := map[string]movePair{}
+ for _, pair := range moves {
+ movedTo[pair.to] = pair
+ }
+ out := make([]refactorFilePair, 0, len(changed))
+ for _, file := range changed {
+ afterPath := normalizePath(file.Path)
+ if afterPath == "" || file.Status == core.ChangedFileDeleted || !isSourceFile(afterPath) ||
+ isDocsOnlyFile(afterPath) || isConfigOnlyFile(afterPath) || isGeneratedOrVendorFile(afterPath) {
+ continue
+ }
+ beforePath := afterPath
+ moved := false
+ if pair, ok := movedTo[afterPath]; ok {
+ beforePath = pair.from
+ moved = true
+ }
+ after, err := env.ReadTargetFile(target, afterPath)
+ if err != nil {
+ continue
+ }
+ base, err := env.ReadBaseFile(target, beforePath)
+ if err != nil {
+ continue
+ }
+ out = append(out, refactorFilePair{
+ beforePath: beforePath,
+ afterPath: afterPath,
+ status: file.Status,
+ base: base,
+ after: after,
+ ranges: scope[afterPath],
+ moved: moved,
+ })
+ }
+ return out
+}
+
+func changedFilesFromCore(files []core.ChangedFile) []changedFile {
+ out := make([]changedFile, 0, len(files))
+ for _, file := range files {
+ out = append(out, changedFile{path: normalizePath(file.Path), status: file.Status})
+ }
+ return out
+}
+
+func refactorPairEvidence(rules core.ChangeRulesConfig, pair refactorFilePair) []refactorFindingEvidence {
+ baseText := string(pair.base)
+ afterText := string(pair.after)
+ production := isProductionFile(pair.afterPath)
+ refactorLike := pair.moved || fileLooksRefactored(pair.beforePath, baseText, afterText)
+ out := make([]refactorFindingEvidence, 0, 8)
+
+ if production && enabled(rules.DetectRefactorPublicContract) {
+ if evidence, ok := publicContractEvidence(pair, baseText, afterText); ok {
+ out = append(out, evidence)
+ }
+ }
+ if production && enabled(rules.DetectRefactorVisibilityExpand) {
+ if evidence, ok := visibilityExpandedEvidence(pair, baseText, afterText); ok {
+ out = append(out, evidence)
+ }
+ }
+ if enabled(rules.DetectRefactorTestCoverageDrop) && isTestFile(pair.afterPath) {
+ if evidence, ok := testCoverageReducedEvidence(pair, baseText, afterText); ok {
+ out = append(out, evidence)
+ }
+ }
+ if production && enabled(rules.DetectRefactorDependencyWorsened) {
+ if evidence, ok := dependencyDirectionEvidence(pair, baseText, afterText); ok {
+ out = append(out, evidence)
+ }
+ }
+ if production && enabled(rules.DetectRefactorDeadPathLeftBehind) {
+ if evidence, ok := deadPathEvidence(pair); ok {
+ out = append(out, evidence)
+ }
+ }
+ if !production || !refactorLike {
+ return out
+ }
+ if enabled(rules.DetectRefactorBehaviorChange) {
+ if evidence, ok := behaviorChangedEvidence(pair, baseText, afterText); ok {
+ out = append(out, evidence)
+ }
+ }
+ if enabled(rules.DetectRefactorErrorPathChange) {
+ if evidence, ok := errorPathChangedEvidence(pair, baseText, afterText); ok {
+ out = append(out, evidence)
+ }
+ }
+ if enabled(rules.DetectRefactorSideEffectReorder) {
+ if evidence, ok := sideEffectOrderEvidence(pair, baseText, afterText); ok {
+ out = append(out, evidence)
+ }
+ }
+ return out
+}
+
+func fileLooksRefactored(path string, baseText string, afterText string) bool {
+ if strings.Contains(strings.ToLower(path), "refactor") {
+ return true
+ }
+ basePublic := publicSignatures(path, baseText)
+ afterPublic := publicSignatures(path, afterText)
+ if len(basePublic) == 0 || signatureOverlap(basePublic, afterPublic) == 0 {
+ return false
+ }
+ return signatureSetChanged(privateSignatures(path, baseText), privateSignatures(path, afterText))
+}
+
+func publicContractEvidence(pair refactorFilePair, baseText string, afterText string) (refactorFindingEvidence, bool) {
+ before := publicSignatures(pair.beforePath, baseText)
+ after := publicSignatures(pair.afterPath, afterText)
+ if len(before) == 0 && len(after) == 0 {
+ return refactorFindingEvidence{}, false
+ }
+ changed := signatureDiff(before, after)
+ if len(changed) == 0 {
+ return refactorFindingEvidence{}, false
+ }
+ return refactorFindingEvidence{
+ ruleID: "refactor.public-contract-changed",
+ level: "fail",
+ confidence: "high",
+ path: pair.afterPath,
+ line: firstUsefulChangedLine(pair),
+ message: fmt.Sprintf("Public contract changed during refactor-sensitive diff: %s.", strings.Join(limitStrings(changed, 4), ", ")),
+ metadata: refactorMetadata(pair, "public-contract", len(changed)),
+ }, true
+}
+
+func visibilityExpandedEvidence(pair refactorFilePair, baseText string, afterText string) (refactorFindingEvidence, bool) {
+ beforePrivate := privateSignatureKeys(pair.beforePath, baseText)
+ afterPublic := publicSignatures(pair.afterPath, afterText)
+ expanded := make([]string, 0)
+ for name := range afterPublic {
+ if _, ok := beforePrivate[visibilityKey(name)]; ok {
+ expanded = append(expanded, name)
+ }
+ }
+ sort.Strings(expanded)
+ if len(expanded) == 0 {
+ return refactorFindingEvidence{}, false
+ }
+ return refactorFindingEvidence{
+ ruleID: "refactor.visibility-expanded",
+ level: "warn",
+ confidence: "high",
+ path: pair.afterPath,
+ line: firstUsefulChangedLine(pair),
+ message: fmt.Sprintf("Visibility expanded for formerly private symbol(s): %s.", strings.Join(limitStrings(expanded, 4), ", ")),
+ metadata: refactorMetadata(pair, "visibility-expanded", len(expanded)),
+ }, true
+}
+
+func testCoverageReducedEvidence(pair refactorFilePair, baseText string, afterText string) (refactorFindingEvidence, bool) {
+ beforeTests := testEvidenceCount(baseText)
+ afterTests := testEvidenceCount(afterText)
+ if beforeTests == 0 || afterTests >= beforeTests {
+ return refactorFindingEvidence{}, false
+ }
+ return refactorFindingEvidence{
+ ruleID: "refactor.test-coverage-reduced",
+ level: "warn",
+ confidence: "high",
+ path: pair.afterPath,
+ line: firstUsefulChangedLine(pair),
+ message: fmt.Sprintf("Changed test file has fewer test/assertion markers after refactor (%d -> %d).", beforeTests, afterTests),
+ metadata: refactorMetadata(pair, "test-coverage-reduced", beforeTests-afterTests),
+ }, true
+}
+
+func behaviorChangedEvidence(pair refactorFilePair, baseText string, afterText string) (refactorFindingEvidence, bool) {
+ before := behaviorFingerprint(baseText)
+ after := behaviorFingerprint(afterText)
+ if strings.Join(before, "|") == strings.Join(after, "|") {
+ return refactorFindingEvidence{}, false
+ }
+ return refactorFindingEvidence{
+ ruleID: "refactor.behavior-change-detected",
+ level: "fail",
+ confidence: "high",
+ path: pair.afterPath,
+ line: firstUsefulChangedLine(pair),
+ message: "Behavior-preservation evidence changed in a refactor-shaped diff: return paths, branches, calls, or mutations differ.",
+ metadata: refactorMetadata(pair, "behavior-fingerprint-changed", len(before)+len(after)),
+ }, true
+}
+
+func errorPathChangedEvidence(pair refactorFilePair, baseText string, afterText string) (refactorFindingEvidence, bool) {
+ before := errorFingerprint(baseText)
+ after := errorFingerprint(afterText)
+ if (len(before) == 0 && len(after) == 0) || strings.Join(before, "|") == strings.Join(after, "|") {
+ return refactorFindingEvidence{}, false
+ }
+ return refactorFindingEvidence{
+ ruleID: "refactor.error-path-changed",
+ level: "fail",
+ confidence: "high",
+ path: pair.afterPath,
+ line: firstUsefulChangedLine(pair),
+ message: "Error-path evidence changed in a refactor-shaped diff: returned errors, thrown exceptions, wrapping, panic, retry, fallback, or rollback behavior differs.",
+ metadata: refactorMetadata(pair, "error-path-changed", len(before)+len(after)),
+ }, true
+}
+
+func sideEffectOrderEvidence(pair refactorFilePair, baseText string, afterText string) (refactorFindingEvidence, bool) {
+ before := sideEffectSequence(baseText)
+ after := sideEffectSequence(afterText)
+ if len(before) < 2 || len(before) != len(after) || strings.Join(before, "|") == strings.Join(after, "|") || strings.Join(sortedCopy(before), "|") != strings.Join(sortedCopy(after), "|") {
+ return refactorFindingEvidence{}, false
+ }
+ return refactorFindingEvidence{
+ ruleID: "refactor.side-effect-order-changed",
+ level: "fail",
+ confidence: "high",
+ path: pair.afterPath,
+ line: firstUsefulChangedLine(pair),
+ message: fmt.Sprintf("Side-effect order changed in a refactor-shaped diff (%s -> %s).", strings.Join(before, " then "), strings.Join(after, " then ")),
+ metadata: refactorMetadata(pair, "side-effect-order-changed", len(before)),
+ }, true
+}
+
+func dependencyDirectionEvidence(pair refactorFilePair, baseText string, afterText string) (refactorFindingEvidence, bool) {
+ layer := layerCategory(pair.afterPath)
+ baseImports := stringSet(importsFor(baseText))
+ afterImports := importsFor(afterText)
+ worse := make([]string, 0)
+ for _, imp := range afterImports {
+ if _, existed := baseImports[imp]; existed {
+ continue
+ }
+ if dependencyWorsensLayer(layer, imp) {
+ worse = append(worse, imp)
+ }
+ }
+ if len(worse) == 0 {
+ return refactorFindingEvidence{}, false
+ }
+ sort.Strings(worse)
+ return refactorFindingEvidence{
+ ruleID: "refactor.dependency-direction-worsened",
+ level: "warn",
+ confidence: "high",
+ path: pair.afterPath,
+ line: firstUsefulChangedLine(pair),
+ message: fmt.Sprintf("Refactor introduced dependency direction from %s code toward outer infrastructure/framework dependency: %s.", layer, strings.Join(limitStrings(worse, 4), ", ")),
+ metadata: refactorMetadata(pair, "dependency-direction-worsened", len(worse)),
+ }, true
+}
+
+func deadPathEvidence(pair refactorFilePair) (refactorFindingEvidence, bool) {
+ lines := strings.Split(string(pair.after), "\n")
+ for idx, raw := range lines {
+ lineNo := idx + 1
+ if !pair.ranges.AllChanged && len(pair.ranges.Ranges) > 0 && !pair.ranges.Contains(lineNo) {
+ continue
+ }
+ line := strings.ToLower(maskLineComments(strings.TrimSpace(raw)))
+ if line == "" {
+ continue
+ }
+ if strings.Contains(line, "todo") && strings.Contains(line, "remove") ||
+ strings.Contains(line, "deprecated") ||
+ strings.Contains(line, "obsolete") ||
+ strings.Contains(line, "compatibility path") ||
+ strings.Contains(line, "legacy path") ||
+ strings.Contains(line, "remove after") ||
+ strings.Contains(line, "if false") ||
+ strings.Contains(line, "if (false") {
+ return refactorFindingEvidence{
+ ruleID: "refactor.dead-path-left-behind",
+ level: "warn",
+ confidence: "medium",
+ path: pair.afterPath,
+ line: lineNo,
+ message: "Refactor leaves an explicit obsolete, deprecated, disabled, or TODO-remove path behind.",
+ metadata: refactorMetadata(pair, "dead-path-marker", 1),
+ }, true
+ }
+ }
+ return refactorFindingEvidence{}, false
+}
+
+func duplicateImplementationFindings(env support.Context, target core.TargetConfig, _ []refactorFilePair) []core.Finding {
+ if env.ListTargetFiles == nil || env.ReadTargetFile == nil {
+ return nil
+ }
+ changedFiles, err := env.ListChangedFiles(target)
+ if err != nil {
+ return nil
+ }
+ changed := map[string]struct{}{}
+ for _, file := range changedFiles {
+ rel := normalizePath(file.Path)
+ if rel != "" && file.Status != core.ChangedFileDeleted {
+ changed[rel] = struct{}{}
+ }
+ }
+ allFiles, err := env.ListTargetFiles(target)
+ if err != nil {
+ return nil
+ }
+ bodies := map[string][]codeBlock{}
+ for _, rel := range allFiles {
+ rel = normalizePath(rel)
+ if !isProductionFile(rel) || !isSourceFile(rel) {
+ continue
+ }
+ data, err := env.ReadTargetFile(target, rel)
+ if err != nil {
+ continue
+ }
+ for _, block := range extractCodeBlocks(rel, string(data)) {
+ if block.key != "" {
+ bodies[block.key] = append(bodies[block.key], block)
+ }
+ }
+ }
+ out := make([]core.Finding, 0)
+ for _, matches := range bodies {
+ if len(matches) < 2 {
+ continue
+ }
+ hasChanged := false
+ for _, match := range matches {
+ if _, ok := changed[match.path]; ok {
+ hasChanged = true
+ }
+ }
+ if !hasChanged {
+ continue
+ }
+ sort.Slice(matches, func(i, j int) bool {
+ if matches[i].path == matches[j].path {
+ return matches[i].line < matches[j].line
+ }
+ return matches[i].path < matches[j].path
+ })
+ paths := make([]string, 0, len(matches))
+ for _, match := range matches {
+ paths = append(paths, fmt.Sprintf("%s:%d", match.path, match.line))
+ }
+ out = append(out, env.NewFinding(support.FindingInput{
+ RuleID: "refactor.duplicate-implementation-left-behind",
+ Level: "warn",
+ Path: matches[0].path,
+ Line: matches[0].line,
+ Confidence: "high",
+ Message: fmt.Sprintf("Refactor leaves duplicate implementation bodies active at %s.", strings.Join(limitStrings(paths, 4), ", ")),
+ Metadata: map[string]string{
+ "evidence": "duplicate-implementation-body",
+ "duplicate_count": strconv.Itoa(len(matches)),
+ },
+ }))
+ break
+ }
+ return out
+}
+
+type codeBlock struct {
+ path string
+ line int
+ key string
+}
+
+func extractCodeBlocks(path string, text string) []codeBlock {
+ lines := strings.Split(text, "\n")
+ out := make([]codeBlock, 0)
+ for idx, raw := range lines {
+ trimmed := strings.TrimSpace(raw)
+ if !looksLikeFunctionStart(path, trimmed) {
+ continue
+ }
+ body := collectBlock(lines[idx:])
+ key := normalizedImplementation(body)
+ if len(key) >= 45 {
+ out = append(out, codeBlock{path: path, line: idx + 1, key: key})
+ }
+ }
+ return out
+}
+
+func looksLikeFunctionStart(path string, line string) bool {
+ ext := strings.ToLower(filepath.Ext(path))
+ switch ext {
+ case ".go":
+ return strings.HasPrefix(line, "func ") && strings.Contains(line, "{")
+ case ".py":
+ return strings.HasPrefix(line, "def ") || strings.HasPrefix(line, "async def ")
+ default:
+ return strings.Contains(line, "(") && strings.Contains(line, "{") && !strings.HasPrefix(line, "if ") && !strings.HasPrefix(line, "for ") && !strings.HasPrefix(line, "while ") && !strings.HasPrefix(line, "switch ")
+ }
+}
+
+func collectBlock(lines []string) string {
+ if len(lines) == 0 {
+ return ""
+ }
+ var b strings.Builder
+ braceDepth := 0
+ seenBrace := false
+ for idx, line := range lines {
+ b.WriteString(line)
+ b.WriteByte('\n')
+ braceDepth += strings.Count(line, "{")
+ braceDepth -= strings.Count(line, "}")
+ if strings.Contains(line, "{") {
+ seenBrace = true
+ }
+ if seenBrace && idx > 0 && braceDepth <= 0 {
+ break
+ }
+ if !seenBrace && idx > 0 && strings.TrimSpace(line) == "" {
+ break
+ }
+ if idx >= 80 {
+ break
+ }
+ }
+ return b.String()
+}
+
+func publicSignatures(path string, text string) map[string]string {
+ return signatureMap(path, text, true)
+}
+
+func privateSignatures(path string, text string) map[string]string {
+ return signatureMap(path, text, false)
+}
+
+func privateSignatureKeys(path string, text string) map[string]struct{} {
+ out := map[string]struct{}{}
+ for name := range privateSignatures(path, text) {
+ out[visibilityKey(name)] = struct{}{}
+ }
+ return out
+}
+
+func signatureMap(path string, text string, public bool) map[string]string {
+ patterns := signaturePatterns(path, public)
+ out := map[string]string{}
+ for _, pattern := range patterns {
+ for _, match := range pattern.FindAllStringSubmatch(text, -1) {
+ if len(match) < 2 {
+ continue
+ }
+ name := strings.TrimSpace(match[1])
+ if name == "" {
+ continue
+ }
+ if !public && isPublicNameForPath(path, name) {
+ continue
+ }
+ out[name] = normalizeSignature(match[0])
+ }
+ }
+ return out
+}
+
+func signaturePatterns(path string, public bool) []*regexp.Regexp {
+ ext := strings.ToLower(filepath.Ext(path))
+ switch ext {
+ case ".go":
+ if public {
+ return []*regexp.Regexp{goPublicSigPattern}
+ }
+ return []*regexp.Regexp{goPrivateSigPattern}
+ case ".py":
+ if public {
+ return []*regexp.Regexp{pythonPublicSigPattern}
+ }
+ return []*regexp.Regexp{pythonPrivateSigPattern}
+ case ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs":
+ if public {
+ return []*regexp.Regexp{tsPublicSigPattern}
+ }
+ return []*regexp.Regexp{tsPrivateSigPattern}
+ default:
+ return []*regexp.Regexp{cppPublicSigPattern}
+ }
+}
+
+func isPublicNameForPath(path string, name string) bool {
+ ext := strings.ToLower(filepath.Ext(path))
+ if ext == ".go" {
+ r := []rune(name)
+ return len(r) > 0 && r[0] >= 'A' && r[0] <= 'Z'
+ }
+ return !strings.HasPrefix(name, "_")
+}
+
+func normalizeSignature(sig string) string {
+ sig = maskLineComments(sig)
+ sig = strings.TrimSpace(sig)
+ return strings.Join(strings.Fields(sig), " ")
+}
+
+func signatureDiff(before map[string]string, after map[string]string) []string {
+ diff := make([]string, 0)
+ for name, beforeSig := range before {
+ afterSig, ok := after[name]
+ if !ok {
+ diff = append(diff, "removed "+name)
+ continue
+ }
+ if beforeSig != afterSig {
+ diff = append(diff, "changed "+name)
+ }
+ }
+ for name := range after {
+ if _, ok := before[name]; !ok {
+ diff = append(diff, "added "+name)
+ }
+ }
+ sort.Strings(diff)
+ return diff
+}
+
+func signatureOverlap(a map[string]string, b map[string]string) int {
+ count := 0
+ for name := range a {
+ if _, ok := b[name]; ok {
+ count++
+ }
+ }
+ return count
+}
+
+func signatureSetChanged(a map[string]string, b map[string]string) bool {
+ if len(a) != len(b) {
+ return true
+ }
+ for name, sig := range a {
+ if b[name] != sig {
+ return true
+ }
+ }
+ return false
+}
+
+func visibilityKey(name string) string {
+ name = strings.TrimLeft(name, "_")
+ if name == "" {
+ return ""
+ }
+ return strings.ToLower(name[:1]) + name[1:]
+}
+
+func behaviorFingerprint(text string) []string {
+ return fingerprint(text, func(line string) string {
+ switch {
+ case strings.Contains(line, "return"):
+ return "return:" + compactLine(line)
+ case strings.Contains(line, "if ") || strings.Contains(line, "if(") || strings.Contains(line, "switch") || strings.Contains(line, "case ") || strings.Contains(line, "for ") || strings.Contains(line, "while "):
+ return "branch:" + compactLine(line)
+ case strings.Contains(line, "=") || strings.Contains(line, ":=") || strings.Contains(line, "+=") || strings.Contains(line, "-="):
+ return "mutation:" + compactLine(line)
+ default:
+ if effect := sideEffectKind(line); effect != "" {
+ return "effect:" + effect
+ }
+ }
+ return ""
+ })
+}
+
+func errorFingerprint(text string) []string {
+ return fingerprint(text, func(line string) string {
+ switch {
+ case strings.Contains(line, "return") && (strings.Contains(line, "err") || strings.Contains(line, "error")):
+ return "return-error:" + compactLine(line)
+ case strings.Contains(line, "fmt.errorf") || strings.Contains(line, "errors.new") || strings.Contains(line, "new error") || strings.Contains(line, "throw") || strings.Contains(line, "raise"):
+ return "new-error:" + compactLine(line)
+ case strings.Contains(line, "catch") || strings.Contains(line, "except"):
+ return "catch:" + compactLine(line)
+ case strings.Contains(line, "panic") || strings.Contains(line, "retry") || strings.Contains(line, "fallback") || strings.Contains(line, "rollback"):
+ return "failure-action:" + compactLine(line)
+ default:
+ return ""
+ }
+ })
+}
+
+func sideEffectSequence(text string) []string {
+ return fingerprint(text, sideEffectKind)
+}
+
+func fingerprint(text string, classify func(string) string) []string {
+ lines := strings.Split(text, "\n")
+ out := make([]string, 0)
+ for _, raw := range lines {
+ line := strings.ToLower(maskLineComments(strings.TrimSpace(raw)))
+ if line == "" || isCommentOnly(line) {
+ continue
+ }
+ if item := classify(line); item != "" {
+ out = append(out, item)
+ }
+ }
+ return out
+}
+
+func sideEffectKind(line string) string {
+ switch {
+ case strings.Contains(line, "authorize") || strings.Contains(line, "permission") || strings.Contains(line, "auth."):
+ return "auth"
+ case strings.Contains(line, ".save") || strings.Contains(line, ".insert") || strings.Contains(line, ".update") || strings.Contains(line, ".delete") || strings.Contains(line, ".exec") || strings.Contains(line, ".query") || strings.Contains(line, "sql."):
+ return "write"
+ case strings.Contains(line, "publish") || strings.Contains(line, "emit") || strings.Contains(line, ".send") || strings.Contains(line, "enqueue"):
+ return "event"
+ case strings.Contains(line, "http.") || strings.Contains(line, "fetch(") || strings.Contains(line, "requests.") || strings.Contains(line, "axios."):
+ return "network"
+ case strings.Contains(line, "os.") || strings.Contains(line, "fs.") || strings.Contains(line, "std::filesystem") || strings.Contains(line, "open("):
+ return "filesystem"
+ case strings.Contains(line, "defer ") || strings.Contains(line, "finally") || strings.Contains(line, "close()"):
+ return "cleanup"
+ default:
+ return ""
+ }
+}
+
+func importsFor(text string) []string {
+ out := make([]string, 0)
+ for _, match := range importPattern.FindAllStringSubmatch(text, -1) {
+ for _, item := range match[1:] {
+ item = strings.TrimSpace(item)
+ if item != "" {
+ out = append(out, strings.ToLower(item))
+ break
+ }
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+func dependencyWorsensLayer(layer string, imp string) bool {
+ imp = strings.ToLower(imp)
+ if strings.Contains(imp, "test") || strings.HasPrefix(imp, ".") {
+ return false
+ }
+ outer := strings.Contains(imp, "infra") || strings.Contains(imp, "adapter") || strings.Contains(imp, "db") ||
+ strings.Contains(imp, "sql") || strings.Contains(imp, "http") || strings.Contains(imp, "axios") ||
+ strings.Contains(imp, "requests") || strings.Contains(imp, "react") || strings.Contains(imp, "express") ||
+ strings.Contains(imp, "boto") || strings.Contains(imp, "aws") || strings.Contains(imp, "filesystem")
+ switch layer {
+ case "domain", "core":
+ return outer
+ case "application":
+ return strings.Contains(imp, "ui") || strings.Contains(imp, "react") || strings.Contains(imp, "view")
+ default:
+ return false
+ }
+}
+
+func testEvidenceCount(text string) int {
+ count := 0
+ for _, line := range strings.Split(strings.ToLower(text), "\n") {
+ line = strings.TrimSpace(maskLineComments(line))
+ if strings.Contains(line, "func test") || strings.Contains(line, "def test_") ||
+ strings.Contains(line, "test(") || strings.Contains(line, "it(") ||
+ strings.Contains(line, "assert") || strings.Contains(line, "expect(") ||
+ strings.Contains(line, ".fatal") || strings.Contains(line, "require.") {
+ count++
+ }
+ }
+ return count
+}
+
+func normalizedImplementation(text string) string {
+ lines := make([]string, 0)
+ skippedSignature := false
+ for _, raw := range strings.Split(text, "\n") {
+ line := strings.ToLower(maskLineComments(strings.TrimSpace(raw)))
+ if line == "" || isCommentOnly(line) {
+ continue
+ }
+ if !skippedSignature {
+ skippedSignature = true
+ continue
+ }
+ lines = append(lines, compactLine(line))
+ }
+ return strings.Join(lines, ";")
+}
+
+func compactLine(line string) string {
+ replacer := strings.NewReplacer(" ", "", "\t", "", ";", "", "{", "", "}", "")
+ return replacer.Replace(line)
+}
+
+func stringSet(values []string) map[string]struct{} {
+ out := map[string]struct{}{}
+ for _, value := range values {
+ out[value] = struct{}{}
+ }
+ return out
+}
+
+func sortedCopy(values []string) []string {
+ out := append([]string(nil), values...)
+ sort.Strings(out)
+ return out
+}
+
+func firstUsefulChangedLine(pair refactorFilePair) int {
+ lines := strings.Split(string(pair.after), "\n")
+ for idx, raw := range lines {
+ lineNo := idx + 1
+ if !pair.ranges.AllChanged && len(pair.ranges.Ranges) > 0 && !pair.ranges.Contains(lineNo) {
+ continue
+ }
+ if strings.TrimSpace(raw) != "" {
+ return lineNo
+ }
+ }
+ return 1
+}
+
+func refactorMetadata(pair refactorFilePair, evidence string, count int) map[string]string {
+ return map[string]string{
+ "evidence": evidence,
+ "before_path": pair.beforePath,
+ "after_path": pair.afterPath,
+ "count": strconv.Itoa(count),
+ "moved": strconv.FormatBool(pair.moved),
+ }
+}
diff --git a/internal/codeguard/checks/change/testability.go b/internal/codeguard/checks/change/testability.go
new file mode 100644
index 0000000..a19ec4a
--- /dev/null
+++ b/internal/codeguard/checks/change/testability.go
@@ -0,0 +1,306 @@
+// Package change implements diff-aware change-safety and testability checks.
+package change
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+
+ "github.com/devr-tools/codeguard/internal/codeguard/checks/support"
+ "github.com/devr-tools/codeguard/internal/codeguard/core"
+)
+
+var (
+ behaviorEvidencePattern = regexp.MustCompile(`\b(return|if|else|switch|case|throw|raise|panic|except|catch|for|while|await|yield|emit|publish|send|save|insert|update|delete|authorize|permission|status|fallback|retry)\b|=>|:=|\+=|-=|=`)
+ failurePathPattern = regexp.MustCompile(`\b(error|err|fail|failure|exception|except|catch|throw|raise|panic|retry|fallback|timeout|unauthorized|forbidden|denied|invalid|rollback|partial|circuit|backoff)\b`)
+ failureTestPattern = regexp.MustCompile(`\b(error|err|fail|failure|exception|except|throw|raise|reject|timeout|unauthorized|forbidden|denied|fallback|retry|rollback|mock|stub|fake)\b`)
+ hardwiredPattern = regexp.MustCompile(`\b(http\.defaultclient|http\.(get|post|do)|httpx\.client|requests\.(get|post|put|delete)|axios\.|fetch\(|sql\.open|boto3\.client|new\s+[a-z0-9_]*client|new[a-z0-9_]*client\(|os\.(open|create|readfile|writefile)|open\(|fs\.(readfilesync|writefilesync)|std::(ifstream|ofstream|filesystem)|exec\.command|subprocess\.(run|popen)|process\.env|std::getenv)`)
+ nondeterministicPattern = regexp.MustCompile(`\b(time\.now|date\.now|new\s+date\(|math\.random|rand\.|random\.|uuid\.|datetime\.(now|today)|time\.time|os\.getenv|process\.env|std::chrono::system_clock::now|std::random_device|std::getenv|getenv\()`)
+)
+
+type testabilityEvidence struct {
+ path string
+ line int
+ ruleID string
+ level string
+ confidence string
+ evidenceKind string
+ messageDetail string
+}
+
+func testabilityFindings(ctx context.Context, env support.Context) []core.Finding {
+ if env.Mode != core.ScanModeDiff || env.ListChangedFiles == nil {
+ return nil
+ }
+ findings := make([]core.Finding, 0)
+ for _, target := range env.Config.Targets {
+ findings = append(findings, testabilityTargetFindings(ctx, env, target)...)
+ }
+ return findings
+}
+
+func testabilityTargetFindings(ctx context.Context, env support.Context, target core.TargetConfig) []core.Finding {
+ changed, err := env.ListChangedFiles(target)
+ if err != nil || len(changed) == 0 {
+ return nil
+ }
+ sort.Slice(changed, func(i, j int) bool { return changed[i].Path < changed[j].Path })
+
+ diffScope := map[string]core.ChangedLineRanges{}
+ if env.DiffScope != nil {
+ diffScope = env.DiffScope()
+ }
+
+ testFiles, testHasFailureEvidence := changedTestEvidence(ctx, env, target, changed)
+ hasChangedTests := len(testFiles) > 0
+
+ findings := make([]core.Finding, 0)
+ for _, file := range changed {
+ path := filepath.ToSlash(file.Path)
+ if file.Status == core.ChangedFileDeleted || isTestPath(path) || !isSupportedProductionPath(path) {
+ continue
+ }
+ data, err := readTargetFile(env, target, path)
+ if err != nil {
+ continue
+ }
+ evidences := fileTestabilityEvidence(env, path, data, diffScope[path], hasChangedTests, testHasFailureEvidence)
+ for _, evidence := range evidences {
+ findings = append(findings, env.NewFinding(support.FindingInput{
+ RuleID: evidence.ruleID,
+ Level: evidence.level,
+ Path: evidence.path,
+ Line: evidence.line,
+ Message: evidence.messageDetail,
+ Confidence: evidence.confidence,
+ Metadata: map[string]string{
+ "evidence": evidence.evidenceKind,
+ },
+ }))
+ }
+ }
+
+ // TODO(testing.legacy-hotspot-uncovered): emit only after the change section
+ // receives reliable per-file history/churn inputs. The current diff context
+ // can identify touched files, but cannot distinguish genuine legacy hotspots
+ // from ordinary modified code without risking misleading findings.
+
+ return findings
+}
+
+func fileTestabilityEvidence(env support.Context, path string, data []byte, ranges core.ChangedLineRanges, hasChangedTests bool, testHasFailureEvidence bool) []testabilityEvidence {
+ lines := strings.Split(string(data), "\n")
+ out := make([]testabilityEvidence, 0, 4)
+
+ behaviorLine := firstChangedLineMatching(lines, ranges, isBehaviorLine)
+ if enabled(env.Config.Checks.ChangeRules.DetectBehaviorChangeWithoutTest) && behaviorLine > 0 && !hasChangedTests {
+ out = append(out, testabilityEvidence{
+ path: path,
+ line: behaviorLine,
+ ruleID: "testing.behavior-change-without-test",
+ level: "fail",
+ confidence: "high",
+ evidenceKind: "changed-production-behavior-without-test-file",
+ messageDetail: "Changed production behavior without any changed test file in the same diff.",
+ })
+ }
+
+ failureLine := firstChangedLineMatching(lines, ranges, isFailurePathLine)
+ if enabled(env.Config.Checks.ChangeRules.DetectFailurePathMissing) && failureLine > 0 && !testHasFailureEvidence {
+ out = append(out, testabilityEvidence{
+ path: path,
+ line: failureLine,
+ ruleID: "testing.failure-path-missing",
+ level: "warn",
+ confidence: confidenceWithTests(hasChangedTests),
+ evidenceKind: "changed-failure-path-without-failure-test-evidence",
+ messageDetail: "Changed failure-path logic without changed tests that exercise an error, retry, fallback, or denial path.",
+ })
+ }
+
+ hardwiredLine := firstChangedLineMatching(lines, ranges, isHardwiredDependencyLine)
+ if enabled(env.Config.Checks.ChangeRules.DetectHardwiredDependency) && hardwiredLine > 0 {
+ out = append(out, testabilityEvidence{
+ path: path,
+ line: hardwiredLine,
+ ruleID: "testing.hardwired-dependency",
+ level: "warn",
+ confidence: "high",
+ evidenceKind: "changed-direct-dependency-construction",
+ messageDetail: "Changed business logic directly wires an external dependency; inject a fakeable boundary for deterministic tests.",
+ })
+ }
+
+ nondeterministicLine := firstChangedLineMatching(lines, ranges, func(line string) bool {
+ return isDomainPath(path) && isNondeterministicLine(line)
+ })
+ if enabled(env.Config.Checks.ChangeRules.DetectNondeterministicDomain) && nondeterministicLine > 0 {
+ out = append(out, testabilityEvidence{
+ path: path,
+ line: nondeterministicLine,
+ ruleID: "testing.nondeterministic-domain-logic",
+ level: "warn",
+ confidence: "high",
+ evidenceKind: "changed-domain-nondeterminism",
+ messageDetail: "Changed domain logic reads time, randomness, environment, or process state directly, making behavior hard to test deterministically.",
+ })
+ }
+
+ return out
+}
+
+func changedTestEvidence(_ context.Context, env support.Context, target core.TargetConfig, changed []core.ChangedFile) ([]string, bool) {
+ testFiles := make([]string, 0)
+ hasFailureEvidence := false
+ for _, file := range changed {
+ path := filepath.ToSlash(file.Path)
+ if file.Status == core.ChangedFileDeleted || !isTestPath(path) || !isSupportedTestPath(path) {
+ continue
+ }
+ testFiles = append(testFiles, path)
+ data, err := readTargetFile(env, target, path)
+ if err != nil && env.ReadBaseFile != nil {
+ data, err = env.ReadBaseFile(target, path)
+ }
+ if err == nil && failureTestPattern.MatchString(strings.ToLower(maskLineComments(string(data)))) {
+ hasFailureEvidence = true
+ }
+ }
+ return testFiles, hasFailureEvidence
+}
+
+func readTargetFile(env support.Context, target core.TargetConfig, path string) ([]byte, error) {
+ if env.ReadTargetFile != nil {
+ return env.ReadTargetFile(target, path)
+ }
+ return nil, errors.New("read target file callback is not configured")
+}
+
+func firstChangedLineMatching(lines []string, ranges core.ChangedLineRanges, match func(string) bool) int {
+ for idx, raw := range lines {
+ lineNo := idx + 1
+ if !ranges.AllChanged && len(ranges.Ranges) > 0 && !ranges.Contains(lineNo) {
+ continue
+ }
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" || isCommentOnly(trimmed) {
+ continue
+ }
+ if match(strings.ToLower(trimmed)) {
+ return lineNo
+ }
+ }
+ return 0
+}
+
+func isBehaviorLine(line string) bool {
+ line = maskLineComments(line)
+ if strings.Contains(line, "logger.") || strings.Contains(line, "log.") || strings.Contains(line, "fmt.print") {
+ return false
+ }
+ return behaviorEvidencePattern.MatchString(line)
+}
+
+func isFailurePathLine(line string) bool {
+ line = maskLineComments(line)
+ return failurePathPattern.MatchString(line)
+}
+
+func isHardwiredDependencyLine(line string) bool {
+ line = maskLineComments(line)
+ return hardwiredPattern.MatchString(line)
+}
+
+func isNondeterministicLine(line string) bool {
+ line = maskLineComments(line)
+ return nondeterministicPattern.MatchString(line)
+}
+
+func confidenceWithTests(hasChangedTests bool) string {
+ if hasChangedTests {
+ return "medium"
+ }
+ return "high"
+}
+
+func isSupportedProductionPath(path string) bool {
+ return isSupportedSourcePath(path) && !isGeneratedPath(path)
+}
+
+func isSupportedTestPath(path string) bool {
+ return isSupportedSourcePath(path)
+}
+
+func isSupportedSourcePath(path string) bool {
+ ext := strings.ToLower(filepath.Ext(path))
+ switch ext {
+ case ".go", ".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".cc", ".cpp", ".cxx", ".c++", ".hh", ".hpp", ".hxx", ".h++":
+ return true
+ default:
+ if ext == ".h" || ext == ".inc" {
+ return true
+ }
+ return false
+ }
+}
+
+func isTestPath(path string) bool {
+ lower := strings.ToLower(filepath.ToSlash(path))
+ base := filepath.Base(lower)
+ if strings.Contains(lower, "/test/") || strings.Contains(lower, "/tests/") || strings.Contains(lower, "/__tests__/") || strings.Contains(lower, "/testdata/") {
+ return true
+ }
+ return strings.HasSuffix(base, "_test.go") ||
+ strings.HasPrefix(base, "test_") && strings.HasSuffix(base, ".py") ||
+ strings.HasSuffix(base, "_test.py") ||
+ strings.Contains(base, ".test.") ||
+ strings.Contains(base, ".spec.") ||
+ strings.HasSuffix(base, "_test.cpp") ||
+ strings.HasSuffix(base, "_test.cc") ||
+ strings.HasSuffix(base, "_spec.cpp") ||
+ strings.HasSuffix(base, "_spec.cc")
+}
+
+func isDomainPath(path string) bool {
+ lower := strings.ToLower(filepath.ToSlash(path))
+ if strings.Contains(lower, "/test/") || strings.Contains(lower, "/tests/") ||
+ strings.Contains(lower, "/cmd/") || strings.Contains(lower, "/cli/") ||
+ strings.Contains(lower, "/infra/") || strings.Contains(lower, "/infrastructure/") ||
+ strings.Contains(lower, "/adapter/") || strings.Contains(lower, "/adapters/") ||
+ strings.Contains(lower, "/migration") || strings.Contains(lower, "/script") ||
+ strings.HasSuffix(lower, "/main.go") {
+ return false
+ }
+ for _, token := range []string{"/domain/", "/service/", "/services/", "/usecase/", "/usecases/", "/business/", "/model/", "/models/", "/core/", "/internal/", "/pkg/", "/app/", "/src/"} {
+ if strings.Contains(lower, token) {
+ return true
+ }
+ }
+ return !strings.Contains(lower, "/config/")
+}
+
+func isGeneratedPath(path string) bool {
+ lower := strings.ToLower(path)
+ return strings.Contains(lower, "generated") ||
+ strings.HasSuffix(lower, ".pb.go") ||
+ strings.HasSuffix(lower, ".gen.go") ||
+ strings.HasSuffix(lower, ".generated.ts") ||
+ strings.HasSuffix(lower, ".generated.js")
+}
+
+func isCommentOnly(line string) bool {
+ return strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "*") || strings.HasPrefix(line, "/*")
+}
+
+func maskLineComments(line string) string {
+ if idx := strings.Index(line, "//"); idx >= 0 {
+ line = line[:idx]
+ }
+ if idx := strings.Index(line, "#"); idx >= 0 {
+ line = line[:idx]
+ }
+ return strings.TrimSpace(line)
+}
diff --git a/internal/codeguard/checks/quality/quality.go b/internal/codeguard/checks/quality/quality.go
index a14d960..33ddf66 100644
--- a/internal/codeguard/checks/quality/quality.go
+++ b/internal/codeguard/checks/quality/quality.go
@@ -25,6 +25,10 @@ func qualityTargetFindings(ctx context.Context, env support.Context, target core
findings = append(findings, semanticFindings(ctx, env, target)...)
findings = append(findings, commandFindings(ctx, env, target)...)
findings = append(findings, coverageDeltaFindings(ctx, env, target)...)
+ if localPrecisionEnabled(env) {
+ findings = append(findings, maintainabilityDeltaFindings(env, target)...)
+ findings = append(findings, maintainabilityHistoryFindings(ctx, env, target)...)
+ }
maybePutAISlopArtifact(env, target, findings)
findings = append(findings, changeRiskFindings(env, target, findings)...) //nolint:contextcheck // git helpers use a contained timeout; deeper ctx threading is a tracked follow-up
return findings
diff --git a/internal/codeguard/checks/quality/quality_additional_languages.go b/internal/codeguard/checks/quality/quality_additional_languages.go
index 3383391..7815a07 100644
--- a/internal/codeguard/checks/quality/quality_additional_languages.go
+++ b/internal/codeguard/checks/quality/quality_additional_languages.go
@@ -31,9 +31,13 @@ func javaFindingsForFile(env support.Context, file string, data []byte) []core.F
func cppFindingsForFile(env support.Context, file string, data []byte) []core.Finding {
findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each function appends a variable number
- for _, fn := range clikeQualityFunctions(string(data), support.CLikeCPP, braceComplexity) {
+ parsed := support.ParseCLike(string(data), support.CLikeCPP)
+ for _, fn := range parsedFunctionMetrics(parsed, braceComplexity) {
findings = append(findings, maintainabilityFindings(env, file, fn)...)
}
+ if localPrecisionEnabled(env) {
+ findings = append(findings, parsedPrecisionFindings(env, file, parsed)...)
+ }
return append(fileLengthFindingWithSignals(env, file, data, findings), findings...)
}
diff --git a/internal/codeguard/checks/quality/quality_go.go b/internal/codeguard/checks/quality/quality_go.go
index 8b73ab2..ef04644 100644
--- a/internal/codeguard/checks/quality/quality_go.go
+++ b/internal/codeguard/checks/quality/quality_go.go
@@ -52,6 +52,9 @@ func goFindingsForFile(env support.Context, file string, data []byte) []core.Fin
}
findings = append(findings, importFindings(env, file, fset, parsed)...)
findings = append(findings, goFunctionFindings(env, file, fset, parsed)...)
+ if localPrecisionEnabled(env) {
+ findings = append(findings, goPrecisionFindings(env, file, fset, parsed, data)...)
+ }
findings = append(findings, goAIQualityFindings(env, file, fset, parsed, data)...)
return append(fileLengthFindingWithSignals(env, file, data, findings), findings...)
}
diff --git a/internal/codeguard/checks/quality/quality_history.go b/internal/codeguard/checks/quality/quality_history.go
new file mode 100644
index 0000000..f7cf8e1
--- /dev/null
+++ b/internal/codeguard/checks/quality/quality_history.go
@@ -0,0 +1,280 @@
+package quality
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/devr-tools/codeguard/internal/codeguard/checks/support"
+ "github.com/devr-tools/codeguard/internal/codeguard/core"
+ "github.com/devr-tools/codeguard/internal/codeguard/history"
+)
+
+const (
+ maintainabilityHighChurnHotspotID = "maintainability.high-churn-hotspot"
+ maintainabilityRepeatDefectAreaID = "maintainability.repeat-defect-area"
+ maintainabilityUnstableInterfaceID = "maintainability.unstable-interface"
+ smellShotgunSurgeryHistoryID = "smell.shotgun-surgery-history"
+ smellDivergentChangeHistoryID = "smell.divergent-change-history"
+ maintainabilityChangeAmplifyID = "maintainability.change-amplification"
+ maintainabilityHotspotID = "maintainability.hotspot"
+
+ historyMaxCommits = 200
+ historyHotspotMinCommits = 4
+ historyHotspotMinChurn = 30
+ historyHighChurnMinCommits = 5
+ historyHighChurnMinChurn = 50
+ historyHighChurnMinComplexity = 8
+ historyRepeatDefectMinCommits = 2
+ historyCoChangeMinPartners = 3
+ historyCoChangeMinCount = 2
+ historyAmplifierMinPartners = 4
+ historyAmplifierMinEvents = 6
+ historyDivergentMinFamilies = 3
+ historyDivergentMinCommits = 5
+)
+
+var historyDecisionPattern = regexp.MustCompile(`\b(if|else if|for|range|switch|case|catch|except|while|&&|\|\|)\b|\?`)
+
+type fileMaintainabilityHints struct {
+ lines int
+ decisionHits int
+ publicSymbols int
+}
+
+type coChangePartner struct {
+ path string
+ count int
+}
+
+func maintainabilityHistoryFindings(ctx context.Context, env support.Context, target core.TargetConfig) []core.Finding {
+ if env.Mode != core.ScanModeDiff {
+ return nil
+ }
+ changed := changedFilesForTarget(env, target)
+ if len(changed) == 0 {
+ return nil
+ }
+
+ historyCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
+ defer cancel()
+ report, err := history.CollectChangeMetrics(historyCtx, history.ChangeMetricsOptions{
+ RepoPath: target.Path,
+ MaxCommits: historyMaxCommits,
+ })
+ if err != nil || !report.Available || len(report.Files) == 0 {
+ return nil
+ }
+
+ findings := make([]core.Finding, 0)
+ for _, rel := range changed {
+ if isQualityFixturePath(rel) || !qualityPrecisionSupportsFile(target.Language, rel) {
+ continue
+ }
+ metric, ok := report.Files[filepath.ToSlash(rel)]
+ if !ok || metric.Commits == 0 {
+ continue
+ }
+ hints := collectMaintainabilityHints(env, target, rel)
+ line := deltaFindingLine(env, rel)
+ findings = append(findings, historyRuleFindings(env, rel, line, metric, hints)...)
+ }
+ sort.SliceStable(findings, func(i, j int) bool {
+ if findings[i].Path != findings[j].Path {
+ return findings[i].Path < findings[j].Path
+ }
+ if findings[i].RuleID != findings[j].RuleID {
+ return findings[i].RuleID < findings[j].RuleID
+ }
+ return findings[i].Message < findings[j].Message
+ })
+ return findings
+}
+
+func historyRuleFindings(env support.Context, rel string, line int, metric history.FileChangeMetrics, hints fileMaintainabilityHints) []core.Finding {
+ findings := make([]core.Finding, 0, 7)
+ metadata := historyMetadata(metric, hints)
+ topPartners := topCoChangePartners(metric, 1, 5)
+ strongPartners := topCoChangePartners(metric, historyCoChangeMinCount, 5)
+ subjectFamilies := subjectConcernFamilies(metric.Subjects)
+
+ if metric.Commits >= historyHotspotMinCommits && (metric.Churn >= historyHotspotMinChurn || metric.DefectCommits >= historyRepeatDefectMinCommits) {
+ findings = append(findings, historyFinding(env, maintainabilityHotspotID, rel, line,
+ fmt.Sprintf("file is a history hotspot: %d commits, %d churn lines, %d defect-linked commits in recent history", metric.Commits, metric.Churn, metric.DefectCommits),
+ core.ConfidenceMedium, metadata))
+ }
+ if metric.Commits >= historyHighChurnMinCommits && metric.Churn >= historyHighChurnMinChurn && complexityScore(hints) >= historyHighChurnMinComplexity {
+ findings = append(findings, historyFinding(env, maintainabilityHighChurnHotspotID, rel, line,
+ fmt.Sprintf("high-churn hotspot combines %d commits and %d churn lines with complexity hints (%d decision points, %d lines)", metric.Commits, metric.Churn, hints.decisionHits, hints.lines),
+ core.ConfidenceHigh, metadata))
+ }
+ if metric.DefectCommits >= historyRepeatDefectMinCommits {
+ findings = append(findings, historyFinding(env, maintainabilityRepeatDefectAreaID, rel, line,
+ fmt.Sprintf("file has %d defect-linked commits in recent history; add regression coverage around this change", metric.DefectCommits),
+ core.ConfidenceHigh, metadata))
+ }
+ if hints.publicSymbols > 0 && metric.Commits >= historyHotspotMinCommits && (metric.Churn >= historyHotspotMinChurn || metric.DefectCommits > 0) {
+ findings = append(findings, historyFinding(env, maintainabilityUnstableInterfaceID, rel, line,
+ fmt.Sprintf("public interface file changed repeatedly (%d commits, %d churn lines, %d public symbols); keep compatibility and callers explicit", metric.Commits, metric.Churn, hints.publicSymbols),
+ core.ConfidenceMedium, metadata))
+ }
+ if len(strongPartners) >= historyCoChangeMinPartners {
+ findings = append(findings, historyFinding(env, smellShotgunSurgeryHistoryID, rel, line,
+ fmt.Sprintf("file historically changes with %d recurring partners (%s), suggesting shotgun surgery risk", len(strongPartners), formatPartners(strongPartners, 3)),
+ core.ConfidenceMedium, metadata))
+ }
+ if metric.Commits >= historyDivergentMinCommits && len(subjectFamilies) >= historyDivergentMinFamilies {
+ findings = append(findings, historyFinding(env, smellDivergentChangeHistoryID, rel, line,
+ fmt.Sprintf("file changed for %d concern families in recent history (%s), suggesting divergent-change pressure", len(subjectFamilies), strings.Join(subjectFamilies, ", ")),
+ core.ConfidenceMedium, metadata))
+ }
+ if len(topPartners) >= historyAmplifierMinPartners && totalPartnerEvents(topPartners) >= historyAmplifierMinEvents {
+ findings = append(findings, historyFinding(env, maintainabilityChangeAmplifyID, rel, line,
+ fmt.Sprintf("changes to this file historically amplify into %d co-change partner events across %d files (%s)", totalPartnerEvents(topPartners), len(topPartners), formatPartners(topPartners, 4)),
+ core.ConfidenceMedium, metadata))
+ }
+ return findings
+}
+
+func historyFinding(env support.Context, ruleID string, rel string, line int, message string, confidence string, metadata map[string]string) core.Finding {
+ return env.NewFinding(support.FindingInput{
+ RuleID: ruleID,
+ Level: "warn",
+ Path: rel,
+ Line: line,
+ Column: 1,
+ Message: message,
+ Confidence: confidence,
+ Metadata: metadata,
+ })
+}
+
+func collectMaintainabilityHints(env support.Context, target core.TargetConfig, rel string) fileMaintainabilityHints {
+ data, ok := readCurrentTargetFile(env, target, rel)
+ if !ok {
+ return fileMaintainabilityHints{}
+ }
+ source := string(data)
+ return fileMaintainabilityHints{
+ lines: env.CountLines(data),
+ decisionHits: len(historyDecisionPattern.FindAllString(source, -1)),
+ publicSymbols: publicSurfaceCount(target.Language, rel, source),
+ }
+}
+
+func complexityScore(hints fileMaintainabilityHints) int {
+ score := hints.decisionHits
+ switch {
+ case hints.lines >= 120:
+ score += 4
+ case hints.lines >= 80:
+ score += 3
+ case hints.lines >= 50:
+ score += 2
+ }
+ return score
+}
+
+func historyMetadata(metric history.FileChangeMetrics, hints fileMaintainabilityHints) map[string]string {
+ partners := topCoChangePartners(metric, 1, 5)
+ return map[string]string{
+ "commits": strconv.Itoa(metric.Commits),
+ "churn": strconv.Itoa(metric.Churn),
+ "additions": strconv.Itoa(metric.Additions),
+ "deletions": strconv.Itoa(metric.Deletions),
+ "defect_commits": strconv.Itoa(metric.DefectCommits),
+ "co_change_partners": strconv.Itoa(len(metric.CoChangePartners)),
+ "top_partners": formatPartners(partners, 5),
+ "lines": strconv.Itoa(hints.lines),
+ "decision_hints": strconv.Itoa(hints.decisionHits),
+ "public_symbols": strconv.Itoa(hints.publicSymbols),
+ }
+}
+
+func topCoChangePartners(metric history.FileChangeMetrics, minCount int, limit int) []coChangePartner {
+ partners := make([]coChangePartner, 0, len(metric.CoChangePartners))
+ for path, count := range metric.CoChangePartners {
+ if count >= minCount {
+ partners = append(partners, coChangePartner{path: path, count: count})
+ }
+ }
+ sort.Slice(partners, func(i, j int) bool {
+ if partners[i].count != partners[j].count {
+ return partners[i].count > partners[j].count
+ }
+ return partners[i].path < partners[j].path
+ })
+ if limit > 0 && len(partners) > limit {
+ return partners[:limit]
+ }
+ return partners
+}
+
+func formatPartners(partners []coChangePartner, limit int) string {
+ if limit > 0 && len(partners) > limit {
+ partners = partners[:limit]
+ }
+ parts := make([]string, 0, len(partners))
+ for _, partner := range partners {
+ parts = append(parts, fmt.Sprintf("%s:%d", partner.path, partner.count))
+ }
+ return strings.Join(parts, ", ")
+}
+
+func totalPartnerEvents(partners []coChangePartner) int {
+ total := 0
+ for _, partner := range partners {
+ total += partner.count
+ }
+ return total
+}
+
+func subjectConcernFamilies(subjects []string) []string {
+ families := map[string]struct{}{}
+ for _, subject := range subjects {
+ for _, family := range subjectFamilies(subject) {
+ families[family] = struct{}{}
+ }
+ }
+ out := make([]string, 0, len(families))
+ for family := range families {
+ out = append(out, family)
+ }
+ sort.Strings(out)
+ return out
+}
+
+func subjectFamilies(subject string) []string {
+ lower := strings.ToLower(subject)
+ families := make([]string, 0, 2)
+ for _, candidate := range []struct {
+ name string
+ terms []string
+ }{
+ {name: "api", terms: []string{"api", "interface", "contract", "endpoint", "schema"}},
+ {name: "build", terms: []string{"build", "ci", "pipeline", "dependency", "deps"}},
+ {name: "data", terms: []string{"db", "database", "migration", "query", "cache"}},
+ {name: "docs", terms: []string{"doc", "readme", "comment"}},
+ {name: "defect", terms: []string{"fix", "bug", "hotfix", "regression", "incident", "broken"}},
+ {name: "performance", terms: []string{"perf", "latency", "speed", "memory"}},
+ {name: "refactor", terms: []string{"refactor", "cleanup", "rename", "move"}},
+ {name: "test", terms: []string{"test", "spec", "coverage"}},
+ {name: "ui", terms: []string{"ui", "view", "component", "style", "css"}},
+ } {
+ for _, term := range candidate.terms {
+ if strings.Contains(lower, term) {
+ families = append(families, candidate.name)
+ break
+ }
+ }
+ }
+ if len(families) == 0 {
+ families = append(families, "behavior")
+ }
+ return families
+}
diff --git a/internal/codeguard/checks/quality/quality_metrics.go b/internal/codeguard/checks/quality/quality_metrics.go
index 7b342b9..bc739c5 100644
--- a/internal/codeguard/checks/quality/quality_metrics.go
+++ b/internal/codeguard/checks/quality/quality_metrics.go
@@ -72,6 +72,9 @@ func maintainabilityFindings(env support.Context, file string, fn functionMetric
if fn.Params > env.Config.Checks.QualityRules.MaxParameters {
findings = append(findings, warnFinding(env, "quality.max-parameters", file, fn.StartLine, 1,
fmt.Sprintf("function %s has %d parameters; max is %d", fn.Name, fn.Params, env.Config.Checks.QualityRules.MaxParameters)))
+ if localPrecisionEnabled(env) {
+ findings = append(findings, excessiveParameterFinding(env, file, fn)...)
+ }
}
if fn.Complexity > env.Config.Checks.QualityRules.MaxCyclomaticComplexity {
findings = append(findings, warnFinding(env, "quality.cyclomatic-complexity", file, fn.StartLine, 1,
diff --git a/internal/codeguard/checks/quality/quality_precision.go b/internal/codeguard/checks/quality/quality_precision.go
new file mode 100644
index 0000000..89409fe
--- /dev/null
+++ b/internal/codeguard/checks/quality/quality_precision.go
@@ -0,0 +1,653 @@
+package quality
+
+import (
+ "fmt"
+ "go/ast"
+ "go/token"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+
+ "github.com/devr-tools/codeguard/internal/codeguard/checks/support"
+ "github.com/devr-tools/codeguard/internal/codeguard/core"
+)
+
+const (
+ namingGenericIdentifierRuleID = "naming.generic-identifier"
+ functionExcessiveParametersRuleID = "function.excessive-parameters"
+ functionMixedAbstractionLevelRuleID = "function.mixed-abstraction-level"
+ functionCommandQueryMixRuleID = "function.command-query-mix"
+ errorLoggedAndIgnoredRuleID = "error.logged-and-ignored"
+ errorContextLostRuleID = "error.context-lost"
+ defensiveUncheckedTypeAssertionRuleID = "defensive.unchecked-type-assertion"
+ defensiveUnsafeNumericConversionRuleID = "defensive.unsafe-numeric-conversion"
+ maintainabilityPublicSurfaceGrowthID = "maintainability.public-surface-growth"
+ maintainabilityDependencyGrowthID = "maintainability.dependency-growth"
+)
+
+var (
+ genericIdentifierNames = map[string]struct{}{
+ "foo": {}, "bar": {}, "baz": {}, "qux": {},
+ "tmp": {}, "temp": {}, "thing": {}, "stuff": {}, "obj": {}, "misc": {},
+ }
+ queryFunctionPrefixPattern = regexp.MustCompile(`^(get|find|list|load|read|lookup|fetch|is|has|can|should|compute|calculate|build|format|parse)`)
+ mutatingCallPattern = regexp.MustCompile(`(?i)(^|[.>:\-_])(add|append|assign|create|delete|emit|insert|mutate|persist|publish|remove|save|send|set|store|update|upsert|write)([A-Z_:\-.]|$)`)
+ lowLevelOperationPattern = regexp.MustCompile(`(?i)(\bsql\.|\.query\(|\.exec\(|\bhttp\.|\bfetch\(|\baxios\.|\brequests\.|\bjson\.|\bJSON\.|\bos\.Getenv\b|\bprocess\.env\b|\bfs\.|#include\b)`)
+ goPublicDeclPattern = regexp.MustCompile(`(?m)^(?:func|type|var|const)\s+(?:\([^)]*\)\s*)?([A-Z][A-Za-z0-9_]*)\b`)
+ pythonPublicDeclPattern = regexp.MustCompile(`(?m)^class\s+([A-Za-z]\w*)\b|^def\s+([A-Za-z]\w*)\s*\(`)
+ tsPublicDeclPattern = regexp.MustCompile(`(?m)^export\s+(?:declare\s+)?(?:async\s+)?(?:class|interface|type|enum|function|const|let|var)\s+([A-Za-z_$][\w$]*)\b`)
+ cppPublicDeclPattern = regexp.MustCompile(`(?m)^\s*(?:class|struct)\s+([A-Z]\w*)\b|^\s*(?:[A-Za-z_][\w:<>,\s*&~]*\s+)+([A-Z]\w*)\s*\([^;{}]*\)\s*;`)
+ cppIncludePattern = regexp.MustCompile(`(?m)^\s*#include\s+[<"]([^>"]+)[>"]`)
+)
+
+type precisionFunction struct {
+ Name string
+ StartLine int
+ EndLine int
+ Signature string
+ Params []support.ParsedParam
+ Assignments []support.ParsedAssignment
+ Calls []support.ParsedCall
+ Statements []support.ParsedStatement
+ Body string
+ Returns bool
+}
+
+func precisionWarnFinding(env support.Context, ruleID string, file string, line int, message string, confidence string) core.Finding {
+ return env.NewFinding(support.FindingInput{
+ RuleID: ruleID,
+ Level: "warn",
+ Path: file,
+ Line: line,
+ Column: 1,
+ Message: message,
+ Confidence: confidence,
+ })
+}
+
+func localPrecisionEnabled(env support.Context) bool {
+ return env.Config.Checks.QualityRules.LocalPrecision == nil || *env.Config.Checks.QualityRules.LocalPrecision
+}
+
+func excessiveParameterFinding(env support.Context, file string, fn functionMetrics) []core.Finding {
+ if fn.Params <= env.Config.Checks.QualityRules.MaxParameters {
+ return nil
+ }
+ return []core.Finding{precisionWarnFinding(env, functionExcessiveParametersRuleID, file, fn.StartLine,
+ fmt.Sprintf("function %s has %d parameters; prefer grouping related inputs or splitting responsibilities", fn.Name, fn.Params),
+ core.ConfidenceHigh)}
+}
+
+func goPrecisionFindings(env support.Context, file string, fset *token.FileSet, parsed *ast.File, data []byte) []core.Finding {
+ findings := make([]core.Finding, 0)
+ ast.Inspect(parsed, func(n ast.Node) bool {
+ switch node := n.(type) {
+ case *ast.FuncDecl:
+ fn := goPrecisionFunction(fset, node, data)
+ findings = append(findings, precisionFunctionFindings(env, file, fn)...)
+ if node.Body != nil {
+ findings = append(findings, goDefensiveFindings(env, file, fset, node.Body)...)
+ }
+ case *ast.GenDecl:
+ findings = append(findings, goGenericDeclFindings(env, file, fset, node)...)
+ }
+ return true
+ })
+ return findings
+}
+
+func goPrecisionFunction(fset *token.FileSet, fn *ast.FuncDecl, data []byte) precisionFunction {
+ out := precisionFunction{
+ Name: fn.Name.Name,
+ StartLine: fset.Position(fn.Pos()).Line,
+ EndLine: fset.Position(fn.End()).Line,
+ Params: goParsedParams(fn),
+ Returns: goFuncReturnsValue(fn),
+ }
+ if fn.Body == nil {
+ return out
+ }
+ start := fset.Position(fn.Body.Lbrace).Offset
+ end := fset.Position(fn.Body.Rbrace).Offset
+ if start >= 0 && end > start && end <= len(data) {
+ out.Body = string(data[start:end])
+ }
+ ast.Inspect(fn.Body, func(n ast.Node) bool {
+ switch node := n.(type) {
+ case *ast.AssignStmt:
+ out.Assignments = append(out.Assignments, goAssignments(fset, node)...)
+ case *ast.ValueSpec:
+ for _, name := range node.Names {
+ out.Assignments = append(out.Assignments, support.ParsedAssignment{Name: name.Name, Line: fset.Position(name.Pos()).Line})
+ }
+ case *ast.CallExpr:
+ out.Calls = append(out.Calls, support.ParsedCall{Callee: goCallName(node.Fun), Line: fset.Position(node.Pos()).Line})
+ case *ast.ReturnStmt:
+ out.Returns = out.Returns || len(node.Results) > 0
+ }
+ return true
+ })
+ for idx, line := range strings.Split(out.Body, "\n") {
+ if strings.TrimSpace(line) != "" {
+ out.Statements = append(out.Statements, support.ParsedStatement{Line: fset.Position(fn.Body.Lbrace).Line + idx, Text: line, Raw: line})
+ }
+ }
+ return out
+}
+
+func goParsedParams(fn *ast.FuncDecl) []support.ParsedParam {
+ if fn.Type == nil || fn.Type.Params == nil {
+ return nil
+ }
+ params := make([]support.ParsedParam, 0)
+ for _, field := range fn.Type.Params.List {
+ typ := ""
+ if field.Type != nil {
+ typ = fmt.Sprintf("%T", field.Type)
+ }
+ for _, name := range field.Names {
+ params = append(params, support.ParsedParam{Name: name.Name, Type: typ})
+ }
+ if len(field.Names) == 0 {
+ params = append(params, support.ParsedParam{Type: typ})
+ }
+ }
+ return params
+}
+
+func goFuncReturnsValue(fn *ast.FuncDecl) bool {
+ return fn.Type != nil && fn.Type.Results != nil && len(fn.Type.Results.List) > 0
+}
+
+func goAssignments(fset *token.FileSet, stmt *ast.AssignStmt) []support.ParsedAssignment {
+ assignments := make([]support.ParsedAssignment, 0, len(stmt.Lhs))
+ for _, expr := range stmt.Lhs {
+ if ident, ok := expr.(*ast.Ident); ok {
+ assignments = append(assignments, support.ParsedAssignment{Name: ident.Name, Line: fset.Position(ident.Pos()).Line})
+ }
+ }
+ return assignments
+}
+
+func goCallName(expr ast.Expr) string {
+ switch value := expr.(type) {
+ case *ast.Ident:
+ return value.Name
+ case *ast.SelectorExpr:
+ prefix := goCallName(value.X)
+ if prefix == "" {
+ return value.Sel.Name
+ }
+ return prefix + "." + value.Sel.Name
+ default:
+ return ""
+ }
+}
+
+func goGenericDeclFindings(env support.Context, file string, fset *token.FileSet, decl *ast.GenDecl) []core.Finding {
+ findings := make([]core.Finding, 0)
+ for _, spec := range decl.Specs {
+ value, ok := spec.(*ast.ValueSpec)
+ if !ok {
+ continue
+ }
+ for _, name := range value.Names {
+ if isGenericIdentifier(name.Name) {
+ findings = append(findings, precisionWarnFinding(env, namingGenericIdentifierRuleID, file, fset.Position(name.Pos()).Line,
+ fmt.Sprintf("identifier %q is too generic to explain its role", name.Name), core.ConfidenceHigh))
+ }
+ }
+ }
+ return findings
+}
+
+func goDefensiveFindings(env support.Context, file string, fset *token.FileSet, body *ast.BlockStmt) []core.Finding {
+ findings := make([]core.Finding, 0)
+ ast.Inspect(body, func(n ast.Node) bool {
+ switch node := n.(type) {
+ case *ast.TypeAssertExpr:
+ if !goTypeAssertionHasCommaOK(body, node) {
+ pos := fset.Position(node.Pos())
+ findings = append(findings, precisionWarnFinding(env, defensiveUncheckedTypeAssertionRuleID, file, pos.Line,
+ "type assertion is not checked with the comma-ok form", core.ConfidenceHigh))
+ }
+ case *ast.CallExpr:
+ if target := unsafeGoNumericConversionTarget(node); target != "" {
+ pos := fset.Position(node.Pos())
+ findings = append(findings, precisionWarnFinding(env, defensiveUnsafeNumericConversionRuleID, file, pos.Line,
+ fmt.Sprintf("numeric conversion to %s can truncate or wrap; validate bounds before converting", target), core.ConfidenceHigh))
+ }
+ }
+ return true
+ })
+ return findings
+}
+
+func goTypeAssertionHasCommaOK(body *ast.BlockStmt, assertion *ast.TypeAssertExpr) bool {
+ checked := false
+ ast.Inspect(body, func(n ast.Node) bool {
+ if checked {
+ return false
+ }
+ assign, ok := n.(*ast.AssignStmt)
+ if !ok || len(assign.Lhs) != 2 || len(assign.Rhs) != 1 {
+ return true
+ }
+ if assign.Rhs[0] == assertion {
+ checked = true
+ return false
+ }
+ return true
+ })
+ return checked
+}
+
+func unsafeGoNumericConversionTarget(call *ast.CallExpr) string {
+ if len(call.Args) != 1 {
+ return ""
+ }
+ target, ok := call.Fun.(*ast.Ident)
+ if !ok {
+ return ""
+ }
+ switch target.Name {
+ case "int8", "int16", "int32", "uint", "uint8", "uint16", "uint32":
+ default:
+ return ""
+ }
+ switch call.Args[0].(type) {
+ case *ast.BasicLit:
+ return ""
+ default:
+ return target.Name
+ }
+}
+
+func parsedPrecisionFindings(env support.Context, file string, parsed *support.ParsedFile) []core.Finding {
+ functions := parsed.AllFunctions()
+ findings := make([]core.Finding, 0, len(functions))
+ for _, fn := range functions {
+ findings = append(findings, precisionFunctionFindings(env, file, parsedPrecisionFunction(fn))...)
+ }
+ findings = append(findings, parsedDefensiveFindings(env, file, parsed)...)
+ return findings
+}
+
+func parsedPrecisionFunction(fn *support.ParsedFunction) precisionFunction {
+ body := maskedFunctionBody(fn)
+ return precisionFunction{
+ Name: fn.Name,
+ StartLine: fn.StartLine,
+ EndLine: fn.EndLine,
+ Signature: fn.Signature,
+ Params: fn.Params,
+ Assignments: fn.Assignments,
+ Calls: fn.Calls,
+ Statements: fn.Statements,
+ Body: body,
+ Returns: strings.Contains(body, "return "),
+ }
+}
+
+func precisionFunctionFindings(env support.Context, file string, fn precisionFunction) []core.Finding {
+ if isQualityFixturePath(file) {
+ return nil
+ }
+ findings := make([]core.Finding, 0)
+ if isGenericIdentifier(fn.Name) {
+ findings = append(findings, precisionWarnFinding(env, namingGenericIdentifierRuleID, file, fn.StartLine,
+ fmt.Sprintf("function name %q is too generic to communicate intent", fn.Name), core.ConfidenceHigh))
+ }
+ for _, param := range fn.Params {
+ if isGenericIdentifier(param.Name) {
+ findings = append(findings, precisionWarnFinding(env, namingGenericIdentifierRuleID, file, fn.StartLine,
+ fmt.Sprintf("parameter %q is too generic to communicate intent", param.Name), core.ConfidenceHigh))
+ }
+ }
+ for _, assignment := range fn.Assignments {
+ if isGenericIdentifier(assignment.Name) {
+ findings = append(findings, precisionWarnFinding(env, namingGenericIdentifierRuleID, file, assignment.Line,
+ fmt.Sprintf("identifier %q is too generic to explain its role", assignment.Name), core.ConfidenceHigh))
+ }
+ }
+ if mixedAbstractionLevel(fn) {
+ findings = append(findings, precisionWarnFinding(env, functionMixedAbstractionLevelRuleID, file, fn.StartLine,
+ fmt.Sprintf("function %s mixes orchestration calls with low-level infrastructure operations", fn.Name), core.ConfidenceMedium))
+ }
+ if commandQueryMix(fn) {
+ findings = append(findings, precisionWarnFinding(env, functionCommandQueryMixRuleID, file, fn.StartLine,
+ fmt.Sprintf("function %s returns a value while also invoking mutating side-effect operations", fn.Name), core.ConfidenceMedium))
+ }
+ findings = append(findings, errorHandlingFindings(env, file, fn)...)
+ return findings
+}
+
+func isGenericIdentifier(name string) bool {
+ name = strings.Trim(name, "_$")
+ if name == "" {
+ return false
+ }
+ _, ok := genericIdentifierNames[strings.ToLower(name)]
+ return ok
+}
+
+func mixedAbstractionLevel(fn precisionFunction) bool {
+ if fn.EndLine-fn.StartLine < 5 || !lowLevelOperationPattern.MatchString(fn.Body) {
+ return false
+ }
+ for _, call := range fn.Calls {
+ if isDomainLevelCall(call.Callee) {
+ return true
+ }
+ }
+ return false
+}
+
+func isDomainLevelCall(callee string) bool {
+ callee = strings.TrimSpace(callee)
+ if callee == "" {
+ return false
+ }
+ lowered := strings.ToLower(callee)
+ for _, prefix := range []string{"fmt.", "log.", "logger.", "console.", "json.", "json.", "http.", "sql.", "strings.", "strconv.", "errors.", "os.", "fs.", "math.", "time."} {
+ if strings.HasPrefix(lowered, prefix) {
+ return false
+ }
+ }
+ return strings.Contains(callee, ".") || queryFunctionPrefixPattern.MatchString(lowered) || len(callee) > 3
+}
+
+func commandQueryMix(fn precisionFunction) bool {
+ if !fn.Returns {
+ return false
+ }
+ name := strings.ToLower(fn.Name)
+ if !queryFunctionPrefixPattern.MatchString(name) && !strings.Contains(fn.Body, "return ") {
+ return false
+ }
+ for _, call := range fn.Calls {
+ if mutatingCallPattern.MatchString(call.Callee) {
+ return true
+ }
+ }
+ return false
+}
+
+func errorHandlingFindings(env support.Context, file string, fn precisionFunction) []core.Finding {
+ findings := make([]core.Finding, 0)
+ statements := fn.Statements
+ for idx, statement := range statements {
+ line := strings.TrimSpace(statement.Text)
+ lowered := strings.ToLower(line)
+ if strings.Contains(lowered, "err") || strings.Contains(lowered, "except") || strings.Contains(lowered, "catch") {
+ if logsError(line) && nearbyIgnoredError(statements, idx) {
+ findings = append(findings, precisionWarnFinding(env, errorLoggedAndIgnoredRuleID, file, statement.Line,
+ "error is logged and then ignored or converted to success", core.ConfidenceHigh))
+ }
+ if returnsBareError(line) || throwsBareError(line) {
+ findings = append(findings, precisionWarnFinding(env, errorContextLostRuleID, file, statement.Line,
+ "error is returned without contextual wrapping", core.ConfidenceMedium))
+ }
+ }
+ }
+ return findings
+}
+
+func logsError(line string) bool {
+ lowered := strings.ToLower(line)
+ return strings.Contains(lowered, "log.") || strings.Contains(lowered, "logger.") || strings.Contains(lowered, "console.error")
+}
+
+func nearbyIgnoredError(statements []support.ParsedStatement, idx int) bool {
+ for lookahead := idx; lookahead < len(statements) && lookahead <= idx+4; lookahead++ {
+ line := strings.TrimSpace(statements[lookahead].Text)
+ lowered := strings.ToLower(line)
+ if strings.Contains(lowered, "return nil") || strings.Contains(lowered, "return none") ||
+ strings.Contains(lowered, "return undefined") || lowered == "return;" ||
+ lowered == "pass" || strings.Contains(lowered, "// ignore") {
+ return true
+ }
+ if strings.HasPrefix(lowered, "return ") && !strings.Contains(lowered, "err") && !strings.Contains(lowered, "error") {
+ return true
+ }
+ }
+ return false
+}
+
+func returnsBareError(line string) bool {
+ trimmed := strings.TrimSpace(line)
+ return trimmed == "return err" || trimmed == "return err;" || trimmed == "return error" || trimmed == "return error;"
+}
+
+func throwsBareError(line string) bool {
+ trimmed := strings.TrimSpace(line)
+ return trimmed == "throw err;" || trimmed == "throw err" || trimmed == "throw error;" || trimmed == "throw error" ||
+ trimmed == "raise err" || trimmed == "raise error"
+}
+
+func parsedDefensiveFindings(env support.Context, file string, parsed *support.ParsedFile) []core.Finding {
+ findings := make([]core.Finding, 0)
+ for _, statement := range parsed.Module.Statements {
+ findings = append(findings, defensiveStatementFindings(env, file, statement)...)
+ }
+ for _, fn := range parsed.AllFunctions() {
+ for _, statement := range fn.Statements {
+ findings = append(findings, defensiveStatementFindings(env, file, statement)...)
+ }
+ }
+ return findings
+}
+
+func defensiveStatementFindings(env support.Context, file string, statement support.ParsedStatement) []core.Finding {
+ text := statement.Text
+ findings := make([]core.Finding, 0, 2)
+ if strings.Contains(text, " as unknown as ") || strings.Contains(text, " as any as ") || strings.Contains(text, "typing.cast(") {
+ findings = append(findings, precisionWarnFinding(env, defensiveUncheckedTypeAssertionRuleID, file, statement.Line,
+ "type assertion bypasses runtime validation", core.ConfidenceHigh))
+ }
+ if unsafeScriptNumericConversion(text) {
+ findings = append(findings, precisionWarnFinding(env, defensiveUnsafeNumericConversionRuleID, file, statement.Line,
+ "numeric conversion can truncate, wrap, or lose precision; validate bounds before converting", core.ConfidenceMedium))
+ }
+ return findings
+}
+
+func unsafeScriptNumericConversion(text string) bool {
+ lowered := strings.ToLower(text)
+ return strings.Contains(lowered, "static_cast") ||
+ strings.Contains(lowered, "static_cast") ||
+ strings.Contains(lowered, "static_cast") ||
+ strings.Contains(lowered, "static_cast") ||
+ strings.Contains(lowered, "static_cast") ||
+ strings.Contains(lowered, "static_cast") ||
+ strings.Contains(lowered, "number(") && strings.Contains(lowered, "bigint")
+}
+
+func maintainabilityDeltaFindings(env support.Context, target core.TargetConfig) []core.Finding {
+ if env.Mode != core.ScanModeDiff || env.ReadBaseFile == nil {
+ return nil
+ }
+ changed := changedFilesForTarget(env, target)
+ if len(changed) == 0 {
+ return nil
+ }
+ findings := make([]core.Finding, 0)
+ for _, rel := range changed {
+ if !qualityPrecisionSupportsFile(target.Language, rel) {
+ continue
+ }
+ current, ok := readCurrentTargetFile(env, target, rel)
+ if !ok {
+ continue
+ }
+ base, err := env.ReadBaseFile(target, rel)
+ if err != nil {
+ continue
+ }
+ line := deltaFindingLine(env, rel)
+ findings = append(findings, publicSurfaceGrowthFinding(env, target, rel, base, current, line)...)
+ findings = append(findings, dependencyGrowthFinding(env, target, rel, base, current, line)...)
+ }
+ return findings
+}
+
+func changedFilesForTarget(env support.Context, target core.TargetConfig) []string {
+ seen := map[string]struct{}{}
+ if env.ListChangedFiles != nil {
+ if changed, err := env.ListChangedFiles(target); err == nil {
+ for _, file := range changed {
+ if file.Status == core.ChangedFileDeleted {
+ continue
+ }
+ seen[filepath.ToSlash(file.Path)] = struct{}{}
+ }
+ }
+ }
+ if len(seen) == 0 && env.DiffScope != nil {
+ for rel := range env.DiffScope() {
+ seen[filepath.ToSlash(rel)] = struct{}{}
+ }
+ }
+ paths := make([]string, 0, len(seen))
+ for rel := range seen {
+ paths = append(paths, rel)
+ }
+ sort.Strings(paths)
+ return paths
+}
+
+func readCurrentTargetFile(env support.Context, target core.TargetConfig, rel string) ([]byte, bool) {
+ if env.ReadTargetFile != nil {
+ if data, err := env.ReadTargetFile(target, rel); err == nil {
+ return data, true
+ }
+ }
+ data, err := os.ReadFile(filepath.Join(target.Path, filepath.FromSlash(rel))) //nolint:gosec // rel comes from the scan's own changed-file list
+ return data, err == nil
+}
+
+func deltaFindingLine(env support.Context, rel string) int {
+ if env.DiffScope == nil {
+ return 1
+ }
+ scope := env.DiffScope()[filepath.ToSlash(rel)]
+ if scope.AllChanged {
+ return 1
+ }
+ for _, r := range scope.Ranges {
+ if r[0] > 0 {
+ return r[0]
+ }
+ }
+ return 1
+}
+
+func publicSurfaceGrowthFinding(env support.Context, target core.TargetConfig, rel string, base []byte, current []byte, line int) []core.Finding {
+ baseCount := publicSurfaceCount(target.Language, rel, string(base))
+ currentCount := publicSurfaceCount(target.Language, rel, string(current))
+ if currentCount <= baseCount {
+ return nil
+ }
+ return []core.Finding{precisionWarnFinding(env, maintainabilityPublicSurfaceGrowthID, rel, line,
+ fmt.Sprintf("public surface grew from %d to %d symbols in this file; keep new exported API intentional", baseCount, currentCount),
+ core.ConfidenceHigh)}
+}
+
+func dependencyGrowthFinding(env support.Context, target core.TargetConfig, rel string, base []byte, current []byte, line int) []core.Finding {
+ baseCount := len(dependencySet(target.Language, rel, string(base)))
+ currentCount := len(dependencySet(target.Language, rel, string(current)))
+ if currentCount <= baseCount {
+ return nil
+ }
+ return []core.Finding{precisionWarnFinding(env, maintainabilityDependencyGrowthID, rel, line,
+ fmt.Sprintf("direct dependencies grew from %d to %d in this file; verify the added dependency surface is necessary", baseCount, currentCount),
+ core.ConfidenceHigh)}
+}
+
+func qualityPrecisionSupportsFile(language string, rel string) bool {
+ switch support.NormalizedLanguage(language) {
+ case "", "go":
+ return strings.HasSuffix(rel, ".go")
+ case "python":
+ return strings.HasSuffix(rel, ".py")
+ case "typescript", "javascript":
+ return isTypeScriptLikeFile(rel)
+ case "c++", "cpp":
+ return strings.HasSuffix(rel, ".cpp") || strings.HasSuffix(rel, ".cc") || strings.HasSuffix(rel, ".cxx") ||
+ strings.HasSuffix(rel, ".hpp") || strings.HasSuffix(rel, ".hh") || strings.HasSuffix(rel, ".h")
+ default:
+ return false
+ }
+}
+
+func publicSurfaceCount(language string, rel string, source string) int {
+ switch support.NormalizedLanguage(language) {
+ case "", "go":
+ return len(goPublicDeclPattern.FindAllStringSubmatch(source, -1))
+ case "python":
+ count := 0
+ for _, match := range pythonPublicDeclPattern.FindAllStringSubmatch(source, -1) {
+ name := firstNonEmptyString(match[1], match[2])
+ if name != "" && !strings.HasPrefix(name, "_") {
+ count++
+ }
+ }
+ return count
+ case "typescript", "javascript":
+ return len(tsPublicDeclPattern.FindAllStringSubmatch(source, -1))
+ case "c++", "cpp":
+ if !strings.HasSuffix(rel, ".h") && !strings.HasSuffix(rel, ".hh") && !strings.HasSuffix(rel, ".hpp") {
+ return 0
+ }
+ return len(cppPublicDeclPattern.FindAllStringSubmatch(source, -1))
+ default:
+ return 0
+ }
+}
+
+func dependencySet(language string, rel string, source string) map[string]struct{} {
+ deps := map[string]struct{}{}
+ switch support.NormalizedLanguage(language) {
+ case "", "go":
+ if fset, parsed, err := support.ParseGoSource(support.Context{}, rel, []byte(source)); err == nil {
+ _ = fset
+ for _, imp := range parsed.Imports {
+ deps[strings.Trim(imp.Path.Value, `"`)] = struct{}{}
+ }
+ }
+ case "python":
+ for _, imp := range support.ParsePython(source).Imports {
+ deps[firstNonEmptyString(imp.Module, imp.Name, imp.Alias)] = struct{}{}
+ }
+ case "typescript", "javascript":
+ for _, imp := range support.ParseCLike(source, support.CLikeTypeScript).Imports {
+ deps[imp.Module] = struct{}{}
+ }
+ case "c++", "cpp":
+ for _, match := range cppIncludePattern.FindAllStringSubmatch(source, -1) {
+ deps[match[1]] = struct{}{}
+ }
+ }
+ return deps
+}
+
+func isQualityFixturePath(path string) bool {
+ normalized := strings.ToLower(filepath.ToSlash(path))
+ if strings.Contains(normalized, "/testdata/") || strings.Contains(normalized, "/fixtures/") || strings.Contains(normalized, "/__fixtures__/") {
+ return true
+ }
+ return strings.HasSuffix(normalized, "_test.go") || strings.HasSuffix(normalized, "_test.py") ||
+ strings.HasSuffix(normalized, ".test.ts") || strings.HasSuffix(normalized, ".spec.ts") ||
+ strings.HasSuffix(normalized, ".test.js") || strings.HasSuffix(normalized, ".spec.js")
+}
+
+func firstNonEmptyString(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
diff --git a/internal/codeguard/checks/quality/quality_python.go b/internal/codeguard/checks/quality/quality_python.go
index 0a4d9d5..9151059 100644
--- a/internal/codeguard/checks/quality/quality_python.go
+++ b/internal/codeguard/checks/quality/quality_python.go
@@ -9,20 +9,17 @@ import (
func pythonFindingsForFile(env support.Context, file string, data []byte) []core.Finding {
findings := make([]core.Finding, 0) //nolint:prealloc // count not known up front; each function appends a variable number
- for _, fn := range pythonFunctions(string(data)) {
+ parsed := support.ParsePython(string(data))
+ for _, fn := range parsedFunctionMetrics(parsed, pythonComplexity) {
findings = append(findings, maintainabilityFindings(env, file, fn)...)
}
+ if localPrecisionEnabled(env) {
+ findings = append(findings, parsedPrecisionFindings(env, file, parsed)...)
+ }
findings = append(findings, pythonAIQualityFindings(env, file, data)...)
return append(fileLengthFindingWithSignals(env, file, data, findings), findings...)
}
-// pythonFunctions extracts function metrics from the structured Python
-// parser, so strings or comments that merely look like code are ignored and
-// multiline signatures are handled.
-func pythonFunctions(source string) []functionMetrics {
- return parsedFunctionMetrics(support.ParsePython(source), pythonComplexity)
-}
-
// maskedFunctionBody joins the masked statements of a function and its
// nested functions, mirroring the full lexical body.
func maskedFunctionBody(fn *support.ParsedFunction) string {
diff --git a/internal/codeguard/checks/quality/quality_typescript.go b/internal/codeguard/checks/quality/quality_typescript.go
index 6e1a529..2b027b1 100644
--- a/internal/codeguard/checks/quality/quality_typescript.go
+++ b/internal/codeguard/checks/quality/quality_typescript.go
@@ -27,9 +27,13 @@ func typeScriptFindingsForFile(env support.Context, file string, data []byte) []
findings = append(findings, appendTypeScriptDirectiveFindings(ctx)...)
findings = append(findings, typeScriptPatternFindings(ctx)...)
findings = append(findings, typeScriptAIQualityFindings(ctx)...)
- for _, fn := range typeScriptFunctions(source) {
+ parsed := support.ParseCLike(source, support.CLikeTypeScript)
+ for _, fn := range parsedFunctionMetrics(parsed, typeScriptComplexity) {
findings = append(findings, maintainabilityFindings(env, file, fn)...)
}
+ if localPrecisionEnabled(env) {
+ findings = append(findings, parsedPrecisionFindings(env, file, parsed)...)
+ }
return append(fileLengthFindingWithSignals(env, file, data, findings), findings...)
}
diff --git a/internal/codeguard/checks/quality/quality_typescript_metrics.go b/internal/codeguard/checks/quality/quality_typescript_metrics.go
index afa590e..5adf869 100644
--- a/internal/codeguard/checks/quality/quality_typescript_metrics.go
+++ b/internal/codeguard/checks/quality/quality_typescript_metrics.go
@@ -1,17 +1,6 @@
package quality
-import (
- "strings"
-
- "github.com/devr-tools/codeguard/internal/codeguard/checks/support"
-)
-
-// typeScriptFunctions extracts function metrics from the structured C-like
-// parser, so functions inside comments or template literals are ignored and
-// braces within string literals cannot corrupt body extents.
-func typeScriptFunctions(source string) []functionMetrics {
- return parsedFunctionMetrics(support.ParseCLike(source, support.CLikeTypeScript), typeScriptComplexity)
-}
+import "strings"
func typeScriptComplexity(body string) int {
complexity := 1
diff --git a/internal/codeguard/checks/supplychain/advisories.go b/internal/codeguard/checks/supplychain/advisories.go
index 101c2a4..31fc706 100644
--- a/internal/codeguard/checks/supplychain/advisories.go
+++ b/internal/codeguard/checks/supplychain/advisories.go
@@ -66,13 +66,22 @@ func vulnerableDependencyFindings(env support.Context, target core.TargetConfig,
if advisory.FixedVersion != "" {
metadata["fixed_version"] = advisory.FixedVersion
}
- message := "dependency " + dep.Name + "@" + version + " is affected by advisory " + advisory.ID + " in the local advisory cache"
+ var message strings.Builder
+ message.WriteString("dependency ")
+ message.WriteString(dep.Name)
+ message.WriteString("@")
+ message.WriteString(version)
+ message.WriteString(" is affected by advisory ")
+ message.WriteString(advisory.ID)
+ message.WriteString(" in the local advisory cache")
if advisory.FixedVersion != "" {
- message += "; upgrade to " + advisory.FixedVersion + " or later"
+ message.WriteString("; upgrade to ")
+ message.WriteString(advisory.FixedVersion)
+ message.WriteString(" or later")
}
findings = append(findings, env.NewFinding(support.FindingInput{
RuleID: "supply_chain.vulnerable-dependency", Level: "fail", Path: manifest.Path,
- Line: dep.Line, Column: 1, Message: message, Confidence: "high", Metadata: metadata,
+ Line: dep.Line, Column: 1, Message: message.String(), Confidence: "high", Metadata: metadata,
}))
}
}
diff --git a/internal/codeguard/checks/support/artifacts.go b/internal/codeguard/checks/support/artifacts.go
index 221f731..0d30dd0 100644
--- a/internal/codeguard/checks/support/artifacts.go
+++ b/internal/codeguard/checks/support/artifacts.go
@@ -117,13 +117,21 @@ func NewPRSummaryArtifact(summary core.PRSummaryArtifact) core.Artifact {
func clonePRSummary(summary core.PRSummaryArtifact) *core.PRSummaryArtifact {
out := core.PRSummaryArtifact{}
- if summary.ProductionRisk != nil {
- components := append([]core.PRSummaryComponent(nil), summary.ProductionRisk.Components...)
- out.ProductionRisk = &core.PRSummaryMetric{
- Score: summary.ProductionRisk.Score,
- Level: summary.ProductionRisk.Level,
- Components: components,
- }
- }
+ out.ProductionRisk = clonePRSummaryMetric(summary.ProductionRisk)
+ out.ChangeSafety = clonePRSummaryMetric(summary.ChangeSafety)
+ out.MaintainabilityDelta = clonePRSummaryMetric(summary.MaintainabilityDelta)
+ out.RefactorConfidence = clonePRSummaryMetric(summary.RefactorConfidence)
return &out
}
+
+func clonePRSummaryMetric(metric *core.PRSummaryMetric) *core.PRSummaryMetric {
+ if metric == nil {
+ return nil
+ }
+ components := append([]core.PRSummaryComponent(nil), metric.Components...)
+ return &core.PRSummaryMetric{
+ Score: metric.Score,
+ Level: metric.Level,
+ Components: components,
+ }
+}
diff --git a/internal/codeguard/config/defaults.go b/internal/codeguard/config/defaults.go
index 9e59565..0bd9e6f 100644
--- a/internal/codeguard/config/defaults.go
+++ b/internal/codeguard/config/defaults.go
@@ -67,6 +67,9 @@ func applyCheckDefaults(cfg *core.Config, def core.Config) {
if cfg.Checks.Data == nil {
cfg.Checks.Data = def.Checks.Data
}
+ if cfg.Checks.Change == nil {
+ cfg.Checks.Change = def.Checks.Change
+ }
applyQualityDefaults(&cfg.Checks.QualityRules, def.Checks.QualityRules)
applyPerformanceDefaults(&cfg.Checks.PerformanceRules)
applyDesignDefaults(&cfg.Checks.DesignRules, def.Checks.DesignRules)
@@ -76,6 +79,7 @@ func applyCheckDefaults(cfg *core.Config, def core.Config) {
applySupplyChainDefaults(&cfg.Checks.SupplyChainRules, def.Checks.SupplyChainRules)
applyReliabilityDefaults(&cfg.Checks.ReliabilityRules, def.Checks.ReliabilityRules)
applyDataDefaults(&cfg.Checks.DataRules, def.Checks.DataRules)
+ applyChangeDefaults(&cfg.Checks.ChangeRules, def.Checks.ChangeRules)
applyContextDefaults(&cfg.Checks.ContextRules, def.Checks.ContextRules)
applyContractDefaults(&cfg.Checks.ContractRules, def.Checks.ContractRules)
applyProductionRiskDefaults(&cfg.Checks.ProductionRisk, def.Checks.ProductionRisk)
diff --git a/internal/codeguard/config/defaults_activation.go b/internal/codeguard/config/defaults_activation.go
index 7666eb5..4c4c959 100644
--- a/internal/codeguard/config/defaults_activation.go
+++ b/internal/codeguard/config/defaults_activation.go
@@ -31,6 +31,7 @@ var checkDisablers = map[string]func(*core.CheckConfig){
"supply_chain": func(checks *core.CheckConfig) { checks.SupplyChain = false },
"reliability": func(checks *core.CheckConfig) { checks.Reliability = boolPtr(false) },
"data": func(checks *core.CheckConfig) { checks.Data = boolPtr(false) },
+ "change": func(checks *core.CheckConfig) { checks.Change = boolPtr(false) },
"context": func(checks *core.CheckConfig) { checks.Context = boolPtr(false) },
"contracts": func(checks *core.CheckConfig) { checks.Contracts = boolPtr(false) },
}
diff --git a/internal/codeguard/config/defaults_rules.go b/internal/codeguard/config/defaults_rules.go
index 38844e5..4cca8e8 100644
--- a/internal/codeguard/config/defaults_rules.go
+++ b/internal/codeguard/config/defaults_rules.go
@@ -17,6 +17,7 @@ func applyQualityDefaults(dst *core.QualityRulesConfig, def core.QualityRulesCon
applyRiskScoringDefaults(&dst.RiskScoring)
applyCoverageDeltaDefaults(&dst.CoverageDelta)
applyCPPToolingDefaults(&dst.CPPTooling)
+ defaultBoolPtr(&dst.LocalPrecision, boolValueOrTrue(def.LocalPrecision))
}
func applyRiskScoringDefaults(dst *core.RiskScoringConfig) {
@@ -236,6 +237,41 @@ func applyDataDefaults(dst *core.DataRulesConfig, def core.DataRulesConfig) {
defaultInt(&dst.MaxWritesWithoutTransaction, def.MaxWritesWithoutTransaction)
}
+func applyChangeDefaults(dst *core.ChangeRulesConfig, def core.ChangeRulesConfig) {
+ applyDefaultBoolPtrs(
+ &dst.DetectBehaviorChangeWithoutTest,
+ &dst.DetectFailurePathMissing,
+ &dst.DetectHardwiredDependency,
+ &dst.DetectNondeterministicDomain,
+ &dst.DetectLegacyHotspotUncovered,
+ &dst.DetectMixedConcerns,
+ &dst.DetectOversizedDiff,
+ &dst.DetectMixedRefactorAndBehavior,
+ &dst.DetectTooManyConcerns,
+ &dst.DetectUnnecessarySurfaceArea,
+ &dst.DetectOneUseAbstraction,
+ &dst.DetectDuplicateHelper,
+ &dst.DetectCleanupRegression,
+ &dst.DetectComplexityIncreased,
+ &dst.DetectMoveWithoutVerification,
+ &dst.DetectRefactorBehaviorChange,
+ &dst.DetectRefactorPublicContract,
+ &dst.DetectRefactorTestCoverageDrop,
+ &dst.DetectRefactorErrorPathChange,
+ &dst.DetectRefactorSideEffectReorder,
+ &dst.DetectRefactorVisibilityExpand,
+ &dst.DetectRefactorDependencyWorsened,
+ &dst.DetectRefactorDuplicateLeftBehind,
+ &dst.DetectRefactorDeadPathLeftBehind,
+ )
+ defaultInt(&dst.MaxChangedFiles, def.MaxChangedFiles)
+ defaultInt(&dst.MaxChangedDirectories, def.MaxChangedDirectories)
+ defaultInt(&dst.MaxChangedLines, def.MaxChangedLines)
+ defaultInt(&dst.MaxPublicInterfacesChanged, def.MaxPublicInterfacesChanged)
+ defaultInt(&dst.MaxConcernFamilies, def.MaxConcernFamilies)
+ defaultInt(&dst.MinTestToProductionRatioPercent, def.MinTestToProductionRatioPercent)
+}
+
func applyProductionRiskDefaults(dst *core.ProductionRiskConfig, def core.ProductionRiskConfig) {
defaultBoolPtr(&dst.Enabled, boolValueOrTrue(def.Enabled))
defaultInt(&dst.WarnThreshold, def.WarnThreshold)
diff --git a/internal/codeguard/config/example.go b/internal/codeguard/config/example.go
index d23d0d8..9afbc6d 100644
--- a/internal/codeguard/config/example.go
+++ b/internal/codeguard/config/example.go
@@ -38,6 +38,7 @@ func exampleChecks() core.CheckConfig {
SupplyChain: false,
Reliability: boolPtr(false),
Data: boolPtr(false),
+ Change: boolPtr(false),
QualityRules: exampleQualityRules(),
PerformanceRules: examplePerformanceRules(),
DesignRules: exampleDesignRules(),
@@ -47,6 +48,7 @@ func exampleChecks() core.CheckConfig {
SupplyChainRules: exampleSupplyChainRules(),
ReliabilityRules: exampleReliabilityRules(),
DataRules: exampleDataRules(),
+ ChangeRules: exampleChangeRules(),
ContractRules: exampleContractRules(),
ContextRules: exampleContextRules(),
ProductionRisk: exampleProductionRisk(),
@@ -115,6 +117,41 @@ func exampleDataRules() core.DataRulesConfig {
}
}
+func exampleChangeRules() core.ChangeRulesConfig {
+ return core.ChangeRulesConfig{
+ DetectBehaviorChangeWithoutTest: boolPtr(true),
+ DetectFailurePathMissing: boolPtr(true),
+ DetectHardwiredDependency: boolPtr(true),
+ DetectNondeterministicDomain: boolPtr(true),
+ DetectLegacyHotspotUncovered: boolPtr(true),
+ DetectMixedConcerns: boolPtr(true),
+ DetectOversizedDiff: boolPtr(true),
+ DetectMixedRefactorAndBehavior: boolPtr(true),
+ DetectTooManyConcerns: boolPtr(true),
+ DetectUnnecessarySurfaceArea: boolPtr(true),
+ DetectOneUseAbstraction: boolPtr(true),
+ DetectDuplicateHelper: boolPtr(true),
+ DetectCleanupRegression: boolPtr(true),
+ DetectComplexityIncreased: boolPtr(true),
+ DetectMoveWithoutVerification: boolPtr(true),
+ DetectRefactorBehaviorChange: boolPtr(true),
+ DetectRefactorPublicContract: boolPtr(true),
+ DetectRefactorTestCoverageDrop: boolPtr(true),
+ DetectRefactorErrorPathChange: boolPtr(true),
+ DetectRefactorSideEffectReorder: boolPtr(true),
+ DetectRefactorVisibilityExpand: boolPtr(true),
+ DetectRefactorDependencyWorsened: boolPtr(true),
+ DetectRefactorDuplicateLeftBehind: boolPtr(true),
+ DetectRefactorDeadPathLeftBehind: boolPtr(true),
+ MaxChangedFiles: 25,
+ MaxChangedDirectories: 8,
+ MaxChangedLines: 800,
+ MaxPublicInterfacesChanged: 3,
+ MaxConcernFamilies: 3,
+ MinTestToProductionRatioPercent: 20,
+ }
+}
+
func exampleProductionRisk() core.ProductionRiskConfig {
return core.ProductionRiskConfig{
Enabled: boolPtr(true),
diff --git a/internal/codeguard/config/profile.go b/internal/codeguard/config/profile.go
index 79d22cb..d9d24c6 100644
--- a/internal/codeguard/config/profile.go
+++ b/internal/codeguard/config/profile.go
@@ -41,6 +41,7 @@ var profileCatalog = map[string]profileSpec{
cfg.Checks.CIRules.RequiredReleaseFiles = []string{".goreleaser.yaml"}
cfg.Checks.CIRules.RequiredAutomationPaths = []string{"Makefile", ".github/workflows/ci.yml"}
cfg.Checks.Data = boolPtr(true)
+ cfg.Checks.Change = boolPtr(true)
},
},
"ai-safe": {
@@ -59,6 +60,11 @@ var profileCatalog = map[string]profileSpec{
cfg.Checks.QualityRules.AIProvenance.SlopScoreFailThreshold = 25
cfg.Checks.Reliability = boolPtr(true)
cfg.Checks.Data = boolPtr(true)
+ cfg.Checks.Change = boolPtr(true)
+ cfg.Checks.ChangeRules.MaxChangedFiles = 20
+ cfg.Checks.ChangeRules.MaxChangedDirectories = 6
+ cfg.Checks.ChangeRules.MaxChangedLines = 600
+ cfg.Checks.ChangeRules.MinTestToProductionRatioPercent = 30
},
},
}
@@ -75,6 +81,7 @@ func applyStrictProfile(cfg *core.Config) {
cfg.Checks.SecurityRules.GovulncheckMode = "required"
cfg.Checks.Contracts = boolPtr(true)
cfg.Checks.Reliability = boolPtr(true)
+ cfg.Checks.Change = boolPtr(true)
}
func ExampleConfig() core.Config {
@@ -187,6 +194,36 @@ func RenderPolicyProfileComparison() string {
}
return strconv.FormatBool(*cfg.Checks.Contracts)
})
+ writeProfileComparisonRow(&b, "`reliability`", configs, func(cfg core.Config) string {
+ if cfg.Checks.Reliability == nil {
+ return "scan-mode"
+ }
+ return strconv.FormatBool(*cfg.Checks.Reliability)
+ })
+ writeProfileComparisonRow(&b, "`data`", configs, func(cfg core.Config) string {
+ if cfg.Checks.Data == nil {
+ return "scan-mode"
+ }
+ return strconv.FormatBool(*cfg.Checks.Data)
+ })
+ writeProfileComparisonRow(&b, "`change`", configs, func(cfg core.Config) string {
+ if cfg.Checks.Change == nil {
+ return "scan-mode"
+ }
+ return strconv.FormatBool(*cfg.Checks.Change)
+ })
+ writeProfileComparisonRow(&b, "`change_rules.max_changed_files`", configs, func(cfg core.Config) string {
+ return strconv.Itoa(cfg.Checks.ChangeRules.MaxChangedFiles)
+ })
+ writeProfileComparisonRow(&b, "`change_rules.max_changed_directories`", configs, func(cfg core.Config) string {
+ return strconv.Itoa(cfg.Checks.ChangeRules.MaxChangedDirectories)
+ })
+ writeProfileComparisonRow(&b, "`change_rules.max_changed_lines`", configs, func(cfg core.Config) string {
+ return strconv.Itoa(cfg.Checks.ChangeRules.MaxChangedLines)
+ })
+ writeProfileComparisonRow(&b, "`change_rules.min_test_to_production_ratio_percent`", configs, func(cfg core.Config) string {
+ return strconv.Itoa(cfg.Checks.ChangeRules.MinTestToProductionRatioPercent)
+ })
b.WriteString("\n")
return b.String()
}
diff --git a/internal/codeguard/config/profile_test.go b/internal/codeguard/config/profile_test.go
index 33fa291..42e20df 100644
--- a/internal/codeguard/config/profile_test.go
+++ b/internal/codeguard/config/profile_test.go
@@ -23,6 +23,13 @@ type profileThresholds struct {
requiredReleaseFiles []string
requiredAutomationPaths []string
contracts *bool
+ reliability *bool
+ data *bool
+ change *bool
+ maxChangedFiles int
+ maxChangedDirectories int
+ maxChangedLines int
+ minTestProdRatioPercent int
}
func TestProfilesPreserveExpectedPolicyValues(t *testing.T) {
@@ -61,6 +68,13 @@ func expectedProfileThresholds() map[string]profileThresholds {
govulncheckMode: "auto",
requiredReleaseFiles: []string{".goreleaser.yaml"},
requiredAutomationPaths: []string{"Makefile"},
+ reliability: boolPtr(false),
+ data: boolPtr(false),
+ change: boolPtr(false),
+ maxChangedFiles: 25,
+ maxChangedDirectories: 8,
+ maxChangedLines: 800,
+ minTestProdRatioPercent: 20,
},
"startup": {
maxFileLines: 600,
@@ -73,6 +87,13 @@ func expectedProfileThresholds() map[string]profileThresholds {
maxInterfaceMethods: 8,
govulncheckMode: "auto",
requiredAutomationPaths: []string{"Makefile"},
+ reliability: boolPtr(false),
+ data: boolPtr(false),
+ change: boolPtr(false),
+ maxChangedFiles: 25,
+ maxChangedDirectories: 8,
+ maxChangedLines: 800,
+ minTestProdRatioPercent: 20,
},
"strict": {
maxFileLines: 300,
@@ -87,6 +108,13 @@ func expectedProfileThresholds() map[string]profileThresholds {
requiredReleaseFiles: []string{".goreleaser.yaml"},
requiredAutomationPaths: []string{"Makefile"},
contracts: boolPtr(true),
+ reliability: boolPtr(true),
+ data: boolPtr(false),
+ change: boolPtr(true),
+ maxChangedFiles: 25,
+ maxChangedDirectories: 8,
+ maxChangedLines: 800,
+ minTestProdRatioPercent: 20,
},
"enterprise": {
maxFileLines: 300,
@@ -101,6 +129,13 @@ func expectedProfileThresholds() map[string]profileThresholds {
requiredReleaseFiles: []string{".goreleaser.yaml"},
requiredAutomationPaths: []string{"Makefile", ".github/workflows/ci.yml"},
contracts: boolPtr(true),
+ reliability: boolPtr(true),
+ data: boolPtr(true),
+ change: boolPtr(true),
+ maxChangedFiles: 25,
+ maxChangedDirectories: 8,
+ maxChangedLines: 800,
+ minTestProdRatioPercent: 20,
},
"ai-safe": {
maxFileLines: 400,
@@ -114,6 +149,13 @@ func expectedProfileThresholds() map[string]profileThresholds {
govulncheckMode: "required",
requiredReleaseFiles: []string{".goreleaser.yaml"},
requiredAutomationPaths: []string{"Makefile"},
+ reliability: boolPtr(true),
+ data: boolPtr(true),
+ change: boolPtr(true),
+ maxChangedFiles: 20,
+ maxChangedDirectories: 6,
+ maxChangedLines: 600,
+ minTestProdRatioPercent: 30,
},
}
}
@@ -132,6 +174,13 @@ func profileThresholdsFromConfig(cfg core.Config) profileThresholds {
requiredReleaseFiles: cfg.Checks.CIRules.RequiredReleaseFiles,
requiredAutomationPaths: cfg.Checks.CIRules.RequiredAutomationPaths,
contracts: cfg.Checks.Contracts,
+ reliability: cfg.Checks.Reliability,
+ data: cfg.Checks.Data,
+ change: cfg.Checks.Change,
+ maxChangedFiles: cfg.Checks.ChangeRules.MaxChangedFiles,
+ maxChangedDirectories: cfg.Checks.ChangeRules.MaxChangedDirectories,
+ maxChangedLines: cfg.Checks.ChangeRules.MaxChangedLines,
+ minTestProdRatioPercent: cfg.Checks.ChangeRules.MinTestToProductionRatioPercent,
}
}
diff --git a/internal/codeguard/config/recommended_defaults_test.go b/internal/codeguard/config/recommended_defaults_test.go
index 8c46842..64b321e 100644
--- a/internal/codeguard/config/recommended_defaults_test.go
+++ b/internal/codeguard/config/recommended_defaults_test.go
@@ -53,6 +53,7 @@ func recommendedDefaultsCases(trueValue, falseValue *bool) []recommendedDefaults
SupplyChain: true,
Reliability: falseValue,
Data: falseValue,
+ Change: falseValue,
Context: falseValue,
Contracts: trueValue,
},
@@ -71,6 +72,7 @@ func recommendedDefaultsCases(trueValue, falseValue *bool) []recommendedDefaults
CI: true,
Reliability: falseValue,
Data: falseValue,
+ Change: falseValue,
},
},
{
@@ -81,6 +83,7 @@ func recommendedDefaultsCases(trueValue, falseValue *bool) []recommendedDefaults
SupplyChain: true,
Reliability: trueValue,
Data: trueValue,
+ Change: trueValue,
},
want: core.CheckConfig{
UseRecommendedDefaults: true,
@@ -93,6 +96,7 @@ func recommendedDefaultsCases(trueValue, falseValue *bool) []recommendedDefaults
SupplyChain: true,
Reliability: trueValue,
Data: trueValue,
+ Change: trueValue,
},
},
{
@@ -105,8 +109,9 @@ func recommendedDefaultsCases(trueValue, falseValue *bool) []recommendedDefaults
Contracts: trueValue,
Reliability: trueValue,
Data: trueValue,
+ Change: trueValue,
Disabled: []string{
- "quality", "performance", "design", "security", "prompts", "ci", "supply_chain", "reliability", "data", "context", "contracts",
+ "quality", "performance", "design", "security", "prompts", "ci", "supply_chain", "reliability", "data", "change", "context", "contracts",
},
},
want: core.CheckConfig{
@@ -116,8 +121,9 @@ func recommendedDefaultsCases(trueValue, falseValue *bool) []recommendedDefaults
Contracts: falseValue,
Reliability: falseValue,
Data: falseValue,
+ Change: falseValue,
Disabled: []string{
- "quality", "performance", "design", "security", "prompts", "ci", "supply_chain", "reliability", "data", "context", "contracts",
+ "quality", "performance", "design", "security", "prompts", "ci", "supply_chain", "reliability", "data", "change", "context", "contracts",
},
},
},
@@ -136,6 +142,7 @@ func sameCheckActivation(got, want core.CheckConfig) bool {
reflect.DeepEqual(got.Performance, want.Performance) &&
reflect.DeepEqual(got.Reliability, want.Reliability) &&
reflect.DeepEqual(got.Data, want.Data) &&
+ reflect.DeepEqual(got.Change, want.Change) &&
reflect.DeepEqual(got.Context, want.Context) &&
reflect.DeepEqual(got.Contracts, want.Contracts)
}
@@ -231,3 +238,52 @@ func TestValidateRejectsNegativeBasicThresholds(t *testing.T) {
})
}
}
+
+func TestApplyDefaultsPopulatesChangeRules(t *testing.T) {
+ cfg := core.Config{}
+ ApplyDefaults(&cfg)
+
+ if cfg.Checks.Change == nil || *cfg.Checks.Change {
+ t.Fatalf("default change activation = %v, want explicit false", cfg.Checks.Change)
+ }
+ if cfg.Checks.ChangeRules.DetectBehaviorChangeWithoutTest == nil || !*cfg.Checks.ChangeRules.DetectBehaviorChangeWithoutTest {
+ t.Fatal("expected behavior-change-without-test detector to default on")
+ }
+ if cfg.Checks.ChangeRules.DetectRefactorBehaviorChange == nil || !*cfg.Checks.ChangeRules.DetectRefactorBehaviorChange {
+ t.Fatal("expected refactor behavior-change detector to default on")
+ }
+ if cfg.Checks.ChangeRules.MaxChangedFiles != 25 {
+ t.Fatalf("max changed files = %d, want 25", cfg.Checks.ChangeRules.MaxChangedFiles)
+ }
+ if cfg.Checks.ChangeRules.MinTestToProductionRatioPercent != 20 {
+ t.Fatalf("min test ratio percent = %d, want 20", cfg.Checks.ChangeRules.MinTestToProductionRatioPercent)
+ }
+}
+
+func TestValidateRejectsInvalidChangeRuleThresholds(t *testing.T) {
+ tests := []struct {
+ name string
+ set func(*core.Config)
+ want string
+ }{
+ {name: "changed files", set: func(cfg *core.Config) { cfg.Checks.ChangeRules.MaxChangedFiles = -1 }, want: "change_rules.max_changed_files must not be negative"},
+ {name: "changed directories", set: func(cfg *core.Config) { cfg.Checks.ChangeRules.MaxChangedDirectories = -1 }, want: "change_rules.max_changed_directories must not be negative"},
+ {name: "changed lines", set: func(cfg *core.Config) { cfg.Checks.ChangeRules.MaxChangedLines = -1 }, want: "change_rules.max_changed_lines must not be negative"},
+ {name: "public interfaces", set: func(cfg *core.Config) { cfg.Checks.ChangeRules.MaxPublicInterfacesChanged = -1 }, want: "change_rules.max_public_interfaces_changed must not be negative"},
+ {name: "concern families", set: func(cfg *core.Config) { cfg.Checks.ChangeRules.MaxConcernFamilies = -1 }, want: "change_rules.max_concern_families must not be negative"},
+ {name: "test ratio low", set: func(cfg *core.Config) { cfg.Checks.ChangeRules.MinTestToProductionRatioPercent = -1 }, want: "change_rules.min_test_to_production_ratio_percent must be between 0 and 100"},
+ {name: "test ratio high", set: func(cfg *core.Config) { cfg.Checks.ChangeRules.MinTestToProductionRatioPercent = 101 }, want: "change_rules.min_test_to_production_ratio_percent must be between 0 and 100"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := ExampleConfig()
+ tt.set(&cfg)
+
+ err := Validate(cfg)
+ if err == nil || !strings.Contains(err.Error(), tt.want) {
+ t.Fatalf("Validate() error = %v, want %q", err, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/codeguard/config/validate.go b/internal/codeguard/config/validate.go
index dae3181..e73f732 100644
--- a/internal/codeguard/config/validate.go
+++ b/internal/codeguard/config/validate.go
@@ -26,6 +26,7 @@ func Validate(cfg core.Config) error {
validateSupplyChainRules(cfg.Checks.SupplyChainRules),
validateReliabilityRules(cfg.Checks.ReliabilityRules),
validateDataRules(cfg.Checks.DataRules),
+ validateChangeRules(cfg.Checks.ChangeRules),
validateProductionRisk(cfg.Checks.ProductionRisk),
validateContractRules(cfg.Checks.ContractRules),
validateContextRules(cfg.Checks.ContextRules),
diff --git a/internal/codeguard/config/validate_defaults.go b/internal/codeguard/config/validate_defaults.go
index bab235b..1e35096 100644
--- a/internal/codeguard/config/validate_defaults.go
+++ b/internal/codeguard/config/validate_defaults.go
@@ -9,7 +9,7 @@ import (
var recognizedDisabledChecks = map[string]struct{}{
"quality": {}, "performance": {}, "design": {}, "security": {}, "prompts": {},
- "ci": {}, "supply_chain": {}, "reliability": {}, "data": {}, "context": {}, "contracts": {},
+ "ci": {}, "supply_chain": {}, "reliability": {}, "data": {}, "change": {}, "context": {}, "contracts": {},
}
func validateDisabledChecks(disabled []string) error {
diff --git a/internal/codeguard/config/validate_reliability_data.go b/internal/codeguard/config/validate_reliability_data.go
index 0350dce..800193d 100644
--- a/internal/codeguard/config/validate_reliability_data.go
+++ b/internal/codeguard/config/validate_reliability_data.go
@@ -26,6 +26,27 @@ func validateDataRules(rules core.DataRulesConfig) error {
return nil
}
+func validateChangeRules(rules core.ChangeRulesConfig) error {
+ for _, item := range []struct {
+ field string
+ value int
+ }{
+ {"change_rules.max_changed_files", rules.MaxChangedFiles},
+ {"change_rules.max_changed_directories", rules.MaxChangedDirectories},
+ {"change_rules.max_changed_lines", rules.MaxChangedLines},
+ {"change_rules.max_public_interfaces_changed", rules.MaxPublicInterfacesChanged},
+ {"change_rules.max_concern_families", rules.MaxConcernFamilies},
+ } {
+ if item.value < 0 {
+ return fmt.Errorf("%s must not be negative", item.field)
+ }
+ }
+ if rules.MinTestToProductionRatioPercent < 0 || rules.MinTestToProductionRatioPercent > 100 {
+ return fmt.Errorf("change_rules.min_test_to_production_ratio_percent must be between 0 and 100")
+ }
+ return nil
+}
+
func validateProductionRisk(risk core.ProductionRiskConfig) error {
if risk.WarnThreshold < 0 || risk.WarnThreshold > 100 {
return fmt.Errorf("production_risk.warn_threshold must be between 0 and 100")
diff --git a/internal/codeguard/core/config_rule_types.go b/internal/codeguard/core/config_rule_types.go
index f2819df..ec99fe2 100644
--- a/internal/codeguard/core/config_rule_types.go
+++ b/internal/codeguard/core/config_rule_types.go
@@ -13,6 +13,7 @@ type QualityRulesConfig struct {
AIChecks AIChecksConfig `json:"ai_checks,omitempty" yaml:"ai_checks,omitempty"`
CoverageDelta CoverageDeltaConfig `json:"coverage_delta,omitempty" yaml:"coverage_delta,omitempty"`
CPPTooling CPPToolingConfig `json:"cpp_tooling,omitempty" yaml:"cpp_tooling,omitempty"`
+ LocalPrecision *bool `json:"local_precision,omitempty" yaml:"local_precision,omitempty"`
}
// PerformanceRulesConfig tunes the performance section (checks.performance).
@@ -256,6 +257,42 @@ type DataRulesConfig struct {
MaxWritesWithoutTransaction int `json:"max_writes_without_transaction,omitempty" yaml:"max_writes_without_transaction,omitempty"`
}
+// ChangeRulesConfig tunes the change-safety, testability, and refactor
+// confidence section. Nil rule toggles default to enabled when the section is
+// enabled by configuration or a profile.
+type ChangeRulesConfig struct {
+ DetectBehaviorChangeWithoutTest *bool `json:"detect_behavior_change_without_test,omitempty" yaml:"detect_behavior_change_without_test,omitempty"`
+ DetectFailurePathMissing *bool `json:"detect_failure_path_missing,omitempty" yaml:"detect_failure_path_missing,omitempty"`
+ DetectHardwiredDependency *bool `json:"detect_hardwired_dependency,omitempty" yaml:"detect_hardwired_dependency,omitempty"`
+ DetectNondeterministicDomain *bool `json:"detect_nondeterministic_domain,omitempty" yaml:"detect_nondeterministic_domain,omitempty"`
+ DetectLegacyHotspotUncovered *bool `json:"detect_legacy_hotspot_uncovered,omitempty" yaml:"detect_legacy_hotspot_uncovered,omitempty"`
+ DetectMixedConcerns *bool `json:"detect_mixed_concerns,omitempty" yaml:"detect_mixed_concerns,omitempty"`
+ DetectOversizedDiff *bool `json:"detect_oversized_diff,omitempty" yaml:"detect_oversized_diff,omitempty"`
+ DetectMixedRefactorAndBehavior *bool `json:"detect_mixed_refactor_and_behavior,omitempty" yaml:"detect_mixed_refactor_and_behavior,omitempty"`
+ DetectTooManyConcerns *bool `json:"detect_too_many_concerns,omitempty" yaml:"detect_too_many_concerns,omitempty"`
+ DetectUnnecessarySurfaceArea *bool `json:"detect_unnecessary_surface_area,omitempty" yaml:"detect_unnecessary_surface_area,omitempty"`
+ DetectOneUseAbstraction *bool `json:"detect_one_use_abstraction,omitempty" yaml:"detect_one_use_abstraction,omitempty"`
+ DetectDuplicateHelper *bool `json:"detect_duplicate_helper,omitempty" yaml:"detect_duplicate_helper,omitempty"`
+ DetectCleanupRegression *bool `json:"detect_cleanup_regression,omitempty" yaml:"detect_cleanup_regression,omitempty"`
+ DetectComplexityIncreased *bool `json:"detect_complexity_increased,omitempty" yaml:"detect_complexity_increased,omitempty"`
+ DetectMoveWithoutVerification *bool `json:"detect_move_without_verification,omitempty" yaml:"detect_move_without_verification,omitempty"`
+ DetectRefactorBehaviorChange *bool `json:"detect_refactor_behavior_change,omitempty" yaml:"detect_refactor_behavior_change,omitempty"`
+ DetectRefactorPublicContract *bool `json:"detect_refactor_public_contract,omitempty" yaml:"detect_refactor_public_contract,omitempty"`
+ DetectRefactorTestCoverageDrop *bool `json:"detect_refactor_test_coverage_drop,omitempty" yaml:"detect_refactor_test_coverage_drop,omitempty"`
+ DetectRefactorErrorPathChange *bool `json:"detect_refactor_error_path_change,omitempty" yaml:"detect_refactor_error_path_change,omitempty"`
+ DetectRefactorSideEffectReorder *bool `json:"detect_refactor_side_effect_reorder,omitempty" yaml:"detect_refactor_side_effect_reorder,omitempty"`
+ DetectRefactorVisibilityExpand *bool `json:"detect_refactor_visibility_expand,omitempty" yaml:"detect_refactor_visibility_expand,omitempty"`
+ DetectRefactorDependencyWorsened *bool `json:"detect_refactor_dependency_worsened,omitempty" yaml:"detect_refactor_dependency_worsened,omitempty"`
+ DetectRefactorDuplicateLeftBehind *bool `json:"detect_refactor_duplicate_left_behind,omitempty" yaml:"detect_refactor_duplicate_left_behind,omitempty"`
+ DetectRefactorDeadPathLeftBehind *bool `json:"detect_refactor_dead_path_left_behind,omitempty" yaml:"detect_refactor_dead_path_left_behind,omitempty"`
+ MaxChangedFiles int `json:"max_changed_files,omitempty" yaml:"max_changed_files,omitempty"`
+ MaxChangedDirectories int `json:"max_changed_directories,omitempty" yaml:"max_changed_directories,omitempty"`
+ MaxChangedLines int `json:"max_changed_lines,omitempty" yaml:"max_changed_lines,omitempty"`
+ MaxPublicInterfacesChanged int `json:"max_public_interfaces_changed,omitempty" yaml:"max_public_interfaces_changed,omitempty"`
+ MaxConcernFamilies int `json:"max_concern_families,omitempty" yaml:"max_concern_families,omitempty"`
+ MinTestToProductionRatioPercent int `json:"min_test_to_production_ratio_percent,omitempty" yaml:"min_test_to_production_ratio_percent,omitempty"`
+}
+
// ProductionRiskConfig controls the additive PR-summary production-risk
// artifact. It never changes individual rule severities.
type ProductionRiskConfig struct {
diff --git a/internal/codeguard/core/config_types.go b/internal/codeguard/core/config_types.go
index f060d0e..9b41f14 100644
--- a/internal/codeguard/core/config_types.go
+++ b/internal/codeguard/core/config_types.go
@@ -85,6 +85,10 @@ type CheckConfig struct {
// missing transaction boundaries, unsafe dual writes, unbounded reads,
// unstable pagination, and cache policy gaps.
Data *bool `json:"data,omitempty" yaml:"data,omitempty"`
+ // Change toggles change-safety, testability, and refactor-confidence checks
+ // such as oversized diffs, mixed concerns, behavior changes without tests,
+ // and refactor-only changes that alter observable behavior.
+ Change *bool `json:"change,omitempty" yaml:"change,omitempty"`
// Contracts toggles the API contract drift family. When nil it defaults
// to enabled in diff scans and disabled in full scans; the strict and
// enterprise profiles enable it unconditionally.
@@ -106,6 +110,7 @@ type CheckConfig struct {
SupplyChainRules SupplyChainRulesConfig `json:"supply_chain_rules" yaml:"supply_chain_rules"`
ReliabilityRules ReliabilityRulesConfig `json:"reliability_rules,omitempty" yaml:"reliability_rules,omitempty"`
DataRules DataRulesConfig `json:"data_rules,omitempty" yaml:"data_rules,omitempty"`
+ ChangeRules ChangeRulesConfig `json:"change_rules,omitempty" yaml:"change_rules,omitempty"`
ContractRules ContractRulesConfig `json:"contract_rules" yaml:"contract_rules"`
ContextRules ContextRulesConfig `json:"context_rules" yaml:"context_rules"`
ProductionRisk ProductionRiskConfig `json:"production_risk,omitempty" yaml:"production_risk,omitempty"`
diff --git a/internal/codeguard/core/report_artifact_types.go b/internal/codeguard/core/report_artifact_types.go
index 6328b05..5106ae1 100644
--- a/internal/codeguard/core/report_artifact_types.go
+++ b/internal/codeguard/core/report_artifact_types.go
@@ -27,7 +27,10 @@ const (
)
type PRSummaryArtifact struct {
- ProductionRisk *PRSummaryMetric `json:"production_risk,omitempty"`
+ ProductionRisk *PRSummaryMetric `json:"production_risk,omitempty"`
+ ChangeSafety *PRSummaryMetric `json:"change_safety,omitempty"`
+ MaintainabilityDelta *PRSummaryMetric `json:"maintainability_delta,omitempty"`
+ RefactorConfidence *PRSummaryMetric `json:"refactor_confidence,omitempty"`
}
type PRSummaryMetric struct {
diff --git a/internal/codeguard/history/change_metrics.go b/internal/codeguard/history/change_metrics.go
new file mode 100644
index 0000000..4ac0d9a
--- /dev/null
+++ b/internal/codeguard/history/change_metrics.go
@@ -0,0 +1,209 @@
+package history
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+const changeCommitMarker = "@@CG-CHANGE@@"
+
+var defectSubjectPattern = regexp.MustCompile(`(?i)\b(fix|bug|bugfix|hotfix|regression|revert|incident|defect|broken|failure)\b`)
+
+// ChangeMetricsOptions configures a bounded, read-only git-history summary.
+type ChangeMetricsOptions struct {
+ RepoPath string
+ MaxCommits int
+}
+
+// ChangeMetricsReport summarizes file-level history for maintainability
+// signals. Available is false when the directory has no usable git history;
+// callers should treat that as "no evidence" instead of a scan failure.
+type ChangeMetricsReport struct {
+ Available bool
+ CommitsScanned int
+ Files map[string]FileChangeMetrics
+}
+
+// FileChangeMetrics aggregates bounded git-log evidence for one path.
+type FileChangeMetrics struct {
+ Path string
+ Commits int
+ Additions int
+ Deletions int
+ Churn int
+ DefectCommits int
+ Subjects []string
+ CoChangePartners map[string]int
+}
+
+type changeCommit struct {
+ subject string
+ files map[string]fileDelta
+}
+
+type fileDelta struct {
+ additions int
+ deletions int
+}
+
+// CollectChangeMetrics walks recent git history using only local repository
+// data. Git failures are returned as an unavailable report with a nil error so
+// quality checks degrade gracefully in shallow, detached, or no-history repos.
+func CollectChangeMetrics(ctx context.Context, opts ChangeMetricsOptions) (ChangeMetricsReport, error) {
+ repo := strings.TrimSpace(opts.RepoPath)
+ if repo == "" {
+ repo = "."
+ }
+ maxCommits := opts.MaxCommits
+ if maxCommits <= 0 {
+ maxCommits = 200
+ }
+
+ args := []string{"-C", repo, "log", "--numstat", "--no-color", "--format=" + changeCommitMarker + "%H%x00%s", fmt.Sprintf("-n%d", maxCommits), "--", "."}
+ cmd := exec.CommandContext(ctx, "git", args...) //nolint:gosec // fixed git log subcommand; repo path and commit limit are caller-controlled scan inputs
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return ChangeMetricsReport{}, err
+ }
+ if err := cmd.Start(); err != nil {
+ return ChangeMetricsReport{Files: map[string]FileChangeMetrics{}}, nil
+ }
+ report := parseChangeMetrics(stdout)
+ if err := cmd.Wait(); err != nil {
+ return ChangeMetricsReport{Files: map[string]FileChangeMetrics{}}, nil
+ }
+ report.Available = report.CommitsScanned > 0
+ if report.Files == nil {
+ report.Files = map[string]FileChangeMetrics{}
+ }
+ return report, nil
+}
+
+func parseChangeMetrics(reader io.Reader) ChangeMetricsReport {
+ parser := &changeMetricsParser{report: ChangeMetricsReport{Files: map[string]FileChangeMetrics{}}}
+ scanner := bufio.NewScanner(reader)
+ scanner.Buffer(make([]byte, 64*1024), 1024*1024)
+ for scanner.Scan() {
+ parser.handleLine(strings.TrimRight(scanner.Text(), "\r"))
+ }
+ parser.flush()
+ return parser.report
+}
+
+type changeMetricsParser struct {
+ report ChangeMetricsReport
+ current changeCommit
+ active bool
+}
+
+func (p *changeMetricsParser) handleLine(line string) {
+ if strings.HasPrefix(line, changeCommitMarker) {
+ p.flush()
+ var subject string
+ if idx := strings.IndexByte(line, 0); idx >= 0 {
+ subject = line[idx+1:]
+ } else {
+ subject = strings.TrimPrefix(line, changeCommitMarker)
+ }
+ p.current = changeCommit{subject: strings.TrimSpace(subject), files: map[string]fileDelta{}}
+ p.active = true
+ return
+ }
+ if !p.active {
+ return
+ }
+ added, deleted, path, ok := parseNumstatLine(line)
+ if !ok {
+ return
+ }
+ p.current.files[path] = fileDelta{additions: added, deletions: deleted}
+}
+
+func (p *changeMetricsParser) flush() {
+ if !p.active {
+ return
+ }
+ if len(p.current.files) == 0 {
+ p.active = false
+ return
+ }
+ p.report.CommitsScanned++
+ paths := make([]string, 0, len(p.current.files))
+ for path := range p.current.files {
+ paths = append(paths, path)
+ }
+ sort.Strings(paths)
+ defect := defectSubjectPattern.MatchString(p.current.subject)
+ for _, path := range paths {
+ delta := p.current.files[path]
+ metric := p.report.Files[path]
+ if metric.Path == "" {
+ metric.Path = path
+ }
+ metric.Commits++
+ metric.Additions += delta.additions
+ metric.Deletions += delta.deletions
+ metric.Churn += delta.additions + delta.deletions
+ if defect {
+ metric.DefectCommits++
+ }
+ if p.current.subject != "" && len(metric.Subjects) < 12 {
+ metric.Subjects = append(metric.Subjects, p.current.subject)
+ }
+ if metric.CoChangePartners == nil {
+ metric.CoChangePartners = map[string]int{}
+ }
+ for _, partner := range paths {
+ if partner != path {
+ metric.CoChangePartners[partner]++
+ }
+ }
+ p.report.Files[path] = metric
+ }
+ p.active = false
+}
+
+func parseNumstatLine(line string) (int, int, string, bool) {
+ parts := strings.Split(line, "\t")
+ if len(parts) < 3 {
+ return 0, 0, "", false
+ }
+ path := normalizeNumstatPath(parts[len(parts)-1])
+ if path == "" {
+ return 0, 0, "", false
+ }
+ added := parseNumstatCount(parts[0])
+ deleted := parseNumstatCount(parts[1])
+ return added, deleted, path, true
+}
+
+func parseNumstatCount(value string) int {
+ if value == "-" {
+ return 0
+ }
+ n, err := strconv.Atoi(value)
+ if err != nil || n < 0 {
+ return 0
+ }
+ return n
+}
+
+func normalizeNumstatPath(path string) string {
+ path = strings.TrimSpace(path)
+ if path == "" || path == "/dev/null" {
+ return ""
+ }
+ if strings.Contains(path, " => ") {
+ path = path[strings.LastIndex(path, " => ")+4:]
+ path = strings.Trim(path, "{}")
+ }
+ return filepath.ToSlash(path)
+}
diff --git a/internal/codeguard/rules/catalog.go b/internal/codeguard/rules/catalog.go
index 2c71488..b205c7b 100644
--- a/internal/codeguard/rules/catalog.go
+++ b/internal/codeguard/rules/catalog.go
@@ -17,6 +17,7 @@ var catalog = withSecurityOWASP(mergeRuleCatalogs(
securityExtraCatalog,
reliabilityCatalog,
dataCatalog,
+ changeSafetyCatalog,
supplyChainCatalog,
contextCatalog,
contextReadinessCatalog,
diff --git a/internal/codeguard/rules/catalog_change_safety.go b/internal/codeguard/rules/catalog_change_safety.go
new file mode 100644
index 0000000..c5c2add
--- /dev/null
+++ b/internal/codeguard/rules/catalog_change_safety.go
@@ -0,0 +1,125 @@
+package rules
+
+import "github.com/devr-tools/codeguard/internal/codeguard/core"
+
+var changeSafetyCatalog = map[string]core.RuleMetadata{
+ "naming.generic-identifier": localQualityRule("naming.generic-identifier", "warn", "Generic identifier", "Warns when a function, parameter, or local variable uses placeholder names such as foo, tmp, thing, or obj instead of domain vocabulary.", "Rename the identifier to describe the role it plays in the surrounding behavior."),
+ "function.excessive-parameters": localQualityRule("function.excessive-parameters", "warn", "Excessive parameters", "Warns when a function exceeds the configured parameter threshold and should likely group related inputs or split responsibilities.", "Group cohesive inputs into a named object or split the function along separate responsibilities."),
+ "function.mixed-abstraction-level": localQualityRule("function.mixed-abstraction-level", "warn", "Mixed abstraction level", "Warns when one function combines orchestration-level calls with low-level infrastructure operations such as SQL, HTTP, filesystem, or environment access.", "Move low-level infrastructure details behind a helper or boundary so the function operates at one clear level of abstraction."),
+ "function.command-query-mix": localQualityRule("function.command-query-mix", "warn", "Command/query mix", "Warns when a function returns a value while also invoking mutating side-effect operations.", "Separate state-changing commands from value-returning queries, or make the side effect explicit in the function name and tests."),
+ "error.logged-and-ignored": localQualityRule("error.logged-and-ignored", "warn", "Logged and ignored error", "Warns when an error is logged and then ignored, converted to a success value, or allowed to continue without propagation.", "Return, wrap, or otherwise handle the error instead of only logging it."),
+ "error.context-lost": localQualityRule("error.context-lost", "warn", "Error context lost", "Warns when an error is rethrown or returned bare from a lower-level call without contextual wrapping.", "Wrap the error with operation-specific context while preserving the original error for callers."),
+ "defensive.unchecked-type-assertion": localQualityRule("defensive.unchecked-type-assertion", "warn", "Unchecked type assertion", "Warns when a type assertion or cast bypasses runtime validation or omits the safe comma-ok form.", "Use a checked assertion, runtime validation, or type narrowing before consuming the value."),
+ "defensive.unsafe-numeric-conversion": localQualityRule("defensive.unsafe-numeric-conversion", "warn", "Unsafe numeric conversion", "Warns when a narrowing or sign-changing numeric conversion can truncate, wrap, or lose precision.", "Validate bounds before converting, or keep values in a type wide enough for the source range."),
+ "maintainability.public-surface-growth": maintainabilityDeltaRule("maintainability.public-surface-growth", "warn", "Public surface growth", "Warns in diff scans when a changed file exports more public symbols than it did at the base ref.", "Keep newly exported symbols intentional, documented, and covered by tests; avoid widening API surface for internal-only behavior."),
+ "maintainability.dependency-growth": maintainabilityDeltaRule("maintainability.dependency-growth", "warn", "Dependency growth", "Warns in diff scans when a changed file imports or includes more direct dependencies than it did at the base ref.", "Remove unnecessary imports/includes or hide optional integrations behind a narrow boundary."),
+ "maintainability.high-churn-hotspot": maintainabilityHistoryRule("maintainability.high-churn-hotspot", "High-churn hotspot", "Warns when a changed file combines repeated churn with current complexity hints, making safe review and future changes harder.", "Reduce local complexity, split the change if possible, and add focused regression coverage around the behavior being touched."),
+ "maintainability.repeat-defect-area": maintainabilityHistoryRule("maintainability.repeat-defect-area", "Repeat defect area", "Warns when a changed file has multiple recent fix, regression, incident, or defect-linked commits in git history.", "Add regression tests for the failure modes that have changed here before and keep the patch narrow."),
+ "maintainability.unstable-interface": maintainabilityHistoryRule("maintainability.unstable-interface", "Unstable interface", "Warns when a changed public-surface file has repeated churn or defect history, suggesting compatibility risk.", "Keep interface changes explicit, document caller impact, and preserve backwards compatibility or add migration tests."),
+ "maintainability.change-amplification": maintainabilityHistoryRule("maintainability.change-amplification", "Change amplification", "Warns when a changed file historically fans out into many co-changed partners.", "Identify the coupled responsibilities and consider extracting a narrower boundary or updating the usual partner files intentionally."),
+ "maintainability.hotspot": maintainabilityHistoryRule("maintainability.hotspot", "Maintainability hotspot", "Warns when a changed file has high recent churn, defect history, or both.", "Treat the file as risky legacy surface: keep changes small, add characterization tests, and note the hotspot evidence for reviewers."),
+ "smell.shotgun-surgery-history": smellHistoryRule("smell.shotgun-surgery-history", "warn", "Shotgun surgery history", "Warns when a changed file repeatedly co-changes with several partners, indicating one concept may be spread across files.", "Consider consolidating the scattered responsibility or make the related partner updates explicit in this PR."),
+ "smell.divergent-change-history": smellHistoryRule("smell.divergent-change-history", "warn", "Divergent change history", "Warns when a changed file has recent commit subjects spanning several concern families.", "Split unrelated responsibilities out of the file or isolate the concern being changed behind a clearer boundary."),
+
+ "testing.behavior-change-without-test": testabilityRule("testing.behavior-change-without-test", "fail", "Behavior change without test", "Fails when production behavior changes without nearby test evidence in the same diff.", "Add or update tests that exercise the changed behavior, including observable success and failure outcomes."),
+ "testing.failure-path-missing": testabilityRule("testing.failure-path-missing", "warn", "Failure path missing", "Warns when high-risk branches add error, retry, fallback, authorization, or external dependency paths without failure-path tests.", "Add tests that force the failure path and assert the returned error, fallback behavior, or partial-failure result."),
+ "testing.hardwired-dependency": testabilityRule("testing.hardwired-dependency", "warn", "Hardwired dependency", "Warns when business logic constructs clocks, random sources, network clients, filesystem access, or infrastructure dependencies directly.", "Inject the dependency or route it through a narrow interface so tests can provide deterministic fakes."),
+ "testing.nondeterministic-domain-logic": testabilityRule("testing.nondeterministic-domain-logic", "warn", "Nondeterministic domain logic", "Warns when domain code reads time, randomness, filesystem, network, or environment state directly.", "Move nondeterministic access to the boundary and pass explicit values or interfaces into domain logic."),
+ "testing.legacy-hotspot-uncovered": testabilityRule("testing.legacy-hotspot-uncovered", "warn", "Legacy hotspot without characterization coverage", "Warns when a high-churn or complex legacy hotspot is touched without characterization or regression-test evidence.", "Add characterization tests around the current behavior before changing the hotspot."),
+
+ "change.mixed-concerns": changeRepoRule("change.mixed-concerns", "Mixed concerns", "Warns when a PR combines unrelated subsystems, architectural layers, or rule families in one review unit.", "Split unrelated concerns into smaller PRs or document why they must ship atomically."),
+ "change.oversized-diff": changeRepoRule("change.oversized-diff", "Oversized diff", "Warns when the changed-file, changed-directory, changed-line, public-interface, or test-ratio budget makes the PR hard to review safely.", "Reduce scope, split mechanical movement from behavior changes, or add focused review notes and tests for the highest-risk paths."),
+ "change.mixed-refactor-and-behavior": changeRepoRule("change.mixed-refactor-and-behavior", "Mixed refactor and behavior change", "Warns when a diff combines moves, renames, or extraction with observable behavior changes.", "Separate behavior-preserving refactors from behavior changes, or provide explicit verification evidence."),
+ "change.too-many-concerns": changeRepoRule("change.too-many-concerns", "Too many concerns", "Warns when change concentration evidence shows too many unrelated concepts being modified at once.", "Split the PR by concern, public contract, architectural layer, or rollout unit."),
+ "change.unnecessary-surface-area": changeRepoRule("change.unnecessary-surface-area", "Unnecessary surface area", "Warns when a narrow change touches more files, directories, or public interfaces than the behavior requires.", "Trim incidental edits and keep public API changes limited to what the feature requires."),
+ "change.one-use-abstraction": testabilityRule("change.one-use-abstraction", "warn", "One-use abstraction", "Warns when a new abstraction is introduced but has only one consumer or delegates without simplifying the caller.", "Inline the abstraction until a second concrete use appears, or make the boundary carry meaningful policy."),
+ "change.duplicate-helper": testabilityRule("change.duplicate-helper", "warn", "Duplicate helper", "Warns when a change introduces helper logic that overlaps existing project vocabulary or utilities.", "Reuse or extend the existing helper, keeping one source of truth for the shared behavior."),
+ "change.cleanup-regression": testabilityRule("change.cleanup-regression", "warn", "Cleanup regression", "Warns when a cleanup-labeled change increases complexity, duplication, public surface, or dependency count.", "Keep cleanup PRs behavior-preserving and ensure maintainability metrics move in the intended direction."),
+ "change.complexity-increased": testabilityRule("change.complexity-increased", "warn", "Complexity increased", "Warns when touched functions, files, or hotspots become materially more complex in the diff.", "Extract decisions, reduce nesting, or add tests explaining why the added complexity is necessary."),
+ "change.move-without-verification": changeRepoRule("change.move-without-verification", "Move without verification", "Warns when files or symbols move without test, build, or behavior-preservation evidence.", "Preserve tests through the move and run the narrow verification target that exercises the moved behavior."),
+
+ "refactor.behavior-change-detected": refactorRule("refactor.behavior-change-detected", "fail", "Behavior change detected in refactor", "Fails when a refactor-labeled diff changes return paths, side effects, authorization checks, writes, emitted events, or external calls.", "Move behavior changes into a separate PR or update the PR label and add tests for the new behavior."),
+ "refactor.public-contract-changed": refactorRule("refactor.public-contract-changed", "fail", "Public contract changed in refactor", "Fails when exported signatures, API schemas, events, or persistence contracts change in a refactor-only PR.", "Keep public contracts stable during the refactor or make the contract change explicit with compatibility tests."),
+ "refactor.test-coverage-reduced": refactorRule("refactor.test-coverage-reduced", "warn", "Test coverage reduced by refactor", "Warns when a refactor removes or weakens tests over the moved or reshaped behavior.", "Move tests with the code and preserve characterization coverage before deleting old tests."),
+ "refactor.error-path-changed": refactorRule("refactor.error-path-changed", "fail", "Error path changed in refactor", "Fails when a refactor changes wrapping, returned errors, ignored errors, panic behavior, or partial-failure handling.", "Preserve existing error contracts or split the error-behavior change into a tested follow-up PR."),
+ "refactor.side-effect-order-changed": refactorRule("refactor.side-effect-order-changed", "fail", "Side-effect order changed in refactor", "Fails when database writes, event publishing, network calls, cleanup, or authorization side effects are reordered.", "Keep side-effect ordering stable or add explicit tests and rollout notes for the new order."),
+ "refactor.visibility-expanded": refactorRule("refactor.visibility-expanded", "warn", "Visibility expanded in refactor", "Warns when private symbols become public or cross-package visible during a refactor.", "Keep visibility as narrow as possible, or document the new consumer that requires the broader API."),
+ "refactor.dependency-direction-worsened": refactorRule("refactor.dependency-direction-worsened", "warn", "Dependency direction worsened in refactor", "Warns when refactoring introduces an inward dependency on infrastructure, UI, persistence, or framework code.", "Invert the dependency or introduce a stable interface owned by the inner layer."),
+ "refactor.duplicate-implementation-left-behind": refactorRule("refactor.duplicate-implementation-left-behind", "warn", "Duplicate implementation left behind", "Warns when an extraction or move leaves the previous implementation active in another path.", "Delete or delegate the old implementation after proving callers use the new path."),
+ "refactor.dead-path-left-behind": refactorRule("refactor.dead-path-left-behind", "warn", "Dead path left behind", "Warns when refactoring leaves obsolete branches, feature flags, wrappers, or compatibility paths without consumers.", "Remove the dead path or add a removal plan with ownership and an expiry trigger."),
+}
+
+func testabilityRule(id string, level string, title string, description string, howToFix string) core.RuleMetadata {
+ return core.RuleMetadata{
+ ID: id,
+ Section: "Change Safety / Testability",
+ DefaultLevel: level,
+ ExecutionModel: core.RuleExecutionModelLanguageAgnostic,
+ LanguageCoverage: core.FixedRuleLanguageCoverage(
+ core.RuleLanguageGo,
+ core.RuleLanguageTypeScript,
+ core.RuleLanguageJavaScript,
+ core.RuleLanguagePython,
+ core.RuleLanguageCPP,
+ ),
+ Title: title,
+ Description: description,
+ HowToFix: howToFix,
+ }
+}
+
+func localQualityRule(id string, level string, title string, description string, howToFix string) core.RuleMetadata {
+ return core.RuleMetadata{
+ ID: id,
+ Section: "Code Quality / Local Precision",
+ DefaultLevel: level,
+ ExecutionModel: core.RuleExecutionModelLanguageAgnostic,
+ LanguageCoverage: core.FixedRuleLanguageCoverage(
+ core.RuleLanguageGo,
+ core.RuleLanguageTypeScript,
+ core.RuleLanguageJavaScript,
+ core.RuleLanguagePython,
+ core.RuleLanguageCPP,
+ ),
+ Title: title,
+ Description: description,
+ HowToFix: howToFix,
+ }
+}
+
+func maintainabilityDeltaRule(id string, level string, title string, description string, howToFix string) core.RuleMetadata {
+ meta := localQualityRule(id, level, title, description, howToFix)
+ meta.Section = "Maintainability Delta"
+ return meta
+}
+
+func maintainabilityHistoryRule(id string, title string, description string, howToFix string) core.RuleMetadata {
+ meta := localQualityRule(id, "warn", title, description, howToFix)
+ meta.Section = "Maintainability History"
+ return meta
+}
+
+func smellHistoryRule(id string, level string, title string, description string, howToFix string) core.RuleMetadata {
+ meta := localQualityRule(id, level, title, description, howToFix)
+ meta.Section = "Code Smells / History"
+ return meta
+}
+
+func refactorRule(id string, level string, title string, description string, howToFix string) core.RuleMetadata {
+ meta := testabilityRule(id, level, title, description, howToFix)
+ meta.Section = "Change Safety / Refactors"
+ return meta
+}
+
+func changeRepoRule(id string, title string, description string, howToFix string) core.RuleMetadata {
+ return core.RuleMetadata{
+ ID: id,
+ Section: "Change Safety",
+ DefaultLevel: "warn",
+ ExecutionModel: core.RuleExecutionModelLanguageAgnostic,
+ LanguageCoverage: core.RepositoryWideRuleLanguageCoverage(),
+ Title: title,
+ Description: description,
+ HowToFix: howToFix,
+ }
+}
diff --git a/internal/codeguard/rules/catalog_fix_templates.go b/internal/codeguard/rules/catalog_fix_templates.go
index 38e0ff0..2a9be84 100644
--- a/internal/codeguard/rules/catalog_fix_templates.go
+++ b/internal/codeguard/rules/catalog_fix_templates.go
@@ -24,6 +24,7 @@ var fixTemplates = mergeFixTemplates(
performanceMeasuredFixTemplates,
reliabilityFixTemplates,
dataFixTemplates,
+ changeSafetyFixTemplates,
securityFixTemplates,
securityLanguageFixTemplates,
designFixTemplates,
diff --git a/internal/codeguard/rules/catalog_fix_templates_change_safety.go b/internal/codeguard/rules/catalog_fix_templates_change_safety.go
new file mode 100644
index 0000000..e44add2
--- /dev/null
+++ b/internal/codeguard/rules/catalog_fix_templates_change_safety.go
@@ -0,0 +1,50 @@
+package rules
+
+import "github.com/devr-tools/codeguard/internal/codeguard/core"
+
+var changeSafetyFixTemplates = map[string]core.FixTemplate{
+ "naming.generic-identifier": {Kind: guided, Text: "Replace placeholder names with domain vocabulary.\n\nBefore:\nfunc process(foo User) { tmp := foo.Email }\n\nAfter:\nfunc process(account User) { normalizedEmail := account.Email }"},
+ "function.excessive-parameters": {Kind: guided, Text: "Group related parameters or split the function.\n\nBefore:\nfunc CreateUser(name, email, country, plan, source string, admin bool) error\n\nAfter:\ntype CreateUserRequest struct { Name, Email, Country, Plan, Source string; Admin bool }\nfunc CreateUser(req CreateUserRequest) error"},
+ "function.mixed-abstraction-level": {Kind: guided, Text: "Keep each function at one abstraction level.\n\nBefore:\nfunc Checkout(order Order) { validate(order); db.Query(...); http.Post(...) }\n\nAfter:\nfunc Checkout(order Order) { validate(order); persistOrder(order); publishCheckout(order) }"},
+ "function.command-query-mix": {Kind: guided, Text: "Separate value queries from state-changing commands.\n\nBefore:\nfunc GetUser(id string) (User, error) { repo.SaveAudit(id); return repo.Find(id) }\n\nAfter:\nfunc GetUser(id string) (User, error) { return repo.Find(id) }\nfunc RecordUserLookup(id string) error { return repo.SaveAudit(id) }"},
+ "error.logged-and-ignored": {Kind: guided, Text: "Do not only log an error and continue as success.\n\nBefore:\nif err != nil { log.Printf(\"load: %v\", err); return nil }\n\nAfter:\nif err != nil { return fmt.Errorf(\"load profile: %w\", err) }"},
+ "error.context-lost": {Kind: guided, Text: "Preserve the original error while adding operation context.\n\nBefore:\nif err != nil { return err }\n\nAfter:\nif err != nil { return fmt.Errorf(\"load profile: %w\", err) }"},
+ "defensive.unchecked-type-assertion": {Kind: guided, Text: "Use checked assertions or validation before consuming dynamic values.\n\nBefore:\nuser := value.(User)\n\nAfter:\nuser, ok := value.(User)\nif !ok { return fmt.Errorf(\"expected User\") }"},
+ "defensive.unsafe-numeric-conversion": {Kind: guided, Text: "Validate numeric bounds before narrowing or changing sign.\n\nBefore:\ncount32 := int32(count)\n\nAfter:\nif count > math.MaxInt32 { return fmt.Errorf(\"count too large\") }\ncount32 := int32(count)"},
+ "maintainability.public-surface-growth": {Kind: guided, Text: "Keep exported surface area intentional.\n\nBefore:\n// change exports helper only used internally\nexport function normalizeForInternalUse(...) { ... }\n\nAfter:\nfunction normalizeForInternalUse(...) { ... }\n// export only stable APIs with tests and docs"},
+ "maintainability.dependency-growth": {Kind: guided, Text: "Avoid unnecessary direct dependencies in changed files.\n\nBefore:\n// file imports a new SDK only for one small formatting branch\n\nAfter:\n// remove the dependency, pass the behavior in, or isolate the integration behind a small adapter"},
+ "maintainability.high-churn-hotspot": {Kind: guided, Text: "Treat high-churn complex files as risky change surfaces.\n\nBefore:\n// broad edit in a repeatedly changed complex file without focused tests\n\nAfter:\n// narrow the edit, reduce local branching where possible, and add regression tests around the touched behavior"},
+ "maintainability.repeat-defect-area": {Kind: guided, Text: "Add regression coverage before changing repeat-defect areas.\n\nBefore:\n// file has repeated fix/regression commits and the PR changes behavior directly\n\nAfter:\n// characterization or regression tests pin the historical failure mode, then the production change follows"},
+ "maintainability.unstable-interface": {Kind: guided, Text: "Keep unstable public interfaces compatible and well-tested.\n\nBefore:\n// exported API changes in a repeatedly churned interface file\n\nAfter:\n// compatibility preserved or migration path documented, with caller-facing tests updated"},
+ "maintainability.change-amplification": {Kind: guided, Text: "Reduce files that force broad follow-up edits.\n\nBefore:\n// changing one central file repeatedly requires several partner files to move too\n\nAfter:\n// extract a narrower boundary, centralize the policy, or intentionally update and test the coupled partners"},
+ "maintainability.hotspot": {Kind: guided, Text: "Handle maintainability hotspots with extra care.\n\nBefore:\n// high-churn or defect-prone file receives a broad behavior edit\n\nAfter:\n// smaller patch, targeted regression tests, and reviewer notes about the risky areas touched"},
+ "smell.shotgun-surgery-history": {Kind: guided, Text: "Consolidate responsibilities that scatter routine changes.\n\nBefore:\n// every pricing change touches parser.go, mapper.go, handler.go, and schema.go\n\nAfter:\n// shared pricing policy lives behind one boundary, with adapters kept thin and tested"},
+ "smell.divergent-change-history": {Kind: guided, Text: "Split files that change for many unrelated reasons.\n\nBefore:\n// one module changes for API, database, UI, and test concerns\n\nAfter:\n// separate modules own separate concerns so future changes touch only the relevant boundary"},
+
+ "testing.behavior-change-without-test": {Kind: guided, Text: "Add tests in the same diff for observable behavior changes.\n\nBefore:\n// production logic changed, no test files changed\n\nAfter:\n// production logic changed\n// matching unit, integration, or contract test asserts the new behavior"},
+ "testing.failure-path-missing": {Kind: guided, Text: "Exercise the new failure path explicitly.\n\nBefore:\nif err := client.Call(ctx); err != nil { return fallback }\n// only success-path tests exist\n\nAfter:\nif err := client.Call(ctx); err != nil { return fallback }\n// test injects client failure and asserts fallback/error behavior"},
+ "testing.hardwired-dependency": {Kind: guided, Text: "Inject dependencies that need deterministic tests.\n\nBefore:\nfunc Price(order Order) Money { return fetchRate(http.DefaultClient, order) }\n\nAfter:\ntype RateClient interface { Rate(context.Context, Order) (Money, error) }\nfunc Price(ctx context.Context, client RateClient, order Order) (Money, error) { return client.Rate(ctx, order) }"},
+ "testing.nondeterministic-domain-logic": {Kind: guided, Text: "Move nondeterministic reads to a boundary and pass values into domain logic.\n\nBefore:\nfunc Expired(token Token) bool { return time.Now().After(token.ExpiresAt) }\n\nAfter:\nfunc Expired(now time.Time, token Token) bool { return now.After(token.ExpiresAt) }"},
+ "testing.legacy-hotspot-uncovered": {Kind: guided, Text: "Characterize existing hotspot behavior before changing it.\n\nBefore:\n// high-churn legacy parser changed directly\n\nAfter:\n// characterization tests pin current parser behavior, then production code changes"},
+
+ "change.mixed-concerns": {Kind: guided, Text: "Split unrelated work or make the coupling explicit.\n\nBefore:\n// PR changes billing, auth middleware, dependency upgrades, and CSS\n\nAfter:\n// PR 1: billing behavior + tests\n// PR 2: auth middleware + tests\n// PR 3: dependency upgrades"},
+ "change.oversized-diff": {Kind: guided, Text: "Reduce review risk by shrinking the diff or adding focused verification notes.\n\nBefore:\n// 60 files, 14 directories, public APIs changed, few tests\n\nAfter:\n// mechanical move PR first, behavior PR second, targeted tests and review map included"},
+ "change.mixed-refactor-and-behavior": {Kind: guided, Text: "Separate behavior-preserving refactors from behavior changes.\n\nBefore:\n// rename service, move files, and change validation result in one PR\n\nAfter:\n// PR 1: rename/move only with unchanged tests\n// PR 2: validation behavior change with tests"},
+ "change.too-many-concerns": {Kind: guided, Text: "Split the PR around one reviewable concern.\n\nBefore:\n// storage migration, API behavior, UI copy, and cleanup together\n\nAfter:\n// one PR per storage/API/UI/cleanup concern or a documented atomic rollout plan"},
+ "change.unnecessary-surface-area": {Kind: guided, Text: "Limit edits to the smallest surface that implements the behavior.\n\nBefore:\n// feature changes interfaces across unrelated packages\n\nAfter:\n// feature changes one boundary and leaves unrelated packages untouched"},
+ "change.one-use-abstraction": {Kind: guided, Text: "Avoid abstractions that do not simplify or isolate policy.\n\nBefore:\ntype UserGetter interface { GetUser(id string) User }\n// one implementation, one caller, pass-through method\n\nAfter:\n// call concrete collaborator directly until another meaningful consumer appears"},
+ "change.duplicate-helper": {Kind: guided, Text: "Reuse the existing project helper or consolidate the duplicate.\n\nBefore:\nfunc normalizeEmailAddress(v string) string { ... }\n// existing canonicalizeEmail already exists\n\nAfter:\nfunc normalizeEmailAddress(v string) string { return canonicalizeEmail(v) }\n// or replace callers with the established helper"},
+ "change.cleanup-regression": {Kind: guided, Text: "Make cleanup diffs improve measured maintainability.\n\nBefore:\n// cleanup PR adds branches, wrappers, and duplicate paths\n\nAfter:\n// cleanup PR reduces complexity/duplication or documents why the temporary regression is needed"},
+ "change.complexity-increased": {Kind: guided, Text: "Reduce newly added complexity before merging.\n\nBefore:\nif a { if b { if c { ... } } }\n\nAfter:\nif !validRequest(req) { return err }\nreturn processValidRequest(req)"},
+ "change.move-without-verification": {Kind: guided, Text: "Preserve behavior evidence when moving code.\n\nBefore:\n// files moved, tests deleted or not run\n\nAfter:\n// tests moved with the code and narrow verification command is documented/run"},
+
+ "refactor.behavior-change-detected": {Kind: guided, Text: "Keep refactor-only diffs behavior-preserving.\n\nBefore:\n// PR labeled refactor removes an authorization branch\n\nAfter:\n// refactor PR preserves authorization; behavior change moves to a separate tested PR"},
+ "refactor.public-contract-changed": {Kind: guided, Text: "Avoid public contract changes in refactor-only work.\n\nBefore:\nfunc GetUser(id string) User\n// changed to return (*User, error) in refactor PR\n\nAfter:\n// refactor keeps signature stable or ships a separate compatibility-tested contract PR"},
+ "refactor.test-coverage-reduced": {Kind: guided, Text: "Move tests with refactored behavior instead of weakening coverage.\n\nBefore:\n// old package tests deleted after extraction\n\nAfter:\n// extracted package has equivalent tests and old integration coverage still passes"},
+ "refactor.error-path-changed": {Kind: guided, Text: "Preserve error behavior during refactors.\n\nBefore:\nif err != nil { return nil }\n// refactor changed returned error to silent success\n\nAfter:\nif err != nil { return fmt.Errorf(\"load profile: %w\", err) }"},
+ "refactor.side-effect-order-changed": {Kind: guided, Text: "Keep side-effect order stable unless the PR is a tested behavior change.\n\nBefore:\nwriteDB(); publishEvent(); checkAuthorization()\n\nAfter:\ncheckAuthorization(); writeDB(); publishEvent()\n// with tests and rollout notes if ordering intentionally changed"},
+ "refactor.visibility-expanded": {Kind: guided, Text: "Keep symbols private unless a real external consumer exists.\n\nBefore:\nfunc computePrice(...) Money\n// changed to exported ComputePrice during refactor\n\nAfter:\nfunc computePrice(...) Money\n// or export only with documented cross-package consumer"},
+ "refactor.dependency-direction-worsened": {Kind: guided, Text: "Preserve architectural dependency direction while refactoring.\n\nBefore:\n// domain package imports http/sql/framework package after extraction\n\nAfter:\n// domain owns an interface; infrastructure implements it outward"},
+ "refactor.duplicate-implementation-left-behind": {Kind: guided, Text: "Remove or delegate old implementations after extraction.\n\nBefore:\nfunc calculateTotalV1(...) Money { ... }\nfunc calculateTotal(...) Money { ...same behavior... }\n\nAfter:\nfunc calculateTotal(...) Money { ... }\n// old callers migrated or old path delegates to the new implementation temporarily"},
+ "refactor.dead-path-left-behind": {Kind: guided, Text: "Delete obsolete paths or attach an owner and expiry.\n\nBefore:\nif oldFlowEnabled { oldFlow() } else { newFlow() }\n// oldFlow has no callers or rollout plan\n\nAfter:\nnewFlow()\n// or TODO(owner, date): remove oldFlow after migration metric reaches zero"},
+}
diff --git a/internal/codeguard/runner/checks/registry.go b/internal/codeguard/runner/checks/registry.go
index e8b1106..53a928e 100644
--- a/internal/codeguard/runner/checks/registry.go
+++ b/internal/codeguard/runner/checks/registry.go
@@ -4,6 +4,7 @@ import (
"context"
agentContextCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/agentcontext"
+ changeCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/change"
ciCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/ci"
contractsCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/contracts"
dataCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/data"
@@ -40,6 +41,16 @@ type sectionDef struct {
// scan result. Build iterates this slice and, for each enabled section, calls
// through the safeRun panic-recovery wrapper.
var sectionRegistry = []sectionDef{
+ {
+ id: "change",
+ name: "Change Safety",
+ enabled: func(sc runnersupport.Context) bool {
+ return sc.Cfg.Checks.Change != nil && *sc.Cfg.Checks.Change
+ },
+ run: func(ctx context.Context, _ runnersupport.Context, checkEnv checkSupport.Context) core.SectionResult {
+ return changeCheck.Run(ctx, checkEnv)
+ },
+ },
{
id: "quality",
name: "Quality",
diff --git a/internal/codeguard/runner/pr_summary.go b/internal/codeguard/runner/pr_summary.go
index e496a8d..08a6d8a 100644
--- a/internal/codeguard/runner/pr_summary.go
+++ b/internal/codeguard/runner/pr_summary.go
@@ -9,20 +9,24 @@ import (
)
func addPRSummaryArtifact(sc runnersupport.Context, sections []core.SectionResult) {
- cfg := sc.Cfg.Checks.ProductionRisk
- if cfg.Enabled == nil || !*cfg.Enabled || sc.Opts.Mode != core.ScanModeDiff {
+ if sc.Opts.Mode != core.ScanModeDiff {
return
}
- metric := productionRiskMetric(cfg, sections)
- if metric == nil {
+ summary := core.PRSummaryArtifact{}
+ cfg := sc.Cfg.Checks.ProductionRisk
+ if cfg.Enabled != nil && *cfg.Enabled {
+ summary.ProductionRisk = productionRiskMetric(cfg, sections)
+ }
+ summary.ChangeSafety = findingFamilyMetric(sections, changeSafetyMetricRules())
+ summary.MaintainabilityDelta = findingFamilyMetric(sections, maintainabilityMetricRules())
+ summary.RefactorConfidence = findingFamilyMetric(sections, refactorConfidenceMetricRules())
+ if summary.ProductionRisk == nil && summary.ChangeSafety == nil && summary.MaintainabilityDelta == nil && summary.RefactorConfidence == nil {
return
}
sc.Artifacts.Put(core.Artifact{
- ID: "pr_summary",
- Kind: core.ReportArtifactKindPRSummary,
- PRSummary: &core.PRSummaryArtifact{
- ProductionRisk: metric,
- },
+ ID: "pr_summary",
+ Kind: core.ReportArtifactKindPRSummary,
+ PRSummary: &summary,
})
}
@@ -117,3 +121,159 @@ func productionRiskLevel(score int, cfg core.ProductionRiskConfig) string {
return "pass"
}
}
+
+type prSummaryMetricRule struct {
+ Label string
+ Detail string
+ Match func(core.Finding) bool
+}
+
+func findingFamilyMetric(sections []core.SectionResult, rules []prSummaryMetricRule) *core.PRSummaryMetric {
+ componentsByLabel := map[string]*core.PRSummaryComponent{}
+ for _, section := range sections {
+ for _, finding := range section.Findings {
+ rule, ok := matchPRSummaryMetricRule(finding, rules)
+ if !ok {
+ continue
+ }
+ weight := prSummaryFindingWeight(finding)
+ if weight == 0 {
+ continue
+ }
+ component := componentsByLabel[rule.Label]
+ if component == nil {
+ component = &core.PRSummaryComponent{
+ Label: rule.Label,
+ Weight: weight,
+ Detail: rule.Detail,
+ }
+ componentsByLabel[rule.Label] = component
+ }
+ component.Count++
+ component.Contribution += weight
+ if weight > component.Weight {
+ component.Weight = weight
+ }
+ }
+ }
+ if len(componentsByLabel) == 0 {
+ return nil
+ }
+ components := make([]core.PRSummaryComponent, 0, len(componentsByLabel))
+ total := 0
+ for _, component := range componentsByLabel {
+ components = append(components, *component)
+ total += component.Contribution
+ }
+ sortPRSummaryComponents(components)
+ score := minRiskScore(total)
+ return &core.PRSummaryMetric{
+ Score: score,
+ Level: prSummaryRiskLevel(score),
+ Components: components,
+ }
+}
+
+func matchPRSummaryMetricRule(finding core.Finding, rules []prSummaryMetricRule) (prSummaryMetricRule, bool) {
+ for _, rule := range rules {
+ if rule.Match(finding) {
+ return rule, true
+ }
+ }
+ return prSummaryMetricRule{}, false
+}
+
+func prSummaryFindingWeight(finding core.Finding) int {
+ weight := 0
+ switch strings.ToLower(finding.Level) {
+ case "fail", "error":
+ weight = 35
+ case "warn", "warning":
+ weight = 18
+ default:
+ weight = 10
+ }
+ switch strings.ToLower(finding.Confidence) {
+ case "high":
+ weight += 5
+ case "low":
+ weight -= 5
+ }
+ if weight < 0 {
+ return 0
+ }
+ return weight
+}
+
+func prSummaryRiskLevel(score int) string {
+ switch {
+ case score >= 70:
+ return "fail"
+ case score >= 35:
+ return "warn"
+ default:
+ return "pass"
+ }
+}
+
+func sortPRSummaryComponents(components []core.PRSummaryComponent) {
+ sort.Slice(components, func(i, j int) bool {
+ if components[i].Contribution != components[j].Contribution {
+ return components[i].Contribution > components[j].Contribution
+ }
+ if components[i].Label != components[j].Label {
+ return components[i].Label < components[j].Label
+ }
+ return components[i].Detail < components[j].Detail
+ })
+}
+
+func changeSafetyMetricRules() []prSummaryMetricRule {
+ return []prSummaryMetricRule{
+ {Label: "change_scope", Detail: "change concentration, size, or concern-mixing findings", Match: ruleIDHasPrefix("change.")},
+ {Label: "test_evidence", Detail: "testability findings in changed behavior", Match: ruleIDHasPrefix("testing.")},
+ }
+}
+
+func maintainabilityMetricRules() []prSummaryMetricRule {
+ return []prSummaryMetricRule{
+ {Label: "maintainability", Detail: "maintainability delta findings in changed code", Match: ruleIDHasPrefix("maintainability.")},
+ {Label: "code_quality", Detail: "local quality findings that affect maintainability", Match: ruleIDHasAnyPrefix("quality.", "smell.", "naming.", "function.")},
+ {Label: "defensive_programming", Detail: "error-handling or defensive-programming findings in changed code", Match: ruleIDHasAnyPrefix("error.", "defensive.")},
+ }
+}
+
+func refactorConfidenceMetricRules() []prSummaryMetricRule {
+ return []prSummaryMetricRule{
+ {Label: "behavior_preservation", Detail: "refactor findings that indicate observable behavior may have changed", Match: ruleIDHasPrefix("refactor.")},
+ {Label: "mixed_refactor", Detail: "change findings that weaken refactor confidence", Match: ruleIDEquals("change.mixed-refactor-and-behavior", "change.move-without-verification")},
+ }
+}
+
+func ruleIDHasPrefix(prefix string) func(core.Finding) bool {
+ return func(finding core.Finding) bool {
+ return strings.HasPrefix(finding.RuleID, prefix)
+ }
+}
+
+func ruleIDHasAnyPrefix(prefixes ...string) func(core.Finding) bool {
+ return func(finding core.Finding) bool {
+ for _, prefix := range prefixes {
+ if strings.HasPrefix(finding.RuleID, prefix) {
+ return true
+ }
+ }
+ return false
+ }
+}
+
+func ruleIDEquals(ids ...string) func(core.Finding) bool {
+ return func(finding core.Finding) bool {
+ for _, id := range ids {
+ if finding.RuleID == id {
+ return true
+ }
+ }
+ return false
+ }
+}
diff --git a/internal/codeguard/runner/pr_summary_test.go b/internal/codeguard/runner/pr_summary_test.go
index 90a4d63..8986a60 100644
--- a/internal/codeguard/runner/pr_summary_test.go
+++ b/internal/codeguard/runner/pr_summary_test.go
@@ -1,9 +1,11 @@
package runner
import (
+ "bytes"
"testing"
"github.com/devr-tools/codeguard/internal/codeguard/core"
+ "github.com/devr-tools/codeguard/internal/codeguard/report"
runnersupport "github.com/devr-tools/codeguard/internal/codeguard/runner/support"
)
@@ -47,6 +49,88 @@ func TestAddPRSummaryArtifactScoresProductionRisk(t *testing.T) {
}
}
+func TestAddPRSummaryArtifactAddsChangeSafetyMetrics(t *testing.T) {
+ enabled := true
+ sc := runnersupport.Context{
+ Opts: core.ScanOptions{Mode: core.ScanModeDiff},
+ Cfg: core.Config{Checks: core.CheckConfig{ProductionRisk: core.ProductionRiskConfig{
+ Enabled: &enabled, WarnThreshold: 35, FailThreshold: 70,
+ ReliabilityWeight: 12, DataWeight: 15, FailWeight: 25, WarnWeight: 10,
+ }}},
+ Artifacts: runnersupport.NewArtifactStore(),
+ }
+ sections := []core.SectionResult{
+ {Name: "Change Safety", Findings: []core.Finding{
+ {RuleID: "change.oversized-diff", Level: "warn", Confidence: "high", Path: "b.go"},
+ {RuleID: "testing.behavior-change-without-test", Level: "fail", Confidence: "high", Path: "a.go"},
+ {RuleID: "change.mixed-refactor-and-behavior", Level: "warn", Path: "c.go"},
+ }},
+ {Name: "Maintainability", Findings: []core.Finding{
+ {RuleID: "maintainability.public-surface-growth", Level: "warn", Path: "api.go"},
+ {RuleID: "function.excessive-parameters", Level: "warn", Confidence: "low", Path: "service.go"},
+ {RuleID: "defensive.unchecked-type-assertion", Level: "fail", Path: "types.go"},
+ }},
+ {Name: "Refactors", Findings: []core.Finding{
+ {RuleID: "refactor.behavior-change-detected", Level: "fail", Confidence: "high", Path: "refactor.go"},
+ }},
+ {Name: "Reliability", Findings: []core.Finding{
+ {RuleID: "reliability.missing-timeout", Level: "fail", Path: "client.go"},
+ }},
+ }
+
+ addPRSummaryArtifact(sc, sections)
+
+ artifact := requirePRSummaryArtifact(t, sc.Artifacts.List())
+ if artifact.ProductionRisk == nil {
+ t.Fatal("expected production risk metric to be preserved")
+ }
+ if artifact.ChangeSafety == nil {
+ t.Fatal("expected change safety metric")
+ }
+ if artifact.ChangeSafety.Score != 81 {
+ t.Fatalf("change safety score = %d, want 81", artifact.ChangeSafety.Score)
+ }
+ if artifact.ChangeSafety.Level != "fail" {
+ t.Fatalf("change safety level = %q, want fail", artifact.ChangeSafety.Level)
+ }
+ if labels := componentLabels(artifact.ChangeSafety.Components); labels != "change_scope,test_evidence" {
+ t.Fatalf("change safety component labels = %q, want deterministic contribution order", labels)
+ }
+ if artifact.MaintainabilityDelta == nil {
+ t.Fatal("expected maintainability delta metric")
+ }
+ if labels := componentLabels(artifact.MaintainabilityDelta.Components); labels != "defensive_programming,maintainability,code_quality" {
+ t.Fatalf("maintainability component labels = %q, want deterministic contribution order", labels)
+ }
+ if artifact.RefactorConfidence == nil {
+ t.Fatal("expected refactor confidence metric")
+ }
+ if artifact.RefactorConfidence.Score != 58 {
+ t.Fatalf("refactor confidence score = %d, want 58", artifact.RefactorConfidence.Score)
+ }
+ if labels := componentLabels(artifact.RefactorConfidence.Components); labels != "behavior_preservation,mixed_refactor" {
+ t.Fatalf("refactor confidence component labels = %q, want deterministic contribution order", labels)
+ }
+}
+
+func TestAddPRSummaryArtifactPublishesChangeMetricsWithoutProductionRisk(t *testing.T) {
+ sc := runnersupport.Context{
+ Opts: core.ScanOptions{Mode: core.ScanModeDiff},
+ Cfg: core.Config{Checks: core.CheckConfig{}},
+ Artifacts: runnersupport.NewArtifactStore(),
+ }
+
+ addPRSummaryArtifact(sc, []core.SectionResult{{Findings: []core.Finding{{RuleID: "change.oversized-diff", Level: "warn"}}}})
+
+ artifact := requirePRSummaryArtifact(t, sc.Artifacts.List())
+ if artifact.ProductionRisk != nil {
+ t.Fatalf("production risk metric = %#v, want nil when disabled", artifact.ProductionRisk)
+ }
+ if artifact.ChangeSafety == nil {
+ t.Fatal("expected change safety metric")
+ }
+}
+
func TestAddPRSummaryArtifactSkipsFullScans(t *testing.T) {
enabled := true
sc := runnersupport.Context{
@@ -62,6 +146,37 @@ func TestAddPRSummaryArtifactSkipsFullScans(t *testing.T) {
}
}
+func TestPRSummaryMetricsAreArtifactOnlyForGitHubAnnotations(t *testing.T) {
+ reportData := core.Report{
+ Name: "sample",
+ Artifacts: []core.Artifact{{
+ ID: "pr_summary",
+ Kind: core.ReportArtifactKindPRSummary,
+ PRSummary: &core.PRSummaryArtifact{
+ ChangeSafety: &core.PRSummaryMetric{
+ Score: 18,
+ Level: "pass",
+ Components: []core.PRSummaryComponent{{
+ Label: "change_scope",
+ Weight: 18,
+ Count: 1,
+ Contribution: 18,
+ }},
+ },
+ },
+ }},
+ Sections: []core.SectionResult{{Name: "Change Safety"}},
+ }
+
+ var out bytes.Buffer
+ if err := report.Write(&out, reportData, "github"); err != nil {
+ t.Fatalf("write github report: %v", err)
+ }
+ if bytes.Contains(out.Bytes(), []byte("change_safety")) || bytes.Contains(out.Bytes(), []byte("pr_summary")) {
+ t.Fatalf("github annotations included metrics artifact:\n%s", out.String())
+ }
+}
+
func requirePRSummaryArtifact(t *testing.T, artifacts []core.Artifact) *core.PRSummaryArtifact {
t.Helper()
for _, artifact := range artifacts {
@@ -75,3 +190,14 @@ func requirePRSummaryArtifact(t *testing.T, artifacts []core.Artifact) *core.PRS
t.Fatalf("pr_summary artifact not found: %#v", artifacts)
return nil
}
+
+func componentLabels(components []core.PRSummaryComponent) string {
+ labels := make([]byte, 0, len(components)*16)
+ for i, component := range components {
+ if i > 0 {
+ labels = append(labels, ',')
+ }
+ labels = append(labels, component.Label...)
+ }
+ return string(labels)
+}
diff --git a/pkg/codeguard/sdk_types_config_checks.go b/pkg/codeguard/sdk_types_config_checks.go
index cb7d6c6..f89125d 100644
--- a/pkg/codeguard/sdk_types_config_checks.go
+++ b/pkg/codeguard/sdk_types_config_checks.go
@@ -10,6 +10,7 @@ type CIRulesConfig = core.CIRulesConfig
type SupplyChainRulesConfig = core.SupplyChainRulesConfig
type ReliabilityRulesConfig = core.ReliabilityRulesConfig
type DataRulesConfig = core.DataRulesConfig
+type ChangeRulesConfig = core.ChangeRulesConfig
type ProductionRiskConfig = core.ProductionRiskConfig
type ContractRulesConfig = core.ContractRulesConfig
type ContextRulesConfig = core.ContextRulesConfig
diff --git a/tests/checks/change_smells_test.go b/tests/checks/change_smells_test.go
new file mode 100644
index 0000000..9f9b2d3
--- /dev/null
+++ b/tests/checks/change_smells_test.go
@@ -0,0 +1,271 @@
+package checks_test
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/devr-tools/codeguard/pkg/codeguard"
+)
+
+func changeSmellQuietConfig(name string, dir string) codeguard.Config {
+ cfg := changeSafetyTestConfig(name, dir)
+ cfg.Checks.ChangeRules.MaxChangedFiles = 100
+ cfg.Checks.ChangeRules.MaxChangedDirectories = 100
+ cfg.Checks.ChangeRules.MaxChangedLines = 5000
+ cfg.Checks.ChangeRules.MaxPublicInterfacesChanged = 100
+ cfg.Checks.ChangeRules.MaxConcernFamilies = 100
+ cfg.Checks.ChangeRules.MinTestToProductionRatioPercent = 0
+ off := false
+ cfg.Checks.ChangeRules.DetectBehaviorChangeWithoutTest = &off
+ cfg.Checks.ChangeRules.DetectFailurePathMissing = &off
+ cfg.Checks.ChangeRules.DetectHardwiredDependency = &off
+ cfg.Checks.ChangeRules.DetectNondeterministicDomain = &off
+ return cfg
+}
+
+func TestChangeOneUseAbstractionDetectsGoInterface(t *testing.T) {
+ dir := initChangeRepo(t)
+ writeFile(t, filepath.Join(dir, "service", "payment.go"), "package service\n\nfunc Charge() error { return nil }\n")
+ commitAll(t, dir, "base")
+
+ writeFile(t, filepath.Join(dir, "service", "payment.go"), strings.Join([]string{
+ "package service",
+ "",
+ "type PaymentGateway interface {",
+ "\tCharge() error",
+ "}",
+ "",
+ "func NewPayment(gateway PaymentGateway) error {",
+ "\treturn gateway.Charge()",
+ "}",
+ "",
+ }, "\n"))
+
+ report := runChangeDiff(t, changeSmellQuietConfig("change-one-use-go", dir))
+ assertFindingRulePresent(t, report, "Change Safety", "change.one-use-abstraction")
+}
+
+func TestChangeOneUseAbstractionAllowsMultipleConsumers(t *testing.T) {
+ dir := initChangeRepo(t)
+ writeFile(t, filepath.Join(dir, "service", "payment.go"), "package service\n\nfunc Charge() error { return nil }\n")
+ commitAll(t, dir, "base")
+
+ writeFile(t, filepath.Join(dir, "service", "payment.go"), strings.Join([]string{
+ "package service",
+ "",
+ "type PaymentGateway interface {",
+ "\tCharge() error",
+ "}",
+ "",
+ "func NewPayment(gateway PaymentGateway) error {",
+ "\treturn gateway.Charge()",
+ "}",
+ "",
+ "func RetryPayment(primary PaymentGateway, fallback PaymentGateway) error {",
+ "\tif err := primary.Charge(); err != nil {",
+ "\t\treturn fallback.Charge()",
+ "\t}",
+ "\treturn nil",
+ "}",
+ "",
+ }, "\n"))
+
+ report := runChangeDiff(t, changeSmellQuietConfig("change-one-use-negative", dir))
+ assertFindingRuleAbsent(t, report, "Change Safety", "change.one-use-abstraction")
+}
+
+func TestChangeOneUseAbstractionDetectsTypeScriptInterface(t *testing.T) {
+ dir := initChangeRepo(t)
+ writeFile(t, filepath.Join(dir, "src", "billing.ts"), "export function charge() { return true }\n")
+ commitAll(t, dir, "base")
+
+ writeFile(t, filepath.Join(dir, "src", "billing.ts"), strings.Join([]string{
+ "export interface BillingGateway {",
+ " charge(): boolean",
+ "}",
+ "",
+ "export function chargeWith(gateway: BillingGateway) {",
+ " return gateway.charge()",
+ "}",
+ "",
+ }, "\n"))
+
+ report := runChangeDiff(t, changeSmellQuietConfig("change-one-use-ts", dir))
+ assertFindingRulePresent(t, report, "Change Safety", "change.one-use-abstraction")
+}
+
+func TestChangeDuplicateHelperDetectsGoDuplicate(t *testing.T) {
+ dir := initChangeRepo(t)
+ writeFile(t, filepath.Join(dir, "service", "email.go"), strings.Join([]string{
+ "package service",
+ "",
+ "import \"strings\"",
+ "",
+ "func canonicalizeEmail(value string) string {",
+ "\ttrimmed := strings.TrimSpace(value)",
+ "\tlower := strings.ToLower(trimmed)",
+ "\treturn lower",
+ "}",
+ "",
+ }, "\n"))
+ commitAll(t, dir, "base")
+
+ writeFile(t, filepath.Join(dir, "service", "email_new.go"), strings.Join([]string{
+ "package service",
+ "",
+ "import \"strings\"",
+ "",
+ "func normalizeEmail(value string) string {",
+ "\ttrimmed := strings.TrimSpace(value)",
+ "\tlower := strings.ToLower(trimmed)",
+ "\treturn lower",
+ "}",
+ "",
+ }, "\n"))
+ runGit(t, dir, "add", "-N", "service/email_new.go")
+
+ report := runChangeDiff(t, changeSmellQuietConfig("change-duplicate-go", dir))
+ assertFindingRulePresent(t, report, "Change Safety", "change.duplicate-helper")
+}
+
+func TestChangeDuplicateHelperAllowsDifferentLogic(t *testing.T) {
+ dir := initChangeRepo(t)
+ writeFile(t, filepath.Join(dir, "service", "email.go"), strings.Join([]string{
+ "package service",
+ "",
+ "import \"strings\"",
+ "",
+ "func canonicalizeEmail(value string) string {",
+ "\ttrimmed := strings.TrimSpace(value)",
+ "\tlower := strings.ToLower(trimmed)",
+ "\treturn lower",
+ "}",
+ "",
+ }, "\n"))
+ commitAll(t, dir, "base")
+
+ writeFile(t, filepath.Join(dir, "service", "email_new.go"), strings.Join([]string{
+ "package service",
+ "",
+ "import \"strings\"",
+ "",
+ "func normalizeDisplayName(value string) string {",
+ "\ttrimmed := strings.TrimSpace(value)",
+ "\treturn strings.ReplaceAll(trimmed, \"_\", \" \")",
+ "}",
+ "",
+ }, "\n"))
+ runGit(t, dir, "add", "-N", "service/email_new.go")
+
+ report := runChangeDiff(t, changeSmellQuietConfig("change-duplicate-negative", dir))
+ assertFindingRuleAbsent(t, report, "Change Safety", "change.duplicate-helper")
+}
+
+func TestChangeDuplicateHelperDetectsTypeScriptDuplicate(t *testing.T) {
+ dir := initChangeRepo(t)
+ writeFile(t, filepath.Join(dir, "src", "email.ts"), strings.Join([]string{
+ "export function canonicalizeEmail(value: string) {",
+ " const trimmed = value.trim()",
+ " const lower = trimmed.toLowerCase()",
+ " return lower",
+ "}",
+ "",
+ }, "\n"))
+ commitAll(t, dir, "base")
+
+ writeFile(t, filepath.Join(dir, "src", "email_new.ts"), strings.Join([]string{
+ "export function normalizeEmail(value: string) {",
+ " const trimmed = value.trim()",
+ " const lower = trimmed.toLowerCase()",
+ " return lower",
+ "}",
+ "",
+ }, "\n"))
+ runGit(t, dir, "add", "-N", "src/email_new.ts")
+
+ report := runChangeDiff(t, changeSmellQuietConfig("change-duplicate-ts", dir))
+ assertFindingRulePresent(t, report, "Change Safety", "change.duplicate-helper")
+}
+
+func TestChangeComplexityIncreasedDetectsPythonBranchGrowth(t *testing.T) {
+ dir := initChangeRepo(t)
+ writeFile(t, filepath.Join(dir, "service", "pricing.py"), strings.Join([]string{
+ "def price(order):",
+ " return order.total",
+ "",
+ }, "\n"))
+ commitAll(t, dir, "base")
+
+ writeFile(t, filepath.Join(dir, "service", "pricing.py"), strings.Join([]string{
+ "def price(order):",
+ " if order.vip:",
+ " return order.total * 0.9",
+ " if order.country == 'AU':",
+ " return order.total + order.tax",
+ " return order.total",
+ "",
+ }, "\n"))
+
+ report := runChangeDiff(t, changeSmellQuietConfig("change-complexity-python", dir))
+ assertFindingRulePresent(t, report, "Change Safety", "change.complexity-increased")
+}
+
+func TestChangeComplexityIncreasedAllowsLinearEdit(t *testing.T) {
+ dir := initChangeRepo(t)
+ writeFile(t, filepath.Join(dir, "service", "pricing.go"), "package service\n\nfunc Price(total int) int {\n\treturn total\n}\n")
+ commitAll(t, dir, "base")
+
+ writeFile(t, filepath.Join(dir, "service", "pricing.go"), "package service\n\nfunc Price(total int) int {\n\tdiscounted := total - 1\n\treturn discounted\n}\n")
+
+ report := runChangeDiff(t, changeSmellQuietConfig("change-complexity-negative", dir))
+ assertFindingRuleAbsent(t, report, "Change Safety", "change.complexity-increased")
+}
+
+func TestChangeCleanupRegressionDetectsClaimedCleanupComplexityGrowth(t *testing.T) {
+ dir := initChangeRepo(t)
+ writeFile(t, filepath.Join(dir, "service", "cleanup.go"), "package service\n\nfunc Route(kind string) string {\n\treturn \"default\"\n}\n")
+ commitAll(t, dir, "base")
+
+ writeFile(t, filepath.Join(dir, "service", "cleanup.go"), strings.Join([]string{
+ "package service",
+ "",
+ "func Route(kind string, admin bool) string {",
+ "\tif admin {",
+ "\t\treturn \"admin\"",
+ "\t}",
+ "\tif kind == \"vip\" {",
+ "\t\treturn \"vip\"",
+ "\t}",
+ "\treturn \"default\"",
+ "}",
+ "",
+ }, "\n"))
+
+ report := runChangeDiff(t, changeSmellQuietConfig("cleanup-refactor-regression", dir))
+ assertFindingRulePresent(t, report, "Change Safety", "change.cleanup-regression")
+}
+
+func TestChangeCleanupRegressionRequiresCleanupClaim(t *testing.T) {
+ dir := initChangeRepo(t)
+ writeFile(t, filepath.Join(dir, "service", "route.go"), "package service\n\nfunc Route(kind string) string {\n\treturn \"default\"\n}\n")
+ commitAll(t, dir, "base")
+
+ writeFile(t, filepath.Join(dir, "service", "route.go"), strings.Join([]string{
+ "package service",
+ "",
+ "func Route(kind string, admin bool) string {",
+ "\tif admin {",
+ "\t\treturn \"admin\"",
+ "\t}",
+ "\tif kind == \"vip\" {",
+ "\t\treturn \"vip\"",
+ "\t}",
+ "\treturn \"default\"",
+ "}",
+ "",
+ }, "\n"))
+
+ report := runChangeDiff(t, changeSmellQuietConfig("feature-route-change", dir))
+ assertFindingRuleAbsent(t, report, "Change Safety", "change.cleanup-regression")
+}
diff --git a/tests/checks/change_test.go b/tests/checks/change_test.go
new file mode 100644
index 0000000..2ef4815
--- /dev/null
+++ b/tests/checks/change_test.go
@@ -0,0 +1,223 @@
+package checks_test
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/devr-tools/codeguard/pkg/codeguard"
+)
+
+func changeSafetyTestConfig(name string, dir string) codeguard.Config {
+ cfg := codeguard.ExampleConfig()
+ cfg.Name = name
+ cfg.Targets = []codeguard.TargetConfig{{Name: "repo", Path: dir, Language: "go"}}
+ cfg.Checks.Quality = false
+ cfg.Checks.Design = false
+ cfg.Checks.Security = false
+ cfg.Checks.Prompts = false
+ cfg.Checks.CI = false
+ cfg.Checks.SupplyChain = false
+ on := true
+ off := false
+ cfg.Checks.Reliability = &off
+ cfg.Checks.Data = &off
+ cfg.Checks.Change = &on
+ cfg.Checks.Contracts = &off
+ cfg.Checks.Context = &off
+ cfg.Cache.Enabled = &off
+ return cfg
+}
+
+func runChangeDiff(t *testing.T, cfg codeguard.Config) codeguard.Report {
+ t.Helper()
+ report, err := codeguard.RunWithOptions(context.Background(), cfg, codeguard.ScanOptions{
+ Mode: codeguard.ScanModeDiff,
+ BaseRef: "main",
+ })
+ if err != nil {
+ t.Fatalf("change diff scan: %v", err)
+ }
+ return report
+}
+
+func initChangeRepo(t *testing.T) string {
+ t.Helper()
+ return initContractsRepo(t)
+}
+
+func TestChangeOversizedDiffUsesConfiguredThresholds(t *testing.T) {
+ dir := initChangeRepo(t)
+ for _, rel := range []string{
+ "service/a.go",
+ "service/b.go",
+ "workers/c.go",
+ } {
+ writeFile(t, filepath.Join(dir, rel), "package sample\n\nfunc Value() int { return 1 }\n")
+ }
+ commitAll(t, dir, "base")
+
+ for _, rel := range []string{
+ "service/a.go",
+ "service/b.go",
+ "workers/c.go",
+ } {
+ writeFile(t, filepath.Join(dir, rel), "package sample\n\nfunc Value() int {\n\treturn 2\n}\n")
+ }
+
+ cfg := changeSafetyTestConfig("change-oversized", dir)
+ cfg.Checks.ChangeRules.MaxChangedFiles = 2
+ cfg.Checks.ChangeRules.MaxChangedDirectories = 1
+ cfg.Checks.ChangeRules.MaxChangedLines = 2
+ cfg.Checks.ChangeRules.MinTestToProductionRatioPercent = 50
+
+ report := runChangeDiff(t, cfg)
+ assertFindingRulePresent(t, report, "Change Safety", "change.oversized-diff")
+ finding := changeRuleFinding(t, report, "change.oversized-diff")
+ if finding.Metadata["files_touched"] != "3" {
+ t.Fatalf("files_touched metadata = %q, want 3", finding.Metadata["files_touched"])
+ }
+ if finding.Metadata["directories_touched"] != "2" {
+ t.Fatalf("directories_touched metadata = %q, want 2", finding.Metadata["directories_touched"])
+ }
+ if finding.Metadata["test_to_production_ratio_percent"] != "0" {
+ t.Fatalf("ratio metadata = %q, want 0", finding.Metadata["test_to_production_ratio_percent"])
+ }
+}
+
+func TestChangeFocusedDiffWithTestsPasses(t *testing.T) {
+ dir := initChangeRepo(t)
+ writeFile(t, filepath.Join(dir, "service", "price.go"), "package service\n\nfunc Price() int { return 1 }\n")
+ writeFile(t, filepath.Join(dir, "service", "price_test.go"), "package service\n\nfunc TestPrice(t *testing.T) {}\n")
+ commitAll(t, dir, "base")
+
+ writeFile(t, filepath.Join(dir, "service", "price.go"), "package service\n\nfunc Price() int { return 2 }\n")
+ writeFile(t, filepath.Join(dir, "service", "price_test.go"), "package service\n\nfunc TestPrice(t *testing.T) { if Price() != 2 { t.Fatal() } }\n")
+
+ report := runChangeDiff(t, changeSafetyTestConfig("change-focused", dir))
+ assertSectionStatus(t, report, "Change Safety", "pass")
+}
+
+func TestChangeDetectsMixedAndTooManyConcerns(t *testing.T) {
+ dir := initChangeRepo(t)
+ fixtures := map[string]string{
+ "api/handler.go": "package api\n\nfunc Handle() int { return 1 }\n",
+ "db/store.go": "package db\n\nfunc Store() int { return 1 }\n",
+ "ui/view.tsx": "export function View() { return 1 }\n",
+ "infra/deploy.go": "package infra\n\nfunc Deploy() int { return 1 }\n",
+ }
+ for rel, content := range fixtures {
+ writeFile(t, filepath.Join(dir, rel), content)
+ }
+ commitAll(t, dir, "base")
+ for rel, content := range fixtures {
+ writeFile(t, filepath.Join(dir, rel), strings.Replace(content, "return 1", "return 2", 1))
+ }
+
+ cfg := changeSafetyTestConfig("change-concerns", dir)
+ cfg.Checks.ChangeRules.MaxChangedFiles = 20
+ cfg.Checks.ChangeRules.MaxChangedDirectories = 20
+ cfg.Checks.ChangeRules.MaxChangedLines = 100
+ cfg.Checks.ChangeRules.MaxPublicInterfacesChanged = 20
+ cfg.Checks.ChangeRules.MaxConcernFamilies = 2
+ cfg.Checks.ChangeRules.MinTestToProductionRatioPercent = 0
+
+ report := runChangeDiff(t, cfg)
+ assertFindingRulePresent(t, report, "Change Safety", "change.mixed-concerns")
+ assertFindingRulePresent(t, report, "Change Safety", "change.too-many-concerns")
+}
+
+func TestChangeDetectsMoveMixedWithBehaviorAndNoVerification(t *testing.T) {
+ dir := initChangeRepo(t)
+ writeFile(t, filepath.Join(dir, "service", "handler.go"), "package service\n\nfunc Handle() int {\n\treturn 1\n}\n")
+ commitAll(t, dir, "base")
+
+ if err := os.Remove(filepath.Join(dir, "service", "handler.go")); err != nil {
+ t.Fatalf("remove old file: %v", err)
+ }
+ writeFile(t, filepath.Join(dir, "app", "handler.go"), "package app\n\nfunc Handle() int {\n\tif true {\n\t\treturn 2\n\t}\n\treturn 1\n}\n")
+ runGit(t, dir, "add", "-N", "app/handler.go")
+
+ cfg := changeSafetyTestConfig("change-move", dir)
+ cfg.Checks.ChangeRules.MaxChangedFiles = 20
+ cfg.Checks.ChangeRules.MaxChangedDirectories = 20
+ cfg.Checks.ChangeRules.MaxChangedLines = 100
+ cfg.Checks.ChangeRules.MaxPublicInterfacesChanged = 20
+ cfg.Checks.ChangeRules.MaxConcernFamilies = 20
+
+ report := runChangeDiff(t, cfg)
+ assertFindingRulePresent(t, report, "Change Safety", "change.mixed-refactor-and-behavior")
+ assertFindingRulePresent(t, report, "Change Safety", "change.move-without-verification")
+}
+
+func TestChangeMoveWithVerificationDoesNotWarnAboutMissingVerification(t *testing.T) {
+ dir := initChangeRepo(t)
+ writeFile(t, filepath.Join(dir, "service", "worker.go"), "package service\n\nfunc Work() int { return 1 }\n")
+ commitAll(t, dir, "base")
+
+ if err := os.Remove(filepath.Join(dir, "service", "worker.go")); err != nil {
+ t.Fatalf("remove old file: %v", err)
+ }
+ writeFile(t, filepath.Join(dir, "app", "worker.go"), "package app\n\nfunc Work() int { return 1 }\n")
+ writeFile(t, filepath.Join(dir, "app", "worker_test.go"), "package app\n\nfunc TestWork(t *testing.T) { if Work() != 1 { t.Fatal() } }\n")
+ runGit(t, dir, "add", "-N", "app/worker.go", "app/worker_test.go")
+
+ report := runChangeDiff(t, changeSafetyTestConfig("change-move-verified", dir))
+ assertFindingRuleAbsent(t, report, "Change Safety", "change.move-without-verification")
+}
+
+func TestChangeDetectsUnnecessarySurfaceArea(t *testing.T) {
+ dir := initChangeRepo(t)
+ fixtures := map[string]string{
+ "pkg/client/api.go": "package client\n\nfunc Do() int { return 1 }\n",
+ "include/demo/client.hpp": "#pragma once\nint Do();\n",
+ "api/openapi.yaml": "openapi: 3.0.0\ninfo: {title: demo, version: '1'}\n",
+ "proto/service.proto": "syntax = \"proto3\";\nmessage Request {}\n",
+ }
+ for rel, content := range fixtures {
+ writeFile(t, filepath.Join(dir, rel), content)
+ }
+ commitAll(t, dir, "base")
+ for rel, content := range fixtures {
+ writeFile(t, filepath.Join(dir, rel), content+"\n")
+ }
+
+ cfg := changeSafetyTestConfig("change-surface", dir)
+ cfg.Checks.ChangeRules.MaxChangedFiles = 20
+ cfg.Checks.ChangeRules.MaxChangedDirectories = 20
+ cfg.Checks.ChangeRules.MaxChangedLines = 100
+ cfg.Checks.ChangeRules.MaxPublicInterfacesChanged = 2
+ cfg.Checks.ChangeRules.MaxConcernFamilies = 20
+
+ report := runChangeDiff(t, cfg)
+ assertFindingRulePresent(t, report, "Change Safety", "change.unnecessary-surface-area")
+}
+
+func TestChangeFullScanNoops(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "service.go"), "package sample\n\nfunc Value() int { return 1 }\n")
+
+ report, err := codeguard.Run(context.Background(), changeSafetyTestConfig("change-full", dir))
+ if err != nil {
+ t.Fatalf("full scan: %v", err)
+ }
+ assertSectionStatus(t, report, "Change Safety", "pass")
+}
+
+func changeRuleFinding(t *testing.T, report codeguard.Report, ruleID string) codeguard.Finding {
+ t.Helper()
+ for _, section := range report.Sections {
+ if section.Name != "Change Safety" {
+ continue
+ }
+ for _, finding := range section.Findings {
+ if finding.RuleID == ruleID {
+ return finding
+ }
+ }
+ }
+ t.Fatalf("missing change finding %q", ruleID)
+ return codeguard.Finding{}
+}
diff --git a/tests/checks/defensive_precision_test.go b/tests/checks/defensive_precision_test.go
new file mode 100644
index 0000000..9ba2049
--- /dev/null
+++ b/tests/checks/defensive_precision_test.go
@@ -0,0 +1,58 @@
+package checks_test
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestDefensiveUncheckedTypeAssertionWarnsForSingleValueAssertion(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "assertion.go"), strings.Join([]string{
+ "package sample",
+ "",
+ "func Decode(value any) string {",
+ "\treturn value.(string)",
+ "}",
+ "",
+ }, "\n"))
+
+ report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir))
+
+ assertFindingRulePresent(t, report, "Code Quality", "defensive.unchecked-type-assertion")
+ assertFindingLevel(t, report, "Code Quality", "defensive.unchecked-type-assertion", "warn")
+}
+
+func TestDefensiveUncheckedTypeAssertionAllowsCommaOKAssertion(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "assertion_safe.go"), strings.Join([]string{
+ "package sample",
+ "",
+ "func Decode(value any) (string, bool) {",
+ "\ttext, ok := value.(string)",
+ "\treturn text, ok",
+ "}",
+ "",
+ }, "\n"))
+
+ report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir))
+
+ assertFindingRuleAbsent(t, report, "Code Quality", "defensive.unchecked-type-assertion")
+}
+
+func TestDefensiveUnsafeNumericConversionWarnsForNarrowingConversion(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "numeric.go"), strings.Join([]string{
+ "package sample",
+ "",
+ "func Narrow(count int64) int32 {",
+ "\treturn int32(count)",
+ "}",
+ "",
+ }, "\n"))
+
+ report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir))
+
+ assertFindingRulePresent(t, report, "Code Quality", "defensive.unsafe-numeric-conversion")
+ assertFindingLevel(t, report, "Code Quality", "defensive.unsafe-numeric-conversion", "warn")
+}
diff --git a/tests/checks/error_precision_test.go b/tests/checks/error_precision_test.go
new file mode 100644
index 0000000..3687ad2
--- /dev/null
+++ b/tests/checks/error_precision_test.go
@@ -0,0 +1,54 @@
+package checks_test
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestErrorLoggedAndIgnoredWarnsWhenErrorBecomesSuccess(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "errors.go"), strings.Join([]string{
+ "package sample",
+ "",
+ "import \"log\"",
+ "",
+ "func LoadProfile(id string) error {",
+ "\tif err := readProfile(id); err != nil {",
+ "\t\tlog.Printf(\"load profile: %v\", err)",
+ "\t\treturn nil",
+ "\t}",
+ "\treturn nil",
+ "}",
+ "",
+ "func readProfile(string) error { return nil }",
+ "",
+ }, "\n"))
+
+ report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir))
+
+ assertFindingRulePresent(t, report, "Code Quality", "error.logged-and-ignored")
+ assertFindingLevel(t, report, "Code Quality", "error.logged-and-ignored", "warn")
+}
+
+func TestErrorContextLostWarnsForBareErrorReturn(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "context.go"), strings.Join([]string{
+ "package sample",
+ "",
+ "func SaveProfile(id string) error {",
+ "\tif err := writeProfile(id); err != nil {",
+ "\t\treturn err",
+ "\t}",
+ "\treturn nil",
+ "}",
+ "",
+ "func writeProfile(string) error { return nil }",
+ "",
+ }, "\n"))
+
+ report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir))
+
+ assertFindingRulePresent(t, report, "Code Quality", "error.context-lost")
+ assertFindingLevel(t, report, "Code Quality", "error.context-lost", "warn")
+}
diff --git a/tests/checks/function_precision_test.go b/tests/checks/function_precision_test.go
new file mode 100644
index 0000000..67e544d
--- /dev/null
+++ b/tests/checks/function_precision_test.go
@@ -0,0 +1,78 @@
+package checks_test
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestFunctionExcessiveParametersWarnsWithSpecificRule(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "params.go"), strings.Join([]string{
+ "package sample",
+ "",
+ "func CreateUser(name string, email string, plan string, source string) string {",
+ "\treturn name + email + plan + source",
+ "}",
+ "",
+ }, "\n"))
+ cfg := qualityPrecisionConfig(dir)
+ cfg.Checks.QualityRules.MaxParameters = 2
+
+ report := runQualityPrecisionScan(t, cfg)
+
+ assertFindingRulePresent(t, report, "Code Quality", "function.excessive-parameters")
+ assertFindingLevel(t, report, "Code Quality", "function.excessive-parameters", "warn")
+}
+
+func TestFunctionMixedAbstractionLevelWarnsForInfrastructureInsideOrchestration(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "checkout.go"), strings.Join([]string{
+ "package sample",
+ "",
+ "func Checkout(order Order) error {",
+ "\tvalidateOrder(order)",
+ "\trows, err := db.Query(\"select 1\")",
+ "\tif err != nil {",
+ "\t\treturn err",
+ "\t}",
+ "\tdefer rows.Close()",
+ "\treturn persistOrder(order)",
+ "}",
+ "",
+ "type Order struct{}",
+ "",
+ }, "\n"))
+
+ report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir))
+
+ assertFindingRulePresent(t, report, "Code Quality", "function.mixed-abstraction-level")
+ assertFindingLevel(t, report, "Code Quality", "function.mixed-abstraction-level", "warn")
+}
+
+func TestFunctionCommandQueryMixWarnsWhenQueryMutatesState(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "query.go"), strings.Join([]string{
+ "package sample",
+ "",
+ "type Repository interface {",
+ "\tFind(string) (User, error)",
+ "\tSaveAudit(string) error",
+ "}",
+ "",
+ "type User struct{}",
+ "",
+ "func GetUser(repo Repository, id string) (User, error) {",
+ "\tif err := repo.SaveAudit(id); err != nil {",
+ "\t\treturn User{}, err",
+ "\t}",
+ "\treturn repo.Find(id)",
+ "}",
+ "",
+ }, "\n"))
+
+ report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir))
+
+ assertFindingRulePresent(t, report, "Code Quality", "function.command-query-mix")
+ assertFindingLevel(t, report, "Code Quality", "function.command-query-mix", "warn")
+}
diff --git a/tests/checks/maintainability_delta_test.go b/tests/checks/maintainability_delta_test.go
new file mode 100644
index 0000000..09d8723
--- /dev/null
+++ b/tests/checks/maintainability_delta_test.go
@@ -0,0 +1,94 @@
+package checks_test
+
+import (
+ "context"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/devr-tools/codeguard/pkg/codeguard"
+)
+
+func initMaintainabilityDeltaRepo(t *testing.T, baseSource string) string {
+ t.Helper()
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "api.go"), baseSource)
+ runGit(t, dir, "init", "-b", "main")
+ runGit(t, dir, "config", "user.email", "test@example.com")
+ runGit(t, dir, "config", "user.name", "CodeGuard Test")
+ runGit(t, dir, "add", ".")
+ runGit(t, dir, "commit", "-m", "base")
+ runGit(t, dir, "checkout", "-b", "feature")
+ return dir
+}
+
+func runMaintainabilityDeltaScan(t *testing.T, cfg codeguard.Config) codeguard.Report {
+ t.Helper()
+ report, err := codeguard.RunWithOptions(context.Background(), cfg, codeguard.ScanOptions{
+ Mode: codeguard.ScanModeDiff,
+ BaseRef: "main",
+ })
+ if err != nil {
+ t.Fatalf("diff scan: %v", err)
+ }
+ return report
+}
+
+func TestMaintainabilityPublicSurfaceGrowthWarnsInDiffScan(t *testing.T) {
+ dir := initMaintainabilityDeltaRepo(t, strings.Join([]string{
+ "package sample",
+ "",
+ "func Existing() string {",
+ "\treturn \"ok\"",
+ "}",
+ "",
+ }, "\n"))
+ writeFile(t, filepath.Join(dir, "api.go"), strings.Join([]string{
+ "package sample",
+ "",
+ "func Existing() string {",
+ "\treturn \"ok\"",
+ "}",
+ "",
+ "func NewExported() string {",
+ "\treturn \"new\"",
+ "}",
+ "",
+ }, "\n"))
+
+ report := runMaintainabilityDeltaScan(t, qualityPrecisionConfig(dir))
+
+ assertFindingRulePresent(t, report, "Code Quality", "maintainability.public-surface-growth")
+ assertFindingLevel(t, report, "Code Quality", "maintainability.public-surface-growth", "warn")
+}
+
+func TestMaintainabilityDependencyGrowthWarnsInDiffScan(t *testing.T) {
+ dir := initMaintainabilityDeltaRepo(t, strings.Join([]string{
+ "package sample",
+ "",
+ "import \"fmt\"",
+ "",
+ "func Existing() string {",
+ "\treturn fmt.Sprint(\"ok\")",
+ "}",
+ "",
+ }, "\n"))
+ writeFile(t, filepath.Join(dir, "api.go"), strings.Join([]string{
+ "package sample",
+ "",
+ "import (",
+ "\t\"fmt\"",
+ "\t\"strings\"",
+ ")",
+ "",
+ "func Existing() string {",
+ "\treturn strings.TrimSpace(fmt.Sprint(\"ok\"))",
+ "}",
+ "",
+ }, "\n"))
+
+ report := runMaintainabilityDeltaScan(t, qualityPrecisionConfig(dir))
+
+ assertFindingRulePresent(t, report, "Code Quality", "maintainability.dependency-growth")
+ assertFindingLevel(t, report, "Code Quality", "maintainability.dependency-growth", "warn")
+}
diff --git a/tests/checks/maintainability_history_test.go b/tests/checks/maintainability_history_test.go
new file mode 100644
index 0000000..fee6e4c
--- /dev/null
+++ b/tests/checks/maintainability_history_test.go
@@ -0,0 +1,114 @@
+package checks_test
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/devr-tools/codeguard/pkg/codeguard"
+)
+
+func initMaintainabilityHistoryRepo(t *testing.T) string {
+ t.Helper()
+ dir := initContractsRepo(t)
+ writeFile(t, filepath.Join(dir, "risky.go"), maintainabilityHistorySource(0, "base"))
+ writeFile(t, filepath.Join(dir, "partner_a.go"), "package sample\n\nfunc PartnerA() int { return 1 }\n")
+ writeFile(t, filepath.Join(dir, "partner_b.go"), "package sample\n\nfunc PartnerB() int { return 1 }\n")
+ writeFile(t, filepath.Join(dir, "partner_c.go"), "package sample\n\nfunc PartnerC() int { return 1 }\n")
+ writeFile(t, filepath.Join(dir, "partner_d.go"), "package sample\n\nfunc PartnerD() int { return 1 }\n")
+ commitAll(t, dir, "api base")
+
+ commits := []struct {
+ message string
+ partners []string
+ }{
+ {message: "fix api bug in risky flow", partners: []string{"partner_a.go", "partner_b.go", "partner_c.go", "partner_d.go"}},
+ {message: "refactor risky flow", partners: []string{"partner_a.go", "partner_b.go", "partner_c.go"}},
+ {message: "db cache update for risky flow", partners: []string{"partner_a.go", "partner_b.go", "partner_c.go"}},
+ {message: "perf speed up risky flow", partners: []string{"partner_a.go", "partner_b.go", "partner_c.go"}},
+ {message: "fix regression in risky flow", partners: []string{"partner_a.go", "partner_b.go", "partner_c.go"}},
+ }
+ for idx, commit := range commits {
+ writeFile(t, filepath.Join(dir, "risky.go"), maintainabilityHistorySource(idx+1, commit.message))
+ for _, partner := range commit.partners {
+ writeFile(t, filepath.Join(dir, partner), fmt.Sprintf("package sample\n\nfunc %s() int { return %d }\n", partnerFunctionName(partner), idx+2))
+ }
+ commitAll(t, dir, commit.message)
+ }
+
+ runGit(t, dir, "checkout", "-b", "feature")
+ writeFile(t, filepath.Join(dir, "risky.go"), maintainabilityHistorySource(99, "feature change"))
+ return dir
+}
+
+func maintainabilityHistorySource(version int, label string) string {
+ lines := []string{
+ "package sample",
+ "",
+ "func PublicRiskyAPI(input int) int {",
+ "\tresult := input",
+ }
+ for idx := 0; idx < 12; idx++ {
+ lines = append(lines,
+ fmt.Sprintf("\tif result > %d {", idx),
+ fmt.Sprintf("\t\tresult += %d", version+idx+1),
+ "\t}",
+ )
+ }
+ for idx := 0; idx < 30; idx++ {
+ lines = append(lines, fmt.Sprintf("\tresult += %d // %s filler %02d", version+idx, label, idx))
+ }
+ lines = append(lines,
+ "\treturn result",
+ "}",
+ "",
+ )
+ return strings.Join(lines, "\n")
+}
+
+func partnerFunctionName(path string) string {
+ switch path {
+ case "partner_a.go":
+ return "PartnerA"
+ case "partner_b.go":
+ return "PartnerB"
+ case "partner_c.go":
+ return "PartnerC"
+ default:
+ return "PartnerD"
+ }
+}
+
+func TestMaintainabilityHistoryHotspotRulesUseGitEvidence(t *testing.T) {
+ dir := initMaintainabilityHistoryRepo(t)
+
+ report := runMaintainabilityDeltaScan(t, qualityPrecisionConfig(dir))
+
+ assertFindingRulePresent(t, report, "Code Quality", "maintainability.hotspot")
+ assertFindingRulePresent(t, report, "Code Quality", "maintainability.high-churn-hotspot")
+ assertFindingRulePresent(t, report, "Code Quality", "maintainability.repeat-defect-area")
+ assertFindingRulePresent(t, report, "Code Quality", "maintainability.unstable-interface")
+ assertFindingLevel(t, report, "Code Quality", "maintainability.high-churn-hotspot", "warn")
+
+ finding := findFinding(t, report, "Code Quality", "maintainability.high-churn-hotspot")
+ if finding.Metadata["commits"] == "" || finding.Metadata["churn"] == "" || finding.Metadata["decision_hints"] == "" {
+ t.Fatalf("missing history evidence metadata: %#v", finding.Metadata)
+ }
+ if !strings.Contains(finding.Message, "churn") {
+ t.Fatalf("finding message should include churn evidence: %q", finding.Message)
+ }
+}
+
+func TestMaintainabilityHistoryUnavailableDoesNotFailScan(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "plain.go"), "package sample\n\nfunc Value() int { return 1 }\n")
+
+ report, err := codeguard.Run(context.Background(), qualityPrecisionConfig(dir))
+ if err != nil {
+ t.Fatalf("full scan without git history: %v", err)
+ }
+ assertFindingRuleAbsent(t, report, "Code Quality", "maintainability.hotspot")
+ assertSectionStatus(t, report, "Code Quality", "pass")
+}
diff --git a/tests/checks/naming_precision_test.go b/tests/checks/naming_precision_test.go
new file mode 100644
index 0000000..1867298
--- /dev/null
+++ b/tests/checks/naming_precision_test.go
@@ -0,0 +1,69 @@
+package checks_test
+
+import (
+ "context"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/devr-tools/codeguard/pkg/codeguard"
+)
+
+func qualityPrecisionConfig(dir string) codeguard.Config {
+ cfg := codeguard.ExampleConfig()
+ cfg.Name = "quality-precision"
+ cfg.Targets = []codeguard.TargetConfig{{Name: "repo", Path: dir, Language: "go"}}
+ cfg.Checks.Quality = true
+ cfg.Checks.Design = false
+ cfg.Checks.Security = false
+ cfg.Checks.Prompts = false
+ cfg.Checks.CI = false
+ off := false
+ cfg.Checks.Context = &off
+ cfg.Cache.Enabled = &off
+ return cfg
+}
+
+func runQualityPrecisionScan(t *testing.T, cfg codeguard.Config) codeguard.Report {
+ t.Helper()
+ report, err := codeguard.Run(context.Background(), cfg)
+ if err != nil {
+ t.Fatalf("run: %v", err)
+ }
+ return report
+}
+
+func TestNamingGenericIdentifierWarnsForPlaceholderNames(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "names.go"), strings.Join([]string{
+ "package sample",
+ "",
+ "func foo(input string) string {",
+ "\ttmp := input",
+ "\treturn tmp",
+ "}",
+ "",
+ }, "\n"))
+
+ report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir))
+
+ assertFindingRulePresent(t, report, "Code Quality", "naming.generic-identifier")
+ assertFindingLevel(t, report, "Code Quality", "naming.generic-identifier", "warn")
+}
+
+func TestNamingGenericIdentifierSkipsTestFixtures(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "names_test.go"), strings.Join([]string{
+ "package sample",
+ "",
+ "func TestFoo(t any) {",
+ "\ttmp := t",
+ "\t_ = tmp",
+ "}",
+ "",
+ }, "\n"))
+
+ report := runQualityPrecisionScan(t, qualityPrecisionConfig(dir))
+
+ assertFindingRuleAbsent(t, report, "Code Quality", "naming.generic-identifier")
+}
diff --git a/tests/checks/refactor_test.go b/tests/checks/refactor_test.go
new file mode 100644
index 0000000..0f335c0
--- /dev/null
+++ b/tests/checks/refactor_test.go
@@ -0,0 +1,278 @@
+package checks_test
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/devr-tools/codeguard/pkg/codeguard"
+)
+
+func TestRefactorDetectsBehaviorErrorAndSideEffectOrderChanges(t *testing.T) {
+ dir := initChangeRepo(t)
+ before := `package service
+
+import (
+ "errors"
+ "fmt"
+)
+
+type Repo interface { Save() error }
+type Bus interface { Publish(string) }
+type User struct{}
+
+func Authorize(User) bool { return true }
+
+func Process(repo Repo, bus Bus, user User) error {
+ if !Authorize(user) {
+ return errors.New("denied")
+ }
+ if err := repo.Save(); err != nil {
+ return fmt.Errorf("save: %w", err)
+ }
+ bus.Publish("saved")
+ return nil
+}
+`
+ after := `package app
+
+type Repo interface { Save() error }
+type Bus interface { Publish(string) }
+type User struct{}
+
+func Authorize(User) bool { return true }
+
+func Process(repo Repo, bus Bus, user User) error {
+ if !Authorize(user) {
+ return nil
+ }
+ bus.Publish("saved")
+ if err := repo.Save(); err != nil {
+ return err
+ }
+ return nil
+}
+`
+ writeFile(t, filepath.Join(dir, "service", "processor.go"), before)
+ commitAll(t, dir, "base")
+
+ if err := os.Remove(filepath.Join(dir, "service", "processor.go")); err != nil {
+ t.Fatalf("remove old processor: %v", err)
+ }
+ writeFile(t, filepath.Join(dir, "app", "processor.go"), after)
+ runGit(t, dir, "add", "-N", "app/processor.go")
+
+ cfg := refactorTestConfig(t, dir, "go")
+ report := runChangeDiff(t, cfg)
+
+ assertFindingRulePresent(t, report, "Change Safety", "refactor.behavior-change-detected")
+ assertFindingRulePresent(t, report, "Change Safety", "refactor.error-path-changed")
+ assertFindingRulePresent(t, report, "Change Safety", "refactor.side-effect-order-changed")
+}
+
+func TestRefactorDetectsPublicContractAndVisibilityExpansion(t *testing.T) {
+ dir := initChangeRepo(t)
+ writeFile(t, filepath.Join(dir, "pkg", "client", "api.go"), `package client
+
+func Price(value int) int { return value }
+
+func normalize(value int) int { return value }
+`)
+ commitAll(t, dir, "base")
+ writeFile(t, filepath.Join(dir, "pkg", "client", "api.go"), `package client
+
+func Price(value int, currency string) int { return value }
+
+func Normalize(value int) int { return value }
+`)
+
+ report := runChangeDiff(t, refactorTestConfig(t, dir, "go"))
+
+ assertFindingRulePresent(t, report, "Change Safety", "refactor.public-contract-changed")
+ assertFindingRulePresent(t, report, "Change Safety", "refactor.visibility-expanded")
+}
+
+func TestRefactorDetectsTestCoverageReduced(t *testing.T) {
+ dir := initChangeRepo(t)
+ writeFile(t, filepath.Join(dir, "pricing", "price_test.go"), `package pricing
+
+import "testing"
+
+func TestPriceBase(t *testing.T) {
+ if got := 10; got != 10 { t.Fatal(got) }
+}
+
+func TestPriceDiscount(t *testing.T) {
+ if got := 9; got != 9 { t.Fatal(got) }
+}
+`)
+ commitAll(t, dir, "base")
+ writeFile(t, filepath.Join(dir, "pricing", "price_test.go"), `package pricing
+
+import "testing"
+
+func TestPriceBase(t *testing.T) {
+ if got := 10; got != 10 { t.Fatal(got) }
+}
+`)
+
+ report := runChangeDiff(t, refactorTestConfig(t, dir, "go"))
+
+ assertFindingRulePresent(t, report, "Change Safety", "refactor.test-coverage-reduced")
+}
+
+func TestRefactorDetectsDependencyDirectionWorsenedAcrossLanguages(t *testing.T) {
+ cases := []struct {
+ name string
+ language string
+ path string
+ before string
+ after string
+ }{
+ {
+ name: "python",
+ language: "python",
+ path: "app/domain/order.py",
+ before: "def total(value):\n return value\n",
+ after: "import requests\n\n\ndef total(value):\n return requests.get('https://example.test').status_code + value\n",
+ },
+ {
+ name: "typescript",
+ language: "typescript",
+ path: "src/domain/order.ts",
+ before: "export function total(value: number) {\n return value;\n}\n",
+ after: "import axios from 'axios';\n\nexport function total(value: number) {\n return value;\n}\n",
+ },
+ {
+ name: "javascript",
+ language: "javascript",
+ path: "src/domain/order.js",
+ before: "export function total(value) {\n return value;\n}\n",
+ after: "import axios from 'axios';\n\nexport function total(value) {\n return value;\n}\n",
+ },
+ {
+ name: "cpp",
+ language: "c++",
+ path: "src/domain/order.cpp",
+ before: "int total(int value) {\n return value;\n}\n",
+ after: "#include \n\nint total(int value) {\n return value;\n}\n",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := initChangeRepo(t)
+ writeFile(t, filepath.Join(dir, tc.path), tc.before)
+ commitAll(t, dir, "base")
+ writeFile(t, filepath.Join(dir, tc.path), tc.after)
+
+ report := runChangeDiff(t, refactorTestConfig(t, dir, tc.language))
+
+ assertFindingRulePresent(t, report, "Change Safety", "refactor.dependency-direction-worsened")
+ })
+ }
+}
+
+func TestRefactorDetectsDuplicateImplementationAndDeadPathLeftBehind(t *testing.T) {
+ dir := initChangeRepo(t)
+ body := `package service
+
+func OldTotal(value int) int {
+ total := value
+ if total > 100 {
+ total = total - 10
+ }
+ if total < 0 {
+ total = 0
+ }
+ return total
+}
+`
+ writeFile(t, filepath.Join(dir, "service", "old_total.go"), body)
+ writeFile(t, filepath.Join(dir, "service", "legacy.go"), "package service\n\nfunc UseLegacy() int { return 1 }\n")
+ commitAll(t, dir, "base")
+
+ writeFile(t, filepath.Join(dir, "service", "new_total.go"), `package service
+
+func NewTotal(value int) int {
+ total := value
+ if total > 100 {
+ total = total - 10
+ }
+ if total < 0 {
+ total = 0
+ }
+ return total
+}
+`)
+ writeFile(t, filepath.Join(dir, "service", "legacy.go"), `package service
+
+func UseLegacy() int {
+ if false {
+ return legacyCompatibility()
+ }
+ return 1
+}
+
+func legacyCompatibility() int { return 0 }
+`)
+ runGit(t, dir, "add", "-N", "service/new_total.go")
+
+ report := runChangeDiff(t, refactorTestConfig(t, dir, "go"))
+
+ assertFindingRulePresent(t, report, "Change Safety", "refactor.duplicate-implementation-left-behind")
+ assertFindingRulePresent(t, report, "Change Safety", "refactor.dead-path-left-behind")
+}
+
+func TestRefactorBehaviorPreservingMoveWithTestsDoesNotEmitRefactorFindings(t *testing.T) {
+ dir := initChangeRepo(t)
+ before := `package worker
+
+type Repo interface { Save() error }
+type Bus interface { Publish(string) }
+
+func Process(repo Repo, bus Bus) error {
+ if err := repo.Save(); err != nil {
+ return err
+ }
+ bus.Publish("saved")
+ return nil
+}
+`
+ writeFile(t, filepath.Join(dir, "service", "worker.go"), before)
+ commitAll(t, dir, "base")
+
+ if err := os.Remove(filepath.Join(dir, "service", "worker.go")); err != nil {
+ t.Fatalf("remove old worker: %v", err)
+ }
+ writeFile(t, filepath.Join(dir, "app", "worker.go"), before)
+ writeFile(t, filepath.Join(dir, "app", "worker_test.go"), "package worker\n\nimport \"testing\"\n\nfunc TestProcess(t *testing.T) {}\n")
+ runGit(t, dir, "add", "-N", "app/worker.go", "app/worker_test.go")
+
+ report := runChangeDiff(t, refactorTestConfig(t, dir, "go"))
+
+ for _, ruleID := range []string{
+ "refactor.behavior-change-detected",
+ "refactor.public-contract-changed",
+ "refactor.test-coverage-reduced",
+ "refactor.error-path-changed",
+ "refactor.side-effect-order-changed",
+ "refactor.visibility-expanded",
+ "refactor.dependency-direction-worsened",
+ "refactor.duplicate-implementation-left-behind",
+ "refactor.dead-path-left-behind",
+ } {
+ assertFindingRuleAbsent(t, report, "Change Safety", ruleID)
+ }
+}
+
+func refactorTestConfig(t *testing.T, dir string, language string) codeguard.Config {
+ t.Helper()
+ cfg := changeSafetyTestConfig("refactor-test", dir)
+ cfg.Targets = []codeguard.TargetConfig{{Name: "repo", Path: dir, Language: language}}
+ cfg.Checks.ChangeRules.DetectBehaviorChangeWithoutTest = boolValue(false)
+ cfg.Checks.ChangeRules.DetectFailurePathMissing = boolValue(false)
+ cfg.Checks.ChangeRules.DetectHardwiredDependency = boolValue(false)
+ cfg.Checks.ChangeRules.DetectNondeterministicDomain = boolValue(false)
+ return cfg
+}
diff --git a/tests/checks/smell_history_test.go b/tests/checks/smell_history_test.go
new file mode 100644
index 0000000..7c6ada5
--- /dev/null
+++ b/tests/checks/smell_history_test.go
@@ -0,0 +1,37 @@
+package checks_test
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestSmellHistoryRulesUseCoChangeEvidence(t *testing.T) {
+ dir := initMaintainabilityHistoryRepo(t)
+
+ report := runMaintainabilityDeltaScan(t, qualityPrecisionConfig(dir))
+
+ assertFindingRulePresent(t, report, "Code Quality", "smell.shotgun-surgery-history")
+ assertFindingRulePresent(t, report, "Code Quality", "smell.divergent-change-history")
+ assertFindingRulePresent(t, report, "Code Quality", "maintainability.change-amplification")
+
+ shotgun := findFinding(t, report, "Code Quality", "smell.shotgun-surgery-history")
+ if partners := shotgun.Metadata["top_partners"]; !strings.Contains(partners, "partner_a.go") || !strings.Contains(partners, "partner_b.go") {
+ t.Fatalf("top_partners metadata = %q, want recurring partner evidence", partners)
+ }
+
+ amplifier := findFinding(t, report, "Code Quality", "maintainability.change-amplification")
+ if !strings.Contains(amplifier.Message, "co-change partner") {
+ t.Fatalf("change amplification message should include co-change evidence: %q", amplifier.Message)
+ }
+}
+
+func TestChangeAmplificationDeterministicMetadataOrdering(t *testing.T) {
+ dir := initMaintainabilityHistoryRepo(t)
+
+ report := runMaintainabilityDeltaScan(t, qualityPrecisionConfig(dir))
+
+ finding := findFinding(t, report, "Code Quality", "maintainability.change-amplification")
+ if got := finding.Metadata["top_partners"]; !strings.HasPrefix(got, "partner_a.go:") {
+ t.Fatalf("top_partners ordering = %q, want lexical tie-break after count ordering", got)
+ }
+}
diff --git a/tests/checks/testing_testability_test.go b/tests/checks/testing_testability_test.go
new file mode 100644
index 0000000..df3f013
--- /dev/null
+++ b/tests/checks/testing_testability_test.go
@@ -0,0 +1,210 @@
+package checks_test
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/devr-tools/codeguard/pkg/codeguard"
+)
+
+func TestTestingBehaviorChangeWithoutTestAcrossLanguages(t *testing.T) {
+ cases := []struct {
+ name string
+ language string
+ path string
+ before string
+ after string
+ }{
+ {
+ name: "go",
+ language: "go",
+ path: "pricing/price.go",
+ before: "package pricing\n\nfunc Price(v int) int {\n\treturn v\n}\n",
+ after: "package pricing\n\nfunc Price(v int) int {\n\tif v > 100 {\n\t\treturn v - 10\n\t}\n\treturn v\n}\n",
+ },
+ {
+ name: "python",
+ language: "python",
+ path: "app/services/pricing.py",
+ before: "def price(value):\n return value\n",
+ after: "def price(value):\n if value > 100:\n return value - 10\n return value\n",
+ },
+ {
+ name: "typescript",
+ language: "typescript",
+ path: "src/domain/pricing.ts",
+ before: "export function price(value: number) {\n return value;\n}\n",
+ after: "export function price(value: number) {\n if (value > 100) {\n return value - 10;\n }\n return value;\n}\n",
+ },
+ {
+ name: "javascript",
+ language: "javascript",
+ path: "src/domain/pricing.js",
+ before: "export function price(value) {\n return value;\n}\n",
+ after: "export function price(value) {\n if (value > 100) {\n return value - 10;\n }\n return value;\n}\n",
+ },
+ {
+ name: "cpp",
+ language: "c++",
+ path: "src/domain/pricing.cpp",
+ before: "int price(int value) {\n return value;\n}\n",
+ after: "int price(int value) {\n if (value > 100) {\n return value - 10;\n }\n return value;\n}\n",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := testingGitRepo(t)
+ writeFile(t, filepath.Join(dir, tc.path), tc.before)
+ commitAll(t, dir, "base")
+ writeFile(t, filepath.Join(dir, tc.path), tc.after)
+
+ report := runTestingChangeScan(t, testingChangeConfig(t, dir, tc.language))
+
+ assertFindingRulePresent(t, report, "Change Safety", "testing.behavior-change-without-test")
+ assertFindingLevel(t, report, "Change Safety", "testing.behavior-change-without-test", "fail")
+ })
+ }
+}
+
+func TestTestingBehaviorChangeWithChangedTestSuppressesFinding(t *testing.T) {
+ dir := testingGitRepo(t)
+ writeFile(t, filepath.Join(dir, "pricing", "price.go"), "package pricing\n\nfunc Price(v int) int {\n\treturn v\n}\n")
+ writeFile(t, filepath.Join(dir, "pricing", "price_test.go"), "package pricing\n\nimport \"testing\"\n\nfunc TestPrice(t *testing.T) {\n\tif Price(10) != 10 { t.Fatal(\"price\") }\n}\n")
+ commitAll(t, dir, "base")
+
+ writeFile(t, filepath.Join(dir, "pricing", "price.go"), "package pricing\n\nfunc Price(v int) int {\n\tif v > 100 {\n\t\treturn v - 10\n\t}\n\treturn v\n}\n")
+ writeFile(t, filepath.Join(dir, "pricing", "price_test.go"), "package pricing\n\nimport \"testing\"\n\nfunc TestPriceDiscount(t *testing.T) {\n\tif Price(120) != 110 { t.Fatal(\"discount\") }\n}\n")
+
+ report := runTestingChangeScan(t, testingChangeConfig(t, dir, "go"))
+
+ assertFindingRuleAbsent(t, report, "Change Safety", "testing.behavior-change-without-test")
+}
+
+func TestTestingFailurePathMissingRequiresFailureTestEvidence(t *testing.T) {
+ t.Run("missing failure test", func(t *testing.T) {
+ dir := testingGitRepo(t)
+ writeFile(t, filepath.Join(dir, "src", "domain", "payment.ts"), "export function authorize(ok: boolean) {\n return ok;\n}\n")
+ commitAll(t, dir, "base")
+ writeFile(t, filepath.Join(dir, "src", "domain", "payment.ts"), "export function authorize(ok: boolean) {\n if (!ok) {\n throw new Error('denied');\n }\n return true;\n}\n")
+
+ cfg := testingChangeConfig(t, dir, "typescript")
+ cfg.Checks.ChangeRules.DetectBehaviorChangeWithoutTest = boolValue(false)
+ report := runTestingChangeScan(t, cfg)
+
+ assertFindingRulePresent(t, report, "Change Safety", "testing.failure-path-missing")
+ })
+
+ t.Run("covered by failure test", func(t *testing.T) {
+ dir := testingGitRepo(t)
+ writeFile(t, filepath.Join(dir, "src", "domain", "payment.ts"), "export function authorize(ok: boolean) {\n return ok;\n}\n")
+ writeFile(t, filepath.Join(dir, "src", "domain", "payment.test.ts"), "import { authorize } from './payment';\n\ntest('authorize allows success', () => {\n expect(authorize(true)).toBe(true);\n});\n")
+ commitAll(t, dir, "base")
+ writeFile(t, filepath.Join(dir, "src", "domain", "payment.ts"), "export function authorize(ok: boolean) {\n if (!ok) {\n throw new Error('denied');\n }\n return true;\n}\n")
+ writeFile(t, filepath.Join(dir, "src", "domain", "payment.test.ts"), "import { authorize } from './payment';\n\ntest('authorize rejects denied payment', () => {\n expect(() => authorize(false)).toThrow('denied');\n});\n")
+
+ cfg := testingChangeConfig(t, dir, "typescript")
+ cfg.Checks.ChangeRules.DetectBehaviorChangeWithoutTest = boolValue(false)
+ report := runTestingChangeScan(t, cfg)
+
+ assertFindingRuleAbsent(t, report, "Change Safety", "testing.failure-path-missing")
+ })
+}
+
+func TestTestingHardwiredDependencyFindsChangedProductionLine(t *testing.T) {
+ dir := testingGitRepo(t)
+ writeFile(t, filepath.Join(dir, "app", "services", "profile.py"), "def load_profile(user_id):\n return {\"id\": user_id}\n")
+ commitAll(t, dir, "base")
+ writeFile(t, filepath.Join(dir, "app", "services", "profile.py"), "import requests\n\n\ndef load_profile(user_id):\n response = requests.get(f\"https://profiles.example/{user_id}\")\n return response.json()\n")
+
+ cfg := testingChangeConfig(t, dir, "python")
+ cfg.Checks.ChangeRules.DetectBehaviorChangeWithoutTest = boolValue(false)
+ report := runTestingChangeScan(t, cfg)
+
+ assertFindingRulePresent(t, report, "Change Safety", "testing.hardwired-dependency")
+}
+
+func TestTestingNondeterministicDomainLogicFindsDomainClock(t *testing.T) {
+ dir := testingGitRepo(t)
+ writeFile(t, filepath.Join(dir, "src", "domain", "coupon.cpp"), "long issued_at() {\n return 0;\n}\n")
+ commitAll(t, dir, "base")
+ writeFile(t, filepath.Join(dir, "src", "domain", "coupon.cpp"), "#include \n\nlong issued_at() {\n return std::chrono::system_clock::now().time_since_epoch().count();\n}\n")
+
+ cfg := testingChangeConfig(t, dir, "c++")
+ cfg.Checks.ChangeRules.DetectBehaviorChangeWithoutTest = boolValue(false)
+ report := runTestingChangeScan(t, cfg)
+
+ assertFindingRulePresent(t, report, "Change Safety", "testing.nondeterministic-domain-logic")
+}
+
+func TestTestingChangeRulesTogglesDisableDetectors(t *testing.T) {
+ dir := testingGitRepo(t)
+ writeFile(t, filepath.Join(dir, "pricing", "price.go"), "package pricing\n\nfunc Price(v int) int {\n\treturn v\n}\n")
+ commitAll(t, dir, "base")
+ writeFile(t, filepath.Join(dir, "pricing", "price.go"), "package pricing\n\nfunc Price(v int) int {\n\tif v > 100 {\n\t\treturn v - 10\n\t}\n\treturn v\n}\n")
+
+ cfg := testingChangeConfig(t, dir, "go")
+ cfg.Checks.ChangeRules.DetectBehaviorChangeWithoutTest = boolValue(false)
+ report := runTestingChangeScan(t, cfg)
+
+ assertFindingRuleAbsent(t, report, "Change Safety", "testing.behavior-change-without-test")
+}
+
+func TestTestingLegacyHotspotUncoveredDoesNotEmitWithoutHistory(t *testing.T) {
+ dir := testingGitRepo(t)
+ writeFile(t, filepath.Join(dir, "legacy", "calculator.py"), "def calculate(value):\n return value\n")
+ commitAll(t, dir, "base")
+ writeFile(t, filepath.Join(dir, "legacy", "calculator.py"), "def calculate(value):\n return value + 1\n")
+
+ cfg := testingChangeConfig(t, dir, "python")
+ cfg.Checks.ChangeRules.DetectBehaviorChangeWithoutTest = boolValue(false)
+ cfg.Checks.ChangeRules.DetectFailurePathMissing = boolValue(false)
+ cfg.Checks.ChangeRules.DetectHardwiredDependency = boolValue(false)
+ cfg.Checks.ChangeRules.DetectNondeterministicDomain = boolValue(false)
+ report := runTestingChangeScan(t, cfg)
+
+ assertFindingRuleAbsent(t, report, "Change Safety", "testing.legacy-hotspot-uncovered")
+}
+
+func testingChangeConfig(t *testing.T, dir string, language string) codeguard.Config {
+ t.Helper()
+ cfg := codeguard.ExampleConfig()
+ cfg.Name = "change-testability"
+ cfg.Targets = []codeguard.TargetConfig{{Name: "repo", Path: dir, Language: language}}
+ cfg.Checks.Quality = false
+ cfg.Checks.Design = false
+ cfg.Checks.Security = false
+ cfg.Checks.Prompts = false
+ cfg.Checks.CI = false
+ cfg.Checks.SupplyChain = false
+ cfg.Checks.Performance = boolValue(false)
+ cfg.Checks.Reliability = boolValue(false)
+ cfg.Checks.Data = boolValue(false)
+ cfg.Checks.Contracts = boolValue(false)
+ cfg.Checks.Context = boolValue(false)
+ cfg.Checks.Change = boolValue(true)
+ cfg.Cache.Enabled = boolValue(false)
+ return cfg
+}
+
+func runTestingChangeScan(t *testing.T, cfg codeguard.Config) codeguard.Report {
+ t.Helper()
+ report, err := codeguard.RunWithOptions(context.Background(), cfg, codeguard.ScanOptions{
+ Mode: codeguard.ScanModeDiff,
+ BaseRef: "main",
+ })
+ if err != nil {
+ t.Fatalf("run diff: %v", err)
+ }
+ return report
+}
+
+func testingGitRepo(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ runGit(t, dir, "init", "-b", "main")
+ runGit(t, dir, "config", "user.email", "test@example.com")
+ runGit(t, dir, "config", "user.name", "CodeGuard Test")
+ return dir
+}
diff --git a/tests/cli/features_metadata_test.go b/tests/cli/features_metadata_test.go
index 862d77b..9ccd0ce 100644
--- a/tests/cli/features_metadata_test.go
+++ b/tests/cli/features_metadata_test.go
@@ -89,6 +89,51 @@ func TestSDKRuleMetadataForDataRule(t *testing.T) {
}
}
+func TestSDKRuleMetadataForChangeSafetyRule(t *testing.T) {
+ rule := requireRuleMetadata(t, "change.oversized-diff")
+ assertExecutionModel(t, rule, codeguard.RuleExecutionModelLanguageAgnostic)
+ assertLanguageCoverage(t, rule, codeguard.RuleLanguageCoverageRepositoryWide)
+ if rule.FixTemplate.Kind != codeguard.FixTemplateKindGuided {
+ t.Fatalf("expected guided change safety fix template, got %q", rule.FixTemplate.Kind)
+ }
+}
+
+func TestSDKRuleMetadataForTestabilityRule(t *testing.T) {
+ rule := requireRuleMetadata(t, "testing.behavior-change-without-test")
+ assertExecutionModel(t, rule, codeguard.RuleExecutionModelLanguageAgnostic)
+ assertLanguageCoverage(
+ t,
+ rule,
+ codeguard.RuleLanguageCoverageFixed,
+ codeguard.RuleLanguageCPP,
+ codeguard.RuleLanguageGo,
+ codeguard.RuleLanguageJavaScript,
+ codeguard.RuleLanguagePython,
+ codeguard.RuleLanguageTypeScript,
+ )
+ if rule.FixTemplate.Kind != codeguard.FixTemplateKindGuided {
+ t.Fatalf("expected guided testability fix template, got %q", rule.FixTemplate.Kind)
+ }
+}
+
+func TestSDKRuleMetadataForRefactorRule(t *testing.T) {
+ rule := requireRuleMetadata(t, "refactor.behavior-change-detected")
+ assertExecutionModel(t, rule, codeguard.RuleExecutionModelLanguageAgnostic)
+ assertLanguageCoverage(
+ t,
+ rule,
+ codeguard.RuleLanguageCoverageFixed,
+ codeguard.RuleLanguageCPP,
+ codeguard.RuleLanguageGo,
+ codeguard.RuleLanguageJavaScript,
+ codeguard.RuleLanguagePython,
+ codeguard.RuleLanguageTypeScript,
+ )
+ if rule.FixTemplate.Kind != codeguard.FixTemplateKindGuided {
+ t.Fatalf("expected guided refactor fix template, got %q", rule.FixTemplate.Kind)
+ }
+}
+
func TestSDKRuleMetadataForNonExpandContractMigration(t *testing.T) {
rule := requireRuleMetadata(t, "contracts.non-expand-contract-migration")
assertExecutionModel(t, rule, codeguard.RuleExecutionModelLanguageAgnostic)
diff --git a/tests/codeguard/fix_verification_non_go_test.go b/tests/codeguard/fix_verification_non_go_test.go
index 84832f6..be11299 100644
--- a/tests/codeguard/fix_verification_non_go_test.go
+++ b/tests/codeguard/fix_verification_non_go_test.go
@@ -107,7 +107,7 @@ test("run rethrows the underlying error", () => {
" doThing();",
"- } catch (err) {}",
"+ } catch (err) {",
- "+ throw err;",
+ "+ throw new Error(\"run failed: \" + err.message);",
"+ }",
" }",
" ",
@@ -170,7 +170,7 @@ assert.throws(() => run(), /boom/);
" doThing();",
"- } catch (err) {}",
"+ } catch (err) {",
- "+ throw err;",
+ "+ throw new Error(\"run failed: \" + err.message);",
"+ }",
" }",
" ",
diff --git a/tests/codeguard/fix_verification_test.go b/tests/codeguard/fix_verification_test.go
index 9354543..4b9dc77 100644
--- a/tests/codeguard/fix_verification_test.go
+++ b/tests/codeguard/fix_verification_test.go
@@ -39,11 +39,14 @@ func doThing() error {
`)
writeAPITestFile(t, filepath.Join(dir, "service_test.go"), `package fixverify
-import "testing"
+import (
+ "strings"
+ "testing"
+)
func TestRunReturnsUnderlyingError(t *testing.T) {
- if err := run(); err == nil || err.Error() != "boom" {
- t.Fatalf("run() = %v, want boom", err)
+ if err := run(); err == nil || !strings.Contains(err.Error(), "boom") {
+ t.Fatalf("run() = %v, want error containing boom", err)
}
}
`)
@@ -55,14 +58,21 @@ func TestRunReturnsUnderlyingError(t *testing.T) {
"diff --git a/service.go b/service.go",
"--- a/service.go",
"+++ b/service.go",
- "@@ -3,9 +3,10 @@ import \"errors\"",
+ "@@ -1,11 +1,15 @@",
+ " package fixverify",
+ " ",
+ "-import \"errors\"",
+ "+import (",
+ "+\t\"errors\"",
+ "+\t\"fmt\"",
+ "+)",
" ",
" func run() error {",
"-\terr := doThing()",
"-\t_ = err",
"-\treturn nil",
"+\tif err := doThing(); err != nil {",
- "+\t\treturn err",
+ "+\t\treturn fmt.Errorf(\"run doThing: %w\", err)",
"+\t}",
"+\treturn nil",
" }",
@@ -87,7 +97,7 @@ func TestRunReturnsUnderlyingError(t *testing.T) {
if result.TestResults[0].CheckName != "go test ." {
t.Fatalf("unexpected inferred test command: %#v", result.TestResults[0])
}
- if !strings.Contains(result.Diff, "return err") {
+ if !strings.Contains(result.Diff, "fmt.Errorf") {
t.Fatalf("expected verified diff in result, got %q", result.Diff)
}
}
@@ -155,11 +165,14 @@ func doThing() error {
`)
writeAPITestFile(t, filepath.Join(dir, "service_test.go"), `package fixverify
-import "testing"
+import (
+ "strings"
+ "testing"
+)
func TestRunReturnsUnderlyingError(t *testing.T) {
- if err := run(); err == nil || err.Error() != "boom" {
- t.Fatalf("run() = %v, want boom", err)
+ if err := run(); err == nil || !strings.Contains(err.Error(), "boom") {
+ t.Fatalf("run() = %v, want error containing boom", err)
}
}
`)
@@ -254,11 +267,14 @@ func doThing() error {
`)
writeAPITestFile(t, filepath.Join(dir, "service_test.go"), `package fixverify
-import "testing"
+import (
+ "strings"
+ "testing"
+)
func TestRunReturnsUnderlyingError(t *testing.T) {
- if err := run(); err == nil || err.Error() != "boom" {
- t.Fatalf("run() = %v, want boom", err)
+ if err := run(); err == nil || !strings.Contains(err.Error(), "boom") {
+ t.Fatalf("run() = %v, want error containing boom", err)
}
}
`)
@@ -269,14 +285,21 @@ func TestRunReturnsUnderlyingError(t *testing.T) {
"diff --git a/service.go b/service.go",
"--- a/service.go",
"+++ b/service.go",
- "@@ -3,9 +3,10 @@ import \"errors\"",
+ "@@ -1,11 +1,15 @@",
+ " package fixverify",
+ " ",
+ "-import \"errors\"",
+ "+import (",
+ "+\t\"errors\"",
+ "+\t\"fmt\"",
+ "+)",
" ",
" func run() error {",
"-\terr := doThing()",
"-\t_ = err",
"-\treturn nil",
"+\tif err := doThing(); err != nil {",
- "+\t\treturn err",
+ "+\t\treturn fmt.Errorf(\"run doThing: %w\", err)",
"+\t}",
"+\treturn nil",
" }",