diff --git a/.codeguard/codeguard.yaml b/.codeguard/codeguard.yaml index ce34dd0..a68a1ba 100644 --- a/.codeguard/codeguard.yaml +++ b/.codeguard/codeguard.yaml @@ -24,6 +24,7 @@ checks: security: false prompts: false ci: true + supply_chain: true quality_rules: max_file_lines: 400 max_function_lines: 80 diff --git a/README.md b/README.md index 0f3698a..a4aac3f 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ func main() { ## Docs - [Getting started](/Users/alex/Documents/GitHub/codeguard/docs/getting-started.md:1) +- [Features](/Users/alex/Documents/GitHub/codeguard/docs/features.md:1) - [AI-generated code quality](/Users/alex/Documents/GitHub/codeguard/docs/ai-quality.md:1) - [Agent-native features](/Users/alex/Documents/GitHub/codeguard/docs/agent-native.md:1) - [Integrations](/Users/alex/Documents/GitHub/codeguard/docs/integrations.md:1) @@ -123,3 +124,4 @@ func main() { - [Homebrew packaging](/Users/alex/Documents/GitHub/codeguard/docs/homebrew.md:1) - [Checks reference](/Users/alex/Documents/GitHub/codeguard/docs/checks.md:1) - [Architecture](/Users/alex/Documents/GitHub/codeguard/docs/architecture.md:1) +- [Competitive roadmap](/Users/alex/Documents/GitHub/codeguard/docs/competitive-roadmap.md:1) diff --git a/docs/README.md b/docs/README.md index d0ad2f2..8b4101b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,7 @@ # CodeGuard Docs - [Getting started](getting-started.md) +- [Features](features.md) - [AI-generated code quality](ai-quality.md) - [Agent-native features](agent-native.md) - [Integrations](integrations.md) @@ -10,3 +11,4 @@ - [Homebrew packaging](homebrew.md) - [Architecture](architecture.md) - [Checks](checks.md) +- [Competitive roadmap](competitive-roadmap.md) diff --git a/docs/ai-quality.md b/docs/ai-quality.md index a8b0f82..e9f2480 100644 --- a/docs/ai-quality.md +++ b/docs/ai-quality.md @@ -16,16 +16,27 @@ This brief tracks the AI-generated-code quality features currently implemented i - `quality.ai.provenance-policy` - configurable through `checks.quality_rules.ai_provenance` - supports `CODEGUARD_AI_ASSISTED`-style environment hints and commit trailers such as `AI-Assisted: true` +- Change-risk rollup + - `quality.ai.change-risk` + - `change_risk` report artifact + - configurable through `checks.quality_rules.ai_change_risk` + - aggregates slop-score signals, semantic findings, coverage gaps, diff breadth, and AI provenance into a review-priority score - Consistency-with-codebase checks - `quality.ai.local-idiom-drift` - currently compares test framework choices against the dominant local repository idiom for Go and TypeScript/JavaScript targets - Optional semantic review for AI-assisted diffs - `quality.ai.semantic-doc-mismatch` + - `quality.ai.contract-drift` - `quality.ai.semantic-error-message` - `quality.ai.semantic-test-coverage` + - `quality.ai.semantic-test-adequacy` + - `quality.ai.semantic-runtime` + - request enrichment now adds framework metadata and contract hints for changed Express handlers and middleware, React components, and Next.js route/component files, so semantic runtimes can reason about handlers, props, route segments, request/response semantics, and middleware ordering with better local context - runs for changed files from patch/diff input, or from a git diff against the scan base ref during full scans - requires the semantic runtime to be explicitly enabled either through `ai.enabled` / `--ai` with a command-backed provider, or through `CODEGUARD_SEMANTIC_CHECKS=1` - - shells out to the command in `CODEGUARD_SEMANTIC_COMMAND`, sends a bounded JSON payload on stdin, and expects JSON verdicts on stdout + - uses the command from `ai.provider.type=command` plus `ai.provider.command`/`args` when configured; otherwise it falls back to `CODEGUARD_SEMANTIC_COMMAND` + - sends a bounded JSON payload on stdin and expects JSON verdicts on stdout + - emits `quality.ai.semantic-runtime` at `fail` level when semantic review is enabled but no command is configured, or when the command crashes or returns invalid JSON - caches verdicts by request content hash in a sibling cache file next to the normal scan cache - Hybrid AI triage for static findings - optional provider-backed pass that tries to verify or dismiss existing findings conservatively @@ -79,8 +90,50 @@ All HTTP providers (OpenAI-compatible and Anthropic, in triage and the shared ru - Semantic review is opt-in: - can be enabled through the normal AI runtime or through `CODEGUARD_SEMANTIC_CHECKS=1` - scopes itself to changed files from diff or patch input, or from a git diff against the configured base ref during full scans, plus a small set of nearby test files - - `ai.semantic.function_contract`, `ai.semantic.misleading_error_messages`, and `ai.semantic.test_behavior_coverage` control which semantic prompts are sent + - includes per-file `frameworks` metadata in the JSON request when changed source snapshots match known framework patterns + - includes a structured `prompt` template in the JSON request so command-backed semantic runtimes receive explicit review instructions, response requirements, per-rule focus areas, and framework-specific reasoning guidance + - each framework entry can now carry low-level `signals` plus higher-level `hints` that summarize likely contracts such as `middleware-next-chain`, `component-props-contract`, `client-component`, `route-segment-component`, and `route-handler-contract` + - the prompt template now teaches `quality.ai.contract-drift` and `quality.ai.semantic-test-adequacy` to explicitly reason about props contracts, route `params` or `searchParams`, request or response contract shifts, and Express middleware sequencing + - current framework coverage is still intentionally narrow but broader than the first slice: Express route and middleware modules, React component files, and Next.js route/component conventions (`app/**/route.*`, `pages/api/**`, `app/**/page.*`, `app/**/layout.*`, `app/**/loading.*`, `app/**/error.*`, and `next/server` request/response patterns) + - `ai.semantic.function_contract`, `ai.semantic.contract_drift`, `ai.semantic.misleading_error_messages`, `ai.semantic.test_behavior_coverage`, and `ai.semantic.test_adequacy` control which semantic prompts are sent - the external semantic command must read a JSON request from stdin and return `{"verdicts":[...]}` with `rule_id`, `path`, `line`, `level`, and `message` + +### Reference semantic runner + +The repo now ships a scaffold runner at `examples/semantic/reference_runner.py`. + +Example wiring: + +```bash +export CODEGUARD_SEMANTIC_CHECKS=1 +export CODEGUARD_SEMANTIC_COMMAND="python3 examples/semantic/reference_runner.py" +``` + +What it does: +- reads the semantic request JSON from stdin +- renders the canonical prompt text from `prompt`, `frameworks`, `diff`, `source_files`, and `test_files` +- prints that prompt to stderr when `CODEGUARD_SEMANTIC_REFERENCE_PRINT_PROMPT=1` +- defaults to scaffold mode and returns `{"verdicts":[]}` so it is safe as a no-op + +Backend modes: + +- scaffold mode: + - `CODEGUARD_SEMANTIC_REFERENCE_MODE=scaffold` + - returns an empty `verdicts` array +- local command mode: + - `CODEGUARD_SEMANTIC_REFERENCE_MODE=command` + - `CODEGUARD_SEMANTIC_REFERENCE_LOCAL_COMMAND="python3 my-semantic-backend.py"` + - sends `{"request":,"prompt_text":"..."}` to the backend command on stdin + - expects `{"verdicts":[...]}` on stdout +- OpenAI-compatible mode: + - `CODEGUARD_SEMANTIC_REFERENCE_MODE=openai` + - `CODEGUARD_SEMANTIC_REFERENCE_OPENAI_BASE_URL=https://api.openai.com/v1` + - `CODEGUARD_SEMANTIC_REFERENCE_OPENAI_API_KEY=...` + - `CODEGUARD_SEMANTIC_REFERENCE_OPENAI_MODEL=gpt-5` + - posts the rendered prompt to `/chat/completions` and expects the model message content to be a JSON object with `verdicts` + +This is intended as the canonical starting point for custom semantic commands. The prompt assembly stays fixed in one place, and you can swap only the backend transport. + - Verified auto-fix is fail-closed: - fix generation requires an explicit AI provider plus `-ai` - the proposed diff must apply cleanly, pass a diff-scoped `codeguard` rerun, and pass inferred or explicit verification tests diff --git a/docs/architecture.md b/docs/architecture.md index 33d0a8c..effee19 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,6 +24,7 @@ The public SDK stays stable for consumers at `github.com/devr-tools/codeguard/pk - `internal/codeguard/checks/security/` holds heuristic and vulnerability checks - `internal/codeguard/checks/prompts/` holds prompt-safety checks - `internal/codeguard/checks/ci/` holds repository and workflow policy checks +- `internal/codeguard/checks/supplychain/` holds dependency-policy and SBOM-oriented checks - `internal/codeguard/checks/support/` holds the shared adapter surface used by the check packages This split keeps a single implementation path while leaving room to add language-specific rules under each category as the scanner expands beyond Go. diff --git a/docs/checks.md b/docs/checks.md index e64cecd..fcc462d 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -11,13 +11,78 @@ This file documents the current check categories in `codeguard` and the config k "design": true, "security": true, "prompts": true, - "ci": true + "ci": true, + "supply_chain": false } } ``` Each top-level boolean enables or disables an entire check family. +`supply_chain` is opt-in and currently covers normalized manifest parsing plus initial policy checks for missing lockfiles, content-based lockfile drift validation, unpinned dependencies, and dependency license policy resolved from local manifest and installed metadata where available. + +For ecosystems where local metadata is not present, `supply_chain_rules.license_commands` can provide an opt-in per-ecosystem command that prints JSON license mappings for unresolved dependencies. + +Each license command receives structured context through environment variables: +- `CODEGUARD_SUPPLY_CHAIN_ECOSYSTEM` +- `CODEGUARD_SUPPLY_CHAIN_MANIFEST_PATH` +- `CODEGUARD_SUPPLY_CHAIN_TARGET_NAME` +- `CODEGUARD_SUPPLY_CHAIN_TARGET_PATH` +- `CODEGUARD_SUPPLY_CHAIN_UNRESOLVED_NAMES` +- `CODEGUARD_SUPPLY_CHAIN_UNRESOLVED_COORDINATES` +- `CODEGUARD_SUPPLY_CHAIN_CONTEXT_FILE` + +`CODEGUARD_SUPPLY_CHAIN_CONTEXT_FILE` points to a JSON payload containing the ecosystem, manifest path, target metadata, and unresolved dependency entries with: +- `coordinate` +- `name` +- `requirement` +- `version` +- `scope` +- `groups` +- `indirect` +- `pinned` +- `line` + +License commands may return either `name`-keyed results for backward compatibility or `coordinate`-keyed results such as `left-pad@1.3.0` to disambiguate multiple versions of the same dependency. + +Supported result shapes: + +```json +[ + { + "coordinate": "left-pad@1.3.0", + "license": "MIT", + "source": "license-command" + } +] +``` + +Or a richer candidate form: + +```json +[ + { + "coordinate": "left-pad@1.3.0", + "candidates": [ + { + "license": "MIT", + "confidence": "high", + "provenance": "spdx-expression", + "source": "license-command" + }, + { + "license": "GPL-3.0", + "confidence": "low", + "provenance": "heuristic-text-match", + "source": "license-command" + } + ] + } +] +``` + +When multiple candidates are returned, CodeGuard prefers stronger evidence such as explicit SPDX provenance or high-confidence results. Heuristic-only matches still inform policy, but they are surfaced as weaker evidence rather than treated the same as definitive metadata. + ## Exclusions Purpose: @@ -187,7 +252,13 @@ Current behavior: - includes an AI-failure-mode pack for swallowed errors, narrative comments, hallucinated imports, plausible dead code, over-mocked tests, and codebase-idiom drift in Go, TypeScript, and JavaScript targets - publishes a `slop_score` artifact in the report when AI-failure-mode signals are present so CI systems can trend the metric over time - can apply a provenance-aware policy for AI-assisted changes through `quality_rules.ai_provenance` using environment hints or commit trailers -- can optionally run command-backed semantic review for changed files from diff/patch input, or from a git diff against the scan base ref during full scans, when a semantic runtime is enabled and `CODEGUARD_SEMANTIC_COMMAND` is set +- can publish a `change_risk` artifact and emit `quality.ai.change-risk` when AI-style and review-risk signals accumulate past configured thresholds +- can optionally run command-backed semantic review for changed files from diff/patch input, or from a git diff against the scan base ref during full scans, when a semantic runtime is enabled and a semantic command is configured either through `ai.provider.type=command` plus `ai.provider.command`/`args`, or through `CODEGUARD_SEMANTIC_COMMAND` +- if semantic review is enabled but no semantic command is configured, or the command crashes or returns invalid JSON, the scan emits `quality.ai.semantic-runtime` at `fail` level instead of silently skipping semantic coverage +- semantic review can also emit `quality.ai.contract-drift` when a changed function appears to silently drift from its prior behavior or nearby contract signals +- semantic review can also emit `quality.ai.semantic-test-adequacy` when nearby tests appear too weak, too happy-path, or otherwise inadequate for the changed behavior +- semantic review request payloads now include lightweight framework metadata plus contract hints for changed Express handlers and middleware, React components, and Next.js route/component files so external semantic runtimes can reason with handler-aware and component-aware context +- semantic review request payloads also include a structured `prompt` template with per-rule focus and framework-specific reasoning guidance, so command-backed runtimes do not have to invent their own contract-drift or test-adequacy instructions from scratch - TypeScript and JavaScript quality built-ins use AST-derived function metrics and compiler-parsed syntax when the semantic runtime is available - includes native maintainability heuristics for Python, TypeScript, JavaScript, Rust, Java, C#, and Ruby targets - TypeScript and JavaScript targets also warn on `@ts-ignore`, `@ts-nocheck`, `@ts-expect-error`, explicit `any`, double assertions, non-null assertions, and committed `debugger` statements @@ -212,6 +283,23 @@ AI provenance example: } ``` +AI change-risk example: + +```json +{ + "checks": { + "quality": true, + "quality_rules": { + "ai_change_risk": { + "enabled": true, + "warn_threshold": 30, + "fail_threshold": 60 + } + } + } +} +``` + Language command example: ```json diff --git a/docs/competitive-roadmap.md b/docs/competitive-roadmap.md new file mode 100644 index 0000000..fd4dfda --- /dev/null +++ b/docs/competitive-roadmap.md @@ -0,0 +1,269 @@ +# Competitive Roadmap + +This roadmap turns the current `codeguard` feature set into a concrete next-build plan. It is ordered by product leverage, implementation fit with the existing codebase, and how much reusable infrastructure each step creates for the next one. + +## Current position + +The repo already has useful primitives to build on: + +- diff-aware scan orchestration in [internal/codeguard/runner/runner.go](/Users/alex/Documents/GitHub/codeguard/internal/codeguard/runner/runner.go:1) +- patch materialization and patch validation in [internal/codeguard/runner/support/patch.go](/Users/alex/Documents/GitHub/codeguard/internal/codeguard/runner/support/patch.go:1) +- single-finding verified fix in [internal/codeguard/ai/fix/verify.go](/Users/alex/Documents/GitHub/codeguard/internal/codeguard/ai/fix/verify.go:1) +- prompt and agent-config governance in [internal/codeguard/checks/prompts/prompts.go](/Users/alex/Documents/GitHub/codeguard/internal/codeguard/checks/prompts/prompts.go:1) +- AI quality signals and `slop_score` artifacts in [internal/codeguard/checks/quality/quality_ai.go](/Users/alex/Documents/GitHub/codeguard/internal/codeguard/checks/quality/quality_ai.go:1) +- lightweight dependency graph support in [internal/codeguard/checks/support/dependency_graph.go](/Users/alex/Documents/GitHub/codeguard/internal/codeguard/checks/support/dependency_graph.go:1) and [internal/codeguard/checks/support/gomod.go](/Users/alex/Documents/GitHub/codeguard/internal/codeguard/checks/support/gomod.go:1) +- language-specific taint engines in `internal/codeguard/checks/security/` + +The gap is not raw capability; it is packaging these primitives into broader product workflows. + +## Execution principles + +- Prefer adding new packages over overloading existing `quality` and `security` families with unrelated concerns. +- Land parser and artifact infrastructure before policy logic. +- Expose every major capability through CLI, SDK, and MCP-facing surfaces where practical. +- Keep diff-mode behavior first-class. Most competitive value comes from PR-time analysis, not only full-repo scans. +- Ship findings plus machine-readable artifacts so downstream agents can reason over rankings, fix queues, and provenance. + +## 1. Real `supply_chain` check family + +This should be the next major family, not an extension of `quality` or `security`. It has enough surface area to justify its own config, rule catalog entries, docs, and tests. + +### Scope + +- dependency graphing across manifest formats +- SBOM output +- license policy enforcement +- lockfile drift detection +- unpinned dependency detection +- manifest parsing for `go.mod`, `package.json`, `requirements*.txt`, `pyproject.toml`, and `Cargo.toml` + +### Suggested shape + +- Add `checks.supply_chain` and `checks.supply_chain_rules` to [internal/codeguard/core/config_types.go](/Users/alex/Documents/GitHub/codeguard/internal/codeguard/core/config_types.go:1) and [internal/codeguard/core/config_rule_types.go](/Users/alex/Documents/GitHub/codeguard/internal/codeguard/core/config_rule_types.go:1). +- Add `internal/codeguard/checks/supplychain/` for rule execution. +- Add manifest parsers under `internal/codeguard/checks/support/` so they can be reused by risk scoring and fix planning later. +- Add SBOM and dependency graph artifacts alongside the existing artifact model in `internal/codeguard/core/report_artifact_types.go`. + +### Milestones + +1. Manifest substrate + - Parse the five target manifest families into one normalized dependency model. + - Preserve package name, version/range, source file, lockfile linkage, dev/runtime scope, and whether the dependency is pinned. +2. Policy rules + - Add rules for unpinned dependencies, missing lockfiles, lockfile drift, and denied licenses. + - Start with deterministic local policy. Defer network-backed advisories unless a stable offline cache story exists. +3. Graph and SBOM artifacts + - Emit a machine-readable dependency graph artifact. + - Emit SPDX or CycloneDX JSON as a report artifact and optional CLI output target. +4. PR ergonomics + - Diff-aware findings should only fail changed manifests and newly introduced violations. + +### Acceptance criteria + +- `codeguard scan` can detect at least one dependency policy issue in each supported ecosystem. +- report artifacts include a normalized dependency graph and SBOM payload. +- lockfile drift works for npm, pip, Cargo, and Go modules where a lock concept exists. +- docs and rule catalog explain exactly which ecosystems and lockfile semantics are supported. + +### Agent task split + +- Agent A: config and rule-catalog scaffolding +- Agent B: manifest parsers plus normalized dependency model +- Agent C: policy rules for pinning, lockfile drift, and license allow/deny lists +- Agent D: SBOM and dependency graph artifact emission +- Agent E: black-box tests in `tests/checks/` and CLI/report coverage + +## 2. Preventive secret protection + +Current prompt governance detects risky secret usage, but the competitive step is prevention before a bad patch lands. + +### Scope + +- patch-time secret rejection +- custom secret patterns +- secret-type classification +- optional validity-check adapters + +### Suggested shape + +- Keep prompt-oriented governance in `prompts`, but move general secret prevention into a shared secret engine under `internal/codeguard/checks/support/`. +- Run the same detector during full scans and `validate-patch` / `RunPatch`. +- Add a structured secret classification result so agents do not only receive a generic "hardcoded secret" finding. + +### Milestones + +1. Shared secret matcher + - Replace the current regex-only secret checks with a reusable matcher library. + - Support built-in detectors for API keys, bearer tokens, passwords, private keys, webhook URLs, and cloud credentials. +2. Patch-time blocking + - Reject diffs that introduce classified secrets, not just files that already contain them. + - Preserve changed-line scope in findings so patch validation stays precise. +3. Custom patterns and config + - Add config-driven custom secret detectors with rule ID prefixing and severity selection. +4. Optional validity adapters + - Add an adapter interface for external secret validation commands. + - Keep adapters opt-in and fail-closed only when explicitly configured. + +### Acceptance criteria + +- `codeguard validate-patch` fails when a patch introduces a known secret pattern. +- findings include secret type metadata that distinguishes hardcoded token, private key, password, and interpolation exposure cases. +- custom patterns are configurable without code changes. + +### Agent task split + +- Agent A: shared matcher and classification types +- Agent B: patch-validation integration and diff-aware tests +- Agent C: config surface and validation +- Agent D: optional adapter execution model and report plumbing + +## 3. Batch verified fix + +The current verified-fix flow is a good primitive, but it is too narrow. The product should be able to remediate a safe subset of findings in one run. + +### Scope + +- select top safe findings in a PR or repo slice +- generate multiple candidate patches +- verify them in sequence or in isolated batches +- stop on conflicts or verification regressions + +### Suggested shape + +- Keep single-finding verification intact. +- Add a planner layer above [internal/codeguard/ai/fix/verify.go](/Users/alex/Documents/GitHub/codeguard/internal/codeguard/ai/fix/verify.go:1), rather than making `Verify` itself stateful. +- Represent remediation as a queue of finding-targeted operations plus merge/conflict metadata. + +### Milestones + +1. Fix planning + - Rank findings by safety and fixability. + - Exclude overlapping files, conflicting hunks, and rules that are not auto-fix-safe. +2. Batch execution + - Materialize a workspace, apply one candidate at a time, rerun targeted verification, and keep only passing patches. +3. UX surfaces + - Add SDK and CLI entrypoints for "fix top findings" and "fix findings in diff scope". + - Extend MCP tools after the SDK contract is stable. +4. Reporting + - Return applied, skipped, conflicted, and failed-verification buckets. + +### Acceptance criteria + +- one command can safely fix multiple independent findings in a repo slice +- verification remains fail-closed +- outputs clearly explain why a finding was skipped + +### Agent task split + +- Agent A: finding ranking and safe-fix eligibility +- Agent B: patch queue executor and conflict detection +- Agent C: CLI and SDK surface +- Agent D: tests for partial success, rollback, and conflicting edits + +## 4. Risk scoring and hotspot ranking + +`slop_score` is useful but too narrow. The next step is a broader risk model that helps reviewers and agents prioritize what matters first. + +### Scope + +- file-level risk ranking +- PR-level hotspot ranking +- inputs from churn, severity, taint reachability, coverage delta, and AI provenance + +### Suggested shape + +- Emit risk as a dedicated artifact rather than overloading finding severity. +- Keep scoring explainable: every score should list the weighted signals that produced it. +- Reuse dependency, semantic, and provenance artifacts instead of recomputing signals ad hoc. + +### Milestones + +1. Data model + - Add artifact types for `file_risk` and `pr_hotspots`. + - Define transparent factor weights in config with stable defaults. +2. Signal collection + - Reuse changed files, coverage delta, taint findings, AI provenance, and future supply-chain signals. +3. Ranking and presentation + - Sort files and PR slices by composite score. + - Surface "why this ranked high" in text, JSON, and GitHub comment output. + +### Acceptance criteria + +- scans produce a deterministic file and PR ranking artifact +- each rank entry shows contributing signals and weights +- diff-mode reports identify the top risky changed files even when finding counts are low + +### Agent task split + +- Agent A: artifact schema and config +- Agent B: score computation pipeline +- Agent C: reporter integration for text, JSON, and GitHub comment modes +- Agent D: tests for explainability and stable ordering + +## 5. Framework-aware semantic models + +This is the deepest technical investment and should come after the supply-chain and secret-prevention foundations are in place. + +### Scope + +- framework-specific sources, sinks, and sanitizers for Express and Next.js +- framework-specific sources, sinks, and sanitizers for Django and FastAPI +- common Go HTTP and database stack models +- reusable model and query packs + +### Suggested shape + +- Keep language parser and taint engines where they are. +- Add framework model packs as data-driven overlays where possible, not hardcoded one-offs inside each scanner. +- Separate "semantic facts extraction" from "framework rule packs" so new frameworks do not require parser rewrites. + +### Milestones + +1. Model-pack format + - Define framework source, sink, sanitizer, and router-binding descriptors. +2. Runtime integration + - Load model packs into the Go, Python, and TypeScript taint engines. +3. Query packs + - Add reusable security and quality queries that consume framework facts. +4. Regression suite + - Add realistic framework fixtures in `tests/checks/` instead of only unit-scale snippets. + +### Acceptance criteria + +- framework-aware taint results catch issues that the current generic models miss +- false positives do not regress sharply on existing tests +- model packs can be extended without changing parser internals + +### Agent task split + +- Agent A: model-pack schema and loader +- Agent B: Express and Next.js bindings +- Agent C: Django and FastAPI bindings +- Agent D: Go HTTP and database sink coverage +- Agent E: fixture-heavy regression tests + +## Recommended sequencing + +1. `supply_chain` family +2. preventive secret protection +3. batch verified fix +4. risk scoring and hotspot ranking +5. framework-aware semantic models + +This order matters: + +- `supply_chain` creates a normalized dependency model that risk scoring can consume later. +- preventive secret protection strengthens patch-time governance before batch auto-remediation becomes broader. +- batch verified fix becomes more valuable once safer finding selection and richer risk metadata exist. +- framework-aware models are high leverage, but they are also the most open-ended and should build on the stronger artifact and ranking story. + +## First implementation batch + +If work starts immediately, the best first batch is: + +1. add config, runner, and rule scaffolding for `supply_chain` +2. implement normalized manifest parsing for `go.mod`, `package.json`, `requirements*.txt`, `pyproject.toml`, and `Cargo.toml` +3. add patch-time secret rejection using a shared secret classifier +4. define the batch verified-fix planner interface without changing current single-finding verification behavior + +That batch produces reusable infrastructure and avoids overcommitting to scoring formulas or framework model details too early. diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..3ebb122 --- /dev/null +++ b/docs/features.md @@ -0,0 +1,171 @@ +# Features + +This page lists the current `codeguard` feature surface and the main config entrypoints for operators. + +## Core scan families + +- `quality` + - maintainability thresholds + - clone detection + - language-native quality heuristics for Go, Python, TypeScript, JavaScript, Rust, Java, C#, and Ruby + - 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 +- `design` + - layering and boundary rules + - import cycle and god-module detection + - high-impact-change analysis and dependency graph artifacts +- `security` + - hardcoded secrets and private keys + - Go, Python, TypeScript, and JavaScript taint-style flow checks + - insecure API heuristics + - optional `govulncheck` +- `prompts` + - prompt-asset governance + - agent config and MCP config checks + - dangerous instruction and standing-permission detection +- `ci` + - workflow/release policy + - test-quality heuristics +- `supply_chain` + - manifest normalization + - lockfile presence and drift validation + - unpinned dependency detection + - dependency and manifest license policy + +## Agent-native features + +- `codeguard serve --mcp` +- `codeguard validate-patch` +- `codeguard explain -format agent` +- verified auto-fix through SDK and CLI +- hook-pack examples for Claude Code and Cursor + +## AI-specific features + +- `slop_score` artifact +- AI provenance policy +- hybrid AI triage +- semantic review: + - `quality.ai.semantic-doc-mismatch` + - `quality.ai.contract-drift` + - `quality.ai.semantic-error-message` + - `quality.ai.semantic-test-coverage` + - `quality.ai.semantic-test-adequacy` + - framework-aware request enrichment for changed Express handlers, middleware chains, React components, and Next.js route/component files so the semantic runtime sees higher-level contract context, not just raw file text + - built-in reference semantic runner scaffold at `examples/semantic/reference_runner.py` for `CODEGUARD_SEMANTIC_COMMAND` + - reference runner supports scaffold, local-command, and OpenAI-compatible backend modes without changing prompt assembly +- `quality.ai.change-risk` + - aggregates AI-quality and review-risk signals into a target-level artifact plus a `Code Quality` finding when thresholds are crossed + +## JSON/YAML config examples + +### Enable AI change risk + +YAML: + +```yaml +checks: + quality: true + quality_rules: + ai_change_risk: + enabled: true + warn_threshold: 30 + fail_threshold: 60 +``` + +JSON: + +```json +{ + "checks": { + "quality": true, + "quality_rules": { + "ai_change_risk": { + "enabled": true, + "warn_threshold": 30, + "fail_threshold": 60 + } + } + } +} +``` + +### AI provenance policy + +YAML: + +```yaml +checks: + quality: true + quality_rules: + ai_provenance: + enabled: true + env_vars: + - CODEGUARD_AI_ASSISTED + commit_trailers: + - AI-Assisted + - AI-Generated + slop_score_warn_threshold: 20 + slop_score_fail_threshold: 40 +``` + +JSON: + +```json +{ + "checks": { + "quality": true, + "quality_rules": { + "ai_provenance": { + "enabled": true, + "env_vars": ["CODEGUARD_AI_ASSISTED"], + "commit_trailers": ["AI-Assisted", "AI-Generated"], + "slop_score_warn_threshold": 20, + "slop_score_fail_threshold": 40 + } + } + } +} +``` + +### Supply-chain license commands + +YAML: + +```yaml +checks: + supply_chain: true + supply_chain_rules: + denied_licenses: + - GPL-3.0 + license_commands: + npm: + name: npm-license-resolver + command: ./scripts/resolve-npm-licenses.sh +``` + +JSON: + +```json +{ + "checks": { + "supply_chain": true, + "supply_chain_rules": { + "denied_licenses": ["GPL-3.0"], + "license_commands": { + "npm": { + "name": "npm-license-resolver", + "command": "./scripts/resolve-npm-licenses.sh" + } + } + } + } +} +``` + +## Next queued AI features + +These are the tracks currently being planned for follow-up implementation: + +- batch verified fix planning +- `agent.permission-risk` diff --git a/examples/semantic/reference_runner.py b/examples/semantic/reference_runner.py new file mode 100644 index 0000000..3fc7ce5 --- /dev/null +++ b/examples/semantic/reference_runner.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +Reference semantic runner for CODEGUARD_SEMANTIC_COMMAND. + +This script is intentionally conservative: +- it reads the semantic-review request JSON from stdin +- it renders the canonical prompt text from request.prompt plus local evidence +- it can either return {"verdicts": []}, call a local command, or call an + OpenAI-compatible chat/completions endpoint without changing the prompt assembly + +Use it as a scaffold for a real model-backed semantic command. +Set CODEGUARD_SEMANTIC_REFERENCE_PRINT_PROMPT=1 to print the rendered prompt to stderr. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request +from typing import Any + +DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1" +DEFAULT_OPENAI_MODEL = "gpt-5" + + +def main() -> int: + request = json.load(sys.stdin) + prompt = render_prompt(request) + if os.getenv("CODEGUARD_SEMANTIC_REFERENCE_PRINT_PROMPT", "").strip().lower() in { + "1", + "true", + "yes", + "on", + }: + sys.stderr.write(prompt) + if not prompt.endswith("\n"): + sys.stderr.write("\n") + json.dump(evaluate(request, prompt), sys.stdout) + return 0 + + +def evaluate(request: dict[str, Any], prompt: str) -> dict[str, Any]: + mode = (os.getenv("CODEGUARD_SEMANTIC_REFERENCE_MODE", "scaffold") or "").strip().lower() + if mode in {"", "scaffold"}: + return {"verdicts": []} + if mode == "command": + return run_local_command(request, prompt) + if mode == "openai": + return run_openai_compatible(request, prompt) + raise SystemExit(f"unsupported CODEGUARD_SEMANTIC_REFERENCE_MODE: {mode}") + + +def run_local_command(request: dict[str, Any], prompt: str) -> dict[str, Any]: + command = (os.getenv("CODEGUARD_SEMANTIC_REFERENCE_LOCAL_COMMAND", "") or "").strip() + if not command: + raise SystemExit("CODEGUARD_SEMANTIC_REFERENCE_LOCAL_COMMAND is required for command mode") + payload = json.dumps({"request": request, "prompt_text": prompt}) + completed = subprocess.run( + command, + input=payload, + text=True, + shell=True, + capture_output=True, + check=False, + ) + if completed.returncode != 0: + raise SystemExit( + "semantic reference local command failed: " + f"{completed.returncode}: {completed.stderr.strip()}" + ) + return parse_verdict_response(completed.stdout) + + +def run_openai_compatible(request: dict[str, Any], prompt: str) -> dict[str, Any]: + base_url = (os.getenv("CODEGUARD_SEMANTIC_REFERENCE_OPENAI_BASE_URL", "") or "").strip() + if not base_url: + base_url = DEFAULT_OPENAI_BASE_URL + model = (os.getenv("CODEGUARD_SEMANTIC_REFERENCE_OPENAI_MODEL", "") or "").strip() + if not model: + model = DEFAULT_OPENAI_MODEL + api_key = (os.getenv("CODEGUARD_SEMANTIC_REFERENCE_OPENAI_API_KEY", "") or "").strip() + body = { + "model": model, + "response_format": {"type": "json_object"}, + "messages": [ + { + "role": "system", + "content": ( + "You are a semantic code reviewer. Return JSON only with the shape " + '{"verdicts":[{"rule_id":"...","path":"...","line":1,"level":"warn","message":"..."}]}. ' + "If the local evidence is insufficient, return an empty verdicts array." + ), + }, + {"role": "user", "content": prompt}, + ], + } + req = urllib.request.Request( + base_url.rstrip("/") + "/chat/completions", + data=json.dumps(body).encode("utf-8"), + headers={ + "Content-Type": "application/json", + **({"Authorization": f"Bearer {api_key}"} if api_key else {}), + }, + method="POST", + ) + try: + with urllib.request.urlopen(req) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as err: + detail = err.read().decode("utf-8", errors="replace") + raise SystemExit(f"semantic reference OpenAI request failed: {err.code}: {detail}") from err + except urllib.error.URLError as err: + raise SystemExit(f"semantic reference OpenAI request failed: {err.reason}") from err + + choices = payload.get("choices") or [] + if not choices: + raise SystemExit("semantic reference OpenAI response contained no choices") + message = ((choices[0] or {}).get("message") or {}).get("content") + if isinstance(message, list): + message = "".join( + part.get("text", "") for part in message if isinstance(part, dict) + ) + if not isinstance(message, str) or not message.strip(): + raise SystemExit("semantic reference OpenAI response contained empty content") + return parse_verdict_response(message) + + +def parse_verdict_response(raw: str) -> dict[str, Any]: + try: + payload = json.loads(raw) + except json.JSONDecodeError as err: + raise SystemExit(f"semantic reference backend returned invalid JSON: {err}") from err + if not isinstance(payload, dict): + raise SystemExit("semantic reference backend must return a JSON object") + verdicts = payload.get("verdicts") + if not isinstance(verdicts, list): + raise SystemExit('semantic reference backend must return {"verdicts":[...]}') + return payload + + +def render_prompt(request: dict[str, Any]) -> str: + lines: list[str] = [] + prompt = request.get("prompt") or {} + + lines.append("Semantic review request") + lines.append("") + append_if_value(lines, "Target", request.get("target_name")) + append_if_value(lines, "Path", request.get("target_path")) + append_if_value(lines, "Language", request.get("language")) + append_if_value(lines, "Base ref", request.get("base_ref")) + lines.append("") + + overview = prompt.get("overview") + if overview: + lines.append("Overview") + lines.append(overview) + lines.append("") + + requirements = prompt.get("response_requirements") or [] + if requirements: + lines.append("Response requirements") + for item in requirements: + lines.append(f"- {item}") + lines.append("") + + checks = request.get("checks") or [] + if checks: + lines.append("Requested checks") + for check in checks: + lines.append(f"- {check.get('rule_id')}: {check.get('title', '')}".rstrip(": ")) + description = check.get("description") + if description: + lines.append(f" Description: {description}") + lines.append("") + + rule_instructions = prompt.get("rule_instructions") or [] + if rule_instructions: + lines.append("Rule-specific instructions") + for rule in rule_instructions: + lines.append(f"- {rule.get('rule_id')}") + focus = rule.get("focus") + if focus: + lines.append(f" Focus: {focus}") + for item in rule.get("consider") or []: + lines.append(f" Consider: {item}") + for item in rule.get("avoid") or []: + lines.append(f" Avoid: {item}") + threshold = rule.get("threshold") + if threshold: + lines.append(f" Threshold: {threshold}") + lines.append("") + + frameworks = request.get("frameworks") or [] + framework_instructions = prompt.get("framework_instructions") or [] + if frameworks or framework_instructions: + lines.append("Framework context") + for item in framework_instructions: + lines.append(f"- {item.get('name')} {item.get('path')}".strip()) + hints = item.get("hints") or [] + if hints: + lines.append(f" Hints: {', '.join(hints)}") + for advice in item.get("advice") or []: + lines.append(f" Advice: {advice}") + if frameworks and not framework_instructions: + for item in frameworks: + lines.append(f"- {item.get('name')} {item.get('path')}".strip()) + hints = item.get("hints") or [] + if hints: + lines.append(f" Hints: {', '.join(hints)}") + signals = item.get("signals") or [] + if signals: + lines.append(f" Signals: {', '.join(signals)}") + lines.append("") + + changed_files = request.get("changed_files") or [] + if changed_files: + lines.append("Changed files") + for path in changed_files: + lines.append(f"- {path}") + lines.append("") + + diff_text = request.get("diff") or "" + if diff_text: + lines.append("Diff") + lines.append("```diff") + lines.append(diff_text.rstrip("\n")) + lines.append("```") + lines.append("") + + append_snapshots(lines, "Source snapshots", request.get("source_files") or []) + append_snapshots(lines, "Test snapshots", request.get("test_files") or []) + lines.append('Return JSON only: {"verdicts":[{"rule_id":"...","path":"...","line":1,"level":"warn","message":"..."}]}') + return "\n".join(lines).rstrip() + "\n" + + +def append_snapshots(lines: list[str], heading: str, snapshots: list[dict[str, Any]]) -> None: + if not snapshots: + return + lines.append(heading) + for snapshot in snapshots: + path = snapshot.get("path", "") + content = (snapshot.get("content") or "").rstrip("\n") + lines.append(f"File: {path}") + lines.append("```") + lines.append(content) + lines.append("```") + lines.append("") + + +def append_if_value(lines: list[str], label: str, value: Any) -> None: + if value is None: + return + text = str(value).strip() + if text: + lines.append(f"{label}: {text}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/internal/codeguard/ai/semantic/cache.go b/internal/codeguard/ai/semantic/cache.go index b9fa9cf..afb89ed 100644 --- a/internal/codeguard/ai/semantic/cache.go +++ b/internal/codeguard/ai/semantic/cache.go @@ -11,7 +11,7 @@ import ( ) const ( - requestVersion = 1 + requestVersion = 6 cacheVersion = 1 ) @@ -48,30 +48,7 @@ func (cache *verdictCache) save() error { } func requestHash(req Request) string { - payload := struct { - Version int `json:"version"` - Runtime string `json:"runtime"` - TargetName string `json:"target_name"` - Language string `json:"language"` - BaseRef string `json:"base_ref,omitempty"` - Diff string `json:"diff,omitempty"` - ChangedFiles []string `json:"changed_files,omitempty"` - Checks []CheckSpec `json:"checks"` - SourceFiles []FileSnapshot `json:"source_files,omitempty"` - TestFiles []FileSnapshot `json:"test_files,omitempty"` - }{ - Version: req.Version, - Runtime: req.Runtime, - TargetName: req.TargetName, - Language: req.Language, - BaseRef: req.BaseRef, - Diff: req.Diff, - ChangedFiles: req.ChangedFiles, - Checks: req.Checks, - SourceFiles: req.SourceFiles, - TestFiles: req.TestFiles, - } - data, err := json.Marshal(payload) + data, err := json.Marshal(req.hashPayload()) if err != nil { return "" } diff --git a/internal/codeguard/ai/semantic/frameworks.go b/internal/codeguard/ai/semantic/frameworks.go new file mode 100644 index 0000000..099a246 --- /dev/null +++ b/internal/codeguard/ai/semantic/frameworks.go @@ -0,0 +1,43 @@ +package semantic + +import "sort" + +func detectFrameworks(files []FileSnapshot) []FrameworkRef { + frameworks := make([]FrameworkRef, 0) + for _, file := range files { + signals := expressSignals(file) + if len(signals) > 0 { + frameworks = append(frameworks, FrameworkRef{ + Name: "express", + Path: file.Path, + Signals: signals, + Hints: expressHints(file), + }) + } + signals = nextJSSignals(file) + if len(signals) > 0 { + frameworks = append(frameworks, FrameworkRef{ + Name: "nextjs", + Path: file.Path, + Signals: signals, + Hints: nextJSHints(file), + }) + } + signals = reactSignals(file) + if len(signals) > 0 { + frameworks = append(frameworks, FrameworkRef{ + Name: "react", + Path: file.Path, + Signals: signals, + Hints: reactHints(file), + }) + } + } + sort.Slice(frameworks, func(i, j int) bool { + if frameworks[i].Name == frameworks[j].Name { + return frameworks[i].Path < frameworks[j].Path + } + return frameworks[i].Name < frameworks[j].Name + }) + return frameworks +} diff --git a/internal/codeguard/ai/semantic/frameworks_express.go b/internal/codeguard/ai/semantic/frameworks_express.go new file mode 100644 index 0000000..80299ed --- /dev/null +++ b/internal/codeguard/ai/semantic/frameworks_express.go @@ -0,0 +1,36 @@ +package semantic + +import "strings" + +func expressSignals(file FileSnapshot) []string { + content := file.Content + signals := make([]string, 0, 4) + if containsAny(content, `from "express"`, "from 'express'", `require("express")`, `require('express')`) { + signals = append(signals, "express-import") + } + if strings.Contains(content, "express.Router(") || strings.Contains(content, ".Router(") || strings.Contains(content, "Router()") { + signals = append(signals, "express-router") + } + if containsAny(content, ".get(", ".post(", ".put(", ".patch(", ".delete(", ".use(") { + signals = append(signals, "http-route-handler") + } + return uniqueSortedStrings(signals) +} + +func expressHints(file FileSnapshot) []string { + content := file.Content + hints := make([]string, 0, 4) + if containsAny(content, ".use(", "next()", "next)") { + hints = append(hints, "middleware-order-sensitive") + } + if containsAny(content, "next: NextFunction", " next)", ", next)", "(req, res, next)", "(request, response, next)") { + hints = append(hints, "middleware-next-chain") + } + if containsAny(content, "req.", "request.", ": Request") { + hints = append(hints, "request-derived-contract") + } + if containsAny(content, "res.", "response.", "Response") { + hints = append(hints, "response-side-effects") + } + return uniqueSortedStrings(hints) +} diff --git a/internal/codeguard/ai/semantic/frameworks_helpers.go b/internal/codeguard/ai/semantic/frameworks_helpers.go new file mode 100644 index 0000000..ffdecd7 --- /dev/null +++ b/internal/codeguard/ai/semantic/frameworks_helpers.go @@ -0,0 +1,84 @@ +package semantic + +import ( + "sort" + "strings" +) + +func containsAny(content string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(content, needle) { + return true + } + } + return false +} + +func hasAnySuffix(value string, suffixes ...string) bool { + for _, suffix := range suffixes { + if strings.HasSuffix(value, suffix) { + return true + } + } + return false +} + +func containsComponentExport(content string) bool { + lines := strings.Split(content, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trimmed, "export default function ") && hasPascalCaseName(trimmed, "export default function "): + return true + case strings.HasPrefix(trimmed, "export function ") && hasPascalCaseName(trimmed, "export function "): + return true + case strings.HasPrefix(trimmed, "const ") && strings.Contains(trimmed, " = (") && strings.Contains(trimmed, "=>"): + name := strings.TrimSpace(strings.TrimPrefix(strings.SplitN(trimmed, "=", 2)[0], "const ")) + if startsUpper(name) { + return true + } + } + } + return false +} + +func hasPascalCaseName(line string, prefix string) bool { + name := strings.TrimSpace(strings.TrimPrefix(line, prefix)) + if name == "" { + return false + } + for i, r := range name { + if r == '(' || r == ' ' || r == '<' { + return i > 0 && startsUpper(name[:i]) + } + } + return startsUpper(name) +} + +func startsUpper(value string) bool { + if value == "" { + return false + } + r := rune(value[0]) + return r >= 'A' && r <= 'Z' +} + +func uniqueSortedStrings(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + sort.Strings(out) + return out +} diff --git a/internal/codeguard/ai/semantic/frameworks_next.go b/internal/codeguard/ai/semantic/frameworks_next.go new file mode 100644 index 0000000..71b4264 --- /dev/null +++ b/internal/codeguard/ai/semantic/frameworks_next.go @@ -0,0 +1,58 @@ +package semantic + +import ( + "path/filepath" + "strings" +) + +func nextJSSignals(file FileSnapshot) []string { + lowerPath := strings.ToLower(filepath.ToSlash(file.Path)) + content := file.Content + signals := make([]string, 0, 5) + if (strings.HasPrefix(lowerPath, "app/") || strings.Contains(lowerPath, "/app/")) && hasAnySuffix(lowerPath, "/route.ts", "/route.tsx", "/route.js", "/route.jsx") { + signals = append(signals, "app-router-route-file") + } + if strings.HasPrefix(lowerPath, "pages/api/") || strings.Contains(lowerPath, "/pages/api/") { + signals = append(signals, "pages-api-route-file") + } + if containsAny(content, `from "next/server"`, "from 'next/server'") { + signals = append(signals, "next-server-import") + } + if (strings.HasPrefix(lowerPath, "app/") || strings.Contains(lowerPath, "/app/")) && hasAnySuffix(lowerPath, "/page.tsx", "/page.jsx", "/layout.tsx", "/layout.jsx", "/loading.tsx", "/loading.jsx", "/error.tsx", "/error.jsx") { + signals = append(signals, "app-router-component-file") + } + if containsAny(content, `"use client"`, `'use client'`) { + signals = append(signals, "use-client-directive") + } + if containsAny(content, "NextRequest", "NextResponse") { + signals = append(signals, "next-request-response") + } + if containsAny(content, "export async function GET", "export async function POST", "export async function PUT", "export async function PATCH", "export async function DELETE", "export function GET", "export function POST", "export function PUT", "export function PATCH", "export function DELETE") { + signals = append(signals, "route-handler-export") + } + return uniqueSortedStrings(signals) +} + +func nextJSHints(file FileSnapshot) []string { + lowerPath := strings.ToLower(filepath.ToSlash(file.Path)) + content := file.Content + hints := make([]string, 0, 5) + if containsAny(content, "export async function GET", "export async function POST", "export async function PUT", "export async function PATCH", "export async function DELETE", "export function GET", "export function POST", "export function PUT", "export function PATCH", "export function DELETE") { + hints = append(hints, "route-handler-contract") + } + if (strings.HasPrefix(lowerPath, "app/") || strings.Contains(lowerPath, "/app/")) && hasAnySuffix(lowerPath, "/page.tsx", "/page.jsx", "/layout.tsx", "/layout.jsx", "/loading.tsx", "/loading.jsx", "/error.tsx", "/error.jsx") { + hints = append(hints, "route-segment-component") + if containsAny(content, `"use client"`, `'use client'`) { + hints = append(hints, "client-component") + } else { + hints = append(hints, "server-component") + } + } + if containsAny(content, "params", "searchParams") { + hints = append(hints, "route-props-contract") + } + if containsAny(content, "export async function", "export default async function") { + hints = append(hints, "async-data-contract") + } + return uniqueSortedStrings(hints) +} diff --git a/internal/codeguard/ai/semantic/frameworks_react.go b/internal/codeguard/ai/semantic/frameworks_react.go new file mode 100644 index 0000000..598a005 --- /dev/null +++ b/internal/codeguard/ai/semantic/frameworks_react.go @@ -0,0 +1,49 @@ +package semantic + +import ( + "path/filepath" + "strings" +) + +func reactSignals(file FileSnapshot) []string { + lowerPath := strings.ToLower(filepath.ToSlash(file.Path)) + content := file.Content + signals := make([]string, 0, 6) + if !hasAnySuffix(lowerPath, ".tsx", ".jsx") { + return nil + } + if containsAny(content, `from "react"`, "from 'react'", `from "next/link"`, `from "next/navigation"`, `from "next/image"`, `from "next/head"`) { + signals = append(signals, "react-import") + } + if containsAny(content, "return (", "return<", "", "= 5, + AIFindingCount: len(aiRuleIDs), + SemanticFindingCount: semanticCount, + Components: components, + } + return support.NewChangeRiskArtifact("change_risk."+language+"."+artifactSafeID(target.Name), language, target.Path, risk), true +} + +func changeRiskLevel(cfg core.AIChangeRiskConfig, score int) string { + failThreshold := cfg.FailThreshold + if failThreshold == 0 { + failThreshold = 60 + } + if score >= failThreshold { + return "fail" + } + return "warn" +} + +func summarizeChangeRisk(risk *core.ChangeRiskArtifact) string { + if risk == nil || len(risk.Components) == 0 { + return "risk signals accumulated beyond the configured threshold" + } + parts := make([]string, 0, len(risk.Components)) + for _, component := range risk.Components { + if component.Detail != "" { + parts = append(parts, component.Detail) + continue + } + parts = append(parts, component.Label) + } + return strings.Join(parts, "; ") +} + +func targetChangedFileCount(env support.Context, _ core.TargetConfig) int { + return len(env.ChangedFiles) +} + +func summarizeChangeRiskInputs(findings []core.Finding) ([]string, int, bool) { + aiRuleIDs := make([]string, 0) + semanticCount := 0 + coverageGap := false + for _, finding := range findings { + if _, ok := aiSlopRuleWeights[finding.RuleID]; ok { + aiRuleIDs = append(aiRuleIDs, finding.RuleID) + } + if strings.HasPrefix(finding.RuleID, "quality.ai.semantic-") && finding.RuleID != "quality.ai.semantic-runtime" { + semanticCount++ + } + if finding.RuleID == "quality.coverage-delta" { + coverageGap = true + } + } + return aiRuleIDs, semanticCount, coverageGap +} + +func addAISignalRisk(score int, components []core.ChangeRiskComponent, aiRuleIDs []string) (int, []core.ChangeRiskComponent) { + if len(aiRuleIDs) == 0 { + return score, components + } + slop := scoreFindings(aiRuleIDs) + contribution := minInt(slop/2, 50) + score += contribution + components = append(components, core.ChangeRiskComponent{ + Label: "ai_signals", + Contribution: contribution, + Detail: fmt.Sprintf("%d AI findings contributed a slop score of %d", len(aiRuleIDs), slop), + }) + return score, components +} + +func addProvenanceRisk(score int, components []core.ChangeRiskComponent, provenanceActive bool) (int, []core.ChangeRiskComponent) { + if !provenanceActive { + return score, components + } + score += 15 + components = append(components, core.ChangeRiskComponent{ + Label: "provenance", + Contribution: 15, + Detail: "AI-assisted provenance is active for the current change", + }) + return score, components +} + +func addDiffBreadthRisk(score int, components []core.ChangeRiskComponent, changedFiles int) (int, []core.ChangeRiskComponent) { + contribution := 0 + switch { + case changedFiles >= 5: + contribution = 10 + case changedFiles >= 2: + contribution = 5 + } + if contribution == 0 { + return score, components + } + score += contribution + components = append(components, core.ChangeRiskComponent{ + Label: "diff_breadth", + Contribution: contribution, + Detail: fmt.Sprintf("%d changed files fall under this target", changedFiles), + }) + return score, components +} + +func addSemanticRisk(score int, components []core.ChangeRiskComponent, semanticCount int) (int, []core.ChangeRiskComponent) { + if semanticCount == 0 { + return score, components + } + contribution := minInt(semanticCount*5, 15) + score += contribution + components = append(components, core.ChangeRiskComponent{ + Label: "semantic_findings", + Contribution: contribution, + Detail: fmt.Sprintf("%d semantic AI findings were reported", semanticCount), + }) + return score, components +} + +func addCoverageGapRisk(score int, components []core.ChangeRiskComponent, coverageGap bool) (int, []core.ChangeRiskComponent) { + if !coverageGap { + return score, components + } + score += 10 + components = append(components, core.ChangeRiskComponent{ + Label: "coverage_gap", + Contribution: 10, + Detail: "changed-line coverage enforcement reported a gap", + }) + return score, components +} diff --git a/internal/codeguard/checks/quality/quality_ai_scoring.go b/internal/codeguard/checks/quality/quality_ai_scoring.go index 6f97f7f..10c444c 100644 --- a/internal/codeguard/checks/quality/quality_ai_scoring.go +++ b/internal/codeguard/checks/quality/quality_ai_scoring.go @@ -9,10 +9,13 @@ var aiSlopRuleWeights = map[string]int{ "quality.ai.local-idiom-drift": 2, "quality.ai.error-style-drift": 2, "quality.ai.naming-drift": 1, + "quality.ai.change-risk": 4, "quality.ai.provenance-policy": 2, "quality.ai.semantic-doc-mismatch": 3, + "quality.ai.contract-drift": 4, "quality.ai.semantic-error-message": 4, "quality.ai.semantic-test-coverage": 4, + "quality.ai.semantic-test-adequacy": 4, } func scoreFindings(findings []string) int { diff --git a/internal/codeguard/checks/quality/quality_ai_semantic.go b/internal/codeguard/checks/quality/quality_ai_semantic.go index 6497596..1aec92a 100644 --- a/internal/codeguard/checks/quality/quality_ai_semantic.go +++ b/internal/codeguard/checks/quality/quality_ai_semantic.go @@ -2,6 +2,7 @@ package quality import ( "context" + "fmt" "strings" "github.com/devr-tools/codeguard/internal/codeguard/ai/semantic" @@ -13,13 +14,17 @@ func semanticFindings(ctx context.Context, env support.Context, target core.Targ if !semanticEligible(env) { return nil } + command := semanticCommand(env.Config.AI) + if strings.TrimSpace(command) == "" { + return []core.Finding{semanticRuntimeFinding(env, target, "semantic review is enabled but no semantic command is configured")} + } findings, err := semantic.Analyze(ctx, semantic.Options{ Target: target, Language: support.NormalizedLanguage(target.Language), BaseRef: env.BaseRef, DiffText: env.DiffText, CachePath: semanticCachePath(env.Config.Cache), - Command: semanticCommand(env.Config.AI), + Command: command, Enabled: semanticEnabled(env), CheckSelection: semanticCheckSelection(env.Config.AI.Semantic), NewFinding: func(ruleID string, level string, path string, line int, message string) core.Finding { @@ -34,11 +39,22 @@ func semanticFindings(ctx context.Context, env support.Context, target core.Targ }, }) if err != nil { - return nil + return []core.Finding{semanticRuntimeFinding(env, target, fmt.Sprintf("semantic review command failed for target %q: %v", target.Name, err))} } return findings } +func semanticRuntimeFinding(env support.Context, target core.TargetConfig, message string) core.Finding { + return env.NewFinding(support.FindingInput{ + RuleID: "quality.ai.semantic-runtime", + Level: "fail", + Path: "", + Line: 0, + Column: 0, + Message: message, + }) +} + func semanticEligible(env support.Context) bool { return semanticEnabled(env) } @@ -53,8 +69,10 @@ func semanticEnabled(env support.Context) bool { func semanticCheckSelection(cfg core.AISemanticConfig) semantic.CheckSelection { return semantic.CheckSelection{ FunctionContract: cfg.FunctionContract == nil || *cfg.FunctionContract, + ContractDrift: cfg.ContractDrift == nil || *cfg.ContractDrift, MisleadingErrorMessages: cfg.MisleadingErrorMessages == nil || *cfg.MisleadingErrorMessages, TestBehaviorCoverage: cfg.TestBehaviorCoverage == nil || *cfg.TestBehaviorCoverage, + TestAdequacy: cfg.TestAdequacy == nil || *cfg.TestAdequacy, } } diff --git a/internal/codeguard/checks/supplychain/policy.go b/internal/codeguard/checks/supplychain/policy.go new file mode 100644 index 0000000..39455e3 --- /dev/null +++ b/internal/codeguard/checks/supplychain/policy.go @@ -0,0 +1,97 @@ +package supplychain + +import ( + "context" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func targetFindings(_ context.Context, env support.Context, target core.TargetConfig, manifests []core.SupplyChainManifest) []core.Finding { + findings := make([]core.Finding, 0) + changed := changedFilesSet(env.ChangedFiles) + for _, manifest := range manifests { + findings = append(findings, unpinnedDependencyFindings(env, manifest)...) + findings = append(findings, lockfilePolicyFindings(env, target, manifest, changed)...) + findings = append(findings, licensePolicyFindings(env, manifest)...) + } + return findings +} + +func unpinnedDependencyFindings(env support.Context, manifest core.SupplyChainManifest) []core.Finding { + if env.Config.Checks.SupplyChainRules.DetectUnpinned == nil || !*env.Config.Checks.SupplyChainRules.DetectUnpinned { + return nil + } + findings := make([]core.Finding, 0) + for _, dep := range manifest.Dependencies { + if dep.Pinned { + continue + } + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: "supply_chain.unpinned-dependency", + Level: "warn", + Path: manifest.Path, + Line: dep.Line, + Column: 1, + Message: "dependency " + dep.Name + " is not pinned to a concrete version or digest", + })) + } + return findings +} + +func lockfilePolicyFindings(env support.Context, target core.TargetConfig, manifest core.SupplyChainManifest, changed map[string]struct{}) []core.Finding { + findings := make([]core.Finding, 0) + expectLockfile := manifestExpectsLockfile(manifest) + if expectLockfile && env.Config.Checks.SupplyChainRules.RequireLockfile != nil && *env.Config.Checks.SupplyChainRules.RequireLockfile && len(manifest.Dependencies) > 0 && len(manifest.Lockfiles) == 0 { + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: "supply_chain.missing-lockfile", + Level: "fail", + Path: manifest.Path, + Message: "manifest has dependencies but no expected lockfile is present", + })) + } + if env.Mode != core.ScanModeDiff || env.Config.Checks.SupplyChainRules.DetectLockfileDrift == nil || !*env.Config.Checks.SupplyChainRules.DetectLockfileDrift { + return append(findings, lockfileContentFindings(env, target, manifest)...) + } + if _, ok := changed[manifest.Path]; !ok { + return append(findings, lockfileContentFindings(env, target, manifest)...) + } + if !expectLockfile || len(manifest.Dependencies) == 0 { + return append(findings, lockfileContentFindings(env, target, manifest)...) + } + if len(manifest.Lockfiles) == 0 { + return findings + } + for _, lockfile := range manifest.Lockfiles { + if _, ok := changed[lockfile]; ok { + return append(findings, lockfileContentFindings(env, target, manifest)...) + } + } + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: "supply_chain.lockfile-drift", + Level: "fail", + Path: manifest.Path, + Message: "manifest changed without a matching lockfile update", + })) + return append(findings, lockfileContentFindings(env, target, manifest)...) +} + +func lockfileContentFindings(env support.Context, target core.TargetConfig, manifest core.SupplyChainManifest) []core.Finding { + if env.Config.Checks.SupplyChainRules.DetectLockfileDrift == nil || !*env.Config.Checks.SupplyChainRules.DetectLockfileDrift { + return nil + } + issues := support.SupplyChainLockfileIssues(target.Path, manifest) + if len(issues) == 0 { + return nil + } + findings := make([]core.Finding, 0, len(issues)) + for _, issue := range issues { + findings = append(findings, env.NewFinding(support.FindingInput{ + RuleID: "supply_chain.lockfile-drift", + Level: "fail", + Path: manifest.Path, + Message: issue, + })) + } + return findings +} diff --git a/internal/codeguard/checks/supplychain/policy_helpers.go b/internal/codeguard/checks/supplychain/policy_helpers.go new file mode 100644 index 0000000..ef79ea8 --- /dev/null +++ b/internal/codeguard/checks/supplychain/policy_helpers.go @@ -0,0 +1,101 @@ +package supplychain + +import ( + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func changedFilesSet(paths []string) map[string]struct{} { + if len(paths) == 0 { + return nil + } + out := make(map[string]struct{}, len(paths)) + for _, path := range paths { + out[filepath.ToSlash(path)] = struct{}{} + } + return out +} + +func manifestExpectsLockfile(manifest core.SupplyChainManifest) bool { + switch manifest.Ecosystem { + case "go", "npm", "cargo": + return true + case "python": + return manifest.PackageManager == "poetry" || manifest.PackageManager == "uv" + default: + return false + } +} + +func normalizeLicenseList(values []string) []string { + normalized := make([]string, 0, len(values)) + for _, value := range values { + if trimmed := strings.ToUpper(strings.TrimSpace(value)); trimmed != "" { + normalized = append(normalized, trimmed) + } + } + slices.Sort(normalized) + return slices.Compact(normalized) +} + +var licenseTokenPattern = regexp.MustCompile(`[A-Za-z0-9.+-]+`) + +func normalizeLicenseExpression(value string) (string, []string) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", nil + } + normalized := strings.ToUpper(trimmed) + tokens := licenseTokenPattern.FindAllString(normalized, -1) + filtered := make([]string, 0, len(tokens)) + for _, token := range tokens { + switch token { + case "AND", "OR", "WITH": + continue + default: + filtered = append(filtered, token) + } + } + slices.Sort(filtered) + filtered = slices.Compact(filtered) + return normalized, filtered +} + +func licenseDenied(normalized string, tokens []string, denied []string) bool { + if len(denied) == 0 { + return false + } + for _, deniedLicense := range denied { + if normalized == deniedLicense { + return true + } + for _, token := range tokens { + if token == deniedLicense { + return true + } + } + } + return false +} + +func licenseOutsideAllowed(normalized string, tokens []string, allowed []string) bool { + if len(allowed) == 0 { + return false + } + if normalized != "" && slices.Contains(allowed, normalized) { + return false + } + if len(tokens) == 0 { + return true + } + for _, token := range tokens { + if !slices.Contains(allowed, token) { + return true + } + } + return false +} diff --git a/internal/codeguard/checks/supplychain/policy_licenses.go b/internal/codeguard/checks/supplychain/policy_licenses.go new file mode 100644 index 0000000..f9f47ca --- /dev/null +++ b/internal/codeguard/checks/supplychain/policy_licenses.go @@ -0,0 +1,141 @@ +package supplychain + +import ( + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func licensePolicyFindings(env support.Context, manifest core.SupplyChainManifest) []core.Finding { + allowed := normalizeLicenseList(env.Config.Checks.SupplyChainRules.AllowedLicenses) + denied := normalizeLicenseList(env.Config.Checks.SupplyChainRules.DeniedLicenses) + findings := make([]core.Finding, 0) + if finding, ok := manifestLicenseFinding(env, manifest, allowed, denied); ok { + findings = append(findings, finding) + } + for _, dep := range manifest.Dependencies { + if finding, ok := dependencyLicenseFinding(env, manifest, dep, allowed, denied); ok { + findings = append(findings, finding) + } + } + return findings +} + +func manifestLicenseFinding(env support.Context, manifest core.SupplyChainManifest, allowed []string, denied []string) (core.Finding, bool) { + license, tokens := normalizeLicenseExpression(manifest.License) + if license == "" && len(tokens) == 0 { + return core.Finding{}, false + } + switch { + case licenseDenied(license, tokens, denied): + return env.NewFinding(support.FindingInput{ + RuleID: "supply_chain.denied-license", + Level: "fail", + Path: manifest.Path, + Line: manifest.LicenseLine, + Column: 1, + Message: "manifest declares denied license " + manifest.License, + }), true + case licenseOutsideAllowed(license, tokens, allowed): + return env.NewFinding(support.FindingInput{ + RuleID: "supply_chain.denied-license", + Level: "fail", + Path: manifest.Path, + Line: manifest.LicenseLine, + Column: 1, + Message: "manifest license " + manifest.License + " is not in the allowed license policy", + }), true + default: + return core.Finding{}, false + } +} + +func dependencyLicenseFinding(env support.Context, manifest core.SupplyChainManifest, dep core.SupplyChainDependency, allowed []string, denied []string) (core.Finding, bool) { + evidence := selectDependencyLicenseEvidence(dep) + license, tokens := normalizeLicenseExpression(evidence.License) + if license == "" && len(tokens) == 0 { + return core.Finding{}, false + } + level := "fail" + if !evidence.Definitive { + level = "warn" + } + switch { + case licenseDenied(license, tokens, denied): + return env.NewFinding(support.FindingInput{ + RuleID: "supply_chain.denied-license", + Level: level, + Path: manifest.Path, + Line: dep.Line, + Column: 1, + Message: "dependency " + dep.Name + " resolves to denied license " + evidence.License + dependencyLicenseEvidenceSuffix(evidence), + }), true + case licenseOutsideAllowed(license, tokens, allowed): + return env.NewFinding(support.FindingInput{ + RuleID: "supply_chain.denied-license", + Level: level, + Path: manifest.Path, + Line: dep.Line, + Column: 1, + Message: "dependency " + dep.Name + " resolves to license " + evidence.License + " which is not in the allowed license policy" + dependencyLicenseEvidenceSuffix(evidence), + }), true + default: + return core.Finding{}, false + } +} + +type dependencyLicenseEvidence struct { + License string + Source string + Confidence string + Provenance string + Definitive bool +} + +func selectDependencyLicenseEvidence(dep core.SupplyChainDependency) dependencyLicenseEvidence { + candidates := dep.LicenseCandidates + if len(candidates) == 0 { + return dependencyLicenseEvidence{ + License: dep.License, + Source: dep.LicenseSource, + Definitive: strings.TrimSpace(dep.License) != "", + } + } + best := candidates[0] + bestScore := support.SupplyChainLicenseCandidateRank(best) + for _, candidate := range candidates[1:] { + score := support.SupplyChainLicenseCandidateRank(candidate) + if score > bestScore { + best = candidate + bestScore = score + } + } + return dependencyLicenseEvidence{ + License: best.License, + Source: support.FirstNonEmptyTrimmedString(best.Source, dep.LicenseSource), + Confidence: best.Confidence, + Provenance: best.Provenance, + Definitive: support.SupplyChainLicenseCandidateDefinitive(best), + } +} + +func dependencyLicenseEvidenceSuffix(evidence dependencyLicenseEvidence) string { + parts := make([]string, 0, 3) + if source := strings.TrimSpace(evidence.Source); source != "" { + parts = append(parts, source) + } + if provenance := strings.TrimSpace(evidence.Provenance); provenance != "" { + parts = append(parts, "provenance="+provenance) + } + if confidence := strings.TrimSpace(evidence.Confidence); confidence != "" { + parts = append(parts, "confidence="+confidence) + } + if len(parts) == 0 { + return "" + } + if !evidence.Definitive { + parts = append(parts, "heuristic") + } + return " (" + strings.Join(parts, ", ") + ")" +} diff --git a/internal/codeguard/checks/supplychain/supplychain.go b/internal/codeguard/checks/supplychain/supplychain.go new file mode 100644 index 0000000..f8fe12d --- /dev/null +++ b/internal/codeguard/checks/supplychain/supplychain.go @@ -0,0 +1,30 @@ +package supplychain + +import ( + "context" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +// Run wires the supply-chain family into the scan pipeline. Rule execution is +// intentionally staged: config, metadata, and reporting land before manifest +// parsing and policy enforcement are added. +func Run(ctx context.Context, env support.Context) core.SectionResult { + findings := make([]core.Finding, 0) + for _, target := range env.Config.Targets { + manifests := support.ResolveSupplyChainLicenses(ctx, env, target, support.CollectSupplyChainManifests(env, target)) + if len(manifests) == 0 { + continue + } + if env.PutArtifact != nil { + env.PutArtifact(support.NewSupplyChainArtifact( + support.SupplyChainArtifactID(target.Name, target.Path), + target.Path, + manifests, + )) + } + findings = append(findings, targetFindings(ctx, env, target, manifests)...) + } + return env.FinalizeSection("supply_chain", "Supply Chain", findings) +} diff --git a/internal/codeguard/checks/support/artifacts.go b/internal/codeguard/checks/support/artifacts.go index b0aa919..cb6d63e 100644 --- a/internal/codeguard/checks/support/artifacts.go +++ b/internal/codeguard/checks/support/artifacts.go @@ -4,6 +4,7 @@ import "github.com/devr-tools/codeguard/internal/codeguard/core" const ArtifactKindDependencyGraph = "dependency_graph" const ArtifactKindSlopScore = "slop_score" +const ArtifactKindChangeRisk = "change_risk" func NewDependencyGraphArtifact(id string, language string, target string, graph DependencyGraph) core.Artifact { nodes := make([]core.DependencyGraphNode, 0, len(graph.Order)) @@ -51,3 +52,24 @@ func NewSlopScoreArtifact(id string, language string, target string, score core. }, } } + +func NewChangeRiskArtifact(id string, language string, target string, risk core.ChangeRiskArtifact) core.Artifact { + components := make([]core.ChangeRiskComponent, 0, len(risk.Components)) + components = append(components, risk.Components...) + return core.Artifact{ + ID: id, + Kind: ArtifactKindChangeRisk, + Language: language, + Target: target, + ChangeRisk: &core.ChangeRiskArtifact{ + Score: risk.Score, + Level: risk.Level, + ProvenanceActive: risk.ProvenanceActive, + ChangedFiles: risk.ChangedFiles, + HighImpactChange: risk.HighImpactChange, + AIFindingCount: risk.AIFindingCount, + SemanticFindingCount: risk.SemanticFindingCount, + Components: components, + }, + } +} diff --git a/internal/codeguard/checks/support/context.go b/internal/codeguard/checks/support/context.go index f0943a1..3ff899c 100644 --- a/internal/codeguard/checks/support/context.go +++ b/internal/codeguard/checks/support/context.go @@ -18,32 +18,33 @@ type FindingInput struct { } type Context struct { - Config core.Config - AIEnabled bool - Mode core.ScanMode - BaseRef string - DiffText string - ScanTime time.Time - ChangedFiles []string - ListChangedFiles func(target core.TargetConfig) ([]core.ChangedFile, error) - ReadBaseFile func(target core.TargetConfig, rel string) ([]byte, error) - DiffScope func() map[string]core.ChangedLineRanges - VisitTargetFiles func(target core.TargetConfig, include func(string) bool, visit func(rel string, data []byte)) - ScanTargetFiles func(target core.TargetConfig, sectionID string, include func(string) bool, evaluator func(string, []byte) []core.Finding) []core.Finding - NewFinding func(FindingInput) core.Finding - FinalizeSection func(id string, name string, findings []core.Finding) core.SectionResult - PutArtifact func(core.Artifact) - GetArtifact func(string) (core.Artifact, bool) - CountLines func(data []byte) int - CyclomaticComplexity func(body *ast.BlockStmt) int - TypeName func(expr ast.Expr) string - IsInternalOrCmdFile func(path string) bool - IsCmdFile func(path string) bool - IsPublicPackageFile func(path string) bool - IsSDKFacadeFile func(path string) bool - IsPromptFile func(rel string) bool - RunGovulncheck func(ctx context.Context, dir string, cmdName string) ([]core.Finding, error) - RunCommandCheck func(ctx context.Context, dir string, check core.CommandCheckConfig) (string, error) - RunDiffCommandCheck func(ctx context.Context, dir string, baseRef string, check core.CommandCheckConfig) (string, error) - NormalizedSeverity func(level string) string + Config core.Config + AIEnabled bool + Mode core.ScanMode + BaseRef string + DiffText string + ScanTime time.Time + ChangedFiles []string + ListChangedFiles func(target core.TargetConfig) ([]core.ChangedFile, error) + ReadBaseFile func(target core.TargetConfig, rel string) ([]byte, error) + DiffScope func() map[string]core.ChangedLineRanges + VisitTargetFiles func(target core.TargetConfig, include func(string) bool, visit func(rel string, data []byte)) + ScanTargetFiles func(target core.TargetConfig, sectionID string, include func(string) bool, evaluator func(string, []byte) []core.Finding) []core.Finding + NewFinding func(FindingInput) core.Finding + FinalizeSection func(id string, name string, findings []core.Finding) core.SectionResult + PutArtifact func(core.Artifact) + GetArtifact func(string) (core.Artifact, bool) + CountLines func(data []byte) int + CyclomaticComplexity func(body *ast.BlockStmt) int + TypeName func(expr ast.Expr) string + IsInternalOrCmdFile func(path string) bool + IsCmdFile func(path string) bool + IsPublicPackageFile func(path string) bool + IsSDKFacadeFile func(path string) bool + IsPromptFile func(rel string) bool + RunGovulncheck func(ctx context.Context, dir string, cmdName string) ([]core.Finding, error) + RunCommandCheck func(ctx context.Context, dir string, check core.CommandCheckConfig) (string, error) + RunCommandCheckWithEnv func(ctx context.Context, dir string, check core.CommandCheckConfig, env []string) (string, error) + RunDiffCommandCheck func(ctx context.Context, dir string, baseRef string, check core.CommandCheckConfig) (string, error) + NormalizedSeverity func(level string) string } diff --git a/internal/codeguard/checks/support/supply_chain.go b/internal/codeguard/checks/support/supply_chain.go new file mode 100644 index 0000000..f748161 --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain.go @@ -0,0 +1,133 @@ +package support + +import ( + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +var ( + requirementNamePattern = regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9._-]*)`) + tomlSectionPattern = regexp.MustCompile(`^\s*\[([^\]]+)\]\s*$`) + tomlKeyPattern = regexp.MustCompile(`^\s*(?:"([^"]+)"|([A-Za-z0-9._-]+))\s*=`) + quotedStringPattern = regexp.MustCompile(`["']([^"']+)["']`) + cargoInlineVersionPattern = regexp.MustCompile(`(?:^|[,{\s])version\s*=\s*["']([^"']+)["']`) +) + +func IsSupplyChainManifest(rel string) bool { + normalized := filepath.ToSlash(rel) + if isInstalledDependencyMetadataPath(normalized) { + return false + } + base := strings.ToLower(path.Base(filepath.ToSlash(rel))) + switch { + case base == "go.mod": + return true + case base == "package.json": + return true + case base == "pyproject.toml": + return true + case base == "cargo.toml": + return true + case strings.HasPrefix(base, "requirements") && strings.HasSuffix(base, ".txt"): + return true + default: + return false + } +} + +func CollectSupplyChainManifests(env Context, target core.TargetConfig) []core.SupplyChainManifest { + type manifestFile struct { + rel string + data []byte + } + + files := make([]manifestFile, 0) + env.VisitTargetFiles(target, IsSupplyChainManifest, func(rel string, data []byte) { + files = append(files, manifestFile{ + rel: filepath.ToSlash(rel), + data: append([]byte(nil), data...), + }) + }) + if len(files) == 0 { + return nil + } + + sort.Slice(files, func(i, j int) bool { return files[i].rel < files[j].rel }) + manifests := make([]core.SupplyChainManifest, 0, len(files)) + for _, file := range files { + manifest, ok := parseSupplyChainManifest(target.Path, file.rel, file.data) + if !ok { + continue + } + manifests = append(manifests, manifest) + } + sort.Slice(manifests, func(i, j int) bool { return manifests[i].Path < manifests[j].Path }) + return manifests +} + +func parseSupplyChainManifest(root string, rel string, data []byte) (core.SupplyChainManifest, bool) { + switch strings.ToLower(path.Base(rel)) { + case "go.mod": + return parseGoModManifest(root, rel, data), true + case "package.json": + return parsePackageJSONManifest(root, rel, data) + case "pyproject.toml": + return parsePyprojectManifest(root, rel, data), true + case "cargo.toml": + return parseCargoManifest(root, rel, data), true + default: + if strings.HasPrefix(strings.ToLower(path.Base(rel)), "requirements") && strings.HasSuffix(strings.ToLower(path.Base(rel)), ".txt") { + return parseRequirementsManifest(root, rel, data), true + } + } + return core.SupplyChainManifest{}, false +} + +func isInstalledDependencyMetadataPath(rel string) bool { + parts := strings.Split(filepath.ToSlash(rel), "/") + for _, part := range parts { + switch part { + case "node_modules", "vendor", ".venv", "site-packages": + return true + } + } + return false +} + +func presentLockfiles(root string, manifestPath string, candidates []string) []string { + dir := path.Dir(manifestPath) + if dir == "." { + dir = "" + } + lockfiles := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + rel := candidate + if dir != "" { + rel = path.Join(dir, candidate) + } + info, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))) + if err == nil && !info.IsDir() { + lockfiles = append(lockfiles, rel) + } + } + sort.Strings(lockfiles) + return lockfiles +} + +func sortDependencies(deps []core.SupplyChainDependency) { + sort.Slice(deps, func(i, j int) bool { + if deps[i].Name == deps[j].Name { + if deps[i].Scope == deps[j].Scope { + return deps[i].Requirement < deps[j].Requirement + } + return deps[i].Scope < deps[j].Scope + } + return deps[i].Name < deps[j].Name + }) +} diff --git a/internal/codeguard/checks/support/supply_chain_artifacts.go b/internal/codeguard/checks/support/supply_chain_artifacts.go new file mode 100644 index 0000000..6a21dcb --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_artifacts.go @@ -0,0 +1,47 @@ +package support + +import ( + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +const ArtifactKindSupplyChain = "supply_chain" + +func NewSupplyChainArtifact(id string, target string, manifests []core.SupplyChainManifest) core.Artifact { + cloned := make([]core.SupplyChainManifest, 0, len(manifests)) + for _, manifest := range manifests { + deps := append([]core.SupplyChainDependency(nil), manifest.Dependencies...) + for i := range deps { + deps[i].Groups = append([]string(nil), deps[i].Groups...) + deps[i].LicenseCandidates = append([]core.SupplyChainLicenseCandidate(nil), deps[i].LicenseCandidates...) + } + lockfiles := append([]string(nil), manifest.Lockfiles...) + cloned = append(cloned, core.SupplyChainManifest{ + Ecosystem: manifest.Ecosystem, + Path: manifest.Path, + Name: manifest.Name, + License: manifest.License, + LicenseLine: manifest.LicenseLine, + PackageManager: manifest.PackageManager, + Lockfiles: lockfiles, + Dependencies: deps, + }) + } + return core.Artifact{ + ID: id, + Kind: ArtifactKindSupplyChain, + Target: target, + SupplyChain: &core.SupplyChainArtifact{ + Manifests: cloned, + }, + } +} + +func SupplyChainArtifactID(targetName string, targetPath string) string { + name := strings.TrimSpace(targetName) + if name == "" { + name = strings.TrimSpace(targetPath) + } + return "supply_chain." + name +} diff --git a/internal/codeguard/checks/support/supply_chain_cargo.go b/internal/codeguard/checks/support/supply_chain_cargo.go new file mode 100644 index 0000000..3f0566b --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_cargo.go @@ -0,0 +1,86 @@ +package support + +import ( + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func parseCargoManifest(root string, rel string, data []byte) core.SupplyChainManifest { + manifest := core.SupplyChainManifest{ + Ecosystem: "cargo", + Path: rel, + Lockfiles: presentLockfiles(root, rel, []string{"Cargo.lock"}), + } + section := "" + for idx, rawLine := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(rawLine) + if match := tomlSectionPattern.FindStringSubmatch(rawLine); match != nil { + section = strings.TrimSpace(match[1]) + continue + } + switch { + case section == "package": + applyManifestIdentityLine(&manifest, line, idx+1) + case isCargoDependencySection(section): + if dep, ok := parseCargoDependencyLine(line, cargoScope(section), idx+1); ok { + manifest.Dependencies = append(manifest.Dependencies, dep) + } + } + } + sortDependencies(manifest.Dependencies) + return manifest +} + +func isCargoDependencySection(section string) bool { + switch { + case section == "dependencies", section == "dev-dependencies", section == "build-dependencies": + return true + case strings.HasSuffix(section, ".dependencies"), strings.HasSuffix(section, ".dev-dependencies"), strings.HasSuffix(section, ".build-dependencies"): + return strings.HasPrefix(section, "target.") + default: + return false + } +} + +func cargoScope(section string) string { + switch { + case strings.Contains(section, "dev-dependencies"): + return "dev" + case strings.Contains(section, "build-dependencies"): + return "build" + default: + return "runtime" + } +} + +func parseCargoDependencyLine(line string, scope string, lineNo int) (core.SupplyChainDependency, bool) { + match := tomlKeyPattern.FindStringSubmatch(line) + if match == nil { + return core.SupplyChainDependency{}, false + } + name := match[1] + if name == "" { + name = match[2] + } + value := strings.TrimSpace(line[strings.Index(line, "=")+1:]) + value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "{"), "}")) + version := "" + if strings.HasPrefix(strings.TrimSpace(line[strings.Index(line, "=")+1:]), `"`) { + version = firstQuotedValue(line) + } else if match := cargoInlineVersionPattern.FindStringSubmatch(value); match != nil { + version = strings.TrimSpace(match[1]) + } + req := version + if strings.TrimSpace(req) == "" { + req = strings.TrimSpace(line[strings.Index(line, "=")+1:]) + } + return core.SupplyChainDependency{ + Name: name, + Requirement: req, + Version: version, + Scope: scope, + Pinned: isCargoVersionPinned(version), + Line: lineNo, + }, true +} diff --git a/internal/codeguard/checks/support/supply_chain_go_node.go b/internal/codeguard/checks/support/supply_chain_go_node.go new file mode 100644 index 0000000..45c8555 --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_go_node.go @@ -0,0 +1,112 @@ +package support + +import ( + "encoding/json" + "sort" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func parseGoModManifest(root string, rel string, data []byte) core.SupplyChainManifest { + manifest := core.SupplyChainManifest{ + Ecosystem: "go", + Path: rel, + Lockfiles: presentLockfiles(root, rel, []string{"go.sum"}), + } + inRequireBlock := false + for idx, rawLine := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(rawLine) + switch { + case strings.HasPrefix(line, "module "): + manifest.Name = strings.TrimSpace(strings.TrimPrefix(line, "module ")) + case strings.HasPrefix(line, "require ("): + inRequireBlock = true + case inRequireBlock && line == ")": + inRequireBlock = false + case strings.HasPrefix(line, "require "): + if dep, ok := parseGoRequireLine(strings.TrimSpace(strings.TrimPrefix(line, "require ")), idx+1); ok { + manifest.Dependencies = append(manifest.Dependencies, dep) + } + case inRequireBlock && line != "": + if dep, ok := parseGoRequireLine(line, idx+1); ok { + manifest.Dependencies = append(manifest.Dependencies, dep) + } + } + } + sortDependencies(manifest.Dependencies) + return manifest +} + +func parseGoRequireLine(line string, lineNo int) (core.SupplyChainDependency, bool) { + if line == "" || strings.HasPrefix(line, "//") { + return core.SupplyChainDependency{}, false + } + indirect := strings.Contains(line, "// indirect") + line = strings.TrimSpace(strings.TrimSuffix(line, "// indirect")) + fields := strings.Fields(line) + if len(fields) < 2 { + return core.SupplyChainDependency{}, false + } + return core.SupplyChainDependency{ + Name: fields[0], + Requirement: fields[1], + Version: fields[1], + Scope: "runtime", + Indirect: indirect, + Pinned: isGoVersionPinned(fields[1]), + Line: lineNo, + }, true +} + +func parsePackageJSONManifest(root string, rel string, data []byte) (core.SupplyChainManifest, bool) { + var manifestData struct { + Name string `json:"name"` + License any `json:"license"` + PackageManager string `json:"packageManager"` + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + PeerDependencies map[string]string `json:"peerDependencies"` + OptionalDependencies map[string]string `json:"optionalDependencies"` + } + if err := json.Unmarshal(data, &manifestData); err != nil { + return core.SupplyChainManifest{}, false + } + manifest := core.SupplyChainManifest{ + Ecosystem: "npm", + Path: rel, + Name: strings.TrimSpace(manifestData.Name), + License: parseJSONLicense(manifestData.License), + LicenseLine: findJSONKeyLine(data, "license"), + PackageManager: packageManagerName(manifestData.PackageManager), + Lockfiles: presentLockfiles(root, rel, []string{"package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"}), + } + appendPackageJSONDeps(data, &manifest.Dependencies, manifestData.Dependencies, "runtime") + appendPackageJSONDeps(data, &manifest.Dependencies, manifestData.DevDependencies, "dev") + appendPackageJSONDeps(data, &manifest.Dependencies, manifestData.PeerDependencies, "peer") + appendPackageJSONDeps(data, &manifest.Dependencies, manifestData.OptionalDependencies, "optional") + sortDependencies(manifest.Dependencies) + return manifest, true +} + +func appendPackageJSONDeps(data []byte, dst *[]core.SupplyChainDependency, deps map[string]string, scope string) { + if len(deps) == 0 { + return + } + names := make([]string, 0, len(deps)) + for name := range deps { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + req := strings.TrimSpace(deps[name]) + *dst = append(*dst, core.SupplyChainDependency{ + Name: name, + Requirement: req, + Version: req, + Scope: scope, + Pinned: isNodeVersionPinned(req), + Line: findJSONKeyLine(data, name), + }) + } +} diff --git a/internal/codeguard/checks/support/supply_chain_license_common.go b/internal/codeguard/checks/support/supply_chain_license_common.go new file mode 100644 index 0000000..18b5e3a --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_license_common.go @@ -0,0 +1,61 @@ +package support + +import ( + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func SupplyChainLicenseCandidateRank(candidate core.SupplyChainLicenseCandidate) int { + confidence := strings.ToLower(strings.TrimSpace(candidate.Confidence)) + provenance := strings.ToLower(strings.TrimSpace(candidate.Provenance)) + score := 0 + switch confidence { + case "definitive", "certain", "exact", "high": + score += 40 + case "medium", "probable": + score += 20 + case "low", "heuristic", "weak": + score += 5 + } + switch { + case strings.Contains(provenance, "spdx"): + score += 40 + case strings.Contains(provenance, "metadata"), strings.Contains(provenance, "manifest"), strings.Contains(provenance, "package-manager"): + score += 20 + case strings.Contains(provenance, "heuristic"), strings.Contains(provenance, "guess"): + score += 5 + } + if score == 0 { + score = 10 + } + return score +} + +func SupplyChainLicenseCandidateDefinitive(candidate core.SupplyChainLicenseCandidate) bool { + confidence := strings.ToLower(strings.TrimSpace(candidate.Confidence)) + if confidence == "definitive" || confidence == "certain" || confidence == "exact" || confidence == "high" { + return true + } + provenance := strings.ToLower(strings.TrimSpace(candidate.Provenance)) + return strings.Contains(provenance, "spdx") || strings.Contains(provenance, "metadata") || strings.Contains(provenance, "manifest") +} + +func FirstNonEmptyTrimmedString(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} + +func SupplyChainDependencyCoordinate(dep core.SupplyChainDependency) string { + name := strings.TrimSpace(dep.Name) + version := strings.TrimSpace(dep.Version) + if name == "" || version == "" { + return "" + } + return name + "@" + version +} diff --git a/internal/codeguard/checks/support/supply_chain_licenses.go b/internal/codeguard/checks/support/supply_chain_licenses.go new file mode 100644 index 0000000..aa29f6c --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_licenses.go @@ -0,0 +1,97 @@ +package support + +import ( + "context" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +type SupplyChainLicenseCommandResult struct { + Name string `json:"name"` + Coordinate string `json:"coordinate,omitempty"` + License string `json:"license,omitempty"` + Source string `json:"source,omitempty"` + Candidates []core.SupplyChainLicenseCandidate `json:"candidates,omitempty"` +} + +type SupplyChainLicenseCommandContext struct { + Ecosystem string `json:"ecosystem"` + ManifestPath string `json:"manifest_path"` + TargetName string `json:"target_name,omitempty"` + TargetPath string `json:"target_path"` + UnresolvedDependencies []SupplyChainDependencyRef `json:"unresolved_dependencies"` +} + +type SupplyChainDependencyRef struct { + Coordinate string `json:"coordinate,omitempty"` + Name string `json:"name"` + Requirement string `json:"requirement,omitempty"` + Version string `json:"version,omitempty"` + Scope string `json:"scope,omitempty"` + Groups []string `json:"groups,omitempty"` + Indirect bool `json:"indirect,omitempty"` + Pinned bool `json:"pinned,omitempty"` + Line int `json:"line,omitempty"` +} + +func ResolveSupplyChainLicenses(ctx context.Context, env Context, target core.TargetConfig, manifests []core.SupplyChainManifest) []core.SupplyChainManifest { + if len(manifests) == 0 { + return nil + } + resolved := make([]core.SupplyChainManifest, 0, len(manifests)) + for _, manifest := range manifests { + updated := manifest + updated.Dependencies = append([]core.SupplyChainDependency(nil), manifest.Dependencies...) + resolveLocalDependencyLicenses(target.Path, manifest, &updated) + fillSupplyChainLicensesFromCommand(ctx, env, target, &updated) + resolved = append(resolved, updated) + } + return resolved +} + +func resolveLocalDependencyLicenses(root string, manifest core.SupplyChainManifest, updated *core.SupplyChainManifest) { + if updated == nil { + return + } + for i := range updated.Dependencies { + dep := &updated.Dependencies[i] + license, source := resolveDependencyLicense(root, manifest, *dep) + if strings.TrimSpace(license) == "" { + continue + } + setSupplyChainDependencyLicense(dep, core.SupplyChainLicenseCandidate{ + License: strings.TrimSpace(license), + Confidence: "high", + Provenance: "local-metadata", + Source: strings.TrimSpace(source), + }) + } +} + +func setSupplyChainDependencyLicense(dep *core.SupplyChainDependency, candidates ...core.SupplyChainLicenseCandidate) { + if dep == nil { + return + } + normalized := normalizeSupplyChainLicenseCandidates(candidates) + if len(normalized) == 0 { + return + } + selected := selectBestSupplyChainLicenseCandidate(normalized) + dep.License = selected.License + dep.LicenseSource = FirstNonEmptyTrimmedString(selected.Source, dep.LicenseSource) + dep.LicenseCandidates = normalized +} + +func resolveDependencyLicense(root string, manifest core.SupplyChainManifest, dep core.SupplyChainDependency) (string, string) { + switch manifest.Ecosystem { + case "npm": + return resolveNodeDependencyLicense(root, manifest, dep) + case "cargo": + return resolveCargoDependencyLicense(root, manifest, dep) + case "python": + return resolvePythonDependencyLicense(root, manifest, dep) + default: + return "", "" + } +} diff --git a/internal/codeguard/checks/support/supply_chain_licenses_candidates.go b/internal/codeguard/checks/support/supply_chain_licenses_candidates.go new file mode 100644 index 0000000..343939b --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_licenses_candidates.go @@ -0,0 +1,109 @@ +package support + +import ( + "encoding/json" + "slices" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func parseSupplyChainLicenseCommandOutput(output string) []SupplyChainLicenseCommandResult { + output = strings.TrimSpace(output) + if output == "" { + return nil + } + var array []SupplyChainLicenseCommandResult + if err := json.Unmarshal([]byte(output), &array); err == nil { + return array + } + var wrapped struct { + Dependencies []SupplyChainLicenseCommandResult `json:"dependencies"` + } + if err := json.Unmarshal([]byte(output), &wrapped); err == nil { + return wrapped.Dependencies + } + return nil +} + +func supplyChainLicenseCandidatesFromResult(result SupplyChainLicenseCommandResult) []core.SupplyChainLicenseCandidate { + candidates := normalizeSupplyChainLicenseCandidates(result.Candidates) + if len(candidates) != 0 { + return candidates + } + if strings.TrimSpace(result.License) == "" { + return nil + } + return []core.SupplyChainLicenseCandidate{{ + License: strings.TrimSpace(result.License), + Confidence: "high", + Provenance: "command-output", + Source: FirstNonEmptyTrimmedString(result.Source, "license-command"), + }} +} + +func normalizeSupplyChainLicenseCandidates(candidates []core.SupplyChainLicenseCandidate) []core.SupplyChainLicenseCandidate { + if len(candidates) == 0 { + return nil + } + normalized := make([]core.SupplyChainLicenseCandidate, 0, len(candidates)) + seen := map[string]struct{}{} + for _, candidate := range candidates { + normalizedCandidate := core.SupplyChainLicenseCandidate{ + License: strings.TrimSpace(candidate.License), + Confidence: strings.TrimSpace(candidate.Confidence), + Provenance: strings.TrimSpace(candidate.Provenance), + Source: strings.TrimSpace(candidate.Source), + } + if normalizedCandidate.License == "" { + continue + } + normalizedCandidate.Source = FirstNonEmptyTrimmedString(normalizedCandidate.Source, "license-command") + key := strings.ToLower(normalizedCandidate.License) + "|" + strings.ToLower(normalizedCandidate.Confidence) + "|" + strings.ToLower(normalizedCandidate.Provenance) + "|" + strings.ToLower(normalizedCandidate.Source) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, normalizedCandidate) + } + slices.SortFunc(normalized, compareSupplyChainLicenseCandidates) + return normalized +} + +func selectBestSupplyChainLicenseCandidate(candidates []core.SupplyChainLicenseCandidate) core.SupplyChainLicenseCandidate { + if len(candidates) == 0 { + return core.SupplyChainLicenseCandidate{} + } + best := candidates[0] + for _, candidate := range candidates[1:] { + if compareSupplyChainLicenseCandidates(candidate, best) < 0 { + best = candidate + } + } + return best +} + +func compareSupplyChainLicenseCandidates(a core.SupplyChainLicenseCandidate, b core.SupplyChainLicenseCandidate) int { + if rankA, rankB := SupplyChainLicenseCandidateRank(a), SupplyChainLicenseCandidateRank(b); rankA != rankB { + if rankA > rankB { + return -1 + } + return 1 + } + if cmp := strings.Compare(strings.ToLower(a.License), strings.ToLower(b.License)); cmp != 0 { + return cmp + } + if cmp := strings.Compare(strings.ToLower(a.Provenance), strings.ToLower(b.Provenance)); cmp != 0 { + return cmp + } + return strings.Compare(strings.ToLower(a.Source), strings.ToLower(b.Source)) +} + +func supplyChainLicenseResultMatchesDependency(result SupplyChainLicenseCommandResult, dep core.SupplyChainDependency) bool { + resultCoordinate := strings.TrimSpace(result.Coordinate) + depCoordinate := strings.TrimSpace(SupplyChainDependencyCoordinate(dep)) + if resultCoordinate != "" && depCoordinate != "" { + return strings.EqualFold(resultCoordinate, depCoordinate) + } + return strings.EqualFold(strings.TrimSpace(result.Name), strings.TrimSpace(dep.Name)) +} diff --git a/internal/codeguard/checks/support/supply_chain_licenses_command.go b/internal/codeguard/checks/support/supply_chain_licenses_command.go new file mode 100644 index 0000000..8f06ad0 --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_licenses_command.go @@ -0,0 +1,175 @@ +package support + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func fillSupplyChainLicensesFromCommand(ctx context.Context, env Context, target core.TargetConfig, manifest *core.SupplyChainManifest) { + command, unresolved, ok := supplyChainLicenseCommandInputs(env, manifest) + if !ok { + return + } + ctxPath, cleanup, err := writeSupplyChainLicenseContext(newSupplyChainLicenseCommandContext(target, manifest, unresolved)) + if err != nil { + return + } + defer cleanup() + + output, err := env.RunCommandCheckWithEnv(ctx, manifestWorkdir(target.Path, manifest.Path), command, supplyChainLicenseCommandEnv(target, manifest, unresolved, ctxPath)) + if err != nil { + return + } + applySupplyChainLicenseCommandResults(manifest, parseSupplyChainLicenseCommandOutput(output)) +} + +func supplyChainLicenseCommandInputs(env Context, manifest *core.SupplyChainManifest) (core.CommandCheckConfig, []SupplyChainDependencyRef, bool) { + if manifest == nil { + return core.CommandCheckConfig{}, nil, false + } + command, ok := env.Config.Checks.SupplyChainRules.LicenseCommands[manifest.Ecosystem] + if !ok { + return core.CommandCheckConfig{}, nil, false + } + unresolved := unresolvedDependencies(manifest.Dependencies) + if len(unresolved) == 0 { + return core.CommandCheckConfig{}, nil, false + } + return command, unresolved, true +} + +func newSupplyChainLicenseCommandContext(target core.TargetConfig, manifest *core.SupplyChainManifest, unresolved []SupplyChainDependencyRef) SupplyChainLicenseCommandContext { + return SupplyChainLicenseCommandContext{ + Ecosystem: manifest.Ecosystem, + ManifestPath: manifest.Path, + TargetName: target.Name, + TargetPath: target.Path, + UnresolvedDependencies: unresolved, + } +} + +func supplyChainLicenseCommandEnv(target core.TargetConfig, manifest *core.SupplyChainManifest, unresolved []SupplyChainDependencyRef, ctxPath string) []string { + return []string{ + "CODEGUARD_SUPPLY_CHAIN_ECOSYSTEM=" + manifest.Ecosystem, + "CODEGUARD_SUPPLY_CHAIN_MANIFEST_PATH=" + manifest.Path, + "CODEGUARD_SUPPLY_CHAIN_MANIFEST_DIR=" + manifestRelativeDir(manifest.Path), + "CODEGUARD_SUPPLY_CHAIN_TARGET_NAME=" + target.Name, + "CODEGUARD_SUPPLY_CHAIN_TARGET_PATH=" + target.Path, + "CODEGUARD_SUPPLY_CHAIN_UNRESOLVED_NAMES=" + strings.Join(unresolvedDependencyRefNames(unresolved), ","), + "CODEGUARD_SUPPLY_CHAIN_UNRESOLVED_COORDINATES=" + strings.Join(unresolvedDependencyRefCoordinates(unresolved), ","), + "CODEGUARD_SUPPLY_CHAIN_CONTEXT_FILE=" + ctxPath, + } +} + +func applySupplyChainLicenseCommandResults(manifest *core.SupplyChainManifest, results []SupplyChainLicenseCommandResult) { + if manifest == nil || len(results) == 0 { + return + } + for i := range manifest.Dependencies { + dep := &manifest.Dependencies[i] + if strings.TrimSpace(dep.License) != "" { + continue + } + if candidates := matchingSupplyChainLicenseCandidates(results, *dep); len(candidates) != 0 { + setSupplyChainDependencyLicense(dep, candidates...) + } + } +} + +func matchingSupplyChainLicenseCandidates(results []SupplyChainLicenseCommandResult, dep core.SupplyChainDependency) []core.SupplyChainLicenseCandidate { + for _, result := range results { + if !supplyChainLicenseResultMatchesDependency(result, dep) { + continue + } + if candidates := supplyChainLicenseCandidatesFromResult(result); len(candidates) != 0 { + return candidates + } + } + return nil +} + +func unresolvedDependencies(deps []core.SupplyChainDependency) []SupplyChainDependencyRef { + out := make([]SupplyChainDependencyRef, 0) + seen := map[string]struct{}{} + for _, dep := range deps { + if strings.TrimSpace(dep.Name) == "" || strings.TrimSpace(dep.License) != "" { + continue + } + key := strings.ToLower(strings.TrimSpace(SupplyChainDependencyCoordinate(dep))) + if key == "" { + key = strings.ToLower(strings.TrimSpace(dep.Name)) + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, SupplyChainDependencyRef{ + Coordinate: SupplyChainDependencyCoordinate(dep), + Name: dep.Name, + Requirement: dep.Requirement, + Version: dep.Version, + Scope: dep.Scope, + Groups: append([]string(nil), dep.Groups...), + Indirect: dep.Indirect, + Pinned: dep.Pinned, + Line: dep.Line, + }) + } + slices.SortFunc(out, func(a, b SupplyChainDependencyRef) int { + return strings.Compare(a.Name, b.Name) + }) + return out +} + +func unresolvedDependencyRefNames(deps []SupplyChainDependencyRef) []string { + names := make([]string, 0, len(deps)) + for _, dep := range deps { + if strings.TrimSpace(dep.Name) != "" { + names = append(names, dep.Name) + } + } + slices.Sort(names) + return names +} + +func unresolvedDependencyRefCoordinates(deps []SupplyChainDependencyRef) []string { + coords := make([]string, 0, len(deps)) + for _, dep := range deps { + if strings.TrimSpace(dep.Coordinate) != "" { + coords = append(coords, dep.Coordinate) + } + } + slices.Sort(coords) + return coords +} + +func writeSupplyChainLicenseContext(ctx SupplyChainLicenseCommandContext) (string, func(), error) { + data, err := json.Marshal(ctx) + if err != nil { + return "", func() {}, err + } + file, err := os.CreateTemp("", "codeguard-supply-chain-license-*.json") + if err != nil { + return "", func() {}, err + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + _ = os.Remove(file.Name()) + return "", func() {}, err + } + if err := file.Close(); err != nil { + _ = os.Remove(file.Name()) + return "", func() {}, err + } + return file.Name(), func() { _ = os.Remove(file.Name()) }, nil +} + +func manifestWorkdir(root string, manifestPath string) string { + return filepath.Join(root, filepath.FromSlash(manifestRelativeDir(manifestPath))) +} diff --git a/internal/codeguard/checks/support/supply_chain_licenses_resolution.go b/internal/codeguard/checks/support/supply_chain_licenses_resolution.go new file mode 100644 index 0000000..2a4602a --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_licenses_resolution.go @@ -0,0 +1,164 @@ +package support + +import ( + "encoding/json" + "os" + "path" + "path/filepath" + "slices" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func resolveNodeDependencyLicense(root string, manifest core.SupplyChainManifest, dep core.SupplyChainDependency) (string, string) { + for _, searchRoot := range manifestSearchRoots(root, manifest.Path) { + manifestPath := filepath.Join(searchRoot, filepath.FromSlash(path.Join("node_modules", dep.Name, "package.json"))) + data, err := os.ReadFile(manifestPath) + if err != nil { + continue + } + var pkg struct { + License any `json:"license"` + Licenses any `json:"licenses"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + continue + } + if license := parseJSONLicense(pkg.License); license != "" { + return license, "node_modules" + } + if license := parseJSONLicenses(pkg.Licenses); license != "" { + return license, "node_modules" + } + } + return "", "" +} + +func resolveCargoDependencyLicense(root string, manifest core.SupplyChainManifest, dep core.SupplyChainDependency) (string, string) { + for _, searchRoot := range manifestSearchRoots(root, manifest.Path) { + candidate := filepath.Join(searchRoot, "vendor", filepath.FromSlash(dep.Name), "Cargo.toml") + data, err := os.ReadFile(candidate) + if err != nil { + continue + } + if license := parseCargoTOMLLicense(data); license != "" { + return license, "cargo-vendor" + } + } + return "", "" +} + +func resolvePythonDependencyLicense(root string, manifest core.SupplyChainManifest, dep core.SupplyChainDependency) (string, string) { + for _, searchRoot := range manifestSearchRoots(root, manifest.Path) { + for _, pattern := range pythonMetadataPatterns(searchRoot, dep.Name) { + matches, _ := filepath.Glob(pattern) + for _, match := range matches { + data, err := os.ReadFile(match) + if err != nil { + continue + } + if license := parsePythonMetadataLicense(data); license != "" { + return license, pythonMetadataSource(match) + } + } + } + } + return "", "" +} + +func manifestRelativeDir(manifestPath string) string { + dir := path.Dir(filepath.ToSlash(manifestPath)) + if dir == "." || dir == "/" { + return "" + } + return dir +} + +func manifestSearchRoots(root string, manifestPath string) []string { + relDir := manifestRelativeDir(manifestPath) + dirs := []string{filepath.Clean(root)} + if relDir == "" { + return dirs + } + dirs = []string{filepath.Join(root, filepath.FromSlash(relDir))} + for current := relDir; current != "" && current != "." && current != "/"; { + parent := path.Dir(current) + if parent == "." || parent == "/" { + break + } + dirs = append(dirs, filepath.Join(root, filepath.FromSlash(parent))) + current = parent + } + dirs = append(dirs, filepath.Clean(root)) + return slices.Compact(dirs) +} + +func parseJSONLicenses(value any) string { + switch typed := value.(type) { + case []any: + licenses := make([]string, 0, len(typed)) + for _, item := range typed { + if text := parseJSONLicense(item); text != "" { + licenses = append(licenses, text) + } + } + return strings.Join(licenses, " OR ") + case map[string]any: + return parseJSONLicense(typed) + default: + return "" + } +} + +func parseCargoTOMLLicense(data []byte) string { + for _, rawLine := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(rawLine) + if strings.HasPrefix(line, "license") && strings.Contains(line, "=") { + return parseTOMLLicenseValue(line) + } + } + return "" +} + +func normalizePythonDistName(name string) string { + lowered := strings.ToLower(strings.TrimSpace(name)) + return strings.NewReplacer("-", "_", ".", "_").Replace(lowered) +} + +func parsePythonMetadataLicense(data []byte) string { + lines := strings.Split(string(data), "\n") + var classifierLicenses []string + for _, rawLine := range lines { + line := strings.TrimSpace(rawLine) + switch { + case strings.HasPrefix(line, "License:"): + value := strings.TrimSpace(strings.TrimPrefix(line, "License:")) + if value != "" && value != "UNKNOWN" { + return value + } + case strings.HasPrefix(line, "Classifier: License ::"): + classifierLicenses = append(classifierLicenses, strings.TrimSpace(strings.TrimPrefix(line, "Classifier: License ::"))) + } + } + if len(classifierLicenses) > 0 { + return strings.Join(classifierLicenses, " OR ") + } + return "" +} + +func pythonMetadataPatterns(searchRoot string, depName string) []string { + dist := normalizePythonDistName(depName) + "-*.dist-info" + return []string{ + filepath.Join(searchRoot, ".venv", "lib", "*", "site-packages", dist, "METADATA"), + filepath.Join(searchRoot, ".venv", "Lib", "site-packages", dist, "METADATA"), + filepath.Join(searchRoot, "site-packages", dist, "METADATA"), + } +} + +func pythonMetadataSource(match string) string { + if strings.Contains(match, ".venv") { + return ".venv dist-info" + } + return "python-dist-info" +} diff --git a/internal/codeguard/checks/support/supply_chain_lockfiles.go b/internal/codeguard/checks/support/supply_chain_lockfiles.go new file mode 100644 index 0000000..642df5d --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_lockfiles.go @@ -0,0 +1,141 @@ +package support + +import ( + "os" + "path/filepath" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +type lockfileState struct { + packages map[string]map[string]struct{} + selectors map[string]map[string]struct{} +} + +func SupplyChainLockfileIssues(root string, manifest core.SupplyChainManifest) []string { + if len(manifest.Lockfiles) == 0 { + return nil + } + + var parsedAny bool + var firstIssues []string + for _, lockfile := range manifest.Lockfiles { + data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(lockfile))) + if err != nil { + continue + } + state, ok := parseLockfileState(lockfile, data) + if !ok { + continue + } + parsedAny = true + issues := compareManifestToLockfile(manifest, lockfile, state) + if len(issues) == 0 { + return nil + } + if len(firstIssues) == 0 { + firstIssues = issues + } + } + if !parsedAny { + return nil + } + return firstIssues +} + +func parseLockfileState(path string, data []byte) (lockfileState, bool) { + base := strings.ToLower(filepath.Base(path)) + switch base { + case "go.sum": + return parseGoSumState(data), true + case "package-lock.json", "npm-shrinkwrap.json": + return parsePackageLockState(data) + case "pnpm-lock.yaml": + return parsePNPMLockState(data) + case "yarn.lock": + return parseYarnLockState(data), true + case "bun.lock": + return parseBunLockState(data), true + case "cargo.lock", "poetry.lock", "uv.lock": + return parsePackageBlockLockState(data), true + default: + return lockfileState{}, false + } +} + +func compareManifestToLockfile(manifest core.SupplyChainManifest, lockfile string, state lockfileState) []string { + issues := make([]string, 0) + for _, dep := range manifest.Dependencies { + if dep.Name == "" { + continue + } + if !lockfileHasPackage(state, dep.Name) { + issues = append(issues, "dependency "+dep.Name+" is not present in "+lockfile) + continue + } + if exact := exactLockedVersion(manifest, dep); exact != "" && !lockfileHasVersion(state, dep.Name, exact) { + if manifest.Ecosystem == "npm" && lockfileHasSelector(state, dep.Name, dep.Requirement) { + continue + } + issues = append(issues, "dependency "+dep.Name+" version "+exact+" is not present in "+lockfile) + } + } + return issues +} + +func newLockfileState() lockfileState { + return lockfileState{ + packages: make(map[string]map[string]struct{}), + selectors: make(map[string]map[string]struct{}), + } +} + +func addLockfilePackage(state lockfileState, name string, version string) { + name = strings.TrimSpace(name) + version = strings.TrimSpace(version) + if name == "" { + return + } + if _, ok := state.packages[name]; !ok { + state.packages[name] = make(map[string]struct{}) + } + if version != "" { + state.packages[name][version] = struct{}{} + } +} + +func addLockfileSelector(state lockfileState, name string, selector string) { + name = strings.TrimSpace(name) + selector = strings.TrimSpace(selector) + if name == "" || selector == "" { + return + } + if _, ok := state.selectors[name]; !ok { + state.selectors[name] = make(map[string]struct{}) + } + state.selectors[name][selector] = struct{}{} +} + +func lockfileHasPackage(state lockfileState, name string) bool { + _, ok := state.packages[name] + return ok +} + +func lockfileHasVersion(state lockfileState, name string, version string) bool { + versions, ok := state.packages[name] + if !ok { + return false + } + _, ok = versions[version] + return ok +} + +func lockfileHasSelector(state lockfileState, name string, selector string) bool { + selectors, ok := state.selectors[name] + if !ok { + return false + } + _, ok = selectors[strings.TrimSpace(selector)] + return ok +} diff --git a/internal/codeguard/checks/support/supply_chain_lockfiles_misc.go b/internal/codeguard/checks/support/supply_chain_lockfiles_misc.go new file mode 100644 index 0000000..a87f0ba --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_lockfiles_misc.go @@ -0,0 +1,114 @@ +package support + +import ( + "regexp" + "sort" + "strings" +) + +var bunCoordinatePattern = regexp.MustCompile(`(?:@[-a-zA-Z0-9_.]+/)?[-a-zA-Z0-9_.]+@[0-9][0-9A-Za-z.+_-]*`) + +func parseGoSumState(data []byte) lockfileState { + state := newLockfileState() + for _, rawLine := range strings.Split(string(data), "\n") { + fields := strings.Fields(strings.TrimSpace(rawLine)) + if len(fields) >= 2 { + addLockfilePackage(state, fields[0], strings.TrimSuffix(fields[1], "/go.mod")) + } + } + return state +} + +func parsePackageBlockLockState(data []byte) lockfileState { + state := newLockfileState() + currentName := "" + currentVersion := "" + inPackage := false + flush := func() { + if currentName != "" { + addLockfilePackage(state, currentName, currentVersion) + } + currentName = "" + currentVersion = "" + } + for _, rawLine := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(rawLine) + switch { + case line == "[[package]]": + flush() + inPackage = true + case inPackage && strings.HasPrefix(line, "name") && strings.Contains(line, "="): + currentName = parseTOMLAssignmentValue(line) + case inPackage && strings.HasPrefix(line, "version") && strings.Contains(line, "="): + currentVersion = parseTOMLAssignmentValue(line) + case inPackage && strings.HasPrefix(line, "[["): + flush() + inPackage = false + } + } + flush() + return state +} + +func parseBunLockState(data []byte) lockfileState { + state := newLockfileState() + seen := map[string]struct{}{} + quoted := quotedStringPattern.FindAllStringSubmatch(string(data), -1) + for _, match := range quoted { + addBunCoordinate(state, seen, match[1]) + } + tokens := bunCoordinatePattern.FindAllString(string(data), -1) + sort.Strings(tokens) + for _, token := range tokens { + addBunCoordinate(state, seen, token) + } + return state +} + +func addBunCoordinate(state lockfileState, seen map[string]struct{}, token string) { + name, version := parsePackageCoordinate(token) + if name == "" || version == "" { + return + } + key := name + "@" + version + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + addLockfilePackage(state, name, version) +} + +func parsePackageCoordinate(token string) (string, string) { + token = strings.TrimSpace(strings.Trim(token, "\"'`,:;()[]{}")) + if token == "" { + return "", "" + } + at := strings.LastIndex(token, "@") + if at <= 0 || at >= len(token)-1 { + return "", "" + } + name := strings.TrimSpace(token[:at]) + version := strings.TrimSpace(token[at+1:]) + if name == "" || version == "" || !startsWithDigit(version) { + return "", "" + } + return name, version +} + +func startsWithDigit(value string) bool { + return value != "" && value[0] >= '0' && value[0] <= '9' +} + +func parseTOMLAssignmentValue(line string) string { + if idx := strings.Index(line, "="); idx >= 0 { + return strings.TrimSpace(strings.Trim(parseTOMLQuotedValue(line[idx+1:]), `"`)) + } + return "" +} + +func parseTOMLQuotedValue(value string) string { + if match := regexp.MustCompile(`["']([^"']+)["']`).FindStringSubmatch(value); match != nil { + return strings.TrimSpace(match[1]) + } + return strings.TrimSpace(value) +} diff --git a/internal/codeguard/checks/support/supply_chain_lockfiles_node.go b/internal/codeguard/checks/support/supply_chain_lockfiles_node.go new file mode 100644 index 0000000..939b717 --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_lockfiles_node.go @@ -0,0 +1,130 @@ +package support + +import ( + "encoding/json" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +func parsePackageLockState(data []byte) (lockfileState, bool) { + var doc struct { + Packages map[string]struct { + Version string `json:"version"` + } `json:"packages"` + Dependencies map[string]json.RawMessage `json:"dependencies"` + } + if err := json.Unmarshal(data, &doc); err != nil { + return lockfileState{}, false + } + state := newLockfileState() + for pkgPath, pkg := range doc.Packages { + name := packageLockEntryName(pkgPath) + if name != "" { + addLockfilePackage(state, name, strings.TrimSpace(pkg.Version)) + } + } + for name, raw := range doc.Dependencies { + var dep struct { + Version string `json:"version"` + } + if err := json.Unmarshal(raw, &dep); err == nil { + addLockfilePackage(state, name, strings.TrimSpace(dep.Version)) + } + } + return state, true +} + +func parsePNPMLockState(data []byte) (lockfileState, bool) { + var doc struct { + Packages map[string]any `yaml:"packages"` + } + if err := yaml.Unmarshal(data, &doc); err != nil { + return lockfileState{}, false + } + state := newLockfileState() + for key := range doc.Packages { + name, version := parsePNPMPackageKey(key) + if name != "" { + addLockfilePackage(state, name, version) + } + } + return state, true +} + +func parseYarnLockState(data []byte) lockfileState { + state := newLockfileState() + lines := strings.Split(string(data), "\n") + currentSelectors := make([]string, 0) + for _, rawLine := range lines { + line := strings.TrimSpace(rawLine) + switch { + case line == "": + currentSelectors = nil + case !strings.HasPrefix(rawLine, " ") && strings.HasSuffix(line, ":"): + currentSelectors = parseYarnSelectors(strings.TrimSuffix(line, ":")) + case len(currentSelectors) > 0 && strings.HasPrefix(line, "version "): + version := firstQuotedValue(line) + for _, selector := range currentSelectors { + name, req := parseYarnSelector(selector) + if name == "" { + continue + } + addLockfilePackage(state, name, version) + addLockfileSelector(state, name, req) + } + } + } + return state +} + +func packageLockEntryName(entry string) string { + entry = filepath.ToSlash(strings.TrimSpace(entry)) + if entry == "" || entry == "node_modules" { + return "" + } + if idx := strings.LastIndex(entry, "node_modules/"); idx >= 0 { + return strings.TrimPrefix(entry[idx:], "node_modules/") + } + return "" +} + +func parsePNPMPackageKey(key string) (string, string) { + key = strings.TrimSpace(strings.TrimPrefix(key, "/")) + if key == "" { + return "", "" + } + if idx := strings.Index(key, "("); idx >= 0 { + key = key[:idx] + } + at := strings.LastIndex(key, "@") + if at <= 0 { + return "", "" + } + return key[:at], key[at+1:] +} + +func parseYarnSelectors(line string) []string { + parts := strings.Split(line, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(strings.Trim(part, `"`)) + if part != "" { + out = append(out, part) + } + } + return out +} + +func parseYarnSelector(selector string) (string, string) { + selector = strings.TrimSpace(strings.Trim(selector, `"`)) + if selector == "" { + return "", "" + } + at := strings.LastIndex(selector, "@") + if at <= 0 { + return "", "" + } + return selector[:at], selector[at+1:] +} diff --git a/internal/codeguard/checks/support/supply_chain_lockfiles_versions.go b/internal/codeguard/checks/support/supply_chain_lockfiles_versions.go new file mode 100644 index 0000000..79c32ea --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_lockfiles_versions.go @@ -0,0 +1,43 @@ +package support + +import ( + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func exactLockedVersion(manifest core.SupplyChainManifest, dep core.SupplyChainDependency) string { + switch manifest.Ecosystem { + case "go": + return strings.TrimSpace(dep.Version) + case "npm": + if dep.Pinned && isExactSemver(dep.Version) { + return strings.TrimSpace(dep.Version) + } + case "cargo": + if dep.Pinned { + return strings.TrimSpace(dep.Version) + } + case "python": + if strings.HasPrefix(strings.TrimSpace(dep.Requirement), "==") || strings.Contains(strings.TrimSpace(dep.Requirement), "==") { + return trimPythonVersion(dep.Version) + } + } + return "" +} + +func trimPythonVersion(version string) string { + version = strings.TrimSpace(version) + if idx := strings.Index(version, ";"); idx >= 0 { + version = strings.TrimSpace(version[:idx]) + } + return version +} + +func isExactSemver(version string) bool { + version = strings.TrimSpace(version) + if version == "" { + return false + } + return !strings.ContainsAny(version, "^~<>=*| ") +} diff --git a/internal/codeguard/checks/support/supply_chain_parse_helpers.go b/internal/codeguard/checks/support/supply_chain_parse_helpers.go new file mode 100644 index 0000000..72cdcf8 --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_parse_helpers.go @@ -0,0 +1,104 @@ +package support + +import ( + "regexp" + "strings" +) + +func packageManagerName(raw string) string { + raw = strings.TrimSpace(raw) + switch { + case strings.HasPrefix(raw, "pnpm@"): + return "pnpm" + case strings.HasPrefix(raw, "yarn@"): + return "yarn" + case strings.HasPrefix(raw, "bun@"): + return "bun" + case strings.HasPrefix(raw, "npm@"): + return "npm" + default: + return "" + } +} + +func parseJSONLicense(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case map[string]any: + for _, key := range []string{"type", "text", "name"} { + if raw, ok := typed[key]; ok { + if text, ok := raw.(string); ok { + return strings.TrimSpace(text) + } + } + } + } + return "" +} + +func parseTOMLLicenseValue(line string) string { + if idx := strings.Index(line, "="); idx >= 0 { + value := strings.TrimSpace(line[idx+1:]) + if match := quotedStringPattern.FindAllStringSubmatch(value, -1); len(match) > 0 { + return strings.TrimSpace(match[0][1]) + } + if textMatch := regexp.MustCompile(`(?:^|[,{\s])text\s*=\s*["']([^"']+)["']`).FindStringSubmatch(value); textMatch != nil { + return strings.TrimSpace(textMatch[1]) + } + } + return "" +} + +func findJSONKeyLine(data []byte, key string) int { + pattern := `"` + regexp.QuoteMeta(strings.TrimSpace(key)) + `"\s*:` + re := regexp.MustCompile(pattern) + lines := strings.Split(string(data), "\n") + for idx, line := range lines { + if re.MatchString(line) { + return idx + 1 + } + } + return 0 +} + +func firstQuotedValue(line string) string { + matches := quotedStringPattern.FindAllStringSubmatch(line, -1) + if len(matches) == 0 { + return "" + } + return strings.TrimSpace(matches[0][1]) +} + +func isGoVersionPinned(version string) bool { + version = strings.TrimSpace(version) + return version != "" && !strings.ContainsAny(version, "<>=~^*") +} + +func isNodeVersionPinned(version string) bool { + version = strings.TrimSpace(version) + if version == "" { + return false + } + lowered := strings.ToLower(version) + if strings.HasPrefix(lowered, "^") || strings.HasPrefix(lowered, "~") || strings.HasPrefix(lowered, ">") || strings.HasPrefix(lowered, "<") { + return false + } + if strings.Contains(lowered, "||") || strings.ContainsAny(lowered, "*x") { + return false + } + if strings.HasPrefix(lowered, "workspace:") || strings.HasPrefix(lowered, "file:") || strings.HasPrefix(lowered, "link:") || strings.HasPrefix(lowered, "git") || strings.HasPrefix(lowered, "http:") || strings.HasPrefix(lowered, "https:") { + return false + } + return lowered != "latest" +} + +func isPythonRequirementPinned(req string) bool { + req = strings.TrimSpace(req) + return strings.Contains(req, "===") || strings.Contains(req, "==") || strings.Contains(req, " @ ") +} + +func isCargoVersionPinned(version string) bool { + version = strings.TrimSpace(version) + return strings.HasPrefix(version, "=") && len(version) > 1 +} diff --git a/internal/codeguard/checks/support/supply_chain_poetry.go b/internal/codeguard/checks/support/supply_chain_poetry.go new file mode 100644 index 0000000..351a058 --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_poetry.go @@ -0,0 +1,76 @@ +package support + +import ( + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func applyPoetryDependencyLine(manifest *core.SupplyChainManifest, section string, line string, lineNo int) { + if manifest.PackageManager == "" { + manifest.PackageManager = "poetry" + } + if dep, ok := parsePoetryDependencyLine(line, section, lineNo); ok { + manifest.Dependencies = append(manifest.Dependencies, dep) + } +} + +func applyPoetryMetadataLine(manifest *core.SupplyChainManifest, line string, lineNo int) { + if manifest.PackageManager == "" { + manifest.PackageManager = "poetry" + } + if strings.HasPrefix(line, "license") && strings.Contains(line, "=") { + manifest.License = parseTOMLLicenseValue(line) + manifest.LicenseLine = lineNo + } +} + +func isPoetryDependencySection(section string) bool { + if section == "tool.poetry.dependencies" || section == "tool.poetry.dev-dependencies" { + return true + } + return strings.HasPrefix(section, "tool.poetry.group.") && strings.HasSuffix(section, ".dependencies") +} + +func parsePoetryDependencyLine(line string, section string, lineNo int) (core.SupplyChainDependency, bool) { + match := tomlKeyPattern.FindStringSubmatch(line) + if match == nil { + return core.SupplyChainDependency{}, false + } + name := match[1] + if name == "" { + name = match[2] + } + if strings.EqualFold(name, "python") { + return core.SupplyChainDependency{}, false + } + req := strings.TrimSpace(line[strings.Index(line, "=")+1:]) + req = strings.Trim(req, `"`) + scope := "runtime" + group := "" + if section == "tool.poetry.dev-dependencies" || strings.HasPrefix(section, "tool.poetry.group.") { + scope = "dev" + if strings.HasPrefix(section, "tool.poetry.group.") { + group = strings.TrimSuffix(strings.TrimPrefix(section, "tool.poetry.group."), ".dependencies") + } + } + dep := core.SupplyChainDependency{ + Name: name, + Requirement: req, + Version: extractPoetryVersion(req), + Scope: scope, + Pinned: isPythonRequirementPinned(req), + Line: lineNo, + } + if group != "" { + dep.Groups = []string{group} + } + return dep, true +} + +func extractPoetryVersion(req string) string { + if match := cargoInlineVersionPattern.FindStringSubmatch(req); match != nil { + return strings.TrimSpace(match[1]) + } + return strings.TrimSpace(strings.Trim(req, `"`)) +} diff --git a/internal/codeguard/checks/support/supply_chain_python.go b/internal/codeguard/checks/support/supply_chain_python.go new file mode 100644 index 0000000..295d908 --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_python.go @@ -0,0 +1,146 @@ +package support + +import ( + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +type pyprojectParseState struct { + section string + inDependenciesArray bool + currentOptionalGroup string +} + +func parseRequirementsManifest(root string, rel string, data []byte) core.SupplyChainManifest { + manifest := core.SupplyChainManifest{ + Ecosystem: "python", + Path: rel, + Lockfiles: presentLockfiles(root, rel, []string{"poetry.lock", "uv.lock"}), + } + for idx, rawLine := range strings.Split(string(data), "\n") { + dep, ok := parsePythonRequirementLine(rawLine, "runtime", idx+1) + if ok { + manifest.Dependencies = append(manifest.Dependencies, dep) + } + } + sortDependencies(manifest.Dependencies) + return manifest +} + +func parsePyprojectManifest(root string, rel string, data []byte) core.SupplyChainManifest { + manifest := core.SupplyChainManifest{ + Ecosystem: "python", + Path: rel, + Lockfiles: presentLockfiles(root, rel, []string{"poetry.lock", "uv.lock"}), + } + state := pyprojectParseState{} + for idx, rawLine := range strings.Split(string(data), "\n") { + if next, ok := nextPyprojectSection(rawLine); ok { + state = next + continue + } + applyPyprojectLine(&manifest, &state, rawLine, idx+1) + } + sortDependencies(manifest.Dependencies) + return manifest +} + +func nextPyprojectSection(rawLine string) (pyprojectParseState, bool) { + match := tomlSectionPattern.FindStringSubmatch(rawLine) + if match == nil { + return pyprojectParseState{}, false + } + section := strings.TrimSpace(match[1]) + state := pyprojectParseState{section: section} + switch { + case strings.HasPrefix(section, "project.optional-dependencies."): + state.currentOptionalGroup = strings.TrimPrefix(section, "project.optional-dependencies.") + case strings.HasPrefix(section, "dependency-groups."): + state.currentOptionalGroup = strings.TrimPrefix(section, "dependency-groups.") + } + return state, true +} + +func applyPyprojectLine(manifest *core.SupplyChainManifest, state *pyprojectParseState, rawLine string, lineNo int) { + line := strings.TrimSpace(rawLine) + switch { + case state.section == "project": + applyPyprojectProjectLine(manifest, state, rawLine, line, lineNo) + case strings.HasPrefix(state.section, "project.optional-dependencies"): + appendPyprojectQuotedDependencies(manifest, rawLine, "optional", state.currentOptionalGroup, lineNo) + case strings.HasPrefix(state.section, "dependency-groups"): + appendPyprojectQuotedDependencies(manifest, rawLine, "dev", state.currentOptionalGroup, lineNo) + case isPoetryDependencySection(state.section): + applyPoetryDependencyLine(manifest, state.section, line, lineNo) + case state.section == "tool.poetry": + applyPoetryMetadataLine(manifest, line, lineNo) + case state.section == "tool.uv": + if manifest.PackageManager == "" { + manifest.PackageManager = "uv" + } + } +} + +func applyPyprojectProjectLine(manifest *core.SupplyChainManifest, state *pyprojectParseState, rawLine string, line string, lineNo int) { + applyManifestIdentityLine(manifest, line, lineNo) + if strings.HasPrefix(line, "dependencies") && strings.Contains(line, "=") { + state.inDependenciesArray = true + } + if !state.inDependenciesArray { + return + } + appendPyprojectQuotedDependencies(manifest, rawLine, "runtime", "", lineNo) + if strings.Contains(line, "]") { + state.inDependenciesArray = false + } +} + +func appendPyprojectQuotedDependencies(manifest *core.SupplyChainManifest, rawLine string, scope string, group string, lineNo int) { + for _, match := range quotedStringPattern.FindAllStringSubmatch(rawLine, -1) { + dep, ok := parsePythonRequirementLine(match[1], scope, lineNo) + if !ok { + continue + } + if group != "" { + dep.Groups = []string{group} + } + manifest.Dependencies = append(manifest.Dependencies, dep) + } +} + +func parsePythonRequirementLine(line string, scope string, lineNo int) (core.SupplyChainDependency, bool) { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") { + return core.SupplyChainDependency{}, false + } + if idx := strings.Index(line, "#"); idx >= 0 { + line = strings.TrimSpace(line[:idx]) + } + match := requirementNamePattern.FindStringSubmatch(line) + if match == nil { + return core.SupplyChainDependency{}, false + } + name := match[1] + return core.SupplyChainDependency{ + Name: name, + Requirement: line, + Version: pythonRequirementVersion(line), + Scope: scope, + Pinned: isPythonRequirementPinned(line), + Line: lineNo, + }, true +} + +func pythonRequirementVersion(line string) string { + operators := []string{"===", "==", "~=", ">=", "<=", "!=", ">", "<"} + for _, op := range operators { + if idx := strings.Index(line, op); idx >= 0 { + return strings.TrimSpace(line[idx+len(op):]) + } + } + if idx := strings.Index(line, "@"); idx >= 0 { + return strings.TrimSpace(line[idx+1:]) + } + return "" +} diff --git a/internal/codeguard/checks/support/supply_chain_toml_metadata.go b/internal/codeguard/checks/support/supply_chain_toml_metadata.go new file mode 100644 index 0000000..c1bd48a --- /dev/null +++ b/internal/codeguard/checks/support/supply_chain_toml_metadata.go @@ -0,0 +1,22 @@ +package support + +import ( + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func applyManifestIdentityLine(manifest *core.SupplyChainManifest, line string, lineNo int) { + if manifest == nil { + return + } + if strings.HasPrefix(line, "name") && strings.Contains(line, "=") { + if values := quotedStringPattern.FindAllStringSubmatch(line, -1); len(values) > 0 { + manifest.Name = strings.TrimSpace(values[0][1]) + } + } + if strings.HasPrefix(line, "license") && strings.Contains(line, "=") { + manifest.License = parseTOMLLicenseValue(line) + manifest.LicenseLine = lineNo + } +} diff --git a/internal/codeguard/config/defaults.go b/internal/codeguard/config/defaults.go index ab856c4..be01ac9 100644 --- a/internal/codeguard/config/defaults.go +++ b/internal/codeguard/config/defaults.go @@ -59,6 +59,7 @@ func applyCheckDefaults(cfg *core.Config, def core.Config) { applyPromptDefaults(&cfg.Checks.PromptRules, def.Checks.PromptRules) applyCIDefaults(&cfg.Checks.CIRules, def.Checks.CIRules) applySecurityDefaults(&cfg.Checks.SecurityRules, def.Checks.SecurityRules) + applySupplyChainDefaults(&cfg.Checks.SupplyChainRules, def.Checks.SupplyChainRules) applyContractDefaults(&cfg.Checks.ContractRules, def.Checks.ContractRules) applyAIDefaults(&cfg.AI, def.AI) diff --git a/internal/codeguard/config/defaults_ai.go b/internal/codeguard/config/defaults_ai.go index d1a5ede..2e5131b 100644 --- a/internal/codeguard/config/defaults_ai.go +++ b/internal/codeguard/config/defaults_ai.go @@ -56,12 +56,18 @@ func applyAISemanticDefaults(dst *core.AISemanticConfig, def core.AISemanticConf if dst.FunctionContract == nil { dst.FunctionContract = boolPtr(true) } + if dst.ContractDrift == nil { + dst.ContractDrift = boolPtr(true) + } if dst.MisleadingErrorMessages == nil { dst.MisleadingErrorMessages = boolPtr(true) } if dst.TestBehaviorCoverage == nil { dst.TestBehaviorCoverage = boolPtr(true) } + if dst.TestAdequacy == nil { + dst.TestAdequacy = boolPtr(true) + } } func applyAIAutoFixDefaults(dst *core.AIAutoFixConfig, def core.AIAutoFixConfig) { diff --git a/internal/codeguard/config/defaults_helpers.go b/internal/codeguard/config/defaults_helpers.go index 806b6f2..105b483 100644 --- a/internal/codeguard/config/defaults_helpers.go +++ b/internal/codeguard/config/defaults_helpers.go @@ -35,6 +35,13 @@ func defaultBoolPtr(dst **bool, value bool) { } } +func valueOrDefault(ptr *bool, def bool) bool { + if ptr == nil { + return def + } + return *ptr +} + // defaultStringSlice fills a string-slice setting with a copy of its default // when unset. requireNonEmpty skips defaults that are empty. func defaultStringSlice(dst *[]string, def []string, requireNonEmpty bool) { @@ -51,3 +58,13 @@ func defaultCommandMap(dst *map[string][]core.CommandCheckConfig, def map[string *dst = cloneCommandCheckMap(def) } } + +func defaultSingleCommandMap(dst *map[string]core.CommandCheckConfig, def map[string]core.CommandCheckConfig) { + if *dst == nil && len(def) > 0 { + cloned := make(map[string]core.CommandCheckConfig, len(def)) + for key, value := range def { + cloned[key] = value + } + *dst = cloned + } +} diff --git a/internal/codeguard/config/defaults_rules.go b/internal/codeguard/config/defaults_rules.go index 15c075a..536585b 100644 --- a/internal/codeguard/config/defaults_rules.go +++ b/internal/codeguard/config/defaults_rules.go @@ -16,6 +16,7 @@ func applyQualityDefaults(dst *core.QualityRulesConfig, def core.QualityRulesCon ) defaultBoolPtr(&dst.DetectPreallocInLoop, false) defaultCommandMap(&dst.LanguageCommands, def.LanguageCommands) + applyAIChangeRiskDefaults(&dst.AIChangeRisk, def.AIChangeRisk) applyCoverageDeltaDefaults(&dst.CoverageDelta) } @@ -96,3 +97,22 @@ func applySecurityDefaults(dst *core.SecurityRulesConfig, def core.SecurityRules dst.LanguageCommands = cloneCommandCheckMap(def.LanguageCommands) } } + +func applyAIChangeRiskDefaults(dst *core.AIChangeRiskConfig, def core.AIChangeRiskConfig) { + defaultBoolPtr(&dst.Enabled, valueOrDefault(def.Enabled, true)) + if dst.WarnThreshold == 0 { + dst.WarnThreshold = def.WarnThreshold + } + if dst.FailThreshold == 0 { + dst.FailThreshold = def.FailThreshold + } +} + +func applySupplyChainDefaults(dst *core.SupplyChainRulesConfig, def core.SupplyChainRulesConfig) { + defaultBoolPtr(&dst.RequireLockfile, valueOrDefault(def.RequireLockfile, true)) + defaultBoolPtr(&dst.DetectLockfileDrift, valueOrDefault(def.DetectLockfileDrift, true)) + defaultBoolPtr(&dst.DetectUnpinned, valueOrDefault(def.DetectUnpinned, true)) + defaultStringSlice(&dst.AllowedLicenses, def.AllowedLicenses, false) + defaultStringSlice(&dst.DeniedLicenses, def.DeniedLicenses, false) + defaultSingleCommandMap(&dst.LicenseCommands, def.LicenseCommands) +} diff --git a/internal/codeguard/config/example.go b/internal/codeguard/config/example.go index 66d2662..12d78db 100644 --- a/internal/codeguard/config/example.go +++ b/internal/codeguard/config/example.go @@ -24,17 +24,27 @@ func exampleTargets() []core.TargetConfig { func exampleChecks() core.CheckConfig { return core.CheckConfig{ - Quality: true, - Design: true, - Security: true, - Prompts: true, - CI: true, - QualityRules: exampleQualityRules(), - DesignRules: exampleDesignRules(), - PromptRules: examplePromptRules(), - CIRules: exampleCIRules(), - SecurityRules: exampleSecurityRules(), - ContractRules: exampleContractRules(), + Quality: true, + Design: true, + Security: true, + Prompts: true, + CI: true, + SupplyChain: false, + QualityRules: exampleQualityRules(), + DesignRules: exampleDesignRules(), + PromptRules: examplePromptRules(), + CIRules: exampleCIRules(), + SecurityRules: exampleSecurityRules(), + SupplyChainRules: exampleSupplyChainRules(), + ContractRules: exampleContractRules(), + } +} + +func exampleSupplyChainRules() core.SupplyChainRulesConfig { + return core.SupplyChainRulesConfig{ + RequireLockfile: boolPtr(true), + DetectLockfileDrift: boolPtr(true), + DetectUnpinned: boolPtr(true), } } diff --git a/internal/codeguard/config/example_ai.go b/internal/codeguard/config/example_ai.go index e370640..d4c5ed0 100644 --- a/internal/codeguard/config/example_ai.go +++ b/internal/codeguard/config/example_ai.go @@ -35,8 +35,10 @@ func exampleAISemanticConfig() core.AISemanticConfig { return core.AISemanticConfig{ Enabled: boolPtr(true), FunctionContract: boolPtr(true), + ContractDrift: boolPtr(true), MisleadingErrorMessages: boolPtr(true), TestBehaviorCoverage: boolPtr(true), + TestAdequacy: boolPtr(true), } } diff --git a/internal/codeguard/config/example_rules.go b/internal/codeguard/config/example_rules.go index b3680c7..207fac6 100644 --- a/internal/codeguard/config/example_rules.go +++ b/internal/codeguard/config/example_rules.go @@ -17,6 +17,11 @@ func exampleQualityRules() core.QualityRulesConfig { SlopScoreWarnThreshold: 20, SlopScoreFailThreshold: 40, }, + AIChangeRisk: core.AIChangeRiskConfig{ + Enabled: boolPtr(true), + WarnThreshold: 30, + FailThreshold: 60, + }, } } diff --git a/internal/codeguard/config/validate.go b/internal/codeguard/config/validate.go index 26efe2a..ed72250 100644 --- a/internal/codeguard/config/validate.go +++ b/internal/codeguard/config/validate.go @@ -19,7 +19,9 @@ func Validate(cfg core.Config) error { validateCommandChecks(cfg), validateAIConfig(cfg.AI), validateAIProvenance(cfg.Checks.QualityRules.AIProvenance), + validateAIChangeRisk(cfg.Checks.QualityRules.AIChangeRisk), validateAIChecks(cfg.Checks.QualityRules.AIChecks), + validateSupplyChainRules(cfg.Checks.SupplyChainRules), validateContractRules(cfg.Checks.ContractRules), validateCoverageDelta(cfg.Checks.QualityRules.CoverageDelta), validateGraphThresholds(cfg.Checks.DesignRules), diff --git a/internal/codeguard/config/validate_ai.go b/internal/codeguard/config/validate_ai.go index a664c90..5d0a838 100644 --- a/internal/codeguard/config/validate_ai.go +++ b/internal/codeguard/config/validate_ai.go @@ -31,6 +31,19 @@ func validateAIProvenance(cfg core.AIProvenanceConfig) error { return nil } +func validateAIChangeRisk(cfg core.AIChangeRiskConfig) error { + if cfg.WarnThreshold < 0 { + return errors.New("quality_rules.ai_change_risk.warn_threshold must be non-negative") + } + if cfg.FailThreshold < 0 { + return errors.New("quality_rules.ai_change_risk.fail_threshold must be non-negative") + } + if cfg.FailThreshold > 0 && cfg.WarnThreshold > 0 && cfg.FailThreshold < cfg.WarnThreshold { + return errors.New("quality_rules.ai_change_risk.fail_threshold must be greater than or equal to warn_threshold") + } + return nil +} + func validateAIChecks(cfg core.AIChecksConfig) error { if cfg.SlopHistoryLimit < 0 { return errors.New("quality_rules.ai_checks.slop_history_limit must be non-negative") diff --git a/internal/codeguard/config/validate_supplychain.go b/internal/codeguard/config/validate_supplychain.go new file mode 100644 index 0000000..15be8e4 --- /dev/null +++ b/internal/codeguard/config/validate_supplychain.go @@ -0,0 +1,55 @@ +package config + +import ( + "errors" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +func validateSupplyChainRules(cfg core.SupplyChainRulesConfig) error { + if err := validateNonEmptyStrings("supply_chain_rules.allowed_licenses", cfg.AllowedLicenses); err != nil { + return err + } + if err := validateNonEmptyStrings("supply_chain_rules.denied_licenses", cfg.DeniedLicenses); err != nil { + return err + } + if err := validateSingleCommandMap("supply_chain_rules.license_commands", cfg.LicenseCommands); err != nil { + return err + } + + allowed := make(map[string]struct{}, len(cfg.AllowedLicenses)) + for _, license := range cfg.AllowedLicenses { + allowed[strings.ToLower(strings.TrimSpace(license))] = struct{}{} + } + for _, license := range cfg.DeniedLicenses { + if _, ok := allowed[strings.ToLower(strings.TrimSpace(license))]; ok { + return errors.New("supply_chain_rules.allowed_licenses and denied_licenses must not overlap") + } + } + return nil +} + +func validateSingleCommandMap(field string, commands map[string]core.CommandCheckConfig) error { + for key, check := range commands { + if strings.TrimSpace(key) == "" { + return errors.New(field + " contains an empty ecosystem key") + } + if strings.TrimSpace(check.Name) == "" { + return errors.New(field + "[" + key + "].name is required") + } + if strings.TrimSpace(check.Command) == "" { + return errors.New(field + "[" + key + "].command is required") + } + } + return nil +} + +func validateNonEmptyStrings(field string, values []string) error { + for _, value := range values { + if strings.TrimSpace(value) == "" { + return errors.New(field + " must not contain empty entries") + } + } + return nil +} diff --git a/internal/codeguard/core/config_ai_types.go b/internal/codeguard/core/config_ai_types.go index cada6c2..c992f6f 100644 --- a/internal/codeguard/core/config_ai_types.go +++ b/internal/codeguard/core/config_ai_types.go @@ -1,44 +1,46 @@ package core type AIConfig struct { - Enabled *bool `json:"enabled,omitempty"` - Provider AIProviderConfig `json:"provider,omitempty"` - Cache AICacheConfig `json:"cache,omitempty"` - HybridTriage AIHybridTriageConfig `json:"hybrid_triage,omitempty"` - Semantic AISemanticConfig `json:"semantic,omitempty"` - AutoFix AIAutoFixConfig `json:"autofix,omitempty"` + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + Provider AIProviderConfig `json:"provider,omitempty" yaml:"provider,omitempty"` + Cache AICacheConfig `json:"cache,omitempty" yaml:"cache,omitempty"` + HybridTriage AIHybridTriageConfig `json:"hybrid_triage,omitempty" yaml:"hybrid_triage,omitempty"` + Semantic AISemanticConfig `json:"semantic,omitempty" yaml:"semantic,omitempty"` + AutoFix AIAutoFixConfig `json:"autofix,omitempty" yaml:"autofix,omitempty"` } type AIProviderConfig struct { - Type string `json:"type,omitempty"` - Model string `json:"model,omitempty"` - BaseURL string `json:"base_url,omitempty"` - APIKeyEnv string `json:"api_key_env,omitempty"` - Command string `json:"command,omitempty"` - Args []string `json:"args,omitempty"` + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Model string `json:"model,omitempty" yaml:"model,omitempty"` + BaseURL string `json:"base_url,omitempty" yaml:"base_url,omitempty"` + APIKeyEnv string `json:"api_key_env,omitempty" yaml:"api_key_env,omitempty"` + Command string `json:"command,omitempty" yaml:"command,omitempty"` + Args []string `json:"args,omitempty" yaml:"args,omitempty"` } type AICacheConfig struct { - Path string `json:"path,omitempty"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` } type AIHybridTriageConfig struct { - Enabled *bool `json:"enabled,omitempty"` - SuppressDismissed *bool `json:"suppress_dismissed,omitempty"` - CandidateSections []string `json:"candidate_sections,omitempty"` - CandidateSeverities []string `json:"candidate_severities,omitempty"` + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + SuppressDismissed *bool `json:"suppress_dismissed,omitempty" yaml:"suppress_dismissed,omitempty"` + CandidateSections []string `json:"candidate_sections,omitempty" yaml:"candidate_sections,omitempty"` + CandidateSeverities []string `json:"candidate_severities,omitempty" yaml:"candidate_severities,omitempty"` } type AISemanticConfig struct { - Enabled *bool `json:"enabled,omitempty"` - FunctionContract *bool `json:"function_contract,omitempty"` - MisleadingErrorMessages *bool `json:"misleading_error_messages,omitempty"` - TestBehaviorCoverage *bool `json:"test_behavior_coverage,omitempty"` + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + FunctionContract *bool `json:"function_contract,omitempty" yaml:"function_contract,omitempty"` + ContractDrift *bool `json:"contract_drift,omitempty" yaml:"contract_drift,omitempty"` + MisleadingErrorMessages *bool `json:"misleading_error_messages,omitempty" yaml:"misleading_error_messages,omitempty"` + TestBehaviorCoverage *bool `json:"test_behavior_coverage,omitempty" yaml:"test_behavior_coverage,omitempty"` + TestAdequacy *bool `json:"test_adequacy,omitempty" yaml:"test_adequacy,omitempty"` } type AIAutoFixConfig struct { - Enabled *bool `json:"enabled,omitempty"` - VerifyTests *bool `json:"verify_tests,omitempty"` - MaxFixes int `json:"max_fixes,omitempty"` - TestCommands []CommandCheckConfig `json:"test_commands,omitempty"` + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + VerifyTests *bool `json:"verify_tests,omitempty" yaml:"verify_tests,omitempty"` + MaxFixes int `json:"max_fixes,omitempty" yaml:"max_fixes,omitempty"` + TestCommands []CommandCheckConfig `json:"test_commands,omitempty" yaml:"test_commands,omitempty"` } diff --git a/internal/codeguard/core/config_contract_types.go b/internal/codeguard/core/config_contract_types.go index 003df71..e863b3e 100644 --- a/internal/codeguard/core/config_contract_types.go +++ b/internal/codeguard/core/config_contract_types.go @@ -1,9 +1,9 @@ package core type ContractRulesConfig struct { - GoExportedBreaking *bool `json:"go_exported_breaking,omitempty"` - OpenAPIBreaking *bool `json:"openapi_breaking,omitempty"` - ProtoBreaking *bool `json:"proto_breaking,omitempty"` - MigrationDestructive *bool `json:"migration_destructive,omitempty"` - MigrationPaths []string `json:"migration_paths,omitempty"` + GoExportedBreaking *bool `json:"go_exported_breaking,omitempty" yaml:"go_exported_breaking,omitempty"` + OpenAPIBreaking *bool `json:"openapi_breaking,omitempty" yaml:"openapi_breaking,omitempty"` + ProtoBreaking *bool `json:"proto_breaking,omitempty" yaml:"proto_breaking,omitempty"` + MigrationDestructive *bool `json:"migration_destructive,omitempty" yaml:"migration_destructive,omitempty"` + MigrationPaths []string `json:"migration_paths,omitempty" yaml:"migration_paths,omitempty"` } diff --git a/internal/codeguard/core/config_coverage_types.go b/internal/codeguard/core/config_coverage_types.go index d16752d..4c2d455 100644 --- a/internal/codeguard/core/config_coverage_types.go +++ b/internal/codeguard/core/config_coverage_types.go @@ -6,30 +6,30 @@ package core type CoverageDeltaConfig struct { // Enabled turns the check on. Defaults to false because the check runs // the target's test suite during the scan. - Enabled *bool `json:"enabled,omitempty"` + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` // MinChangedLineCoverage is the warn threshold (percent, default 60): // files whose changed lines are covered below this emit a warn finding. - MinChangedLineCoverage *int `json:"min_changed_line_coverage,omitempty"` + MinChangedLineCoverage *int `json:"min_changed_line_coverage,omitempty" yaml:"min_changed_line_coverage,omitempty"` // FailUnder, when set, escalates findings to fail for files whose // changed-line coverage falls below this percentage. - FailUnder *int `json:"fail_under,omitempty"` + FailUnder *int `json:"fail_under,omitempty" yaml:"fail_under,omitempty"` // LanguageCommands configures non-Go targets: a coverage command to run // plus the coverage report it produces. Go targets run // `go test -coverprofile` natively and need no entry here. - LanguageCommands map[string]CoverageCommandConfig `json:"language_commands,omitempty"` + LanguageCommands map[string]CoverageCommandConfig `json:"language_commands,omitempty" yaml:"language_commands,omitempty"` } // CoverageCommandConfig describes how to produce and read a coverage report // for a non-Go language target. type CoverageCommandConfig struct { - Name string `json:"name,omitempty"` - Command string `json:"command"` - Args []string `json:"args,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Command string `json:"command" yaml:"command"` + Args []string `json:"args,omitempty" yaml:"args,omitempty"` // ReportPath is the coverage report the command writes, relative to the // target path. - ReportPath string `json:"report_path"` + ReportPath string `json:"report_path" yaml:"report_path"` // Format of the report. Only "lcov" is supported (the default). - Format string `json:"format,omitempty"` + Format string `json:"format,omitempty" yaml:"format,omitempty"` } // TestQualityRulesConfig controls the regex-based test assertion rules in the @@ -37,8 +37,8 @@ type CoverageCommandConfig struct { // ci.conditional-assertion). type TestQualityRulesConfig struct { // Enabled defaults to true. - Enabled *bool `json:"enabled,omitempty"` + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` // AssertionHelpers lists custom assertion helper function names // (for example "assertValid") that count as real assertions. - AssertionHelpers []string `json:"assertion_helpers,omitempty"` + AssertionHelpers []string `json:"assertion_helpers,omitempty" yaml:"assertion_helpers,omitempty"` } diff --git a/internal/codeguard/core/config_rule_types.go b/internal/codeguard/core/config_rule_types.go index ca062d2..89029d0 100644 --- a/internal/codeguard/core/config_rule_types.go +++ b/internal/codeguard/core/config_rule_types.go @@ -1,95 +1,111 @@ package core type QualityRulesConfig struct { - MaxFileLines int `json:"max_file_lines"` - MaxFunctionLines int `json:"max_function_lines"` - MaxParameters int `json:"max_parameters"` - MaxCyclomaticComplexity int `json:"max_cyclomatic_complexity"` - CloneTokenThreshold int `json:"clone_token_threshold,omitempty"` - LanguageCommands map[string][]CommandCheckConfig `json:"language_commands,omitempty"` - DetectNPlusOneQuery *bool `json:"detect_n_plus_one_query,omitempty"` - DetectAllocInLoop *bool `json:"detect_alloc_in_loop,omitempty"` + MaxFileLines int `json:"max_file_lines" yaml:"max_file_lines"` + MaxFunctionLines int `json:"max_function_lines" yaml:"max_function_lines"` + MaxParameters int `json:"max_parameters" yaml:"max_parameters"` + MaxCyclomaticComplexity int `json:"max_cyclomatic_complexity" yaml:"max_cyclomatic_complexity"` + CloneTokenThreshold int `json:"clone_token_threshold,omitempty" yaml:"clone_token_threshold,omitempty"` + LanguageCommands map[string][]CommandCheckConfig `json:"language_commands,omitempty" yaml:"language_commands,omitempty"` + DetectNPlusOneQuery *bool `json:"detect_n_plus_one_query,omitempty" yaml:"detect_n_plus_one_query,omitempty"` + DetectAllocInLoop *bool `json:"detect_alloc_in_loop,omitempty" yaml:"detect_alloc_in_loop,omitempty"` // DetectPreallocInLoop gates the append-without-preallocation branch of // quality.go.alloc-in-loop. Defaults to false: preallocating is a // micro-optimization, and idiomatic accumulation loops legitimately skip it. - DetectPreallocInLoop *bool `json:"detect_prealloc_in_loop,omitempty"` - DetectSyncIOInHandlers *bool `json:"detect_sync_io_in_handlers,omitempty"` - DetectUnboundedConcurrency *bool `json:"detect_unbounded_concurrency,omitempty"` - AIProvenance AIProvenanceConfig `json:"ai_provenance,omitempty"` - AIChecks AIChecksConfig `json:"ai_checks,omitempty"` - CoverageDelta CoverageDeltaConfig `json:"coverage_delta,omitempty"` + DetectPreallocInLoop *bool `json:"detect_prealloc_in_loop,omitempty" yaml:"detect_prealloc_in_loop,omitempty"` + DetectSyncIOInHandlers *bool `json:"detect_sync_io_in_handlers,omitempty" yaml:"detect_sync_io_in_handlers,omitempty"` + DetectUnboundedConcurrency *bool `json:"detect_unbounded_concurrency,omitempty" yaml:"detect_unbounded_concurrency,omitempty"` + AIProvenance AIProvenanceConfig `json:"ai_provenance,omitempty" yaml:"ai_provenance,omitempty"` + AIChangeRisk AIChangeRiskConfig `json:"ai_change_risk,omitempty" yaml:"ai_change_risk,omitempty"` + AIChecks AIChecksConfig `json:"ai_checks,omitempty" yaml:"ai_checks,omitempty"` + CoverageDelta CoverageDeltaConfig `json:"coverage_delta,omitempty" yaml:"coverage_delta,omitempty"` } // AIChecksConfig toggles individual AI-quality heuristics. A nil pointer // leaves the check enabled, matching the rest of the rule pack defaults. type AIChecksConfig struct { - HallucinatedImport *bool `json:"hallucinated_import,omitempty"` - DeadCode *bool `json:"dead_code,omitempty"` - ErrorStyleDrift *bool `json:"error_style_drift,omitempty"` - NamingDrift *bool `json:"naming_drift,omitempty"` - SlopHistory *bool `json:"slop_history,omitempty"` - SlopHistoryLimit int `json:"slop_history_limit,omitempty"` + HallucinatedImport *bool `json:"hallucinated_import,omitempty" yaml:"hallucinated_import,omitempty"` + DeadCode *bool `json:"dead_code,omitempty" yaml:"dead_code,omitempty"` + ErrorStyleDrift *bool `json:"error_style_drift,omitempty" yaml:"error_style_drift,omitempty"` + NamingDrift *bool `json:"naming_drift,omitempty" yaml:"naming_drift,omitempty"` + SlopHistory *bool `json:"slop_history,omitempty" yaml:"slop_history,omitempty"` + SlopHistoryLimit int `json:"slop_history_limit,omitempty" yaml:"slop_history_limit,omitempty"` } type AIProvenanceConfig struct { - Enabled *bool `json:"enabled,omitempty"` - EnvVars []string `json:"env_vars,omitempty"` - CommitTrailers []string `json:"commit_trailers,omitempty"` - SlopScoreWarnThreshold int `json:"slop_score_warn_threshold,omitempty"` - SlopScoreFailThreshold int `json:"slop_score_fail_threshold,omitempty"` + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + EnvVars []string `json:"env_vars,omitempty" yaml:"env_vars,omitempty"` + CommitTrailers []string `json:"commit_trailers,omitempty" yaml:"commit_trailers,omitempty"` + SlopScoreWarnThreshold int `json:"slop_score_warn_threshold,omitempty" yaml:"slop_score_warn_threshold,omitempty"` + SlopScoreFailThreshold int `json:"slop_score_fail_threshold,omitempty" yaml:"slop_score_fail_threshold,omitempty"` +} + +type AIChangeRiskConfig struct { + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + WarnThreshold int `json:"warn_threshold,omitempty" yaml:"warn_threshold,omitempty"` + FailThreshold int `json:"fail_threshold,omitempty" yaml:"fail_threshold,omitempty"` } type DesignRulesConfig struct { - RequireCmdThroughInternalCLI *bool `json:"require_cmd_through_internal_cli,omitempty"` - ForbidInternalImportCmd *bool `json:"forbid_internal_import_cmd,omitempty"` - ForbidServiceImportInternal *bool `json:"forbid_service_import_internal,omitempty"` - ForbidServiceImportCmd *bool `json:"forbid_service_import_cmd,omitempty"` - MaxDeclsPerFile int `json:"max_decls_per_file"` - MaxMethodsPerType int `json:"max_methods_per_type"` - MaxInterfaceMethods int `json:"max_interface_methods"` - DetectImportCycles *bool `json:"detect_import_cycles,omitempty"` - DetectGodModules *bool `json:"detect_god_modules,omitempty"` - GodModuleThreshold int `json:"god_module_threshold"` - DetectHighImpactChanges *bool `json:"detect_high_impact_changes,omitempty"` - HighImpactChangeThreshold int `json:"high_impact_change_threshold"` - ForbiddenPackageNames []string `json:"forbidden_package_names,omitempty"` - LanguageCommands map[string][]CommandCheckConfig `json:"language_commands,omitempty"` - LanguageDiffCommands map[string][]CommandCheckConfig `json:"language_diff_commands,omitempty"` + RequireCmdThroughInternalCLI *bool `json:"require_cmd_through_internal_cli,omitempty" yaml:"require_cmd_through_internal_cli,omitempty"` + ForbidInternalImportCmd *bool `json:"forbid_internal_import_cmd,omitempty" yaml:"forbid_internal_import_cmd,omitempty"` + ForbidServiceImportInternal *bool `json:"forbid_service_import_internal,omitempty" yaml:"forbid_service_import_internal,omitempty"` + ForbidServiceImportCmd *bool `json:"forbid_service_import_cmd,omitempty" yaml:"forbid_service_import_cmd,omitempty"` + MaxDeclsPerFile int `json:"max_decls_per_file" yaml:"max_decls_per_file"` + MaxMethodsPerType int `json:"max_methods_per_type" yaml:"max_methods_per_type"` + MaxInterfaceMethods int `json:"max_interface_methods" yaml:"max_interface_methods"` + DetectImportCycles *bool `json:"detect_import_cycles,omitempty" yaml:"detect_import_cycles,omitempty"` + DetectGodModules *bool `json:"detect_god_modules,omitempty" yaml:"detect_god_modules,omitempty"` + GodModuleThreshold int `json:"god_module_threshold" yaml:"god_module_threshold"` + DetectHighImpactChanges *bool `json:"detect_high_impact_changes,omitempty" yaml:"detect_high_impact_changes,omitempty"` + HighImpactChangeThreshold int `json:"high_impact_change_threshold" yaml:"high_impact_change_threshold"` + ForbiddenPackageNames []string `json:"forbidden_package_names,omitempty" yaml:"forbidden_package_names,omitempty"` + LanguageCommands map[string][]CommandCheckConfig `json:"language_commands,omitempty" yaml:"language_commands,omitempty"` + LanguageDiffCommands map[string][]CommandCheckConfig `json:"language_diff_commands,omitempty" yaml:"language_diff_commands,omitempty"` } type PromptRulesConfig struct { - FileExtensions []string `json:"file_extensions,omitempty"` - PathContains []string `json:"path_contains,omitempty"` - ForbidSecretInterpolation *bool `json:"forbid_secret_interpolation,omitempty"` - ForbidUnsafeInstructions *bool `json:"forbid_unsafe_instructions,omitempty"` + FileExtensions []string `json:"file_extensions,omitempty" yaml:"file_extensions,omitempty"` + PathContains []string `json:"path_contains,omitempty" yaml:"path_contains,omitempty"` + ForbidSecretInterpolation *bool `json:"forbid_secret_interpolation,omitempty" yaml:"forbid_secret_interpolation,omitempty"` + ForbidUnsafeInstructions *bool `json:"forbid_unsafe_instructions,omitempty" yaml:"forbid_unsafe_instructions,omitempty"` } type CIRulesConfig struct { - RequireWorkflowDir *bool `json:"require_workflow_dir,omitempty"` - RequiredWorkflowFiles []string `json:"required_workflow_files,omitempty"` - WorkflowContentRules []WorkflowRuleConfig `json:"workflow_content_rules,omitempty"` - RequiredReleaseFiles []string `json:"required_release_files,omitempty"` - RequiredAutomationPaths []string `json:"required_automation_paths,omitempty"` - AllowedTestPaths []string `json:"allowed_test_paths,omitempty"` - TestQuality TestQualityRulesConfig `json:"test_quality,omitempty"` + RequireWorkflowDir *bool `json:"require_workflow_dir,omitempty" yaml:"require_workflow_dir,omitempty"` + RequiredWorkflowFiles []string `json:"required_workflow_files,omitempty" yaml:"required_workflow_files,omitempty"` + WorkflowContentRules []WorkflowRuleConfig `json:"workflow_content_rules,omitempty" yaml:"workflow_content_rules,omitempty"` + RequiredReleaseFiles []string `json:"required_release_files,omitempty" yaml:"required_release_files,omitempty"` + RequiredAutomationPaths []string `json:"required_automation_paths,omitempty" yaml:"required_automation_paths,omitempty"` + AllowedTestPaths []string `json:"allowed_test_paths,omitempty" yaml:"allowed_test_paths,omitempty"` + TestQuality TestQualityRulesConfig `json:"test_quality,omitempty" yaml:"test_quality,omitempty"` } type WorkflowRuleConfig struct { - Path string `json:"path"` - RequiredContains []string `json:"required_contains,omitempty"` + Path string `json:"path" yaml:"path"` + RequiredContains []string `json:"required_contains,omitempty" yaml:"required_contains,omitempty"` } type SecurityRulesConfig struct { - GovulncheckMode string `json:"govulncheck_mode,omitempty"` - GovulncheckCommand string `json:"govulncheck_command,omitempty"` - TaintGo *bool `json:"taint_go,omitempty"` - TaintPython *bool `json:"taint_python,omitempty"` - TypeScriptTaintMaxDepth int `json:"typescript_taint_max_depth,omitempty"` - LanguageCommands map[string][]CommandCheckConfig `json:"language_commands,omitempty"` + GovulncheckMode string `json:"govulncheck_mode,omitempty" yaml:"govulncheck_mode,omitempty"` + GovulncheckCommand string `json:"govulncheck_command,omitempty" yaml:"govulncheck_command,omitempty"` + TaintGo *bool `json:"taint_go,omitempty" yaml:"taint_go,omitempty"` + TaintPython *bool `json:"taint_python,omitempty" yaml:"taint_python,omitempty"` + TypeScriptTaintMaxDepth int `json:"typescript_taint_max_depth,omitempty" yaml:"typescript_taint_max_depth,omitempty"` + LanguageCommands map[string][]CommandCheckConfig `json:"language_commands,omitempty" yaml:"language_commands,omitempty"` +} + +type SupplyChainRulesConfig struct { + RequireLockfile *bool `json:"require_lockfile,omitempty" yaml:"require_lockfile,omitempty"` + DetectLockfileDrift *bool `json:"detect_lockfile_drift,omitempty" yaml:"detect_lockfile_drift,omitempty"` + DetectUnpinned *bool `json:"detect_unpinned,omitempty" yaml:"detect_unpinned,omitempty"` + AllowedLicenses []string `json:"allowed_licenses,omitempty" yaml:"allowed_licenses,omitempty"` + DeniedLicenses []string `json:"denied_licenses,omitempty" yaml:"denied_licenses,omitempty"` + LicenseCommands map[string]CommandCheckConfig `json:"license_commands,omitempty" yaml:"license_commands,omitempty"` } type CommandCheckConfig struct { - Name string `json:"name"` - Command string `json:"command"` - Args []string `json:"args,omitempty"` + Name string `json:"name" yaml:"name"` + Command string `json:"command" yaml:"command"` + Args []string `json:"args,omitempty" yaml:"args,omitempty"` } diff --git a/internal/codeguard/core/config_types.go b/internal/codeguard/core/config_types.go index 9b8207d..74f8709 100644 --- a/internal/codeguard/core/config_types.go +++ b/internal/codeguard/core/config_types.go @@ -1,60 +1,64 @@ package core type Config struct { - Name string `json:"name"` - Profile string `json:"profile,omitempty"` - Targets []TargetConfig `json:"targets"` - Checks CheckConfig `json:"checks"` - AI AIConfig `json:"ai,omitempty"` - RulePacks []RulePackConfig `json:"rule_packs,omitempty"` - Output OutputConfig `json:"output"` - Exclude []string `json:"exclude,omitempty"` - Baseline BaselineConfig `json:"baseline,omitempty"` - Waivers []WaiverConfig `json:"waivers,omitempty"` - Cache CacheConfig `json:"cache,omitempty"` + Name string `json:"name" yaml:"name"` + Profile string `json:"profile,omitempty" yaml:"profile,omitempty"` + Targets []TargetConfig `json:"targets" yaml:"targets"` + Checks CheckConfig `json:"checks" yaml:"checks"` + AI AIConfig `json:"ai,omitempty" yaml:"ai,omitempty"` + RulePacks []RulePackConfig `json:"rule_packs,omitempty" yaml:"rule_packs,omitempty"` + Output OutputConfig `json:"output" yaml:"output"` + Exclude []string `json:"exclude,omitempty" yaml:"exclude,omitempty"` + Baseline BaselineConfig `json:"baseline,omitempty" yaml:"baseline,omitempty"` + Waivers []WaiverConfig `json:"waivers,omitempty" yaml:"waivers,omitempty"` + Cache CacheConfig `json:"cache,omitempty" yaml:"cache,omitempty"` } type TargetConfig struct { - Name string `json:"name"` - Path string `json:"path"` - Language string `json:"language"` - Entrypoints []string `json:"entrypoints,omitempty"` + Name string `json:"name" yaml:"name"` + Path string `json:"path" yaml:"path"` + Language string `json:"language" yaml:"language"` + Entrypoints []string `json:"entrypoints,omitempty" yaml:"entrypoints,omitempty"` } type CheckConfig struct { - Quality bool `json:"quality"` - Design bool `json:"design"` - Security bool `json:"security"` - Prompts bool `json:"prompts"` - CI bool `json:"ci"` + Quality bool `json:"quality" yaml:"quality"` + Design bool `json:"design" yaml:"design"` + Security bool `json:"security" yaml:"security"` + Prompts bool `json:"prompts" yaml:"prompts"` + CI bool `json:"ci" yaml:"ci"` + // SupplyChain toggles dependency-policy checks such as manifest hygiene, + // lockfile drift, license policy, and SBOM-oriented validation. + SupplyChain bool `json:"supply_chain,omitempty" yaml:"supply_chain,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. - Contracts *bool `json:"contracts,omitempty"` - QualityRules QualityRulesConfig `json:"quality_rules"` - DesignRules DesignRulesConfig `json:"design_rules"` - PromptRules PromptRulesConfig `json:"prompt_rules"` - CIRules CIRulesConfig `json:"ci_rules"` - SecurityRules SecurityRulesConfig `json:"security_rules"` - ContractRules ContractRulesConfig `json:"contract_rules"` + Contracts *bool `json:"contracts,omitempty" yaml:"contracts,omitempty"` + QualityRules QualityRulesConfig `json:"quality_rules" yaml:"quality_rules"` + DesignRules DesignRulesConfig `json:"design_rules" yaml:"design_rules"` + PromptRules PromptRulesConfig `json:"prompt_rules" yaml:"prompt_rules"` + CIRules CIRulesConfig `json:"ci_rules" yaml:"ci_rules"` + SecurityRules SecurityRulesConfig `json:"security_rules" yaml:"security_rules"` + SupplyChainRules SupplyChainRulesConfig `json:"supply_chain_rules" yaml:"supply_chain_rules"` + ContractRules ContractRulesConfig `json:"contract_rules" yaml:"contract_rules"` } type OutputConfig struct { - Format string `json:"format"` + Format string `json:"format" yaml:"format"` } type CacheConfig struct { - Enabled *bool `json:"enabled,omitempty"` - Path string `json:"path,omitempty"` + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` } type BaselineConfig struct { - Path string `json:"path,omitempty"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` } type WaiverConfig struct { - Rule string `json:"rule"` - Path string `json:"path,omitempty"` - Reason string `json:"reason,omitempty"` - ExpiresOn string `json:"expires_on,omitempty"` + Rule string `json:"rule" yaml:"rule"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` + Reason string `json:"reason,omitempty" yaml:"reason,omitempty"` + ExpiresOn string `json:"expires_on,omitempty" yaml:"expires_on,omitempty"` } diff --git a/internal/codeguard/core/report_artifact_ai_types.go b/internal/codeguard/core/report_artifact_ai_types.go index 45cb9fc..f85fb6a 100644 --- a/internal/codeguard/core/report_artifact_ai_types.go +++ b/internal/codeguard/core/report_artifact_ai_types.go @@ -10,6 +10,23 @@ type SlopScoreArtifact struct { Delta *int `json:"delta,omitempty"` } +type ChangeRiskArtifact struct { + Score int `json:"score"` + Level string `json:"level,omitempty"` + ProvenanceActive bool `json:"provenance_active,omitempty"` + ChangedFiles int `json:"changed_files,omitempty"` + HighImpactChange bool `json:"high_impact_change,omitempty"` + AIFindingCount int `json:"ai_finding_count,omitempty"` + SemanticFindingCount int `json:"semantic_finding_count,omitempty"` + Components []ChangeRiskComponent `json:"components,omitempty"` +} + +type ChangeRiskComponent struct { + Label string `json:"label"` + Contribution int `json:"contribution"` + Detail string `json:"detail,omitempty"` +} + // SlopHistoryEntry is one persisted slop-score observation for a target, // recorded once per scan so trends can be reported over time. type SlopHistoryEntry struct { diff --git a/internal/codeguard/core/report_artifact_types.go b/internal/codeguard/core/report_artifact_types.go index 1f8c53a..cce5f74 100644 --- a/internal/codeguard/core/report_artifact_types.go +++ b/internal/codeguard/core/report_artifact_types.go @@ -6,7 +6,9 @@ type Artifact struct { Language string `json:"language,omitempty"` Target string `json:"target,omitempty"` DependencyGraph *DependencyGraphArtifact `json:"dependency_graph,omitempty"` + SupplyChain *SupplyChainArtifact `json:"supply_chain,omitempty"` SlopScore *SlopScoreArtifact `json:"slop_score,omitempty"` + ChangeRisk *ChangeRiskArtifact `json:"change_risk,omitempty"` AIAnalysis *AIAnalysisArtifact `json:"ai_analysis,omitempty"` AIFix *AIFixArtifact `json:"ai_fix,omitempty"` ChangeImpact *ChangeImpactArtifact `json:"change_impact,omitempty"` @@ -30,6 +32,42 @@ type DependencyGraphEdge struct { Names []string `json:"names,omitempty"` } +type SupplyChainArtifact struct { + Manifests []SupplyChainManifest `json:"manifests"` +} + +type SupplyChainManifest struct { + Ecosystem string `json:"ecosystem"` + Path string `json:"path"` + Name string `json:"name,omitempty"` + License string `json:"license,omitempty"` + LicenseLine int `json:"license_line,omitempty"` + PackageManager string `json:"package_manager,omitempty"` + Lockfiles []string `json:"lockfiles,omitempty"` + Dependencies []SupplyChainDependency `json:"dependencies,omitempty"` +} + +type SupplyChainDependency struct { + Name string `json:"name"` + Requirement string `json:"requirement,omitempty"` + Version string `json:"version,omitempty"` + Scope string `json:"scope,omitempty"` + Groups []string `json:"groups,omitempty"` + Indirect bool `json:"indirect,omitempty"` + Pinned bool `json:"pinned,omitempty"` + Line int `json:"line,omitempty"` + License string `json:"license,omitempty"` + LicenseSource string `json:"license_source,omitempty"` + LicenseCandidates []SupplyChainLicenseCandidate `json:"license_candidates,omitempty"` +} + +type SupplyChainLicenseCandidate struct { + License string `json:"license"` + Confidence string `json:"confidence,omitempty"` + Provenance string `json:"provenance,omitempty"` + Source string `json:"source,omitempty"` +} + const ReportArtifactKindChangeImpact = "change-impact" // ChangeImpactArtifact summarizes the transitive dependency impact of changed diff --git a/internal/codeguard/core/rule_metadata_helpers.go b/internal/codeguard/core/rule_metadata_helpers.go index 63b5d03..4bbeabc 100644 --- a/internal/codeguard/core/rule_metadata_helpers.go +++ b/internal/codeguard/core/rule_metadata_helpers.go @@ -64,6 +64,10 @@ func defaultRuleLanguageCoverage(ruleID string, executionModel RuleExecutionMode case "security.hardcoded-secret", "security.private-key", + "supply_chain.unpinned-dependency", + "supply_chain.missing-lockfile", + "supply_chain.lockfile-drift", + "supply_chain.denied-license", "prompts.secret-interpolation", "prompts.unsafe-instructions", "prompts.agent-dangerous-instructions", diff --git a/internal/codeguard/core/rule_scan_pack_types.go b/internal/codeguard/core/rule_scan_pack_types.go index 6d4f9e5..c681e8e 100644 --- a/internal/codeguard/core/rule_scan_pack_types.go +++ b/internal/codeguard/core/rule_scan_pack_types.go @@ -16,29 +16,29 @@ type ScanOptions struct { } type RulePackConfig struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Rules []CustomRuleConfig `json:"rules"` + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Rules []CustomRuleConfig `json:"rules" yaml:"rules"` } type CustomRuleConfig struct { - ID string `json:"id"` - Section string `json:"section,omitempty"` - Severity string `json:"severity,omitempty"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Message string `json:"message"` - HowToFix string `json:"how_to_fix,omitempty"` - NaturalLanguage string `json:"natural_language,omitempty"` - Paths []string `json:"paths,omitempty"` - Exclude []string `json:"exclude,omitempty"` - FileExtensions []string `json:"file_extensions,omitempty"` - PathRegex string `json:"path_regex,omitempty"` - ContentRegex string `json:"content_regex,omitempty"` - AIPrompt string `json:"ai_prompt,omitempty"` + ID string `json:"id" yaml:"id"` + Section string `json:"section,omitempty" yaml:"section,omitempty"` + Severity string `json:"severity,omitempty" yaml:"severity,omitempty"` + Title string `json:"title" yaml:"title"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Message string `json:"message" yaml:"message"` + HowToFix string `json:"how_to_fix,omitempty" yaml:"how_to_fix,omitempty"` + NaturalLanguage string `json:"natural_language,omitempty" yaml:"natural_language,omitempty"` + Paths []string `json:"paths,omitempty" yaml:"paths,omitempty"` + Exclude []string `json:"exclude,omitempty" yaml:"exclude,omitempty"` + FileExtensions []string `json:"file_extensions,omitempty" yaml:"file_extensions,omitempty"` + PathRegex string `json:"path_regex,omitempty" yaml:"path_regex,omitempty"` + ContentRegex string `json:"content_regex,omitempty" yaml:"content_regex,omitempty"` + AIPrompt string `json:"ai_prompt,omitempty" yaml:"ai_prompt,omitempty"` } type PolicyProfile struct { - Name string `json:"name"` - Description string `json:"description"` + Name string `json:"name" yaml:"name"` + Description string `json:"description" yaml:"description"` } diff --git a/internal/codeguard/rules/catalog.go b/internal/codeguard/rules/catalog.go index 4231cfd..caa0b82 100644 --- a/internal/codeguard/rules/catalog.go +++ b/internal/codeguard/rules/catalog.go @@ -8,6 +8,7 @@ var catalog = mergeRuleCatalogs( designCatalog, designGraphCatalog, securityCatalog, + supplyChainCatalog, contractsCatalog, securityTaintCatalog, miscCatalog, diff --git a/internal/codeguard/rules/catalog_fix_templates.go b/internal/codeguard/rules/catalog_fix_templates.go index f9f4da0..c6672c1 100644 --- a/internal/codeguard/rules/catalog_fix_templates.go +++ b/internal/codeguard/rules/catalog_fix_templates.go @@ -12,6 +12,8 @@ var fixTemplates = map[string]string{ "quality.ai.narrative-comment": "Delete comments that narrate the adjacent code, or rewrite them to capture intent, constraints, or tradeoffs.\n\nBefore:\n// loop over the users and print each one\nfor _, user := range users {\n\tfmt.Println(user)\n}\n\nAfter:\n// emit one line per user so the audit job can diff snapshots\nfor _, user := range users {\n\tfmt.Println(user)\n}", "quality.ai.dead-code": "Delete the unreachable constant-condition branch or replace the placeholder with a real runtime check.\n\nBefore:\nif (false) {\n runLegacyMigration();\n}\n\nAfter:\nif (config.legacyMigrationEnabled) {\n runLegacyMigration();\n}\n// or delete the branch and its dead callee entirely", "quality.ai.over-mocked-test": "Exercise the real unit boundary and assert on observable behavior instead of mock wiring.\n\nBefore:\nmockRepo.On(\"Save\", mock.Anything).Return(nil)\nsvc.Create(user)\nmockRepo.AssertExpectations(t)\n\nAfter:\nrepo := newInMemoryRepo()\nsvc := NewService(repo)\nsvc.Create(user)\nif got := len(repo.All()); got != 1 {\n\tt.Fatalf(\"saved users = %d, want 1\", got)\n}", + "quality.ai.contract-drift": "Make the behavior change explicit and keep docs, errors, and tests aligned with the new contract.\n\nBefore:\n// BuildUser creates a user record.\nfunc BuildUser() error {\n\treturn errors.New(\"user deleted\")\n}\n\nAfter:\n// DeleteUser removes an existing user record.\nfunc DeleteUser() error {\n\treturn errors.New(\"user deleted\")\n}\n// or keep BuildUser semantics and restore create behavior with matching tests", + "quality.ai.semantic-test-adequacy": "Strengthen the nearby tests so they prove the changed behavior with meaningful assertions, negative paths, and boundary coverage.\n\nBefore:\nfunc TestBuildUser(t *testing.T) {\n\tBuildUser()\n}\n\nAfter:\nfunc TestBuildUserReturnsErrorOnConflict(t *testing.T) {\n\terr := BuildUser(conflictingInput)\n\tif err == nil {\n\t\tt.Fatal(\"expected conflict error\")\n\t}\n\tif !strings.Contains(err.Error(), \"conflict\") {\n\t\tt.Fatalf(\"error = %v, want conflict details\", err)\n\t}\n}", "quality.max-function-lines": "Extract cohesive steps into named helpers until the function fits the configured limit.\n\nBefore:\nfunc handle(req Request) (Response, error) {\n\t// dozens of lines: validate, transform, persist, render\n}\n\nAfter:\nfunc handle(req Request) (Response, error) {\n\tinput, err := validate(req)\n\tif err != nil {\n\t\treturn Response{}, err\n\t}\n\trecord := transform(input)\n\tif err := persist(record); err != nil {\n\t\treturn Response{}, err\n\t}\n\treturn render(record), nil\n}", "quality.cyclomatic-complexity": "Flatten branching with early returns, or replace branch ladders with table-driven dispatch.\n\nBefore:\nif ok {\n\tif valid {\n\t\tif ready {\n\t\t\tprocess()\n\t\t}\n\t}\n}\n\nAfter:\nif !ok || !valid || !ready {\n\treturn\n}\nprocess()", "quality.typescript.explicit-any": "Replace any with a precise type, a generic constraint, or unknown plus narrowing.\n\nBefore:\nfunction parse(input: any): any {\n return JSON.parse(input);\n}\n\nAfter:\nfunction parse(input: string): T {\n return JSON.parse(input) as T;\n}", diff --git a/internal/codeguard/rules/catalog_quality.go b/internal/codeguard/rules/catalog_quality.go index 5718804..7dc4300 100644 --- a/internal/codeguard/rules/catalog_quality.go +++ b/internal/codeguard/rules/catalog_quality.go @@ -265,6 +265,16 @@ var qualityCatalog = map[string]core.RuleMetadata{ Description: "Warns when new tests diverge from the repository's dominant local testing idiom, which is a common smell in AI-generated contributions that are technically valid but inconsistent.", HowToFix: "Match the established local framework and style unless there is a deliberate migration plan with repository-wide buy-in.", }, + "quality.ai.change-risk": { + ID: "quality.ai.change-risk", + Section: "Code Quality", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.RepositoryWideRuleLanguageCoverage(), + Title: "AI change risk", + Description: "Warns when an AI-assisted or AI-shaped change accumulates enough risky signals that it should receive stricter review before merge.", + HowToFix: "Reduce the risky surface area of the change, add or strengthen tests, split the patch into smaller steps, or resolve the AI-quality findings driving the score.", + }, "quality.ai.provenance-policy": { ID: "quality.ai.provenance-policy", Section: "Code Quality", @@ -290,6 +300,21 @@ var qualityCatalog = map[string]core.RuleMetadata{ Description: "Warns when optional LLM-assisted semantic review finds that a changed function name or adjacent documentation no longer matches the implementation.", HowToFix: "Rename the function, update the surrounding documentation, or change the implementation so all three describe the same behavior.", }, + "quality.ai.contract-drift": { + ID: "quality.ai.contract-drift", + Section: "Code Quality", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelCommandDriven, + LanguageCoverage: core.FixedRuleLanguageCoverage( + core.RuleLanguageGo, + core.RuleLanguagePython, + core.RuleLanguageTypeScript, + core.RuleLanguageJavaScript, + ), + Title: "Semantic contract drift", + Description: "Warns when optional LLM-assisted semantic review finds that a changed function appears to drift from its prior behavior or surrounding contract without a clear caller-facing update.", + HowToFix: "Align the implementation, nearby docs, error semantics, and tests around the intended contract, or make the caller-visible behavior change explicit and fully covered.", + }, "quality.ai.semantic-error-message": { ID: "quality.ai.semantic-error-message", Section: "Code Quality", @@ -320,6 +345,36 @@ var qualityCatalog = map[string]core.RuleMetadata{ Description: "Warns when optional LLM-assisted semantic review finds that changed behavior does not appear to be exercised by nearby repository tests.", HowToFix: "Add or update tests that cover the new branch, return value, failure mode, or externally visible behavior introduced by the change.", }, + "quality.ai.semantic-test-adequacy": { + ID: "quality.ai.semantic-test-adequacy", + Section: "Code Quality", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelCommandDriven, + LanguageCoverage: core.FixedRuleLanguageCoverage( + core.RuleLanguageGo, + core.RuleLanguagePython, + core.RuleLanguageTypeScript, + core.RuleLanguageJavaScript, + ), + Title: "Semantic test adequacy gap", + Description: "Warns when optional LLM-assisted semantic review finds that nearby tests for a changed behavior look weak, overly happy-path, or otherwise inadequate for the risk introduced by the change.", + HowToFix: "Strengthen the nearby tests so they exercise the changed behavior with meaningful assertions, negative paths, and relevant edge cases instead of only shallow or mock-heavy checks.", + }, + "quality.ai.semantic-runtime": { + ID: "quality.ai.semantic-runtime", + Section: "Code Quality", + DefaultLevel: "fail", + ExecutionModel: core.RuleExecutionModelCommandDriven, + LanguageCoverage: core.FixedRuleLanguageCoverage( + core.RuleLanguageGo, + core.RuleLanguagePython, + core.RuleLanguageTypeScript, + core.RuleLanguageJavaScript, + ), + Title: "Semantic review runtime failure", + Description: "Fails when semantic review was enabled but the configured command was missing, crashed, or returned invalid output, so the scan would otherwise lose semantic coverage silently.", + HowToFix: "Configure a valid semantic command, then fix any runtime or JSON response errors so semantic review can run deterministically.", + }, "quality.typescript.ts-ignore": { ID: "quality.typescript.ts-ignore", Section: "Code Quality", diff --git a/internal/codeguard/rules/catalog_supplychain.go b/internal/codeguard/rules/catalog_supplychain.go new file mode 100644 index 0000000..1b29486 --- /dev/null +++ b/internal/codeguard/rules/catalog_supplychain.go @@ -0,0 +1,46 @@ +package rules + +import "github.com/devr-tools/codeguard/internal/codeguard/core" + +var supplyChainCatalog = map[string]core.RuleMetadata{ + "supply_chain.unpinned-dependency": { + ID: "supply_chain.unpinned-dependency", + Section: "Supply Chain", + DefaultLevel: "warn", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.RepositoryWideRuleLanguageCoverage(), + Title: "Unpinned dependency", + Description: "Warns when a dependency declaration does not pin to a concrete version or digest.", + HowToFix: "Pin the dependency to a reviewed version or digest and commit the corresponding lockfile update.", + }, + "supply_chain.missing-lockfile": { + ID: "supply_chain.missing-lockfile", + Section: "Supply Chain", + DefaultLevel: "fail", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.RepositoryWideRuleLanguageCoverage(), + Title: "Missing lockfile", + Description: "Fails when a supported package manifest is committed without its expected lockfile.", + HowToFix: "Generate and commit the lockfile that matches the manifest before merging the change.", + }, + "supply_chain.lockfile-drift": { + ID: "supply_chain.lockfile-drift", + Section: "Supply Chain", + DefaultLevel: "fail", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.RepositoryWideRuleLanguageCoverage(), + Title: "Lockfile drift", + Description: "Fails when a manifest change is not reflected in the associated lockfile.", + HowToFix: "Regenerate the lockfile from the updated manifest and commit both files together.", + }, + "supply_chain.denied-license": { + ID: "supply_chain.denied-license", + Section: "Supply Chain", + DefaultLevel: "fail", + ExecutionModel: core.RuleExecutionModelLanguageAgnostic, + LanguageCoverage: core.RepositoryWideRuleLanguageCoverage(), + Title: "Denied dependency license", + Description: "Fails when a dependency resolves to a license that violates configured policy.", + HowToFix: "Replace or upgrade the dependency, or adjust the license policy if the exception is intentional and approved.", + }, +} diff --git a/internal/codeguard/runner/checks/checks.go b/internal/codeguard/runner/checks/checks.go index 772ca56..4620d75 100644 --- a/internal/codeguard/runner/checks/checks.go +++ b/internal/codeguard/runner/checks/checks.go @@ -9,6 +9,7 @@ import ( promptsCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/prompts" qualityCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/quality" securityCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/security" + supplyChainCheck "github.com/devr-tools/codeguard/internal/codeguard/checks/supplychain" checkSupport "github.com/devr-tools/codeguard/internal/codeguard/checks/support" "github.com/devr-tools/codeguard/internal/codeguard/core" customrunner "github.com/devr-tools/codeguard/internal/codeguard/runner/custom" @@ -17,7 +18,7 @@ import ( ) func Build(ctx context.Context, sc runnersupport.Context) []core.SectionResult { - sections := make([]core.SectionResult, 0, 6) + sections := make([]core.SectionResult, 0, 7) checkEnv := buildCheckContext(sc) if sc.Cfg.Checks.Quality { sections = append(sections, qualityCheck.Run(ctx, checkEnv)) @@ -34,6 +35,9 @@ func Build(ctx context.Context, sc runnersupport.Context) []core.SectionResult { if sc.Cfg.Checks.CI { sections = append(sections, ciCheck.Run(ctx, checkEnv)) } + if sc.Cfg.Checks.SupplyChain { + sections = append(sections, supplyChainCheck.Run(ctx, checkEnv)) + } if contractsEnabled(sc) { sections = append(sections, contractsCheck.Run(ctx, checkEnv)) } @@ -115,6 +119,9 @@ func buildCheckContext(sc runnersupport.Context) checkSupport.Context { RunCommandCheck: func(ctx context.Context, dir string, check core.CommandCheckConfig) (string, error) { return runnersupport.RunCommandCheck(ctx, dir, check) }, + RunCommandCheckWithEnv: func(ctx context.Context, dir string, check core.CommandCheckConfig, env []string) (string, error) { + return runnersupport.RunCommandCheckWithEnv(ctx, dir, check, env) + }, RunDiffCommandCheck: func(ctx context.Context, dir string, baseRef string, check core.CommandCheckConfig) (string, error) { return runnersupport.RunDiffCommandCheckWithContext(ctx, sc, dir, baseRef, check) }, diff --git a/internal/codeguard/runner/support/commands.go b/internal/codeguard/runner/support/commands.go index ffc3f8f..a9851d9 100644 --- a/internal/codeguard/runner/support/commands.go +++ b/internal/codeguard/runner/support/commands.go @@ -15,6 +15,10 @@ func RunCommandCheck(ctx context.Context, dir string, check core.CommandCheckCon return runCommandCheck(ctx, dir, check, nil) } +func RunCommandCheckWithEnv(ctx context.Context, dir string, check core.CommandCheckConfig, env []string) (string, error) { + return runCommandCheck(ctx, dir, check, env) +} + func RunDiffCommandCheck(ctx context.Context, dir string, baseRef string, check core.CommandCheckConfig) (string, error) { diffEnv, cleanup, err := prepareDiffCommandEnv(dir, baseRef) if err != nil { diff --git a/pkg/codeguard/sdk_types_config_checks.go b/pkg/codeguard/sdk_types_config_checks.go index 2e3c374..a2ed8c5 100644 --- a/pkg/codeguard/sdk_types_config_checks.go +++ b/pkg/codeguard/sdk_types_config_checks.go @@ -6,6 +6,7 @@ type QualityRulesConfig = core.QualityRulesConfig type DesignRulesConfig = core.DesignRulesConfig type PromptRulesConfig = core.PromptRulesConfig type CIRulesConfig = core.CIRulesConfig +type SupplyChainRulesConfig = core.SupplyChainRulesConfig type ContractRulesConfig = core.ContractRulesConfig type WorkflowRuleConfig = core.WorkflowRuleConfig type CommandCheckConfig = core.CommandCheckConfig diff --git a/pkg/codeguard/sdk_types_runtime_report.go b/pkg/codeguard/sdk_types_runtime_report.go index 25dbc15..e1269eb 100644 --- a/pkg/codeguard/sdk_types_runtime_report.go +++ b/pkg/codeguard/sdk_types_runtime_report.go @@ -3,16 +3,22 @@ package codeguard import "github.com/devr-tools/codeguard/internal/codeguard/core" type ( - Report = core.Report - Artifact = core.Artifact - SlopScoreArtifact = core.SlopScoreArtifact - SlopScoreComponent = core.SlopScoreComponent - SlopHistoryEntry = core.SlopHistoryEntry - AIAnalysisArtifact = core.AIAnalysisArtifact - AIAnalysisVerdict = core.AIAnalysisVerdict - AIFixArtifact = core.AIFixArtifact - SectionResult = core.SectionResult - Finding = core.Finding + Report = core.Report + Artifact = core.Artifact + SupplyChainArtifact = core.SupplyChainArtifact + SupplyChainManifest = core.SupplyChainManifest + SupplyChainDependency = core.SupplyChainDependency + SupplyChainLicenseCandidate = core.SupplyChainLicenseCandidate + SlopScoreArtifact = core.SlopScoreArtifact + SlopScoreComponent = core.SlopScoreComponent + ChangeRiskArtifact = core.ChangeRiskArtifact + ChangeRiskComponent = core.ChangeRiskComponent + SlopHistoryEntry = core.SlopHistoryEntry + AIAnalysisArtifact = core.AIAnalysisArtifact + AIAnalysisVerdict = core.AIAnalysisVerdict + AIFixArtifact = core.AIFixArtifact + SectionResult = core.SectionResult + Finding = core.Finding ChangeImpactArtifact = core.ChangeImpactArtifact ChangeImpactEntry = core.ChangeImpactEntry diff --git a/tests/checks/quality_ai_additional_test.go b/tests/checks/quality_ai_additional_test.go index 29f4498..fd558f1 100644 --- a/tests/checks/quality_ai_additional_test.go +++ b/tests/checks/quality_ai_additional_test.go @@ -134,3 +134,36 @@ func doThing() error { return nil } assertFindingRulePresent(t, report, "Code Quality", "quality.ai.provenance-policy") } + +func TestQualityCheckPublishesChangeRiskForAIHeavyChange(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "service.go"), `package sample + +// Initialize the client. +func buildClient() error { + err := doThing() + _ = err + return nil +} + +func doThing() error { return nil } +`) + t.Setenv("CODEGUARD_AI_ASSISTED", "true") + + report, err := codeguard.Run(context.Background(), qualityAITestConfig(dir, "quality-ai-change-risk")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRulePresent(t, report, "Code Quality", "quality.ai.change-risk") + for _, artifact := range report.Artifacts { + if artifact.Kind != "change_risk" || artifact.ChangeRisk == nil { + continue + } + if artifact.ChangeRisk.Score <= 0 { + t.Fatalf("unexpected change risk artifact %#v", artifact.ChangeRisk) + } + return + } + t.Fatalf("expected change_risk artifact, got %#v", report.Artifacts) +} diff --git a/tests/checks/quality_ai_semantic_frameworks_test.go b/tests/checks/quality_ai_semantic_frameworks_test.go new file mode 100644 index 0000000..df6b4a1 --- /dev/null +++ b/tests/checks/quality_ai_semantic_frameworks_test.go @@ -0,0 +1,286 @@ +package checks_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestQualitySemanticChecksIncludeExpressFrameworkContext(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "routes.ts"), `import express from "express" + +const router = express.Router() + +export function mountRoutes(app: express.Express) { + app.use("/api", router) +} +`) + diff := stringsJoin( + "diff --git a/src/routes.ts b/src/routes.ts", + "--- a/src/routes.ts", + "+++ b/src/routes.ts", + "@@ -2,5 +2,8 @@", + " import express from \"express\"", + " ", + " const router = express.Router()", + " ", + " export function mountRoutes(app: express.Express) {", + "+\trouter.get(\"/users/:id\", async (_req, res) => {", + "+\t\tres.status(500).json({ error: \"failed\" })", + "+\t})", + " \tapp.use(\"/api\", router)", + " }", + ) + counterPath := filepath.Join(dir, "semantic-calls.txt") + requestPath := filepath.Join(dir, "semantic-request.json") + scriptPath := filepath.Join(dir, "semantic.sh") + writeExecutableFile(t, scriptPath, semanticCaptureScript(counterPath, requestPath, `{"verdicts":[]}`)) + + t.Setenv("CODEGUARD_SEMANTIC_CHECKS", "1") + t.Setenv("CODEGUARD_SEMANTIC_COMMAND", scriptPath) + + if _, err := codeguard.RunPatch(context.Background(), qualityAISemanticConfig(dir, "quality-ai-semantic-express"), diff); err != nil { + t.Fatalf("run patch: %v", err) + } + + var req struct { + Frameworks []struct { + Name string `json:"name"` + Path string `json:"path"` + Signals []string `json:"signals"` + Hints []string `json:"hints"` + } `json:"frameworks"` + Prompt semanticPromptTemplate `json:"prompt"` + } + data, err := os.ReadFile(requestPath) + if err != nil { + t.Fatalf("read request: %v", err) + } + if err := json.Unmarshal(data, &req); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + if len(req.Frameworks) != 1 { + t.Fatalf("frameworks = %#v, want 1 express framework entry", req.Frameworks) + } + if req.Frameworks[0].Name != "express" || req.Frameworks[0].Path != "src/routes.ts" { + t.Fatalf("framework entry = %#v, want express src/routes.ts", req.Frameworks[0]) + } + assertStringSliceContainsAll(t, req.Frameworks[0].Signals, "express-import", "express-router", "http-route-handler") + assertStringSliceContainsAll(t, req.Frameworks[0].Hints, "middleware-order-sensitive", "response-side-effects") + assertRulePromptContainsAll(t, req.Prompt, "quality.ai.contract-drift", "middleware ordering alters which downstream handlers run") + assertRulePromptContainsAll(t, req.Prompt, "quality.ai.semantic-test-adequacy", "tests prove next() chaining") +} + +func TestQualitySemanticChecksIncludeNextJSFrameworkContext(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "api", "users", "route.ts"), `import { NextRequest, NextResponse } from "next/server" + +export async function POST(request: NextRequest) { + return NextResponse.json({ ok: true }, { status: 201 }) +} +`) + diff := stringsJoin( + "diff --git a/app/api/users/route.ts b/app/api/users/route.ts", + "--- a/app/api/users/route.ts", + "+++ b/app/api/users/route.ts", + "@@ -1,4 +1,5 @@", + " import { NextRequest, NextResponse } from \"next/server\"", + " ", + " export async function POST(request: NextRequest) {", + "+\tconst body = await request.json()", + "-\treturn NextResponse.json({ ok: true }, { status: 201 })", + "+\treturn NextResponse.json({ id: body.id }, { status: 201 })", + " }", + ) + counterPath := filepath.Join(dir, "semantic-calls.txt") + requestPath := filepath.Join(dir, "semantic-request.json") + scriptPath := filepath.Join(dir, "semantic.sh") + writeExecutableFile(t, scriptPath, semanticCaptureScript(counterPath, requestPath, `{"verdicts":[]}`)) + + t.Setenv("CODEGUARD_SEMANTIC_CHECKS", "1") + t.Setenv("CODEGUARD_SEMANTIC_COMMAND", scriptPath) + + if _, err := codeguard.RunPatch(context.Background(), qualityAISemanticConfig(dir, "quality-ai-semantic-nextjs"), diff); err != nil { + t.Fatalf("run patch: %v", err) + } + + var req struct { + Frameworks []struct { + Name string `json:"name"` + Path string `json:"path"` + Signals []string `json:"signals"` + Hints []string `json:"hints"` + } `json:"frameworks"` + Prompt semanticPromptTemplate `json:"prompt"` + } + data, err := os.ReadFile(requestPath) + if err != nil { + t.Fatalf("read request: %v", err) + } + if err := json.Unmarshal(data, &req); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + if len(req.Frameworks) != 1 { + t.Fatalf("frameworks = %#v, want 1 nextjs framework entry", req.Frameworks) + } + if req.Frameworks[0].Name != "nextjs" || req.Frameworks[0].Path != "app/api/users/route.ts" { + t.Fatalf("framework entry = %#v, want nextjs app/api/users/route.ts", req.Frameworks[0]) + } + assertStringSliceContainsAll(t, req.Frameworks[0].Signals, "app-router-route-file", "next-request-response", "next-server-import", "route-handler-export") + assertStringSliceContainsAll(t, req.Frameworks[0].Hints, "route-handler-contract", "async-data-contract") + assertRulePromptContainsAll(t, req.Prompt, "quality.ai.contract-drift", "changed request parsing, status codes, or response payloads shift the handler contract") + assertRulePromptContainsAll(t, req.Prompt, "quality.ai.semantic-test-adequacy", "tests cover changed request shapes, status codes, and response bodies") +} + +func TestQualitySemanticChecksIncludeExpressMiddlewareHints(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "auth.ts"), `import express, { NextFunction, Request, Response } from "express" + +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + if (!req.headers.authorization) { + res.status(401).json({ error: "missing auth" }) + return + } + next() +} +`) + diff := stringsJoin( + "diff --git a/src/auth.ts b/src/auth.ts", + "--- a/src/auth.ts", + "+++ b/src/auth.ts", + "@@ -2,7 +2,8 @@", + " ", + " export function authMiddleware(req: Request, res: Response, next: NextFunction) {", + " \tif (!req.headers.authorization) {", + " \t\tres.status(401).json({ error: \"missing auth\" })", + " \t\treturn", + " \t}", + "+\tres.locals.user = req.headers.authorization", + " \tnext()", + " }", + ) + counterPath := filepath.Join(dir, "semantic-calls.txt") + requestPath := filepath.Join(dir, "semantic-request.json") + scriptPath := filepath.Join(dir, "semantic.sh") + writeExecutableFile(t, scriptPath, semanticCaptureScript(counterPath, requestPath, `{"verdicts":[]}`)) + + t.Setenv("CODEGUARD_SEMANTIC_CHECKS", "1") + t.Setenv("CODEGUARD_SEMANTIC_COMMAND", scriptPath) + + if _, err := codeguard.RunPatch(context.Background(), qualityAISemanticConfig(dir, "quality-ai-semantic-express-middleware"), diff); err != nil { + t.Fatalf("run patch: %v", err) + } + + frameworks := readSemanticFrameworks(t, requestPath) + entry := requireFrameworkEntry(t, frameworks, "express", "src/auth.ts") + assertStringSliceContainsAll(t, entry.Signals, "express-import") + assertStringSliceContainsAll(t, entry.Hints, "middleware-next-chain", "middleware-order-sensitive", "request-derived-contract", "response-side-effects") + prompt := readSemanticPrompt(t, requestPath) + assertRulePromptContainsAll(t, prompt, "quality.ai.contract-drift", "next() flow", "request state they receive") + assertRulePromptContainsAll(t, prompt, "quality.ai.semantic-test-adequacy", "tests prove next() chaining", "res.locals or request mutation") +} + +func TestQualitySemanticChecksIncludeReactComponentFrameworkContext(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "components", "UserCard.tsx"), `import { useState } from "react" + +interface Props { + userID: string +} + +export function UserCard({ userID }: Props) { + const [expanded, setExpanded] = useState(false) + return +} +`) + diff := stringsJoin( + "diff --git a/src/components/UserCard.tsx b/src/components/UserCard.tsx", + "--- a/src/components/UserCard.tsx", + "+++ b/src/components/UserCard.tsx", + "@@ -5,5 +5,5 @@", + " ", + " export function UserCard({ userID }: Props) {", + "-\tconst [expanded, setExpanded] = useState(false)", + "+\tconst [expanded, setExpanded] = useState(true)", + " \treturn ", + " }", + ) + counterPath := filepath.Join(dir, "semantic-calls.txt") + requestPath := filepath.Join(dir, "semantic-request.json") + scriptPath := filepath.Join(dir, "semantic.sh") + writeExecutableFile(t, scriptPath, semanticCaptureScript(counterPath, requestPath, `{"verdicts":[]}`)) + + t.Setenv("CODEGUARD_SEMANTIC_CHECKS", "1") + t.Setenv("CODEGUARD_SEMANTIC_COMMAND", scriptPath) + + if _, err := codeguard.RunPatch(context.Background(), qualityAISemanticConfig(dir, "quality-ai-semantic-react-component"), diff); err != nil { + t.Fatalf("run patch: %v", err) + } + + frameworks := readSemanticFrameworks(t, requestPath) + entry := requireFrameworkEntry(t, frameworks, "react", "src/components/UserCard.tsx") + assertStringSliceContainsAll(t, entry.Signals, "jsx-component", "component-export", "react-hooks") + assertStringSliceContainsAll(t, entry.Hints, "component-props-contract", "stateful-component") + prompt := readSemanticPrompt(t, requestPath) + assertRulePromptContainsAll(t, prompt, "quality.ai.contract-drift", "changed props shape, required props, or children expectations") + assertRulePromptContainsAll(t, prompt, "quality.ai.semantic-test-adequacy", "tests cover changed prop combinations", "changed interaction or state transition") +} + +func TestQualitySemanticChecksIncludeNextJSComponentFrameworkContext(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "app", "users", "page.tsx"), `"use client" + +import { useState } from "react" + +type Props = { + searchParams: { + q?: string + } +} + +export default function UsersPage({ searchParams }: Props) { + const [query, setQuery] = useState(searchParams.q ?? "") + return
+} +`) + diff := stringsJoin( + "diff --git a/app/users/page.tsx b/app/users/page.tsx", + "--- a/app/users/page.tsx", + "+++ b/app/users/page.tsx", + "@@ -9,5 +9,5 @@", + " ", + " export default function UsersPage({ searchParams }: Props) {", + "-\tconst [query, setQuery] = useState(searchParams.q ?? \"\")", + "+\tconst [query, setQuery] = useState((searchParams.q ?? \"\").trim())", + " \treturn
", + " }", + ) + counterPath := filepath.Join(dir, "semantic-calls.txt") + requestPath := filepath.Join(dir, "semantic-request.json") + scriptPath := filepath.Join(dir, "semantic.sh") + writeExecutableFile(t, scriptPath, semanticCaptureScript(counterPath, requestPath, `{"verdicts":[]}`)) + + t.Setenv("CODEGUARD_SEMANTIC_CHECKS", "1") + t.Setenv("CODEGUARD_SEMANTIC_COMMAND", scriptPath) + + if _, err := codeguard.RunPatch(context.Background(), qualityAISemanticConfig(dir, "quality-ai-semantic-nextjs-component"), diff); err != nil { + t.Fatalf("run patch: %v", err) + } + + frameworks := readSemanticFrameworks(t, requestPath) + nextEntry := requireFrameworkEntry(t, frameworks, "nextjs", "app/users/page.tsx") + assertStringSliceContainsAll(t, nextEntry.Signals, "app-router-component-file", "use-client-directive") + assertStringSliceContainsAll(t, nextEntry.Hints, "route-segment-component", "client-component", "route-props-contract") + + reactEntry := requireFrameworkEntry(t, frameworks, "react", "app/users/page.tsx") + assertStringSliceContainsAll(t, reactEntry.Signals, "jsx-component", "component-export", "react-hooks", "use-client-directive") + assertStringSliceContainsAll(t, reactEntry.Hints, "component-props-contract", "stateful-component", "client-component") + prompt := readSemanticPrompt(t, requestPath) + assertRulePromptContainsAll(t, prompt, "quality.ai.contract-drift", "params or searchParams handling changes the expected route input contract") + assertRulePromptContainsAll(t, prompt, "quality.ai.semantic-test-adequacy", "tests cover changed params or searchParams inputs") +} diff --git a/tests/checks/quality_ai_semantic_helpers_test.go b/tests/checks/quality_ai_semantic_helpers_test.go new file mode 100644 index 0000000..fc5184d --- /dev/null +++ b/tests/checks/quality_ai_semantic_helpers_test.go @@ -0,0 +1,63 @@ +package checks_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func qualityAISemanticConfig(dir string, name string) codeguard.Config { + cfg := qualityAITestConfig(dir, name) + enabled := true + cfg.Cache.Enabled = &enabled + cfg.Cache.Path = filepath.Join(dir, ".codeguard", "cache.json") + return cfg +} + +func semanticScript(counterPath string, response string) string { + return "#!/bin/sh\n" + + "count=0\n" + + "if [ -f \"" + counterPath + "\" ]; then count=$(cat \"" + counterPath + "\"); fi\n" + + "count=$((count + 1))\n" + + "printf \"%s\" \"$count\" > \"" + counterPath + "\"\n" + + "cat >/dev/null\n" + + "printf '%s' '" + response + "'\n" +} + +func semanticCaptureScript(counterPath string, requestPath string, response string) string { + return "#!/bin/sh\n" + + "count=0\n" + + "if [ -f \"" + counterPath + "\" ]; then count=$(cat \"" + counterPath + "\"); fi\n" + + "count=$((count + 1))\n" + + "printf \"%s\" \"$count\" > \"" + counterPath + "\"\n" + + "cat >\"" + requestPath + "\"\n" + + "printf '%s' '" + response + "'\n" +} + +func runSemanticGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, string(output)) + } +} + +func assertFileEquals(t *testing.T, path string, want string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if string(data) != want { + t.Fatalf("%s = %q, want %q", path, string(data), want) + } +} + +func stringsJoin(lines ...string) string { return strings.Join(lines, "\n") } diff --git a/tests/checks/quality_ai_semantic_payload_helpers_test.go b/tests/checks/quality_ai_semantic_payload_helpers_test.go new file mode 100644 index 0000000..bc54455 --- /dev/null +++ b/tests/checks/quality_ai_semantic_payload_helpers_test.go @@ -0,0 +1,116 @@ +package checks_test + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +type semanticFrameworkEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Signals []string `json:"signals"` + Hints []string `json:"hints"` +} + +type semanticPromptTemplate struct { + Overview string `json:"overview"` + ResponseRequirements []string `json:"response_requirements"` + RuleInstructions []semanticRulePrompt `json:"rule_instructions"` + FrameworkInstructions []semanticFrameworkPrompt `json:"framework_instructions"` +} + +type semanticRulePrompt struct { + RuleID string `json:"rule_id"` + Focus string `json:"focus"` + Consider []string `json:"consider"` + Avoid []string `json:"avoid"` + Threshold string `json:"threshold"` +} + +type semanticFrameworkPrompt struct { + Name string `json:"name"` + Path string `json:"path"` + Hints []string `json:"hints"` + Advice []string `json:"advice"` +} + +func readSemanticFrameworks(t *testing.T, requestPath string) []semanticFrameworkEntry { + t.Helper() + var req struct { + Frameworks []semanticFrameworkEntry `json:"frameworks"` + } + readSemanticRequest(t, requestPath, &req) + return req.Frameworks +} + +func readSemanticPrompt(t *testing.T, requestPath string) semanticPromptTemplate { + t.Helper() + var req struct { + Prompt semanticPromptTemplate `json:"prompt"` + } + readSemanticRequest(t, requestPath, &req) + return req.Prompt +} + +func readSemanticRequest(t *testing.T, requestPath string, out any) { + t.Helper() + data, err := os.ReadFile(requestPath) + if err != nil { + t.Fatalf("read request: %v", err) + } + if err := json.Unmarshal(data, out); err != nil { + t.Fatalf("unmarshal request: %v", err) + } +} + +func requireFrameworkEntry(t *testing.T, frameworks []semanticFrameworkEntry, name string, path string) semanticFrameworkEntry { + t.Helper() + for _, framework := range frameworks { + if framework.Name == name && framework.Path == path { + return framework + } + } + t.Fatalf("framework entry %s %s not found in %#v", name, path, frameworks) + return semanticFrameworkEntry{} +} + +func assertStringSliceContainsAll(t *testing.T, got []string, want ...string) { + t.Helper() + seen := map[string]struct{}{} + for _, value := range got { + seen[value] = struct{}{} + } + for _, value := range want { + if _, ok := seen[value]; !ok { + t.Fatalf("slice %v missing %q", got, value) + } + } +} + +func assertRulePromptContainsAll(t *testing.T, prompt semanticPromptTemplate, ruleID string, want ...string) { + t.Helper() + for _, instruction := range prompt.RuleInstructions { + if instruction.RuleID != ruleID { + continue + } + all := append([]string{instruction.Focus, instruction.Threshold}, instruction.Consider...) + for _, value := range want { + if !containsSubstring(all, value) { + t.Fatalf("rule prompt %s missing %q in %#v", ruleID, value, instruction) + } + } + return + } + t.Fatalf("rule prompt %s not found in %#v", ruleID, prompt.RuleInstructions) +} + +func containsSubstring(values []string, want string) bool { + for _, value := range values { + if strings.Contains(value, want) { + return true + } + } + return false +} diff --git a/tests/checks/quality_ai_semantic_reference_runner_helpers_test.go b/tests/checks/quality_ai_semantic_reference_runner_helpers_test.go new file mode 100644 index 0000000..1618f0c --- /dev/null +++ b/tests/checks/quality_ai_semantic_reference_runner_helpers_test.go @@ -0,0 +1,75 @@ +package checks_test + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +func semanticOpenAIHandler(t *testing.T) func(http.ResponseWriter, *http.Request) { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + t.Helper() + assertSemanticOpenAIRequest(t, r) + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{ + "content": `{"verdicts":[{"rule_id":"quality.ai.semantic-test-adequacy","path":"app/users/page.tsx","line":4,"level":"warn","message":"openai-compatible backend verdict"}]}`, + }, + }, + }, + }) + } +} + +func assertSemanticOpenAIRequest(t *testing.T, r *http.Request) { + t.Helper() + if r.URL.Path != "/chat/completions" { + t.Fatalf("path = %q, want /chat/completions", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-openai-key" { + t.Fatalf("authorization = %q", got) + } + var body struct { + Model string `json:"model"` + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + if body.Model != "gpt-test-semantic" { + t.Fatalf("model = %q", body.Model) + } + if len(body.Messages) < 2 { + t.Fatalf("messages = %#v", body.Messages) + } + assertContainsAll(t, body.Messages[1].Content, + "tests prove next() chaining", + "changed params or searchParams handling changes the expected route input contract", + ) +} + +func repoRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + return filepath.Dir(filepath.Dir(wd)) +} + +func assertContainsAll(t *testing.T, value string, wants ...string) { + t.Helper() + for _, want := range wants { + if !strings.Contains(value, want) { + t.Fatalf("output missing %q\nfull output:\n%s", want, value) + } + } +} diff --git a/tests/checks/quality_ai_semantic_reference_runner_test.go b/tests/checks/quality_ai_semantic_reference_runner_test.go new file mode 100644 index 0000000..9b367e1 --- /dev/null +++ b/tests/checks/quality_ai_semantic_reference_runner_test.go @@ -0,0 +1,252 @@ +package checks_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestSemanticReferenceRunnerPrintsCanonicalPromptAndValidJSON(t *testing.T) { + root := repoRoot(t) + scriptPath := filepath.Join(root, "examples", "semantic", "reference_runner.py") + payload := semanticReferenceRequest(t) + + cmd := exec.Command("python3", scriptPath) + cmd.Dir = root + cmd.Env = append(os.Environ(), "CODEGUARD_SEMANTIC_REFERENCE_PRINT_PROMPT=1") + cmd.Stdin = bytes.NewReader(payload) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("run reference runner: %v\nstderr:\n%s", err, stderr.String()) + } + + var resp struct { + Verdicts []any `json:"verdicts"` + } + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v\nstdout:\n%s", err, stdout.String()) + } + if len(resp.Verdicts) != 0 { + t.Fatalf("verdicts = %#v, want empty scaffold response", resp.Verdicts) + } + + prompt := stderr.String() + assertContainsAll(t, prompt, + "Rule-specific instructions", + "quality.ai.contract-drift", + "quality.ai.semantic-test-adequacy", + "changed params or searchParams handling changes the expected route input contract", + "tests prove next() chaining", + "Framework context", + "Treat next() flow as part of middleware sequencing semantics.", + "Return JSON only:", + ) +} + +func TestSemanticReferenceRunnerCommandModeUsesCanonicalPrompt(t *testing.T) { + root := repoRoot(t) + scriptPath := filepath.Join(root, "examples", "semantic", "reference_runner.py") + backendPath := filepath.Join(t.TempDir(), "semantic-backend.py") + writeFile(t, backendPath, `#!/usr/bin/env python3 +import json +import sys + +payload = json.load(sys.stdin) +prompt = payload["prompt_text"] +if "tests prove next() chaining" not in prompt: + raise SystemExit("missing middleware test guidance") +if "changed params or searchParams handling changes the expected route input contract" not in prompt: + raise SystemExit("missing nextjs route input guidance") +json.dump({"verdicts":[{"rule_id":"quality.ai.contract-drift","path":"src/auth.ts","line":2,"level":"warn","message":"backend verified prompt contract"}]}, sys.stdout) +`) + if err := os.Chmod(backendPath, 0o755); err != nil { + t.Fatalf("chmod backend: %v", err) + } + + payload := semanticReferenceRequest(t) + cmd := exec.Command("python3", scriptPath) + cmd.Dir = root + cmd.Env = append( + os.Environ(), + "CODEGUARD_SEMANTIC_REFERENCE_MODE=command", + "CODEGUARD_SEMANTIC_REFERENCE_LOCAL_COMMAND=python3 "+backendPath, + ) + cmd.Stdin = bytes.NewReader(payload) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("run reference runner command mode: %v\nstderr:\n%s", err, stderr.String()) + } + + var resp struct { + Verdicts []map[string]any `json:"verdicts"` + } + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v\nstdout:\n%s", err, stdout.String()) + } + if len(resp.Verdicts) != 1 || resp.Verdicts[0]["rule_id"] != "quality.ai.contract-drift" { + t.Fatalf("verdicts = %#v", resp.Verdicts) + } +} + +func TestSemanticReferenceRunnerOpenAICompatibleMode(t *testing.T) { + root := repoRoot(t) + scriptPath := filepath.Join(root, "examples", "semantic", "reference_runner.py") + server := httptest.NewServer(http.HandlerFunc(semanticOpenAIHandler(t))) + defer server.Close() + + payload := semanticReferenceRequest(t) + cmd := exec.Command("python3", scriptPath) + cmd.Dir = root + cmd.Env = append( + os.Environ(), + "CODEGUARD_SEMANTIC_REFERENCE_MODE=openai", + "CODEGUARD_SEMANTIC_REFERENCE_OPENAI_BASE_URL="+server.URL, + "CODEGUARD_SEMANTIC_REFERENCE_OPENAI_API_KEY=test-openai-key", + "CODEGUARD_SEMANTIC_REFERENCE_OPENAI_MODEL=gpt-test-semantic", + ) + cmd.Stdin = bytes.NewReader(payload) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("run reference runner openai mode: %v\nstderr:\n%s", err, stderr.String()) + } + + var resp struct { + Verdicts []map[string]any `json:"verdicts"` + } + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v\nstdout:\n%s", err, stdout.String()) + } + if len(resp.Verdicts) != 1 || resp.Verdicts[0]["rule_id"] != "quality.ai.semantic-test-adequacy" { + t.Fatalf("verdicts = %#v", resp.Verdicts) + } +} + +func semanticReferenceRequest(t *testing.T) []byte { + t.Helper() + payload, err := json.Marshal(semanticReferenceRequestBody()) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + return payload +} + +func semanticReferenceRequestBody() map[string]any { + return map[string]any{ + "target_name": "repo", + "target_path": ".", + "language": "typescript", + "checks": semanticReferenceChecks(), + "frameworks": semanticReferenceFrameworks(), + "prompt": semanticReferencePrompt(), + "changed_files": []string{"src/auth.ts", "app/users/page.tsx"}, + "diff": semanticReferenceDiff(), + "source_files": []map[string]any{ + { + "path": "src/auth.ts", + "content": "export function authMiddleware(req, res, next) {\n next()\n}\n", + }, + }, + "test_files": []map[string]any{ + { + "path": "src/auth.test.ts", + "content": "test('auth', () => {})\n", + }, + }, + } +} + +func semanticReferenceChecks() []map[string]any { + return []map[string]any{ + { + "rule_id": "quality.ai.contract-drift", + "title": "Silent contract drift", + "description": "Flag changed functions whose observable behavior appears to drift from the existing contract.", + }, + { + "rule_id": "quality.ai.semantic-test-adequacy", + "title": "Tests appear inadequate for changed behavior", + "description": "Flag changed behavior when nearby tests look too weak or mismatched.", + }, + } +} + +func semanticReferenceFrameworks() []map[string]any { + return []map[string]any{ + { + "name": "express", + "path": "src/auth.ts", + "hints": []string{"middleware-next-chain", "middleware-order-sensitive"}, + "signals": []string{"express-import"}, + }, + { + "name": "nextjs", + "path": "app/users/page.tsx", + "hints": []string{"route-props-contract", "client-component"}, + "signals": []string{"app-router-component-file", "use-client-directive"}, + }, + } +} + +func semanticReferencePrompt() map[string]any { + return map[string]any{ + "overview": "Review only the changed behavior and nearby tests.", + "response_requirements": []string{ + "Return JSON only.", + "If uncertain, omit the verdict rather than guessing.", + }, + "rule_instructions": []map[string]any{ + { + "rule_id": "quality.ai.contract-drift", + "focus": "Find changed behavior that silently shifts the observable contract.", + "consider": []string{ + "For Next.js route-segment components, check whether changed params or searchParams handling changes the expected route input contract.", + "For Express middleware, check whether changed next() flow alters which downstream handlers run.", + }, + }, + { + "rule_id": "quality.ai.semantic-test-adequacy", + "focus": "Find changed behavior where nearby tests appear too weak.", + "consider": []string{ + "For React or Next.js components, check whether tests cover changed prop combinations.", + "For Express middleware, check whether tests prove next() chaining.", + }, + }, + }, + "framework_instructions": []map[string]any{ + { + "name": "express", + "path": "src/auth.ts", + "hints": []string{"middleware-next-chain"}, + "advice": []string{"Treat next() flow as part of middleware sequencing semantics."}, + }, + }, + } +} + +func semanticReferenceDiff() string { + return strings.Join([]string{ + "diff --git a/src/auth.ts b/src/auth.ts", + "--- a/src/auth.ts", + "+++ b/src/auth.ts", + "@@ -1,3 +1,4 @@", + " export function authMiddleware(req, res, next) {", + "+ res.locals.user = req.headers.authorization", + " next()", + " }", + }, "\n") +} diff --git a/tests/checks/quality_ai_semantic_selection_test.go b/tests/checks/quality_ai_semantic_selection_test.go new file mode 100644 index 0000000..6976982 --- /dev/null +++ b/tests/checks/quality_ai_semantic_selection_test.go @@ -0,0 +1,189 @@ +package checks_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestQualitySemanticChecksHonorRuleSelection(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "service.go"), `package sample + +func BuildUser() error { + return nil +} +`) + diff := stringsJoin( + "diff --git a/service.go b/service.go", + "--- a/service.go", + "+++ b/service.go", + "@@ -1,4 +1,5 @@", + " package sample", + " ", + "+// BuildUser removes a user.", + " func BuildUser() error {", + " \treturn nil", + " }", + ) + counterPath := filepath.Join(dir, "semantic-calls.txt") + requestPath := filepath.Join(dir, "semantic-request.json") + scriptPath := filepath.Join(dir, "semantic.sh") + writeExecutableFile(t, scriptPath, semanticCaptureScript(counterPath, requestPath, `{"verdicts":[{"rule_id":"quality.ai.semantic-doc-mismatch","path":"service.go","line":3,"message":"comment and implementation disagree"}]}`)) + + t.Setenv("CODEGUARD_SEMANTIC_CHECKS", "1") + t.Setenv("CODEGUARD_SEMANTIC_COMMAND", scriptPath) + + cfg := qualityAISemanticConfig(dir, "quality-ai-semantic-selection") + enabled := false + cfg.AI.Semantic.ContractDrift = &enabled + cfg.AI.Semantic.MisleadingErrorMessages = &enabled + cfg.AI.Semantic.TestBehaviorCoverage = &enabled + cfg.AI.Semantic.TestAdequacy = &enabled + + report, err := codeguard.RunPatch(context.Background(), cfg, diff) + if err != nil { + t.Fatalf("run patch: %v", err) + } + + assertFindingRulePresent(t, report, "Code Quality", "quality.ai.semantic-doc-mismatch") + assertFileEquals(t, counterPath, "1") + + var req struct { + Checks []struct { + RuleID string `json:"rule_id"` + } `json:"checks"` + } + data, err := os.ReadFile(requestPath) + if err != nil { + t.Fatalf("read request: %v", err) + } + if err := json.Unmarshal(data, &req); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + if len(req.Checks) != 1 || req.Checks[0].RuleID != "quality.ai.semantic-doc-mismatch" { + t.Fatalf("semantic checks = %#v, want only doc-mismatch", req.Checks) + } +} + +func TestQualitySemanticChecksCanSelectOnlyTestAdequacy(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "service.go"), `package sample + +func BuildUser() error { + return nil +} +`) + diff := stringsJoin( + "diff --git a/service.go b/service.go", + "--- a/service.go", + "+++ b/service.go", + "@@ -1,4 +1,4 @@", + " package sample", + " ", + " func BuildUser() error {", + "-\treturn nil", + "+\treturn errors.New(\"conflict\")", + " }", + ) + counterPath := filepath.Join(dir, "semantic-calls.txt") + requestPath := filepath.Join(dir, "semantic-request.json") + scriptPath := filepath.Join(dir, "semantic.sh") + writeExecutableFile(t, scriptPath, semanticCaptureScript(counterPath, requestPath, `{"verdicts":[{"rule_id":"quality.ai.semantic-test-adequacy","path":"service.go","line":3,"message":"tests appear inadequate for changed behavior: [risky-change-without-matching-test]"}]}`)) + + t.Setenv("CODEGUARD_SEMANTIC_CHECKS", "1") + t.Setenv("CODEGUARD_SEMANTIC_COMMAND", scriptPath) + + cfg := qualityAISemanticConfig(dir, "quality-ai-semantic-adequacy-selection") + disabled := false + cfg.AI.Semantic.FunctionContract = &disabled + cfg.AI.Semantic.ContractDrift = &disabled + cfg.AI.Semantic.MisleadingErrorMessages = &disabled + cfg.AI.Semantic.TestBehaviorCoverage = &disabled + + report, err := codeguard.RunPatch(context.Background(), cfg, diff) + if err != nil { + t.Fatalf("run patch: %v", err) + } + + assertFindingRulePresent(t, report, "Code Quality", "quality.ai.semantic-test-adequacy") + + var req struct { + Checks []struct { + RuleID string `json:"rule_id"` + } `json:"checks"` + } + data, err := os.ReadFile(requestPath) + if err != nil { + t.Fatalf("read request: %v", err) + } + if err := json.Unmarshal(data, &req); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + if len(req.Checks) != 1 || req.Checks[0].RuleID != "quality.ai.semantic-test-adequacy" { + t.Fatalf("semantic checks = %#v, want only test-adequacy", req.Checks) + } +} + +func TestQualitySemanticChecksCanSelectOnlyContractDrift(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "service.go"), `package sample + +func BuildUser() error { + return nil +} +`) + diff := stringsJoin( + "diff --git a/service.go b/service.go", + "--- a/service.go", + "+++ b/service.go", + "@@ -1,4 +1,4 @@", + " package sample", + " ", + " func BuildUser() error {", + "-\treturn nil", + "+\treturn errors.New(\"user deleted\")", + " }", + ) + counterPath := filepath.Join(dir, "semantic-calls.txt") + requestPath := filepath.Join(dir, "semantic-request.json") + scriptPath := filepath.Join(dir, "semantic.sh") + writeExecutableFile(t, scriptPath, semanticCaptureScript(counterPath, requestPath, `{"verdicts":[{"rule_id":"quality.ai.contract-drift","path":"service.go","line":3,"message":"behavior appears to drift from the existing contract"}]}`)) + + t.Setenv("CODEGUARD_SEMANTIC_CHECKS", "1") + t.Setenv("CODEGUARD_SEMANTIC_COMMAND", scriptPath) + + cfg := qualityAISemanticConfig(dir, "quality-ai-contract-drift-selection") + disabled := false + cfg.AI.Semantic.FunctionContract = &disabled + cfg.AI.Semantic.MisleadingErrorMessages = &disabled + cfg.AI.Semantic.TestBehaviorCoverage = &disabled + cfg.AI.Semantic.TestAdequacy = &disabled + + report, err := codeguard.RunPatch(context.Background(), cfg, diff) + if err != nil { + t.Fatalf("run patch: %v", err) + } + + assertFindingRulePresent(t, report, "Code Quality", "quality.ai.contract-drift") + + var req struct { + Checks []struct { + RuleID string `json:"rule_id"` + } `json:"checks"` + } + data, err := os.ReadFile(requestPath) + if err != nil { + t.Fatalf("read request: %v", err) + } + if err := json.Unmarshal(data, &req); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + if len(req.Checks) != 1 || req.Checks[0].RuleID != "quality.ai.contract-drift" { + t.Fatalf("semantic checks = %#v, want only contract-drift", req.Checks) + } +} diff --git a/tests/checks/quality_ai_semantic_test.go b/tests/checks/quality_ai_semantic_test.go index 7466378..18754e8 100644 --- a/tests/checks/quality_ai_semantic_test.go +++ b/tests/checks/quality_ai_semantic_test.go @@ -2,9 +2,6 @@ package checks_test import ( "context" - "encoding/json" - "os" - "os/exec" "path/filepath" "strings" "testing" @@ -14,12 +11,7 @@ import ( func TestQualitySemanticChecksRunWithoutProvenanceWhenSemanticRuntimeIsConfigured(t *testing.T) { dir := t.TempDir() - writeFile(t, filepath.Join(dir, "service.go"), `package sample - -func BuildUser() error { - return nil -} -`) + writeFile(t, filepath.Join(dir, "service.go"), "package sample\n\nfunc BuildUser() error {\n\treturn nil\n}\n") diff := stringsJoin( "diff --git a/service.go b/service.go", "--- a/service.go", @@ -51,18 +43,8 @@ func BuildUser() error { func TestQualitySemanticChecksEmitVerdictsForAIAssistedPatch(t *testing.T) { dir := t.TempDir() - writeFile(t, filepath.Join(dir, "service.go"), `package sample - -func BuildUser() error { - return nil -} -`) - writeFile(t, filepath.Join(dir, "service_test.go"), `package sample - -import "testing" - -func TestBuildUser(t *testing.T) {} -`) + writeFile(t, filepath.Join(dir, "service.go"), "package sample\n\nfunc BuildUser() error {\n\treturn nil\n}\n") + writeFile(t, filepath.Join(dir, "service_test.go"), "package sample\n\nimport \"testing\"\n\nfunc TestBuildUser(t *testing.T) {}\n") diff := stringsJoin( "diff --git a/service.go b/service.go", "--- a/service.go", @@ -96,26 +78,81 @@ func TestBuildUser(t *testing.T) {} assertFileEquals(t, counterPath, "1") } +func TestQualitySemanticChecksEmitTestAdequacyVerdict(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "service.go"), "package sample\n\nfunc BuildUser() error {\n\treturn nil\n}\n") + writeFile(t, filepath.Join(dir, "service_test.go"), "package sample\n\nimport \"testing\"\n\nfunc TestBuildUser(t *testing.T) {}\n") + diff := stringsJoin( + "diff --git a/service.go b/service.go", + "--- a/service.go", + "+++ b/service.go", + "@@ -1,4 +1,4 @@", + " package sample", + " ", + " func BuildUser() error {", + "-\treturn nil", + "+\treturn errors.New(\"conflict\")", + " }", + ) + counterPath := filepath.Join(dir, "semantic-calls.txt") + scriptPath := filepath.Join(dir, "semantic.sh") + writeExecutableFile(t, scriptPath, semanticScript(counterPath, `{"verdicts":[{"rule_id":"quality.ai.semantic-test-adequacy","path":"service.go","line":3,"message":"tests appear inadequate for changed behavior: [happy-path-only] [missing-negative-path] nearby tests only cover the success path"}]}`)) + + t.Setenv("CODEGUARD_AI_ASSISTED", "true") + t.Setenv("CODEGUARD_SEMANTIC_CHECKS", "1") + t.Setenv("CODEGUARD_SEMANTIC_COMMAND", scriptPath) + + report, err := codeguard.RunPatch(context.Background(), qualityAISemanticConfig(dir, "quality-ai-semantic-adequacy"), diff) + if err != nil { + t.Fatalf("run patch: %v", err) + } + + assertFindingRulePresent(t, report, "Code Quality", "quality.ai.semantic-test-adequacy") + assertFileEquals(t, counterPath, "1") +} + +func TestQualitySemanticChecksEmitContractDriftVerdict(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "service.go"), "package sample\n\nfunc BuildUser() error {\n\treturn nil\n}\n") + diff := stringsJoin( + "diff --git a/service.go b/service.go", + "--- a/service.go", + "+++ b/service.go", + "@@ -1,4 +1,5 @@", + " package sample", + " ", + "+// BuildUser creates a new user.", + " func BuildUser() error {", + "-\treturn nil", + "+\treturn errors.New(\"user deleted\")", + " }", + ) + counterPath := filepath.Join(dir, "semantic-calls.txt") + scriptPath := filepath.Join(dir, "semantic.sh") + writeExecutableFile(t, scriptPath, semanticScript(counterPath, `{"verdicts":[{"rule_id":"quality.ai.contract-drift","path":"service.go","line":3,"message":"function behavior appears to drift from the existing create-user contract without matching caller or test updates"}]}`)) + + t.Setenv("CODEGUARD_AI_ASSISTED", "true") + t.Setenv("CODEGUARD_SEMANTIC_CHECKS", "1") + t.Setenv("CODEGUARD_SEMANTIC_COMMAND", scriptPath) + + report, err := codeguard.RunPatch(context.Background(), qualityAISemanticConfig(dir, "quality-ai-contract-drift"), diff) + if err != nil { + t.Fatalf("run patch: %v", err) + } + + assertFindingRulePresent(t, report, "Code Quality", "quality.ai.contract-drift") + assertFileEquals(t, counterPath, "1") +} + func TestQualitySemanticChecksRunInFullScanUsingGitBaseRef(t *testing.T) { dir := t.TempDir() runSemanticGit(t, dir, "init", "-b", "main") runSemanticGit(t, dir, "config", "user.email", "test@example.com") runSemanticGit(t, dir, "config", "user.name", "CodeGuard Test") - writeFile(t, filepath.Join(dir, "service.go"), `package sample - -func BuildUser() error { - return nil -} -`) + writeFile(t, filepath.Join(dir, "service.go"), "package sample\n\nfunc BuildUser() error {\n\treturn nil\n}\n") runSemanticGit(t, dir, "add", ".") runSemanticGit(t, dir, "commit", "-m", "base") - writeFile(t, filepath.Join(dir, "service.go"), `package sample - -// BuildUser removes a user. -func BuildUser() error { - return nil -} -`) + writeFile(t, filepath.Join(dir, "service.go"), "package sample\n\n// BuildUser removes a user.\nfunc BuildUser() error {\n\treturn nil\n}\n") counterPath := filepath.Join(dir, "semantic-calls.txt") scriptPath := filepath.Join(dir, "semantic.sh") writeExecutableFile(t, scriptPath, semanticScript(counterPath, `{"verdicts":[{"rule_id":"quality.ai.semantic-doc-mismatch","path":"service.go","line":3,"message":"comment and implementation disagree"}]}`)) @@ -134,12 +171,7 @@ func BuildUser() error { func TestQualitySemanticChecksUseVerdictCache(t *testing.T) { dir := t.TempDir() - writeFile(t, filepath.Join(dir, "service.go"), `package sample - -func BuildUser() error { - return nil -} -`) + writeFile(t, filepath.Join(dir, "service.go"), "package sample\n\nfunc BuildUser() error {\n\treturn nil\n}\n") diff := stringsJoin( "diff --git a/service.go b/service.go", "index 1111111..2222222 100644", @@ -173,114 +205,81 @@ func BuildUser() error { assertFileEquals(t, counterPath, "1") } -func TestQualitySemanticChecksHonorRuleSelection(t *testing.T) { +func TestQualitySemanticChecksEmitFindingWhenSemanticCommandFails(t *testing.T) { dir := t.TempDir() - writeFile(t, filepath.Join(dir, "service.go"), `package sample - -func BuildUser() error { - return nil -} -`) + writeFile(t, filepath.Join(dir, "service.go"), "package sample\n\nfunc BuildUser() error {\n\treturn nil\n}\n") diff := stringsJoin( "diff --git a/service.go b/service.go", "--- a/service.go", "+++ b/service.go", - "@@ -1,4 +1,5 @@", + "@@ -1,5 +1,7 @@", " package sample", + "+", " ", "+// BuildUser removes a user.", " func BuildUser() error {", " \treturn nil", " }", ) - counterPath := filepath.Join(dir, "semantic-calls.txt") - requestPath := filepath.Join(dir, "semantic-request.json") scriptPath := filepath.Join(dir, "semantic.sh") - writeExecutableFile(t, scriptPath, semanticCaptureScript(counterPath, requestPath, `{"verdicts":[{"rule_id":"quality.ai.semantic-doc-mismatch","path":"service.go","line":3,"message":"comment and implementation disagree"}]}`)) + writeExecutableFile(t, scriptPath, "#!/bin/sh\ncat >/dev/null\necho 'semantic backend exploded' >&2\nexit 2\n") t.Setenv("CODEGUARD_SEMANTIC_CHECKS", "1") t.Setenv("CODEGUARD_SEMANTIC_COMMAND", scriptPath) - cfg := qualityAISemanticConfig(dir, "quality-ai-semantic-selection") - enabled := false - cfg.AI.Semantic.MisleadingErrorMessages = &enabled - cfg.AI.Semantic.TestBehaviorCoverage = &enabled - - report, err := codeguard.RunPatch(context.Background(), cfg, diff) + report, err := codeguard.RunPatch(context.Background(), qualityAISemanticConfig(dir, "quality-ai-semantic-runtime-failure"), diff) if err != nil { t.Fatalf("run patch: %v", err) } - assertFindingRulePresent(t, report, "Code Quality", "quality.ai.semantic-doc-mismatch") - assertFileEquals(t, counterPath, "1") - - var req struct { - Checks []struct { - RuleID string `json:"rule_id"` - } `json:"checks"` - } - data, err := os.ReadFile(requestPath) - if err != nil { - t.Fatalf("read request: %v", err) - } - if err := json.Unmarshal(data, &req); err != nil { - t.Fatalf("unmarshal request: %v", err) - } - if len(req.Checks) != 1 || req.Checks[0].RuleID != "quality.ai.semantic-doc-mismatch" { - t.Fatalf("semantic checks = %#v, want only doc-mismatch", req.Checks) - } -} - -func qualityAISemanticConfig(dir string, name string) codeguard.Config { - cfg := qualityAITestConfig(dir, name) - enabled := true - cfg.Cache.Enabled = &enabled - cfg.Cache.Path = filepath.Join(dir, ".codeguard", "cache.json") - return cfg + assertFindingRulePresent(t, report, "Code Quality", "quality.ai.semantic-runtime") + assertFindingLevel(t, report, "Code Quality", "quality.ai.semantic-runtime", "fail") + assertSemanticRuntimeMessageContains(t, report, "semantic backend exploded") } -func semanticScript(counterPath string, response string) string { - return "#!/bin/sh\n" + - "count=0\n" + - "if [ -f \"" + counterPath + "\" ]; then count=$(cat \"" + counterPath + "\"); fi\n" + - "count=$((count + 1))\n" + - "printf \"%s\" \"$count\" > \"" + counterPath + "\"\n" + - "cat >/dev/null\n" + - "printf '%s' '" + response + "'\n" -} +func TestQualitySemanticChecksEmitFindingWhenSemanticCommandIsMissing(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "service.go"), "package sample\n\nfunc BuildUser() error {\n\treturn nil\n}\n") + diff := stringsJoin( + "diff --git a/service.go b/service.go", + "--- a/service.go", + "+++ b/service.go", + "@@ -1,5 +1,7 @@", + " package sample", + "+", + " ", + "+// BuildUser removes a user.", + " func BuildUser() error {", + " \treturn nil", + " }", + ) -func semanticCaptureScript(counterPath string, requestPath string, response string) string { - return "#!/bin/sh\n" + - "count=0\n" + - "if [ -f \"" + counterPath + "\" ]; then count=$(cat \"" + counterPath + "\"); fi\n" + - "count=$((count + 1))\n" + - "printf \"%s\" \"$count\" > \"" + counterPath + "\"\n" + - "cat >\"" + requestPath + "\"\n" + - "printf '%s' '" + response + "'\n" -} + t.Setenv("CODEGUARD_SEMANTIC_CHECKS", "1") -func runSemanticGit(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1") - output, err := cmd.CombinedOutput() + report, err := codeguard.RunPatch(context.Background(), qualityAISemanticConfig(dir, "quality-ai-semantic-runtime-missing"), diff) if err != nil { - t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, string(output)) + t.Fatalf("run patch: %v", err) } + + assertFindingRulePresent(t, report, "Code Quality", "quality.ai.semantic-runtime") + assertFindingLevel(t, report, "Code Quality", "quality.ai.semantic-runtime", "fail") + assertSemanticRuntimeMessageContains(t, report, "no semantic command is configured") } -func assertFileEquals(t *testing.T, path string, want string) { +func assertSemanticRuntimeMessageContains(t *testing.T, report codeguard.Report, want string) { t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read %s: %v", path, err) - } - if string(data) != want { - t.Fatalf("%s = %q, want %q", path, string(data), want) + for _, section := range report.Sections { + if section.Name != "Code Quality" { + continue + } + for _, finding := range section.Findings { + if finding.RuleID == "quality.ai.semantic-runtime" { + if !strings.Contains(finding.Message, want) { + t.Fatalf("semantic runtime message = %q, want substring %q", finding.Message, want) + } + return + } + } } -} - -func stringsJoin(lines ...string) string { - return strings.Join(lines, "\n") + t.Fatal("quality.ai.semantic-runtime finding not found") } diff --git a/tests/checks/supplychain_additional_test.go b/tests/checks/supplychain_additional_test.go new file mode 100644 index 0000000..aaf8586 --- /dev/null +++ b/tests/checks/supplychain_additional_test.go @@ -0,0 +1,97 @@ +package checks_test + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestSupplyChainResolvesDependencyLicenseByCoordinate(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "requirements.txt"), "leftpad==1.0.0\nleftpad==2.0.0\n") + script := filepath.Join(dir, "resolve-licenses.sh") + writeExecutableFile(t, script, "#!/bin/sh\nset -eu\n[ \"$CODEGUARD_SUPPLY_CHAIN_ECOSYSTEM\" = \"python\" ]\n[ \"$CODEGUARD_SUPPLY_CHAIN_UNRESOLVED_COORDINATES\" = \"leftpad@1.0.0,leftpad@2.0.0\" ]\ngrep -q '\"coordinate\":\"leftpad@1.0.0\"' \"$CODEGUARD_SUPPLY_CHAIN_CONTEXT_FILE\"\ngrep -q '\"coordinate\":\"leftpad@2.0.0\"' \"$CODEGUARD_SUPPLY_CHAIN_CONTEXT_FILE\"\nprintf '%s\n' '[{\"coordinate\":\"leftpad@1.0.0\",\"license\":\"MIT\",\"source\":\"license-command\"},{\"coordinate\":\"leftpad@2.0.0\",\"license\":\"Apache-2.0\",\"source\":\"license-command\"}]'\n") + + cfg := supplyChainTestConfig(dir, "coordinate-license") + cfg.Checks.SupplyChainRules.LicenseCommands = map[string]codeguard.CommandCheckConfig{ + "python": {Name: "python-license-resolver", Command: script}, + } + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Supply Chain", "pass") + deps := requireSupplyChainArtifact(t, report, 1).SupplyChain.Manifests[0].Dependencies + if len(deps) != 2 { + t.Fatalf("dependency count = %d, want 2", len(deps)) + } + licensesByVersion := map[string]string{} + for _, dep := range deps { + licensesByVersion[dep.Version] = dep.License + } + if licensesByVersion["1.0.0"] != "MIT" { + t.Fatalf("version 1.0.0 license = %q, want MIT", licensesByVersion["1.0.0"]) + } + if licensesByVersion["2.0.0"] != "Apache-2.0" { + t.Fatalf("version 2.0.0 license = %q, want Apache-2.0", licensesByVersion["2.0.0"]) + } +} + +func TestSupplyChainValidatesBunLockfileContent(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "package.json"), `{ + "name": "frontend", + "packageManager": "bun@1.1.0", + "dependencies": { + "left-pad": "1.3.0" + } +}`) + writeFile(t, filepath.Join(dir, "bun.lock"), `{ + "packages": { + "left-pad@1.3.0": {} + } +}`) + + report, err := codeguard.Run(context.Background(), supplyChainTestConfig(dir, "bun-lock")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Supply Chain", "pass") + + writeFile(t, filepath.Join(dir, "bun.lock"), `{ + "packages": { + "other@1.0.0": {} + } +}`) + + report, err = codeguard.Run(context.Background(), supplyChainTestConfig(dir, "bun-lock-stale")) + if err != nil { + t.Fatalf("run stale: %v", err) + } + + assertSectionStatus(t, report, "Supply Chain", "fail") + assertFindingRulePresent(t, report, "Supply Chain", "supply_chain.lockfile-drift") +} + +func TestSupplyChainFailsForStaleGoSumInFullScan(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "go.mod"), "module example.com/stale\n\ngo 1.23.0\n\nrequire github.com/stretchr/testify v1.9.0\n") + writeFile(t, filepath.Join(dir, "go.sum"), "github.com/other/module v1.0.0 h1:test\n") + + report, err := codeguard.Run(context.Background(), supplyChainTestConfig(dir, "stale-go-sum")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Supply Chain", "fail") + assertFindingRulePresent(t, report, "Supply Chain", "supply_chain.lockfile-drift") + if messages := supplyChainRuleMessages(report, "supply_chain.lockfile-drift"); len(messages) == 0 || !strings.Contains(messages[0], "github.com/stretchr/testify") { + t.Fatalf("unexpected lockfile drift messages: %v", messages) + } +} diff --git a/tests/checks/supplychain_artifact_test.go b/tests/checks/supplychain_artifact_test.go new file mode 100644 index 0000000..3e590e6 --- /dev/null +++ b/tests/checks/supplychain_artifact_test.go @@ -0,0 +1,168 @@ +package checks_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestSupplyChainPublishesNormalizedManifestArtifact(t *testing.T) { + dir := t.TempDir() + writeSupplyChainArtifactFixtures(t, dir) + + cacheEnabled := false + cfg := codeguard.Config{ + Name: "supply-chain-artifact", + Targets: []codeguard.TargetConfig{{ + Name: "repo", + Path: dir, + Language: "go", + }}, + Checks: codeguard.CheckConfig{ + SupplyChain: true, + }, + Output: codeguard.OutputConfig{Format: "json"}, + Cache: codeguard.CacheConfig{Enabled: &cacheEnabled}, + } + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + artifact := requireSupplyChainArtifact(t, report, 5) + manifests := manifestByPath(artifact.SupplyChain.Manifests) + assertGoManifest(t, manifests["go.mod"]) + assertWebManifest(t, manifests["web/package.json"]) + assertPythonManifests(t, manifests["requirements.txt"], manifests["pyproject.toml"]) + assertCargoManifest(t, manifests["rust/Cargo.toml"]) +} + +func writeSupplyChainArtifactFixtures(t *testing.T, dir string) { + t.Helper() + writeFile(t, filepath.Join(dir, "go.mod"), "module example.com/supplychain\n\ngo 1.23.0\n\nrequire github.com/stretchr/testify v1.9.0\n") + writeFile(t, filepath.Join(dir, "go.sum"), "github.com/stretchr/testify v1.9.0 h1:test\n") + writeFile(t, filepath.Join(dir, "web", "package.json"), `{ + "name": "frontend", + "packageManager": "pnpm@9.0.0", + "dependencies": {"react": "18.2.0"}, + "devDependencies": {"vitest": "^1.6.0"}, + "peerDependencies": {"typescript": "~5.4.0"} +}`) + writeFile(t, filepath.Join(dir, "web", "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n") + writeFile(t, filepath.Join(dir, "node_modules", "react", "package.json"), `{"name":"react","version":"18.2.0","license":"MIT"}`) + writeFile(t, filepath.Join(dir, "node_modules", "vitest", "package.json"), `{"name":"vitest","version":"1.6.0","license":"MIT"}`) + writeFile(t, filepath.Join(dir, "node_modules", "typescript", "package.json"), `{"name":"typescript","version":"5.4.0","license":"Apache-2.0"}`) + writeFile(t, filepath.Join(dir, "requirements.txt"), "requests==2.31.0\nflask>=3.0\n") + writeFile(t, filepath.Join(dir, "pyproject.toml"), `[project] +name = "backend" +dependencies = [ + "pydantic==2.7.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0"] +`) + writeFile(t, filepath.Join(dir, "poetry.lock"), "# lock\n") + writeFile(t, filepath.Join(dir, "rust", "Cargo.toml"), `[package] +name = "worker" +version = "0.1.0" + +[dependencies] +serde = "1.0" +tokio = { version = "=1.38.0" } +`) + writeFile(t, filepath.Join(dir, "rust", "Cargo.lock"), "# lock\n") +} + +func requireSupplyChainArtifact(t *testing.T, report codeguard.Report, manifestCount int) codeguard.Artifact { + t.Helper() + if len(report.Artifacts) != 1 { + t.Fatalf("artifacts = %d, want 1", len(report.Artifacts)) + } + artifact := report.Artifacts[0] + if artifact.Kind != "supply_chain" { + t.Fatalf("artifact kind = %q, want supply_chain", artifact.Kind) + } + if artifact.SupplyChain == nil { + t.Fatal("expected supply_chain payload") + } + if got := len(artifact.SupplyChain.Manifests); got != manifestCount { + t.Fatalf("manifest count = %d, want %d", got, manifestCount) + } + return artifact +} + +func manifestByPath(manifests []codeguard.SupplyChainManifest) map[string]codeguard.SupplyChainManifest { + indexed := map[string]codeguard.SupplyChainManifest{} + for _, manifest := range manifests { + indexed[manifest.Path] = manifest + } + return indexed +} + +func assertGoManifest(t *testing.T, manifest codeguard.SupplyChainManifest) { + t.Helper() + if manifest.Ecosystem != "go" { + t.Fatalf("go.mod ecosystem = %q", manifest.Ecosystem) + } + if got := manifest.Dependencies[0].Pinned; !got { + t.Fatalf("expected go dependency to be pinned") + } +} + +func assertWebManifest(t *testing.T, manifest codeguard.SupplyChainManifest) { + t.Helper() + if got := manifest.PackageManager; got != "pnpm" { + t.Fatalf("package manager = %q, want pnpm", got) + } + if got := manifest.Lockfiles[0]; got != "web/pnpm-lock.yaml" { + t.Fatalf("lockfile = %q, want web/pnpm-lock.yaml", got) + } + webDeps := map[string]codeguard.SupplyChainDependency{} + for _, dep := range manifest.Dependencies { + webDeps[dep.Name] = dep + } + if got := webDeps["react"].License; got != "MIT" { + t.Fatalf("react license = %q, want MIT", got) + } + if got := webDeps["typescript"].LicenseSource; got != "node_modules" { + t.Fatalf("typescript license source = %q, want node_modules", got) + } +} + +func assertPythonManifests(t *testing.T, requirements codeguard.SupplyChainManifest, pyproject codeguard.SupplyChainManifest) { + t.Helper() + requirementsDeps := map[string]codeguard.SupplyChainDependency{} + for _, dep := range requirements.Dependencies { + requirementsDeps[dep.Name] = dep + } + if got := requirementsDeps["requests"].Pinned; !got { + t.Fatalf("expected requests dependency to be pinned") + } + if got := requirementsDeps["flask"].Pinned; got { + t.Fatalf("expected flask dependency to be unpinned") + } + if got := pyproject.Name; got != "backend" { + t.Fatalf("pyproject name = %q, want backend", got) + } + if got := pyproject.PackageManager; got != "" { + t.Fatalf("pyproject package manager = %q, want empty", got) + } +} + +func assertCargoManifest(t *testing.T, manifest codeguard.SupplyChainManifest) { + t.Helper() + cargoDeps := map[string]codeguard.SupplyChainDependency{} + for _, dep := range manifest.Dependencies { + cargoDeps[dep.Name] = dep + } + if got := cargoDeps["serde"].Pinned; got { + t.Fatalf("expected serde cargo dependency to be unpinned") + } + if got := cargoDeps["tokio"].Pinned; !got { + t.Fatalf("expected tokio cargo dependency to be pinned") + } +} diff --git a/tests/checks/supplychain_helpers_test.go b/tests/checks/supplychain_helpers_test.go new file mode 100644 index 0000000..44bf2f3 --- /dev/null +++ b/tests/checks/supplychain_helpers_test.go @@ -0,0 +1,33 @@ +package checks_test + +import "github.com/devr-tools/codeguard/pkg/codeguard" + +func supplyChainTestConfig(dir string, name 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 = true + cacheOff := false + cfg.Cache.Enabled = &cacheOff + return cfg +} + +func supplyChainRuleMessages(report codeguard.Report, ruleID string) []string { + messages := make([]string, 0) + for _, section := range report.Sections { + if section.ID != "supply_chain" { + continue + } + for _, finding := range section.Findings { + if finding.RuleID == ruleID { + messages = append(messages, finding.Message) + } + } + } + return messages +} diff --git a/tests/checks/supplychain_license_test.go b/tests/checks/supplychain_license_test.go new file mode 100644 index 0000000..aa507a0 --- /dev/null +++ b/tests/checks/supplychain_license_test.go @@ -0,0 +1,251 @@ +package checks_test + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestSupplyChainFailsForDeniedManifestLicense(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "Cargo.toml"), `[package] +name = "worker" +version = "0.1.0" +license = "GPL-3.0" + +[dependencies] +tokio = "=1.38.0" +`) + writeFile(t, filepath.Join(dir, "Cargo.lock"), "# lock\n") + + cfg := supplyChainTestConfig(dir, "denied-license") + cfg.Checks.SupplyChainRules.DeniedLicenses = []string{"GPL-3.0"} + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Supply Chain", "fail") + assertFindingRulePresent(t, report, "Supply Chain", "supply_chain.denied-license") +} + +func TestSupplyChainFailsForDeniedDependencyLicenseFromNodeModules(t *testing.T) { + dir := t.TempDir() + writeNodeLicenseFixture(t, dir, nodeLicenseFixture{ + packagePath: "package.json", + lockfilePath: "package-lock.json", + nodeModulesDir: "node_modules", + license: "GPL-3.0", + }) + + cfg := supplyChainTestConfig(dir, "denied-dependency-license") + cfg.Checks.SupplyChainRules.DeniedLicenses = []string{"GPL-3.0"} + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Supply Chain", "fail") + assertFindingRulePresent(t, report, "Supply Chain", "supply_chain.denied-license") + if messages := supplyChainRuleMessages(report, "supply_chain.denied-license"); len(messages) == 0 || !strings.Contains(messages[0], "left-pad") { + t.Fatalf("unexpected denied dependency license messages: %v", messages) + } +} + +func TestSupplyChainResolvesNestedManifestDependencyLicense(t *testing.T) { + dir := t.TempDir() + writeNodeLicenseFixture(t, dir, nodeLicenseFixture{ + packagePath: filepath.Join("web", "package.json"), + lockfilePath: filepath.Join("web", "package-lock.json"), + nodeModulesDir: filepath.Join("web", "node_modules"), + license: "GPL-3.0", + }) + + cfg := supplyChainTestConfig(dir, "nested-node-license") + cfg.Checks.SupplyChainRules.DeniedLicenses = []string{"GPL-3.0"} + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Supply Chain", "fail") + if messages := supplyChainRuleMessages(report, "supply_chain.denied-license"); len(messages) == 0 || !strings.Contains(messages[0], "left-pad") { + t.Fatalf("unexpected nested dependency license messages: %v", messages) + } +} + +func TestSupplyChainAllowsMultiLicenseExpressionWhenAllTermsAllowed(t *testing.T) { + dir := t.TempDir() + writeNodeLicenseFixture(t, dir, nodeLicenseFixture{ + packagePath: "package.json", + lockfilePath: "package-lock.json", + nodeModulesDir: "node_modules", + license: "MIT OR Apache-2.0", + }) + + cfg := supplyChainTestConfig(dir, "multi-license-allowed") + cfg.Checks.SupplyChainRules.AllowedLicenses = []string{"MIT", "Apache-2.0"} + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Supply Chain", "pass") + if messages := supplyChainRuleMessages(report, "supply_chain.denied-license"); len(messages) != 0 { + t.Fatalf("expected no denied license finding, got %v", messages) + } +} + +func TestSupplyChainResolvesDependencyLicenseFromCommand(t *testing.T) { + dir := t.TempDir() + writeNodeManifestOnlyFixture(t, dir) + script := filepath.Join(dir, "resolve-licenses.sh") + writeExecutableFile(t, script, "#!/bin/sh\nset -eu\n[ \"$CODEGUARD_SUPPLY_CHAIN_ECOSYSTEM\" = \"npm\" ]\n[ \"$CODEGUARD_SUPPLY_CHAIN_MANIFEST_PATH\" = \"package.json\" ]\n[ \"$CODEGUARD_SUPPLY_CHAIN_TARGET_NAME\" = \"repo\" ]\n[ \"$CODEGUARD_SUPPLY_CHAIN_UNRESOLVED_NAMES\" = \"left-pad\" ]\n[ \"$CODEGUARD_SUPPLY_CHAIN_UNRESOLVED_COORDINATES\" = \"left-pad@1.3.0\" ]\n[ -f \"$CODEGUARD_SUPPLY_CHAIN_CONTEXT_FILE\" ]\ngrep -q '\"ecosystem\":\"npm\"' \"$CODEGUARD_SUPPLY_CHAIN_CONTEXT_FILE\"\ngrep -q '\"manifest_path\":\"package.json\"' \"$CODEGUARD_SUPPLY_CHAIN_CONTEXT_FILE\"\ngrep -q '\"name\":\"left-pad\"' \"$CODEGUARD_SUPPLY_CHAIN_CONTEXT_FILE\"\ngrep -q '\"coordinate\":\"left-pad@1.3.0\"' \"$CODEGUARD_SUPPLY_CHAIN_CONTEXT_FILE\"\nprintf '%s\n' '[{\"coordinate\":\"left-pad@1.3.0\",\"license\":\"GPL-3.0\",\"source\":\"license-command\"}]'\n") + + cfg := supplyChainTestConfig(dir, "command-license") + cfg.Checks.SupplyChainRules.DeniedLicenses = []string{"GPL-3.0"} + cfg.Checks.SupplyChainRules.LicenseCommands = map[string]codeguard.CommandCheckConfig{ + "npm": {Name: "npm-license-resolver", Command: script}, + } + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Supply Chain", "fail") + assertFindingRulePresent(t, report, "Supply Chain", "supply_chain.denied-license") + if messages := supplyChainRuleMessages(report, "supply_chain.denied-license"); len(messages) == 0 || !strings.Contains(messages[0], "license-command") { + t.Fatalf("unexpected command-backed license messages: %v", messages) + } + deps := requireSupplyChainArtifact(t, report, 1).SupplyChain.Manifests[0].Dependencies + if len(deps) != 1 || deps[0].License != "GPL-3.0" || deps[0].LicenseSource != "license-command" { + t.Fatalf("unexpected command-backed dependency metadata: %#v", deps) + } +} + +func TestSupplyChainPrefersDefinitiveCommandLicenseCandidate(t *testing.T) { + dir := t.TempDir() + writeNodeManifestOnlyFixture(t, dir) + script := filepath.Join(dir, "resolve-licenses.sh") + writeExecutableFile(t, script, "#!/bin/sh\nset -eu\nprintf '%s\n' '[{\"coordinate\":\"left-pad@1.3.0\",\"candidates\":[{\"license\":\"GPL-3.0\",\"confidence\":\"low\",\"provenance\":\"heuristic-text-match\",\"source\":\"license-command\"},{\"license\":\"MIT\",\"confidence\":\"high\",\"provenance\":\"spdx-expression\",\"source\":\"license-command\"}]}]'\n") + + cfg := supplyChainTestConfig(dir, "candidate-license") + cfg.Checks.SupplyChainRules.DeniedLicenses = []string{"GPL-3.0"} + cfg.Checks.SupplyChainRules.LicenseCommands = map[string]codeguard.CommandCheckConfig{ + "npm": {Name: "npm-license-resolver", Command: script}, + } + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Supply Chain", "pass") + if messages := supplyChainRuleMessages(report, "supply_chain.denied-license"); len(messages) != 0 { + t.Fatalf("expected no denied license findings, got %v", messages) + } + deps := requireSupplyChainArtifact(t, report, 1).SupplyChain.Manifests[0].Dependencies + if len(deps) != 1 { + t.Fatalf("dependency count = %d, want 1", len(deps)) + } + if deps[0].License != "MIT" { + t.Fatalf("selected license = %q, want MIT", deps[0].License) + } + if len(deps[0].LicenseCandidates) != 2 { + t.Fatalf("candidate count = %d, want 2", len(deps[0].LicenseCandidates)) + } + if deps[0].LicenseCandidates[0].License != "MIT" { + t.Fatalf("best candidate = %q, want MIT", deps[0].LicenseCandidates[0].License) + } +} + +func TestSupplyChainWarnsForHeuristicCommandLicenseCandidate(t *testing.T) { + dir := t.TempDir() + writeNodeManifestOnlyFixture(t, dir) + script := filepath.Join(dir, "resolve-licenses.sh") + writeExecutableFile(t, script, "#!/bin/sh\nset -eu\nprintf '%s\n' '[{\"coordinate\":\"left-pad@1.3.0\",\"candidates\":[{\"license\":\"GPL-3.0\",\"confidence\":\"low\",\"provenance\":\"heuristic-text-match\",\"source\":\"license-command\"}]}]'\n") + + cfg := supplyChainTestConfig(dir, "heuristic-license") + cfg.Checks.SupplyChainRules.DeniedLicenses = []string{"GPL-3.0"} + cfg.Checks.SupplyChainRules.LicenseCommands = map[string]codeguard.CommandCheckConfig{ + "npm": {Name: "npm-license-resolver", Command: script}, + } + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Supply Chain", "warn") + assertFindingRulePresent(t, report, "Supply Chain", "supply_chain.denied-license") + messages := supplyChainRuleMessages(report, "supply_chain.denied-license") + if len(messages) == 0 || !strings.Contains(messages[0], "confidence=low") || !strings.Contains(messages[0], "heuristic") { + t.Fatalf("unexpected heuristic license messages: %v", messages) + } +} + +func writeNodeManifestOnlyFixture(t *testing.T, dir string) { + t.Helper() + writeFile(t, filepath.Join(dir, "package.json"), `{ + "name": "frontend", + "dependencies": { + "left-pad": "1.3.0" + } +}`) + writeFile(t, filepath.Join(dir, "package-lock.json"), `{ + "name": "frontend", + "lockfileVersion": 3, + "packages": { + "": {"name": "frontend"}, + "node_modules/left-pad": {"version": "1.3.0"} + } +}`) +} + +type nodeLicenseFixture struct { + packagePath string + lockfilePath string + nodeModulesDir string + license string +} + +func writeNodeLicenseFixture(t *testing.T, dir string, fixture nodeLicenseFixture) { + t.Helper() + writeNodeManifestOnlyFixture(t, dirForManifest(dir, fixture.packagePath)) + if fixture.packagePath != "package.json" { + writeFile(t, filepath.Join(dir, fixture.packagePath), `{ + "name": "frontend", + "dependencies": { + "left-pad": "1.3.0" + } +}`) + writeFile(t, filepath.Join(dir, fixture.lockfilePath), `{ + "name": "frontend", + "lockfileVersion": 3, + "packages": { + "": {"name": "frontend"}, + "node_modules/left-pad": {"version": "1.3.0"} + } +}`) + } + writeFile(t, filepath.Join(dir, fixture.nodeModulesDir, "left-pad", "package.json"), `{ + "name": "left-pad", + "version": "1.3.0", + "license": "`+fixture.license+`" +}`) +} + +func dirForManifest(root string, packagePath string) string { + if packagePath == "package.json" { + return root + } + return filepath.Join(root, filepath.Dir(packagePath)) +} diff --git a/tests/checks/supplychain_test.go b/tests/checks/supplychain_test.go new file mode 100644 index 0000000..219c346 --- /dev/null +++ b/tests/checks/supplychain_test.go @@ -0,0 +1,126 @@ +package checks_test + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestSupplyChainSectionPassesWhenEnabled(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "go.mod"), "module example.com/supplychain\n\ngo 1.23.0\n") + writeFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n") + + cfg := codeguard.ExampleConfig() + cfg.Name = "supply-chain-enabled" + 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 = true + + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Supply Chain", "pass") +} + +func TestSupplyChainWarnsForUnpinnedDependencies(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "package.json"), `{ + "name": "frontend", + "dependencies": { + "react": "^18.2.0" + }, + "devDependencies": { + "vitest": "1.6.0" + } +}`) + writeFile(t, filepath.Join(dir, "package-lock.json"), `{ + "name": "frontend", + "lockfileVersion": 3, + "packages": { + "": {"name": "frontend"}, + "node_modules/react": {"version": "18.2.0"}, + "node_modules/vitest": {"version": "1.6.0"} + } +}`) + + report, err := codeguard.Run(context.Background(), supplyChainTestConfig(dir, "unpinned")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Supply Chain", "warn") + assertFindingRulePresent(t, report, "Supply Chain", "supply_chain.unpinned-dependency") + if messages := supplyChainRuleMessages(report, "supply_chain.unpinned-dependency"); len(messages) != 1 || !strings.Contains(messages[0], "react") { + t.Fatalf("unexpected unpinned messages: %v", messages) + } +} + +func TestSupplyChainFailsForMissingLockfile(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "package.json"), `{ + "name": "frontend", + "dependencies": { + "react": "18.2.0" + } +}`) + + report, err := codeguard.Run(context.Background(), supplyChainTestConfig(dir, "missing-lockfile")) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertSectionStatus(t, report, "Supply Chain", "fail") + assertFindingRulePresent(t, report, "Supply Chain", "supply_chain.missing-lockfile") +} + +func TestSupplyChainFailsForLockfileDriftInDiffMode(t *testing.T) { + 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") + writeFile(t, filepath.Join(dir, "package.json"), `{ + "name": "frontend", + "dependencies": { + "react": "18.2.0" + } +}`) + writeFile(t, filepath.Join(dir, "package-lock.json"), `{ + "name": "frontend", + "lockfileVersion": 3, + "packages": { + "": {"name": "frontend"}, + "node_modules/react": {"version": "18.2.0"} + } +}`) + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", "base") + + writeFile(t, filepath.Join(dir, "package.json"), `{ + "name": "frontend", + "dependencies": { + "react": "18.3.0" + } +}`) + + cfg := supplyChainTestConfig(dir, "lockfile-drift") + report, err := codeguard.RunWithOptions(context.Background(), cfg, codeguard.ScanOptions{ + Mode: codeguard.ScanModeDiff, + BaseRef: "main", + }) + if err != nil { + t.Fatalf("run diff: %v", err) + } + + assertSectionStatus(t, report, "Supply Chain", "fail") + assertFindingRulePresent(t, report, "Supply Chain", "supply_chain.lockfile-drift") +} diff --git a/tests/cli/features_metadata_custom_test.go b/tests/cli/features_metadata_custom_test.go new file mode 100644 index 0000000..af227af --- /dev/null +++ b/tests/cli/features_metadata_custom_test.go @@ -0,0 +1,120 @@ +package cli_test + +import ( + "strings" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func TestSDKRuleMetadataForSemanticTestAdequacyRule(t *testing.T) { + rule := requireRuleMetadata(t, "quality.ai.semantic-test-adequacy") + assertExecutionModel(t, rule, codeguard.RuleExecutionModelCommandDriven) + assertLanguageCoverage( + t, + rule, + codeguard.RuleLanguageCoverageFixed, + codeguard.RuleLanguageGo, + codeguard.RuleLanguageJavaScript, + codeguard.RuleLanguagePython, + codeguard.RuleLanguageTypeScript, + ) +} + +func TestSDKRuleMetadataForContractDriftRule(t *testing.T) { + rule := requireRuleMetadata(t, "quality.ai.contract-drift") + assertExecutionModel(t, rule, codeguard.RuleExecutionModelCommandDriven) + assertLanguageCoverage( + t, + rule, + codeguard.RuleLanguageCoverageFixed, + codeguard.RuleLanguageGo, + codeguard.RuleLanguageJavaScript, + codeguard.RuleLanguagePython, + codeguard.RuleLanguageTypeScript, + ) +} + +func TestSDKRuleMetadataForCustomRulePack(t *testing.T) { + cfg := codeguard.ExampleConfig() + cfg.RulePacks = []codeguard.RulePackConfig{{ + Name: "repo-policy", + Rules: []codeguard.CustomRuleConfig{{ + ID: "custom.disallow-env", + Title: "Disallow env files", + Severity: "fail", + Message: "env files must not be committed", + Paths: []string{".env"}, + }}, + }} + + var customRule codeguard.RuleMetadata + for _, meta := range codeguard.RulesForConfig(cfg) { + if meta.ID == "custom.disallow-env" { + customRule = meta + break + } + } + if customRule.ID == "" { + t.Fatal("expected custom.disallow-env metadata") + } + assertExecutionModel(t, customRule, codeguard.RuleExecutionModelLanguageAgnostic) + assertLanguageCoverage(t, customRule, codeguard.RuleLanguageCoverageConfigurable) +} + +func TestSDKRuleMetadataForNaturalLanguageCustomRulePack(t *testing.T) { + cfg := codeguard.ExampleConfig() + cfg.RulePacks = []codeguard.RulePackConfig{{ + Name: "repo-policy", + Rules: []codeguard.CustomRuleConfig{{ + ID: "custom.no-request-body-logs", + Title: "Never log request bodies", + Severity: "fail", + Message: "request bodies must not be logged in handlers", + NaturalLanguage: "never log request bodies in handlers", + Paths: []string{"handlers/**"}, + }}, + }} + + var customRule codeguard.RuleMetadata + for _, meta := range codeguard.RulesForConfig(cfg) { + if meta.ID == "custom.no-request-body-logs" { + customRule = meta + break + } + } + if customRule.ID == "" { + t.Fatal("expected custom.no-request-body-logs metadata") + } + assertExecutionModel(t, customRule, codeguard.RuleExecutionModelCommandDriven) + assertLanguageCoverage(t, customRule, codeguard.RuleLanguageCoverageConfigurable) +} + +func TestSDKRuleMetadataFixTemplatesPopulated(t *testing.T) { + ruleIDs := []string{ + "quality.gofmt", + "quality.ai.swallowed-error", + "quality.ai.hallucinated-import", + "quality.ai.narrative-comment", + "quality.ai.dead-code", + "quality.ai.over-mocked-test", + "quality.ai.contract-drift", + "quality.ai.semantic-test-adequacy", + "quality.javascript.explicit-any", + "quality.javascript.ts-ignore", + "quality.javascript.debugger-statement", + "quality.javascript.non-null-assertion", + "prompts.secret-interpolation", + "prompts.agent-standing-permissions", + "prompts.mcp-config-risk", + "ci.test-without-assertion", + "quality.max-function-lines", + "quality.cyclomatic-complexity", + } + for _, ruleID := range ruleIDs { + rule := requireRuleMetadata(t, ruleID) + if strings.TrimSpace(rule.FixTemplate) == "" { + t.Fatalf("%s fix template is empty, want a populated template", ruleID) + } + } +} diff --git a/tests/cli/features_metadata_test.go b/tests/cli/features_metadata_test.go index f7557ee..785e511 100644 --- a/tests/cli/features_metadata_test.go +++ b/tests/cli/features_metadata_test.go @@ -46,86 +46,10 @@ func TestSDKRuleMetadataForRepositoryWideRule(t *testing.T) { assertLanguageCoverage(t, rule, codeguard.RuleLanguageCoverageRepositoryWide) } -func TestSDKRuleMetadataForCustomRulePack(t *testing.T) { - cfg := codeguard.ExampleConfig() - cfg.RulePacks = []codeguard.RulePackConfig{{ - Name: "repo-policy", - Rules: []codeguard.CustomRuleConfig{{ - ID: "custom.disallow-env", - Title: "Disallow env files", - Severity: "fail", - Message: "env files must not be committed", - Paths: []string{".env"}, - }}, - }} - - var customRule codeguard.RuleMetadata - for _, meta := range codeguard.RulesForConfig(cfg) { - if meta.ID == "custom.disallow-env" { - customRule = meta - break - } - } - if customRule.ID == "" { - t.Fatal("expected custom.disallow-env metadata") - } - assertExecutionModel(t, customRule, codeguard.RuleExecutionModelLanguageAgnostic) - assertLanguageCoverage(t, customRule, codeguard.RuleLanguageCoverageConfigurable) -} - -func TestSDKRuleMetadataForNaturalLanguageCustomRulePack(t *testing.T) { - cfg := codeguard.ExampleConfig() - cfg.RulePacks = []codeguard.RulePackConfig{{ - Name: "repo-policy", - Rules: []codeguard.CustomRuleConfig{{ - ID: "custom.no-request-body-logs", - Title: "Never log request bodies", - Severity: "fail", - Message: "request bodies must not be logged in handlers", - NaturalLanguage: "never log request bodies in handlers", - Paths: []string{"handlers/**"}, - }}, - }} - - var customRule codeguard.RuleMetadata - for _, meta := range codeguard.RulesForConfig(cfg) { - if meta.ID == "custom.no-request-body-logs" { - customRule = meta - break - } - } - if customRule.ID == "" { - t.Fatal("expected custom.no-request-body-logs metadata") - } - assertExecutionModel(t, customRule, codeguard.RuleExecutionModelCommandDriven) - assertLanguageCoverage(t, customRule, codeguard.RuleLanguageCoverageConfigurable) -} - -func TestSDKRuleMetadataFixTemplatesPopulated(t *testing.T) { - ruleIDs := []string{ - "quality.gofmt", - "quality.ai.swallowed-error", - "quality.ai.hallucinated-import", - "quality.ai.narrative-comment", - "quality.ai.dead-code", - "quality.ai.over-mocked-test", - "quality.javascript.explicit-any", - "quality.javascript.ts-ignore", - "quality.javascript.debugger-statement", - "quality.javascript.non-null-assertion", - "prompts.secret-interpolation", - "prompts.agent-standing-permissions", - "prompts.mcp-config-risk", - "ci.test-without-assertion", - "quality.max-function-lines", - "quality.cyclomatic-complexity", - } - for _, ruleID := range ruleIDs { - rule := requireRuleMetadata(t, ruleID) - if strings.TrimSpace(rule.FixTemplate) == "" { - t.Fatalf("%s fix template is empty, want a populated template", ruleID) - } - } +func TestSDKRuleMetadataForSupplyChainRule(t *testing.T) { + rule := requireRuleMetadata(t, "supply_chain.lockfile-drift") + assertExecutionModel(t, rule, codeguard.RuleExecutionModelLanguageAgnostic) + assertLanguageCoverage(t, rule, codeguard.RuleLanguageCoverageRepositoryWide) } func TestSDKRuleMetadataFixTemplateIncludesBeforeAfterSnippet(t *testing.T) { diff --git a/tests/codeguard/api_test.go b/tests/codeguard/api_test.go index a803e24..5e8b82c 100644 --- a/tests/codeguard/api_test.go +++ b/tests/codeguard/api_test.go @@ -26,6 +26,35 @@ func TestValidateConfigRejectsBlankTargetPath(t *testing.T) { } } +func TestValidateConfigRejectsOverlappingSupplyChainLicensePolicy(t *testing.T) { + cfg := codeguard.ExampleConfig() + cfg.Checks.SupplyChainRules.AllowedLicenses = []string{"MIT"} + cfg.Checks.SupplyChainRules.DeniedLicenses = []string{"mit"} + + err := codeguard.ValidateConfig(cfg) + if err == nil { + t.Fatal("expected validation error") + } + if !strings.Contains(err.Error(), "allowed_licenses and denied_licenses must not overlap") { + t.Fatalf("unexpected validation error: %v", err) + } +} + +func TestValidateConfigRejectsSupplyChainLicenseCommandWithoutName(t *testing.T) { + cfg := codeguard.ExampleConfig() + cfg.Checks.SupplyChainRules.LicenseCommands = map[string]codeguard.CommandCheckConfig{ + "npm": {Command: "./resolve-licenses.sh"}, + } + + err := codeguard.ValidateConfig(cfg) + if err == nil { + t.Fatal("expected validation error") + } + if !strings.Contains(err.Error(), "supply_chain_rules.license_commands[npm].name is required") { + t.Fatalf("unexpected validation error: %v", err) + } +} + func TestWriteReportTextIncludesSummary(t *testing.T) { t.Setenv("NO_COLOR", "1") diff --git a/tests/codeguard/runner_artifacts_test.go b/tests/codeguard/runner_artifacts_test.go index cb1feb9..d1e2b3a 100644 --- a/tests/codeguard/runner_artifacts_test.go +++ b/tests/codeguard/runner_artifacts_test.go @@ -93,6 +93,7 @@ func doThing() error { return nil } t.Fatalf("Run returned error: %v", err) } assertSlopScoreArtifact(t, report, root) + assertChangeRiskArtifact(t, report, root) } func writeArtifactFile(t *testing.T, path string, content string) { @@ -166,3 +167,23 @@ func assertSlopScoreArtifact(t *testing.T, report codeguard.Report, root string) } t.Fatalf("expected slop_score artifact, got %#v", report.Artifacts) } + +func assertChangeRiskArtifact(t *testing.T, report codeguard.Report, root string) { + t.Helper() + for _, artifact := range report.Artifacts { + if artifact.Kind != "change_risk" { + continue + } + if artifact.Target != root { + t.Fatalf("unexpected change-risk artifact target %q", artifact.Target) + } + if artifact.ChangeRisk == nil { + t.Fatal("expected change risk payload") + } + if artifact.ChangeRisk.Score <= 0 { + t.Fatalf("unexpected change risk payload %#v", artifact.ChangeRisk) + } + return + } + t.Fatalf("expected change_risk artifact, got %#v", report.Artifacts) +} diff --git a/tests/codeguard/runner_test.go b/tests/codeguard/runner_test.go index bf8f647..bc19a29 100644 --- a/tests/codeguard/runner_test.go +++ b/tests/codeguard/runner_test.go @@ -50,47 +50,32 @@ func TestYAMLConfigRoundTrip(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "codeguard.yaml") - cfg := codeguard.ExampleConfig() - cfg.Checks.QualityRules.LanguageCommands = map[string][]codeguard.CommandCheckConfig{ - "typescript": {{ - Name: "tsc", - Command: "npx", - Args: []string{"tsc", "--noEmit"}, - }}, - } - cfg.Checks.DesignRules.LanguageCommands = map[string][]codeguard.CommandCheckConfig{ - "python": {{ - Name: "import-linter", - Command: "lint-imports", - Args: []string{"--config", "importlinter.ini"}, - }}, - } - cfg.Checks.DesignRules.LanguageDiffCommands = map[string][]codeguard.CommandCheckConfig{ - "go": {{ - Name: "api-diff", - Command: "./scripts/api-diff.sh", - }}, - } + cfg := yamlRoundTripConfig() if err := codeguard.WriteConfigFile(path, cfg); err != nil { t.Fatalf("write yaml: %v", err) } + assertYAMLSchemaMarkers(t, path) loaded, err := codeguard.LoadConfigFile(path) if err != nil { t.Fatalf("load yaml: %v", err) } - if loaded.Name != cfg.Name { - t.Fatalf("loaded name = %q, want %q", loaded.Name, cfg.Name) - } - if got := loaded.Checks.QualityRules.LanguageCommands["typescript"][0].Command; got != "npx" { - t.Fatalf("loaded command = %q, want %q", got, "npx") - } - if got := loaded.Checks.DesignRules.LanguageCommands["python"][0].Command; got != "lint-imports" { - t.Fatalf("loaded design command = %q, want %q", got, "lint-imports") + assertYAMLRoundTripConfig(t, loaded, cfg) +} + +func TestLoadConfigFileAcceptsDocumentedSnakeCaseYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "codeguard.yaml") + config := snakeCaseYAMLFixture() + if err := os.WriteFile(path, []byte(config), 0o644); err != nil { + t.Fatalf("write yaml: %v", err) } - if got := loaded.Checks.DesignRules.LanguageDiffCommands["go"][0].Name; got != "api-diff" { - t.Fatalf("loaded diff command = %q, want %q", got, "api-diff") + + loaded, err := codeguard.LoadConfigFile(path) + if err != nil { + t.Fatalf("load yaml: %v", err) } + assertSnakeCaseYAMLLoaded(t, loaded) } func TestLoadConfigFileResolvesTargetPathsRelativeToConfig(t *testing.T) { diff --git a/tests/codeguard/yaml_config_helpers_test.go b/tests/codeguard/yaml_config_helpers_test.go new file mode 100644 index 0000000..21032f6 --- /dev/null +++ b/tests/codeguard/yaml_config_helpers_test.go @@ -0,0 +1,178 @@ +package codeguard_test + +import ( + "os" + "strings" + "testing" + + "github.com/devr-tools/codeguard/pkg/codeguard" +) + +func yamlRoundTripConfig() codeguard.Config { + cfg := codeguard.ExampleConfig() + cfg.Checks.SupplyChain = true + cfg.Checks.QualityRules.LanguageCommands = map[string][]codeguard.CommandCheckConfig{ + "typescript": {{Name: "tsc", Command: "npx", Args: []string{"tsc", "--noEmit"}}}, + } + cfg.Checks.DesignRules.LanguageCommands = map[string][]codeguard.CommandCheckConfig{ + "python": {{Name: "import-linter", Command: "lint-imports", Args: []string{"--config", "importlinter.ini"}}}, + } + cfg.Checks.DesignRules.LanguageDiffCommands = map[string][]codeguard.CommandCheckConfig{ + "go": {{Name: "api-diff", Command: "./scripts/api-diff.sh"}}, + } + cfg.AI.AutoFix.TestCommands = []codeguard.CommandCheckConfig{{ + Name: "unit", Command: "go", Args: []string{"test", "./..."}, + }} + cfg.RulePacks = []codeguard.RulePackConfig{{ + Name: "repo-policy", Description: "Example pack", Rules: []codeguard.CustomRuleConfig{{ + ID: "custom.no-debug", Title: "No debug", Message: "Debug code is forbidden", HowToFix: "Remove debug code", Paths: []string{"**/*.go"}, + }}, + }} + return cfg +} + +func assertYAMLSchemaMarkers(t *testing.T, path string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read yaml: %v", err) + } + rendered := string(data) + for _, want := range []string{"supply_chain:", "quality_rules:", "max_file_lines:", "language_commands:", "ci_rules:", "required_workflow_files:", "hybrid_triage:", "candidate_sections:", "function_contract:", "test_commands:", "rule_packs:"} { + if !strings.Contains(rendered, want) { + t.Fatalf("written yaml missing %q:\n%s", want, rendered) + } + } +} + +func assertYAMLRoundTripConfig(t *testing.T, loaded codeguard.Config, want codeguard.Config) { + t.Helper() + if loaded.Name != want.Name { + t.Fatalf("loaded name = %q, want %q", loaded.Name, want.Name) + } + assertYAMLCommand(t, loaded.Checks.QualityRules.LanguageCommands["typescript"][0].Command, "npx", "loaded command") + assertYAMLCommand(t, loaded.Checks.DesignRules.LanguageCommands["python"][0].Command, "lint-imports", "loaded design command") + assertYAMLCommand(t, loaded.Checks.DesignRules.LanguageDiffCommands["go"][0].Name, "api-diff", "loaded diff command") +} + +func assertYAMLCommand(t *testing.T, got string, want string, label string) { + t.Helper() + if got != want { + t.Fatalf("%s = %q, want %q", label, got, want) + } +} + +func snakeCaseYAMLFixture() string { + return `name: snake-case-config +targets: + - name: repo + path: . + language: go +checks: + quality: false + design: false + security: false + prompts: false + ci: true + supply_chain: true + quality_rules: + max_file_lines: 123 + coverage_delta: + enabled: true + min_changed_line_coverage: 77 + ci_rules: + require_workflow_dir: true + required_workflow_files: + - .github/workflows/ci.yml + supply_chain_rules: + require_lockfile: true + detect_lockfile_drift: true + detect_unpinned: true +ai: + enabled: true + hybrid_triage: + enabled: true + candidate_sections: + - Code Quality + semantic: + enabled: true + function_contract: true + test_adequacy: true + autofix: + enabled: true + verify_tests: true + max_fixes: 2 + test_commands: + - name: unit + command: go + args: ["test", "./..."] +rule_packs: + - name: repo-policy + description: Example pack + rules: + - id: custom.no-debug + title: No debug + message: Debug code is forbidden + how_to_fix: Remove debug code + natural_language: reject debug code + paths: + - "**/*.go" +output: + format: text +` +} + +func assertSnakeCaseYAMLLoaded(t *testing.T, loaded codeguard.Config) { + t.Helper() + assertSnakeCaseChecks(t, loaded) + assertSnakeCaseAI(t, loaded) + assertSnakeCaseRulePack(t, loaded) +} + +func assertSnakeCaseChecks(t *testing.T, loaded codeguard.Config) { + t.Helper() + if !loaded.Checks.SupplyChain { + t.Fatal("expected supply_chain to load from snake_case yaml") + } + if loaded.Checks.QualityRules.MaxFileLines != 123 { + t.Fatalf("max_file_lines = %d, want 123", loaded.Checks.QualityRules.MaxFileLines) + } + if loaded.Checks.CIRules.RequireWorkflowDir == nil || !*loaded.Checks.CIRules.RequireWorkflowDir { + t.Fatal("expected require_workflow_dir to load") + } + if loaded.Checks.QualityRules.CoverageDelta.Enabled == nil || !*loaded.Checks.QualityRules.CoverageDelta.Enabled { + t.Fatal("expected coverage_delta.enabled to load") + } + if loaded.Checks.QualityRules.CoverageDelta.MinChangedLineCoverage == nil || *loaded.Checks.QualityRules.CoverageDelta.MinChangedLineCoverage != 77 { + t.Fatalf("min_changed_line_coverage = %#v, want 77", loaded.Checks.QualityRules.CoverageDelta.MinChangedLineCoverage) + } +} + +func assertSnakeCaseAI(t *testing.T, loaded codeguard.Config) { + t.Helper() + if loaded.AI.HybridTriage.Enabled == nil || !*loaded.AI.HybridTriage.Enabled { + t.Fatal("expected hybrid_triage.enabled to load") + } + if len(loaded.AI.HybridTriage.CandidateSections) != 1 || loaded.AI.HybridTriage.CandidateSections[0] != "Code Quality" { + t.Fatalf("candidate_sections = %#v, want [Code Quality]", loaded.AI.HybridTriage.CandidateSections) + } + if loaded.AI.Semantic.FunctionContract == nil || !*loaded.AI.Semantic.FunctionContract { + t.Fatal("expected semantic.function_contract to load") + } + if loaded.AI.Semantic.TestAdequacy == nil || !*loaded.AI.Semantic.TestAdequacy { + t.Fatal("expected semantic.test_adequacy to load") + } + if loaded.AI.AutoFix.MaxFixes != 2 { + t.Fatalf("max_fixes = %d, want 2", loaded.AI.AutoFix.MaxFixes) + } + if len(loaded.AI.AutoFix.TestCommands) != 1 || loaded.AI.AutoFix.TestCommands[0].Name != "unit" { + t.Fatalf("test_commands = %#v, want one named unit", loaded.AI.AutoFix.TestCommands) + } +} + +func assertSnakeCaseRulePack(t *testing.T, loaded codeguard.Config) { + t.Helper() + if len(loaded.RulePacks) != 1 || loaded.RulePacks[0].Rules[0].HowToFix != "Remove debug code" { + t.Fatalf("rule_packs = %#v, want custom rule with how_to_fix", loaded.RulePacks) + } +}