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/discover.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Inspecting an unfamiliar repository well enough to write a target profile.
|
|
2
|
+
|
|
3
|
+
This is deliberately dumb pattern-matching, not analysis. Its job is to save a
|
|
4
|
+
person ten minutes of typing and to be obviously wrong when it is wrong — every
|
|
5
|
+
value it produces is a guess a human is expected to correct, and `qaas init`
|
|
6
|
+
says so. CARTOGRAPHER does the real mapping later, with a model and the whole
|
|
7
|
+
repository in front of it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from qaas.target import DEFAULT_EXCLUDES, Auth, Environment, Layout, Role, TargetProfile
|
|
18
|
+
|
|
19
|
+
SPEC_NAMES = [
|
|
20
|
+
"openapi.yaml", "openapi.yml", "openapi.json",
|
|
21
|
+
"swagger.yaml", "swagger.yml", "swagger.json",
|
|
22
|
+
"api/openapi.yaml", "docs/openapi.yaml", "spec/openapi.yaml",
|
|
23
|
+
]
|
|
24
|
+
OWNERSHIP_NAMES = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS", ".gitlab/CODEOWNERS"]
|
|
25
|
+
COMPOSE_NAMES = ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]
|
|
26
|
+
|
|
27
|
+
BACKEND_MARKERS = {
|
|
28
|
+
"python": ("requirements.txt", "pyproject.toml", "manage.py", "Pipfile"),
|
|
29
|
+
"node": ("package.json",),
|
|
30
|
+
"go": ("go.mod",),
|
|
31
|
+
"java": ("pom.xml", "build.gradle", "build.gradle.kts"),
|
|
32
|
+
"ruby": ("Gemfile",),
|
|
33
|
+
"rust": ("Cargo.toml",),
|
|
34
|
+
"php": ("composer.json",),
|
|
35
|
+
}
|
|
36
|
+
BACKEND_HINTS = re.compile(
|
|
37
|
+
r"\b(fastapi|flask|django|express|nestjs|gin|echo|spring|rails|sinatra|laravel|actix|axum)\b",
|
|
38
|
+
re.I,
|
|
39
|
+
)
|
|
40
|
+
FRONTEND_HINTS = re.compile(r'"(react|vue|svelte|@angular/core|next|nuxt|solid-js)"', re.I)
|
|
41
|
+
|
|
42
|
+
TEST_DIR_NAMES = {"tests", "test", "__tests__", "spec", "e2e", "integration_tests"}
|
|
43
|
+
MIGRATION_DIR_NAMES = {"migrations", "migrate", "alembic", "db/migrate", "prisma/migrations"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Discovery:
|
|
48
|
+
"""What inspection found, with a note on anything it could not settle."""
|
|
49
|
+
|
|
50
|
+
layout: Layout
|
|
51
|
+
environment: Environment
|
|
52
|
+
auth: Auth
|
|
53
|
+
languages: set[str] = field(default_factory=set)
|
|
54
|
+
notes: list[str] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _rel(path: Path, root: Path) -> str:
|
|
58
|
+
return str(path.relative_to(root)).replace("\\", "/")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _walk(root: Path, excludes: set[str], max_depth: int = 4):
|
|
62
|
+
"""Directories worth looking at, breadth-first, skipping vendored trees."""
|
|
63
|
+
stack = [(root, 0)]
|
|
64
|
+
while stack:
|
|
65
|
+
current, depth = stack.pop()
|
|
66
|
+
if depth > max_depth:
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
children = list(current.iterdir())
|
|
70
|
+
except (PermissionError, OSError):
|
|
71
|
+
continue
|
|
72
|
+
yield current, children
|
|
73
|
+
for child in children:
|
|
74
|
+
if child.is_dir() and child.name not in excludes and not child.name.startswith("."):
|
|
75
|
+
stack.append((child, depth + 1))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _has_component_source(path: Path, excludes: set[str]) -> bool:
|
|
79
|
+
"""Whether a directory holds component source of its own.
|
|
80
|
+
|
|
81
|
+
Deliberately not `rglob`: that descends into `node_modules`, where a great
|
|
82
|
+
many packages ship .tsx, so it answers yes for almost any directory in a
|
|
83
|
+
JavaScript repository — and walks a vendored tree to get there. `_walk`
|
|
84
|
+
honours the same exclusions as the rest of this module.
|
|
85
|
+
"""
|
|
86
|
+
return any(
|
|
87
|
+
c.suffix in {".tsx", ".jsx"}
|
|
88
|
+
for _, children in _walk(path, excludes)
|
|
89
|
+
for c in children
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def inspect(root: Path) -> Discovery:
|
|
94
|
+
"""Guess a repository's shape. Every field is a hint, not a conclusion."""
|
|
95
|
+
root = root.resolve()
|
|
96
|
+
excludes = set(DEFAULT_EXCLUDES)
|
|
97
|
+
notes: list[str] = []
|
|
98
|
+
languages: set[str] = set()
|
|
99
|
+
|
|
100
|
+
backend: list[str] = []
|
|
101
|
+
frontend: list[str] = []
|
|
102
|
+
tests: list[str] = []
|
|
103
|
+
migrations: list[str] = []
|
|
104
|
+
|
|
105
|
+
for directory, children in _walk(root, excludes):
|
|
106
|
+
names = {c.name for c in children}
|
|
107
|
+
rel = _rel(directory, root) if directory != root else "."
|
|
108
|
+
|
|
109
|
+
for language, markers in BACKEND_MARKERS.items():
|
|
110
|
+
if names & set(markers):
|
|
111
|
+
languages.add(language)
|
|
112
|
+
if language == "node" and (directory / "package.json").exists():
|
|
113
|
+
# package.json alone says nothing; the dependencies do.
|
|
114
|
+
try:
|
|
115
|
+
pkg = (directory / "package.json").read_text()
|
|
116
|
+
except OSError:
|
|
117
|
+
pkg = ""
|
|
118
|
+
if FRONTEND_HINTS.search(pkg):
|
|
119
|
+
frontend.append(rel)
|
|
120
|
+
continue
|
|
121
|
+
if BACKEND_HINTS.search(pkg):
|
|
122
|
+
backend.append(rel)
|
|
123
|
+
continue
|
|
124
|
+
elif rel not in backend:
|
|
125
|
+
backend.append(rel)
|
|
126
|
+
|
|
127
|
+
if directory.name in TEST_DIR_NAMES and rel not in tests:
|
|
128
|
+
tests.append(rel)
|
|
129
|
+
if directory.name in MIGRATION_DIR_NAMES and rel not in migrations:
|
|
130
|
+
migrations.append(rel)
|
|
131
|
+
|
|
132
|
+
# A source directory with .tsx/.jsx in it is a frontend even without a
|
|
133
|
+
# package.json of its own — monorepos often hoist dependencies.
|
|
134
|
+
if not frontend:
|
|
135
|
+
for candidate in ("web", "frontend", "client", "ui", "app"):
|
|
136
|
+
path = root / candidate
|
|
137
|
+
if path.is_dir() and _has_component_source(path, excludes):
|
|
138
|
+
frontend.append(candidate)
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
spec = next((s for s in SPEC_NAMES if (root / s).exists()), None)
|
|
142
|
+
ownership = next((o for o in OWNERSHIP_NAMES if (root / o).exists()), None)
|
|
143
|
+
compose = next((c for c in COMPOSE_NAMES if (root / c).exists()), None)
|
|
144
|
+
|
|
145
|
+
if not spec:
|
|
146
|
+
notes.append(
|
|
147
|
+
"No OpenAPI document found. CONDUIT can still audit the API, but it has "
|
|
148
|
+
"no declared contract to diff against — set layout.spec if one exists "
|
|
149
|
+
"somewhere this did not look."
|
|
150
|
+
)
|
|
151
|
+
if not ownership:
|
|
152
|
+
notes.append(
|
|
153
|
+
"No CODEOWNERS file. Tickets will be filed unassigned unless you add one "
|
|
154
|
+
"or set layout.ownership."
|
|
155
|
+
)
|
|
156
|
+
if not backend and not frontend:
|
|
157
|
+
notes.append(
|
|
158
|
+
"Could not identify backend or frontend directories. Set layout.backend "
|
|
159
|
+
"and layout.frontend by hand — leaving them empty makes CARTOGRAPHER "
|
|
160
|
+
"explore blind, which is slower and less accurate."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
environment = Environment(mode="none")
|
|
164
|
+
if compose:
|
|
165
|
+
notes.append(
|
|
166
|
+
f"Found {compose}. Environment mode is still 'none': review the compose "
|
|
167
|
+
"file, then set mode to 'compose' with the service names and URLs. This "
|
|
168
|
+
"is not switched on automatically because bringing up someone's stack "
|
|
169
|
+
"unasked is not a decision a tool should make."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# "." is not a service. A root pyproject.toml or package.json is usually
|
|
173
|
+
# tooling or workspace config, and listing the root as a backend directory
|
|
174
|
+
# tells CARTOGRAPHER nothing while making it read the whole repository.
|
|
175
|
+
if len(set(backend)) > 1:
|
|
176
|
+
backend = [b for b in backend if b != "."]
|
|
177
|
+
|
|
178
|
+
return Discovery(
|
|
179
|
+
layout=Layout(
|
|
180
|
+
backend=sorted(set(backend))[:6],
|
|
181
|
+
frontend=sorted(set(frontend))[:4],
|
|
182
|
+
tests=sorted(set(tests))[:4],
|
|
183
|
+
migrations=sorted(set(migrations))[:3],
|
|
184
|
+
spec=spec,
|
|
185
|
+
ownership=ownership,
|
|
186
|
+
exclude=list(DEFAULT_EXCLUDES),
|
|
187
|
+
),
|
|
188
|
+
environment=environment,
|
|
189
|
+
auth=Auth(mode="none"),
|
|
190
|
+
languages=languages,
|
|
191
|
+
notes=notes,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def build_profile(
|
|
196
|
+
name: str,
|
|
197
|
+
root: Path,
|
|
198
|
+
*,
|
|
199
|
+
repo_url: str | None = None,
|
|
200
|
+
description: str = "",
|
|
201
|
+
default_branch: str = "main",
|
|
202
|
+
) -> tuple[TargetProfile, list[str]]:
|
|
203
|
+
found = inspect(root)
|
|
204
|
+
profile = TargetProfile(
|
|
205
|
+
name=name,
|
|
206
|
+
root=str(root),
|
|
207
|
+
repo_url=repo_url,
|
|
208
|
+
description=description or _describe(root, found),
|
|
209
|
+
default_branch=default_branch,
|
|
210
|
+
layout=found.layout,
|
|
211
|
+
environment=found.environment,
|
|
212
|
+
auth=found.auth,
|
|
213
|
+
)
|
|
214
|
+
return profile, found.notes
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _describe(root: Path, found: Discovery) -> str:
|
|
218
|
+
langs = ", ".join(sorted(found.languages)) or "unknown stack"
|
|
219
|
+
readme = next((root / n for n in ("README.md", "readme.md", "README.rst") if (root / n).exists()), None)
|
|
220
|
+
first_line = ""
|
|
221
|
+
if readme:
|
|
222
|
+
for line in readme.read_text(errors="ignore").splitlines():
|
|
223
|
+
stripped = line.strip().lstrip("#").strip()
|
|
224
|
+
if stripped and not stripped.startswith(("!", "[", "<")):
|
|
225
|
+
first_line = stripped
|
|
226
|
+
break
|
|
227
|
+
return f"{first_line} ({langs})".strip() if first_line else f"A {langs} project."
|
qaas/envelope.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
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
|
+
return bool(self.evidence) or self.reproduction.failing_test is not None
|
|
209
|
+
|
|
210
|
+
def is_fileable(self, min_confidence: float = 0.6) -> tuple[bool, str]:
|
|
211
|
+
"""Whether CLERK may file this. Returns (ok, reason-if-not).
|
|
212
|
+
|
|
213
|
+
The confidence gate is §7; the evidence gate is §2. Anything that fails
|
|
214
|
+
goes to the human review queue instead of the tracker.
|
|
215
|
+
"""
|
|
216
|
+
if not self.has_evidence():
|
|
217
|
+
return False, "no evidence: needs an artifact or a failing test"
|
|
218
|
+
if self.confidence < min_confidence:
|
|
219
|
+
return False, (
|
|
220
|
+
f"confidence {self.confidence:.2f} below gate {min_confidence:.2f}"
|
|
221
|
+
)
|
|
222
|
+
if self.reproduction.status == ReproStatus.NOT_REPRODUCIBLE:
|
|
223
|
+
return False, "not reproducible"
|
|
224
|
+
return True, ""
|
|
225
|
+
|
|
226
|
+
# -- dedupe -----------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
def fingerprint(self) -> str:
|
|
229
|
+
"""A stable structural identity for this defect.
|
|
230
|
+
|
|
231
|
+
Deliberately excludes prose, line numbers, commit sha, run id and
|
|
232
|
+
timestamps: the same defect reported by two agents in different words,
|
|
233
|
+
or found again after the file moved a few lines, must hash the same.
|
|
234
|
+
"""
|
|
235
|
+
paths = sorted(normalize_path(p) for p in self.location.paths)
|
|
236
|
+
parts = [
|
|
237
|
+
self.domain.value,
|
|
238
|
+
self.defect_class.value,
|
|
239
|
+
self.location.service or "",
|
|
240
|
+
self.location.endpoint or "",
|
|
241
|
+
self.location.ui_route or "",
|
|
242
|
+
"|".join(paths),
|
|
243
|
+
]
|
|
244
|
+
digest = hashlib.sha256("\x1f".join(parts).encode()).hexdigest()
|
|
245
|
+
return f"sha256:{digest}"
|
|
246
|
+
|
|
247
|
+
def with_fingerprint(self) -> "DefectEnvelope":
|
|
248
|
+
"""Return a copy carrying its computed fingerprint."""
|
|
249
|
+
return self.model_copy(
|
|
250
|
+
update={"dedupe": self.dedupe.model_copy(update={"fingerprint": self.fingerprint()})}
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def to_json(self) -> str:
|
|
254
|
+
return self.model_dump_json(by_alias=True, indent=2)
|
|
255
|
+
|
|
256
|
+
@classmethod
|
|
257
|
+
def from_json(cls, raw: str | bytes) -> "DefectEnvelope":
|
|
258
|
+
return cls.model_validate_json(raw)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def normalize_path(path: str) -> str:
|
|
262
|
+
r"""Reduce a cited path to the file it names, so the same file hashes alike.
|
|
263
|
+
|
|
264
|
+
`target-app/api/app/auth.py:112-113` -> `api/app/auth.py`
|
|
265
|
+
|
|
266
|
+
Two things defeated the old version, and both were found in live data rather
|
|
267
|
+
than reasoned about. Its regex was `:\d+(?::\d+)?$`, which matched `:142`
|
|
268
|
+
and `:142:5` but NOT `:112-113` -- and a line *range* is how an agent
|
|
269
|
+
naturally cites a region, so the strip almost never fired. And nothing
|
|
270
|
+
removed the repo-root prefix, so `target-app/api/app/auth.py` and
|
|
271
|
+
`api/app/auth.py` were different files as far as the hash was concerned.
|
|
272
|
+
|
|
273
|
+
The visible damage was that `occurrence_count` never left 1: the same defect
|
|
274
|
+
reported across three runs produced three identities, so `get_occurrences`
|
|
275
|
+
could never say a defect was recurring. Deduplication itself survived only
|
|
276
|
+
because CLERK matches on similarity rather than on this hash.
|
|
277
|
+
|
|
278
|
+
`scorecard._norm_path` delegates here. They must not drift: a scorer that
|
|
279
|
+
considers two paths equal while the fingerprint considers them distinct is
|
|
280
|
+
two answers to one question.
|
|
281
|
+
"""
|
|
282
|
+
p = re.sub(r":\d+(?:[-:]\d+)?$", "", path.strip().replace("\\", "/")).lstrip("./")
|
|
283
|
+
for prefix in ("target-app/", "target_app/"):
|
|
284
|
+
if p.startswith(prefix):
|
|
285
|
+
p = p[len(prefix):]
|
|
286
|
+
return p
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
#: Kept as the old name so nothing importing it breaks; it always meant this.
|
|
290
|
+
_strip_line_number = normalize_path
|