qaas-python 0.1.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.
- qaas/adapters/__init__.py +19 -0
- qaas/adapters/tracker.py +1350 -0
- qaas/adapters/vcs.py +494 -0
- qaas/cli.py +1564 -0
- qaas/conductor.py +527 -0
- qaas/config.py +407 -0
- qaas/defaults/config/agents/arbiter.yaml +19 -0
- qaas/defaults/config/agents/cartographer.yaml +20 -0
- qaas/defaults/config/agents/clerk.yaml +21 -0
- qaas/defaults/config/agents/conduit.yaml +19 -0
- qaas/defaults/config/agents/forge.yaml +22 -0
- qaas/defaults/config/agents/mender.yaml +56 -0
- qaas/defaults/config/agents/proof.yaml +21 -0
- qaas/defaults/config/agents/surface.yaml +16 -0
- qaas/defaults/config/system.yaml +69 -0
- qaas/discover.py +227 -0
- qaas/envelope.py +290 -0
- qaas/guardrails.py +431 -0
- qaas/mcp/__init__.py +0 -0
- qaas/mcp/context.py +70 -0
- qaas/mcp/contract_diff.py +937 -0
- qaas/mcp/defect_memory.py +495 -0
- qaas/mcp/env_control.py +905 -0
- qaas/mcp/envelope_server.py +463 -0
- qaas/mcp/test_runner.py +773 -0
- qaas/mcp/tracker.py +412 -0
- qaas/mcp/vcs.py +506 -0
- qaas/paths.py +317 -0
- qaas/plugin/.claude-plugin/plugin.json +9 -0
- qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
- qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
- qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
- qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
- qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
- qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
- qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
- qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
- qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
- qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
- qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
- qaas/plugin/skills/flake-detection/SKILL.md +39 -0
- qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
- qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
- qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
- qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
- qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
- qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
- qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
- qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
- qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
- qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
- qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
- qaas/plugin/skills/routing-rules/SKILL.md +34 -0
- qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
- qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
- qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
- qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
- qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
- qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
- qaas/prompts/ARBITER.md +53 -0
- qaas/prompts/CARTOGRAPHER.md +46 -0
- qaas/prompts/CLERK.md +45 -0
- qaas/prompts/CONDUIT.md +44 -0
- qaas/prompts/FORGE.md +43 -0
- qaas/prompts/MENDER.md +55 -0
- qaas/prompts/PROOF.md +41 -0
- qaas/prompts/SURFACE.md +46 -0
- qaas/prompts/_shared.md +45 -0
- qaas/registry.py +465 -0
- qaas/runner.py +192 -0
- qaas/scorecard.py +425 -0
- qaas/sdk_compat.py +52 -0
- qaas/store.py +290 -0
- qaas/target.py +261 -0
- qaas/tasks.py +361 -0
- qaas/trace.py +270 -0
- qaas_python-0.1.0.dist-info/METADATA +388 -0
- qaas_python-0.1.0.dist-info/RECORD +81 -0
- qaas_python-0.1.0.dist-info/WHEEL +4 -0
- qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
- qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
qaas/scorecard.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Scoring a run against the golden ledger.
|
|
2
|
+
|
|
3
|
+
This is the module that keeps the project honest. Everything else in the system
|
|
4
|
+
can look like it works — agents run, envelopes appear, tickets get filed — while
|
|
5
|
+
the findings are noise. The scorecard is what says otherwise, in numbers.
|
|
6
|
+
|
|
7
|
+
Matching is deliberately deterministic. Using a model to judge whether a finding
|
|
8
|
+
matches a seeded defect would make the score depend on the same class of system
|
|
9
|
+
being measured, and a generous judge would flatter the result exactly when the
|
|
10
|
+
result least deserves it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Iterable
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
from qaas.envelope import DefectEnvelope, normalize_path, Severity
|
|
23
|
+
|
|
24
|
+
MATCH_THRESHOLD = 0.5
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class GoldenDefect:
|
|
29
|
+
"""One seeded defect, as recorded in target-app/defects.yaml."""
|
|
30
|
+
|
|
31
|
+
id: str
|
|
32
|
+
domain: str
|
|
33
|
+
defect_class: str
|
|
34
|
+
severity: str
|
|
35
|
+
title: str
|
|
36
|
+
detail: str
|
|
37
|
+
endpoint: str | None = None
|
|
38
|
+
ui_route: str | None = None
|
|
39
|
+
paths: tuple[str, ...] = ()
|
|
40
|
+
keywords: tuple[str, ...] = ()
|
|
41
|
+
phase: int = 1
|
|
42
|
+
security_relevant: bool = False
|
|
43
|
+
# True for defects the system found rather than ones placed for it to find.
|
|
44
|
+
# They still count, but they are weaker evidence: the ledger is a floor on
|
|
45
|
+
# what exists in the app, never a complete oracle.
|
|
46
|
+
discovered_not_seeded: bool = False
|
|
47
|
+
#: The ref a fix for this defect landed on, once one has. Retires the entry
|
|
48
|
+
#: from the recall denominator without deleting it -- ARBITER escalated a
|
|
49
|
+
#: correct fix because the schema had no way to say this, and CLAUDE.md
|
|
50
|
+
#: requires the ledger to change in the same commit as the defect. Deleting
|
|
51
|
+
#: the entry instead would lose the severity and domain expectations that
|
|
52
|
+
#: make a past score reproducible.
|
|
53
|
+
fixed_in: str | None = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def retired(self) -> bool:
|
|
57
|
+
"""Fixed, so no longer expected to be present. Not scored for recall."""
|
|
58
|
+
return self.fixed_in is not None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class PlantedNonDefect:
|
|
63
|
+
"""Correct behaviour that looks wrong. Reporting one is a false positive."""
|
|
64
|
+
|
|
65
|
+
id: str
|
|
66
|
+
title: str
|
|
67
|
+
why_correct: str
|
|
68
|
+
endpoint: str | None = None
|
|
69
|
+
ui_route: str | None = None
|
|
70
|
+
paths: tuple[str, ...] = ()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class GoldenLedger:
|
|
75
|
+
defects: list[GoldenDefect]
|
|
76
|
+
not_defects: list[PlantedNonDefect]
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def load(cls, path: Path | str) -> "GoldenLedger":
|
|
80
|
+
raw = yaml.safe_load(Path(path).read_text())
|
|
81
|
+
return cls(
|
|
82
|
+
defects=[_golden(d) for d in raw.get("defects", [])],
|
|
83
|
+
not_defects=[_planted(d) for d in raw.get("not_defects", [])],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def for_phase(self, phase: int) -> list[GoldenDefect]:
|
|
87
|
+
return [d for d in self.defects if d.phase <= phase]
|
|
88
|
+
|
|
89
|
+
def by_id(self, defect_id: str) -> GoldenDefect | None:
|
|
90
|
+
return next((d for d in self.defects if d.id == defect_id), None)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _golden(d: dict[str, Any]) -> GoldenDefect:
|
|
94
|
+
loc = d.get("location", {}) or {}
|
|
95
|
+
return GoldenDefect(
|
|
96
|
+
id=d["id"],
|
|
97
|
+
domain=d["domain"],
|
|
98
|
+
defect_class=d.get("class", "bug"),
|
|
99
|
+
severity=d["severity"],
|
|
100
|
+
title=d["title"],
|
|
101
|
+
detail=d.get("detail", ""),
|
|
102
|
+
endpoint=loc.get("endpoint"),
|
|
103
|
+
ui_route=loc.get("ui_route"),
|
|
104
|
+
paths=tuple(loc.get("paths", ())),
|
|
105
|
+
keywords=tuple(d.get("keywords", ())),
|
|
106
|
+
phase=int(d.get("phase", 1)),
|
|
107
|
+
security_relevant=bool(d.get("security_relevant", False)),
|
|
108
|
+
discovered_not_seeded=bool(d.get("discovered_not_seeded", False)),
|
|
109
|
+
fixed_in=(str(d["fixed_in"]) if d.get("fixed_in") else None),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _planted(d: dict[str, Any]) -> PlantedNonDefect:
|
|
114
|
+
loc = d.get("location", {}) or {}
|
|
115
|
+
return PlantedNonDefect(
|
|
116
|
+
id=d["id"],
|
|
117
|
+
title=d["title"],
|
|
118
|
+
why_correct=d.get("why_correct", ""),
|
|
119
|
+
endpoint=loc.get("endpoint"),
|
|
120
|
+
ui_route=loc.get("ui_route"),
|
|
121
|
+
paths=tuple(loc.get("paths", ())),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# -- similarity -------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _norm_endpoint(endpoint: str | None) -> str | None:
|
|
129
|
+
"""`GET /v1/orders/{order_id}` and `get /v1/orders/{id}` are the same endpoint."""
|
|
130
|
+
if not endpoint:
|
|
131
|
+
return None
|
|
132
|
+
e = re.sub(r"\{[^}]*\}", "{}", endpoint.strip().lower())
|
|
133
|
+
return re.sub(r"\s+", " ", e)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _norm_path(path: str) -> str:
|
|
137
|
+
"""Compare by tail, so `target-app/api/app/routes/orders.py:88` matches `api/app/routes/orders.py`.
|
|
138
|
+
|
|
139
|
+
Delegates to the envelope's own normalisation rather than restating it. The
|
|
140
|
+
two were separate implementations and drifted: the scorer handled `:104-112`
|
|
141
|
+
line ranges and the repo-root prefix, the fingerprint handled neither, so a
|
|
142
|
+
defect the scorer counted as one thing hashed as three.
|
|
143
|
+
"""
|
|
144
|
+
return normalize_path(path)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _path_overlap(a: Iterable[str], b: Iterable[str]) -> float:
|
|
148
|
+
"""Fraction of the golden defect's files the report also names."""
|
|
149
|
+
golden = {_norm_path(p) for p in b}
|
|
150
|
+
if not golden:
|
|
151
|
+
return 0.0
|
|
152
|
+
reported = {_norm_path(p) for p in a}
|
|
153
|
+
hits = sum(
|
|
154
|
+
1
|
|
155
|
+
for g in golden
|
|
156
|
+
if any(r == g or r.endswith("/" + g) or g.endswith("/" + r) for r in reported)
|
|
157
|
+
)
|
|
158
|
+
return hits / len(golden)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _keyword_overlap(text: str, keywords: Iterable[str]) -> float:
|
|
162
|
+
terms = list(keywords)
|
|
163
|
+
if not terms:
|
|
164
|
+
return 0.0
|
|
165
|
+
blob = text.lower()
|
|
166
|
+
return sum(1 for t in terms if t.lower() in blob) / len(terms)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# Domains that describe the same surface. An agent choosing either one has
|
|
170
|
+
# classified the defect defensibly, so scoring must accept both.
|
|
171
|
+
#
|
|
172
|
+
# Both entries were learned from real runs, and both times the scorer was wrong
|
|
173
|
+
# rather than the agent: CONDUIT filed a cross-tenant read under `security`, and
|
|
174
|
+
# SURFACE filed a missing label and a contrast failure under `ux`. Marking those
|
|
175
|
+
# as misses would have hidden a perfect discovery run behind a 50% score.
|
|
176
|
+
_EQUIVALENT_DOMAINS: dict[str, set[str]] = {
|
|
177
|
+
"ux": {"frontend"},
|
|
178
|
+
"frontend": {"ux"},
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _domains_compatible(reported: str, golden: GoldenDefect) -> bool:
|
|
183
|
+
"""Whether a reported domain is an acceptable classification of this defect.
|
|
184
|
+
|
|
185
|
+
Domain stays a gate — naming the right file under a genuinely wrong domain
|
|
186
|
+
means the defect was misunderstood — but the gate accepts any defensible
|
|
187
|
+
reading, not only the one the ledger happened to write down.
|
|
188
|
+
"""
|
|
189
|
+
if reported == golden.domain:
|
|
190
|
+
return True
|
|
191
|
+
if reported == "security" and golden.security_relevant:
|
|
192
|
+
return True
|
|
193
|
+
return golden.domain in _EQUIVALENT_DOMAINS.get(reported, set())
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def similarity(env: DefectEnvelope, golden: GoldenDefect) -> float:
|
|
197
|
+
"""0..1 confidence that `env` reports `golden`."""
|
|
198
|
+
if not _domains_compatible(env.domain.value, golden):
|
|
199
|
+
return 0.0
|
|
200
|
+
|
|
201
|
+
text = f"{env.title} {env.summary} {env.suggested_fix_area}"
|
|
202
|
+
|
|
203
|
+
endpoint_match = (
|
|
204
|
+
_norm_endpoint(env.location.endpoint) is not None
|
|
205
|
+
and _norm_endpoint(env.location.endpoint) == _norm_endpoint(golden.endpoint)
|
|
206
|
+
)
|
|
207
|
+
route_match = (
|
|
208
|
+
env.location.ui_route is not None
|
|
209
|
+
and golden.ui_route is not None
|
|
210
|
+
and env.location.ui_route.rstrip("/") == golden.ui_route.rstrip("/")
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# A cross-cutting defect has no single endpoint or route to anchor on, and
|
|
214
|
+
# the files that best demonstrate it are a judgement call — a report of
|
|
215
|
+
# inconsistent error shapes may cite whichever two handlers differ. For
|
|
216
|
+
# those, the prose has to carry the identification.
|
|
217
|
+
anchored = bool(golden.endpoint or golden.ui_route)
|
|
218
|
+
weights = (0.45, 0.30, 0.35) if anchored else (0.0, 0.35, 0.65)
|
|
219
|
+
w_location, w_paths, w_keywords = weights
|
|
220
|
+
|
|
221
|
+
score = w_location if (endpoint_match or route_match) else 0.0
|
|
222
|
+
score += w_paths * _path_overlap(env.location.paths, golden.paths)
|
|
223
|
+
score += w_keywords * _keyword_overlap(text, golden.keywords)
|
|
224
|
+
return min(score, 1.0)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def resembles_planted(env: DefectEnvelope, planted: PlantedNonDefect) -> float:
|
|
228
|
+
"""Whether a finding is a report of deliberately-correct behaviour.
|
|
229
|
+
|
|
230
|
+
This needs a real anchor — the same endpoint or the same route. Sharing a
|
|
231
|
+
file is not enough: `orders.py` holds six seeded defects as well as the
|
|
232
|
+
deliberately-correct legacy handler, so a path-only rule blamed agents for
|
|
233
|
+
reporting the legacy endpoint when they had reported something else entirely.
|
|
234
|
+
"""
|
|
235
|
+
if planted.endpoint and _norm_endpoint(env.location.endpoint) == _norm_endpoint(planted.endpoint):
|
|
236
|
+
return 1.0
|
|
237
|
+
if planted.ui_route and env.location.ui_route == planted.ui_route:
|
|
238
|
+
return 1.0
|
|
239
|
+
return 0.0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# -- results ----------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@dataclass
|
|
246
|
+
class Match:
|
|
247
|
+
golden_id: str
|
|
248
|
+
envelope_id: str
|
|
249
|
+
score: float
|
|
250
|
+
reported_severity: str
|
|
251
|
+
expected_severity: str
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def severity_delta(self) -> int:
|
|
255
|
+
"""Ranks apart. 0 is agreement, positive means the report was too calm."""
|
|
256
|
+
return Severity(self.reported_severity).rank - Severity(self.expected_severity).rank
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@dataclass
|
|
260
|
+
class Scorecard:
|
|
261
|
+
matches: list[Match] = field(default_factory=list)
|
|
262
|
+
missed: list[str] = field(default_factory=list)
|
|
263
|
+
false_positives: list[str] = field(default_factory=list)
|
|
264
|
+
duplicates: list[str] = field(default_factory=list)
|
|
265
|
+
regressions_on_planted: list[tuple[str, str]] = field(default_factory=list)
|
|
266
|
+
#: Findings that matched a defect already marked `fixed_in`. Neither a find
|
|
267
|
+
#: nor a false positive: the report is correct wherever the fix has not
|
|
268
|
+
#: landed, so scoring it either way would be a lie about the run.
|
|
269
|
+
retired_hits: list[tuple[str, str]] = field(default_factory=list)
|
|
270
|
+
total_golden: int = 0
|
|
271
|
+
total_envelopes: int = 0
|
|
272
|
+
cost_usd: float = 0.0
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def recall(self) -> float:
|
|
276
|
+
return len(self.matches) / self.total_golden if self.total_golden else 0.0
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def precision(self) -> float:
|
|
280
|
+
judged = len(self.matches) + len(self.false_positives)
|
|
281
|
+
return len(self.matches) / judged if judged else 0.0
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def false_positive_rate(self) -> float:
|
|
285
|
+
return len(self.false_positives) / self.total_envelopes if self.total_envelopes else 0.0
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def duplicate_rate(self) -> float:
|
|
289
|
+
return len(self.duplicates) / self.total_envelopes if self.total_envelopes else 0.0
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def severity_agreement(self) -> float:
|
|
293
|
+
"""Share of matches scored within one rank of the expected severity."""
|
|
294
|
+
if not self.matches:
|
|
295
|
+
return 0.0
|
|
296
|
+
return sum(1 for m in self.matches if abs(m.severity_delta) <= 1) / len(self.matches)
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def cost_per_accepted(self) -> float | None:
|
|
300
|
+
return self.cost_usd / len(self.matches) if self.matches else None
|
|
301
|
+
|
|
302
|
+
def summary(self) -> dict[str, Any]:
|
|
303
|
+
return {
|
|
304
|
+
"found": len(self.matches),
|
|
305
|
+
"of": self.total_golden,
|
|
306
|
+
"recall": round(self.recall, 3),
|
|
307
|
+
"precision": round(self.precision, 3),
|
|
308
|
+
"false_positives": len(self.false_positives),
|
|
309
|
+
"false_positive_rate": round(self.false_positive_rate, 3),
|
|
310
|
+
"duplicates": len(self.duplicates),
|
|
311
|
+
"duplicate_rate": round(self.duplicate_rate, 3),
|
|
312
|
+
"severity_agreement": round(self.severity_agreement, 3),
|
|
313
|
+
"planted_misreported": len(self.regressions_on_planted),
|
|
314
|
+
"retired_hits": len(self.retired_hits),
|
|
315
|
+
"cost_usd": round(self.cost_usd, 4),
|
|
316
|
+
"cost_per_accepted": (
|
|
317
|
+
round(self.cost_per_accepted, 4) if self.cost_per_accepted is not None else None
|
|
318
|
+
),
|
|
319
|
+
"missed": sorted(self.missed),
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def score(
|
|
324
|
+
envelopes: list[DefectEnvelope],
|
|
325
|
+
ledger: GoldenLedger,
|
|
326
|
+
*,
|
|
327
|
+
phase: int = 1,
|
|
328
|
+
domains: set[str] | None = None,
|
|
329
|
+
cost_usd: float = 0.0,
|
|
330
|
+
threshold: float = MATCH_THRESHOLD,
|
|
331
|
+
) -> Scorecard:
|
|
332
|
+
"""Match findings to seeded defects, best pair first.
|
|
333
|
+
|
|
334
|
+
Assignment is one-to-one and greedy on the strongest pair remaining. A second
|
|
335
|
+
envelope for an already-matched defect is a duplicate, not a second find —
|
|
336
|
+
counting it as a find would reward exactly the ticket-spam this system is
|
|
337
|
+
built to avoid.
|
|
338
|
+
"""
|
|
339
|
+
in_scope = [d for d in ledger.for_phase(phase) if domains is None or d.domain in domains]
|
|
340
|
+
# A defect marked `fixed_in` is still matched -- so a report of it is not
|
|
341
|
+
# written off as a false positive -- but it leaves the recall denominator.
|
|
342
|
+
# Counting a repaired defect as a miss on every future run is exactly the
|
|
343
|
+
# silent corruption CLAUDE.md warns about.
|
|
344
|
+
golden = [d for d in in_scope if not d.retired]
|
|
345
|
+
retired = {d.id for d in in_scope if d.retired}
|
|
346
|
+
matchable = in_scope
|
|
347
|
+
card = Scorecard(total_golden=len(golden), total_envelopes=len(envelopes), cost_usd=cost_usd)
|
|
348
|
+
|
|
349
|
+
pairs = sorted(
|
|
350
|
+
(
|
|
351
|
+
(similarity(env, g), env, g)
|
|
352
|
+
for env in envelopes
|
|
353
|
+
for g in matchable
|
|
354
|
+
if similarity(env, g) >= threshold
|
|
355
|
+
),
|
|
356
|
+
key=lambda t: -t[0],
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
claimed_golden: set[str] = set()
|
|
360
|
+
claimed_env: set[str] = set()
|
|
361
|
+
contested: list[tuple[float, DefectEnvelope, GoldenDefect]] = []
|
|
362
|
+
|
|
363
|
+
# First pass: settle the unambiguous pairs, strongest first.
|
|
364
|
+
for sim, env, g in pairs:
|
|
365
|
+
if g.id in claimed_golden or env.id in claimed_env:
|
|
366
|
+
contested.append((sim, env, g))
|
|
367
|
+
continue
|
|
368
|
+
claimed_golden.add(g.id)
|
|
369
|
+
claimed_env.add(env.id)
|
|
370
|
+
if g.id in retired:
|
|
371
|
+
card.retired_hits.append((env.id, g.id))
|
|
372
|
+
else:
|
|
373
|
+
card.matches.append(
|
|
374
|
+
Match(
|
|
375
|
+
golden_id=g.id,
|
|
376
|
+
envelope_id=env.id,
|
|
377
|
+
score=round(sim, 3),
|
|
378
|
+
reported_severity=env.severity.value,
|
|
379
|
+
expected_severity=g.severity,
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# Second pass: an envelope whose best match was taken gets its next-best
|
|
384
|
+
# before anything else. Branding it a duplicate here was a real bug — two
|
|
385
|
+
# defects in one file on one route (a missing empty state and an unhandled
|
|
386
|
+
# rejection, both on /orders in OrdersList.tsx) each match the other's
|
|
387
|
+
# golden entry, so whichever lost the first pass was written off entirely.
|
|
388
|
+
# Only an envelope with no unclaimed match left is genuinely a duplicate.
|
|
389
|
+
for sim, env, g in contested:
|
|
390
|
+
if env.id in claimed_env:
|
|
391
|
+
continue
|
|
392
|
+
if g.id not in claimed_golden:
|
|
393
|
+
claimed_golden.add(g.id)
|
|
394
|
+
claimed_env.add(env.id)
|
|
395
|
+
if g.id in retired:
|
|
396
|
+
card.retired_hits.append((env.id, g.id))
|
|
397
|
+
else:
|
|
398
|
+
card.matches.append(
|
|
399
|
+
Match(
|
|
400
|
+
golden_id=g.id,
|
|
401
|
+
envelope_id=env.id,
|
|
402
|
+
score=round(sim, 3),
|
|
403
|
+
reported_severity=env.severity.value,
|
|
404
|
+
expected_severity=g.severity,
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
for sim, env, g in contested:
|
|
409
|
+
if env.id not in claimed_env:
|
|
410
|
+
card.duplicates.append(env.id)
|
|
411
|
+
claimed_env.add(env.id)
|
|
412
|
+
|
|
413
|
+
card.missed = [g.id for g in golden if g.id not in claimed_golden]
|
|
414
|
+
|
|
415
|
+
for env in envelopes:
|
|
416
|
+
if env.id in claimed_env:
|
|
417
|
+
continue
|
|
418
|
+
card.false_positives.append(env.id)
|
|
419
|
+
for planted in ledger.not_defects:
|
|
420
|
+
if resembles_planted(env, planted) >= MATCH_THRESHOLD:
|
|
421
|
+
card.regressions_on_planted.append((env.id, planted.id))
|
|
422
|
+
break
|
|
423
|
+
|
|
424
|
+
card.matches.sort(key=lambda m: m.golden_id)
|
|
425
|
+
return card
|
qaas/sdk_compat.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Pinned facts about the installed Claude Agent SDK.
|
|
2
|
+
|
|
3
|
+
These were wrong in the published docs at the time of writing: the docs give
|
|
4
|
+
hook events as camelCase (`preToolUse`) and `HookMatcher(event=, handler=)`,
|
|
5
|
+
while the installed SDK uses PascalCase events and `HookMatcher(matcher=, hooks=)`.
|
|
6
|
+
Rather than trust either, this module reads the truth out of the installed
|
|
7
|
+
package at import time and fails loudly if it changes under us.
|
|
8
|
+
|
|
9
|
+
Guardrails do not depend on hooks working (they live in `can_use_tool`), so a
|
|
10
|
+
drift here degrades observability, not enforcement — but it should still be a
|
|
11
|
+
noisy failure rather than a silent one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import typing
|
|
17
|
+
|
|
18
|
+
from claude_agent_sdk import types as _sdk_types
|
|
19
|
+
|
|
20
|
+
_EVENTS: tuple[str, ...] = tuple(
|
|
21
|
+
typing.get_args(arg)[0] for arg in typing.get_args(_sdk_types.HookEvent)
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
PRE_TOOL_USE = "PreToolUse"
|
|
25
|
+
POST_TOOL_USE = "PostToolUse"
|
|
26
|
+
SUBAGENT_START = "SubagentStart"
|
|
27
|
+
STOP = "Stop"
|
|
28
|
+
|
|
29
|
+
_REQUIRED = (PRE_TOOL_USE, POST_TOOL_USE, STOP)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check() -> None:
|
|
33
|
+
"""Raise if the SDK no longer exposes the hook events we register."""
|
|
34
|
+
missing = [e for e in _REQUIRED if e not in _EVENTS]
|
|
35
|
+
if missing:
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
f"claude-agent-sdk no longer exposes hook events {missing}; "
|
|
38
|
+
f"it offers {list(_EVENTS)}. Update qaas.sdk_compat and guardrails."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def mcp_tool_name(server: str, tool: str) -> str:
|
|
43
|
+
"""The name an MCP tool is exposed under: `mcp__<server>__<tool>`."""
|
|
44
|
+
return f"mcp__{server}__{tool}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def mcp_server_wildcard(server: str) -> str:
|
|
48
|
+
"""Allowlist pattern covering every tool on one server."""
|
|
49
|
+
return f"mcp__{server}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
check()
|