Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 23 additions & 9 deletions desloppify/app/commands/plan/triage/stages/evidence_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 []

Expand Down Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions desloppify/tests/commands/plan/test_triage_evidence_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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
Expand Down