Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,17 @@ jobs:

- name: Build package
run: uv build

setup:
name: Setup and real analyzers
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Use shared setup action
uses: ./
- name: Verify installed CLI and real analyzers
run: bash tests/integration/smoke_setup.sh
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ Configure only the commands your project uses. `doctor` checks readiness without
| --- | --- |
| `gauntlet` or `gauntlet check` | Check staged, unstaged, and untracked source changes |
| `gauntlet check --all` | Check the entire repository |
| `gauntlet check --base main` | Check committed changes from the merge base with `main` to the checked-out `HEAD` |
| `gauntlet check src/domain` | Check a specific file or directory |
| `gauntlet scan` | Inspect hotspots without tests, coverage, or mutation execution |
| `gauntlet check --json` | Get JSON-only output for an agent or CI |
Expand Down Expand Up @@ -124,6 +125,33 @@ Do not bypass or weaken the checks.

For machine-readable results, run `gauntlet check --json`. It prints only JSON to stdout and also saves `.gauntlet/results.json`. The same command works in CI, where a non-zero exit code fails the build on blocking findings.

On a clean CI checkout, use `gauntlet check --base BASE_SHA --json`; working-tree
`--changed` does not select committed PR changes. Fetch base/head history first.
`--base` requires a clean tracked working tree, excludes untracked/deleted files,
and preserves committed line ranges for CRAP policy. JSON records the resolved
base, head, and merge-base SHAs. It can also be used with `scan` and explicit paths.

For explicit installation of Gauntlet and compatible pinned analyzers, run
`bash scripts/setup-tools.sh /path/to/tool-environment` from this checkout,
then activate that environment. Setup uses the network; analysis does not invoke
setup. The [setup action](action.yml) runs the same installer in GitHub Actions:

```yaml
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: matheuseabra/gauntlet-cli@<reviewed-commit-sha>
- run: gauntlet check --base "$BASE_SHA" --json
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
```

Run this example in a secretless `pull_request` job with read-only permissions.
Install project runtimes and locked dependencies separately. Local/cloud agents
must run setup inside their own environment; GitHub runner installation does
not provision a remote agent.

## More details

- [Tutorial](https://github.com/matheuseabra/gauntlet-cli/blob/main/docs/tutorial.md): build a sample project and repair a test gap.
Expand Down
31 changes: 31 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Set up Gauntlet
description: Install this Gauntlet revision and its pinned external analyzers in one isolated environment.
inputs:
python-version:
description: Compatible Python version for the quality tools.
default: '3.12'
environment-path:
description: Optional virtual environment location (defaults to runner temporary storage).
default: ''
outputs:
bin-path:
description: Directory containing Gauntlet and analyzer executables.
value: ${{ steps.setup.outputs.bin_path }}
runs:
using: composite
steps:
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Install quality tools
id: setup
shell: bash
env:
QUALITY_ENVIRONMENT: ${{ inputs.environment-path || format('{0}/gauntlet-tools', runner.temp) }}
GAUNTLET_PYTHON: python
run: |
set -euo pipefail
bash "$GITHUB_ACTION_PATH/scripts/setup-tools.sh" "$QUALITY_ENVIRONMENT"
quality_bin="$(cd "$QUALITY_ENVIRONMENT/bin" && pwd)"
echo "$quality_bin" >> "$GITHUB_PATH"
echo "bin_path=$quality_bin" >> "$GITHUB_OUTPUT"
13 changes: 12 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Gauntlet Architecture

Gauntlet coordinates local repository checks and normalizes their results. It owns configuration, source selection, command execution, policy, and reporting. Crapper, Mutator, and Dryer own complexity, mutation, and similarity analysis. Gauntlet does not implement those analyzers, install them, modify project source, or upload reports.
Gauntlet coordinates local repository checks and normalizes their results. It owns configuration, source selection, command execution, policy, and reporting. Crapper, Mutator, and Dryer own complexity, mutation, and similarity analysis. Checks do not install analyzers, modify project source, or upload reports. A separate explicit setup script/action installs compatible tools before checks.

## Components and boundaries

Expand Down Expand Up @@ -33,6 +33,17 @@ flowchart TB

Project detection can supply test and coverage commands. Explicit commands from `gauntlet.toml` override detected commands. Enabled analyzers are resolved before project commands run. A missing executable therefore fails preflight instead of starting a partial check.

`--base REF` selects committed changes from the unique merge base with checked-out
HEAD. Git validates a clean tracked working tree and resolves immutable base/head
SHAs. The context carries the merge base to the policy runner so function spans
are compared with committed changed lines, not an empty working-tree diff. The
report records all three SHAs. This mode uses explicit analyzer paths rather than
their native working-tree `--changed` flag.

`scripts/setup-tools.sh` and `action.yml` are explicit network-enabled setup
surfaces. They share `requirements-tools.txt`; the CLI never invokes them. Cloud
agents run setup in their own runtime, independently of their dispatcher.

```mermaid
sequenceDiagram
actor User
Expand Down
33 changes: 31 additions & 2 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,15 +125,44 @@ missing domain abstraction.
Re-run Gauntlet after making repairs.
```

CI can run the same local command without a Gauntlet server or provider API:
CI can run committed PR comparisons without a Gauntlet server or provider API:

```yaml
- name: Gauntlet
run: gauntlet check --changed --json
run: gauntlet check --base "$BASE_SHA" --json
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
```

Archive `.gauntlet/results.json` as a CI artifact if useful. Gauntlet does not upload source or findings.

`--base REF` is mutually exclusive with `--changed` and `--all`. It resolves REF
and checked-out HEAD, requires a clean tracked working tree, and selects existing
files changed since their unique merge base. Untracked files and deletions are
excluded. Paths can further limit this selection. Missing/shallow history,
invalid refs, or ambiguous merge bases fail with exit 4; no fallback full scan is
performed. An empty selection passes without analysis. Changed line ranges come
from the committed comparison, so high CRAP in an unchanged function remains
nonblocking when function spans are available. This is not historical CRAP score
comparison; Mutator still examines selected files rather than changed lines only.

Results add `repository.base_sha`, `head_sha`, and `merge_base_sha` for these runs.
The report schema stays at version 1 with additive repository metadata.

## Explicit tool setup

`bash scripts/setup-tools.sh ENVIRONMENT_PATH` installs this checkout's Gauntlet
and external analyzers from `requirements-tools.txt` in one isolated environment.
Python 3.12+ is required; use `GAUNTLET_PYTHON` to select an interpreter. It also
prefetches supported parser grammars. Setup is network-enabled and separate from
the CLI: checks never invoke it, clone dependencies, or silently install tools.
Use the root composite action at a reviewed immutable commit for the same setup
in Actions; it adds executables to PATH and exposes a `bin-path` output.

Project dependencies and commands remain the consumer's responsibility. Bootstrap
each cloud agent inside its own runtime, or use a provisioned environment; setup
on the GitHub runner is insufficient for remote execution.

## Exit codes

| Code | Meaning |
Expand Down
7 changes: 7 additions & 0 deletions requirements-tools.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Explicit setup dependencies. Checks never install tools or access the network.
crapper @ git+https://github.com/unclebob/crapper.git@9f1bead298b5a9d576bdd6319289fcf426e5b18a
mutator @ git+https://github.com/unclebob/mutator.git@c57f03879a08d2afe8c7e044e86c80bb164afd30
dryer @ git+https://github.com/unclebob/dryer.git@66ff6d21a42c04afcad89c78a80066176d1294b0
tree-sitter-language-pack==1.20.0
tree-sitter==0.26.0
coverage==7.16.2
30 changes: 30 additions & 0 deletions scripts/setup-tools.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Explicit, network-enabled setup for local agents and CI. Never called by check.
set -euo pipefail

if [ "$#" -ne 1 ]; then
printf 'Usage: bash scripts/setup-tools.sh ENVIRONMENT_PATH\n' >&2
exit 4
fi
setup_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [ -n "${GAUNTLET_PYTHON:-}" ]; then
setup_python="$GAUNTLET_PYTHON"
elif command -v python3.12 >/dev/null 2>&1; then
setup_python=python3.12
else
setup_python=python3
fi
"$setup_python" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 12) else "Gauntlet setup requires Python 3.12+; set GAUNTLET_PYTHON to a compatible interpreter")'
"$setup_python" -m venv "$1"
environment_path="$(cd "$1" && pwd)"
"$environment_path/bin/python" -m pip install \
-r "$setup_root/requirements-tools.txt" "$setup_root"
"$environment_path/bin/python" -c \
'from tree_sitter_language_pack import prefetch; prefetch(["python", "typescript", "tsx", "javascript", "go", "rust", "java", "clojure"])'
for tool in gauntlet crapper mutator dryer; do
if [ ! -x "$environment_path/bin/$tool" ]; then
printf 'Setup did not provide %s in %s/bin\n' "$tool" "$environment_path" >&2
exit 5
fi
done
printf 'Quality tools ready. Activate %s/bin/activate in your shell.\n' "$environment_path"
23 changes: 20 additions & 3 deletions src/gauntlet/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ def parser() -> argparse.ArgumentParser:
mode = sub.add_mutually_exclusive_group()
mode.add_argument("--changed", dest="mode", action="store_const", const="changed")
mode.add_argument("--all", dest="mode", action="store_const", const="all")
mode.add_argument(
"--base", metavar="REF", help="Check committed changes from REF's merge base to HEAD"
)
sub.add_argument("--json", action="store_true", help="Emit only JSON to stdout")
sub.add_argument("--output", help="JSON report path, relative to repository root")
sub.add_argument("--quiet", action="store_true", help="Suppress terminal output")
Expand All @@ -59,15 +62,22 @@ def _check(args: argparse.Namespace) -> Results:
root = git.root(Path.cwd())
config = load(root)
project = detect(root)
changed = git.changed_files(root)
mode = args.mode or ("all" if args.paths else config.mode)
comparison = git.compare(root, args.base) if args.base is not None else None
changed = comparison.files if comparison else git.changed_files(root)
mode = "changed" if comparison else args.mode or ("all" if args.paths else config.mode)
candidates = changed if mode == "changed" else source_files(root)
selected = select(root, candidates, config, args.paths)
tools = dict(config.tools)
for name, disabled in zip(TOOLS, (args.no_crap, args.no_mutate, args.no_dry), strict=True):
tools[name] = replace(tools[name], enabled=False) if disabled else tools[name]
config = replace(config, tools=tools, commands=project.commands | config.commands)
native = mode == "changed" and not args.paths and not config.include and not config.exclude
native = (
comparison is None
and mode == "changed"
and not args.paths
and not config.include
and not config.exclude
)
context = RunContext(
root,
mode,
Expand All @@ -78,8 +88,15 @@ def _check(args: argparse.Namespace) -> Results:
args.command == "scan",
args.quiet,
args.verbose,
comparison_base=comparison.merge_base_sha if comparison else None,
)
results = run(context)
if comparison:
results.repository.update(
base_sha=comparison.base_sha,
head_sha=comparison.head_sha,
merge_base_sha=comparison.merge_base_sha,
)
results.repository["project"] = {
"languages": project.languages,
"package_manager": project.package_manager,
Expand Down
76 changes: 74 additions & 2 deletions src/gauntlet/git.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,69 @@
import re
from dataclasses import dataclass
from pathlib import Path

from gauntlet.errors import GauntletError
from gauntlet.process import run


@dataclass(frozen=True)
class Comparison:
base_sha: str
head_sha: str
merge_base_sha: str
files: list[str]


def resolve_commit(directory: Path, ref: str) -> str:
result = run(
["git", "rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}"], directory
)
if result.exit_code or result.timed_out:
raise GauntletError(f"Cannot resolve Git commit {ref!r}; fetch its history first", 4)
return result.stdout.strip()


def compare(directory: Path, base: str) -> Comparison:
"""Select committed changes from the merge base to the checked-out HEAD."""
status = run(["git", "status", "--porcelain=v1", "--untracked-files=no"], directory)
if status.exit_code or status.timed_out:
raise GauntletError("Git could not inspect the working tree", 1)
if status.stdout:
raise GauntletError(
"--base requires a clean tracked working tree; commit or stash changes", 4
)
base_sha, head_sha = resolve_commit(directory, base), resolve_commit(directory, "HEAD")
ancestor = run(["git", "merge-base", "--all", base_sha, head_sha], directory)
ancestors = ancestor.stdout.splitlines()
if ancestor.exit_code or ancestor.timed_out or len(ancestors) != 1:
raise GauntletError(
"Git comparison needs one merge base; fetch complete base/head history", 4
)
merge_base = ancestors[0]
result = run(
[
"git",
"diff",
"--no-ext-diff",
"--no-textconv",
"--no-renames",
"--name-only",
"--diff-filter=ACMRT",
"-z",
merge_base,
head_sha,
"--",
],
directory,
)
if result.exit_code or result.timed_out:
raise GauntletError("Git could not enumerate committed changes", 1)
files = sorted(
{name for name in result.stdout.split("\0") if name and (directory / name).is_file()}
)
return Comparison(base_sha, head_sha, merge_base, files)


def root(directory: Path) -> Path:
result = run(["git", "rev-parse", "--show-toplevel"], directory)
if result.exit_code or result.timed_out:
Expand All @@ -31,16 +90,29 @@ def changed_files(directory: Path) -> list[str]:
return [name for name in parse_changed(result.stdout) if (directory / name).is_file()]


def changed_lines(directory: Path, files: list[str]) -> dict[str, list[tuple[int, int]]]:
def changed_lines(
directory: Path, files: list[str], base: str | None = None
) -> dict[str, list[tuple[int, int]]]:
head = run(["git", "rev-parse", "--verify", "HEAD"], directory)
ranges = {}
for name in files:
tracked = run(["git", "ls-files", "--error-unmatch", "--", name], directory)
if head.exit_code or tracked.exit_code:
ranges[name] = [(1, len((directory / name).read_bytes().splitlines()) + 1)]
continue
comparison = [base, "HEAD"] if base else ["HEAD"]
diff = run(
["git", "diff", "HEAD", "--unified=0", "--no-ext-diff", "--no-textconv", "--", name],
[
"git",
"diff",
*comparison,
"--unified=0",
"--no-ext-diff",
"--no-textconv",
"--no-renames",
"--",
name,
],
directory,
)
if diff.exit_code or diff.timed_out:
Expand Down
1 change: 1 addition & 0 deletions src/gauntlet/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,4 @@ class RunContext:
quiet: bool = False
verbose: bool = False
function_files: list[str] | None = None
comparison_base: str | None = None
2 changes: 1 addition & 1 deletion src/gauntlet/pipeline/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def _analyze(context: RunContext, results: Results, accept: dict[str, str]) -> N
)
if name == "mutator" and not context.scan:
enrich(results.findings, adapter.snapshots, context)
ranges = git.changed_lines(context.root, context.changed_files)
ranges = git.changed_lines(context.root, context.changed_files, context.comparison_base)
results.diagnostics.extend(
policy.apply(
results.findings, context.config, accept, context.changed_files, context.mode, ranges
Expand Down
Loading
Loading