ai-test-loop 0.0.0-stage → 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +144 -2
- package/SKILL.md +111 -0
- package/bin/cli.js +29 -0
- package/package.json +37 -4
- package/references/reviewer-prompt.md +61 -0
- package/references/scoring-rubric.md +60 -0
- package/scripts/loop_runner.py +266 -0
- package/templates/loop.example.json +15 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Samuel Medeiros
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,145 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ai-test-loop
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Your agent is grading its own homework.** This makes it impossible: a critic script runs real
|
|
4
|
+
checks, scores the artifact 0–100, loops until the threshold is met, and a fail-closed gate blocks
|
|
5
|
+
delivery on security findings.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
No API keys, no dependencies, no model calls. `loop_runner.py` is deliberately dumb: exit codes and
|
|
10
|
+
optional output patterns decide pass/fail. The model never gets to *say* the work is good — it has
|
|
11
|
+
to *show* it.
|
|
12
|
+
|
|
13
|
+
> **Quer isso pronto para o seu time?** Os packs com entrega por e-mail, o gate com relatório
|
|
14
|
+
> e o guia de verificação estão em **https://skills.seu.pet/gratis/ai-test-loop**
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## The problem
|
|
19
|
+
|
|
20
|
+
Ask an agent "is this done?" and it answers about its own reasoning. The same context that produced
|
|
21
|
+
the artifact also decides it is correct, so real defects survive to delivery:
|
|
22
|
+
|
|
23
|
+
- a handler that is never called (tests pass, feature dead)
|
|
24
|
+
- a test that passes because it asserts nothing
|
|
25
|
+
- a page that breaks only at mobile width
|
|
26
|
+
- a migration that works on the author's data and nowhere else
|
|
27
|
+
- a "fix" that reverted a config someone changed on purpose
|
|
28
|
+
|
|
29
|
+
Running "one more review pass" does not help, because the reviewer and the builder are the same
|
|
30
|
+
process. Separating them is the whole trick.
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
git clone https://github.com/Samuelfmedeiros/ai-test-loop
|
|
36
|
+
cd ai-test-loop
|
|
37
|
+
|
|
38
|
+
# 1. describe your checks (name, command, weight — weights sum to 100)
|
|
39
|
+
cat > loop.json <<'JSON'
|
|
40
|
+
{
|
|
41
|
+
"name": "my-project",
|
|
42
|
+
"threshold": 85,
|
|
43
|
+
"max_rounds": 8,
|
|
44
|
+
"checks": [
|
|
45
|
+
{"name": "unit tests", "cmd": "pytest -q", "weight": 35, "timeout_s": 900},
|
|
46
|
+
{"name": "type check", "cmd": "mypy src", "weight": 10},
|
|
47
|
+
{"name": "build", "cmd": "npm run build", "weight": 20},
|
|
48
|
+
{"name": "api up", "cmd": "curl -sS 127.0.0.1:8000/health", "weight": 15,
|
|
49
|
+
"must_match": "\"status\"\\s*:\\s*\"ok\""},
|
|
50
|
+
{"name": "no secrets", "cmd": "gitleaks detect --no-banner", "weight": 20,
|
|
51
|
+
"fail_if_output_matches": "leaks found: [1-9]", "required": true}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
JSON
|
|
55
|
+
|
|
56
|
+
# 2. run the critic
|
|
57
|
+
python3 scripts/loop_runner.py --config loop.json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Output is machine-readable, so the *agent* can act on it and a CI job can gate on it:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
SCORE=72
|
|
64
|
+
GAPS=["build: command exited 1", "api up: output did not match pattern"]
|
|
65
|
+
EVIDENCE=.loop/report-1.json
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Fix the **single largest** gap, run `--round 2`, repeat. Cap the rounds (8 is plenty): if the score
|
|
69
|
+
never converges, the check set is wrong, not the code.
|
|
70
|
+
|
|
71
|
+
A self-contained demo lives in `examples/demo` and runs in CI on every push, so you can see a real
|
|
72
|
+
pass and a real fail without configuring anything.
|
|
73
|
+
|
|
74
|
+
## The four roles (never merge them)
|
|
75
|
+
|
|
76
|
+
| Role | Who | Job |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| Orchestrator | the agent running the task | picks checks, runs rounds, owns the report |
|
|
79
|
+
| Builder | the agent/model that wrote the artifact | fixes one class of gap per pass |
|
|
80
|
+
| Critic | this script + your checks | runs real commands, returns score + gaps |
|
|
81
|
+
| Reviewer | a **separate** context/model | audits critic and builder, can reopen the loop |
|
|
82
|
+
|
|
83
|
+
Rule zero: builder ≠ critic ≠ reviewer. If one context does all three, the loop is theatre.
|
|
84
|
+
|
|
85
|
+
## Three rules that make it honest
|
|
86
|
+
|
|
87
|
+
1. **Every point is earned by a command that actually ran.** No points for "read the code". Weights
|
|
88
|
+
sum to 100 and the report stores the raw output next to each score.
|
|
89
|
+
2. **`NOT VERIFIABLE` is not a pass.** If a check cannot run (missing device, no network, jail), it
|
|
90
|
+
scores 0 and the report says why. Silent skips are how a green board lies.
|
|
91
|
+
3. **The security gate is fail-closed.** A secret in the diff or history, a high-severity SAST
|
|
92
|
+
finding, or an auth/path-traversal/injection issue found in review blocks delivery **even with a
|
|
93
|
+
perfect score**. An inconclusive answer counts as blocked, never as pass.
|
|
94
|
+
|
|
95
|
+
## Reviewer panel
|
|
96
|
+
|
|
97
|
+
For anything other people will use, ask 2–3 specialists instead of one generalist — quality and
|
|
98
|
+
security always, performance only if there is a build or runtime cost, UX only if there is a
|
|
99
|
+
user-facing surface. Each returns strict JSON:
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{"parecer":"CONCORDO|DISCORDO","nota":0-100,"gaps":["..."],"texto":"one paragraph with evidence"}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Consensus → deliver. Majority disagree → reopen the loop with the combined gaps. Prompt in
|
|
106
|
+
`references/reviewer-prompt.md`, starting rubric in `references/scoring-rubric.md`.
|
|
107
|
+
|
|
108
|
+
## When not to use it
|
|
109
|
+
|
|
110
|
+
Throwaway exploration, spike branches, and one-line answers where the cost of the loop exceeds the
|
|
111
|
+
cost of being wrong. It is for artifacts a human will rely on.
|
|
112
|
+
|
|
113
|
+
## Files
|
|
114
|
+
|
|
115
|
+
| Path | What |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `SKILL.md` | the loop, the scoring rules, the fail-closed gate — drop it in your agent's skills dir |
|
|
118
|
+
| `scripts/loop_runner.py` | the deterministic critic (stdlib only) |
|
|
119
|
+
| `references/reviewer-prompt.md` | independent-review prompt, strict JSON output |
|
|
120
|
+
| `references/scoring-rubric.md` | starting weights |
|
|
121
|
+
| `templates/loop.example.json` | config with the usual check set |
|
|
122
|
+
| `examples/demo/` | self-contained example used by CI |
|
|
123
|
+
|
|
124
|
+
## Get the full pack
|
|
125
|
+
|
|
126
|
+
This repo is the free, complete tool. The store has the ready-made packs for the cases where
|
|
127
|
+
writing the checks is the slow part — CI gates, visual regression, MCP servers, MLOps guards:
|
|
128
|
+
|
|
129
|
+
- Free download / e-mail delivery: **https://skills.seu.pet/gratis/ai-test-loop**
|
|
130
|
+
- Catalog: **https://skills.seu.pet**
|
|
131
|
+
- Want it installed and wired into your repo: **https://skills.seu.pet/servico**
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT — see `LICENSE`. Use it commercially, fork it, ship it.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
### Em português
|
|
140
|
+
|
|
141
|
+
O loop de teste para agentes: o construtor não assina o próprio trabalho. Um crítico roda checagens
|
|
142
|
+
de verdade, dá nota 0–100, repete até o limite e barra a entrega se houver achado de segurança.
|
|
143
|
+
`python3 scripts/loop_runner.py --config loop.json` — sem chave de API e sem dependência.
|
|
144
|
+
|
|
145
|
+
Versão com entrega por e-mail e os packs prontos: https://skills.seu.pet/gratis/ai-test-loop
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ai-test-loop
|
|
3
|
+
description: "Use before declaring any work done or shipped, when the agent must verify its own output, when a deliverable needs a score before release, when the builder keeps approving its own work, or when repeated review rounds never converge. Runs an independent critic that scores the artifact 0-100 with real checks, loops until the threshold, and blocks delivery on a fail-closed security gate."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# AI Test Loop — never ship unverified work
|
|
7
|
+
|
|
8
|
+
The builder is not allowed to grade its own homework. This skill turns "looks done" into
|
|
9
|
+
"verified, scored, and independently reviewed" — the loop either reaches the threshold with
|
|
10
|
+
evidence, or it reports exactly which gaps remain.
|
|
11
|
+
|
|
12
|
+
Use it whenever an agent produced something a human will rely on: code, UI, a pipeline, a
|
|
13
|
+
document, a data migration. Not for throwaway exploration.
|
|
14
|
+
|
|
15
|
+
## The four roles (never merge them)
|
|
16
|
+
|
|
17
|
+
| Role | Who | Job |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| Orchestrator | the agent running the task | picks checks, runs rounds, fixes infrastructure, owns the report |
|
|
20
|
+
| Builder | the agent/model that wrote the artifact | fixes gaps, one class of gap per pass |
|
|
21
|
+
| Critic | a **script you write** (`loop_runner.py` + your checks) | runs real commands, returns a 0-100 score and a gap list |
|
|
22
|
+
| Reviewer | a **separate** agent/model context | audits critic + builder, votes, can reopen the loop |
|
|
23
|
+
|
|
24
|
+
Rule zero: builder ≠ critic ≠ reviewer. If the same context does all three, the loop is theatre.
|
|
25
|
+
Run steps 1-3 below in the working session, then drive the script for the machine part.
|
|
26
|
+
|
|
27
|
+
## The loop
|
|
28
|
+
|
|
29
|
+
1. **Declare the checks before writing code.** For each check: name, command, weight, and what
|
|
30
|
+
"pass" means. Weights must sum to 100.
|
|
31
|
+
2. **Run the critic** — `python scripts/loop_runner.py --config loop.json --round N`. It executes
|
|
32
|
+
every command for real, records raw output as evidence, and prints `SCORE=<0-100>` and
|
|
33
|
+
`GAPS=[...]`. It never asks a model whether the work is good; it measures.
|
|
34
|
+
3. **Score, compare, decide.** `SCORE >= threshold` → go to the reviewer. Otherwise pick the
|
|
35
|
+
**single largest** gap, fix it, and re-run. Cap the rounds (8 is plenty); if it never
|
|
36
|
+
converges, the check set is wrong, not the code.
|
|
37
|
+
4. **Independent review** before delivery — same artifact, fresh context, no justification from
|
|
38
|
+
the builder: `references/reviewer-prompt.md`. The reviewer returns strict JSON.
|
|
39
|
+
5. **Security gate, fail-closed.** Any secret in the diff/history, any high-severity SAST finding,
|
|
40
|
+
any auth/path-traversal/injection issue found by review blocks delivery even with perfect
|
|
41
|
+
tests. Treat an inconclusive answer as blocked, never as pass.
|
|
42
|
+
6. **Report** with the score, the evidence, the reviewer verdict, and what was *not* verifiable.
|
|
43
|
+
|
|
44
|
+
## Scoring
|
|
45
|
+
|
|
46
|
+
Weights are yours to set; start from `references/scoring-rubric.md`. Non-negotiables:
|
|
47
|
+
|
|
48
|
+
- Every point must be earned by a command that actually ran. No points for "read the code".
|
|
49
|
+
- Evidence is captured, not summarised: keep the raw output next to the score.
|
|
50
|
+
- If a check cannot run (no environment, no device, dependency missing), it scores 0 and the
|
|
51
|
+
report says `NOT VERIFIABLE: <reason>` — never silently skipped, never counted as passing.
|
|
52
|
+
|
|
53
|
+
## Reviewer panel (for anything that will be used by others)
|
|
54
|
+
|
|
55
|
+
Ask 2-3 specialists instead of one generalist, each returning strict JSON:
|
|
56
|
+
`{"parecer":"CONCORDO|DISCORDO","nota":0-100,"gaps":["..."],"texto":"one paragraph with evidence"}`.
|
|
57
|
+
|
|
58
|
+
- Profiles by nature of the work: quality (always), security (always), performance (only if the
|
|
59
|
+
artifact has a build or a runtime cost), UX (only if there is a user-facing surface).
|
|
60
|
+
- **Consensus** → deliver. **Majority disagree** → reopen the loop with the combined gaps.
|
|
61
|
+
**Security disagrees or is inconclusive** → block. **Tie 1-1** → a third specialist decides.
|
|
62
|
+
- Do not send an empty `gaps` array as a formality: a reviewer that finds nothing must still cite
|
|
63
|
+
the evidence it inspected.
|
|
64
|
+
|
|
65
|
+
## Pitfalls (each one measured in production)
|
|
66
|
+
|
|
67
|
+
- **Template echo.** A model asked to answer with an enum often echoes the enum back
|
|
68
|
+
(`"CONCORDO|DISCORDO"`). Reject any value that matches the literal template, treat it as
|
|
69
|
+
inconclusive, retry once, then fail closed. A parser that accepts the echo reopens the loop
|
|
70
|
+
forever with a false gap.
|
|
71
|
+
- **Piping hides the exit code.** `pytest | tail` returns the exit code of `tail` (0) — the critic
|
|
72
|
+
scores 100 with a failing test hidden in the output. Use `set -o pipefail` (or read exit codes
|
|
73
|
+
without a pipe) inside every check.
|
|
74
|
+
- **A timeout shorter than the check is a silent zero.** When the critic is killed by its own
|
|
75
|
+
timeout, the orchestrator sees a score of 0 with an empty gap list and loops with nothing to fix.
|
|
76
|
+
Set the timeout above the real duration and always keep the critic's raw log.
|
|
77
|
+
- **A short-circuiting "pass" is not a pass.** A server that responds 200 may be the wrong
|
|
78
|
+
server: on a port collision the check tests a different app and "fails" for the wrong reason.
|
|
79
|
+
Assert identity, not reachability (title, version, a known payload).
|
|
80
|
+
- **Stale baselines.** A visual or snapshot baseline regenerated "to make it green" erases the
|
|
81
|
+
regression. Regenerating is allowed only when the change was intended — and it must be stated
|
|
82
|
+
in the report, with the diff before and after.
|
|
83
|
+
- **Cold start.** First call to a cold service can take seconds; re-test before declaring a
|
|
84
|
+
timeout, and record that you retested.
|
|
85
|
+
- **Fixing many gap classes at once.** One worker per gap class keeps the next verdict readable.
|
|
86
|
+
Batch everything and you cannot tell which change moved the score.
|
|
87
|
+
|
|
88
|
+
## Output format
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
ROUND: <n> SCORE: <0-100> THRESHOLD: <t> GATE: <fail-closed | passed>
|
|
92
|
+
CHECKS: <name> PASS|FAIL <weight> — <one line of real evidence>
|
|
93
|
+
GAPS: <sorted by weight, biggest first>
|
|
94
|
+
REVIEWER: <CONCORDO|DISCORDO> <nota> — <evidence cited> | panel: q=… s=… p=… u=…
|
|
95
|
+
SECURITY: <clean | BLOCKED: <finding>> | INCONCLUSIVE → blocked
|
|
96
|
+
NOT VERIFIABLE: <check — reason>
|
|
97
|
+
NEXT: <single next action>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Scope and network access
|
|
101
|
+
|
|
102
|
+
Runs only the commands you put in the config, on your machine. No network calls of its own, no
|
|
103
|
+
telemetry, no uploads; the security step greps the repo locally. It never deploys and never
|
|
104
|
+
merges: delivery decisions stay with the human owner.
|
|
105
|
+
|
|
106
|
+
## Files
|
|
107
|
+
|
|
108
|
+
- `scripts/loop_runner.py` — the critic: runs weighed checks, scores 0-100, prints gaps, keeps state.
|
|
109
|
+
- `templates/loop.example.json` — a ready-to-edit config; copy it next to the project and adjust.
|
|
110
|
+
- `references/scoring-rubric.md` — starting weights per artifact type.
|
|
111
|
+
- `references/reviewer-prompt.md` — the strict-JSON reviewer prompt plus the anti-echo parser rules.
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Wrapper do critic determinístico (Python) para uso via `npx ai-test-loop`.
|
|
3
|
+
// Por que existe: o runner é Python puro (stdlib) e a porta de entrada que dev
|
|
4
|
+
// usa é `npx`. O wrapper só acha o interpretador e repassa os argumentos.
|
|
5
|
+
const { spawn } = require("node:child_process");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const fs = require("node:fs");
|
|
8
|
+
|
|
9
|
+
const script = path.join(__dirname, "..", "scripts", "loop_runner.py");
|
|
10
|
+
if (!fs.existsSync(script)) {
|
|
11
|
+
console.error("ai-test-loop: runner não encontrado em " + script);
|
|
12
|
+
process.exit(2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const candidatos = process.env.AI_TEST_LOOP_PYTHON
|
|
16
|
+
? [process.env.AI_TEST_LOOP_PYTHON]
|
|
17
|
+
: ["python3", "python"];
|
|
18
|
+
|
|
19
|
+
function tentar(i) {
|
|
20
|
+
if (i >= candidatos.length) {
|
|
21
|
+
console.error("ai-test-loop: Python 3 não encontrado (tente AI_TEST_LOOP_PYTHON=/caminho/python3)");
|
|
22
|
+
process.exit(2);
|
|
23
|
+
}
|
|
24
|
+
const p = spawn(candidatos[i], [script, ...process.argv.slice(2)], { stdio: "inherit" });
|
|
25
|
+
p.on("error", () => tentar(i + 1));
|
|
26
|
+
p.on("exit", (code, signal) => process.exit(signal ? 1 : code ?? 0));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
tentar(0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-test-loop",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Deterministic critic for AI agents: runs weighed checks for real, scores 0-100 and blocks delivery below the threshold. Stdlib-only runner.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Samuel Medeiros",
|
|
7
|
+
"homepage": "https://skills.seu.pet/en/p/ai-test-loop?utm_source=npm&utm_medium=readme&utm_campaign=ai-test-loop",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Samuelfmedeiros/ai-test-loop.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"ai-agents",
|
|
14
|
+
"test-loop",
|
|
15
|
+
"quality-gate",
|
|
16
|
+
"critic",
|
|
17
|
+
"llm",
|
|
18
|
+
"claude-code",
|
|
19
|
+
"codex"
|
|
20
|
+
],
|
|
21
|
+
"bin": {
|
|
22
|
+
"ai-test-loop": "bin/cli.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"bin/",
|
|
26
|
+
"scripts/",
|
|
27
|
+
"templates/",
|
|
28
|
+
"references/",
|
|
29
|
+
"SKILL.md",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=16"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"smoke": "node bin/cli.js --config templates/loop.example.json --dry-run"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Reviewer prompt (independent critic) — strict JSON, fail-closed
|
|
2
|
+
|
|
3
|
+
Send this to a **separate** model context that did not write the artifact. Never paste the
|
|
4
|
+
builder's justification: the reviewer must see the artifact and the evidence, not the defence.
|
|
5
|
+
Include: the artifact (diff or files), the critic report (`loop-report-rN.json`), the acceptance
|
|
6
|
+
criteria, and the raw evidence of the checks that failed.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
You are the independent reviewer of a deliverable you did not write. You have no stake in it
|
|
11
|
+
being accepted. Your job is to find what is wrong, not to be agreeable.
|
|
12
|
+
|
|
13
|
+
Artifact: <paste diff / file list / content>
|
|
14
|
+
Critic report: <paste SCORE, GAPS, evidence>
|
|
15
|
+
Acceptance criteria: <paste>
|
|
16
|
+
|
|
17
|
+
Answer with ONE JSON object and nothing else:
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
"parecer": "CONCORDO" | "DISCORDO",
|
|
21
|
+
"nota": <integer 0-100>,
|
|
22
|
+
"gaps": ["<gap 1>", "<gap 2>"],
|
|
23
|
+
"texto": "<one paragraph citing concrete evidence: file, line, command output>"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
Rules:
|
|
27
|
+
|
|
28
|
+
1. `CONCORDO` means you inspected the artifact and the evidence and believe it is safe to deliver
|
|
29
|
+
at the stated threshold. It does not mean the work is perfect.
|
|
30
|
+
2. `DISCORDO` requires at least one concrete gap with evidence. A gap is actionable and specific
|
|
31
|
+
("handler at src/api.py:88 returns 500 when payload is empty"), never vague ("could be cleaner").
|
|
32
|
+
3. Check these in order, and stop at the first blocking finding:
|
|
33
|
+
a. Do the checks actually exercise the deliverable, or do they test a different process,
|
|
34
|
+
an old build, or a mock of the very thing under test?
|
|
35
|
+
b. Is any security-relevant change present (secrets, auth, crypto, injection, path traversal,
|
|
36
|
+
deserialisation, dependency additions)? If yes, judge it explicitly.
|
|
37
|
+
c. Are there irreversible or destructive operations (DELETE, DROP, TRUNCATE, force push,
|
|
38
|
+
overwrite of production data) without a stated backup and rollback?
|
|
39
|
+
d. Does the score come from real commands, with raw evidence, or from a summary someone wrote?
|
|
40
|
+
4. If you cannot decide because information is missing, answer `DISCORDO` with
|
|
41
|
+
`"gaps": ["INCONCLUSIVE: <what is missing>"]` and `"nota": 0`. Inconclusive blocks.
|
|
42
|
+
5. Do not reward length, style, or effort. Only correctness, evidence, and safety.
|
|
43
|
+
6. Cite at least one piece of inspected evidence in `texto`, even when you agree.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Parser rules (put these in the orchestrator, not in the model)
|
|
48
|
+
|
|
49
|
+
- **Anti-echo.** Models frequently return the enum template literally: `"parecer": "CONCORDO|DISCORDO"`
|
|
50
|
+
or `nota: <integer 0-100>`. If the value equals the template, contains `|`, or is not one of the
|
|
51
|
+
allowed literals, treat the answer as inconclusive: retry once with the template restated as a
|
|
52
|
+
plain sentence, then fail closed (block).
|
|
53
|
+
- **Strict parse.** Extract the first balanced `{...}` block; `json.loads` it. Anything else (prose,
|
|
54
|
+
markdown fences, trailing commentary) is ignored, not interpreted.
|
|
55
|
+
- **Missing keys.** A missing `gaps` list is treated as `[]`; a missing `nota` is treated as 0.
|
|
56
|
+
- **Numeric bounds.** Clamp `nota` to 0-100; a float is rounded down.
|
|
57
|
+
- **Consensus.** Panel of 2-3 reviewers, all `CONCORDO` → deliver. Any `DISCORDO` → reopen the loop
|
|
58
|
+
with the union of gaps. Tie → a third reviewer decides. Security `DISCORDO` or inconclusive →
|
|
59
|
+
block, regardless of the other votes and of the score.
|
|
60
|
+
- **Cost control.** Reviewer calls cost tokens: send the diff and the failing evidence, not the
|
|
61
|
+
whole repository, and keep a per-round cap on reviewer invocations.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Starting weights per artifact type
|
|
2
|
+
|
|
3
|
+
Weights are normalised by `loop_runner.py`, so they need not sum to 100 — but keep them readable.
|
|
4
|
+
A check with weight 0 is useless (it can never move the score), and a check with weight 80 makes
|
|
5
|
+
every other check decoration. Copy the row that matches the artifact and tune after one round.
|
|
6
|
+
|
|
7
|
+
## Backend / API service
|
|
8
|
+
|
|
9
|
+
| Check | Command example | Weight |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| unit tests | `pytest -q` | 35 |
|
|
12
|
+
| integration / e2e | `pytest -q tests/e2e` | 20 |
|
|
13
|
+
| type check | `mypy src` | 10 |
|
|
14
|
+
| real HTTP call | `curl -sS localhost:8000/health` with `must_match` on a known payload | 15 |
|
|
15
|
+
| no secrets | `gitleaks detect --no-banner` with `fail_if_output_matches` | 20 |
|
|
16
|
+
|
|
17
|
+
## Web app / UI
|
|
18
|
+
|
|
19
|
+
| Check | Command example | Weight |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| build | `npm run build` | 25 |
|
|
22
|
+
| unit tests | `npm test -- --run` | 20 |
|
|
23
|
+
| e2e smoke | `npx playwright test --grep @smoke` | 25 |
|
|
24
|
+
| desktop + mobile screenshot | screenshot script, `must_match` on a non-empty page title | 15 |
|
|
25
|
+
| accessibility | `npx axe` or Lighthouse CI budget | 15 |
|
|
26
|
+
|
|
27
|
+
Visual baselines: a snapshot diff is a check, never a pass by regeneration. If the baseline was
|
|
28
|
+
regenerated, say so in the report and attach the before/after diff.
|
|
29
|
+
|
|
30
|
+
## Data pipeline / migration
|
|
31
|
+
|
|
32
|
+
| Check | Command example | Weight |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| row counts before vs after | script printing `src=… dst=…` with `must_match` on equality | 30 |
|
|
35
|
+
| schema matches | `alembic check` / `pg_dump --schema-only` diff | 25 |
|
|
36
|
+
| dry run on a copy | pipeline run against a scratch database | 25 |
|
|
37
|
+
| no destructive statements | `grep -nE 'DROP|TRUNCATE'` with `fail_if_output_matches` | 20 |
|
|
38
|
+
|
|
39
|
+
## Document / report
|
|
40
|
+
|
|
41
|
+
| Check | Command example | Weight |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| required sections present | grep for each heading, one check per group | 30 |
|
|
44
|
+
| links resolve | `curl -sI` each URL, `expect_exit: 0` | 20 |
|
|
45
|
+
| numbers traceable to source | command re-deriving the totals | 30 |
|
|
46
|
+
| no placeholders left | `grep -nE 'TODO|TBD|lorem'` with `fail_if_output_matches` | 20 |
|
|
47
|
+
|
|
48
|
+
## Threshold
|
|
49
|
+
|
|
50
|
+
- 85-90 for anything user-facing or public.
|
|
51
|
+
- 95-100 for money, auth, migrations, or irreversible actions — and the security check must be
|
|
52
|
+
`required: true`.
|
|
53
|
+
- Below 85 the loop keeps going; if three rounds move the score by less than 2 points, the checks
|
|
54
|
+
are measuring noise — fix the checks before fixing the code.
|
|
55
|
+
|
|
56
|
+
## Non-blocking checks
|
|
57
|
+
|
|
58
|
+
Mark `required: false` for quality-nice-to-haves (coverage delta, bundle-size budget, docs polish).
|
|
59
|
+
They still score 0 when they fail and they are printed as `NON_BLOCKING_GAPS`, but they do not gate
|
|
60
|
+
delivery. Never mark a security, data-integrity or destructive-operation check as non-blocking.
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""loop_runner — the deterministic critic of the AI Test Loop.
|
|
3
|
+
|
|
4
|
+
Runs a list of weighed checks for real, captures their raw output as evidence, and prints:
|
|
5
|
+
|
|
6
|
+
SCORE=<0-100>
|
|
7
|
+
GAPS=["<check>: <why it failed>", ...]
|
|
8
|
+
EVIDENCE=<path to the JSON report>
|
|
9
|
+
|
|
10
|
+
The agent (builder) is not allowed to grade its own work; this script measures. It is
|
|
11
|
+
deliberately dumb: exit codes + optional output patterns decide pass/fail. No model calls,
|
|
12
|
+
no network, no telemetry.
|
|
13
|
+
|
|
14
|
+
Config (JSON):
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
"name": "my-project",
|
|
18
|
+
"threshold": 85,
|
|
19
|
+
"max_rounds": 8,
|
|
20
|
+
"checks": [
|
|
21
|
+
{"name": "unit tests", "cmd": "pytest -q", "weight": 40, "timeout_s": 900},
|
|
22
|
+
{"name": "build", "cmd": "npm run build", "weight": 25, "timeout_s": 600},
|
|
23
|
+
{"name": "lint", "cmd": "ruff check .", "weight": 10},
|
|
24
|
+
{"name": "no secrets", "cmd": "gitleaks detect --no-banner", "weight": 15,
|
|
25
|
+
"fail_if_output_matches": "leaks found: [1-9]"},
|
|
26
|
+
{"name": "api responds", "cmd": "curl -sS localhost:8000/health", "weight": 10,
|
|
27
|
+
"must_match": "\"status\"\\s*:\\s*\"ok\""}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
Check keys:
|
|
32
|
+
cmd (required) shell command; runs with pipefail semantics via bash -o pipefail -c
|
|
33
|
+
weight (required) points; all weights are normalised, so they need not sum to 100
|
|
34
|
+
timeout_s (optional, default 900) hard timeout — a timeout is a FAIL, never a skip
|
|
35
|
+
expect_exit (optional, default 0) exit code that means pass
|
|
36
|
+
must_match (optional) regex that MUST appear in combined output
|
|
37
|
+
fail_if_output_matches (optional) regex that fails the check when it appears
|
|
38
|
+
required (optional, default true) when false, a FAIL scores 0 but is reported as a
|
|
39
|
+
non-blocking gap (useful for "nice to have" checks)
|
|
40
|
+
cwd (optional) working directory for this check
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
python loop_runner.py --config loop.json [--round 2] [--json] [--quiet]
|
|
44
|
+
python loop_runner.py --config loop.json --show-state # print round history and stop
|
|
45
|
+
python loop_runner.py --config loop.json --dry-run # list checks, run nothing
|
|
46
|
+
|
|
47
|
+
Exit code: 0 when SCORE >= threshold (gate passed), 1 when below, 2 on usage/config error.
|
|
48
|
+
|
|
49
|
+
State: <config dir>/.<config-stem>-state.json accumulates every round, so a crash or a restart
|
|
50
|
+
never loses the evidence trail. The report of the latest round is written to
|
|
51
|
+
<config dir>/<config-stem>-report-r<round>.json. Names are derived from the config file so two
|
|
52
|
+
projects in the same folder never share a history.
|
|
53
|
+
"""
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import argparse
|
|
57
|
+
import json
|
|
58
|
+
import os
|
|
59
|
+
import pathlib
|
|
60
|
+
import re
|
|
61
|
+
import shlex
|
|
62
|
+
import subprocess
|
|
63
|
+
import sys
|
|
64
|
+
import time
|
|
65
|
+
from datetime import datetime, timezone
|
|
66
|
+
|
|
67
|
+
DEFAULT_TIMEOUT = 900
|
|
68
|
+
MAX_OUTPUT_CHARS = 8000
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_config(path: str) -> dict:
|
|
72
|
+
try:
|
|
73
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
74
|
+
cfg = json.load(fh)
|
|
75
|
+
except FileNotFoundError:
|
|
76
|
+
sys.exit(f"[loop_runner] config not found: {path}")
|
|
77
|
+
except json.JSONDecodeError as exc:
|
|
78
|
+
sys.exit(f"[loop_runner] config is not valid JSON: {exc}")
|
|
79
|
+
checks = cfg.get("checks")
|
|
80
|
+
if not isinstance(checks, list) or not checks:
|
|
81
|
+
sys.exit("[loop_runner] config needs a non-empty 'checks' list")
|
|
82
|
+
for i, chk in enumerate(checks):
|
|
83
|
+
if not chk.get("cmd"):
|
|
84
|
+
sys.exit(f"[loop_runner] check #{i} has no 'cmd'")
|
|
85
|
+
if not isinstance(chk.get("weight", None), (int, float)) or chk["weight"] <= 0:
|
|
86
|
+
sys.exit(f"[loop_runner] check {chk.get('name', i)!r} needs a positive 'weight'")
|
|
87
|
+
cfg.setdefault("name", os.path.basename(os.path.dirname(os.path.abspath(path))) or "loop")
|
|
88
|
+
cfg.setdefault("threshold", 85)
|
|
89
|
+
cfg.setdefault("max_rounds", 8)
|
|
90
|
+
return cfg
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run_check(chk: dict) -> dict:
|
|
94
|
+
"""Run one check for real, return its result record with raw evidence."""
|
|
95
|
+
name = chk.get("name") or chk["cmd"][:40]
|
|
96
|
+
timeout = int(chk.get("timeout_s", DEFAULT_TIMEOUT))
|
|
97
|
+
cwd = chk.get("cwd") or None
|
|
98
|
+
expect_exit = int(chk.get("expect_exit", 0))
|
|
99
|
+
# bash -o pipefail: without it, `pytest | tail` returns tail's exit code (0) and a failing
|
|
100
|
+
# check scores as passing — the single most common false green in this kind of runner.
|
|
101
|
+
wrapped = ["bash", "-o", "pipefail", "-c", chk["cmd"]]
|
|
102
|
+
started = time.time()
|
|
103
|
+
timed_out = False
|
|
104
|
+
try:
|
|
105
|
+
proc = subprocess.run(
|
|
106
|
+
wrapped, cwd=cwd, capture_output=True, text=True, timeout=timeout,
|
|
107
|
+
stdin=subprocess.DEVNULL,
|
|
108
|
+
)
|
|
109
|
+
rc, out, err = proc.returncode, proc.stdout or "", proc.stderr or ""
|
|
110
|
+
except subprocess.TimeoutExpired as exc:
|
|
111
|
+
timed_out = True
|
|
112
|
+
rc = None
|
|
113
|
+
out = (exc.stdout or b"").decode("utf-8", "replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
|
|
114
|
+
err = (exc.stderr or b"").decode("utf-8", "replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
|
|
115
|
+
duration = round(time.time() - started, 2)
|
|
116
|
+
combined = (out or "") + ("\n" + err if err else "")
|
|
117
|
+
|
|
118
|
+
reasons: list[str] = []
|
|
119
|
+
if timed_out:
|
|
120
|
+
reasons.append(f"timed out after {timeout}s")
|
|
121
|
+
elif rc != expect_exit:
|
|
122
|
+
reasons.append(f"exit code {rc}, expected {expect_exit}")
|
|
123
|
+
|
|
124
|
+
must = chk.get("must_match")
|
|
125
|
+
if must and not re.search(must, combined):
|
|
126
|
+
reasons.append(f"output does not match required pattern /{must}/")
|
|
127
|
+
|
|
128
|
+
fail_pat = chk.get("fail_if_output_matches")
|
|
129
|
+
if fail_pat:
|
|
130
|
+
hit = re.search(fail_pat, combined)
|
|
131
|
+
if hit:
|
|
132
|
+
reasons.append(f"output matched failure pattern /{fail_pat}/: {hit.group(0)!r}")
|
|
133
|
+
|
|
134
|
+
passed = not reasons
|
|
135
|
+
evidence = combined.strip()
|
|
136
|
+
if len(evidence) > MAX_OUTPUT_CHARS:
|
|
137
|
+
evidence = evidence[: MAX_OUTPUT_CHARS // 2] + "\n...[truncated]...\n" + evidence[-MAX_OUTPUT_CHARS // 2:]
|
|
138
|
+
return {
|
|
139
|
+
"name": name,
|
|
140
|
+
"cmd": chk["cmd"],
|
|
141
|
+
"weight": float(chk["weight"]),
|
|
142
|
+
"required": bool(chk.get("required", True)),
|
|
143
|
+
"passed": passed,
|
|
144
|
+
"reasons": reasons,
|
|
145
|
+
"exit_code": rc,
|
|
146
|
+
"duration_s": duration,
|
|
147
|
+
"evidence": evidence,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def score(checks: list[dict]) -> float:
|
|
152
|
+
total = sum(c["weight"] for c in checks) or 1.0
|
|
153
|
+
earned = sum(c["weight"] for c in checks if c["passed"])
|
|
154
|
+
return round(earned / total * 100, 1)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main() -> int:
|
|
158
|
+
ap = argparse.ArgumentParser(add_help=True, description="Deterministic critic for the AI Test Loop")
|
|
159
|
+
ap.add_argument("--config", required=True)
|
|
160
|
+
ap.add_argument("--round", type=int)
|
|
161
|
+
ap.add_argument("--json", action="store_true", help="also dump the full report as JSON to stdout")
|
|
162
|
+
ap.add_argument("--quiet", action="store_true", help="only SCORE/GAPS/EVIDENCE lines")
|
|
163
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
164
|
+
ap.add_argument("--show-state", action="store_true")
|
|
165
|
+
args = ap.parse_args()
|
|
166
|
+
|
|
167
|
+
cfg_path = os.path.abspath(args.config)
|
|
168
|
+
cfg = load_config(cfg_path)
|
|
169
|
+
base = os.path.dirname(cfg_path)
|
|
170
|
+
# Per-config filenames: two configs in the same folder must not share one state file,
|
|
171
|
+
# otherwise rounds from project A pollute project B's history and round numbers.
|
|
172
|
+
stem = os.path.splitext(os.path.basename(cfg_path))[0]
|
|
173
|
+
state_path = os.path.join(base, f".{stem}-state.json")
|
|
174
|
+
|
|
175
|
+
if args.show_state:
|
|
176
|
+
if not os.path.exists(state_path):
|
|
177
|
+
print("[loop_runner] no state file yet")
|
|
178
|
+
return 0
|
|
179
|
+
with open(state_path, "r", encoding="utf-8") as fh:
|
|
180
|
+
st = json.load(fh)
|
|
181
|
+
for r in st.get("rounds", []):
|
|
182
|
+
print(f"round {r['round']}: SCORE={r['score']} threshold={r['threshold']} "
|
|
183
|
+
f"failed={[c['name'] for c in r['checks'] if not c['passed']]}")
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
if args.dry_run:
|
|
187
|
+
for c in cfg["checks"]:
|
|
188
|
+
print(f"[dry-run] {c.get('name', c['cmd'][:40])} weight={c['weight']} cmd={c['cmd']}")
|
|
189
|
+
print(f"[dry-run] threshold={cfg['threshold']} max_rounds={cfg['max_rounds']}")
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
round_no = args.round
|
|
193
|
+
if round_no is None:
|
|
194
|
+
prev = 0
|
|
195
|
+
if os.path.exists(state_path):
|
|
196
|
+
with open(state_path, "r", encoding="utf-8") as fh:
|
|
197
|
+
prev = len(json.load(fh).get("rounds", []))
|
|
198
|
+
round_no = prev + 1
|
|
199
|
+
|
|
200
|
+
if round_no > int(cfg["max_rounds"]):
|
|
201
|
+
print(f"ROUND: {round_no} ABORT: exceeded max_rounds={cfg['max_rounds']}")
|
|
202
|
+
print("GAPS=[\"round budget exhausted — the check set or the threshold needs revisiting\"]")
|
|
203
|
+
return 1
|
|
204
|
+
|
|
205
|
+
results = []
|
|
206
|
+
for chk in cfg["checks"]:
|
|
207
|
+
if not args.quiet:
|
|
208
|
+
print(f"[loop_runner] round {round_no}: running {chk.get('name', chk['cmd'][:40])!r} ...",
|
|
209
|
+
file=sys.stderr, flush=True)
|
|
210
|
+
results.append(run_check(chk))
|
|
211
|
+
|
|
212
|
+
value = score(results)
|
|
213
|
+
threshold = float(cfg["threshold"])
|
|
214
|
+
gate = value >= threshold
|
|
215
|
+
gaps = [f"{c['name']}: {'; '.join(c['reasons'])}" for c in results if not c["passed"] and c["required"]]
|
|
216
|
+
soft = [f"{c['name']}: {'; '.join(c['reasons'])}" for c in results if not c["passed"] and not c["required"]]
|
|
217
|
+
|
|
218
|
+
report = {
|
|
219
|
+
"project": cfg["name"],
|
|
220
|
+
"round": round_no,
|
|
221
|
+
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
222
|
+
"threshold": threshold,
|
|
223
|
+
"score": value,
|
|
224
|
+
"gate": "passed" if gate else "failed",
|
|
225
|
+
"checks": results,
|
|
226
|
+
"gaps": gaps,
|
|
227
|
+
"non_blocking_gaps": soft,
|
|
228
|
+
}
|
|
229
|
+
report_path = os.path.join(base, f"{stem}-report-r{round_no}.json")
|
|
230
|
+
pathlib.Path(report_path).write_text(
|
|
231
|
+
json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
232
|
+
|
|
233
|
+
state = {"project": cfg["name"], "rounds": []}
|
|
234
|
+
if os.path.exists(state_path):
|
|
235
|
+
try:
|
|
236
|
+
with open(state_path, "r", encoding="utf-8") as fh:
|
|
237
|
+
state = json.load(fh)
|
|
238
|
+
state.setdefault("rounds", [])
|
|
239
|
+
except json.JSONDecodeError:
|
|
240
|
+
pass
|
|
241
|
+
state["rounds"].append({"round": round_no, "score": value, "threshold": threshold,
|
|
242
|
+
"gate": report["gate"], "ts": report["ts"], "report": report_path,
|
|
243
|
+
"checks": results})
|
|
244
|
+
pathlib.Path(state_path).write_text(
|
|
245
|
+
json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
246
|
+
|
|
247
|
+
if not args.quiet:
|
|
248
|
+
for c in results:
|
|
249
|
+
status = "PASS" if c["passed"] else "FAIL"
|
|
250
|
+
detail = c["evidence"].strip().splitlines()[-1][:160] if c["evidence"].strip() else "(no output)"
|
|
251
|
+
print(f" {status} {c['name']:<28} w={c['weight']:<5} {c['duration_s']:>7}s {detail}")
|
|
252
|
+
|
|
253
|
+
print(f"SCORE={value}")
|
|
254
|
+
print(f"THRESHOLD={threshold}")
|
|
255
|
+
print(f"GATE={'passed' if gate else 'failed'} (round {round_no}/{cfg['max_rounds']})")
|
|
256
|
+
print(f"GAPS={json.dumps(gaps, ensure_ascii=False)}")
|
|
257
|
+
if soft:
|
|
258
|
+
print(f"NON_BLOCKING_GAPS={json.dumps(soft, ensure_ascii=False)}")
|
|
259
|
+
print(f"EVIDENCE={report_path}")
|
|
260
|
+
if args.json:
|
|
261
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
262
|
+
return 0 if gate else 1
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-project",
|
|
3
|
+
"threshold": 85,
|
|
4
|
+
"max_rounds": 8,
|
|
5
|
+
"checks": [
|
|
6
|
+
{"name": "unit tests", "cmd": "pytest -q", "weight": 35, "timeout_s": 900},
|
|
7
|
+
{"name": "type check", "cmd": "mypy src", "weight": 10, "timeout_s": 300},
|
|
8
|
+
{"name": "build", "cmd": "npm run build", "weight": 20, "timeout_s": 600},
|
|
9
|
+
{"name": "api responds", "cmd": "curl -sS 127.0.0.1:8000/health", "weight": 15,
|
|
10
|
+
"must_match": "\"status\"\\s*:\\s*\"ok\""},
|
|
11
|
+
{"name": "no secrets", "cmd": "gitleaks detect --no-banner", "weight": 20,
|
|
12
|
+
"fail_if_output_matches": "leaks found: [1-9]",
|
|
13
|
+
"required": true}
|
|
14
|
+
]
|
|
15
|
+
}
|