qaas-python 0.0.1__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 (96) hide show
  1. qaas/adapters/__init__.py +19 -0
  2. qaas/adapters/tracker.py +1783 -0
  3. qaas/adapters/vcs.py +555 -0
  4. qaas/cli.py +1757 -0
  5. qaas/config.py +409 -0
  6. qaas/defaults/config/agents/api.yaml +18 -0
  7. qaas/defaults/config/agents/architect.yaml +21 -0
  8. qaas/defaults/config/agents/auditor.yaml +19 -0
  9. qaas/defaults/config/agents/browser.yaml +15 -0
  10. qaas/defaults/config/agents/dba.yaml +20 -0
  11. qaas/defaults/config/agents/fixer.yaml +55 -0
  12. qaas/defaults/config/agents/guide.yaml +23 -0
  13. qaas/defaults/config/agents/load.yaml +26 -0
  14. qaas/defaults/config/agents/mapper.yaml +19 -0
  15. qaas/defaults/config/agents/reporter.yaml +19 -0
  16. qaas/defaults/config/agents/reproducer.yaml +21 -0
  17. qaas/defaults/config/agents/reviewer.yaml +18 -0
  18. qaas/defaults/config/agents/socket.yaml +23 -0
  19. qaas/defaults/config/agents/triage.yaml +20 -0
  20. qaas/defaults/config/agents/verifier.yaml +20 -0
  21. qaas/defaults/config/system.yaml +64 -0
  22. qaas/discover.py +242 -0
  23. qaas/envelope.py +318 -0
  24. qaas/envfile.py +100 -0
  25. qaas/guardrails.py +589 -0
  26. qaas/mcp/__init__.py +0 -0
  27. qaas/mcp/context.py +78 -0
  28. qaas/mcp/contract_diff.py +1011 -0
  29. qaas/mcp/defect_memory.py +495 -0
  30. qaas/mcp/env_control.py +925 -0
  31. qaas/mcp/envelope_server.py +463 -0
  32. qaas/mcp/test_runner.py +842 -0
  33. qaas/mcp/tracker.py +420 -0
  34. qaas/mcp/vcs.py +501 -0
  35. qaas/paths.py +317 -0
  36. qaas/plugin/.claude-plugin/plugin.json +9 -0
  37. qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
  38. qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
  39. qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
  40. qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
  41. qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
  42. qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
  43. qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
  44. qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
  45. qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
  46. qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
  47. qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
  48. qaas/plugin/skills/flake-detection/SKILL.md +39 -0
  49. qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
  50. qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
  51. qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
  52. qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
  53. qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
  54. qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
  55. qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
  56. qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
  57. qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
  58. qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
  59. qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
  60. qaas/plugin/skills/routing-rules/SKILL.md +34 -0
  61. qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
  62. qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
  63. qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
  64. qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
  65. qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
  66. qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
  67. qaas/prompts/API.md +44 -0
  68. qaas/prompts/ARCHITECT.md +80 -0
  69. qaas/prompts/AUDITOR.md +62 -0
  70. qaas/prompts/BROWSER.md +46 -0
  71. qaas/prompts/DBA.md +59 -0
  72. qaas/prompts/FIXER.md +55 -0
  73. qaas/prompts/GUIDE.md +94 -0
  74. qaas/prompts/LOAD.md +109 -0
  75. qaas/prompts/MAPPER.md +46 -0
  76. qaas/prompts/REPORTER.md +61 -0
  77. qaas/prompts/REPRODUCER.md +43 -0
  78. qaas/prompts/REVIEWER.md +53 -0
  79. qaas/prompts/SOCKET.md +100 -0
  80. qaas/prompts/TRIAGE.md +45 -0
  81. qaas/prompts/VERIFIER.md +41 -0
  82. qaas/prompts/_shared.md +45 -0
  83. qaas/registry.py +496 -0
  84. qaas/router.py +581 -0
  85. qaas/runner.py +210 -0
  86. qaas/scorecard.py +448 -0
  87. qaas/sdk_compat.py +52 -0
  88. qaas/store.py +323 -0
  89. qaas/target.py +287 -0
  90. qaas/tasks.py +438 -0
  91. qaas/trace.py +342 -0
  92. qaas_python-0.0.1.dist-info/METADATA +429 -0
  93. qaas_python-0.0.1.dist-info/RECORD +96 -0
  94. qaas_python-0.0.1.dist-info/WHEEL +4 -0
  95. qaas_python-0.0.1.dist-info/entry_points.txt +2 -0
  96. qaas_python-0.0.1.dist-info/licenses/LICENSE +21 -0
qaas/envelope.py ADDED
@@ -0,0 +1,318 @@
1
+ """The DefectEnvelope — the one contract every agent speaks (architecture §6).
2
+
3
+ Validate on write and on read; reject malformed envelopes rather than repairing
4
+ them. Agents never pass prose to each other, only these.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import re
11
+ import uuid
12
+ from datetime import datetime, timezone
13
+ from enum import StrEnum
14
+ from typing import Annotated, Any, Literal
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
17
+
18
+ ENVELOPE_VERSION = "1.0"
19
+
20
+
21
+ class Domain(StrEnum):
22
+ ARCHITECTURE = "architecture"
23
+ DATABASE = "database"
24
+ API = "api"
25
+ WEBSOCKET = "websocket"
26
+ FRONTEND = "frontend"
27
+ UX = "ux"
28
+ SECURITY = "security"
29
+ PERFORMANCE = "performance"
30
+
31
+
32
+ class DefectClass(StrEnum):
33
+ BUG = "bug"
34
+ REGRESSION = "regression"
35
+ UX_FRICTION = "ux-friction"
36
+ TECH_DEBT = "tech-debt"
37
+ VULNERABILITY = "vulnerability"
38
+ PERF_REGRESSION = "perf-regression"
39
+
40
+
41
+ class Severity(StrEnum):
42
+ BLOCKER = "blocker"
43
+ CRITICAL = "critical"
44
+ MAJOR = "major"
45
+ MINOR = "minor"
46
+ TRIVIAL = "trivial"
47
+
48
+ @property
49
+ def rank(self) -> int:
50
+ """0 is most severe. Lets callers compare and sort without a lookup table."""
51
+ return _SEVERITY_ORDER.index(self)
52
+
53
+
54
+ _SEVERITY_ORDER = [
55
+ Severity.BLOCKER,
56
+ Severity.CRITICAL,
57
+ Severity.MAJOR,
58
+ Severity.MINOR,
59
+ Severity.TRIVIAL,
60
+ ]
61
+
62
+
63
+ class ReproStatus(StrEnum):
64
+ REPRODUCED = "reproduced"
65
+ FLAKY = "flaky"
66
+ NOT_REPRODUCIBLE = "not_reproducible"
67
+ UNATTEMPTED = "unattempted"
68
+
69
+
70
+ class EvidenceType(StrEnum):
71
+ SCREENSHOT = "screenshot"
72
+ TRACE = "trace"
73
+ QUERY_PLAN = "query_plan"
74
+ LOG = "log"
75
+ FRAME_CAPTURE = "frame_capture"
76
+ TEST_OUTPUT = "test_output"
77
+ HAR = "har"
78
+
79
+
80
+ class Strict(BaseModel):
81
+ """Base for every envelope part: unknown fields are an error, not a shrug."""
82
+
83
+ model_config = ConfigDict(extra="forbid", use_enum_values=False)
84
+
85
+
86
+ class Location(Strict):
87
+ service: str | None = None
88
+ paths: list[str] = Field(default_factory=list)
89
+ endpoint: str | None = None
90
+ ui_route: str | None = None
91
+ commit_sha: str | None = None
92
+
93
+
94
+ class Evidence(Strict):
95
+ type: EvidenceType
96
+ uri: str
97
+ note: str = ""
98
+
99
+ @field_validator("uri")
100
+ @classmethod
101
+ def _known_scheme(cls, v: str) -> str:
102
+ if not re.match(r"^(artifact|file|https?)://", v):
103
+ raise ValueError(
104
+ "evidence uri must start with artifact://, file://, http:// or https://"
105
+ )
106
+ return v
107
+
108
+
109
+ class Environment(Strict):
110
+ branch: str = ""
111
+ fixture: str = ""
112
+ flags: dict[str, Any] = Field(default_factory=dict)
113
+
114
+
115
+ class Reproduction(Strict):
116
+ status: ReproStatus = ReproStatus.UNATTEMPTED
117
+ environment: Environment = Field(default_factory=Environment)
118
+ steps: list[str] = Field(default_factory=list)
119
+ failing_test: str | None = None
120
+ flake_rate: float = Field(default=0.0, ge=0.0, le=1.0)
121
+ verified_by: str | None = None
122
+
123
+
124
+ class Impact(Strict):
125
+ user_facing: bool = False
126
+ affected_surface: str = ""
127
+ data_loss_risk: bool = False
128
+ security_relevant: bool = False
129
+ frequency_estimate: str = ""
130
+
131
+
132
+ class SuggestedOwner(Strict):
133
+ component: str | None = None
134
+ team: str | None = None
135
+
136
+
137
+ class Dedupe(Strict):
138
+ fingerprint: str | None = None
139
+ similar_to: list[str] = Field(default_factory=list)
140
+ occurrence_count: int = Field(default=1, ge=1)
141
+
142
+
143
+ class TrackerRef(Strict):
144
+ key: str | None = None
145
+ project: str | None = None
146
+ status: str | None = None
147
+
148
+
149
+ def _utcnow() -> datetime:
150
+ return datetime.now(timezone.utc)
151
+
152
+
153
+ class DefectEnvelope(Strict):
154
+ """A single finding, at any stage of its life from draft to filed."""
155
+
156
+ envelope_version: Literal["1.0"] = ENVELOPE_VERSION
157
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
158
+ run_id: str
159
+ discovered_by: str
160
+ discovered_at: datetime = Field(default_factory=_utcnow)
161
+
162
+ domain: Domain
163
+ defect_class: DefectClass = Field(alias="class")
164
+
165
+ title: Annotated[str, Field(min_length=1, max_length=90)]
166
+ summary: Annotated[str, Field(min_length=1)]
167
+
168
+ location: Location = Field(default_factory=Location)
169
+ evidence: list[Evidence] = Field(default_factory=list)
170
+ reproduction: Reproduction = Field(default_factory=Reproduction)
171
+ impact: Impact = Field(default_factory=Impact)
172
+
173
+ severity: Severity
174
+ confidence: float = Field(ge=0.0, le=1.0)
175
+
176
+ suggested_owner: SuggestedOwner = Field(default_factory=SuggestedOwner)
177
+ suggested_fix_area: str = ""
178
+ autonomy_eligible: bool = False
179
+
180
+ dedupe: Dedupe = Field(default_factory=Dedupe)
181
+ jira: TrackerRef = Field(default_factory=TrackerRef)
182
+
183
+ model_config = ConfigDict(
184
+ extra="forbid",
185
+ populate_by_name=True,
186
+ ser_json_timedelta="iso8601",
187
+ )
188
+
189
+ @field_validator("title")
190
+ @classmethod
191
+ def _single_line_title(cls, v: str) -> str:
192
+ if "\n" in v:
193
+ raise ValueError("title must be a single line")
194
+ return v.strip()
195
+
196
+ @field_validator("discovered_by")
197
+ @classmethod
198
+ def _agent_name(cls, v: str) -> str:
199
+ if not re.fullmatch(r"[A-Z][A-Z_]{2,23}", v):
200
+ raise ValueError("discovered_by must be an agent name in SCREAMING_CASE")
201
+ return v
202
+
203
+ # -- evidence gate ----------------------------------------------------
204
+ # "Evidence or it did not happen" (§2). Enforced here rather than in a
205
+ # prompt so an agent cannot talk its way past it.
206
+
207
+ def has_evidence(self) -> bool:
208
+ # Truthiness, not `is not None`: `failing_test` is an optional string,
209
+ # and an agent filling an optional string with "" is an ordinary model
210
+ # habit. With the identity test, an envelope carrying no evidence at all
211
+ # and `failing_test=""` returned True and sailed through `is_fileable` —
212
+ # turning the one gate a model is not supposed to be able to argue past
213
+ # into one it could satisfy by typing nothing.
214
+ return bool(self.evidence) or bool(self.reproduction.failing_test)
215
+
216
+ def is_fileable(self, min_confidence: float = 0.6) -> tuple[bool, str]:
217
+ """Whether TRIAGE may file this. Returns (ok, reason-if-not).
218
+
219
+ The confidence gate is §7; the evidence gate is §2. Anything that fails
220
+ goes to the human review queue instead of the tracker.
221
+ """
222
+ if not self.has_evidence():
223
+ return False, "no evidence: needs an artifact or a failing test"
224
+ if self.confidence < min_confidence:
225
+ return False, (
226
+ f"confidence {self.confidence:.2f} below gate {min_confidence:.2f}"
227
+ )
228
+ if self.reproduction.status == ReproStatus.NOT_REPRODUCIBLE:
229
+ return False, "not reproducible"
230
+ return True, ""
231
+
232
+ # -- dedupe -----------------------------------------------------------
233
+
234
+ def fingerprint(self) -> str:
235
+ """A stable structural identity for this defect.
236
+
237
+ Deliberately excludes prose, line numbers, commit sha, run id and
238
+ timestamps: the same defect reported by two agents in different words,
239
+ or found again after the file moved a few lines, must hash the same.
240
+ """
241
+ paths = sorted(normalize_path(p) for p in self.location.paths)
242
+ parts = [
243
+ self.domain.value,
244
+ self.defect_class.value,
245
+ self.location.service or "",
246
+ self.location.endpoint or "",
247
+ self.location.ui_route or "",
248
+ "|".join(paths),
249
+ ]
250
+ # Everything above comes from `location`, which is entirely optional in
251
+ # EMIT_SCHEMA. With none of it, two unrelated findings that share only a
252
+ # domain and a class hashed identically — and `defect_memory.record`
253
+ # then reported the second as "already tracked as PROJ-N, do not file
254
+ # again", suppressing a real defect and persisting that suppression into
255
+ # the cross-run store, where it repeats every future run.
256
+ #
257
+ # So when there is no structure to hash, fall back to the title. Prose is
258
+ # what this function otherwise excludes on purpose, and it is still the
259
+ # right answer here: a weaker identity beats a wrong one, and the
260
+ # alternative is asserting that two defects are the same on the evidence
261
+ # that neither said where it lived.
262
+ if not any(parts[2:]) or not "".join(parts[2:]):
263
+ parts.append(_normalize_title(self.title))
264
+ digest = hashlib.sha256("\x1f".join(parts).encode()).hexdigest()
265
+ return f"sha256:{digest}"
266
+
267
+ def with_fingerprint(self) -> "DefectEnvelope":
268
+ """Return a copy carrying its computed fingerprint."""
269
+ return self.model_copy(
270
+ update={"dedupe": self.dedupe.model_copy(update={"fingerprint": self.fingerprint()})}
271
+ )
272
+
273
+ def to_json(self) -> str:
274
+ return self.model_dump_json(by_alias=True, indent=2)
275
+
276
+ @classmethod
277
+ def from_json(cls, raw: str | bytes) -> "DefectEnvelope":
278
+ return cls.model_validate_json(raw)
279
+
280
+
281
+ def _normalize_title(title: str) -> str:
282
+ """A title reduced to its words, so wording drift does not fork the hash.
283
+
284
+ Only reached when an envelope carries no location at all — see `fingerprint`.
285
+ """
286
+ return " ".join(re.findall(r"[a-z0-9]+", title.lower()))
287
+
288
+
289
+ def normalize_path(path: str) -> str:
290
+ r"""Reduce a cited path to the file it names, so the same file hashes alike.
291
+
292
+ `target-app/api/app/auth.py:112-113` -> `api/app/auth.py`
293
+
294
+ Two things defeated the old version, and both were found in live data rather
295
+ than reasoned about. Its regex was `:\d+(?::\d+)?$`, which matched `:142`
296
+ and `:142:5` but NOT `:112-113` -- and a line *range* is how an agent
297
+ naturally cites a region, so the strip almost never fired. And nothing
298
+ removed the repo-root prefix, so `target-app/api/app/auth.py` and
299
+ `api/app/auth.py` were different files as far as the hash was concerned.
300
+
301
+ The visible damage was that `occurrence_count` never left 1: the same defect
302
+ reported across three runs produced three identities, so `get_occurrences`
303
+ could never say a defect was recurring. Deduplication itself survived only
304
+ because TRIAGE matches on similarity rather than on this hash.
305
+
306
+ `scorecard._norm_path` delegates here. They must not drift: a scorer that
307
+ considers two paths equal while the fingerprint considers them distinct is
308
+ two answers to one question.
309
+ """
310
+ p = re.sub(r":\d+(?:[-:]\d+)?$", "", path.strip().replace("\\", "/")).lstrip("./")
311
+ for prefix in ("target-app/", "target_app/"):
312
+ if p.startswith(prefix):
313
+ p = p[len(prefix):]
314
+ return p
315
+
316
+
317
+ #: Kept as the old name so nothing importing it breaks; it always meant this.
318
+ _strip_line_number = normalize_path
qaas/envfile.py ADDED
@@ -0,0 +1,100 @@
1
+ """Reading credentials out of a `.env` file, without a dependency.
2
+
3
+ Every credential this system needs is an environment variable, deliberately:
4
+ `config/` is committed, and a token in a committed file is a token that leaks.
5
+ That rule is right and it made the tool tedious to use — four exports in every
6
+ new shell, and a run that dies on the fourth because one was forgotten.
7
+
8
+ So: a `.env` next to the project, read once, at CLI start. Two rules keep it
9
+ from becoming a second configuration system:
10
+
11
+ * **The real environment always wins.** A value already exported is never
12
+ overwritten, so `JIRA_PROJECT_KEY=OTHER qaas run` still means what it says,
13
+ and a stale `.env` cannot silently redirect a run.
14
+ * **It is only ever read for credentials.** Nothing in `config/` is looked up
15
+ here. A `.env` that sets `QAAS_TRACKER` works because that is an environment
16
+ override that already existed, not because this file is config.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from pathlib import Path
23
+
24
+ #: Where to look, in order. The state directory first, because `.qaas/` is
25
+ #: already gitignored — a credential written there cannot be committed by
26
+ #: accident, which is not true of a `.env` at the root of someone's repository.
27
+ CANDIDATES = (".qaas/.env", ".env")
28
+
29
+ #: Overrides the search. A path names the file to read; an empty value turns
30
+ #: the whole mechanism off. Both are needed by real callers: CI passes real
31
+ #: environment variables and must not have a stray `.env` in a checkout
32
+ #: override them, and the test suite must be hermetic against whatever the
33
+ #: developer happens to have on disk.
34
+ ENV_FILE_VAR = "QAAS_ENV_FILE"
35
+
36
+
37
+ def parse_env(text: str) -> dict[str, str]:
38
+ """`KEY=value` lines to a dict. Comments, blanks and `export ` tolerated.
39
+
40
+ Quotes are stripped only when they wrap the whole value: a token that
41
+ genuinely contains a quote character is more likely than a caller who meant
42
+ to keep the wrapping ones.
43
+ """
44
+ values: dict[str, str] = {}
45
+ for raw in text.splitlines():
46
+ line = raw.strip()
47
+ if not line or line.startswith("#") or "=" not in line:
48
+ continue
49
+ if line.startswith("export "):
50
+ line = line[len("export ") :].lstrip()
51
+ key, _, value = line.partition("=")
52
+ key = key.strip()
53
+ if not key:
54
+ continue
55
+ value = value.strip()
56
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
57
+ value = value[1:-1]
58
+ values[key] = value
59
+ return values
60
+
61
+
62
+ def load_env_file(start: Path | str | None = None) -> tuple[Path | None, list[str]]:
63
+ """Load the first `.env` found, without clobbering the real environment.
64
+
65
+ Returns `(path, names_set)` — the file that was used and the variables it
66
+ actually contributed. Names already present in the environment are reported
67
+ as not set by the file, because that is the fact an operator debugging a
68
+ wrong project key needs.
69
+ """
70
+ override = os.environ.get(ENV_FILE_VAR)
71
+ if override is not None and not override.strip():
72
+ return None, []
73
+
74
+ base = Path(start).expanduser() if start else Path.cwd()
75
+ candidates = (Path(override).expanduser(),) if override else tuple(base / n for n in CANDIDATES)
76
+ for path in candidates:
77
+ if not path.is_file():
78
+ continue
79
+ try:
80
+ values = parse_env(path.read_text(encoding="utf-8", errors="replace"))
81
+ except OSError:
82
+ # An unreadable .env is not worth killing a command over; the
83
+ # missing variable will produce a far clearer error downstream.
84
+ return None, []
85
+ applied = []
86
+ for key, value in values.items():
87
+ # Presence, not truthiness. `os.environ.get(key)` treated an
88
+ # exported-but-empty variable as unset, so the file won — the exact
89
+ # opposite of what this module, CLAUDE.md and
90
+ # `test_the_real_environment_always_wins` all promise. The case that
91
+ # matters is a CI job with `JIRA_API_TOKEN: ${{ secrets.X }}` where
92
+ # the secret is not set: GitHub exports it as "", and a checkout
93
+ # carrying an old `.env` would then file tickets into whatever
94
+ # instance that stale token pointed at.
95
+ if key in os.environ:
96
+ continue
97
+ os.environ[key] = value
98
+ applied.append(key)
99
+ return path, applied
100
+ return None, []