agentic-harness 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_harness/__init__.py +6 -0
- agent_harness/cli.py +179 -0
- agent_harness/config.py +57 -0
- agent_harness/conftest.py +51 -0
- agent_harness/detect.py +54 -0
- agent_harness/exclusions.py +64 -0
- agent_harness/fix.py +36 -0
- agent_harness/git_files.py +75 -0
- agent_harness/init/__init__.py +0 -0
- agent_harness/init/diagnostic.py +69 -0
- agent_harness/init/scaffold.py +169 -0
- agent_harness/init/templates.py +141 -0
- agent_harness/lint.py +38 -0
- agent_harness/policies/compose/configs.rego +33 -0
- agent_harness/policies/compose/configs_test.rego +23 -0
- agent_harness/policies/compose/escaping.rego +72 -0
- agent_harness/policies/compose/escaping_test.rego +46 -0
- agent_harness/policies/compose/hostname.rego +63 -0
- agent_harness/policies/compose/hostname_test.rego +69 -0
- agent_harness/policies/compose/images.rego +108 -0
- agent_harness/policies/compose/images_test.rego +72 -0
- agent_harness/policies/compose/services.rego +95 -0
- agent_harness/policies/compose/services_test.rego +75 -0
- agent_harness/policies/compose/volumes.rego +58 -0
- agent_harness/policies/compose/volumes_test.rego +39 -0
- agent_harness/policies/dockerfile/base_image.rego +45 -0
- agent_harness/policies/dockerfile/base_image_test.rego +44 -0
- agent_harness/policies/dockerfile/cache.rego +48 -0
- agent_harness/policies/dockerfile/cache_test.rego +44 -0
- agent_harness/policies/dockerfile/healthcheck.rego +38 -0
- agent_harness/policies/dockerfile/healthcheck_test.rego +33 -0
- agent_harness/policies/dockerfile/layers.rego +100 -0
- agent_harness/policies/dockerfile/layers_test.rego +55 -0
- agent_harness/policies/dockerfile/secrets.rego +88 -0
- agent_harness/policies/dockerfile/secrets_test.rego +47 -0
- agent_harness/policies/dockerfile/user.rego +39 -0
- agent_harness/policies/dockerfile/user_test.rego +33 -0
- agent_harness/policies/dokploy/traefik.rego +72 -0
- agent_harness/policies/dokploy/traefik_test.rego +90 -0
- agent_harness/policies/gitignore/secrets.rego +64 -0
- agent_harness/policies/gitignore/secrets_test.rego +62 -0
- agent_harness/policies/javascript/package.rego +61 -0
- agent_harness/policies/javascript/package_test.rego +66 -0
- agent_harness/policies/python/coverage.rego +43 -0
- agent_harness/policies/python/coverage_test.rego +50 -0
- agent_harness/policies/python/pytest.rego +56 -0
- agent_harness/policies/python/pytest_test.rego +41 -0
- agent_harness/policies/python/ruff.rego +69 -0
- agent_harness/policies/python/ruff_test.rego +47 -0
- agent_harness/policies/python/test_isolation.rego +31 -0
- agent_harness/policies/python/test_isolation_test.rego +20 -0
- agent_harness/preset.py +47 -0
- agent_harness/presets/__init__.py +1 -0
- agent_harness/presets/docker/__init__.py +60 -0
- agent_harness/presets/docker/conftest_compose_check.py +51 -0
- agent_harness/presets/docker/conftest_dockerfile_check.py +76 -0
- agent_harness/presets/docker/detect.py +20 -0
- agent_harness/presets/docker/hadolint_check.py +51 -0
- agent_harness/presets/docker/templates.py +46 -0
- agent_harness/presets/dokploy/__init__.py +38 -0
- agent_harness/presets/dokploy/conftest_dokploy_check.py +51 -0
- agent_harness/presets/dokploy/detect.py +16 -0
- agent_harness/presets/javascript/__init__.py +56 -0
- agent_harness/presets/javascript/biome_check.py +58 -0
- agent_harness/presets/javascript/conftest_package_check.py +34 -0
- agent_harness/presets/javascript/detect.py +10 -0
- agent_harness/presets/javascript/fix.py +23 -0
- agent_harness/presets/javascript/templates.py +39 -0
- agent_harness/presets/javascript/type_check.py +75 -0
- agent_harness/presets/python/__init__.py +51 -0
- agent_harness/presets/python/conftest_check.py +18 -0
- agent_harness/presets/python/detect.py +10 -0
- agent_harness/presets/python/fix.py +36 -0
- agent_harness/presets/python/ruff_check.py +47 -0
- agent_harness/presets/python/setup_check.py +168 -0
- agent_harness/presets/python/templates.py +225 -0
- agent_harness/presets/python/ty_check.py +32 -0
- agent_harness/presets/universal/__init__.py +96 -0
- agent_harness/presets/universal/claudemd_setup.py +64 -0
- agent_harness/presets/universal/conftest_gitignore_check.py +40 -0
- agent_harness/presets/universal/conftest_json_check.py +91 -0
- agent_harness/presets/universal/file_length_check.py +85 -0
- agent_harness/presets/universal/gitignore_setup.py +167 -0
- agent_harness/presets/universal/gitignore_tracked_check.py +59 -0
- agent_harness/presets/universal/gitignore_tracked_fix.py +37 -0
- agent_harness/presets/universal/precommit_check.py +82 -0
- agent_harness/presets/universal/templates.py +49 -0
- agent_harness/presets/universal/yamllint_check.py +95 -0
- agent_harness/registry.py +10 -0
- agent_harness/runner.py +73 -0
- agent_harness/security/__init__.py +0 -0
- agent_harness/security/audit.py +36 -0
- agent_harness/security/config.py +48 -0
- agent_harness/security/display.py +60 -0
- agent_harness/security/gitleaks_scanner.py +100 -0
- agent_harness/security/models.py +76 -0
- agent_harness/security/osv_scanner.py +150 -0
- agent_harness/setup_check.py +21 -0
- agent_harness/templates/gitignore/Linux.gitignore +16 -0
- agent_harness/templates/gitignore/Node.gitignore +144 -0
- agent_harness/templates/gitignore/Python.gitignore +216 -0
- agent_harness/templates/gitignore/SOURCE.md +6 -0
- agent_harness/templates/gitignore/Windows.gitignore +24 -0
- agent_harness/templates/gitignore/macOS.gitignore +25 -0
- agent_harness/workspace.py +13 -0
- agentic_harness-0.2.0.dist-info/METADATA +310 -0
- agentic_harness-0.2.0.dist-info/RECORD +110 -0
- agentic_harness-0.2.0.dist-info/WHEEL +4 -0
- agentic_harness-0.2.0.dist-info/entry_points.txt +2 -0
- agentic_harness-0.2.0.dist-info/licenses/LICENSE +21 -0
agent_harness/cli.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# src/agent_harness/cli.py
|
|
2
|
+
import click
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@click.group()
|
|
7
|
+
@click.version_option()
|
|
8
|
+
def cli():
|
|
9
|
+
"""AI Harness — deterministic controls for AI agents."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@cli.command()
|
|
13
|
+
def detect():
|
|
14
|
+
"""Detect project stacks and subprojects."""
|
|
15
|
+
from agent_harness.detect import detect_all
|
|
16
|
+
|
|
17
|
+
cwd = Path.cwd()
|
|
18
|
+
results = detect_all(cwd)
|
|
19
|
+
if not results:
|
|
20
|
+
click.echo("no stacks detected")
|
|
21
|
+
return
|
|
22
|
+
for path, stacks in sorted(results.items()):
|
|
23
|
+
rel = "." if path == cwd else str(path.relative_to(cwd))
|
|
24
|
+
stacks_str = ", ".join(sorted(stacks))
|
|
25
|
+
has_harness = (path / ".agent-harness.yml").exists()
|
|
26
|
+
suffix = "" if has_harness else " (no .agent-harness.yml)"
|
|
27
|
+
click.echo(f"{rel:<30} {stacks_str}{suffix}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def print_results(results) -> int:
|
|
31
|
+
"""Print lint results and return exit code."""
|
|
32
|
+
failed = [r for r in results if not r.passed]
|
|
33
|
+
passed = [r for r in results if r.passed]
|
|
34
|
+
total_ms = sum(r.duration_ms for r in results)
|
|
35
|
+
|
|
36
|
+
for r in results:
|
|
37
|
+
icon = "PASS" if r.passed else "FAIL"
|
|
38
|
+
click.echo(f" {icon} {r.name} ({r.duration_ms}ms)")
|
|
39
|
+
if not r.passed:
|
|
40
|
+
detail = r.error or r.output
|
|
41
|
+
if detail:
|
|
42
|
+
for line in detail.strip().splitlines():
|
|
43
|
+
click.echo(f" {line}")
|
|
44
|
+
|
|
45
|
+
click.echo(f"\n{len(passed)} passed, {len(failed)} failed ({total_ms}ms)")
|
|
46
|
+
return 1 if failed else 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@cli.command()
|
|
50
|
+
def lint():
|
|
51
|
+
"""Run all harness checks (auto-discovers subprojects)."""
|
|
52
|
+
from agent_harness.lint import run_lint_all
|
|
53
|
+
|
|
54
|
+
cwd = Path.cwd()
|
|
55
|
+
all_results = run_lint_all(cwd)
|
|
56
|
+
total_exit = 0
|
|
57
|
+
for path, results in sorted(all_results.items()):
|
|
58
|
+
rel = "." if path == cwd else str(path.relative_to(cwd))
|
|
59
|
+
if len(all_results) > 1:
|
|
60
|
+
click.echo(f"\n=== {rel} ===")
|
|
61
|
+
exit_code = print_results(results)
|
|
62
|
+
if exit_code:
|
|
63
|
+
total_exit = 1
|
|
64
|
+
raise SystemExit(total_exit)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@cli.command()
|
|
68
|
+
def fix():
|
|
69
|
+
"""Auto-fix what's fixable, then lint (auto-discovers subprojects)."""
|
|
70
|
+
from agent_harness.fix import run_fix_all
|
|
71
|
+
from agent_harness.lint import run_lint_all
|
|
72
|
+
|
|
73
|
+
cwd = Path.cwd()
|
|
74
|
+
|
|
75
|
+
click.echo("Fixing...")
|
|
76
|
+
fix_results = run_fix_all(cwd)
|
|
77
|
+
for path, actions in sorted(fix_results.items()):
|
|
78
|
+
rel = "." if path == cwd else str(path.relative_to(cwd))
|
|
79
|
+
for a in actions:
|
|
80
|
+
click.echo(f" {rel}: {a}")
|
|
81
|
+
|
|
82
|
+
click.echo("\nLinting...")
|
|
83
|
+
lint_results = run_lint_all(cwd)
|
|
84
|
+
total_exit = 0
|
|
85
|
+
for path, results in sorted(lint_results.items()):
|
|
86
|
+
rel = "." if path == cwd else str(path.relative_to(cwd))
|
|
87
|
+
if len(lint_results) > 1:
|
|
88
|
+
click.echo(f"\n=== {rel} ===")
|
|
89
|
+
exit_code = print_results(results)
|
|
90
|
+
if exit_code:
|
|
91
|
+
total_exit = 1
|
|
92
|
+
raise SystemExit(total_exit)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@cli.command()
|
|
96
|
+
@click.option("--apply", is_flag=True, help="Apply fixes and create missing files")
|
|
97
|
+
def init(apply):
|
|
98
|
+
"""Diagnose harness setup: check tools, config quality, missing files.
|
|
99
|
+
|
|
100
|
+
Without --apply: report mode (shows issues, suggests fixes).
|
|
101
|
+
With --apply: applies auto-fixes and creates missing config files.
|
|
102
|
+
|
|
103
|
+
Setup checks diagnose configuration quality (thresholds, flags,
|
|
104
|
+
coverage settings) and offer fixes. Lint checks run separately
|
|
105
|
+
via 'agent-harness lint' for fast pass/fail enforcement.
|
|
106
|
+
|
|
107
|
+
Examples:
|
|
108
|
+
agent-harness init # diagnose only
|
|
109
|
+
agent-harness init --apply # diagnose and fix
|
|
110
|
+
"""
|
|
111
|
+
from agent_harness.init.scaffold import scaffold_all
|
|
112
|
+
|
|
113
|
+
all_results = scaffold_all(Path.cwd(), apply=apply)
|
|
114
|
+
any_actions = False
|
|
115
|
+
for path, actions in sorted(all_results.items()):
|
|
116
|
+
for action in actions:
|
|
117
|
+
click.echo(f" {action}")
|
|
118
|
+
if actions:
|
|
119
|
+
any_actions = True
|
|
120
|
+
if any_actions:
|
|
121
|
+
click.echo("\n Done. Run: make lint")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _run_audit(base_branch: str | None, full_history: bool) -> None:
|
|
125
|
+
"""Shared logic for security audit commands."""
|
|
126
|
+
from agent_harness.config import load_config
|
|
127
|
+
from agent_harness.security.audit import run_security_audit
|
|
128
|
+
from agent_harness.security.display import format_report
|
|
129
|
+
|
|
130
|
+
cwd = Path.cwd()
|
|
131
|
+
config = load_config(cwd)
|
|
132
|
+
|
|
133
|
+
if base_branch:
|
|
134
|
+
config.setdefault("security", {})["base_branch"] = base_branch
|
|
135
|
+
|
|
136
|
+
stacks = config.get("stacks", set())
|
|
137
|
+
mode = "full history" if full_history else "working directory"
|
|
138
|
+
click.echo(f"Running security audit ({mode})...")
|
|
139
|
+
|
|
140
|
+
report = run_security_audit(
|
|
141
|
+
cwd, stacks=stacks, config=config, full_history=full_history
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
for line in format_report(report):
|
|
145
|
+
click.echo(line)
|
|
146
|
+
|
|
147
|
+
if report.has_failures:
|
|
148
|
+
raise SystemExit(1)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@cli.command("security-audit")
|
|
152
|
+
@click.option(
|
|
153
|
+
"--base-branch",
|
|
154
|
+
default=None,
|
|
155
|
+
help="Base branch for dep diff (default: from config or origin/main)",
|
|
156
|
+
)
|
|
157
|
+
def security_audit(base_branch):
|
|
158
|
+
"""Scan working directory for vulnerabilities and leaked secrets.
|
|
159
|
+
|
|
160
|
+
Checks deps for known CVEs (blocks new deps with High/Critical + fix).
|
|
161
|
+
Scans working directory for secrets. Fast, safe for every commit.
|
|
162
|
+
"""
|
|
163
|
+
_run_audit(base_branch, full_history=False)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@cli.command("security-audit-history")
|
|
167
|
+
@click.option(
|
|
168
|
+
"--base-branch",
|
|
169
|
+
default=None,
|
|
170
|
+
help="Base branch for dep diff (default: from config or origin/main)",
|
|
171
|
+
)
|
|
172
|
+
def security_audit_history(base_branch):
|
|
173
|
+
"""Deep scan full git history for leaked secrets.
|
|
174
|
+
|
|
175
|
+
Same as security-audit but also scans all past commits for secrets
|
|
176
|
+
that were committed and later deleted. Slower (10-60s). Run once
|
|
177
|
+
during setup or periodically in CI.
|
|
178
|
+
"""
|
|
179
|
+
_run_audit(base_branch, full_history=True)
|
agent_harness/config.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Config loading — dict-based, each preset reads its own section."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _resolve_git_root(project_dir: Path) -> Path | None:
|
|
12
|
+
"""Find the git repository root for project_dir, or None if not in a repo."""
|
|
13
|
+
result = subprocess.run(
|
|
14
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
15
|
+
capture_output=True,
|
|
16
|
+
text=True,
|
|
17
|
+
cwd=str(project_dir),
|
|
18
|
+
)
|
|
19
|
+
if result.returncode != 0:
|
|
20
|
+
return None
|
|
21
|
+
return Path(result.stdout.strip())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_config(project_dir: Path) -> dict:
|
|
25
|
+
"""Load .agent-harness.yml. Returns dict."""
|
|
26
|
+
config: dict = {"stacks": set(), "exclude": []}
|
|
27
|
+
config["git_root"] = _resolve_git_root(project_dir)
|
|
28
|
+
cfg_path = project_dir / ".agent-harness.yml"
|
|
29
|
+
|
|
30
|
+
if cfg_path.exists():
|
|
31
|
+
try:
|
|
32
|
+
raw = yaml.safe_load(cfg_path.read_text()) or {}
|
|
33
|
+
except yaml.YAMLError as e:
|
|
34
|
+
import click
|
|
35
|
+
|
|
36
|
+
click.echo(
|
|
37
|
+
f" WARNING: {cfg_path} is malformed, using defaults\n {e}", err=True
|
|
38
|
+
)
|
|
39
|
+
raw = {}
|
|
40
|
+
|
|
41
|
+
if "stacks" in raw:
|
|
42
|
+
config["stacks"] = set(raw["stacks"])
|
|
43
|
+
if "exclude" in raw:
|
|
44
|
+
config["exclude"] = list(raw["exclude"])
|
|
45
|
+
|
|
46
|
+
# Pass through all other sections for presets to read
|
|
47
|
+
for key, value in raw.items():
|
|
48
|
+
if key not in ("stacks", "exclude"):
|
|
49
|
+
config[key] = value
|
|
50
|
+
|
|
51
|
+
# Auto-detect if no stacks specified
|
|
52
|
+
if not config["stacks"]:
|
|
53
|
+
from agent_harness.registry import PRESETS
|
|
54
|
+
|
|
55
|
+
config["stacks"] = {p.name for p in PRESETS if p.detect(project_dir)}
|
|
56
|
+
|
|
57
|
+
return config
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Shared conftest runner for Rego policy checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from agent_harness import POLICIES_DIR
|
|
11
|
+
from agent_harness.runner import CheckResult, run_check
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_conftest(
|
|
15
|
+
name: str,
|
|
16
|
+
project_dir: Path,
|
|
17
|
+
target_file: str,
|
|
18
|
+
policy_subdir: str,
|
|
19
|
+
data: dict | None = None,
|
|
20
|
+
) -> CheckResult:
|
|
21
|
+
"""Run conftest test on a target file with bundled policies."""
|
|
22
|
+
target = project_dir / target_file
|
|
23
|
+
if not target.exists():
|
|
24
|
+
return CheckResult(
|
|
25
|
+
name=name, passed=True, output=f"Skipping {name}: {target_file} not found"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
policy_path = POLICIES_DIR / policy_subdir
|
|
29
|
+
cmd = [
|
|
30
|
+
"conftest",
|
|
31
|
+
"test",
|
|
32
|
+
str(target),
|
|
33
|
+
"--policy",
|
|
34
|
+
str(policy_path),
|
|
35
|
+
"--no-color",
|
|
36
|
+
"--all-namespaces",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
data_path = None
|
|
40
|
+
if data:
|
|
41
|
+
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
|
|
42
|
+
json.dump(data, tmp)
|
|
43
|
+
tmp.close()
|
|
44
|
+
data_path = tmp.name
|
|
45
|
+
cmd.extend(["--data", data_path])
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
return run_check(name, cmd, cwd=str(project_dir))
|
|
49
|
+
finally:
|
|
50
|
+
if data_path:
|
|
51
|
+
os.unlink(data_path)
|
agent_harness/detect.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Stack detection orchestrator — delegates to preset detect methods."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from agent_harness.git_files import find_files
|
|
8
|
+
from agent_harness.registry import PRESETS
|
|
9
|
+
|
|
10
|
+
# Dependency manifests that define a real project (not just a build target)
|
|
11
|
+
_PROJECT_MANIFESTS = [
|
|
12
|
+
"pyproject.toml",
|
|
13
|
+
"package.json",
|
|
14
|
+
"go.mod",
|
|
15
|
+
"Cargo.toml",
|
|
16
|
+
"Gemfile",
|
|
17
|
+
"composer.json",
|
|
18
|
+
"pom.xml",
|
|
19
|
+
"build.gradle",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def detect_stacks(project_dir: Path) -> set[str]:
|
|
24
|
+
"""Detect which stacks a project uses based on file presence."""
|
|
25
|
+
return {p.name for p in PRESETS if p.detect(project_dir)}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def detect_all(project_dir: Path) -> dict[Path, set[str]]:
|
|
29
|
+
"""Detect stacks in root and subdirectories.
|
|
30
|
+
|
|
31
|
+
A directory is a project only if it has a dependency manifest
|
|
32
|
+
(pyproject.toml, package.json, etc.) or is the root directory.
|
|
33
|
+
Docker-only directories are build targets, not projects.
|
|
34
|
+
"""
|
|
35
|
+
all_patterns = [f"**/{m}" for m in _PROJECT_MANIFESTS] + [
|
|
36
|
+
m for m in _PROJECT_MANIFESTS
|
|
37
|
+
]
|
|
38
|
+
manifest_files = find_files(project_dir, all_patterns)
|
|
39
|
+
|
|
40
|
+
project_dirs: set[Path] = set()
|
|
41
|
+
for f in manifest_files:
|
|
42
|
+
project_dirs.add(f.parent)
|
|
43
|
+
|
|
44
|
+
root_stacks = detect_stacks(project_dir)
|
|
45
|
+
if root_stacks:
|
|
46
|
+
project_dirs.add(project_dir)
|
|
47
|
+
|
|
48
|
+
results: dict[Path, set[str]] = {}
|
|
49
|
+
for d in sorted(project_dirs):
|
|
50
|
+
stacks = detect_stacks(d)
|
|
51
|
+
if stacks:
|
|
52
|
+
results[d] = stacks
|
|
53
|
+
|
|
54
|
+
return results
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File exclusion system.
|
|
3
|
+
|
|
4
|
+
WHAT: Provides default and configurable file exclusion patterns for all checks.
|
|
5
|
+
|
|
6
|
+
WHY: Without exclusions, agent-harness scans lock files, build output, archives,
|
|
7
|
+
and vendored code. This wastes time (yamllint on pnpm-lock.yaml: 1.7s) and
|
|
8
|
+
produces false positives (JSONC parse failures on tsconfig.json in _archive/).
|
|
9
|
+
|
|
10
|
+
WITHOUT IT: 2.5s lint runs that should be 200ms, false positives on generated
|
|
11
|
+
files, agents fixing issues in files they shouldn't touch.
|
|
12
|
+
|
|
13
|
+
FIX: Add patterns to `exclude:` in .agent-harness.yml.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import fnmatch
|
|
19
|
+
|
|
20
|
+
DEFAULT_EXCLUSIONS = [
|
|
21
|
+
# Lock files
|
|
22
|
+
"*.lock",
|
|
23
|
+
"*-lock.*",
|
|
24
|
+
"package-lock.json",
|
|
25
|
+
# Build output
|
|
26
|
+
"dist/",
|
|
27
|
+
".astro/",
|
|
28
|
+
".next/",
|
|
29
|
+
".nuxt/",
|
|
30
|
+
# Dependencies
|
|
31
|
+
"node_modules/",
|
|
32
|
+
".venv/",
|
|
33
|
+
# Caches
|
|
34
|
+
"__pycache__/",
|
|
35
|
+
".pytest_cache/",
|
|
36
|
+
".ruff_cache/",
|
|
37
|
+
# Archives
|
|
38
|
+
"_archive/",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_excluded_patterns(config_exclude: list[str]) -> list[str]:
|
|
43
|
+
"""Merge default exclusions with config-provided ones."""
|
|
44
|
+
return DEFAULT_EXCLUSIONS + config_exclude
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_excluded(filepath: str, patterns: list[str]) -> bool:
|
|
48
|
+
"""Check if a filepath matches any exclusion pattern."""
|
|
49
|
+
for pattern in patterns:
|
|
50
|
+
# Directory prefix match: "dist/" matches "dist/foo/bar.js"
|
|
51
|
+
if pattern.endswith("/") and filepath.startswith(pattern):
|
|
52
|
+
return True
|
|
53
|
+
# Also match if any path segment matches directory pattern
|
|
54
|
+
if pattern.endswith("/"):
|
|
55
|
+
dir_name = pattern.rstrip("/")
|
|
56
|
+
if f"/{dir_name}/" in f"/{filepath}" or filepath.startswith(f"{dir_name}/"):
|
|
57
|
+
return True
|
|
58
|
+
# Glob match: "*.lock" matches "poetry.lock"
|
|
59
|
+
if fnmatch.fnmatch(filepath, pattern):
|
|
60
|
+
return True
|
|
61
|
+
# Also match basename: "*-lock.*" matches "path/to/pnpm-lock.yaml"
|
|
62
|
+
if fnmatch.fnmatch(filepath.split("/")[-1], pattern):
|
|
63
|
+
return True
|
|
64
|
+
return False
|
agent_harness/fix.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from agent_harness.config import load_config
|
|
7
|
+
from agent_harness.registry import PRESETS, UNIVERSAL
|
|
8
|
+
from agent_harness.workspace import discover_roots
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_fix(project_dir: Path) -> list[str]:
|
|
12
|
+
"""Auto-fix what's fixable, then return actions taken."""
|
|
13
|
+
config = load_config(project_dir)
|
|
14
|
+
actions = UNIVERSAL.run_fix(project_dir, config)
|
|
15
|
+
for preset in PRESETS:
|
|
16
|
+
if preset.name in config.get("stacks", set()):
|
|
17
|
+
actions.extend(preset.run_fix(project_dir, config))
|
|
18
|
+
return actions
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_fix_all(project_dir: Path) -> dict[Path, list[str]]:
|
|
22
|
+
"""Discover all subprojects and run fix in each."""
|
|
23
|
+
roots = discover_roots(project_dir)
|
|
24
|
+
if not roots:
|
|
25
|
+
return {project_dir: run_fix(project_dir)}
|
|
26
|
+
|
|
27
|
+
results: dict[Path, list[str]] = {}
|
|
28
|
+
with ThreadPoolExecutor() as pool:
|
|
29
|
+
futures = {pool.submit(run_fix, root): root for root in roots}
|
|
30
|
+
for future in as_completed(futures):
|
|
31
|
+
root = futures[future]
|
|
32
|
+
try:
|
|
33
|
+
results[root] = future.result()
|
|
34
|
+
except Exception as e:
|
|
35
|
+
results[root] = [f"error: {e}"]
|
|
36
|
+
return results
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Git-aware file discovery -- respects .gitignore, finds tracked + untracked files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def find_files(project_dir: Path, patterns: list[str]) -> list[Path]:
|
|
11
|
+
"""Find files matching glob patterns, respecting .gitignore.
|
|
12
|
+
|
|
13
|
+
Uses git ls-files for repos (tracked + untracked, excluding ignored).
|
|
14
|
+
Falls back to filesystem walk for non-git directories.
|
|
15
|
+
"""
|
|
16
|
+
files = _git_find(project_dir, patterns)
|
|
17
|
+
if files is not None:
|
|
18
|
+
return files
|
|
19
|
+
return _fs_find(project_dir, patterns)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _git_find(project_dir: Path, patterns: list[str]) -> list[Path] | None:
|
|
23
|
+
"""Use git ls-files to find files. Returns None if not in a git repo."""
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
[
|
|
26
|
+
"git",
|
|
27
|
+
"ls-files",
|
|
28
|
+
"--cached",
|
|
29
|
+
"--others",
|
|
30
|
+
"--exclude-standard",
|
|
31
|
+
*patterns,
|
|
32
|
+
],
|
|
33
|
+
capture_output=True,
|
|
34
|
+
text=True,
|
|
35
|
+
cwd=str(project_dir),
|
|
36
|
+
)
|
|
37
|
+
if result.returncode != 0:
|
|
38
|
+
return None
|
|
39
|
+
paths = []
|
|
40
|
+
for line in result.stdout.strip().splitlines():
|
|
41
|
+
if line:
|
|
42
|
+
paths.append((project_dir / line).resolve())
|
|
43
|
+
return sorted(set(paths))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fs_find(project_dir: Path, patterns: list[str]) -> list[Path]:
|
|
47
|
+
"""Fallback: filesystem walk with basic glob matching."""
|
|
48
|
+
_skip = {
|
|
49
|
+
".venv",
|
|
50
|
+
"venv",
|
|
51
|
+
"node_modules",
|
|
52
|
+
".git",
|
|
53
|
+
"dist",
|
|
54
|
+
"build",
|
|
55
|
+
"__pycache__",
|
|
56
|
+
".astro",
|
|
57
|
+
".next",
|
|
58
|
+
".nuxt",
|
|
59
|
+
".worktrees",
|
|
60
|
+
"_archive",
|
|
61
|
+
".pytest_cache",
|
|
62
|
+
".ruff_cache",
|
|
63
|
+
}
|
|
64
|
+
results: set[Path] = set()
|
|
65
|
+
for path in project_dir.rglob("*"):
|
|
66
|
+
if any(part in _skip for part in path.parts):
|
|
67
|
+
continue
|
|
68
|
+
if not path.is_file():
|
|
69
|
+
continue
|
|
70
|
+
rel = str(path.relative_to(project_dir))
|
|
71
|
+
if any(
|
|
72
|
+
fnmatch.fnmatch(rel, p) or fnmatch.fnmatch(path.name, p) for p in patterns
|
|
73
|
+
):
|
|
74
|
+
results.add(path.resolve())
|
|
75
|
+
return sorted(results)
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Diagnostic display for init command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from agent_harness.preset import ToolInfo
|
|
10
|
+
from agent_harness.runner import tool_available
|
|
11
|
+
from agent_harness.setup_check import SetupIssue
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def display_setup_issues(
|
|
15
|
+
preset_name: str,
|
|
16
|
+
issues: list[SetupIssue],
|
|
17
|
+
tools: list[ToolInfo],
|
|
18
|
+
project_dir: Path,
|
|
19
|
+
) -> tuple[int, int, int]:
|
|
20
|
+
"""Display setup check results. Returns (critical_count, recommendation_count, fixable_count)."""
|
|
21
|
+
click.echo(f"\n {preset_name}:")
|
|
22
|
+
|
|
23
|
+
critical_count = 0
|
|
24
|
+
recommendation_count = 0
|
|
25
|
+
fixable_count = 0
|
|
26
|
+
|
|
27
|
+
for issue in issues:
|
|
28
|
+
if issue.severity == "critical":
|
|
29
|
+
suffix = " (fixable)" if issue.fixable else ""
|
|
30
|
+
click.echo(f" \u2717 {issue.file}: {issue.message} critical{suffix}")
|
|
31
|
+
critical_count += 1
|
|
32
|
+
if issue.fixable:
|
|
33
|
+
fixable_count += 1
|
|
34
|
+
else:
|
|
35
|
+
click.echo(f" ~ {issue.file}: {issue.message} recommendation")
|
|
36
|
+
recommendation_count += 1
|
|
37
|
+
|
|
38
|
+
for tool in tools:
|
|
39
|
+
available = tool_available(tool.binary, project_dir)
|
|
40
|
+
if available:
|
|
41
|
+
click.echo(f" \u2713 {tool.name} installed")
|
|
42
|
+
else:
|
|
43
|
+
click.echo(f" \u2717 {tool.name} not installed ({tool.install_hint})")
|
|
44
|
+
critical_count += 1
|
|
45
|
+
|
|
46
|
+
return critical_count, recommendation_count, fixable_count
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def display_summary(
|
|
50
|
+
critical: int, recommendations: int, fixable: int, missing_files: int
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Display final summary line."""
|
|
53
|
+
parts = []
|
|
54
|
+
if critical:
|
|
55
|
+
fix_note = f" ({fixable} fixable)" if fixable else ""
|
|
56
|
+
parts.append(f"{critical} critical{fix_note}")
|
|
57
|
+
if recommendations:
|
|
58
|
+
parts.append(
|
|
59
|
+
f"{recommendations} recommendation{'s' if recommendations != 1 else ''}"
|
|
60
|
+
)
|
|
61
|
+
if missing_files:
|
|
62
|
+
parts.append(
|
|
63
|
+
f"{missing_files} file{'s' if missing_files != 1 else ''} to create"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if parts:
|
|
67
|
+
click.echo(f"\n {', '.join(parts)}.")
|
|
68
|
+
else:
|
|
69
|
+
click.echo("\n All checks passed.")
|