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/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