diff --git a/desloppify/app/commands/plan/triage/runner/stage_prompts_observe.py b/desloppify/app/commands/plan/triage/runner/stage_prompts_observe.py index 0172b4d6c..809c2ebd1 100644 --- a/desloppify/app/commands/plan/triage/runner/stage_prompts_observe.py +++ b/desloppify/app/commands/plan/triage/runner/stage_prompts_observe.py @@ -87,7 +87,8 @@ def build_observe_batch_prompt( title = f.get("title", fid) file_path = detail.get("file_path", "") description = detail.get("description", f.get("description", "")) - line = f"- [{fid[:8]}] ({dim}) **{title}**" + issue_token = str(detail.get("summary_hash") or fid.rsplit("::", 1)[-1]) + line = f"- [{issue_token}] ({dim}) **{title}**" if file_path: line += f" — `{file_path}`" if description: diff --git a/desloppify/app/commands/plan/triage/stages/evidence_parsing.py b/desloppify/app/commands/plan/triage/stages/evidence_parsing.py index 1197da778..63699750c 100644 --- a/desloppify/app/commands/plan/triage/stages/evidence_parsing.py +++ b/desloppify/app/commands/plan/triage/stages/evidence_parsing.py @@ -55,7 +55,7 @@ class ObserveEvidence: entries: list[ObserveAssessment] = field(default_factory=list) unparsed_citation_count: int = 0 # hashes cited without a verdict - has_parseable_ids: bool = True # False if valid_ids had no hex-hash IDs + has_parseable_ids: bool = True # False if valid_ids had no parseable short IDs @dataclass @@ -93,12 +93,18 @@ def _normalise_verdict(raw: str) -> str | None: def _build_short_map(valid_ids: set[str]) -> dict[str, str]: - """Build short-hash → full-id map from valid issue IDs.""" + """Build collision-safe short-id → full-id map from valid issue IDs.""" short_map: dict[str, str] = {} for fid in valid_ids: - short = fid.rsplit("::", 1)[-1] - if re.fullmatch(r"[0-9a-f]{6,}", short): + short = fid.rsplit("::", 1)[-1].lower() + if not short: + continue + existing = short_map.get(short) + if existing is None: short_map[short] = fid + continue + if existing != fid: + short_map.pop(short, None) return short_map @@ -109,8 +115,8 @@ def _is_valid_hash(raw_hash: str, short_map: dict[str, str], valid_ids: set[str] # --- YAML-like template parser --- -# Matches lines like: - hash: abc12345 or hash: abc12345 -_YAML_HASH_RE = re.compile(r"^\s*-?\s*hash\s*:\s*([0-9a-f]{6,})", re.IGNORECASE) +# Matches lines like: - hash: abc12345 or hash: task_param_bag +_YAML_HASH_RE = re.compile(r"^\s*-?\s*hash\s*:\s*(\S+)", re.IGNORECASE) # Matches lines like: verdict: genuine or verdict: false-positive _YAML_VERDICT_RE = re.compile(r"^\s*verdict\s*:\s*(.+)", re.IGNORECASE) # Matches lines like: verdict_reasoning: some reason @@ -214,9 +220,13 @@ def parse_observe_evidence(report: str, valid_ids: set[str]) -> ObserveEvidence: short_map = _build_short_map(valid_ids) entries = _parse_yaml_template(report, short_map, valid_ids) - # Count hashes that appear in report but weren't parsed as entries - all_hashes_in_report = set(re.findall(r"[0-9a-f]{8,}", report.lower())) + # Count short IDs that appear in report but weren't parsed as entries. valid_short_hashes = set(short_map.keys()) + all_hashes_in_report = { + match.group(1).lower() + for line in report.splitlines() + if (match := _YAML_HASH_RE.match(line)) + } cited_hashes = all_hashes_in_report & valid_short_hashes parsed_hashes = {e.issue_hash for e in entries} unparsed = len(cited_hashes - parsed_hashes) @@ -250,7 +260,7 @@ def validate_observe_evidence( if issue_count == 0: return [] - # If valid_ids contained no hex-hash IDs, verdict parsing is impossible — skip. + # If valid_ids contained no parseable short IDs, verdict parsing is impossible — skip. if not evidence.has_parseable_ids: return [] @@ -590,6 +600,10 @@ def resolve_short_hash_to_full_id(short_hash: str, valid_ids: set[str]) -> str | # Try direct match first if short_hash in valid_ids: return short_hash + short_hash = short_hash.lower() + bucket = maps.short_id_buckets.get(short_hash) + if bucket and len(bucket) == 1: + return bucket[0] # Try collision-aware short hex map resolved = maps.short_hex_map.get(short_hash) if resolved: diff --git a/desloppify/tests/commands/plan/test_triage_evidence_parsing.py b/desloppify/tests/commands/plan/test_triage_evidence_parsing.py index 135e87e58..73c74c542 100644 --- a/desloppify/tests/commands/plan/test_triage_evidence_parsing.py +++ b/desloppify/tests/commands/plan/test_triage_evidence_parsing.py @@ -144,6 +144,22 @@ def test_yaml_entries_parse_when_legacy_lines_are_present(self): assert len(evidence.entries) == 1 assert evidence.entries[0].issue_hash == "abc12345" + def test_yaml_template_parses_semantic_holistic_short_ids(self): + valid_ids = {"review::.::holistic::naming_quality::task_param_bag"} + report = ( + "- hash: task_param_bag\n" + " verdict: genuine\n" + " verdict_reasoning: Real issue confirmed in src/types.ts.\n" + " files_read: [src/types.ts]\n" + " recommendation: Refactor the shared parameter bag\n" + ) + + evidence = parse_observe_evidence(report, valid_ids) + + assert evidence.has_parseable_ids is True + assert len(evidence.entries) == 1 + assert evidence.entries[0].issue_hash == "task_param_bag" + # --------------------------------------------------------------------------- # validate_observe_evidence — field presence checks diff --git a/desloppify/tests/commands/plan/test_triage_stage_prompts_flow_direct.py b/desloppify/tests/commands/plan/test_triage_stage_prompts_flow_direct.py index bb466ddee..b541b9e07 100644 --- a/desloppify/tests/commands/plan/test_triage_stage_prompts_flow_direct.py +++ b/desloppify/tests/commands/plan/test_triage_stage_prompts_flow_direct.py @@ -64,7 +64,15 @@ def test_observe_and_sense_prompt_builders_include_expected_context(tmp_path) -> "title": "Naming issue", "description": "rename to clear name", "detail": {"dimension": "naming_quality", "file_path": "src/a.py"}, - } + }, + "review::.::holistic::naming_quality::task_param_bag": { + "title": "Holistic naming issue", + "description": "shared task parameter bag hides intent", + "detail": { + "dimension": "naming_quality", + "summary_hash": "1a2b3c4d", + }, + }, }, repo_root=tmp_path, ) @@ -73,7 +81,8 @@ def test_observe_and_sense_prompt_builders_include_expected_context(tmp_path) -> assert "observe batch 1/2" in observe assert "naming_quality" in observe assert f"Repo root: {tmp_path}" in observe - assert "[review::s" not in observe # hash prefix truncation is used + assert "[abcdef12]" in observe + assert "[1a2b3c4d]" in observe for required_field in ("- hash:", "verdict:", "verdict_reasoning:", "files_read:", "recommendation:"): assert required_field in observe assert "Do NOT run any `desloppify` commands" in observe