aramid 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.
- aramid/__init__.py +1 -0
- aramid/__main__.py +3 -0
- aramid/autolearn.py +313 -0
- aramid/cli.py +264 -0
- aramid/commands/__init__.py +0 -0
- aramid/commands/arm.py +222 -0
- aramid/commands/autolearn_cmd.py +76 -0
- aramid/commands/check.py +175 -0
- aramid/commands/doctor.py +723 -0
- aramid/commands/drain.py +270 -0
- aramid/commands/hooks_template.py +134 -0
- aramid/commands/init.py +472 -0
- aramid/commands/ledger_cmd.py +245 -0
- aramid/commands/mutation_score.py +65 -0
- aramid/commands/override.py +131 -0
- aramid/commands/pack_cmd.py +76 -0
- aramid/commands/rebaseline.py +46 -0
- aramid/commands/schedule.py +262 -0
- aramid/commands/status.py +342 -0
- aramid/commands/triage_cmd.py +91 -0
- aramid/commands/uninstall.py +51 -0
- aramid/commands/update_rules.py +52 -0
- aramid/config.py +271 -0
- aramid/consumers/__init__.py +0 -0
- aramid/consumers/base.py +54 -0
- aramid/consumers/dast.py +83 -0
- aramid/consumers/fuzz.py +221 -0
- aramid/consumers/js_mutation.py +236 -0
- aramid/consumers/llm_review.py +415 -0
- aramid/consumers/mutation.py +285 -0
- aramid/consumers/regression_pack.py +51 -0
- aramid/dast_probe.py +265 -0
- aramid/data/ARAMID.md.tmpl +192 -0
- aramid/data/block_rules.toml +30 -0
- aramid/data/defaults.toml +219 -0
- aramid/detectors.py +250 -0
- aramid/diagnostics.py +49 -0
- aramid/fingerprint.py +14 -0
- aramid/fuzzdriver.py +121 -0
- aramid/fuzzgen.py +117 -0
- aramid/gitutil.py +153 -0
- aramid/hooks.py +522 -0
- aramid/jsmutate.py +371 -0
- aramid/ledger.py +263 -0
- aramid/models.py +84 -0
- aramid/mutation.py +109 -0
- aramid/mutation_gate.py +103 -0
- aramid/mutation_score.py +114 -0
- aramid/mutation_score_gate.py +103 -0
- aramid/normalizer.py +91 -0
- aramid/pack.py +117 -0
- aramid/pipeline.py +921 -0
- aramid/policy.py +263 -0
- aramid/providers/__init__.py +0 -0
- aramid/providers/base.py +91 -0
- aramid/providers/claude_cli.py +70 -0
- aramid/providers/codex_cli.py +99 -0
- aramid/providers/ollama_cloud.py +91 -0
- aramid/providers/openrouter.py +119 -0
- aramid/providers/spend.py +51 -0
- aramid/queue.py +137 -0
- aramid/red_proof.py +310 -0
- aramid/redact.py +26 -0
- aramid/registry.py +48 -0
- aramid/reporter.py +106 -0
- aramid/review.py +519 -0
- aramid/rules/owasp.yml +362 -0
- aramid/runners/__init__.py +0 -0
- aramid/runners/_util.py +96 -0
- aramid/runners/base.py +217 -0
- aramid/runners/clippy.py +326 -0
- aramid/runners/deps.py +656 -0
- aramid/runners/eslint.py +136 -0
- aramid/runners/gitleaks.py +105 -0
- aramid/runners/ruff.py +93 -0
- aramid/runners/semgrep.py +189 -0
- aramid/runners/tests.py +471 -0
- aramid/runners/typecheck.py +185 -0
- aramid/tdd.py +115 -0
- aramid/tests_gate.py +74 -0
- aramid/toolpath.py +92 -0
- aramid/toolset.py +159 -0
- aramid/triage.py +185 -0
- aramid-0.2.0.dist-info/METADATA +426 -0
- aramid-0.2.0.dist-info/RECORD +89 -0
- aramid-0.2.0.dist-info/WHEEL +5 -0
- aramid-0.2.0.dist-info/entry_points.txt +2 -0
- aramid-0.2.0.dist-info/licenses/LICENSE +21 -0
- aramid-0.2.0.dist-info/top_level.txt +1 -0
aramid/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
aramid/__main__.py
ADDED
aramid/autolearn.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""autolearn -- the learned model-selection engine (spec
|
|
2
|
+
2026-07-18-aramid-autolearn-model-selection-design.md): feature bucketing,
|
|
3
|
+
Beta-posterior math, the escalate-only Thompson UPLIFT decision, cascade
|
|
4
|
+
trigger rules, audit sampling, the machine-global state file, and the
|
|
5
|
+
ledger->state rollup. Pure computation except the two explicit state I/O
|
|
6
|
+
functions; provider calls and drain wiring live in consumers.llm_review
|
|
7
|
+
and commands.drain.
|
|
8
|
+
|
|
9
|
+
Terminology: the learned tier-raise is an UPLIFT -- "escalation" already
|
|
10
|
+
means policy.escalate_degraded's gate-exit behavior and is never used for
|
|
11
|
+
arm selection.
|
|
12
|
+
|
|
13
|
+
Fail-open contract (spec section 11): consumers wrap every call here in
|
|
14
|
+
try/except; load_state additionally degrades any unreadable or
|
|
15
|
+
foreign-version state to empty_state(). Cold start == shipped deterministic
|
|
16
|
+
ladder: with no data a zero-evidence cell uses the deterministic prior mean
|
|
17
|
+
1/(1+PRIOR_CLEAN) = 0.10, under the 0.15 default threshold, so the floor
|
|
18
|
+
qualifies and no uplift happens -- exactly, not just in expectation (spec
|
|
19
|
+
section 3.2).
|
|
20
|
+
"""
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import random
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from aramid import review as review_mod
|
|
28
|
+
from aramid.models import EventType
|
|
29
|
+
|
|
30
|
+
STATE_VERSION = 1
|
|
31
|
+
# Trust-the-ladder prior (spec section 8.2): Beta(1, PRIOR_CLEAN) keeps the
|
|
32
|
+
# no-data mean at 0.10 <= the 0.15 default threshold -- absence of evidence
|
|
33
|
+
# reproduces the deterministic ladder exactly.
|
|
34
|
+
PRIOR_CLEAN = 9
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- state ------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def state_path() -> Path:
|
|
40
|
+
"""Seam for tests -- monkeypatch this rather than touching the real
|
|
41
|
+
~/.aramid (mirrors spend.spend_path / registry.registry_path)."""
|
|
42
|
+
return Path.home() / ".aramid" / "autolearn_state.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def empty_state() -> dict:
|
|
46
|
+
return {"version": STATE_VERSION, "updated_at": "", "cursors": {},
|
|
47
|
+
"posteriors": {},
|
|
48
|
+
"shadow": {"decisions": 0, "would_uplift": 0},
|
|
49
|
+
"audits": {"performed": 0, "missed_criticals": 0}}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_state(path: Path | None = None) -> dict:
|
|
53
|
+
"""Unreadable, malformed, or foreign-version state degrades to
|
|
54
|
+
empty_state() -- cold start, never a crash (spec section 11)."""
|
|
55
|
+
p = path if path is not None else state_path()
|
|
56
|
+
try:
|
|
57
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
58
|
+
except Exception:
|
|
59
|
+
return empty_state()
|
|
60
|
+
if not isinstance(data, dict) or data.get("version") != STATE_VERSION:
|
|
61
|
+
return empty_state()
|
|
62
|
+
out = empty_state()
|
|
63
|
+
for key, default in list(out.items()):
|
|
64
|
+
val = data.get(key, default)
|
|
65
|
+
out[key] = val if isinstance(val, type(default)) else default
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def save_state(state: dict, now_iso: str, path: Path | None = None) -> None:
|
|
70
|
+
"""Atomic write (tmp + os.replace): a torn write can never corrupt the
|
|
71
|
+
previous state (spec section 11)."""
|
|
72
|
+
p = path if path is not None else state_path()
|
|
73
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
payload = {**state, "updated_at": now_iso}
|
|
75
|
+
tmp = p.with_name(p.name + ".tmp")
|
|
76
|
+
tmp.write_text(json.dumps(payload, indent=1), encoding="utf-8")
|
|
77
|
+
os.replace(tmp, p)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --- features ---------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def bucket_for(reasons) -> str:
|
|
83
|
+
"""Coarse feature bucket (spec section 8.1): 'sec' iff any triage reason
|
|
84
|
+
names a security signal, else 'plain'. Deliberately 2-valued -- ~18
|
|
85
|
+
reviews/day cannot support finer cells."""
|
|
86
|
+
for r in reasons:
|
|
87
|
+
if "security-path" in r or "risky-content" in r:
|
|
88
|
+
return "sec"
|
|
89
|
+
return "plain"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def posterior_key(arm, band: str, bucket: str) -> str:
|
|
93
|
+
return f"{arm.provider}/{arm.model}|{band}|{bucket}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _counts(state: dict, key: str) -> dict:
|
|
97
|
+
rec = state.get("posteriors", {}).get(key)
|
|
98
|
+
return rec if isinstance(rec, dict) else {}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# --- uplift decision --------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def decision_rng(item_id: str, state: dict) -> random.Random:
|
|
104
|
+
"""Deterministic per-(item, state-generation) RNG (spec section 8.2):
|
|
105
|
+
reproducible in tests, varies across state updates in production."""
|
|
106
|
+
seed = hashlib.sha256(
|
|
107
|
+
f"{item_id}|{state.get('updated_at', '')}".encode()).hexdigest()
|
|
108
|
+
# S311 justification: non-cryptographic is the REQUIREMENT here, not a
|
|
109
|
+
# compromise. This RNG picks which arm to explore; it is seeded from a
|
|
110
|
+
# sha256 of (item_id, updated_at) precisely so the choice is reproducible
|
|
111
|
+
# in tests and re-derivable from state. A cryptographic generator would
|
|
112
|
+
# destroy the determinism the spec asks for, and nothing here is a secret.
|
|
113
|
+
return random.Random(int(seed, 16)) # noqa: S311
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def uplift_pick(arms, score: int, bucket: str, state: dict,
|
|
117
|
+
threshold: float, rng: random.Random):
|
|
118
|
+
"""Escalate-only Thompson decision (spec section 8.2). Walk arms from
|
|
119
|
+
the deterministic floor upward; for each, sample its miss-probability
|
|
120
|
+
q ~ Beta(1+misses, PRIOR_CLEAN+clean) -- except a cell with ZERO
|
|
121
|
+
evidence (misses + clean == 0), which uses the deterministic prior mean
|
|
122
|
+
1/(1+PRIOR_CLEAN) instead of sampling, so empty posteriors reproduce
|
|
123
|
+
today's ladder exactly rather than in expectation (spec section 3.2).
|
|
124
|
+
Serve the lowest arm with q <= threshold. The top arm always qualifies
|
|
125
|
+
(it is the measuring ceiling). Returns (arm, floor_q) where floor_q is
|
|
126
|
+
the q for the floor arm -- the number that explains the decision -- or
|
|
127
|
+
None when there are no arms."""
|
|
128
|
+
tgt = review_mod.target_arm(arms, score)
|
|
129
|
+
if tgt is None:
|
|
130
|
+
return None
|
|
131
|
+
band = tgt.tier
|
|
132
|
+
ladder_up = [a for a in arms if a.min_score >= tgt.min_score]
|
|
133
|
+
floor_q = None
|
|
134
|
+
for a in ladder_up[:-1]:
|
|
135
|
+
c = _counts(state, posterior_key(a, band, bucket))
|
|
136
|
+
n_evidence = int(c.get("misses", 0)) + int(c.get("clean", 0))
|
|
137
|
+
if n_evidence == 0:
|
|
138
|
+
# Zero-evidence cell: deterministic prior mean (spec section 3.2
|
|
139
|
+
# -- cold start must reproduce the ladder EXACTLY, not just in
|
|
140
|
+
# expectation; Thompson sampling begins once audit evidence
|
|
141
|
+
# exists).
|
|
142
|
+
q = 1.0 / (1.0 + PRIOR_CLEAN)
|
|
143
|
+
else:
|
|
144
|
+
q = rng.betavariate(1 + int(c.get("misses", 0)),
|
|
145
|
+
PRIOR_CLEAN + int(c.get("clean", 0)))
|
|
146
|
+
if floor_q is None:
|
|
147
|
+
floor_q = q
|
|
148
|
+
if q <= threshold:
|
|
149
|
+
return a, floor_q
|
|
150
|
+
return ladder_up[-1], (floor_q if floor_q is not None else 0.0)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def next_arm_above(arms, served_arm):
|
|
154
|
+
"""The next-higher tier for a cascade re-review, or None at the top."""
|
|
155
|
+
for a in arms:
|
|
156
|
+
if a.min_score > served_arm.min_score:
|
|
157
|
+
return a
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --- audit sampling ---------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def audit_arm(arms, available: set[str]):
|
|
164
|
+
"""The audit reviewer (spec section 10): the highest-min_score available
|
|
165
|
+
arm, or None when nothing is available."""
|
|
166
|
+
avail = [a for a in arms if a.provider in available]
|
|
167
|
+
return avail[-1] if avail else None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def should_audit(item_id: str, served_arm, arms, audit_every: int) -> bool:
|
|
171
|
+
"""Deterministic 1-in-N sampling (spec section 10): hash the item id --
|
|
172
|
+
no RNG state, reproducible in tests. Only items served BELOW the top
|
|
173
|
+
arm are auditable (self-audit measures nothing)."""
|
|
174
|
+
if audit_every <= 0 or not arms:
|
|
175
|
+
return False
|
|
176
|
+
if served_arm.min_score >= arms[-1].min_score:
|
|
177
|
+
return False
|
|
178
|
+
digest = int(hashlib.sha256(item_id.encode()).hexdigest(), 16)
|
|
179
|
+
return digest % audit_every == 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _fid(cand: dict) -> str | None:
|
|
183
|
+
try:
|
|
184
|
+
return review_mod.llm_fingerprint(
|
|
185
|
+
f"llm/{cand['owasp']}", cand["file"], cand["line_content"])
|
|
186
|
+
except (KeyError, TypeError):
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def audit_diff(served_verified: list, audit_verified: list) -> tuple[int, int]:
|
|
191
|
+
"""(new_findings, missed_criticals): audit candidates whose fingerprint
|
|
192
|
+
the served review did not produce (spec section 10). Malformed
|
|
193
|
+
candidates are skipped, never counted."""
|
|
194
|
+
served = {f for f in (_fid(c) for c in served_verified) if f}
|
|
195
|
+
new_findings = 0
|
|
196
|
+
missed_criticals = 0
|
|
197
|
+
for c in audit_verified:
|
|
198
|
+
fid = _fid(c)
|
|
199
|
+
if fid is None or fid in served:
|
|
200
|
+
continue
|
|
201
|
+
new_findings += 1
|
|
202
|
+
if c.get("severity") == "critical":
|
|
203
|
+
missed_criticals += 1
|
|
204
|
+
return new_findings, missed_criticals
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# --- cascade ----------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def cascade_trigger(served_arm, arms, verified: list, rejected: int,
|
|
210
|
+
truncated: bool, halluc_min: int) -> str | None:
|
|
211
|
+
"""Deterministic danger signs after the served review (spec section 9).
|
|
212
|
+
Returns 'critical' | 'hallucination' | 'truncated' or None. Never fires
|
|
213
|
+
for a top-tier review."""
|
|
214
|
+
if not arms or served_arm.min_score >= arms[-1].min_score:
|
|
215
|
+
return None
|
|
216
|
+
if any(c.get("severity") == "critical" for c in verified):
|
|
217
|
+
return "critical"
|
|
218
|
+
if rejected >= halluc_min:
|
|
219
|
+
return "hallucination"
|
|
220
|
+
if truncated:
|
|
221
|
+
return "truncated"
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# --- reward rollup ----------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def rollup(state: dict, events: list, repo_key: str) -> dict:
|
|
228
|
+
"""Fold ledger events past this repo's cursor into posterior counts
|
|
229
|
+
(spec section 8.3). Pure: returns a NEW state dict; the caller saves.
|
|
230
|
+
The cursor is an event COUNT (the ledger is append-only and
|
|
231
|
+
seq-ordered), so replaying the same list twice is a no-op; a shorter
|
|
232
|
+
list than the cursor (rebuilt/compacted ledger) SKIPS the fold (a
|
|
233
|
+
correct rebuild is cross-repo -- run `aramid autolearn --rebuild`).
|
|
234
|
+
|
|
235
|
+
Primary reward: audit outcomes -> misses/clean on the SERVED arm's
|
|
236
|
+
(band, bucket) cell. Secondary counters (halluc/malformed/refuted/
|
|
237
|
+
survived/overridden) are recorded for reporting but NOT read by
|
|
238
|
+
uplift_pick (spec section 8.3)."""
|
|
239
|
+
out = json.loads(json.dumps(state)) # deep copy; state is JSON-shaped
|
|
240
|
+
cursor = int(out.get("cursors", {}).get(repo_key, 0))
|
|
241
|
+
if cursor > len(events):
|
|
242
|
+
# Shrunk/compacted ledger: a correct rebuild is CROSS-REPO (posteriors
|
|
243
|
+
# aggregate across every registered repo, keyed by arm-cell), so a
|
|
244
|
+
# single per-repo rollup cannot re-fold without double-counting the
|
|
245
|
+
# surviving events onto posteriors that already include them. Skip the
|
|
246
|
+
# fold (leave the cursor as-is); correct counts after a compaction
|
|
247
|
+
# require a global `aramid autolearn --rebuild`. (Was: cursor=0 then
|
|
248
|
+
# re-fold -> posterior double-count.)
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
# Join maps from the FULL stream (a finding's detect event may precede
|
|
252
|
+
# the cursor): llm finding -> its drain run_id -> the served-arm cell.
|
|
253
|
+
run_key: dict[str, str] = {}
|
|
254
|
+
fid_run: dict[str, str] = {}
|
|
255
|
+
for e in events:
|
|
256
|
+
if e.type is EventType.CONSUMER_RUN_FINISHED:
|
|
257
|
+
sel = e.payload.get("selection")
|
|
258
|
+
if isinstance(sel, dict):
|
|
259
|
+
served = sel.get("served") or {}
|
|
260
|
+
band, bucket = sel.get("target_tier"), sel.get("bucket")
|
|
261
|
+
if served.get("provider") and band and bucket:
|
|
262
|
+
run_key[e.run_id] = (f"{served['provider']}/"
|
|
263
|
+
f"{served.get('model', '')}"
|
|
264
|
+
f"|{band}|{bucket}")
|
|
265
|
+
elif (e.type is EventType.FINDING_DETECTED
|
|
266
|
+
and e.payload.get("source") == "llm" and e.finding_id):
|
|
267
|
+
fid_run[e.finding_id] = e.run_id
|
|
268
|
+
|
|
269
|
+
posts = out.setdefault("posteriors", {})
|
|
270
|
+
|
|
271
|
+
def bump(key: str | None, field: str, n: int = 1) -> None:
|
|
272
|
+
if not key or n <= 0:
|
|
273
|
+
return
|
|
274
|
+
rec = posts.setdefault(key, {"misses": 0, "clean": 0, "halluc": 0,
|
|
275
|
+
"malformed": 0, "refuted": 0,
|
|
276
|
+
"survived": 0, "overridden": 0})
|
|
277
|
+
rec[field] = int(rec.get(field, 0)) + n
|
|
278
|
+
|
|
279
|
+
for e in events[cursor:]:
|
|
280
|
+
if e.type is EventType.CONSUMER_RUN_FINISHED:
|
|
281
|
+
sel = e.payload.get("selection")
|
|
282
|
+
if not isinstance(sel, dict):
|
|
283
|
+
continue
|
|
284
|
+
key = run_key.get(e.run_id)
|
|
285
|
+
audit = sel.get("audit")
|
|
286
|
+
if isinstance(audit, dict) and audit.get("performed"):
|
|
287
|
+
out["audits"]["performed"] += 1
|
|
288
|
+
missed = int(audit.get("missed_criticals", 0))
|
|
289
|
+
out["audits"]["missed_criticals"] += missed
|
|
290
|
+
if missed:
|
|
291
|
+
bump(key, "misses", missed)
|
|
292
|
+
else:
|
|
293
|
+
bump(key, "clean")
|
|
294
|
+
bump(key, "halluc", int(sel.get("hallucination_rejected", 0)))
|
|
295
|
+
if sel.get("malformed"):
|
|
296
|
+
bump(key, "malformed")
|
|
297
|
+
for r in sel.get("refutes") or []:
|
|
298
|
+
if not isinstance(r, dict):
|
|
299
|
+
continue
|
|
300
|
+
if r.get("outcome") == "refuted":
|
|
301
|
+
bump(key, "refuted")
|
|
302
|
+
elif r.get("outcome") == "survived":
|
|
303
|
+
bump(key, "survived")
|
|
304
|
+
up = sel.get("uplift")
|
|
305
|
+
if isinstance(up, dict) and up.get("mode") == "shadow":
|
|
306
|
+
out["shadow"]["decisions"] += 1
|
|
307
|
+
if up.get("pick") and up.get("pick") != sel.get("target_tier"):
|
|
308
|
+
out["shadow"]["would_uplift"] += 1
|
|
309
|
+
elif e.type is EventType.FINDING_OVERRIDDEN and e.finding_id in fid_run:
|
|
310
|
+
bump(run_key.get(fid_run[e.finding_id]), "overridden")
|
|
311
|
+
|
|
312
|
+
out.setdefault("cursors", {})[repo_key] = len(events)
|
|
313
|
+
return out
|
aramid/cli.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""cli -- top-level argparse dispatch tree. Each subcommand maps 1:1 to a
|
|
2
|
+
`aramid.commands.<name>.cmd_<name>` function; this module's only job is
|
|
3
|
+
argv parsing and translating parsed args into that function's positional/
|
|
4
|
+
keyword arguments, then returning its int exit code unchanged.
|
|
5
|
+
|
|
6
|
+
Any argparse parse failure -- unknown subcommand, missing required
|
|
7
|
+
argument, bad flag -- normally raises `SystemExit(2)`; that is intercepted
|
|
8
|
+
here and remapped to exit code 3 (design doc section 3's "engine or config
|
|
9
|
+
error" tier), so `python -m aramid bogus-command` and a genuinely crashed
|
|
10
|
+
engine report the same code, never a bare argparse 2. `-h`/`--help`
|
|
11
|
+
(`SystemExit(0)`) and `--version` are the only paths that exit 0 without
|
|
12
|
+
dispatching to a subcommand.
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from aramid import __version__
|
|
19
|
+
from aramid.commands.autolearn_cmd import cmd_autolearn
|
|
20
|
+
from aramid.commands.arm import cmd_arm
|
|
21
|
+
from aramid.commands.check import cmd_check
|
|
22
|
+
from aramid.commands.doctor import cmd_doctor
|
|
23
|
+
from aramid.commands.drain import cmd_drain
|
|
24
|
+
from aramid.commands.init import cmd_init
|
|
25
|
+
from aramid.commands.ledger_cmd import (
|
|
26
|
+
cmd_ledger_filter,
|
|
27
|
+
cmd_ledger_list,
|
|
28
|
+
cmd_ledger_mark_not_a_secret,
|
|
29
|
+
cmd_ledger_mark_rotated,
|
|
30
|
+
cmd_ledger_mark_unreachable,
|
|
31
|
+
cmd_ledger_show,
|
|
32
|
+
)
|
|
33
|
+
from aramid.commands.mutation_score import cmd_mutation_score
|
|
34
|
+
from aramid.commands.override import cmd_override
|
|
35
|
+
from aramid.commands.pack_cmd import cmd_pack_add, cmd_pack_compile, cmd_pack_list
|
|
36
|
+
from aramid.commands.rebaseline import cmd_rebaseline
|
|
37
|
+
from aramid.commands.schedule import cmd_schedule
|
|
38
|
+
from aramid.commands.status import cmd_status
|
|
39
|
+
from aramid.commands.triage_cmd import cmd_triage
|
|
40
|
+
from aramid.commands.uninstall import cmd_uninstall
|
|
41
|
+
from aramid.commands.update_rules import cmd_update_rules
|
|
42
|
+
from aramid.models import Gate
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
46
|
+
p = argparse.ArgumentParser(prog="aramid")
|
|
47
|
+
p.add_argument("--version", action="store_true")
|
|
48
|
+
sub = p.add_subparsers(dest="command")
|
|
49
|
+
|
|
50
|
+
p_init = sub.add_parser("init", help="onboard a repo (write config, install hooks, baseline)")
|
|
51
|
+
p_init.add_argument("path", nargs="?", default=".")
|
|
52
|
+
p_init.add_argument("--discover", action="store_true")
|
|
53
|
+
|
|
54
|
+
p_check = sub.add_parser("check", help="run the gate pipeline")
|
|
55
|
+
p_check.add_argument("--gate", choices=["pre-commit", "pre-push"], default="pre-commit")
|
|
56
|
+
mode = p_check.add_mutually_exclusive_group()
|
|
57
|
+
mode.add_argument("--staged", action="store_true")
|
|
58
|
+
mode.add_argument("--range", action="store_true")
|
|
59
|
+
mode.add_argument("--all", action="store_true")
|
|
60
|
+
p_check.add_argument("--strict", action="store_true", help="CI mode: treat 2/3 as failure")
|
|
61
|
+
p_check.add_argument("--json", action="store_true")
|
|
62
|
+
p_check.add_argument("--accept-degraded", action="store_true")
|
|
63
|
+
p_check.add_argument("--reason", default=None)
|
|
64
|
+
|
|
65
|
+
p_doctor = sub.add_parser("doctor", help="probe/repair the toolchain")
|
|
66
|
+
p_doctor.add_argument("--fix", action="store_true")
|
|
67
|
+
|
|
68
|
+
sub.add_parser("status", help="report ledger/config state")
|
|
69
|
+
|
|
70
|
+
p_ms = sub.add_parser("mutation-score",
|
|
71
|
+
help="advisory per-function mutation-score + regression report")
|
|
72
|
+
p_ms.add_argument("--json", action="store_true")
|
|
73
|
+
|
|
74
|
+
p_triage = sub.add_parser("triage", help="score a commit (or range) and enqueue if risky")
|
|
75
|
+
p_triage.add_argument("rev", nargs="?", default="HEAD")
|
|
76
|
+
p_triage.add_argument("--budget", type=float, default=None,
|
|
77
|
+
help="wall-clock watchdog in seconds; on expiry triage "
|
|
78
|
+
"self-kills with exit 3 (used by the post-commit shim)")
|
|
79
|
+
|
|
80
|
+
p_drain = sub.add_parser("drain", help="sweep registered repos, pop queued items, consume")
|
|
81
|
+
drain_scope = p_drain.add_mutually_exclusive_group()
|
|
82
|
+
drain_scope.add_argument("--all", action="store_true", help="drain every registered repo")
|
|
83
|
+
drain_scope.add_argument("--repo", default=None, help="drain a single repo (default: .)")
|
|
84
|
+
p_drain.add_argument("--dry-run", action="store_true")
|
|
85
|
+
p_drain.add_argument("--max-items", type=int, default=None)
|
|
86
|
+
|
|
87
|
+
p_ledger = sub.add_parser("ledger", help="query the findings ledger")
|
|
88
|
+
ledger_sub = p_ledger.add_subparsers(dest="ledger_command")
|
|
89
|
+
ledger_sub.add_parser("list")
|
|
90
|
+
p_show = ledger_sub.add_parser("show")
|
|
91
|
+
p_show.add_argument("id")
|
|
92
|
+
p_filter = ledger_sub.add_parser("filter")
|
|
93
|
+
p_filter.add_argument("--tool")
|
|
94
|
+
p_filter.add_argument("--rule")
|
|
95
|
+
p_filter.add_argument("--status")
|
|
96
|
+
p_filter.add_argument("--severity")
|
|
97
|
+
p_rotated = ledger_sub.add_parser("mark-rotated")
|
|
98
|
+
p_rotated.add_argument("id")
|
|
99
|
+
p_rotated.add_argument("--reason", required=True)
|
|
100
|
+
p_nas = ledger_sub.add_parser("mark-not-a-secret")
|
|
101
|
+
p_nas.add_argument("id")
|
|
102
|
+
p_nas.add_argument("--reason", required=True)
|
|
103
|
+
p_unreachable = ledger_sub.add_parser("mark-unreachable")
|
|
104
|
+
p_unreachable.add_argument("id")
|
|
105
|
+
p_unreachable.add_argument("--reason", required=True)
|
|
106
|
+
|
|
107
|
+
p_override = sub.add_parser("override", help="suppress a WARN finding (ledger-logged)")
|
|
108
|
+
p_override.add_argument("id")
|
|
109
|
+
p_override.add_argument("--reason", required=True)
|
|
110
|
+
|
|
111
|
+
p_pack = sub.add_parser("pack", help="manage the regression attack pack")
|
|
112
|
+
pack_sub = p_pack.add_subparsers(dest="pack_command")
|
|
113
|
+
pack_sub.add_parser("list")
|
|
114
|
+
p_pack_add = pack_sub.add_parser("add")
|
|
115
|
+
p_pack_add.add_argument("id")
|
|
116
|
+
pack_sub.add_parser("compile")
|
|
117
|
+
|
|
118
|
+
p_autolearn = sub.add_parser("autolearn",
|
|
119
|
+
help="learned model-selection report (--rebuild: replay registry ledgers)")
|
|
120
|
+
p_autolearn.add_argument("--rebuild", action="store_true")
|
|
121
|
+
|
|
122
|
+
p_arm = sub.add_parser("arm", help="end a WARN-only bake (semgrep default, --llm for the LLM reviewer, --autolearn for learned uplift, --tdd for code-without-test findings, --mutation for surviving-mutant findings, --mutation-score for score-regression transitions, --red-proof for never-red test findings)")
|
|
123
|
+
arm_which = p_arm.add_mutually_exclusive_group()
|
|
124
|
+
arm_which.add_argument("--llm", action="store_true")
|
|
125
|
+
arm_which.add_argument("--autolearn", action="store_true")
|
|
126
|
+
arm_which.add_argument("--tdd", action="store_true")
|
|
127
|
+
arm_which.add_argument("--mutation", action="store_true")
|
|
128
|
+
arm_which.add_argument("--mutation-score", action="store_true")
|
|
129
|
+
arm_which.add_argument("--red-proof", action="store_true")
|
|
130
|
+
sub.add_parser("update-rules", help="refresh the vendored semgrep ruleset")
|
|
131
|
+
|
|
132
|
+
p_uninstall = sub.add_parser("uninstall", help="reverse init")
|
|
133
|
+
p_uninstall.add_argument("path", nargs="?", default=".")
|
|
134
|
+
|
|
135
|
+
p_schedule = sub.add_parser("schedule", help="register/remove/query the Windows Task Scheduler drain job")
|
|
136
|
+
p_schedule.add_argument("action", choices=["install", "remove", "status"])
|
|
137
|
+
|
|
138
|
+
p_hooks = sub.add_parser("hooks",
|
|
139
|
+
help="manage the machine-wide git hook template (seeds NEW clones/inits; "
|
|
140
|
+
"the shims no-op in repos without an aramid.toml)")
|
|
141
|
+
p_hooks.add_argument("action", choices=["install", "remove", "status"])
|
|
142
|
+
|
|
143
|
+
p_rebaseline = sub.add_parser("rebaseline",
|
|
144
|
+
help="re-snapshot current findings as the ratchet baseline (after a fingerprint-affecting upgrade)")
|
|
145
|
+
p_rebaseline.add_argument("path", nargs="?", default=".")
|
|
146
|
+
p_rebaseline.add_argument("--yes", action="store_true",
|
|
147
|
+
help="required: confirms discarding current ratchet grandfathering")
|
|
148
|
+
|
|
149
|
+
return p
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _check_mode(args: argparse.Namespace) -> str:
|
|
153
|
+
if args.all:
|
|
154
|
+
return "all"
|
|
155
|
+
if args.range:
|
|
156
|
+
return "range"
|
|
157
|
+
if args.staged:
|
|
158
|
+
return "staged"
|
|
159
|
+
return "staged" if args.gate == "pre-commit" else "range"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main(argv: list[str] | None = None) -> int:
|
|
163
|
+
parser = build_parser()
|
|
164
|
+
try:
|
|
165
|
+
args = parser.parse_args(argv)
|
|
166
|
+
except SystemExit as exc:
|
|
167
|
+
code = exc.code if isinstance(exc.code, int) else 1
|
|
168
|
+
return 0 if code == 0 else 3
|
|
169
|
+
|
|
170
|
+
if args.version:
|
|
171
|
+
print(f"aramid {__version__}")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
if args.command is None:
|
|
175
|
+
print("aramid: no command", file=sys.stderr)
|
|
176
|
+
return 3
|
|
177
|
+
|
|
178
|
+
root = Path.cwd()
|
|
179
|
+
|
|
180
|
+
if args.command == "init":
|
|
181
|
+
return cmd_init(Path(args.path), discover=args.discover)
|
|
182
|
+
|
|
183
|
+
if args.command == "check":
|
|
184
|
+
gate = Gate(args.gate)
|
|
185
|
+
accept_degraded = (args.reason or "no reason given") if args.accept_degraded else None
|
|
186
|
+
return cmd_check(root, gate, _check_mode(args), strict=args.strict,
|
|
187
|
+
as_json=args.json, accept_degraded=accept_degraded)
|
|
188
|
+
|
|
189
|
+
if args.command == "doctor":
|
|
190
|
+
return cmd_doctor(root, fix=args.fix)
|
|
191
|
+
|
|
192
|
+
if args.command == "status":
|
|
193
|
+
return cmd_status(root)
|
|
194
|
+
|
|
195
|
+
if args.command == "mutation-score":
|
|
196
|
+
return cmd_mutation_score(root, as_json=args.json)
|
|
197
|
+
|
|
198
|
+
if args.command == "triage":
|
|
199
|
+
return cmd_triage(root, args.rev, budget=args.budget)
|
|
200
|
+
|
|
201
|
+
if args.command == "drain":
|
|
202
|
+
targets = [] if args.all else ([args.repo] if args.repo else [str(root)])
|
|
203
|
+
return cmd_drain(targets, dry_run=args.dry_run, max_items=args.max_items)
|
|
204
|
+
|
|
205
|
+
if args.command == "ledger":
|
|
206
|
+
if args.ledger_command == "list":
|
|
207
|
+
return cmd_ledger_list(root)
|
|
208
|
+
if args.ledger_command == "show":
|
|
209
|
+
return cmd_ledger_show(root, args.id)
|
|
210
|
+
if args.ledger_command == "filter":
|
|
211
|
+
return cmd_ledger_filter(root, tool=args.tool, rule=args.rule,
|
|
212
|
+
status=args.status, severity=args.severity)
|
|
213
|
+
if args.ledger_command == "mark-rotated":
|
|
214
|
+
return cmd_ledger_mark_rotated(root, args.id, args.reason)
|
|
215
|
+
if args.ledger_command == "mark-not-a-secret":
|
|
216
|
+
return cmd_ledger_mark_not_a_secret(root, args.id, args.reason)
|
|
217
|
+
if args.ledger_command == "mark-unreachable":
|
|
218
|
+
return cmd_ledger_mark_unreachable(root, args.id, args.reason)
|
|
219
|
+
print("aramid: ledger: a subcommand is required "
|
|
220
|
+
"(list|show|filter|mark-rotated|mark-not-a-secret|mark-unreachable)",
|
|
221
|
+
file=sys.stderr)
|
|
222
|
+
return 3
|
|
223
|
+
|
|
224
|
+
if args.command == "override":
|
|
225
|
+
return cmd_override(root, args.id, args.reason)
|
|
226
|
+
|
|
227
|
+
if args.command == "pack":
|
|
228
|
+
if args.pack_command == "list":
|
|
229
|
+
return cmd_pack_list(root)
|
|
230
|
+
if args.pack_command == "add":
|
|
231
|
+
return cmd_pack_add(root, args.id)
|
|
232
|
+
if args.pack_command == "compile":
|
|
233
|
+
return cmd_pack_compile(root)
|
|
234
|
+
print("aramid: pack: a subcommand is required (list|add|compile)",
|
|
235
|
+
file=sys.stderr)
|
|
236
|
+
return 3
|
|
237
|
+
|
|
238
|
+
if args.command == "autolearn":
|
|
239
|
+
return cmd_autolearn(root, rebuild=args.rebuild)
|
|
240
|
+
|
|
241
|
+
if args.command == "arm":
|
|
242
|
+
return cmd_arm(root, llm=args.llm, autolearn=args.autolearn,
|
|
243
|
+
tdd=args.tdd, mutation=args.mutation,
|
|
244
|
+
mutation_score=args.mutation_score,
|
|
245
|
+
red_proof=args.red_proof)
|
|
246
|
+
|
|
247
|
+
if args.command == "update-rules":
|
|
248
|
+
return cmd_update_rules(root)
|
|
249
|
+
|
|
250
|
+
if args.command == "uninstall":
|
|
251
|
+
return cmd_uninstall(Path(args.path))
|
|
252
|
+
|
|
253
|
+
if args.command == "schedule":
|
|
254
|
+
return cmd_schedule(root, args.action)
|
|
255
|
+
|
|
256
|
+
if args.command == "hooks":
|
|
257
|
+
from aramid.commands.hooks_template import cmd_hooks
|
|
258
|
+
return cmd_hooks(args.action)
|
|
259
|
+
|
|
260
|
+
if args.command == "rebaseline":
|
|
261
|
+
return cmd_rebaseline(Path(args.path), yes=args.yes)
|
|
262
|
+
|
|
263
|
+
print(f"aramid: unknown command: {args.command}", file=sys.stderr)
|
|
264
|
+
return 3
|
|
File without changes
|