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.
Files changed (89) hide show
  1. aramid/__init__.py +1 -0
  2. aramid/__main__.py +3 -0
  3. aramid/autolearn.py +313 -0
  4. aramid/cli.py +264 -0
  5. aramid/commands/__init__.py +0 -0
  6. aramid/commands/arm.py +222 -0
  7. aramid/commands/autolearn_cmd.py +76 -0
  8. aramid/commands/check.py +175 -0
  9. aramid/commands/doctor.py +723 -0
  10. aramid/commands/drain.py +270 -0
  11. aramid/commands/hooks_template.py +134 -0
  12. aramid/commands/init.py +472 -0
  13. aramid/commands/ledger_cmd.py +245 -0
  14. aramid/commands/mutation_score.py +65 -0
  15. aramid/commands/override.py +131 -0
  16. aramid/commands/pack_cmd.py +76 -0
  17. aramid/commands/rebaseline.py +46 -0
  18. aramid/commands/schedule.py +262 -0
  19. aramid/commands/status.py +342 -0
  20. aramid/commands/triage_cmd.py +91 -0
  21. aramid/commands/uninstall.py +51 -0
  22. aramid/commands/update_rules.py +52 -0
  23. aramid/config.py +271 -0
  24. aramid/consumers/__init__.py +0 -0
  25. aramid/consumers/base.py +54 -0
  26. aramid/consumers/dast.py +83 -0
  27. aramid/consumers/fuzz.py +221 -0
  28. aramid/consumers/js_mutation.py +236 -0
  29. aramid/consumers/llm_review.py +415 -0
  30. aramid/consumers/mutation.py +285 -0
  31. aramid/consumers/regression_pack.py +51 -0
  32. aramid/dast_probe.py +265 -0
  33. aramid/data/ARAMID.md.tmpl +192 -0
  34. aramid/data/block_rules.toml +30 -0
  35. aramid/data/defaults.toml +219 -0
  36. aramid/detectors.py +250 -0
  37. aramid/diagnostics.py +49 -0
  38. aramid/fingerprint.py +14 -0
  39. aramid/fuzzdriver.py +121 -0
  40. aramid/fuzzgen.py +117 -0
  41. aramid/gitutil.py +153 -0
  42. aramid/hooks.py +522 -0
  43. aramid/jsmutate.py +371 -0
  44. aramid/ledger.py +263 -0
  45. aramid/models.py +84 -0
  46. aramid/mutation.py +109 -0
  47. aramid/mutation_gate.py +103 -0
  48. aramid/mutation_score.py +114 -0
  49. aramid/mutation_score_gate.py +103 -0
  50. aramid/normalizer.py +91 -0
  51. aramid/pack.py +117 -0
  52. aramid/pipeline.py +921 -0
  53. aramid/policy.py +263 -0
  54. aramid/providers/__init__.py +0 -0
  55. aramid/providers/base.py +91 -0
  56. aramid/providers/claude_cli.py +70 -0
  57. aramid/providers/codex_cli.py +99 -0
  58. aramid/providers/ollama_cloud.py +91 -0
  59. aramid/providers/openrouter.py +119 -0
  60. aramid/providers/spend.py +51 -0
  61. aramid/queue.py +137 -0
  62. aramid/red_proof.py +310 -0
  63. aramid/redact.py +26 -0
  64. aramid/registry.py +48 -0
  65. aramid/reporter.py +106 -0
  66. aramid/review.py +519 -0
  67. aramid/rules/owasp.yml +362 -0
  68. aramid/runners/__init__.py +0 -0
  69. aramid/runners/_util.py +96 -0
  70. aramid/runners/base.py +217 -0
  71. aramid/runners/clippy.py +326 -0
  72. aramid/runners/deps.py +656 -0
  73. aramid/runners/eslint.py +136 -0
  74. aramid/runners/gitleaks.py +105 -0
  75. aramid/runners/ruff.py +93 -0
  76. aramid/runners/semgrep.py +189 -0
  77. aramid/runners/tests.py +471 -0
  78. aramid/runners/typecheck.py +185 -0
  79. aramid/tdd.py +115 -0
  80. aramid/tests_gate.py +74 -0
  81. aramid/toolpath.py +92 -0
  82. aramid/toolset.py +159 -0
  83. aramid/triage.py +185 -0
  84. aramid-0.2.0.dist-info/METADATA +426 -0
  85. aramid-0.2.0.dist-info/RECORD +89 -0
  86. aramid-0.2.0.dist-info/WHEEL +5 -0
  87. aramid-0.2.0.dist-info/entry_points.txt +2 -0
  88. aramid-0.2.0.dist-info/licenses/LICENSE +21 -0
  89. 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
@@ -0,0 +1,3 @@
1
+ import sys
2
+ from aramid.cli import main
3
+ sys.exit(main())
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