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.
- ref_agents/__init__.py +9 -0
- ref_agents/api_keys.json.example +8 -0
- ref_agents/auth.py +129 -0
- ref_agents/codemap/..md +62 -0
- ref_agents/codemap/CODE_MAP.md +37 -0
- ref_agents/codemap/core.md +43 -0
- ref_agents/codemap/models.md +43 -0
- ref_agents/codemap/prompts.md +40 -0
- ref_agents/codemap/security.md +45 -0
- ref_agents/codemap/tools.md +94 -0
- ref_agents/codemap/tools_browser.md +44 -0
- ref_agents/codemap/utils.md +42 -0
- ref_agents/codemap/workflow.md +42 -0
- ref_agents/config/ai_patterns.yaml +101 -0
- ref_agents/config/frameworks/angular.yaml +104 -0
- ref_agents/config/frameworks/aspnet.yaml +84 -0
- ref_agents/config/frameworks/ef_core.yaml +81 -0
- ref_agents/config/frameworks/react.yaml +111 -0
- ref_agents/config/frameworks/spring_boot.yaml +117 -0
- ref_agents/config/languages/csharp.yaml +153 -0
- ref_agents/config/languages/java.yaml +188 -0
- ref_agents/config/languages/javascript.yaml +172 -0
- ref_agents/config/languages/python.yaml +153 -0
- ref_agents/config/languages/typescript.yaml +193 -0
- ref_agents/constants.py +553 -0
- ref_agents/core/__init__.py +15 -0
- ref_agents/core/config_loader.py +160 -0
- ref_agents/core/config_models.py +167 -0
- ref_agents/core/config_parsing.py +84 -0
- ref_agents/core/language_detector.py +388 -0
- ref_agents/core/validation_models.py +66 -0
- ref_agents/core/validation_primitives.py +176 -0
- ref_agents/errors.py +34 -0
- ref_agents/license_client.py +247 -0
- ref_agents/models/__init__.py +22 -0
- ref_agents/models/gherkin.py +45 -0
- ref_agents/models/hierarchy.py +80 -0
- ref_agents/models/invest.py +59 -0
- ref_agents/models/version.py +49 -0
- ref_agents/prompts/__init__.py +9 -0
- ref_agents/prompts/start_agent.py +772 -0
- ref_agents/rules/architecture/backend_patterns.md +43 -0
- ref_agents/rules/architecture/diagramming.md +100 -0
- ref_agents/rules/architecture/frontend_patterns.md +40 -0
- ref_agents/rules/architecture/impact_analysis.md +129 -0
- ref_agents/rules/architecture/migration_strategy.md +208 -0
- ref_agents/rules/architecture/regression_protocol.md +77 -0
- ref_agents/rules/architecture/system_design.md +97 -0
- ref_agents/rules/common/codemap_standard.md +97 -0
- ref_agents/rules/common/core_protocol.md +59 -0
- ref_agents/rules/common/prompt_engineering.md +294 -0
- ref_agents/rules/development/debugging.md +32 -0
- ref_agents/rules/development/implementation.md +205 -0
- ref_agents/rules/operations/completion.md +119 -0
- ref_agents/rules/operations/cutover_protocol.md +218 -0
- ref_agents/rules/operations/discovery.md +179 -0
- ref_agents/rules/operations/fix_workflow.md +87 -0
- ref_agents/rules/operations/forensics.md +278 -0
- ref_agents/rules/operations/platform.md +263 -0
- ref_agents/rules/operations/synchronous_flow.md +25 -0
- ref_agents/rules/product/ac_validation.md +25 -0
- ref_agents/rules/product/brainstorming.md +27 -0
- ref_agents/rules/product/ref_flow.md +101 -0
- ref_agents/rules/product/requirements_std.md +114 -0
- ref_agents/rules/product/spec_writing.md +235 -0
- ref_agents/rules/product/strategy.md +96 -0
- ref_agents/rules/quality/documentation_standards.md +46 -0
- ref_agents/rules/quality/parity_testing.md +234 -0
- ref_agents/rules/quality/project_documentation.md +56 -0
- ref_agents/rules/quality/qa_lead.md +111 -0
- ref_agents/rules/quality/test_design.md +146 -0
- ref_agents/rules/quality/testing_standards.md +293 -0
- ref_agents/rules/review/pr_review.md +116 -0
- ref_agents/rules/security/security_audit.md +83 -0
- ref_agents/security/__init__.py +22 -0
- ref_agents/security/dependency_audit.py +188 -0
- ref_agents/security/file_audit.py +208 -0
- ref_agents/security/network_scan.py +179 -0
- ref_agents/security/report_generator.py +313 -0
- ref_agents/security/secret_scan.py +252 -0
- ref_agents/security/url_scan.py +240 -0
- ref_agents/security_scan.py +236 -0
- ref_agents/server.py +1586 -0
- ref_agents/session.py +100 -0
- ref_agents/tool_names.py +55 -0
- ref_agents/tools/__init__.py +8 -0
- ref_agents/tools/agents_generator.py +315 -0
- ref_agents/tools/ai_pattern_detector.py +815 -0
- ref_agents/tools/brownfield_populator.py +529 -0
- ref_agents/tools/browser/__init__.py +50 -0
- ref_agents/tools/browser/evidence_verifier.py +302 -0
- ref_agents/tools/browser/execution_logger.py +249 -0
- ref_agents/tools/browser/playwright_mcp_client.py +259 -0
- ref_agents/tools/browser/screenshot_utils.py +184 -0
- ref_agents/tools/browser/test_executor.py +537 -0
- ref_agents/tools/code_quality_scanner.py +629 -0
- ref_agents/tools/codemap/..md +93 -0
- ref_agents/tools/codemap/CODE_MAP.md +30 -0
- ref_agents/tools/codemap/browser.md +44 -0
- ref_agents/tools/codemap.py +403 -0
- ref_agents/tools/codemap_freshness.py +234 -0
- ref_agents/tools/comment_smell_scanner.py +346 -0
- ref_agents/tools/complexity.py +436 -0
- ref_agents/tools/complexity_ast.py +333 -0
- ref_agents/tools/compliance.py +246 -0
- ref_agents/tools/compliance_remediation.py +846 -0
- ref_agents/tools/context_graph.py +839 -0
- ref_agents/tools/context_manager.py +550 -0
- ref_agents/tools/context_tools.py +121 -0
- ref_agents/tools/cross_repo_linker.py +393 -0
- ref_agents/tools/dead_code_scanner.py +637 -0
- ref_agents/tools/debt_scanner.py +1092 -0
- ref_agents/tools/dependency_graph.py +272 -0
- ref_agents/tools/discovery_audit.py +372 -0
- ref_agents/tools/docs_scanner.py +600 -0
- ref_agents/tools/evaluate_gate.py +119 -0
- ref_agents/tools/external_detector.py +524 -0
- ref_agents/tools/features_generator.py +282 -0
- ref_agents/tools/flow_gap_detector.py +373 -0
- ref_agents/tools/flow_mapper.py +327 -0
- ref_agents/tools/full_suite_runner.py +740 -0
- ref_agents/tools/gherkin_parser.py +227 -0
- ref_agents/tools/guard_tools.py +139 -0
- ref_agents/tools/handoff_tools.py +282 -0
- ref_agents/tools/health_scanner.py +1211 -0
- ref_agents/tools/hierarchy_manager.py +289 -0
- ref_agents/tools/invest_scorer.py +249 -0
- ref_agents/tools/jira_confluence_export.py +306 -0
- ref_agents/tools/json_output.py +76 -0
- ref_agents/tools/migration_mapper.py +946 -0
- ref_agents/tools/migration_readiness_scanner.py +209 -0
- ref_agents/tools/pattern_learner.py +522 -0
- ref_agents/tools/report_utils.py +155 -0
- ref_agents/tools/requirements_serializer.py +225 -0
- ref_agents/tools/security_audit_tool.py +106 -0
- ref_agents/tools/sequencing_engine.py +288 -0
- ref_agents/tools/summary_generator.py +275 -0
- ref_agents/tools/symbol_resolver.py +306 -0
- ref_agents/tools/symbol_smoke_runner.py +336 -0
- ref_agents/tools/test_plan_validator.py +189 -0
- ref_agents/tools/test_smell_walker.py +902 -0
- ref_agents/tools/tier1_fixer.py +502 -0
- ref_agents/tools/validators/__init__.py +419 -0
- ref_agents/tools/validators/architect.py +268 -0
- ref_agents/tools/validators/cutover_engineer.py +167 -0
- ref_agents/tools/validators/developer.py +180 -0
- ref_agents/tools/validators/discovery.py +150 -0
- ref_agents/tools/validators/forensic_engineer.py +191 -0
- ref_agents/tools/validators/impact_architect.py +181 -0
- ref_agents/tools/validators/migration_planner.py +166 -0
- ref_agents/tools/validators/parity_tester.py +155 -0
- ref_agents/tools/validators/platform_engineer.py +134 -0
- ref_agents/tools/validators/pr_reviewer.py +129 -0
- ref_agents/tools/validators/product_manager.py +291 -0
- ref_agents/tools/validators/qa_lead.py +172 -0
- ref_agents/tools/validators/scrum_master.py +212 -0
- ref_agents/tools/validators/security_owner.py +162 -0
- ref_agents/tools/validators/specifier.py +134 -0
- ref_agents/tools/validators/strategist.py +149 -0
- ref_agents/tools/validators/tester.py +121 -0
- ref_agents/tools/version_manager.py +202 -0
- ref_agents/tools/workflow_tools.py +1549 -0
- ref_agents/utils/__init__.py +21 -0
- ref_agents/utils/git_utils.py +351 -0
- ref_agents/utils/handoff_logger.py +368 -0
- ref_agents/utils/ignore_matcher.py +270 -0
- ref_agents/workflow/__init__.py +19 -0
- ref_agents/workflow/capabilities.py +328 -0
- ref_agents/workflow/state_machine.py +708 -0
- ref_agents/workflow/transitions.py +658 -0
- ref_agents-1.0.0.dist-info/METADATA +365 -0
- ref_agents-1.0.0.dist-info/RECORD +175 -0
- ref_agents-1.0.0.dist-info/WHEEL +4 -0
- ref_agents-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|
+
}
|