From 1a5a3d70af3674bde052d7efcd5f646fc5107d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florin=20P=C4=83=C8=9Ban?= Date: Tue, 3 Nov 2026 12:46:51 +0100 Subject: [PATCH 1/5] Adapt the PR review skill to any language and infrastructure Feedback was that ardan-pr-review was too focused on Go. It now reviews Rust, Python, Terraform/OpenTofu, Helm, Kustomize/K8s, Ansible, Dockerfiles, and CI pipelines (GitHub Actions, GitLab, Jenkins, ...) as first-class code, with each ecosystem's own static analysis. - intake: path- and content-aware classification (charts, kustomize, K8s manifests, Ansible, CI systems); infrastructure counts as prod so correctness/tests agents run on infra-only PRs; symbols for Rust, Python, Terraform addresses, and Helm templates; per-language signals; stack.tsv (units per ecosystem), idioms=, unmapped.txt; Go layers only on Go files; --include-generated opt-in (sh and PowerShell) - checks: cargo fmt/clippy/test/audit/deny (--locked); terraform or tofu fmt + init -backend=false + validate (temp TF_DATA_DIR, read-only lock) and tflint; helm lint/template and kustomize build into kubeconform + kube-linter; trivy/checkov IaC scan; ruff, mypy, pytest, bandit, pip-audit without caches; ansible-lint, yamllint, hadolint, actionlint, zizmor; user-approved repo gates (--extra); a lens column in summary.tsv; go build -o /dev/null so a main package never writes its binary into the repo - discovery agent: reads configs only, finds ecosystems the scripts miss and the repo's own quality gates, offered to the user as extra checks; extract-findings --discovery saves its reply - lenses, contract, and roles are language-neutral; per-language idioms live in references/langs/*.md and agents load only the ones in scope - SKILL.md: discovery step, repo-gate approval, generated files skip / secret-scan / review choice, test setup for Rust, Python, Terraform Tests: zarf/tests/ardan-pr-review builds a fixture repo with real problems in every ecosystem and asserts sh/PowerShell parity, golden intake and check results, and a read-only working tree, with stub analyzers on the host (BSD and GNU) and the real tools at pinned versions in a Docker image. `make test` runs lint and both modes; CI runs them on PRs touching the skill. --- .agents/skills/ardan-pr-review/SKILL.md | 42 +- .../ardan-pr-review/references/contract.md | 37 +- .../references/langs/ansible.md | 21 + .../ardan-pr-review/references/langs/ci.md | 24 ++ .../references/langs/docker.md | 21 + .../ardan-pr-review/references/langs/go.md | 33 ++ .../references/langs/helm-k8s.md | 21 + .../ardan-pr-review/references/langs/proto.md | 9 + .../references/langs/python.md | 21 + .../ardan-pr-review/references/langs/rego.md | 12 + .../ardan-pr-review/references/langs/rust.md | 21 + .../ardan-pr-review/references/langs/shell.md | 21 + .../ardan-pr-review/references/langs/sql.md | 18 + .../references/langs/terraform.md | 23 ++ .../ardan-pr-review/references/langs/web.md | 21 + .../references/lenses/boundary-keeper.md | 33 +- .../references/lenses/clone-hunter.md | 6 +- .../references/lenses/doc-drift-check.md | 6 +- .../references/lenses/error-tripwire.md | 24 +- .../references/lenses/harness-map.md | 19 +- .../references/lenses/security-sentinel.md | 4 +- .../references/lenses/service-steward.md | 29 +- .../references/lenses/spec-cartographer.md | 2 +- .../references/lenses/straight-line-pass.md | 6 +- .../references/roles/aggregator.md | 2 +- .../references/roles/context-builder.md | 8 +- .../references/roles/diagrams.md | 1 + .../references/roles/discovery.md | 58 +++ .../references/roles/generated-scan.md | 2 +- .../ardan-pr-review/references/size-guard.md | 2 +- .../skills/ardan-pr-review/scripts/checks.ps1 | 277 +++++++++++++- .../skills/ardan-pr-review/scripts/checks.sh | 330 +++++++++++++++- .../scripts/extract-findings.ps1 | 30 +- .../scripts/extract-findings.sh | 31 +- .../skills/ardan-pr-review/scripts/intake.ps1 | 334 +++++++++++++--- .../skills/ardan-pr-review/scripts/intake.sh | 358 +++++++++++++++--- .claude-plugin/marketplace.json | 10 +- .github/workflows/ardan-pr-review.yml | 76 ++++ Makefile | 27 ++ README.md | 23 +- zarf/tests/ardan-pr-review/Dockerfile | 101 +++++ zarf/tests/ardan-pr-review/README.md | 28 ++ .../expected/container.checks.tsv | 44 +++ .../ardan-pr-review/expected/diff.files.tsv | 14 + .../expected/diff.packages.tsv | 1 + .../ardan-pr-review/expected/diff.refs.tsv | 2 + .../ardan-pr-review/expected/diff.scope.txt | 29 ++ .../ardan-pr-review/expected/diff.signals.txt | 7 + .../ardan-pr-review/expected/diff.skipped.tsv | 2 + .../ardan-pr-review/expected/diff.stack.tsv | 11 + .../ardan-pr-review/expected/diff.symbols.tsv | 8 + .../expected/diff.unmapped.txt | 1 + .../ardan-pr-review/expected/gen.files.tsv | 15 + .../expected/gen.review-set.tsv | 24 ++ .../ardan-pr-review/expected/gen.skipped.tsv | 1 + .../ardan-pr-review/expected/host.checks.tsv | 44 +++ .../expected/project.review-set.tsv | 26 ++ .../expected/project.shards.tsv | 0 .../ardan-pr-review/expected/project.size.tsv | 2 + .../expected/project.stack.tsv | 11 + .../ardan-pr-review/expected/tofu.checks.tsv | 5 + .../ardan-pr-review/expected/tofu.stack.tsv | 2 + zarf/tests/ardan-pr-review/fixture.sh | 215 +++++++++++ zarf/tests/ardan-pr-review/run.sh | 42 ++ zarf/tests/ardan-pr-review/suite.sh | 139 +++++++ 65 files changed, 2544 insertions(+), 273 deletions(-) create mode 100644 .agents/skills/ardan-pr-review/references/langs/ansible.md create mode 100644 .agents/skills/ardan-pr-review/references/langs/ci.md create mode 100644 .agents/skills/ardan-pr-review/references/langs/docker.md create mode 100644 .agents/skills/ardan-pr-review/references/langs/go.md create mode 100644 .agents/skills/ardan-pr-review/references/langs/helm-k8s.md create mode 100644 .agents/skills/ardan-pr-review/references/langs/proto.md create mode 100644 .agents/skills/ardan-pr-review/references/langs/python.md create mode 100644 .agents/skills/ardan-pr-review/references/langs/rego.md create mode 100644 .agents/skills/ardan-pr-review/references/langs/rust.md create mode 100644 .agents/skills/ardan-pr-review/references/langs/shell.md create mode 100644 .agents/skills/ardan-pr-review/references/langs/sql.md create mode 100644 .agents/skills/ardan-pr-review/references/langs/terraform.md create mode 100644 .agents/skills/ardan-pr-review/references/langs/web.md create mode 100644 .agents/skills/ardan-pr-review/references/roles/discovery.md create mode 100644 .github/workflows/ardan-pr-review.yml create mode 100644 Makefile create mode 100644 zarf/tests/ardan-pr-review/Dockerfile create mode 100644 zarf/tests/ardan-pr-review/README.md create mode 100644 zarf/tests/ardan-pr-review/expected/container.checks.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/diff.files.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/diff.packages.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/diff.refs.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/diff.scope.txt create mode 100644 zarf/tests/ardan-pr-review/expected/diff.signals.txt create mode 100644 zarf/tests/ardan-pr-review/expected/diff.skipped.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/diff.stack.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/diff.symbols.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/diff.unmapped.txt create mode 100644 zarf/tests/ardan-pr-review/expected/gen.files.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/gen.review-set.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/gen.skipped.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/host.checks.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/project.review-set.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/project.shards.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/project.size.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/project.stack.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/tofu.checks.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/tofu.stack.tsv create mode 100644 zarf/tests/ardan-pr-review/fixture.sh create mode 100644 zarf/tests/ardan-pr-review/run.sh create mode 100644 zarf/tests/ardan-pr-review/suite.sh diff --git a/.agents/skills/ardan-pr-review/SKILL.md b/.agents/skills/ardan-pr-review/SKILL.md index 75af91d..790d00e 100644 --- a/.agents/skills/ardan-pr-review/SKILL.md +++ b/.agents/skills/ardan-pr-review/SKILL.md @@ -1,6 +1,6 @@ --- name: ardan-pr-review -description: "Guided, read-only PR/diff review (Service DiffGuard). Reviews only the change (plus unchanged code it breaks or the ticket requires) unless a full review is requested (changed files, changed packages, or whole project). Scripts preprocess the diff and run checks once; parallel agents review security (only in the All agents + Security set), acceptance criteria (BDD; finds required behavior missing even in unchanged files), correctness, error handling, comments, tests, contracts, duplication, and simplification across Go, Vue/JS/TS, Rego, proto, SQL, Helm/YAML, and shell. Asks for the ticket link, flags weak acceptance criteria, warns about oversized changes, never reviews generated files, and writes an issues-only report plus color-coded Mermaid diagrams to .reviews///. USE for any review request: \"review this PR\", \"review my changes/diff/branch\", \"PR review\", \"pre-commit review\", \"look over these changes\", \"is this PR too big to review\", or a single aspect (security, tests, errors, duplication, …)." +description: "Guided, read-only PR/diff review (Service DiffGuard) for code and infrastructure in any language. Reviews only the change (plus unchanged code it breaks or the ticket requires) unless a full review is requested (changed files, changed packages, or whole project). Scripts classify the change and run each ecosystem's own static analysis once (Go, Rust, Python, Vue/JS/TS, Terraform/OpenTofu, Helm, Kustomize/K8s, Ansible, Dockerfiles, CI pipelines such as GitHub Actions/GitLab/Jenkins, SQL, proto, Rego, shell); a discovery agent finds anything else and the repo's own quality gates. Parallel agents review security (only in the All agents + Security set), acceptance criteria (BDD; finds required behavior missing even in unchanged files), correctness, error handling, comments, tests, contracts, duplication, and simplification using per-language idioms. Asks for the ticket link, flags weak acceptance criteria, warns about oversized changes, skips generated files unless the user opts in, and writes an issues-only report plus color-coded Mermaid diagrams to .reviews///. USE for any review request: \"review this PR\", \"review my changes/diff/branch\", \"PR review\", \"pre-commit review\", \"look over these changes\", \"is this PR too big to review\", or a single aspect (security, tests, errors, duplication, …)." --- # Ardan PR Review @@ -10,8 +10,8 @@ You are the **orchestrator**: scripts do the mechanical work once, a context bui Rules for you: - **Start from a fresh session.** Every orchestrator turn re-reads the whole conversation; inside a long session that costs more than the agents. If the conversation already holds unrelated work, suggest `/clear` (or a new session) before starting. -- **Keep your context small.** Read only script output, agents' one-line replies (the aggregator's also carries `findings.md`, which you only save), `ac-review.md` (when ACs are weak/missing), `references/size-guard.md` (when there are size warnings), and the `F…` headings of `findings.md`. Never read the diff, brief, check logs, or agent reports. -- **Advisory only.** Nothing here edits, stages, or commits code. +- **Keep your context small.** Read only script output, agents' replies (one line; the discovery agent's and the aggregator's also carry blocks you only save), `ac-review.md` (when ACs are weak/missing), `references/size-guard.md` (when there are size warnings), and the `F…` headings of `findings.md`. Never read the diff, brief, check logs, or agent reports. +- **Advisory only.** Nothing here edits, stages, or commits code, and nothing deploys, applies, or plans infrastructure. - **The change, not the codebase.** By default only the change is reviewed (`contract.md` → Review scope). Problems that predate it are dropped, not reported. Only an explicit full-review request widens that. - **Runs are independent.** Never open a previous run's files. @@ -32,30 +32,34 @@ If a full review was requested without an extent, ask first; the preview needs t `intake.sh --dry-run [--depth ] [--id ]` writes nothing. On exit `3` the reviewed code isn't checked out: relay the printed command and stop. On `4` there is nothing to review: say so and stop. On `2` fix the arguments. -**Missing tools come first.** If `missing_tools > 0`, before any question show the `# missing tools` rows as a table (tool, what it checks, install command) and say: install them now and they are used by this run; the review goes ahead either way, and a missing tool's checks show as `skipped`. Rows marked `rebuild` are installed but built with an older Go than the active toolchain, so they fail on its standard library — the same install command rebuilds them. A `cgo` row means `go test -race` will fail until a C compiler is available. +**Discovery.** Then launch the discovery agent, on a cheaper model when your runtime picks models per subagent (it reads configs only): "Follow `/references/roles/discovery.md`. Skill directory: ``. Repo root: ``. Review depth: <…>. Preview: ." Wait for its reply (step 3 offers its repo gates; step 4 saves it). + +**Missing tools come first.** If `missing_tools > 0` or discovery lists *Missing analyzers*, before any question show them as one table (tool, what it checks, install command) and say: install them now and they are used by this run; the review goes ahead either way, and a missing tool's checks show as `skipped`. Rows marked `rebuild` are installed but built with an older Go than the active toolchain, so they fail on its standard library — the same install command rebuilds them. A `cgo` row means `go test -race` will fail until a C compiler is available. ## 3. Ask -One round (native question UI if available, ≤2 batches). Skip what the request already answered. +One round (native question UI if available, ≤3 batches). Skip what the request already answered. 1. **Ticket link** — always ask if none was given. "No ticket" is valid: spec review then works best-effort from the PR description and commits. 2. **Scope and id** — confirm the preview's scope, counts, `id` (ticket id, else branch name — same work, same directory), and depth (at full depth, `review_files` / `review_lines` too). 3. **Size** — if `size_warnings > 0`, follow `references/size-guard.md`. -4. **Agents** — *Routed* · *All agents* · *All agents + Security* · *Partial* (see step 6), naming the agents and checklists in each option, and saying when the small-diff merge applies. *Partial* asks a follow-up: *Risk only* or *Polish only*. -5. **Security** — only with *All agents + Security*; then ask: +4. **Repo gates** — if discovery proposed extra checks: list each (name, command, source) and ask which to run (*all* · *pick* · *none*; default *none*). They run the repo's own commands, so only approved ones run. +5. **Agents** — *Routed* · *All agents* · *All agents + Security* · *Partial* (see step 6), naming the agents and checklists in each option, and saying when the small-diff merge applies. *Partial* asks a follow-up: *Risk only* or *Polish only*. +6. **Security** — only with *All agents + Security*; then ask: - fan-out: *single* · *capped* (≤3 attacker sub-agents) · *full* (attacker + refuter per route); - report: *redacted* (default — `findings.md` says only "Security issues detected."; details only in `security-issues/`) · *named* (security issues listed in `findings.md` and `diagrams.md` like any finding). -6. **Secret-scan generated files** — if `skipped_files > 0`. Off by default. Give the file count and `noise_tokens_est` as the worst-case cost; a secret scanner in `tools.tsv` makes it much cheaper. -7. **Model tier** — if your runtime picks models per subagent: run the context builder and generated-file scan on a cheaper model? Every run. Reviewers and the aggregator always use the session model. -8. **Test setup** — if `go_packages > 0` and memory has no `ardan-project-interaction-guide` for this repo: test env vars (`K=V`), packages whose tests must never run, timeout, and how to run the project for security PoCs (*All agents + Security* only). Save it as project memory `ardan-project-interaction-guide` (no memory → ask every run). +7. **Generated files** — if `skipped.tsv` has generated or vendored rows (`noise` rows in the size table), say how many are skipped and ask: *Skip* (default) · *Secret-scan only* (cost ≈ `noise_tokens_est`; a secret scanner in `tools.tsv` makes it much cheaper) · *Review them too* — re-run the preview with `--include-generated`, show the new counts and token estimates, and confirm before going on. Symlinks are never reviewed. +8. **Model tier** — if your runtime picks models per subagent: run the context builder and generated-file scan on a cheaper model? Every run. Reviewers and the aggregator always use the session model. +9. **Test setup** — if `go_packages > 0` or `ecosystems=` has `rust`, `python`, or `terraform`, and memory has no `ardan-project-interaction-guide` for this repo: test env vars (`K=V`, including registry or provider credentials Terraform init needs), units whose tests must never run, timeout, and how to run the project for security PoCs (*All agents + Security* only). Save it as project memory `ardan-project-interaction-guide` (no memory → ask every run). -The answers are the confirmation; echo the final setup in ≤6 lines and continue. +The answers are the confirmation; echo the final setup in ≤6 lines and continue. If they change the scope or depth, re-run the preview and discovery first. ## 4. Prepare -1. `intake.sh --id [--depth ] [--exclude ]…` creates ``. `checks.sh` reads the depth from `scope.txt`. -2. Save the ticket raw to `/context/ticket.md` without reading it — tracker CLI first (`gh issue view --comments > …`, `glab issue view`, `jira issue view`), then a tracker MCP, then web fetch. -3. In parallel: `checks.sh [--env K=V]… [--exclude ]… [--timeout D]` in the background, and the context builder: "Follow `/references/roles/context-builder.md`. Run directory: ``." +1. `intake.sh --id [--depth ] [--exclude ]… [--include-generated]` creates ``. `checks.sh` reads the depth from `scope.txt`. +2. Save the discovery reply without re-typing it: `sh /scripts/extract-findings.sh --discovery ` (PowerShell: `-Discovery`). With no transcript file, or on exit `3`/`4`, write the text between `===== stack.md =====` and `===== extra-checks.tsv =====` to `/context/stack.md` and the rest up to `===== end =====` to `/context/extra-checks.tsv`, verbatim. +3. Save the ticket raw to `/context/ticket.md` without reading it — tracker CLI first (`gh issue view --comments > …`, `glab issue view`, `jira issue view`), then a tracker MCP, then web fetch. +4. In parallel: `checks.sh [--env K=V]… [--exclude ]… [--timeout D] [--extra |all]` in the background, and the context builder: "Follow `/references/roles/context-builder.md`. Run directory: ``." ## 5. Acceptance-criteria gate @@ -73,7 +77,7 @@ After checks finish, launch the chosen agents in one message. |---------------|------------------------------------------------------------------------------------------------------|----------------------------------------| | `security` | `security-sentinel` | *All agents + Security* only | | `spec` | `spec-cartographer` | ticket given (then mandatory) | -| `correctness` | `service-steward`, + `error-tripwire` if `failure=1`, + `boundary-keeper` if `contract=1` | `prod_files > 0` | +| `correctness` | `service-steward`, + `error-tripwire` if `failure=1`, + `boundary-keeper` if `contract=1` | `prod_files > 0` (infrastructure counts) | | `tests` | `harness-map` | `prod_files > 0` or `test_files > 0` | | `polish` | `clone-hunter` if `logic=1`, `doc-drift-check` if `comments=1`; `straight-line-pass` in All agents/Polish only | `logic=1` or `comments=1` | @@ -88,6 +92,7 @@ Prompt: ```text You are the reviewer in a PR review run. Read /references/contract.md, then /references/lenses/.md[, …]. +Idioms: /references/langs/.md[, …] (from scope.txt idioms=)[, /context/stack.md (when saved)]. Run directory: . Shard: . Review depth: . Write your report to /lenses/[-].md and reply with the one line the contract specifies. ``` @@ -119,9 +124,10 @@ From its reply and `rg '^#### F' /findings.md`: review depth, overall Gate, security-issues/poc// optional PoCs lenses/[-].md raw agent reports context/ scope.txt files.tsv skipped.tsv symbols.tsv refs.tsv signals.txt shards.tsv - size.tsv packages.tsv rules.txt tools.tsv missing.tsv diff.patch generated.patch - review-set.tsv (full depth) + size.tsv packages.tsv stack.tsv unmapped.txt rules.txt tools.tsv missing.tsv + diff.patch generated.patch review-set.tsv (full depth) + stack.md extra-checks.tsv (discovery) ticket.md pr.md commits.txt brief.md ac-review.md checks/ ``` -Generated and vendored files (lockfiles included) and symlinks are never reviewed: intake moves them to `skipped.tsv` and `generated.patch`. Never open a symlink in the reviewed repo: its target can be outside the repo. +Generated and vendored files (lockfiles included) are not reviewed unless the user opts in (step 3): intake moves them to `skipped.tsv` and `generated.patch`. Symlinks are never reviewed. Never open a symlink in the reviewed repo: its target can be outside the repo. diff --git a/.agents/skills/ardan-pr-review/references/contract.md b/.agents/skills/ardan-pr-review/references/contract.md index f19ed2d..e38d872 100644 --- a/.agents/skills/ardan-pr-review/references/contract.md +++ b/.agents/skills/ardan-pr-review/references/contract.md @@ -1,6 +1,6 @@ # Reviewer contract -Every review agent follows this file plus its lens checklists. ``, ``, and the review depth come from your prompt. +Every review agent follows this file, its lens checklists, and the idiom files in its prompt. ``, ``, the review depth, and the idiom files come from your prompt. ## Review scope @@ -18,26 +18,27 @@ The depth (`Review depth:` in your prompt, `depth=` in `scope.txt`) sets what yo | File | Holds | |-----------------------------------|---------------------------------------------------------------------------------------------------------| | `brief.md` | **Read first.** Summary, acceptance criteria (AC1…), per-file notes, changed symbols and callers, repo rules. | -| `scope.txt` | Scope, head commit, counts, routing signals. | -| `files.tsv` | `status added deleted lang kind layer path` per reviewed file; `kind` is prod, test, doc, or config. | -| `symbols.tsv` | `status kind name path line` for added, removed, and modified funcs, methods, and types. | -| `refs.tsv` | `symbol path:line` — word-match references to each changed symbol (capped, `+N more`). A starting list; confirm with `gopls`. | +| `scope.txt` | Scope, head commit, counts, ecosystems, idioms, `generated=` (skipped or included), routing signals. | +| `files.tsv` | `status added deleted lang kind layer path` per reviewed file; `kind` is prod (code and infrastructure), test, doc, config, or — only with `generated=included` — generated/vendor. `layer` is set for Go only. | +| `stack.tsv`, `stack.md` | `eco unit files marker` per build/deploy unit (Go module, crate, Terraform module, chart, …); `stack.md` (when present): the repo's ecosystems, its own quality gates, and idioms for ecosystems with no idiom file. | +| `symbols.tsv` | `status kind name path line` for added, removed, and modified funcs, types, Terraform blocks (addresses such as `aws_s3_bucket.this`, `var.x`), and Helm templates. | +| `refs.tsv` | `symbol path:line` — word-match references to each changed symbol (capped, `+N more`). A starting list; confirm with the language tool. | | `diff.patch` | The diff (8 context lines). Find `diff --git a/` and read only the sections you need. | | `review-set.tsv` | Full depth only: the `files.tsv` columns plus `lines` for every file in scope; `status` `-` = untouched by the diff. Empty at `diff` depth. | | `shards.tsv` | `shard path`, large diffs or review sets only. With a shard in your prompt, review only its files. | -| `checks/summary.tsv` | Build, vet, tests (`-race`), linters, analyzers, scanners, clone detectors — already run. Status `pass`, `fail`, `findings` (hits reported on output), or `skipped`. Open a log only when its row matters. Package-wide tools (staticcheck, errcheck, nilaway, checklocks, gosec) report on whole packages: only hits in scope (see Review scope) are findings. | +| `checks/summary.tsv` | `check status exit lines file lens` — builds, linters, tests, analyzers, scanners, clone detectors, and user-approved repo gates (`x-*`), already run. Status `pass`, `fail`, `findings` (hits reported on output), or `skipped`; `lens` owns its failures. Open a log only when its row matters. Unit-wide tools (Go packages, crates, modules, charts) report on whole units: only hits in scope (see Review scope) are findings. | | `ticket.md`, `pr.md`, `commits.txt` | Ticket, PR description, commit messages (whichever exist). | | `rules.txt` | `AGENTS.md`/`CLAUDE.md` files governing the changed paths; the brief digests them. | -| `skipped.tsv` | Generated and vendored files and symlinks. Never reviewed; never read `generated.patch`. Never open a symlink (`kind=symlink`): its target can be outside the repo. | +| `skipped.tsv` | Files not reviewed: symlinks, and generated/vendored files unless `generated=included`. Never read them or `generated.patch`. Never open a symlink (`kind=symlink`): its target can be outside the repo. | ## Rules 1. **Batch your reads.** Each turn re-reads your whole context, so issue independent reads (contract, lenses, brief, the files you need) together in one turn, not one per turn. -2. **Don't redo preprocessing.** No `git` to rediscover scope, no reading the whole diff to get oriented, no re-running what `checks/` covers. One narrow command to prove or refute a finding is fine (`go test -run TestX ./pkg`, an `rg`), within `checks/setup.txt`. +2. **Don't redo preprocessing.** No `git` to rediscover scope, no reading the whole diff to get oriented, no re-running what `checks/` covers. One narrow command to prove or refute a finding is fine (one test by name, an `rg`, a single render), within `checks/setup.txt`. Never one that deploys, applies, or mutates state. 3. **Read-only.** Write only your report (security may also write PoCs). If a write is refused, never retry it through Bash or any other tool: put the content in your reply and say the write was refused. 4. **Never open another run** under `.reviews/`. -5. **Tools before eyeballing.** For Go: `gopls` MCP (`go_symbol_references`, `go_search`, `go_file_context`), else `gopls`/`go doc`, then `rg`. -6. **Repo rules are authoritative.** On `.go` files apply `use-modern-go` and `ardan-service-architecture` where your lens says so. +5. **Tools before eyeballing.** The language tool your idiom file names (Go: `gopls` MCP), then `rg`/`ast-grep`. +6. **Repo rules are authoritative.** `rules.txt`, the repo's lint configs, and the skills your idiom files name (Go: `use-modern-go`, `ardan-service-architecture`). 7. **No "by design" without proof.** Dismissing a gap as intentional or handled elsewhere needs a citation (ticket, code, contract). Otherwise report it and state the assumption. 8. **Issues only.** No praise, no "looks good", no lists of what you checked or what is fine. No problems → `No findings.` Sole exception: the spec agent's acceptance-criteria table lists every criterion, Met included. @@ -55,19 +56,13 @@ A G2+ finding needs `file:line`, Proof, why it matters, Patch direction (smalles **Verify before writing.** Treat each finding as a hypothesis: re-open the cited line, confirm the Proof shows the claim, and look for what would make it wrong (stale line, out of scope for the depth, handled elsewhere, false pattern match). Drop, downgrade, or move to Open checks what doesn't hold. Set the Gate last. -## Languages +## Idioms -Review each file in its own idiom; Go is primary, not the only one. +Review each file in its own language's idiom; no language is primary. Infrastructure (Terraform/OpenTofu, Helm, K8s, Ansible, Dockerfiles, CI pipelines) is code: every lens applies to it. -| Area | Focus | -|---------------------|-----------------------------------------------------------------------------------------------| -| Go | Every lens applies; layered type rules and the Go skills apply. | -| Vue/JS/TS/SCSS | `props`/`emits` contracts, reactivity, async/`fetch` errors, a11y, leaked watchers/listeners. | -| Rego | Default deny, `input` shape assumptions, unintended allows. | -| proto | Field-number stability, compatibility, breaking changes. | -| SQL | Reversible, idempotent migrations; indexes, locking, destructive changes, seeds. | -| Helm/YAML/config | Resource limits, secrets, probes, templating. | -| Shell/PowerShell | `set -euo pipefail` / `$ErrorActionPreference`, quoting, exit codes. | +- Read the idiom files your prompt lists (`/references/langs/.md`) with your first batch, and `context/stack.md` when it exists. Apply the section your lens names (Correctness, Failure paths, Contracts, Tests, Security, Clarity) plus the file's **Tools** and **Checks** lines. +- A language with no idiom file: use `stack.md`'s inline idioms, else the language's established practice and its linters' rules; note "best-effort idioms: " under Open checks. +- `generated=included`: generated and vendored files are in scope. Patch direction fixes the generator or its input, never the generated file by hand. ## Report diff --git a/.agents/skills/ardan-pr-review/references/langs/ansible.md b/.agents/skills/ardan-pr-review/references/langs/ansible.md new file mode 100644 index 0000000..151f451 --- /dev/null +++ b/.agents/skills/ardan-pr-review/references/langs/ansible.md @@ -0,0 +1,21 @@ +# Ansible idioms + +**Tools:** `rg`; `ansible-doc ` if installed. **Checks:** `ansible-lint` (offline), `yamllint` (with the repo's config). Never run playbooks against hosts; `--syntax-check` or `--check` only with the project interaction guide. + +## Correctness +Idempotency: `command`/`shell` without `creates`/`removes`/`changed_when`; FQCN module names; `become` scope; handler names that `notify` no longer matches; variable precedence surprises (role defaults vs vars vs group_vars vs extra vars); `when` on bare variables or string booleans; `loop` vs `with_*`; Jinja2 templating and escaping in `.j2`; check-mode support; `serial` for rolling changes. + +## Failure paths +`ignore_errors` hiding failures; wrong `failed_when`/`changed_when`; `block`/`rescue`/`always`; `retries`/`until` without a sensible bound; `any_errors_fatal` and `max_fail_percentage` on rolling plays. + +## Contracts +Role `defaults/`, `vars/`, and `meta/argument_specs.yml` are the role's API: renamed variables break playbooks; inventory group names and tags used by other plays or CI. + +## Tests +Molecule scenarios, `--check --diff` runs, `ansible-test` for collections; otherwise name the manual check (a check-mode run against a staging inventory). + +## Security +`no_log: true` on tasks handling secrets; secrets in plain vars instead of Vault; file and directory modes/owners; `validate:` on files such as sudoers; variables interpolated into `shell`/`command`; `get_url` without `checksum`; `validate_certs: false`; broad `become_user`. + +## Clarity +Task names describe the outcome; README and `argument_specs` match the defaults. diff --git a/.agents/skills/ardan-pr-review/references/langs/ci.md b/.agents/skills/ardan-pr-review/references/langs/ci.md new file mode 100644 index 0000000..c60d372 --- /dev/null +++ b/.agents/skills/ardan-pr-review/references/langs/ci.md @@ -0,0 +1,24 @@ +# CI pipeline idioms + +Covers GitHub Actions, GitLab CI, Jenkins, CircleCI, Azure Pipelines, Bitbucket, Buildkite, Tekton, Drone, Travis, Cloud Build, Woodpecker — `stack.tsv` names the system in the marker column. Apply the general rules below in that system's syntax. + +**Tools:** `rg`. **Checks:** `actionlint` and `zizmor` for GitHub Actions; other systems have no offline linter — review by reading (repo-defined validators arrive as extra checks when approved). Never trigger a pipeline. + +## Correctness +Triggers run where intended (PRs, forks, tags, branches, path filters, `rules`/`only`/`except`, `when`); job ordering (`needs`, stages, `dependsOn`); matrix coverage; cache keys that miss inputs or poison builds; artifact names and paths between jobs; concurrency groups and cancel-in-progress; `if:` expressions (string vs boolean, `success()` vs `always()`); shell steps quoting and `set -euo pipefail`; reusable workflow / template inputs. + +## Failure paths +`continue-on-error`, `allow_failure`, `|| true`, `catchError` hiding failures; retries masking flakes; missing timeouts; post/cleanup steps that can't fail the build when they should. + +## Contracts +Job and workflow names used as required status checks (renaming breaks branch protection); reusable workflow / template / shared-library inputs, outputs, and secrets; artifact and image names consumed downstream. + +## Tests +No automated path: say so and name the manual check (a run on a test branch, `act`, `gitlab-ci-local`, Jenkins replay). + +## Security +- GitHub Actions: `pull_request_target` or `workflow_run` checking out PR code (pwn request); `${{ github.event.* }}` interpolated into `run:` (script injection); third-party actions not pinned to a full SHA; `permissions:` missing or broader than needed (defaults to write); secrets reachable from fork PRs; OIDC trust conditions too broad; self-hosted runners on public repos; cache poisoning across trust levels. +- GitLab/Jenkins/others: unprotected variables exposed to MR/fork pipelines; credentials echoed or interpolated into `sh`; Groovy `sh "${params.X}"` injection; script approvals and sandbox escapes; agents/runners shared across trust levels. + +## Clarity +Step names describe what they do; comments on non-obvious conditions. diff --git a/.agents/skills/ardan-pr-review/references/langs/docker.md b/.agents/skills/ardan-pr-review/references/langs/docker.md new file mode 100644 index 0000000..73cd988 --- /dev/null +++ b/.agents/skills/ardan-pr-review/references/langs/docker.md @@ -0,0 +1,21 @@ +# Dockerfile / Compose idioms + +**Tools:** `rg`. **Checks:** `hadolint` (Dockerfiles), `iac-scan`. No image is built or run by the checks. + +## Correctness +Base image pinned by tag and digest; multi-stage builds copying the right artifacts; layer order vs build cache; `.dockerignore` keeping secrets and build output out of the context; exec-form `ENTRYPOINT`/`CMD` (signals reach PID 1); `apt-get update` and `install` in one `RUN` with `--no-install-recommends` and cleanup; `ARG` vs `ENV` scope. Compose: `depends_on` with health conditions, restart policies, volume and port mappings, env files. + +## Failure paths +`SHELL ["/bin/sh", "-o", "pipefail", "-c"]` (or bash) when a `RUN` pipes; `HEALTHCHECK`; entrypoint scripts with `set -eu` and real exit codes. + +## Contracts +Exposed ports, environment variable names, volume paths, entrypoint arguments, and the image name/tag scheme are the image's API for deployments and Compose files. + +## Tests +Container structure tests or a CI build; otherwise name the manual check (build, run, hit the health endpoint). + +## Security +`USER` non-root; no secrets in `ARG`/`ENV`/`COPY` or layers (use build secrets); `ADD` from URLs; `curl | sh`; `latest` tags; minimal base; setuid binaries; Compose `privileged`, host network, or Docker socket mounts. + +## Clarity +Comments explain non-obvious build steps; labels (`org.opencontainers.image.*`) are accurate. diff --git a/.agents/skills/ardan-pr-review/references/langs/go.md b/.agents/skills/ardan-pr-review/references/langs/go.md new file mode 100644 index 0000000..90cbc03 --- /dev/null +++ b/.agents/skills/ardan-pr-review/references/langs/go.md @@ -0,0 +1,33 @@ +# Go idioms + +**Tools:** `gopls` MCP (`go_symbol_references`, `go_search`, `go_file_context`), else `gopls`/`go doc`, then `rg`. Skills: `use-modern-go` on every `.go` file; `ardan-service-architecture` on files with a `layer` (`app`, `business`, `storage`, `foundation`) — `references/types.md` for boundaries, `branching.md` for conditionals, `events.md` for domain events, `extensions.md` only when extension wiring is touched. + +**Checks** (`checks/summary.tsv`; package-wide tools report on whole packages, only in-scope hits count): +- `go-build`, `go-vet`, `staticcheck`, `golangci-lint` — each hit is a finding at the Gate its effect warrants. +- `checklocks` — a violation of an existing `+checklocks`/`+checkatomic` annotation is a data race (G4). "may require checklocks annotation for X" names the lock guarding those fields: verify in-scope code holds it on every access. +- `nilaway` — candidate nil dereferences; confirm the nil flow before reporting, else Open check. +- `go-fix` — modernizer diff on in-scope lines: G1 (G2 where `use-modern-go` makes it a rule). `gofmt`, `go-mod-tidy` — G2. +- `apidiff` — incompatible exported-API change: confirm every caller was updated; an unannounced break of a library package is G3 (Boundary Keeper). +- `errcheck` — a finding only when the lost error matters (failed write, commit, close of a written file); a dropped `Close` on a read-only body is G1 at most. +- `deadcode` — unreachable functions; an exported function of a library package may be public API. + +## Correctness +`context` stored or not propagated, cancellation ignored; nil derefs; slice/map bounds; data races; leaked goroutines, rows, bodies, files; wrong transaction scope; surprising zero values. Architecture: primitives at API/DB edges, strong `business/types/*` in Business, crossings via `toBus` / `fromBusResponse` / `toDB`, cross-cutting concerns via the `ExtBusiness`/`Extension` seam, shallow branching. + +## Failure paths +Errors checked, wrapped with `%w`, matched with `errors.Is/As`, not `_`-discarded when they matter; overwritten before use; zero value returned after failure; `defer` cleanup failures that matter; goroutines with no error path; row-scan and iterator errors. + +## Contracts +The type-boundary rule: API request/response structs use primitives; DB rows use primitive/native DB shapes; Business uses `business/types/*` strong types; every crossing goes through a named converter; nothing is assigned directly across App ↔ Business ↔ Storage. Storage is private to its domain: a `*bus` may import another domain's `*bus`, but nothing outside domain `` imports ``'s store or touches its tables; the App layer owns cross-domain transactions and every tx-bound `*bus` passes the tx to the `*bus` packages it depends on. +- Check: primitives leaking into Business, strong types in API/DB structs, missing converters, validation with no owner; imports of another domain's store; SQL in `db` reading or joining another domain's tables; a `*bus` beginning a transaction, or `NewWithTx` not rebinding a dependency; domain events defined outside the publisher's `bus/event.go`, handlers not binding to the passed tx, external side effects inside a handler. +- Types: exported vs unexported fields, constructors and their validation, zero-value meaning, pointer vs value receivers, mutators keeping invariants, small consumer-owned interfaces, needless interfaces/generics, aliases that erase meaning. +- G4: layer-boundary or storage-isolation violation, constructible invalid Business state, converter bypass. Extra fields: `Type: (layer)`, `Crossing: toBus | fromBusResponse | toDB | none`. + +## Tests +Table-driven tests with named subtests; `errors.Is/As` on error paths; negative, validation, boundary, cancellation, timeout, transaction and store-failure cases; HTTP request/response; converters; strong-type parsing. Tests ran with `-race`: a `WARNING: DATA RACE` whose stack touches in-scope code is G4 — quote the two conflicting frames. Prove a point with one `go test -run TestX ./pkg`. + +## Security +`gosec`, `govulncheck` rows; trace taint from request/env/file to sink with `gopls`; `database/sql` string building, `exec.Command` with a shell, `html/template` vs `text/template`, `InsecureSkipVerify`, `math/rand` for tokens. + +## Clarity +Exported identifiers documented where package style requires, starting with the identifier's name; comments on converters and strong types match the layer rules. Branch chains → default-first assignment or a naked switch (`branching.md`). Don't flatten layer converters or extension seams. diff --git a/.agents/skills/ardan-pr-review/references/langs/helm-k8s.md b/.agents/skills/ardan-pr-review/references/langs/helm-k8s.md new file mode 100644 index 0000000..3aea418 --- /dev/null +++ b/.agents/skills/ardan-pr-review/references/langs/helm-k8s.md @@ -0,0 +1,21 @@ +# Helm / Kubernetes / Kustomize idioms + +**Tools:** `rg`; `helm template -f ` to render one environment's values when a finding depends on it. **Checks:** `helm-lint`, `helm-render` (default values → `kubeconform`, `kube-linter`), `kustomize` (build → same), `k8s-manifests`, `iac-scan`. Only default values are rendered: read the other values files for environment-specific paths. A render failure from missing chart dependencies (`charts/` not built) is an Open check. + +## Correctness +Immutable fields changed (Deployment/StatefulSet `spec.selector`, Service `clusterIP`, PVC size/class) — the upgrade fails; selectors vs pod labels; Service `targetPort` vs container port names; probes (liveness identical to readiness, no startup probe for slow boots); requests/limits; rollout strategy, `maxUnavailable` vs replicas and PDBs; HPA fighting fixed `replicas`; ConfigMap/Secret changes not rolling pods (checksum annotation); image tag `latest` or unpinned; deprecated `apiVersion`s; Kustomize patches targeting names that no longer exist. Templates: `nindent`/whitespace, `quote` for YAML-ambiguous strings (`on`, `yes`, numbers), `toYaml` of nil, `default` masking a required value. + +## Failure paths +`required`/`fail` for mandatory values; graceful shutdown (`preStop`, `terminationGracePeriodSeconds`); Jobs: `backoffLimit`, `activeDeadlineSeconds`, `restartPolicy`; init containers that can hang; hooks with no delete policy. + +## Contracts +`values.yaml` is the chart's API: renamed or removed keys break every install (update `values.schema.json`, document the migration); `Chart.yaml` `version` bumped by SemVer, `appVersion`, pinned dependencies; Service names and ports, CRD schemas, and labels that other workloads, NetworkPolicies, or monitors select on. + +## Tests +helm-unittest suites, `templates/tests` hooks, chart-testing (`ct`); each Kustomize overlay rendered. Without them, name the manual check: render each environment's values and diff against the current release. + +## Security +`securityContext` (`runAsNonRoot`, `readOnlyRootFilesystem`, `allowPrivilegeEscalation: false`, drop `ALL` capabilities, seccomp); `privileged`, `hostNetwork`, `hostPID`, `hostPath`; RBAC least privilege (`*` verbs or resources, `cluster-admin` bindings, secret `get/list`); `automountServiceAccountToken`; secrets in values files or ConfigMaps; missing NetworkPolicies; untrusted image registries. + +## Clarity +`NOTES.txt`, chart README, and values comments match the templates. diff --git a/.agents/skills/ardan-pr-review/references/langs/proto.md b/.agents/skills/ardan-pr-review/references/langs/proto.md new file mode 100644 index 0000000..bc4ac7a --- /dev/null +++ b/.agents/skills/ardan-pr-review/references/langs/proto.md @@ -0,0 +1,9 @@ +# Protocol Buffers idioms + +**Tools:** `rg`. **Checks:** `buf-lint`, `buf-breaking` (against the base commit; with `buf.yaml`). A `buf-breaking` hit the ticket doesn't call for is G3, G4 for a wire break on a published proto (Boundary Keeper). + +## Correctness / Contracts +Field numbers stable; removed fields `reserved` (number and name); `optional` semantics; enum zero values; back/forward compatibility for every consumer; package and `go_package`/options changes that break codegen. + +## Tests +Contract tests or codegen checks in CI. diff --git a/.agents/skills/ardan-pr-review/references/langs/python.md b/.agents/skills/ardan-pr-review/references/langs/python.md new file mode 100644 index 0000000..60c95dc --- /dev/null +++ b/.agents/skills/ardan-pr-review/references/langs/python.md @@ -0,0 +1,21 @@ +# Python idioms + +**Tools:** an LSP (pyright/pylsp) if available, else `rg`. **Checks:** `ruff`, `ruff-format`, `mypy` (when the repo configures it), `pytest`, `bandit`, `pip-audit`. The repo's own tools (tox/nox/uv/poetry scripts) arrive as extra checks when approved. + +## Correctness +Mutable default arguments; late-binding closures in loops; `is` vs `==`; shadowed builtins; naive vs aware `datetime`; blocking calls inside `async def`; un-awaited coroutines; resource handling without `with`; import-time side effects; type hints that contradict behavior. + +## Failure paths +Bare `except:` / `except Exception: pass`; `raise` losing the cause (`raise ... from err`); broad `try` blocks; `assert` used for runtime validation (stripped with `-O`); return `None` on failure where callers expect a value. + +## Contracts +Public functions/classes (`__all__`, no leading `_`) and their signatures; dataclass/pydantic models and their defaults/validators; API schemas; renamed keyword arguments break callers. + +## Tests +pytest fixtures and parametrization; error paths with `pytest.raises(match=…)`; mocks patching the wrong target or hiding the real call; async tests; tests that depend on order or wall-clock time. + +## Security +`subprocess` with `shell=True`, `eval`/`exec`, `pickle`/`yaml.load` on untrusted data, SQL string formatting, `requests` with `verify=False`, path traversal, `random` for tokens, secrets in settings/fixtures; dependency advisories (`pip-audit`). + +## Clarity +Docstrings match signatures and raised exceptions; comprehensions only while readable. diff --git a/.agents/skills/ardan-pr-review/references/langs/rego.md b/.agents/skills/ardan-pr-review/references/langs/rego.md new file mode 100644 index 0000000..8db3fcb --- /dev/null +++ b/.agents/skills/ardan-pr-review/references/langs/rego.md @@ -0,0 +1,12 @@ +# Rego idioms + +**Tools:** `rg`; `opa`/`conftest` if installed (`opa test` on the changed package). + +## Correctness / Failure paths +Default deny; fails closed — a missing or malformed `input` field never grants access; `input` shape assumptions; unintended allows through `some`/iteration or partial rules; `else` chains. + +## Tests +`opa test` with allow and deny cases for each changed rule, including missing fields. + +## Security +Every rule change is an authorization change: who gains access, and is it intended per the ticket? diff --git a/.agents/skills/ardan-pr-review/references/langs/rust.md b/.agents/skills/ardan-pr-review/references/langs/rust.md new file mode 100644 index 0000000..431612b --- /dev/null +++ b/.agents/skills/ardan-pr-review/references/langs/rust.md @@ -0,0 +1,21 @@ +# Rust idioms + +**Tools:** `rust-analyzer` via LSP if available, else `rg`; `cargo tree -i ` for dependency questions. **Checks:** `cargo-fmt`, `cargo-clippy` (`-D warnings`; `cargo-check` when clippy is missing), `cargo-test`, `cargo-audit`, `cargo-deny`. All run with `--locked`: a failure saying the lock file needs updating is a real finding (`Cargo.lock` out of date). + +## Correctness +Blocking in async (`std::fs`, `thread::sleep`, sync `Mutex` held across `.await`); `RefCell` borrow panics; `as` casts that truncate or change sign; integer overflow (wraps in release); `clone()` added only to silence the borrow checker on hot paths; `Send`/`Sync` assumptions; feature-flag and `#[cfg]` combinations that no longer compile; iterator invalidation via indices. + +## Failure paths +`unwrap`/`expect`/`panic!`/indexing on library or request paths; `?` losing context (add `.context()` / typed errors); errors dropped with `let _ =` or `.ok()`; `#[must_use]` results ignored; `Drop` can't report errors — flush/sync files explicitly. + +## Contracts +Public API and SemVer (removed/renamed `pub` items, changed signatures, new required trait bounds; `cargo-semver-checks` if installed, else Open check); `#[non_exhaustive]` on public enums/structs; newtypes and validating constructors (`TryFrom`) for invariants; `serde` attributes (`rename`, `default`, `deny_unknown_fields`, `untagged`) keep the wire format; feature-gated API. + +## Tests +Unit tests in `#[cfg(test)]`, integration tests in `tests/`, doctests compile; error variants asserted with `matches!`; async tests (`#[tokio::test]`); property tests for parsers; `#[ignore]`/`#[should_panic]` hiding a real failure. + +## Security +`unsafe` without a `// SAFETY:` invariant or with an unsound one; FFI boundaries; untrusted deserialization without size limits; `Command` via a shell; path traversal; non-constant-time secret comparison; RUSTSEC advisories (`cargo-audit`); licenses and sources (`cargo-deny`). + +## Clarity +`///` docs on `pub` items with `# Errors`, `# Panics`, `# Safety` where they apply; `?` over nested `match`; iterator chains only while readable. diff --git a/.agents/skills/ardan-pr-review/references/langs/shell.md b/.agents/skills/ardan-pr-review/references/langs/shell.md new file mode 100644 index 0000000..5e86683 --- /dev/null +++ b/.agents/skills/ardan-pr-review/references/langs/shell.md @@ -0,0 +1,21 @@ +# Shell / PowerShell / Make idioms + +**Tools:** `rg`. **Checks:** `shellcheck` (sh/bash). + +## Correctness +Quoting and word splitting; globbing on untrusted names; `sh` vs bash-isms for the shebang in use; `cd` without a check; temp files via `mktemp` and cleaned with `trap`; portable flags (GNU vs BSD `sed -i`, `date`, `readlink`). PowerShell: `Set-StrictMode`, `-LiteralPath`, scalar-vs-array pipeline results, `$LASTEXITCODE` after native commands. Make: `.PHONY`, recipe lines run in separate shells, `-` prefix ignoring errors. + +## Failure paths +`set -euo pipefail` (or explicit checks), `$ErrorActionPreference = 'Stop'`; exit codes captured right after the command; pipeline failures; `|| true` hiding real errors. + +## Contracts +Script arguments, environment variables, exit codes, and output formats other scripts or CI parse. + +## Tests +bats/Pester tests if the repo has them; otherwise name the manual run. + +## Security +`eval`, unquoted variables in commands, `curl | sh`, secrets in arguments (visible in `ps`) or echoed to logs, world-writable temp paths. + +## Clarity +Usage header matches the real flags; comments explain non-obvious commands. diff --git a/.agents/skills/ardan-pr-review/references/langs/sql.md b/.agents/skills/ardan-pr-review/references/langs/sql.md new file mode 100644 index 0000000..4a07cfb --- /dev/null +++ b/.agents/skills/ardan-pr-review/references/langs/sql.md @@ -0,0 +1,18 @@ +# SQL idioms + +**Tools:** `rg`; `sqlfluff` if installed and configured. + +## Correctness +Migrations reversible and idempotent; locking on large tables (`ALTER` rewrites, `CREATE INDEX` without `CONCURRENTLY`); destructive changes (drop, rename, type narrowing) without a backfill or expand/contract plan; defaults on large tables; seeds; ordering against application deploys. + +## Failure paths +Migrations fail loudly and roll back rather than half-commit; transactions around multi-statement changes. + +## Contracts +Types, nullability, constraints, and keys encode the same invariants as the application's types; columns other services or views read. + +## Tests +Migration up/down on a copy of production-shaped data; query tests for changed queries. + +## Security +String-built queries, missing tenant/owner predicates, grants broader than needed. diff --git a/.agents/skills/ardan-pr-review/references/langs/terraform.md b/.agents/skills/ardan-pr-review/references/langs/terraform.md new file mode 100644 index 0000000..0527924 --- /dev/null +++ b/.agents/skills/ardan-pr-review/references/langs/terraform.md @@ -0,0 +1,23 @@ +# Terraform / OpenTofu idioms + +**Tools:** the binary named in `stack.tsv` (`terraform` or `tofu`); `rg` for references (`refs.tsv` names are addresses: `aws_s3_bucket.this`, `module.vpc`, `var.name`). **Checks:** `tf-fmt`, `tf-validate` (`init -backend=false`: providers and modules, no state, no plan), `tflint`, `iac-scan`. Never run `plan`, `apply`, `destroy`, `import`, `state`, or `test` against real infrastructure — no reviewer has the state or the credentials. An init failure (private registry, credentials) is an Open check, not a finding. + +## Correctness +- **Replacement risk:** a renamed or moved resource/module without a `moved` block, a `count` ↔ `for_each` switch, changed `for_each` keys, or a changed force-new argument destroys and recreates it. On stateful resources (databases, buckets, volumes, KMS keys, DNS zones) that is G4; name the address. +- `for_each`/`count` on values unknown at plan time; implicit vs explicit `depends_on`; data sources read at plan time; hardcoded account IDs, regions, AMIs; provider and `required_version` constraints vs `.terraform.lock.hcl`; per-environment `tfvars` drifting from the variable set. +- `lifecycle`: `prevent_destroy` missing on stateful resources, `ignore_changes` masking drift, `create_before_destroy` for zero-downtime replacement. + +## Failure paths +Variable `validation`, `precondition`/`postcondition`, `check` blocks; `try()`/`can()`/`coalesce()` hiding a misconfiguration; `nullable` and defaults that silently apply. + +## Contracts +A module's interface is its variables (type, default, `nullable`, `sensitive`, `validation`) and outputs: removed or renamed ones break callers (find them in `refs.tsv` and parent modules), and a changed type breaks `tfvars`. Backend and state configuration, workspace names, `terraform_remote_state` consumers, and provider configuration passed into modules are contracts too. + +## Tests +`terraform test` (`.tftest.hcl`) with mock providers, Terratest, policy tests (OPA/conftest, Sentinel). Without them, say so and name the manual check: a `plan` reviewed by someone with access, looking for the replacements above. + +## Security +IAM wildcards (`"*"` actions or resources), trust policies open to any principal, public exposure (`0.0.0.0/0` ingress, public buckets/ACLs/IPs), encryption at rest or in transit off, logging/versioning disabled, secrets in variable defaults, `tfvars`, or outputs not marked `sensitive` (state stores them in plain text), `local-exec` / `external` running commands, unpinned module sources (`?ref=` a branch). + +## Clarity +README variable/output tables (terraform-docs) match the code; descriptions on every variable and output. diff --git a/.agents/skills/ardan-pr-review/references/langs/web.md b/.agents/skills/ardan-pr-review/references/langs/web.md new file mode 100644 index 0000000..c6508d2 --- /dev/null +++ b/.agents/skills/ardan-pr-review/references/langs/web.md @@ -0,0 +1,21 @@ +# Vue / JS / TS / CSS idioms + +**Tools:** an LSP if available, else `rg`/`ast-grep`. **Checks:** `jscpd` (clones); the repo's own `lint`/`typecheck`/`test` scripts arrive as extra checks when approved. + +## Correctness +Reactivity (mutating props, lost reactivity on destructuring, `watch` without cleanup), leaked listeners/timers/subscriptions, unhandled async, race between overlapping requests, stale closures, `==` coercion, a11y (labels, roles, keyboard, focus). + +## Failure paths +Promises and `await` caught; non-2xx handled; `catch` doesn't swallow; the UI shows failure instead of empty or stale state. + +## Contracts +`props`/`emits`: required vs optional, types, defaults, payload shapes; props immutable; internal state not leaked; exported types and API client shapes match the backend contract. + +## Tests +Rendering, `props`/`emits`, interactions, async and error states; mocks that hide the real request shape. + +## Security +XSS (`v-html`, `innerHTML`, `dangerouslySetInnerHTML`), secrets or tokens in bundles/localStorage, open redirects, `postMessage` origin checks, new or unpinned dependencies. + +## Clarity +JSDoc and component comments match the real `props`, `emits`, and behavior. diff --git a/.agents/skills/ardan-pr-review/references/lenses/boundary-keeper.md b/.agents/skills/ardan-pr-review/references/lenses/boundary-keeper.md index d2dd268..84fde3d 100644 --- a/.agents/skills/ardan-pr-review/references/lenses/boundary-keeper.md +++ b/.agents/skills/ardan-pr-review/references/lenses/boundary-keeper.md @@ -1,35 +1,28 @@ # Boundary Keeper (types and contracts) -Review new or changed data shapes (at full depth, every data shape in scope): do they make valid states easy, invalid states hard, and crossings explicit? Start from the `type`, `New*`, and converter rows in `symbols.tsv` and the `layer` column in `files.tsv` (`review-set.tsv` at full depth). +Review new or changed data shapes and interfaces (at full depth, every one in scope): do they make valid states easy, invalid states hard, and crossings explicit? Start from `symbols.tsv`, the `layer` column of `files.tsv` (`review-set.tsv` at full depth), and the **Contracts** section of each idiom file in your prompt — it holds the language's primary duty (Go: the App ↔ Business ↔ Storage type boundary and storage isolation). -Primary duty: the type-boundary rule. API request/response structs use primitives; DB rows use primitive/native DB shapes; Business uses `business/types/*` strong types; every crossing goes through a named converter; nothing is assigned directly across App ↔ Business ↔ Storage. Storage is private to its domain: a `*bus` may import another domain's `*bus`, but nothing outside domain `` imports ``'s store or touches its tables; the App layer owns cross-domain transactions and every tx-bound `*bus` passes the tx to the `*bus` packages it depends on. When in-scope code is in `app/*`, `business/*`, or stores, apply `ardan-service-architecture` (`references/types.md`). +A contract is anything another component consumes: types, constructors, and converters; public APIs; wire formats (proto, JSON, serde); DB schemas; Terraform module variables and outputs, resource addresses, and state; Helm values keys and Service/CRD shapes; Ansible role variables; CI workflow inputs and required-check names; image ports and env vars. -Mechanical evidence in `checks/`: `apidiff` (incompatible exported Go API changes against the base commit) and `buf-breaking` / `buf-lint` (proto wire and style). An `apidiff` or `buf-breaking` hit the ticket doesn't call for is G3 (G4 for a wire break on a published proto); cite the log line. - -## Language lens - -- **Go** — exported vs unexported fields, constructors and their validation, zero-value meaning, pointer vs value receivers, mutators that keep invariants, small consumer-owned interfaces, needless interfaces/generics, aliases that erase meaning, converter naming and validation, errors for invalid construction. -- **Vue/JS/TS** — `props`/`emits`: required vs optional, types, defaults, payload shapes; props immutable; internal state not leaked. -- **proto** — field numbers stable, reserved fields, optional semantics, back/forward compatibility. -- **SQL** — types, nullability, constraints, and keys encoding the same invariants as the Go strong types. +Mechanical evidence: `fail`/`findings` rows with `lens=boundary-keeper` in `checks/` (Go `apidiff`, `buf-breaking`, `buf-lint`). A break the ticket doesn't call for is G3 (G4 for a wire break on a published API); cite the log line. ## Patrol route -1. **Per in-scope type** (working notes, not reported): layer, exported surface, valid states, how it's built, mutated, and crosses layers. -2. **Invariants.** Can invalid values be built directly? Validated once, at the boundary? Mutators guarded? Is the type simpler than the bug it prevents? -3. **Boundaries.** Primitives leaking into Business, strong types leaking into API/DB structs, missing `toBus` / `fromBusResponse` / `toDB`, direct assignment across layers, validation with no clear owner. -4. **Storage isolation.** Any import of another domain's store (from a `*bus`, store, App, or composite package); SQL in `db` that reads, writes, or joins another domain's tables; a `*bus` that begins a transaction, or a tx-bound `*bus` whose `NewWithTx` doesn't rebind a `*bus` it depends on; a cross-domain view owned by a domain, or by a composite that writes. For domain events (`ardan-service-architecture` `references/events.md`): event definitions outside the publisher's `bus/event.go`; a handler that doesn't bind to the passed tx via `NewWithTx`; an external side effect inside a handler. +1. **Per in-scope shape** (working notes, not reported): who produces and consumes it, its valid states, how it's built, mutated, and crosses boundaries. +2. **Invariants.** Can invalid values be built or configured directly? Validated once, at the boundary (constructors, variable `validation`, `values.schema.json`, argument specs)? Is the type simpler than the bug it prevents? +3. **Boundaries.** The idiom file's crossing rules; defaults that silently change meaning; renamed or removed fields, keys, variables, or outputs with consumers left behind (`refs.tsv`, then a repo-wide search). +4. **Compatibility.** Wire and schema evolution, SemVer of published packages and charts, state addresses (Terraform `moved` blocks), migration notes for breaking changes. ## Gates -- **G4** — layer-boundary or storage-isolation violation, constructible invalid Business state, converter bypass, or a design that can corrupt stored data. -- **G3** — exported fields or a missing constructor allow real invariant breakage. +- **G4** — layer-boundary or storage-isolation violation, constructible invalid state, converter bypass, a break that corrupts stored data or destroys state. +- **G3** — exported surface or missing validation allows real invariant breakage; an unannounced breaking change. - **G2** — invariants unclear, scattered, or weakly named. -- **G1** — naming or doc around type intent. +- **G1** — naming or doc around intent. -Prefer compile-time guarantees, weigh each suggestion's cost; a simpler type with fewer guarantees often wins. +Prefer compile-time and schema guarantees; weigh each suggestion's cost — a simpler shape with fewer guarantees often wins. ## Extra fields -- Type: `` (layer) -- Crossing: `toBus` / `fromBusResponse` / `toDB` / none +- Contract: `` (type / API / schema / module interface / values / workflow input …) +- Crossing: the converter, boundary, or consumer involved, or none diff --git a/.agents/skills/ardan-pr-review/references/lenses/clone-hunter.md b/.agents/skills/ardan-pr-review/references/lenses/clone-hunter.md index 32b6bdc..8321dff 100644 --- a/.agents/skills/ardan-pr-review/references/lenses/clone-hunter.md +++ b/.agents/skills/ardan-pr-review/references/lenses/clone-hunter.md @@ -10,8 +10,8 @@ Duplicated logic is a defect: copies drift, and a fix to one silently skips the ## Detection, in order -1. **Detector output** — `dupl*` and `jscpd` rows in `checks/`; keep clones with at least one in-scope site. -2. **gopls / LSP** — `go_search`, `go_symbol_references` for repeated call shapes and twin helpers. +1. **Detector output** — `lens=clone-hunter` rows in `checks/` (`dupl*`, `jscpd`); keep clones with at least one in-scope site. +2. **Language tool** — the one your idiom files name (Go: `gopls` `go_search`, `go_symbol_references`) for repeated call shapes and twin helpers. 3. **Targeted search** — `rg`/`ast-grep` for the in-scope code's distinctive literals and call patterns, repo-wide. 4. **Inspection** — only when nothing above works for the language; say it's best-effort. @@ -19,7 +19,7 @@ Name the method on each finding. ## Scope -At `diff` depth, at least one site must be changed code. Report a clone that predates the change only when the diff adds a third copy or edits one of a pair. At full depth, report every clone with a site in the review set. Ignore boilerplate the framework forces. +At `diff` depth, at least one site must be changed code. Report a clone that predates the change only when the diff adds a third copy or edits one of a pair. At full depth, report every clone with a site in the review set. Ignore boilerplate the framework forces. In infrastructure, copy-pasted resources, values blocks, overlays, tasks, or pipeline jobs that should be a module, a loop (`for_each`, `range`), a shared role, or a reusable workflow/template count as clones. ## Gates diff --git a/.agents/skills/ardan-pr-review/references/lenses/doc-drift-check.md b/.agents/skills/ardan-pr-review/references/lenses/doc-drift-check.md index ed26d4e..f5e2ee5 100644 --- a/.agents/skills/ardan-pr-review/references/lenses/doc-drift-check.md +++ b/.agents/skills/ardan-pr-review/references/lenses/doc-drift-check.md @@ -6,10 +6,8 @@ Flag comments that contradict the code, promise behavior it lacks, omit an impor ## Language lens -- **Go** — exported identifiers documented where package style requires, starting with the identifier's name; comments on converters and strong types match the layer rules. -- **Vue/JS/TS** — JSDoc and component comments match the real `props`, `emits`, and behavior. -- **SQL / Rego / proto / Helm** — headers and inline comments describe what the file actually does. -- **All** — comments describe behavior, errors, side effects, ordering, invariants; examples still compile against the current API; TODOs are still true and actionable. +- The **Clarity** section of each idiom file in your prompt (doc conventions such as Go's exported-identifier comments, Rust's `# Errors`/`# Safety`, terraform-docs tables, chart READMEs). +- **All** — comments describe behavior, errors, side effects, ordering, invariants; examples still compile or render against the current API; TODOs are still true and actionable. ## Patrol route diff --git a/.agents/skills/ardan-pr-review/references/lenses/error-tripwire.md b/.agents/skills/ardan-pr-review/references/lenses/error-tripwire.md index 1bbca0d..6ace908 100644 --- a/.agents/skills/ardan-pr-review/references/lenses/error-tripwire.md +++ b/.agents/skills/ardan-pr-review/references/lenses/error-tripwire.md @@ -1,26 +1,18 @@ # Error Tripwire (failure-path visibility) -Follow every in-scope failure path (every changed one, at `diff` depth) to its end and ask: when this fails, who finds out, and is that enough to debug it? Catch zero values returned after errors, log-and-continue, and success reported after partial failure. Vet, linter, and `errcheck` output in `checks/` flags unchecked errors mechanically — start there, then go further. An `errcheck` hit on an in-scope line is a finding only when the lost error matters (a failed write, commit, or close of a written file); a dropped `Close` on a read-only body is G1 at most. +Follow every in-scope failure path (every changed one, at `diff` depth) to its end and ask: when this fails, who finds out, and is that enough to debug it? Catch defaults returned after errors, log-and-continue, success reported after partial failure, and configuration that fails open. Use the **Failure paths** section of each idiom file in your prompt; `lens=error-tripwire` rows in `checks/` flag unchecked errors mechanically — start there, then go further. -## Idioms +## Patrol route -- **Go** — errors checked, wrapped with `%w`, matched with `errors.Is/As`, not `_`-discarded when they matter; `defer` cleanup failures handled; goroutines can report failure. -- **Vue/JS/TS** — promises and `await` caught; non-2xx handled; `catch` doesn't swallow; the UI shows failure instead of empty or stale state. -- **SQL** — migrations fail loudly and reversibly; transactions roll back rather than half-commit. -- **Rego** — fails closed; a missing `input` field never grants access. -- **Shell/PowerShell** — `set -euo pipefail` / `$ErrorActionPreference = 'Stop'`; exit codes and pipe failures checked. - -## Patrol route (Go; same steps elsewhere) - -1. **Every `err`.** Checked? Discarded? Overwritten before use? Zero/default returned after failure? Success reported despite partial failure? -2. **Propagation.** Cause kept with `%w` where callers need it; sentinels matched with `errors.Is/As`; storage or transport details not leaking upward by accident. -3. **Continuation.** Log-and-continue where it should stop; defaults masking broken config, DB, network, or validation; retries that exhaust silently; goroutines with no error path; `defer` cleanup whose failure matters. -4. **Boundaries.** Transactions, row scans and iteration errors, HTTP handlers and middleware, App → Business validation, Business → Storage conversion, context cancellation and timeouts. +1. **Every failure.** Checked? Discarded? Overwritten before use? A default returned after failure? Success reported despite partial failure? +2. **Propagation.** Cause kept where callers need it; typed errors matched, not string-compared; storage or transport details not leaking upward by accident. +3. **Continuation.** Log-and-continue where it should stop; defaults masking broken config, DB, network, or validation; retries that exhaust silently; background work with no error path; cleanup whose failure matters; `ignore_errors`, `continue-on-error`, `|| true`, `try()`/`can()` hiding real failures. +4. **Boundaries.** Transactions, iteration and scan errors, request handlers and middleware, layer crossings, cancellation and timeouts, deploy-time validation (preconditions, `required`, probes, health checks). ## Gates -- **G4** — a swallowed error yields false success, corrupt data, skipped validation, or hidden failed persistence. -- **G3** — cause lost, hidden fallback, unobserved goroutine failure, silent retry exhaustion. +- **G4** — a swallowed error yields false success, corrupt data, skipped validation, hidden failed persistence, or a deploy that reports success while broken. +- **G3** — cause lost, hidden fallback, unobserved background failure, silent retry exhaustion. - **G2** — surfaced, but without useful context. - **G1** — clearer wording or wrapping, same behavior. diff --git a/.agents/skills/ardan-pr-review/references/lenses/harness-map.md b/.agents/skills/ardan-pr-review/references/lenses/harness-map.md index c18a363..57b9539 100644 --- a/.agents/skills/ardan-pr-review/references/lenses/harness-map.md +++ b/.agents/skills/ardan-pr-review/references/lenses/harness-map.md @@ -1,26 +1,21 @@ # Harness Map (behavior-to-test mapping) -Would the tests fail for the bugs this diff is most likely to introduce (at full depth, the bugs most likely in the code in scope)? Judge by regression value, not coverage percentage; skip trivial accessors. +Would the tests fail for the bugs this diff is most likely to introduce (at full depth, the bugs most likely in the code in scope)? Judge by regression value, not coverage percentage; skip trivial accessors. Use the **Tests** section of each idiom file in your prompt. -**Tests already ran** — see `checks/summary.tsv`, the `go-test*` logs, and `checks/setup.txt`. A failing or timed-out test is a finding when the change can cause it (at full depth, any test in a reviewed package), or an Open check when it's environmental (say why). At `diff` depth, a failure unrelated to the change is out of scope: drop it. Tests run with `-race`: a `WARNING: DATA RACE` whose stack touches in-scope code is G4 — quote the two conflicting stack frames. Don't re-run the suite; one `go test -run` to prove a point is fine. +**Tests already ran** — see the `lens=harness-map` rows of `checks/summary.tsv` (`go-test*`, `cargo-test*`, `pytest*`, approved `x-*` test gates) and `checks/setup.txt`. A failing or timed-out test is a finding when the change can cause it (at full depth, any test in a reviewed unit), or an Open check when it's environmental (say why). At `diff` depth, a failure unrelated to the change is out of scope: drop it. Don't re-run the suite; one targeted test run to prove a point is fine. -## Language lens - -- **Go** — table-driven tests with named subtests; `errors.Is/As` on error paths; negative and validation cases; boundaries; cancellation and timeouts; transaction and store failures; HTTP request/response; converters across layers; strong-type parsing; race-sensitive code. -- **Vue/JS/TS** — rendering, `props`/`emits`, interactions, async and error states. -- **SQL / Rego / proto** — migration up/down, policy allow/deny cases, contract and codegen checks. -- No automated path to test a change? Say so and name the manual check. +For infrastructure and pipelines, tests include policy tests, `terraform test`, chart unit tests, Molecule, and render/validate checks. When the change has no automated path to test it, say so and name the manual check. ## Patrol route -1. **Behaviors** (working notes, not reported): for each changed production symbol (at full depth, each in-scope one), its success, failure, boundary, state, and layer-crossing behavior. -2. **Match to tests** via `gopls` references or `rg` in test files; integration tests count when they truly exercise the behavior. +1. **Behaviors** (working notes, not reported): for each changed production symbol, resource, template, or task (at full depth, each in-scope one), its success, failure, boundary, state, and boundary-crossing behavior. +2. **Match to tests** via the language tool or `rg` in test files; integration tests count when they truly exercise the behavior. 3. **Judge the tests.** Flag ones that assert implementation details, wouldn't catch a realistic regression, are too broad, or weaken assertions, skip, or mock around the real problem. ## Gates -- **G4** — critical behavior untested (auth, data integrity, money, persistence, validation, security), or an in-scope test fails for a code reason. -- **G3** — an important error path, boundary, converter, or business rule untested. +- **G4** — critical behavior untested (auth, data integrity, money, persistence, validation, security, destructive infrastructure change), or an in-scope test fails for a code reason. +- **G3** — an important error path, boundary, converter, business rule, or policy untested. - **G2** — tests exist but are brittle, incomplete, or poorly named. - **G1** — a small case, name, or assertion. diff --git a/.agents/skills/ardan-pr-review/references/lenses/security-sentinel.md b/.agents/skills/ardan-pr-review/references/lenses/security-sentinel.md index df9be7c..4a2398c 100644 --- a/.agents/skills/ardan-pr-review/references/lenses/security-sentinel.md +++ b/.agents/skills/ardan-pr-review/references/lenses/security-sentinel.md @@ -10,7 +10,7 @@ Think in chains (leak → replay → escalate), including unlikely ones. A suspi ## Tools -Scanner results are in `checks/summary.tsv`: secrets (`gitleaks`/`trufflehog`, or a `secrets` row saying none is installed), `gosec`, `govulncheck`, `semgrep` (only with a repo config), linters. Start there. You may run another installed tool from `tools.tsv` on in-scope files only (e.g. `osv-scanner` on a changed lockfile, `conftest` on Rego, `hadolint` on a Dockerfile). Trace taint with `gopls`/LSP; `rg` sweeps supplement, never replace. A missing tool goes to Open checks — the concern still gets a manual pass. +Scanner results are the `lens=security-sentinel` rows of `checks/summary.tsv`: secrets (`gitleaks`/`trufflehog`, or a `secrets` row saying none is installed), language scanners and advisories (`gosec`, `govulncheck`, `cargo-audit`, `cargo-deny`, `bandit`, `pip-audit`), IaC misconfiguration (`iac-scan`: trivy or checkov over Terraform, charts, manifests, Dockerfiles), `zizmor` (GitHub Actions), `semgrep` (only with a repo config). Start there, then apply the **Security** section of each idiom file in your prompt. You may run another installed tool from `tools.tsv` on in-scope files only (e.g. `osv-scanner` on a changed lockfile, `conftest` on Rego). Trace taint with the language tool; `rg` sweeps supplement, never replace. A missing tool goes to Open checks — the concern still gets a manual pass. ## Patrol route @@ -19,7 +19,7 @@ Scanner results are in `checks/summary.tsv`: secrets (`gitleaks`/`trufflehog`, o 3. **Injection and input.** SQL/NoSQL/command/template injection, unparameterized queries, shell interpolation, path traversal, SSRF, unsafe deserialization, XXE, XSS. Trace source (request, header, file, env, DB) to sink. 4. **AuthN/AuthZ and tenancy.** Handlers without auth, IDOR, queries without tenant/owner scope, Rego failing open, checks after side effects, skipped or weak JWT/signature verification. 5. **Crypto and transport.** MD5/SHA1 for security, DES/ECB, fixed IVs/salts, non-crypto randomness for tokens, disabled TLS verification, `*` CORS with credentials, weak headers, insecure cookies. -6. **Config and supply chain.** Debug modes, default creds, broad permissions/IAM/RBAC, exposed admin ports, new or unpinned dependencies (typosquats, known CVEs), CI secrets echoed, `curl | sh`. +6. **Config, infrastructure, and supply chain.** Debug modes, default creds, broad IAM/RBAC (`*` actions, resources, or verbs), public network exposure and storage, encryption or logging turned off, privileged containers, secrets in state, values, or variables, exposed admin ports, new or unpinned dependencies, images, actions, or modules (typosquats, known CVEs), CI injection and secrets reachable from forks, `curl | sh`. ## Fan-out (from your prompt) diff --git a/.agents/skills/ardan-pr-review/references/lenses/service-steward.md b/.agents/skills/ardan-pr-review/references/lenses/service-steward.md index 36952fa..945f1f4 100644 --- a/.agents/skills/ardan-pr-review/references/lenses/service-steward.md +++ b/.agents/skills/ardan-pr-review/references/lenses/service-steward.md @@ -2,34 +2,25 @@ Report what affects whether the change should merge, not preferences. Test: would a careful reviewer block or request changes? If not, it's G1 at most. -Priority: correctness, explicit repo rules, Go idioms, App ↔ Business ↔ Storage boundaries, operational safety, local maintainability. +Priority: correctness, explicit repo rules, the language's idioms, architecture and layer boundaries, operational safety, local maintainability. Use the **Correctness** section and the **Checks** line of each idiom file in your prompt. -A failing `go-build`, `go-vet`, or linter row in `checks/` on in-scope code is a finding — cite the log line. For `.go` files apply `use-modern-go` and `ardan-service-architecture`. - -Go analyzer rows in `checks/` (in-scope code only): - -- `staticcheck`, `golangci-lint` — each hit is a finding at the Gate its effect warrants. -- `checklocks` — a violation of an existing `+checklocks`/`+checkatomic` annotation is a data race (G4). "may require checklocks annotation for X" is not a finding by itself: it names the lock that guards those fields, so verify the in-scope code holds it on every access. -- `nilaway` — candidate nil dereferences. Confirm the nil flow can happen before reporting; unconfirmed ones go to Open checks. -- `go-fix` — a modernizer diff on in-scope lines: G1 (G2 where `use-modern-go` makes it a rule). -- `gofmt` (file not formatted) and `go-mod-tidy` (`go.mod`/`go.sum` not tidy) — G2. -- `apidiff` — incompatible exported-API changes: confirm every caller in the repo was updated (step 1); an unannounced break of a library package is G3. +A `fail` or `findings` row with `lens=service-steward` in `checks/` on in-scope code is a finding — cite the log line. That covers builds, compilers, type checkers, linters, formatters (G2 unless the repo makes formatting a gate), renderers and validators (`helm-render`, `kustomize`, `tf-validate`, `kubeconform`), and approved repo gates (`x-*`). A failure caused by the environment (missing credentials, registry, chart dependencies, toolchain) is an Open check: say what is needed. ## Patrol route -1. **Surface and contract.** Broken signatures, changed exported APIs not updated at call sites (callers are in `brief.md`), surprising zero values, `context` misuse (stored, not propagated, cancellation ignored). -2. **Runtime correctness.** Nil derefs, off-by-one and slice/map bounds, data races, leaked goroutines or resources (rows, bodies, files), wrong transaction scope, authorization regressions. -3. **Architectural fit.** Primitives at API/DB edges; strong `business/types/*` in Business; crossings via `toBus` / `fromBusResponse` / `toDB`; cross-cutting concerns via the `ExtBusiness`/`Extension` seam; shallow branching. -4. **Maintainability.** Only material issues: drift-prone duplication, unclear ownership, avoidable coupling, hidden side effects, names that hide domain meaning. -5. **Other languages**, same intent in their idiom: Vue/JS/TS contracts, reactivity, leaks, unhandled async, a11y; Rego default deny; proto compatibility; SQL migration safety; Helm limits, probes, secrets; shell `set -euo pipefail`, quoting, exit codes. +1. **Surface and contract.** Broken signatures or interfaces, changed public APIs, module inputs, values keys, or resource addresses not updated at their users (callers are in `brief.md` and `refs.tsv`). +2. **Runtime correctness.** Per the idiom files: nil/None/unwrap, bounds, concurrency, leaked resources, wrong transaction scope, authorization regressions; for infrastructure, destroy/replace of stateful resources, broken rollouts, immutable-field changes, drift between environments. +3. **Architectural fit.** The architecture the repo and its idiom files define (Go: Ardan layering and the `ExtBusiness`/`Extension` seam; others: the module, chart, or role structure already in the repo). +4. **Operational safety.** Config that only works in one environment, missing limits/timeouts/probes, changes that need a coordinated rollout with no note. +5. **Maintainability.** Only material issues: drift-prone duplication, unclear ownership, avoidable coupling, hidden side effects, names that hide domain meaning. ## Gates -- **G4** — compile break, data race, security/authz hole, data loss, explicit AGENTS.md or boundary violation. -- **G3** — likely production bug: nil/error mishandling, leaked resource, wrong transaction, non-idempotent behavior. +- **G4** — compile/render/validate break, data race, security/authz hole, data loss or destroyed stateful resource, explicit AGENTS.md or boundary violation. +- **G3** — likely production bug: nil/error mishandling, leaked resource, wrong transaction, non-idempotent behavior, failed rollout. - **G2** — real convention or maintainability problem in scope. - **G1** — small naming or idiom cleanup. ## Extra fields -- Rule: the AGENTS.md rule, skill, or Go convention involved +- Rule: the AGENTS.md rule, skill, linter rule, or language convention involved diff --git a/.agents/skills/ardan-pr-review/references/lenses/spec-cartographer.md b/.agents/skills/ardan-pr-review/references/lenses/spec-cartographer.md index b5ad6b9..9347127 100644 --- a/.agents/skills/ardan-pr-review/references/lenses/spec-cartographer.md +++ b/.agents/skills/ardan-pr-review/references/lenses/spec-cartographer.md @@ -15,7 +15,7 @@ For each criterion, find **every** site that must implement it, then prove each ## Patrol route 1. **Decompose** each criterion into Given/When/Then scenarios, including failure and edge states. "All / every / always / at access time" means several sites. -2. **Enumerate all sites from the codebase, not the diff** — every entry point, reader, writer, handler, and layer of that kind. Start from `refs.tsv` and the callers in `brief.md`, then `gopls` (`go_symbol_references`, `go_search`) and `rg` for parallel call shapes. Keep the list as a checklist. +2. **Enumerate all sites from the codebase, not the diff** — every entry point, reader, writer, handler, and layer of that kind; for infrastructure, every environment, overlay, values file, module instance, and pipeline the criterion covers. Start from `refs.tsv` and the callers in `brief.md`, then the language tool (Go: `gopls` `go_symbol_references`, `go_search`) and `rg` for parallel shapes. Keep the list as a checklist. 3. **Check each site, changed or not.** An unchanged site the criterion covers is the most valuable finding; cite the line that should have changed. 4. **Follow data to the end.** For "stored / isolated / scoped" criteria, trace every downstream copy and derived artifact. 5. **Safe ≠ complete.** Name any compensating control (DB filter, downstream check) and say whether it meets the criterion's intent. diff --git a/.agents/skills/ardan-pr-review/references/lenses/straight-line-pass.md b/.agents/skills/ardan-pr-review/references/lenses/straight-line-pass.md index 114a536..0d06a9a 100644 --- a/.agents/skills/ardan-pr-review/references/lenses/straight-line-pass.md +++ b/.agents/skills/ardan-pr-review/references/lenses/straight-line-pass.md @@ -2,7 +2,7 @@ Propose the smallest changes that make the in-scope code (the diff, at `diff` depth) easier to read, test, and maintain, with behavior exactly unchanged. Optimize for obvious control flow and clear names, not fewer lines; explicit beats clever. -For `.go` files apply `use-modern-go` and `ardan-service-architecture` (`references/extensions.md` only when extension wiring is touched). Elsewhere follow the language's idioms and the patterns already in that part of the repo. +Apply the **Clarity** section and the skills of each idiom file in your prompt (Go: `use-modern-go`, `ardan-service-architecture`) and the patterns already in that part of the repo. ## Leave alone @@ -10,9 +10,9 @@ Don't propose behavior or public-contract changes, merging unrelated concerns, d ## Patrol route -1. **Control flow.** Nested conditionals → guard clauses; branch chains → default-first assignment or a naked switch (`ardan-service-architecture` `references/branching.md`); tangled success/error paths. +1. **Control flow.** Nested conditionals → guard clauses; branch chains → the idiom file's preferred form (Go: default-first assignment or a naked switch); tangled success/error paths; deeply nested templates or conditional expressions in infrastructure code. 2. **Noise.** Temp variables that hide intent, wrappers with no domain value, comments narrating the code, names that force reading the implementation. (Duplication belongs to Clone Hunter.) -3. **Dead code.** `deadcode` in `checks/` lists functions in the reviewed files that neither a `main` nor a test reaches. Report ones the diff added or left unreachable (G2), and at full depth ones already dead; an exported function of a library package may be public API — say so instead of calling it dead. +3. **Dead code.** `lens=straight-line-pass` rows in `checks/` (Go `deadcode`) and dead-code lints in the linter logs. Report what the diff added or left unreachable (G2) — including unused variables, outputs, values keys, templates, or tasks — and at full depth what is already dead; a public function of a library may be API — say so instead of calling it dead. 4. **Preservation.** For each suggestion, say why observable behavior is unchanged. ## Gates diff --git a/.agents/skills/ardan-pr-review/references/roles/aggregator.md b/.agents/skills/ardan-pr-review/references/roles/aggregator.md index d321552..2c15732 100644 --- a/.agents/skills/ardan-pr-review/references/roles/aggregator.md +++ b/.agents/skills/ardan-pr-review/references/roles/aggregator.md @@ -9,7 +9,7 @@ Turn the agents' reports into the one report the user reads, and act as the seco 1. **Merge.** Deduplicate findings by failure mode and location; keep the higher Gate and list every lens that raised it. 2. **Scope.** At `diff` depth, drop every finding, Open check, and failed check that sits outside the review scope in `contract.md`: a problem that predates the change and that the change neither causes nor worsens. Drop it silently; don't move it to Open checks. At full depth, keep the `Origin: pre-existing` field on findings that carry it. 3. **Verify.** For each G2+ finding, re-open the cited line and try to refute it: stale line, handled elsewhere (find where), intended per ticket or contract, false pattern match. Spot-check G1. Drop, downgrade, or move to Open checks what doesn't hold. Upgrade only on evidence. -4. **Failed checks.** A `fail` or `findings` row in `checks/summary.tsv` on in-scope code that no agent reported becomes a finding: build, vet, staticcheck/golangci-lint, checklocks, nilaway, go-fix, gofmt, go-mod-tidy → Service Steward; errcheck → Error Tripwire; tests (including data races) → Harness Map; apidiff, buf-lint, buf-breaking → Boundary Keeper; deadcode → Straight-Line Pass. +4. **Failed checks.** A `fail` or `findings` row in `checks/summary.tsv` on in-scope code that no agent reported becomes a finding under the lens in its `lens` column (security rows only when security ran). Environmental failures (missing credentials, registry, chart dependencies, toolchain) go to Open checks. 5. **Reopened risk.** A polish G3/G4 that contradicts a risk agent's conclusion goes first in the Fix queue, naming the agent to re-run. 6. **Security issues** are the surviving Security Sentinel and generated-code findings. Your prompt gives the mode: *off* (none exist), *redacted*, or *named*. 7. **Number** findings `F1…Fn`, G4 → G1, then by path — in *redacted* mode, without the security issues. diff --git a/.agents/skills/ardan-pr-review/references/roles/context-builder.md b/.agents/skills/ardan-pr-review/references/roles/context-builder.md index 21c8740..2caaa4d 100644 --- a/.agents/skills/ardan-pr-review/references/roles/context-builder.md +++ b/.agents/skills/ardan-pr-review/references/roles/context-builder.md @@ -2,7 +2,7 @@ Write the brief every reviewer reads first, so none of them re-discovers the change. **Describe; don't review** — no findings, gates, or quality opinions. Your one judgment is on the acceptance criteria. -**Inputs** (`/context/`): `scope.txt`, `files.tsv`, `symbols.tsv`, `refs.tsv`, `diff.patch`, `rules.txt`, `review-set.tsv` (non-empty at full depth only), and whichever of `ticket.md`, `pr.md`, `commits.txt` exist. Don't run `git`. Ignore `skipped.tsv` and never read `generated.patch`. Write only the two files below. +**Inputs** (`/context/`): `scope.txt`, `files.tsv`, `stack.tsv`, `symbols.tsv`, `refs.tsv`, `diff.patch`, `rules.txt`, `review-set.tsv` (non-empty at full depth only), and whichever of `stack.md`, `ticket.md`, `pr.md`, `commits.txt` exist. Don't run `git`. Ignore `skipped.tsv` and never read `generated.patch`. Write only the two files below. ## `brief.md` (≤ 400 lines) @@ -18,7 +18,7 @@ In force: - AC1: ## Files - + - `path` (status, lang, layer) — ## Changed symbols and callers @@ -33,11 +33,11 @@ In force: - (`source path`) ## Risk pointers - + - `path:line` — ``` -Start from `refs.tsv`; confirm or prune with the `gopls` MCP (`go_symbol_references`) for Go when a name is ambiguous. For removed symbols, list the references that remain — a dangling caller is worth recording. Read your inputs in one batch. +Start from `refs.tsv`; confirm or prune with the language tool (Go: `gopls` MCP `go_symbol_references`) when a name is ambiguous. For removed symbols, list the references that remain — a dangling caller is worth recording. Read your inputs in one batch. ## `ac-review.md` diff --git a/.agents/skills/ardan-pr-review/references/roles/diagrams.md b/.agents/skills/ardan-pr-review/references/roles/diagrams.md index 2e4be0f..74409db 100644 --- a/.agents/skills/ardan-pr-review/references/roles/diagrams.md +++ b/.agents/skills/ardan-pr-review/references/roles/diagrams.md @@ -11,6 +11,7 @@ Focused Mermaid diagrams, at least: 1. **Data types** the change adds or alters — `classDiagram` (or `flowchart` when relations matter more than fields). 2. **Client interaction** — the call sequence from the caller's side (`sequenceDiagram`). 3. **Inner workings** — the internal flow across packages and layers (`flowchart`). +4. **Infrastructure** the change adds or alters — resources, modules, charts, workloads, and pipeline jobs with their dependencies, exposure (ingress, IAM, RBAC), and data stores (`flowchart`). Replaced or destroyed resources are `removed` + `added` nodes. Show unchanged elements only for context, unstyled. Skip a diagram whose subject the change doesn't touch. If the change alters no type, contract, or flow, write a one-line `diagrams.md` saying so. diff --git a/.agents/skills/ardan-pr-review/references/roles/discovery.md b/.agents/skills/ardan-pr-review/references/roles/discovery.md new file mode 100644 index 0000000..d07f926 --- /dev/null +++ b/.agents/skills/ardan-pr-review/references/roles/discovery.md @@ -0,0 +1,58 @@ +# Discovery + +Find what the change is built with and how the repo itself checks it, so the run adapts: which idiom files reviewers load, what the built-in checks miss, and which of the repo's own quality gates to offer the user. **Read configs, not code** — no findings, no opinions. Write nothing; your reply is the output. + +**Inputs** (your prompt): skill directory, repo root, review depth, and intake's preview — the `languages=` / `ecosystems=` / `idioms=` lines, `# stack` rows (`eco unit files marker`), and `# unmapped` rows (`lang path`). Read only build, CI, lint, and package configuration: `Makefile`/`justfile`/`Taskfile*`, CI files (`.github/workflows/*`, `.gitlab-ci.yml`, `Jenkinsfile`, …), `.pre-commit-config.yaml`, lint and scanner configs (`.golangci*`, `clippy.toml`, `rustfmt.toml`, `deny.toml`, `.tflint.hcl`, `.ansible-lint`, `ruff.toml`, tool sections of `pyproject.toml`, `.hadolint.yaml`, `.yamllint*`, `.kube-linter*`, `.checkov*`, `trivy.yaml`, `.semgrep*`), manifests (`Cargo.toml`, `package.json` scripts, `pyproject.toml`, `Chart.yaml`, `kustomization.yaml`, `ansible.cfg`, `go.mod`), and at most the first 20 lines of an `# unmapped` file. Never source code, generated or vendored files, or symlinks. One batch of reads. + +## Steps + +1. **Stack.** Confirm the `# stack` rows. For `# unmapped` paths and marker files the scripts don't know (e.g. Pulumi, CDK, CloudFormation/SAM, Serverless, Packer, Nomad, Bazel, Nix, Earthly, Tilt, Crossplane, Argo CD/Flux, Jenkins shared libraries, Java/Kotlin/C#/Ruby/PHP/C/C++ builds), name the ecosystem and its units. +2. **Idioms.** Each ecosystem in scope maps to a file in `/references/langs/` (list the directory) or, when none fits, gets ≤ 8 terse review bullets under *Inline idioms*, one per concern: correctness, failure paths, contracts, tests, security. +3. **Repo gates.** From the Makefile, CI, and pre-commit files: the commands the repo runs to lint, type-check, test, or scan the in-scope ecosystems. Keep those the built-in checks (below) don't cover, or that wrap the repo's own configuration (`make lint`, `npm run lint`, `just check`, `nox -s lint`, `./gradlew check -x test`, `mvn -q verify -DskipTests`). +4. **Extra checks** — propose a gate only if it is safe to run unattended: + - read-only on the working tree: no `--fix`, `--write`, or formatter without `--check`/`--diff`; + - no deploy, apply, push, publish, or release: no `terraform plan|apply`, `helm install|upgrade`, `kubectl apply`, cloud CLIs, `docker push`; + - no credentials beyond the test setup; bounded runtime; scoped to the changed units where the tool allows; + - one command, valid in both sh and PowerShell: no pipes, `&&`, globs, or inline env assignments — use the repo's wrapper target instead; + - tests only for an ecosystem whose tests the built-in checks don't run. +5. **Missing analyzers.** For an in-scope ecosystem with no built-in check, name the standard static analyzer and its install command. Never install anything. + +## Built-in checks (don't propose these again) + +| Ecosystem | Checks | +|--------------------|-----------------------------------------------------------------------------------------------------| +| Go | build, vet, `test -race`, staticcheck, golangci-lint (repo config), checklocks, errcheck, nilaway, deadcode, apidiff, `fix -diff`, `mod tidy -diff`, gofmt, gosec, govulncheck, dupl | +| Rust | `cargo fmt --check`, clippy `-D warnings` (or `cargo check`), `cargo test`, cargo-audit, cargo-deny (`deny.toml`) | +| Terraform/OpenTofu | `fmt -check`, `init -backend=false` + `validate`, tflint (repo config) | +| Helm / Kustomize / K8s | `helm lint`, `helm template` / `kustomize build` → kubeconform + kube-linter | +| IaC (all of these, Dockerfiles) | trivy config, else checkov | +| Python | ruff check + format, mypy (repo config), pytest, bandit, pip-audit (`requirements*.txt`) | +| Ansible / YAML | ansible-lint, yamllint (repo config) | +| Dockerfile | hadolint | +| GitHub Actions | actionlint, zizmor | +| Shell / JS / proto | shellcheck, jscpd, buf lint + breaking (`buf.yaml`) | +| All | gitleaks or trufflehog, semgrep (repo config) | + +## Reply + +The summary line, then both blocks, nothing after. `stack.md` ≤ 60 lines. `extra-checks.tsv` has one tab-separated row per proposed check — `name` (`[A-Za-z0-9._-]`), `dir` (repo-relative, `.` for the root), `lens` (`service-steward`, `error-tripwire`, `boundary-keeper`, `harness-map`, `security-sentinel`, `clone-hunter`, or `straight-line-pass`), `command`, `source` (`file:line`) — and is empty when you propose none. + +```` +discovery: ecosystems= idioms= inline= extra= missing= +===== stack.md ===== +# Stack +## Ecosystems +- — units: ``, …; idioms: `langs/.md` | inline; +## Inline idioms +### +- +## Repo gates +- `` (``) — ; built-in `` | proposed `` | not proposed: +## Missing analyzers +- : `` — +===== extra-checks.tsv ===== + +===== end ===== +```` + +Omit empty sections. diff --git a/.agents/skills/ardan-pr-review/references/roles/generated-scan.md b/.agents/skills/ardan-pr-review/references/roles/generated-scan.md index 8dfcdb5..6c27a64 100644 --- a/.agents/skills/ardan-pr-review/references/roles/generated-scan.md +++ b/.agents/skills/ardan-pr-review/references/roles/generated-scan.md @@ -1,6 +1,6 @@ # Generated-code secret scan (opt-in) -Generated, vendored, and lockfile changes are never reviewed. This agent checks one thing in them — a secret baked into build output, bundles, embedded config, or vendored fixtures — and runs separately so those large files never enter anyone else's context. +Generated, vendored, and lockfile changes are not reviewed unless the user opted in (`generated=included` in `scope.txt` — then this agent doesn't run). This agent checks one thing in them — a secret baked into build output, bundles, embedded config, or vendored fixtures — and runs separately so those large files never enter anyone else's context. **Inputs:** `/context/generated.patch`, `skipped.tsv`, `tools.tsv`. Write only `/lenses/generated-scan.md`. diff --git a/.agents/skills/ardan-pr-review/references/size-guard.md b/.agents/skills/ardan-pr-review/references/size-guard.md index 7d10a85..fbc2e3e 100644 --- a/.agents/skills/ardan-pr-review/references/size-guard.md +++ b/.agents/skills/ardan-pr-review/references/size-guard.md @@ -18,4 +18,4 @@ Show a compact table (flag, path, metric, ~tokens) with `diff_tokens_est` (and ` - **Split the PR** — suggest splits from `shards.tsv` (package groups) and stop. At full depth `shards.tsv` splits the review set, so base the split on `files.tsv` areas instead. - **Narrow the depth** — only for `big-review-set`: re-run the preview with a smaller `--depth` (project → packages → files) and confirm. -When only `noise` is flagged, don't ask — say "N generated/vendored files and symlinks skipped" and move on. Secret-scanning them is a separate opt-in (SKILL.md step 3). +When only `noise` is flagged, don't ask — say "N generated/vendored files and symlinks skipped" and move on. Secret-scanning or reviewing them is a separate opt-in (SKILL.md step 3). diff --git a/.agents/skills/ardan-pr-review/scripts/checks.ps1 b/.agents/skills/ardan-pr-review/scripts/checks.ps1 index 5ec2dc2..0a8ae7b 100644 --- a/.agents/skills/ardan-pr-review/scripts/checks.ps1 +++ b/.agents/skills/ardan-pr-review/scripts/checks.ps1 @@ -1,24 +1,27 @@ -# Usage: checks.ps1 RUN_DIR [-Env K=V,...] [-Exclude PATH,...] [-Timeout 15m] [-NoTests] +# Usage: checks.ps1 RUN_DIR [-Env K=V,...] [-Exclude PATH,...] [-Timeout 15m] [-NoTests] [-Extra NAME,...|all] # PowerShell port of checks.sh (same outputs). Runs every mechanical check ONCE, scoped to the -# files/packages intake recorded in RUN_DIR/context, in parallel; the depth in scope.txt picks -# the files (see checks.sh). Output: +# files/units intake recorded in RUN_DIR/context, in parallel; the depth in scope.txt picks +# the files (see checks.sh, also for what is written outside the run directory). Output: # RUN_DIR/context/checks/.txt (full log) and RUN_DIR/context/checks/summary.tsv. # -Env K=V set for every check (e.g. GITHUB_WORKSPACE=C:\repo) -# -Exclude PATH never run tests in packages under PATH (repo-relative) -# -Timeout D go test -timeout (default 15m) +# -Exclude PATH never run tests in packages/crates/projects under PATH (repo-relative) +# -Timeout D test timeout (default 15m) # -NoTests skip test runs +# -Extra NAMES also run these user-approved rows of context/extra-checks.tsv (all = every row) # A failing check is data for the reviewers, not a script failure. Exit: 0 ran | 2 usage. param( [Parameter(Mandatory = $true, Position = 0)][string]$RunDir, [string[]]$Env = @(), [string[]]$Exclude = @(), [string]$Timeout = '15m', - [switch]$NoTests + [switch]$NoTests, + [string[]]$Extra = @() ) # -File passes 'a,b' as one string: split list parameters ourselves (-Env only before NAME=). $Env = @($Env | ForEach-Object { $_ -split ',(?=[A-Za-z_][A-Za-z0-9_]*=)' } | Where-Object { $_ }) $Exclude = @($Exclude | ForEach-Object { $_ -split ',' } | Where-Object { $_ }) +$Extra = @($Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ }) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 2 $utf8 = [Text.UTF8Encoding]::new($false) @@ -98,7 +101,24 @@ function Skip([string]$name, [string]$why) { } $files = @(ReadTsv $scoped) -function Live([string]$lang) { @($files | Where-Object { $_[0] -ne 'D' -and ($lang -eq '*' -or $_[3] -ceq $lang) } | ForEach-Object { $_[6] }) } +# Generated files reviewed on request (-IncludeGenerated) are read by reviewers but not linted. +function Live([string]$lang) { @($files | Where-Object { $_[0] -ne 'D' -and $_[4] -ne 'generated' -and $_[4] -ne 'vendor' -and ($lang -eq '*' -or $_[3] -ceq $lang) } | ForEach-Object { $_[6] }) } +function DirOf([string]$p) { if ($p -cmatch '/') { $p -creplace '/[^/]*$', '' } else { '.' } } +# Units ECO: unit directories of an ecosystem from stack.tsv ("-" rows are file-level) +$stack = @(ReadTsv (Join-Path $ctx 'stack.tsv')) +function Units([string]$eco) { @($stack | Where-Object { $_[0] -ceq $eco -and $_[1] -ne '-' } | ForEach-Object { $_[1] }) } +# USfx ECO UNIT: log-name suffix, "@unit" when the ecosystem has more than one unit +function USfx([string]$eco, [string]$u) { if (@(Units $eco).Count -gt 1) { '@' + ($u -creplace '/', '_') } else { '' } } +function IsExcl([string]$p) { foreach ($e in $Exclude) { if ($p -ceq $e -or $p.StartsWith("$e/", [StringComparison]::Ordinal)) { return $true } }; $false } +# Under UNIT LANG: live files of LANG inside UNIT, relative to UNIT +function Under([string]$u, [string]$lang) { @(Live $lang | ForEach-Object { if ($u -eq '.') { $_ } elseif ($_.StartsWith("$u/", [StringComparison]::Ordinal)) { $_.Substring($u.Length + 1) } }) } +$onWin = [Environment]::OSVersion.Platform -eq 'Win32NT' +# timeout/gtimeout (coreutils) bound test runs; Windows' timeout.exe is something else. +$tmo = if ($onWin) { $null } else { Get-Command timeout, gtimeout -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 } +function JobT([string]$name, [string]$dir, [string]$exe, [string[]]$a) { + if ($tmo) { Job $name $dir $tmo.Source (@($Timeout, $exe) + $a) } else { Job $name $dir $exe $a } +} +function UnitDir([string]$u) { (Resolve-Path -LiteralPath $u).Path } # --- base tree (apidiff, buf breaking) --------------------------------------------------- # The base commit's files, extracted outside the repo with git archive: nothing is written @@ -168,7 +188,8 @@ foreach ($m in $modules) { foreach ($e in $Exclude) { if ($full -ceq $e -or $full.StartsWith("$e/", [StringComparison]::Ordinal)) { $ex = $true } } if (-not $ex) { $p } }) - Job "go-build$sfx" $dir 'go' (@('build') + $pkgs) + # -o NUL / /dev/null: a single main package would otherwise write its binary into the repo + Job "go-build$sfx" $dir 'go' (@('build', '-o', $(if ([Environment]::OSVersion.Platform -eq 'Win32NT') { 'NUL' } else { '/dev/null' })) + $pkgs) Job "go-vet$sfx" $dir 'go' (@('vet') + $pkgs) if (-not $tests) { Skip "go-test$sfx" '--no-tests' } elseif (-not $testPkgs.Count) { Skip "go-test$sfx" 'all packages excluded' } @@ -222,6 +243,226 @@ if ($sh.Count) { $jsDirs = @(@(Live 'js') + @(Live 'ts') + @(Live 'vue') | ForEach-Object { $_ -creplace '/[^/]*$', '' } | Select-Object -Unique) if ($jsDirs.Count -and (Has 'jscpd')) { Job 'jscpd' $root 'jscpd' (@('--min-tokens', '50', '--reporters', 'console', '--silent') + $jsDirs) } +# --- Rust, per crate ------------------------------------------------------------------- +$rustUnits = @(Units 'rust') +if ($rustUnits.Count) { + if (-not (Has 'cargo')) { Skip 'cargo' 'cargo not installed' } + else { + & cargo fmt --version *> $null; $hasFmt = $LASTEXITCODE -eq 0 + & cargo clippy --version *> $null; $hasClippy = $LASTEXITCODE -eq 0 + foreach ($u in $rustUnits) { + $s = USfx 'rust' $u; $dir = UnitDir $u + if ($hasFmt) { Job "cargo-fmt$s" $dir 'cargo' @('fmt', '--check') } else { Skip "cargo-fmt$s" 'rustfmt not installed' } + # --locked: fail instead of rewriting Cargo.lock + if ($hasClippy) { Job "cargo-clippy$s" $dir 'cargo' @('clippy', '--all-targets', '--locked', '--', '-D', 'warnings') } + else { Skip "cargo-clippy$s" 'clippy not installed'; Job "cargo-check$s" $dir 'cargo' @('check', '--all-targets', '--locked') } + if (-not $tests) { Skip "cargo-test$s" '--no-tests' } + elseif (IsExcl $u) { Skip "cargo-test$s" 'crate excluded' } + else { JobT "cargo-test$s" $dir 'cargo' @('test', '--all-targets', '--locked') } + } + $lockDirs = [string[]]@(@(foreach ($u in $rustUnits) { + $d = $u + while (-not (Test-Path -LiteralPath "$d/Cargo.lock") -and $d -ne '.') { $d = DirOf $d } + if (Test-Path -LiteralPath "$d/Cargo.lock") { $d } + }) | Select-Object -Unique) + foreach ($d in $lockDirs) { + $s = if ($lockDirs.Count -gt 1) { '@' + ($d -creplace '/', '_') } else { '' }; $dir = UnitDir $d + if (Has 'cargo-audit') { Job "cargo-audit$s" $dir 'cargo' @('audit') } else { Skip "cargo-audit$s" 'cargo-audit not installed' } + if (Test-Path -LiteralPath "$d/deny.toml") { + if (Has 'cargo-deny') { Job "cargo-deny$s" $dir 'cargo' @('deny', 'check') } else { Skip "cargo-deny$s" 'cargo-deny not installed' } + } + } + } +} + +# --- Terraform / OpenTofu, per module -------------------------------------------------- +# The stack marker names the binary (terraform or tofu). init downloads providers and modules +# into a temp TF_DATA_DIR; an existing .terraform.lock.hcl is never rewritten +# (-lockfile=readonly), and one that init creates is removed again. +$tfRow = $stack | Where-Object { $_[0] -ceq 'terraform' } | Select-Object -First 1 +$tfBin = if ($tfRow) { $tfRow[3] } else { '' } +$tfFmtFn = { + param($st, [string]$bin, [string[]]$fs) + foreach ($f in $fs) { & $bin fmt -check -diff -no-color $f 2>&1 | ForEach-Object { "$_" }; if ($LASTEXITCODE -ne 0) { $st.rc = $LASTEXITCODE } } +} +$tfValidateFn = { + param($st, [string]$bin) + $d = Join-Path ([IO.Path]::GetTempPath()) ('tf-data-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $d | Out-Null + $env:TF_DATA_DIR = $d + $hadLock = Test-Path -LiteralPath '.terraform.lock.hcl' + $lock = @(if ($hadLock) { '-lockfile=readonly' }) + $init = @(& $bin init -backend=false -input=false @lock -no-color 2>&1 | ForEach-Object { "$_" }) + $initRc = $LASTEXITCODE + if ($initRc -ne 0) { $st.rc = $initRc; '# init failed (providers, modules, or credentials):'; $init } + else { & $bin validate -no-color 2>&1 | ForEach-Object { "$_" }; $st.rc = $LASTEXITCODE } + # validate needs the lock file init wrote; remove it only afterwards + if (-not $hadLock) { Remove-Item -LiteralPath '.terraform.lock.hcl' -Force -ErrorAction SilentlyContinue } + Remove-Item -LiteralPath $d -Recurse -Force -ErrorAction SilentlyContinue +} +$tfLintFn = { + param($st, [string]$root) + $cfg = '' + if (Test-Path -LiteralPath '.tflint.hcl') { $cfg = (Resolve-Path -LiteralPath '.tflint.hcl').Path } + elseif (Test-Path -LiteralPath (Join-Path $root '.tflint.hcl')) { $cfg = Join-Path $root '.tflint.hcl' } + if ($cfg) { & tflint --init "--config=$cfg" *> $null; & tflint "--config=$cfg" --format=compact --no-color 2>&1 | ForEach-Object { "$_" } } + else { & tflint --format=compact --no-color 2>&1 | ForEach-Object { "$_" } } + $st.rc = $LASTEXITCODE +} +$tfFiles = @(Live 'tf' | Where-Object { $_ -cmatch '\.(tf|tofu|tfvars|tftest\.hcl)$' }) +if ($tfFiles.Count) { + if (-not $tfBin -or -not (Has $tfBin)) { Skip 'tf' "$tfBin not installed" } + else { + JobFn 'tf-fmt' $root "tf_fmt $($tfFiles -join ' ')" $tfFmtFn @($tfBin, [string[]]$tfFiles) + foreach ($u in @(Units 'terraform')) { JobFn "tf-validate$(USfx 'terraform' $u)" (UnitDir $u) 'tf_validate' $tfValidateFn @($tfBin) } + } + foreach ($u in @(Units 'terraform')) { + $s = USfx 'terraform' $u + if (Has 'tflint') { JobFn "tflint$s" (UnitDir $u) 'tf_lint' $tfLintFn @($root) } else { Skip "tflint$s" 'tflint not installed' } + } +} + +# --- Helm, Kustomize, K8s manifests ----------------------------------------------------- +# Render (helm template / kustomize build), then kubeconform + kube-linter on the output (in a +# .yaml file: kubeconform skips files without a YAML extension). +$kubeFn = { + param($st, [string]$kind, [string]$target, [bool]$kc, [bool]$kl, [bool]$kust, [string[]]$fs) + if ($kind) { + $o = @(switch ($kind) { + 'helm' { & helm template review $target 2>&1 } + 'kustomize' { if ($kust) { & kustomize build $target 2>&1 } else { & kubectl kustomize $target 2>&1 } } + }) + $rc = $LASTEXITCODE + $o | Where-Object { $_ -is [Management.Automation.ErrorRecord] } | ForEach-Object { "$_" } + if ($rc -ne 0) { $st.rc = $rc; return } + $tdir = Join-Path ([IO.Path]::GetTempPath()) ('render-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $tdir | Out-Null + $f = Join-Path $tdir 'render.yaml' + [IO.File]::WriteAllText($f, ((@($o | Where-Object { $_ -isnot [Management.Automation.ErrorRecord] }) | ForEach-Object { "$_" }) -join "`n") + "`n") + $fs = @($f) + } + if ($kc) { '## kubeconform'; & kubeconform -strict -ignore-missing-schemas -summary @fs 2>&1 | ForEach-Object { "$_" }; if ($LASTEXITCODE -ne 0) { $st.rc = $LASTEXITCODE } } + if ($kl) { '## kube-linter'; & kube-linter lint @fs 2>&1 | ForEach-Object { "$_" }; if ($LASTEXITCODE -ne 0) { $st.rc = $LASTEXITCODE } } + if ($kind) { Remove-Item -LiteralPath $tdir -Recurse -Force -ErrorAction SilentlyContinue } +} +$kc = Has 'kubeconform'; $kl = Has 'kube-linter'; $kust = Has 'kustomize' +foreach ($u in @(Units 'helm')) { + $s = USfx 'helm' $u + if (Has 'helm') { + Job "helm-lint$s" $root 'helm' @('lint', $u) + JobFn "helm-render$s" $root "render helm $u" $kubeFn @('helm', $u, $kc, $kl, $kust, [string[]]@()) + } else { Skip "helm$s" 'helm not installed' } +} +foreach ($u in @(Units 'kustomize')) { + $s = USfx 'kustomize' $u + if ($kust -or (Has 'kubectl')) { JobFn "kustomize$s" $root "render kustomize $u" $kubeFn @('kustomize', $u, $kc, $kl, $kust, [string[]]@()) } + else { Skip "kustomize$s" 'kustomize/kubectl not installed' } +} +$kuRoots = @(Units 'kustomize') +$k8sPlain = @(Live 'k8s' | Where-Object { $p = $_; -not ($kuRoots | Where-Object { $_ -eq '.' -or $p.StartsWith("$_/", [StringComparison]::Ordinal) }) }) +if ($k8sPlain.Count) { + if ($kc -or $kl) { JobFn 'k8s-manifests' $root 'kube_check' $kubeFn @('', '', $kc, $kl, $kust, [string[]]$k8sPlain) } + else { Skip 'k8s-manifests' 'kubeconform/kube-linter not installed' } +} + +# IaC misconfiguration scan: trivy, else checkov, over each infra unit directory plus the +# K8s and Docker files outside those units (a file target scans only that file). +$iacUnits = @(@(Units 'terraform') + @(Units 'helm') + @(Units 'kustomize') | Select-Object -Unique) +$iacFiles = @(@(Live 'k8s') + @(Live 'docker') | Where-Object { $p = $_; -not ($iacUnits | Where-Object { $_ -eq '.' -or $p.StartsWith("$_/", [StringComparison]::Ordinal) }) }) +$iacDirs = [string[]]@(@($iacUnits) + @($iacFiles) | Where-Object { $_ } | Select-Object -Unique) +[Array]::Sort($iacDirs, [StringComparer]::Ordinal) +$iacFn = { + param($st, [bool]$trivy, [string[]]$targets) + foreach ($x in $targets) { + "## $x" + if ($trivy) { & trivy config --quiet --exit-code 1 $x 2>&1 | ForEach-Object { "$_" } } + elseif (Test-Path -LiteralPath $x -PathType Container) { & checkov --quiet --compact -d $x 2>&1 | ForEach-Object { "$_" } } + else { & checkov --quiet --compact -f $x 2>&1 | ForEach-Object { "$_" } } + if ($LASTEXITCODE -ne 0) { $st.rc = $LASTEXITCODE } + } +} +if ($iacDirs.Count) { + if ((Has 'trivy') -or (Has 'checkov')) { JobFn 'iac-scan' $root "iac_scan $($iacDirs -join ' ')" $iacFn @((Has 'trivy'), $iacDirs) } + else { Skip 'iac-scan' 'trivy/checkov not installed' } +} + +# --- Python, per project ---------------------------------------------------------------- +# No caches or bytecode in the repo: ruff --no-cache, mypy without a cache dir, pytest without +# its cache provider, PYTHONDONTWRITEBYTECODE. +$pyFiles = @(Live 'py') +if ($pyFiles.Count) { + $env:PYTHONDONTWRITEBYTECODE = '1' + if (Has 'ruff') { + Job 'ruff' $root 'ruff' (@('check', '--no-cache', '--no-fix', '--output-format=concise') + $pyFiles) + Job 'ruff-format' $root 'ruff' (@('format', '--no-cache', '--check', '--diff') + $pyFiles) + } else { Skip 'ruff' 'ruff not installed' } + if (Has 'bandit') { Job 'bandit' $root 'bandit' (@('-q') + $pyFiles) } else { Skip 'bandit' 'bandit not installed' } + $nullDir = if ($onWin) { 'nul' } else { '/dev/null' } + foreach ($u in @(Units 'python')) { + $s = USfx 'python' $u; $dir = UnitDir $u + $py = if ($onWin) { Join-Path $dir '.venv\Scripts\python.exe' } else { Join-Path $dir '.venv/bin/python' } + if (-not (Test-Path -LiteralPath $py)) { $py = if ($onWin) { 'python' } else { 'python3' } } + $pyproject = Join-Path $dir 'pyproject.toml'; $setupCfg = Join-Path $dir 'setup.cfg' + $mypyCfg = (Test-Path -LiteralPath (Join-Path $dir 'mypy.ini')) -or (Test-Path -LiteralPath (Join-Path $dir '.mypy.ini')) -or + ((Test-Path -LiteralPath $pyproject) -and ([IO.File]::ReadAllLines($pyproject) -cmatch '^\[tool\.mypy')) -or + ((Test-Path -LiteralPath $setupCfg) -and ([IO.File]::ReadAllLines($setupCfg) -cmatch '^\[mypy')) + if ($mypyCfg) { + if (Has 'mypy') { Job "mypy$s" $dir 'mypy' (@("--cache-dir=$nullDir") + (Under $u 'py')) } else { Skip "mypy$s" 'mypy not installed' } + } + Push-Location -LiteralPath $dir + $hasTests = [bool](@(& git -c core.quotePath=false ls-files -- . 2>$null) -cmatch '(^|/)(test_[^/]*|[^/]*_test)\.py$') + Pop-Location + if ($hasTests) { + if (-not $tests) { Skip "pytest$s" '--no-tests' } + elseif (IsExcl $u) { Skip "pytest$s" 'project excluded' } + else { + # the project's venv, else a pytest importable by python, else a standalone pytest (pipx) + & $py -m pytest --version *> $null + $pyRun = @(if ($LASTEXITCODE -eq 0) { $py, '-m', 'pytest' } elseif (Has 'pytest') { 'pytest' }) + if ($pyRun.Count) { + $ign = @(foreach ($e in $Exclude) { if ($e.StartsWith("$u/", [StringComparison]::Ordinal)) { "--ignore=$($e.Substring($u.Length + 1))" } }) + JobT "pytest$s" $dir $pyRun[0] (@($pyRun | Select-Object -Skip 1) + @('-q', '-p', 'no:cacheprovider') + $ign) + } else { Skip "pytest$s" 'pytest not installed' } + } + } + foreach ($r in @(Get-ChildItem -LiteralPath $dir -File -Filter 'requirements*.txt' | ForEach-Object { $_.Name } | Sort-Object)) { + if (Has 'pip-audit') { Job "pip-audit$s@$($r -creplace '\.txt$', '')" $dir 'pip-audit' @('-r', $r) } else { Skip "pip-audit$s" 'pip-audit not installed' } + } + } +} + +# --- Ansible, YAML, Dockerfiles, CI ----------------------------------------------------- +$ans = @(Live 'ansible') +if ($ans.Count) { + if (Has 'ansible-lint') { Job 'ansible-lint' $root 'ansible-lint' (@('--offline', '-q') + $ans) } else { Skip 'ansible-lint' 'ansible-lint not installed' } +} +if (@('.yamllint', '.yamllint.yaml', '.yamllint.yml') | Where-Object { Test-Path -LiteralPath $_ }) { + $yf = @(@(Live 'yaml') + @(Live 'ansible') + @(Live 'k8s') + @(Live 'ci') | Where-Object { $_ -cmatch '\.ya?ml$' }) + if ($yf.Count) { if (Has 'yamllint') { Job 'yamllint' $root 'yamllint' (@('-f', 'parsable') + $yf) } else { Skip 'yamllint' 'yamllint not installed' } } +} +$dockerfiles = @(Live 'docker' | Where-Object { ($_ -creplace '.*/', '').ToLowerInvariant() -cnotmatch 'compose' }) +if ($dockerfiles.Count) { if (Has 'hadolint') { Job 'hadolint' $root 'hadolint' $dockerfiles } else { Skip 'hadolint' 'hadolint not installed' } } +$gha = @(Live 'ci' | Where-Object { $_ -cmatch '(^|/)\.github/|(^|/)action\.ya?ml$' }) +if ($gha.Count) { + $wf = @($gha | Where-Object { $_ -cmatch '\.github/workflows/' }) + if ($wf.Count) { if (Has 'actionlint') { Job 'actionlint' $root 'actionlint' (@('-no-color') + $wf) } else { Skip 'actionlint' 'actionlint not installed' } } + if (Has 'zizmor') { Job 'zizmor' $root 'zizmor' (@('--offline') + $gha) } else { Skip 'zizmor' 'zizmor not installed' } +} + +# --- extra checks the user approved (context/extra-checks.tsv: name dir lens command source) --- +# Each command runs in this PowerShell (sh on the shell port). +$extraRows = @(ReadTsv (Join-Path $ctx 'extra-checks.tsv')) +if ($Extra.Count -and $extraRows.Count) { + $psExe = (Get-Process -Id $PID).Path + foreach ($x in $extraRows) { + if (-not $x[0] -or $x[0].StartsWith('#') -or $x.Count -lt 4) { continue } + if ($Extra -cnotcontains $x[0] -and $Extra -cnotcontains 'all') { continue } + if ($x[0] -cmatch '[^A-Za-z0-9._-]') { continue } + $xd = if ($x[1]) { $x[1] } else { '.' } + Job "x-$($x[0])" (UnitDir $xd) $psExe @('-NoProfile', '-Command', $x[3]) + } +} + # Secrets: the reviewed diff, or at full depth a copy of the review set (so generated and # vendored files stay out; findings show repo-relative paths). generated.patch is scanned # only on request, by the generated-scan agent (see references/roles/generated-scan.md). @@ -260,7 +501,23 @@ if ($setCopy) { Remove-Item -LiteralPath $setCopy -Recurse -Force -ErrorAction S # --- summary ---------------------------------------------------------------------------- $rows = New-Object System.Collections.Generic.List[string] -$rows.Add("check`tstatus`texit`tlines`tfile") +# LensOf CHECK: the lens that owns a check's failures (extra checks: their lens column) +function LensOf([string]$n) { + if ($n.StartsWith('x-')) { + $r = $extraRows | Where-Object { $_[0] -ceq $n.Substring(2) } | Select-Object -First 1 + if ($r -and $r.Count -gt 2 -and $r[2]) { return $r[2] }; return 'service-steward' + } + switch -Regex -CaseSensitive ($n) { + '^(go-test|cargo-test|pytest)' { return 'harness-map' } + '^errcheck' { return 'error-tripwire' } + '^(apidiff|buf-)' { return 'boundary-keeper' } + '^deadcode' { return 'straight-line-pass' } + '^(dupl|jscpd)' { return 'clone-hunter' } + '^(gosec|govulncheck|cargo-audit|cargo-deny|pip-audit)|^(gitleaks|trufflehog|secrets|semgrep|bandit|iac-scan|zizmor)$' { return 'security-sentinel' } + } + 'service-steward' +} +$rows.Add("check`tstatus`texit`tlines`tfile`tlens") $names = [string[]]@(Get-ChildItem -LiteralPath $out -Filter '*.exit' | ForEach-Object { $_.Name }) [Array]::Sort($names, [StringComparer]::Ordinal) foreach ($nm in $names) { @@ -270,7 +527,7 @@ foreach ($nm in $names) { $lines = 0; foreach ($x in [IO.File]::ReadLines($log)) { $lines++ } # These exit 0 and report through output: any line not starting with "#" is a hit. if ($n -cmatch '^(dupl|gofmt$|deadcode|apidiff)' -and $s -eq 'pass' -and @([IO.File]::ReadLines($log) | Where-Object { $_ -cmatch '^[^#]' }).Count) { $s = 'findings' } - $rows.Add("$n`t$s`t$e`t$lines`tchecks/$n.txt") + $rows.Add("$n`t$s`t$e`t$lines`tchecks/$n.txt`t$(LensOf $n)") Remove-Item -LiteralPath $f } Out-Text (Join-Path $out 'summary.tsv') (($rows -join "`n") + "`n") diff --git a/.agents/skills/ardan-pr-review/scripts/checks.sh b/.agents/skills/ardan-pr-review/scripts/checks.sh index e78484c..2222079 100644 --- a/.agents/skills/ardan-pr-review/scripts/checks.sh +++ b/.agents/skills/ardan-pr-review/scripts/checks.sh @@ -1,15 +1,20 @@ #!/bin/sh # Usage: sh checks.sh RUN_DIR [--env K=V]... [--exclude PATH]... [--timeout 15m] [--no-tests] -# Runs every mechanical check ONCE, scoped to the files/packages intake.sh recorded in +# [--extra NAME,...|all] +# Runs every mechanical check ONCE, scoped to the files/units intake.sh recorded in # RUN_DIR/context, in parallel. The depth comes from scope.txt: at diff depth file checks # cover the changed files; at full depth (files/packages/project) they cover review-set.tsv, -# and Go checks cover packages.tsv, which intake fills to match the depth. +# and unit checks (Go packages, crates, Terraform modules, charts, ...) cover packages.tsv and +# stack.tsv, which intake fills to match the depth. Nothing writes to the working tree beyond +# build caches (Go cache, cargo target/): Terraform init uses a temp TF_DATA_DIR and a read-only +# lock file; Python checks run without caches or bytecode. # Output: RUN_DIR/context/checks/.txt (full log) and RUN_DIR/context/checks/summary.tsv -# (check status exit lines file). Prints the summary. +# (check status exit lines file lens). Prints the summary. # --env K=V exported for every check (e.g. GITHUB_WORKSPACE=/repo); repeatable -# --exclude PATH never run tests in packages under PATH (repo-relative); repeatable -# --timeout D go test -timeout (default 15m) +# --exclude PATH never run tests in packages/crates/projects under PATH (repo-relative); repeatable +# --timeout D test timeout (default 15m) # --no-tests skip test runs +# --extra NAMES also run these user-approved rows of context/extra-checks.tsv (all = every row) # A failing check is data for the reviewers, not a script failure. Exit: 0 ran | 2 usage. set -u @@ -18,7 +23,7 @@ die() { echo "checks: $*" >&2; exit 2; } [ $# -gt 0 ] || die "RUN_DIR required" run=$1; shift -excludes='' timeout=15m tests=1 envkeys='' +excludes='' timeout=15m tests=1 envkeys='' extra='' while [ $# -gt 0 ]; do case $1 in --env) @@ -31,7 +36,8 @@ while [ $# -gt 0 ]; do "; shift 2 ;; --timeout) timeout=${2-}; shift 2 ;; --no-tests) tests=0; shift ;; - -h|--help) sed -n '2,13p' "$0"; exit 0 ;; + --extra) extra=${2-}; shift 2 ;; + -h|--help) sed -n '2,19p' "$0"; exit 0 ;; *) die "unknown argument: $1" ;; esac done @@ -67,8 +73,20 @@ job() { } skip() { printf '# skipped: %s\n' "$2" >"$out/$1.txt"; echo skip >"$out/$1.exit"; } -# live LANG: reviewed (not deleted) files of LANG ("*" = any), repo-relative +# live LANG: reviewed (not deleted) files of LANG ("*" = any), repo-relative. Generated files +# reviewed on request (--include-generated) are read by reviewers but not linted. live() { awk -F '\t' -v l="$1" '$1 != "D" && $5 != "generated" && $5 != "vendor" && (l == "*" || $4 == l) { print $7 }' "$scoped"; } +# units ECO: unit directories of an ecosystem from stack.tsv ("-" rows are file-level) +units() { awk -F '\t' -v e="$1" '$1 == e && $2 != "-" { print $2 }' "$ctx/stack.tsv" 2>/dev/null; } +# usfx ECO UNIT: log-name suffix, "@unit" when the ecosystem has more than one unit +usfx() { [ "$(units "$1" | grep -c .)" -gt 1 ] && printf '@%s' "$(printf '%s' "$2" | tr '/' '_')"; return 0; } +# excluded PATH: true when PATH is under a --exclude entry +excluded() { for e in $excludes; do case $1 in "$e"|"$e"/*) return 0 ;; esac; done; return 1; } +# under UNIT LANG: live files of LANG inside UNIT, relative to UNIT +under() { live "$2" | awk -v u="$1" 'u == "." { print; next } index($0, u "/") == 1 { print substr($0, length(u) + 2) }'; } +tmo=$(command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null) +# with_timeout CMD...: CMD under the test timeout when timeout/gtimeout exists +with_timeout() { if [ -n "$tmo" ]; then "$tmo" "$timeout" "$@"; else "$@"; fi; } # --- base tree (apidiff, buf breaking) --------------------------------------------------- # The base commit's files, extracted outside the repo with git archive: nothing is written @@ -145,7 +163,8 @@ for m in $modules; do # shellcheck disable=SC2086 # package lists are space-separated by design ( cd -- "$m" || exit 1 - job "go-build$sfx" go build $pkgs + # -o /dev/null: a single main package would otherwise write its binary into the repo + job "go-build$sfx" go build -o /dev/null $pkgs job "go-vet$sfx" go vet $pkgs if [ $tests = 0 ]; then skip "go-test$sfx" "--no-tests" elif [ -z "$testpkgs" ]; then skip "go-test$sfx" "all packages excluded" @@ -223,6 +242,282 @@ if [ -n "$js_dirs" ] && has jscpd; then job jscpd jscpd --min-tokens 50 --reporters console --silent $js_dirs fi +# --- Rust, per crate ------------------------------------------------------------------- +# cargo_audit_dirs: directories holding the Cargo.lock of each crate unit, deduplicated +cargo_lock_dirs() { + units rust | while IFS= read -r u; do + d=$u + while [ ! -f "$d/Cargo.lock" ] && [ "$d" != . ]; do case $d in */*) d=${d%/*} ;; *) d=. ;; esac; done + [ -f "$d/Cargo.lock" ] && printf '%s\n' "$d" + done | sort -u +} +if [ -n "$(units rust)" ]; then + if ! has cargo; then skip cargo "cargo not installed" + else + while IFS= read -r u; do + [ -n "$u" ] || continue + s=$(usfx rust "$u") + ( + cd -- "$u" || exit 1 + if cargo fmt --version >/dev/null 2>&1; then job "cargo-fmt$s" cargo fmt --check + else skip "cargo-fmt$s" "rustfmt not installed" + fi + # --locked: fail instead of rewriting Cargo.lock + if cargo clippy --version >/dev/null 2>&1; then job "cargo-clippy$s" cargo clippy --all-targets --locked -- -D warnings + else skip "cargo-clippy$s" "clippy not installed"; job "cargo-check$s" cargo check --all-targets --locked + fi + if [ $tests = 0 ]; then skip "cargo-test$s" "--no-tests" + elif excluded "$u"; then skip "cargo-test$s" "crate excluded" + else job "cargo-test$s" with_timeout cargo test --all-targets --locked + fi + wait + ) & + done </dev/null) +tf_fmt() { rc=0; for f in "$@"; do "$tfbin" fmt -check -diff -no-color "$f" || rc=$?; done; return $rc; } +tf_validate() { + d=$(mktemp -d) || return 1 + lock=-lockfile=readonly + [ -f .terraform.lock.hcl ] || lock='' + # shellcheck disable=SC2086 # $lock is one flag or nothing + TF_DATA_DIR=$d "$tfbin" init -backend=false -input=false $lock -no-color >"$d.init" 2>&1 + rc=$? + if [ $rc = 0 ]; then TF_DATA_DIR=$d "$tfbin" validate -no-color; rc=$? + else echo "# init failed (providers, modules, or credentials):"; cat "$d.init" + fi + # validate needs the lock file init wrote; remove it only afterwards + [ -n "$lock" ] || rm -f .terraform.lock.hcl + rm -rf "$d" "$d.init"; return $rc +} +tf_lint() { + cfg='' + if [ -f .tflint.hcl ]; then cfg=$PWD/.tflint.hcl; elif [ -f "$root/.tflint.hcl" ]; then cfg=$root/.tflint.hcl; fi + if [ -n "$cfg" ]; then tflint --init --config="$cfg" >/dev/null 2>&1; tflint --config="$cfg" --format=compact --no-color + else tflint --format=compact --no-color + fi +} +tf_files=$(live tf | grep -E '\.(tf|tofu|tfvars|tftest\.hcl)$') +if [ -n "$tf_files" ]; then + if ! has "$tfbin"; then skip tf "$tfbin not installed" + else + # shellcheck disable=SC2086 + job tf-fmt tf_fmt $tf_files + while IFS= read -r u; do + [ -n "$u" ] || continue + s=$(usfx terraform "$u") + ( cd -- "$u" || exit 1; job "tf-validate$s" tf_validate; wait ) & + done <"$f" ;; + kustomize) if has kustomize; then kustomize build "$2" >"$f"; else kubectl kustomize "$2" >"$f"; fi ;; + esac + rc=$? + [ $rc = 0 ] && { kube_check "$f"; rc=$?; } + rm -rf "$t"; return $rc +} +while IFS= read -r u; do + [ -n "$u" ] || continue + s=$(usfx helm "$u") + if has helm; then job "helm-lint$s" helm lint "$u"; job "helm-render$s" render helm "$u" + else skip "helm$s" "helm not installed" + fi +done < 0) { split(l, f, "\t"); if (f[1] == "kustomize") ku[f[2]] = 1 } } + { for (u in ku) if (u == "." || index($0, u "/") == 1) next; print }') +if [ -n "$k8s_plain" ]; then + if has kubeconform || has kube-linter; then + # shellcheck disable=SC2086 + job k8s-manifests kube_check $k8s_plain + else skip k8s-manifests "kubeconform/kube-linter not installed" + fi +fi + +# IaC misconfiguration scan: trivy, else checkov, over each infra unit directory plus the +# K8s and Docker files outside those units (a file target scans only that file). +# (BSD awk rejects newlines in -v values: the unit list goes through a file.) +iac_units=$out/.iac-units +{ units terraform; units helm; units kustomize; } | sort -u >"$iac_units" +iac_targets=$( { cat "$iac_units"; { live k8s; live docker; } | awk -v U="$iac_units" ' + BEGIN { while ((getline l < U) > 0) if (l != "") u[++n] = l } + { for (i = 1; i <= n; i++) if (u[i] == "." || index($0, u[i] "/") == 1) next; print }'; } | grep . | sort -u) +rm -f "$iac_units" +iac_scan() { + rc=0 + for x in "$@"; do + echo "## $x" + if has trivy; then trivy config --quiet --exit-code 1 "$x" || rc=$? + elif [ -d "$x" ]; then checkov --quiet --compact -d "$x" || rc=$? + else checkov --quiet --compact -f "$x" || rc=$? + fi + done + return $rc +} +if [ -n "$iac_targets" ]; then + if has trivy || has checkov; then + # shellcheck disable=SC2086 + job iac-scan iac_scan $iac_targets + else skip iac-scan "trivy/checkov not installed" + fi +fi + +# --- Python, per project ---------------------------------------------------------------- +# No caches or bytecode in the repo: ruff --no-cache, mypy --cache-dir=/dev/null, pytest +# without its cache provider, PYTHONDONTWRITEBYTECODE. +py_files=$(live py) +if [ -n "$py_files" ]; then + export PYTHONDONTWRITEBYTECODE=1 + if has ruff; then + # shellcheck disable=SC2086 + job ruff ruff check --no-cache --no-fix --output-format=concise $py_files + # shellcheck disable=SC2086 + job ruff-format ruff format --no-cache --check --diff $py_files + else skip ruff "ruff not installed" + fi + # shellcheck disable=SC2086 + if has bandit; then job bandit bandit -q $py_files; else skip bandit "bandit not installed"; fi + while IFS= read -r u; do + [ -n "$u" ] || continue + s=$(usfx python "$u") + ( + cd -- "$u" || exit 1 + py=python3; [ -x .venv/bin/python ] && py=.venv/bin/python + if [ -f mypy.ini ] || [ -f .mypy.ini ] || grep -qs '^\[tool\.mypy' pyproject.toml || grep -qs '^\[mypy' setup.cfg; then + rel=$(under "$u" py) + # shellcheck disable=SC2086 + if has mypy; then job "mypy$s" mypy --cache-dir=/dev/null $rel; else skip "mypy$s" "mypy not installed"; fi + fi + if git ls-files -- . | grep -Eq '(^|/)(test_[^/]*|[^/]*_test)\.py$'; then + if [ $tests = 0 ]; then skip "pytest$s" "--no-tests" + elif excluded "$u"; then skip "pytest$s" "project excluded" + else + # the project's venv, else a pytest importable by python3, else a standalone pytest (pipx) + set -- "$py" -m pytest + if ! "$py" -m pytest --version >/dev/null 2>&1; then + if has pytest; then set -- pytest; else set --; fi + fi + if [ $# = 0 ]; then skip "pytest$s" "pytest not installed" + else + ign='' + for e in $excludes; do case $e in "$u"/*) ign="$ign --ignore=${e#"$u"/}" ;; esac; done + # shellcheck disable=SC2086 + job "pytest$s" with_timeout "$@" -q -p no:cacheprovider $ign + fi + fi + fi + for r in requirements*.txt; do + [ -f "$r" ] || continue + if has pip-audit; then job "pip-audit$s@${r%.txt}" pip-audit -r "$r"; else skip "pip-audit$s" "pip-audit not installed"; fi + done + wait + ) & + done <"$out/summary.tsv" +# lens_of CHECK: the lens that owns a check's failures (extra checks: their lens column) +lens_of() { + case $1 in + x-*) awk -F '\t' -v n="${1#x-}" '$1 == n { print ($3 == "" ? "service-steward" : $3); exit }' "$ctx/extra-checks.tsv" ;; + go-test*|cargo-test*|pytest*) echo harness-map ;; + errcheck*) echo error-tripwire ;; + apidiff*|buf-*) echo boundary-keeper ;; + deadcode*) echo straight-line-pass ;; + dupl*|jscpd*) echo clone-hunter ;; + gosec*|govulncheck*|gitleaks|trufflehog|secrets|semgrep|cargo-audit*|cargo-deny*|bandit|pip-audit*|iac-scan|zizmor) echo security-sentinel ;; + *) echo service-steward ;; + esac +} +printf 'check\tstatus\texit\tlines\tfile\tlens\n' >"$out/summary.tsv" for f in "$out"/*.exit; do [ -e "$f" ] || continue n=${f##*/}; n=${n%.exit}; e=$(cat "$f") @@ -276,7 +584,7 @@ for f in "$out"/*.exit; do dupl*|gofmt|deadcode*|apidiff*) [ "$s" = pass ] && grep -q '^[^#]' "$out/$n.txt" && s=findings ;; esac - printf '%s\t%s\t%s\t%s\t%s\n' "$n" "$s" "$e" "$(wc -l <"$out/$n.txt" | tr -d ' ')" "checks/$n.txt" >>"$out/summary.tsv" + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$n" "$s" "$e" "$(wc -l <"$out/$n.txt" | tr -d ' ')" "checks/$n.txt" "$(lens_of "$n")" >>"$out/summary.tsv" rm -f "$f" done cat "$out/summary.tsv" diff --git a/.agents/skills/ardan-pr-review/scripts/extract-findings.ps1 b/.agents/skills/ardan-pr-review/scripts/extract-findings.ps1 index 94dbaff..6fbfcd6 100644 --- a/.agents/skills/ardan-pr-review/scripts/extract-findings.ps1 +++ b/.agents/skills/ardan-pr-review/scripts/extract-findings.ps1 @@ -1,13 +1,16 @@ -# Usage: extract-findings.ps1 TRANSCRIPT RUN_DIR +# Usage: extract-findings.ps1 [-Discovery] TRANSCRIPT RUN_DIR # PowerShell port of extract-findings.sh (same output). Saves the aggregator's findings.md to # RUN_DIR/findings.md exactly as written, taken from its JSONL transcript (Claude Code's agent # output_file) so the orchestrator never re-types it. Takes the text between the last # "===== findings.md =====" and "===== end =====" markers; it must start with -# "# Service DiffGuard Report". Uses the built-in ConvertFrom-Json. -# Exit: 0 saved | 2 usage | 4 no valid findings.md in the transcript. +# "# Service DiffGuard Report". -Discovery: saves the discovery agent's stack.md and +# extra-checks.tsv to RUN_DIR/context instead (see extract-findings.sh). Uses the built-in +# ConvertFrom-Json. +# Exit: 0 saved | 2 usage | 4 no valid block in the transcript. param( [Parameter(Mandatory = $true, Position = 0)][string]$Transcript, - [Parameter(Mandatory = $true, Position = 1)][string]$RunDir + [Parameter(Mandatory = $true, Position = 1)][string]$RunDir, + [switch]$Discovery ) $ErrorActionPreference = 'Stop' @@ -18,7 +21,7 @@ function Die([int]$code, [string]$msg) { [Console]::Error.WriteLine("extract-fin if (-not (Test-Path -LiteralPath $Transcript -PathType Leaf)) { Die 2 "no transcript at $Transcript" } if (-not (Test-Path -LiteralPath $RunDir -PathType Container)) { Die 2 "no run directory at $RunDir" } -$open = "`n===== findings.md =====`n" +$open = if ($Discovery) { "`n===== stack.md =====`n" } else { "`n===== findings.md =====`n" } $close = "`n===== end =====" # Every JSON string holding both markers, in transcript order; the last is the final reply @@ -41,9 +44,24 @@ foreach ($line in [IO.File]::ReadLines((Resolve-Path -LiteralPath $Transcript).P Walk $obj } -if ($null -eq $script:last) { Die 4 "no findings.md between the markers in $Transcript" } +if ($null -eq $script:last) { Die 4 "no valid block between the markers in $Transcript" } $body = $script:last.Substring($script:last.LastIndexOf($open) + $open.Length) $body = $body.Substring(0, $body.IndexOf($close)) +if ($Discovery) { + $ctxDir = Join-Path $RunDir 'context' + if (-not (Test-Path -LiteralPath $ctxDir -PathType Container)) { Die 2 "no context directory in $RunDir" } + $mid = "`n===== extra-checks.tsv =====`n" + $body += "`n" # an empty extra-checks block ends right after its marker line + $at = $body.IndexOf($mid) + if (-not $body.StartsWith("# Stack`n") -or $at -lt 0) { Die 4 "no stack.md and extra-checks.tsv between the markers in $Transcript" } + $ctxDir = (Resolve-Path -LiteralPath $ctxDir).Path + [IO.File]::WriteAllText((Join-Path $ctxDir 'stack.md'), $body.Substring(0, $at) + "`n", $utf8) + $x = $body.Substring($at + $mid.Length).TrimEnd("`n") + [IO.File]::WriteAllText((Join-Path $ctxDir 'extra-checks.tsv'), $(if ($x) { $x + "`n" } else { '' }), $utf8) + $n = @([IO.File]::ReadAllLines((Join-Path $ctxDir 'extra-checks.tsv')) | Where-Object { $_ }).Count + Write-Output "stack=$(Join-Path $ctxDir 'stack.md') extra_checks=$n" + exit 0 +} if (-not $body.StartsWith("# Service DiffGuard Report`n")) { Die 4 "no findings.md between the markers in $Transcript" } $dest = Join-Path (Resolve-Path -LiteralPath $RunDir).Path 'findings.md' diff --git a/.agents/skills/ardan-pr-review/scripts/extract-findings.sh b/.agents/skills/ardan-pr-review/scripts/extract-findings.sh index 13670b5..90aa36c 100644 --- a/.agents/skills/ardan-pr-review/scripts/extract-findings.sh +++ b/.agents/skills/ardan-pr-review/scripts/extract-findings.sh @@ -1,16 +1,22 @@ #!/bin/sh -# Usage: sh extract-findings.sh TRANSCRIPT RUN_DIR +# Usage: sh extract-findings.sh [--discovery] TRANSCRIPT RUN_DIR # Saves the aggregator's findings.md to RUN_DIR/findings.md exactly as written, taken from its # JSONL transcript (Claude Code's agent output_file) so the orchestrator never re-types it. # Takes the text between the last "===== findings.md =====" and "===== end =====" markers; # it must start with "# Service DiffGuard Report". Needs jq. -# Exit: 0 saved | 2 usage | 3 jq missing | 4 no valid findings.md in the transcript. +# --discovery: saves the discovery agent's reply instead: the text between +# "===== stack.md =====" and "===== extra-checks.tsv =====" (must start with "# Stack") to +# RUN_DIR/context/stack.md, and the text from there to "===== end =====" to +# RUN_DIR/context/extra-checks.tsv. +# Exit: 0 saved | 2 usage | 3 jq missing | 4 no valid block in the transcript. set -u die() { code=$1; shift; echo "extract-findings: $*" >&2; exit "$code"; } -[ $# -eq 2 ] || die 2 "usage: extract-findings.sh TRANSCRIPT RUN_DIR" +mode=findings +[ "${1-}" = --discovery ] && { mode=discovery; shift; } +[ $# -eq 2 ] || die 2 "usage: extract-findings.sh [--discovery] TRANSCRIPT RUN_DIR" transcript=$1 run=$2 [ -f "$transcript" ] || die 2 "no transcript at $transcript" [ -d "$run" ] || die 2 "no run directory at $run" @@ -19,6 +25,11 @@ command -v jq >/dev/null 2>&1 || die 3 "jq not found" open='===== findings.md =====' close='===== end =====' tmp="$run/.findings.md.tmp" +if [ $mode = discovery ]; then + [ -d "$run/context" ] || die 2 "no context directory in $run" + open='===== stack.md =====' + tmp="$run/context/.discovery.tmp" +fi # Every JSON string holding both markers, in transcript order; the last is the final reply # (earlier ones are the role file the aggregator read). Lines that aren't JSON are skipped. @@ -31,6 +42,20 @@ jq -rRn --arg o "$open" --arg c "$close" ' EXIT_CODE=$? [ $EXIT_CODE -eq 0 ] || { rm -f "$tmp"; die 4 "jq failed on $transcript (exit $EXIT_CODE)"; } +if [ $mode = discovery ]; then + mid='===== extra-checks.tsv =====' + if [ "$(head -n 1 "$tmp")" != '# Stack' ] || ! grep -qx "$mid" "$tmp"; then + rm -f "$tmp" + die 4 "no stack.md and extra-checks.tsv between the markers in $transcript" + fi + awk -v m="$mid" -v S="$run/context/stack.md" -v X="$run/context/extra-checks.tsv" ' + $0 == m { x = 1; next } { print > (x ? X : S) }' "$tmp" + [ -f "$run/context/extra-checks.tsv" ] || : >"$run/context/extra-checks.tsv" + rm -f "$tmp" + echo "stack=$run/context/stack.md extra_checks=$(grep -c . "$run/context/extra-checks.tsv")" + exit 0 +fi + if [ "$(head -n 1 "$tmp")" != '# Service DiffGuard Report' ]; then rm -f "$tmp" die 4 "no findings.md between the markers in $transcript" diff --git a/.agents/skills/ardan-pr-review/scripts/intake.ps1 b/.agents/skills/ardan-pr-review/scripts/intake.ps1 index 66f5425..c3c0b5b 100644 --- a/.agents/skills/ardan-pr-review/scripts/intake.ps1 +++ b/.agents/skills/ardan-pr-review/scripts/intake.ps1 @@ -1,11 +1,11 @@ # Usage: intake.ps1 [-Id ID] [-Base REF | -Range A..B | -Pr N | -Staged | -Files F,...] # [-Exclude PATHSPEC,...] [-Depth diff|files|packages|project] -# [-MaxFileDiff N] [-MaxFileLines N] [-ReviewsDir DIR] [-DryRun] +# [-MaxFileDiff N] [-MaxFileLines N] [-ReviewsDir DIR] [-IncludeGenerated] [-DryRun] # PowerShell port of intake.sh (same outputs). Resolves the review scope in the current git # repo and writes ///context/ (default .reviews at the repo root). Default scope: unstaged changes + untracked # files. -Exclude drops paths from the scope. -Depth: see intake.sh (review-set.tsv). -# Size guard: see intake.sh. Symlinks are never reviewed. -DryRun prints the summary without -# writing anything. +# Size guard: see intake.sh. Generated and vendored files are skipped unless -IncludeGenerated; +# symlinks are never reviewed. -DryRun prints the summary without writing anything. # Exit: 0 ok | 2 usage | 3 code on disk is not the code under review | 4 empty diff | 5 not a git repo. param( [string]$Id = '', @@ -19,6 +19,7 @@ param( [int]$MaxFileDiff = 800, [int]$MaxFileLines = 2000, [string]$ReviewsDir = '.reviews', + [switch]$IncludeGenerated, [switch]$DryRun ) @@ -33,7 +34,7 @@ $utf8 = [Text.UTF8Encoding]::new($false) # paths and names taken from them are re-decoded as UTF-8 with FixPath. $latin1 = [Text.Encoding]::GetEncoding(28591) function FixPath([string]$s) { $utf8.GetString($latin1.GetBytes($s)) } -$LockFiles = '(go\.sum|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|composer\.lock|Gemfile\.lock|poetry\.lock|gomod2nix\.toml)' +$LockFiles = '(go\.sum|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|composer\.lock|Gemfile\.lock|poetry\.lock|gomod2nix\.toml|\.terraform\.lock\.hcl|uv\.lock|Pipfile\.lock|Chart\.lock)' $ShardFiles = 30; $ShardLines = 2500; $BinFiles = 15; $BinLines = 1200; $MaxRefSymbols = 200; $MaxRefs = 25 # Full-depth shards weigh whole files, not changed lines. $SetShardFiles = 30; $SetShardLines = 6000; $SetBinFiles = 20; $SetBinLines = 4000 @@ -152,39 +153,60 @@ try { $run = "$ReviewsDir/$slug/$nn" # --- classify --------------------------------------------------------------------- + # Every file in the repo (tracked + untracked, not ignored): marker lookups without a + # filesystem probe per file. Infrastructure code (Terraform, Helm, K8s, Ansible, Dockerfiles, + # CI pipelines) is prod: it is reviewed like any other code. + function DirOf([string]$p) { if ($p -cmatch '/') { $p -creplace '/[^/]*$', '' } else { '.' } } + $repoFiles = @(GitOut ls-files --cached --others --exclude-standard | Where-Object { $_ }) + $charts = @($repoFiles | Where-Object { $_ -cmatch '(^|/)Chart\.yaml$' } | ForEach-Object { DirOf $_ }) + function InChart([string]$p) { foreach ($c in $charts) { if ($c -eq '.' -or $p.StartsWith("$c/", [StringComparison]::Ordinal)) { return $true } }; $false } function LangOf([string]$p) { $b = ($p -creplace '.*/', '').ToLowerInvariant() - switch -Regex -CaseSensitive ($b) { - '\.go$' { return 'go' } - '\.vue$' { return 'vue' } - '\.(ts|tsx|mts|cts)$' { return 'ts' } - '\.(js|jsx|mjs|cjs)$' { return 'js' } - '\.(scss|sass|css|less)$' { return 'css' } - '\.rego$' { return 'rego' } - '\.proto$' { return 'proto' } - '\.sql$' { return 'sql' } - '\.(ya?ml|tpl)$' { return 'yaml' } - '\.(sh|bash|zsh)$' { return 'sh' } - '\.(ps1|psm1)$' { return 'ps1' } - '\.py$' { return 'py' } - '\.(tf|tfvars|hcl)$' { return 'tf' } - '\.(md|markdown|rst|txt|adoc)$' { return 'doc' } - '\.(json|toml|ini|conf|cfg|env)$' { if ($b -cnotmatch '^package(-lock)?\.json$') { return 'config' } } - '^(makefile|.*\.mk)$' { return 'make' } - '^dockerfile|\.dockerfile$|^containerfile' { return 'docker' } - '^go\.(mod|sum|work)$|^package(-lock)?\.json$|^(yarn\.lock|pnpm-lock\.yaml|cargo\.lock)$' { return 'deps' } - } + if ($p -cmatch '(^|/)\.github/workflows/[^/]+\.ya?ml$|(^|/)\.gitlab-ci\.ya?ml$|(^|/)\.gitlab/.*\.ya?ml$|(^|/)\.(circleci|buildkite|tekton|woodpecker)/.*\.ya?ml$' -or + $b -cmatch '^(jenkinsfile.*|.*\.jenkinsfile|action\.ya?ml|azure-pipelines.*\.ya?ml|bitbucket-pipelines\.ya?ml|cloudbuild.*\.ya?ml|\.drone\.ya?ml|\.travis\.ya?ml|\.woodpecker\.ya?ml)$') { return 'ci' } + if ($b -cmatch '\.go$') { return 'go' } + if ($b -cmatch '\.rs$') { return 'rust' } + if ($b -cmatch '\.vue$') { return 'vue' } + if ($b -cmatch '\.(ts|tsx|mts|cts)$') { return 'ts' } + if ($b -cmatch '\.(js|jsx|mjs|cjs)$') { return 'js' } + if ($b -cmatch '\.(scss|sass|css|less)$') { return 'css' } + if ($b -cmatch '\.rego$') { return 'rego' } + if ($b -cmatch '\.proto$') { return 'proto' } + if ($b -cmatch '\.sql$') { return 'sql' } + if ($b -cmatch '\.pyi?$') { return 'py' } + if ($b -ceq '.tflint.hcl') { return 'config' } + if ($b -cmatch '\.(tf|tofu|tfvars|hcl)$|\.tf\.json$') { return 'tf' } + if ($b -cmatch '^(dockerfile|containerfile)([.-].*)?$|\.(dockerfile|containerfile)$|^(docker-)?compose([.-].*)?\.ya?ml$') { return 'docker' } + if ((InChart $p) -and ($b -cmatch '^(chart\.ya?ml|values[^/]*\.(ya?ml|json))$' -or ($p -cmatch '(^|/)templates/' -and $b -cmatch '\.(ya?ml|tpl|txt|json)$'))) { return 'helm' } + if ($b -cmatch '^kustomization(\.ya?ml)?$') { return 'k8s' } + if ($b -ceq 'ansible.cfg' -or ($b -cmatch '\.(ya?ml|j2)$' -and $p -cmatch '(^|/)(roles|playbooks|group_vars|host_vars|inventories|molecule)/')) { return 'ansible' } + if ($b -cmatch '\.(ya?ml|tpl)$') { return 'yaml' } + if ($b -cmatch '\.(sh|bash|zsh)$') { return 'sh' } + if ($b -cmatch '\.(ps1|psm1)$') { return 'ps1' } + if ($b -cmatch '^(go\.(mod|sum|work)|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|cargo\.(toml|lock)|pyproject\.toml|requirements[^/]*\.txt|pipfile(\.lock)?|poetry\.lock|uv\.lock)$') { return 'deps' } + if ($b -cmatch '\.(md|markdown|rst|txt|adoc)$') { return 'doc' } + if ($b -cmatch '\.(json|toml|ini|conf|cfg|env)$') { return 'config' } + if ($b -cmatch '^(makefile|justfile|.*\.mk)$') { return 'make' } + if ($b -cmatch '^(build|workspace)(\.bazel)?$|\.bzl$') { return 'bazel' } + if ($b -cmatch '\.(java|kt|kts|scala|cs|rb|php|c|h|cc|cpp|cxx|hpp|swift|lua|ex|exs|dart|nix|zig|groovy|cue|jsonnet|libsonnet)$') { return $Matches[1] } 'other' } function KindOf([string]$p, [string]$l) { if ($p -cmatch '(^|/)(vendor|node_modules|third_party)/') { return 'vendor' } - if ($p -cmatch '(\.pb\.go|_gen\.go|\.gen\.go|zz_generated[^/]*|\.pb\.[a-z]+|\.min\.js|\.lock)$' -or $p -cmatch '(^|/)(dist|gen|generated)/' -or $p -cmatch ('(^|/)' + $LockFiles + '$')) { return 'generated' } - if ($p -cmatch '(_test\.go|\.(test|spec)\.[jt]sx?|_test\.py|_spec\.rb)$' -or $p -cmatch '(^|/)(tests?|testdata|__tests__|e2e)/') { return 'test' } + if ($p -cmatch '(\.pb\.go|_gen\.go|\.gen\.go|zz_generated[^/]*|\.pb\.[a-z]+|\.min\.js|\.lock)$' -or $p -cmatch '(^|/)(dist|gen|generated|\.terraform)/' -or $p -cmatch ('(^|/)' + $LockFiles + '$')) { return 'generated' } + if ($p -cmatch '(_test\.go|\.(test|spec)\.[jt]sx?|_test\.py|_spec\.rb|\.tftest\.hcl)$' -or $p -cmatch '(^|/)(tests?|testdata|__tests__|e2e|molecule)/' -or $p -cmatch '(^|/)(test_[^/]*\.py|conftest\.py)$') { return 'test' } if ($l -eq 'doc') { return 'doc' } - if ('config', 'yaml', 'deps', 'make', 'docker', 'tf' -contains $l) { return 'config' } + if ('config', 'yaml', 'deps', 'make' -contains $l) { return 'config' } 'prod' } - function LayerOf([string]$p) { + # YamlKind TEXT: k8s for a manifest (apiVersion + kind), ansible for a playbook/task file. + function YamlKind([string]$text) { + if ($text -cmatch '(?m)^- (hosts|import_playbook):|^[ \t-]*ansible\.(builtin|posix)\.') { return 'ansible' } + if ($text -cmatch '(?m)^apiVersion:' -and $text -cmatch '(?m)^kind:') { return 'k8s' } + '' + } + function LayerOf([string]$p, [string]$l) { + if ($l -ne 'go') { return '-' } if ($p -cmatch '(^|/)app/') { return 'app' } if ($p -cmatch '(^|/)stores/|db/[^/]*\.go$') { return 'storage' } if ($p -cmatch '(^|/)business/') { return 'business' } @@ -239,26 +261,32 @@ try { if ($item -and ($item.LinkType -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint))) { $k = 'symlink' } if ($k -ne 'generated' -and $k -ne 'vendor' -and $k -ne 'symlink' -and $r.St -ne 'D' -and (Test-Path -LiteralPath $r.Path -PathType Leaf)) { if ((GitOut check-attr linguist-generated -- $r.Path) -cmatch ': (set|true)$') { $k = 'generated' } - if ($k -ne 'generated' -and 'go', 'proto', 'ts', 'js', 'py' -contains $l) { + if ($k -ne 'generated' -and 'go', 'proto', 'ts', 'js', 'py', 'rust' -contains $l) { if ((Get-Content -LiteralPath $r.Path -TotalCount 5 -ErrorAction SilentlyContinue) -cmatch 'Code generated .*DO NOT EDIT') { $k = 'generated' } } if ($k -ne 'generated' -and 'js', 'ts', 'css' -contains $l) { foreach ($x in [IO.File]::ReadLines((Resolve-Path -LiteralPath $r.Path).Path, $latin1)) { if ($x.Length -gt 1000) { $k = 'generated'; break } } } + if ($l -eq 'yaml') { + $y = YamlKind ([IO.File]::ReadAllText((Resolve-Path -LiteralPath $r.Path).Path, $latin1)) + if ($y) { $l = $y; if ($k -eq 'config') { $k = 'prod' } } + } } $langOf[$r.Path] = $l; $kindOf[$r.Path] = $k - [pscustomobject]@{ St = $r.St; Add = $r.Add; Del = $r.Del; Lang = $l; Kind = $k; Layer = (LayerOf $r.Path); Path = $r.Path; Bytes = $r.Bytes; Start = $r.Start; End = $r.End } + [pscustomobject]@{ St = $r.St; Add = $r.Add; Del = $r.Del; Lang = $l; Kind = $k; Layer = (LayerOf $r.Path $l); Path = $r.Path; Bytes = $r.Bytes; Start = $r.Start; End = $r.End } } $allRecs = @($recs2) function RowOf($f) { "$($f.St)`t$($f.Add)`t$($f.Del)`t$($f.Lang)`t$($f.Kind)`t$($f.Layer)`t$($f.Path)" } - function IsSkipped($f) { $f.Kind -eq 'generated' -or $f.Kind -eq 'vendor' -or $f.Kind -eq 'symlink' } + # Not reviewed: symlinks always; generated and vendored files unless -IncludeGenerated + # (included ones keep kind generated/vendor in files.tsv). + function IsSkipped($f) { $f.Kind -eq 'symlink' -or (-not $IncludeGenerated -and ($f.Kind -eq 'generated' -or $f.Kind -eq 'vendor')) } $fileRecs = @($allRecs | Where-Object { -not (IsSkipped $_) }) $skippedRecs = @($allRecs | Where-Object { IsSkipped $_ }) Out-Lines (Join-Path $ctx 'files.tsv') ($fileRecs | ForEach-Object { RowOf $_ }) Out-Lines (Join-Path $ctx 'skipped.tsv') ($skippedRecs | ForEach-Object { RowOf $_ }) - # Generated and vendored files are never reviewed: move their diff sections out of - # diff.patch into generated.patch (secret scanning only). + # Files not reviewed: move their diff sections out of diff.patch into generated.patch + # (secret scanning only). if ($skippedRecs.Count) { $drop = New-Object 'bool[]' $lines.Count foreach ($f in $skippedRecs) { for ($j = $f.Start; $j -le $f.End; $j++) { $drop[$j] = $true } } @@ -288,9 +316,41 @@ try { if ($s -cmatch '^export (const|let) [A-Za-z_$]') { return "const`t" + (($n -creplace '^(const|let) ', '') -creplace '[^A-Za-z0-9_$].*', '') } '' } + function RsSym([string]$s) { + $n = $s -creplace '^pub(\([^)]*\))? +', '' + while ($n -cmatch '^(async|unsafe|const|default|extern( "[^"]*")?) +[a-z]') { $n = $n -creplace '^[a-z]+( "[^"]*")? +', '' } + if ($n -cmatch '^fn [A-Za-z_]') { return "func`t" + (($n -creplace '^fn ', '') -creplace '[^A-Za-z0-9_].*', '') } + if ($n -cmatch '^(struct|enum|trait|union|type|mod) [A-Za-z_]') { return "type`t" + (($n -creplace '^[a-z]+ ', '') -creplace '[^A-Za-z0-9_].*', '') } + if ($n -cmatch '^macro_rules! *[A-Za-z_]') { return "macro`t" + (($n -creplace '^macro_rules! *', '') -creplace '[^A-Za-z0-9_].*', '') } + '' + } + function PySym([string]$s) { + $n = $s -creplace '^async ', '' + if ($n -cmatch '^def [A-Za-z_]') { return "func`t" + (($n -creplace '^def ', '') -creplace '[^A-Za-z0-9_].*', '') } + if ($n -cmatch '^class [A-Za-z_]') { return "type`t" + (($n -creplace '^class ', '') -creplace '[^A-Za-z0-9_].*', '') } + '' + } + # Terraform: names are the reference address (aws_s3_bucket.this, data.x.y, module.m, var.v). + function TfSym([string]$s) { + if ($s -cnotmatch '^(resource|data|module|variable|output) +"[^"]+"') { return '' } + $k = $s -creplace ' .*', ''; $n = $s -creplace '^[a-z]+ +', '' + if ($k -eq 'resource' -or $k -eq 'data') { + if (-not ($n -cmatch '^"[^"]+" +"[^"]+"')) { return '' } + $n = ($Matches[0] -creplace '"', '') -creplace ' +', '.' + return "$k`t" + $(if ($k -eq 'data') { 'data.' } else { '' }) + $n + } + $null = $n -cmatch '^"([^"]+)"'; $nm = $Matches[1] + $pre = switch ($k) { 'module' { 'module.' } 'variable' { 'var.' } default { '' } } + "$k`t$pre$nm" + } + function HelmSym([string]$s) { if ($s -cnotmatch '\{\{-? *define +"') { return '' }; "template`t" + (($s -creplace '.*define +"', '') -creplace '".*', '') } function Sym([string]$s, [string]$l) { if ($l -eq 'go') { return GoSym $s } if ('ts', 'js', 'vue' -contains $l) { return JsSym $s } + if ($l -eq 'rust') { return RsSym $s } + if ($l -eq 'py') { return PySym $s } + if ($l -eq 'tf') { return TfSym $s } + if ($l -eq 'helm') { return HelmSym $s } '' } @@ -313,8 +373,7 @@ try { if ($ln.StartsWith('+') -or $ln.StartsWith('-')) { $c = $ln[0]; $s = $ln.Substring(1); $l = $langOf[$path]; $k = $kindOf[$path] if ($c -eq '+') { $newln++ } else { $oldln++ } - if ($k -eq 'generated' -or $k -eq 'vendor') { continue } - if ($k -eq 'prod') { if ($c -eq '+') { $prodAdd++ }; $prodF[$path] = 1 } + if ('prod', 'generated', 'vendor' -contains $k) { if ($c -eq '+') { $prodAdd++ }; $prodF[$path] = 1 } if ($k -eq 'test') { $testF[$path] = 1 } if ($k -eq 'doc') { $comments = 1 } $t = $s -creplace '^[ \t]+', '' @@ -343,7 +402,42 @@ try { if ($t -cmatch 'set -e|exit |\|\||ErrorAction|try \{|trap ') { $failure = 1 } if ($t -cmatch '^#') { $comments = 1 } } - { 'yaml', 'py', 'tf', 'make', 'docker' -contains $_ } { if ($t -cmatch '^#') { $comments = 1 } } + 'rust' { + if ($t -cmatch 'Result<|Err\(|\?[;)]|\.unwrap\(|\.expect\(|panic!|unreachable!|todo!|unsafe ') { $failure = 1 } + if ($t -cmatch '^pub |^#\[derive|^impl |^trait ') { $contract = 1 } + if ($t -cmatch '^//|^/\*') { $comments = 1 } + } + 'py' { + if ($t -cmatch '^(try:|except|finally:|raise |assert )|with ') { $failure = 1 } + if ($t -cmatch '^(async )?def [A-Za-z]|^class [A-Za-z]|^__all__') { $contract = 1 } + if ($t -cmatch '^#|^"""') { $comments = 1 } + } + 'tf' { + if ($t -cmatch 'lifecycle|prevent_destroy|create_before_destroy|ignore_changes|precondition|postcondition|validation|^check |depends_on|count *=|for_each *=') { $failure = 1 } + if ($t -cmatch '^(variable|output|module|provider|terraform|required_providers|resource|data|moved|removed|import) ') { $contract = 1 } + if ($t -cmatch '^(#|//|/\*)') { $comments = 1 } + } + { 'helm', 'k8s' -contains $_ } { + if ($t -cmatch 'Probe:|restartPolicy|terminationGracePeriod|preStop|PodDisruptionBudget|maxUnavailable|required |fail ') { $failure = 1 } + if ($t -cmatch '^kind:|^apiVersion:|selector:|matchLabels:|ports:|serviceAccountName|define |\.Values\.' -or $path -cmatch '(^|/)values[^/]*\.(ya?ml|json)$') { $contract = 1 } + if ($t -cmatch '^#|\{\{-? */\*') { $comments = 1 } + } + 'ansible' { + if ($t -cmatch 'failed_when|ignore_errors|changed_when|^- *block:|rescue:|always:|any_errors_fatal|until:|retries:') { $failure = 1 } + if ($path -cmatch '(^|/)(defaults|vars|meta)/' -and $t -cnotmatch '^#') { $contract = 1 } + if ($t -cmatch '^#') { $comments = 1 } + } + 'docker' { + if ($t -cmatch 'HEALTHCHECK|pipefail|set -e|\|\| *exit') { $failure = 1 } + if ($t -cmatch '^(FROM|EXPOSE|ENTRYPOINT|CMD|USER|ENV|ARG|VOLUME|ports:|image:)') { $contract = 1 } + if ($t -cmatch '^#') { $comments = 1 } + } + 'ci' { + if ($t -cmatch 'continue-on-error|allow_failure|always\(\)|\|\| *true|set -e|retry|timeout') { $failure = 1 } + if ($t -cmatch '^on:|workflow_call|workflow_dispatch|inputs:|outputs:|secrets:|permissions:|needs:|stages:|parameters') { $contract = 1 } + if ($t -cmatch '^#|^//') { $comments = 1 } + } + { 'yaml', 'make' -contains $_ } { if ($t -cmatch '^#') { $comments = 1 } } } if ($t -cmatch 'TODO|FIXME|XXX') { $comments = 1 } $sk = Sym $t $l @@ -381,9 +475,17 @@ try { $done = 0 foreach ($s in $syms) { if ($done -ge $MaxRefSymbols) { break }; $done++ + # Search term and file glob per language; Terraform and Helm names are full addresses. $name = $s.Name -creplace '.*\.', '' - $glob = if ($s.Path -cmatch '\.go$') { '*.go' } else { '*.{ts,tsx,js,jsx,mjs,cjs,vue}' } - $hits = @(& rg -n -w --no-heading --sort path -g $glob -g '!vendor/**' -g '!node_modules/**' -g '!.reviews/**' -- $name . 2>$null) + $glob = switch -Regex -CaseSensitive ($s.Path) { + '\.go$' { '*.go'; break } + '\.rs$' { '*.rs'; break } + '\.pyi?$' { '*.{py,pyi}'; break } + '\.(tf|tofu|tfvars|hcl)$|\.tf\.json$' { '*.{tf,tofu,tfvars,hcl}'; if ($s.Kind -ne 'output') { $name = $s.Name }; break } + '\.(yaml|yml|tpl|txt|json)$' { '*.{tpl,yaml,yml,txt}'; $name = $s.Name; break } + default { '*.{ts,tsx,js,jsx,mjs,cjs,vue}' } + } + $hits = @(& rg -n -F -w --no-heading --sort path -g $glob -g '!vendor/**' -g '!node_modules/**' -g '!.reviews/**' -- $name . 2>$null) $c = 0 foreach ($o in $hits) { if (-not $o) { continue } @@ -402,8 +504,8 @@ try { # --- review set (full depth): review-set.tsv ------------------------------------------ # status added deleted lang kind layer path lines — the files.tsv columns plus the file's # line count; status is "-" for files the diff doesn't touch. The same content rules as for - # changed files drop generated files; binary, empty, excluded, and .reviews files are left out. - function DirOf([string]$p) { if ($p -cmatch '/') { $p -creplace '/[^/]*$', '' } else { '.' } } + # changed files drop generated files (or mark them, with -IncludeGenerated); binary, empty, + # excluded, and .reviews files are left out. $setRecs = New-Object System.Collections.Generic.List[object] $setBytes = 0 if ($Depth -ne 'diff') { @@ -413,15 +515,15 @@ try { 'packages' { $dirs = New-Object hashtable ([StringComparer]::Ordinal) foreach ($p in $changed) { $dirs[(DirOf $p)] = $true } - @(GitOut ls-files --cached --others --exclude-standard) | Where-Object { $_ -and $dirs.ContainsKey((DirOf $_)) } + $repoFiles | Where-Object { $dirs.ContainsKey((DirOf $_)) } } - 'project' { @(GitOut ls-files --cached --others --exclude-standard) } + 'project' { $repoFiles } } $rd = $ReviewsDir.TrimEnd('/') + '/' $cls = @(foreach ($p in @($cand | Where-Object { $_ } | OrdinalUnique)) { if ($p.StartsWith($rd, [StringComparison]::Ordinal) -or $p -cmatch '(^|/)\.reviews/' -or (IsExcluded $p)) { continue } $l = LangOf $p; $k = KindOf $p $l - if ($k -ne 'generated' -and $k -ne 'vendor') { [pscustomobject]@{ Lang = $l; Kind = $k; Layer = (LayerOf $p); Path = $p } } + if ($IncludeGenerated -or ($k -ne 'generated' -and $k -ne 'vendor')) { [pscustomobject]@{ Lang = $l; Kind = $k; Layer = (LayerOf $p $l); Path = $p } } }) $lg = New-Object hashtable ([StringComparer]::Ordinal) if ($cls.Count) { @@ -432,15 +534,18 @@ try { $chg = New-Object hashtable ([StringComparer]::Ordinal) foreach ($f in $fileRecs) { $chg[$f.Path] = $f } foreach ($c in $cls) { - if ($lg.ContainsKey($c.Path) -or -not (Test-Path -LiteralPath $c.Path -PathType Leaf)) { continue } + if (-not (Test-Path -LiteralPath $c.Path -PathType Leaf)) { continue } # Regular files only: a symlink can point outside the repo (~/.aws/credentials). $item = Get-Item -LiteralPath $c.Path -Force if ($item.LinkType -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { continue } $raw = [IO.File]::ReadAllBytes((Join-Path $top $c.Path)) if ($raw.Length -eq 0 -or [Array]::IndexOf($raw, [byte]0) -ge 0) { continue } # empty or binary $text = $latin1.GetString($raw) - if ('go', 'proto', 'ts', 'js', 'py' -ccontains $c.Lang -and ((($text -split "`n", 6) | Select-Object -First 5) -join "`n") -cmatch 'Code generated .*DO NOT EDIT') { continue } - if ('js', 'ts', 'css' -ccontains $c.Lang -and @($text -split "`n" | Where-Object { $_.Length -gt 1000 }).Count) { continue } + $gen = $lg.ContainsKey($c.Path) -or + ('go', 'proto', 'ts', 'js', 'py', 'rust' -ccontains $c.Lang -and ((($text -split "`n", 6) | Select-Object -First 5) -join "`n") -cmatch 'Code generated .*DO NOT EDIT') -or + ('js', 'ts', 'css' -ccontains $c.Lang -and @($text -split "`n" | Where-Object { $_.Length -gt 1000 }).Count) + if ($gen) { if (-not $IncludeGenerated) { continue }; $c.Kind = 'generated' } + if ($c.Lang -eq 'yaml') { $y = YamlKind $text; if ($y) { $c.Lang = $y; if ($c.Kind -eq 'config') { $c.Kind = 'prod' } } } $n = [regex]::Matches($text, "`n").Count; if (-not $text.EndsWith("`n")) { $n++ } # = awk records $f = $chg[$c.Path] $st, $a, $d = if ($f) { $f.St, $f.Add, $f.Del } else { '-', 0, 0 } @@ -453,7 +558,7 @@ try { $setLines = 0; foreach ($r in $setRecs) { $setLines += $r.Lines } # --- totals + shards -------------------------------------------------------------- - $rev = @($fileRecs | Where-Object { $_.Kind -ne 'generated' -and $_.Kind -ne 'vendor' }) + $rev = $fileRecs $code = @($rev | Where-Object { $_.Kind -ne 'doc' }) $added = ($rev | Measure-Object Add -Sum).Sum; if (-not $added) { $added = 0 } $deleted = ($rev | Measure-Object Del -Sum).Sum; if (-not $deleted) { $deleted = 0 } @@ -494,7 +599,7 @@ try { if (IsSkipped $f) { $tokNoise += $t $lbl = $f.Kind + $(if ($lock) { '/lockfile' } else { '' }) - $sug = if ($f.Kind -eq 'symlink') { 'skipped: symlinks are never reviewed (the target can be outside the repo)' } else { 'skipped: generated/vendored files are never reviewed; keep them out of the PR' } + $sug = if ($f.Kind -eq 'symlink') { 'skipped: symlinks are never reviewed (the target can be outside the repo)' } else { 'skipped: generated/vendored files are not reviewed unless opted in; keep them out of the PR' } $size.Add([pscustomobject]@{ F = 'noise'; P = $f.Path; M = "$lbl $($f.Add + $f.Del) lines"; T = $t; S = $sug }) continue } @@ -556,12 +661,74 @@ try { # --- tools -------------------------------------------------------------------------- $tools = foreach ($t in 'git', 'gh', 'go', 'gopls', 'staticcheck', 'golangci-lint', 'gosec', 'govulncheck', 'dupl', 'checklocks', 'errcheck', 'nilaway', 'deadcode', 'apidiff', 'gitleaks', 'trufflehog', 'detect-secrets', 'semgrep', 'osv-scanner', 'trivy', 'grype', 'jscpd', 'pmd', 'ast-grep', 'rg', 'shellcheck', - 'node', 'npm', 'pnpm', 'yarn', 'opa', 'conftest', 'buf', 'sqlfluff', 'helm', 'kubectl', 'terraform', 'tflint', 'hadolint') { + 'node', 'npm', 'pnpm', 'yarn', 'opa', 'conftest', 'buf', 'sqlfluff', 'helm', 'kubectl', 'terraform', 'tofu', 'tflint', 'hadolint', + 'cargo', 'cargo-audit', 'cargo-deny', 'rust-analyzer', 'python3', 'ruff', 'mypy', 'pytest', 'bandit', 'pip-audit', 'ansible-lint', + 'yamllint', 'kubeconform', 'kube-linter', 'kustomize', 'checkov', 'actionlint', 'zizmor', 'timeout', 'gtimeout') { $c = Get-Command $t -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($c) { "$t`t$($c.Source)" } } Out-Lines (Join-Path $ctx 'tools.tsv') @($tools) + # --- ecosystems: stack.tsv = eco unit files marker ------------------------------------ + # One row per build/deploy unit of the reviewed files (see intake.sh); "-" as unit for + # file-level ecosystems. The Terraform marker is the binary to use. + $hasTool = @{}; foreach ($r in @($tools)) { $hasTool[($r -split "`t")[0]] = $true } + $tfWant = if ($repoFiles | Where-Object { $_ -cmatch '\.tofu$|(^|/)\.opentofu-version$' } | Select-Object -First 1) { 'tofu' } else { 'terraform' } + $tfBin = $tfWant + if (-not $hasTool[$tfWant]) { + if ($tfWant -eq 'tofu' -and $hasTool['terraform']) { $tfBin = 'terraform' } + elseif ($tfWant -eq 'terraform' -and $hasTool['tofu']) { $tfBin = 'tofu' } + } + $mk = @{}; foreach ($n in 'cargo', 'chart', 'kust', 'pyp', 'ans', 'pkg', 'tfm') { $mk[$n] = New-Object hashtable ([StringComparer]::Ordinal) } + foreach ($f in $repoFiles) { + $b = $f -creplace '.*/', ''; $d = DirOf $f + if ($b -ceq 'Cargo.toml') { $mk.cargo[$d] = 1 } + elseif ($b -ceq 'Chart.yaml') { $mk.chart[$d] = 1 } + elseif ($b -cmatch '^(kustomization\.ya?ml|Kustomization)$') { $mk.kust[$d] = 1 } + elseif ($b -cmatch '^(pyproject\.toml|setup\.py|setup\.cfg)$') { $mk.pyp[$d] = 1 } + elseif ($b -ceq 'ansible.cfg') { $mk.ans[$d] = 1 } + elseif ($b -ceq 'package.json') { $mk.pkg[$d] = 1 } + elseif ($b -cmatch '\.(tf|tofu)$') { $mk.tfm[$d] = 1 } + } + function Up([string]$d, $set) { while ($true) { if ($set.ContainsKey($d)) { return $d }; if ($d -eq '.') { return '' }; $d = DirOf $d } } + function CiOf([string]$p) { + $b = ($p -creplace '.*/', '').ToLowerInvariant() + if ($p -cmatch '(^|/)\.github/' -or $b -cmatch '^action\.ya?ml$') { return 'github-actions' } + if ($p -cmatch '(^|/)\.gitlab') { return 'gitlab-ci' } + if ($b -cmatch 'jenkinsfile') { return 'jenkins' } + if ($p -cmatch '(^|/)\.circleci/') { return 'circleci' } + if ($b -cmatch '^azure-pipelines') { return 'azure-pipelines' } + if ($b -cmatch '^bitbucket-pipelines') { return 'bitbucket-pipelines' } + if ($p -cmatch '(^|/)\.buildkite/') { return 'buildkite' } + if ($p -cmatch '(^|/)\.tekton/') { return 'tekton' } + if ($b -cmatch '^cloudbuild') { return 'cloud-build' } + if ($b -cmatch 'drone') { return 'drone' } + if ($b -cmatch 'travis') { return 'travis' } + 'woodpecker' + } + $units = New-Object hashtable ([StringComparer]::Ordinal) + function AddUnit([string]$e, [string]$u, [string]$m) { $key = "$e`t$u`t$m"; $units[$key] = 1 + $(if ($units.ContainsKey($key)) { $units[$key] } else { 0 }) } + foreach ($r in $scopedRecs) { + if ($r.St -eq 'D') { continue } + $p = $r.Path; $l = $r.Lang; $d = DirOf $p; $b = $p -creplace '.*/', '' + if ($l -eq 'rust' -or $b -ceq 'Cargo.toml') { $u = Up $d $mk.cargo; if ($u) { AddUnit 'rust' $u 'Cargo.toml' } } + elseif ($l -eq 'py' -or $b -ceq 'pyproject.toml' -or $b -cmatch '^requirements[^/]*\.txt$') { $u = Up $d $mk.pyp; if ($u) { AddUnit 'python' $u 'pyproject' } else { AddUnit 'python' '.' '-' } } + elseif ($l -eq 'tf') { AddUnit 'terraform' $(if ($mk.tfm.ContainsKey($d)) { $d } else { '-' }) $tfBin } + elseif ($l -eq 'helm') { $u = Up $d $mk.chart; if ($u) { AddUnit 'helm' $u 'Chart.yaml' } } + elseif ($l -eq 'k8s') { $u = Up $d $mk.kust; if ($u) { AddUnit 'kustomize' $u 'kustomization' } else { AddUnit 'k8s' '-' 'manifests' } } + elseif ($l -eq 'ansible') { $u = Up $d $mk.ans; if ($u) { AddUnit 'ansible' $u 'ansible.cfg' } else { AddUnit 'ansible' '.' '-' } } + elseif ($l -eq 'docker') { AddUnit 'docker' '-' $(if ($b -cmatch 'compose') { 'compose' } else { 'dockerfile' }) } + elseif ($l -eq 'ci') { AddUnit 'ci' '-' (CiOf $p) } + elseif ('js', 'ts', 'vue' -contains $l) { $u = Up $d $mk.pkg; if ($u) { AddUnit 'node' $u 'package.json' } else { AddUnit 'node' '.' '-' } } + } + $goMods = @{}; foreach ($r in $pk) { $m = ($r -split "`t")[0]; $goMods[$m] = 1 + $(if ($goMods.ContainsKey($m)) { $goMods[$m] } else { 0 }) } + $stack = [string[]]@(@($goMods.Keys | ForEach-Object { "go`t$_`t$($goMods[$_])`tgo.mod" }) + + @($units.Keys | ForEach-Object { $f = $_ -split "`t"; "$($f[0])`t$($f[1])`t$($units[$_])`t$($f[2])" })) + [Array]::Sort($stack, [StringComparer]::Ordinal) + Out-Lines (Join-Path $ctx 'stack.tsv') $stack + function Eco([string]$e, [string]$m = '') { [bool]($stack | Where-Object { $f = $_ -split "`t"; $f[0] -ceq $e -and (-not $m -or $f[3] -ceq $m) } | Select-Object -First 1) } + function RepoHas([string]$re) { [bool]($repoFiles | Where-Object { $_ -cmatch $re } | Select-Object -First 1) } + # --- missing.tsv: tool needed_for fix ------------------------------------------------- # Tools this change needs but that are absent, or Go tools built with an older Go release # than the active toolchain (analyzers built that way fail on the newer standard library). @@ -602,6 +769,47 @@ try { } if ($liveLangs['sh']) { Need 'shellcheck' 'shell lint' 'https://github.com/koalaman/shellcheck#installing' } if ($liveLangs['js'] -or $liveLangs['ts'] -or $liveLangs['vue']) { Need 'jscpd' 'JS/TS clone detection' 'npm install -g jscpd' } + if (Eco 'rust') { + Need 'cargo' 'Rust build, fmt, clippy, tests' 'https://rustup.rs' + if ($toolPaths.ContainsKey('cargo')) { + & cargo fmt --version *> $null; if ($LASTEXITCODE -ne 0) { "rustfmt`tcargo fmt --check`trustup component add rustfmt" } + & cargo clippy --version *> $null; if ($LASTEXITCODE -ne 0) { "clippy`tRust lints`trustup component add clippy" } + } + Need 'cargo-audit' 'Rust vulnerability scan' 'cargo install cargo-audit --locked' + if (RepoHas '(^|/)deny\.toml$') { Need 'cargo-deny' 'repo cargo-deny config' 'cargo install cargo-deny --locked' } + } + if (Eco 'terraform') { + if ($tfWant -eq 'tofu') { Need $tfBin 'OpenTofu fmt, init, validate' 'https://opentofu.org/docs/intro/install/' } + else { Need $tfBin 'Terraform fmt, init, validate' 'https://developer.hashicorp.com/terraform/install' } + Need 'tflint' 'Terraform lint' 'https://github.com/terraform-linters/tflint#installation' + } + if (Eco 'helm') { Need 'helm' 'chart lint and render' 'https://helm.sh/docs/intro/install/' } + if ((Eco 'helm') -or (Eco 'kustomize') -or (Eco 'k8s')) { + Need 'kubeconform' 'K8s schema validation' 'go install github.com/yannh/kubeconform/cmd/kubeconform@latest' + Need 'kube-linter' 'K8s best practices' 'go install golang.stackrox.io/kube-linter/cmd/kube-linter@latest' + } + if ((Eco 'kustomize') -and -not $toolPaths.ContainsKey('kubectl')) { + Need 'kustomize' 'render Kustomize roots' 'go install sigs.k8s.io/kustomize/kustomize/v5@latest' + } + if (((Eco 'terraform') -or (Eco 'helm') -or (Eco 'kustomize') -or (Eco 'k8s') -or (Eco 'docker')) -and -not $toolPaths.ContainsKey('checkov')) { + Need 'trivy' 'IaC misconfiguration scan' 'https://trivy.dev/latest/getting-started/installation/' + } + if (Eco 'python') { + Need 'ruff' 'Python lint and format' 'python3 -m pip install ruff' + if (RepoHas '(^|/)(mypy\.ini|\.mypy\.ini)$') { Need 'mypy' 'repo mypy config' 'python3 -m pip install mypy' } + if (RepoHas '(^|/)(test_[^/]*|[^/]*_test)\.py$') { Need 'pytest' 'Python tests' 'python3 -m pip install pytest' } + Need 'bandit' 'Python security scan' 'python3 -m pip install bandit' + if (RepoHas '(^|/)requirements[^/]*\.txt$') { Need 'pip-audit' 'Python vulnerability scan' 'python3 -m pip install pip-audit' } + } + if (Eco 'ansible') { Need 'ansible-lint' 'Ansible lint' 'python3 -m pip install ansible-lint' } + if ((RepoHas '(^|/)\.yamllint(\.ya?ml)?$') -and ($liveLangs['yaml'] -or (Eco 'ansible') -or (Eco 'k8s') -or (Eco 'kustomize') -or (Eco 'ci'))) { + Need 'yamllint' 'repo yamllint config' 'python3 -m pip install yamllint' + } + if (Eco 'docker' 'dockerfile') { Need 'hadolint' 'Dockerfile lint' 'https://github.com/hadolint/hadolint#install' } + if (Eco 'ci' 'github-actions') { + Need 'actionlint' 'GitHub Actions lint' 'go install github.com/rhysd/actionlint/cmd/actionlint@latest' + Need 'zizmor' 'GitHub Actions security' 'python3 -m pip install zizmor' + } if (-not $toolPaths.ContainsKey('trufflehog')) { Need 'gitleaks' 'secret scan' 'go install github.com/zricethezav/gitleaks/v8@latest' } if (@('.semgrep.yml', '.semgrep.yaml', '.semgrep') | Where-Object { Test-Path -LiteralPath $_ }) { Need 'semgrep' 'repo semgrep rules' 'python3 -m pip install semgrep' @@ -613,26 +821,40 @@ try { # --- scope.txt ---------------------------------------------------------------------- $langs = ($rev | Group-Object Lang | ForEach-Object { "$($_.Name):$($_.Count)" }) -join ' ' $branch = GitOut symbolic-ref --short -q HEAD; if (-not $branch) { $branch = 'detached' } + $ecos = (@($stack | ForEach-Object { ($_ -split "`t")[0] }) | Group-Object -NoElement | ForEach-Object { "$($_.Name):$($_.Count)" }) -join ' ' + # idioms: the references/langs/.md files reviewers load for the reviewed languages + $idiomOf = @{ go = 'go'; rust = 'rust'; ansible = 'ansible'; docker = 'docker'; ci = 'ci'; sql = 'sql'; proto = 'proto'; rego = 'rego' + vue = 'web'; ts = 'web'; js = 'web'; css = 'web'; py = 'python'; tf = 'terraform'; helm = 'helm-k8s'; k8s = 'helm-k8s'; sh = 'shell'; ps1 = 'shell'; make = 'shell' } + $idioms = [string[]]@($scopedRecs | Where-Object { $_.St -ne 'D' -and $idiomOf.ContainsKey($_.Lang) } | ForEach-Object { $idiomOf[$_.Lang] } | Select-Object -Unique) + [Array]::Sort($idioms, [StringComparer]::Ordinal) + # unmapped.txt: reviewed files in languages no idiom file covers (for the discovery agent) + $mapped = 'go', 'rust', 'vue', 'ts', 'js', 'css', 'py', 'tf', 'helm', 'k8s', 'ansible', 'docker', 'ci', 'sh', 'ps1', 'make', 'sql', 'proto', 'rego', 'doc', 'config', 'deps', 'yaml' + $unmapped = @($scopedRecs | Where-Object { $_.St -ne 'D' -and $mapped -cnotcontains $_.Lang -and $_.Path -cnotmatch '(^|/)(\.git[a-z]*|\.editorconfig|\.DS_Store|LICENSE[^/]*|NOTICE|COPYING|CODEOWNERS)$' } | + ForEach-Object { "$($_.Lang)`t$($_.Path)" }) + Out-Lines (Join-Path $ctx 'unmapped.txt') $unmapped $setInfo = if ($Depth -ne 'diff') { @("review_files=$($setRecs.Count)", "review_code_files=$($setCode.Count)", "review_lines=$setLines", "review_tokens_est=$tokSet") } else { @() } $scope = @("id=$slug", "nn=$nn", "run=$run", "root=$top", "scope=$desc", "depth=$Depth", "head=$head", "base=$baseSha", "branch=$branch", "files=$($allRecs.Count)", "reviewable_files=$($rev.Count)", "skipped_files=$($skippedRecs.Count)", "lines_added=$added", "lines_deleted=$deleted", - "code_files=$($code.Count)", "code_lines_changed=$codeLines", "languages=$langs", "go_packages=$($pk.Count)", + "code_files=$($code.Count)", "code_lines_changed=$codeLines", "languages=$langs", "ecosystems=$ecos", "idioms=$($idioms -join ',')", + "generated=$(if ($IncludeGenerated) { 'included' } else { 'skipped' })", "go_packages=$($pk.Count)", "symbols=$($syms.Count)", "refs=$($refs.Count)", "shards=$shards", "diff_tokens_est=$tokReview") + $setInfo + @("noise_tokens_est=$tokNoise", "size_warnings=$($sizeLines.Count)", "missing_tools=$($missing.Count)") + $signals Out-Lines (Join-Path $ctx 'scope.txt') $scope - $sizeOut = @(); if ($sizeLines.Count) { $sizeOut = @('# size warnings: flag path metric est_tokens suggestion') + $sizeLines } - $missOut = @(); if ($missing.Count) { $missOut = @('# missing tools: tool needed_for install') + $missing } - if ($DryRun) { '# dry run: nothing written'; $scope; $sizeOut; $missOut; exit 0 } + # summary: scope, stack, unmapped languages (first 20), size warnings, missing tools + $summary = @($scope) + if ($stack.Count) { $summary += @('# stack: eco unit files marker') + $stack } + if ($unmapped.Count) { $summary += @('# unmapped: lang path (first 20)') + @($unmapped | Select-Object -First 20) } + if ($sizeLines.Count) { $summary += @('# size warnings: flag path metric est_tokens suggestion') + $sizeLines } + if ($missing.Count) { $summary += @('# missing tools: tool needed_for install') + $missing } + if ($DryRun) { '# dry run: nothing written'; $summary; exit 0 } New-Item -ItemType Directory -Path $idDir -Force | Out-Null if (Test-Path -LiteralPath $run) { Die 1 "run directory $run already exists" } New-Item -ItemType Directory -Path $run | Out-Null New-Item -ItemType Directory -Path "$run/lenses", "$run/security-issues/poc" | Out-Null Copy-Item -LiteralPath $ctx -Destination "$run/context" -Recurse - $scope - $sizeOut - $missOut + $summary } finally { Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/.agents/skills/ardan-pr-review/scripts/intake.sh b/.agents/skills/ardan-pr-review/scripts/intake.sh index e2dd302..736251a 100644 --- a/.agents/skills/ardan-pr-review/scripts/intake.sh +++ b/.agents/skills/ardan-pr-review/scripts/intake.sh @@ -1,24 +1,25 @@ #!/bin/sh # Usage: sh intake.sh [--id ID] [--base REF | --range A..B | --pr N | --staged | --files F...] # [--exclude PATHSPEC]... [--depth diff|files|packages|project] -# [--max-file-diff N] [--max-file-lines N] [--reviews-dir DIR] [--dry-run] +# [--max-file-diff N] [--max-file-lines N] [--reviews-dir DIR] [--include-generated] +# [--dry-run] # Resolves the review scope in the current git repo and writes the run directory # ///context/ (default .reviews at the repo root) (diff, file inventory, symbols, routing signals, -# shards, packages, tools, missing tools, rules, size warnings). Default scope: unstaged changes + -# untracked files. --exclude drops paths from the scope (git pathspec; repeatable). +# shards, packages, ecosystems, tools, missing tools, rules, size warnings). Default scope: unstaged +# changes + untracked files. --exclude drops paths from the scope (git pathspec; repeatable). # --depth sets what reviewers may report on: diff (default) = the change only; files / packages / # project = a full review of the changed files, every file in their directories, or the whole # repo, listed in review-set.tsv (generated, vendored, binary, and excluded files left out). # Size guard: flags files with > --max-file-diff changed lines (800), files agents must # read with > --max-file-lines lines (2000), noise (generated/vendor/lockfiles) and PRs -# over the shard threshold. Symlinks are never reviewed. --dry-run prints the summary without -# writing anything. +# over the shard threshold. Generated and vendored files are skipped unless --include-generated; +# symlinks are never reviewed. --dry-run prints the summary without writing anything. # Exit: 0 ok | 2 usage | 3 code on disk is not the code under review | 4 empty diff | 5 not a git repo. set -u LC_ALL=C; export LC_ALL # byte-wise sort/awk, identical on every machine -scope=unstaged base='' range='' pr='' id='' reviews=.reviews dry=0 files='' excl='' depth=diff +scope=unstaged base='' range='' pr='' id='' reviews=.reviews dry=0 files='' excl='' depth=diff incgen=0 SHARD_FILES=30 SHARD_LINES=2500 BIN_FILES=15 BIN_LINES=1200 MAX_FILE_DIFF=800 MAX_FILE_LINES=2000 # Full-depth shards weigh whole files, not changed lines. SET_SHARD_FILES=30 SET_SHARD_LINES=6000 SET_BIN_FILES=20 SET_BIN_LINES=4000 @@ -41,8 +42,9 @@ while [ $# -gt 0 ]; do --max-file-diff) MAX_FILE_DIFF=${2-}; shift 2 ;; --max-file-lines) MAX_FILE_LINES=${2-}; shift 2 ;; --reviews-dir) reviews=${2-}; shift 2 ;; + --include-generated) incgen=1; shift ;; --dry-run) dry=1; shift ;; - -h|--help) sed -n '2,16p' "$0"; exit 0 ;; + -h|--help) sed -n '2,17p' "$0"; exit 0 ;; *) die 2 "unknown argument: $1" ;; esac done @@ -161,11 +163,23 @@ done nn=$(printf '%03d' $((last + 1))) run=$reviews/$slug/$nn -# lang/kind/layer of a repo-relative path; shared by files.tsv and review-set.tsv. +# Every file in the repo (tracked + untracked, not ignored): marker lookups (Chart.yaml, +# Cargo.toml, ...) without a fork per file. +git -c core.quotePath=false ls-files --cached --others --exclude-standard >"$tmp/repo.files" +awk '/(^|\/)Chart\.yaml$/ { d = $0; if (d !~ /\//) d = "."; else sub(/\/[^\/]*$/, "", d); print d }' "$tmp/repo.files" >"$tmp/charts" + +# lang/kind/layer of a repo-relative path; shared by files.tsv and review-set.tsv. Needs +# -v CHARTS=. Infrastructure code (Terraform, Helm, K8s, Ansible, +# Dockerfiles, CI pipelines) is prod: it is reviewed like any other code. classify_awk=' + BEGIN { while ((getline l < CHARTS) > 0) chart[++nch] = l } + function inchart(p, i) { for (i = 1; i <= nch; i++) if (chart[i] == "." || index(p, chart[i] "/") == 1) return 1; return 0 } function lang(p, b) { b = p; sub(/.*\//, "", b); b = tolower(b) + if (p ~ /(^|\/)\.github\/workflows\/[^\/]+\.ya?ml$|(^|\/)\.gitlab-ci\.ya?ml$|(^|\/)\.gitlab\/.*\.ya?ml$|(^|\/)\.(circleci|buildkite|tekton|woodpecker)\/.*\.ya?ml$/ || + b ~ /^(jenkinsfile.*|.*\.jenkinsfile|action\.ya?ml|azure-pipelines.*\.ya?ml|bitbucket-pipelines\.ya?ml|cloudbuild.*\.ya?ml|\.drone\.ya?ml|\.travis\.ya?ml|\.woodpecker\.ya?ml)$/) return "ci" if (b ~ /\.go$/) return "go" + if (b ~ /\.rs$/) return "rust" if (b ~ /\.vue$/) return "vue" if (b ~ /\.(ts|tsx|mts|cts)$/) return "ts" if (b ~ /\.(js|jsx|mjs|cjs)$/) return "js" @@ -173,27 +187,34 @@ classify_awk=' if (b ~ /\.rego$/) return "rego" if (b ~ /\.proto$/) return "proto" if (b ~ /\.sql$/) return "sql" + if (b ~ /\.pyi?$/) return "py" + if (b == ".tflint.hcl") return "config" + if (b ~ /\.(tf|tofu|tfvars|hcl)$|\.tf\.json$/) return "tf" + if (b ~ /^(dockerfile|containerfile)([.-].*)?$|\.(dockerfile|containerfile)$|^(docker-)?compose([.-].*)?\.ya?ml$/) return "docker" + if (inchart(p) && (b ~ /^(chart\.ya?ml|values[^\/]*\.(ya?ml|json))$/ || (p ~ /(^|\/)templates\// && b ~ /\.(ya?ml|tpl|txt|json)$/))) return "helm" + if (b ~ /^kustomization(\.ya?ml)?$/) return "k8s" + if (b == "ansible.cfg" || (b ~ /\.(ya?ml|j2)$/ && p ~ /(^|\/)(roles|playbooks|group_vars|host_vars|inventories|molecule)\//)) return "ansible" if (b ~ /\.(ya?ml|tpl)$/) return "yaml" if (b ~ /\.(sh|bash|zsh)$/) return "sh" if (b ~ /\.(ps1|psm1)$/) return "ps1" - if (b ~ /\.py$/) return "py" - if (b ~ /\.(tf|tfvars|hcl)$/) return "tf" + if (b ~ /^(go\.(mod|sum|work)|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|cargo\.(toml|lock)|pyproject\.toml|requirements[^\/]*\.txt|pipfile(\.lock)?|poetry\.lock|uv\.lock)$/) return "deps" if (b ~ /\.(md|markdown|rst|txt|adoc)$/) return "doc" - if (b ~ /^go\.(mod|sum|work)$|^package(-lock)?\.json$|^(yarn\.lock|pnpm-lock\.yaml|cargo\.lock)$/) return "deps" if (b ~ /\.(json|toml|ini|conf|cfg|env)$/) return "config" - if (b ~ /^(makefile|.*\.mk)$/) return "make" - if (b ~ /^dockerfile|\.dockerfile$|^containerfile/) return "docker" + if (b ~ /^(makefile|justfile|.*\.mk)$/) return "make" + if (b ~ /^(build|workspace)(\.bazel)?$|\.bzl$/) return "bazel" + if (match(b, /\.(java|kt|kts|scala|cs|rb|php|c|h|cc|cpp|cxx|hpp|swift|lua|ex|exs|dart|nix|zig|groovy|cue|jsonnet|libsonnet)$/)) return substr(b, RSTART + 1) return "other" } function kind(p, l) { if (p ~ /(^|\/)(vendor|node_modules|third_party)\//) return "vendor" - if (p ~ /(\.pb\.go|_gen\.go|\.gen\.go|zz_generated[^\/]*|\.pb\.[a-z]+|\.min\.js|\.lock)$/ || p ~ /(^|\/)(dist|gen|generated)\// || p ~ /(^|\/)(go\.sum|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|composer\.lock|Gemfile\.lock|poetry\.lock|gomod2nix\.toml)$/) return "generated" - if (p ~ /(_test\.go|\.(test|spec)\.[jt]sx?|_test\.py|_spec\.rb)$/ || p ~ /(^|\/)(tests?|testdata|__tests__|e2e)\//) return "test" + if (p ~ /(\.pb\.go|_gen\.go|\.gen\.go|zz_generated[^\/]*|\.pb\.[a-z]+|\.min\.js|\.lock)$/ || p ~ /(^|\/)(dist|gen|generated|\.terraform)\// || p ~ /(^|\/)(go\.sum|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|composer\.lock|Gemfile\.lock|poetry\.lock|gomod2nix\.toml|\.terraform\.lock\.hcl)$/) return "generated" + if (p ~ /(_test\.go|\.(test|spec)\.[jt]sx?|_test\.py|_spec\.rb|\.tftest\.hcl)$/ || p ~ /(^|\/)(tests?|testdata|__tests__|e2e|molecule)\// || p ~ /(^|\/)(test_[^\/]*\.py|conftest\.py)$/) return "test" if (l == "doc") return "doc" - if (l == "config" || l == "yaml" || l == "deps" || l == "make" || l == "docker" || l == "tf") return "config" + if (l == "config" || l == "yaml" || l == "deps" || l == "make") return "config" return "prod" } - function layer(p) { + function layer(p, l) { + if (l != "go") return "-" if (p ~ /(^|\/)app\//) return "app" if (p ~ /(^|\/)stores\/|db\/[^\/]*\.go$/) return "storage" if (p ~ /(^|\/)business\//) return "business" @@ -202,16 +223,27 @@ classify_awk=' } ' +# yamlkind_awk FILE...: "pathk8s|ansible" for plain YAML that is a K8s manifest +# (apiVersion + kind) or an Ansible playbook/task file. +# shellcheck disable=SC2016 # awk program +yamlkind_awk=' + function out() { if (f == "") return; if (v) print f "\tansible"; else if (a && k) print f "\tk8s" } + FNR == 1 { out(); f = FILENAME; a = k = v = 0 } + /^apiVersion:/ { a = 1 } + /^kind:/ { k = 1 } + /^- (hosts|import_playbook):|^[ \t-]*ansible\.(builtin|posix)\./ { v = 1 } + END { out() }' + # --- per-file inventory: files.tsv ---------------------------------------------- # status added deleted lang kind layer path -awk "$classify_awk"' +awk -v CHARTS="$tmp/charts" "$classify_awk"' # one row per diff section: sec status added deleted lang kind layer path bytes function flush() { if (sec == 0) return if (path == "") path = gitpath if (path == "") return l = lang(path) - printf "%d\t%s\t%d\t%d\t%s\t%s\t%s\t%s\t%d\n", sec, st, add, del, l, lnk ? "symlink" : kind(path, l), layer(path), path, bytes + printf "%d\t%s\t%d\t%d\t%s\t%s\t%s\t%s\t%d\n", sec, st, add, del, l, lnk ? "symlink" : kind(path, l), layer(path, l), path, bytes } { bytes += length($0) + 1 } /^diff --git / { @@ -247,26 +279,34 @@ while IFS="$tab" read -r sec st a d l k ly p by; do case $(git check-attr linguist-generated -- "$p" 2>/dev/null) in *": set"|*": true") k=generated ;; esac if [ "$k" != generated ]; then case $l in - go|proto|ts|js|py) head -n 5 -- "$p" | grep -q 'Code generated .*DO NOT EDIT' && k=generated ;; + go|proto|ts|js|py|rust) head -n 5 -- "$p" | grep -q 'Code generated .*DO NOT EDIT' && k=generated ;; esac case $l in js|ts|css) awk 'length($0) > 1000 { f = 1; exit } END { exit !f }' "$p" && k=generated ;; esac fi + if [ "$l" = yaml ]; then + y=$(awk "$yamlkind_awk" "$p" | cut -f2) + if [ -n "$y" ]; then l=$y; [ "$k" = config ] && k=prod; fi + fi fi printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$sec" "$st" "$a" "$d" "$l" "$k" "$ly" "$p" "$by" done <"$tmp/all.raw" >"$tmp/all.tsv" -# Generated and vendored files and symlinks are never reviewed: move their diff sections out -# of diff.patch into generated.patch (secret scanning only) and list them in skipped.tsv. +# Generated and vendored files (unless --include-generated) and symlinks are not reviewed: move +# their diff sections out of diff.patch into generated.patch (secret scanning only) and list +# them in skipped.tsv. Included generated files keep kind generated/vendor in files.tsv. +# skip_awk: true for an all.tsv row that is not reviewed. +# shellcheck disable=SC2016 # awk code +skip_awk='$6 == "symlink" || (!G && ($6 == "generated" || $6 == "vendor"))' : >"$ctx/skipped.tsv" -awk -F '\t' -v OFS='\t' -v SK="$ctx/skipped.tsv" ' +awk -F '\t' -v OFS='\t' -v SK="$ctx/skipped.tsv" -v G="$incgen" ' { row = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 } - $6 == "generated" || $6 == "vendor" || $6 == "symlink" { print row > SK; next } + '"$skip_awk"' { print row > SK; next } { print row }' "$tmp/all.tsv" >"$ctx/files.tsv" if [ -s "$ctx/skipped.tsv" ]; then - awk -F '\t' -v GEN="$ctx/generated.patch" ' - FNR == NR { if ($6 == "generated" || $6 == "vendor" || $6 == "symlink") drop[$1] = 1; next } + awk -F '\t' -v GEN="$ctx/generated.patch" -v G="$incgen" ' + FNR == NR { if ('"$skip_awk"') drop[$1] = 1; next } /^diff --git / { sec++ } { if (sec in drop) print > GEN; else print }' "$tmp/all.tsv" "$patch" >"$tmp/kept.patch" && mv "$tmp/kept.patch" "$patch" @@ -295,7 +335,42 @@ awk -v SIG="$ctx/signals.txt" -v SYM="$ctx/symbols.tsv" ' if (s ~ /^export (const|let) [A-Za-z_$]/) { sub(/^(const|let) /, "", n); sub(/[^A-Za-z0-9_$].*/, "", n); return "const\t" n } return "" } - function sym(s, l) { return l == "go" ? gosym(s) : (l == "ts" || l == "js" || l == "vue") ? jssym(s) : "" } + function rssym(s, n) { + n = s; sub(/^pub(\([^)]*\))? +/, "", n) + while (n ~ /^(async|unsafe|const|default|extern( "[^"]*")?) +[a-z]/) sub(/^[a-z]+( "[^"]*")? +/, "", n) + if (n ~ /^fn [A-Za-z_]/) { sub(/^fn /, "", n); sub(/[^A-Za-z0-9_].*/, "", n); return "func\t" n } + if (n ~ /^(struct|enum|trait|union|type|mod) [A-Za-z_]/) { sub(/^[a-z]+ /, "", n); sub(/[^A-Za-z0-9_].*/, "", n); return "type\t" n } + if (n ~ /^macro_rules! *[A-Za-z_]/) { sub(/^macro_rules! */, "", n); sub(/[^A-Za-z0-9_].*/, "", n); return "macro\t" n } + return "" + } + function pysym(s, n) { + n = s; sub(/^async /, "", n) + if (n ~ /^def [A-Za-z_]/) { sub(/^def /, "", n); sub(/[^A-Za-z0-9_].*/, "", n); return "func\t" n } + if (n ~ /^class [A-Za-z_]/) { sub(/^class /, "", n); sub(/[^A-Za-z0-9_].*/, "", n); return "type\t" n } + return "" + } + # Terraform: names are the reference address (aws_s3_bucket.this, data.x.y, module.m, var.v). + function tfsym(s, k, n) { + if (s !~ /^(resource|data|module|variable|output) +"[^"]+"/) return "" + k = s; sub(/ .*/, "", k); n = s; sub(/^[a-z]+ +/, "", n) + if (k == "resource" || k == "data") { + if (!match(n, /^"[^"]+" +"[^"]+"/)) return "" + n = substr(n, 1, RLENGTH); gsub(/"/, "", n); sub(/ +/, ".", n) + return k "\t" (k == "data" ? "data." : "") n + } + match(n, /^"[^"]+"/); n = substr(n, 2, RLENGTH - 2) + return k "\t" (k == "module" ? "module." : k == "variable" ? "var." : "") n + } + function helmsym(s, n) { if (s !~ /\{\{-? *define +"/) return ""; n = s; sub(/.*define +"/, "", n); sub(/".*/, "", n); return "template\t" n } + function sym(s, l) { + if (l == "go") return gosym(s) + if (l == "ts" || l == "js" || l == "vue") return jssym(s) + if (l == "rust") return rssym(s) + if (l == "py") return pysym(s) + if (l == "tf") return tfsym(s) + if (l == "helm") return helmsym(s) + return "" + } function note(tab, key, at) { if (key == "") return; if (!((path SUBSEP key) in tab)) tab[path SUBSEP key] = at } /^diff --git / { path = ""; inh = 1; next } inh && /^\+\+\+ / { if ($0 != "+++ /dev/null") { path = substr($0, 5); sub(/^b\//, "", path); sub(/\t.*/, "", path) } inh = 0; next } @@ -311,8 +386,7 @@ awk -v SIG="$ctx/signals.txt" -v SYM="$ctx/symbols.tsv" ' /^[+-]/ { c = substr($0, 1, 1); s = substr($0, 2); l = L[path]; k = K[path] if (c == "+") newln++; else oldln++ - if (k == "generated" || k == "vendor") next - if (k == "prod") { if (c == "+") prodadd++; prodf[path] = 1 } + if (k == "prod" || k == "generated" || k == "vendor") { if (c == "+") prodadd++; prodf[path] = 1 } if (k == "test") testf[path] = 1 if (k == "doc") comments = 1 t = s; sub(/^[ \t]+/, "", t) @@ -336,7 +410,35 @@ awk -v SIG="$ctx/signals.txt" -v SYM="$ctx/symbols.tsv" ' } else if (l == "sh" || l == "ps1") { if (t ~ /set -e|exit |\|\||ErrorAction|try \{|trap /) failure = 1 if (t ~ /^#/) comments = 1 - } else if (l == "yaml" || l == "py" || l == "tf" || l == "make" || l == "docker") { + } else if (l == "rust") { + if (t ~ /Result<|Err\(|\?[;)]|\.unwrap\(|\.expect\(|panic!|unreachable!|todo!|unsafe /) failure = 1 + if (t ~ /^pub |^#\[derive|^impl |^trait /) contract = 1 + if (t ~ /^\/\/|^\/\*/) comments = 1 + } else if (l == "py") { + if (t ~ /^(try:|except|finally:|raise |assert )|with /) failure = 1 + if (t ~ /^(async )?def [A-Za-z]|^class [A-Za-z]|^__all__/) contract = 1 + if (t ~ /^#|^"""/) comments = 1 + } else if (l == "tf") { + if (t ~ /lifecycle|prevent_destroy|create_before_destroy|ignore_changes|precondition|postcondition|validation|^check |depends_on|count *=|for_each *=/) failure = 1 + if (t ~ /^(variable|output|module|provider|terraform|required_providers|resource|data|moved|removed|import) /) contract = 1 + if (t ~ /^(#|\/\/|\/\*)/) comments = 1 + } else if (l == "helm" || l == "k8s") { + if (t ~ /Probe:|restartPolicy|terminationGracePeriod|preStop|PodDisruptionBudget|maxUnavailable|required |fail /) failure = 1 + if (t ~ /^kind:|^apiVersion:|selector:|matchLabels:|ports:|serviceAccountName|define |\.Values\./ || path ~ /(^|\/)values[^\/]*\.(ya?ml|json)$/) contract = 1 + if (t ~ /^#|\{\{-? *\/\*/) comments = 1 + } else if (l == "ansible") { + if (t ~ /failed_when|ignore_errors|changed_when|^- *block:|rescue:|always:|any_errors_fatal|until:|retries:/) failure = 1 + if (path ~ /(^|\/)(defaults|vars|meta)\// && t !~ /^#/) contract = 1 + if (t ~ /^#/) comments = 1 + } else if (l == "docker") { + if (t ~ /HEALTHCHECK|pipefail|set -e|\|\| *exit/) failure = 1 + if (t ~ /^(FROM|EXPOSE|ENTRYPOINT|CMD|USER|ENV|ARG|VOLUME|ports:|image:)/) contract = 1 + if (t ~ /^#/) comments = 1 + } else if (l == "ci") { + if (t ~ /continue-on-error|allow_failure|always\(\)|\|\| *true|set -e|retry|timeout/) failure = 1 + if (t ~ /^on:|workflow_call|workflow_dispatch|inputs:|outputs:|secrets:|permissions:|needs:|stages:|parameters/) contract = 1 + if (t ~ /^#|^\/\//) comments = 1 + } else if (l == "yaml" || l == "make") { if (t ~ /^#/) comments = 1 } if (t ~ /TODO|FIXME|XXX/) comments = 1 @@ -369,10 +471,18 @@ fi # Best effort with rg (word match, capped); reviewers refine with gopls. Skipped files excluded. : >"$ctx/refs.tsv" if command -v rg >/dev/null 2>&1 && [ -s "$ctx/symbols.tsv" ]; then - head -n "$MAX_REF_SYMBOLS" "$ctx/symbols.tsv" | while IFS="$tab" read -r _ _ sym path line; do + head -n "$MAX_REF_SYMBOLS" "$ctx/symbols.tsv" | while IFS="$tab" read -r _ skind sym path line; do + # Search term and file glob per language; Terraform and Helm names are full addresses. name=${sym##*.} - case $path in *.go) glob='*.go' ;; *) glob='*.{ts,tsx,js,jsx,mjs,cjs,vue}' ;; esac - rg -n -w --no-heading --sort path -g "$glob" -g '!vendor/**' -g '!node_modules/**' -g '!.reviews/**' -- "$name" . 2>/dev/null | + case $path in + *.go) glob='*.go' ;; + *.rs) glob='*.rs' ;; + *.py|*.pyi) glob='*.{py,pyi}' ;; + *.tf|*.tofu|*.tfvars|*.hcl|*.tf.json) glob='*.{tf,tofu,tfvars,hcl}'; [ "$skind" = output ] || name=$sym ;; + *.yaml|*.yml|*.tpl|*.txt|*.json) glob='*.{tpl,yaml,yml,txt}'; name=$sym ;; + *) glob='*.{ts,tsx,js,jsx,mjs,cjs,vue}' ;; + esac + rg -n -F -w --no-heading --sort path -g "$glob" -g '!vendor/**' -g '!node_modules/**' -g '!.reviews/**' -- "$name" . 2>/dev/null | awk -F: -v s="$sym" -v dp="$path" -v dl="$line" -v max="$MAX_REFS" -v sk="$ctx/skipped.tsv" -v nm="$name" ' BEGIN { while ((getline l < sk) > 0) { split(l, f, "\t"); skip[f[7]] = 1 } } { p = $1; sub(/^\.\//, "", p); if ((p in skip) || (p == dp && $2 == dl)) next @@ -386,7 +496,8 @@ fi # --- review set (full depth): review-set.tsv ---------------------------------------- # status added deleted lang kind layer path lines — the files.tsv columns plus the file's # line count; status is "-" for files the diff doesn't touch. The same content rules as for -# changed files drop generated files; binary, empty, excluded, and .reviews files are left out. +# changed files drop generated files (or mark them, with --include-generated); binary, empty, +# excluded, and .reviews files are left out. review_files=0 review_lines=0 tok_set=0 : >"$ctx/review-set.tsv" if [ "$depth" != diff ]; then @@ -398,15 +509,14 @@ if [ "$depth" != diff ]; then files) cat "$tmp/set.changed" ;; packages) awk "$dirof"'{ print d }' "$tmp/set.changed" | sort -u >"$tmp/set.dirs" - git -c core.quotePath=false ls-files --cached --others --exclude-standard | - awk "$dirof"'FILENAME != "-" { D[$0] = 1; next } d in D' "$tmp/set.dirs" - ;; - project) git -c core.quotePath=false ls-files --cached --others --exclude-standard ;; - esac | sort -u | awk -F '\t' -v OFS='\t' -v RD="${reviews%/}/" "$classify_awk"' + awk "$dirof"'FILENAME != "-" { D[$0] = 1; next } d in D' "$tmp/set.dirs" - <"$tmp/repo.files" ;; + project) cat "$tmp/repo.files" ;; + esac | sort -u | awk -F '\t' -v OFS='\t' -v RD="${reviews%/}/" -v G="$incgen" -v CHARTS="$tmp/charts" "$classify_awk"' FILENAME != "-" { if ($0 != "") X[$0] = 1; next } $0 == "" || index($0, RD) == 1 || $0 ~ /(^|\/)\.reviews\// { next } { for (x in X) if ($0 == x || index($0, x "/") == 1) next l = lang($0); k = kind($0, l) - if (k != "generated" && k != "vendor") print l, k, layer($0), $0 }' "$tmp/excl" - >"$tmp/set.raw" + if (G || (k != "generated" && k != "vendor")) print l, k, layer($0, l), $0 }' "$tmp/excl" - >"$tmp/set.raw" # Regular files only: a symlink can point outside the repo (~/.aws/credentials) and would # put that file in front of every reviewer. Shell builtins only, so no fork per file. while IFS="$tab" read -r l k ly p; do @@ -420,21 +530,27 @@ if [ "$depth" != diff ]; then sed -n -e 's/: linguist-generated: set$//p' -e 's/: linguist-generated: true$//p' | sed 's|^|./|' >"$tmp/set.skip" # shellcheck disable=SC2016 # awk programs run through xargs - files0 '^(go|proto|ts|js|py)$' | + files0 '^(go|proto|ts|js|py|rust)$' | xargs -0 awk 'FNR > 5 { nextfile } /Code generated .*DO NOT EDIT/ { print FILENAME; nextfile }' >>"$tmp/set.skip" 2>/dev/null # shellcheck disable=SC2016 files0 '^(js|ts|css)$' | xargs -0 awk 'length($0) > 1000 { print FILENAME; nextfile }' >>"$tmp/set.skip" 2>/dev/null + # shellcheck disable=SC2016 + files0 '^yaml$' | xargs -0 awk "$yamlkind_awk" >"$tmp/set.yaml" 2>/dev/null # Text files only: grep -I lists a file when it is non-empty and not binary. # shellcheck disable=SC2016 files0 . | xargs -0 grep -Il . -- 2>/dev/null | tr '\n' '\0' | xargs -0 awk '{ l[FILENAME]++; b[FILENAME] += length($0) + 1 } END { for (f in l) print f "\t" l[f] "\t" b[f] }' >"$tmp/set.size" 2>/dev/null - awk -F '\t' -v OFS='\t' -v SK="$tmp/set.skip" -v SZ="$tmp/set.size" -v FT="$ctx/files.tsv" -v TOT="$tmp/set.tot" ' + awk -F '\t' -v OFS='\t' -v SK="$tmp/set.skip" -v SZ="$tmp/set.size" -v FT="$ctx/files.tsv" -v TOT="$tmp/set.tot" \ + -v YK="$tmp/set.yaml" -v G="$incgen" ' BEGIN { while ((getline l < SK) > 0) skip[substr(l, 3)] = 1 while ((getline l < SZ) > 0) { split(l, f, "\t"); p = substr(f[1], 3); ln[p] = f[2]; by[p] = f[3] } + while ((getline l < YK) > 0) { split(l, f, "\t"); yk[substr(f[1], 3)] = f[2] } while ((getline l < FT) > 0) { split(l, f, "\t"); ch[f[7]] = f[1] OFS f[2] OFS f[3] } } - ($4 in skip) || !($4 in ln) { next } + !($4 in ln) || (($4 in skip) && !G) { next } + $4 in skip { $2 = "generated" } + $4 in yk { $1 = yk[$4]; if ($2 == "config") $2 = "prod" } { print (($4 in ch) ? ch[$4] : "-" OFS 0 OFS 0), $1, $2, $3, $4, ln[$4]; n++; nl += ln[$4]; b += by[$4] } END { print n + 0, nl + 0, int((b + 3) / 4) > TOT }' "$tmp/set.cls" >"$ctx/review-set.tsv" read -r review_files review_lines tok_set <"$tmp/set.tot" @@ -442,8 +558,8 @@ fi # --- totals + shards ------------------------------------------------------------ # shellcheck disable=SC2046 # split the five totals -set -- $(awk -F '\t' '$5 != "generated" && $5 != "vendor" { n++; a += $2; d += $3 } - $5 != "generated" && $5 != "vendor" && $5 != "doc" { cn++; cl += $2 + $3 } +set -- $(awk -F '\t' '{ n++; a += $2; d += $3 } + $5 != "doc" { cn++; cl += $2 + $3 } END { print n + 0, a + 0, d + 0, cn + 0, cl + 0 }' "$ctx/files.tsv") reviewable=$1 added=$2 deleted=$3 codefiles=$4 codelines=$5 total=$(wc -l <"$tmp/all.tsv" | tr -d ' ') @@ -483,14 +599,15 @@ awk -F '\t' '$1 != "D" { print $7 }' "$ctx/files.tsv" | done >"$tmp/filesize.tsv" # all.tsv: sec status added deleted lang kind layer path bytes awk -F '\t' -v OFS='\t' -v MD="$MAX_FILE_DIFF" -v ML="$MAX_FILE_LINES" -v SF="$SHARD_FILES" -v SL="$SHARD_LINES" \ - -v CF="$codefiles" -v CL="$codelines" -v NS="$shards" -v DEPTH="$depth" -v TOT="$tmp/totals" -v OUT="$tmp/size.raw" ' + -v CF="$codefiles" -v CL="$codelines" -v NS="$shards" -v DEPTH="$depth" -v TOT="$tmp/totals" -v OUT="$tmp/size.raw" \ + -v G="$incgen" ' FNR == NR { fl[$1] = $2; fb[$1] = $3; next } { p = $8; n = $3 + $4; t = int(($9 + 3) / 4); b = p; sub(/.*\//, "", b) - if ($6 == "generated" || $6 == "vendor" || $6 == "symlink") { + if ('"$skip_awk"') { noise += t - lock = (b ~ /^(go\.sum|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|composer\.lock|Gemfile\.lock|poetry\.lock|gomod2nix\.toml)$/) - print "noise", p, $6 (lock ? "/lockfile" : "") " " n " lines", t, ($6 == "symlink" ? "skipped: symlinks are never reviewed (the target can be outside the repo)" : "skipped: generated/vendored files are never reviewed; keep them out of the PR") > OUT + lock = (b ~ /^(go\.sum|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|composer\.lock|Gemfile\.lock|poetry\.lock|gomod2nix\.toml|\.terraform\.lock\.hcl|uv\.lock|Pipfile\.lock|Chart\.lock)$/) + print "noise", p, $6 (lock ? "/lockfile" : "") " " n " lines", t, ($6 == "symlink" ? "skipped: symlinks are never reviewed (the target can be outside the repo)" : "skipped: generated/vendored files are not reviewed unless opted in; keep them out of the PR") > OUT next } review += t @@ -541,15 +658,78 @@ git -c core.quotePath=false ls-files 'AGENTS.md' '*/AGENTS.md' 'CLAUDE.md' '*/CL # --- tools ---------------------------------------------------------------------- for t in git gh go gopls staticcheck golangci-lint gosec govulncheck dupl checklocks errcheck nilaway \ deadcode apidiff gitleaks trufflehog detect-secrets semgrep osv-scanner trivy grype jscpd pmd ast-grep \ - rg shellcheck node npm pnpm yarn opa conftest buf sqlfluff helm kubectl terraform tflint hadolint; do + rg shellcheck node npm pnpm yarn opa conftest buf sqlfluff helm kubectl terraform tofu tflint hadolint \ + cargo cargo-audit cargo-deny rust-analyzer python3 ruff mypy pytest bandit pip-audit ansible-lint \ + yamllint kubeconform kube-linter kustomize checkov actionlint zizmor timeout gtimeout; do p=$(command -v "$t" 2>/dev/null) && printf '%s\t%s\n' "$t" "$p" done >"$ctx/tools.tsv" +# --- ecosystems: stack.tsv = eco unit files marker -------------------------------------- +# One row per build/deploy unit of the reviewed files: Go module, Rust crate (Cargo.toml), +# Python project, Terraform module (directory), Helm chart, Kustomize root, Ansible project, +# Node package; "-" as unit for file-level ecosystems (plain K8s manifests, Dockerfiles, CI +# pipelines, where marker names the CI system). The Terraform marker is the binary to use. +has_tool() { grep -q "^$1 " "$ctx/tools.tsv"; } +tfwant=terraform +grep -Eq '\.tofu$|(^|/)\.opentofu-version$' "$tmp/repo.files" && tfwant=tofu +tfbin=$tfwant +if ! has_tool "$tfwant"; then + if [ $tfwant = tofu ] && has_tool terraform; then tfbin=terraform + elif [ $tfwant = terraform ] && has_tool tofu; then tfbin=tofu + fi +fi +{ + awk -F '\t' '{ c[$1]++ } END { for (m in c) print "go\t" m "\t" c[m] "\tgo.mod" }' "$ctx/packages.tsv" + awk -F '\t' -v OFS='\t' -v RF="$tmp/repo.files" -v TF="$tfbin" ' + function dir(p, d) { d = p; if (d !~ /\//) return "."; sub(/\/[^\/]*$/, "", d); return d } + function up(d, set) { while (1) { if (d in set) return d; if (d == ".") return ""; d = dir(d) } } + function base(p, b) { b = p; sub(/.*\//, "", b); return b } + function add(e, u, m) { n[e SUBSEP u SUBSEP m]++ } + function ci(p, b) { + b = tolower(base(p)) + if (p ~ /(^|\/)\.github\// || b ~ /^action\.ya?ml$/) return "github-actions" + if (p ~ /(^|\/)\.gitlab/) return "gitlab-ci" + if (b ~ /jenkinsfile/) return "jenkins" + if (p ~ /(^|\/)\.circleci\//) return "circleci" + if (b ~ /^azure-pipelines/) return "azure-pipelines" + if (b ~ /^bitbucket-pipelines/) return "bitbucket-pipelines" + if (p ~ /(^|\/)\.buildkite\//) return "buildkite" + if (p ~ /(^|\/)\.tekton\//) return "tekton" + if (b ~ /^cloudbuild/) return "cloud-build" + if (b ~ /drone/) return "drone" + if (b ~ /travis/) return "travis" + return "woodpecker" + } + FILENAME == RF { b = base($0); d = dir($0) + if (b == "Cargo.toml") cargo[d] = 1 + else if (b == "Chart.yaml") chart[d] = 1 + else if (b ~ /^(kustomization\.ya?ml|Kustomization)$/) kust[d] = 1 + else if (b ~ /^(pyproject\.toml|setup\.py|setup\.cfg)$/) pyp[d] = 1 + else if (b == "ansible.cfg") ans[d] = 1 + else if (b == "package.json") pkg[d] = 1 + else if (b ~ /\.(tf|tofu)$/) tfm[d] = 1 + next } + $1 == "D" { next } + { p = $7; l = $4; d = dir(p); b = base(p) } + l == "rust" || b == "Cargo.toml" { u = up(d, cargo); if (u != "") add("rust", u, "Cargo.toml"); next } + l == "py" || b == "pyproject.toml" || b ~ /^requirements[^\/]*\.txt$/ { u = up(d, pyp); add("python", u == "" ? "." : u, u == "" ? "-" : "pyproject"); next } + l == "tf" { add("terraform", (d in tfm) ? d : "-", TF); next } + l == "helm" { u = up(d, chart); if (u != "") add("helm", u, "Chart.yaml"); next } + l == "k8s" { u = up(d, kust); if (u != "") add("kustomize", u, "kustomization"); else add("k8s", "-", "manifests"); next } + l == "ansible" { u = up(d, ans); add("ansible", u == "" ? "." : u, u == "" ? "-" : "ansible.cfg"); next } + l == "docker" { add("docker", "-", b ~ /compose/ ? "compose" : "dockerfile"); next } + l == "ci" { add("ci", "-", ci(p)); next } + l == "js" || l == "ts" || l == "vue" { u = up(d, pkg); add("node", u == "" ? "." : u, u == "" ? "-" : "package.json") } + END { for (k in n) { split(k, a, SUBSEP); print a[1], a[2], n[k], a[3] } } + ' "$tmp/repo.files" "$scoped" +} | sort >"$ctx/stack.tsv" +eco() { awk -F '\t' -v e="$1" -v m="${2-}" '$1 == e && (m == "" || $4 == m) { f = 1; exit } END { exit !f }' "$ctx/stack.tsv"; } +repo_has() { grep -Eq "$1" "$tmp/repo.files"; } + # --- missing.tsv: tool needed_for fix --------------------------------------------- # Tools this change needs but that are absent, or Go tools built with an older Go # release than the active toolchain (analyzers built that way fail on the newer # standard library). Checks skip what is missing; the user is told how to install it. -has_tool() { grep -q "^$1 " "$ctx/tools.tsv"; } live_lang() { awk -F '\t' -v l="$1" '$1 != "D" && $4 == l { f = 1; exit } END { exit !f }' "$scoped"; } gominor() { sed -n 's/.*go\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -n 1; } gocur='' @@ -599,6 +779,49 @@ need() { # need TOOL NEEDED_FOR INSTALL [go]: record TOOL if missing (or stale, if live_lang js || live_lang ts || live_lang vue; then need jscpd "JS/TS clone detection" "npm install -g jscpd" fi + if eco rust; then + need cargo "Rust build, fmt, clippy, tests" "https://rustup.rs" + if has_tool cargo; then + cargo fmt --version >/dev/null 2>&1 || printf 'rustfmt\tcargo fmt --check\trustup component add rustfmt\n' + cargo clippy --version >/dev/null 2>&1 || printf 'clippy\tRust lints\trustup component add clippy\n' + fi + need cargo-audit "Rust vulnerability scan" "cargo install cargo-audit --locked" + repo_has '(^|/)deny\.toml$' && need cargo-deny "repo cargo-deny config" "cargo install cargo-deny --locked" + fi + if eco terraform; then + case $tfwant in + tofu) need "$tfbin" "OpenTofu fmt, init, validate" "https://opentofu.org/docs/intro/install/" ;; + *) need "$tfbin" "Terraform fmt, init, validate" "https://developer.hashicorp.com/terraform/install" ;; + esac + need tflint "Terraform lint" "https://github.com/terraform-linters/tflint#installation" + fi + eco helm && need helm "chart lint and render" "https://helm.sh/docs/intro/install/" + if eco helm || eco kustomize || eco k8s; then + need kubeconform "K8s schema validation" "go install github.com/yannh/kubeconform/cmd/kubeconform@latest" + need kube-linter "K8s best practices" "go install golang.stackrox.io/kube-linter/cmd/kube-linter@latest" + fi + if eco kustomize && ! has_tool kubectl; then + need kustomize "render Kustomize roots" "go install sigs.k8s.io/kustomize/kustomize/v5@latest" + fi + if { eco terraform || eco helm || eco kustomize || eco k8s || eco docker; } && ! has_tool checkov; then + need trivy "IaC misconfiguration scan" "https://trivy.dev/latest/getting-started/installation/" + fi + if eco python; then + need ruff "Python lint and format" "python3 -m pip install ruff" + repo_has '(^|/)(mypy\.ini|\.mypy\.ini)$' && need mypy "repo mypy config" "python3 -m pip install mypy" + repo_has '(^|/)(test_[^/]*|[^/]*_test)\.py$' && need pytest "Python tests" "python3 -m pip install pytest" + need bandit "Python security scan" "python3 -m pip install bandit" + repo_has '(^|/)requirements[^/]*\.txt$' && need pip-audit "Python vulnerability scan" "python3 -m pip install pip-audit" + fi + eco ansible && need ansible-lint "Ansible lint" "python3 -m pip install ansible-lint" + if repo_has '(^|/)\.yamllint(\.ya?ml)?$' && { live_lang yaml || eco ansible || eco k8s || eco kustomize || eco ci; }; then + need yamllint "repo yamllint config" "python3 -m pip install yamllint" + fi + eco docker dockerfile && need hadolint "Dockerfile lint" "https://github.com/hadolint/hadolint#install" + if eco ci github-actions; then + need actionlint "GitHub Actions lint" "go install github.com/rhysd/actionlint/cmd/actionlint@latest" + need zizmor "GitHub Actions security" "python3 -m pip install zizmor" + fi has_tool trufflehog || need gitleaks "secret scan" "go install github.com/zricethezav/gitleaks/v8@latest" if ls .semgrep.yml .semgrep.yaml .semgrep >/dev/null 2>&1; then need semgrep "repo semgrep rules" "python3 -m pip install semgrep" @@ -606,7 +829,20 @@ need() { # need TOOL NEEDED_FOR INSTALL [go]: record TOOL if missing (or stale, } >"$ctx/missing.tsv" # --- scope.txt ------------------------------------------------------------------ -langs=$(awk -F '\t' '$5 != "generated" && $5 != "vendor" { c[$4]++ } END { for (l in c) printf "%s:%d ", l, c[l] }' "$ctx/files.tsv") +langs=$(awk -F '\t' '{ c[$4]++ } END { for (l in c) printf "%s:%d ", l, c[l] }' "$ctx/files.tsv") +ecos=$(cut -f1 "$ctx/stack.tsv" | uniq -c | awk '{ printf "%s:%d ", $2, $1 }') +# idioms: the references/langs/.md files reviewers load for the reviewed languages +idioms=$(awk -F '\t' '$1 != "D" { l = $4; i = "" + if (l == "go" || l == "rust" || l == "ansible" || l == "docker" || l == "ci" || l == "sql" || l == "proto" || l == "rego") i = l + else if (l ~ /^(vue|ts|js|css)$/) i = "web" + else if (l == "py") i = "python" + else if (l == "tf") i = "terraform" + else if (l == "helm" || l == "k8s") i = "helm-k8s" + else if (l ~ /^(sh|ps1|make)$/) i = "shell" + if (i != "") print i }' "$scoped" | sort -u | paste -sd, -) +# unmapped.txt: reviewed files in languages no idiom file covers (for the discovery agent) +awk -F '\t' '$1 != "D" && $4 !~ /^(go|rust|vue|ts|js|css|py|tf|helm|k8s|ansible|docker|ci|sh|ps1|make|sql|proto|rego|doc|config|deps|yaml)$/ && + $7 !~ /(^|\/)(\.git[a-z]*|\.editorconfig|\.DS_Store|LICENSE[^\/]*|NOTICE|COPYING|CODEOWNERS)$/ { print $4 "\t" $7 }' "$scoped" >"$ctx/unmapped.txt" { echo "id=$slug" echo "nn=$nn" @@ -625,6 +861,9 @@ langs=$(awk -F '\t' '$5 != "generated" && $5 != "vendor" { c[$4]++ } END { for ( echo "code_files=$codefiles" echo "code_lines_changed=$codelines" echo "languages=${langs% }" + echo "ecosystems=${ecos% }" + echo "idioms=$idioms" + echo "generated=$([ "$incgen" = 1 ] && echo included || echo skipped)" echo "go_packages=$(wc -l <"$ctx/packages.tsv" | tr -d ' ')" echo "symbols=$(wc -l <"$ctx/symbols.tsv" | tr -d ' ')" echo "refs=$(wc -l <"$ctx/refs.tsv" | tr -d ' ')" @@ -642,11 +881,18 @@ langs=$(awk -F '\t' '$5 != "generated" && $5 != "vendor" { c[$4]++ } END { for ( cat "$ctx/signals.txt" } >"$ctx/scope.txt" +# summary: scope, stack, unmapped languages (first 20), size warnings, missing tools +summary() { + cat "$1/scope.txt" + [ -s "$1/stack.tsv" ] && { echo "# stack: eco unit files marker"; cat "$1/stack.tsv"; } + [ -s "$1/unmapped.txt" ] && { echo "# unmapped: lang path (first 20)"; head -n 20 "$1/unmapped.txt"; } + [ -s "$1/size.tsv" ] && { echo "# size warnings: flag path metric est_tokens suggestion"; cat "$1/size.tsv"; } + [ -s "$1/missing.tsv" ] && { echo "# missing tools: tool needed_for install"; cat "$1/missing.tsv"; } + return 0 +} if [ "$dry" = 1 ]; then echo "# dry run: nothing written" - cat "$ctx/scope.txt" - [ -s "$ctx/size.tsv" ] && { echo "# size warnings: flag path metric est_tokens suggestion"; cat "$ctx/size.tsv"; } -[ -s "$ctx/missing.tsv" ] && { echo "# missing tools: tool needed_for install"; cat "$ctx/missing.tsv"; } + summary "$ctx" exit 0 fi @@ -654,7 +900,5 @@ mkdir -p "$reviews/$slug" || die 1 "cannot create $reviews/$slug" mkdir "$run" 2>/dev/null || die 1 "run directory $run already exists" mkdir -p "$run/lenses" "$run/security-issues/poc" || die 1 "cannot populate $run" cp -R "$ctx" "$run/context" || die 1 "cannot write $run/context" -cat "$run/context/scope.txt" -[ -s "$ctx/size.tsv" ] && { echo "# size warnings: flag path metric est_tokens suggestion"; cat "$ctx/size.tsv"; } -[ -s "$ctx/missing.tsv" ] && { echo "# missing tools: tool needed_for install"; cat "$ctx/missing.tsv"; } +summary "$run/context" exit 0 diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 641877e..7f18a08 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -46,14 +46,20 @@ "skills": [ "./.agents/skills/ardan-pr-review" ], - "description": "Guided, read-only PR review: scripted preprocessing and checks run once, parallel lens agents (security, acceptance criteria, correctness, tests, polish) read a shared brief, and an issues-only report plus color-coded Mermaid diagrams are written per run.", + "description": "Guided, read-only PR review for code and infrastructure in any language: scripted preprocessing and per-ecosystem static analysis run once, a discovery agent adapts the run to the repo's stack and quality gates, parallel lens agents (security, acceptance criteria, correctness, tests, polish) read a shared brief, and an issues-only report plus color-coded Mermaid diagrams are written per run.", "category": "code-review", "keywords": [ "review", "pull-request", "security", "acceptance-criteria", - "go" + "go", + "rust", + "python", + "terraform", + "kubernetes", + "helm", + "infrastructure" ], "license": "Apache-2.0", "repository": "https://github.com/ardanlabs/skills" diff --git a/.github/workflows/ardan-pr-review.yml b/.github/workflows/ardan-pr-review.yml new file mode 100644 index 0000000..ac4c8b2 --- /dev/null +++ b/.github/workflows/ardan-pr-review.yml @@ -0,0 +1,76 @@ +name: ardan-pr-review + +on: + pull_request: + paths: + - ".agents/skills/ardan-pr-review/**" + - "zarf/tests/ardan-pr-review/**" + - "Makefile" + - ".github/workflows/ardan-pr-review.yml" + push: + branches: [main] + paths: + - ".agents/skills/ardan-pr-review/**" + - "zarf/tests/ardan-pr-review/**" + - "Makefile" + - ".github/workflows/ardan-pr-review.yml" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: make lint + + # Stub analyzers with the runner's own shell tools: GNU on Linux, BSD on macOS. + host: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install ripgrep, jq, PowerShell + run: | + if [ "$RUNNER_OS" = Linux ]; then + sudo apt-get update && sudo apt-get install -y ripgrep jq + else + brew install ripgrep jq + fi + command -v pwsh >/dev/null || brew install --cask powershell + - run: make test-pr-review-host + + # Real analyzers at the versions pinned in zarf/tests/ardan-pr-review/Dockerfile. + container: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: zarf/tests/ardan-pr-review + load: true + tags: ardan-pr-review-tools:ci + cache-from: type=gha + cache-to: type=gha,mode=max + - run: make test-pr-review-container + env: + IMAGE: ardan-pr-review-tools:ci + NO_BUILD: "1" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..92a41d1 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +# Repository checks. `make test` runs lint and every skill test suite. +# ardan-pr-review suite: see zarf/tests/ardan-pr-review/README.md. + +PR_REVIEW_TESTS := zarf/tests/ardan-pr-review +PR_REVIEW_SCRIPTS := $(wildcard .agents/skills/ardan-pr-review/scripts/*.sh) $(wildcard $(PR_REVIEW_TESTS)/*.sh) + +.PHONY: test lint test-pr-review test-pr-review-host test-pr-review-container test-pr-review-update + +test: lint test-pr-review + +lint: + shellcheck $(PR_REVIEW_SCRIPTS) + +# Host (stub analyzers, the host's sh/awk and PowerShell) and container (real analyzers at +# pinned versions; needs Docker). +test-pr-review: test-pr-review-host test-pr-review-container + +test-pr-review-host: + sh $(PR_REVIEW_TESTS)/run.sh host + +test-pr-review-container: + sh $(PR_REVIEW_TESTS)/run.sh container + +# Rewrite the golden files from both modes after an intended change; review the diff. +test-pr-review-update: + UPDATE=1 sh $(PR_REVIEW_TESTS)/run.sh host + UPDATE=1 sh $(PR_REVIEW_TESTS)/run.sh container diff --git a/README.md b/README.md index 90839f1..94c9c6d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ A collection of AI skills for development work, primarily Go. Compatible with Cl |----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| | [use-modern-go](.agents/skills/use-modern-go/SKILL.md) | Apply modern Go syntax based on the project's Go version. | | [ardan-service-architecture](.agents/skills/ardan-service-architecture/SKILL.md) | Service layering (primitives at edges, strong types in Business, named converters), the `ExtBusiness`/`Extension` decorator seam, and default-first branching. | -| [ardan-pr-review](.agents/skills/ardan-pr-review/SKILL.md) | Guided read-only PR review: scripted intake/checks, parallel lens agents, issues-only findings and color-coded Mermaid diagrams. | +| [ardan-pr-review](.agents/skills/ardan-pr-review/SKILL.md) | Guided read-only PR review for code and infrastructure in any language: scripted intake and per-ecosystem checks, stack discovery, parallel lens agents, issues-only findings and color-coded Mermaid diagrams. | ## Install as a Claude Code plugin @@ -33,7 +33,7 @@ Plugin skills are namespaced, so `ardan-pr-review` is invoked as `/ardan-pr-revi ## PR review tools -`ardan-pr-review` runs the tools below when the change needs them. A missing tool is listed with its install command at the start of a review, and its check is skipped. Go tools built with an older Go than the active toolchain are flagged for a rebuild: analyzers fail on a newer standard library. +`ardan-pr-review` runs the tools below when the change needs them, per ecosystem (Go module, crate, Terraform module, chart, …). A missing tool is listed with its install command at the start of a review, and its check is skipped. Go tools built with an older Go than the active toolchain are flagged for a rebuild: analyzers fail on a newer standard library. Ecosystems not listed here are found by a discovery agent, which also offers the repo's own quality gates (Makefile, CI, pre-commit) as extra checks; only the ones you approve run. | Tool | Checks | Install | |--------------------------------------|-------------------------------------------------|------------------------------------------------------------------------------| @@ -54,9 +54,25 @@ Plugin skills are namespaced, so `ardan-pr-review` is invoked as `/ardan-pr-revi | `shellcheck` | shell scripts | [shellcheck#installing](https://github.com/koalaman/shellcheck#installing) | | `jscpd` | JS/TS clones | `npm install -g jscpd` | | `semgrep` | the repo's own semgrep rules, when it has them | `python3 -m pip install semgrep` | +| `cargo` (`fmt --check`, `clippy`, `test`) | Rust format, lints, tests (`--locked`) | [rustup.rs](https://rustup.rs); `rustup component add clippy rustfmt` | +| `cargo-audit`, `cargo-deny` | Rust advisories; licenses and sources (`deny.toml`) | `cargo install cargo-audit cargo-deny --locked` | +| `terraform` or `tofu` (`fmt`, `init -backend=false`, `validate`) | Terraform/OpenTofu format and validation; init uses a temp data dir and never rewrites the lock file | [terraform](https://developer.hashicorp.com/terraform/install), [opentofu](https://opentofu.org/docs/intro/install/) | +| `tflint` | Terraform lint (repo `.tflint.hcl`) | [tflint#installation](https://github.com/terraform-linters/tflint#installation) | +| `helm` (`lint`, `template`) | chart lint and render | [helm.sh](https://helm.sh/docs/intro/install/) | +| `kustomize` or `kubectl kustomize` | render Kustomize roots | `go install sigs.k8s.io/kustomize/kustomize/v5@latest` | +| `kubeconform` | K8s schema validation of manifests and renders | `go install github.com/yannh/kubeconform/cmd/kubeconform@latest` | +| `kube-linter` | K8s best practices | `go install golang.stackrox.io/kube-linter/cmd/kube-linter@latest` | +| `trivy` (else `checkov`) | IaC misconfiguration: Terraform, charts, manifests, Dockerfiles | [trivy.dev](https://trivy.dev/latest/getting-started/installation/) | +| `ruff`, `mypy`, `pytest` | Python lint/format, types (repo config), tests | `python3 -m pip install ruff mypy pytest` | +| `bandit`, `pip-audit` | Python security; advisories (`requirements*.txt`) | `python3 -m pip install bandit pip-audit` | +| `ansible-lint`, `yamllint` | Ansible lint; YAML lint (repo config) | `python3 -m pip install ansible-lint yamllint` | +| `hadolint` | Dockerfiles | [hadolint#install](https://github.com/hadolint/hadolint#install) | +| `actionlint`, `zizmor` | GitHub Actions lint and security | `go install github.com/rhysd/actionlint/cmd/actionlint@latest`; `python3 -m pip install zizmor` | `checklocks` is installed from gVisor's `go` branch, the only one that builds with plain `go install`. +Changes to the skill's scripts are verified by `make test-pr-review` (see [zarf/tests/ardan-pr-review](zarf/tests/ardan-pr-review/README.md)): a fixture repo covering every supported ecosystem, golden results, and sh ↔ PowerShell parity, with stub analyzers on the host and the real tools above in a pinned Docker image. + ## Claude Code settings Example Claude Code configuration, split into global (user-level) and project-level. @@ -80,6 +96,9 @@ CLAUDE.md # Claude Code entry point -> AGENTS.md .claude-plugin/ # marketplace.json — plugin catalog pointing at .agents/skills claude/global/ # example user-level Claude Code config claude/project/ # example project-level Claude Code config +Makefile # `make test`: lint + skill test suites +zarf/tests/ # skill test suites (ardan-pr-review: fixture, golden results, tools image) +.github/workflows/ # CI running those suites ``` `.claude/skills` is a symlink so Claude Code and `.agents`-aware tools share one source. diff --git a/zarf/tests/ardan-pr-review/Dockerfile b/zarf/tests/ardan-pr-review/Dockerfile new file mode 100644 index 0000000..3acf7fa --- /dev/null +++ b/zarf/tests/ardan-pr-review/Dockerfile @@ -0,0 +1,101 @@ +# Every analyzer the ardan-pr-review checks scripts can run, at pinned versions, plus PowerShell +# for the PowerShell port. Builds for amd64 and arm64. Bump a version deliberately, re-run +# `make test-pr-review`, and update expected/ if the change in results is intended. +FROM golang:1.27.3-bookworm + +ARG UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:157.0) Gecko/20100101 Firefox/157.0" +ARG TARGETARCH +ARG PWSH_VERSION=7.6.9 +ARG HADOLINT_VERSION=2.15.1 +ARG TERRAFORM_VERSION=1.17.1 +ARG TOFU_VERSION=1.13.2 +ARG TFLINT_VERSION=0.65.0 +ARG HELM_VERSION=4.3.1 +ARG TRIVY_VERSION=0.76.1 +ARG KUBECONFORM_VERSION=0.8.0 +ARG KUBE_LINTER_VERSION=0.8.3 +ARG ACTIONLINT_VERSION=1.7.12 +ARG KUSTOMIZE_VERSION=5.8.3 +ARG GITLEAKS_VERSION=8.30.1 +ARG STATICCHECK_VERSION=0.8.2 +ARG GOVULNCHECK_VERSION=1.9.0 +ARG GOSEC_VERSION=2.30.0 +ARG DUPL_VERSION=1.1.0 +ARG ERRCHECK_VERSION=1.30.0 +ARG NILAWAY_VERSION=v0.0.0-20260918162853-acb8859b9031 +ARG XTOOLS_VERSION=0.52.0 +ARG XEXP_VERSION=v0.0.0-20261014143641-f0251eb4aa3e +ARG CHECKLOCKS_VERSION=v0.0.0-20261103030936-1899f71d79be +ARG RUST_VERSION=1.99.1 +ARG CARGO_AUDIT_VERSION=0.22.2 +ARG CARGO_DENY_VERSION=0.20.2 +ARG RUFF_VERSION=0.17.3 +ARG MYPY_VERSION=2.4.1 +ARG PYTEST_VERSION=9.2.0 +ARG BANDIT_VERSION=1.9.4 +ARG PIP_AUDIT_VERSION=2.10.1 +ARG ANSIBLE_LINT_VERSION=26.10.1 +ARG YAMLLINT_VERSION=1.38.0 +ARG ZIZMOR_VERSION=1.32.0 + +ENV PATH=/root/.cargo/bin:/root/.local/bin:/root/go/bin:/opt/pwsh:$PATH + +RUN apt-get update && apt-get install -y --no-install-recommends \ + jq ripgrep shellcheck python3 python3-venv pipx unzip ca-certificates curl coreutils libicu72 \ + && rm -rf /var/lib/apt/lists/* + +# Release binaries. "a" is the Debian arch (amd64/arm64); some projects spell it differently. +RUN a=${TARGETARCH:-$(dpkg --print-architecture)}; p=$( [ "$a" = amd64 ] && echo x64 || echo arm64 ) \ + && mkdir -p /opt/pwsh \ + && curl -fsSL --retry 5 --retry-all-errors -A "$UA" "https://github.com/PowerShell/PowerShell/releases/download/v${PWSH_VERSION}/powershell-${PWSH_VERSION}-linux-$p.tar.gz" | tar -xz -C /opt/pwsh \ + && chmod +x /opt/pwsh/pwsh +RUN a=${TARGETARCH:-$(dpkg --print-architecture)}; h=$( [ "$a" = amd64 ] && echo x86_64 || echo arm64 ) \ + && curl -fsSL --retry 5 --retry-all-errors -A "$UA" -o /usr/local/bin/hadolint "https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-Linux-$h" \ + && chmod +x /usr/local/bin/hadolint +RUN a=${TARGETARCH:-$(dpkg --print-architecture)} \ + && curl -fsSL --retry 5 --retry-all-errors -A "$UA" -o /tmp/tf.zip "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_$a.zip" \ + && unzip -q -o /tmp/tf.zip terraform -d /usr/local/bin && rm /tmp/tf.zip +RUN a=${TARGETARCH:-$(dpkg --print-architecture)} \ + && curl -fsSL --retry 5 --retry-all-errors -A "$UA" -o /tmp/tofu.zip "https://github.com/opentofu/opentofu/releases/download/v${TOFU_VERSION}/tofu_${TOFU_VERSION}_linux_$a.zip" \ + && unzip -q -o /tmp/tofu.zip tofu -d /usr/local/bin && rm /tmp/tofu.zip +RUN a=${TARGETARCH:-$(dpkg --print-architecture)} \ + && curl -fsSL --retry 5 --retry-all-errors -A "$UA" -o /tmp/tflint.zip "https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/tflint_linux_$a.zip" \ + && unzip -q -o /tmp/tflint.zip -d /usr/local/bin && rm /tmp/tflint.zip +RUN a=${TARGETARCH:-$(dpkg --print-architecture)} \ + && curl -fsSL --retry 5 --retry-all-errors -A "$UA" "https://get.helm.sh/helm-v${HELM_VERSION}-linux-$a.tar.gz" | tar -xz -C /tmp \ + && mv "/tmp/linux-$a/helm" /usr/local/bin/ +RUN a=${TARGETARCH:-$(dpkg --print-architecture)}; t=$( [ "$a" = amd64 ] && echo 64bit || echo ARM64 ) \ + && curl -fsSL --retry 5 --retry-all-errors -A "$UA" "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-$t.tar.gz" | tar -xz -C /usr/local/bin trivy +RUN a=${TARGETARCH:-$(dpkg --print-architecture)} \ + && curl -fsSL --retry 5 --retry-all-errors -A "$UA" "https://github.com/yannh/kubeconform/releases/download/v${KUBECONFORM_VERSION}/kubeconform-linux-$a.tar.gz" | tar -xz -C /usr/local/bin kubeconform + +# Go-built tools, one step each. +RUN go install golang.stackrox.io/kube-linter/cmd/kube-linter@v${KUBE_LINTER_VERSION} && go clean -cache +RUN go install github.com/rhysd/actionlint/cmd/actionlint@v${ACTIONLINT_VERSION} && go clean -cache +RUN go install sigs.k8s.io/kustomize/kustomize/v5@v${KUSTOMIZE_VERSION} && go clean -cache +RUN go install github.com/zricethezav/gitleaks/v8@v${GITLEAKS_VERSION} && go clean -cache +RUN go install honnef.co/go/tools/cmd/staticcheck@v${STATICCHECK_VERSION} \ + && go install golang.org/x/vuln/cmd/govulncheck@v${GOVULNCHECK_VERSION} \ + && go install github.com/securego/gosec/v2/cmd/gosec@v${GOSEC_VERSION} \ + && go install github.com/mibk/dupl@v${DUPL_VERSION} \ + && go install github.com/kisielk/errcheck@v${ERRCHECK_VERSION} \ + && go install go.uber.org/nilaway/cmd/nilaway@${NILAWAY_VERSION} \ + && go install golang.org/x/tools/cmd/deadcode@v${XTOOLS_VERSION} \ + && go install golang.org/x/exp/cmd/apidiff@${XEXP_VERSION} \ + && go install gvisor.dev/gvisor/tools/checklocks/cmd/checklocks@${CHECKLOCKS_VERSION} \ + && go clean -cache + +# Python tools, each isolated. +RUN pipx install "ruff==${RUFF_VERSION}" && pipx install "mypy==${MYPY_VERSION}" \ + && pipx install "pytest==${PYTEST_VERSION}" && pipx install "bandit==${BANDIT_VERSION}" \ + && pipx install "pip-audit==${PIP_AUDIT_VERSION}" && pipx install "ansible-lint==${ANSIBLE_LINT_VERSION}" \ + && pipx install "yamllint==${YAMLLINT_VERSION}" && pipx install "zizmor==${ZIZMOR_VERSION}" + +# Rust toolchain + audit tools. +RUN curl -fsSL --retry 5 --retry-all-errors -A "$UA" https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain "${RUST_VERSION}" --component clippy,rustfmt \ + && cargo install --locked "cargo-audit@${CARGO_AUDIT_VERSION}" "cargo-deny@${CARGO_DENY_VERSION}" \ + && rm -rf /root/.cargo/registry/cache + +RUN git config --global user.email test@example.com && git config --global user.name test \ + && git config --global init.defaultBranch main +WORKDIR /work diff --git a/zarf/tests/ardan-pr-review/README.md b/zarf/tests/ardan-pr-review/README.md new file mode 100644 index 0000000..9948e15 --- /dev/null +++ b/zarf/tests/ardan-pr-review/README.md @@ -0,0 +1,28 @@ +# ardan-pr-review test suite + +Regression tests for the skill's scripts (`.agents/skills/ardan-pr-review/scripts/`). Run them after any change to a script or to the file classification, symbols, signals, or checks. + +``` +make test-pr-review # host + container +make test-pr-review-host # stub analyzers, the host's sh/awk/sed and PowerShell (needs git, pwsh, rg, jq) +make test-pr-review-container # real analyzers at pinned versions (needs Docker) +make test-pr-review-update # rewrite expected/ after an intended change; review the diff +``` + +CI (`.github/workflows/ardan-pr-review.yml`) runs `lint`, host mode on Linux and macOS, and container mode on every PR that touches the skill or these tests. + +## What it checks + +`fixture.sh` builds a git repo whose feature branch has real, tool-detectable problems in every ecosystem the scripts know (Go, Rust, Python, Terraform, Helm, Kustomize, plain K8s, Ansible, Dockerfile, GitHub Actions, Java as an unmapped language), plus a generated file and a symlink. `suite.sh` then asserts: + +- **sh ↔ PowerShell parity** — `intake.sh`/`intake.ps1` write identical context files at diff, project, and packages + `--include-generated` depth; `checks.sh`/`checks.ps1` give the same check, status, and lens; both `extract-findings` ports save the same files. +- **Golden results** — `expected/` holds the intake outputs (files, stack, symbols, refs, signals, review sets, …) and the check statuses per mode (`host.checks.tsv`, `container.checks.tsv`, `tofu.*`). +- **Read-only** — checks leave `git status` unchanged (cargo's gitignored `target/` excepted), including Terraform init and the OpenTofu pass. + +Host mode replaces every analyzer with a stub, so it tests routing and both ports on BSD (macOS) and GNU (Linux) userlands. Container mode runs the real tools from `Dockerfile`. + +## Changing things + +- **Script change with intended output changes:** `make test-pr-review-update`, then read the `expected/` diff before committing. +- **Tool upgrade:** bump the `ARG` in `Dockerfile`, run `make test-pr-review-container`; a changed status means the tool changed behavior — confirm it, then update. +- Network access is needed in container mode: Terraform providers, advisory databases (cargo-audit, pip-audit, govulncheck), trivy's check bundle, kubeconform schemas. diff --git a/zarf/tests/ardan-pr-review/expected/container.checks.tsv b/zarf/tests/ardan-pr-review/expected/container.checks.tsv new file mode 100644 index 0000000..8a228c2 --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/container.checks.tsv @@ -0,0 +1,44 @@ +check status lens +actionlint fail service-steward +ansible-lint fail service-steward +apidiff pass boundary-keeper +bandit fail security-sentinel +cargo-audit pass security-sentinel +cargo-clippy fail service-steward +cargo-deny fail security-sentinel +cargo-fmt pass service-steward +cargo-test fail harness-map +checklocks pass service-steward +deadcode findings straight-line-pass +dupl pass clone-hunter +errcheck pass error-tripwire +gitleaks pass security-sentinel +go-build pass service-steward +go-fix pass service-steward +go-mod-tidy pass service-steward +go-test fail harness-map +go-vet fail service-steward +gofmt findings service-steward +gosec pass security-sentinel +govulncheck pass security-sentinel +hadolint fail service-steward +helm-lint pass service-steward +helm-render fail service-steward +iac-scan fail security-sentinel +k8s-manifests fail service-steward +kustomize fail service-steward +mypy fail service-steward +nilaway pass service-steward +pip-audit@requirements fail security-sentinel +pytest fail harness-map +ruff-format fail service-steward +ruff fail service-steward +staticcheck fail service-steward +tf-fmt fail service-steward +tf-validate@infra_locked pass service-steward +tf-validate@infra_nolock fail service-steward +tflint@infra_locked fail service-steward +tflint@infra_nolock fail service-steward +x-echo-x pass service-steward +yamllint pass service-steward +zizmor fail security-sentinel diff --git a/zarf/tests/ardan-pr-review/expected/diff.files.tsv b/zarf/tests/ardan-pr-review/expected/diff.files.tsv new file mode 100644 index 0000000..9ce4039 --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/diff.files.tsv @@ -0,0 +1,14 @@ +M 2 0 ci prod - .github/workflows/ci.yml +M 2 2 docker prod - Dockerfile +M 10 0 go prod app app/fx/main.go +M 1 0 helm prod - charts/api/templates/_helpers.tpl +M 2 2 helm prod - charts/api/values.yaml +M 12 0 rust prod - crates/core/src/lib.rs +M 1 1 k8s prod - deploy/base/deploy.yaml +M 3 0 tf prod - infra/locked/main.tf +M 4 0 tf prod - infra/nolock/main.tf +M 1 0 java prod - java/Foo.java +M 1 0 k8s prod - k8s/cm.yaml +M 8 0 py prod - py/pkg/mod.py +M 1 1 deps config - py/requirements.txt +M 2 0 ansible prod - roles/web/tasks/main.yml diff --git a/zarf/tests/ardan-pr-review/expected/diff.packages.tsv b/zarf/tests/ardan-pr-review/expected/diff.packages.tsv new file mode 100644 index 0000000..0bcef8c --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/diff.packages.tsv @@ -0,0 +1 @@ +. ./app/fx diff --git a/zarf/tests/ardan-pr-review/expected/diff.refs.tsv b/zarf/tests/ardan-pr-review/expected/diff.refs.tsv new file mode 100644 index 0000000..cd3ae96 --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/diff.refs.tsv @@ -0,0 +1,2 @@ +Parse app/fx/main.go:13 +double crates/core/src/lib.rs:13 diff --git a/zarf/tests/ardan-pr-review/expected/diff.scope.txt b/zarf/tests/ardan-pr-review/expected/diff.scope.txt new file mode 100644 index 0000000..34e4972 --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/diff.scope.txt @@ -0,0 +1,29 @@ +id=diff +nn=001 +scope=main...HEAD +depth=diff +branch=feature +files=16 +reviewable_files=14 +skipped_files=2 +lines_added=50 +lines_deleted=6 +code_files=14 +code_lines_changed=56 +ecosystems=ansible:1 ci:1 docker:1 go:1 helm:1 k8s:1 kustomize:1 python:1 rust:1 terraform:2 +idioms=ansible,ci,docker,go,helm-k8s,python,rust,terraform +generated=skipped +go_packages=1 +symbols=8 +refs=2 +shards=0 +diff_tokens_est=1028 +noise_tokens_est=109 +size_warnings=2 +prod_files=13 +test_files=0 +prod_lines_added=49 +failure=1 +contract=1 +comments=1 +logic=1 diff --git a/zarf/tests/ardan-pr-review/expected/diff.signals.txt b/zarf/tests/ardan-pr-review/expected/diff.signals.txt new file mode 100644 index 0000000..5d70ee7 --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/diff.signals.txt @@ -0,0 +1,7 @@ +prod_files=13 +test_files=0 +prod_lines_added=49 +failure=1 +contract=1 +comments=1 +logic=1 diff --git a/zarf/tests/ardan-pr-review/expected/diff.skipped.tsv b/zarf/tests/ardan-pr-review/expected/diff.skipped.tsv new file mode 100644 index 0000000..b2ad15e --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/diff.skipped.tsv @@ -0,0 +1,2 @@ +A 1 0 other symlink - app/fx/hosts-link +A 5 0 go generated app app/fx/zz_generated.go diff --git a/zarf/tests/ardan-pr-review/expected/diff.stack.tsv b/zarf/tests/ardan-pr-review/expected/diff.stack.tsv new file mode 100644 index 0000000..42c44ce --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/diff.stack.tsv @@ -0,0 +1,11 @@ +ansible . 1 - +ci - 1 github-actions +docker - 1 dockerfile +go . 1 go.mod +helm charts/api 2 Chart.yaml +k8s - 1 manifests +kustomize deploy/base 1 kustomization +python py 2 pyproject +rust crates/core 1 Cargo.toml +terraform infra/locked 1 terraform +terraform infra/nolock 1 terraform diff --git a/zarf/tests/ardan-pr-review/expected/diff.symbols.tsv b/zarf/tests/ardan-pr-review/expected/diff.symbols.tsv new file mode 100644 index 0000000..9a3cdb1 --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/diff.symbols.tsv @@ -0,0 +1,8 @@ +added func Parse app/fx/main.go 14 +added template api.name charts/api/templates/_helpers.tpl 4 +added func double crates/core/src/lib.rs 5 +added type tests crates/core/src/lib.rs 10 +added func double_works crates/core/src/lib.rs 12 +added output name infra/locked/main.tf 12 +added resource random_pet.extra infra/nolock/main.tf 12 +added func broken py/pkg/mod.py 6 diff --git a/zarf/tests/ardan-pr-review/expected/diff.unmapped.txt b/zarf/tests/ardan-pr-review/expected/diff.unmapped.txt new file mode 100644 index 0000000..e5aff03 --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/diff.unmapped.txt @@ -0,0 +1 @@ +java java/Foo.java diff --git a/zarf/tests/ardan-pr-review/expected/gen.files.tsv b/zarf/tests/ardan-pr-review/expected/gen.files.tsv new file mode 100644 index 0000000..1823591 --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/gen.files.tsv @@ -0,0 +1,15 @@ +M 2 0 ci prod - .github/workflows/ci.yml +M 2 2 docker prod - Dockerfile +M 10 0 go prod app app/fx/main.go +A 5 0 go generated app app/fx/zz_generated.go +M 1 0 helm prod - charts/api/templates/_helpers.tpl +M 2 2 helm prod - charts/api/values.yaml +M 12 0 rust prod - crates/core/src/lib.rs +M 1 1 k8s prod - deploy/base/deploy.yaml +M 3 0 tf prod - infra/locked/main.tf +M 4 0 tf prod - infra/nolock/main.tf +M 1 0 java prod - java/Foo.java +M 1 0 k8s prod - k8s/cm.yaml +M 8 0 py prod - py/pkg/mod.py +M 1 1 deps config - py/requirements.txt +M 2 0 ansible prod - roles/web/tasks/main.yml diff --git a/zarf/tests/ardan-pr-review/expected/gen.review-set.tsv b/zarf/tests/ardan-pr-review/expected/gen.review-set.tsv new file mode 100644 index 0000000..3250294 --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/gen.review-set.tsv @@ -0,0 +1,24 @@ +M 2 0 ci prod - .github/workflows/ci.yml 9 +- 0 0 other prod - .yamllint 3 +M 2 2 docker prod - Dockerfile 2 +M 10 0 go prod app app/fx/main.go 21 +- 0 0 go test app app/fx/main_test.go 9 +A 5 0 go generated app app/fx/zz_generated.go 5 +- 0 0 helm prod - charts/api/Chart.yaml 3 +M 1 0 helm prod - charts/api/templates/_helpers.tpl 4 +- 0 0 helm prod - charts/api/templates/deploy.yaml 17 +M 2 2 helm prod - charts/api/values.yaml 2 +M 12 0 rust prod - crates/core/src/lib.rs 15 +M 1 1 k8s prod - deploy/base/deploy.yaml 16 +- 0 0 k8s prod - deploy/base/kustomization.yaml 2 +- 0 0 deps config - go.mod 3 +- 0 0 tf generated - infra/locked/.terraform.lock.hcl 22 +M 3 0 tf prod - infra/locked/main.tf 14 +M 4 0 tf prod - infra/nolock/main.tf 15 +M 1 0 java prod - java/Foo.java 2 +M 1 0 k8s prod - k8s/cm.yaml 7 +- 0 0 config config - py/mypy.ini 1 +M 8 0 py prod - py/pkg/mod.py 10 +- 0 0 deps config - py/pyproject.toml 3 +M 1 1 deps config - py/requirements.txt 1 +M 2 0 ansible prod - roles/web/tasks/main.yml 4 diff --git a/zarf/tests/ardan-pr-review/expected/gen.skipped.tsv b/zarf/tests/ardan-pr-review/expected/gen.skipped.tsv new file mode 100644 index 0000000..c80f5a2 --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/gen.skipped.tsv @@ -0,0 +1 @@ +A 1 0 other symlink - app/fx/hosts-link diff --git a/zarf/tests/ardan-pr-review/expected/host.checks.tsv b/zarf/tests/ardan-pr-review/expected/host.checks.tsv new file mode 100644 index 0000000..ad4fc5b --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/host.checks.tsv @@ -0,0 +1,44 @@ +check status lens +actionlint pass service-steward +ansible-lint pass service-steward +apidiff findings boundary-keeper +bandit pass security-sentinel +cargo-audit pass security-sentinel +cargo-clippy pass service-steward +cargo-deny pass security-sentinel +cargo-fmt pass service-steward +cargo-test pass harness-map +checklocks pass service-steward +deadcode pass straight-line-pass +dupl findings clone-hunter +errcheck pass error-tripwire +gitleaks pass security-sentinel +go-build pass service-steward +go-fix skipped service-steward +go-mod-tidy pass service-steward +go-test pass harness-map +go-vet pass service-steward +gofmt findings service-steward +gosec pass security-sentinel +govulncheck pass security-sentinel +hadolint pass service-steward +helm-lint pass service-steward +helm-render pass service-steward +iac-scan pass security-sentinel +k8s-manifests pass service-steward +kustomize pass service-steward +mypy pass service-steward +nilaway pass service-steward +pip-audit@requirements pass security-sentinel +pytest pass harness-map +ruff-format pass service-steward +ruff pass service-steward +staticcheck pass service-steward +tf-fmt pass service-steward +tf-validate@infra_locked pass service-steward +tf-validate@infra_nolock pass service-steward +tflint@infra_locked pass service-steward +tflint@infra_nolock pass service-steward +x-echo-x pass service-steward +yamllint pass service-steward +zizmor pass security-sentinel diff --git a/zarf/tests/ardan-pr-review/expected/project.review-set.tsv b/zarf/tests/ardan-pr-review/expected/project.review-set.tsv new file mode 100644 index 0000000..720e253 --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/project.review-set.tsv @@ -0,0 +1,26 @@ +M 2 0 ci prod - .github/workflows/ci.yml 9 +- 0 0 other prod - .yamllint 3 +M 2 2 docker prod - Dockerfile 2 +M 10 0 go prod app app/fx/main.go 21 +- 0 0 go test app app/fx/main_test.go 9 +- 0 0 helm prod - charts/api/Chart.yaml 3 +M 1 0 helm prod - charts/api/templates/_helpers.tpl 4 +- 0 0 helm prod - charts/api/templates/deploy.yaml 17 +M 2 2 helm prod - charts/api/values.yaml 2 +- 0 0 other prod - crates/core/.gitignore 1 +- 0 0 deps config - crates/core/Cargo.toml 6 +- 0 0 config config - crates/core/deny.toml 1 +M 12 0 rust prod - crates/core/src/lib.rs 15 +M 1 1 k8s prod - deploy/base/deploy.yaml 16 +- 0 0 k8s prod - deploy/base/kustomization.yaml 2 +- 0 0 deps config - go.mod 3 +M 3 0 tf prod - infra/locked/main.tf 14 +M 4 0 tf prod - infra/nolock/main.tf 15 +M 1 0 java prod - java/Foo.java 2 +M 1 0 k8s prod - k8s/cm.yaml 7 +- 0 0 config config - py/mypy.ini 1 +M 8 0 py prod - py/pkg/mod.py 10 +- 0 0 deps config - py/pyproject.toml 3 +M 1 1 deps config - py/requirements.txt 1 +- 0 0 py test - py/tests/test_mod.py 5 +M 2 0 ansible prod - roles/web/tasks/main.yml 4 diff --git a/zarf/tests/ardan-pr-review/expected/project.shards.tsv b/zarf/tests/ardan-pr-review/expected/project.shards.tsv new file mode 100644 index 0000000..e69de29 diff --git a/zarf/tests/ardan-pr-review/expected/project.size.tsv b/zarf/tests/ardan-pr-review/expected/project.size.tsv new file mode 100644 index 0000000..3fd94f7 --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/project.size.tsv @@ -0,0 +1,2 @@ +noise app/fx/zz_generated.go generated 5 lines 62 skipped: generated/vendored files are not reviewed unless opted in; keep them out of the PR +noise app/fx/hosts-link symlink 1 lines 47 skipped: symlinks are never reviewed (the target can be outside the repo) diff --git a/zarf/tests/ardan-pr-review/expected/project.stack.tsv b/zarf/tests/ardan-pr-review/expected/project.stack.tsv new file mode 100644 index 0000000..8f1e46c --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/project.stack.tsv @@ -0,0 +1,11 @@ +ansible . 1 - +ci - 1 github-actions +docker - 1 dockerfile +go . 1 go.mod +helm charts/api 4 Chart.yaml +k8s - 1 manifests +kustomize deploy/base 2 kustomization +python py 4 pyproject +rust crates/core 2 Cargo.toml +terraform infra/locked 1 terraform +terraform infra/nolock 1 terraform diff --git a/zarf/tests/ardan-pr-review/expected/tofu.checks.tsv b/zarf/tests/ardan-pr-review/expected/tofu.checks.tsv new file mode 100644 index 0000000..e0c3ba6 --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/tofu.checks.tsv @@ -0,0 +1,5 @@ +tf-fmt fail service-steward +tf-validate@infra_locked fail service-steward +tf-validate@infra_nolock fail service-steward +tflint@infra_locked fail service-steward +tflint@infra_nolock fail service-steward diff --git a/zarf/tests/ardan-pr-review/expected/tofu.stack.tsv b/zarf/tests/ardan-pr-review/expected/tofu.stack.tsv new file mode 100644 index 0000000..66ccee2 --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/tofu.stack.tsv @@ -0,0 +1,2 @@ +terraform infra/locked 1 tofu +terraform infra/nolock 1 tofu diff --git a/zarf/tests/ardan-pr-review/fixture.sh b/zarf/tests/ardan-pr-review/fixture.sh new file mode 100644 index 0000000..af5bec8 --- /dev/null +++ b/zarf/tests/ardan-pr-review/fixture.sh @@ -0,0 +1,215 @@ +#!/bin/sh +# Usage: sh fixture.sh DIR +# Builds the test repo in DIR (must not exist): a base commit on main and a feature branch +# with real, tool-detectable problems in every ecosystem the review scripts know (Go, Rust, +# Python, Terraform, Helm, Kustomize, plain K8s, Ansible, Dockerfile, GitHub Actions, Java as +# an unmapped language), plus a generated file and a symlink that must never be reviewed. +# Only needs git and sh: lock files are static, so the result is identical on every machine. +set -eu +F=$1 +[ ! -e "$F" ] || { echo "fixture: $F already exists" >&2; exit 2; } +mkdir -p "$F" && cd "$F" +git init -q -b main +git config user.email test@example.com && git config user.name test +# Commits must be identical everywhere: fixed dates. +GIT_AUTHOR_DATE='2026-01-01T00:00:00Z' GIT_COMMITTER_DATE='2026-01-01T00:00:00Z' +export GIT_AUTHOR_DATE GIT_COMMITTER_DATE +printf '.reviews/\n' >>.git/info/exclude +mkdir -p app/fx crates/core/src infra/nolock infra/locked charts/api/templates deploy/base k8s \ + .github/workflows roles/web/tasks py/pkg py/tests java + +# --- base ------------------------------------------------------------------------------ +printf 'module example.com/fx\n\ngo 1.24\n' >go.mod +cat >app/fx/main.go <<'EOF' +package main + +import "fmt" + +func main() { + fmt.Println(greet("world")) +} + +func greet(name string) string { + return "hello " + name +} +EOF +cat >app/fx/main_test.go <<'EOF' +package main + +import "testing" + +func TestGreet(t *testing.T) { + if got := greet("x"); got != "hello x" { + t.Fatalf("greet = %q", got) + } +} +EOF + +printf '[package]\nname = "core"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n' >crates/core/Cargo.toml +printf 'pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n' >crates/core/src/lib.rs +cat >crates/core/Cargo.lock <<'EOF' +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "core" +version = "0.1.0" +EOF +printf '[advisories]\n' >crates/core/deny.toml +printf 'target/\n' >crates/core/.gitignore + +tfmain='terraform {\n required_providers {\n random = {\n source = "hashicorp/random"\n }\n }\n}\n\nresource "random_pet" "this" {\n length = 2\n}\n' +# shellcheck disable=SC2059 # the format string is the file content +printf "$tfmain" >infra/nolock/main.tf +# shellcheck disable=SC2059 +printf "$tfmain" >infra/locked/main.tf +cat >infra/locked/.terraform.lock.hcl <<'EOF' +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.1" + hashes = [ + "h1:nozfr4CZq73d4HjubKJundX/8A+Mj282oS/hyNfjUPU=", + "zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7", + "zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26", + "zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2", + "zh:19388a4074b76a89a43a6c8328d7ae8ee2e7de3d346af51e80d3e6d3d12925f1", + "zh:23e74d48c5e2ac2e823fd527f49fee9db37d32a1990c9e3bf126ead697b843eb", + "zh:3cabf7fbd096c520064aae3aba61aba670af83ab91291a71fa1b1332929c2b7f", + "zh:5c0a3b8af0be60be4eca12ddee385cfa8babc1ec8e98cdf9de2f2274c73eabfa", + "zh:60b4f8a8ef18f52bf8e19215229dae408bee732825964092db7c989fd2de4097", + "zh:7359015acfedcbd6366f2329c854cf8d3c8ca5cd0faa89d2d37db358d6eba6c5", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b38758402f0e13a1071162da28994023cd2ac676e54af350c9ffd8dfa73fa7b", + "zh:7c7fbb8895eb75bb4de1f933e98553bd99c8d048c89a925ddba490aa5a67f7dc", + "zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c", + ] +} +EOF + +printf 'apiVersion: v2\nname: api\nversion: 0.1.0\n' >charts/api/Chart.yaml +printf 'replicas: 1\nimage: nginx:1.27\n' >charts/api/values.yaml +cat >charts/api/templates/_helpers.tpl <<'EOF' +{{- define "api.labels" -}} +app: api +{{- end }} +EOF +cat >charts/api/templates/deploy.yaml <<'EOF' +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: api + template: + metadata: + labels: + app: api + spec: + containers: + - name: api + image: {{ .Values.image }} +EOF +printf 'resources:\n- deploy.yaml\n' >deploy/base/kustomization.yaml +cat >deploy/base/deploy.yaml <<'EOF' +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web +spec: + selector: + matchLabels: + app: web + template: + metadata: + labels: + app: web + spec: + containers: + - name: web + image: nginx:1.27 +EOF +printf 'apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cfg\ndata:\n k: v\n' >k8s/cm.yaml +printf 'name: ci\non: push\njobs:\n b:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n' >.github/workflows/ci.yml +printf 'FROM alpine:3.20\nRUN echo hi\n' >Dockerfile +printf -- '- name: ping\n ansible.builtin.ping:\n' >roles/web/tasks/main.yml +printf 'extends: default\nrules:\n document-start: disable\n' >.yamllint +printf '[project]\nname = "pkg"\nversion = "0.1.0"\n' >py/pyproject.toml +printf '[mypy]\n' >py/mypy.ini +printf 'requests==2.31.0\n' >py/requirements.txt +printf 'def hello() -> int:\n return 1\n' >py/pkg/mod.py +: >py/pkg/__init__.py +printf 'from pkg.mod import hello\n\n\ndef test_hello():\n assert hello() == 1\n' >py/tests/test_mod.py +printf 'class Foo { }\n' >java/Foo.java +git add -A && git commit -qm base && git switch -qc feature + +# --- feature: real problems ------------------------------------------------------------ +# Go: unformatted, vet printf mismatch, exported func (contract), error path. A single main +# package outside the root: a plain `go build` would write its binary (fx) into the repo. +cat >>app/fx/main.go <<'EOF' + +// Parse doubles a number given as text. +func Parse(s string) (int, error) { + var n int + if _, err := fmt.Sscanf(s, "%d", &n); err != nil { + return 0, fmt.Errorf("parse %q: %w", s, err) + } + fmt.Printf("%d\n", s) + return n * 2, nil +} +EOF +printf '// Code generated by fixture. DO NOT EDIT.\n\npackage main\n\nconst generated = 1\n' >app/fx/zz_generated.go +ln -s /etc/hosts app/fx/hosts-link +# Rust: clippy needless_return + a failing test. +cat >>crates/core/src/lib.rs <<'EOF' + +pub fn double(x: i32) -> i32 { + return x * 2; +} + +#[cfg(test)] +mod tests { + #[test] + fn double_works() { + assert_eq!(super::double(2), 5); + } +} +EOF +# Terraform: unformatted, undeclared variable (validate error), a new output. +printf 'resource "random_pet" "extra" {\n length = var.missing\n prefix = "x"\n}\n' >>infra/nolock/main.tf +printf 'output "name" {\n value = random_pet.this.id\n}\n' >>infra/locked/main.tf +# Helm: replicas as a string (schema error), latest tag, a new template. +printf 'replicas: "two"\nimage: nginx:latest\n' >charts/api/values.yaml +printf '{{- define "api.name" -}}api{{- end }}\n' >>charts/api/templates/_helpers.tpl +# Kustomize: latest tag. +sed 's/nginx:1.27/nginx:latest/' deploy/base/deploy.yaml >deploy/base/deploy.yaml.new && mv deploy/base/deploy.yaml.new deploy/base/deploy.yaml +# Plain manifest: non-string ConfigMap value (schema error). +printf ' n: 1\n' >>k8s/cm.yaml +# GitHub Actions: unpinned third-party action, script injection. +cat >>.github/workflows/ci.yml <<'EOF' + - uses: some-org/some-action@main + - run: echo "${{ github.event.head_commit.message }}" +EOF +# Dockerfile: untagged base image, unpinned apt. +printf 'FROM ubuntu\nRUN apt-get update && apt-get install -y curl\n' >Dockerfile +# Ansible: shell without changed_when, short module name. +printf -- '- name: run\n shell: echo hi\n' >>roles/web/tasks/main.yml +# Python: unused import, type error, bare except, vulnerable pin. +cat >>py/pkg/mod.py <<'EOF' +import os + + +def broken() -> int: + try: + return "x" + except: + pass +EOF +printf 'requests==2.19.0\n' >py/requirements.txt +printf 'class Bar { }\n' >>java/Foo.java +git add -A && git commit -qm feature diff --git a/zarf/tests/ardan-pr-review/run.sh b/zarf/tests/ardan-pr-review/run.sh new file mode 100644 index 0000000..35a7b1f --- /dev/null +++ b/zarf/tests/ardan-pr-review/run.sh @@ -0,0 +1,42 @@ +#!/bin/sh +# Usage: sh run.sh host|container +# Entry point for the ardan-pr-review regression suite (suite.sh). Called by `make test-pr-review*`. +# host stub analyzers on PATH; needs git, pwsh, rg, jq on the host. +# container builds the pinned tools image (Dockerfile) and runs the suite inside it; needs Docker. +# Environment: UPDATE=1 rewrites expected/ from the run; IMAGE=name picks the image tag +# (default ardan-pr-review-tools); NO_BUILD=1 uses an existing image (CI builds it with caching); +# KEEP=1 keeps the host work directory. +set -eu +here=$(cd "$(dirname "$0")" && pwd) +skill=$(cd "$here/../../../.agents/skills/ardan-pr-review" && pwd) + +die() { echo "run: $*" >&2; exit 2; } + +case ${1-} in + host) + for t in git pwsh rg jq; do command -v "$t" >/dev/null 2>&1 || die "$t is required on the host"; done + work=$(mktemp -d) + [ "${KEEP-}" = 1 ] || trap 'rm -rf "$work"' EXIT + mkdir "$work/stubs" "$work/bin" + # Every analyzer intake.sh looks for, as a stub that prints its arguments and succeeds, so + # results don't depend on what the host has installed. timeout/gtimeout stay real. + for t in gh go gofmt gopls staticcheck golangci-lint gosec govulncheck dupl checklocks errcheck nilaway \ + deadcode apidiff gitleaks trufflehog detect-secrets semgrep osv-scanner trivy grype jscpd pmd ast-grep \ + shellcheck node npm pnpm yarn opa conftest buf sqlfluff helm kubectl terraform tofu tflint hadolint \ + cargo cargo-audit cargo-deny rust-analyzer python3 ruff mypy pytest bandit pip-audit ansible-lint \ + yamllint kubeconform kube-linter kustomize checkov actionlint zizmor; do + printf '#!/bin/sh\necho "STUB %s $*"\n' "$t" >"$work/stubs/$t" + chmod +x "$work/stubs/$t" + done + for t in git pwsh rg jq; do ln -s "$(command -v "$t")" "$work/bin/$t"; done + PATH=$work/stubs:$work/bin:/usr/bin:/bin sh "$here/suite.sh" host "$skill" "$work" + ;; + container) + command -v docker >/dev/null 2>&1 || die "docker is required" + image=${IMAGE:-ardan-pr-review-tools} + [ "${NO_BUILD-}" = 1 ] || docker build -q -t "$image" "$here" >/dev/null + docker run --rm -e "UPDATE=${UPDATE-}" -v "$skill:/skill:ro" -v "$here:/tests" "$image" \ + sh -c 'w=$(mktemp -d) && sh /tests/suite.sh container /skill "$w"' + ;; + *) die "usage: run.sh host|container" ;; +esac diff --git a/zarf/tests/ardan-pr-review/suite.sh b/zarf/tests/ardan-pr-review/suite.sh new file mode 100644 index 0000000..43aafff --- /dev/null +++ b/zarf/tests/ardan-pr-review/suite.sh @@ -0,0 +1,139 @@ +#!/bin/sh +# Usage: sh suite.sh MODE SKILL_DIR WORK_DIR +# Regression suite for the ardan-pr-review scripts; run.sh sets up MODE and calls this. +# host analyzers are stubs (run.sh puts them first on PATH): exercises the scripts with the +# host's own sh/awk/sed (BSD on macOS, GNU on Linux) and PowerShell. +# container real analyzers at pinned versions (Dockerfile). +# Asserts: sh and PowerShell ports write identical context files and check results; intake and +# check results match expected/ (golden files); checks leave the working tree untouched. +# UPDATE=1 rewrites the golden files from this run instead of comparing. Exit: 0 pass | 1 fail. +set -u +mode=$1 skill=$2 work=$3 +here=$(cd "$(dirname "$0")" && pwd) +sk=$skill/scripts exp=$here/expected +# No global or system git config: signing, hooks, or diff settings must not change results. +GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 +export GIT_CONFIG_GLOBAL GIT_CONFIG_NOSYSTEM +fails=0 + +say() { printf '%s\n' "$*"; } +fail() { say "FAIL $*"; fails=$((fails + 1)); } +pass() { say "ok $*"; } + +# golden NAME FILE: FILE must equal expected/NAME (UPDATE=1: write it instead). +golden() { + if [ "${UPDATE-}" = 1 ]; then mkdir -p "$exp" && cp "$2" "$exp/$1" && say "wrote expected/$1"; return; fi + if [ ! -f "$exp/$1" ]; then fail "golden $1: expected/$1 missing (run with UPDATE=1)"; return; fi + if diff -u "$exp/$1" "$2" >"$work/golden.diff" 2>&1; then pass "golden $1" + else fail "golden $1"; sed -n '1,40s/^/ /p' "$work/golden.diff" + fi +} + +# scope_view FILE: scope.txt without host- or run-specific keys +scope_view() { grep -v -E '^(run|root|languages|missing_tools)=' "$1"; } + +# parity_ctx A B LABEL: the context files written by the two ports must be identical. +parity_ctx() { + bad='' + for f in "$1"/*; do + n=${f##*/} + [ -f "$f" ] || continue + case $n in + scope.txt) scope_view "$f" >"$work/p1"; scope_view "$2/$n" >"$work/p2" 2>/dev/null ;; + *) cp "$f" "$work/p1"; cp "$2/$n" "$work/p2" 2>/dev/null || : >"$work/p2" ;; + esac + cmp -s "$work/p1" "$work/p2" || { bad="$bad $n"; diff "$work/p1" "$work/p2" | sed -n '1,10s/^/ /p'; } + done + for f in "$2"/*; do [ -f "$f" ] && [ ! -e "$1/${f##*/}" ] && bad="$bad ${f##*/}(ps only)"; done + if [ -z "$bad" ]; then pass "parity $3"; else fail "parity $3:$bad"; fi +} + +# checks_view RUN: check, status, lens of a run's summary +checks_view() { cut -f1,2,6 "$1/context/checks/summary.tsv"; } + +# tree_state: what git sees, ignoring the run directories and cargo's build output +tree_state() { git status --porcelain --ignored | grep -v -e '^!! \.reviews/' -e 'crates/core/target/' || true; } + +# intake_pair ID SH_ARGS PS_ARGS: both ports into .reviews/sh and .reviews/ps +intake_pair() { + # shellcheck disable=SC2086 # argument lists + sh "$sk/intake.sh" --id "$1" --reviews-dir .reviews/sh $2 >"$work/intake-sh-$1.out" 2>&1 || fail "intake.sh $1 exit $?" + # shellcheck disable=SC2086 + pwsh -NoProfile -File "$sk/intake.ps1" -Id "$1" -ReviewsDir .reviews/ps $3 >"$work/intake-ps-$1.out" 2>&1 || fail "intake.ps1 $1 exit $?" + parity_ctx ".reviews/sh/$1/001/context" ".reviews/ps/$1/001/context" "intake $1" +} + +# checks_pair ID [SH_ARGS] [PS_ARGS]: both ports' checks on the ID runs; summaries must agree +checks_pair() { + # shellcheck disable=SC2086 + sh "$sk/checks.sh" ".reviews/sh/$1/001" ${2-} >"$work/checks-sh-$1.out" 2>&1 || fail "checks.sh $1 exit $?" + # shellcheck disable=SC2086 + pwsh -NoProfile -File "$sk/checks.ps1" ".reviews/ps/$1/001" ${3-} >"$work/checks-ps-$1.out" 2>&1 || fail "checks.ps1 $1 exit $?" + checks_view ".reviews/sh/$1/001" >"$work/c1"; checks_view ".reviews/ps/$1/001" >"$work/c2" + if cmp -s "$work/c1" "$work/c2"; then pass "parity checks $1" + else fail "parity checks $1"; diff "$work/c1" "$work/c2" | sed 's/^/ /'; tail -n 5 "$work/checks-ps-$1.out" | sed 's/^/ ps: /' + fi +} + +say "== ardan-pr-review suite ($mode)" +sh "$here/fixture.sh" "$work/fx" || { say "FAIL fixture"; exit 1; } +cd "$work/fx" || exit 1 +before=$(tree_state) + +# --- intake: three depths, both ports, golden context ------------------------------------ +intake_pair diff "--base main" "-Base main" +c=.reviews/sh/diff/001/context +for f in files.tsv skipped.tsv stack.tsv symbols.tsv refs.tsv unmapped.txt signals.txt packages.tsv; do golden "diff.$f" "$c/$f"; done +scope_view "$c/scope.txt" | grep -v -E '^(head|base)=' >"$work/scope.view"; golden diff.scope.txt "$work/scope.view" + +intake_pair project "--base main --depth project" "-Base main -Depth project" +c=.reviews/sh/project/001/context +for f in review-set.tsv stack.tsv shards.tsv size.tsv; do [ -f "$c/$f" ] || : >"$c/$f"; golden "project.$f" "$c/$f"; done + +intake_pair gen "--base main --depth packages --include-generated" "-Base main -Depth packages -IncludeGenerated" +c=.reviews/sh/gen/001/context +for f in files.tsv skipped.tsv review-set.tsv; do golden "gen.$f" "$c/$f"; done + +# --- extract-findings: both modes, both ports ------------------------------------------------ +mkdir -p "$work/ex/sh/context" "$work/ex/ps/context" +cat >"$work/ex/transcript.jsonl" <<'EOF' +{"text":"role file\n===== stack.md =====\nTEMPLATE\n===== end ====="} +{"text":"discovery: x\n===== stack.md =====\n# Stack\n- rust\n===== extra-checks.tsv =====\nlint\t.\tservice-steward\tmake lint\tMakefile:1\n===== end ====="} +{"text":"findings: G1\n===== findings.md =====\n# Service DiffGuard Report\nok\n===== end ====="} +EOF +if ! { sh "$sk/extract-findings.sh" --discovery "$work/ex/transcript.jsonl" "$work/ex/sh" >/dev/null && + sh "$sk/extract-findings.sh" "$work/ex/transcript.jsonl" "$work/ex/sh" >/dev/null; }; then fail "extract-findings.sh"; fi +if ! { pwsh -NoProfile -File "$sk/extract-findings.ps1" -Discovery "$work/ex/transcript.jsonl" "$work/ex/ps" >/dev/null && + pwsh -NoProfile -File "$sk/extract-findings.ps1" "$work/ex/transcript.jsonl" "$work/ex/ps" >/dev/null; }; then fail "extract-findings.ps1"; fi +if diff -r "$work/ex/sh" "$work/ex/ps" >/dev/null && [ "$(cat "$work/ex/sh/context/extra-checks.tsv")" = "$(printf 'lint\t.\tservice-steward\tmake lint\tMakefile:1')" ]; then + pass "extract-findings (discovery + findings, both ports)" +else fail "extract-findings"; diff -r "$work/ex/sh" "$work/ex/ps" | sed 's/^/ /' +fi + +# --- checks: both ports, golden statuses, read-only tree ---------------------------------------- +printf 'echo-x\t.\tservice-steward\techo extra\tMakefile:1\nnot-approved\t.\t\techo no\tx\n' >"$work/extra.tsv" +cp "$work/extra.tsv" .reviews/sh/diff/001/context/extra-checks.tsv +cp "$work/extra.tsv" .reviews/ps/diff/001/context/extra-checks.tsv +checks_pair diff "--extra echo-x" "-Extra echo-x" +checks_view .reviews/sh/diff/001 >"$work/checks.view"; golden "$mode.checks.tsv" "$work/checks.view" +after=$(tree_state) +if [ "$before" = "$after" ]; then pass "working tree unchanged by checks" +else fail "working tree changed by checks"; printf '%s\n' "$after" | sed 's/^/ /' +fi + +# --- container: OpenTofu selection and its read-only lock ------------------------------------- +if [ "$mode" = container ]; then + echo 1.13.0 >.opentofu-version + intake_pair tofu "--base main" "-Base main" + grep '^terraform' .reviews/sh/tofu/001/context/stack.tsv >"$work/tofu.stack"; golden tofu.stack.tsv "$work/tofu.stack" + checks_pair tofu + grep -E '^(tf|tflint)' "$work/c1" >"$work/tofu.view"; golden tofu.checks.tsv "$work/tofu.view" + rm -f .opentofu-version + after=$(tree_state) + if [ "$before" = "$after" ]; then pass "working tree unchanged by OpenTofu checks" + else fail "working tree changed by OpenTofu checks"; printf '%s\n' "$after" | sed 's/^/ /' + fi +fi + +if [ $fails -gt 0 ]; then say "== $fails failure(s); logs and runs in $work"; exit 1; fi +say "== all passed" From e6030bccc62f5ded494663912174a7897dbf1e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florin=20P=C4=83=C8=9Ban?= Date: Tue, 3 Nov 2026 13:17:59 +0100 Subject: [PATCH 2/5] Never run the reviewed code unless it's a maintainer PR Several checks executed code the reviewed change controls, and --no-tests didn't stop them: cargo clippy/check (build scripts, proc macros), terraform/tofu init + validate (PR-chosen providers and modules), tflint --init (repo plugins), mypy and ansible-lint (repo plugins and rules), pip-audit dependency resolution (setup.py), repo gates, and the existing go test / cargo test / pytest runs. - intake: trust=maintainer only for --pr when GitHub reports the PR author has write/maintain/admin access and the head branch is in the base repo (commit authors are not used: they can be forged); every other scope or gh error is trust=untrusted, recorded with a reason (sh and PowerShell) - checks: read trust from scope.txt; when untrusted, skip tests, cargo clippy/check/test, terraform validate, ansible-lint, golangci-lint with custom linter plugins, and repo gates; run tflint and mypy with neutral configs; pip-audit always audits pins as written (--no-deps --disable-pip) - SKILL.md, contract, lenses, and idioms: no tests, builds, or PoCs of the change unless trust=maintainer; skipped rows become one Open check - tests: a fake gh drives --pr runs for maintainer, read-only author, and fork; golden results for untrusted and trusted check runs --- .agents/skills/ardan-pr-review/SKILL.md | 11 ++-- .../ardan-pr-review/references/contract.md | 6 +- .../references/langs/ansible.md | 2 +- .../ardan-pr-review/references/langs/ci.md | 2 +- .../ardan-pr-review/references/langs/go.md | 4 +- .../references/langs/python.md | 2 +- .../ardan-pr-review/references/langs/rust.md | 2 +- .../references/langs/terraform.md | 2 +- .../ardan-pr-review/references/langs/web.md | 2 +- .../references/lenses/harness-map.md | 2 +- .../references/lenses/security-sentinel.md | 2 +- .../references/roles/aggregator.md | 2 +- .../references/roles/discovery.md | 2 +- .../skills/ardan-pr-review/scripts/checks.ps1 | 63 ++++++++++++++----- .../skills/ardan-pr-review/scripts/checks.sh | 54 ++++++++++++---- .../skills/ardan-pr-review/scripts/intake.ps1 | 23 ++++++- .../skills/ardan-pr-review/scripts/intake.sh | 30 +++++++++ README.md | 4 +- zarf/tests/ardan-pr-review/README.md | 1 + .../expected/container.checks.tsv | 16 ++--- .../expected/container.trusted.checks.tsv | 44 +++++++++++++ .../ardan-pr-review/expected/diff.scope.txt | 2 + .../ardan-pr-review/expected/host.checks.tsv | 16 ++--- .../expected/host.trusted.checks.tsv | 44 +++++++++++++ .../ardan-pr-review/expected/tofu.checks.tsv | 4 +- zarf/tests/ardan-pr-review/expected/trust.txt | 9 +++ zarf/tests/ardan-pr-review/stub-gh.sh | 14 +++++ zarf/tests/ardan-pr-review/suite.sh | 26 ++++++++ 28 files changed, 326 insertions(+), 65 deletions(-) create mode 100644 zarf/tests/ardan-pr-review/expected/container.trusted.checks.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/host.trusted.checks.tsv create mode 100644 zarf/tests/ardan-pr-review/expected/trust.txt create mode 100644 zarf/tests/ardan-pr-review/stub-gh.sh diff --git a/.agents/skills/ardan-pr-review/SKILL.md b/.agents/skills/ardan-pr-review/SKILL.md index 790d00e..46e8105 100644 --- a/.agents/skills/ardan-pr-review/SKILL.md +++ b/.agents/skills/ardan-pr-review/SKILL.md @@ -12,6 +12,7 @@ Rules for you: - **Start from a fresh session.** Every orchestrator turn re-reads the whole conversation; inside a long session that costs more than the agents. If the conversation already holds unrelated work, suggest `/clear` (or a new session) before starting. - **Keep your context small.** Read only script output, agents' replies (one line; the discovery agent's and the aggregator's also carry blocks you only save), `ac-review.md` (when ACs are weak/missing), `references/size-guard.md` (when there are size warnings), and the `F…` headings of `findings.md`. Never read the diff, brief, check logs, or agent reports. - **Advisory only.** Nothing here edits, stages, or commits code, and nothing deploys, applies, or plans infrastructure. +- **Never run the reviewed code unless it's a maintainer PR.** Tests, Rust builds, Terraform init/validate, linter plugins, repo gates, and security PoCs run only when `scope.txt` says `trust=maintainer`: intake sets it for `--pr` when GitHub reports the PR author has write access and the head branch is in the base repo. Every other scope is untrusted; the read-only checks still run. Never edit `scope.txt` or work around it — to run the code, review it as a maintainer's PR. - **The change, not the codebase.** By default only the change is reviewed (`contract.md` → Review scope). Problems that predate it are dropped, not reported. Only an explicit full-review request widens that. - **Runs are independent.** Never open a previous run's files. @@ -30,7 +31,7 @@ Note what was given: scope (default: unstaged + untracked; or `--staged`, `--bas If a full review was requested without an extent, ask first; the preview needs the answer: *Changed files* (default) · *Changed packages* (every file in their directories) · *Whole project*. Map the answer to `--depth files|packages|project`. -`intake.sh --dry-run [--depth ] [--id ]` writes nothing. On exit `3` the reviewed code isn't checked out: relay the printed command and stop. On `4` there is nothing to review: say so and stop. On `2` fix the arguments. +`intake.sh --dry-run [--depth ] [--id ]` writes nothing. On exit `3` the reviewed code isn't checked out: relay the printed command and stop. On `4` there is nothing to review: say so and stop. On `2` fix the arguments. Unless `trust=maintainer`, say in one line that checks which run the code (tests, builds, Terraform init, plugins, repo gates) will be skipped, with `trust_reason`. **Discovery.** Then launch the discovery agent, on a cheaper model when your runtime picks models per subagent (it reads configs only): "Follow `/references/roles/discovery.md`. Skill directory: ``. Repo root: ``. Review depth: <…>. Preview: ." Wait for its reply (step 3 offers its repo gates; step 4 saves it). @@ -43,14 +44,14 @@ One round (native question UI if available, ≤3 batches). Skip what the request 1. **Ticket link** — always ask if none was given. "No ticket" is valid: spec review then works best-effort from the PR description and commits. 2. **Scope and id** — confirm the preview's scope, counts, `id` (ticket id, else branch name — same work, same directory), and depth (at full depth, `review_files` / `review_lines` too). 3. **Size** — if `size_warnings > 0`, follow `references/size-guard.md`. -4. **Repo gates** — if discovery proposed extra checks: list each (name, command, source) and ask which to run (*all* · *pick* · *none*; default *none*). They run the repo's own commands, so only approved ones run. +4. **Repo gates** — only with `trust=maintainer`, if discovery proposed extra checks: list each (name, command, source) and ask which to run (*all* · *pick* · *none*; default *none*). Otherwise they stay in `stack.md` for reviewers to read and never run. 5. **Agents** — *Routed* · *All agents* · *All agents + Security* · *Partial* (see step 6), naming the agents and checklists in each option, and saying when the small-diff merge applies. *Partial* asks a follow-up: *Risk only* or *Polish only*. 6. **Security** — only with *All agents + Security*; then ask: - fan-out: *single* · *capped* (≤3 attacker sub-agents) · *full* (attacker + refuter per route); - report: *redacted* (default — `findings.md` says only "Security issues detected."; details only in `security-issues/`) · *named* (security issues listed in `findings.md` and `diagrams.md` like any finding). 7. **Generated files** — if `skipped.tsv` has generated or vendored rows (`noise` rows in the size table), say how many are skipped and ask: *Skip* (default) · *Secret-scan only* (cost ≈ `noise_tokens_est`; a secret scanner in `tools.tsv` makes it much cheaper) · *Review them too* — re-run the preview with `--include-generated`, show the new counts and token estimates, and confirm before going on. Symlinks are never reviewed. 8. **Model tier** — if your runtime picks models per subagent: run the context builder and generated-file scan on a cheaper model? Every run. Reviewers and the aggregator always use the session model. -9. **Test setup** — if `go_packages > 0` or `ecosystems=` has `rust`, `python`, or `terraform`, and memory has no `ardan-project-interaction-guide` for this repo: test env vars (`K=V`, including registry or provider credentials Terraform init needs), units whose tests must never run, timeout, and how to run the project for security PoCs (*All agents + Security* only). Save it as project memory `ardan-project-interaction-guide` (no memory → ask every run). +9. **Test setup** — only with `trust=maintainer`, if `go_packages > 0` or `ecosystems=` has `rust`, `python`, or `terraform`, and memory has no `ardan-project-interaction-guide` for this repo: test env vars (`K=V`, including registry or provider credentials Terraform init needs), units whose tests must never run, timeout, and how to run the project for security PoCs (*All agents + Security* only). Save it as project memory `ardan-project-interaction-guide` (no memory → ask every run). The answers are the confirmation; echo the final setup in ≤6 lines and continue. If they change the scope or depth, re-run the preview and discovery first. @@ -59,7 +60,7 @@ The answers are the confirmation; echo the final setup in ≤6 lines and continu 1. `intake.sh --id [--depth ] [--exclude ]… [--include-generated]` creates ``. `checks.sh` reads the depth from `scope.txt`. 2. Save the discovery reply without re-typing it: `sh /scripts/extract-findings.sh --discovery ` (PowerShell: `-Discovery`). With no transcript file, or on exit `3`/`4`, write the text between `===== stack.md =====` and `===== extra-checks.tsv =====` to `/context/stack.md` and the rest up to `===== end =====` to `/context/extra-checks.tsv`, verbatim. 3. Save the ticket raw to `/context/ticket.md` without reading it — tracker CLI first (`gh issue view --comments > …`, `glab issue view`, `jira issue view`), then a tracker MCP, then web fetch. -4. In parallel: `checks.sh [--env K=V]… [--exclude ]… [--timeout D] [--extra |all]` in the background, and the context builder: "Follow `/references/roles/context-builder.md`. Run directory: ``." +4. In parallel: `checks.sh [--env K=V]… [--exclude ]… [--timeout D] [--extra |all]` in the background (it reads `trust` from `scope.txt` itself), and the context builder: "Follow `/references/roles/context-builder.md`. Run directory: ``." ## 5. Acceptance-criteria gate @@ -97,7 +98,7 @@ Run directory: . Shard: . Review depth: /lenses/[-].md and reply with the one line the contract specifies. ``` -Append for `security`: `Fan-out: . Project interaction guide: . PoCs only under /security-issues/poc//.` For `spec`: `Criteria in force: .` +Append for `security`: `Fan-out: . Trust: . Project interaction guide: . PoCs only under /security-issues/poc//.` For `spec`: `Criteria in force: .` If the generated-file scan was accepted, also launch: "Follow `/references/roles/generated-scan.md`. Run directory: ``. Skill directory: ``." No other agent reads generated files. diff --git a/.agents/skills/ardan-pr-review/references/contract.md b/.agents/skills/ardan-pr-review/references/contract.md index e38d872..4bd4bd4 100644 --- a/.agents/skills/ardan-pr-review/references/contract.md +++ b/.agents/skills/ardan-pr-review/references/contract.md @@ -18,7 +18,7 @@ The depth (`Review depth:` in your prompt, `depth=` in `scope.txt`) sets what yo | File | Holds | |-----------------------------------|---------------------------------------------------------------------------------------------------------| | `brief.md` | **Read first.** Summary, acceptance criteria (AC1…), per-file notes, changed symbols and callers, repo rules. | -| `scope.txt` | Scope, head commit, counts, ecosystems, idioms, `generated=` (skipped or included), routing signals. | +| `scope.txt` | Scope, head commit, counts, ecosystems, idioms, `generated=` (skipped or included), `trust=` (`maintainer` or `untrusted`) with `trust_reason=`, routing signals. | | `files.tsv` | `status added deleted lang kind layer path` per reviewed file; `kind` is prod (code and infrastructure), test, doc, config, or — only with `generated=included` — generated/vendor. `layer` is set for Go only. | | `stack.tsv`, `stack.md` | `eco unit files marker` per build/deploy unit (Go module, crate, Terraform module, chart, …); `stack.md` (when present): the repo's ecosystems, its own quality gates, and idioms for ecosystems with no idiom file. | | `symbols.tsv` | `status kind name path line` for added, removed, and modified funcs, types, Terraform blocks (addresses such as `aws_s3_bucket.this`, `var.x`), and Helm templates. | @@ -34,8 +34,8 @@ The depth (`Review depth:` in your prompt, `depth=` in `scope.txt`) sets what yo ## Rules 1. **Batch your reads.** Each turn re-reads your whole context, so issue independent reads (contract, lenses, brief, the files you need) together in one turn, not one per turn. -2. **Don't redo preprocessing.** No `git` to rediscover scope, no reading the whole diff to get oriented, no re-running what `checks/` covers. One narrow command to prove or refute a finding is fine (one test by name, an `rg`, a single render), within `checks/setup.txt`. Never one that deploys, applies, or mutates state. -3. **Read-only.** Write only your report (security may also write PoCs). If a write is refused, never retry it through Bash or any other tool: put the content in your reply and say the write was refused. +2. **Don't redo preprocessing.** No `git` to rediscover scope, no reading the whole diff to get oriented, no re-running what `checks/` covers. One narrow command to prove or refute a finding is fine (an `rg`, a single render; one test by name only with `trust=maintainer`), within `checks/setup.txt`. Never one that deploys, applies, or mutates state. +3. **Read-only, and never run the reviewed code unless `trust=maintainer`.** No tests, builds, scripts, or PoCs of the change otherwise; reading, `rg`, and the language server are fine. Rows skipped with "runs the reviewed code" are not findings and not Open checks: the aggregator notes them once. Write only your report (security may also write PoCs). If a write is refused, never retry it through Bash or any other tool: put the content in your reply and say the write was refused. 4. **Never open another run** under `.reviews/`. 5. **Tools before eyeballing.** The language tool your idiom file names (Go: `gopls` MCP), then `rg`/`ast-grep`. 6. **Repo rules are authoritative.** `rules.txt`, the repo's lint configs, and the skills your idiom files name (Go: `use-modern-go`, `ardan-service-architecture`). diff --git a/.agents/skills/ardan-pr-review/references/langs/ansible.md b/.agents/skills/ardan-pr-review/references/langs/ansible.md index 151f451..d309641 100644 --- a/.agents/skills/ardan-pr-review/references/langs/ansible.md +++ b/.agents/skills/ardan-pr-review/references/langs/ansible.md @@ -1,6 +1,6 @@ # Ansible idioms -**Tools:** `rg`; `ansible-doc ` if installed. **Checks:** `ansible-lint` (offline), `yamllint` (with the repo's config). Never run playbooks against hosts; `--syntax-check` or `--check` only with the project interaction guide. +**Tools:** `rg`; `ansible-doc ` if installed. **Checks:** `yamllint` (with the repo's config); `ansible-lint` (offline) for a maintainer PR only — it loads the repo's plugins and custom rules. Never run playbooks against hosts; `--syntax-check` or `--check` only for a maintainer PR and as the project interaction guide allows. ## Correctness Idempotency: `command`/`shell` without `creates`/`removes`/`changed_when`; FQCN module names; `become` scope; handler names that `notify` no longer matches; variable precedence surprises (role defaults vs vars vs group_vars vs extra vars); `when` on bare variables or string booleans; `loop` vs `with_*`; Jinja2 templating and escaping in `.j2`; check-mode support; `serial` for rolling changes. diff --git a/.agents/skills/ardan-pr-review/references/langs/ci.md b/.agents/skills/ardan-pr-review/references/langs/ci.md index c60d372..0a1cf95 100644 --- a/.agents/skills/ardan-pr-review/references/langs/ci.md +++ b/.agents/skills/ardan-pr-review/references/langs/ci.md @@ -2,7 +2,7 @@ Covers GitHub Actions, GitLab CI, Jenkins, CircleCI, Azure Pipelines, Bitbucket, Buildkite, Tekton, Drone, Travis, Cloud Build, Woodpecker — `stack.tsv` names the system in the marker column. Apply the general rules below in that system's syntax. -**Tools:** `rg`. **Checks:** `actionlint` and `zizmor` for GitHub Actions; other systems have no offline linter — review by reading (repo-defined validators arrive as extra checks when approved). Never trigger a pipeline. +**Tools:** `rg`. **Checks:** `actionlint` and `zizmor` for GitHub Actions; other systems have no offline linter — review by reading (repo-defined validators arrive as extra checks when approved, for a maintainer PR only). Never trigger a pipeline. ## Correctness Triggers run where intended (PRs, forks, tags, branches, path filters, `rules`/`only`/`except`, `when`); job ordering (`needs`, stages, `dependsOn`); matrix coverage; cache keys that miss inputs or poison builds; artifact names and paths between jobs; concurrency groups and cancel-in-progress; `if:` expressions (string vs boolean, `success()` vs `always()`); shell steps quoting and `set -euo pipefail`; reusable workflow / template inputs. diff --git a/.agents/skills/ardan-pr-review/references/langs/go.md b/.agents/skills/ardan-pr-review/references/langs/go.md index 90cbc03..17cae43 100644 --- a/.agents/skills/ardan-pr-review/references/langs/go.md +++ b/.agents/skills/ardan-pr-review/references/langs/go.md @@ -3,7 +3,7 @@ **Tools:** `gopls` MCP (`go_symbol_references`, `go_search`, `go_file_context`), else `gopls`/`go doc`, then `rg`. Skills: `use-modern-go` on every `.go` file; `ardan-service-architecture` on files with a `layer` (`app`, `business`, `storage`, `foundation`) — `references/types.md` for boundaries, `branching.md` for conditionals, `events.md` for domain events, `extensions.md` only when extension wiring is touched. **Checks** (`checks/summary.tsv`; package-wide tools report on whole packages, only in-scope hits count): -- `go-build`, `go-vet`, `staticcheck`, `golangci-lint` — each hit is a finding at the Gate its effect warrants. +- `go-build`, `go-vet`, `staticcheck`, `golangci-lint` — each hit is a finding at the Gate its effect warrants. (`golangci-lint` is skipped for an untrusted change whose config loads custom linter plugins.) - `checklocks` — a violation of an existing `+checklocks`/`+checkatomic` annotation is a data race (G4). "may require checklocks annotation for X" names the lock guarding those fields: verify in-scope code holds it on every access. - `nilaway` — candidate nil dereferences; confirm the nil flow before reporting, else Open check. - `go-fix` — modernizer diff on in-scope lines: G1 (G2 where `use-modern-go` makes it a rule). `gofmt`, `go-mod-tidy` — G2. @@ -24,7 +24,7 @@ The type-boundary rule: API request/response structs use primitives; DB rows use - G4: layer-boundary or storage-isolation violation, constructible invalid Business state, converter bypass. Extra fields: `Type: (layer)`, `Crossing: toBus | fromBusResponse | toDB | none`. ## Tests -Table-driven tests with named subtests; `errors.Is/As` on error paths; negative, validation, boundary, cancellation, timeout, transaction and store-failure cases; HTTP request/response; converters; strong-type parsing. Tests ran with `-race`: a `WARNING: DATA RACE` whose stack touches in-scope code is G4 — quote the two conflicting frames. Prove a point with one `go test -run TestX ./pkg`. +Table-driven tests with named subtests; `errors.Is/As` on error paths; negative, validation, boundary, cancellation, timeout, transaction and store-failure cases; HTTP request/response; converters; strong-type parsing. For a maintainer PR tests ran with `-race`: a `WARNING: DATA RACE` whose stack touches in-scope code is G4 — quote the two conflicting frames; prove a point with one `go test -run TestX ./pkg`. Otherwise `go test` didn't run. ## Security `gosec`, `govulncheck` rows; trace taint from request/env/file to sink with `gopls`; `database/sql` string building, `exec.Command` with a shell, `html/template` vs `text/template`, `InsecureSkipVerify`, `math/rand` for tokens. diff --git a/.agents/skills/ardan-pr-review/references/langs/python.md b/.agents/skills/ardan-pr-review/references/langs/python.md index 60c95dc..0ccd619 100644 --- a/.agents/skills/ardan-pr-review/references/langs/python.md +++ b/.agents/skills/ardan-pr-review/references/langs/python.md @@ -1,6 +1,6 @@ # Python idioms -**Tools:** an LSP (pyright/pylsp) if available, else `rg`. **Checks:** `ruff`, `ruff-format`, `mypy` (when the repo configures it), `pytest`, `bandit`, `pip-audit`. The repo's own tools (tox/nox/uv/poetry scripts) arrive as extra checks when approved. +**Tools:** an LSP (pyright/pylsp) if available, else `rg`. **Checks:** `ruff`, `ruff-format`, `mypy` (when the repo configures it; for an untrusted change with default settings, since the config can load plugins), `bandit`, `pip-audit` (pins as written, nothing resolved; unpinned requirements are reported, not audited); `pytest` for a maintainer PR only. The repo's own tools (tox/nox/uv/poetry scripts) arrive as extra checks when approved, for a maintainer PR only. ## Correctness Mutable default arguments; late-binding closures in loops; `is` vs `==`; shadowed builtins; naive vs aware `datetime`; blocking calls inside `async def`; un-awaited coroutines; resource handling without `with`; import-time side effects; type hints that contradict behavior. diff --git a/.agents/skills/ardan-pr-review/references/langs/rust.md b/.agents/skills/ardan-pr-review/references/langs/rust.md index 431612b..43f6478 100644 --- a/.agents/skills/ardan-pr-review/references/langs/rust.md +++ b/.agents/skills/ardan-pr-review/references/langs/rust.md @@ -1,6 +1,6 @@ # Rust idioms -**Tools:** `rust-analyzer` via LSP if available, else `rg`; `cargo tree -i ` for dependency questions. **Checks:** `cargo-fmt`, `cargo-clippy` (`-D warnings`; `cargo-check` when clippy is missing), `cargo-test`, `cargo-audit`, `cargo-deny`. All run with `--locked`: a failure saying the lock file needs updating is a real finding (`Cargo.lock` out of date). +**Tools:** `rust-analyzer` via LSP if available, else `rg`; `cargo tree -i ` for dependency questions. **Checks:** `cargo-fmt`, `cargo-audit`, `cargo-deny`; for a maintainer PR also `cargo-clippy` (`-D warnings`; `cargo-check` when clippy is missing) and `cargo-test` — compiling runs build scripts and proc macros, so they are skipped otherwise. Cargo runs with `--locked`: a failure saying the lock file needs updating is a real finding (`Cargo.lock` out of date). ## Correctness Blocking in async (`std::fs`, `thread::sleep`, sync `Mutex` held across `.await`); `RefCell` borrow panics; `as` casts that truncate or change sign; integer overflow (wraps in release); `clone()` added only to silence the borrow checker on hot paths; `Send`/`Sync` assumptions; feature-flag and `#[cfg]` combinations that no longer compile; iterator invalidation via indices. diff --git a/.agents/skills/ardan-pr-review/references/langs/terraform.md b/.agents/skills/ardan-pr-review/references/langs/terraform.md index 0527924..53bb763 100644 --- a/.agents/skills/ardan-pr-review/references/langs/terraform.md +++ b/.agents/skills/ardan-pr-review/references/langs/terraform.md @@ -1,6 +1,6 @@ # Terraform / OpenTofu idioms -**Tools:** the binary named in `stack.tsv` (`terraform` or `tofu`); `rg` for references (`refs.tsv` names are addresses: `aws_s3_bucket.this`, `module.vpc`, `var.name`). **Checks:** `tf-fmt`, `tf-validate` (`init -backend=false`: providers and modules, no state, no plan), `tflint`, `iac-scan`. Never run `plan`, `apply`, `destroy`, `import`, `state`, or `test` against real infrastructure — no reviewer has the state or the credentials. An init failure (private registry, credentials) is an Open check, not a finding. +**Tools:** the binary named in `stack.tsv` (`terraform` or `tofu`); `rg` for references (`refs.tsv` names are addresses: `aws_s3_bucket.this`, `module.vpc`, `var.name`). **Checks:** `tf-fmt`, `tflint`, `iac-scan`; for a maintainer PR also `tf-validate` (`init -backend=false`: providers and modules, no state, no plan) and `tflint` with the repo's config and plugins — otherwise tflint runs its bundled rules only. Never run `plan`, `apply`, `destroy`, `import`, `state`, or `test` against real infrastructure — no reviewer has the state or the credentials. An init failure (private registry, credentials) is an Open check, not a finding. ## Correctness - **Replacement risk:** a renamed or moved resource/module without a `moved` block, a `count` ↔ `for_each` switch, changed `for_each` keys, or a changed force-new argument destroys and recreates it. On stateful resources (databases, buckets, volumes, KMS keys, DNS zones) that is G4; name the address. diff --git a/.agents/skills/ardan-pr-review/references/langs/web.md b/.agents/skills/ardan-pr-review/references/langs/web.md index c6508d2..7b939f7 100644 --- a/.agents/skills/ardan-pr-review/references/langs/web.md +++ b/.agents/skills/ardan-pr-review/references/langs/web.md @@ -1,6 +1,6 @@ # Vue / JS / TS / CSS idioms -**Tools:** an LSP if available, else `rg`/`ast-grep`. **Checks:** `jscpd` (clones); the repo's own `lint`/`typecheck`/`test` scripts arrive as extra checks when approved. +**Tools:** an LSP if available, else `rg`/`ast-grep`. **Checks:** `jscpd` (clones); the repo's own `lint`/`typecheck`/`test` scripts arrive as extra checks when approved, for a maintainer PR only. ## Correctness Reactivity (mutating props, lost reactivity on destructuring, `watch` without cleanup), leaked listeners/timers/subscriptions, unhandled async, race between overlapping requests, stale closures, `==` coercion, a11y (labels, roles, keyboard, focus). diff --git a/.agents/skills/ardan-pr-review/references/lenses/harness-map.md b/.agents/skills/ardan-pr-review/references/lenses/harness-map.md index 57b9539..84a67fb 100644 --- a/.agents/skills/ardan-pr-review/references/lenses/harness-map.md +++ b/.agents/skills/ardan-pr-review/references/lenses/harness-map.md @@ -2,7 +2,7 @@ Would the tests fail for the bugs this diff is most likely to introduce (at full depth, the bugs most likely in the code in scope)? Judge by regression value, not coverage percentage; skip trivial accessors. Use the **Tests** section of each idiom file in your prompt. -**Tests already ran** — see the `lens=harness-map` rows of `checks/summary.tsv` (`go-test*`, `cargo-test*`, `pytest*`, approved `x-*` test gates) and `checks/setup.txt`. A failing or timed-out test is a finding when the change can cause it (at full depth, any test in a reviewed unit), or an Open check when it's environmental (say why). At `diff` depth, a failure unrelated to the change is out of scope: drop it. Don't re-run the suite; one targeted test run to prove a point is fine. +**Tests ran only for a maintainer PR** (`trust=maintainer`); otherwise their rows say "runs the reviewed code" and you judge the tests by reading them — don't report that they didn't run. When they ran, see the `lens=harness-map` rows of `checks/summary.tsv` (`go-test*`, `cargo-test*`, `pytest*`, approved `x-*` test gates) and `checks/setup.txt`. A failing or timed-out test is a finding when the change can cause it (at full depth, any test in a reviewed unit), or an Open check when it's environmental (say why). At `diff` depth, a failure unrelated to the change is out of scope: drop it. Don't re-run the suite; one targeted test run to prove a point is fine. For infrastructure and pipelines, tests include policy tests, `terraform test`, chart unit tests, Molecule, and render/validate checks. When the change has no automated path to test it, say so and name the manual check. diff --git a/.agents/skills/ardan-pr-review/references/lenses/security-sentinel.md b/.agents/skills/ardan-pr-review/references/lenses/security-sentinel.md index 4a2398c..a35d845 100644 --- a/.agents/skills/ardan-pr-review/references/lenses/security-sentinel.md +++ b/.agents/skills/ardan-pr-review/references/lenses/security-sentinel.md @@ -31,7 +31,7 @@ Sub-agents are read-only except for PoCs. ## Running the project -Only when static reading is inconclusive, and only as the project interaction guide in your prompt allows. Without one, write "needs project interaction guide: " under Open checks. Local/test instances only; nothing destructive, no real data. +Only with `Trust: maintainer` in your prompt, only when static reading is inconclusive, and only as the project interaction guide in your prompt allows. Otherwise never run the project or a PoC: write the PoC if it helps and say under Open checks that it was not run. Without one, write "needs project interaction guide: " under Open checks. Local/test instances only; nothing destructive, no real data. ## Intent diff --git a/.agents/skills/ardan-pr-review/references/roles/aggregator.md b/.agents/skills/ardan-pr-review/references/roles/aggregator.md index 2c15732..61da8c7 100644 --- a/.agents/skills/ardan-pr-review/references/roles/aggregator.md +++ b/.agents/skills/ardan-pr-review/references/roles/aggregator.md @@ -9,7 +9,7 @@ Turn the agents' reports into the one report the user reads, and act as the seco 1. **Merge.** Deduplicate findings by failure mode and location; keep the higher Gate and list every lens that raised it. 2. **Scope.** At `diff` depth, drop every finding, Open check, and failed check that sits outside the review scope in `contract.md`: a problem that predates the change and that the change neither causes nor worsens. Drop it silently; don't move it to Open checks. At full depth, keep the `Origin: pre-existing` field on findings that carry it. 3. **Verify.** For each G2+ finding, re-open the cited line and try to refute it: stale line, handled elsewhere (find where), intended per ticket or contract, false pattern match. Spot-check G1. Drop, downgrade, or move to Open checks what doesn't hold. Upgrade only on evidence. -4. **Failed checks.** A `fail` or `findings` row in `checks/summary.tsv` on in-scope code that no agent reported becomes a finding under the lens in its `lens` column (security rows only when security ran). Environmental failures (missing credentials, registry, chart dependencies, toolchain) go to Open checks. +4. **Failed checks.** A `fail` or `findings` row in `checks/summary.tsv` on in-scope code that no agent reported becomes a finding under the lens in its `lens` column (security rows only when security ran). Environmental failures (missing credentials, registry, chart dependencies, toolchain) go to Open checks. When rows were skipped because they run the reviewed code (`trust` ≠ `maintainer`), add one Open check: "Not run (not a maintainer PR): ". 5. **Reopened risk.** A polish G3/G4 that contradicts a risk agent's conclusion goes first in the Fix queue, naming the agent to re-run. 6. **Security issues** are the surviving Security Sentinel and generated-code findings. Your prompt gives the mode: *off* (none exist), *redacted*, or *named*. 7. **Number** findings `F1…Fn`, G4 → G1, then by path — in *redacted* mode, without the security issues. diff --git a/.agents/skills/ardan-pr-review/references/roles/discovery.md b/.agents/skills/ardan-pr-review/references/roles/discovery.md index d07f926..43b18e9 100644 --- a/.agents/skills/ardan-pr-review/references/roles/discovery.md +++ b/.agents/skills/ardan-pr-review/references/roles/discovery.md @@ -8,7 +8,7 @@ Find what the change is built with and how the repo itself checks it, so the run 1. **Stack.** Confirm the `# stack` rows. For `# unmapped` paths and marker files the scripts don't know (e.g. Pulumi, CDK, CloudFormation/SAM, Serverless, Packer, Nomad, Bazel, Nix, Earthly, Tilt, Crossplane, Argo CD/Flux, Jenkins shared libraries, Java/Kotlin/C#/Ruby/PHP/C/C++ builds), name the ecosystem and its units. 2. **Idioms.** Each ecosystem in scope maps to a file in `/references/langs/` (list the directory) or, when none fits, gets ≤ 8 terse review bullets under *Inline idioms*, one per concern: correctness, failure paths, contracts, tests, security. -3. **Repo gates.** From the Makefile, CI, and pre-commit files: the commands the repo runs to lint, type-check, test, or scan the in-scope ecosystems. Keep those the built-in checks (below) don't cover, or that wrap the repo's own configuration (`make lint`, `npm run lint`, `just check`, `nox -s lint`, `./gradlew check -x test`, `mvn -q verify -DskipTests`). +3. **Repo gates.** They execute the reviewed repo's own commands, so they are only ever offered for a maintainer PR; list them either way. From the Makefile, CI, and pre-commit files: the commands the repo runs to lint, type-check, test, or scan the in-scope ecosystems. Keep those the built-in checks (below) don't cover, or that wrap the repo's own configuration (`make lint`, `npm run lint`, `just check`, `nox -s lint`, `./gradlew check -x test`, `mvn -q verify -DskipTests`). 4. **Extra checks** — propose a gate only if it is safe to run unattended: - read-only on the working tree: no `--fix`, `--write`, or formatter without `--check`/`--diff`; - no deploy, apply, push, publish, or release: no `terraform plan|apply`, `helm install|upgrade`, `kubectl apply`, cloud CLIs, `docker push`; diff --git a/.agents/skills/ardan-pr-review/scripts/checks.ps1 b/.agents/skills/ardan-pr-review/scripts/checks.ps1 index 0a8ae7b..15da1e4 100644 --- a/.agents/skills/ardan-pr-review/scripts/checks.ps1 +++ b/.agents/skills/ardan-pr-review/scripts/checks.ps1 @@ -1,7 +1,8 @@ # Usage: checks.ps1 RUN_DIR [-Env K=V,...] [-Exclude PATH,...] [-Timeout 15m] [-NoTests] [-Extra NAME,...|all] # PowerShell port of checks.sh (same outputs). Runs every mechanical check ONCE, scoped to the # files/units intake recorded in RUN_DIR/context, in parallel; the depth in scope.txt picks -# the files (see checks.sh, also for what is written outside the run directory). Output: +# the files (see checks.sh, also for what is written outside the run directory, and for the +# checks that run the reviewed code only when scope.txt says trust=maintainer). Output: # RUN_DIR/context/checks/.txt (full log) and RUN_DIR/context/checks/summary.tsv. # -Env K=V set for every check (e.g. GITHUB_WORKSPACE=C:\repo) # -Exclude PATH never run tests in packages/crates/projects under PATH (repo-relative) @@ -38,6 +39,11 @@ $baseLine = $scopeLines | Where-Object { $_.StartsWith('base=') } | Select-Objec $baseSha = if ($baseLine) { $baseLine.Substring(5) } else { '' } $depthLine = $scopeLines | Where-Object { $_.StartsWith('depth=') } | Select-Object -First 1 $depth = if ($depthLine) { $depthLine.Substring(6) } else { 'diff' } +$trustLine = $scopeLines | Where-Object { $_.StartsWith('trust=') } | Select-Object -First 1 +$trust = if ($trustLine) { $trustLine.Substring(6) } else { 'untrusted' } +# RunsCode: checks may execute the reviewed code (maintainer PR only) +$runsCode = $trust -ceq 'maintainer' +$untrusted = "runs the reviewed code: only for a maintainer PR (trust=$trust)" # scoped: the files under review — the changed files, or the review set at full depth $scoped = Join-Path $ctx $(if ($depth -eq 'diff') { 'files.tsv' } else { 'review-set.tsv' }) if (-not (Test-Path -LiteralPath $root -PathType Container)) { Die "cannot enter repo root $root" } @@ -51,7 +57,7 @@ $envKeys = foreach ($kv in $Env) { } $Exclude = @($Exclude | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('/') }) $tests = -not $NoTests -Out-Text (Join-Path $out 'setup.txt') ("env_keys=$(@($envKeys) -join ' ')`ntest_excludes=$(($Exclude | ForEach-Object { "$_ " }) -join '')`ntest_timeout=$Timeout`ntests=$([int]$tests)`n") +Out-Text (Join-Path $out 'setup.txt') ("env_keys=$(@($envKeys) -join ' ')`ntest_excludes=$(($Exclude | ForEach-Object { "$_ " }) -join '')`ntest_timeout=$Timeout`ntests=$([int]$tests)`ntrust=$trust`n") $tools = @{}; foreach ($r in @(ReadTsv (Join-Path $ctx 'tools.tsv'))) { $tools[$r[0]] = $r[1] } function Has([string]$t) { $tools.ContainsKey($t) } @@ -191,12 +197,16 @@ foreach ($m in $modules) { # -o NUL / /dev/null: a single main package would otherwise write its binary into the repo Job "go-build$sfx" $dir 'go' (@('build', '-o', $(if ([Environment]::OSVersion.Platform -eq 'Win32NT') { 'NUL' } else { '/dev/null' })) + $pkgs) Job "go-vet$sfx" $dir 'go' (@('vet') + $pkgs) - if (-not $tests) { Skip "go-test$sfx" '--no-tests' } + if (-not $runsCode) { Skip "go-test$sfx" $untrusted } + elseif (-not $tests) { Skip "go-test$sfx" '--no-tests' } elseif (-not $testPkgs.Count) { Skip "go-test$sfx" 'all packages excluded' } else { Job "go-test$sfx" $dir 'go' (@('test', '-race', '-count=1', '-timeout', $Timeout) + $testPkgs) } $golangciCfg = @(Get-ChildItem -LiteralPath $dir -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -cmatch '^\.golangci\.(ya?ml|toml|json)$' }) if ($golangciCfg.Count) { - if (Has 'golangci-lint') { Job "golangci-lint$sfx" $dir 'golangci-lint' (@('run') + $pkgs) } + # custom linters load plugin code named in the repo's config + $customLinters = [bool](@($golangciCfg | ForEach-Object { [IO.File]::ReadAllLines($_.FullName) }) -cmatch '^\s*custom\s*[:=]') + if (-not $runsCode -and $customLinters) { Skip "golangci-lint$sfx" "$untrusted (custom linter plugins)" } + elseif (Has 'golangci-lint') { Job "golangci-lint$sfx" $dir 'golangci-lint' (@('run') + $pkgs) } else { Skip "golangci-lint$sfx" 'golangci-lint not installed' } } if (Has 'staticcheck') { Job "staticcheck$sfx" $dir 'staticcheck' $pkgs } else { Skip "staticcheck$sfx" 'staticcheck not installed' } @@ -254,9 +264,12 @@ if ($rustUnits.Count) { $s = USfx 'rust' $u; $dir = UnitDir $u if ($hasFmt) { Job "cargo-fmt$s" $dir 'cargo' @('fmt', '--check') } else { Skip "cargo-fmt$s" 'rustfmt not installed' } # --locked: fail instead of rewriting Cargo.lock - if ($hasClippy) { Job "cargo-clippy$s" $dir 'cargo' @('clippy', '--all-targets', '--locked', '--', '-D', 'warnings') } + # clippy/check/test compile the crate: build scripts and proc macros run + if (-not $runsCode) { Skip "cargo-clippy$s" "$untrusted (build scripts, proc macros)" } + elseif ($hasClippy) { Job "cargo-clippy$s" $dir 'cargo' @('clippy', '--all-targets', '--locked', '--', '-D', 'warnings') } else { Skip "cargo-clippy$s" 'clippy not installed'; Job "cargo-check$s" $dir 'cargo' @('check', '--all-targets', '--locked') } - if (-not $tests) { Skip "cargo-test$s" '--no-tests' } + if (-not $runsCode) { Skip "cargo-test$s" $untrusted } + elseif (-not $tests) { Skip "cargo-test$s" '--no-tests' } elseif (IsExcl $u) { Skip "cargo-test$s" 'crate excluded' } else { JobT "cargo-test$s" $dir 'cargo' @('test', '--all-targets', '--locked') } } @@ -300,9 +313,16 @@ $tfValidateFn = { if (-not $hadLock) { Remove-Item -LiteralPath '.terraform.lock.hcl' -Force -ErrorAction SilentlyContinue } Remove-Item -LiteralPath $d -Recurse -Force -ErrorAction SilentlyContinue } +# The repo's .tflint.hcl (and its plugins) only for a maintainer PR; otherwise a neutral config +# with the bundled rules and no plugin installation. $tfLintFn = { - param($st, [string]$root) + param($st, [string]$root, [bool]$trusted) $cfg = '' + if (-not $trusted) { + $cfg = [IO.Path]::GetTempFileName(); [IO.File]::WriteAllText($cfg, "config {`n}`n") + & tflint "--config=$cfg" --format=compact --no-color 2>&1 | ForEach-Object { "$_" } + $st.rc = $LASTEXITCODE; Remove-Item -LiteralPath $cfg -Force -ErrorAction SilentlyContinue; return + } if (Test-Path -LiteralPath '.tflint.hcl') { $cfg = (Resolve-Path -LiteralPath '.tflint.hcl').Path } elseif (Test-Path -LiteralPath (Join-Path $root '.tflint.hcl')) { $cfg = Join-Path $root '.tflint.hcl' } if ($cfg) { & tflint --init "--config=$cfg" *> $null; & tflint "--config=$cfg" --format=compact --no-color 2>&1 | ForEach-Object { "$_" } } @@ -314,11 +334,15 @@ if ($tfFiles.Count) { if (-not $tfBin -or -not (Has $tfBin)) { Skip 'tf' "$tfBin not installed" } else { JobFn 'tf-fmt' $root "tf_fmt $($tfFiles -join ' ')" $tfFmtFn @($tfBin, [string[]]$tfFiles) - foreach ($u in @(Units 'terraform')) { JobFn "tf-validate$(USfx 'terraform' $u)" (UnitDir $u) 'tf_validate' $tfValidateFn @($tfBin) } + # init installs the providers and modules the change names, and validate runs them + foreach ($u in @(Units 'terraform')) { + if ($runsCode) { JobFn "tf-validate$(USfx 'terraform' $u)" (UnitDir $u) 'tf_validate' $tfValidateFn @($tfBin) } + else { Skip "tf-validate$(USfx 'terraform' $u)" "$untrusted (providers, modules)" } + } } foreach ($u in @(Units 'terraform')) { $s = USfx 'terraform' $u - if (Has 'tflint') { JobFn "tflint$s" (UnitDir $u) 'tf_lint' $tfLintFn @($root) } else { Skip "tflint$s" 'tflint not installed' } + if (Has 'tflint') { JobFn "tflint$s" (UnitDir $u) 'tf_lint' $tfLintFn @($root, $runsCode) } else { Skip "tflint$s" 'tflint not installed' } } } @@ -407,13 +431,17 @@ if ($pyFiles.Count) { ((Test-Path -LiteralPath $pyproject) -and ([IO.File]::ReadAllLines($pyproject) -cmatch '^\[tool\.mypy')) -or ((Test-Path -LiteralPath $setupCfg) -and ([IO.File]::ReadAllLines($setupCfg) -cmatch '^\[mypy')) if ($mypyCfg) { - if (Has 'mypy') { Job "mypy$s" $dir 'mypy' (@("--cache-dir=$nullDir") + (Under $u 'py')) } else { Skip "mypy$s" 'mypy not installed' } + # The repo's config can load mypy plugins: only for a maintainer PR; otherwise defaults. + $mcfg = @() + if (-not $runsCode) { $mf = Join-Path $out '.mypy-neutral.ini'; Out-Text $mf "[mypy]`n"; $mcfg = @("--config-file=$mf") } + if (Has 'mypy') { Job "mypy$s" $dir 'mypy' (@("--cache-dir=$nullDir") + $mcfg + (Under $u 'py')) } else { Skip "mypy$s" 'mypy not installed' } } Push-Location -LiteralPath $dir $hasTests = [bool](@(& git -c core.quotePath=false ls-files -- . 2>$null) -cmatch '(^|/)(test_[^/]*|[^/]*_test)\.py$') Pop-Location if ($hasTests) { - if (-not $tests) { Skip "pytest$s" '--no-tests' } + if (-not $runsCode) { Skip "pytest$s" $untrusted } + elseif (-not $tests) { Skip "pytest$s" '--no-tests' } elseif (IsExcl $u) { Skip "pytest$s" 'project excluded' } else { # the project's venv, else a pytest importable by python, else a standalone pytest (pipx) @@ -426,7 +454,8 @@ if ($pyFiles.Count) { } } foreach ($r in @(Get-ChildItem -LiteralPath $dir -File -Filter 'requirements*.txt' | ForEach-Object { $_.Name } | Sort-Object)) { - if (Has 'pip-audit') { Job "pip-audit$s@$($r -creplace '\.txt$', '')" $dir 'pip-audit' @('-r', $r) } else { Skip "pip-audit$s" 'pip-audit not installed' } + # --no-deps --disable-pip: audit the pins as written; resolving could run setup.py code + if (Has 'pip-audit') { Job "pip-audit$s@$($r -creplace '\.txt$', '')" $dir 'pip-audit' @('--no-deps', '--disable-pip', '-r', $r) } else { Skip "pip-audit$s" 'pip-audit not installed' } } } } @@ -434,7 +463,10 @@ if ($pyFiles.Count) { # --- Ansible, YAML, Dockerfiles, CI ----------------------------------------------------- $ans = @(Live 'ansible') if ($ans.Count) { - if (Has 'ansible-lint') { Job 'ansible-lint' $root 'ansible-lint' (@('--offline', '-q') + $ans) } else { Skip 'ansible-lint' 'ansible-lint not installed' } + # ansible-lint loads the repo's plugins, modules, and custom rules + if (-not $runsCode) { Skip 'ansible-lint' "$untrusted (repo plugins and rules)" } + elseif (Has 'ansible-lint') { Job 'ansible-lint' $root 'ansible-lint' (@('--offline', '-q') + $ans) } + else { Skip 'ansible-lint' 'ansible-lint not installed' } } if (@('.yamllint', '.yamllint.yaml', '.yamllint.yml') | Where-Object { Test-Path -LiteralPath $_ }) { $yf = @(@(Live 'yaml') + @(Live 'ansible') + @(Live 'k8s') + @(Live 'ci') | Where-Object { $_ -cmatch '\.ya?ml$' }) @@ -452,7 +484,9 @@ if ($gha.Count) { # --- extra checks the user approved (context/extra-checks.tsv: name dir lens command source) --- # Each command runs in this PowerShell (sh on the shell port). $extraRows = @(ReadTsv (Join-Path $ctx 'extra-checks.tsv')) -if ($Extra.Count -and $extraRows.Count) { +# Repo gates run commands the reviewed repo defines: only for a maintainer PR. +if ($Extra.Count -and $extraRows.Count -and -not $runsCode) { Skip 'extra-checks' "$untrusted (repo-defined commands)" } +elseif ($Extra.Count -and $extraRows.Count) { $psExe = (Get-Process -Id $PID).Path foreach ($x in $extraRows) { if (-not $x[0] -or $x[0].StartsWith('#') -or $x.Count -lt 4) { continue } @@ -496,6 +530,7 @@ $semcfg = @('.semgrep.yml', '.semgrep.yaml', '.semgrep') | Where-Object { Test-P if ((Has 'semgrep') -and $semcfg) { Job 'semgrep' $root 'semgrep' (@('scan', '--metrics=off', '--config', $semcfg) + @(Live '*')) } if ($jobs.Count) { $jobs | Wait-Job | Out-Null; $jobs | Receive-Job -ErrorAction SilentlyContinue | Out-Null; $jobs | Remove-Job } +Remove-Item -LiteralPath (Join-Path $out '.mypy-neutral.ini') -Force -ErrorAction SilentlyContinue if ($baseSrc) { Remove-Item -LiteralPath $baseSrc -Recurse -Force -ErrorAction SilentlyContinue } if ($setCopy) { Remove-Item -LiteralPath $setCopy -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/.agents/skills/ardan-pr-review/scripts/checks.sh b/.agents/skills/ardan-pr-review/scripts/checks.sh index 2222079..31a5698 100644 --- a/.agents/skills/ardan-pr-review/scripts/checks.sh +++ b/.agents/skills/ardan-pr-review/scripts/checks.sh @@ -5,7 +5,10 @@ # RUN_DIR/context, in parallel. The depth comes from scope.txt: at diff depth file checks # cover the changed files; at full depth (files/packages/project) they cover review-set.tsv, # and unit checks (Go packages, crates, Terraform modules, charts, ...) cover packages.tsv and -# stack.tsv, which intake fills to match the depth. Nothing writes to the working tree beyond +# stack.tsv, which intake fills to match the depth. Checks that run the reviewed code (tests, +# Rust builds, Terraform init/validate, tflint plugins, mypy and Ansible plugins, repo gates) run +# only when scope.txt says trust=maintainer (a maintainer PR, see intake.sh); otherwise they are +# skipped and the rest run against neutral configs. Nothing writes to the working tree beyond # build caches (Go cache, cargo target/): Terraform init uses a temp TF_DATA_DIR and a read-only # lock file; Python checks run without caches or bytecode. # Output: RUN_DIR/context/checks/.txt (full log) and RUN_DIR/context/checks/summary.tsv @@ -46,6 +49,7 @@ ctx=$(cd -- "$run/context" 2>/dev/null && pwd) || die "no context in $run (run i root=$(sed -n 's/^root=//p' "$ctx/scope.txt") basesha=$(sed -n 's/^base=//p' "$ctx/scope.txt") depth=$(sed -n 's/^depth=//p' "$ctx/scope.txt") +trust=$(sed -n 's/^trust=//p' "$ctx/scope.txt") # scoped: the files under review — the changed files, or the review set at full depth scoped=$ctx/files.tsv [ "${depth:-diff}" = diff ] || scoped=$ctx/review-set.tsv @@ -60,6 +64,7 @@ toolpath() { awk -F '\t' -v t="$1" '$1 == t { print $2; exit }' "$ctx/tools.tsv" printf 'test_excludes=%s\n' "$(printf '%s' "$excludes" | tr '\n' ' ')" echo "test_timeout=$timeout" echo "tests=$tests" + echo "trust=${trust:-untrusted}" } >"$out/setup.txt" # job NAME CMD...: run CMD in the background, log to $out/NAME.txt, exit code to NAME.exit. @@ -72,6 +77,9 @@ job() { ) >"$out/$name.txt" 2>&1 & } skip() { printf '# skipped: %s\n' "$2" >"$out/$1.txt"; echo skip >"$out/$1.exit"; } +# runs_code: true when checks may execute the reviewed code (maintainer PR only) +runs_code() { [ "$trust" = maintainer ]; } +UNTRUSTED="runs the reviewed code: only for a maintainer PR (trust=${trust:-untrusted})" # live LANG: reviewed (not deleted) files of LANG ("*" = any), repo-relative. Generated files # reviewed on request (--include-generated) are read by reviewers but not linted. @@ -166,12 +174,15 @@ for m in $modules; do # -o /dev/null: a single main package would otherwise write its binary into the repo job "go-build$sfx" go build -o /dev/null $pkgs job "go-vet$sfx" go vet $pkgs - if [ $tests = 0 ]; then skip "go-test$sfx" "--no-tests" + if ! runs_code; then skip "go-test$sfx" "$UNTRUSTED" + elif [ $tests = 0 ]; then skip "go-test$sfx" "--no-tests" elif [ -z "$testpkgs" ]; then skip "go-test$sfx" "all packages excluded" else job "go-test$sfx" go test -race -count=1 -timeout "$timeout" $testpkgs fi if ls .golangci.y*ml .golangci.toml .golangci.json >/dev/null 2>&1; then - if has golangci-lint; then job "golangci-lint$sfx" golangci-lint run $pkgs + # custom linters load plugin code named in the repo's config + if ! runs_code && grep -Eqs '^[[:space:]]*custom[[:space:]]*[:=]' .golangci.*; then skip "golangci-lint$sfx" "$UNTRUSTED (custom linter plugins)" + elif has golangci-lint; then job "golangci-lint$sfx" golangci-lint run $pkgs else skip "golangci-lint$sfx" "golangci-lint not installed" fi fi @@ -263,10 +274,13 @@ if [ -n "$(units rust)" ]; then else skip "cargo-fmt$s" "rustfmt not installed" fi # --locked: fail instead of rewriting Cargo.lock - if cargo clippy --version >/dev/null 2>&1; then job "cargo-clippy$s" cargo clippy --all-targets --locked -- -D warnings + # clippy/check/test compile the crate: build scripts and proc macros run + if ! runs_code; then skip "cargo-clippy$s" "$UNTRUSTED (build scripts, proc macros)" + elif cargo clippy --version >/dev/null 2>&1; then job "cargo-clippy$s" cargo clippy --all-targets --locked -- -D warnings else skip "cargo-clippy$s" "clippy not installed"; job "cargo-check$s" cargo check --all-targets --locked fi - if [ $tests = 0 ]; then skip "cargo-test$s" "--no-tests" + if ! runs_code; then skip "cargo-test$s" "$UNTRUSTED" + elif [ $tests = 0 ]; then skip "cargo-test$s" "--no-tests" elif excluded "$u"; then skip "cargo-test$s" "crate excluded" else job "cargo-test$s" with_timeout cargo test --all-targets --locked fi @@ -312,8 +326,11 @@ tf_validate() { [ -n "$lock" ] || rm -f .terraform.lock.hcl rm -rf "$d" "$d.init"; return $rc } +# tf_lint: the repo's .tflint.hcl (and its plugins) only for a maintainer PR; otherwise a +# neutral config with the bundled rules and no plugin installation. tf_lint() { cfg='' + if ! runs_code; then cfg=$(mktemp) || return 1; printf 'config {\n}\n' >"$cfg"; tflint --config="$cfg" --format=compact --no-color; rc=$?; rm -f "$cfg"; return $rc; fi if [ -f .tflint.hcl ]; then cfg=$PWD/.tflint.hcl; elif [ -f "$root/.tflint.hcl" ]; then cfg=$root/.tflint.hcl; fi if [ -n "$cfg" ]; then tflint --init --config="$cfg" >/dev/null 2>&1; tflint --config="$cfg" --format=compact --no-color else tflint --format=compact --no-color @@ -328,7 +345,10 @@ if [ -n "$tf_files" ]; then while IFS= read -r u; do [ -n "$u" ] || continue s=$(usfx terraform "$u") - ( cd -- "$u" || exit 1; job "tf-validate$s" tf_validate; wait ) & + # init installs the providers and modules the change names, and validate runs them + if runs_code; then ( cd -- "$u" || exit 1; job "tf-validate$s" tf_validate; wait ) & + else skip "tf-validate$s" "$UNTRUSTED (providers, modules)" + fi done <"$mcfg"; fi # shellcheck disable=SC2086 - if has mypy; then job "mypy$s" mypy --cache-dir=/dev/null $rel; else skip "mypy$s" "mypy not installed"; fi + if has mypy; then job "mypy$s" mypy --cache-dir=/dev/null ${mcfg:+--config-file="$mcfg"} $rel; else skip "mypy$s" "mypy not installed"; fi fi if git ls-files -- . | grep -Eq '(^|/)(test_[^/]*|[^/]*_test)\.py$'; then - if [ $tests = 0 ]; then skip "pytest$s" "--no-tests" + if ! runs_code; then skip "pytest$s" "$UNTRUSTED" + elif [ $tests = 0 ]; then skip "pytest$s" "--no-tests" elif excluded "$u"; then skip "pytest$s" "project excluded" else # the project's venv, else a pytest importable by python3, else a standalone pytest (pipx) @@ -468,7 +492,8 @@ if [ -n "$py_files" ]; then fi for r in requirements*.txt; do [ -f "$r" ] || continue - if has pip-audit; then job "pip-audit$s@${r%.txt}" pip-audit -r "$r"; else skip "pip-audit$s" "pip-audit not installed"; fi + # --no-deps --disable-pip: audit the pins as written; resolving could run setup.py code + if has pip-audit; then job "pip-audit$s@${r%.txt}" pip-audit --no-deps --disable-pip -r "$r"; else skip "pip-audit$s" "pip-audit not installed"; fi done wait ) & @@ -481,7 +506,11 @@ fi ans_files=$(live ansible) if [ -n "$ans_files" ]; then # shellcheck disable=SC2086 - if has ansible-lint; then job ansible-lint ansible-lint --offline -q $ans_files; else skip ansible-lint "ansible-lint not installed"; fi + # ansible-lint loads the repo's plugins, modules, and custom rules + if ! runs_code; then skip ansible-lint "$UNTRUSTED (repo plugins and rules)" + elif has ansible-lint; then job ansible-lint ansible-lint --offline -q $ans_files + else skip ansible-lint "ansible-lint not installed" + fi fi ycfg='' for c in .yamllint .yamllint.yaml .yamllint.yml; do [ -e "$c" ] && ycfg=$c && break; done @@ -509,7 +538,9 @@ if [ -n "$gha" ]; then fi # --- extra checks the user approved (context/extra-checks.tsv: name dir lens command source) --- -if [ -n "$extra" ] && [ -f "$ctx/extra-checks.tsv" ]; then +# Repo gates run commands the reviewed repo defines: only for a maintainer PR. +if [ -n "$extra" ] && [ -f "$ctx/extra-checks.tsv" ] && ! runs_code; then skip extra-checks "$UNTRUSTED (repo-defined commands)" +elif [ -n "$extra" ] && [ -f "$ctx/extra-checks.tsv" ]; then while IFS="$(printf '\t')" read -r xn xd _ xc _; do case $xn in ''|\#*) continue ;; esac case ",$extra," in *",$xn,"*|,all,) ;; *) continue ;; esac @@ -559,6 +590,7 @@ if has semgrep && [ -n "$semcfg" ]; then fi wait +rm -f "$out/.mypy-neutral.ini" # --- summary ---------------------------------------------------------------------------- # lens_of CHECK: the lens that owns a check's failures (extra checks: their lens column) diff --git a/.agents/skills/ardan-pr-review/scripts/intake.ps1 b/.agents/skills/ardan-pr-review/scripts/intake.ps1 index c3c0b5b..7c405e4 100644 --- a/.agents/skills/ardan-pr-review/scripts/intake.ps1 +++ b/.agents/skills/ardan-pr-review/scripts/intake.ps1 @@ -6,6 +6,7 @@ # files. -Exclude drops paths from the scope. -Depth: see intake.sh (review-set.tsv). # Size guard: see intake.sh. Generated and vendored files are skipped unless -IncludeGenerated; # symlinks are never reviewed. -DryRun prints the summary without writing anything. +# Trust: see intake.sh — trust=maintainer only for a -Pr from a maintainer, on a base-repo branch. # Exit: 0 ok | 2 usage | 3 code on disk is not the code under review | 4 empty diff | 5 not a git repo. param( [string]$Id = '', @@ -818,6 +819,26 @@ try { $missing = @($missing) Out-Lines (Join-Path $ctx 'missing.tsv') $missing + # --- trust: may checks run the reviewed code? (see intake.sh) ---------------------------- + $trust = 'untrusted'; $trustReason = 'not a pull request (--pr)' + if ($Pr) { + $trustReason = 'could not read the PR author from GitHub' + $tj = ((& gh pr view $Pr --json author,isCrossRepository,url --jq '.author.login + " " + (.isCrossRepository | tostring) + " " + .url' 2>$null) -join ' ').Trim() + $parts = @($tj -split ' ') + @('', '', '') + $login = $parts[0]; $cross = $parts[1]; $prUrl = $parts[2] + $prHost = if ($prUrl -cmatch '^https://([^/]*)/[^/]*/[^/]*/pull/') { $Matches[1] } else { '' } + $repo = if ($prUrl -cmatch '^https://[^/]*/([^/]*/[^/]*)/pull/') { $Matches[1] } else { '' } + if ($login -and $repo) { + if ($cross -ne 'false') { $trustReason = 'PR head branch is in a fork' } + else { + $perm = ((& gh api --hostname $prHost "repos/$repo/collaborators/$login/permission" --jq .permission 2>$null) -join '').Trim() + if ('admin', 'maintain', 'write' -ccontains $perm) { $trust = 'maintainer'; $trustReason = "$login has $perm access to $repo; head branch in $repo" } + elseif (-not $perm) { $trustReason = "could not read $login's permission on $repo" } + else { $trustReason = "$login has $perm access to $repo" } + } + } + } + # --- scope.txt ---------------------------------------------------------------------- $langs = ($rev | Group-Object Lang | ForEach-Object { "$($_.Name):$($_.Count)" }) -join ' ' $branch = GitOut symbolic-ref --short -q HEAD; if (-not $branch) { $branch = 'detached' } @@ -836,7 +857,7 @@ try { $scope = @("id=$slug", "nn=$nn", "run=$run", "root=$top", "scope=$desc", "depth=$Depth", "head=$head", "base=$baseSha", "branch=$branch", "files=$($allRecs.Count)", "reviewable_files=$($rev.Count)", "skipped_files=$($skippedRecs.Count)", "lines_added=$added", "lines_deleted=$deleted", "code_files=$($code.Count)", "code_lines_changed=$codeLines", "languages=$langs", "ecosystems=$ecos", "idioms=$($idioms -join ',')", - "generated=$(if ($IncludeGenerated) { 'included' } else { 'skipped' })", "go_packages=$($pk.Count)", + "generated=$(if ($IncludeGenerated) { 'included' } else { 'skipped' })", "trust=$trust", "trust_reason=$trustReason", "go_packages=$($pk.Count)", "symbols=$($syms.Count)", "refs=$($refs.Count)", "shards=$shards", "diff_tokens_est=$tokReview") + $setInfo + @("noise_tokens_est=$tokNoise", "size_warnings=$($sizeLines.Count)", "missing_tools=$($missing.Count)") + $signals Out-Lines (Join-Path $ctx 'scope.txt') $scope diff --git a/.agents/skills/ardan-pr-review/scripts/intake.sh b/.agents/skills/ardan-pr-review/scripts/intake.sh index 736251a..890a308 100644 --- a/.agents/skills/ardan-pr-review/scripts/intake.sh +++ b/.agents/skills/ardan-pr-review/scripts/intake.sh @@ -14,6 +14,9 @@ # read with > --max-file-lines lines (2000), noise (generated/vendor/lockfiles) and PRs # over the shard threshold. Generated and vendored files are skipped unless --include-generated; # symlinks are never reviewed. --dry-run prints the summary without writing anything. +# Trust: scope.txt trust=maintainer only for --pr when GitHub says the PR author has write, +# maintain, or admin access and the head branch is in the base repo (not a fork); anything else +# is trust=untrusted, and checks never run the reviewed code (tests, builds, plugins, repo gates). # Exit: 0 ok | 2 usage | 3 code on disk is not the code under review | 4 empty diff | 5 not a git repo. set -u @@ -828,6 +831,31 @@ need() { # need TOOL NEEDED_FOR INSTALL [go]: record TOOL if missing (or stale, fi } >"$ctx/missing.tsv" +# --- trust: may checks run the reviewed code? ---------------------------------------- +# Only a pull request from a maintainer: its author has write/maintain/admin on the base repo +# and its head branch lives there, so only people with write access can have pushed to it +# (commit authors are not checked: anyone can set them). Every failure means untrusted. +trust=untrusted trust_reason="not a pull request (--pr)" +if [ "$scope" = pr ]; then + trust_reason="could not read the PR author from GitHub" + # shellcheck disable=SC2046 # split "login cross url" + set -- $(gh pr view "$pr" --json author,isCrossRepository,url --jq '.author.login + " " + (.isCrossRepository | tostring) + " " + .url' 2>/dev/null) + login=${1-} cross=${2-} prurl=${3-} + host=$(printf '%s' "$prurl" | sed -n 's|^https://\([^/]*\)/[^/]*/[^/]*/pull/.*|\1|p') + repo=$(printf '%s' "$prurl" | sed -n 's|^https://[^/]*/\([^/]*/[^/]*\)/pull/.*|\1|p') + if [ -n "$login" ] && [ -n "$repo" ]; then + if [ "$cross" != false ]; then trust_reason="PR head branch is in a fork" + else + perm=$(gh api --hostname "$host" "repos/$repo/collaborators/$login/permission" --jq .permission 2>/dev/null) + case $perm in + admin|maintain|write) trust=maintainer trust_reason="$login has $perm access to $repo; head branch in $repo" ;; + '') trust_reason="could not read $login's permission on $repo" ;; + *) trust_reason="$login has $perm access to $repo" ;; + esac + fi + fi +fi + # --- scope.txt ------------------------------------------------------------------ langs=$(awk -F '\t' '{ c[$4]++ } END { for (l in c) printf "%s:%d ", l, c[l] }' "$ctx/files.tsv") ecos=$(cut -f1 "$ctx/stack.tsv" | uniq -c | awk '{ printf "%s:%d ", $2, $1 }') @@ -864,6 +892,8 @@ awk -F '\t' '$1 != "D" && $4 !~ /^(go|rust|vue|ts|js|css|py|tf|helm|k8s|ansible| echo "ecosystems=${ecos% }" echo "idioms=$idioms" echo "generated=$([ "$incgen" = 1 ] && echo included || echo skipped)" + echo "trust=$trust" + echo "trust_reason=$trust_reason" echo "go_packages=$(wc -l <"$ctx/packages.tsv" | tr -d ' ')" echo "symbols=$(wc -l <"$ctx/symbols.tsv" | tr -d ' ')" echo "refs=$(wc -l <"$ctx/refs.tsv" | tr -d ' ')" diff --git a/README.md b/README.md index 94c9c6d..38cb00a 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,9 @@ Plugin skills are namespaced, so `ardan-pr-review` is invoked as `/ardan-pr-revi ## PR review tools -`ardan-pr-review` runs the tools below when the change needs them, per ecosystem (Go module, crate, Terraform module, chart, …). A missing tool is listed with its install command at the start of a review, and its check is skipped. Go tools built with an older Go than the active toolchain are flagged for a rebuild: analyzers fail on a newer standard library. Ecosystems not listed here are found by a discovery agent, which also offers the repo's own quality gates (Makefile, CI, pre-commit) as extra checks; only the ones you approve run. +`ardan-pr-review` runs the tools below when the change needs them, per ecosystem (Go module, crate, Terraform module, chart, …). A missing tool is listed with its install command at the start of a review, and its check is skipped. Go tools built with an older Go than the active toolchain are flagged for a rebuild: analyzers fail on a newer standard library. Ecosystems not listed here are found by a discovery agent, which also lists the repo's own quality gates (Makefile, CI, pre-commit). + +**The reviewed code never runs unless it's a maintainer's PR.** Tests, `cargo clippy`/`check`/`test`, Terraform `init`/`validate`, linter plugins (tflint, mypy, ansible-lint, golangci-lint custom linters), the repo's own gates, and security PoCs run only for `--pr` when GitHub reports that the PR author has write access and the head branch is in the base repo. Any other change gets the read-only checks, with neutral configs where a repo config could load code. | Tool | Checks | Install | |--------------------------------------|-------------------------------------------------|------------------------------------------------------------------------------| diff --git a/zarf/tests/ardan-pr-review/README.md b/zarf/tests/ardan-pr-review/README.md index 9948e15..4381618 100644 --- a/zarf/tests/ardan-pr-review/README.md +++ b/zarf/tests/ardan-pr-review/README.md @@ -17,6 +17,7 @@ CI (`.github/workflows/ardan-pr-review.yml`) runs `lint`, host mode on Linux and - **sh ↔ PowerShell parity** — `intake.sh`/`intake.ps1` write identical context files at diff, project, and packages + `--include-generated` depth; `checks.sh`/`checks.ps1` give the same check, status, and lens; both `extract-findings` ports save the same files. - **Golden results** — `expected/` holds the intake outputs (files, stack, symbols, refs, signals, review sets, …) and the check statuses per mode (`host.checks.tsv`, `container.checks.tsv`, `tofu.*`). +- **Trust** — a fake `gh` (`stub-gh.sh`) drives `--pr` runs: only an author with write access on a base-repo branch gets `trust=maintainer`; read access or a fork stays untrusted. Code-executing checks are skipped in the untrusted runs (`*.checks.tsv`) and run in the maintainer run (`*.trusted.checks.tsv`). - **Read-only** — checks leave `git status` unchanged (cargo's gitignored `target/` excepted), including Terraform init and the OpenTofu pass. Host mode replaces every analyzer with a stub, so it tests routing and both ports on BSD (macOS) and GNU (Linux) userlands. Container mode runs the real tools from `Dockerfile`. diff --git a/zarf/tests/ardan-pr-review/expected/container.checks.tsv b/zarf/tests/ardan-pr-review/expected/container.checks.tsv index 8a228c2..ce4162d 100644 --- a/zarf/tests/ardan-pr-review/expected/container.checks.tsv +++ b/zarf/tests/ardan-pr-review/expected/container.checks.tsv @@ -1,22 +1,23 @@ check status lens actionlint fail service-steward -ansible-lint fail service-steward +ansible-lint skipped service-steward apidiff pass boundary-keeper bandit fail security-sentinel cargo-audit pass security-sentinel -cargo-clippy fail service-steward +cargo-clippy skipped service-steward cargo-deny fail security-sentinel cargo-fmt pass service-steward -cargo-test fail harness-map +cargo-test skipped harness-map checklocks pass service-steward deadcode findings straight-line-pass dupl pass clone-hunter errcheck pass error-tripwire +extra-checks skipped service-steward gitleaks pass security-sentinel go-build pass service-steward go-fix pass service-steward go-mod-tidy pass service-steward -go-test fail harness-map +go-test skipped harness-map go-vet fail service-steward gofmt findings service-steward gosec pass security-sentinel @@ -30,15 +31,14 @@ kustomize fail service-steward mypy fail service-steward nilaway pass service-steward pip-audit@requirements fail security-sentinel -pytest fail harness-map +pytest skipped harness-map ruff-format fail service-steward ruff fail service-steward staticcheck fail service-steward tf-fmt fail service-steward -tf-validate@infra_locked pass service-steward -tf-validate@infra_nolock fail service-steward +tf-validate@infra_locked skipped service-steward +tf-validate@infra_nolock skipped service-steward tflint@infra_locked fail service-steward tflint@infra_nolock fail service-steward -x-echo-x pass service-steward yamllint pass service-steward zizmor fail security-sentinel diff --git a/zarf/tests/ardan-pr-review/expected/container.trusted.checks.tsv b/zarf/tests/ardan-pr-review/expected/container.trusted.checks.tsv new file mode 100644 index 0000000..8a228c2 --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/container.trusted.checks.tsv @@ -0,0 +1,44 @@ +check status lens +actionlint fail service-steward +ansible-lint fail service-steward +apidiff pass boundary-keeper +bandit fail security-sentinel +cargo-audit pass security-sentinel +cargo-clippy fail service-steward +cargo-deny fail security-sentinel +cargo-fmt pass service-steward +cargo-test fail harness-map +checklocks pass service-steward +deadcode findings straight-line-pass +dupl pass clone-hunter +errcheck pass error-tripwire +gitleaks pass security-sentinel +go-build pass service-steward +go-fix pass service-steward +go-mod-tidy pass service-steward +go-test fail harness-map +go-vet fail service-steward +gofmt findings service-steward +gosec pass security-sentinel +govulncheck pass security-sentinel +hadolint fail service-steward +helm-lint pass service-steward +helm-render fail service-steward +iac-scan fail security-sentinel +k8s-manifests fail service-steward +kustomize fail service-steward +mypy fail service-steward +nilaway pass service-steward +pip-audit@requirements fail security-sentinel +pytest fail harness-map +ruff-format fail service-steward +ruff fail service-steward +staticcheck fail service-steward +tf-fmt fail service-steward +tf-validate@infra_locked pass service-steward +tf-validate@infra_nolock fail service-steward +tflint@infra_locked fail service-steward +tflint@infra_nolock fail service-steward +x-echo-x pass service-steward +yamllint pass service-steward +zizmor fail security-sentinel diff --git a/zarf/tests/ardan-pr-review/expected/diff.scope.txt b/zarf/tests/ardan-pr-review/expected/diff.scope.txt index 34e4972..72aa95a 100644 --- a/zarf/tests/ardan-pr-review/expected/diff.scope.txt +++ b/zarf/tests/ardan-pr-review/expected/diff.scope.txt @@ -13,6 +13,8 @@ code_lines_changed=56 ecosystems=ansible:1 ci:1 docker:1 go:1 helm:1 k8s:1 kustomize:1 python:1 rust:1 terraform:2 idioms=ansible,ci,docker,go,helm-k8s,python,rust,terraform generated=skipped +trust=untrusted +trust_reason=not a pull request (--pr) go_packages=1 symbols=8 refs=2 diff --git a/zarf/tests/ardan-pr-review/expected/host.checks.tsv b/zarf/tests/ardan-pr-review/expected/host.checks.tsv index ad4fc5b..b1084b3 100644 --- a/zarf/tests/ardan-pr-review/expected/host.checks.tsv +++ b/zarf/tests/ardan-pr-review/expected/host.checks.tsv @@ -1,22 +1,23 @@ check status lens actionlint pass service-steward -ansible-lint pass service-steward +ansible-lint skipped service-steward apidiff findings boundary-keeper bandit pass security-sentinel cargo-audit pass security-sentinel -cargo-clippy pass service-steward +cargo-clippy skipped service-steward cargo-deny pass security-sentinel cargo-fmt pass service-steward -cargo-test pass harness-map +cargo-test skipped harness-map checklocks pass service-steward deadcode pass straight-line-pass dupl findings clone-hunter errcheck pass error-tripwire +extra-checks skipped service-steward gitleaks pass security-sentinel go-build pass service-steward go-fix skipped service-steward go-mod-tidy pass service-steward -go-test pass harness-map +go-test skipped harness-map go-vet pass service-steward gofmt findings service-steward gosec pass security-sentinel @@ -30,15 +31,14 @@ kustomize pass service-steward mypy pass service-steward nilaway pass service-steward pip-audit@requirements pass security-sentinel -pytest pass harness-map +pytest skipped harness-map ruff-format pass service-steward ruff pass service-steward staticcheck pass service-steward tf-fmt pass service-steward -tf-validate@infra_locked pass service-steward -tf-validate@infra_nolock pass service-steward +tf-validate@infra_locked skipped service-steward +tf-validate@infra_nolock skipped service-steward tflint@infra_locked pass service-steward tflint@infra_nolock pass service-steward -x-echo-x pass service-steward yamllint pass service-steward zizmor pass security-sentinel diff --git a/zarf/tests/ardan-pr-review/expected/host.trusted.checks.tsv b/zarf/tests/ardan-pr-review/expected/host.trusted.checks.tsv new file mode 100644 index 0000000..ad4fc5b --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/host.trusted.checks.tsv @@ -0,0 +1,44 @@ +check status lens +actionlint pass service-steward +ansible-lint pass service-steward +apidiff findings boundary-keeper +bandit pass security-sentinel +cargo-audit pass security-sentinel +cargo-clippy pass service-steward +cargo-deny pass security-sentinel +cargo-fmt pass service-steward +cargo-test pass harness-map +checklocks pass service-steward +deadcode pass straight-line-pass +dupl findings clone-hunter +errcheck pass error-tripwire +gitleaks pass security-sentinel +go-build pass service-steward +go-fix skipped service-steward +go-mod-tidy pass service-steward +go-test pass harness-map +go-vet pass service-steward +gofmt findings service-steward +gosec pass security-sentinel +govulncheck pass security-sentinel +hadolint pass service-steward +helm-lint pass service-steward +helm-render pass service-steward +iac-scan pass security-sentinel +k8s-manifests pass service-steward +kustomize pass service-steward +mypy pass service-steward +nilaway pass service-steward +pip-audit@requirements pass security-sentinel +pytest pass harness-map +ruff-format pass service-steward +ruff pass service-steward +staticcheck pass service-steward +tf-fmt pass service-steward +tf-validate@infra_locked pass service-steward +tf-validate@infra_nolock pass service-steward +tflint@infra_locked pass service-steward +tflint@infra_nolock pass service-steward +x-echo-x pass service-steward +yamllint pass service-steward +zizmor pass security-sentinel diff --git a/zarf/tests/ardan-pr-review/expected/tofu.checks.tsv b/zarf/tests/ardan-pr-review/expected/tofu.checks.tsv index e0c3ba6..9df05fb 100644 --- a/zarf/tests/ardan-pr-review/expected/tofu.checks.tsv +++ b/zarf/tests/ardan-pr-review/expected/tofu.checks.tsv @@ -1,5 +1,5 @@ tf-fmt fail service-steward -tf-validate@infra_locked fail service-steward -tf-validate@infra_nolock fail service-steward +tf-validate@infra_locked skipped service-steward +tf-validate@infra_nolock skipped service-steward tflint@infra_locked fail service-steward tflint@infra_nolock fail service-steward diff --git a/zarf/tests/ardan-pr-review/expected/trust.txt b/zarf/tests/ardan-pr-review/expected/trust.txt new file mode 100644 index 0000000..9bd9812 --- /dev/null +++ b/zarf/tests/ardan-pr-review/expected/trust.txt @@ -0,0 +1,9 @@ +# perm=write cross=false +trust=maintainer +trust_reason=maint has write access to fx/fx; head branch in fx/fx +# perm=read cross=false +trust=untrusted +trust_reason=maint has read access to fx/fx +# perm=write cross=true +trust=untrusted +trust_reason=PR head branch is in a fork diff --git a/zarf/tests/ardan-pr-review/stub-gh.sh b/zarf/tests/ardan-pr-review/stub-gh.sh new file mode 100644 index 0000000..5a38977 --- /dev/null +++ b/zarf/tests/ardan-pr-review/stub-gh.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Fake `gh` for the suite's pull-request runs: answers exactly the calls intake makes for +# `--pr 1`, from FX_HEAD / FX_BASE (commits), FX_GH_PERM (the author's permission), and +# FX_GH_CROSS (true when the head branch is in a fork). Anything else fails loudly. +url=https://github.com/fx/fx/pull/1 +case "$*" in + "pr view 1 --json headRefOid,baseRefOid,url --jq "*) echo "$FX_HEAD $FX_BASE $url" ;; + "pr view 1 --json headRefOid,baseRefOid,url") printf '{"headRefOid":"%s","baseRefOid":"%s","url":"%s"}\n' "$FX_HEAD" "$FX_BASE" "$url" ;; + "pr view 1 --json title,body,url --jq "*) printf '# Fixture PR\n\n%s\n\nFixture body\n' "$url" ;; + "pr view 1 --json title,body,url") printf '{"title":"Fixture PR","body":"Fixture body","url":"%s"}\n' "$url" ;; + "pr view 1 --json author,isCrossRepository,url --jq "*) echo "maint $FX_GH_CROSS $url" ;; + "api --hostname github.com repos/fx/fx/collaborators/maint/permission --jq .permission") echo "$FX_GH_PERM" ;; + *) echo "stub gh: unexpected call: $*" >&2; exit 1 ;; +esac diff --git a/zarf/tests/ardan-pr-review/suite.sh b/zarf/tests/ardan-pr-review/suite.sh index 43aafff..4ecebb9 100644 --- a/zarf/tests/ardan-pr-review/suite.sh +++ b/zarf/tests/ardan-pr-review/suite.sh @@ -121,6 +121,32 @@ if [ "$before" = "$after" ]; then pass "working tree unchanged by checks" else fail "working tree changed by checks"; printf '%s\n' "$after" | sed 's/^/ /' fi +# --- trust: PR runs through a fake gh ---------------------------------------------------------- +# Only a PR whose author has write access and whose head branch is in the base repo may run the +# reviewed code. The untrusted runs above (--base) already showed those checks are skipped. +mkdir -p "$work/ghbin" && cp "$here/stub-gh.sh" "$work/ghbin/gh" && chmod +x "$work/ghbin/gh" +FX_HEAD=$(git rev-parse HEAD) FX_BASE=$(git rev-parse main) +export FX_HEAD FX_BASE +path0=$PATH +PATH=$work/ghbin:$PATH +: >"$work/trust.view" +for t in write:false read:false write:true; do + FX_GH_PERM=${t%:*} FX_GH_CROSS=${t#*:} + export FX_GH_PERM FX_GH_CROSS + intake_pair "pr-$FX_GH_PERM-$FX_GH_CROSS" "--pr 1" "-Pr 1" + { echo "# perm=$FX_GH_PERM cross=$FX_GH_CROSS"; grep '^trust' ".reviews/sh/pr-$FX_GH_PERM-$FX_GH_CROSS/001/context/scope.txt"; } >>"$work/trust.view" +done +golden trust.txt "$work/trust.view" +cp "$work/extra.tsv" .reviews/sh/pr-write-false/001/context/extra-checks.tsv +cp "$work/extra.tsv" .reviews/ps/pr-write-false/001/context/extra-checks.tsv +checks_pair pr-write-false "--extra echo-x" "-Extra echo-x" +checks_view .reviews/sh/pr-write-false/001 >"$work/trusted.view"; golden "$mode.trusted.checks.tsv" "$work/trusted.view" +PATH=$path0 +after=$(tree_state) +if [ "$before" = "$after" ]; then pass "working tree unchanged by maintainer-PR checks" +else fail "working tree changed by maintainer-PR checks"; printf '%s\n' "$after" | sed 's/^/ /' +fi + # --- container: OpenTofu selection and its read-only lock ------------------------------------- if [ "$mode" = container ]; then echo 1.13.0 >.opentofu-version From 958b21b144161649e1a7c97d227b30891f8e028a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florin=20P=C4=83=C8=9Ban?= Date: Tue, 3 Nov 2026 13:29:29 +0100 Subject: [PATCH 3/5] Never let an untrusted change's golangci config load plugins The custom-linter guard grepped .golangci.* for "custom:", which other spellings of the same config (JSON, TOML tables, YAML flow style or anchors) slip past. For an untrusted change golangci-lint now runs with --no-config, so the repo's config is never parsed; a maintainer PR keeps the repo's config. The new test exposed two older bugs that kept golangci-lint from running at all: - sh: config detection used `ls a b c`, which fails when any one file is missing (intake and checks; also semgrep in intake) - PowerShell: Get-ChildItem hides dotfiles on Unix without -Force Tests: the fixture ships a .golangci.yml naming a custom plugin; the suite asserts --no-config for untrusted runs and the repo config for the maintainer run (both ports); golangci-lint pinned in the tools image. --- .../skills/ardan-pr-review/references/langs/go.md | 2 +- .agents/skills/ardan-pr-review/scripts/checks.ps1 | 12 ++++++------ .agents/skills/ardan-pr-review/scripts/checks.sh | 11 ++++++----- .agents/skills/ardan-pr-review/scripts/intake.ps1 | 2 +- .agents/skills/ardan-pr-review/scripts/intake.sh | 6 ++++-- README.md | 2 +- zarf/tests/ardan-pr-review/Dockerfile | 2 ++ .../ardan-pr-review/expected/container.checks.tsv | 1 + .../expected/container.trusted.checks.tsv | 1 + .../ardan-pr-review/expected/gen.review-set.tsv | 1 + zarf/tests/ardan-pr-review/expected/host.checks.tsv | 1 + .../ardan-pr-review/expected/host.trusted.checks.tsv | 1 + .../ardan-pr-review/expected/project.review-set.tsv | 1 + zarf/tests/ardan-pr-review/fixture.sh | 2 ++ zarf/tests/ardan-pr-review/suite.sh | 9 +++++++++ 15 files changed, 38 insertions(+), 16 deletions(-) diff --git a/.agents/skills/ardan-pr-review/references/langs/go.md b/.agents/skills/ardan-pr-review/references/langs/go.md index 17cae43..321a1c4 100644 --- a/.agents/skills/ardan-pr-review/references/langs/go.md +++ b/.agents/skills/ardan-pr-review/references/langs/go.md @@ -3,7 +3,7 @@ **Tools:** `gopls` MCP (`go_symbol_references`, `go_search`, `go_file_context`), else `gopls`/`go doc`, then `rg`. Skills: `use-modern-go` on every `.go` file; `ardan-service-architecture` on files with a `layer` (`app`, `business`, `storage`, `foundation`) — `references/types.md` for boundaries, `branching.md` for conditionals, `events.md` for domain events, `extensions.md` only when extension wiring is touched. **Checks** (`checks/summary.tsv`; package-wide tools report on whole packages, only in-scope hits count): -- `go-build`, `go-vet`, `staticcheck`, `golangci-lint` — each hit is a finding at the Gate its effect warrants. (`golangci-lint` is skipped for an untrusted change whose config loads custom linter plugins.) +- `go-build`, `go-vet`, `staticcheck`, `golangci-lint` — each hit is a finding at the Gate its effect warrants. (For an untrusted change `golangci-lint` runs with `--no-config`: default linters, since the repo's config can load custom linter plugins.) - `checklocks` — a violation of an existing `+checklocks`/`+checkatomic` annotation is a data race (G4). "may require checklocks annotation for X" names the lock guarding those fields: verify in-scope code holds it on every access. - `nilaway` — candidate nil dereferences; confirm the nil flow before reporting, else Open check. - `go-fix` — modernizer diff on in-scope lines: G1 (G2 where `use-modern-go` makes it a rule). `gofmt`, `go-mod-tidy` — G2. diff --git a/.agents/skills/ardan-pr-review/scripts/checks.ps1 b/.agents/skills/ardan-pr-review/scripts/checks.ps1 index 15da1e4..7ef791f 100644 --- a/.agents/skills/ardan-pr-review/scripts/checks.ps1 +++ b/.agents/skills/ardan-pr-review/scripts/checks.ps1 @@ -201,13 +201,13 @@ foreach ($m in $modules) { elseif (-not $tests) { Skip "go-test$sfx" '--no-tests' } elseif (-not $testPkgs.Count) { Skip "go-test$sfx" 'all packages excluded' } else { Job "go-test$sfx" $dir 'go' (@('test', '-race', '-count=1', '-timeout', $Timeout) + $testPkgs) } - $golangciCfg = @(Get-ChildItem -LiteralPath $dir -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -cmatch '^\.golangci\.(ya?ml|toml|json)$' }) + $golangciCfg = @(Get-ChildItem -LiteralPath $dir -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -cmatch '^\.golangci\.(ya?ml|toml|json)$' }) if ($golangciCfg.Count) { - # custom linters load plugin code named in the repo's config - $customLinters = [bool](@($golangciCfg | ForEach-Object { [IO.File]::ReadAllLines($_.FullName) }) -cmatch '^\s*custom\s*[:=]') - if (-not $runsCode -and $customLinters) { Skip "golangci-lint$sfx" "$untrusted (custom linter plugins)" } - elseif (Has 'golangci-lint') { Job "golangci-lint$sfx" $dir 'golangci-lint' (@('run') + $pkgs) } - else { Skip "golangci-lint$sfx" 'golangci-lint not installed' } + # The repo's config can load custom linter plugins: read it only for a maintainer PR; + # otherwise --no-config runs the default linters and never parses that file. + if (-not (Has 'golangci-lint')) { Skip "golangci-lint$sfx" 'golangci-lint not installed' } + elseif ($runsCode) { Job "golangci-lint$sfx" $dir 'golangci-lint' (@('run') + $pkgs) } + else { Job "golangci-lint$sfx" $dir 'golangci-lint' (@('run', '--no-config') + $pkgs) } } if (Has 'staticcheck') { Job "staticcheck$sfx" $dir 'staticcheck' $pkgs } else { Skip "staticcheck$sfx" 'staticcheck not installed' } if (Has 'checklocks') { Job "checklocks$sfx" $dir 'go' (@('vet', "-vettool=$($tools['checklocks'])") + $pkgs) } diff --git a/.agents/skills/ardan-pr-review/scripts/checks.sh b/.agents/skills/ardan-pr-review/scripts/checks.sh index 31a5698..69d9620 100644 --- a/.agents/skills/ardan-pr-review/scripts/checks.sh +++ b/.agents/skills/ardan-pr-review/scripts/checks.sh @@ -179,11 +179,12 @@ for m in $modules; do elif [ -z "$testpkgs" ]; then skip "go-test$sfx" "all packages excluded" else job "go-test$sfx" go test -race -count=1 -timeout "$timeout" $testpkgs fi - if ls .golangci.y*ml .golangci.toml .golangci.json >/dev/null 2>&1; then - # custom linters load plugin code named in the repo's config - if ! runs_code && grep -Eqs '^[[:space:]]*custom[[:space:]]*[:=]' .golangci.*; then skip "golangci-lint$sfx" "$UNTRUSTED (custom linter plugins)" - elif has golangci-lint; then job "golangci-lint$sfx" golangci-lint run $pkgs - else skip "golangci-lint$sfx" "golangci-lint not installed" + if [ -e .golangci.yml ] || [ -e .golangci.yaml ] || [ -e .golangci.toml ] || [ -e .golangci.json ]; then + # The repo's config can load custom linter plugins: read it only for a maintainer PR; + # otherwise --no-config runs the default linters and never parses that file. + if ! has golangci-lint; then skip "golangci-lint$sfx" "golangci-lint not installed" + elif runs_code; then job "golangci-lint$sfx" golangci-lint run $pkgs + else job "golangci-lint$sfx" golangci-lint run --no-config $pkgs fi fi if has staticcheck; then job "staticcheck$sfx" staticcheck $pkgs; else skip "staticcheck$sfx" "staticcheck not installed"; fi diff --git a/.agents/skills/ardan-pr-review/scripts/intake.ps1 b/.agents/skills/ardan-pr-review/scripts/intake.ps1 index 7c405e4..8dd6ef7 100644 --- a/.agents/skills/ardan-pr-review/scripts/intake.ps1 +++ b/.agents/skills/ardan-pr-review/scripts/intake.ps1 @@ -753,7 +753,7 @@ try { Need 'gopls' 'symbol lookups by reviewers' 'go install golang.org/x/tools/gopls@latest' -Go Need 'staticcheck' 'staticcheck' 'go install honnef.co/go/tools/cmd/staticcheck@latest' -Go $mods = @($pk | ForEach-Object { ($_ -split "`t")[0] } | Select-Object -Unique) - if ($mods | Where-Object { Get-ChildItem -LiteralPath $_ -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -cmatch '^\.golangci\.(ya?ml|toml|json)$' } }) { + if ($mods | Where-Object { Get-ChildItem -LiteralPath $_ -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -cmatch '^\.golangci\.(ya?ml|toml|json)$' } }) { Need 'golangci-lint' 'repo golangci-lint config' 'go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest' -Go } Need 'gosec' 'Go security scan' 'go install github.com/securego/gosec/v2/cmd/gosec@latest' -Go diff --git a/.agents/skills/ardan-pr-review/scripts/intake.sh b/.agents/skills/ardan-pr-review/scripts/intake.sh index 890a308..fe40b25 100644 --- a/.agents/skills/ardan-pr-review/scripts/intake.sh +++ b/.agents/skills/ardan-pr-review/scripts/intake.sh @@ -730,6 +730,8 @@ eco() { awk -F '\t' -v e="$1" -v m="${2-}" '$1 == e && (m == "" || $4 == m) { f repo_has() { grep -Eq "$1" "$tmp/repo.files"; } # --- missing.tsv: tool needed_for fix --------------------------------------------- +# any_file PATH...: true when at least one PATH exists (`ls a b` fails if any one is missing) +any_file() { for f in "$@"; do [ -e "$f" ] && return 0; done; return 1; } # Tools this change needs but that are absent, or Go tools built with an older Go # release than the active toolchain (analyzers built that way fail on the newer # standard library). Checks skip what is missing; the user is told how to install it. @@ -757,7 +759,7 @@ need() { # need TOOL NEEDED_FOR INSTALL [go]: record TOOL if missing (or stale, need gopls "symbol lookups by reviewers" "go install golang.org/x/tools/gopls@latest" go need staticcheck "staticcheck" "go install honnef.co/go/tools/cmd/staticcheck@latest" go cut -f1 "$ctx/packages.tsv" | sort -u | while IFS= read -r m; do - if ls "$m"/.golangci.y*ml "$m"/.golangci.toml "$m"/.golangci.json >/dev/null 2>&1; then + if any_file "$m"/.golangci.yml "$m"/.golangci.yaml "$m"/.golangci.toml "$m"/.golangci.json; then need golangci-lint "repo golangci-lint config" \ "go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest" go break @@ -826,7 +828,7 @@ need() { # need TOOL NEEDED_FOR INSTALL [go]: record TOOL if missing (or stale, need zizmor "GitHub Actions security" "python3 -m pip install zizmor" fi has_tool trufflehog || need gitleaks "secret scan" "go install github.com/zricethezav/gitleaks/v8@latest" - if ls .semgrep.yml .semgrep.yaml .semgrep >/dev/null 2>&1; then + if any_file .semgrep.yml .semgrep.yaml .semgrep; then need semgrep "repo semgrep rules" "python3 -m pip install semgrep" fi } >"$ctx/missing.tsv" diff --git a/README.md b/README.md index 38cb00a..ee02fff 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Plugin skills are namespaced, so `ardan-pr-review` is invoked as `/ardan-pr-revi `ardan-pr-review` runs the tools below when the change needs them, per ecosystem (Go module, crate, Terraform module, chart, …). A missing tool is listed with its install command at the start of a review, and its check is skipped. Go tools built with an older Go than the active toolchain are flagged for a rebuild: analyzers fail on a newer standard library. Ecosystems not listed here are found by a discovery agent, which also lists the repo's own quality gates (Makefile, CI, pre-commit). -**The reviewed code never runs unless it's a maintainer's PR.** Tests, `cargo clippy`/`check`/`test`, Terraform `init`/`validate`, linter plugins (tflint, mypy, ansible-lint, golangci-lint custom linters), the repo's own gates, and security PoCs run only for `--pr` when GitHub reports that the PR author has write access and the head branch is in the base repo. Any other change gets the read-only checks, with neutral configs where a repo config could load code. +**The reviewed code never runs unless it's a maintainer's PR.** Tests, `cargo clippy`/`check`/`test`, Terraform `init`/`validate`, linter plugins (tflint, mypy, ansible-lint, golangci-lint), the repo's own gates, and security PoCs run only for `--pr` when GitHub reports that the PR author has write access and the head branch is in the base repo. Any other change gets the read-only checks, with neutral configs where a repo config could load code. | Tool | Checks | Install | |--------------------------------------|-------------------------------------------------|------------------------------------------------------------------------------| diff --git a/zarf/tests/ardan-pr-review/Dockerfile b/zarf/tests/ardan-pr-review/Dockerfile index 3acf7fa..cdbf75b 100644 --- a/zarf/tests/ardan-pr-review/Dockerfile +++ b/zarf/tests/ardan-pr-review/Dockerfile @@ -17,6 +17,7 @@ ARG KUBE_LINTER_VERSION=0.8.3 ARG ACTIONLINT_VERSION=1.7.12 ARG KUSTOMIZE_VERSION=5.8.3 ARG GITLEAKS_VERSION=8.30.1 +ARG GOLANGCI_LINT_VERSION=2.15.0 ARG STATICCHECK_VERSION=0.8.2 ARG GOVULNCHECK_VERSION=1.9.0 ARG GOSEC_VERSION=2.30.0 @@ -74,6 +75,7 @@ RUN go install golang.stackrox.io/kube-linter/cmd/kube-linter@v${KUBE_LINTER_VER RUN go install github.com/rhysd/actionlint/cmd/actionlint@v${ACTIONLINT_VERSION} && go clean -cache RUN go install sigs.k8s.io/kustomize/kustomize/v5@v${KUSTOMIZE_VERSION} && go clean -cache RUN go install github.com/zricethezav/gitleaks/v8@v${GITLEAKS_VERSION} && go clean -cache +RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v${GOLANGCI_LINT_VERSION} && go clean -cache RUN go install honnef.co/go/tools/cmd/staticcheck@v${STATICCHECK_VERSION} \ && go install golang.org/x/vuln/cmd/govulncheck@v${GOVULNCHECK_VERSION} \ && go install github.com/securego/gosec/v2/cmd/gosec@v${GOSEC_VERSION} \ diff --git a/zarf/tests/ardan-pr-review/expected/container.checks.tsv b/zarf/tests/ardan-pr-review/expected/container.checks.tsv index ce4162d..8b5875c 100644 --- a/zarf/tests/ardan-pr-review/expected/container.checks.tsv +++ b/zarf/tests/ardan-pr-review/expected/container.checks.tsv @@ -20,6 +20,7 @@ go-mod-tidy pass service-steward go-test skipped harness-map go-vet fail service-steward gofmt findings service-steward +golangci-lint fail service-steward gosec pass security-sentinel govulncheck pass security-sentinel hadolint fail service-steward diff --git a/zarf/tests/ardan-pr-review/expected/container.trusted.checks.tsv b/zarf/tests/ardan-pr-review/expected/container.trusted.checks.tsv index 8a228c2..c22643e 100644 --- a/zarf/tests/ardan-pr-review/expected/container.trusted.checks.tsv +++ b/zarf/tests/ardan-pr-review/expected/container.trusted.checks.tsv @@ -19,6 +19,7 @@ go-mod-tidy pass service-steward go-test fail harness-map go-vet fail service-steward gofmt findings service-steward +golangci-lint fail service-steward gosec pass security-sentinel govulncheck pass security-sentinel hadolint fail service-steward diff --git a/zarf/tests/ardan-pr-review/expected/gen.review-set.tsv b/zarf/tests/ardan-pr-review/expected/gen.review-set.tsv index 3250294..bd3892f 100644 --- a/zarf/tests/ardan-pr-review/expected/gen.review-set.tsv +++ b/zarf/tests/ardan-pr-review/expected/gen.review-set.tsv @@ -1,4 +1,5 @@ M 2 0 ci prod - .github/workflows/ci.yml 9 +- 0 0 yaml config - .golangci.yml 7 - 0 0 other prod - .yamllint 3 M 2 2 docker prod - Dockerfile 2 M 10 0 go prod app app/fx/main.go 21 diff --git a/zarf/tests/ardan-pr-review/expected/host.checks.tsv b/zarf/tests/ardan-pr-review/expected/host.checks.tsv index b1084b3..eb3c288 100644 --- a/zarf/tests/ardan-pr-review/expected/host.checks.tsv +++ b/zarf/tests/ardan-pr-review/expected/host.checks.tsv @@ -20,6 +20,7 @@ go-mod-tidy pass service-steward go-test skipped harness-map go-vet pass service-steward gofmt findings service-steward +golangci-lint pass service-steward gosec pass security-sentinel govulncheck pass security-sentinel hadolint pass service-steward diff --git a/zarf/tests/ardan-pr-review/expected/host.trusted.checks.tsv b/zarf/tests/ardan-pr-review/expected/host.trusted.checks.tsv index ad4fc5b..9104f9e 100644 --- a/zarf/tests/ardan-pr-review/expected/host.trusted.checks.tsv +++ b/zarf/tests/ardan-pr-review/expected/host.trusted.checks.tsv @@ -19,6 +19,7 @@ go-mod-tidy pass service-steward go-test pass harness-map go-vet pass service-steward gofmt findings service-steward +golangci-lint pass service-steward gosec pass security-sentinel govulncheck pass security-sentinel hadolint pass service-steward diff --git a/zarf/tests/ardan-pr-review/expected/project.review-set.tsv b/zarf/tests/ardan-pr-review/expected/project.review-set.tsv index 720e253..d33aa55 100644 --- a/zarf/tests/ardan-pr-review/expected/project.review-set.tsv +++ b/zarf/tests/ardan-pr-review/expected/project.review-set.tsv @@ -1,4 +1,5 @@ M 2 0 ci prod - .github/workflows/ci.yml 9 +- 0 0 yaml config - .golangci.yml 7 - 0 0 other prod - .yamllint 3 M 2 2 docker prod - Dockerfile 2 M 10 0 go prod app app/fx/main.go 21 diff --git a/zarf/tests/ardan-pr-review/fixture.sh b/zarf/tests/ardan-pr-review/fixture.sh index af5bec8..f83e200 100644 --- a/zarf/tests/ardan-pr-review/fixture.sh +++ b/zarf/tests/ardan-pr-review/fixture.sh @@ -20,6 +20,8 @@ mkdir -p app/fx crates/core/src infra/nolock infra/locked charts/api/templates d # --- base ------------------------------------------------------------------------------ printf 'module example.com/fx\n\ngo 1.24\n' >go.mod +# A custom linter plugin: golangci-lint may read this config only for a maintainer PR. +printf 'version: "2"\nlinters:\n enable: [evil]\n settings:\n custom:\n evil:\n path: ./evil.so\n' >.golangci.yml cat >app/fx/main.go <<'EOF' package main diff --git a/zarf/tests/ardan-pr-review/suite.sh b/zarf/tests/ardan-pr-review/suite.sh index 4ecebb9..9b31fb5 100644 --- a/zarf/tests/ardan-pr-review/suite.sh +++ b/zarf/tests/ardan-pr-review/suite.sh @@ -116,6 +116,11 @@ cp "$work/extra.tsv" .reviews/sh/diff/001/context/extra-checks.tsv cp "$work/extra.tsv" .reviews/ps/diff/001/context/extra-checks.tsv checks_pair diff "--extra echo-x" "-Extra echo-x" checks_view .reviews/sh/diff/001 >"$work/checks.view"; golden "$mode.checks.tsv" "$work/checks.view" +# untrusted: golangci-lint never reads the repo's config (it names a custom plugin) +for p in sh ps; do + if grep -q -- '--no-config' ".reviews/$p/diff/001/context/checks/golangci-lint.txt"; then pass "golangci-lint --no-config when untrusted ($p)" + else fail "golangci-lint read the repo config when untrusted ($p)"; fi +done after=$(tree_state) if [ "$before" = "$after" ]; then pass "working tree unchanged by checks" else fail "working tree changed by checks"; printf '%s\n' "$after" | sed 's/^/ /' @@ -141,6 +146,10 @@ cp "$work/extra.tsv" .reviews/sh/pr-write-false/001/context/extra-checks.tsv cp "$work/extra.tsv" .reviews/ps/pr-write-false/001/context/extra-checks.tsv checks_pair pr-write-false "--extra echo-x" "-Extra echo-x" checks_view .reviews/sh/pr-write-false/001 >"$work/trusted.view"; golden "$mode.trusted.checks.tsv" "$work/trusted.view" +for p in sh ps; do + if head -n 1 ".reviews/$p/pr-write-false/001/context/checks/golangci-lint.txt" | grep -q -- '--no-config'; then fail "golangci-lint ignored the repo config for a maintainer PR ($p)" + else pass "golangci-lint uses the repo config for a maintainer PR ($p)"; fi +done PATH=$path0 after=$(tree_state) if [ "$before" = "$after" ]; then pass "working tree unchanged by maintainer-PR checks" From 8c4b84bce67b5b914cc9f226778d7a8056518165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florin=20P=C4=83=C8=9Ban?= Date: Tue, 3 Nov 2026 13:45:06 +0100 Subject: [PATCH 4/5] Replace A && B || C with explicit ifs in intake.sh CI's shellcheck flags SC2015 on these (the local 0.11.0 doesn't): an &&/|| chain reads like if/else but runs the || branch when B fails too. --- .agents/skills/ardan-pr-review/scripts/intake.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/skills/ardan-pr-review/scripts/intake.sh b/.agents/skills/ardan-pr-review/scripts/intake.sh index fe40b25..37e1672 100644 --- a/.agents/skills/ardan-pr-review/scripts/intake.sh +++ b/.agents/skills/ardan-pr-review/scripts/intake.sh @@ -110,7 +110,7 @@ case $scope in ;; range) a=${range%%..*} b=${range##*..} - [ -n "$a" ] && [ -n "$b" ] && [ "$a" != "$range" ] || die 2 "--range must be A..B" + if [ -z "$a" ] || [ -z "$b" ] || [ "$a" = "$range" ]; then die 2 "--range must be A..B"; fi bsha=$(git rev-parse --verify -q "$b^{commit}") || die 2 "unknown ref: $b" [ "$bsha" = "$head" ] || die 3 "range end $b is not HEAD; check out $b so checks run on the reviewed code" basesha=$(git rev-parse --verify -q "$a^{commit}") || die 2 "unknown ref: $a" @@ -154,7 +154,7 @@ esac # --- issue id --------------------------------------------------------------------- [ -n "$id" ] || id=$(git symbolic-ref --short -q HEAD) || die 2 "detached HEAD: pass --id" slug=$(printf '%s' "$id" | tr '[:upper:]' '[:lower:]' | sed -e 's/[^a-z0-9._-]/-/g' -e 's/--*/-/g' -e 's/^[-.]*//' -e 's/-*$//') -[ -n "$slug" ] && [ "$slug" != . ] && [ "$slug" != .. ] || die 2 "issue id '$id' slugifies to nothing" +if [ -z "$slug" ] || [ "$slug" = . ] || [ "$slug" = .. ]; then die 2 "issue id '$id' slugifies to nothing"; fi last=0 for d in "$reviews/$slug"/*/; do @@ -893,7 +893,7 @@ awk -F '\t' '$1 != "D" && $4 !~ /^(go|rust|vue|ts|js|css|py|tf|helm|k8s|ansible| echo "languages=${langs% }" echo "ecosystems=${ecos% }" echo "idioms=$idioms" - echo "generated=$([ "$incgen" = 1 ] && echo included || echo skipped)" + if [ "$incgen" = 1 ]; then echo generated=included; else echo generated=skipped; fi echo "trust=$trust" echo "trust_reason=$trust_reason" echo "go_packages=$(wc -l <"$ctx/packages.tsv" | tr -d ' ')" From 1cadc01e9f82941aa9ef342a196cca1085f00ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florin=20P=C4=83=C8=9Ban?= Date: Tue, 3 Nov 2026 13:55:36 +0100 Subject: [PATCH 5/5] Restore the Terraform lock file after init instead of a read-only init CI (amd64) failed tf-validate on a module whose lock file was made on arm64: lock files record h1 hashes only for the platforms someone ran init on, a read-only init can't add the current one, and validate then rejects the cached provider. Reviews from another OS or CPU than the author's would always hit this. init now runs normally (locked versions still apply; nothing upgrades without -upgrade), and the original .terraform.lock.hcl is restored byte for byte after validate (sh and PowerShell); a lock file init created is removed as before. Tests: the OpenTofu pass runs as a maintainer PR, so init/validate actually run and the suite checks that tofu's lock rewrite is undone. --- .../skills/ardan-pr-review/scripts/checks.ps1 | 16 +++++++------ .../skills/ardan-pr-review/scripts/checks.sh | 24 ++++++++++--------- README.md | 2 +- .../ardan-pr-review/expected/tofu.checks.tsv | 4 ++-- zarf/tests/ardan-pr-review/suite.sh | 6 +++-- 5 files changed, 29 insertions(+), 23 deletions(-) diff --git a/.agents/skills/ardan-pr-review/scripts/checks.ps1 b/.agents/skills/ardan-pr-review/scripts/checks.ps1 index 7ef791f..1234452 100644 --- a/.agents/skills/ardan-pr-review/scripts/checks.ps1 +++ b/.agents/skills/ardan-pr-review/scripts/checks.ps1 @@ -290,8 +290,9 @@ if ($rustUnits.Count) { # --- Terraform / OpenTofu, per module -------------------------------------------------- # The stack marker names the binary (terraform or tofu). init downloads providers and modules -# into a temp TF_DATA_DIR; an existing .terraform.lock.hcl is never rewritten -# (-lockfile=readonly), and one that init creates is removed again. +# into a temp TF_DATA_DIR. init may add this platform's hashes to .terraform.lock.hcl (validate +# needs them; a read-only lock fails wherever the lock lacks them), so the file is restored +# byte for byte afterwards, and one that init creates is removed. Locked versions still apply. $tfRow = $stack | Where-Object { $_[0] -ceq 'terraform' } | Select-Object -First 1 $tfBin = if ($tfRow) { $tfRow[3] } else { '' } $tfFmtFn = { @@ -303,14 +304,15 @@ $tfValidateFn = { $d = Join-Path ([IO.Path]::GetTempPath()) ('tf-data-' + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $d | Out-Null $env:TF_DATA_DIR = $d - $hadLock = Test-Path -LiteralPath '.terraform.lock.hcl' - $lock = @(if ($hadLock) { '-lockfile=readonly' }) - $init = @(& $bin init -backend=false -input=false @lock -no-color 2>&1 | ForEach-Object { "$_" }) + $lockPath = Join-Path (Get-Location).Path '.terraform.lock.hcl' + $orig = if (Test-Path -LiteralPath $lockPath) { [IO.File]::ReadAllBytes($lockPath) } else { $null } + $init = @(& $bin init -backend=false -input=false -no-color 2>&1 | ForEach-Object { "$_" }) $initRc = $LASTEXITCODE if ($initRc -ne 0) { $st.rc = $initRc; '# init failed (providers, modules, or credentials):'; $init } else { & $bin validate -no-color 2>&1 | ForEach-Object { "$_" }; $st.rc = $LASTEXITCODE } - # validate needs the lock file init wrote; remove it only afterwards - if (-not $hadLock) { Remove-Item -LiteralPath '.terraform.lock.hcl' -Force -ErrorAction SilentlyContinue } + # validate needs the lock file init wrote: put the original back only afterwards + if ($null -eq $orig) { Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue } + elseif (-not (Test-Path -LiteralPath $lockPath) -or [Convert]::ToBase64String([IO.File]::ReadAllBytes($lockPath)) -cne [Convert]::ToBase64String($orig)) { [IO.File]::WriteAllBytes($lockPath, $orig) } Remove-Item -LiteralPath $d -Recurse -Force -ErrorAction SilentlyContinue } # The repo's .tflint.hcl (and its plugins) only for a maintainer PR; otherwise a neutral config diff --git a/.agents/skills/ardan-pr-review/scripts/checks.sh b/.agents/skills/ardan-pr-review/scripts/checks.sh index 69d9620..bc434e3 100644 --- a/.agents/skills/ardan-pr-review/scripts/checks.sh +++ b/.agents/skills/ardan-pr-review/scripts/checks.sh @@ -9,8 +9,8 @@ # Rust builds, Terraform init/validate, tflint plugins, mypy and Ansible plugins, repo gates) run # only when scope.txt says trust=maintainer (a maintainer PR, see intake.sh); otherwise they are # skipped and the rest run against neutral configs. Nothing writes to the working tree beyond -# build caches (Go cache, cargo target/): Terraform init uses a temp TF_DATA_DIR and a read-only -# lock file; Python checks run without caches or bytecode. +# build caches (Go cache, cargo target/): Terraform init uses a temp TF_DATA_DIR and leaves the +# lock file as it was; Python checks run without caches or bytecode. # Output: RUN_DIR/context/checks/.txt (full log) and RUN_DIR/context/checks/summary.tsv # (check status exit lines file lens). Prints the summary. # --env K=V exported for every check (e.g. GITHUB_WORKSPACE=/repo); repeatable @@ -309,23 +309,25 @@ fi # --- Terraform / OpenTofu, per module -------------------------------------------------- # The stack marker names the binary (terraform or tofu). init downloads providers and modules -# into a temp TF_DATA_DIR; an existing .terraform.lock.hcl is never rewritten -# (-lockfile=readonly), and one that init creates is removed again. +# into a temp TF_DATA_DIR. init may add this platform's hashes to .terraform.lock.hcl (validate +# needs them; a read-only lock fails wherever the lock lacks them), so the file is restored +# byte for byte afterwards, and one that init creates is removed. Locked versions still apply. tfbin=$(awk -F '\t' '$1 == "terraform" { print $4; exit }' "$ctx/stack.tsv" 2>/dev/null) tf_fmt() { rc=0; for f in "$@"; do "$tfbin" fmt -check -diff -no-color "$f" || rc=$?; done; return $rc; } tf_validate() { d=$(mktemp -d) || return 1 - lock=-lockfile=readonly - [ -f .terraform.lock.hcl ] || lock='' - # shellcheck disable=SC2086 # $lock is one flag or nothing - TF_DATA_DIR=$d "$tfbin" init -backend=false -input=false $lock -no-color >"$d.init" 2>&1 + bak='' + if [ -f .terraform.lock.hcl ]; then bak=$d.lock; cp .terraform.lock.hcl "$bak" || return 1; fi + TF_DATA_DIR=$d "$tfbin" init -backend=false -input=false -no-color >"$d.init" 2>&1 rc=$? if [ $rc = 0 ]; then TF_DATA_DIR=$d "$tfbin" validate -no-color; rc=$? else echo "# init failed (providers, modules, or credentials):"; cat "$d.init" fi - # validate needs the lock file init wrote; remove it only afterwards - [ -n "$lock" ] || rm -f .terraform.lock.hcl - rm -rf "$d" "$d.init"; return $rc + # validate needs the lock file init wrote: put the original back only afterwards + if [ -z "$bak" ]; then rm -f .terraform.lock.hcl + elif ! cmp -s "$bak" .terraform.lock.hcl; then cat "$bak" >.terraform.lock.hcl + fi + rm -rf "$d" "$d.init" "$bak"; return $rc } # tf_lint: the repo's .tflint.hcl (and its plugins) only for a maintainer PR; otherwise a # neutral config with the bundled rules and no plugin installation. diff --git a/README.md b/README.md index ee02fff..854366f 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Plugin skills are namespaced, so `ardan-pr-review` is invoked as `/ardan-pr-revi | `semgrep` | the repo's own semgrep rules, when it has them | `python3 -m pip install semgrep` | | `cargo` (`fmt --check`, `clippy`, `test`) | Rust format, lints, tests (`--locked`) | [rustup.rs](https://rustup.rs); `rustup component add clippy rustfmt` | | `cargo-audit`, `cargo-deny` | Rust advisories; licenses and sources (`deny.toml`) | `cargo install cargo-audit cargo-deny --locked` | -| `terraform` or `tofu` (`fmt`, `init -backend=false`, `validate`) | Terraform/OpenTofu format and validation; init uses a temp data dir and never rewrites the lock file | [terraform](https://developer.hashicorp.com/terraform/install), [opentofu](https://opentofu.org/docs/intro/install/) | +| `terraform` or `tofu` (`fmt`, `init -backend=false`, `validate`) | Terraform/OpenTofu format and validation; init uses a temp data dir and leaves the lock file as it was | [terraform](https://developer.hashicorp.com/terraform/install), [opentofu](https://opentofu.org/docs/intro/install/) | | `tflint` | Terraform lint (repo `.tflint.hcl`) | [tflint#installation](https://github.com/terraform-linters/tflint#installation) | | `helm` (`lint`, `template`) | chart lint and render | [helm.sh](https://helm.sh/docs/intro/install/) | | `kustomize` or `kubectl kustomize` | render Kustomize roots | `go install sigs.k8s.io/kustomize/kustomize/v5@latest` | diff --git a/zarf/tests/ardan-pr-review/expected/tofu.checks.tsv b/zarf/tests/ardan-pr-review/expected/tofu.checks.tsv index 9df05fb..170a079 100644 --- a/zarf/tests/ardan-pr-review/expected/tofu.checks.tsv +++ b/zarf/tests/ardan-pr-review/expected/tofu.checks.tsv @@ -1,5 +1,5 @@ tf-fmt fail service-steward -tf-validate@infra_locked skipped service-steward -tf-validate@infra_nolock skipped service-steward +tf-validate@infra_locked pass service-steward +tf-validate@infra_nolock fail service-steward tflint@infra_locked fail service-steward tflint@infra_nolock fail service-steward diff --git a/zarf/tests/ardan-pr-review/suite.sh b/zarf/tests/ardan-pr-review/suite.sh index 9b31fb5..71ea734 100644 --- a/zarf/tests/ardan-pr-review/suite.sh +++ b/zarf/tests/ardan-pr-review/suite.sh @@ -156,10 +156,12 @@ if [ "$before" = "$after" ]; then pass "working tree unchanged by maintainer-PR else fail "working tree changed by maintainer-PR checks"; printf '%s\n' "$after" | sed 's/^/ /' fi -# --- container: OpenTofu selection and its read-only lock ------------------------------------- +# --- container: OpenTofu, as a maintainer PR so init/validate run ------------------------------ +# tofu init rewrites the Terraform-made lock file (registry addresses, hashes): the checks must +# put it back byte for byte. if [ "$mode" = container ]; then echo 1.13.0 >.opentofu-version - intake_pair tofu "--base main" "-Base main" + PATH=$work/ghbin:$PATH FX_GH_PERM=write FX_GH_CROSS=false intake_pair tofu "--pr 1" "-Pr 1" grep '^terraform' .reviews/sh/tofu/001/context/stack.tsv >"$work/tofu.stack"; golden tofu.stack.tsv "$work/tofu.stack" checks_pair tofu grep -E '^(tf|tflint)' "$work/c1" >"$work/tofu.view"; golden tofu.checks.tsv "$work/tofu.view"