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.
Files changed (81) hide show
  1. qaas/adapters/__init__.py +19 -0
  2. qaas/adapters/tracker.py +1350 -0
  3. qaas/adapters/vcs.py +494 -0
  4. qaas/cli.py +1564 -0
  5. qaas/conductor.py +527 -0
  6. qaas/config.py +407 -0
  7. qaas/defaults/config/agents/arbiter.yaml +19 -0
  8. qaas/defaults/config/agents/cartographer.yaml +20 -0
  9. qaas/defaults/config/agents/clerk.yaml +21 -0
  10. qaas/defaults/config/agents/conduit.yaml +19 -0
  11. qaas/defaults/config/agents/forge.yaml +22 -0
  12. qaas/defaults/config/agents/mender.yaml +56 -0
  13. qaas/defaults/config/agents/proof.yaml +21 -0
  14. qaas/defaults/config/agents/surface.yaml +16 -0
  15. qaas/defaults/config/system.yaml +69 -0
  16. qaas/discover.py +227 -0
  17. qaas/envelope.py +290 -0
  18. qaas/guardrails.py +431 -0
  19. qaas/mcp/__init__.py +0 -0
  20. qaas/mcp/context.py +70 -0
  21. qaas/mcp/contract_diff.py +937 -0
  22. qaas/mcp/defect_memory.py +495 -0
  23. qaas/mcp/env_control.py +905 -0
  24. qaas/mcp/envelope_server.py +463 -0
  25. qaas/mcp/test_runner.py +773 -0
  26. qaas/mcp/tracker.py +412 -0
  27. qaas/mcp/vcs.py +506 -0
  28. qaas/paths.py +317 -0
  29. qaas/plugin/.claude-plugin/plugin.json +9 -0
  30. qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
  31. qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
  32. qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
  33. qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
  34. qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
  35. qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
  36. qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
  37. qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
  38. qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
  39. qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
  40. qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
  41. qaas/plugin/skills/flake-detection/SKILL.md +39 -0
  42. qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
  43. qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
  44. qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
  45. qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
  46. qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
  47. qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
  48. qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
  49. qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
  50. qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
  51. qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
  52. qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
  53. qaas/plugin/skills/routing-rules/SKILL.md +34 -0
  54. qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
  55. qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
  56. qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
  57. qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
  58. qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
  59. qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
  60. qaas/prompts/ARBITER.md +53 -0
  61. qaas/prompts/CARTOGRAPHER.md +46 -0
  62. qaas/prompts/CLERK.md +45 -0
  63. qaas/prompts/CONDUIT.md +44 -0
  64. qaas/prompts/FORGE.md +43 -0
  65. qaas/prompts/MENDER.md +55 -0
  66. qaas/prompts/PROOF.md +41 -0
  67. qaas/prompts/SURFACE.md +46 -0
  68. qaas/prompts/_shared.md +45 -0
  69. qaas/registry.py +465 -0
  70. qaas/runner.py +192 -0
  71. qaas/scorecard.py +425 -0
  72. qaas/sdk_compat.py +52 -0
  73. qaas/store.py +290 -0
  74. qaas/target.py +261 -0
  75. qaas/tasks.py +361 -0
  76. qaas/trace.py +270 -0
  77. qaas_python-0.1.0.dist-info/METADATA +388 -0
  78. qaas_python-0.1.0.dist-info/RECORD +81 -0
  79. qaas_python-0.1.0.dist-info/WHEEL +4 -0
  80. qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
  81. qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
qaas/store.py ADDED
@@ -0,0 +1,290 @@
1
+ """Run state on disk: the ledger, the artifact store, the versioned system map.
2
+
3
+ Everything an agent produces lands here. The ledger is the audit trail the
4
+ architecture asks for (§2, §8) — every agent invocation, every cost, every
5
+ guardrail denial, appended and never rewritten.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import uuid
14
+ from datetime import datetime, timezone
15
+ from enum import StrEnum
16
+ from pathlib import Path
17
+ from typing import Any, Iterator
18
+
19
+ from pydantic import BaseModel, ConfigDict, Field
20
+
21
+ from qaas.envelope import DefectEnvelope
22
+
23
+ DEFAULT_ROOT = Path(".qaas")
24
+
25
+
26
+ def _utcnow() -> datetime:
27
+ return datetime.now(timezone.utc)
28
+
29
+
30
+ class LedgerKind(StrEnum):
31
+ """Every kind of line the ledger may contain.
32
+
33
+ This was a bare `str` whose comment named 7 of the 28 kinds actually
34
+ written, which left the ledger unreadable by anything but grep: nothing
35
+ could enumerate what a run might contain, and a typo at a `store.log()`
36
+ call site invented a 29th kind that no reader would ever look for. It is a
37
+ closed set now, so a misspelling fails at the write instead of vanishing.
38
+
39
+ `StrEnum` and not a plain `Enum` on purpose: members *are* their strings, so
40
+ `entry.kind == "denial"` still holds, `model_dump_json()` still writes
41
+ `"kind":"denial"`, and every ledger already on disk still parses. Adding a
42
+ kind means adding a member here -- deliberately a visible act, because the
43
+ conductor reads several of these back for control flow (`_latest_verdict`,
44
+ `_latest_review`, `_branch_written_since`), so a rename is a breaking
45
+ change to a wire format, not a rename.
46
+ """
47
+
48
+ # run lifecycle (conductor)
49
+ RUN_STARTED = "run_started"
50
+ RUN_FINISHED = "run_finished"
51
+ SKIPPED = "skipped"
52
+ ESCALATION = "escalation"
53
+
54
+ # agent lifecycle (runner, store)
55
+ AGENT_STARTED = "agent_started"
56
+ AGENT_FINISHED = "agent_finished"
57
+ AGENT_ERROR = "agent_error"
58
+ SKILLS_MISSING = "skills_missing"
59
+
60
+ # tool traffic and its refusals (guardrails, registry)
61
+ TOOL_CALL = "tool_call"
62
+ TOOL_ERROR = "tool_error"
63
+ DENIAL = "denial"
64
+ STOP_BLOCKED = "stop_blocked"
65
+ CONTRACT_UNMET = "contract_unmet"
66
+
67
+ # findings and the evidence behind them
68
+ ENVELOPE = "envelope"
69
+ REPRODUCTION = "reproduction"
70
+ CONTRACT_TEST = "contract_test"
71
+ SYSTEM_MAP = "system_map"
72
+ DEFECT_MEMORY = "defect_memory"
73
+ REGRESSION = "regression"
74
+
75
+ # the file/verify/fix loop
76
+ TICKET = "ticket"
77
+ VERDICT = "verdict"
78
+ VERIFIED = "verified"
79
+ REOPENED = "reopened"
80
+ REVIEW = "review"
81
+ REVIEW_ROUND_TRIP = "review_round_trip"
82
+
83
+ # side effects on the world outside the run
84
+ VCS = "vcs"
85
+ ENV = "env"
86
+ DRY_RUN = "dry_run"
87
+
88
+
89
+ class LedgerEntry(BaseModel):
90
+ """One line in the run ledger. Append-only."""
91
+
92
+ model_config = ConfigDict(extra="forbid")
93
+
94
+ at: datetime = Field(default_factory=_utcnow)
95
+ kind: LedgerKind
96
+ agent: str | None = None
97
+ detail: dict[str, Any] = Field(default_factory=dict)
98
+
99
+
100
+ class AgentResult(BaseModel):
101
+ """What one agent invocation cost and produced."""
102
+
103
+ model_config = ConfigDict(extra="forbid")
104
+
105
+ agent: str
106
+ subtype: str = "success"
107
+ cost_usd: float = 0.0
108
+ num_turns: int = 0
109
+ duration_s: float = 0.0
110
+ envelope_ids: list[str] = Field(default_factory=list)
111
+ error: str | None = None
112
+
113
+
114
+ class RunStore:
115
+ """The filesystem home of a single run.
116
+
117
+ Layout:
118
+ .qaas/runs/<run_id>/ledger.jsonl
119
+ envelopes/<envelope_id>.json
120
+ artifacts/<name>
121
+ results/<AGENT>.json
122
+ .qaas/system-map/<version>.json (shared across runs)
123
+ .qaas/system-map/latest (pointer file)
124
+ """
125
+
126
+ def __init__(self, run_id: str, root: Path | str = DEFAULT_ROOT):
127
+ self.run_id = run_id
128
+ self.root = Path(root)
129
+ self.dir = self.root / "runs" / run_id
130
+ for sub in ("envelopes", "artifacts", "results"):
131
+ (self.dir / sub).mkdir(parents=True, exist_ok=True)
132
+
133
+ @classmethod
134
+ def new(cls, root: Path | str = DEFAULT_ROOT, prefix: str = "run") -> "RunStore":
135
+ run_id = f"{prefix}-{_utcnow():%Y%m%dT%H%M%S}-{uuid.uuid4().hex[:6]}"
136
+ return cls(run_id, root)
137
+
138
+ # -- ledger -----------------------------------------------------------
139
+
140
+ @property
141
+ def ledger_path(self) -> Path:
142
+ return self.dir / "ledger.jsonl"
143
+
144
+ def log(self, kind: LedgerKind | str, agent: str | None = None, **detail: Any) -> LedgerEntry:
145
+ # `str` stays in the signature because ~60 call sites pass a literal and
146
+ # reading `store.log("denial", ...)` at the call site beats reading
147
+ # `store.log(LedgerKind.DENIAL, ...)`. Pydantic converts and, crucially,
148
+ # rejects: an unknown kind raises here rather than appending a line no
149
+ # reader will ever ask for.
150
+ entry = LedgerEntry(kind=kind, agent=agent, detail=detail)
151
+ with self.ledger_path.open("a") as fh:
152
+ fh.write(entry.model_dump_json() + "\n")
153
+ return entry
154
+
155
+ def ledger(self, kind: LedgerKind | str | None = None) -> Iterator[LedgerEntry]:
156
+ if not self.ledger_path.exists():
157
+ return
158
+ for line in self.ledger_path.read_text().splitlines():
159
+ if not line.strip():
160
+ continue
161
+ entry = LedgerEntry.model_validate_json(line)
162
+ if kind is None or entry.kind == kind:
163
+ yield entry
164
+
165
+ # -- envelopes --------------------------------------------------------
166
+
167
+ def put_envelope(self, envelope: DefectEnvelope) -> Path:
168
+ """Persist an envelope, stamping its fingerprint if absent."""
169
+ if envelope.dedupe.fingerprint is None:
170
+ envelope = envelope.with_fingerprint()
171
+ path = self.dir / "envelopes" / f"{envelope.id}.json"
172
+ path.write_text(envelope.to_json())
173
+ self.log(
174
+ "envelope",
175
+ agent=envelope.discovered_by,
176
+ envelope_id=envelope.id,
177
+ domain=envelope.domain.value,
178
+ severity=envelope.severity.value,
179
+ confidence=envelope.confidence,
180
+ fingerprint=envelope.dedupe.fingerprint,
181
+ )
182
+ return path
183
+
184
+ def envelopes(self) -> list[DefectEnvelope]:
185
+ paths = sorted((self.dir / "envelopes").glob("*.json"))
186
+ return [DefectEnvelope.from_json(p.read_text()) for p in paths]
187
+
188
+ def get_envelope(self, envelope_id: str) -> DefectEnvelope | None:
189
+ path = self.dir / "envelopes" / f"{envelope_id}.json"
190
+ return DefectEnvelope.from_json(path.read_text()) if path.exists() else None
191
+
192
+ # -- artifacts --------------------------------------------------------
193
+
194
+ def put_artifact(self, name: str, content: str | bytes) -> str:
195
+ """Store evidence and return the artifact:// uri that references it."""
196
+ safe = name.replace("/", "_").replace("..", "_")
197
+ path = self.dir / "artifacts" / safe
198
+ if isinstance(content, bytes):
199
+ path.write_bytes(content)
200
+ else:
201
+ path.write_text(content)
202
+ return f"artifact://{self.run_id}/{safe}"
203
+
204
+ def copy_artifact(self, name: str, source: Path | str) -> str:
205
+ safe = name.replace("/", "_").replace("..", "_")
206
+ shutil.copy2(source, self.dir / "artifacts" / safe)
207
+ return f"artifact://{self.run_id}/{safe}"
208
+
209
+ def resolve_artifact(self, uri: str) -> Path:
210
+ """artifact://<run_id>/<name> -> a real path. Raises if it escapes the store."""
211
+ if not uri.startswith("artifact://"):
212
+ raise ValueError(f"not an artifact uri: {uri}")
213
+ run_id, _, name = uri[len("artifact://"):].partition("/")
214
+ path = (self.root / "runs" / run_id / "artifacts" / name).resolve()
215
+ base = (self.root / "runs" / run_id / "artifacts").resolve()
216
+ if not path.is_relative_to(base):
217
+ raise ValueError(f"artifact uri escapes the store: {uri}")
218
+ return path
219
+
220
+ # -- per-agent results ------------------------------------------------
221
+
222
+ def put_result(self, result: AgentResult) -> None:
223
+ # One file per invocation, not per agent. FORGE runs once per finding and
224
+ # MENDER once per review round trip, so a per-agent filename silently
225
+ # keeps only the last one — and the persisted cost of a run then
226
+ # under-reports by however much the repeated agents actually spent.
227
+ existing = len(list((self.dir / "results").glob(f"{result.agent}-*.json")))
228
+ path = self.dir / "results" / f"{result.agent}-{existing + 1:02d}.json"
229
+ path.write_text(result.model_dump_json(indent=2))
230
+ self.log(
231
+ "agent_finished",
232
+ agent=result.agent,
233
+ subtype=result.subtype,
234
+ cost_usd=result.cost_usd,
235
+ num_turns=result.num_turns,
236
+ envelopes=len(result.envelope_ids),
237
+ error=result.error,
238
+ )
239
+
240
+ def results(self) -> list[AgentResult]:
241
+ paths = sorted((self.dir / "results").glob("*.json"))
242
+ return [AgentResult.model_validate_json(p.read_text()) for p in paths]
243
+
244
+ def total_cost_usd(self) -> float:
245
+ return sum(r.cost_usd for r in self.results())
246
+
247
+
248
+ class SystemMapStore:
249
+ """Versioned Cartographer output, shared across runs.
250
+
251
+ Agents pin a map version for the length of a run so a bad map cannot
252
+ half-propagate mid-run (§10, context poisoning).
253
+ """
254
+
255
+ def __init__(self, root: Path | str = DEFAULT_ROOT):
256
+ self.dir = Path(root) / "system-map"
257
+ self.dir.mkdir(parents=True, exist_ok=True)
258
+
259
+ def put(self, payload: dict[str, Any]) -> str:
260
+ # The suffix is not decoration: a bare second-resolution timestamp lets
261
+ # two maps written in the same second collide, which would silently
262
+ # rewrite a version another run had already pinned.
263
+ version = f"{_utcnow():%Y%m%dT%H%M%S}-{uuid.uuid4().hex[:6]}"
264
+ path = self.dir / f"{version}.json"
265
+ if path.exists():
266
+ raise RuntimeError(f"system map version {version} already exists")
267
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True))
268
+ tmp = self.dir / "latest.tmp"
269
+ tmp.write_text(version)
270
+ os.replace(tmp, self.dir / "latest")
271
+ return version
272
+
273
+ def latest_version(self) -> str | None:
274
+ pointer = self.dir / "latest"
275
+ return pointer.read_text().strip() if pointer.exists() else None
276
+
277
+ def get(self, version: str | None = None) -> dict[str, Any] | None:
278
+ version = version or self.latest_version()
279
+ if not version:
280
+ return None
281
+ path = self.dir / f"{version}.json"
282
+ return json.loads(path.read_text()) if path.exists() else None
283
+
284
+ def versions(self) -> list[str]:
285
+ return sorted(p.stem for p in self.dir.glob("*.json"))
286
+
287
+
288
+ def list_runs(root: Path | str = DEFAULT_ROOT) -> list[str]:
289
+ runs = Path(root) / "runs"
290
+ return sorted((p.name for p in runs.iterdir() if p.is_dir()), reverse=True) if runs.exists() else []
qaas/target.py ADDED
@@ -0,0 +1,261 @@
1
+ """What the system is pointed at.
2
+
3
+ Everything the agents need to know about an application they have never seen:
4
+ where its code lives, how to reach it, who its users are. Without this the
5
+ system can only ever run against the app it was built alongside — which is the
6
+ difference between a demo and a tool.
7
+
8
+ A profile is deliberately small and mostly optional. An agent can discover a
9
+ great deal on its own; what it cannot discover is anything requiring a
10
+ credential, a URL that is not in the repository, or a judgement about which of
11
+ three directories is "the backend". Those are what a profile supplies.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import re
18
+ from pathlib import Path
19
+ from typing import Any, Literal
20
+
21
+ import yaml
22
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
23
+
24
+ from qaas.paths import project_root
25
+
26
+ # Directories that are never product code. Excluded by default so a profile does
27
+ # not have to restate them and an agent does not waste a turn reading vendored
28
+ # dependencies.
29
+ DEFAULT_EXCLUDES = [
30
+ "node_modules", "vendor", "dist", "build", ".venv", "venv", "__pycache__",
31
+ ".git", ".next", "target", "coverage", ".pytest_cache", "site-packages",
32
+ ]
33
+
34
+
35
+ class Layout(BaseModel):
36
+ """Where things are. Every field is a hint; agents verify what they find."""
37
+
38
+ model_config = ConfigDict(extra="forbid")
39
+
40
+ backend: list[str] = Field(default_factory=list)
41
+ frontend: list[str] = Field(default_factory=list)
42
+ tests: list[str] = Field(default_factory=list)
43
+ migrations: list[str] = Field(default_factory=list)
44
+ spec: str | None = Field(default=None, description="OpenAPI document, if one exists.")
45
+ ownership: str | None = Field(default=None, description="CODEOWNERS or equivalent.")
46
+ docs: list[str] = Field(default_factory=list)
47
+ exclude: list[str] = Field(default_factory=lambda: list(DEFAULT_EXCLUDES))
48
+
49
+ def described(self) -> str:
50
+ """A prose sketch of the layout for an agent's task prompt."""
51
+ bits = []
52
+ for label, paths in (
53
+ ("backend", self.backend), ("frontend", self.frontend),
54
+ ("tests", self.tests), ("migrations", self.migrations),
55
+ ):
56
+ if paths:
57
+ bits.append(f"{label}: {', '.join(paths)}")
58
+ if self.spec:
59
+ bits.append(f"API spec: {self.spec}")
60
+ if self.ownership:
61
+ bits.append(f"ownership: {self.ownership}")
62
+ return "; ".join(bits) or "not recorded — discover it yourself"
63
+
64
+
65
+ class Role(BaseModel):
66
+ """One account an agent can act as.
67
+
68
+ Credentials are read from the environment, never stored here: a profile is
69
+ committed to a repository and a password in it is a leak, not a convenience.
70
+ """
71
+
72
+ model_config = ConfigDict(extra="forbid")
73
+
74
+ username: str
75
+ password_env: str = "QAAS_PASSWORD"
76
+ description: str = ""
77
+
78
+ def password(self) -> str | None:
79
+ return os.environ.get(self.password_env)
80
+
81
+
82
+ class Auth(BaseModel):
83
+ """How an agent gets a credential for the running app."""
84
+
85
+ model_config = ConfigDict(extra="forbid")
86
+
87
+ mode: Literal["none", "login", "token"] = "none"
88
+ login_endpoint: str | None = Field(
89
+ default=None, description="e.g. 'POST /v1/auth/login'"
90
+ )
91
+ username_field: str = "email"
92
+ password_field: str = "password"
93
+ token_path: str = "access_token"
94
+ token_env: str | None = Field(
95
+ default=None, description="For mode=token: env var holding a bearer token."
96
+ )
97
+ roles: dict[str, Role] = Field(default_factory=dict)
98
+
99
+ @model_validator(mode="after")
100
+ def _coherent(self) -> "Auth":
101
+ if self.mode == "login" and not self.login_endpoint:
102
+ raise ValueError("auth.mode is 'login' but no login_endpoint is set")
103
+ if self.mode == "login" and not self.roles:
104
+ raise ValueError("auth.mode is 'login' but no roles are defined")
105
+ if self.mode == "token" and not self.token_env:
106
+ raise ValueError("auth.mode is 'token' but no token_env is set")
107
+ return self
108
+
109
+ def missing_secrets(self) -> list[str]:
110
+ """Env vars this profile needs that are not set. Checked before a run."""
111
+ missing = []
112
+ if self.mode == "token" and self.token_env and not os.environ.get(self.token_env):
113
+ missing.append(self.token_env)
114
+ if self.mode == "login":
115
+ for role in self.roles.values():
116
+ if not role.password() and role.password_env not in missing:
117
+ missing.append(role.password_env)
118
+ return missing
119
+
120
+
121
+ class Environment(BaseModel):
122
+ """How to get a running instance of the application.
123
+
124
+ Three modes, because real projects differ and pretending otherwise is what
125
+ makes a tool unusable:
126
+
127
+ compose — this system brings the app up and owns its lifecycle.
128
+ external — the app is already running somewhere (staging, a dev server).
129
+ Agents may read and exercise it but never reset or reseed it.
130
+ none — there is no reachable instance. Discovery is static only:
131
+ code, spec and schema. Most first runs against a real repo
132
+ start here, and that is a perfectly useful mode.
133
+ """
134
+
135
+ model_config = ConfigDict(extra="forbid")
136
+
137
+ mode: Literal["compose", "external", "none"] = "none"
138
+ api_url: str | None = None
139
+ web_url: str | None = None
140
+ health_path: str = "/health"
141
+
142
+ compose_file: str | None = None
143
+ services: list[str] = Field(default_factory=list)
144
+ seed_sql: str | None = None
145
+ db_service: str | None = None
146
+ db_user: str | None = None
147
+ db_name: str | None = None
148
+
149
+ startup_timeout_s: int = 180
150
+
151
+ @property
152
+ def is_managed(self) -> bool:
153
+ """Whether this system may create, reset and destroy the environment."""
154
+ return self.mode == "compose"
155
+
156
+ @property
157
+ def is_reachable(self) -> bool:
158
+ return self.mode in {"compose", "external"}
159
+
160
+ @model_validator(mode="after")
161
+ def _coherent(self) -> "Environment":
162
+ if self.mode == "compose" and not self.compose_file:
163
+ raise ValueError("environment.mode is 'compose' but no compose_file is set")
164
+ if self.mode == "external" and not (self.api_url or self.web_url):
165
+ raise ValueError(
166
+ "environment.mode is 'external' but neither api_url nor web_url is set"
167
+ )
168
+ return self
169
+
170
+
171
+ class TargetProfile(BaseModel):
172
+ """One application this system can be pointed at."""
173
+
174
+ model_config = ConfigDict(extra="forbid")
175
+
176
+ name: str
177
+ root: str = Field(description="Path to the repository, relative to cwd or absolute.")
178
+ description: str = ""
179
+ repo_url: str | None = None
180
+ default_branch: str = "main"
181
+
182
+ layout: Layout = Field(default_factory=Layout)
183
+ environment: Environment = Field(default_factory=Environment)
184
+ auth: Auth = Field(default_factory=Auth)
185
+
186
+ #: Golden ledger for calibration. Absent for real applications — you only
187
+ #: have one for an app whose defects you planted yourself.
188
+ ledger: str | None = None
189
+
190
+ @model_validator(mode="after")
191
+ def _name_is_a_slug(self) -> "TargetProfile":
192
+ if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,40}", self.name):
193
+ raise ValueError("target name must be a lowercase slug, e.g. 'my-app'")
194
+ return self
195
+
196
+ def root_path(self, base: Path | None = None) -> Path:
197
+ """Where the application under test actually is, on this machine.
198
+
199
+ This is *the* answer to "what am I testing", and it is deliberately not
200
+ the process cwd. `qaas run --repo <url>` clones into
201
+ `.qaas/targets/<slug>`, so the target is routinely somewhere the qaas
202
+ project is not; an absolute `root` in a profile is honoured as written.
203
+ """
204
+ root = Path(self.root)
205
+ if root.is_absolute():
206
+ return root
207
+ return (base if base is not None else project_root()) / root
208
+
209
+ def readiness(self, base: Path | None = None) -> list[str]:
210
+ """Everything that would stop a run right now. Empty means ready."""
211
+ problems: list[str] = []
212
+ root = self.root_path(base)
213
+ if not root.exists():
214
+ problems.append(f"repository root does not exist: {root}")
215
+ elif not root.is_dir():
216
+ problems.append(f"repository root is not a directory: {root}")
217
+
218
+ for missing in self.auth.missing_secrets():
219
+ problems.append(f"environment variable {missing} is not set")
220
+
221
+ if self.environment.mode == "compose":
222
+ compose = root / (self.environment.compose_file or "")
223
+ if not compose.exists():
224
+ problems.append(f"compose file not found: {compose}")
225
+ if self.layout.spec:
226
+ spec = root / self.layout.spec
227
+ if not spec.exists():
228
+ problems.append(f"API spec not found: {spec}")
229
+ return problems
230
+
231
+ def capabilities(self) -> dict[str, bool]:
232
+ """What this profile makes possible. Drives which agents can usefully run."""
233
+ return {
234
+ "static_analysis": True,
235
+ "spec_diff": self.layout.spec is not None,
236
+ "live_api": self.environment.is_reachable and self.environment.api_url is not None,
237
+ "live_ui": self.environment.is_reachable and self.environment.web_url is not None,
238
+ "reset_state": self.environment.is_managed,
239
+ "impersonate": self.auth.mode != "none",
240
+ "scored": self.ledger is not None,
241
+ }
242
+
243
+
244
+ def load_target(name: str, targets_dir: Path | str = "config/targets") -> TargetProfile:
245
+ path = Path(targets_dir) / f"{name}.yaml"
246
+ if not path.exists():
247
+ available = sorted(p.stem for p in Path(targets_dir).glob("*.yaml"))
248
+ raise FileNotFoundError(
249
+ f"no target profile '{name}' at {path}. "
250
+ f"Available: {', '.join(available) or 'none'}. "
251
+ "Create one with `qaas init <path-to-repo>`."
252
+ )
253
+ raw: dict[str, Any] = yaml.safe_load(path.read_text()) or {}
254
+ raw.setdefault("name", name)
255
+ return TargetProfile.model_validate(raw)
256
+
257
+
258
+ # `list_targets(one_dir)` lived here and is gone. Listing profiles from a single
259
+ # directory is the bug that hid `<project>/config/targets/` the moment anything
260
+ # wrote into `.qaas/config/targets/`; profiles layer across every config
261
+ # directory, and `config.target_files(dirs)` is the one place that knows it.