ref-agents 1.0.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 (175) hide show
  1. ref_agents/__init__.py +9 -0
  2. ref_agents/api_keys.json.example +8 -0
  3. ref_agents/auth.py +129 -0
  4. ref_agents/codemap/..md +62 -0
  5. ref_agents/codemap/CODE_MAP.md +37 -0
  6. ref_agents/codemap/core.md +43 -0
  7. ref_agents/codemap/models.md +43 -0
  8. ref_agents/codemap/prompts.md +40 -0
  9. ref_agents/codemap/security.md +45 -0
  10. ref_agents/codemap/tools.md +94 -0
  11. ref_agents/codemap/tools_browser.md +44 -0
  12. ref_agents/codemap/utils.md +42 -0
  13. ref_agents/codemap/workflow.md +42 -0
  14. ref_agents/config/ai_patterns.yaml +101 -0
  15. ref_agents/config/frameworks/angular.yaml +104 -0
  16. ref_agents/config/frameworks/aspnet.yaml +84 -0
  17. ref_agents/config/frameworks/ef_core.yaml +81 -0
  18. ref_agents/config/frameworks/react.yaml +111 -0
  19. ref_agents/config/frameworks/spring_boot.yaml +117 -0
  20. ref_agents/config/languages/csharp.yaml +153 -0
  21. ref_agents/config/languages/java.yaml +188 -0
  22. ref_agents/config/languages/javascript.yaml +172 -0
  23. ref_agents/config/languages/python.yaml +153 -0
  24. ref_agents/config/languages/typescript.yaml +193 -0
  25. ref_agents/constants.py +553 -0
  26. ref_agents/core/__init__.py +15 -0
  27. ref_agents/core/config_loader.py +160 -0
  28. ref_agents/core/config_models.py +167 -0
  29. ref_agents/core/config_parsing.py +84 -0
  30. ref_agents/core/language_detector.py +388 -0
  31. ref_agents/core/validation_models.py +66 -0
  32. ref_agents/core/validation_primitives.py +176 -0
  33. ref_agents/errors.py +34 -0
  34. ref_agents/license_client.py +247 -0
  35. ref_agents/models/__init__.py +22 -0
  36. ref_agents/models/gherkin.py +45 -0
  37. ref_agents/models/hierarchy.py +80 -0
  38. ref_agents/models/invest.py +59 -0
  39. ref_agents/models/version.py +49 -0
  40. ref_agents/prompts/__init__.py +9 -0
  41. ref_agents/prompts/start_agent.py +772 -0
  42. ref_agents/rules/architecture/backend_patterns.md +43 -0
  43. ref_agents/rules/architecture/diagramming.md +100 -0
  44. ref_agents/rules/architecture/frontend_patterns.md +40 -0
  45. ref_agents/rules/architecture/impact_analysis.md +129 -0
  46. ref_agents/rules/architecture/migration_strategy.md +208 -0
  47. ref_agents/rules/architecture/regression_protocol.md +77 -0
  48. ref_agents/rules/architecture/system_design.md +97 -0
  49. ref_agents/rules/common/codemap_standard.md +97 -0
  50. ref_agents/rules/common/core_protocol.md +59 -0
  51. ref_agents/rules/common/prompt_engineering.md +294 -0
  52. ref_agents/rules/development/debugging.md +32 -0
  53. ref_agents/rules/development/implementation.md +205 -0
  54. ref_agents/rules/operations/completion.md +119 -0
  55. ref_agents/rules/operations/cutover_protocol.md +218 -0
  56. ref_agents/rules/operations/discovery.md +179 -0
  57. ref_agents/rules/operations/fix_workflow.md +87 -0
  58. ref_agents/rules/operations/forensics.md +278 -0
  59. ref_agents/rules/operations/platform.md +263 -0
  60. ref_agents/rules/operations/synchronous_flow.md +25 -0
  61. ref_agents/rules/product/ac_validation.md +25 -0
  62. ref_agents/rules/product/brainstorming.md +27 -0
  63. ref_agents/rules/product/ref_flow.md +101 -0
  64. ref_agents/rules/product/requirements_std.md +114 -0
  65. ref_agents/rules/product/spec_writing.md +235 -0
  66. ref_agents/rules/product/strategy.md +96 -0
  67. ref_agents/rules/quality/documentation_standards.md +46 -0
  68. ref_agents/rules/quality/parity_testing.md +234 -0
  69. ref_agents/rules/quality/project_documentation.md +56 -0
  70. ref_agents/rules/quality/qa_lead.md +111 -0
  71. ref_agents/rules/quality/test_design.md +146 -0
  72. ref_agents/rules/quality/testing_standards.md +293 -0
  73. ref_agents/rules/review/pr_review.md +116 -0
  74. ref_agents/rules/security/security_audit.md +83 -0
  75. ref_agents/security/__init__.py +22 -0
  76. ref_agents/security/dependency_audit.py +188 -0
  77. ref_agents/security/file_audit.py +208 -0
  78. ref_agents/security/network_scan.py +179 -0
  79. ref_agents/security/report_generator.py +313 -0
  80. ref_agents/security/secret_scan.py +252 -0
  81. ref_agents/security/url_scan.py +240 -0
  82. ref_agents/security_scan.py +236 -0
  83. ref_agents/server.py +1586 -0
  84. ref_agents/session.py +100 -0
  85. ref_agents/tool_names.py +55 -0
  86. ref_agents/tools/__init__.py +8 -0
  87. ref_agents/tools/agents_generator.py +315 -0
  88. ref_agents/tools/ai_pattern_detector.py +815 -0
  89. ref_agents/tools/brownfield_populator.py +529 -0
  90. ref_agents/tools/browser/__init__.py +50 -0
  91. ref_agents/tools/browser/evidence_verifier.py +302 -0
  92. ref_agents/tools/browser/execution_logger.py +249 -0
  93. ref_agents/tools/browser/playwright_mcp_client.py +259 -0
  94. ref_agents/tools/browser/screenshot_utils.py +184 -0
  95. ref_agents/tools/browser/test_executor.py +537 -0
  96. ref_agents/tools/code_quality_scanner.py +629 -0
  97. ref_agents/tools/codemap/..md +93 -0
  98. ref_agents/tools/codemap/CODE_MAP.md +30 -0
  99. ref_agents/tools/codemap/browser.md +44 -0
  100. ref_agents/tools/codemap.py +403 -0
  101. ref_agents/tools/codemap_freshness.py +234 -0
  102. ref_agents/tools/comment_smell_scanner.py +346 -0
  103. ref_agents/tools/complexity.py +436 -0
  104. ref_agents/tools/complexity_ast.py +333 -0
  105. ref_agents/tools/compliance.py +246 -0
  106. ref_agents/tools/compliance_remediation.py +846 -0
  107. ref_agents/tools/context_graph.py +839 -0
  108. ref_agents/tools/context_manager.py +550 -0
  109. ref_agents/tools/context_tools.py +121 -0
  110. ref_agents/tools/cross_repo_linker.py +393 -0
  111. ref_agents/tools/dead_code_scanner.py +637 -0
  112. ref_agents/tools/debt_scanner.py +1092 -0
  113. ref_agents/tools/dependency_graph.py +272 -0
  114. ref_agents/tools/discovery_audit.py +372 -0
  115. ref_agents/tools/docs_scanner.py +600 -0
  116. ref_agents/tools/evaluate_gate.py +119 -0
  117. ref_agents/tools/external_detector.py +524 -0
  118. ref_agents/tools/features_generator.py +282 -0
  119. ref_agents/tools/flow_gap_detector.py +373 -0
  120. ref_agents/tools/flow_mapper.py +327 -0
  121. ref_agents/tools/full_suite_runner.py +740 -0
  122. ref_agents/tools/gherkin_parser.py +227 -0
  123. ref_agents/tools/guard_tools.py +139 -0
  124. ref_agents/tools/handoff_tools.py +282 -0
  125. ref_agents/tools/health_scanner.py +1211 -0
  126. ref_agents/tools/hierarchy_manager.py +289 -0
  127. ref_agents/tools/invest_scorer.py +249 -0
  128. ref_agents/tools/jira_confluence_export.py +306 -0
  129. ref_agents/tools/json_output.py +76 -0
  130. ref_agents/tools/migration_mapper.py +946 -0
  131. ref_agents/tools/migration_readiness_scanner.py +209 -0
  132. ref_agents/tools/pattern_learner.py +522 -0
  133. ref_agents/tools/report_utils.py +155 -0
  134. ref_agents/tools/requirements_serializer.py +225 -0
  135. ref_agents/tools/security_audit_tool.py +106 -0
  136. ref_agents/tools/sequencing_engine.py +288 -0
  137. ref_agents/tools/summary_generator.py +275 -0
  138. ref_agents/tools/symbol_resolver.py +306 -0
  139. ref_agents/tools/symbol_smoke_runner.py +336 -0
  140. ref_agents/tools/test_plan_validator.py +189 -0
  141. ref_agents/tools/test_smell_walker.py +902 -0
  142. ref_agents/tools/tier1_fixer.py +502 -0
  143. ref_agents/tools/validators/__init__.py +419 -0
  144. ref_agents/tools/validators/architect.py +268 -0
  145. ref_agents/tools/validators/cutover_engineer.py +167 -0
  146. ref_agents/tools/validators/developer.py +180 -0
  147. ref_agents/tools/validators/discovery.py +150 -0
  148. ref_agents/tools/validators/forensic_engineer.py +191 -0
  149. ref_agents/tools/validators/impact_architect.py +181 -0
  150. ref_agents/tools/validators/migration_planner.py +166 -0
  151. ref_agents/tools/validators/parity_tester.py +155 -0
  152. ref_agents/tools/validators/platform_engineer.py +134 -0
  153. ref_agents/tools/validators/pr_reviewer.py +129 -0
  154. ref_agents/tools/validators/product_manager.py +291 -0
  155. ref_agents/tools/validators/qa_lead.py +172 -0
  156. ref_agents/tools/validators/scrum_master.py +212 -0
  157. ref_agents/tools/validators/security_owner.py +162 -0
  158. ref_agents/tools/validators/specifier.py +134 -0
  159. ref_agents/tools/validators/strategist.py +149 -0
  160. ref_agents/tools/validators/tester.py +121 -0
  161. ref_agents/tools/version_manager.py +202 -0
  162. ref_agents/tools/workflow_tools.py +1549 -0
  163. ref_agents/utils/__init__.py +21 -0
  164. ref_agents/utils/git_utils.py +351 -0
  165. ref_agents/utils/handoff_logger.py +368 -0
  166. ref_agents/utils/ignore_matcher.py +270 -0
  167. ref_agents/workflow/__init__.py +19 -0
  168. ref_agents/workflow/capabilities.py +328 -0
  169. ref_agents/workflow/state_machine.py +708 -0
  170. ref_agents/workflow/transitions.py +658 -0
  171. ref_agents-1.0.0.dist-info/METADATA +365 -0
  172. ref_agents-1.0.0.dist-info/RECORD +175 -0
  173. ref_agents-1.0.0.dist-info/WHEEL +4 -0
  174. ref_agents-1.0.0.dist-info/entry_points.txt +2 -0
  175. ref_agents-1.0.0.dist-info/licenses/LICENSE +115 -0
@@ -0,0 +1,336 @@
1
+ """Symbol smoke runner for STORY-TQ-003 M3b.
2
+
3
+ Reads a story's test plan, generates import-only smoke tests under
4
+ ``tests/generated/STORY-XXX/``, runs ``pytest`` against that directory, and
5
+ emits ``symbol_drift.import_failed`` findings when a grounded symbol cannot
6
+ be imported by its recorded module path.
7
+
8
+ The generator handles only ``code_map`` and ``story_public_interface`` sources
9
+ in v1. OpenAPI and ``.pyi`` sources are recorded but skipped here (deferred).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import shutil
16
+ import subprocess
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ # NOTE: ``shutil`` is imported for ``shutil.which`` only — never for ``rmtree``.
22
+ # Generated test files are cleaned per-file with ``Path.unlink`` (see
23
+ # ``_reset_generated_dir``) to stay inside the REF security_scan allowlist.
24
+ import structlog
25
+
26
+ logger = structlog.get_logger(__name__)
27
+
28
+
29
+ SMOKE_RUN_TIMEOUT_SECONDS: int = 60
30
+ SUPPORTED_SOURCES: frozenset[str] = frozenset({"code_map", "story_public_interface"})
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class SmokeFinding:
35
+ """Single ``symbol_drift.import_failed`` finding.
36
+
37
+ Attributes:
38
+ pattern_id: Constant ``symbol_drift.import_failed``.
39
+ severity: ``HIGH``.
40
+ expected_symbol: Symbol name from test plan.
41
+ expected_module_path: Module path from test plan.
42
+ error_message: Captured import/attribute error.
43
+ file_path: Path of the generated test file.
44
+ line: Line where the failing import occurs.
45
+ test_case_id: Originating test case identifier.
46
+ """
47
+
48
+ __test__ = False
49
+
50
+ pattern_id: str
51
+ severity: str
52
+ expected_symbol: str
53
+ expected_module_path: str
54
+ error_message: str
55
+ file_path: str
56
+ line: int
57
+ test_case_id: str
58
+
59
+
60
+ def run_smoke_tests(
61
+ story_id: str,
62
+ project_root: Path,
63
+ ) -> list[SmokeFinding]:
64
+ """Generate + execute import-only smoke tests for one story.
65
+
66
+ Args:
67
+ story_id: Active story identifier.
68
+ project_root: Project root containing ``tests/plans/`` and source.
69
+
70
+ Returns:
71
+ List of findings. Empty when every grounded symbol imports cleanly OR
72
+ when the plan contains no grounded entries.
73
+ """
74
+ plan_path = project_root / "tests" / "plans" / f"{story_id}.json"
75
+ if not plan_path.exists():
76
+ logger.info(
77
+ "symbol_smoke_plan_missing",
78
+ story_id=story_id,
79
+ path=str(plan_path),
80
+ )
81
+ return []
82
+
83
+ try:
84
+ plan = json.loads(plan_path.read_text(encoding="utf-8"))
85
+ except (OSError, json.JSONDecodeError) as exc:
86
+ logger.warning("symbol_smoke_plan_unreadable", error=str(exc))
87
+ return []
88
+
89
+ grounded = _collect_grounded_entries(plan)
90
+ target_dir = project_root / "tests" / "generated" / story_id
91
+ _reset_generated_dir(target_dir)
92
+
93
+ if not grounded:
94
+ logger.info("symbol_smoke_no_grounded_entries", story_id=story_id)
95
+ return []
96
+
97
+ generated_files: dict[str, dict[str, Any]] = {}
98
+ for entry in grounded:
99
+ path = _write_smoke_test(target_dir, story_id, entry)
100
+ generated_files[path.name] = entry
101
+
102
+ pytest_output = _run_pytest_on_dir(target_dir, project_root)
103
+ findings = _parse_pytest_output(pytest_output, generated_files, target_dir)
104
+ logger.info(
105
+ "symbol_smoke_complete",
106
+ story_id=story_id,
107
+ generated=len(generated_files),
108
+ findings=len(findings),
109
+ )
110
+ return findings
111
+
112
+
113
+ def _collect_grounded_entries(plan: dict[str, Any]) -> list[dict[str, Any]]:
114
+ test_cases = plan.get("test_cases", [])
115
+ grounded: list[dict[str, Any]] = []
116
+ if not isinstance(test_cases, list):
117
+ return grounded
118
+ for tc in test_cases:
119
+ if not isinstance(tc, dict):
120
+ continue
121
+ sut = tc.get("symbol_under_test")
122
+ if not isinstance(sut, dict):
123
+ continue
124
+ source = sut.get("source")
125
+ name = sut.get("name")
126
+ module_path = sut.get("module_path")
127
+ if source not in SUPPORTED_SOURCES:
128
+ continue
129
+ if not isinstance(name, str) or not name.strip():
130
+ continue
131
+ if not isinstance(module_path, str) or not module_path.strip():
132
+ # Cannot generate an import without a target module.
133
+ continue
134
+ grounded.append(
135
+ {
136
+ "test_case_id": str(tc.get("id", "UNKNOWN")),
137
+ "name": name,
138
+ "module_path": module_path,
139
+ "source": source,
140
+ "grounding": sut.get("grounding", ""),
141
+ }
142
+ )
143
+ return grounded
144
+
145
+
146
+ def _reset_generated_dir(target_dir: Path) -> None:
147
+ """Reset target_dir bounded to its own python files (security: no rmtree).
148
+
149
+ Removes every ``test_*.py`` plus the ``__init__.py`` placeholder that the
150
+ runner itself produced. Foreign files are left in place — operator visibility.
151
+ """
152
+ target_dir.mkdir(parents=True, exist_ok=True)
153
+ for stale in target_dir.glob("test_*.py"):
154
+ try:
155
+ stale.unlink()
156
+ except OSError as exc:
157
+ logger.warning(
158
+ "symbol_smoke_unlink_failed",
159
+ path=str(stale),
160
+ error=str(exc),
161
+ )
162
+ init_file = target_dir / "__init__.py"
163
+ init_file.write_text("", encoding="utf-8")
164
+
165
+
166
+ def _write_smoke_test(
167
+ target_dir: Path,
168
+ story_id: str,
169
+ entry: dict[str, Any],
170
+ ) -> Path:
171
+ test_case_id = entry["test_case_id"]
172
+ safe_id = test_case_id.replace("-", "_")
173
+ name = entry["name"]
174
+ module_path = entry["module_path"]
175
+ grounding = entry["grounding"]
176
+
177
+ # For class.method form, import the attribute root.
178
+ import_target = name.split(".", 1)[0]
179
+
180
+ body = (
181
+ f"# AUTO-GENERATED by REF symbol_smoke_runner (STORY-TQ-003 M3b).\n"
182
+ f"# Source story: {story_id}, test case {test_case_id}\n"
183
+ f"# Grounding: {grounding}\n"
184
+ f"# DO NOT EDIT — regenerated on every Tester run.\n"
185
+ f"\n"
186
+ f"from {module_path} import {import_target} # noqa: F401\n"
187
+ f"\n"
188
+ f"def test_{safe_id}_symbol_present() -> None:\n"
189
+ f' """Smoke test — confirms `{name}` is importable from `{module_path}`."""\n'
190
+ f" assert {import_target} is not None\n"
191
+ )
192
+ path = target_dir / f"test_{safe_id}.py"
193
+ path.write_text(body, encoding="utf-8")
194
+ return path
195
+
196
+
197
+ def _run_pytest_on_dir(
198
+ target_dir: Path,
199
+ project_root: Path,
200
+ ) -> str:
201
+ if not shutil.which("pytest") and not (project_root / ".venv").exists():
202
+ logger.warning("pytest_not_available")
203
+ return ""
204
+ # Resolve pytest via uv if available so we honour the project's venv.
205
+ pytest_cmd = (
206
+ ["uv", "run", "pytest", "--no-header", "-q", str(target_dir)]
207
+ if shutil.which("uv")
208
+ else ["pytest", "--no-header", "-q", str(target_dir)]
209
+ )
210
+ try:
211
+ result = subprocess.run(
212
+ pytest_cmd,
213
+ capture_output=True,
214
+ text=True,
215
+ timeout=SMOKE_RUN_TIMEOUT_SECONDS,
216
+ cwd=str(project_root),
217
+ check=False,
218
+ )
219
+ except (OSError, subprocess.TimeoutExpired) as exc:
220
+ logger.warning("symbol_smoke_pytest_failed", error=str(exc))
221
+ return ""
222
+ return (result.stdout or "") + "\n" + (result.stderr or "")
223
+
224
+
225
+ def _parse_pytest_output(
226
+ output: str,
227
+ generated_files: dict[str, dict[str, Any]],
228
+ target_dir: Path,
229
+ ) -> list[SmokeFinding]:
230
+ """Best-effort extraction of import failures from pytest output.
231
+
232
+ pytest's collection failures surface as ``ERROR <path>`` and
233
+ ``ImportError: ...`` / ``ModuleNotFoundError: ...`` lines. We attribute
234
+ each failure back to its generating entry by matching the file basename.
235
+ """
236
+ findings: list[SmokeFinding] = []
237
+ if not output:
238
+ return findings
239
+
240
+ failure_files: set[str] = set()
241
+ for line in output.splitlines():
242
+ stripped = line.strip()
243
+ # Collection error format: "ERROR tests/generated/STORY-X/test_TC_001.py"
244
+ if stripped.startswith("ERROR ") and ".py" in stripped:
245
+ failure_files.add(Path(stripped.split()[1]).name)
246
+ # Test failure format: "FAILED tests/generated/STORY-X/test_TC_001.py::test_..."
247
+ elif stripped.startswith("FAILED ") and ".py" in stripped:
248
+ failure_files.add(Path(stripped.split()[1].split("::")[0]).name)
249
+
250
+ for filename in failure_files:
251
+ entry = generated_files.get(filename)
252
+ if entry is None:
253
+ continue
254
+ error_message = _extract_error_for_file(output, filename)
255
+ findings.append(
256
+ SmokeFinding(
257
+ pattern_id="symbol_drift.import_failed",
258
+ severity="HIGH",
259
+ expected_symbol=entry["name"],
260
+ expected_module_path=entry["module_path"],
261
+ error_message=error_message,
262
+ file_path=str(target_dir / filename),
263
+ line=_FROM_IMPORT_LINE,
264
+ test_case_id=entry["test_case_id"],
265
+ )
266
+ )
267
+ return findings
268
+
269
+
270
+ _FROM_IMPORT_LINE: int = 6 # The `from X import Y` line inside generated files.
271
+
272
+
273
+ def _extract_error_for_file(output: str, filename: str) -> str:
274
+ lines = output.splitlines()
275
+ capture = False
276
+ captured: list[str] = []
277
+ for line in lines:
278
+ if filename in line and (
279
+ line.lstrip().startswith("ERROR ")
280
+ or "ImportError" in line
281
+ or "ModuleNotFoundError" in line
282
+ ):
283
+ capture = True
284
+ if capture:
285
+ captured.append(line)
286
+ if "ImportError:" in line or "ModuleNotFoundError:" in line:
287
+ return "\n".join(captured[-5:])
288
+ return "\n".join(captured[-3:]) if captured else "Import failed (see pytest log)."
289
+
290
+
291
+ def emit_findings_to_health_report(
292
+ findings: list[SmokeFinding],
293
+ project_root: Path,
294
+ ) -> None:
295
+ """Append ``symbol_drift.import_failed`` entries to ``health_report.json``.
296
+
297
+ Findings appear in the ``findings`` list with scanner ``"symbol_smoke"``
298
+ and severity ``"critical"`` so the existing auto-remediation bootstrap
299
+ picks them up (mirrors test_smell HIGH routing).
300
+
301
+ No-op when the report file is absent — caller should run a health scan
302
+ first if they want the routing.
303
+ """
304
+ if not findings:
305
+ return
306
+ report_path = (
307
+ project_root / "ref-reports" / "platform_engineer" / "health_report.json"
308
+ )
309
+ if not report_path.exists():
310
+ logger.info("symbol_smoke_skip_routing_no_report", path=str(report_path))
311
+ return
312
+ try:
313
+ data = json.loads(report_path.read_text(encoding="utf-8"))
314
+ except (OSError, json.JSONDecodeError) as exc:
315
+ logger.warning("symbol_smoke_route_failed", error=str(exc))
316
+ return
317
+ existing = data.get("findings", []) if isinstance(data, dict) else []
318
+ for finding in findings:
319
+ existing.append(
320
+ {
321
+ "scanner": "symbol_smoke",
322
+ "file": finding.file_path,
323
+ "line": finding.line,
324
+ "severity": "critical",
325
+ "rule": finding.pattern_id,
326
+ "message": (
327
+ f"{finding.pattern_id}: expected `{finding.expected_symbol}` "
328
+ f"from `{finding.expected_module_path}` — {finding.error_message[:120]}"
329
+ ),
330
+ "owner": "developer",
331
+ }
332
+ )
333
+ if isinstance(data, dict):
334
+ data["findings"] = existing
335
+ data["critical"] = int(data.get("critical", 0)) + len(findings)
336
+ report_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
@@ -0,0 +1,189 @@
1
+ """Test plan validator for STORY-TQ-003 — schema check for ``symbol_under_test``.
2
+
3
+ Validates the optional ``symbol_under_test`` object on each test case in
4
+ ``tests/plans/STORY-XXX.json``. Pre-TQ-003 plans (no field) pass without
5
+ findings. Malformed entries (missing required sub-fields, unknown ``source``
6
+ values) produce per-entry validation errors.
7
+
8
+ Cross-checking the resolver's output (FR-6 invented-name detection) is rule-text
9
+ only in v1 — see ``docs/specs/SPEC-STORY-TQ-003.md`` Open Question 1.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ from ref_agents.tools.symbol_resolver import VALID_SOURCES, is_valid_source
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ValidationError:
22
+ """Single validator error referencing the offending test case.
23
+
24
+ Attributes:
25
+ test_case_id: Identifier of the offending test case.
26
+ field: Dotted path of the offending field (e.g. ``symbol_under_test.name``).
27
+ message: Human-readable description.
28
+ """
29
+
30
+ test_case_id: str
31
+ field: str
32
+ message: str
33
+
34
+
35
+ def validate(plan: dict[str, Any]) -> list[ValidationError]:
36
+ """Return validation errors for ``plan``.
37
+
38
+ Empty list means the plan passes the v1 schema check. Pre-TQ-003 plans (no
39
+ ``symbol_under_test`` anywhere) return an empty list.
40
+
41
+ Args:
42
+ plan: Parsed test plan dict (loaded from
43
+ ``tests/plans/STORY-XXX.json``).
44
+
45
+ Returns:
46
+ Ordered list of validation errors. Empty when the plan is valid.
47
+ """
48
+ errors: list[ValidationError] = []
49
+ test_cases = plan.get("test_cases", [])
50
+ if not isinstance(test_cases, list):
51
+ errors.append(
52
+ ValidationError(
53
+ test_case_id="<root>",
54
+ field="test_cases",
55
+ message="`test_cases` must be a list.",
56
+ )
57
+ )
58
+ return errors
59
+
60
+ for tc in test_cases:
61
+ if not isinstance(tc, dict):
62
+ errors.append(
63
+ ValidationError(
64
+ test_case_id="<unknown>",
65
+ field="<test_case>",
66
+ message="Each test case must be an object.",
67
+ )
68
+ )
69
+ continue
70
+ tc_id = str(tc.get("id", "<missing-id>"))
71
+ sut = tc.get("symbol_under_test")
72
+ if sut is None:
73
+ # Pre-TQ-003 / ungrounded path — FR-5, FR-15.
74
+ continue
75
+ errors.extend(_validate_symbol_under_test(tc_id, sut))
76
+ return errors
77
+
78
+
79
+ def _validate_symbol_under_test(
80
+ tc_id: str,
81
+ sut: Any,
82
+ ) -> list[ValidationError]:
83
+ if not isinstance(sut, dict):
84
+ return [
85
+ ValidationError(
86
+ test_case_id=tc_id,
87
+ field="symbol_under_test",
88
+ message="`symbol_under_test` must be an object.",
89
+ )
90
+ ]
91
+
92
+ errors: list[ValidationError] = []
93
+
94
+ name = sut.get("name")
95
+ if not isinstance(name, str) or not name.strip():
96
+ errors.append(
97
+ ValidationError(
98
+ test_case_id=tc_id,
99
+ field="symbol_under_test.name",
100
+ message="`name` is required and must be a non-empty string.",
101
+ )
102
+ )
103
+
104
+ source = sut.get("source")
105
+ if not isinstance(source, str):
106
+ errors.append(
107
+ ValidationError(
108
+ test_case_id=tc_id,
109
+ field="symbol_under_test.source",
110
+ message=(
111
+ "`source` is required and must be one of: "
112
+ + ", ".join(VALID_SOURCES)
113
+ + "."
114
+ ),
115
+ )
116
+ )
117
+ elif not is_valid_source(source):
118
+ errors.append(
119
+ ValidationError(
120
+ test_case_id=tc_id,
121
+ field="symbol_under_test.source",
122
+ message=(
123
+ f"`source` value `{source}` is unknown. Use one of: "
124
+ + ", ".join(VALID_SOURCES)
125
+ + "."
126
+ ),
127
+ )
128
+ )
129
+
130
+ grounding = sut.get("grounding")
131
+ if not isinstance(grounding, str) or not grounding.strip():
132
+ errors.append(
133
+ ValidationError(
134
+ test_case_id=tc_id,
135
+ field="symbol_under_test.grounding",
136
+ message="`grounding` is required and must be a non-empty string.",
137
+ )
138
+ )
139
+
140
+ # `module_path` is optional; if present, must be a string.
141
+ module_path = sut.get("module_path")
142
+ if module_path is not None and not isinstance(module_path, str):
143
+ errors.append(
144
+ ValidationError(
145
+ test_case_id=tc_id,
146
+ field="symbol_under_test.module_path",
147
+ message="`module_path`, when present, must be a string.",
148
+ )
149
+ )
150
+
151
+ return errors
152
+
153
+
154
+ def grounding_summary(plan: dict[str, Any]) -> dict[str, Any]:
155
+ """Compute the FR-14 grounding summary block.
156
+
157
+ Args:
158
+ plan: Parsed test plan dict.
159
+
160
+ Returns:
161
+ Dict with keys ``total_test_cases``, ``grounded``, ``ungrounded``,
162
+ ``by_source``.
163
+ """
164
+ test_cases = plan.get("test_cases", [])
165
+ total = 0
166
+ grounded = 0
167
+ by_source: dict[str, int] = {src: 0 for src in VALID_SOURCES if src != "none"}
168
+
169
+ for tc in test_cases:
170
+ if not isinstance(tc, dict):
171
+ continue
172
+ total += 1
173
+ sut = tc.get("symbol_under_test")
174
+ if not isinstance(sut, dict):
175
+ continue
176
+ source = sut.get("source")
177
+ if not isinstance(source, str) or not is_valid_source(source):
178
+ continue
179
+ if source == "none":
180
+ continue
181
+ grounded += 1
182
+ by_source[source] = by_source.get(source, 0) + 1
183
+
184
+ return {
185
+ "total_test_cases": total,
186
+ "grounded": grounded,
187
+ "ungrounded": total - grounded,
188
+ "by_source": by_source,
189
+ }