diff --git a/.github/workflows/scripts/tweet_release.py b/.github/workflows/scripts/tweet_release.py index 98a4187d..73d097a0 100644 --- a/.github/workflows/scripts/tweet_release.py +++ b/.github/workflows/scripts/tweet_release.py @@ -21,6 +21,12 @@ import requests import tweepy +REQUEST_TIMEOUT_SECONDS = 30 + + +class ReleaseTweetError(RuntimeError): + """Stage-specific failure surfaced by the release tweet workflow.""" + # ── Extract section headers from release markdown ──────────────────────────── @@ -37,17 +43,16 @@ def extract_headers(body: str) -> list[str]: def generate_tweet_and_prompt(tag: str, headers: list[str], url: str) -> dict: """Ask Claude to produce a tweet and an image-gen prompt.""" - client = anthropic.Anthropic() - headers_text = "\n".join(f"- {h}" for h in headers) - - msg = client.messages.create( - model="claude-sonnet-4-20250514", - max_tokens=1024, - messages=[ - { - "role": "user", - "content": f"""You're writing a tweet and an image prompt for a software release announcement. + try: + client = anthropic.Anthropic() + msg = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=1024, + messages=[ + { + "role": "user", + "content": f"""You're writing a tweet and an image prompt for a software release announcement. Project: desloppify — a CLI tool that tracks codebase health and technical debt. Release: {tag} @@ -74,15 +79,26 @@ def generate_tweet_and_prompt(tag: str, headers: list[str], url: str) -> dict: The overall vibe should be "someone explaining the release on a whiteboard with too much enthusiasm". Return ONLY valid JSON, no markdown fences.""", - } - ], - ) - - text = msg.content[0].text + } + ], + ) + except Exception as exc: + raise ReleaseTweetError(f"Anthropic request failed: {exc}") from exc + + try: + text = msg.content[0].text + except (AttributeError, IndexError, KeyError, TypeError) as exc: + raise ReleaseTweetError("Anthropic response did not include text content") from exc # Strip markdown fences if Claude adds them anyway text = re.sub(r"^```json\s*", "", text.strip()) text = re.sub(r"\s*```$", "", text.strip()) - return json.loads(text) + try: + result = json.loads(text) + except (TypeError, ValueError) as exc: + raise ReleaseTweetError("Anthropic returned invalid JSON payload") from exc + if not isinstance(result, dict): + raise ReleaseTweetError("Anthropic returned a non-object JSON payload") + return result # ── Generate image via fal.ai Nano Banana 2 ────────────────────────────────── @@ -92,37 +108,56 @@ def generate_tweet_and_prompt(tag: str, headers: list[str], url: str) -> dict: def generate_image(prompt: str, api_key: str) -> str: """Call fal.ai and return the image URL.""" - resp = requests.post( - FAL_ENDPOINT, - headers={ - "Authorization": f"Key {api_key}", - "Content-Type": "application/json", - }, - json={ - "prompt": prompt, - "num_images": 1, - "aspect_ratio": "1:1", - "resolution": "1K", - "output_format": "png", - }, - ) + try: + resp = requests.post( + FAL_ENDPOINT, + headers={ + "Authorization": f"Key {api_key}", + "Content-Type": "application/json", + }, + json={ + "prompt": prompt, + "num_images": 1, + "aspect_ratio": "1:1", + "resolution": "1K", + "output_format": "png", + }, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + except requests.RequestException as exc: + raise ReleaseTweetError(f"fal.ai request failed: {exc}") from exc if not resp.ok: - raise RuntimeError(f"fal.ai error {resp.status_code}: {resp.text}") + raise ReleaseTweetError(f"fal.ai error {resp.status_code}: {resp.text}") - images = resp.json().get("images", []) + try: + images = resp.json().get("images", []) + except (ValueError, AttributeError) as exc: + raise ReleaseTweetError("fal.ai returned invalid JSON payload") from exc if not images: - raise RuntimeError("fal.ai returned no images") - return images[0]["url"] + raise ReleaseTweetError("fal.ai returned no images") + image_url = images[0].get("url") if isinstance(images[0], dict) else None + if not isinstance(image_url, str) or not image_url: + raise ReleaseTweetError("fal.ai returned an image without a URL") + return image_url def download_image(url: str) -> str: """Download image to a temp file and return the path.""" - resp = requests.get(url, stream=True) - resp.raise_for_status() + try: + resp = requests.get(url, stream=True, timeout=REQUEST_TIMEOUT_SECONDS) + resp.raise_for_status() + except requests.RequestException as exc: + raise ReleaseTweetError(f"image download failed: {exc}") from exc tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False) - for chunk in resp.iter_content(8192): - tmp.write(chunk) - tmp.close() + try: + for chunk in resp.iter_content(8192): + if chunk: + tmp.write(chunk) + except requests.RequestException as exc: + os.unlink(tmp.name) + raise ReleaseTweetError(f"image download failed while streaming: {exc}") from exc + finally: + tmp.close() return tmp.name @@ -140,7 +175,10 @@ def post_tweet_with_reply(tweet_text: str, image_path: str, reply_text: str): api_v1 = tweepy.API(auth) # Upload image - media = api_v1.media_upload(image_path) + try: + media = api_v1.media_upload(image_path) + except Exception as exc: + raise ReleaseTweetError(f"Twitter media upload failed: {exc}") from exc print(f"Uploaded media: {media.media_id}") # v2 client for tweeting @@ -159,12 +197,14 @@ def post_tweet_with_reply(tweet_text: str, image_path: str, reply_text: str): media_ids=[media.media_id], ) break - except tweepy.errors.TwitterServerError: + except tweepy.errors.TwitterServerError as exc: if attempt < 2: print(f" Twitter 5xx error, retrying in {5 * (attempt + 1)}s...") time.sleep(5 * (attempt + 1)) else: - raise + raise ReleaseTweetError("Twitter create_tweet failed after retries") from exc + except Exception as exc: + raise ReleaseTweetError(f"Twitter create_tweet failed: {exc}") from exc tweet_id = response.data["id"] print(f"Posted tweet: https://twitter.com/i/web/status/{tweet_id}") @@ -177,12 +217,14 @@ def post_tweet_with_reply(tweet_text: str, image_path: str, reply_text: str): in_reply_to_tweet_id=tweet_id, ) break - except tweepy.errors.TwitterServerError: + except tweepy.errors.TwitterServerError as exc: if attempt < 2: print(f" Twitter 5xx error, retrying in {5 * (attempt + 1)}s...") time.sleep(5 * (attempt + 1)) else: - raise + raise ReleaseTweetError("Twitter reply failed after retries") from exc + except Exception as exc: + raise ReleaseTweetError(f"Twitter reply failed: {exc}") from exc reply_id = reply.data["id"] print(f"Posted reply: https://twitter.com/i/web/status/{reply_id}") @@ -203,40 +245,46 @@ def main(): print(f"Release: {tag}") print(f"Headers: {headers}") - # Step 1: Generate tweet text + image prompt via Claude - print("\nGenerating tweet and image prompt...") - result = generate_tweet_and_prompt(tag, headers, url) - tweet_text = result["tweet"] - image_prompt = result["image_prompt"] - - print(f"\nTweet: {tweet_text}") - print(f"\nImage prompt: {image_prompt[:200]}...") - - # Step 2: Generate image via fal.ai - print("\nGenerating image...") - fal_key = os.environ["FAL_KEY"] - image_url = generate_image(image_prompt, fal_key) - print(f"Image URL: {image_url}") - - image_path = download_image(image_url) - print(f"Downloaded to: {image_path}") - - # Step 3: Post main tweet with image, reply with release link - if len(tweet_text) > 280: - lines = tweet_text.strip().splitlines() - while len(chr(10).join(lines)) > 280 and len(lines) > 1: - lines.pop() - tweet_text = chr(10).join(lines) - - reply_text = f"Release notes: {url}" - - print(f"\nPosting tweet ({len(tweet_text)} chars):") - print(tweet_text) - print(f"\nReply: {reply_text}") - post_tweet_with_reply(tweet_text, image_path, reply_text) + image_path: str | None = None + try: + # Step 1: Generate tweet text + image prompt via Claude + print("\nGenerating tweet and image prompt...") + result = generate_tweet_and_prompt(tag, headers, url) + tweet_text = result["tweet"] + image_prompt = result["image_prompt"] + + print(f"\nTweet: {tweet_text}") + print(f"\nImage prompt: {image_prompt[:200]}...") + + # Step 2: Generate image via fal.ai + print("\nGenerating image...") + fal_key = os.environ["FAL_KEY"] + image_url = generate_image(image_prompt, fal_key) + print(f"Image URL: {image_url}") + + image_path = download_image(image_url) + print(f"Downloaded to: {image_path}") + + # Step 3: Post main tweet with image, reply with release link + if len(tweet_text) > 280: + lines = tweet_text.strip().splitlines() + while len(chr(10).join(lines)) > 280 and len(lines) > 1: + lines.pop() + tweet_text = chr(10).join(lines) + + reply_text = f"Release notes: {url}" + + print(f"\nPosting tweet ({len(tweet_text)} chars):") + print(tweet_text) + print(f"\nReply: {reply_text}") + post_tweet_with_reply(tweet_text, image_path, reply_text) + except ReleaseTweetError as exc: + print(f"Release tweet failed: {exc}", file=sys.stderr) + sys.exit(1) + finally: + if image_path and os.path.exists(image_path): + os.unlink(image_path) - # Cleanup - os.unlink(image_path) print("\nDone!") diff --git a/AGENTS.md b/AGENTS.md index 55dad4af..9df2da93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ description: > about code quality, technical debt, dead code, large files, god classes, duplicate functions, code smells, naming issues, import cycles, or coupling problems. Also use when asked for a health score, what to fix next, or to - create a cleanup plan. Supports 28 languages. + create a cleanup plan. Supports 29 languages. allowed-tools: Bash(desloppify *) --- diff --git a/Makefile b/Makefile index 5836dcea..3a1a8e5c 100644 --- a/Makefile +++ b/Makefile @@ -41,7 +41,7 @@ arch: install-ci-tools ci-contracts: install-ci-tools pytest -q desloppify/tests/ci/test_ci_contracts.py - pytest -q desloppify/tests/commands/test_lifecycle_transitions.py -k "subjective_then_score_then_triage" + pytest -q desloppify/tests/commands/test_lifecycle_transitions.py -k "assessment_then_score_when_no_review_followup" integration-roslyn: install-ci-tools pytest -q desloppify/tests/lang/csharp/test_csharp_deps.py -k "roslyn" diff --git a/README.md b/README.md index ff4f2935..8efe293c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ That score generates a scorecard badge for your GitHub profile or README: -Currently supports 28 languages — full plugin depth for TypeScript, Python, C#, C++, Dart, GDScript, Go, and Rust; generic linter + tree-sitter support for Ruby, Java, Kotlin, and 16 more. For C++ projects, `compile_commands.json` is the primary analysis path and `Makefile` repositories fall back to best-effort local include scanning. +Currently supports 29 languages — full plugin depth for TypeScript, Python, C#, C++, Dart, GDScript, Go, and Rust; generic linter + tree-sitter support for Ruby, Java, Kotlin, and 18 more. For C++ projects, `compile_commands.json` is the primary analysis path and `Makefile` repositories fall back to best-effort local include scanning. ## For your agent's consideration... @@ -25,6 +25,8 @@ Run ALL of the following (requires Python 3.11+): pip install --upgrade "desloppify[full]" desloppify update-skill claude # installs the full workflow guide — pick yours: claude, cursor, codex, copilot, windsurf, gemini +Add .desloppify/ to your .gitignore — it contains local state that shouldn't be committed. + Before scanning, check for directories that should be excluded (vendor, build output, generated code, worktrees, etc.) and exclude obvious ones with `desloppify exclude `. Share any questionable candidates with me before excluding. diff --git a/assets/scorecard.png b/assets/scorecard.png index d8046760..a5eb76c8 100644 Binary files a/assets/scorecard.png and b/assets/scorecard.png differ diff --git a/desloppify/app/cli_support/parser_groups_admin.py b/desloppify/app/cli_support/parser_groups_admin.py index 7839f262..4cf629ce 100644 --- a/desloppify/app/cli_support/parser_groups_admin.py +++ b/desloppify/app/cli_support/parser_groups_admin.py @@ -4,7 +4,7 @@ import logging -from desloppify.languages.framework import get_lang +from desloppify.app.commands.helpers.lang import load_lang_config from .parser_groups_admin_review import _add_review_parser # noqa: F401 (re-export) logger = logging.getLogger(__name__) @@ -94,7 +94,7 @@ def _fixer_help_lines(langs: list[str]) -> list[str]: fixer_help_lines: list[str] = [] for lang_name in langs: try: - fixer_names = sorted(get_lang(lang_name).fixers.keys()) + fixer_names = sorted(load_lang_config(lang_name).fixers.keys()) except (ImportError, ValueError, TypeError, AttributeError) as exc: logger.debug("Failed to load fixer metadata for %s: %s", lang_name, exc) fixer_help_lines.append( diff --git a/desloppify/app/cli_support/parser_groups_plan_impl.py b/desloppify/app/cli_support/parser_groups_plan_impl.py index 971678fe..8b0042d0 100644 --- a/desloppify/app/cli_support/parser_groups_plan_impl.py +++ b/desloppify/app/cli_support/parser_groups_plan_impl.py @@ -11,6 +11,7 @@ ) from .parser_groups_plan_impl_sections_cluster import _add_cluster_subparser from .parser_groups_plan_impl_sections_queue_reorder import ( + _add_promote_subparser, _add_queue_subparser, _add_reorder_subparser, ) @@ -50,6 +51,7 @@ def add_plan_parser(sub) -> None: show Show plan metadata summary queue Compact table of execution queue items reset Reset plan to empty + promote Promote backlog issues or clusters into the queue reorder Reposition issues or clusters in the queue resolve Mark issues as fixed (score movement + next-step) describe Set augmented description @@ -81,6 +83,7 @@ def add_plan_parser(sub) -> None: # plan reset plan_sub.add_parser("reset", help="Reset plan to empty") + _add_promote_subparser(plan_sub) _add_reorder_subparser(plan_sub) _add_annotation_subparsers(plan_sub) _add_skip_subparsers(plan_sub) diff --git a/desloppify/app/cli_support/parser_groups_plan_impl_sections_queue_reorder.py b/desloppify/app/cli_support/parser_groups_plan_impl_sections_queue_reorder.py index d8524ab3..b09489c0 100644 --- a/desloppify/app/cli_support/parser_groups_plan_impl_sections_queue_reorder.py +++ b/desloppify/app/cli_support/parser_groups_plan_impl_sections_queue_reorder.py @@ -16,6 +16,34 @@ def _add_queue_subparser(plan_sub) -> None: help="Sort order (default: priority)") +def _add_promote_subparser(plan_sub) -> None: + p_promote = plan_sub.add_parser( + "promote", + help="Promote backlog issues or clusters into the queue", + epilog="""\ +patterns accept issue IDs, detector names, file paths, globs, or cluster names. +cluster names expand to all member IDs automatically. + +examples: + desloppify plan promote security top + desloppify plan promote auto/test_coverage bottom + desloppify plan promote my-cluster before -t workflow::run-scan""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_promote.add_argument( + "patterns", nargs="+", metavar="PATTERN", + help="Issue ID(s), detector, file path, glob, or cluster name", + ) + p_promote.add_argument( + "position", nargs="?", choices=["top", "bottom", "before", "after"], default="bottom", + help="Where to insert in the active queue (default: bottom)", + ) + p_promote.add_argument( + "-t", "--target", default=None, + help="Required for before/after (issue ID or cluster name)", + ) + + def _add_reorder_subparser(plan_sub) -> None: p_move = plan_sub.add_parser( "reorder", @@ -48,5 +76,4 @@ def _add_reorder_subparser(plan_sub) -> None: help="Required for before/after (issue ID or cluster name) and up/down (integer offset)", ) - -__all__ = ["_add_queue_subparser", "_add_reorder_subparser"] +__all__ = ["_add_promote_subparser", "_add_queue_subparser", "_add_reorder_subparser"] diff --git a/desloppify/app/commands/autofix/apply_retro.py b/desloppify/app/commands/autofix/apply_retro.py index e9afb121..3f5ac16b 100644 --- a/desloppify/app/commands/autofix/apply_retro.py +++ b/desloppify/app/commands/autofix/apply_retro.py @@ -19,14 +19,17 @@ def _resolve_fixer_results( state: dict, results: list[dict], detector: str, fixer_name: str ) -> list[str]: + work_items = state.get("work_items") or state.get("issues", {}) + state["work_items"] = work_items + state["issues"] = work_items resolved_ids = [] for result in results: result_file = rel(result["file"]) for symbol in result["removed"]: issue_id = f"{detector}::{result_file}::{symbol}" - if issue_id in state["issues"] and state["issues"][issue_id]["status"] == "open": - state["issues"][issue_id]["status"] = "fixed" - state["issues"][issue_id]["note"] = ( + if issue_id in work_items and work_items[issue_id]["status"] == "open": + work_items[issue_id]["status"] = "fixed" + work_items[issue_id]["note"] = ( f"auto-fixed by desloppify autofix {fixer_name}" ) resolved_ids.append(issue_id) diff --git a/desloppify/app/commands/exclude.py b/desloppify/app/commands/exclude.py index 647e2b7d..8183bde8 100644 --- a/desloppify/app/commands/exclude.py +++ b/desloppify/app/commands/exclude.py @@ -30,9 +30,11 @@ def _state_file_for_runtime(runtime) -> Path: def _prune_excluded_issues(state: dict, pattern: str) -> list[str]: """Drop issues whose file path matches a new exclusion pattern.""" - issues = state.get("issues") + issues = state.get("work_items") or state.get("issues") if not isinstance(issues, dict): return [] + state["work_items"] = issues + state["issues"] = issues removed_ids = [ issue_id @@ -62,6 +64,11 @@ def cmd_exclude(args: argparse.Namespace) -> None: runtime = command_runtime(args) config = runtime.config state = runtime.state + if isinstance(state, dict): + issues = state.get("work_items") or state.get("issues") + if isinstance(issues, dict): + state["work_items"] = issues + state["issues"] = issues state_file = _state_file_for_runtime(runtime) config_mod.add_exclude_pattern(config, args.pattern) diff --git a/desloppify/app/commands/helpers/dynamic_loaders.py b/desloppify/app/commands/helpers/dynamic_loaders.py new file mode 100644 index 00000000..ed9001f2 --- /dev/null +++ b/desloppify/app/commands/helpers/dynamic_loaders.py @@ -0,0 +1,52 @@ +"""Approved dynamic loader seams for app-layer command modules.""" + +from __future__ import annotations + +import importlib +import logging +from types import ModuleType + +from desloppify.base.exception_sets import CommandError + +logger = logging.getLogger(__name__) + + +def load_score_update_module() -> ModuleType: + """Load the queue/score update helper on demand.""" + return importlib.import_module("desloppify.app.commands.helpers.score_update") + + +def load_optional_scorecard_module() -> ModuleType | None: + """Load the optional scorecard module when PIL-backed output is available.""" + try: + return importlib.import_module("desloppify.app.output.scorecard") + except ImportError: + return None + + +def load_language_move_module(lang_name: str) -> ModuleType: + """Load a language move module, falling back to the shared scaffold.""" + module_name = f"desloppify.languages.{lang_name}.move" + try: + return importlib.import_module(module_name) + except ImportError as exc: + if exc.name != module_name: + raise CommandError( + f"Failed to import language move module {module_name}: {exc}" + ) from exc + logger.debug("Language-specific move module missing: %s", module_name) + + scaffold_module = "desloppify.languages._framework.scaffold_move" + try: + return importlib.import_module(scaffold_module) + except ImportError as exc: + raise CommandError( + f"Move not yet supported for language: {lang_name} ({exc})" + ) from exc + + +__all__ = [ + "load_language_move_module", + "load_optional_scorecard_module", + "load_score_update_module", +] diff --git a/desloppify/app/commands/helpers/guardrails.py b/desloppify/app/commands/helpers/guardrails.py index 1c1af9d0..d3286503 100644 --- a/desloppify/app/commands/helpers/guardrails.py +++ b/desloppify/app/commands/helpers/guardrails.py @@ -5,9 +5,11 @@ import logging from dataclasses import dataclass, field +from desloppify import state as state_mod from desloppify.app.commands.helpers.issue_id_display import short_issue_id from desloppify.base.exception_sets import PLAN_LOAD_EXCEPTIONS, CommandError from desloppify.base.output.terminal import colorize +from desloppify.engine._plan.sync.context import has_objective_backlog, is_mid_cycle from desloppify.engine.plan_state import load_plan from desloppify.engine.plan_triage import ( TRIAGE_CMD_RUN_STAGES_CLAUDE, @@ -18,6 +20,7 @@ ) logger = logging.getLogger(__name__) +_REVIEW_DETECTORS = frozenset({"review", "concerns"}) @dataclass @@ -25,6 +28,7 @@ class TriageGuardrailResult: """Structured result from triage staleness detection.""" is_stale: bool = False + pending_behind_objective_backlog: bool = False new_ids: set[str] = field(default_factory=set) _plan: dict | None = field(default=None, repr=False) _snapshot: TriageSnapshot | None = field(default=None, repr=False) @@ -48,8 +52,16 @@ def triage_guardrail_status( if not snapshot.is_triage_stale: return TriageGuardrailResult(_plan=resolved_plan, _snapshot=snapshot) + pending_behind_objective_backlog = ( + not snapshot.has_triage_in_queue + and bool(resolved_state) + and is_mid_cycle(resolved_plan) + and has_objective_backlog(resolved_state, None) + ) + return TriageGuardrailResult( is_stale=True, + pending_behind_objective_backlog=pending_behind_objective_backlog, new_ids=set(snapshot.new_since_triage_ids), _plan=resolved_plan, _snapshot=snapshot, @@ -69,11 +81,17 @@ def triage_guardrail_messages( messages: list[str] = [] if result.new_ids: - messages.append( - f"{len(result.new_ids)} new review issue(s) not yet triaged." - " Run the staged triage runner to incorporate them " - f"(`{TRIAGE_CMD_RUN_STAGES_CODEX}` or `{TRIAGE_CMD_RUN_STAGES_CLAUDE}`)." - ) + if result.pending_behind_objective_backlog: + messages.append( + f"{len(result.new_ids)} new review issue(s) arrived since the last triage." + " They will activate after the current objective backlog is clear." + ) + else: + messages.append( + f"{len(result.new_ids)} new review issue(s) not yet triaged." + " Run the staged triage runner to incorporate them " + f"(`{TRIAGE_CMD_RUN_STAGES_CODEX}` or `{TRIAGE_CMD_RUN_STAGES_CLAUDE}`)." + ) if result._plan is not None: banner = triage_phase_banner(result._plan, resolved_state, snapshot=result._snapshot) @@ -98,11 +116,13 @@ def print_triage_guardrail_info( def require_triage_current_or_exit( *, state: dict, + plan: dict | None = None, + patterns: list[str] | None = None, bypass: bool = False, attest: str = "", ) -> None: """Gate: exit(1) if triage is stale and not bypassed. Name signals the exit.""" - result = triage_guardrail_status(state=state) + result = triage_guardrail_status(plan=plan, state=state) if not result.is_stale: return @@ -113,13 +133,35 @@ def require_triage_current_or_exit( )) return + if result.pending_behind_objective_backlog and patterns: + matched_targets = _matched_open_targets(state, patterns) + if matched_targets and not _targets_include_review_work(matched_targets): + banner = triage_phase_banner( + result._plan or {}, + state, + snapshot=result._snapshot, + ) + if banner: + print(colorize(f" {banner}", "yellow")) + return + new_ids = result.new_ids + if result.pending_behind_objective_backlog: + lines = [ + "BLOCKED: review issues changed since the last triage, but triage is pending" + " behind the current objective backlog.", + "", + " Finish current objective work first; triage will activate after the backlog clears.", + ' To bypass: --force-resolve --attest "I understand the plan may be stale..."', + ] + raise CommandError("\n".join(lines)) + lines = [ - f"BLOCKED: {len(new_ids) or 'some'} new review issue(s) have not been triaged." + f"BLOCKED: {len(new_ids) or 'some'} new review work item(s) have not been triaged." ] if new_ids: for fid in sorted(new_ids)[:5]: - issue = state.get("issues", {}).get(fid, {}) + issue = (state.get("work_items") or state.get("issues", {})).get(fid, {}) lines.append(f" * [{short_issue_id(fid)}] {issue.get('summary', '')}") if len(new_ids) > 5: lines.append(f" ... and {len(new_ids) - 5} more") @@ -135,6 +177,20 @@ def require_triage_current_or_exit( raise CommandError("\n".join(lines)) +def _matched_open_targets(state: dict, patterns: list[str]) -> list[dict]: + matched_by_id: dict[str, dict] = {} + for pattern in patterns: + for issue in state_mod.match_issues(state, pattern, status_filter="open"): + issue_id = str(issue.get("id", "")).strip() + if issue_id and issue_id not in matched_by_id: + matched_by_id[issue_id] = issue + return list(matched_by_id.values()) + + +def _targets_include_review_work(matched_targets: list[dict]) -> bool: + return any(issue.get("detector") in _REVIEW_DETECTORS for issue in matched_targets) + + __all__ = [ "TriageGuardrailResult", "print_triage_guardrail_info", diff --git a/desloppify/app/commands/helpers/lang.py b/desloppify/app/commands/helpers/lang.py index 90d0f749..e1e93216 100644 --- a/desloppify/app/commands/helpers/lang.py +++ b/desloppify/app/commands/helpers/lang.py @@ -47,6 +47,15 @@ def load_lang_config(lang_name: str): ) from exc +def load_lang_config_metadata(lang_name: str) -> LangConfig | None: + """Load language metadata, isolating broken plugins from unrelated commands.""" + try: + return load_lang_config(lang_name) + except LangResolutionError as exc: + logger.warning("Skipping broken language plugin metadata for %s: %s", lang_name, exc) + return None + + EXTRA_ROOT_MARKERS = ( "package.json", "pyproject.toml", @@ -62,7 +71,9 @@ def _lang_config_markers() -> tuple[str, ...]: markers = set(EXTRA_ROOT_MARKERS) for lang_name in lang_api.available_langs(): - cfg = load_lang_config(lang_name) + cfg = load_lang_config_metadata(lang_name) + if cfg is None: + continue for marker in getattr(cfg, "detect_markers", []) or []: if not isinstance(marker, str): continue diff --git a/desloppify/app/commands/helpers/queue_progress.py b/desloppify/app/commands/helpers/queue_progress.py index 89a09c40..cfed4511 100644 --- a/desloppify/app/commands/helpers/queue_progress.py +++ b/desloppify/app/commands/helpers/queue_progress.py @@ -3,14 +3,16 @@ from __future__ import annotations import enum -import importlib import logging from dataclasses import dataclass from typing import TYPE_CHECKING from desloppify.base.exception_sets import PLAN_LOAD_EXCEPTIONS +from desloppify.app.commands.helpers.dynamic_loaders import load_score_update_module from desloppify.base.output.terminal import colorize from desloppify.engine._plan.refresh_lifecycle import ( + COARSE_PHASE_MAP, + coarse_lifecycle_phase, LIFECYCLE_PHASE_EXECUTE, LIFECYCLE_PHASE_REVIEW, LIFECYCLE_PHASE_SCAN, @@ -23,7 +25,6 @@ from desloppify.engine._work_queue.context import queue_context from desloppify.engine._work_queue.helpers import is_subjective_queue_item from desloppify.engine._work_queue.plan_order import collapse_clusters -from desloppify.engine._work_queue.snapshot import coarse_phase_name from desloppify.engine.planning.queue_policy import build_execution_queue from desloppify.app.commands.helpers.queue_progress_render import ( format_plan_delta, @@ -153,7 +154,13 @@ def plan_aware_queue_breakdown( if context is not None else queue_context(state, plan=effective_plan).snapshot ) - lifecycle_phase = coarse_phase_name(snapshot.phase) + refresh_state = effective_plan.get("refresh_state") if isinstance(effective_plan, dict) else None + persisted_phase = ( + coarse_lifecycle_phase(effective_plan) + if isinstance(refresh_state, dict) and isinstance(refresh_state.get("lifecycle_phase"), str) + else None + ) + lifecycle_phase = persisted_phase or COARSE_PHASE_MAP.get(snapshot.phase, snapshot.phase) if effective_plan and not effective_plan.get("active_cluster"): items = collapse_clusters(items, effective_plan) @@ -208,7 +215,7 @@ def plan_aware_queue_breakdown( cluster_member_ids = set(cluster_data.get("issue_ids", [])) open_issues = { fid - for fid, f in state.get("issues", {}).items() + for fid, f in (state.get("work_items") or state.get("issues", {})).items() if f.get("status") == "open" } focus_cluster_count = len(cluster_member_ids & open_issues) @@ -236,6 +243,7 @@ def _snapshot_item_ids(snapshot: object) -> set[str] | None: partition_names = ( "all_objective_items", "all_initial_review_items", + "all_postflight_assessment_items", "all_postflight_review_items", "all_scan_items", "all_postflight_workflow_items", @@ -336,9 +344,7 @@ def print_execution_or_reveal( return # LIVE or PHASE_TRANSITION: show current scores - score_update_mod = importlib.import_module( - "desloppify.app.commands.helpers.score_update" - ) + score_update_mod = load_score_update_module() score_update_mod.print_score_update(state, prev) if mode is ScoreDisplayMode.PHASE_TRANSITION: diff --git a/desloppify/app/commands/helpers/state_persistence.py b/desloppify/app/commands/helpers/state_persistence.py index 59588e8f..2b9bd270 100644 --- a/desloppify/app/commands/helpers/state_persistence.py +++ b/desloppify/app/commands/helpers/state_persistence.py @@ -4,15 +4,15 @@ from pathlib import Path -from desloppify import state_compat as state_compat from desloppify.base import config as config_mod from desloppify.base.exception_sets import CommandError +from desloppify.engine._state.persistence import save_state def save_state_or_exit(state: dict, state_file: Path | None) -> None: """Persist state with a consistent CLI error boundary.""" try: - state_compat.save_state(state, state_file) + save_state(state, state_file) except OSError as exc: raise CommandError(f"could not save state: {exc}") from exc diff --git a/desloppify/app/commands/move/language.py b/desloppify/app/commands/move/language.py index b4747279..2f4fe44a 100644 --- a/desloppify/app/commands/move/language.py +++ b/desloppify/app/commands/move/language.py @@ -2,24 +2,24 @@ from __future__ import annotations -import importlib -import logging from functools import lru_cache from pathlib import Path from types import ModuleType +from desloppify.app.commands.helpers.dynamic_loaders import ( + load_language_move_module as load_dynamic_language_move_module, +) from desloppify.languages import framework as lang_mod -from desloppify.app.commands.helpers.lang import load_lang_config, resolve_lang -from desloppify.base.exception_sets import CommandError - -logger = logging.getLogger(__name__) +from desloppify.app.commands.helpers.lang import load_lang_config_metadata, resolve_lang def _build_ext_to_lang_map() -> dict[str, str]: """Build extension→language map from registered language configs.""" ext_map: dict[str, str] = {} for lang_name in lang_mod.available_langs(): - cfg = load_lang_config(lang_name) + cfg = load_lang_config_metadata(lang_name) + if cfg is None: + continue for ext in cfg.extensions: ext_map.setdefault(ext, lang_name) return ext_map @@ -79,26 +79,7 @@ def load_lang_move_module(lang_name: str) -> ModuleType: Falls back to the shared scaffold move module when a language does not provide its own ``move.py``. """ - module_name = f"desloppify.languages.{lang_name}.move" - try: - return importlib.import_module(module_name) - except ImportError as exc: - if exc.name != module_name: - raise CommandError( - f"Failed to import language move module {module_name}: {exc}" - ) from exc - logger.debug( - "Language-specific move module missing: %s", - module_name, - ) - # Fall back to the scaffold move module that provides default stubs. - scaffold_module = "desloppify.languages._framework.scaffold_move" - try: - return importlib.import_module(scaffold_module) - except ImportError as exc: - raise CommandError( - f"Move not yet supported for language: {lang_name} ({exc})" - ) from exc + return load_dynamic_language_move_module(lang_name) def resolve_move_verify_hint(move_mod: ModuleType) -> str: diff --git a/desloppify/app/commands/next/queue_flow.py b/desloppify/app/commands/next/queue_flow.py index 4bbff368..641f847b 100644 --- a/desloppify/app/commands/next/queue_flow.py +++ b/desloppify/app/commands/next/queue_flow.py @@ -212,7 +212,7 @@ def _render_terminal_queue_view( """Render terminal output for a non-empty queue.""" dim_scores = state.get("dimension_scores", {}) issues_scoped = path_scoped_issues( - state.get("issues", {}), + (state.get("work_items") or state.get("issues", {})), state.get("scan_path"), ) plan_start_strict = None diff --git a/desloppify/app/commands/next/render.py b/desloppify/app/commands/next/render.py index 9f32a49f..1124c521 100644 --- a/desloppify/app/commands/next/render.py +++ b/desloppify/app/commands/next/render.py @@ -10,7 +10,14 @@ compute_score_impact, get_dimension_for_detector, ) -from desloppify.engine._work_queue.helpers import workflow_stage_name +from desloppify.engine._state.issue_semantics import ( + is_review_finding, + is_assessment_request, +) +from desloppify.engine._work_queue.helpers import ( + is_auto_fix_item, + workflow_stage_name, +) from .render_support import is_auto_fix_command from .render_support import render_cluster_item as _render_cluster_item @@ -188,17 +195,19 @@ def _render_score_impact( def _render_item_type(item: dict) -> None: - detector = item.get("detector") - if detector == "review": + if is_review_finding(item): print(colorize(" Type: Design review (requires judgment)", "dim")) return - if is_auto_fix_command(item.get("primary_command")): + if is_assessment_request(item): + print(colorize(" Type: Assessment request", "dim")) + return + if is_auto_fix_item(item): print(colorize(" Type: Auto-fixable", "dim")) def _render_auto_fix_batch_hint(item: dict, issues_scoped: dict) -> None: auto_fix_command = item.get("primary_command") - if not is_auto_fix_command(auto_fix_command): + if not is_auto_fix_item(item) or not is_auto_fix_command(auto_fix_command): return detector_name = item.get("detector", "") similar_count = sum( diff --git a/desloppify/app/commands/next/render_nudges.py b/desloppify/app/commands/next/render_nudges.py index c8da6fd5..9e3c31e7 100644 --- a/desloppify/app/commands/next/render_nudges.py +++ b/desloppify/app/commands/next/render_nudges.py @@ -16,7 +16,9 @@ from desloppify.base.output.terminal import colorize, log from desloppify.base.output.user_message import print_user_message from desloppify.engine._scoring.results.core import compute_health_breakdown +from desloppify.engine._state.issue_semantics import is_assessment_request from desloppify.engine._work_queue.core import ATTEST_EXAMPLE +from desloppify.engine._work_queue.helpers import is_auto_fix_item from desloppify.intelligence.integrity import ( unassessed_subjective_dimensions, ) @@ -60,8 +62,7 @@ def render_single_item_resolution_hint(items: list[dict]) -> None: if kind != "issue": return item = items[0] - detector_name = item.get("detector", "") - if detector_name == "subjective_review": + if is_assessment_request(item): dim_key = (item.get("detail") or {}).get("dimension", "") primary = item.get("primary_command", "") if not primary: @@ -84,7 +85,7 @@ def render_single_item_resolution_hint(items: list[dict]) -> None: return primary = item.get("primary_command", "") - if is_auto_fix_command(primary): + if is_auto_fix_item(item) and is_auto_fix_command(primary): print(colorize("\n Fix with:", "dim")) print(f" {primary}") print(colorize(" Or resolve individually:", "dim")) @@ -215,7 +216,7 @@ def _subjective_summary_parts( parts.append(f"{unassessed_count} unassessed") if open_review_count: parts.append( - f"{open_review_count} review issue{'s' if open_review_count != 1 else ''} open" + f"{open_review_count} review work item{'s' if open_review_count != 1 else ''} open" ) if coverage_open > 0: parts.append(f"{coverage_open} file{'s' if coverage_open != 1 else ''} need review") diff --git a/desloppify/app/commands/next/render_scoring.py b/desloppify/app/commands/next/render_scoring.py index 4e962e5f..9268084c 100644 --- a/desloppify/app/commands/next/render_scoring.py +++ b/desloppify/app/commands/next/render_scoring.py @@ -2,6 +2,8 @@ from __future__ import annotations +from desloppify.engine._state.issue_semantics import is_review_finding + def _normalized_dimension_key(value: str | None) -> str: return str(value or "").lower().replace(" ", "_") @@ -161,7 +163,7 @@ def render_score_impact( get_dimension_for_detector_fn=get_dimension_for_detector_fn, ) return - if detector == "review" and dim_scores: + if is_review_finding(item) and dim_scores: render_review_dimension_drag( item, dim_scores, @@ -198,7 +200,7 @@ def render_item_explain( f"Dimension: {dimension.name} at {ds['score']:.1f}% " f"({ds.get('failing', 0)} open issues)" ) - if item.get("detector") == "review" and dim_scores: + if is_review_finding(item) and dim_scores: entry = _review_dimension_score_entry(item, dim_scores) if entry is not None: ds_name, ds_data = entry diff --git a/desloppify/app/commands/next/render_support.py b/desloppify/app/commands/next/render_support.py index 80bd26e7..004f3b86 100644 --- a/desloppify/app/commands/next/render_support.py +++ b/desloppify/app/commands/next/render_support.py @@ -7,6 +7,8 @@ from desloppify.app.commands.helpers.queue_progress import format_plan_delta from desloppify.base.output.terminal import colorize +from desloppify.engine._state.issue_semantics import is_review_finding +from desloppify.engine._work_queue.helpers import is_auto_fix_item from desloppify.engine.work_queue import group_queue_items from desloppify.engine.planning.scorecard_projection import ( scorecard_subjective_entries, @@ -65,9 +67,9 @@ def is_auto_fix_command(command: str | None) -> bool: def effort_tag(item: dict) -> str: """Return a short effort/type tag for a queue item.""" - if item.get("detector") == "review": + if is_review_finding(item): return "[review]" - if is_auto_fix_command(item.get("primary_command")): + if is_auto_fix_item(item): return "[auto]" return "" diff --git a/desloppify/app/commands/next/render_workflow.py b/desloppify/app/commands/next/render_workflow.py index a2f76a05..37b5aef9 100644 --- a/desloppify/app/commands/next/render_workflow.py +++ b/desloppify/app/commands/next/render_workflow.py @@ -91,7 +91,7 @@ def render_workflow_stage(item: dict, *, colorize_fn, workflow_stage_name_fn) -> print(f" {colorize_fn(item.get('summary', ''), 'yellow')}") total = detail.get("total_review_issues", 0) if total: - print(colorize_fn(f" {total} review issues to analyze", "dim")) + print(colorize_fn(f" {total} review work items to analyze", "dim")) if blocked: _print_blocked_stage_actions( blocked_by=item.get("blocked_by", []), diff --git a/desloppify/app/commands/plan/cluster/dispatch.py b/desloppify/app/commands/plan/cluster/dispatch.py index 456c33b2..21018642 100644 --- a/desloppify/app/commands/plan/cluster/dispatch.py +++ b/desloppify/app/commands/plan/cluster/dispatch.py @@ -20,15 +20,15 @@ from desloppify.app.commands.plan.shared.patterns import resolve_ids_from_patterns from desloppify.base.output.terminal import colorize -from ..cluster_ops_display import _cmd_cluster_list -from ..cluster_ops_display import _cmd_cluster_show -from ..cluster_ops_manage import _cmd_cluster_create -from ..cluster_ops_manage import _cmd_cluster_delete -from ..cluster_ops_manage import _cmd_cluster_export -from ..cluster_ops_manage import _cmd_cluster_import -from ..cluster_ops_manage import _cmd_cluster_merge -from ..cluster_ops_reorder import _cmd_cluster_reorder -from ..cluster_update import cmd_cluster_update as _cmd_cluster_update_impl +from .ops_display import _cmd_cluster_list +from .ops_display import _cmd_cluster_show +from .ops_manage import _cmd_cluster_create +from .ops_manage import _cmd_cluster_delete +from .ops_manage import _cmd_cluster_export +from .ops_manage import _cmd_cluster_import +from .ops_manage import _cmd_cluster_merge +from .ops_reorder import _cmd_cluster_reorder +from .update import cmd_cluster_update as _cmd_cluster_update_impl _HEX8_RE = re.compile(r"^[0-9a-f]{8}$") _HINT_TONE = "dim" @@ -44,7 +44,7 @@ def _all_known_issue_ids(state: dict, plan: dict | None) -> list[str]: - all_ids: list[str] = list(state.get("issues", {}).keys()) + all_ids: list[str] = list((state.get("work_items") or state.get("issues", {})).keys()) if plan is None: return all_ids seen_ids: set[str] = set(all_ids) diff --git a/desloppify/app/commands/plan/cluster_ops_display.py b/desloppify/app/commands/plan/cluster/ops_display.py similarity index 99% rename from desloppify/app/commands/plan/cluster_ops_display.py rename to desloppify/app/commands/plan/cluster/ops_display.py index 75f8d2d7..7e08573a 100644 --- a/desloppify/app/commands/plan/cluster_ops_display.py +++ b/desloppify/app/commands/plan/cluster/ops_display.py @@ -9,7 +9,7 @@ from desloppify.base.output.terminal import colorize from desloppify.engine.plan_state import load_plan -from .cluster_steps import print_step +from .steps import print_step def _print_cluster_member(idx: int, fid: str, issue: dict | None) -> None: @@ -33,7 +33,7 @@ def _print_cluster_member(idx: int, fid: str, issue: dict | None) -> None: def _load_issues_best_effort(args: argparse.Namespace) -> dict: """Load issues from state, returning empty dict on failure.""" rt = command_runtime(args) - return rt.state.get("issues", {}) + return rt.state.get("work_items") or rt.state.get("issues", {}) def _load_cluster_or_print_missing(cluster_name: str) -> dict | None: diff --git a/desloppify/app/commands/plan/cluster_ops_manage.py b/desloppify/app/commands/plan/cluster/ops_manage.py similarity index 100% rename from desloppify/app/commands/plan/cluster_ops_manage.py rename to desloppify/app/commands/plan/cluster/ops_manage.py diff --git a/desloppify/app/commands/plan/cluster_ops_reorder.py b/desloppify/app/commands/plan/cluster/ops_reorder.py similarity index 100% rename from desloppify/app/commands/plan/cluster_ops_reorder.py rename to desloppify/app/commands/plan/cluster/ops_reorder.py diff --git a/desloppify/app/commands/plan/cluster_steps.py b/desloppify/app/commands/plan/cluster/steps.py similarity index 100% rename from desloppify/app/commands/plan/cluster_steps.py rename to desloppify/app/commands/plan/cluster/steps.py diff --git a/desloppify/app/commands/plan/cluster_update.py b/desloppify/app/commands/plan/cluster/update.py similarity index 97% rename from desloppify/app/commands/plan/cluster_update.py rename to desloppify/app/commands/plan/cluster/update.py index 848ac085..15ad3749 100644 --- a/desloppify/app/commands/plan/cluster_update.py +++ b/desloppify/app/commands/plan/cluster/update.py @@ -18,7 +18,7 @@ ) from desloppify.state_io import utc_now -from .cluster_update_flow import ( +from .update_flow import ( ClusterUpdateServices, build_request, print_no_update_warning, diff --git a/desloppify/app/commands/plan/cluster_update_flow.py b/desloppify/app/commands/plan/cluster/update_flow.py similarity index 99% rename from desloppify/app/commands/plan/cluster_update_flow.py rename to desloppify/app/commands/plan/cluster/update_flow.py index b652585a..09be99e6 100644 --- a/desloppify/app/commands/plan/cluster_update_flow.py +++ b/desloppify/app/commands/plan/cluster/update_flow.py @@ -14,7 +14,7 @@ PlanModel, ) -from .cluster_steps import print_step +from .steps import print_step StepLike = str | ActionStep diff --git a/desloppify/app/commands/plan/cmd.py b/desloppify/app/commands/plan/cmd.py index ec081d35..254c7977 100644 --- a/desloppify/app/commands/plan/cmd.py +++ b/desloppify/app/commands/plan/cmd.py @@ -29,6 +29,7 @@ from desloppify.app.commands.plan.queue_render import cmd_plan_queue from desloppify.app.commands.plan.repair_state import cmd_plan_repair_state from desloppify.app.commands.plan.reorder_handlers import cmd_plan_reorder +from desloppify.app.commands.plan.reorder_handlers import cmd_plan_promote from desloppify.app.commands.plan.shared.cluster_membership import cluster_issue_ids from desloppify.app.commands.plan.triage.command import cmd_plan_triage from desloppify.engine.plan_state import ( @@ -212,6 +213,7 @@ def _cmd_plan_reset(args: argparse.Namespace) -> None: "queue": cmd_plan_queue, "reset": _cmd_plan_reset, "reorder": cmd_plan_reorder, + "promote": cmd_plan_promote, "describe": cmd_plan_describe, "resolve": cmd_plan_resolve, "note": cmd_plan_note, diff --git a/desloppify/app/commands/plan/override/__init__.py b/desloppify/app/commands/plan/override/__init__.py index 339310ce..42464819 100644 --- a/desloppify/app/commands/plan/override/__init__.py +++ b/desloppify/app/commands/plan/override/__init__.py @@ -2,15 +2,15 @@ from __future__ import annotations -from desloppify.app.commands.plan.override_misc import ( +from .misc import ( cmd_plan_describe, cmd_plan_focus, cmd_plan_note, cmd_plan_reopen, cmd_plan_scan_gate, ) -from desloppify.app.commands.plan.override_resolve_cmd import cmd_plan_resolve -from desloppify.app.commands.plan.override_skip import ( +from .resolve_cmd import cmd_plan_resolve +from .skip import ( cmd_plan_backlog, cmd_plan_skip, cmd_plan_unskip, diff --git a/desloppify/app/commands/plan/override_io.py b/desloppify/app/commands/plan/override/io.py similarity index 100% rename from desloppify/app/commands/plan/override_io.py rename to desloppify/app/commands/plan/override/io.py diff --git a/desloppify/app/commands/plan/override_misc.py b/desloppify/app/commands/plan/override/misc.py similarity index 98% rename from desloppify/app/commands/plan/override_misc.py rename to desloppify/app/commands/plan/override/misc.py index 3be5f658..b5025566 100644 --- a/desloppify/app/commands/plan/override_misc.py +++ b/desloppify/app/commands/plan/override/misc.py @@ -8,7 +8,7 @@ from desloppify.app.commands.helpers.command_runtime import command_runtime from desloppify.app.commands.helpers.state import require_issue_inventory, state_path from desloppify.app.commands.plan.shared.patterns import resolve_ids_from_patterns -from desloppify.app.commands.plan.override_io import ( +from .io import ( _plan_file_for_state, save_plan_state_transactional, ) @@ -113,7 +113,7 @@ def cmd_plan_reopen(args: argparse.Namespace) -> None: count += 1 append_log_entry(plan, "reopen", issue_ids=reopened, actor="user") - clear_postflight_scan_completion(plan, issue_ids=reopened) + clear_postflight_scan_completion(plan, issue_ids=reopened, state=state_data) save_plan_state_transactional( plan=plan, plan_path=plan_file, diff --git a/desloppify/app/commands/plan/override_resolve_cmd.py b/desloppify/app/commands/plan/override/resolve_cmd.py similarity index 97% rename from desloppify/app/commands/plan/override_resolve_cmd.py rename to desloppify/app/commands/plan/override/resolve_cmd.py index eb1cfb59..6dddeac4 100644 --- a/desloppify/app/commands/plan/override_resolve_cmd.py +++ b/desloppify/app/commands/plan/override/resolve_cmd.py @@ -23,11 +23,11 @@ ) from desloppify.engine.plan_ops import append_log_entry -from .override_resolve_helpers import ( +from .resolve_helpers import ( check_cluster_guard, split_synthetic_patterns, ) -from .override_resolve_workflow import resolve_workflow_patterns +from .resolve_workflow import resolve_workflow_patterns logger = logging.getLogger(__name__) diff --git a/desloppify/app/commands/plan/override_resolve_helpers.py b/desloppify/app/commands/plan/override/resolve_helpers.py similarity index 90% rename from desloppify/app/commands/plan/override_resolve_helpers.py rename to desloppify/app/commands/plan/override/resolve_helpers.py index 5f410abf..614c2b08 100644 --- a/desloppify/app/commands/plan/override_resolve_helpers.py +++ b/desloppify/app/commands/plan/override/resolve_helpers.py @@ -6,6 +6,7 @@ from desloppify.base.output.terminal import colorize from desloppify.engine._plan.constants import ( confirmed_triage_stage_names, + is_synthetic_id, recorded_unconfirmed_triage_stage_names, ) from desloppify.engine.plan_triage import ( @@ -20,7 +21,7 @@ def check_cluster_guard(patterns: list[str], plan: dict, state: dict) -> bool: """Return True if blocked by cluster guard, False if OK to proceed.""" clusters = plan.get("clusters", {}) - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) for pattern in patterns: if pattern in clusters: cluster = clusters[pattern] @@ -45,7 +46,7 @@ def check_cluster_guard(patterns: list[str], plan: dict, state: dict) -> bool: def print_cluster_guard(cluster_name: str, issue_ids: list[str], state: dict) -> None: - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) print( colorize( f"\n Cluster '{cluster_name}' has {len(issue_ids)} item(s) — mark them done individually first:\n", @@ -69,17 +70,6 @@ def print_cluster_guard(cluster_name: str, issue_ids: list[str], state: dict) -> "dim", ) ) - - -def is_synthetic_id(issue_id: str) -> bool: - """Return True if the ID is a synthetic workflow/triage item.""" - return ( - issue_id.startswith("triage::") - or issue_id.startswith("workflow::") - or issue_id.startswith("subjective::") - ) - - def split_synthetic_patterns(patterns: list[str]) -> tuple[list[str], list[str]]: """Partition synthetic workflow/triage patterns from real issue patterns.""" synthetic = [pattern for pattern in patterns if is_synthetic_id(pattern)] @@ -95,7 +85,14 @@ def resolve_synthetic_ids(patterns: list[str]) -> tuple[list[str], list[str]]: def blocked_triage_stages(plan: dict) -> dict[str, list[str]]: """Return triage stages that are blocked by unmet dependencies.""" order_set = set(plan.get("queue_order", [])) - stage_names = ("observe", "reflect", "organize", "enrich", "sense-check", "commit") + stage_names = ( + "observe", + "reflect", + "organize", + "enrich", + "sense-check", + "commit", + ) present_names = { name for stage_id, name in zip(TRIAGE_STAGE_IDS, stage_names, strict=False) diff --git a/desloppify/app/commands/plan/override_resolve_workflow.py b/desloppify/app/commands/plan/override/resolve_workflow.py similarity index 89% rename from desloppify/app/commands/plan/override_resolve_workflow.py rename to desloppify/app/commands/plan/override/resolve_workflow.py index cf6d4c05..5363ee59 100644 --- a/desloppify/app/commands/plan/override_resolve_workflow.py +++ b/desloppify/app/commands/plan/override/resolve_workflow.py @@ -8,7 +8,12 @@ from desloppify import state as state_mod from desloppify.app.commands.helpers.state import state_path -from desloppify.app.commands.plan.override_resolve_helpers import blocked_triage_stages +from desloppify.app.commands.plan.triage.review_coverage import ( + ensure_active_triage_issue_ids, + has_open_review_issues, +) +from desloppify.base.config import target_strict_score_from_config +from .resolve_helpers import blocked_triage_stages from desloppify.app.commands.plan.triage.stage_queue import ( has_triage_in_queue, inject_triage_stages, @@ -28,6 +33,11 @@ WORKFLOW_SCORE_CHECKPOINT_ID, confirmed_triage_stage_names, ) +from desloppify.engine._plan.refresh_lifecycle import ( + LIFECYCLE_PHASE_TRIAGE_POSTFLIGHT, + set_lifecycle_phase, +) +from desloppify.engine._plan.sync import live_planned_queue_empty, reconcile_plan from desloppify.engine.plan_triage import ( triage_manual_stage_command, triage_runner_commands, @@ -326,6 +336,31 @@ def _finalize_workflow_resolution( print(colorize(f" Resolved: {synthetic_id}", "green")) +def _reconcile_if_queue_drained( + args: argparse.Namespace, + plan: dict, + *, + synthetic_ids: list[str], +) -> None: + """Advance postflight when resolving a workflow item drains the live queue.""" + if not live_planned_queue_empty(plan): + return + resolved_state_path = state_path(args) + state_data = state_mod.load_state(resolved_state_path) + if WORKFLOW_CREATE_PLAN_ID in synthetic_ids and has_open_review_issues(state_data): + ensure_active_triage_issue_ids(plan, state_data) + inject_triage_stages(plan) + set_lifecycle_phase(plan, LIFECYCLE_PHASE_TRIAGE_POSTFLIGHT) + save_plan(plan) + return + reconcile_plan( + plan, + state_data, + target_strict=target_strict_score_from_config(state_data.get("config")), + ) + save_plan(plan) + + def resolve_workflow_patterns( args: argparse.Namespace, *, @@ -385,6 +420,7 @@ def resolve_workflow_patterns( synthetic_ids=synthetic_ids, note=note, ) + _reconcile_if_queue_drained(args, plan, synthetic_ids=synthetic_ids) if not real_patterns: return WorkflowResolveOutcome(status="handled", remaining_patterns=[]) diff --git a/desloppify/app/commands/plan/override_skip.py b/desloppify/app/commands/plan/override/skip.py similarity index 98% rename from desloppify/app/commands/plan/override_skip.py rename to desloppify/app/commands/plan/override/skip.py index 15ee3090..51f294f8 100644 --- a/desloppify/app/commands/plan/override_skip.py +++ b/desloppify/app/commands/plan/override/skip.py @@ -14,7 +14,7 @@ ) from desloppify.app.commands.helpers.command_runtime import command_runtime from desloppify.app.commands.helpers.state import require_issue_inventory -from desloppify.app.commands.plan.override_io import ( +from .io import ( _plan_file_for_state, save_plan_state_transactional, ) @@ -245,7 +245,7 @@ def cmd_plan_skip(args: argparse.Namespace) -> None: note=note, detail={"kind": kind, "reason": reason}, ) - clear_postflight_scan_completion(plan, issue_ids=issue_ids) + clear_postflight_scan_completion(plan, issue_ids=issue_ids, state=state) _save_skip_plan_state( plan=plan, plan_file=plan_file, @@ -304,7 +304,7 @@ def cmd_plan_unskip(args: argparse.Namespace) -> None: actor="user", detail={"need_reopen": need_reopen}, ) - clear_postflight_scan_completion(plan, issue_ids=unskipped_ids) + clear_postflight_scan_completion(plan, issue_ids=unskipped_ids, state=state) reopened: list[str] = [] if need_reopen: @@ -358,7 +358,7 @@ def cmd_plan_backlog(args: argparse.Namespace) -> None: # so they don't get stranded with a non-open status while untracked by plan. _BACKLOG_REOPEN_STATUSES = {"deferred", "triaged_out"} state_data = state_mod.load_state(state_file) - issues = state_data.get("issues", {}) + issues = state_data.get("work_items", {}) reopen_ids = [ fid for fid in removed if issues.get(fid, {}).get("status") in _BACKLOG_REOPEN_STATUSES @@ -374,7 +374,7 @@ def cmd_plan_backlog(args: argparse.Namespace) -> None: issue_ids=removed, actor="user", ) - clear_postflight_scan_completion(plan, issue_ids=removed) + clear_postflight_scan_completion(plan, issue_ids=removed, state=state_data) if reopen_ids: save_plan_state_transactional( diff --git a/desloppify/app/commands/plan/reorder_handlers.py b/desloppify/app/commands/plan/reorder_handlers.py index 7ab91ca6..591eba5c 100644 --- a/desloppify/app/commands/plan/reorder_handlers.py +++ b/desloppify/app/commands/plan/reorder_handlers.py @@ -17,6 +17,7 @@ append_log_entry, move_items, ) +from desloppify.engine._plan.promoted_ids import add_promoted_ids def resolve_target(plan: dict, target: str | None, position: str) -> str | None: @@ -81,4 +82,39 @@ def cmd_plan_reorder(args: argparse.Namespace) -> None: print(colorize(f" Moved {count} item(s) to {position}.", "green")) -__all__ = ["cmd_plan_reorder", "resolve_target"] +def cmd_plan_promote(args: argparse.Namespace) -> None: + """Promote backlog issues or cluster members into the active queue.""" + state = command_runtime(args).state + if not require_issue_inventory(state): + return + + patterns: list[str] = getattr(args, "patterns", []) + position: str = getattr(args, "position", "bottom") + target: str | None = getattr(args, "target", None) + + if position in ("before", "after") and target is None: + print(colorize(f" '{position}' requires --target (-t). Example: plan promote {position} -t ", "red")) + return + + plan = load_plan() + target = resolve_target(plan, target, position) + + issue_ids = resolve_ids_from_patterns(state, patterns, plan=plan) + if not issue_ids: + print(colorize(" No matching issues found.", "yellow")) + return + + count = move_items(plan, issue_ids, position, target=target) + add_promoted_ids(plan, issue_ids) + append_log_entry( + plan, + "promote", + issue_ids=issue_ids, + actor="user", + detail={"position": position, "target": target}, + ) + save_plan(plan) + print(colorize(f" Promoted {count} item(s) into the active queue.", "green")) + + +__all__ = ["cmd_plan_promote", "cmd_plan_reorder", "resolve_target"] diff --git a/desloppify/app/commands/plan/shared/cluster_membership.py b/desloppify/app/commands/plan/shared/cluster_membership.py index e34b8e87..b61fd5f3 100644 --- a/desloppify/app/commands/plan/shared/cluster_membership.py +++ b/desloppify/app/commands/plan/shared/cluster_membership.py @@ -1,37 +1,7 @@ -"""Shared cluster membership accessors for plan command readers.""" +"""Compatibility re-export for canonical plan cluster membership helpers.""" from __future__ import annotations -from desloppify.engine.plan_state import Cluster - - -def cluster_issue_ids(cluster: Cluster | dict[str, object]) -> list[str]: - """Return the effective issue IDs for a cluster.""" - ordered: list[str] = [] - seen: set[str] = set() - - def _append(raw_ids: object) -> None: - if not isinstance(raw_ids, list): - return - for raw_id in raw_ids: - if not isinstance(raw_id, str): - continue - issue_id = raw_id.strip() - if not issue_id or issue_id in seen: - continue - seen.add(issue_id) - ordered.append(issue_id) - - _append(cluster.get("issue_ids")) - - steps = cluster.get("action_steps") - if isinstance(steps, list): - for step in steps: - if not isinstance(step, dict): - continue - _append(step.get("issue_refs")) - - return ordered - +from desloppify.engine._plan.cluster_membership import cluster_issue_ids __all__ = ["cluster_issue_ids"] diff --git a/desloppify/app/commands/plan/shared/patterns.py b/desloppify/app/commands/plan/shared/patterns.py index f2f49f5c..7c86e94b 100644 --- a/desloppify/app/commands/plan/shared/patterns.py +++ b/desloppify/app/commands/plan/shared/patterns.py @@ -114,7 +114,7 @@ def resolve_ids_from_patterns( When *plan* is provided, literal IDs that exist only in the plan (e.g. ``subjective::*`` synthetic items) are included even if they - have no corresponding entry in ``state["issues"]``. + have no corresponding entry in ``state["work_items"]``. """ seen: set[str] = set() result: list[str] = [] diff --git a/desloppify/app/commands/plan/triage/confirmations/basic.py b/desloppify/app/commands/plan/triage/confirmations/basic.py index 9ac11fac..9c8fe77b 100644 --- a/desloppify/app/commands/plan/triage/confirmations/basic.py +++ b/desloppify/app/commands/plan/triage/confirmations/basic.py @@ -12,8 +12,8 @@ ensure_stage_is_confirmable, finalize_stage_confirmation, ) -from ..observe_batches import observe_dimension_breakdown from ..services import TriageServices, default_triage_services +from ..stages.records import TriageStages # Observe verdicts that trigger auto-skip on confirmation _AUTO_SKIP_VERDICTS = frozenset({"false positive", "exaggerated"}) @@ -31,6 +31,14 @@ def _find_referenced_names(text: str, names: list[str] | None) -> list[str]: ] +def _contains_any(text: str, phrases: tuple[str, ...]) -> bool: + return any(phrase in text for phrase in phrases) + + +def _count_phrase_hits(text: str, phrases: tuple[str, ...]) -> int: + return sum(1 for phrase in phrases if phrase in text) + + def _validate_observe_attestation(text: str, dimensions: list[str] | None) -> str | None: found = _find_referenced_names(text, dimensions) if found or not dimensions: @@ -60,12 +68,34 @@ def _validate_cluster_attestation( *, cluster_names: list[str] | None, action: str, + stage: str, ) -> str | None: found = _find_referenced_names(text, cluster_names) if found or not cluster_names: return None + if stage == "organize": + if _contains_any(text, ("cluster", "clusters")) and _count_phrase_hits( + text, + ("priority", "priorities", "action step", "action steps", "description", "descriptions", "depends-on", "dependency", "dependencies", "issue", "issues", "consolidat"), + ) >= 2: + return None + elif stage == "enrich": + if _contains_any(text, ("step", "steps", "cluster", "clusters")) and _count_phrase_hits( + text, + ("executor-ready", "detail", "details", "file path", "file paths", "issue ref", "issue refs", "effort"), + ) >= 2: + return None + elif stage == "sense-check": + if _count_phrase_hits( + text, + ("content", "structure", "value", "cross-cluster", "dependency", "dependencies", "decision ledger", "enrich-level", "factually accurate"), + ) >= 2 and _contains_any(text, ("verified", "safe", "pass", "passes", "recorded", "checked")): + return None names = ", ".join(cluster_names[:6]) - return f"Attestation must reference at least one cluster you {action}. Mention one of: {names}" + return ( + f"Attestation must reference at least one cluster you {action}, or clearly describe the " + f"verified {stage} work product. Mention one of: {names}" + ) def validate_attestation( @@ -88,16 +118,19 @@ def validate_attestation( text, cluster_names=cluster_names, action="organized", + stage="organize", ), "enrich": lambda: _validate_cluster_attestation( text, cluster_names=cluster_names, action="enriched", + stage="enrich", ), "sense-check": lambda: _validate_cluster_attestation( text, cluster_names=cluster_names, action="sense-checked", + stage="sense-check", ), } validator = validators.get(stage) @@ -186,7 +219,7 @@ def _undo_observe_auto_skips(plan: dict, meta: dict) -> int: def confirm_observe( args: argparse.Namespace, plan: dict, - stages: dict, + stages: TriageStages, attestation: str | None, *, services: TriageServices | None = None, @@ -196,16 +229,14 @@ def confirm_observe( if not ensure_stage_is_confirmable(stages, stage="observe"): return - runtime = resolved_services.command_runtime(args) - si = resolved_services.collect_triage_input(plan, runtime.state) obs = stages["observe"] - print(colorize(" Stage: OBSERVE — Analyse issues & spot contradictions", "bold")) + print(colorize(" Stage: OBSERVE — Verify queued issues against the code", "bold")) print(colorize(" " + "─" * 54, "dim")) - by_dim, dim_names = observe_dimension_breakdown(si) - - issue_count = obs.get("issue_count", len(si.open_issues)) + by_dim = obs.get("dimension_counts", {}) + dim_names = obs.get("dimension_names", sorted(by_dim)) + issue_count = int(obs.get("issue_count", 0) or 0) print(f" Your analysis covered {issue_count} issues across {len(by_dim)} dimensions:") for dim in dim_names: print(f" {dim}: {by_dim[dim]} issues") @@ -273,7 +304,8 @@ def confirm_reflect( print(colorize(" Stage: REFLECT — Form strategy & present to user", "bold")) print(colorize(" " + "─" * 50, "dim")) - recurring = resolved_services.detect_recurring_patterns(si.open_issues, si.resolved_issues) + review_issues = getattr(si, "review_issues", getattr(si, "open_issues", {})) + recurring = resolved_services.detect_recurring_patterns(review_issues, si.resolved_issues) if recurring: print(f" Your strategy identified {len(recurring)} recurring dimension(s):") for dim, info in sorted(recurring.items()): @@ -294,7 +326,8 @@ def confirm_reflect( print(colorize(" │ ...", "cyan")) print(colorize(" └" + "─" * 51 + "┘", "cyan")) - _by_dim, observe_dims = observe_dimension_breakdown(si) + observe_stage = stages.get("observe", {}) + observe_dims = list(observe_stage.get("dimension_names", [])) reflect_dims = sorted(set((list(recurring.keys()) if recurring else []) + observe_dims)) reflect_clusters = [name for name in plan.get("clusters", {}) if not plan["clusters"][name].get("auto")] diff --git a/desloppify/app/commands/plan/triage/confirmations/enrich.py b/desloppify/app/commands/plan/triage/confirmations/enrich.py index b71a66fe..40b31501 100644 --- a/desloppify/app/commands/plan/triage/confirmations/enrich.py +++ b/desloppify/app/commands/plan/triage/confirmations/enrich.py @@ -14,6 +14,8 @@ finalize_stage_confirmation, ) from ..services import TriageServices, default_triage_services +from ..stages.helpers import scoped_manual_clusters_with_issues +from ..review_coverage import active_triage_issue_ids from ..validation.enrich_quality import ( EnrichQualityIssue as _ConfirmationCheckIssue, EnrichQualityReport as _ConfirmationCheckReport, @@ -41,6 +43,7 @@ def _collect_enrich_level_confirmation_checks( plan: dict, *, include_stale_issue_ref_warning: bool, + triage_issue_ids: set[str] | None = None, ) -> _ConfirmationCheckReport: from desloppify.base.discovery.paths import get_project_root @@ -53,6 +56,7 @@ def _collect_enrich_level_confirmation_checks( include_missing_issue_refs=True, include_vague_detail=True, stale_issue_refs_severity="warning" if include_stale_issue_ref_warning else None, + triage_issue_ids=triage_issue_ids, ) @@ -170,10 +174,12 @@ def confirm_enrich( resolved_services = services or default_triage_services() if not ensure_stage_is_confirmable(stages, stage="enrich"): return + state = resolved_services.command_runtime(args).state checks = _collect_enrich_level_confirmation_checks( plan, include_stale_issue_ref_warning=True, + triage_issue_ids=active_triage_issue_ids(plan, state) or None, ) print(colorize(" Stage: ENRICH — Make steps executor-ready (detail, refs)", "bold")) @@ -184,7 +190,7 @@ def confirm_enrich( _print_stale_ref_warning(checks.warning("stale_issue_refs")) - enrich_clusters = [n for n in plan.get("clusters", {}) if not plan["clusters"][n].get("auto")] + enrich_clusters = scoped_manual_clusters_with_issues(plan, state) if not finalize_stage_confirmation( plan=plan, @@ -221,10 +227,12 @@ def confirm_sense_check( resolved_services = services or default_triage_services() if not ensure_stage_is_confirmable(stages, stage="sense-check"): return + state = resolved_services.command_runtime(args).state checks = _collect_enrich_level_confirmation_checks( plan, include_stale_issue_ref_warning=False, + triage_issue_ids=active_triage_issue_ids(plan, state) or None, ) print(colorize(" Stage: SENSE-CHECK — Verify accuracy & cross-cluster deps", "bold")) @@ -235,7 +243,7 @@ def confirm_sense_check( print(colorize(" All enrich-level checks pass.", "green")) - sense_check_clusters = [n for n in plan.get("clusters", {}) if not plan["clusters"][n].get("auto")] + sense_check_clusters = scoped_manual_clusters_with_issues(plan, state) if not finalize_stage_confirmation( plan=plan, diff --git a/desloppify/app/commands/plan/triage/confirmations/organize.py b/desloppify/app/commands/plan/triage/confirmations/organize.py index 621ba263..979b0013 100644 --- a/desloppify/app/commands/plan/triage/confirmations/organize.py +++ b/desloppify/app/commands/plan/triage/confirmations/organize.py @@ -21,6 +21,11 @@ triage_coverage, ) from ..services import TriageServices, default_triage_services +from ..validation.enrich_checks import ( + _cluster_file_overlaps, + _clusters_with_directory_scatter, + _clusters_with_high_step_ratio, +) def _require_enriched_clusters(plan: dict) -> bool: @@ -67,12 +72,6 @@ def _print_reflect_activity_summary(plan: dict, stages: dict) -> None: def _print_cluster_shape_warnings(plan: dict) -> None: - from ..validation.core import ( # noqa: PLC0415 - _cluster_file_overlaps, - _clusters_with_directory_scatter, - _clusters_with_high_step_ratio, - ) - scattered = _clusters_with_directory_scatter(plan) if scattered: print(colorize(f"\n Warning: {len(scattered)} cluster(s) span many unrelated directories:", "yellow")) diff --git a/desloppify/app/commands/plan/triage/display/dashboard.py b/desloppify/app/commands/plan/triage/display/dashboard.py index 817f0d5d..e679e05d 100644 --- a/desloppify/app/commands/plan/triage/display/dashboard.py +++ b/desloppify/app/commands/plan/triage/display/dashboard.py @@ -182,12 +182,12 @@ def print_reflect_dashboard( resolved_services = services or default_triage_services() completed = getattr(si, "completed_clusters", []) resolved = getattr(si, "resolved_issues", {}) - open_issues = getattr(si, "open_issues", {}) + review_issues = getattr(si, "review_issues", getattr(si, "open_issues", {})) _print_completed_clusters(completed) _print_resolved_issue_deltas(resolved) _print_recurring_or_first_triage( - recurring=resolved_services.detect_recurring_patterns(open_issues, resolved), + recurring=resolved_services.detect_recurring_patterns(review_issues, resolved), completed=completed, resolved=resolved, ) @@ -269,12 +269,13 @@ def cmd_triage_dashboard( print_dashboard_header(si, stages, meta, plan, snapshot=snapshot) print_action_guidance(stages, meta, si, plan, snapshot=snapshot) print_prior_stage_reports(stages) - print_issues_by_dimension(si.open_issues) + review_issues = getattr(si, "review_issues", getattr(si, "open_issues", {})) + print_issues_by_dimension(review_issues) if "observe" in stages and "reflect" not in stages: print_reflect_dashboard(si, plan, services=resolved_services) - print_progress(plan, si.open_issues) + print_progress(plan, review_issues) __all__ = [ diff --git a/desloppify/app/commands/plan/triage/display/layout.py b/desloppify/app/commands/plan/triage/display/layout.py index 8c71e983..a27947fb 100644 --- a/desloppify/app/commands/plan/triage/display/layout.py +++ b/desloppify/app/commands/plan/triage/display/layout.py @@ -5,6 +5,7 @@ from collections import defaultdict from desloppify.app.commands.helpers.issue_id_display import short_issue_id +from desloppify.engine._plan.constants import is_synthetic_id from desloppify.engine.plan_triage import TriageSnapshot from desloppify.engine.plan_triage import ( TRIAGE_CMD_CLUSTER_ADD, @@ -56,9 +57,10 @@ def print_dashboard_header( snapshot: TriageSnapshot | None = None, ) -> None: """Print the header section: title, open issues count, stage progress, overall status.""" + review_issues = getattr(si, "review_issues", getattr(si, "open_issues", {})) print(colorize(" Cluster triage", "bold")) print(colorize(" " + "─" * 60, "dim")) - print(f" Open review issues: {len(si.open_issues)}") + print(f" Open review issues: {len(review_issues)}") print(colorize(" Goal: identify contradictions, resolve them, then group the coherent", "cyan")) print(colorize(" remainder into clusters by root cause with action steps and priorities.", "cyan")) print(colorize(" Preferred: staged runner workflow (Codex or Claude).", "cyan")) @@ -74,7 +76,7 @@ def print_dashboard_header( if new_since_last: print(colorize(f" New since last triage: {len(new_since_last)}", "yellow")) for fid in sorted(new_since_last): - issue = si.open_issues.get(fid, {}) + issue = review_issues.get(fid, {}) dim = "" detail = issue.get("detail") if isinstance(detail, dict): @@ -288,7 +290,7 @@ def show_plan_summary(plan: dict, state: dict) -> None: for name, cluster in clusters.items() if cluster_issue_ids(cluster) and not cluster.get("auto") } - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) if active: print(colorize(f"\n Clusters ({len(active)}):", "bold")) @@ -301,7 +303,7 @@ def show_plan_summary(plan: dict, state: dict) -> None: queue_order = [ fid for fid in plan.get("queue_order", []) - if not fid.startswith("triage::") and not fid.startswith("workflow::") + if not is_synthetic_id(fid) ] if queue_order: show = min(15, len(queue_order)) diff --git a/desloppify/app/commands/plan/triage/lifecycle.py b/desloppify/app/commands/plan/triage/lifecycle.py index 75b0ac2f..ecd20beb 100644 --- a/desloppify/app/commands/plan/triage/lifecycle.py +++ b/desloppify/app/commands/plan/triage/lifecycle.py @@ -12,6 +12,10 @@ TriageStartDecision, decide_triage_start, ) +from desloppify.engine._plan.policy.subjective import ( + SubjectiveVisibility, + compute_subjective_visibility, +) from desloppify.base.output.terminal import colorize from desloppify.state_io import StateModel @@ -58,7 +62,7 @@ def _print_triage_start_block(reason: str, *, deps: TriageLifecycleDeps) -> None ) return - print(deps.colorize(" Cannot start triage while objective backlog is still open.", "red")) + print(deps.colorize(" Triage is pending behind the current objective backlog.", "red")) print( deps.colorize( " Finish current objective work first, or pass --attestation " @@ -73,6 +77,7 @@ def ensure_triage_started( *, services: TriageServices, state: StateModel | None = None, + policy: SubjectiveVisibility | None = None, attestation: str | None = None, log_action: str | None = None, log_actor: str = "system", @@ -84,6 +89,9 @@ def ensure_triage_started( """Ensure triage stages are injected or return a blocked/already-active outcome.""" resolved_deps = deps or TriageLifecycleDeps() meta = plan.setdefault("epic_triage_meta", {}) + resolved_policy = policy + if resolved_policy is None and state is not None: + resolved_policy = compute_subjective_visibility(state, plan=plan) if resolved_deps.has_triage_in_queue(plan): if state is not None: @@ -95,6 +103,7 @@ def ensure_triage_started( decision = resolved_deps.decide_triage_start( plan, state, + policy=resolved_policy, explicit_start=True, attested_override=bool(attestation and len(attestation.strip()) >= 30), ) diff --git a/desloppify/app/commands/plan/triage/observe_batches.py b/desloppify/app/commands/plan/triage/observe_batches.py index 653423a1..8bec4e5b 100644 --- a/desloppify/app/commands/plan/triage/observe_batches.py +++ b/desloppify/app/commands/plan/triage/observe_batches.py @@ -10,8 +10,9 @@ def observe_dimension_breakdown(si: TriageInput) -> tuple[dict[str, int], list[str]]: """Count open triage issues by review dimension.""" + review_issues = getattr(si, "review_issues", getattr(si, "open_issues", {})) by_dim: dict[str, int] = defaultdict(int) - for issue in si.open_issues.values(): + for issue in review_issues.values(): detail = issue.get("detail", {}) if isinstance(issue.get("detail"), dict) else {} dim = detail.get("dimension", "unknown") by_dim[dim] += 1 @@ -24,9 +25,10 @@ def group_issues_into_observe_batches( max_batches: int = 5, ) -> list[tuple[list[str], dict[str, Issue]]]: """Group observe issues into dimension-balanced batches.""" + review_issues = getattr(si, "review_issues", getattr(si, "open_issues", {})) by_dim, dim_names = observe_dimension_breakdown(si) if len(dim_names) <= 1: - return [(dim_names, dict(si.open_issues))] + return [(dim_names, dict(review_issues))] num_batches = min(max_batches, len(dim_names)) batch_dims: list[list[str]] = [[] for _ in range(num_batches)] @@ -37,7 +39,7 @@ def group_issues_into_observe_batches( batch_counts[lightest] += by_dim[dim] dim_to_issues: dict[str, dict[str, Issue]] = defaultdict(dict) - for fid, issue in si.open_issues.items(): + for fid, issue in review_issues.items(): detail = issue.get("detail", {}) if isinstance(issue.get("detail"), dict) else {} dim = detail.get("dimension", "unknown") dim_to_issues[dim][fid] = issue diff --git a/desloppify/app/commands/plan/triage/plan_state_access.py b/desloppify/app/commands/plan/triage/plan_state_access.py index 00519d05..427a68ea 100644 --- a/desloppify/app/commands/plan/triage/plan_state_access.py +++ b/desloppify/app/commands/plan/triage/plan_state_access.py @@ -2,9 +2,15 @@ from __future__ import annotations -from typing import Any, cast +from typing import cast -from desloppify.engine.plan_state import Cluster, PlanModel +from desloppify.engine.plan_state import ( + Cluster, + EpicTriageMeta, + ExecutionLogEntry, + PlanModel, + SkipEntry, +) def ensure_queue_order(plan: PlanModel) -> list[str]: @@ -17,12 +23,12 @@ def ensure_queue_order(plan: PlanModel) -> list[str]: return normalized -def ensure_skipped_map(plan: PlanModel) -> dict[str, Any]: +def ensure_skipped_map(plan: PlanModel) -> dict[str, SkipEntry]: """Return skipped metadata, creating the stored map when missing.""" skipped = plan.get("skipped") if isinstance(skipped, dict): - return cast(dict[str, Any], skipped) - normalized: dict[str, Any] = {} + return cast(dict[str, SkipEntry], skipped) + normalized: dict[str, SkipEntry] = {} plan["skipped"] = normalized return normalized @@ -37,25 +43,25 @@ def ensure_cluster_map(plan: PlanModel) -> dict[str, Cluster]: return normalized -def ensure_triage_meta(plan: PlanModel) -> dict[str, Any]: +def ensure_triage_meta(plan: PlanModel) -> EpicTriageMeta: """Return triage metadata, creating the stored map when missing.""" meta = plan.get("epic_triage_meta") if isinstance(meta, dict): - return cast(dict[str, Any], meta) - normalized: dict[str, Any] = {} + return cast(EpicTriageMeta, meta) + normalized: EpicTriageMeta = {} plan["epic_triage_meta"] = normalized return normalized -def ensure_execution_log(plan: PlanModel) -> list[dict[str, Any]]: +def ensure_execution_log(plan: PlanModel) -> list[ExecutionLogEntry]: """Return execution log, creating the stored list when missing.""" log = plan.get("execution_log") if isinstance(log, list): normalized = [entry for entry in log if isinstance(entry, dict)] if normalized is not log: plan["execution_log"] = normalized - return normalized - normalized: list[dict[str, Any]] = [] + return cast(list[ExecutionLogEntry], normalized) + normalized: list[ExecutionLogEntry] = [] plan["execution_log"] = normalized return normalized diff --git a/desloppify/app/commands/plan/triage/runner/orchestrator_claude.py b/desloppify/app/commands/plan/triage/runner/orchestrator_claude.py index 077b2e3f..4fe8463b 100644 --- a/desloppify/app/commands/plan/triage/runner/orchestrator_claude.py +++ b/desloppify/app/commands/plan/triage/runner/orchestrator_claude.py @@ -67,7 +67,7 @@ def run_claude_orchestrator( print(" - Check the dashboard between stages.") print(" - Observe subagent should use sub-subagents (one per dimension group).") print(" - Enrich subagent should use sub-subagents (one per cluster).") - print(" - Sense-check launches TWO parallel subagents (content + structure).") + print(" - Sense-check launches THREE subagents (content + structure + value).") print(" - If a stage fails validation, fix and re-record.") diff --git a/desloppify/app/commands/plan/triage/runner/orchestrator_codex_pipeline.py b/desloppify/app/commands/plan/triage/runner/orchestrator_codex_pipeline.py index c75b2c9b..ebdae47e 100644 --- a/desloppify/app/commands/plan/triage/runner/orchestrator_codex_pipeline.py +++ b/desloppify/app/commands/plan/triage/runner/orchestrator_codex_pipeline.py @@ -49,6 +49,7 @@ ) from .orchestrator_common import STAGES, run_stamp from .stage_prompts import build_stage_prompt +from ..stages.helpers import value_check_targets _STAGE_HANDLERS: dict[str, StageHandler] = DEFAULT_STAGE_HANDLERS _analyze_reflect_issue_accounting = analyze_reflect_issue_accounting _validate_reflect_issue_accounting = validate_reflect_accounting @@ -160,6 +161,13 @@ def _run_stage_sequence( pipeline_context.append_run_log(f"stage-start stage={stage}") si = pipeline_context.services.collect_triage_input(plan, pipeline_context.state) + if stage == "sense-check": + si.value_check_targets = value_check_targets(plan, pipeline_context.state) + setattr( + pipeline_context.args, + "sense_check_value_targets", + list(si.value_check_targets), + ) last_triage_input = si execution_result = execute_stage_impl( StageRunContext( diff --git a/desloppify/app/commands/plan/triage/runner/orchestrator_codex_pipeline_completion.py b/desloppify/app/commands/plan/triage/runner/orchestrator_codex_pipeline_completion.py index 33586e8c..f6d99955 100644 --- a/desloppify/app/commands/plan/triage/runner/orchestrator_codex_pipeline_completion.py +++ b/desloppify/app/commands/plan/triage/runner/orchestrator_codex_pipeline_completion.py @@ -119,13 +119,29 @@ def validate_and_confirm_stage( def build_completion_strategy(stages_data: Mapping[str, Mapping[str, Any]]) -> str: """Derive a completion strategy from stage reports.""" strategy_parts: list[str] = [] + recorded_stages: list[str] = [] for stage in STAGES: report = str(stages_data.get(stage, {}).get("report", "")) if report: + recorded_stages.append(stage) strategy_parts.append(f"[{stage}] {report[:200]}") strategy = " ".join(strategy_parts) if len(strategy) < 200: - strategy = strategy + " " + "Automated triage via codex subagent pipeline. " * 3 + if recorded_stages: + stage_list = ", ".join(recorded_stages) + summary = ( + f"Triage covered {len(recorded_stages)} stage(s): {stage_list}. " + "Use the recorded stage reports as the execution plan, preserve the " + "observe and reflect evidence trail, keep organize and enrich changes " + "aligned with the recorded dispositions, and verify each cluster before completion." + ) + strategy = f"{strategy} {summary}".strip() + else: + strategy = ( + "Triage completed without recorded stage reports. Before completion, capture " + "the execution order, the cluster or skip decisions that will change plan state, " + "and the verification steps needed to confirm the resulting queue is correct." + ) return strategy diff --git a/desloppify/app/commands/plan/triage/runner/orchestrator_codex_pipeline_execution.py b/desloppify/app/commands/plan/triage/runner/orchestrator_codex_pipeline_execution.py index 3771cb51..255b0fba 100644 --- a/desloppify/app/commands/plan/triage/runner/orchestrator_codex_pipeline_execution.py +++ b/desloppify/app/commands/plan/triage/runner/orchestrator_codex_pipeline_execution.py @@ -14,7 +14,7 @@ from desloppify.engine.plan_triage import compute_triage_progress from ..services import TriageServices -from ..validation.core import validate_organize_against_reflect_ledger +from ..validation.organize_policy import validate_organize_against_reflect_ledger from ..validation.reflect_accounting import ( analyze_reflect_issue_accounting, validate_reflect_accounting, @@ -85,6 +85,7 @@ def _record_sense_check_report( stage="sense-check", report=report, state=getattr(args, "state", None), + value_targets=getattr(args, "sense_check_value_targets", None), ) cmd_stage_sense_check(record_args, services=services) @@ -125,6 +126,7 @@ def _record_sense_check_report( apply_updates=True, reload_plan=context.services.load_plan, append_run_log=context.append_run_log, + state=context.state, ), record_report=_record_sense_check_report, ), @@ -211,7 +213,9 @@ def preflight_stage( reflect_report = str(stages.get("reflect", {}).get("report", "")) accounting_ok, _cited, missing_ids, duplicate_ids = validate_reflect_issue_accounting( report=reflect_report, - valid_ids=set(getattr(triage_input, "open_issues", {}).keys()), + valid_ids=set( + getattr(triage_input, "review_issues", getattr(triage_input, "open_issues", {})).keys() + ), ) if not accounting_ok: reason_parts: list[str] = [] @@ -311,7 +315,9 @@ def repair_reflect_report_if_needed( """Retry reflect once with a targeted repair prompt when accounting is invalid.""" _cited, missing_ids, duplicate_ids = dependencies.analyze_reflect_issue_accounting( report=report, - valid_ids=set(getattr(triage_input, "open_issues", {}).keys()), + valid_ids=set( + getattr(triage_input, "review_issues", getattr(triage_input, "open_issues", {})).keys() + ), ) if not missing_ids and not duplicate_ids: return report, None @@ -354,7 +360,9 @@ def repair_reflect_report_if_needed( _cited, missing_after, duplicates_after = dependencies.analyze_reflect_issue_accounting( report=repaired_report, - valid_ids=set(getattr(triage_input, "open_issues", {}).keys()), + valid_ids=set( + getattr(triage_input, "review_issues", getattr(triage_input, "open_issues", {})).keys() + ), ) if missing_after or duplicates_after: return None, "reflect_repair_invalid" diff --git a/desloppify/app/commands/plan/triage/runner/orchestrator_codex_sense.py b/desloppify/app/commands/plan/triage/runner/orchestrator_codex_sense.py index cbb4007b..01d756fd 100644 --- a/desloppify/app/commands/plan/triage/runner/orchestrator_codex_sense.py +++ b/desloppify/app/commands/plan/triage/runner/orchestrator_codex_sense.py @@ -15,7 +15,7 @@ render_policy_block, ) -from ..review_coverage import manual_clusters_with_issues +from ..stages.helpers import scoped_manual_clusters_with_issues, triage_scoped_plan from .codex_runner import ( TriageStageRunResult, _output_file_has_text, @@ -26,6 +26,7 @@ build_sense_check_content_prompt, build_sense_check_structure_prompt, ) +from .stage_prompts_sense import build_sense_check_value_prompt def _noop_log(_msg: str) -> None: @@ -45,13 +46,13 @@ def _print_sense_header(total_content: int, *, apply_updates: bool, log: Callabl if apply_updates: print( colorize( - f"\n Sense-check: {total_content} content batches, then 1 structure batch.", + f"\n Sense-check: {total_content} content batches, then 1 structure batch, then 1 value batch.", "bold", ) ) log(f"sense-check-sequenced content_batches={total_content} apply_updates=1") return - print(colorize(f"\n Sense-check: {total_content} content batches + 1 structure batch.", "bold")) + print(colorize(f"\n Sense-check: {total_content} content batches + 1 structure batch + 1 value batch.", "bold")) log(f"sense-check-parallel content_batches={total_content}") @@ -246,6 +247,33 @@ def _merge_batch_outputs(batch_meta: list[tuple[str, Path]]) -> str: return "\n\n---\n\n".join(parts) +def _value_batch_config( + *, + plan: dict, + state: dict | None, + repo_root: Path, + prompts_dir: Path, + output_dir: Path, + logs_dir: Path, + mode: str, + cli_command: str, +) -> SenseBatchConfig: + prompt = build_sense_check_value_prompt( + plan=dict(plan), + state=state, + repo_root=repo_root, + mode=mode, + cli_command=cli_command, + ) + return SenseBatchConfig( + label="value", + prompt_file=prompts_dir / "sense_check_value.md", + output_file=output_dir / "sense_check_value.raw.txt", + log_file=logs_dir / "sense_check_value.log", + prompt=prompt, + ) + + def run_sense_check( *, plan: dict, @@ -259,13 +287,15 @@ def run_sense_check( apply_updates: bool = False, reload_plan: Callable[[], dict] | None = None, append_run_log=None, + state: dict | None = None, ) -> TriageStageRunResult: - """Run sense-check via parallel codex subprocess batches.""" + """Run sense-check via parallel codex subprocess batches (content → structure → value).""" _log = append_run_log or _noop_log - clusters = manual_clusters_with_issues(plan) + scoped_plan = triage_scoped_plan(plan, state) + clusters = scoped_manual_clusters_with_issues(plan, state) total_content = len(clusters) - total = total_content + 1 + total = total_content + 2 # +1 structure +1 value _print_sense_header(total_content, apply_updates=apply_updates, log=_log) policy_result = load_policy_result() @@ -280,7 +310,7 @@ def run_sense_check( content_mode, structure_mode = _sense_modes(apply_updates=apply_updates) content_tasks, batch_meta = _content_tasks_and_meta( clusters=clusters, - plan=plan, + plan=scoped_plan, repo_root=repo_root, prompts_dir=prompts_dir, output_dir=output_dir, @@ -292,7 +322,7 @@ def run_sense_check( cli_command=cli_command, log=_log, ) - structure_plan = dict(plan) + structure_plan = dict(scoped_plan) structure_config = _structure_batch_config( plan=structure_plan, repo_root=repo_root, @@ -355,6 +385,51 @@ def run_sense_check( if structure_failures: return _parallel_failure_result(structure_failures, log=_log) + # Value batch — runs after structure (needs corrected plan state) + value_plan = dict(plan) + if apply_updates and reload_plan is not None: + reloaded = _reload_structure_plan(reload_plan=reload_plan, log=_log) + if reloaded is not None: + value_plan = reloaded + _log("sense-check-plan-reloaded phase=value") + + value_mode = "self_record" if apply_updates else "output_only" + value_config = _value_batch_config( + plan=value_plan, + state=state, + repo_root=repo_root, + prompts_dir=prompts_dir, + output_dir=output_dir, + logs_dir=logs_dir, + mode=value_mode, + cli_command=cli_command, + ) + _write_batch_prompt(value_config) + batch_meta.append((value_config.label, value_config.output_file)) + print(colorize(" Value batch: YAGNI/KISS pass", "dim")) + _log("sense-check-value batch=global") + + if not dry_run: + value_tasks: dict[int, Callable[[], TriageStageRunResult]] = { + 0: partial( + run_triage_stage, + prompt=value_config.prompt, + repo_root=repo_root, + output_file=value_config.output_file, + log_file=value_config.log_file, + timeout_seconds=timeout_seconds, + validate_output_fn=_output_file_has_text, + ) + } + value_failures = _run_parallel_or_fail( + tasks=value_tasks, + stage_label="Sense-check", + batch_label_fn=lambda _idx: "value", + log=_log, + ) + if value_failures: + return _parallel_failure_result(value_failures, log=_log) + merged = _merge_batch_outputs(batch_meta) print(colorize(f" Sense-check: merged {total} batch outputs ({len(merged)} chars).", "green")) _log(f"sense-check-parallel-done merged_chars={len(merged)}") diff --git a/desloppify/app/commands/plan/triage/runner/orchestrator_common.py b/desloppify/app/commands/plan/triage/runner/orchestrator_common.py index 1a3c0175..64dcb210 100644 --- a/desloppify/app/commands/plan/triage/runner/orchestrator_common.py +++ b/desloppify/app/commands/plan/triage/runner/orchestrator_common.py @@ -4,7 +4,13 @@ from datetime import UTC, datetime -STAGES: tuple[str, ...] = ("observe", "reflect", "organize", "enrich", "sense-check") +STAGES: tuple[str, ...] = ( + "observe", + "reflect", + "organize", + "enrich", + "sense-check", +) def parse_only_stages(raw: str | None) -> list[str]: diff --git a/desloppify/app/commands/plan/triage/runner/stage_prompts.py b/desloppify/app/commands/plan/triage/runner/stage_prompts.py index 0586453d..ee98acdf 100644 --- a/desloppify/app/commands/plan/triage/runner/stage_prompts.py +++ b/desloppify/app/commands/plan/triage/runner/stage_prompts.py @@ -27,25 +27,28 @@ from .stage_prompts_sense import ( build_sense_check_content_prompt, build_sense_check_structure_prompt, + build_sense_check_value_prompt, ) from .stage_prompts_validation import _validation_requirements def _required_issue_hashes(triage_input: TriageInput) -> list[str]: """Return sorted short hashes for open review issues.""" - return sorted(issue_id.rsplit("::", 1)[-1] for issue_id in triage_input.open_issues) + review_issues = getattr(triage_input, "review_issues", getattr(triage_input, "open_issues", {})) + return sorted(issue_id.rsplit("::", 1)[-1] for issue_id in review_issues) def _compact_issue_summary(triage_input: TriageInput) -> str: """Return a compact issue summary for later triage stages.""" + review_issues = getattr(triage_input, "review_issues", getattr(triage_input, "open_issues", {})) by_dim: Counter[str] = Counter() - for issue in triage_input.open_issues.values(): + for issue in review_issues.values(): detail = issue.get("detail", {}) if isinstance(issue.get("detail"), dict) else {} by_dim[str(detail.get("dimension", "unknown"))] += 1 dims = ", ".join(f"{name} ({count})" for name, count in sorted(by_dim.items())) parts = [ "## Issue Summary", - f"Open review issues: {len(triage_input.open_issues)}", + f"Open review issues: {len(review_issues)}", ] if dims: parts.append(f"Open dimensions: {dims}") @@ -343,6 +346,7 @@ def cmd_stage_prompt( "build_observe_batch_prompt", "build_sense_check_content_prompt", "build_sense_check_structure_prompt", + "build_sense_check_value_prompt", "build_stage_prompt", "cmd_stage_prompt", "_observe_batch_instructions", diff --git a/desloppify/app/commands/plan/triage/runner/stage_prompts_instruction_blocks.py b/desloppify/app/commands/plan/triage/runner/stage_prompts_instruction_blocks.py index 14ce2954..0abc9479 100644 --- a/desloppify/app/commands/plan/triage/runner/stage_prompts_instruction_blocks.py +++ b/desloppify/app/commands/plan/triage/runner/stage_prompts_instruction_blocks.py @@ -134,7 +134,16 @@ def _reflect_instructions(mode: PromptMode = "self_record") -> str: 6. **Check recurring patterns** — compare current issues against resolved history. If the same dimension keeps producing issues, that's a root cause that needs addressing, not just another round of fixes. -7. **Account for every issue exactly once** — every open issue hash must appear in exactly one +7. **Consider mechanical backlog** — the backlog section shows auto-clusters + (pre-grouped detector findings) and unclustered items. For each auto-cluster: + - **promote**: name it in a `## Backlog Promotions` section. Prefer clusters with + `[autofix: ...]` hints because they are lower-risk. + - **leave**: say nothing. Silence means it stays in backlog. + - **supersede**: absorb the underlying work into a review cluster when the same files + or root cause already belong together. + For unclustered items: promote individually or group related ones into a manual cluster. + Mechanical items are NOT part of the Coverage Ledger — that ledger remains review-issues only. +8. **Account for every issue exactly once** — every open issue hash must appear in exactly one cluster line or one skip line. Do not drop hashes, and do not repeat a hash in multiple clusters or in both a cluster and a skip. @@ -150,6 +159,9 @@ def _reflect_instructions(mode: PromptMode = "self_record") -> str: Cluster "media-lightbox-hooks" (all in src/domains/media-lightbox/) Cluster "task-typing" (both touch src/types/database.ts) +## Backlog Promotions +- Promote auto/unused-imports (overlaps with the files in cluster "task-typing") + ## Skip Decisions Skip "false-positive-current-code" (false positive per observe) ``` @@ -203,14 +215,18 @@ def _organize_instructions(mode: PromptMode = "self_record") -> str: 3. Create clusters as specified in the blueprint: `desloppify plan cluster create --description "..."` 4. Add issues: `desloppify plan cluster add ` -5. Add steps that consolidate: one step per file or logical change, NOT one step per issue -6. Set `--effort` on each step individually (trivial/small/medium/large) -7. Set `--depends-on` when clusters touch overlapping files +5. Promote any mechanical backlog items that reflect explicitly selected: + - Auto-clusters: `desloppify plan promote auto/` + - Individual items: `desloppify plan promote ` + - With placement: `desloppify plan promote before -t ` +6. Add steps that consolidate: one step per file or logical change, NOT one step per issue +7. Set `--effort` on each step individually (trivial/small/medium/large) +8. Set `--depends-on` when clusters touch overlapping files """ tail = """\ When done, run: ``` -desloppify plan triage --stage organize --report "" --attestation "<80+ chars mentioning cluster names>" +desloppify plan triage --stage organize --report "" --attestation "<80+ chars mentioning cluster names or the organized work>" ``` """ if mode == "output_only": @@ -284,7 +300,7 @@ def _enrich_instructions(mode: PromptMode = "self_record") -> str: tail = """\ When done, run: ``` -desloppify plan triage --stage enrich --report "" --attestation "<80+ chars mentioning cluster names>" +desloppify plan triage --stage enrich --report "" --attestation "<80+ chars mentioning cluster names or the executor-ready work>" ``` """ if mode == "output_only": @@ -389,6 +405,15 @@ def _sense_check_instructions(mode: PromptMode = "self_record") -> str: structure_fix_block = """\ Fix with: `desloppify plan cluster update --depends-on ` Fix with: `desloppify plan cluster update --add-step "..." --detail "..." --effort trivial --issue-refs ` +""" + value_commands = """\ +Use these commands aggressively: +- `desloppify next --count 100` to inspect the current execution queue +- `desloppify plan cluster show ` to inspect cluster members and steps +- `desloppify show --no-budget` to re-read the underlying finding +- `desloppify plan cluster update --description "..." --update-step N --detail "..." --effort small` +- `desloppify plan skip --permanent --note "" --attest "I have reviewed this triage skip against the code and I am not gaming the score by suppressing a real defect."` +- `desloppify plan cluster delete ` if you skipped everything it contained """ tail = """\ When done, run: @@ -404,8 +429,12 @@ def _sense_check_instructions(mode: PromptMode = "self_record") -> str: "Report the exact dependency additions or cascade steps that need to be made; " "the orchestrator will apply them.\n" ) + value_commands = ( + "State the exact queue items to keep, tighten, or skip, plus any cluster-step " + "updates or deletions needed. The orchestrator will apply them." + ) tail = """\ -When done, write a plain-text sense-check report with concrete content and structure fixes. +When done, write a plain-text sense-check report with concrete content, structure, and value findings. The orchestrator records and confirms the stage. """ investigation_hint = ( @@ -418,8 +447,11 @@ def _sense_check_instructions(mode: PromptMode = "self_record") -> str: return f"""\ ## SENSE-CHECK Stage Instructions -This stage is handled by two parallel subagents. If you are being run as a -single-subprocess fallback, perform BOTH the content and structure checks below. +This stage is handled by three subagents. If you are being run as a +single-subprocess fallback, perform ALL three checks below. + +Execution order: content batches (parallel) → structure batch → value batch (sequential). +Value runs last because it needs the corrected plan state from content and structure. ### Content Check (per cluster) {investigation_hint}For EVERY step in every cluster, read the actual source file and verify: @@ -452,6 +484,45 @@ def _sense_check_instructions(mode: PromptMode = "self_record") -> str: {structure_fix_block} +### Value Check (global — YAGNI/KISS pass) + +Walk the CURRENT planned work item by item and make the final judgment about value. +Ask: does doing this make the codebase genuinely better? Beauty is a valid reason to keep work, +but not if it buys that beauty with new indirection, wrappers, abstraction layers, or confusion. + +For EVERY live queue target, choose exactly one: +1. `keep` — clearly improves correctness, clarity, cohesion, simplicity, or elegance +2. `tighten` — worth doing, but the plan must be simplified or made more concrete first +3. `skip` — the fix would add churn, indirection, coordination, or abstraction for too little gain + +What should usually be skipped: +- facade pruning that just spreads imports +- abstraction-for-abstraction's-sake +- tiny theoretical cleanups that make the code harder to follow +- fixes whose implementation is more complicated than the current code + +What can still be worth keeping: +- simplifications that delete layers or reduce branching +- aesthetic cleanups that genuinely improve readability without adding machinery +- focused unifications that make naming or flow more coherent with less confusion + +{value_commands} + +Required process: +1. Re-read the actual code behind each queue target. +2. Apply the rubric above, not the raw issue title. +3. Tighten any keeper whose steps are too vague, too broad, or too complicated. +4. Permanently skip anything that fails the value test. +5. Delete dead clusters after skipping all their members. + +Required report structure — include a Decision Ledger section: +``` +## Decision Ledger +- cluster-or-id -> keep +- cluster-or-id -> tighten +- cluster-or-id -> skip +``` + {tail} """ diff --git a/desloppify/app/commands/plan/triage/runner/stage_prompts_instruction_shared.py b/desloppify/app/commands/plan/triage/runner/stage_prompts_instruction_shared.py index 8c6e5660..318063a5 100644 --- a/desloppify/app/commands/plan/triage/runner/stage_prompts_instruction_shared.py +++ b/desloppify/app/commands/plan/triage/runner/stage_prompts_instruction_shared.py @@ -150,12 +150,17 @@ ``` {cli_command} plan cluster create --description "" {cli_command} plan cluster add +{cli_command} plan cluster remove {cli_command} plan cluster update --description "" --steps "step 1" "step 2" {cli_command} plan cluster update --add-step "" --detail "<sub-points>" --effort small --issue-refs <id1> <id2> {cli_command} plan cluster update <name> --update-step N --detail "<sub-points>" --effort medium --issue-refs <id1> {cli_command} plan cluster update <name> --depends-on <other-cluster-name> +{cli_command} plan cluster delete <name> {cli_command} plan cluster show <name> {cli_command} plan cluster list --verbose +{cli_command} plan promote <pattern> +{cli_command} plan promote <pattern> top +{cli_command} plan promote <pattern> before -t <target> ``` ### Skip/dismiss @@ -169,6 +174,7 @@ ``` {cli_command} show <issue-id-or-pattern> --no-budget # full issue detail with suggestion {cli_command} show review --status open --no-budget # all open review issues +{cli_command} backlog --count 20 # inspect backlog-only detector work ``` ### Effort tags diff --git a/desloppify/app/commands/plan/triage/runner/stage_prompts_sense.py b/desloppify/app/commands/plan/triage/runner/stage_prompts_sense.py index 7571786a..e872d548 100644 --- a/desloppify/app/commands/plan/triage/runner/stage_prompts_sense.py +++ b/desloppify/app/commands/plan/triage/runner/stage_prompts_sense.py @@ -36,7 +36,11 @@ def _sense_check_fix_list() -> str: " asking a single question? If not:\n" " - Replace \"refactor X\" with the specific transformation\n" " - Replace \"update imports\" with the specific file list\n" - " - Replace \"extract into new hook\" with the filename, function signature, return type\n" + " - Replace \"extract into new hook\" with the existing package/directory surface,\n" + " function signature, and return type\n" + " - ONLY reference file paths that already exist on disk\n" + " - If a new file is warranted, name the existing parent directory or package and\n" + " describe the new module generically; do NOT invent a future filename\n" "6. EFFORT TAGS: Does the tag match the actual scope? A one-line rename is \"trivial\",\n" " not \"small\". Decomposing a 400-line file is \"large\", not \"medium\".\n" "7. DUPLICATES: If you notice this step does the same thing as a step in another\n" @@ -85,6 +89,8 @@ def _sense_check_content_not_to_do(mode: str) -> str: "- Do NOT reorder steps (the structure subagent handles that)\n" "- Do NOT add --depends-on (the structure subagent handles that)\n" "- Do NOT add new steps for missing cascade updates (the structure subagent handles that)\n" + "- Do NOT introduce speculative future file or directory paths into step detail text\n" + "- Do NOT name a concrete new file unless it already exists on disk\n" "- Do NOT modify any cluster other than the one assigned in this prompt\n" "- Do NOT run triage stage commands (`plan triage --stage ...`)\n" "- Do NOT debug or repair the CLI / environment\n" @@ -94,6 +100,8 @@ def _sense_check_content_not_to_do(mode: str) -> str: "- Do NOT reorder steps (the structure subagent handles that)\n" "- Do NOT add --depends-on (the structure subagent handles that)\n" "- Do NOT add new steps for missing cascade updates (the structure subagent handles that)\n" + "- Do NOT invent future file or directory paths when rewriting steps\n" + "- Do NOT name a concrete new file unless it already exists on disk\n" "- Do NOT run any `desloppify` commands\n" "- Do NOT debug or repair the CLI / environment\n" ) @@ -145,6 +153,7 @@ def _sense_check_structure_not_to_do(mode: str) -> str: "- Do NOT modify existing step detail text (content subagents handled that)\n" "- Do NOT change effort tags on existing steps\n" "- Do NOT remove existing steps\n" + "- Do NOT add cascade steps that point at speculative future files; reference only existing files\n" "- Do NOT run triage stage commands (`plan triage --stage ...`)\n" "- Do NOT debug or repair the CLI / environment\n" ) @@ -153,6 +162,7 @@ def _sense_check_structure_not_to_do(mode: str) -> str: "- Do NOT modify step detail text (the content subagent handles that)\n" "- Do NOT change effort tags (the content subagent handles that)\n" "- Do NOT remove steps or deduplicate (the content subagent handles that)\n" + "- Do NOT add cascade steps that point at speculative future files\n" "- Do NOT run any `desloppify` commands\n" "- Do NOT debug or repair the CLI / environment\n" ) @@ -287,7 +297,124 @@ def build_sense_check_structure_prompt( return "\n\n".join(parts) +def build_sense_check_value_prompt( + *, + plan: dict, + state: dict | None, + repo_root: Path, + mode: str = "self_record", + cli_command: str = "desloppify", +) -> str: + """Build a value-check prompt for the YAGNI/KISS pass as sense-check's 3rd subagent.""" + from ..stages.helpers import value_check_targets + + targets = value_check_targets(plan, state) + clusters = {name: c for name, c in plan.get("clusters", {}).items() if not c.get("auto")} + + parts: list[str] = [] + parts.append( + "You are running the VALUE CHECK pass as part of sense-check.\n" + f"Repo root: {repo_root}\n\n" + f"Live queue targets: {len(targets)}" + ) + + job = ( + "## Your job\n" + "Walk every live queue target and make the final YAGNI/KISS judgment.\n" + "Ask: does doing this make the codebase genuinely better? Beauty is a valid\n" + "reason to keep work, but not if it buys that beauty with new indirection,\n" + "wrappers, abstraction layers, or confusion.\n" + ) + parts.append(job) + + rubric = ( + "## Rubric\n" + "For EVERY live queue target, choose exactly one:\n" + "1. `keep` — clearly improves correctness, clarity, cohesion, simplicity, or elegance\n" + "2. `tighten` — worth doing, but the plan must be simplified or made more concrete first\n" + "3. `skip` — the fix would add churn, indirection, coordination, or abstraction for too little gain\n\n" + "What should usually be skipped:\n" + "- facade pruning that just spreads imports\n" + "- abstraction-for-abstraction's-sake\n" + "- tiny theoretical cleanups that make the code harder to follow\n" + "- fixes whose implementation is more complicated than the current code\n\n" + "What can still be worth keeping:\n" + "- simplifications that delete layers or reduce branching\n" + "- aesthetic cleanups that genuinely improve readability without adding machinery\n" + "- focused unifications that make naming or flow more coherent with less confusion\n" + ) + parts.append(rubric) + + if mode == "self_record": + commands = ( + "## Commands\n" + f"Use the exact CLI prefix: `{cli_command}`\n" + f"- `{cli_command} next --count 100` to inspect the current execution queue\n" + f"- `{cli_command} plan cluster show <name>` to inspect cluster members and steps\n" + f"- `{cli_command} show <issue-id-or-hash> --no-budget` to re-read the underlying finding\n" + f"- `{cli_command} plan cluster update <name> --update-step N --detail \"...\" --effort small`\n" + f'- `{cli_command} plan skip --permanent <pattern> --note "<why>"' + ' --attest "I have reviewed this triage skip against the code and I am not gaming the score' + ' by suppressing a real defect."`\n' + f"- `{cli_command} plan cluster delete <name>` if you skipped everything it contained\n" + ) + parts.append(commands) + else: + parts.append( + "## Output contract\n" + "State the exact queue items to keep, tighten, or skip, plus any cluster-step\n" + "updates or deletions needed. The orchestrator will apply them.\n" + ) + + process = ( + "## Required process\n" + "1. Re-read the actual code behind each queue target.\n" + "2. Apply the rubric above, not the raw issue title.\n" + "3. Tighten any keeper whose steps are too vague, too broad, or too complicated.\n" + "4. Permanently skip anything that fails the value test.\n" + "5. Delete dead clusters after skipping all their members.\n" + ) + parts.append(process) + + # Include targets + parts.append("## Live Queue Targets\n") + for target in targets: + if target in clusters: + cluster = clusters[target] + steps = cluster.get("action_steps", []) + parts.append(f"- **{target}** ({len(steps)} steps)") + else: + parts.append(f"- {target}") + + # Include cluster details + if clusters: + parts.append("\n## Cluster Details\n") + for name, cluster in sorted(clusters.items()): + steps = cluster.get("action_steps", []) + issues = cluster_issue_ids(cluster) + parts.append(f"### {name} ({len(steps)} steps, {len(issues)} issues)") + for i, step in enumerate(steps, 1): + parts.append(_format_cluster_step(i, step)) + + output_section = ( + "\n## Output\n" + "Start with a `## Decision Ledger` section:\n" + "```\n" + "## Decision Ledger\n" + "- cluster-or-id -> keep\n" + "- cluster-or-id -> tighten\n" + "- cluster-or-id -> skip\n" + "```\n" + "Then add short sections explaining the reasoning, with concrete file references.\n" + "Cover every live queue target exactly once in the ledger.\n" + ) + parts.append(output_section) + + return "\n\n".join(parts) + + __all__ = [ "build_sense_check_content_prompt", "build_sense_check_structure_prompt", + "build_sense_check_value_prompt", ] diff --git a/desloppify/app/commands/plan/triage/runner/stage_prompts_validation.py b/desloppify/app/commands/plan/triage/runner/stage_prompts_validation.py index e01aaf03..cf6f40f9 100644 --- a/desloppify/app/commands/plan/triage/runner/stage_prompts_validation.py +++ b/desloppify/app/commands/plan/triage/runner/stage_prompts_validation.py @@ -50,7 +50,10 @@ def _validation_requirements(stage: str) -> str: "## Validation Requirements (ALL BLOCKING)\n" "- Re-runs ALL enrich-level checks (detail, issue_refs, effort, paths, vagueness)\n" "- Stage must be recorded with a 100+ char report\n" - "- Stage must be confirmed with an 80+ char attestation mentioning cluster names\n" + "- Report must include a `## Decision Ledger` with one line per live queue target\n" + "- Every live queue target must appear exactly once as keep, tighten, or skip\n" + "- Report must cite real file paths to prove the code was re-read\n" + "- Stage must be confirmed with an 80+ char attestation mentioning cluster names or clearly describing the verified sense-check work\n" ) return "" diff --git a/desloppify/app/commands/plan/triage/runner/stage_validation.py b/desloppify/app/commands/plan/triage/runner/stage_validation.py index 30c79391..d73d9309 100644 --- a/desloppify/app/commands/plan/triage/runner/stage_validation.py +++ b/desloppify/app/commands/plan/triage/runner/stage_validation.py @@ -15,7 +15,8 @@ validate_report_references_clusters, ) from ..validation.enrich_quality import evaluate_enrich_quality -from ..validation.core import ( +from ..validation.completion_policy import evaluate_completion_readiness +from ..validation.enrich_checks import ( _cluster_file_overlaps, _clusters_with_directory_scatter, _clusters_with_high_step_ratio, @@ -23,11 +24,19 @@ from ..completion_flow import count_log_activity_since from ..observe_batches import observe_dimension_breakdown from ..review_coverage import ( + active_triage_issue_ids, cluster_issue_ids, - manual_clusters_with_issues, open_review_ids_from_state, ) -from ..stages.helpers import unclustered_review_issues, unenriched_clusters +from ..stages.helpers import ( + active_triage_issue_scope, + scoped_manual_clusters_with_issues, + triage_scoped_plan, + unclustered_review_issues, + unenriched_clusters, + value_check_targets, +) +from ..stages.evidence_parsing import parse_value_check_decision_ledger @dataclass(frozen=True) @@ -43,6 +52,7 @@ def run_enrich_quality_checks( repo_root: Path, *, phase_label: str, + triage_issue_ids: set[str] | None = None, ) -> list[EnrichQualityFailure]: """Run enrich-level executor-readiness checks for a phase.""" report = evaluate_enrich_quality( @@ -54,6 +64,7 @@ def run_enrich_quality_checks( include_missing_issue_refs=True, include_vague_detail=True, stale_issue_refs_severity="failure", + triage_issue_ids=triage_issue_ids, ) code_map = {"underspecified": "underspecified_steps", "bad_paths": "missing_paths"} return [ @@ -87,7 +98,12 @@ def _validate_observe_stage( f"(need {min_citations}+). Reference specific issue " f"hashes to prove you read them." ) - valid_ids = set(triage_input.open_issues.keys()) if triage_input else set() + review_issues = ( + getattr(triage_input, "review_issues", getattr(triage_input, "open_issues", {})) + if triage_input + else {} + ) + valid_ids = set(review_issues.keys()) evidence = parse_observe_evidence(report, valid_ids) ev_failures = validate_observe_evidence(evidence, issue_count) blocking = [failure for failure in ev_failures if failure.blocking] @@ -152,8 +168,9 @@ def _validate_organize_stage(plan: dict, state: dict, stages: dict) -> tuple[boo """Validate recorded organize-stage content.""" if "organize" not in stages: return False, "Organize stage not recorded." - open_review_ids = open_review_ids_from_state(state) - manual = manual_clusters_with_issues(plan) + triage_scope = active_triage_issue_scope(plan, state) + open_review_ids = open_review_ids_from_state(state) if triage_scope is None else triage_scope + manual = scoped_manual_clusters_with_issues(plan, state) if not open_review_ids and not manual: report = stages["organize"].get("report", "") if len(report) < 100: @@ -161,7 +178,7 @@ def _validate_organize_stage(plan: dict, state: dict, stages: dict) -> tuple[boo return True, "" if not manual: return False, "No manual clusters with issues exist." - gaps = unenriched_clusters(plan) + gaps = unenriched_clusters(plan, state) if gaps: names = ", ".join(name for name, _ in gaps) return False, f"Unenriched clusters: {names}" @@ -192,11 +209,21 @@ def _validate_organize_stage(plan: dict, state: dict, stages: dict) -> tuple[boo return True, "" -def _validate_enrich_stage(plan: dict, repo_root: Path, stages: dict) -> tuple[bool, str]: +def _validate_enrich_stage( + plan: dict, + state: dict, + repo_root: Path, + stages: dict, +) -> tuple[bool, str]: """Validate recorded enrich-stage content.""" if "enrich" not in stages: return False, "Enrich stage not recorded." - failures = run_enrich_quality_checks(plan, repo_root, phase_label="enrich") + failures = run_enrich_quality_checks( + plan, + repo_root, + phase_label="enrich", + triage_issue_ids=active_triage_issue_ids(plan, state) or None, + ) if failures: return False, failures[0].message return True, "" @@ -207,19 +234,26 @@ def _validate_sense_check_stage( state: dict, repo_root: Path, stages: dict, + *, + triage_input: TriageInput | None = None, ) -> tuple[bool, str]: - """Validate recorded sense-check-stage content.""" + """Validate recorded sense-check-stage content (includes value decisions).""" if "sense-check" not in stages: return False, "Sense-check stage not recorded." report = stages["sense-check"].get("report", "") if len(report) < 100: return False, f"Sense-check report too short ({len(report)} chars, need 100+)." - if not open_review_ids_from_state(state) and not manual_clusters_with_issues(plan): + manual_clusters = scoped_manual_clusters_with_issues(plan, state) + triage_issue_ids = active_triage_issue_ids(plan, state) or None + triage_scope = active_triage_issue_scope(plan, state) + open_review_ids = open_review_ids_from_state(state) if triage_scope is None else triage_scope + if not open_review_ids and not manual_clusters: return True, "" failures = run_enrich_quality_checks( plan, repo_root, phase_label="sense-check", + triage_issue_ids=triage_issue_ids, ) if failures: return False, failures[0].message @@ -227,11 +261,33 @@ def _validate_sense_check_stage( blocking_pf = [failure for failure in path_failures if failure.blocking] if blocking_pf: return False, blocking_pf[0].message - sc_clusters = manual_clusters_with_issues(plan) - cluster_failures = validate_report_references_clusters(report, sc_clusters) + cluster_failures = validate_report_references_clusters(report, manual_clusters) blocking_cf = [failure for failure in cluster_failures if failure.blocking] if blocking_cf: return False, blocking_cf[0].message + # Decision Ledger validation (value subagent output) + frozen_targets = None + if isinstance(stages.get("sense-check"), dict): + recorded_targets = stages["sense-check"].get("value_targets") + if isinstance(recorded_targets, list): + frozen_targets = [target for target in recorded_targets if isinstance(target, str)] + if frozen_targets is None and triage_input is not None: + triage_targets = getattr(triage_input, "value_check_targets", None) + if isinstance(triage_targets, list): + frozen_targets = [target for target in triage_targets if isinstance(target, str)] + targets = frozen_targets if frozen_targets is not None else value_check_targets(plan, state) + if targets: + parsed = parse_value_check_decision_ledger(report) + if not parsed.entries: + return False, "Sense-check report missing `## Decision Ledger` entries." + if parsed.duplicates: + return False, f"Sense-check report duplicates decision targets: {', '.join(parsed.duplicates[:5])}" + missing = [target for target in targets if target not in parsed.entries] + if missing: + return False, f"Sense-check report missing decision(s) for: {', '.join(missing[:5])}" + extras = [target for target in parsed.entries if target not in targets] + if extras: + return False, f"Sense-check report references non-live target(s): {', '.join(extras[:5])}" return True, "" @@ -253,8 +309,14 @@ def validate_stage( ), "reflect": lambda: _validate_reflect_stage(stages), "organize": lambda: _validate_organize_stage(plan, state, stages), - "enrich": lambda: _validate_enrich_stage(plan, repo_root, stages), - "sense-check": lambda: _validate_sense_check_stage(plan, state, repo_root, stages), + "enrich": lambda: _validate_enrich_stage(plan, state, repo_root, stages), + "sense-check": lambda: _validate_sense_check_stage( + plan, + state, + repo_root, + stages, + triage_input=triage_input, + ), } validator = validators.get(stage) if validator is None: @@ -262,79 +324,19 @@ def validate_stage( return validator() -def _validate_required_stages(stages: dict) -> tuple[bool, str]: - """Validate that all required triage stages are present and confirmed.""" - for required in ("observe", "reflect", "organize", "enrich", "sense-check"): - if required not in stages: - return False, f"Stage {required} not recorded." - if not stages[required].get("confirmed_at"): - return False, f"Stage {required} not confirmed." - return True, "" - - -def _validate_cluster_dependency_cycles(clusters: dict) -> tuple[bool, str]: - """Reject self-referential cluster dependencies.""" - for name, cluster in clusters.items(): - deps = cluster.get("depends_on_clusters", []) - if name in deps: - return False, f"Cluster {name} depends on itself." - return True, "" - - -def _find_all_trivial_clusters(clusters: dict) -> list[str]: - """Return manual clusters whose action steps are all marked trivial.""" - trivial_clusters: list[str] = [] - for name, cluster in clusters.items(): - if cluster.get("auto") or not cluster_issue_ids(cluster): - continue - steps = cluster.get("action_steps") or [] - if steps and all( - isinstance(step, dict) and step.get("effort") == "trivial" - for step in steps - ): - trivial_clusters.append(name) - return trivial_clusters - - def validate_completion( plan: dict, state: dict, repo_root: Path, ) -> tuple[bool, str]: """Validate plan is ready for triage completion. Returns (ok, error_msg).""" - meta = plan.get("epic_triage_meta", {}) - stages = meta.get("triage_stages", {}) - - ok, message = _validate_required_stages(stages) - if not ok: - return ok, message - - open_review_ids = open_review_ids_from_state(state) - manual = manual_clusters_with_issues(plan) - if not open_review_ids and not manual: - return True, "" - if not manual: - return False, "No manual clusters with issues." - - gaps = unenriched_clusters(plan) - if gaps: - return False, f"{len(gaps)} cluster(s) still need enrichment." - - unclustered = unclustered_review_issues(plan, state) - if unclustered: - return False, f"{len(unclustered)} review issue(s) not in any cluster." - - clusters = plan.get("clusters", {}) - ok, message = _validate_cluster_dependency_cycles(clusters) - if not ok: - return ok, message - - all_trivial_clusters = _find_all_trivial_clusters(clusters) - if all_trivial_clusters: - names = ", ".join(sorted(all_trivial_clusters)) - return True, f"Advisory: all action steps are marked trivial in cluster(s): {names}" - - return True, "" + _ = repo_root + readiness = evaluate_completion_readiness( + plan, + state, + require_confirmed_stages=True, + ) + return readiness.ok, readiness.message def build_auto_attestation( @@ -343,12 +345,13 @@ def build_auto_attestation( triage_input: TriageInput, ) -> str: """Generate valid 80+ char attestation referencing real dimensions/cluster names.""" + review_issues = getattr(triage_input, "review_issues", getattr(triage_input, "open_issues", {})) if stage == "observe": _by_dim, dim_names = observe_dimension_breakdown(triage_input) top_dims = dim_names[:3] dims_str = ", ".join(top_dims) return ( - f"I have thoroughly analysed {len(triage_input.open_issues)} issues " + f"I have thoroughly analysed {len(review_issues)} issues " f"across dimensions including {dims_str}, identifying themes, " f"root causes, and contradictions across the codebase." ) @@ -358,13 +361,13 @@ def build_auto_attestation( top_dims = dim_names[:3] dims_str = ", ".join(top_dims) return ( - f"My strategy accounts for {len(triage_input.open_issues)} issues " + f"My strategy accounts for {len(review_issues)} issues " f"across dimensions including {dims_str}, comparing against " f"resolved history and forming priorities for execution." ) if stage == "organize": - cluster_names = manual_clusters_with_issues(plan) + cluster_names = scoped_manual_clusters_with_issues(plan) if not cluster_names: return ( "I verified there are zero open review issues in this organize batch, " @@ -378,7 +381,7 @@ def build_auto_attestation( ) if stage == "enrich": - cluster_names = manual_clusters_with_issues(plan) + cluster_names = scoped_manual_clusters_with_issues(plan) if not cluster_names: return ( "I verified there are zero open review issues in this enrich batch, " @@ -392,7 +395,7 @@ def build_auto_attestation( ) if stage == "sense-check": - cluster_names = manual_clusters_with_issues(plan) + cluster_names = scoped_manual_clusters_with_issues(plan) if not cluster_names: return ( "I verified there are zero open review issues in this sense-check batch, " @@ -400,9 +403,9 @@ def build_auto_attestation( ) names_str = ", ".join(cluster_names[:3]) return ( - f"Content and structure verified for clusters including {names_str}. " + f"Content, structure and value verified for clusters including {names_str}. " f"All step details are factually accurate, cross-cluster dependencies " - f"are safe, and enrich-level checks pass." + f"are safe, enrich-level checks pass, and value decisions are recorded." ) return f"Stage {stage} completed with thorough analysis of all available data and verified against codebase." diff --git a/desloppify/app/commands/plan/triage/stages/completion.py b/desloppify/app/commands/plan/triage/stages/completion.py index 688b6b03..a2e8c6f0 100644 --- a/desloppify/app/commands/plan/triage/stages/completion.py +++ b/desloppify/app/commands/plan/triage/stages/completion.py @@ -9,24 +9,24 @@ from .records import record_confirm_existing_completion from .rendering import _print_complete_summary -from ..validation.core import ( - _auto_confirm_enrich_for_complete, - _completion_clusters_valid, +from ..validation.completion_policy import ( _completion_strategy_valid, _confirm_existing_stages_valid, _confirm_note_valid, _confirm_strategy_valid, _confirmed_text_or_error, _note_cites_new_issues_or_error, - _require_enrich_stage_for_complete, - _require_organize_stage_for_complete, _require_prior_strategy_for_confirm, - _require_sense_check_stage_for_complete, _resolve_completion_strategy, _resolve_confirm_existing_strategy, + evaluate_completion_readiness, ) from ..validation.completion_stages import ( + _auto_confirm_enrich_for_complete, _auto_confirm_stage_for_complete, + _require_enrich_stage_for_complete, + _require_organize_stage_for_complete, + _require_sense_check_stage_for_complete, ) from ..validation.enrich_checks import _underspecified_steps from ..completion_flow import apply_completion @@ -38,6 +38,7 @@ ) from ..stage_queue import has_triage_in_queue from ..services import TriageServices, default_triage_services +from .helpers import active_triage_issue_scope, triage_scoped_plan def _print_completion_coverage_warning(*, organized: int, total: int) -> None: @@ -169,7 +170,8 @@ def _cmd_triage_complete( stages = meta.get("triage_stages", {}) state = resolved_services.command_runtime(args).state - review_ids = open_review_ids_from_state(state) + triage_scope = active_triage_issue_scope(plan, state) + review_ids = open_review_ids_from_state(state) if triage_scope is None else triage_scope # Organize gate if not _require_organize_stage_for_complete(plan=plan, meta=meta, stages=stages): @@ -181,7 +183,7 @@ def _cmd_triage_complete( return # Enrich gate — compute underspecified steps once, share across require + confirm - underspec = _underspecified_steps(plan) + underspec = _underspecified_steps(triage_scoped_plan(plan, state)) if not _require_enrich_stage_for_complete( plan=plan, meta=meta, stages=stages, underspec=underspec, ): @@ -201,7 +203,12 @@ def _cmd_triage_complete( ): return - if not _completion_clusters_valid(plan, state): + readiness = evaluate_completion_readiness( + plan, + state, + require_confirmed_stages=False, + ) + if not readiness.ok: _record_incomplete_recovery( plan=plan, state=state, @@ -210,7 +217,8 @@ def _cmd_triage_complete( ) return - organized, total, _clusters = triage_coverage(plan, open_review_ids=review_ids) + organized = readiness.organized + total = readiness.total if total > 0 and organized == 0: _print_completion_coverage_warning(organized=organized, total=total) return diff --git a/desloppify/app/commands/plan/triage/stages/evidence_parsing.py b/desloppify/app/commands/plan/triage/stages/evidence_parsing.py index c5eed56c..2ed5c233 100644 --- a/desloppify/app/commands/plan/triage/stages/evidence_parsing.py +++ b/desloppify/app/commands/plan/triage/stages/evidence_parsing.py @@ -58,6 +58,14 @@ class ObserveEvidence: has_parseable_ids: bool = True # False if valid_ids had no hex-hash IDs +@dataclass +class DecisionLedger: + """Parsed keep/tighten/skip coverage from a value-check report.""" + + entries: dict[str, str] = field(default_factory=dict) + duplicates: list[str] = field(default_factory=list) + + # --------------------------------------------------------------------------- # OBSERVE evidence parsing — structured template format # --------------------------------------------------------------------------- @@ -422,6 +430,29 @@ def validate_report_has_file_paths(report: str) -> list[EvidenceFailure]: )] +_VALUE_LEDGER_RE = re.compile( + r"^\s*-\s*(?P<target>.+?)\s*->\s*(?P<decision>keep|tighten|skip)\s*$", + re.IGNORECASE, +) + + +def parse_value_check_decision_ledger(report: str) -> DecisionLedger: + """Parse `## Decision Ledger` lines from a value-check report.""" + entries: dict[str, str] = {} + duplicates: list[str] = [] + for line in report.splitlines(): + match = _VALUE_LEDGER_RE.match(line) + if match is None: + continue + target = match.group("target").strip() + decision = match.group("decision").strip().lower() + if target in entries: + duplicates.append(target) + continue + entries[target] = decision + return DecisionLedger(entries=entries, duplicates=duplicates) + + # --------------------------------------------------------------------------- # Shared output helpers # --------------------------------------------------------------------------- @@ -466,11 +497,13 @@ def resolve_short_hash_to_full_id(short_hash: str, valid_ids: set[str]) -> str | __all__ = [ + "DecisionLedger", "EvidenceFailure", "ObserveAssessment", "ObserveEvidence", "VERDICT_KEYWORDS", "format_evidence_failures", + "parse_value_check_decision_ledger", "parse_observe_evidence", "resolve_short_hash_to_full_id", "validate_observe_evidence", diff --git a/desloppify/app/commands/plan/triage/stages/helpers.py b/desloppify/app/commands/plan/triage/stages/helpers.py index 26d02f08..880292aa 100644 --- a/desloppify/app/commands/plan/triage/stages/helpers.py +++ b/desloppify/app/commands/plan/triage/stages/helpers.py @@ -3,9 +3,16 @@ from __future__ import annotations from desloppify.base.output.terminal import colorize +from desloppify.engine._plan.constants import is_synthetic_id +from desloppify.engine._state.issue_semantics import is_triage_finding from desloppify.engine.plan_triage import TRIAGE_IDS -from ..review_coverage import cluster_issue_ids, live_active_triage_issue_ids +from ..review_coverage import ( + active_triage_issue_ids, + cluster_issue_ids, + live_active_triage_issue_ids, + manual_clusters_with_issues, +) def _require_triage_pending(plan: dict, *, action: str) -> bool: @@ -45,7 +52,63 @@ def _validate_stage_report( return cleaned -def unenriched_clusters(plan: dict) -> list[tuple[str, list[str]]]: +def active_triage_issue_scope( + plan: dict, + state: dict | None = None, +) -> set[str] | None: + """Return the active triage issue scope, or None when no scope is frozen. + + `None` means "do not scope" for legacy/non-triage flows. + An empty set means a frozen triage run exists but none of its issues are live. + """ + frozen = active_triage_issue_ids(plan, state) + if not frozen: + return None + if state is None: + return frozen + return live_active_triage_issue_ids(plan, state) + + +def scoped_manual_clusters_with_issues( + plan: dict, + state: dict | None = None, +) -> list[str]: + """Return manual clusters relevant to the current triage session.""" + scope = active_triage_issue_scope(plan, state) + clusters = plan.get("clusters", {}) + names = manual_clusters_with_issues(plan) + if scope is None: + return names + return [ + name + for name in names + if set(cluster_issue_ids(clusters.get(name, {}))) & scope + ] + + +def triage_scoped_plan( + plan: dict, + state: dict | None = None, +) -> dict: + """Return a plan view filtered to the current triage session when active.""" + scope = active_triage_issue_scope(plan, state) + if scope is None: + return plan + allowed = set(scoped_manual_clusters_with_issues(plan, state)) + return { + **plan, + "clusters": { + name: cluster + for name, cluster in plan.get("clusters", {}).items() + if name in allowed + }, + } + + +def unenriched_clusters( + plan: dict, + state: dict | None = None, +) -> list[tuple[str, list[str]]]: """Return clusters with issues that are missing required enrichment. Requirements: @@ -55,12 +118,10 @@ def unenriched_clusters(plan: dict) -> list[tuple[str, list[str]]]: steps overall (cluster-level plan is sufficient). """ gaps: list[tuple[str, list[str]]] = [] - for name, cluster in plan.get("clusters", {}).items(): + clusters = plan.get("clusters", {}) + for name in scoped_manual_clusters_with_issues(plan, state): + cluster = clusters.get(name, {}) issue_ids = cluster_issue_ids(cluster) - if not issue_ids: - continue - if cluster.get("auto"): - continue missing: list[str] = [] if not cluster.get("description"): missing.append("description") @@ -95,9 +156,9 @@ def unclustered_review_issues(plan: dict, state: dict | None = None) -> list[str if state is not None: review_ids = [ - fid for fid, finding in state.get("issues", {}).items() + fid for fid, finding in (state.get("work_items") or state.get("issues", {})).items() if finding.get("status") == "open" - and finding.get("detector") in ("review", "concerns") + and is_triage_finding(finding) ] frozen_ids = (plan.get("epic_triage_meta", {}) or {}).get("active_triage_issue_ids") if isinstance(frozen_ids, list) and frozen_ids: @@ -106,7 +167,7 @@ def unclustered_review_issues(plan: dict, state: dict | None = None) -> list[str else: review_ids = [ fid for fid in plan.get("queue_order", []) - if not fid.startswith("triage::") and not fid.startswith("workflow::") + if not is_synthetic_id(fid) and (fid.startswith("review::") or fid.startswith("concerns::")) ] @@ -114,3 +175,17 @@ def unclustered_review_issues(plan: dict, state: dict | None = None) -> list[str fid for fid in review_ids if fid not in clustered_ids and fid not in skipped_ids ] + + +def value_check_targets(plan: dict, state: dict | None = None) -> list[str]: + """Return the current execution targets that value-check must judge once each.""" + targets = list(scoped_manual_clusters_with_issues(plan, state)) + targets.extend(unclustered_review_issues(plan, state)) + seen: set[str] = set() + ordered: list[str] = [] + for target in targets: + if target in seen: + continue + seen.add(target) + ordered.append(target) + return ordered diff --git a/desloppify/app/commands/plan/triage/stages/observe.py b/desloppify/app/commands/plan/triage/stages/observe.py index 88d74fe6..081089c2 100644 --- a/desloppify/app/commands/plan/triage/stages/observe.py +++ b/desloppify/app/commands/plan/triage/stages/observe.py @@ -14,6 +14,7 @@ print_cascade_clear_feedback, ) from ..lifecycle import TriageLifecycleDeps, ensure_triage_started +from ..observe_batches import observe_dimension_breakdown from ..services import TriageServices, default_triage_services from .flow_helpers import validate_stage_report_length from .records import record_observe_stage, resolve_reusable_report @@ -27,7 +28,7 @@ def cmd_stage_observe( has_triage_in_queue_fn=has_triage_in_queue, inject_triage_stages_fn=inject_triage_stages, ) -> None: - """Record the OBSERVE stage: agent analyses themes and root causes.""" + """Record the OBSERVE stage: verify each queued issue against the code.""" report: str | None = getattr(args, "report", None) attestation: str | None = getattr(args, "attestation", None) @@ -61,7 +62,8 @@ def cmd_stage_observe( return si = resolved_services.collect_triage_input(plan, state) - issue_count = len(si.open_issues) + review_issues = getattr(si, "review_issues", getattr(si, "open_issues", {})) + issue_count = len(review_issues) if issue_count == 0: cleared = record_observe_stage( stages, @@ -70,6 +72,8 @@ def cmd_stage_observe( cited_ids=[], existing_stage=existing_stage, is_reuse=is_reuse, + dimension_names=[], + dimension_counts={}, ) resolved_services.save_plan(plan) print(colorize(" Observe stage recorded (no issues to analyse).", "green")) @@ -79,10 +83,14 @@ def cmd_stage_observe( print_cascade_clear_feedback(cleared, stages) return + by_dim, dim_names = observe_dimension_breakdown(si) if not validate_stage_report_length( report=report, issue_count=issue_count, - guidance=" Describe themes, root causes, contradictions, and how issues relate.", + guidance=( + " Verify each issue with code evidence, record the verdicts you reached," + " and cite the files you read." + ), ): return @@ -93,7 +101,7 @@ def cmd_stage_observe( validate_observe_evidence, ) - valid_ids = set(si.open_issues.keys()) + valid_ids = set(review_issues.keys()) cited = resolved_services.extract_issue_citations(report, valid_ids) evidence = parse_observe_evidence(report, valid_ids) evidence_failures = validate_observe_evidence(evidence, issue_count) @@ -144,6 +152,8 @@ def cmd_stage_observe( existing_stage=existing_stage, is_reuse=is_reuse, assessments=assessments, + dimension_names=dim_names, + dimension_counts=by_dim, ) resolved_services.save_plan(plan) resolved_services.append_log_entry( diff --git a/desloppify/app/commands/plan/triage/stages/organize.py b/desloppify/app/commands/plan/triage/stages/organize.py index eac3de70..5c007af8 100644 --- a/desloppify/app/commands/plan/triage/stages/organize.py +++ b/desloppify/app/commands/plan/triage/stages/organize.py @@ -22,10 +22,27 @@ _unclustered_review_issues_or_error, _validate_organize_against_ledger_or_error, ) -from ..validation.core import _require_reflect_stage_for_organize +from ..validation.stage_policy import require_prerequisite from .records import record_organize_stage +def _require_reflect_stage_for_organize(stages: dict) -> bool: + return require_prerequisite( + stages, + flow="organize", + messages={ + "observe": ( + " Cannot organize: observe stage not complete.", + ' Run: desloppify plan triage --stage observe --report "..."', + ), + "reflect": ( + " Cannot organize: reflect stage not complete.", + ' Run: desloppify plan triage --stage reflect --report "..."', + ), + }, + ) + + def _enforce_cluster_activity_for_organize( *, plan: dict, diff --git a/desloppify/app/commands/plan/triage/stages/records.py b/desloppify/app/commands/plan/triage/stages/records.py index 1d110578..e77ac1f4 100644 --- a/desloppify/app/commands/plan/triage/stages/records.py +++ b/desloppify/app/commands/plan/triage/stages/records.py @@ -2,14 +2,90 @@ from __future__ import annotations +from typing import TypedDict + from desloppify.state_io import utc_now from ..stage_queue import cascade_clear_later_confirmations +class ObserveAssessmentRecord(TypedDict): + """Persisted observe-stage verdict for one cited issue hash.""" + + hash: str + verdict: str + verdict_reasoning: str + files_read: list[str] + recommendation: str + + +class ObserveRecord(TypedDict, total=False): + stage: str + report: str + cited_ids: list[str] + timestamp: str + issue_count: int + dimension_names: list[str] + dimension_counts: dict[str, int] + assessments: list[ObserveAssessmentRecord] + confirmed_at: str + confirmed_text: str + + +class ReflectRecord(TypedDict, total=False): + stage: str + report: str + cited_ids: list[str] + timestamp: str + issue_count: int + missing_issue_ids: list[str] + duplicate_issue_ids: list[str] + recurring_dims: list[str] + disposition_ledger: list[dict[str, str]] + confirmed_at: str + confirmed_text: str + + +class OrganizeRecord(TypedDict, total=False): + stage: str + report: str + cited_ids: list[str] + timestamp: str + issue_count: int + confirmed_at: str + confirmed_text: str + reused_existing_plan: bool + completion_note: str + + +class EnrichRecord(TypedDict, total=False): + stage: str + report: str + timestamp: str + shallow_count: int + confirmed_at: str + confirmed_text: str + + +class SenseCheckRecord(TypedDict, total=False): + stage: str + report: str + timestamp: str + confirmed_at: str + confirmed_text: str + value_decisions: dict[str, str] + value_targets: list[str] + + +TriageStages = dict[ + str, + ObserveRecord | ReflectRecord | OrganizeRecord | EnrichRecord | SenseCheckRecord, +] + + def resolve_reusable_report( report: str | None, - existing_stage: dict | None, + existing_stage: dict | ObserveRecord | ReflectRecord | OrganizeRecord | EnrichRecord | SenseCheckRecord | None, ) -> tuple[str | None, bool]: if report: return report, False @@ -19,43 +95,50 @@ def resolve_reusable_report( def record_observe_stage( - stages: dict, + stages: TriageStages, *, report: str, issue_count: int, cited_ids: list[str], - existing_stage: dict | None, + existing_stage: ObserveRecord | None, is_reuse: bool, - assessments: list[dict] | None = None, + assessments: list[ObserveAssessmentRecord] | None = None, + dimension_names: list[str] | None = None, + dimension_counts: dict[str, int] | None = None, ) -> list[str]: - stages["observe"] = { + observe: ObserveRecord = { "stage": "observe", "report": report, "cited_ids": cited_ids, "timestamp": utc_now(), "issue_count": issue_count, } + if dimension_names is not None: + observe["dimension_names"] = dimension_names + if dimension_counts is not None: + observe["dimension_counts"] = dimension_counts if assessments is not None: - stages["observe"]["assessments"] = assessments + observe["assessments"] = assessments if is_reuse and existing_stage and existing_stage.get("confirmed_at"): - stages["observe"]["confirmed_at"] = existing_stage["confirmed_at"] - stages["observe"]["confirmed_text"] = existing_stage.get("confirmed_text", "") + observe["confirmed_at"] = existing_stage["confirmed_at"] + observe["confirmed_text"] = existing_stage.get("confirmed_text", "") + stages["observe"] = observe cleared = cascade_clear_later_confirmations(stages, "observe") if not is_reuse: - stages["observe"].pop("confirmed_at", None) - stages["observe"].pop("confirmed_text", None) + observe.pop("confirmed_at", None) + observe.pop("confirmed_text", None) return cleared def record_organize_stage( - stages: dict, + stages: TriageStages, *, report: str, issue_count: int, - existing_stage: dict | None, + existing_stage: OrganizeRecord | None, is_reuse: bool, ) -> list[str]: - stages["organize"] = { + organize: OrganizeRecord = { "stage": "organize", "report": report, "cited_ids": [], @@ -63,52 +146,60 @@ def record_organize_stage( "issue_count": issue_count, } if is_reuse and existing_stage and existing_stage.get("confirmed_at"): - stages["organize"]["confirmed_at"] = existing_stage["confirmed_at"] - stages["organize"]["confirmed_text"] = existing_stage.get("confirmed_text", "") + organize["confirmed_at"] = existing_stage["confirmed_at"] + organize["confirmed_text"] = existing_stage.get("confirmed_text", "") + stages["organize"] = organize return cascade_clear_later_confirmations(stages, "organize") def record_enrich_stage( - stages: dict, + stages: TriageStages, *, report: str, shallow_count: int, - existing_stage: dict | None, + existing_stage: EnrichRecord | None, is_reuse: bool, ) -> list[str]: - stages["enrich"] = { + enrich: EnrichRecord = { "stage": "enrich", "report": report, "timestamp": utc_now(), "shallow_count": shallow_count, } if is_reuse and existing_stage and existing_stage.get("confirmed_at"): - stages["enrich"]["confirmed_at"] = existing_stage["confirmed_at"] - stages["enrich"]["confirmed_text"] = existing_stage.get("confirmed_text", "") + enrich["confirmed_at"] = existing_stage["confirmed_at"] + enrich["confirmed_text"] = existing_stage.get("confirmed_text", "") + stages["enrich"] = enrich return cascade_clear_later_confirmations(stages, "enrich") def record_sense_check_stage( - stages: dict, + stages: TriageStages, *, report: str, - existing_stage: dict | None, + existing_stage: SenseCheckRecord | None, is_reuse: bool, + value_targets: list[str] | None = None, ) -> list[str]: - stages["sense-check"] = { + sense_check: SenseCheckRecord = { "stage": "sense-check", "report": report, "timestamp": utc_now(), } + if value_targets: + sense_check["value_targets"] = list(value_targets) + elif existing_stage and existing_stage.get("value_targets"): + sense_check["value_targets"] = list(existing_stage["value_targets"]) if is_reuse and existing_stage and existing_stage.get("confirmed_at"): - stages["sense-check"]["confirmed_at"] = existing_stage["confirmed_at"] - stages["sense-check"]["confirmed_text"] = existing_stage.get("confirmed_text", "") + sense_check["confirmed_at"] = existing_stage["confirmed_at"] + sense_check["confirmed_text"] = existing_stage.get("confirmed_text", "") + stages["sense-check"] = sense_check return cascade_clear_later_confirmations(stages, "sense-check") def record_confirm_existing_completion( *, - stages: dict, + stages: TriageStages, note: str, issue_count: int, confirmed_text: str, @@ -127,6 +218,10 @@ def record_confirm_existing_completion( __all__ = [ + "ObserveAssessmentRecord", + "ObserveRecord", + "ReflectRecord", + "TriageStages", "record_confirm_existing_completion", "record_enrich_stage", "record_observe_stage", diff --git a/desloppify/app/commands/plan/triage/stages/reflect.py b/desloppify/app/commands/plan/triage/stages/reflect.py index 865d902f..52f512ba 100644 --- a/desloppify/app/commands/plan/triage/stages/reflect.py +++ b/desloppify/app/commands/plan/triage/stages/reflect.py @@ -10,18 +10,43 @@ from ..display.dashboard import print_reflect_result from ..stage_queue import cascade_clear_dispositions, cascade_clear_later_confirmations, has_triage_in_queue from ..services import TriageServices, default_triage_services -from ..validation.core import ( +from ..validation.reflect_accounting import ( ReflectDisposition, - _validate_recurring_dimension_mentions, parse_reflect_dispositions, + validate_reflect_accounting, ) -from ..validation.reflect_accounting import validate_reflect_accounting from ..validation.stage_policy import auto_confirm_observe_if_attested from .flow_helpers import validate_stage_report_length from .records import resolve_reusable_report from .rendering import _print_reflect_report_requirement +def _validate_recurring_dimension_mentions( + *, + report: str, + recurring_dims: list[str], + recurring: dict[str, dict[str, list[str]]], +) -> bool: + if not recurring_dims: + return True + report_lower = report.lower() + mentioned = [dim for dim in recurring_dims if dim.lower() in report_lower] + if mentioned: + return True + print(colorize(" Recurring patterns detected but not addressed in report:", "red")) + for dim in recurring_dims: + info = recurring[dim] + print( + colorize( + f" {dim}: {len(info['resolved'])} resolved, " + f"{len(info['open'])} still open — potential loop", + "yellow", + ) + ) + print(colorize(" Your report must mention at least one recurring dimension name.", "dim")) + return False + + def _validate_reflect_submission( *, report: str, @@ -46,7 +71,8 @@ def _validate_reflect_submission( ): return None - issue_count = len(triage_input.open_issues) + review_issues = getattr(triage_input, "review_issues", getattr(triage_input, "open_issues", {})) + issue_count = len(review_issues) if not validate_stage_report_length( report=report, issue_count=issue_count, @@ -55,7 +81,7 @@ def _validate_reflect_submission( return None recurring = services.detect_recurring_patterns( - triage_input.open_issues, + review_issues, triage_input.resolved_issues, ) recurring_dims = sorted(recurring.keys()) @@ -66,7 +92,7 @@ def _validate_reflect_submission( ): return None - valid_ids = set(triage_input.open_issues.keys()) + valid_ids = set(review_issues.keys()) # Exclude issues already auto-skipped by observe from reflect accounting meta = plan.get("epic_triage_meta", {}) diff --git a/desloppify/app/commands/plan/triage/stages/rendering.py b/desloppify/app/commands/plan/triage/stages/rendering.py index 53542eca..d3ad6dac 100644 --- a/desloppify/app/commands/plan/triage/stages/rendering.py +++ b/desloppify/app/commands/plan/triage/stages/rendering.py @@ -10,9 +10,9 @@ def _print_observe_report_requirement() -> None: print(colorize(" --report is required for --stage observe.", "red")) - print(colorize(" Write an analysis of the issues: themes, root causes, contradictions.", "dim")) - print(colorize(" Identify issues that contradict each other (opposite recommendations).", "dim")) - print(colorize(" Do NOT just list issue IDs — describe what you actually observe.", "dim")) + print(colorize(" Verify the queued issues one by one against the code.", "dim")) + print(colorize(" State whether each issue is genuine, false positive, exaggerated, or not worth fixing.", "dim")) + print(colorize(" Cite the files you read and the concrete evidence behind each verdict.", "dim")) def _print_reflect_report_requirement() -> None: @@ -51,13 +51,14 @@ def _print_complete_summary(plan: dict, stages: dict) -> None: else: print(colorize(" Enrich: all steps detailed", "dim")) if "sense-check" in stages: - print(colorize(" Sense-check: content & structure verified", "dim")) + print(colorize(" Sense-check: content, structure & value verified", "dim")) def _print_new_issues_since_last(si) -> None: print(colorize(f" {len(si.new_since_last)} new issue(s) since last triage:", "cyan")) + review_issues = getattr(si, "review_issues", getattr(si, "open_issues", {})) for fid in sorted(si.new_since_last): - issue = si.open_issues.get(fid, {}) + issue = review_issues.get(fid, {}) print(f" * [{short_issue_id(fid)}] {issue.get('summary', '')}") print() diff --git a/desloppify/app/commands/plan/triage/stages/sense_check.py b/desloppify/app/commands/plan/triage/stages/sense_check.py index 79e4d09a..7a4c4085 100644 --- a/desloppify/app/commands/plan/triage/stages/sense_check.py +++ b/desloppify/app/commands/plan/triage/stages/sense_check.py @@ -10,6 +10,7 @@ from desloppify.base.output.terminal import colorize from .records import record_sense_check_stage, resolve_reusable_report +from .helpers import value_check_targets from ..validation.enrich_quality import evaluate_enrich_quality from ..validation.enrich_checks import ( _steps_missing_issue_refs, @@ -205,11 +206,18 @@ def run_stage_sense_check( ) stages = meta.setdefault("triage_stages", {}) + frozen_value_targets = getattr(args, "value_targets", None) + if not isinstance(frozen_value_targets, list): + if existing_stage and isinstance(existing_stage.get("value_targets"), list): + frozen_value_targets = list(existing_stage["value_targets"]) + else: + frozen_value_targets = value_check_targets(plan, state) cleared = resolved_deps.record_sense_check_stage( stages, report=report, existing_stage=existing_stage, is_reuse=is_reuse, + value_targets=frozen_value_targets, ) resolved_services.save_plan(plan) diff --git a/desloppify/app/commands/plan/triage/validation/completion_policy.py b/desloppify/app/commands/plan/triage/validation/completion_policy.py index efbb49c3..4e48a8da 100644 --- a/desloppify/app/commands/plan/triage/validation/completion_policy.py +++ b/desloppify/app/commands/plan/triage/validation/completion_policy.py @@ -2,6 +2,8 @@ from __future__ import annotations +from dataclasses import dataclass + from desloppify.engine.plan_triage import TRIAGE_CMD_ORGANIZE from desloppify.base.output.terminal import colorize from desloppify.engine.plan_triage import extract_issue_citations @@ -9,10 +11,34 @@ from ..display.dashboard import show_plan_summary from ..review_coverage import ( cluster_issue_ids, - manual_clusters_with_issues, open_review_ids_from_state, + triage_coverage, +) +from ..stages.helpers import ( + active_triage_issue_scope, + scoped_manual_clusters_with_issues, + triage_scoped_plan, + unclustered_review_issues, + unenriched_clusters, ) -from ..stages.helpers import unclustered_review_issues, unenriched_clusters +from .completion_stages import ( + _auto_confirm_enrich_for_complete, + _require_enrich_stage_for_complete, + _require_organize_stage_for_complete, + _require_sense_check_stage_for_complete, +) + + +@dataclass(frozen=True) +class CompletionReadiness: + """Single completion-boundary verdict shared by command and runner paths.""" + + ok: bool + message: str = "" + open_review_ids: frozenset[str] = frozenset() + manual_clusters: tuple[str, ...] = () + organized: int = 0 + total: int = 0 def _resolve_strategy_input( @@ -59,10 +85,69 @@ def _strategy_valid_or_error( def _completion_clusters_valid(plan: dict, state: dict | None = None) -> bool: - if state is not None and not open_review_ids_from_state(state): - return True + return evaluate_completion_readiness( + plan, + state, + require_confirmed_stages=False, + ).ok + + +def _required_completion_stages_valid(stages: dict) -> tuple[bool, str]: + """Validate that the triage completion stages are present and confirmed.""" + for required in ("observe", "reflect", "organize", "enrich", "sense-check"): + if required not in stages: + return False, f"Stage {required} not recorded." + if not stages[required].get("confirmed_at"): + return False, f"Stage {required} not confirmed." + return True, "" + + +def _validate_cluster_dependency_cycles(clusters: dict) -> tuple[bool, str]: + """Reject self-referential cluster dependencies.""" + for name, cluster in clusters.items(): + deps = cluster.get("depends_on_clusters", []) + if name in deps: + return False, f"Cluster {name} depends on itself." + return True, "" + + +def _find_all_trivial_clusters(clusters: dict) -> list[str]: + """Return manual clusters whose action steps are all marked trivial.""" + trivial_clusters: list[str] = [] + for name, cluster in clusters.items(): + if cluster.get("auto") or not cluster_issue_ids(cluster): + continue + steps = cluster.get("action_steps") or [] + if steps and all( + isinstance(step, dict) and step.get("effort") == "trivial" + for step in steps + ): + trivial_clusters.append(name) + return trivial_clusters + + +def evaluate_completion_readiness( + plan: dict, + state: dict | None, + *, + require_confirmed_stages: bool = False, +) -> CompletionReadiness: + """Evaluate whether triage completion is ready from one owned boundary.""" + meta = plan.get("epic_triage_meta", {}) + stages = meta.get("triage_stages", {}) + if require_confirmed_stages: + ok, message = _required_completion_stages_valid(stages) + if not ok: + return CompletionReadiness(ok=False, message=message) + + triage_scope = active_triage_issue_scope(plan, state) + in_scope_open_ids = ( + open_review_ids_from_state(state) if state is not None and triage_scope is None else (triage_scope or set()) + ) + if state is not None and not in_scope_open_ids: + return CompletionReadiness(ok=True) - manual_clusters = manual_clusters_with_issues(plan) + manual_clusters = scoped_manual_clusters_with_issues(plan, state) if not manual_clusters: any_clusters = [ name for name, cluster in plan.get("clusters", {}).items() @@ -71,16 +156,16 @@ def _completion_clusters_valid(plan: dict, state: dict | None = None) -> bool: if not any_clusters: print(colorize(" Cannot complete: no clusters with issues exist.", "red")) print(colorize(' Create clusters: desloppify plan cluster create <name> --description "..."', "dim")) - return False + return CompletionReadiness(ok=False, message="No clusters with issues exist.") - gaps = unenriched_clusters(plan) + gaps = unenriched_clusters(plan, state) if gaps: print(colorize(f" Cannot complete: {len(gaps)} cluster(s) still need enrichment.", "red")) for name, missing in gaps: print(colorize(f" {name}: missing {', '.join(missing)}", "yellow")) print(colorize(" Small clusters (<5 issues) need at least 1 action step per issue.", "dim")) print(colorize(' Fix: desloppify plan cluster update <name> --description "..." --steps "step1" "step2"', "dim")) - return False + return CompletionReadiness(ok=False, message=f"{len(gaps)} cluster(s) still need enrichment.") unclustered = unclustered_review_issues(plan, state) if unclustered: @@ -91,9 +176,36 @@ def _completion_clusters_valid(plan: dict, state: dict | None = None) -> bool: if len(unclustered) > 5: print(colorize(f" ... and {len(unclustered) - 5} more", "yellow")) print(colorize(" Add to a cluster or wontfix each unclustered issue.", "dim")) - return False + return CompletionReadiness( + ok=False, + message=f"{len(unclustered)} review issue(s) not in any cluster.", + ) - return True + scoped_clusters = triage_scoped_plan(plan, state).get("clusters", {}) + ok, message = _validate_cluster_dependency_cycles(scoped_clusters) + if not ok: + return CompletionReadiness(ok=False, message=message) + + organized, total, _ = triage_coverage(plan, open_review_ids=in_scope_open_ids) + trivial_clusters = _find_all_trivial_clusters(scoped_clusters) + if trivial_clusters: + names = ", ".join(sorted(trivial_clusters)) + return CompletionReadiness( + ok=True, + message=f"Advisory: all action steps are marked trivial in cluster(s): {names}", + open_review_ids=frozenset(in_scope_open_ids), + manual_clusters=tuple(manual_clusters), + organized=organized, + total=total, + ) + + return CompletionReadiness( + ok=True, + open_review_ids=frozenset(in_scope_open_ids), + manual_clusters=tuple(manual_clusters), + organized=organized, + total=total, + ) def _resolve_completion_strategy(strategy: str | None, *, meta: dict) -> str | None: @@ -177,7 +289,8 @@ def _note_cites_new_issues_or_error(note: str, si) -> bool: new_ids = si.new_since_last if not new_ids: return True - valid_ids = set(si.open_issues.keys()) + review_issues = getattr(si, "review_issues", getattr(si, "open_issues", {})) + valid_ids = set(review_issues.keys()) cited = extract_issue_citations(note, valid_ids) new_cited = cited & new_ids if new_cited: @@ -192,6 +305,8 @@ def _note_cites_new_issues_or_error(note: str, si) -> bool: __all__ = [ + "_auto_confirm_enrich_for_complete", + "CompletionReadiness", "_completion_clusters_valid", "_completion_strategy_valid", "_confirm_existing_stages_valid", @@ -199,7 +314,12 @@ def _note_cites_new_issues_or_error(note: str, si) -> bool: "_confirm_strategy_valid", "_confirmed_text_or_error", "_note_cites_new_issues_or_error", + "_require_enrich_stage_for_complete", + "_require_organize_stage_for_complete", "_require_prior_strategy_for_confirm", + "_require_sense_check_stage_for_complete", + "_required_completion_stages_valid", "_resolve_completion_strategy", "_resolve_confirm_existing_strategy", + "evaluate_completion_readiness", ] diff --git a/desloppify/app/commands/plan/triage/validation/stage_policy.py b/desloppify/app/commands/plan/triage/validation/stage_policy.py index 2a506c5c..d3176ee2 100644 --- a/desloppify/app/commands/plan/triage/validation/stage_policy.py +++ b/desloppify/app/commands/plan/triage/validation/stage_policy.py @@ -172,7 +172,8 @@ def auto_confirm_reflect_for_organize( runtime = runtime_factory(args) triage_input = resolved_deps.collect_triage_input_fn(plan, runtime.state) - valid_ids = set(triage_input.open_issues.keys()) + review_issues = getattr(triage_input, "review_issues", getattr(triage_input, "open_issues", {})) + valid_ids = set(review_issues.keys()) accounting_ok, cited_ids, missing_ids, duplicate_ids = validate_reflect_accounting( report=str(reflect_stage.get("report", "")), valid_ids=valid_ids, @@ -184,7 +185,7 @@ def auto_confirm_reflect_for_organize( reflect_stage["duplicate_issue_ids"] = duplicate_ids recurring = resolved_deps.detect_recurring_patterns_fn( - triage_input.open_issues, + review_issues, triage_input.resolved_issues, ) _by_dim, observe_dims = observe_dimension_breakdown(triage_input) diff --git a/desloppify/app/commands/plan/triage/workflow.py b/desloppify/app/commands/plan/triage/workflow.py index eedd478b..1d99046b 100644 --- a/desloppify/app/commands/plan/triage/workflow.py +++ b/desloppify/app/commands/plan/triage/workflow.py @@ -80,7 +80,8 @@ def _cmd_triage_start( return si = services.collect_triage_input(plan, state) - print(f" Open review issues: {len(si.open_issues)}") + review_issues = getattr(si, "review_issues", getattr(si, "open_issues", {})) + print(f" Open review issues: {len(review_issues)}") print(colorize(" Begin with observe:", "dim")) print(colorize(f" {TRIAGE_CMD_OBSERVE}", "dim")) @@ -122,7 +123,8 @@ def _run_dry_run( existing_clusters = si.existing_clusters print(colorize(" Cluster triage - dry run", "bold")) print(colorize(" " + "─" * 60, "dim")) - print(f" Open review issues: {len(si.open_issues)}") + review_issues = getattr(si, "review_issues", getattr(si, "open_issues", {})) + print(f" Open review issues: {len(review_issues)}") print(f" Existing clusters: {len(existing_clusters)}") print(f" New since last: {len(si.new_since_last)}") print(f" Resolved since last: {len(si.resolved_since_last)}") diff --git a/desloppify/app/commands/resolve/cmd.py b/desloppify/app/commands/resolve/cmd.py index 7ff69870..aad50bca 100644 --- a/desloppify/app/commands/resolve/cmd.py +++ b/desloppify/app/commands/resolve/cmd.py @@ -74,6 +74,8 @@ def _load_state_with_guards( if args.status == "fixed": require_triage_current_or_exit( state=state, + plan=plan_access.plan if isinstance(plan_access.plan, dict) and not plan_access.degraded else None, + patterns=args.patterns, bypass=bool(getattr(args, "force_resolve", False)), attest=getattr(args, "attest", "") or "", ) @@ -134,6 +136,7 @@ def cmd_resolve(args: argparse.Namespace) -> None: args=args, all_resolved=all_resolved, attestation=attestation, + state=state, state_file=state_file, ) mid_cluster = ( diff --git a/desloppify/app/commands/resolve/living_plan.py b/desloppify/app/commands/resolve/living_plan.py index 06179966..17044eee 100644 --- a/desloppify/app/commands/resolve/living_plan.py +++ b/desloppify/app/commands/resolve/living_plan.py @@ -7,9 +7,11 @@ from pathlib import Path from typing import NamedTuple +from desloppify.base.config import target_strict_score_from_config from desloppify.base.exception_sets import PLAN_LOAD_EXCEPTIONS from desloppify.base.output.terminal import colorize from desloppify.app.commands.resolve.plan_load import warn_plan_load_degraded_once +from desloppify.engine._plan.sync import live_planned_queue_empty, reconcile_plan from desloppify.engine.plan_ops import ( append_log_entry, auto_complete_steps, @@ -34,6 +36,14 @@ class ClusterContext(NamedTuple): cluster_remaining: int +def _reconcile_if_queue_drained(plan: dict, state: dict | None) -> None: + """Advance the living plan when a resolve drains the explicit live queue.""" + if state is None or not live_planned_queue_empty(plan): + return + target_strict = target_strict_score_from_config(state.get("config")) + reconcile_plan(plan, state, target_strict=target_strict) + + def capture_cluster_context(plan: dict, resolved_ids: list[str]) -> ClusterContext: """Determine cluster membership for resolved issues before purge.""" clusters = plan.get("clusters") or {} @@ -60,6 +70,7 @@ def update_living_plan_after_resolve( args: argparse.Namespace, all_resolved: list[str], attestation: str | None, + state: dict | None = None, state_file: Path | str | None = None, ) -> tuple[dict | None, ClusterContext]: """Apply resolve side effects to the living plan when it exists.""" @@ -95,7 +106,8 @@ def update_living_plan_after_resolve( add_uncommitted_issues(plan, all_resolved) elif args.status == "open": purge_uncommitted_ids(plan, all_resolved) - clear_postflight_scan_completion(plan, issue_ids=all_resolved) + clear_postflight_scan_completion(plan, issue_ids=all_resolved, state=state) + _reconcile_if_queue_drained(plan, state) save_plan(plan, plan_path) if purged: print(colorize(f" Plan updated: {purged} item(s) removed from queue.", "dim")) diff --git a/desloppify/app/commands/resolve/plan_load.py b/desloppify/app/commands/resolve/plan_load.py index 405ccde1..25b774f6 100644 --- a/desloppify/app/commands/resolve/plan_load.py +++ b/desloppify/app/commands/resolve/plan_load.py @@ -34,6 +34,7 @@ class ResolvePlanAccess: degraded: bool error_kind: str | None warning_state: DegradedPlanWarningState + recovery: str | None = None def usable_plan(self, *, behavior: str, command_label: str = "resolve") -> dict | None: """Return the loaded plan, warning once when resolve falls back.""" @@ -44,6 +45,8 @@ def usable_plan(self, *, behavior: str, command_label: str = "resolve") -> dict behavior=behavior, warning_state=self.warning_state, ) + if self.recovery == "backup": + return self.plan if isinstance(self.plan, dict) else None return None return self.plan if isinstance(self.plan, dict) else None @@ -59,6 +62,7 @@ def load_resolve_plan_access( plan=status.plan, degraded=status.degraded, error_kind=status.error_kind, + recovery=getattr(status, "recovery", None), warning_state=resolved_warning_state, ) diff --git a/desloppify/app/commands/resolve/queue_guard.py b/desloppify/app/commands/resolve/queue_guard.py index a50ccdc3..788fd526 100644 --- a/desloppify/app/commands/resolve/queue_guard.py +++ b/desloppify/app/commands/resolve/queue_guard.py @@ -144,7 +144,7 @@ def _check_queue_order_guard( return False clusters = plan.get("clusters", {}) - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) resolved_ids = _resolve_target_ids(patterns, clusters) resolved_ids = _filter_open_or_cluster_targets( resolved_ids, diff --git a/desloppify/app/commands/resolve/render.py b/desloppify/app/commands/resolve/render.py index e79c4d75..2ea320d1 100644 --- a/desloppify/app/commands/resolve/render.py +++ b/desloppify/app/commands/resolve/render.py @@ -35,12 +35,13 @@ def _print_wontfix_batch_warning( ) -> None: if status != "wontfix" or resolved_count <= 10: return + work_items = state.get("work_items") or state.get("issues", {}) wontfix_count = sum( - 1 for issue in state["issues"].values() if issue["status"] == "wontfix" + 1 for issue in work_items.values() if issue["status"] == "wontfix" ) actionable = sum( 1 - for issue in state["issues"].values() + for issue in work_items.values() if issue["status"] in ("open", "wontfix", "fixed", "auto_resolved", "false_positive") ) @@ -114,8 +115,9 @@ def _print_subjective_reset_hint( all_resolved: list[str], prev_subjective_scores: dict[str, float], ) -> None: + work_items = state.get("work_items") or state.get("issues", {}) has_review = any( - state["issues"].get(fid, {}).get("detector") == "review" + work_items.get(fid, {}).get("detector") == "review" for fid in all_resolved ) if not has_review or not state.get("subjective_assessments"): @@ -125,10 +127,10 @@ def _print_subjective_reset_hint( dim for dim in { str( - state["issues"].get(fid, {}).get("detail", {}).get("dimension", "") + work_items.get(fid, {}).get("detail", {}).get("dimension", "") ).strip() for fid in all_resolved - if state["issues"].get(fid, {}).get("detector") == "review" + if work_items.get(fid, {}).get("detector") == "review" } if dim and dim in (state.get("subjective_assessments") or {}) ) @@ -236,9 +238,10 @@ def render_commit_guidance( def _print_next_command(state: dict) -> str: + work_items = state.get("work_items") or state.get("issues", {}) remaining = sum( 1 - for issue in state["issues"].values() + for issue in work_items.values() if issue["status"] == "open" and not issue.get("suppressed") ) diff --git a/desloppify/app/commands/review/batch/core_models.py b/desloppify/app/commands/review/batch/core_models.py index 67f3bf99..f8880a8c 100644 --- a/desloppify/app/commands/review/batch/core_models.py +++ b/desloppify/app/commands/review/batch/core_models.py @@ -51,7 +51,6 @@ class BatchDimensionJudgmentPayload(TypedDict, total=False): """Reviewer's holistic judgment narrative for a dimension.""" strengths: list[str] - issue_character: str dimension_character: str score_rationale: str diff --git a/desloppify/app/commands/review/batch/core_normalize.py b/desloppify/app/commands/review/batch/core_normalize.py index 62c13cab..be15089f 100644 --- a/desloppify/app/commands/review/batch/core_normalize.py +++ b/desloppify/app/commands/review/batch/core_normalize.py @@ -117,7 +117,6 @@ def _validate_dimension_judgment( require_complete=require_complete, ) - # Accept dimension_character (new) or issue_character (legacy) dimension_character = _normalize_dimension_judgment_text( key, raw.get("dimension_character"), @@ -125,17 +124,9 @@ def _validate_dimension_judgment( require_complete=False, log_fn=log_fn, ) - issue_character = _normalize_dimension_judgment_text( - key, - raw.get("issue_character"), - field_name="issue_character", - require_complete=False, - log_fn=log_fn, - ) - # Require at least one character field when completeness is required - if require_complete and not dimension_character and not issue_character: + if require_complete and not dimension_character: raise ValueError( - f"dimension_judgment.{key} must include dimension_character or issue_character" + f"dimension_judgment.{key} must include dimension_character" ) score_rationale = _normalize_dimension_judgment_text( @@ -147,7 +138,7 @@ def _validate_dimension_judgment( min_length=50, ) - if not dimension_character and not issue_character and not score_rationale and not strengths: + if not dimension_character and not score_rationale and not strengths: return None result: BatchDimensionJudgmentPayload = {} @@ -155,8 +146,6 @@ def _validate_dimension_judgment( result["strengths"] = strengths if dimension_character: result["dimension_character"] = dimension_character - if issue_character: - result["issue_character"] = issue_character if score_rationale: result["score_rationale"] = score_rationale return result @@ -198,8 +187,8 @@ def _normalize_dimension_judgment_text( log_fn(f" dimension_judgment.{key}.{field_name}: missing or empty") return "" if min_length is not None and len(value) < min_length: - log_fn( - f" dimension_judgment.{key}.{field_name}: " + raise ValueError( + f"dimension_judgment.{key}.{field_name}: " f"too short ({len(value)} chars, want ≥{min_length})" ) return value @@ -531,7 +520,7 @@ def _normalize_dimension_judgments( ) if validated is None: raise ValueError( - f"dimension_judgment.{key} must include dimension_character (or issue_character) and score_rationale" + f"dimension_judgment.{key} must include dimension_character and score_rationale" ) dimension_judgment[key] = validated return dimension_judgment diff --git a/desloppify/app/commands/review/batch/execution.py b/desloppify/app/commands/review/batch/execution.py index 44e84a8e..f0d528ca 100644 --- a/desloppify/app/commands/review/batch/execution.py +++ b/desloppify/app/commands/review/batch/execution.py @@ -7,17 +7,57 @@ from typing import Any, Callable +@dataclass(frozen=True) +class LoadOrPreparePacketRequest: + """Stable inputs for resolving a reusable or fresh review packet.""" + + args: Any + state: dict[str, Any] + lang: Any + config: dict[str, Any] + stamp: str + + +@dataclass(frozen=True) +class PrepareRunArtifactsRequest: + """Stable inputs for creating a review batch run directory.""" + + stamp: str + selected_indexes: list[int] + batches: list[dict[str, Any]] + packet_path: Path + run_root: Path + repo_root: Path + + +@dataclass(frozen=True) +class CollectBatchResultsRequest: + """Stable inputs for parsing and normalizing batch result payloads.""" + + selected_indexes: list[int] + failures: list[int] + output_files: dict[int, Path] + allowed_dims: set[str] + + @dataclass(frozen=True) class BatchRunDeps: """Explicit callable surface for batch-run orchestration.""" run_stamp_fn: Callable[[], str] - load_or_prepare_packet_fn: Callable[..., tuple[dict[str, Any], Path, Path]] + load_or_prepare_packet_fn: Callable[ + [LoadOrPreparePacketRequest], tuple[dict[str, Any], Path, Path] + ] selected_batch_indexes_fn: Callable[..., list[int]] - prepare_run_artifacts_fn: Callable[..., tuple[Path, Path, dict[int, Path], dict[int, Path], dict[int, Path]]] + prepare_run_artifacts_fn: Callable[ + [PrepareRunArtifactsRequest], + tuple[Path, Path, dict[int, Path], dict[int, Path], dict[int, Path]], + ] run_codex_batch_fn: Callable[..., int] execute_batches_fn: Callable[..., list[int]] - collect_batch_results_fn: Callable[..., tuple[list[dict[str, Any]], list[int]]] + collect_batch_results_fn: Callable[ + [CollectBatchResultsRequest], tuple[list[dict[str, Any]], list[int]] + ] print_failures_fn: Callable[..., None] print_failures_and_raise_fn: Callable[..., None] merge_batch_results_fn: Callable[[list[dict[str, Any]]], dict[str, object]] @@ -27,4 +67,9 @@ class BatchRunDeps: safe_write_text_fn: Callable[[Path, str], None] colorize_fn: Callable[[str, str | None], str] -__all__ = ["BatchRunDeps"] +__all__ = [ + "BatchRunDeps", + "CollectBatchResultsRequest", + "LoadOrPreparePacketRequest", + "PrepareRunArtifactsRequest", +] diff --git a/desloppify/app/commands/review/batch/execution_phases.py b/desloppify/app/commands/review/batch/execution_phases.py index abf0065a..b38fe6ff 100644 --- a/desloppify/app/commands/review/batch/execution_phases.py +++ b/desloppify/app/commands/review/batch/execution_phases.py @@ -33,6 +33,11 @@ merge_and_write_results, ) from .execution_summary import build_run_summary_writer +from .execution import ( + CollectBatchResultsRequest, + LoadOrPreparePacketRequest, + PrepareRunArtifactsRequest, +) from .scope import ( normalize_dimension_list, print_preflight_dimension_scope_notice, @@ -42,6 +47,7 @@ ) if TYPE_CHECKING: + from ..runtime.policy import BatchRunPolicy from .execution import BatchRunDeps @@ -96,18 +102,40 @@ class ExecutedBatchRunContext: failure_set: set[int] -def _resolve_runtime_policy(args) -> tuple[bool, int, float, float, int, float, float, float]: - policy = resolve_batch_run_policy(args) - return ( - policy.run_parallel, - policy.max_parallel_batches, - policy.heartbeat_seconds, - policy.batch_timeout_seconds, - policy.batch_max_retries, - policy.batch_retry_backoff_seconds, - policy.stall_warning_seconds, - policy.stall_kill_seconds, - ) +@dataclass(frozen=True) +class PreparedPacketScope: + """Packet and selection state shared from prepare into execution.""" + + packet: dict[str, Any] + immutable_packet_path: Path + prompt_packet_path: Path + scan_path: str + packet_dimensions: list[str] + scored_dimensions: list[str] + batches: list[dict[str, Any]] + selected_indexes: list[int] + + +@dataclass(frozen=True) +class PreparedRunArtifacts: + """Runtime artifacts and callbacks created for one batch run.""" + + run_dir: Path + logs_dir: Path + prompt_files: dict[int, Path] + output_files: dict[int, Path] + log_files: dict[int, Path] + run_log_path: Path + append_run_log: Any + batch_positions: dict[int, int] + batch_status: dict[str, dict[str, object]] + report_progress: Any + record_issue: Any + write_run_summary: Any + + +def _resolve_runtime_policy(args) -> BatchRunPolicy: + return resolve_batch_run_policy(args) def _prepare_packet_scope( @@ -118,13 +146,15 @@ def _prepare_packet_scope( config: dict[str, Any], deps: BatchRunDeps, stamp: str, -) -> tuple[dict[str, Any], Path, Path, str, list[str], list[str], list[dict[str, Any]], list[int]]: +) -> PreparedPacketScope: packet, immutable_packet_path, prompt_packet_path = deps.load_or_prepare_packet_fn( - args, - state=state, - lang=lang, - config=config, - stamp=stamp, + LoadOrPreparePacketRequest( + args=args, + state=state, + lang=lang, + config=config, + stamp=stamp, + ) ) scan_path = str(getattr(args, "path", ".") or ".") packet_dimensions = normalize_dimension_list(packet.get("dimensions", [])) @@ -147,15 +177,15 @@ def _prepare_packet_scope( dimension_prompts=raw_dim_prompts if isinstance(raw_dim_prompts, dict) else None, ) selected_indexes = deps.selected_batch_indexes_fn(args, batch_count=len(batches)) - return ( - packet, - immutable_packet_path, - prompt_packet_path, - scan_path, - packet_dimensions, - scored_dimensions, - batches, - selected_indexes, + return PreparedPacketScope( + packet=packet, + immutable_packet_path=immutable_packet_path, + prompt_packet_path=prompt_packet_path, + scan_path=scan_path, + packet_dimensions=packet_dimensions, + scored_dimensions=scored_dimensions, + batches=batches, + selected_indexes=selected_indexes, ) @@ -202,27 +232,16 @@ def _prepare_run_runtime( batch_retry_backoff_seconds: float, stall_warning_seconds: float, stall_kill_seconds: float, -) -> tuple[ - Path, - Path, - dict[int, Path], - dict[int, Path], - dict[int, Path], - Path, - Any, - dict[int, int], - dict[str, dict[str, object]], - Any, - Any, - Any, -]: +) -> PreparedRunArtifacts: run_dir, logs_dir, prompt_files, output_files, log_files = deps.prepare_run_artifacts_fn( - stamp=stamp, - selected_indexes=selected_indexes, - batches=batches, - packet_path=prompt_packet_path, - run_root=subagent_runs_dir, - repo_root=project_root, + PrepareRunArtifactsRequest( + stamp=stamp, + selected_indexes=selected_indexes, + batches=batches, + packet_path=prompt_packet_path, + run_root=subagent_runs_dir, + repo_root=project_root, + ) ) run_log_path = resolve_run_log_path( getattr(args, "run_log_file", None), @@ -302,19 +321,19 @@ def _prepare_run_runtime( colorize_fn=deps.colorize_fn, append_run_log=append_run_log, ) - return ( - run_dir, - logs_dir, - prompt_files, - output_files, - log_files, - run_log_path, - append_run_log, - batch_positions, - batch_status, - report_progress, - record_issue, - write_run_summary, + return PreparedRunArtifacts( + run_dir=run_dir, + logs_dir=logs_dir, + prompt_files=prompt_files, + output_files=output_files, + log_files=log_files, + run_log_path=run_log_path, + append_run_log=append_run_log, + batch_positions=batch_positions, + batch_status=batch_status, + report_progress=report_progress, + record_issue=record_issue, + write_run_summary=write_run_summary, ) @@ -333,28 +352,10 @@ def prepare_batch_run( validate_runner(runner, colorize_fn=deps.colorize_fn) allow_partial = bool(getattr(args, "allow_partial", False)) - ( - run_parallel, - max_parallel_batches, - heartbeat_seconds, - batch_timeout_seconds, - batch_max_retries, - batch_retry_backoff_seconds, - stall_warning_seconds, - stall_kill_seconds, - ) = _resolve_runtime_policy(args) + policy = _resolve_runtime_policy(args) stamp = deps.run_stamp_fn() - ( - packet, - immutable_packet_path, - prompt_packet_path, - scan_path, - packet_dimensions, - scored_dimensions, - batches, - selected_indexes, - ) = _prepare_packet_scope( + packet_scope = _prepare_packet_scope( args=args, state=state, lang=lang, @@ -362,70 +363,57 @@ def prepare_batch_run( deps=deps, stamp=stamp, ) - total_batches = len(selected_indexes) + total_batches = len(packet_scope.selected_indexes) _print_runtime_expectation( deps=deps, total_batches=total_batches, - run_parallel=run_parallel, - max_parallel_batches=max_parallel_batches, - batch_timeout_seconds=batch_timeout_seconds, + run_parallel=policy.run_parallel, + max_parallel_batches=policy.max_parallel_batches, + batch_timeout_seconds=policy.batch_timeout_seconds, ) - ( - run_dir, - logs_dir, - prompt_files, - output_files, - log_files, - run_log_path, - append_run_log, - batch_positions, - batch_status, - report_progress, - record_issue, - write_run_summary, - ) = _prepare_run_runtime( + runtime_artifacts = _prepare_run_runtime( args=args, deps=deps, stamp=stamp, - selected_indexes=selected_indexes, - batches=batches, - prompt_packet_path=prompt_packet_path, - immutable_packet_path=immutable_packet_path, + selected_indexes=packet_scope.selected_indexes, + batches=packet_scope.batches, + prompt_packet_path=packet_scope.prompt_packet_path, + immutable_packet_path=packet_scope.immutable_packet_path, project_root=project_root, subagent_runs_dir=subagent_runs_dir, runner=runner, allow_partial=allow_partial, - run_parallel=run_parallel, - max_parallel_batches=max_parallel_batches, - heartbeat_seconds=heartbeat_seconds, - batch_timeout_seconds=batch_timeout_seconds, - batch_max_retries=batch_max_retries, - batch_retry_backoff_seconds=batch_retry_backoff_seconds, - stall_warning_seconds=stall_warning_seconds, - stall_kill_seconds=stall_kill_seconds, + run_parallel=policy.run_parallel, + max_parallel_batches=policy.max_parallel_batches, + heartbeat_seconds=policy.heartbeat_seconds, + batch_timeout_seconds=policy.batch_timeout_seconds, + batch_max_retries=policy.batch_max_retries, + batch_retry_backoff_seconds=policy.batch_retry_backoff_seconds, + stall_warning_seconds=policy.stall_warning_seconds, + stall_kill_seconds=policy.stall_kill_seconds, ) if maybe_handle_dry_run( args=args, stamp=stamp, - selected_indexes=selected_indexes, - run_dir=run_dir, - logs_dir=logs_dir, - immutable_packet_path=immutable_packet_path, - prompt_packet_path=prompt_packet_path, - prompt_files=prompt_files, - output_files=output_files, + selected_indexes=packet_scope.selected_indexes, + run_dir=runtime_artifacts.run_dir, + logs_dir=runtime_artifacts.logs_dir, + immutable_packet_path=packet_scope.immutable_packet_path, + prompt_packet_path=packet_scope.prompt_packet_path, + prompt_files=runtime_artifacts.prompt_files, + output_files=runtime_artifacts.output_files, safe_write_text_fn=deps.safe_write_text_fn, colorize_fn=deps.colorize_fn, - append_run_log=append_run_log, + append_run_log=runtime_artifacts.append_run_log, ): return None - if run_parallel: + if policy.run_parallel: print( deps.colorize_fn( " Parallel runner config: " - f"max-workers={min(total_batches, max_parallel_batches)}, " - f"heartbeat={heartbeat_seconds:.1f}s", + f"max-workers={min(total_batches, policy.max_parallel_batches)}, " + f"heartbeat={policy.heartbeat_seconds:.1f}s", "dim", ) ) @@ -436,37 +424,37 @@ def prepare_batch_run( config=config, runner=runner, allow_partial=allow_partial, - run_parallel=run_parallel, - max_parallel_batches=max_parallel_batches, - heartbeat_seconds=heartbeat_seconds, - batch_timeout_seconds=batch_timeout_seconds, - batch_max_retries=batch_max_retries, - batch_retry_backoff_seconds=batch_retry_backoff_seconds, - stall_warning_seconds=stall_warning_seconds, - stall_kill_seconds=stall_kill_seconds, + run_parallel=policy.run_parallel, + max_parallel_batches=policy.max_parallel_batches, + heartbeat_seconds=policy.heartbeat_seconds, + batch_timeout_seconds=policy.batch_timeout_seconds, + batch_max_retries=policy.batch_max_retries, + batch_retry_backoff_seconds=policy.batch_retry_backoff_seconds, + stall_warning_seconds=policy.stall_warning_seconds, + stall_kill_seconds=policy.stall_kill_seconds, state=state, lang=lang, - packet=packet, - immutable_packet_path=immutable_packet_path, - prompt_packet_path=prompt_packet_path, - scan_path=scan_path, - packet_dimensions=packet_dimensions, - scored_dimensions=scored_dimensions, - batches=batches, - selected_indexes=selected_indexes, + packet=packet_scope.packet, + immutable_packet_path=packet_scope.immutable_packet_path, + prompt_packet_path=packet_scope.prompt_packet_path, + scan_path=packet_scope.scan_path, + packet_dimensions=packet_scope.packet_dimensions, + scored_dimensions=packet_scope.scored_dimensions, + batches=packet_scope.batches, + selected_indexes=packet_scope.selected_indexes, project_root=project_root, - run_dir=run_dir, - logs_dir=logs_dir, - prompt_files=prompt_files, - output_files=output_files, - log_files=log_files, - run_log_path=run_log_path, - append_run_log=append_run_log, - batch_positions=batch_positions, - batch_status=batch_status, - report_progress=report_progress, - record_issue=record_issue, - write_run_summary=write_run_summary, + run_dir=runtime_artifacts.run_dir, + logs_dir=runtime_artifacts.logs_dir, + prompt_files=runtime_artifacts.prompt_files, + output_files=runtime_artifacts.output_files, + log_files=runtime_artifacts.log_files, + run_log_path=runtime_artifacts.run_log_path, + append_run_log=runtime_artifacts.append_run_log, + batch_positions=runtime_artifacts.batch_positions, + batch_status=runtime_artifacts.batch_status, + report_progress=runtime_artifacts.report_progress, + record_issue=runtime_artifacts.record_issue, + write_run_summary=runtime_artifacts.write_run_summary, ) @@ -509,10 +497,17 @@ def execute_batch_run(*, prepared: PreparedBatchRunContext, deps: BatchRunDeps) batch_results, successful_indexes, failures, failure_set = collect_and_reconcile_results( collect_batch_results_fn=deps.collect_batch_results_fn, - selected_indexes=selected_indexes, + request=CollectBatchResultsRequest( + selected_indexes=selected_indexes, + failures=execution_failures, + output_files=prepared.output_files, + allowed_dims={ + str(dim) + for dim in prepared.packet.get("dimensions", []) + if isinstance(dim, str) + }, + ), execution_failures=execution_failures, - output_files=prepared.output_files, - packet=prepared.packet, batch_positions=prepared.batch_positions, batch_status=prepared.batch_status, colorize_fn=deps.colorize_fn, @@ -608,7 +603,9 @@ def merge_and_import_batch_run( __all__ = [ "ExecutedBatchRunContext", + "PreparedPacketScope", "PreparedBatchRunContext", + "PreparedRunArtifacts", "_prepare_packet_scope", "_prepare_run_runtime", "_print_runtime_expectation", diff --git a/desloppify/app/commands/review/batch/execution_results.py b/desloppify/app/commands/review/batch/execution_results.py index b35b3823..8f8bd869 100644 --- a/desloppify/app/commands/review/batch/execution_results.py +++ b/desloppify/app/commands/review/batch/execution_results.py @@ -8,6 +8,7 @@ from desloppify.base.exception_sets import CommandError from ..importing.flags import ReviewImportConfig +from .execution import CollectBatchResultsRequest from .scope import ( collect_reviewed_files_from_batches, @@ -21,24 +22,16 @@ def collect_and_reconcile_results( *, collect_batch_results_fn, - selected_indexes: list[int], + request: CollectBatchResultsRequest, execution_failures: list[int], - output_files: dict, - packet: dict, batch_positions: dict[int, int], batch_status: dict[str, dict[str, object]], colorize_fn=None, ) -> tuple[list[dict], list[int], list[int], set[int]]: """Collect batch results and reconcile per-batch status entries.""" - allowed_dims = { - str(dim) for dim in packet.get("dimensions", []) if isinstance(dim, str) - } - batch_results, failures = collect_batch_results_fn( - selected_indexes=selected_indexes, - failures=execution_failures, - output_files=output_files, - allowed_dims=allowed_dims, - ) + selected_indexes = request.selected_indexes + output_files = request.output_files + batch_results, failures = collect_batch_results_fn(request) execution_failure_set = set(execution_failures) failure_set = set(failures) diff --git a/desloppify/app/commands/review/batch/orchestrator.py b/desloppify/app/commands/review/batch/orchestrator.py index ec56b3bc..0d6ea09c 100644 --- a/desloppify/app/commands/review/batch/orchestrator.py +++ b/desloppify/app/commands/review/batch/orchestrator.py @@ -149,8 +149,13 @@ def _build_batch_run_deps(*, policy, project_root: Path) -> review_batches_mod.B parse_fn=parse_batch_selection, colorize_fn=colorize, ), - prepare_run_artifacts_fn=partial( - prepare_run_artifacts, + prepare_run_artifacts_fn=lambda request: prepare_run_artifacts( + stamp=request.stamp, + selected_indexes=request.selected_indexes, + batches=request.batches, + packet_path=request.packet_path, + run_root=request.run_root, + repo_root=request.repo_root, build_prompt_fn=partial(render_batch_prompt, policy_block=policy_block), safe_write_text_fn=safe_write_text, colorize_fn=colorize, @@ -169,11 +174,8 @@ def _build_batch_run_deps(*, policy, project_root: Path) -> review_batches_mod.B progress_fn=kwargs.get("progress_fn"), error_log_fn=kwargs.get("error_log_fn"), ), - collect_batch_results_fn=lambda **kwargs: collect_batch_results( - selected_indexes=kwargs["selected_indexes"], - failures=kwargs["failures"], - output_files=kwargs["output_files"], - allowed_dims=kwargs["allowed_dims"], + collect_batch_results_fn=lambda request: collect_batch_results( + request=request, extract_payload_fn=lambda raw: extract_json_payload(raw, log_fn=log), normalize_result_fn=lambda payload, dims: normalize_batch_result( payload, @@ -272,14 +274,14 @@ def _merge_batch_results(batch_results: list[object]) -> dict[str, object]: def _load_or_prepare_packet( - args, - *, - state: dict, - lang, - config: dict, - stamp: str, + request: review_batches_mod.LoadOrPreparePacketRequest, ) -> tuple[dict, Path, Path]: """Load packet override or prepare a fresh packet snapshot.""" + args = request.args + state = request.state + lang = request.lang + config = request.config + stamp = request.stamp packet_override = getattr(args, "packet", None) if packet_override: packet_path = Path(packet_override) @@ -498,10 +500,12 @@ def do_import_run( raise CommandError(hint, exit_code=1) batch_results, failures = collect_batch_results( - selected_indexes=selected_indexes, - failures=[], - output_files=output_files, - allowed_dims=allowed_dims, + request=review_batches_mod.CollectBatchResultsRequest( + selected_indexes=selected_indexes, + failures=[], + output_files=output_files, + allowed_dims=allowed_dims, + ), extract_payload_fn=lambda raw: extract_json_payload(raw, log_fn=log), normalize_result_fn=lambda payload, dims: normalize_batch_result( payload, diff --git a/desloppify/app/commands/review/importing/cmd.py b/desloppify/app/commands/review/importing/cmd.py index 8f0da2a7..903a7e4c 100644 --- a/desloppify/app/commands/review/importing/cmd.py +++ b/desloppify/app/commands/review/importing/cmd.py @@ -6,7 +6,6 @@ from pathlib import Path from types import SimpleNamespace -from desloppify import state as state_mod from desloppify.app.commands.scan.reporting import ( dimensions as reporting_dimensions_mod, ) @@ -23,11 +22,14 @@ import_scores_meta_matches, pending_import_scores_meta, ) +from desloppify.engine._state.persistence import load_state, save_state +from desloppify.engine._state.schema import utc_now from desloppify.intelligence import integrity as subjective_integrity_mod from desloppify.intelligence.review.importing.holistic import import_holistic_issues from desloppify.intelligence.review.importing.contracts_models import ( AssessmentImportPolicyModel, ) +from desloppify.state_score_snapshot import score_snapshot from ..assessment_integrity import ( bind_scorecard_subjective_at_target, @@ -45,10 +47,11 @@ print_assessment_policy_notice, print_import_load_errors, ) +from ..state_payloads import append_assessment_import_audit from .policy import assessment_policy_model_from_payload -from .helpers import load_import_issues_data from .parse import ( ImportPayloadLoadError, + load_import_issues_data, resolve_override_context, ) from .plan_sync import PlanImportSyncRequest, sync_plan_after_import @@ -96,7 +99,7 @@ def _build_working_state(state: dict, state_file) -> dict: """Return state snapshot used for import mutation/dry-run rendering.""" state_path = Path(state_file) if state_file is not None else None if state_path is not None and state_path.exists(): - return copy.deepcopy(state_mod.load_state(state_path)) + return copy.deepcopy(load_state(state_path)) return copy.deepcopy(state) @@ -145,14 +148,17 @@ def _append_assessment_import_audit( provisional_count: int, override_attest: str | None, import_file, + import_payload: dict, ) -> None: """Record audit metadata for assessment-bearing import payloads.""" if not assessment_policy.assessments_present: return - audit = working_state.setdefault("assessment_import_audit", []) - audit.append( + provenance = import_payload.get("provenance") + provenance_dict = provenance if isinstance(provenance, dict) else {} + append_assessment_import_audit( + working_state, { - "timestamp": state_mod.utc_now(), + "timestamp": utc_now(), "mode": assessment_policy.mode, "trusted": bool(assessment_policy.trusted), "reason": assessment_policy.reason, @@ -162,6 +168,7 @@ def _append_assessment_import_audit( "provisional_count": int(provisional_count), "attest": (override_attest or "").strip(), "import_file": str(import_file), + "packet_sha256": str(provenance_dict.get("packet_sha256", "")).strip(), } ) @@ -180,7 +187,7 @@ def _persist_import_state( """Persist imported state and synchronize the work plan.""" state.clear() state.update(working_state) - state_mod.save_state(state, state_file) + save_state(state, state_file) sync_plan_after_import( state, diff, @@ -316,7 +323,7 @@ def do_import( assessment_policy=assessment_policy, ) - prev = state_mod.score_snapshot(state) + prev = score_snapshot(state) working_state = _build_working_state(state, state_file) diff = import_holistic_issues(issues_data, working_state, lang.name) @@ -333,6 +340,7 @@ def do_import( provisional_count=provisional_count, override_attest=override_attest, import_file=import_file, + import_payload=issues_data, ) if not dry_run: @@ -382,7 +390,7 @@ def do_validate_import( try: issues_data = load_import_issues_data( import_file, - config=build_import_load_config( + options=build_import_load_config( lang_name=lang.name, import_config=resolved_import_config, override_enabled=override_enabled, diff --git a/desloppify/app/commands/review/importing/flags.py b/desloppify/app/commands/review/importing/flags.py index 81dda1e2..b7ea44e0 100644 --- a/desloppify/app/commands/review/importing/flags.py +++ b/desloppify/app/commands/review/importing/flags.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from desloppify import state as state_mod +from desloppify.engine._state.schema import utc_now from desloppify.intelligence.review.dimensions import normalize_dimension_name from desloppify.intelligence.review.importing.contracts_types import ( NormalizedReviewImportPayload, @@ -97,7 +97,7 @@ def mark_manual_override_assessments_provisional( if not isinstance(store, dict): return 0 - now = state_mod.utc_now() + now = utc_now() expires_scan = int(state.get("scan_count", 0) or 0) + 1 marked = 0 for key in sorted(assessment_keys): diff --git a/desloppify/app/commands/review/importing/output.py b/desloppify/app/commands/review/importing/output.py index 8ab53ac3..cd82146f 100644 --- a/desloppify/app/commands/review/importing/output.py +++ b/desloppify/app/commands/review/importing/output.py @@ -297,18 +297,18 @@ def print_assessments_summary(state: StateModel, *, colorize_fn) -> None: def print_open_review_summary(state: StateModel, *, colorize_fn) -> str: - """Print current open review issue count and return next command.""" + """Print current open review work item count and return next command.""" + work_items = state.get("work_items") or state.get("issues", {}) open_review = [ issue - for issue in state["issues"].values() + for issue in work_items.values() if issue["status"] == "open" and issue.get("detector") == "review" ] if not open_review: return "desloppify scan" print( colorize_fn( - f"\n {len(open_review)} review issue{'s' if len(open_review) != 1 else ''} open total " - f"({len(open_review)} review issue{'s' if len(open_review) != 1 else ''} open total)", + f"\n {len(open_review)} review work item{'s' if len(open_review) != 1 else ''} open total", "bold", ) ) @@ -320,7 +320,6 @@ def print_review_import_scores_and_integrity( state: StateModel, config: dict[str, Any], *, - state_mod, target_strict_score_from_config_fn, subjective_at_target_fn, subjective_rerun_command_fn, diff --git a/desloppify/app/commands/review/importing/plan_sync.py b/desloppify/app/commands/review/importing/plan_sync.py index 79fffa98..d54ef012 100644 --- a/desloppify/app/commands/review/importing/plan_sync.py +++ b/desloppify/app/commands/review/importing/plan_sync.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path from desloppify.app.commands.helpers.issue_id_display import short_issue_id @@ -10,8 +10,6 @@ from desloppify.base.config import target_strict_score_from_config from desloppify.base.exception_sets import PLAN_LOAD_EXCEPTIONS from desloppify.base.output.terminal import colorize -from desloppify.engine._plan.auto_cluster import auto_cluster_issues -from desloppify.engine._plan.constants import QueueSyncResult from desloppify.engine._plan.operations.meta import append_log_entry from desloppify.engine._plan.persistence import ( has_living_plan, @@ -19,22 +17,18 @@ plan_path_for_state, save_plan, ) -from desloppify.engine._plan.policy.subjective import ( - SubjectiveVisibility, - compute_subjective_visibility, -) -from desloppify.engine._plan.reconcile_review_import import ( +from desloppify.engine._plan.sync.review_import import ( ReviewImportSyncResult, sync_plan_after_review_import, ) -from desloppify.engine._plan.refresh_lifecycle import sync_lifecycle_phase -from desloppify.engine._plan.sync.dimensions import sync_subjective_dimensions -from desloppify.engine._plan.sync.workflow_gates import ( - ScoreSnapshot, - sync_communicate_score_needed, - sync_create_plan_needed, - sync_import_scores_needed, +from desloppify.engine._plan.sync import ( + ReconcileResult, + live_planned_queue_empty, + reconcile_plan, ) +from desloppify.engine._plan.sync.workflow_gates import sync_import_scores_needed +from desloppify.engine._plan.sync.workflow import clear_score_communicated_sentinel +from desloppify.engine._plan.refresh_lifecycle import mark_subjective_review_completed from desloppify.engine.plan_triage import ( TRIAGE_CMD_RUN_STAGES_CLAUDE, TRIAGE_CMD_RUN_STAGES_CODEX, @@ -42,7 +36,6 @@ from desloppify.intelligence.review.importing.contracts_types import ( NormalizedReviewImportPayload, ) -from desloppify.state_scoring import score_snapshot @dataclass(frozen=True) @@ -62,58 +55,20 @@ class _ImportSyncInputs: covered_ids: tuple[str, ...] -@dataclass -class _PlanImportMutations: - dirty: bool = False - workflow_injected_ids: list[str] = field(default_factory=list) - import_result: ReviewImportSyncResult | None = None - stale_sync_result: QueueSyncResult | None = None - auto_cluster_changes: int = 0 - - -def _has_postflight_review_work(state: dict, *, policy) -> bool: - issues = state.get("issues", {}) - if any( - isinstance(issue, dict) - and issue.get("status") == "open" - and issue.get("detector") in {"review", "concerns", "subjective_review"} - for issue in issues.values() - ): - return True - return bool(policy.stale_ids or policy.under_target_ids) +@dataclass(frozen=True) +class PlanImportSyncOutcome: + """Visible result of post-import plan synchronization.""" + status: str + message: str | None = None -def _has_deferred_disposition_work(plan: dict) -> bool: - skipped = plan.get("skipped", {}) - if not isinstance(skipped, dict): - return False - return any( - isinstance(entry, dict) and str(entry.get("kind", "temporary")) == "temporary" - for entry in skipped.values() - ) - -def _sync_lifecycle_phase_after_import(plan: dict, state: dict, *, policy) -> bool: - return sync_lifecycle_phase( - plan, - has_initial_reviews=bool(policy.unscored_ids), - has_objective_backlog=bool(policy.has_objective_backlog), - has_postflight_review=_has_postflight_review_work(state, policy=policy), - has_postflight_workflow=any( - item_id in plan.get("queue_order", []) - for item_id in ( - "workflow::import-scores", - "workflow::communicate-score", - "workflow::score-checkpoint", - "workflow::create-plan", - ) - ), - has_triage=any( - isinstance(item_id, str) and item_id.startswith("triage::") - for item_id in plan.get("queue_order", []) - ), - has_deferred=_has_deferred_disposition_work(plan), - )[1] +@dataclass(frozen=True) +class _ImportPlanTransition: + import_result: ReviewImportSyncResult | None + covered_pruned: list[str] + import_scores_result: object + reconcile_result: ReconcileResult def _print_review_import_sync( @@ -121,24 +76,34 @@ def _print_review_import_sync( result: ReviewImportSyncResult, *, workflow_injected: bool, + triage_injected: bool, + outcome: PlanImportSyncOutcome, ) -> None: """Print summary of plan changes after review import sync.""" new_ids = result.new_ids stale_pruned = result.stale_pruned_from_queue + covered_pruned = getattr(result, "covered_subjective_pruned_from_queue", []) print() _print_new_review_items(state, new_ids) _print_stale_review_prunes(stale_pruned) - _print_review_import_footer(result, workflow_injected=workflow_injected) + _print_covered_subjective_prunes(covered_pruned) + _print_review_import_footer( + workflow_injected=workflow_injected, + triage_injected=triage_injected, + outcome=outcome, + ) def _print_new_review_items(state: dict, new_ids: list[str]) -> None: if not new_ids: return - print(colorize( - f" Plan updated: {len(new_ids)} new review issue(s) added to queue.", - "bold", - )) - issues = state.get("issues", {}) + print( + colorize( + f" Plan updated: {len(new_ids)} new review work item(s) added to queue.", + "bold", + ) + ) + issues = (state.get("work_items") or state.get("issues", {})) for finding_id in sorted(new_ids)[:10]: finding = issues.get(finding_id, {}) print(f" * [{short_issue_id(finding_id)}] {finding.get('summary', '')}") @@ -149,22 +114,38 @@ def _print_new_review_items(state: dict, new_ids: list[str]) -> None: def _print_stale_review_prunes(stale_pruned: list[str]) -> None: if not stale_pruned: return - print(colorize( - f" Plan updated: {len(stale_pruned)} stale review issue(s) removed from queue.", - "bold", - )) + print( + colorize( + f" Plan updated: {len(stale_pruned)} stale review work item(s) removed from queue.", + "bold", + ) + ) + + +def _print_covered_subjective_prunes(covered_pruned: list[str]) -> None: + if not covered_pruned: + return + print( + colorize( + f" Plan updated: {len(covered_pruned)} covered subjective queue item(s) removed.", + "bold", + ) + ) def _print_review_import_footer( - result: ReviewImportSyncResult, *, workflow_injected: bool, + triage_injected: bool, + outcome: PlanImportSyncOutcome, ) -> None: print() - print(colorize( - " Review queue sync completed. Workflow follow-up may be front-loaded.", - "dim", - )) + status_line = " Review queue sync completed. Workflow follow-up may be front-loaded." + status_tone = "dim" + if outcome.status == "degraded" and outcome.message: + status_line = f" Review queue sync degraded: {outcome.message}" + status_tone = "yellow" + print(colorize(status_line, status_tone)) print() print(colorize(" View execution queue: desloppify plan queue", "dim")) print(colorize(" View newest first: desloppify plan queue --sort recent", "dim")) @@ -172,14 +153,16 @@ def _print_review_import_footer( print() print(colorize(" NEXT STEP:", "yellow")) print(colorize(" Run: desloppify next", "yellow")) - if result.triage_injected and not workflow_injected: + if triage_injected and not workflow_injected: print(colorize(f" Codex: {TRIAGE_CMD_RUN_STAGES_CODEX}", "dim")) print(colorize(f" Claude: {TRIAGE_CMD_RUN_STAGES_CLAUDE}", "dim")) print(colorize(" Manual dashboard: desloppify plan triage", "dim")) - print(colorize( - " (Follow the queue in order; score communication and planning come before triage.)", - "dim", - )) + print( + colorize( + " (Follow the queue in order; score communication and planning come before triage.)", + "dim", + ) + ) def _review_delta_present(diff: dict) -> bool: @@ -193,10 +176,12 @@ def _print_workflow_injected_message(workflow_injected_ids: list[str]) -> None: if not workflow_injected_ids: return injected_parts = [f"`{workflow_id}`" for workflow_id in workflow_injected_ids] - print(colorize( - f" Plan: {' and '.join(injected_parts)} queued. Run `desloppify next`.", - "cyan", - )) + print( + colorize( + f" Plan: {' and '.join(injected_parts)} queued. Run `desloppify next`.", + "cyan", + ) + ) def _build_import_sync_inputs( @@ -218,121 +203,143 @@ def _build_import_sync_inputs( ) -def _record_workflow_change( - mutations: _PlanImportMutations, - result, - *, - workflow_id: str, - injected: bool = True, -) -> None: - if not result.changes: - return - mutations.dirty = True - if injected: - mutations.workflow_injected_ids.append(workflow_id) - - def _sync_review_delta( - mutations: _PlanImportMutations, plan: dict, state: dict, - *, - policy: SubjectiveVisibility, sync_inputs: _ImportSyncInputs, -) -> None: +) -> ReviewImportSyncResult | None: if not sync_inputs.has_review_issue_delta: - return - mutations.import_result = sync_plan_after_review_import( + return None + return sync_plan_after_review_import( plan, state, - policy=policy, + inject_triage=False, ) - if mutations.import_result is not None: - mutations.dirty = True -def _sync_subjective_queue_after_import( - mutations: _PlanImportMutations, +def _apply_import_plan_transitions( plan: dict, state: dict, *, - policy: SubjectiveVisibility, - target_strict: float, sync_inputs: _ImportSyncInputs, -) -> None: - if not (sync_inputs.has_review_issue_delta or sync_inputs.assessment_keys): - return - cycle_just_completed = not plan.get("plan_start_scores") - mutations.stale_sync_result = sync_subjective_dimensions( + assessment_mode: str, + trusted: bool, + import_file: str | None, + import_payload: NormalizedReviewImportPayload | None, + target_strict: float, + was_boundary_ready: bool, +) -> _ImportPlanTransition: + """Apply plan mutations driven by a review import before persistence/output.""" + import_result = _sync_review_delta(plan, state, sync_inputs) + covered_pruned = ( + _prune_covered_subjective_ids_from_plan(plan, covered_ids=sync_inputs.covered_ids) + if trusted + else [] + ) + import_scores_result = sync_import_scores_needed( plan, state, - policy=policy, - cycle_just_completed=cycle_just_completed, + assessment_mode=assessment_mode, + import_file=import_file, + import_payload=import_payload, ) - if mutations.stale_sync_result.changes: - mutations.dirty = True + if trusted: + clear_score_communicated_sentinel(plan) + if sync_inputs.covered_ids: + mark_subjective_review_completed( + plan, + scan_count=int(state.get("scan_count", 0) or 0), + ) - mutations.auto_cluster_changes = int(auto_cluster_issues( - plan, - state, - target_strict=target_strict, - policy=policy, - )) - if mutations.auto_cluster_changes: - mutations.dirty = True + reconcile_result = ReconcileResult() + if was_boundary_ready and ( + sync_inputs.has_review_issue_delta + or import_scores_result.changes + or (trusted and bool(sync_inputs.covered_ids)) + ): + reconcile_result = reconcile_plan(plan, state, target_strict=target_strict) + + if import_result is not None and covered_pruned: + import_result = ReviewImportSyncResult( + new_ids=import_result.new_ids, + added_to_queue=import_result.added_to_queue, + triage_injected=import_result.triage_injected, + stale_pruned_from_queue=import_result.stale_pruned_from_queue, + covered_subjective_pruned_from_queue=covered_pruned, + triage_injected_ids=import_result.triage_injected_ids, + triage_deferred=import_result.triage_deferred, + ) + + return _ImportPlanTransition( + import_result=import_result, + covered_pruned=covered_pruned, + import_scores_result=import_scores_result, + reconcile_result=reconcile_result, + ) -def _append_workflow_log_entries( +def _prune_covered_subjective_ids_from_plan( plan: dict, *, - communicate_result, - import_scores_result, - create_plan_result, -) -> None: - if communicate_result.changes: - append_log_entry( - plan, - "sync_communicate_score", - actor="system", - detail={"trigger": "review_import", "injected": True}, - ) - if import_scores_result.changes: - injected = bool(getattr(import_scores_result, "injected", ())) - pruned = list(getattr(import_scores_result, "pruned", ())) - append_log_entry( - plan, - "sync_import_scores", - actor="system", - detail={ - "trigger": "review_import", - "injected": injected, - "pruned": pruned, - }, - ) - if create_plan_result.changes: - append_log_entry( - plan, - "sync_create_plan", - actor="system", - detail={"trigger": "review_import", "injected": True}, - ) + covered_ids: tuple[str, ...], +) -> list[str]: + """Prune subjective queue placeholders that were just covered by review import.""" + covered = { + issue_id + for issue_id in covered_ids + if isinstance(issue_id, str) and issue_id.startswith("subjective::") + } + if not covered: + return [] + + order = plan.get("queue_order") + if not isinstance(order, list): + return [] + + pruned = [issue_id for issue_id in order if issue_id in covered] + if not pruned: + return [] + + pruned_set = set(pruned) + order[:] = [issue_id for issue_id in order if issue_id not in pruned_set] + + overrides = plan.get("overrides") + if isinstance(overrides, dict): + for issue_id in pruned_set: + overrides.pop(issue_id, None) + + for cluster in plan.get("clusters", {}).values(): + if not isinstance(cluster, dict): + continue + issue_ids = cluster.get("issue_ids") + if not isinstance(issue_ids, list): + continue + cluster["issue_ids"] = [ + issue_id for issue_id in issue_ids if issue_id not in pruned_set + ] + + return pruned def _append_review_import_sync_log( plan: dict, diff: dict, - mutations: _PlanImportMutations, + import_result: ReviewImportSyncResult | None, + import_scores_result, + pipeline_result: ReconcileResult, *, covered_ids: tuple[str, ...], + outcome: PlanImportSyncOutcome, ) -> None: if not ( - mutations.import_result is not None - or mutations.workflow_injected_ids + import_result is not None + or import_scores_result.changes + or pipeline_result.dirty or covered_ids ): return - import_result = mutations.import_result - stale_sync_result = mutations.stale_sync_result + subjective = pipeline_result.subjective + triage = pipeline_result.triage append_log_entry( plan, "review_import_sync", @@ -340,35 +347,30 @@ def _append_review_import_sync_log( detail={ "trigger": "review_import", "new_ids": sorted(import_result.new_ids) if import_result is not None else [], - "added_to_queue": ( - import_result.added_to_queue if import_result is not None else [] - ), - "workflow_injected_ids": mutations.workflow_injected_ids, - "triage_injected": ( - import_result.triage_injected if import_result is not None else False - ), - "triage_injected_ids": ( - import_result.triage_injected_ids if import_result is not None else [] - ), - "triage_deferred": ( - import_result.triage_deferred if import_result is not None else False - ), + "added_to_queue": import_result.added_to_queue if import_result is not None else [], + "workflow_injected_ids": pipeline_result.workflow_injected_ids, + "triage_injected": bool(triage and triage.injected), + "triage_injected_ids": list(triage.injected) if triage is not None else [], + "triage_deferred": bool(triage and triage.deferred), "diff_new": diff.get("new", 0), "diff_reopened": diff.get("reopened", 0), "diff_auto_resolved": diff.get("auto_resolved", 0), "stale_pruned_from_queue": ( import_result.stale_pruned_from_queue if import_result is not None else [] ), - "covered_subjective": list(covered_ids), - "stale_sync_injected": ( - sorted(stale_sync_result.injected) - if stale_sync_result is not None else [] - ), - "stale_sync_pruned": ( - sorted(stale_sync_result.pruned) - if stale_sync_result is not None else [] + "covered_subjective_pruned_from_queue": ( + getattr(import_result, "covered_subjective_pruned_from_queue", []) + if import_result is not None + else [] ), - "auto_cluster_changes": mutations.auto_cluster_changes, + "covered_subjective": list(covered_ids), + "stale_sync_injected": sorted(subjective.injected) if subjective is not None else [], + "stale_sync_pruned": sorted(subjective.pruned) if subjective is not None else [], + "auto_cluster_changes": pipeline_result.auto_cluster_changes, + "import_scores_injected": list(getattr(import_scores_result, "injected", []) or []), + "import_scores_pruned": list(getattr(import_scores_result, "pruned", []) or []), + "sync_status": outcome.status, + "sync_message": outcome.message, }, ) @@ -379,7 +381,7 @@ def sync_plan_after_import( assessment_mode: str, *, request: PlanImportSyncRequest | None = None, -) -> None: +) -> PlanImportSyncOutcome: """Apply issue/workflow syncs after import in one load/save cycle.""" try: state_file = request.state_file if request is not None else None @@ -392,107 +394,66 @@ def sync_plan_after_import( if state_file is not None: plan_path = plan_path_for_state(Path(state_file)) if not has_living_plan(plan_path): - return + return PlanImportSyncOutcome(status="skipped") plan = load_plan(plan_path) - policy = compute_subjective_visibility( - state, - target_strict=target_strict, - plan=plan, - ) - snapshot = score_snapshot(state) - current_scores = ScoreSnapshot( - strict=snapshot.strict, - overall=snapshot.overall, - objective=snapshot.objective, - verified=snapshot.verified, - ) - trusted_score_import = assessment_mode in {"trusted_internal", "attested_external"} - communicate_result = sync_communicate_score_needed( - plan, - state, - policy=policy, - scores_just_imported=trusted_score_import, - current_scores=current_scores, - ) - import_scores_result = sync_import_scores_needed( + sync_inputs = _build_import_sync_inputs(diff, import_payload) + trusted = assessment_mode in {"trusted_internal", "attested_external"} + was_boundary_ready = live_planned_queue_empty(plan) + + transition = _apply_import_plan_transitions( plan, state, + sync_inputs=sync_inputs, assessment_mode=assessment_mode, + trusted=trusted, import_file=import_file, import_payload=import_payload, - ) - create_plan_result = sync_create_plan_needed( - plan, - state, - policy=policy, - ) - - sync_inputs = _build_import_sync_inputs(diff, import_payload) - mutations = _PlanImportMutations() - _record_workflow_change( - mutations, - communicate_result, - workflow_id="workflow::communicate-score", - ) - _record_workflow_change( - mutations, - import_scores_result, - workflow_id="workflow::import-scores", - injected=bool(getattr(import_scores_result, "injected", ())), - ) - _record_workflow_change( - mutations, - create_plan_result, - workflow_id="workflow::create-plan", - ) - _sync_review_delta( - mutations, - plan, - state, - policy=policy, - sync_inputs=sync_inputs, - ) - _sync_subjective_queue_after_import( - mutations, - plan, - state, - policy=policy, target_strict=target_strict, - sync_inputs=sync_inputs, + was_boundary_ready=was_boundary_ready, ) - - if mutations.dirty: - if _sync_lifecycle_phase_after_import(plan, state, policy=policy): - mutations.dirty = True - _append_workflow_log_entries( - plan, - communicate_result=communicate_result, - import_scores_result=import_scores_result, - create_plan_result=create_plan_result, - ) + import_result = transition.import_result + covered_pruned = transition.covered_pruned + import_scores_result = transition.import_scores_result + result = transition.reconcile_result + + dirty = bool( + import_result is not None + or covered_pruned + or import_scores_result.changes + or result.dirty + ) + outcome = PlanImportSyncOutcome(status="synced") + if dirty: _append_review_import_sync_log( plan, diff, - mutations, + import_result, + import_scores_result, + result, covered_ids=sync_inputs.covered_ids, + outcome=outcome, ) save_plan(plan, plan_path) - if mutations.import_result is not None: + if import_result is not None: _print_review_import_sync( state, - mutations.import_result, - workflow_injected=bool(mutations.workflow_injected_ids), + import_result, + workflow_injected=bool(result.workflow_injected_ids), + triage_injected=bool(result.triage and result.triage.injected), + outcome=outcome, ) - _print_workflow_injected_message(mutations.workflow_injected_ids) + _print_workflow_injected_message(result.workflow_injected_ids) + return outcome except PLAN_LOAD_EXCEPTIONS as exc: - print( - colorize( - f" Note: skipped plan sync after review import ({exc}).", - "dim", - ) - ) + message = f"skipped plan sync after review import ({exc})" + print(colorize(f" Plan sync degraded: {message}.", "yellow")) + return PlanImportSyncOutcome(status="degraded", message=message) -__all__ = ["PlanImportSyncRequest", "sync_plan_after_import"] +__all__ = [ + "PlanImportSyncOutcome", + "PlanImportSyncRequest", + "sync_plan_after_import", +] diff --git a/desloppify/app/commands/review/importing/results.py b/desloppify/app/commands/review/importing/results.py index 6c4d44f8..fd756357 100644 --- a/desloppify/app/commands/review/importing/results.py +++ b/desloppify/app/commands/review/importing/results.py @@ -3,7 +3,6 @@ from __future__ import annotations import desloppify.intelligence.narrative.core as narrative_core -from desloppify import state_compat as state_compat from desloppify.app.commands.helpers.query import write_query from desloppify.app.commands.helpers.queue_progress import show_score_with_plan_context from desloppify.app.commands.scan.reporting import dimensions as reporting_dimensions @@ -67,7 +66,6 @@ def report_review_import_outcome( at_target = print_review_import_scores_and_integrity( state, config or {}, - state_mod=state_compat, target_strict_score_from_config_fn=target_strict_score_from_config, subjective_at_target_fn=scorecard_subjective_at_target_fn, subjective_rerun_command_fn=reporting_dimensions.subjective_rerun_command, diff --git a/desloppify/app/commands/review/merge.py b/desloppify/app/commands/review/merge.py index 21f30a4c..f293d3db 100644 --- a/desloppify/app/commands/review/merge.py +++ b/desloppify/app/commands/review/merge.py @@ -5,12 +5,19 @@ import argparse from typing import Any, NamedTuple, TypedDict -from desloppify import state as state_mod from desloppify.app.commands.helpers.query import write_query -from desloppify.app.commands.helpers.queue_progress import show_score_with_plan_context from desloppify.app.commands.helpers.command_runtime import command_runtime +from desloppify.app.commands.helpers.queue_progress import show_score_with_plan_context from desloppify.base.output.issues import issue_weight from desloppify.base.output.terminal import colorize +from desloppify.engine._state.persistence import save_state +from desloppify.engine._state.schema import StateModel, utc_now +from desloppify.engine._state.schema_scores import ( + get_objective_score, + get_overall_score, + get_strict_score, + get_verified_strict_score, +) from desloppify.engine.work_queue import list_open_review_issues from desloppify.intelligence.narrative.core import NarrativeContext, compute_narrative from desloppify.intelligence.review.issue_merge import ( @@ -20,9 +27,6 @@ track_merged_from, ) -save_state = state_mod.save_state -utc_now = state_mod.utc_now - class ResolutionAttestationPayload(TypedDict, total=False): """Resolution metadata attached to auto-resolved duplicate issues.""" @@ -65,12 +69,12 @@ class _ScoreSnapshot(NamedTuple): verified: float | None -def _score_snapshot(state: state_mod.StateModel) -> _ScoreSnapshot: +def _score_snapshot(state: StateModel) -> _ScoreSnapshot: return _ScoreSnapshot( - overall=state_mod.get_overall_score(state), - objective=state_mod.get_objective_score(state), - strict=state_mod.get_strict_score(state), - verified=state_mod.get_verified_strict_score(state), + overall=get_overall_score(state), + objective=get_objective_score(state), + strict=get_strict_score(state), + verified=get_verified_strict_score(state), ) diff --git a/desloppify/app/commands/review/runner_parallel/__init__.py b/desloppify/app/commands/review/runner_parallel/__init__.py index 5ee578e3..1a087de8 100644 --- a/desloppify/app/commands/review/runner_parallel/__init__.py +++ b/desloppify/app/commands/review/runner_parallel/__init__.py @@ -23,6 +23,7 @@ BatchResult, BatchTask, ) +from ..batch.execution import CollectBatchResultsRequest from ..runner_process_impl.io import extract_payload_from_log logger = logging.getLogger(__name__) @@ -96,16 +97,16 @@ def execute_batches( def collect_batch_results( *, - selected_indexes: list[int], - failures: list[int], - output_files: dict[int, Path], - allowed_dims: set[str], + request: CollectBatchResultsRequest, extract_payload_fn, normalize_result_fn, ) -> tuple[list[BatchResult], list[int]]: """Parse and normalize batch outputs, preserving prior failures.""" + selected_indexes = request.selected_indexes + output_files = request.output_files + allowed_dims = request.allowed_dims batch_results: list[BatchResult] = [] - failure_set = set(failures) + failure_set = set(request.failures) for idx in selected_indexes: had_execution_failure = idx in failure_set raw_path = output_files[idx] diff --git a/desloppify/app/commands/review/state_payloads.py b/desloppify/app/commands/review/state_payloads.py index 623a4b3c..7cbd69c1 100644 --- a/desloppify/app/commands/review/state_payloads.py +++ b/desloppify/app/commands/review/state_payloads.py @@ -4,6 +4,10 @@ from typing import TypedDict, cast +from desloppify.engine._state.schema_types_review import ( + AssessmentImportAuditEntry, +) + class SubjectiveAssessmentPayload(TypedDict, total=False): score: float @@ -19,19 +23,6 @@ class SubjectiveAssessmentPayload(TypedDict, total=False): component_scores: dict[str, float] -class AssessmentImportAuditEntry(TypedDict): - timestamp: str - mode: str - trusted: bool - reason: str - override_used: bool - attested_external: bool - provisional: bool - provisional_count: int - attest: str - import_file: str - - def subjective_assessment_store( state: dict, ) -> dict[str, SubjectiveAssessmentPayload]: diff --git a/desloppify/app/commands/scan/artifacts.py b/desloppify/app/commands/scan/artifacts.py index 12007599..002dddc5 100644 --- a/desloppify/app/commands/scan/artifacts.py +++ b/desloppify/app/commands/scan/artifacts.py @@ -2,11 +2,11 @@ from __future__ import annotations -import importlib import logging import os from pathlib import Path +from desloppify.app.commands.helpers.dynamic_loaders import load_optional_scorecard_module from desloppify.app.commands.scan.contracts import ScanQueryPayload from desloppify.app.commands.scan.workflow import ( ScanMergeResult, @@ -38,7 +38,7 @@ def build_scan_query_payload( ) -> ScanQueryPayload: """Build the canonical query payload persisted after a scan.""" scores = score_snapshot(state) - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) open_scope = ( open_scope_breakdown(issues, state.get("scan_path")) if isinstance(issues, dict) @@ -92,13 +92,12 @@ def build_scan_query_payload( def _load_scorecard_helpers(): - """Load scorecard helper callables lazily via importlib. + """Load scorecard helper callables lazily through the approved loader seam. Deferred: scorecard depends on PIL (optional dependency). """ - try: - scorecard_module = importlib.import_module("desloppify.app.output.scorecard") - except ImportError: + scorecard_module = load_optional_scorecard_module() + if scorecard_module is None: return None, None generate = getattr(scorecard_module, "generate_scorecard", None) badge_config = getattr(scorecard_module, "get_badge_config", None) diff --git a/desloppify/app/commands/scan/plan_reconcile.py b/desloppify/app/commands/scan/plan_reconcile.py index 653e140c..95fbd125 100644 --- a/desloppify/app/commands/scan/plan_reconcile.py +++ b/desloppify/app/commands/scan/plan_reconcile.py @@ -6,56 +6,43 @@ from typing import Any from desloppify import state as state_mod -from desloppify.base.config import DEFAULT_TARGET_STRICT_SCORE from desloppify.base.exception_sets import PLAN_LOAD_EXCEPTIONS from desloppify.base.output.fallbacks import log_best_effort_failure from desloppify.base.output.terminal import colorize -from desloppify.engine._plan.auto_cluster import auto_cluster_issues +from desloppify.base.config import target_strict_score_from_config from desloppify.engine._plan.constants import ( - SYNTHETIC_PREFIXES, WORKFLOW_COMMUNICATE_SCORE_ID, + is_synthetic_id, ) from desloppify.engine._plan.operations.meta import append_log_entry -from desloppify.engine._plan.persistence import ( - load_plan, - save_plan, -) -from desloppify.engine._plan.policy.subjective import compute_subjective_visibility -from desloppify.engine._plan.reconcile import reconcile_plan_after_scan +from desloppify.engine._plan.persistence import load_plan, save_plan +from desloppify.engine._plan.scan_issue_reconcile import reconcile_plan_after_scan from desloppify.engine._plan.refresh_lifecycle import ( mark_postflight_scan_completed, ) -from desloppify.engine._plan.sync.context import is_mid_cycle -from desloppify.engine._plan.sync.dimensions import sync_subjective_dimensions -from desloppify.engine._plan.sync.triage import sync_triage_needed -from desloppify.engine._plan.sync.workflow_gates import ( - ScoreSnapshot, - sync_communicate_score_needed, - sync_create_plan_needed, +from desloppify.engine._plan.sync import ( + ReconcileResult, + live_planned_queue_empty, + reconcile_plan, ) -from desloppify.engine._plan.refresh_lifecycle import set_lifecycle_phase -from desloppify.engine._work_queue.context import queue_context -from desloppify.engine._work_queue.snapshot import coarse_phase_name +from desloppify.engine._plan.sync.dimensions import current_unscored_ids +from desloppify.engine._plan.sync.context import is_mid_cycle +from desloppify.engine._plan.sync.workflow import clear_score_communicated_sentinel from desloppify.engine.work_queue import build_deferred_disposition_item logger = logging.getLogger(__name__) def _reset_cycle_for_force_rescan(plan: dict[str, object]) -> bool: - """Clear all cycle state when --force-rescan is used. - - Force-rescan means "start over" — the old cycle's triage stages, - workflow items, subjective dimensions, plan-start scores, and triage - metadata are all stale and must be removed. - """ + """Clear all cycle state when --force-rescan is used.""" order: list[str] = plan.get("queue_order", []) - synthetic = [item for item in order if any(item.startswith(p) for p in SYNTHETIC_PREFIXES)] + synthetic = [item for item in order if is_synthetic_id(item)] if not synthetic and not plan.get("plan_start_scores"): return False for item in synthetic: order.remove(item) plan["plan_start_scores"] = {} - plan.pop("previous_plan_start_scores", None) + clear_score_communicated_sentinel(plan) plan.pop("scan_count_at_plan_start", None) meta = plan.get("epic_triage_meta", {}) if isinstance(meta, dict): @@ -73,7 +60,6 @@ def _reset_cycle_for_force_rescan(plan: dict[str, object]) -> bool: def _plan_has_user_content(plan: dict[str, object]) -> bool: - """Return True when the living plan has any user-managed queue metadata.""" return bool( plan.get("queue_order") or plan.get("overrides") @@ -82,11 +68,10 @@ def _plan_has_user_content(plan: dict[str, object]) -> bool: ) -def _apply_plan_reconciliation(plan: dict[str, object], state: state_mod.StateModel, reconcile_fn) -> bool: - """Apply standard post-scan plan reconciliation when user content exists.""" +def _apply_plan_reconciliation(plan: dict[str, object], state: state_mod.StateModel) -> bool: if not _plan_has_user_content(plan): return False - recon = reconcile_fn(plan, state) + recon = reconcile_plan_after_scan(plan, state) if recon.resurfaced: print( colorize( @@ -97,54 +82,11 @@ def _apply_plan_reconciliation(plan: dict[str, object], state: state_mod.StateMo return bool(recon.changes) -def _sync_subjective_dimensions_display(plan: dict[str, object], state: state_mod.StateModel, sync_fn) -> bool: - """Sync all subjective dimensions (unscored + stale + under-target) in plan queue.""" - sync = sync_fn(plan, state) - if sync.resurfaced: - print( - colorize( - f" Plan: {len(sync.resurfaced)} skipped subjective dimension(s) resurfaced — never reviewed.", - "yellow", - ) - ) - if sync.pruned: - print( - colorize( - f" Plan: {len(sync.pruned)} refreshed subjective dimension(s) removed from queue.", - "cyan", - ) - ) - if sync.injected: - print( - colorize( - f" Plan: {len(sync.injected)} subjective dimension(s) queued for review.", - "cyan", - ) - ) - return bool(sync.changes) - - -def _sync_auto_clusters( - plan: dict[str, object], - state: state_mod.StateModel, - *, - target_strict: float = DEFAULT_TARGET_STRICT_SCORE, - policy=None, -) -> bool: - """Regenerate automatic task clusters after scan merge.""" - return bool(auto_cluster_issues( - plan, state, - target_strict=target_strict, - policy=policy, - )) - - def _seed_plan_start_scores(plan: dict[str, object], state: state_mod.StateModel) -> bool: """Set plan_start_scores when beginning a new queue cycle.""" existing = plan.get("plan_start_scores") if existing and not isinstance(existing, dict): return False - # Seed when empty OR when it's the reset sentinel ({"reset": True}) if existing and not existing.get("reset"): return False scores = state_mod.score_snapshot(state) @@ -156,9 +98,7 @@ def _seed_plan_start_scores(plan: dict[str, object], state: state_mod.StateModel "objective": scores.objective, "verified": scores.verified, } - # New cycle — clear the communicate-score sentinel so it can fire again. - plan.pop("previous_plan_start_scores", None) - # Record scan count at cycle start so gates can detect whether a new scan ran + clear_score_communicated_sentinel(plan) plan["scan_count_at_plan_start"] = int(state.get("scan_count", 0) or 0) return True @@ -169,9 +109,7 @@ def _has_objective_cycle( ) -> bool | None: """Return True when objective queue work exists and a cycle baseline should freeze.""" try: - from desloppify.app.commands.helpers.queue_progress import ( - plan_aware_queue_breakdown, - ) + from desloppify.app.commands.helpers.queue_progress import plan_aware_queue_breakdown breakdown = plan_aware_queue_breakdown(state, plan) except PLAN_LOAD_EXCEPTIONS as exc: @@ -186,8 +124,6 @@ def _clear_plan_start_scores_if_queue_empty( """Clear plan-start score snapshot once the queue is fully drained.""" if not plan.get("plan_start_scores"): return False - # Don't clear while communicate-score is pending — the rebaseline just - # set plan_start_scores and the user hasn't seen the update yet. if WORKFLOW_COMMUNICATE_SCORE_ID in plan.get("queue_order", []): return False @@ -208,9 +144,7 @@ def _clear_plan_start_scores_if_queue_empty( return False state["_plan_start_scores_for_reveal"] = dict(plan["plan_start_scores"]) plan["plan_start_scores"] = {} - # Clear the cycle sentinel so communicate-score can be injected - # in the next cycle. - plan.pop("previous_plan_start_scores", None) + clear_score_communicated_sentinel(plan) return True @@ -218,11 +152,10 @@ def _mark_postflight_scan_completed_if_ready( state: state_mod.StateModel, plan: dict[str, object], ) -> bool: - """Record that the scan stage completed for the current empty-queue boundary.""" - if build_deferred_disposition_item(plan) is not None: + """Record that the cycle's post-review scan has completed.""" + if plan.get("plan_start_scores") and current_unscored_ids(state): return False - objective_cycle = _has_objective_cycle(state, plan) - if objective_cycle is not False: + if build_deferred_disposition_item(plan) is not None: return False return mark_postflight_scan_completed( plan, @@ -230,139 +163,6 @@ def _mark_postflight_scan_completed_if_ready( ) -def _subjective_policy_context( - runtime: Any, - plan: dict[str, object], -) -> tuple[float, object, bool]: - from desloppify.base.config import target_strict_score_from_config - - target_strict = target_strict_score_from_config(runtime.config) - policy = compute_subjective_visibility( - runtime.state, - target_strict=target_strict, - plan=plan, - ) - cycle_just_completed = not plan.get("plan_start_scores") - return target_strict, policy, cycle_just_completed - - -def _sync_subjective_and_log( - plan: dict[str, object], - state: state_mod.StateModel, - *, - policy, - cycle_just_completed: bool, -) -> bool: - changed = _sync_subjective_dimensions_display( - plan, - state, - lambda p, s: sync_subjective_dimensions( - p, - s, - policy=policy, - cycle_just_completed=cycle_just_completed, - ), - ) - if changed: - append_log_entry(plan, "sync_subjective", actor="system", detail={"changes": True}) - return changed - - -def _sync_auto_clusters_and_log( - plan: dict[str, object], - state: state_mod.StateModel, - *, - target_strict: float, - policy, -) -> bool: - changed = _sync_auto_clusters( - plan, - state, - target_strict=target_strict, - policy=policy, - ) - if changed: - append_log_entry(plan, "auto_cluster", actor="system", detail={"changes": True}) - return changed - - -def _sync_triage_and_log( - plan: dict[str, object], - state: state_mod.StateModel, - *, - policy=None, -) -> bool: - triage_sync = sync_triage_needed(plan, state, policy=policy) - if triage_sync.deferred: - meta = plan.get("epic_triage_meta", {}) - if meta.get("triage_recommended"): - print( - colorize( - " Plan: review issues changed — triage recommended after current work.", - "dim", - ) - ) - return False - if not triage_sync.changes: - return False - if triage_sync.injected: - print( - colorize( - " Plan: planning mode needed — review issues changed since last triage.", - "cyan", - ) - ) - append_log_entry(plan, "sync_triage", actor="system", detail={"injected": True}) - return True - - -def _sync_communicate_score_and_log( - plan: dict[str, object], - state: state_mod.StateModel, - *, - policy, -) -> bool: - snapshot = state_mod.score_snapshot(state) - current_scores = ScoreSnapshot( - strict=snapshot.strict, - overall=snapshot.overall, - objective=snapshot.objective, - verified=snapshot.verified, - ) - communicate_sync = sync_communicate_score_needed( - plan, state, policy=policy, current_scores=current_scores, - ) - if not communicate_sync.changes: - return False - append_log_entry( - plan, - "sync_communicate_score", - actor="system", - detail={"injected": True}, - ) - return True - - -def _sync_create_plan_and_log( - plan: dict[str, object], - state: state_mod.StateModel, - *, - policy, -) -> bool: - create_plan_sync = sync_create_plan_needed(plan, state, policy=policy) - if not create_plan_sync.changes: - return False - if create_plan_sync.injected: - print( - colorize( - " Plan: reviews complete — `workflow::create-plan` queued.", - "cyan", - ) - ) - append_log_entry(plan, "sync_create_plan", actor="system", detail={"injected": True}) - return True - - def _sync_plan_start_scores_and_log( plan: dict[str, object], state: state_mod.StateModel, @@ -371,8 +171,6 @@ def _sync_plan_start_scores_and_log( if seeded: append_log_entry(plan, "seed_start_scores", actor="system", detail={}) return True - # Only clear scores that existed before this reconcile pass — - # never clear scores we just seeded in the same scan. cleared = _clear_plan_start_scores_if_queue_empty(state, plan) if cleared: append_log_entry(plan, "clear_start_scores", actor="system", detail={}) @@ -394,143 +192,82 @@ def _sync_postflight_scan_completion_and_log( return changed -def _has_postflight_review_work( - state: state_mod.StateModel, - *, - policy, -) -> bool: - issues = state.get("issues", {}) - has_review_like_issue = any( - isinstance(issue, dict) - and issue.get("status") == "open" - and issue.get("detector") in {"review", "concerns", "subjective_review"} - for issue in issues.values() - ) - if has_review_like_issue: - return True - return bool(policy.stale_ids or policy.under_target_ids) - - -def _has_postflight_workflow_items(plan: dict[str, object]) -> bool: - order = plan.get("queue_order", []) - return any( - item_id in order - for item_id in ( - "workflow::import-scores", - "workflow::communicate-score", - "workflow::score-checkpoint", - "workflow::create-plan", - ) - ) - - -def _has_triage_items(plan: dict[str, object]) -> bool: - return any( - isinstance(item_id, str) and item_id.startswith("triage::") - for item_id in plan.get("queue_order", []) - ) - - -def _sync_lifecycle_phase_and_log( - plan: dict[str, object], - state: state_mod.StateModel, -) -> bool: - phase = coarse_phase_name(queue_context(state, plan=plan).snapshot.phase) - changed = set_lifecycle_phase(plan, phase) - if changed: - append_log_entry( - plan, - "sync_lifecycle_phase", - actor="system", - detail={"phase": phase}, - ) - return changed - - def _sync_post_scan_without_policy( *, plan: dict[str, object], state: state_mod.StateModel, ) -> bool: """Run post-scan sync steps that do not require subjective policy context.""" - return bool(_apply_plan_reconciliation(plan, state, reconcile_plan_after_scan)) + return bool(_apply_plan_reconciliation(plan, state)) def _is_mid_cycle_scan(plan: dict[str, object], state: state_mod.StateModel) -> bool: - """Return True when a plan cycle is active and queue items remain. - - Extends ``is_mid_cycle`` (which checks ``plan_start_scores``) with an - additional queue-items guard — even if a cycle is nominally active, we - only skip destructive operations when work actually remains. - - Mid-cycle scans (via --force-rescan or PHASE_TRANSITION gate) must NOT - regenerate clusters or inject triage stages — doing so wipes triage - state and reorders the queue, undoing prioritisation work. - """ + """Return True when a plan cycle is active and queue items remain.""" if not is_mid_cycle(plan): return False - order = plan.get("queue_order", []) - skipped = plan.get("skipped", {}) - return any(item not in skipped for item in order) + return not live_planned_queue_empty(plan) -def _sync_post_scan_with_policy( +def _display_reconcile_results( + result: ReconcileResult, + plan: dict, *, - plan: dict[str, object], - state: state_mod.StateModel, - target_strict: float, - policy, - cycle_just_completed: bool, - force_rescan: bool = False, -) -> bool: - """Run post-scan sync steps that require policy/cycle context. - - When running mid-cycle (plan_start_scores set, queue non-empty) or - via --force-rescan, skip auto-clustering and triage injection. These - steps regenerate queue structure and issue IDs, which wipes triage - state and reorders the queue. They only run at cycle boundaries - (pre-flight / post-flight). - """ - dirty = False - mid_cycle = _is_mid_cycle_scan(plan, state) or force_rescan - - if _sync_subjective_and_log( - plan, - state, - policy=policy, - cycle_just_completed=cycle_just_completed, + mid_cycle: bool, +) -> None: + subjective = result.subjective + if subjective and subjective.resurfaced: + print( + colorize( + f" Plan: {len(subjective.resurfaced)} skipped subjective dimension(s) resurfaced — never reviewed.", + "yellow", + ) + ) + if subjective and subjective.pruned: + print( + colorize( + f" Plan: {len(subjective.pruned)} refreshed subjective dimension(s) removed from queue.", + "cyan", + ) + ) + if subjective and subjective.injected: + print( + colorize( + f" Plan: {len(subjective.injected)} subjective dimension(s) queued for review.", + "cyan", + ) + ) + if mid_cycle and not result.auto_cluster_changes: + print( + colorize( + " Plan: mid-cycle scan — skipping cluster regeneration to preserve queue state.", + "dim", + ) + ) + if result.create_plan and result.create_plan.injected: + print( + colorize( + " Plan: reviews complete — `workflow::create-plan` queued.", + "cyan", + ) + ) + if ( + result.triage + and result.triage.deferred + and plan.get("epic_triage_meta", {}).get("triage_recommended") ): - dirty = True - if not mid_cycle: - if _sync_auto_clusters_and_log( - plan, - state, - target_strict=target_strict, - policy=policy, - ): - dirty = True - else: print( colorize( - " Plan: mid-cycle scan — skipping cluster regeneration to " - "preserve queue state.", + " Plan: review work items changed — triage recommended after current work.", "dim", ) ) - if _sync_communicate_score_and_log(plan, state, policy=policy): - dirty = True - if _sync_create_plan_and_log(plan, state, policy=policy): - dirty = True - if _sync_triage_and_log(plan, state, policy=policy): - dirty = True - if not force_rescan: - if _sync_plan_start_scores_and_log(plan, state): - dirty = True - if _sync_postflight_scan_completion_and_log(plan, state): - dirty = True - if _sync_lifecycle_phase_and_log(plan, state): - dirty = True - return dirty + if result.triage and result.triage.injected: + print( + colorize( + " Plan: planning mode needed — review work items changed since last triage.", + "cyan", + ) + ) def reconcile_plan_post_scan(runtime: Any) -> None: @@ -543,31 +280,47 @@ def reconcile_plan_post_scan(runtime: Any) -> None: return force_rescan = getattr(runtime, "force_rescan", False) - - # Force-rescan: clear all cycle state first. The user explicitly chose - # to start over, so triage stages, workflow items, subjective dimensions, - # and plan-start scores from the old cycle are all stale. dirty = _reset_cycle_for_force_rescan(plan) if force_rescan else False - dirty = _sync_post_scan_without_policy(plan=plan, state=runtime.state) or dirty - # Policy must be computed after reconciliation, which mutates plan - # (supersede/prune resolved issues) before policy reads it. - target_strict, policy, cycle_just_completed = _subjective_policy_context( - runtime, - plan, - ) - dirty = _sync_post_scan_with_policy( - plan=plan, - state=runtime.state, - target_strict=target_strict, - policy=policy, - cycle_just_completed=cycle_just_completed, - force_rescan=getattr(runtime, "force_rescan", False), - ) or dirty + boundary_crossed = live_planned_queue_empty(plan) + if boundary_crossed: + result = reconcile_plan( + plan, + runtime.state, + target_strict=target_strict_score_from_config(runtime.config), + ) + _display_reconcile_results( + result, + plan, + mid_cycle=_is_mid_cycle_scan(plan, runtime.state) or force_rescan, + ) + dirty = result.dirty or dirty + + if not force_rescan: + if _sync_plan_start_scores_and_log(plan, runtime.state): + dirty = True + if _sync_postflight_scan_completion_and_log(plan, runtime.state): + dirty = True if dirty: try: save_plan(plan, plan_path) except PLAN_LOAD_EXCEPTIONS as exc: logger.warning("Plan reconciliation save failed: %s", exc) + + +__all__ = [ + "_clear_plan_start_scores_if_queue_empty", + "_display_reconcile_results", + "_has_objective_cycle", + "_is_mid_cycle_scan", + "_mark_postflight_scan_completed_if_ready", + "_reset_cycle_for_force_rescan", + "_seed_plan_start_scores", + "_sync_plan_start_scores_and_log", + "_sync_post_scan_without_policy", + "_sync_postflight_scan_completion_and_log", + "reconcile_plan_after_scan", + "reconcile_plan_post_scan", +] diff --git a/desloppify/app/commands/scan/preflight.py b/desloppify/app/commands/scan/preflight.py index 4870ea96..b1d47815 100644 --- a/desloppify/app/commands/scan/preflight.py +++ b/desloppify/app/commands/scan/preflight.py @@ -16,10 +16,27 @@ from desloppify.base.output.terminal import colorize from desloppify.app.commands.resolve.plan_load import warn_plan_load_degraded_once from desloppify.engine._work_queue.context import resolve_plan_load_status +from desloppify.engine._plan.constants import WORKFLOW_RUN_SCAN_ID +from desloppify.engine.planning.queue_policy import build_execution_queue +from desloppify.engine._work_queue.core import QueueBuildOptions _logger = logging.getLogger(__name__) +def _only_run_scan_workflow_remaining(state: dict, plan: dict) -> bool: + result = build_execution_queue( + state, + options=QueueBuildOptions( + status="open", + count=None, + plan=plan, + include_skipped=False, + ), + ) + items = result.get("items", []) + return len(items) == 1 and items[0].get("id") == WORKFLOW_RUN_SCAN_ID + + def scan_queue_preflight(args: object) -> None: """Warn and gate scan when queue has unfinished items.""" # CI profile always passes @@ -79,6 +96,13 @@ def scan_queue_preflight(args: object) -> None: return if mode is ScoreDisplayMode.LIVE: return # Queue fully clear or no active cycle — scan allowed + if ( + mode is ScoreDisplayMode.PHASE_TRANSITION + and breakdown.queue_total == 1 + and breakdown.workflow == 1 + and _only_run_scan_workflow_remaining(state, plan) + ): + return remaining = breakdown.queue_total # GATE — block both FROZEN (objective work) and PHASE_TRANSITION diff --git a/desloppify/app/commands/scan/reporting/dimensions.py b/desloppify/app/commands/scan/reporting/dimensions.py index 9d6d49d1..98b69fad 100644 --- a/desloppify/app/commands/scan/reporting/dimensions.py +++ b/desloppify/app/commands/scan/reporting/dimensions.py @@ -125,7 +125,7 @@ def show_scorecard_subjective_measures(state: dict) -> None: stale_keys = [e["dimension_key"] for e in entries if e.get("stale")] has_open = any( f.get("status") == "open" and not f.get("suppressed") - for f in (state.get("issues") or {}).values() + for f in (state.get("work_items") or {}).values() ) stale_followup = _stale_subjective_followup(stale_keys, has_open=has_open) if stale_followup: diff --git a/desloppify/app/commands/scan/reporting/presentation.py b/desloppify/app/commands/scan/reporting/presentation.py index cd63bd02..3ee44e6c 100644 --- a/desloppify/app/commands/scan/reporting/presentation.py +++ b/desloppify/app/commands/scan/reporting/presentation.py @@ -386,7 +386,8 @@ def show_detector_progress( colorize_fn: Callable[[str, str], str], ) -> None: """Show per-detector progress bars.""" - issues = state_mod.path_scoped_issues(state["issues"], state.get("scan_path")) + work_items = state.get("work_items") or state.get("issues", {}) + issues = state_mod.path_scoped_issues(work_items, state.get("scan_path")) if not issues: return diff --git a/desloppify/app/commands/scan/reporting/subjective.py b/desloppify/app/commands/scan/reporting/subjective.py index f7f49f61..712e1a6c 100644 --- a/desloppify/app/commands/scan/reporting/subjective.py +++ b/desloppify/app/commands/scan/reporting/subjective.py @@ -422,7 +422,7 @@ def build_subjective_followup( def _subjective_coverage_global(state: dict) -> int: - all_issues = state.get("issues", {}) + all_issues = (state.get("work_items") or state.get("issues", {})) if not isinstance(all_issues, dict): all_issues = {} coverage_global, _reason_counts, _holistic_reasons = ( diff --git a/desloppify/app/commands/scan/wontfix.py b/desloppify/app/commands/scan/wontfix.py index 44dd5a14..15ecda37 100644 --- a/desloppify/app/commands/scan/wontfix.py +++ b/desloppify/app/commands/scan/wontfix.py @@ -147,7 +147,7 @@ def augment_with_stale_wontfix_issues( decay_scans: int, ) -> tuple[list[dict[str, Any]], int]: """Append re-triage issues for stale or worsening wontfix debt.""" - existing = state.get("issues", {}) + existing = (state.get("work_items") or state.get("issues", {})) if not isinstance(existing, dict): return issues, 0 diff --git a/desloppify/app/commands/scan/workflow.py b/desloppify/app/commands/scan/workflow.py index fa09bab5..ceb37a6e 100644 --- a/desloppify/app/commands/scan/workflow.py +++ b/desloppify/app/commands/scan/workflow.py @@ -53,7 +53,7 @@ ) from desloppify.engine._work_queue.issues import mark_stale_holistic from desloppify.engine.planning.scan import PlanScanOptions, generate_issues as generate_plan_issues -from desloppify.intelligence.review.dimensions.metadata import ( +from desloppify.base.subjective_dimensions import ( resettable_default_dimensions, ) from desloppify.languages.framework import ( @@ -121,7 +121,7 @@ def _ensure_state_lang_capabilities( def _state_issues(state: StateModel) -> dict[str, dict[str, Any]]: """Return normalized issue map from state.""" - issues = state.get("issues") + issues = state.get("work_items") if isinstance(issues, dict): return issues raise ScanStateContractError( @@ -420,6 +420,7 @@ def merge_scan_results( include_slow=runtime.effective_include_slow, ignore=runtime.config.get("ignore", []), subjective_integrity_target=target_score, + project_root=str(get_project_root()), ), ) diff --git a/desloppify/app/commands/show/dimension_views.py b/desloppify/app/commands/show/dimension_views.py index 0fe58726..df6bd703 100644 --- a/desloppify/app/commands/show/dimension_views.py +++ b/desloppify/app/commands/show/dimension_views.py @@ -5,6 +5,7 @@ from desloppify.app.commands.helpers.query import write_query from desloppify.base.config import target_strict_score_from_config from desloppify.base.output.terminal import colorize +from desloppify.engine._state.issue_semantics import is_triage_finding from .render import show_subjective_followup from .scope import _detector_names_hint, _lookup_dimension_score, load_matches @@ -26,7 +27,7 @@ def _print_dimension_score(dim_data: dict, display_name: str) -> None: def _render_judgment(state: dict, dimension_key: str) -> None: - """Print judgment narrative (strengths, issue_character, score_rationale) if available.""" + """Print judgment narrative (strengths, dimension_character, score_rationale) if available.""" assessments = state.get("subjective_assessments", {}) assessment = assessments.get(dimension_key, {}) if not isinstance(assessment, dict): @@ -48,10 +49,6 @@ def _render_judgment(state: dict, dimension_key: str) -> None: dim_char = judgment.get("dimension_character", "") if dim_char: print(colorize(f" Dimension character: {dim_char}", "dim")) - else: - issue_character = judgment.get("issue_character", "") - if issue_character: - print(colorize(f" Issue character: {issue_character}", "dim")) def _render_subjective_dimension( @@ -74,8 +71,8 @@ def _render_subjective_dimension( ) dim_reviews = [ issue - for issue in (state.get("issues") or {}).values() - if issue.get("detector") == "review" + for issue in (state.get("work_items") or {}).values() + if is_triage_finding(issue) and issue.get("status") == "open" and lowered in str(issue.get("detail", {}).get("dimension", "")).lower().replace(" ", "_") @@ -83,7 +80,7 @@ def _render_subjective_dimension( if dim_reviews: print( colorize( - f" {len(dim_reviews)} open review issue(s). " + f" {len(dim_reviews)} open review work item(s). " "Run `show review --status open`.", "dim", ) @@ -165,7 +162,7 @@ def _render_subjective_views_guide(entity) -> None: "subjective_review", ): print(colorize(" Related views:", "dim")) - print(colorize(" `show review --status open` Per-file design review issues", "dim")) + print(colorize(" `show review --status open` Per-file design review work items", "dim")) print(colorize(" `show subjective_review --status open` Files needing re-review", "dim")) diff --git a/desloppify/app/commands/status/cmd.py b/desloppify/app/commands/status/cmd.py index 3903df30..70d8d1c4 100644 --- a/desloppify/app/commands/status/cmd.py +++ b/desloppify/app/commands/status/cmd.py @@ -69,7 +69,7 @@ def _status_json_payload( suppression: dict, ) -> dict: scores = score_snapshot(state) - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) open_scope = ( open_scope_breakdown(issues, state.get("scan_path")) if isinstance(issues, dict) diff --git a/desloppify/app/commands/status/render.py b/desloppify/app/commands/status/render.py index c78d9dc5..82473dfc 100644 --- a/desloppify/app/commands/status/render.py +++ b/desloppify/app/commands/status/render.py @@ -321,8 +321,8 @@ def show_structural_areas(state: StateModel) -> None: def show_review_summary(state: StateModel) -> None: - """Show review issues summary if any exist.""" - issues = state.get("issues", {}) + """Show review work items summary if any exist.""" + issues = (state.get("work_items") or state.get("issues", {})) review_open = [ f for f in issues.values() @@ -341,7 +341,7 @@ def show_review_summary(state: StateModel) -> None: if "Test health" in dim_scores: print( colorize( - " Test health tracks coverage + review; review issues track issues found.", + " Test health tracks coverage + review; review work items track issues found.", "dim", ) ) diff --git a/desloppify/app/commands/status/render_dimensions.py b/desloppify/app/commands/status/render_dimensions.py index 1b37cd8b..9083c085 100644 --- a/desloppify/app/commands/status/render_dimensions.py +++ b/desloppify/app/commands/status/render_dimensions.py @@ -45,7 +45,7 @@ def find_lowest_dimension( def open_review_issue_counts(state: dict) -> dict[str, int]: """Count open review issues grouped by subjective dimension key.""" - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) if not isinstance(issues, dict): return {} diff --git a/desloppify/app/commands/status/render_io.py b/desloppify/app/commands/status/render_io.py index 0a02c63d..e48f0f5c 100644 --- a/desloppify/app/commands/status/render_io.py +++ b/desloppify/app/commands/status/render_io.py @@ -103,7 +103,7 @@ def write_status_query(request: StatusQueryRequest) -> None: verified_strict_score = request.verified_strict_score plan = request.plan - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) open_scope = ( open_scope_breakdown(issues, state.get("scan_path")) if isinstance(issues, dict) diff --git a/desloppify/app/commands/status/render_structural.py b/desloppify/app/commands/status/render_structural.py index 7459ea57..75d16bae 100644 --- a/desloppify/app/commands/status/render_structural.py +++ b/desloppify/app/commands/status/render_structural.py @@ -14,7 +14,7 @@ def collect_structural_areas( ) -> list[tuple[str, list]] | None: """Collect T3/T4 structural issues grouped by area.""" issues = path_scoped_issues( - state.get("issues", {}), state.get("scan_path") + (state.get("work_items") or state.get("issues", {})), state.get("scan_path") ) structural = [ issue diff --git a/desloppify/app/commands/status/summary.py b/desloppify/app/commands/status/summary.py index 9d9ee04c..7d2a6012 100644 --- a/desloppify/app/commands/status/summary.py +++ b/desloppify/app/commands/status/summary.py @@ -115,7 +115,7 @@ def print_scan_completeness(state: dict) -> None: def print_open_scope_breakdown(state: dict) -> None: """Print open counts with explicit in-scope/out-of-scope semantics.""" - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) if not isinstance(issues, dict): return diff --git a/desloppify/app/commands/zone.py b/desloppify/app/commands/zone.py index e7e5d501..758551cd 100644 --- a/desloppify/app/commands/zone.py +++ b/desloppify/app/commands/zone.py @@ -104,7 +104,7 @@ def _zone_set(args: argparse.Namespace): sp = state_path(args) if sp.exists(): state = load_state(sp) - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) updated = 0 for issue in issues.values(): if issue.get("file") == normalized: @@ -138,7 +138,7 @@ def _zone_clear(args: argparse.Namespace): sp = state_path(args) if sp.exists(): state = load_state(sp) - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) updated = 0 for issue in issues.values(): if issue.get("file") == normalized: diff --git a/desloppify/app/output/visualize_data.py b/desloppify/app/output/visualize_data.py index 54b0b63c..96765285 100644 --- a/desloppify/app/output/visualize_data.py +++ b/desloppify/app/output/visualize_data.py @@ -203,8 +203,9 @@ def _build_dep_graph_for_path(path: Path, lang) -> dict: def _issues_by_file(state: dict | None) -> dict[str, list]: """Group issues from state by file path.""" result: dict[str, list] = defaultdict(list) - if state and state.get("issues"): - for f in state["issues"].values(): + work_items = (state.get("work_items") or state.get("issues", {})) if state else {} + if work_items: + for f in work_items.values(): result[f["file"]].append(f) return result diff --git a/desloppify/base/output/issues.py b/desloppify/base/output/issues.py index 2f4ddd86..a8d9a06c 100644 --- a/desloppify/base/output/issues.py +++ b/desloppify/base/output/issues.py @@ -1,4 +1,4 @@ -"""Rendering and scoring helpers for issue work orders.""" +"""Rendering and scoring helpers for work-item work orders.""" from __future__ import annotations @@ -10,12 +10,13 @@ CONFIDENCE_WEIGHTS, HOLISTIC_MULTIPLIER, ) +from desloppify.engine._state.issue_semantics import is_triage_finding logger = logging.getLogger(__name__) def issue_weight(issue: dict) -> tuple[float, float, str]: - """Compute (weight, impact_pts, issue_id) for a issue.""" + """Compute (weight, impact_pts, work_item_id) for a tracked work item.""" confidence = issue.get("confidence", "low") is_holistic = issue.get("detail", {}).get("holistic", False) @@ -46,7 +47,7 @@ def _append_assessment_context( f"({source} review, {assessed_at})" ) lines.append( - "Fixing this issue and re-reviewing should improve the " + "Fixing this work item and re-reviewing should improve the " f"{display_dimension} score.\n" ) @@ -150,12 +151,12 @@ def render_issue_detail( number: int | None = None, subjective_assessments: dict | None = None, ) -> str: - """Render one issue as a markdown work order from state.""" + """Render one tracked work item as a markdown work order from state.""" issue_id = issue["id"] detail = issue.get("detail", {}) detector = issue.get("detector", "") is_holistic = detail.get("holistic", False) - is_review = detector in ("review", "concerns") + is_review = is_triage_finding(issue) # Derive dimension: from detail for review, from registry for mechanical. raw_dimension = detail.get("dimension", "") @@ -174,7 +175,7 @@ def render_issue_detail( lines: list[str] = [] lines.append(f"# {dimension}: {identifier}\n") - lines.append(f"**Issue**: `{issue_id}` ") + lines.append(f"**Work item**: `{issue_id}` ") if not is_review: meta = DETECTORS.get(detector) detector_display = meta.display if meta else detector diff --git a/desloppify/engine/_concerns/state.py b/desloppify/engine/_concerns/state.py index 45113b2c..227ce793 100644 --- a/desloppify/engine/_concerns/state.py +++ b/desloppify/engine/_concerns/state.py @@ -10,7 +10,7 @@ def _open_issues(state: StateModel) -> list[dict[str, Any]]: """Return all open issues from state.""" - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) return [ finding for finding in issues.values() if isinstance(finding, dict) and finding.get("status") == "open" diff --git a/desloppify/engine/_plan/__init__.py b/desloppify/engine/_plan/__init__.py index ea9aa49f..8f823ea9 100644 --- a/desloppify/engine/_plan/__init__.py +++ b/desloppify/engine/_plan/__init__.py @@ -14,7 +14,7 @@ Other modules: - persistence: JSON read/write with atomic saves -- reconcile: post-scan plan↔state synchronization +- scan_issue_reconcile: post-scan stale/dead-reference synchronization - auto_cluster: automatic issue clustering - commit_tracking: git commit↔plan-item linking diff --git a/desloppify/engine/_plan/auto_cluster.py b/desloppify/engine/_plan/auto_cluster.py index c6037f31..aeae9c88 100644 --- a/desloppify/engine/_plan/auto_cluster.py +++ b/desloppify/engine/_plan/auto_cluster.py @@ -3,6 +3,7 @@ from __future__ import annotations from desloppify.base.config import DEFAULT_TARGET_STRICT_SCORE +from desloppify.engine._plan.cluster_semantics import cluster_is_active from desloppify.engine._plan.constants import AUTO_PREFIX from desloppify.engine._plan.auto_cluster_sync import ( prune_stale_clusters as _prune_stale_clusters, @@ -11,6 +12,7 @@ ) from desloppify.engine._plan.schema import PlanModel, ensure_plan_defaults from desloppify.engine._plan.policy.subjective import SubjectiveVisibility +from desloppify.engine._plan.sync.context import is_mid_cycle from desloppify.engine._state.schema import StateModel, utc_now # --------------------------------------------------------------------------- @@ -144,6 +146,28 @@ def _sync_auto_clusters( return changes +def _sync_active_auto_cluster_queue_membership(plan: PlanModel) -> int: + order: list[str] = plan.get("queue_order", []) + skipped = set(plan.get("skipped", {}).keys()) + existing = set(order) + added = 0 + for cluster in plan.get("clusters", {}).values(): + if not isinstance(cluster, dict) or not cluster.get("auto") or not cluster_is_active(cluster): + continue + for issue_id in cluster.get("issue_ids", []): + if ( + not isinstance(issue_id, str) + or not issue_id + or issue_id in skipped + or issue_id in existing + ): + continue + order.append(issue_id) + existing.add(issue_id) + added += 1 + return added + + def auto_cluster_issues( plan: PlanModel, state: StateModel, @@ -156,8 +180,10 @@ def auto_cluster_issues( Returns count of changes made (clusters created, updated, or deleted). """ ensure_plan_defaults(plan) + if is_mid_cycle(plan): + return 0 - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) clusters = plan.get("clusters", {}) now = utc_now() @@ -172,6 +198,7 @@ def auto_cluster_issues( policy=policy, ) changes += _repair_ghost_cluster_refs(plan, now) + changes += _sync_active_auto_cluster_queue_membership(plan) plan["updated"] = now return changes diff --git a/desloppify/engine/_plan/auto_cluster_sync_issue.py b/desloppify/engine/_plan/auto_cluster_sync_issue.py index 15e95a25..a794b176 100644 --- a/desloppify/engine/_plan/auto_cluster_sync_issue.py +++ b/desloppify/engine/_plan/auto_cluster_sync_issue.py @@ -6,6 +6,13 @@ from dataclasses import dataclass from desloppify.base.registry import DETECTORS +from desloppify.engine._plan.cluster_semantics import ( + EXECUTION_STATUS_ACTIVE, + EXECUTION_STATUS_REVIEW, + EXECUTION_POLICY_EPHEMERAL_AUTOPROMOTE, + infer_cluster_execution_policy, + normalize_cluster_semantics, +) from desloppify.engine._plan.cluster_strategy import ( cluster_name_from_key as _cluster_name_from_key, ) @@ -22,6 +29,19 @@ _MIN_CLUSTER_SIZE = 2 +def _auto_cluster_execution_status( + cluster: dict, + *, + detector: str = "", +) -> str: + if ( + infer_cluster_execution_policy(cluster, detector=detector) + == EXECUTION_POLICY_EPHEMERAL_AUTOPROMOTE + ): + return EXECUTION_STATUS_ACTIVE + return EXECUTION_STATUS_REVIEW + + @dataclass(frozen=True) class AutoClusterSyncResult: """Explicit result describing what `_sync_auto_cluster` changed.""" @@ -107,6 +127,7 @@ def _sync_auto_cluster( description: str, action: str, now: str, + detector: str = "", optional: bool = False, ) -> AutoClusterSyncResult: """Create/update one auto-cluster and report the mutation outcome. @@ -133,6 +154,14 @@ def _sync_auto_cluster( cluster["action"] = action cluster["updated_at"] = now changes = 1 + execution_status = _auto_cluster_execution_status(cluster, detector=detector) + if cluster.get("execution_status") != execution_status: + cluster["execution_status"] = execution_status + cluster["updated_at"] = now + changes = 1 + if normalize_cluster_semantics(cluster, detector=detector): + cluster["updated_at"] = now + changes = 1 else: new_cluster = { "name": cluster_name, @@ -143,10 +172,15 @@ def _sync_auto_cluster( "auto": True, "cluster_key": cluster_key, "action": action, + "execution_status": _auto_cluster_execution_status( + {"auto": True, "action": action}, + detector=detector, + ), "user_modified": False, } if optional: new_cluster["optional"] = True + normalize_cluster_semantics(new_cluster, detector=detector) clusters[cluster_name] = new_cluster existing_by_key[cluster_key] = cluster_name changes = 1 @@ -224,6 +258,7 @@ def sync_issue_clusters( description=description, action=action, now=now, + detector=detector, ) changes += int(sync_result.changed) diff --git a/desloppify/engine/_plan/cluster_membership.py b/desloppify/engine/_plan/cluster_membership.py new file mode 100644 index 00000000..b1ba2ee0 --- /dev/null +++ b/desloppify/engine/_plan/cluster_membership.py @@ -0,0 +1,37 @@ +"""Canonical cluster membership helpers for persisted plan payloads.""" + +from __future__ import annotations + +from desloppify.engine.plan_state import Cluster + + +def cluster_issue_ids(cluster: Cluster | dict[str, object]) -> list[str]: + """Return the effective issue IDs for a cluster.""" + ordered: list[str] = [] + seen: set[str] = set() + + def _append(raw_ids: object) -> None: + if not isinstance(raw_ids, list): + return + for raw_id in raw_ids: + if not isinstance(raw_id, str): + continue + issue_id = raw_id.strip() + if not issue_id or issue_id in seen: + continue + seen.add(issue_id) + ordered.append(issue_id) + + _append(cluster.get("issue_ids")) + + steps = cluster.get("action_steps") + if isinstance(steps, list): + for step in steps: + if not isinstance(step, dict): + continue + _append(step.get("issue_refs")) + + return ordered + + +__all__ = ["cluster_issue_ids"] diff --git a/desloppify/engine/_plan/cluster_semantics.py b/desloppify/engine/_plan/cluster_semantics.py new file mode 100644 index 00000000..b0c1f2d2 --- /dev/null +++ b/desloppify/engine/_plan/cluster_semantics.py @@ -0,0 +1,191 @@ +"""Canonical semantic helpers for plan clusters. + +Cluster behavior should be derived from explicit semantic metadata rather than +from command-string sniffing spread across queue assembly and rendering code. +""" + +from __future__ import annotations + +from collections.abc import MutableMapping +from typing import Any + +from desloppify.base.registry import DETECTORS + +ACTION_TYPE_AUTO_FIX = "auto_fix" +ACTION_TYPE_REFACTOR = "refactor" +ACTION_TYPE_MANUAL_FIX = "manual_fix" +ACTION_TYPE_REORGANIZE = "reorganize" +EXECUTION_STATUS_ACTIVE = "active" +EXECUTION_STATUS_REVIEW = "review" +VALID_ACTION_TYPES = frozenset( + { + ACTION_TYPE_AUTO_FIX, + ACTION_TYPE_REFACTOR, + ACTION_TYPE_MANUAL_FIX, + ACTION_TYPE_REORGANIZE, + } +) + +EXECUTION_POLICY_EPHEMERAL_AUTOPROMOTE = "ephemeral_autopromote" +EXECUTION_POLICY_PLANNED_ONLY = "planned_only" +VALID_EXECUTION_POLICIES = frozenset( + { + EXECUTION_POLICY_EPHEMERAL_AUTOPROMOTE, + EXECUTION_POLICY_PLANNED_ONLY, + } +) +VALID_EXECUTION_STATUSES = frozenset( + { + EXECUTION_STATUS_ACTIVE, + EXECUTION_STATUS_REVIEW, + } +) + + +def _action_text(cluster: MutableMapping[str, Any] | dict[str, Any]) -> str: + return str(cluster.get("action") or "").strip() + + +def infer_cluster_action_type( + cluster: MutableMapping[str, Any] | dict[str, Any], + *, + detector: str = "", +) -> str: + """Return the canonical action type for a cluster.""" + explicit = str(cluster.get("action_type") or "").strip() + if explicit in VALID_ACTION_TYPES: + return explicit + + action = _action_text(cluster) + if action.startswith("desloppify autofix ") and "--dry-run" in action: + return ACTION_TYPE_AUTO_FIX + if action == "desloppify move": + return ACTION_TYPE_REORGANIZE + + meta = DETECTORS.get(detector) + if meta is not None: + # Legacy safeguard: a detector may be auto-fixable in principle, but + # if the stored action is not an autofix command, keep cluster handling + # conservative and render it as refactor work. + if meta.action_type == ACTION_TYPE_AUTO_FIX and action and ( + not action.startswith("desloppify autofix ") + or "--dry-run" not in action + ): + return ACTION_TYPE_REFACTOR + if meta.action_type in VALID_ACTION_TYPES: + return meta.action_type + + return ACTION_TYPE_MANUAL_FIX + + +def infer_cluster_execution_policy( + cluster: MutableMapping[str, Any] | dict[str, Any], + *, + detector: str = "", +) -> str: + """Return how a cluster is allowed to surface in the execution queue.""" + explicit = str(cluster.get("execution_policy") or "").strip() + if explicit in VALID_EXECUTION_POLICIES: + return explicit + if ( + cluster.get("auto") + and infer_cluster_action_type(cluster, detector=detector) == ACTION_TYPE_AUTO_FIX + ): + return EXECUTION_POLICY_EPHEMERAL_AUTOPROMOTE + return EXECUTION_POLICY_PLANNED_ONLY + + +def infer_cluster_execution_status( + cluster: MutableMapping[str, Any] | dict[str, Any], +) -> str: + """Return whether the cluster is active queue work or review backlog.""" + explicit = str(cluster.get("execution_status") or "").strip() + if explicit in VALID_EXECUTION_STATUSES: + return explicit + return EXECUTION_STATUS_REVIEW + + +def normalize_cluster_semantics( + cluster: MutableMapping[str, Any], + *, + detector: str = "", +) -> bool: + """Populate canonical semantic fields and report whether anything changed.""" + explicit_action_type = str(cluster.get("action_type") or "").strip() + explicit_execution_policy = str(cluster.get("execution_policy") or "").strip() + explicit_execution_status = str(cluster.get("execution_status") or "").strip() + if ( + explicit_action_type not in VALID_ACTION_TYPES + and explicit_execution_policy not in VALID_EXECUTION_POLICIES + and explicit_execution_status not in VALID_EXECUTION_STATUSES + and cluster.get("auto") + and not detector + ): + action = _action_text(cluster) + if action != "desloppify move" and not ( + action.startswith("desloppify autofix ") and "--dry-run" in action + ): + return False + + action_type = infer_cluster_action_type(cluster, detector=detector) + execution_policy = infer_cluster_execution_policy(cluster, detector=detector) + execution_status = infer_cluster_execution_status(cluster) + changed = False + if cluster.get("action_type") != action_type: + cluster["action_type"] = action_type + changed = True + if cluster.get("execution_policy") != execution_policy: + cluster["execution_policy"] = execution_policy + changed = True + if cluster.get("execution_status") != execution_status: + cluster["execution_status"] = execution_status + changed = True + return changed + + +def cluster_allows_ephemeral_execution( + cluster: MutableMapping[str, Any] | dict[str, Any], + *, + detector: str = "", +) -> bool: + return bool(cluster.get("auto")) and ( + infer_cluster_execution_policy(cluster, detector=detector) + == EXECUTION_POLICY_EPHEMERAL_AUTOPROMOTE + ) + + +def cluster_is_active( + cluster: MutableMapping[str, Any] | dict[str, Any], +) -> bool: + return infer_cluster_execution_status(cluster) == EXECUTION_STATUS_ACTIVE + + +def cluster_autofix_hint( + cluster: MutableMapping[str, Any] | dict[str, Any], + *, + detector: str = "", +) -> str | None: + """Return the autofix command to suggest for a cluster when semantically valid.""" + if infer_cluster_action_type(cluster, detector=detector) != ACTION_TYPE_AUTO_FIX: + return None + action = _action_text(cluster) + return action or None + + +__all__ = [ + "ACTION_TYPE_AUTO_FIX", + "ACTION_TYPE_MANUAL_FIX", + "ACTION_TYPE_REFACTOR", + "ACTION_TYPE_REORGANIZE", + "EXECUTION_STATUS_ACTIVE", + "EXECUTION_STATUS_REVIEW", + "EXECUTION_POLICY_EPHEMERAL_AUTOPROMOTE", + "EXECUTION_POLICY_PLANNED_ONLY", + "cluster_allows_ephemeral_execution", + "cluster_autofix_hint", + "cluster_is_active", + "infer_cluster_action_type", + "infer_cluster_execution_status", + "infer_cluster_execution_policy", + "normalize_cluster_semantics", +] diff --git a/desloppify/engine/_plan/cluster_strategy.py b/desloppify/engine/_plan/cluster_strategy.py index 8517b75c..baae1ac3 100644 --- a/desloppify/engine/_plan/cluster_strategy.py +++ b/desloppify/engine/_plan/cluster_strategy.py @@ -4,6 +4,10 @@ from desloppify.base.registry import DetectorMeta from desloppify.engine._plan.constants import AUTO_PREFIX +from desloppify.engine._state.issue_semantics import ( + is_review_finding, + is_assessment_request, +) def grouping_key(issue: dict, meta: DetectorMeta | None) -> str | None: @@ -17,7 +21,7 @@ def grouping_key(issue: dict, meta: DetectorMeta | None) -> str | None: if meta is None: return f"detector::{detector}" - if detector in ("review", "subjective_review"): + if is_review_finding(issue) or is_assessment_request(issue): detail = issue.get("detail") or {} dimension = detail.get("dimension", "") if dimension: @@ -54,7 +58,7 @@ def generate_description( count = len(members) detector = members[0].get("detector", "") if members else "" - if detector in ("review", "subjective_review"): + if is_review_finding(members[0]) or is_assessment_request(members[0]): detail = (members[0].get("detail") or {}) if members else {} dimension = detail.get("dimension", detector) return f"Address {count} {dimension} review issues" diff --git a/desloppify/engine/_plan/commit_tracking.py b/desloppify/engine/_plan/commit_tracking.py index 3a6c5de7..54489b85 100644 --- a/desloppify/engine/_plan/commit_tracking.py +++ b/desloppify/engine/_plan/commit_tracking.py @@ -137,7 +137,7 @@ def commit_tracking_summary(plan: dict[str, Any]) -> dict[str, int]: def _issue_summary(state: StateModel, issue_id: str) -> str: """Extract a short summary for a issue ID from state.""" - issue = state.get("issues", {}).get(issue_id, {}) + issue = (state.get("work_items") or state.get("issues", {})).get(issue_id, {}) summary = issue.get("summary", "") if summary: return summary[:80] diff --git a/desloppify/engine/_plan/constants.py b/desloppify/engine/_plan/constants.py index 4c1c45c3..27de6256 100644 --- a/desloppify/engine/_plan/constants.py +++ b/desloppify/engine/_plan/constants.py @@ -19,6 +19,9 @@ "triage::sense-check", "triage::commit", ) +# value-check was folded into sense-check as a subagent in v0.9.9. +# Old plan.json files may still contain triage_stages["value-check"] data; +# it is silently ignored because _TRIAGE_STAGE_NAMES no longer includes it. TRIAGE_STAGE_SPECS = tuple( (stage_id.removeprefix("triage::"), stage_id) for stage_id in TRIAGE_STAGE_IDS ) @@ -55,6 +58,21 @@ SYNTHETIC_PREFIXES = ("triage::", "workflow::", "subjective::") +def is_synthetic_id(issue_id: str) -> bool: + """Return True when a raw plan ID refers to synthetic queue work.""" + return any(issue_id.startswith(prefix) for prefix in SYNTHETIC_PREFIXES) + + +def is_workflow_id(issue_id: str) -> bool: + """Return True when a raw plan ID is a workflow synthetic.""" + return issue_id.startswith(WORKFLOW_PREFIX) + + +def is_triage_id(issue_id: str) -> bool: + """Return True when a raw plan ID is a triage synthetic.""" + return issue_id.startswith(TRIAGE_PREFIX) + + @dataclass class QueueSyncResult: """Unified result for all queue sync operations.""" @@ -134,6 +152,9 @@ def normalize_queue_workflow_and_triage_prefix(queue_order: list[str]) -> None: "QueueSyncResult", "normalize_queue_workflow_and_triage_prefix", "confirmed_triage_stage_names", + "is_synthetic_id", + "is_triage_id", + "is_workflow_id", "recorded_unconfirmed_triage_stage_names", "SUBJECTIVE_PREFIX", "SYNTHETIC_PREFIXES", diff --git a/desloppify/engine/_plan/operations/cluster.py b/desloppify/engine/_plan/operations/cluster.py index e624b439..1c9fd663 100644 --- a/desloppify/engine/_plan/operations/cluster.py +++ b/desloppify/engine/_plan/operations/cluster.py @@ -2,6 +2,11 @@ from __future__ import annotations +from desloppify.engine._plan.cluster_semantics import ( + ACTION_TYPE_MANUAL_FIX, + EXECUTION_STATUS_ACTIVE, + EXECUTION_POLICY_PLANNED_ONLY, +) from desloppify.engine._plan.operations.lifecycle import clear_focus_if_cluster_empty from desloppify.engine._plan.operations.queue import move_items from desloppify.engine._plan.schema import Cluster, PlanModel, ensure_plan_defaults @@ -79,6 +84,9 @@ def create_cluster( "auto": False, "cluster_key": "", "action": action, + "action_type": ACTION_TYPE_MANUAL_FIX, + "execution_policy": EXECUTION_POLICY_PLANNED_ONLY, + "execution_status": EXECUTION_STATUS_ACTIVE, "user_modified": False, } plan["clusters"][name] = cluster @@ -120,6 +128,7 @@ def remove_from_cluster( ensure_plan_defaults(plan) cluster = _cluster_or_raise(plan, cluster_name) member_ids: list[str] = cluster["issue_ids"] + removed_ids = set(issue_ids) now = utc_now() count = 0 for fid in issue_ids: @@ -133,6 +142,21 @@ def remove_from_cluster( timestamp=now, ) + steps = cluster.get("action_steps") + if isinstance(steps, list): + for step in steps: + if not isinstance(step, dict): + continue + refs = step.get("issue_refs") + if not isinstance(refs, list): + continue + filtered_refs = [ + ref for ref in refs + if isinstance(ref, str) and ref not in removed_ids + ] + if filtered_refs != refs: + step["issue_refs"] = filtered_refs + if count > 0 and cluster.get("auto"): cluster["user_modified"] = True diff --git a/desloppify/engine/_plan/persistence.py b/desloppify/engine/_plan/persistence.py index 8eb318fd..1e323d52 100644 --- a/desloppify/engine/_plan/persistence.py +++ b/desloppify/engine/_plan/persistence.py @@ -43,6 +43,7 @@ class PlanLoadStatus: plan: PlanModel | None degraded: bool error_kind: str | None = None + recovery: str | None = None def get_plan_file() -> Path: @@ -89,36 +90,11 @@ def plan_lock(path: Path | None = None) -> Iterator[None]: os.close(fd) -def load_plan(path: Path | None = None) -> PlanModel: - """Load plan from disk, or return empty plan on missing/corruption.""" - plan_path = path or _default_plan_file() - if not plan_path.exists(): - return empty_plan() - - try: - data = json.loads(plan_path.read_text()) - except (json.JSONDecodeError, UnicodeDecodeError, OSError) as ex: - # Try backup before giving up - backup = plan_path.with_suffix(".json.bak") - if backup.exists(): - try: - data = json.loads(backup.read_text()) - logger.warning("Plan file corrupted (%s), loaded from backup.", ex) - print(f" Warning: Plan file corrupted ({ex}), loaded from backup.", file=sys.stderr) - # Fall through to validation below - except (json.JSONDecodeError, UnicodeDecodeError, OSError) as backup_ex: - logger.warning("Plan file and backup both corrupted: %s / %s", ex, backup_ex) - print(f" Warning: Plan file corrupted ({ex}). Starting fresh.", file=sys.stderr) - return empty_plan() - else: - logger.warning("Plan file corrupted (%s). Starting fresh.", ex) - print(f" Warning: Plan file corrupted ({ex}). Starting fresh.", file=sys.stderr) - return empty_plan() - +def _load_validated_plan(plan_path: Path) -> PlanModel: + """Load, normalize, and validate one plan payload from disk.""" + data = json.loads(plan_path.read_text()) if not isinstance(data, dict): - logger.warning("Plan file root is not a JSON object. Starting fresh.") - print(" Warning: Plan file root must be a JSON object. Starting fresh.", file=sys.stderr) - return empty_plan() + raise ValueError("Plan file root must be a JSON object.") version = data.get("version", 1) if version > PLAN_VERSION: @@ -130,13 +106,7 @@ def load_plan(path: Path | None = None) -> PlanModel: ) ensure_plan_defaults(data) - try: - validate_plan(data) - except ValueError as ex: - logger.warning("Plan invariants invalid (%s). Starting fresh.", ex) - print(f" Warning: Plan invariants invalid ({ex}). Starting fresh.", file=sys.stderr) - return empty_plan() - + validate_plan(data) return cast(PlanModel, data) @@ -144,21 +114,59 @@ def resolve_plan_load_status(path: Path | None = None) -> PlanLoadStatus: """Load a plan with explicit degraded-mode metadata.""" plan_path = path or _default_plan_file() if not plan_path.exists(): - return PlanLoadStatus(plan=None, degraded=False, error_kind=None) + return PlanLoadStatus(plan=None, degraded=False, error_kind=None, recovery=None) try: return PlanLoadStatus( - plan=load_plan(plan_path), + plan=_load_validated_plan(plan_path), degraded=False, error_kind=None, + recovery=None, ) except PLAN_LOAD_EXCEPTIONS as exc: + backup = plan_path.with_suffix(".json.bak") + if backup.exists(): + try: + plan = _load_validated_plan(backup) + logger.warning( + "Plan file load degraded for %s (%s); recovered from backup %s.", + plan_path, + exc, + backup, + ) + print( + f" Warning: Plan file load degraded ({exc}); recovered from backup.", + file=sys.stderr, + ) + return PlanLoadStatus( + plan=plan, + degraded=True, + error_kind=exc.__class__.__name__, + recovery="backup", + ) + except PLAN_LOAD_EXCEPTIONS as backup_exc: + logger.warning( + "Plan file and backup both failed for %s: %s / %s", + plan_path, + exc, + backup_exc, + ) + + logger.warning("Plan file load degraded for %s (%s); starting fresh.", plan_path, exc) + print(f" Warning: Plan file load degraded ({exc}); starting fresh.", file=sys.stderr) return PlanLoadStatus( - plan=None, + plan=empty_plan(), degraded=True, error_kind=exc.__class__.__name__, + recovery="fresh_start", ) +def load_plan(path: Path | None = None) -> PlanModel: + """Load plan from disk, or return empty plan on missing/corruption.""" + status = resolve_plan_load_status(path) + return status.plan or empty_plan() + + def save_plan(plan: PlanModel | dict, path: Path | None = None) -> None: """Validate and save plan to disk atomically.""" ensure_plan_defaults(plan) diff --git a/desloppify/engine/_plan/policy/stale.py b/desloppify/engine/_plan/policy/stale.py index bda281e5..17f12f8d 100644 --- a/desloppify/engine/_plan/policy/stale.py +++ b/desloppify/engine/_plan/policy/stale.py @@ -5,19 +5,17 @@ import hashlib from desloppify.base.config import DEFAULT_TARGET_STRICT_SCORE +from desloppify.engine._state.issue_semantics import is_triage_finding from desloppify.engine._state.schema import StateModel from desloppify.engine._work_queue.helpers import slugify from desloppify.engine.planning.scorecard_projection import all_subjective_entries -_REVIEW_DETECTORS = ("review", "concerns") - - def open_review_ids(state: StateModel) -> set[str]: """Return IDs of open review/concerns issues from state.""" return { fid - for fid, f in state.get("issues", {}).items() - if f.get("status") == "open" and f.get("detector") in _REVIEW_DETECTORS + for fid, f in (state.get("work_items") or state.get("issues", {})).items() + if f.get("status") == "open" and is_triage_finding(f) } diff --git a/desloppify/engine/_plan/policy/subjective.py b/desloppify/engine/_plan/policy/subjective.py index 39f18be8..8ab25ac0 100644 --- a/desloppify/engine/_plan/policy/subjective.py +++ b/desloppify/engine/_plan/policy/subjective.py @@ -12,12 +12,14 @@ from desloppify.base.config import DEFAULT_TARGET_STRICT_SCORE from desloppify.base.enums import Status from desloppify.base.registry import DETECTORS -from desloppify.engine._plan.schema import planned_objective_ids as _planned_objective_ids +from desloppify.engine._plan.schema import executable_objective_ids as _executable_objective_ids from desloppify.engine._state.filtering import issue_in_scan_scope +from desloppify.engine._state.issue_semantics import counts_toward_objective_backlog from desloppify.engine._state.schema import StateModel from desloppify.engine.planning.helpers import CONFIDENCE_ORDER -# Detectors whose issues are NOT objective mechanical work. +# Legacy export for modules that still need detector-name display behavior. +# Objective/non-objective semantics should flow through issue_kind helpers. NON_OBJECTIVE_DETECTORS: frozenset[str] = frozenset({ "review", "concerns", "subjective_review", "subjective_assessment", }) @@ -106,7 +108,7 @@ def compute_subjective_visibility( else scan_path ) - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) skipped_ids = set((plan or {}).get("skipped", {}).keys()) # Count open, non-suppressed, objective issues. @@ -118,16 +120,16 @@ def compute_subjective_visibility( issue_id for issue_id, issue in issues.items() if issue.get("status") == Status.OPEN - and issue.get("detector") not in NON_OBJECTIVE_DETECTORS + and counts_toward_objective_backlog(issue) and not issue.get("suppressed") and not _is_evidence_only(issue) and issue_in_scan_scope(str(issue.get("file", "")), resolved_scan_path) and issue_id not in skipped_ids ] - # When the plan has tracked objectives (post-triage), only count - # planned items — unplanned items are backlog, not blocking work. - objective_count = len(_planned_objective_ids(set(objective_issue_ids), plan)) + # Only explicitly queued objectives count — backlog items don't block + # subjective reruns. + objective_count = len(_executable_objective_ids(set(objective_issue_ids), plan)) unscored = current_unscored_ids(state) stale = current_stale_ids(state) diff --git a/desloppify/engine/_plan/refresh_lifecycle.py b/desloppify/engine/_plan/refresh_lifecycle.py index ca947c9f..86a5f693 100644 --- a/desloppify/engine/_plan/refresh_lifecycle.py +++ b/desloppify/engine/_plan/refresh_lifecycle.py @@ -1,10 +1,4 @@ -"""Helpers for the persisted queue lifecycle phase. - -The persisted phase is the normal source of truth for CLI/debugging, but it is -not trusted blindly. Queue assembly still re-resolves the active phase from the -currently visible items as a safety net so stale saved state cannot strand the -user in the wrong phase after out-of-band changes. -""" +"""Helpers for the persisted queue lifecycle phase.""" from __future__ import annotations @@ -12,24 +6,39 @@ from desloppify.engine._plan.constants import SYNTHETIC_PREFIXES from desloppify.engine._plan.schema import PlanModel, ensure_plan_defaults +from desloppify.engine._state.issue_semantics import counts_toward_objective_backlog _POSTFLIGHT_SCAN_KEY = "postflight_scan_completed_at_scan_count" +_SUBJECTIVE_REVIEW_KEY = "subjective_review_completed_at_scan_count" _LIFECYCLE_PHASE_KEY = "lifecycle_phase" +LIFECYCLE_PHASE_REVIEW_INITIAL = "review_initial" +LIFECYCLE_PHASE_ASSESSMENT_POSTFLIGHT = "assessment_postflight" +LIFECYCLE_PHASE_REVIEW_POSTFLIGHT = "review_postflight" +LIFECYCLE_PHASE_WORKFLOW_POSTFLIGHT = "workflow_postflight" +LIFECYCLE_PHASE_TRIAGE_POSTFLIGHT = "triage_postflight" +LIFECYCLE_PHASE_EXECUTE = "execute" LIFECYCLE_PHASE_SCAN = "scan" + +# Coarse lifecycle names remain valid persisted values for older plan data. LIFECYCLE_PHASE_REVIEW = "review" LIFECYCLE_PHASE_WORKFLOW = "workflow" LIFECYCLE_PHASE_TRIAGE = "triage" -LIFECYCLE_PHASE_EXECUTE = "execute" -VALID_LIFECYCLE_PHASES = frozenset( - { - LIFECYCLE_PHASE_SCAN, - LIFECYCLE_PHASE_REVIEW, - LIFECYCLE_PHASE_WORKFLOW, - LIFECYCLE_PHASE_TRIAGE, - LIFECYCLE_PHASE_EXECUTE, - } -) + +COARSE_PHASE_MAP = { + LIFECYCLE_PHASE_REVIEW_INITIAL: LIFECYCLE_PHASE_REVIEW, + LIFECYCLE_PHASE_ASSESSMENT_POSTFLIGHT: LIFECYCLE_PHASE_REVIEW, + LIFECYCLE_PHASE_REVIEW_POSTFLIGHT: LIFECYCLE_PHASE_REVIEW, + LIFECYCLE_PHASE_WORKFLOW_POSTFLIGHT: LIFECYCLE_PHASE_WORKFLOW, + LIFECYCLE_PHASE_TRIAGE_POSTFLIGHT: LIFECYCLE_PHASE_TRIAGE, + LIFECYCLE_PHASE_EXECUTE: LIFECYCLE_PHASE_EXECUTE, + LIFECYCLE_PHASE_SCAN: LIFECYCLE_PHASE_SCAN, + LIFECYCLE_PHASE_REVIEW: LIFECYCLE_PHASE_REVIEW, + LIFECYCLE_PHASE_WORKFLOW: LIFECYCLE_PHASE_WORKFLOW, + LIFECYCLE_PHASE_TRIAGE: LIFECYCLE_PHASE_TRIAGE, +} + +VALID_LIFECYCLE_PHASES = frozenset(COARSE_PHASE_MAP) def _refresh_state(plan: PlanModel) -> dict[str, object]: @@ -45,6 +54,38 @@ def _is_real_queue_issue(issue_id: str) -> bool: return not any(str(issue_id).startswith(prefix) for prefix in SYNTHETIC_PREFIXES) +def _touches_objective_issue( + *, + issue_ids: Iterable[str] | None, + state: dict[str, object] | None, +) -> bool: + if issue_ids is None: + return True + + real_issue_ids = [ + issue_id + for issue_id in issue_ids + if _is_real_queue_issue(str(issue_id)) + ] + if not real_issue_ids: + return False + if not isinstance(state, dict): + return True + + issues = state.get("work_items") or state.get("issues", {}) + if not isinstance(issues, dict): + return True + + objective_seen = False + for issue_id in real_issue_ids: + issue = issues.get(issue_id) + if not isinstance(issue, dict): + return True + if counts_toward_objective_backlog(issue): + objective_seen = True + return objective_seen + + def current_lifecycle_phase(plan: PlanModel) -> str | None: """Return the persisted lifecycle phase, falling back for legacy plans.""" refresh_state = plan.get("refresh_state") @@ -70,56 +111,14 @@ def set_lifecycle_phase(plan: PlanModel, phase: str) -> bool: return True -def sync_lifecycle_phase( - plan: PlanModel, - *, - has_initial_reviews: bool, - has_objective_backlog: bool, - has_postflight_review: bool, - has_postflight_workflow: bool, - has_triage: bool, - has_deferred: bool, -) -> tuple[str, bool]: - """Resolve and persist the current lifecycle phase from queue-state facts.""" - phase = resolve_lifecycle_phase( - plan, - has_initial_reviews=has_initial_reviews, - has_objective_backlog=has_objective_backlog, - has_postflight_review=has_postflight_review, - has_postflight_workflow=has_postflight_workflow, - has_triage=has_triage, - has_deferred=has_deferred, - ) - return phase, set_lifecycle_phase(plan, phase) - - -def resolve_lifecycle_phase( - plan: PlanModel, - *, - has_initial_reviews: bool, - has_objective_backlog: bool, - has_postflight_review: bool, - has_postflight_workflow: bool, - has_triage: bool, - has_deferred: bool, -) -> str: - """Resolve the lifecycle phase from explicit queue-state facts.""" - if has_initial_reviews: - return LIFECYCLE_PHASE_REVIEW - if has_objective_backlog: - return LIFECYCLE_PHASE_EXECUTE - if has_deferred or postflight_scan_pending(plan): - return LIFECYCLE_PHASE_SCAN - if has_postflight_review: - return LIFECYCLE_PHASE_REVIEW - if has_postflight_workflow: - return LIFECYCLE_PHASE_WORKFLOW - if has_triage: - return LIFECYCLE_PHASE_TRIAGE - persisted = current_lifecycle_phase(plan) - if persisted is not None: - return persisted - return LIFECYCLE_PHASE_SCAN +def coarse_lifecycle_phase(plan: PlanModel | None) -> str | None: + """Return the coarse lifecycle phase for persisted fine/coarse plan data.""" + if not isinstance(plan, dict): + return None + phase = current_lifecycle_phase(plan) + if phase is None: + return None + return COARSE_PHASE_MAP.get(phase) def postflight_scan_pending(plan: PlanModel) -> bool: @@ -147,15 +146,47 @@ def mark_postflight_scan_completed( return True +def subjective_review_completed_for_scan( + plan: PlanModel, + *, + scan_count: int | None, +) -> bool: + """Return True when postflight subjective review finished for *scan_count*.""" + refresh_state = plan.get("refresh_state") + if not isinstance(refresh_state, dict): + return False + try: + normalized_scan_count = int(scan_count or 0) + except (TypeError, ValueError): + normalized_scan_count = 0 + return refresh_state.get(_SUBJECTIVE_REVIEW_KEY) == normalized_scan_count + + +def mark_subjective_review_completed( + plan: PlanModel, + *, + scan_count: int | None, +) -> bool: + """Record that subjective review completed for the current postflight scan.""" + refresh_state = _refresh_state(plan) + try: + normalized_scan_count = int(scan_count or 0) + except (TypeError, ValueError): + normalized_scan_count = 0 + if refresh_state.get(_SUBJECTIVE_REVIEW_KEY) == normalized_scan_count: + return False + refresh_state[_SUBJECTIVE_REVIEW_KEY] = normalized_scan_count + return True + + def clear_postflight_scan_completion( plan: PlanModel, *, issue_ids: Iterable[str] | None = None, + state: dict[str, object] | None = None, ) -> bool: - """Require a fresh scan after queue-changing work on real issues.""" - if issue_ids is not None and not any( - _is_real_queue_issue(issue_id) for issue_id in issue_ids - ): + """Require a fresh scan after queue-changing work on objective issues.""" + if not _touches_objective_issue(issue_ids=issue_ids, state=state): return False refresh_state = _refresh_state(plan) if _POSTFLIGHT_SCAN_KEY not in refresh_state: @@ -166,17 +197,24 @@ def clear_postflight_scan_completion( __all__ = [ + "COARSE_PHASE_MAP", + "coarse_lifecycle_phase", + "LIFECYCLE_PHASE_ASSESSMENT_POSTFLIGHT", "clear_postflight_scan_completion", "current_lifecycle_phase", "LIFECYCLE_PHASE_EXECUTE", "LIFECYCLE_PHASE_REVIEW", + "LIFECYCLE_PHASE_REVIEW_INITIAL", + "LIFECYCLE_PHASE_REVIEW_POSTFLIGHT", "LIFECYCLE_PHASE_SCAN", "LIFECYCLE_PHASE_TRIAGE", + "LIFECYCLE_PHASE_TRIAGE_POSTFLIGHT", "LIFECYCLE_PHASE_WORKFLOW", + "LIFECYCLE_PHASE_WORKFLOW_POSTFLIGHT", "mark_postflight_scan_completed", + "mark_subjective_review_completed", "postflight_scan_pending", - "resolve_lifecycle_phase", + "subjective_review_completed_for_scan", "set_lifecycle_phase", - "sync_lifecycle_phase", "VALID_LIFECYCLE_PHASES", ] diff --git a/desloppify/engine/_plan/reconcile.py b/desloppify/engine/_plan/scan_issue_reconcile.py similarity index 80% rename from desloppify/engine/_plan/reconcile.py rename to desloppify/engine/_plan/scan_issue_reconcile.py index 4125e6c4..f8bb75d3 100644 --- a/desloppify/engine/_plan/reconcile.py +++ b/desloppify/engine/_plan/scan_issue_reconcile.py @@ -1,4 +1,4 @@ -"""Post-scan plan reconciliation — handle issue churn.""" +"""Post-scan reconciliation for stale or disappeared issue references.""" from __future__ import annotations @@ -11,10 +11,6 @@ from desloppify.engine._plan.operations.meta import append_log_entry from desloppify.engine._plan.operations.skip import resurface_stale_skips from desloppify.engine._plan.promoted_ids import prune_promoted_ids -from desloppify.engine._plan.reconcile_review_import import ( - ReviewImportSyncResult, - sync_plan_after_review_import, -) from desloppify.engine._plan.schema import ( EPIC_PREFIX, PlanModel, @@ -22,7 +18,7 @@ ensure_plan_defaults, ) from desloppify.engine._plan.skip_policy import skip_kind_state_status -from desloppify.engine._state.schema import StateModel, utc_now +from desloppify.engine._state.schema import StateModel, ensure_state_defaults, utc_now SUPERSEDED_TTL_DAYS = 90 @@ -43,7 +39,7 @@ def _find_candidates( ) -> list[str]: """Find alive issues that could be remaps for a disappeared issue.""" candidates: list[str] = [] - for fid, issue in state.get("issues", {}).items(): + for fid, issue in (state.get("work_items") or state.get("issues", {})).items(): if issue.get("status") not in _ALIVE_STATUSES: continue if issue.get("detector") == detector and issue.get("file") == file: @@ -56,7 +52,7 @@ def _find_candidates( def _is_issue_alive(state: StateModel, issue_id: str) -> bool: """Return True if the issue exists and is actionable (open/deferred/triaged_out).""" - issue = state.get("issues", {}).get(issue_id) + issue = (state.get("work_items") or state.get("issues", {})).get(issue_id) if issue is None: return False return issue.get("status") in _ALIVE_STATUSES @@ -69,7 +65,7 @@ def _supersede_id( now: str, ) -> bool: """Move a disappeared issue to superseded. Returns True if changed.""" - issue = state.get("issues", {}).get(issue_id) + issue = (state.get("work_items") or state.get("issues", {})).get(issue_id) detector = "" file = "" summary = "" @@ -160,6 +156,59 @@ def _referenced_plan_issue_ids(plan: PlanModel) -> set[str]: } +def _prune_existing_superseded_references( + plan: PlanModel, + *, + result: ReconcileResult, +) -> None: + superseded_ids = { + fid for fid in plan.get("superseded", {}) + if isinstance(fid, str) and fid + } + if not superseded_ids: + return + + changes = 0 + order = plan.get("queue_order", []) + kept_order = [fid for fid in order if fid not in superseded_ids] + if len(kept_order) != len(order): + changes += len(order) - len(kept_order) + order[:] = kept_order + + skipped = plan.get("skipped", {}) + for fid in list(skipped): + if fid in superseded_ids: + skipped.pop(fid, None) + changes += 1 + + promoted_before = len(plan.get("promoted_ids", [])) + prune_promoted_ids(plan, superseded_ids) + changes += max(0, promoted_before - len(plan.get("promoted_ids", []))) + + for cluster in plan.get("clusters", {}).values(): + issue_ids = cluster.get("issue_ids", []) + kept_issue_ids = [fid for fid in issue_ids if fid not in superseded_ids] + if len(kept_issue_ids) != len(issue_ids): + changes += len(issue_ids) - len(kept_issue_ids) + cluster["issue_ids"] = kept_issue_ids + for step in cluster.get("action_steps", []): + if not isinstance(step, dict): + continue + refs = step.get("issue_refs", []) + kept_refs = [fid for fid in refs if fid not in superseded_ids] + if len(kept_refs) != len(refs): + changes += len(refs) - len(kept_refs) + step["issue_refs"] = kept_refs + + for fid in superseded_ids: + override = plan.get("overrides", {}).get(fid) + if override and override.get("cluster"): + override["cluster"] = None + changes += 1 + + result.changes += changes + + def _supersede_dead_references( plan: PlanModel, state: StateModel, @@ -231,7 +280,7 @@ def _sync_skipped_issue_statuses(plan: PlanModel, state: StateModel) -> None: Runs on every reconcile so existing data gets migrated on next scan. """ skipped = plan.get("skipped", {}) - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) for fid, entry in skipped.items(): issue = issues.get(fid) if issue is None or issue.get("status") != "open": @@ -252,10 +301,12 @@ def reconcile_plan_after_scan( open, moves them to superseded, and prunes old superseded entries. """ ensure_plan_defaults(plan) + ensure_state_defaults(state) result = ReconcileResult() now = utc_now() now_dt = datetime.now(UTC) + _prune_existing_superseded_references(plan, result=result) referenced_ids = _referenced_plan_issue_ids(plan) # Snapshot non-epic cluster sizes before superseding so we can detect @@ -291,7 +342,7 @@ def reconcile_plan_after_scan( result.resurfaced = resurfaced result.changes += len(resurfaced) # Reopen resurfaced issues in state (they were deferred) - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) for fid in resurfaced: issue = issues.get(fid) if issue and issue.get("status") == "deferred": @@ -321,7 +372,5 @@ def reconcile_plan_after_scan( __all__ = [ "ReconcileResult", - "ReviewImportSyncResult", "reconcile_plan_after_scan", - "sync_plan_after_review_import", ] diff --git a/desloppify/engine/_plan/schema/__init__.py b/desloppify/engine/_plan/schema/__init__.py index 71121ccf..ab859b06 100644 --- a/desloppify/engine/_plan/schema/__init__.py +++ b/desloppify/engine/_plan/schema/__init__.py @@ -44,6 +44,7 @@ class ActionStep(TypedDict, total=False): title: Required[str] # Short summary, 1 line detail: str # Long description, paragraphs OK issue_refs: list[str] # Issue ID suffixes this step addresses + effort: str # "trivial" | "small" | "medium" | "large" done: bool # Completion tracking (default False) @@ -56,6 +57,9 @@ class Cluster(TypedDict, total=False): auto: bool # True for auto-generated clusters cluster_key: str # Deterministic grouping key (for regeneration) action: str | None # Primary resolution command/guidance text + action_type: str + execution_policy: str + execution_status: str user_modified: bool # True when user manually edits membership optional: bool thesis: str @@ -65,6 +69,7 @@ class Cluster(TypedDict, total=False): dismissed: list[str] agent_safe: bool dependency_order: int + depends_on_clusters: list[str] action_steps: list[ActionStep] priority: int source_clusters: list[str] @@ -158,9 +163,12 @@ class TriageStagePayload(TypedDict, total=False): cited_ids: list[str] timestamp: str issue_count: int + dimension_names: list[str] + dimension_counts: dict[str, int] recurring_dims: list[str] confirmed_at: str confirmed_text: str + assessments: list[dict[str, Any]] # Structured reflect contract (populated only for reflect stage) disposition_ledger: list[ReflectDisposition] cluster_blueprint: list[ReflectClusterBlueprint] @@ -216,7 +224,7 @@ class PlanModel(TypedDict, total=False): overrides: dict[str, ItemOverride] clusters: dict[str, Cluster] superseded: dict[str, SupersededEntry] - promoted_ids: list[str] # IDs user explicitly positioned via move_items() + promoted_ids: list[str] # IDs explicitly promoted from backlog into the queue plan_start_scores: PlanStartScores previous_plan_start_scores: PlanStartScores refresh_state: RefreshState @@ -280,49 +288,54 @@ def triage_clusters(plan: dict[str, Any]) -> dict[str, Cluster]: } -def tracked_plan_ids(plan: dict[str, Any] | None) -> set[str]: - """Collect all issue IDs the plan is actively tracking. +def live_planned_queue_ids(plan: dict[str, Any] | None) -> set[str]: + """Return substantive live queue IDs sourced only from ``queue_order``. - Includes queue_order, skipped, overrides, and cluster members/step refs. - Returns an empty set for None or non-dict plans. + Overrides and clusters are ownership metadata — they must never expand + the live queue. Only explicit ``queue_order`` entries count. """ if not isinstance(plan, dict): return set() - tracked: set[str] = set(plan.get("queue_order", [])) - tracked.update(plan.get("skipped", {}).keys()) - tracked.update(plan.get("overrides", {}).keys()) - for cluster in plan.get("clusters", {}).values(): - tracked.update(cluster.get("issue_ids", [])) - for step in cluster.get("action_steps", []): - if isinstance(step, dict): - tracked.update(step.get("issue_refs", [])) - return tracked - - -def planned_objective_ids( + skipped_ids = set(plan.get("skipped", {}).keys()) + return { + str(issue_id) + for issue_id in plan.get("queue_order", []) + if isinstance(issue_id, str) + and issue_id + and issue_id not in skipped_ids + and not any(issue_id.startswith(prefix) for prefix in SYNTHETIC_PREFIXES) + } + + +def executable_objective_ids( all_objective_ids: set[str], plan: dict[str, Any] | None, ) -> set[str]: - """Return the subset of objective IDs the plan considers active work. + """Return objective IDs eligible for execution. - Pre-triage (plan tracks nothing): all objective IDs are treated as - planned work. - - Once the plan tracks anything, only the intersection with live - objective IDs counts as planned. This lets stale queue_order entries - fail closed into postflight/backlog instead of broadening execution - back out to the entire objective backlog. + Before the plan tracks any queue work at all, all objective IDs are + implicitly executable. Once *any* queue items exist — including synthetic + review/workflow/triage items — execution becomes queue-driven and only + objective IDs explicitly present in ``plan["queue_order"]`` remain + eligible for ``next``. """ - tracked = tracked_plan_ids(plan) - skipped_ids = set(plan.get("skipped", {}).keys()) if isinstance(plan, dict) else set() - active_tracked = { + if not isinstance(plan, dict): + return set(all_objective_ids) + skipped_ids = set(plan.get("skipped", {}).keys()) + queued_ids = { issue_id - for issue_id in tracked - skipped_ids - if not any(issue_id.startswith(prefix) for prefix in SYNTHETIC_PREFIXES) + for issue_id in plan.get("queue_order", []) + if isinstance(issue_id, str) + and issue_id + and issue_id not in skipped_ids } - if not active_tracked: - return set(all_objective_ids) - return all_objective_ids & active_tracked + live_queue_ids = live_planned_queue_ids(plan) + queued_objective_ids = all_objective_ids & live_queue_ids + if queued_objective_ids: + return queued_objective_ids + if not queued_ids: + return set(all_objective_ids) - skipped_ids + return set() def validate_plan(plan: dict[str, Any]) -> None: @@ -374,8 +387,8 @@ def validate_plan(plan: dict[str, Any]) -> None: "VALID_SKIP_KINDS", "empty_plan", "ensure_plan_defaults", - "planned_objective_ids", - "tracked_plan_ids", + "executable_objective_ids", + "live_planned_queue_ids", "triage_clusters", "validate_plan", ] diff --git a/desloppify/engine/_plan/schema/normalize.py b/desloppify/engine/_plan/schema/normalize.py index 4753a8b9..17cd9399 100644 --- a/desloppify/engine/_plan/schema/normalize.py +++ b/desloppify/engine/_plan/schema/normalize.py @@ -5,6 +5,8 @@ import re from typing import Any +from desloppify.engine._plan.cluster_semantics import normalize_cluster_semantics + _HEX_SUFFIX_RE = re.compile(r"^[0-9a-f]{8}$") @@ -206,6 +208,7 @@ def normalize_cluster_defaults(plan: dict[str, Any]) -> None: cluster.setdefault("cluster_key", "") cluster.setdefault("action", None) cluster.setdefault("user_modified", False) + normalize_cluster_semantics(cluster) def _append_normalized_issue_id( diff --git a/desloppify/engine/_plan/sync/__init__.py b/desloppify/engine/_plan/sync/__init__.py index bffcc616..068f91b4 100644 --- a/desloppify/engine/_plan/sync/__init__.py +++ b/desloppify/engine/_plan/sync/__init__.py @@ -4,15 +4,39 @@ ``engine._plan`` namespace: - ``context``: shared cycle/objective backlog predicates - ``dimensions``: subjective dimension queue sync +- ``phase_cleanup``: prune stale synthetic IDs on phase transitions +- ``pipeline``: shared boundary-triggered reconcile pipeline +- ``review_import``: review-import-specific queue mutation before boundary sync - ``triage``: triage-stage queue sync - ``workflow``: workflow gate queue sync - ``auto_prune``: auto-cluster stale pruning helper """ +from __future__ import annotations + +from typing import Any + __all__ = [ + "ReconcileResult", "auto_prune", "context", "dimensions", + "live_planned_queue_empty", + "pipeline", + "reconcile_plan", + "review_import", "triage", "workflow", ] + + +def __getattr__(name: str) -> Any: + if name in {"ReconcileResult", "live_planned_queue_empty", "reconcile_plan"}: + from .pipeline import ReconcileResult, live_planned_queue_empty, reconcile_plan + + return { + "ReconcileResult": ReconcileResult, + "live_planned_queue_empty": live_planned_queue_empty, + "reconcile_plan": reconcile_plan, + }[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/desloppify/engine/_plan/sync/context.py b/desloppify/engine/_plan/sync/context.py index 5e77f9ed..87351bb3 100644 --- a/desloppify/engine/_plan/sync/context.py +++ b/desloppify/engine/_plan/sync/context.py @@ -35,10 +35,13 @@ def has_objective_backlog( if policy is not None: return policy.has_objective_backlog - # Accept either full state payload (`{"issues": ...}`) or raw issues dict. + # Accept either full state payload (`{"work_items": ...}`) or a raw + # work-item mapping, with legacy ``issues`` as a fallback alias. issues = state_or_issues if isinstance(state_or_issues, dict): - maybe_issues = state_or_issues.get("issues") + maybe_issues = state_or_issues.get("work_items") + if not isinstance(maybe_issues, dict): + maybe_issues = state_or_issues.get("issues") if isinstance(maybe_issues, dict): issues = maybe_issues return any( diff --git a/desloppify/engine/_plan/sync/dimensions.py b/desloppify/engine/_plan/sync/dimensions.py index b7c71039..37535175 100644 --- a/desloppify/engine/_plan/sync/dimensions.py +++ b/desloppify/engine/_plan/sync/dimensions.py @@ -18,7 +18,12 @@ from desloppify.base.config import DEFAULT_TARGET_STRICT_SCORE from desloppify.engine._plan.policy import stale as stale_policy_mod -from desloppify.engine._plan.constants import SUBJECTIVE_PREFIX, QueueSyncResult +from desloppify.engine._plan.constants import ( + SUBJECTIVE_PREFIX, + QueueSyncResult, + is_triage_id, + is_workflow_id, +) from desloppify.engine._plan.schema import PlanModel, ensure_plan_defaults from desloppify.engine._plan.policy.subjective import SubjectiveVisibility from desloppify.engine._state.schema import StateModel @@ -167,9 +172,7 @@ def _promote_subjective_ids(order: list[str], ids: list[str]) -> int: insert_at = 0 while insert_at < len(order): current = str(order[insert_at]) - if not ( - current.startswith("workflow::") or current.startswith("triage::") - ): + if not (is_workflow_id(current) or is_triage_id(current)): break insert_at += 1 diff --git a/desloppify/engine/_plan/sync/phase_cleanup.py b/desloppify/engine/_plan/sync/phase_cleanup.py new file mode 100644 index 00000000..35154bc4 --- /dev/null +++ b/desloppify/engine/_plan/sync/phase_cleanup.py @@ -0,0 +1,82 @@ +"""Cleanup helpers for stale synthetic queue items across lifecycle phases.""" + +from __future__ import annotations + +from collections.abc import Iterable + +from desloppify.engine._plan.constants import ( + SUBJECTIVE_PREFIX, + TRIAGE_PREFIX, + WORKFLOW_PREFIX, +) +from desloppify.engine._plan.refresh_lifecycle import ( + LIFECYCLE_PHASE_EXECUTE, + LIFECYCLE_PHASE_REVIEW_POSTFLIGHT, + LIFECYCLE_PHASE_TRIAGE_POSTFLIGHT, + LIFECYCLE_PHASE_WORKFLOW_POSTFLIGHT, +) +from desloppify.engine._plan.schema import PlanModel, ensure_plan_defaults + + +def _phase_prefixes(phase: str) -> tuple[str, ...]: + if phase == LIFECYCLE_PHASE_WORKFLOW_POSTFLIGHT: + return (SUBJECTIVE_PREFIX,) + if phase in {LIFECYCLE_PHASE_TRIAGE_POSTFLIGHT, LIFECYCLE_PHASE_REVIEW_POSTFLIGHT}: + return (SUBJECTIVE_PREFIX, WORKFLOW_PREFIX) + if phase == LIFECYCLE_PHASE_EXECUTE: + return (SUBJECTIVE_PREFIX, WORKFLOW_PREFIX, TRIAGE_PREFIX) + return () + + +def _matches_any_prefix(issue_id: str, prefixes: Iterable[str]) -> bool: + return any(issue_id.startswith(prefix) for prefix in prefixes) + + +def prune_synthetic_for_phase(plan: PlanModel, phase: str) -> list[str]: + """Remove synthetic IDs that should not survive into ``phase``.""" + ensure_plan_defaults(plan) + prefixes = _phase_prefixes(phase) + if not prefixes: + return [] + + pruned: list[str] = [] + seen: set[str] = set() + + queue_order = plan.get("queue_order", []) + kept_order: list[str] = [] + for raw_id in queue_order: + if not isinstance(raw_id, str) or not _matches_any_prefix(raw_id, prefixes): + kept_order.append(raw_id) + continue + if raw_id not in seen: + pruned.append(raw_id) + seen.add(raw_id) + plan["queue_order"] = kept_order + + overrides = plan.get("overrides") + if isinstance(overrides, dict): + for issue_id in list(overrides): + if isinstance(issue_id, str) and _matches_any_prefix(issue_id, prefixes): + overrides.pop(issue_id, None) + + clusters = plan.get("clusters") + if isinstance(clusters, dict): + for cluster in clusters.values(): + if not isinstance(cluster, dict): + continue + issue_ids = cluster.get("issue_ids") + if not isinstance(issue_ids, list): + continue + cluster["issue_ids"] = [ + issue_id + for issue_id in issue_ids + if not ( + isinstance(issue_id, str) + and _matches_any_prefix(issue_id, prefixes) + ) + ] + + return pruned + + +__all__ = ["prune_synthetic_for_phase"] diff --git a/desloppify/engine/_plan/sync/pipeline.py b/desloppify/engine/_plan/sync/pipeline.py new file mode 100644 index 00000000..8d0a959b --- /dev/null +++ b/desloppify/engine/_plan/sync/pipeline.py @@ -0,0 +1,248 @@ +"""Shared boundary-triggered plan reconciliation pipeline.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from desloppify.state_scoring import score_snapshot +from desloppify.engine._plan.auto_cluster import auto_cluster_issues +from desloppify.engine._plan.constants import QueueSyncResult, is_synthetic_id +from desloppify.engine._plan.operations.meta import append_log_entry +from desloppify.engine._plan.policy.subjective import compute_subjective_visibility +from desloppify.engine._plan.policy.stale import open_review_ids +from desloppify.engine._plan.refresh_lifecycle import ( + LIFECYCLE_PHASE_ASSESSMENT_POSTFLIGHT, + LIFECYCLE_PHASE_REVIEW_INITIAL, + LIFECYCLE_PHASE_REVIEW_POSTFLIGHT, + LIFECYCLE_PHASE_SCAN, + LIFECYCLE_PHASE_TRIAGE_POSTFLIGHT, + LIFECYCLE_PHASE_WORKFLOW_POSTFLIGHT, + current_lifecycle_phase, + set_lifecycle_phase, +) +from desloppify.engine._plan.sync.dimensions import sync_subjective_dimensions +from desloppify.engine._plan.sync.phase_cleanup import prune_synthetic_for_phase +from desloppify.engine._plan.sync.triage import sync_triage_needed +from desloppify.engine._plan.triage.snapshot import build_triage_snapshot +from desloppify.engine._plan.sync.workflow import ( + ScoreSnapshot, + _subjective_review_current_for_cycle, + sync_communicate_score_needed, + sync_create_plan_needed, +) + + +@dataclass +class ReconcileResult: + """Mutation summary for one boundary-triggered reconcile pass.""" + + subjective: QueueSyncResult | None = None + auto_cluster_changes: int = 0 + communicate_score: QueueSyncResult | None = None + create_plan: QueueSyncResult | None = None + triage: QueueSyncResult | None = None + lifecycle_phase: str = "" + lifecycle_phase_changed: bool = False + phase_cleanup_pruned: list[str] | None = None + + @property + def dirty(self) -> bool: + return any( + ( + self.subjective is not None and bool(self.subjective.changes), + self.auto_cluster_changes > 0, + self.communicate_score is not None + and bool(self.communicate_score.changes), + self.create_plan is not None + and bool(self.create_plan.changes), + self.triage is not None + and bool( + self.triage.changes + or getattr(self.triage, "deferred", False) + ), + self.lifecycle_phase_changed, + bool(self.phase_cleanup_pruned), + ) + ) + + @property + def workflow_injected_ids(self) -> list[str]: + injected: list[str] = [] + for result in (self.communicate_score, self.create_plan): + if result is None: + continue + injected.extend(list(result.injected)) + return injected + + +def _current_scores(state: dict) -> ScoreSnapshot: + snapshot = score_snapshot(state) + return ScoreSnapshot( + strict=snapshot.strict, + overall=snapshot.overall, + objective=snapshot.objective, + verified=snapshot.verified, + ) + + +def _log_gate_changes(plan: dict, action: str, detail: dict[str, object]) -> None: + append_log_entry(plan, action, actor="system", detail=detail) + + +def _resolve_reconcile_phase( + plan: dict, + state: dict, + *, + result: ReconcileResult, + policy: object | None, +) -> str: + order = [item for item in plan.get("queue_order", []) if isinstance(item, str)] + if result.workflow_injected_ids or any(item.startswith("workflow::") for item in order): + return LIFECYCLE_PHASE_WORKFLOW_POSTFLIGHT + + if result.triage and (result.triage.injected or result.triage.deferred): + return LIFECYCLE_PHASE_TRIAGE_POSTFLIGHT + if any(item.startswith("triage::") for item in order): + return LIFECYCLE_PHASE_TRIAGE_POSTFLIGHT + + subjective_ids = [item for item in order if item.startswith("subjective::")] + if subjective_ids: + unscored_ids = set(getattr(policy, "unscored_ids", ()) or ()) + if any(item in unscored_ids for item in subjective_ids): + return LIFECYCLE_PHASE_REVIEW_INITIAL + return LIFECYCLE_PHASE_ASSESSMENT_POSTFLIGHT + + triage_snapshot = build_triage_snapshot(plan, state) + if ( + triage_snapshot.triage_has_run + and not triage_snapshot.has_triage_in_queue + and not triage_snapshot.is_triage_stale + and bool(triage_snapshot.live_open_ids) + ): + return LIFECYCLE_PHASE_REVIEW_POSTFLIGHT + + persisted = current_lifecycle_phase(plan) + if persisted: + return persisted + if open_review_ids(state): + return LIFECYCLE_PHASE_REVIEW_POSTFLIGHT + return LIFECYCLE_PHASE_SCAN + + +def live_planned_queue_empty(plan: dict) -> bool: + """Return True when queue_order has no remaining substantive items. + + Overrides and clusters are ownership metadata — they must never expand + the live queue. Only explicit ``queue_order`` entries count. + """ + order = plan.get("queue_order", []) + skipped = plan.get("skipped", {}) + return not any( + isinstance(item_id, str) + and item_id not in skipped + and not is_synthetic_id(item_id) + for item_id in order + ) + + +def reconcile_plan(plan: dict, state: dict, *, target_strict: float) -> ReconcileResult: + """Run the shared boundary reconciliation pipeline.""" + result = ReconcileResult() + if not live_planned_queue_empty(plan): + return result + + policy = compute_subjective_visibility( + state, + target_strict=target_strict, + plan=plan, + ) + cycle_just_completed = not plan.get("plan_start_scores") + + # Skip subjective sync when workflow will supersede it: all dims are + # scored and communicate-score hasn't fired yet this cycle. The phase + # cleanup safety net still prunes if this peek is wrong. + will_inject_workflow = ( + "previous_plan_start_scores" not in plan + and _subjective_review_current_for_cycle( + plan, + state, + policy=policy, + ) + ) + if will_inject_workflow: + result.subjective = QueueSyncResult() + else: + result.subjective = sync_subjective_dimensions( + plan, + state, + policy=policy, + cycle_just_completed=cycle_just_completed, + ) + if result.subjective.changes: + _log_gate_changes(plan, "sync_subjective", {"changes": True}) + + if will_inject_workflow: + result.auto_cluster_changes = 0 + else: + result.auto_cluster_changes = int( + auto_cluster_issues( + plan, + state, + target_strict=target_strict, + policy=policy, + ) + ) + if result.auto_cluster_changes: + _log_gate_changes(plan, "auto_cluster", {"changes": True}) + + result.communicate_score = sync_communicate_score_needed( + plan, + state, + policy=policy, + current_scores=_current_scores(state), + ) + if result.communicate_score.changes: + _log_gate_changes(plan, "sync_communicate_score", {"injected": True}) + + result.create_plan = sync_create_plan_needed( + plan, + state, + policy=policy, + ) + if result.create_plan.changes: + _log_gate_changes(plan, "sync_create_plan", {"injected": True}) + + result.triage = sync_triage_needed( + plan, + state, + policy=policy, + ) + if result.triage.injected: + _log_gate_changes(plan, "sync_triage", {"injected": True}) + + result.lifecycle_phase = _resolve_reconcile_phase( + plan, + state, + result=result, + policy=policy, + ) + result.lifecycle_phase_changed = set_lifecycle_phase(plan, result.lifecycle_phase) + if result.lifecycle_phase_changed: + _log_gate_changes( + plan, + "sync_lifecycle_phase", + {"phase": result.lifecycle_phase}, + ) + + result.phase_cleanup_pruned = prune_synthetic_for_phase(plan, result.lifecycle_phase) + if result.phase_cleanup_pruned: + _log_gate_changes( + plan, + "phase_transition_cleanup", + {"phase": result.lifecycle_phase, "pruned": list(result.phase_cleanup_pruned)}, + ) + + return result + + +__all__ = ["ReconcileResult", "live_planned_queue_empty", "reconcile_plan"] diff --git a/desloppify/engine/_plan/reconcile_review_import.py b/desloppify/engine/_plan/sync/review_import.py similarity index 83% rename from desloppify/engine/_plan/reconcile_review_import.py rename to desloppify/engine/_plan/sync/review_import.py index e20d26a7..47835f85 100644 --- a/desloppify/engine/_plan/reconcile_review_import.py +++ b/desloppify/engine/_plan/sync/review_import.py @@ -1,4 +1,4 @@ -"""Plan sync helpers for review-import flows.""" +"""Review-import-specific queue sync helpers.""" from __future__ import annotations @@ -11,6 +11,7 @@ compute_open_issue_ids, sync_triage_needed, ) +from desloppify.engine._state.issue_semantics import is_triage_finding from desloppify.engine._state.schema import StateModel @@ -22,6 +23,7 @@ class ReviewImportSyncResult: added_to_queue: list[str] triage_injected: bool stale_pruned_from_queue: list[str] = field(default_factory=list) + covered_subjective_pruned_from_queue: list[str] = field(default_factory=list) triage_injected_ids: list[str] = field(default_factory=list) triage_deferred: bool = False @@ -50,8 +52,15 @@ def _review_issue_ids_for_import_sync( return set(open_review_ids) if open_review_ids is not None else compute_open_issue_ids(state) -def _is_review_queue_id(issue_id: str) -> bool: - """Return True for queue IDs representing review/concerns issues.""" +def _is_review_queue_id(issue_id: str, state: StateModel) -> bool: + """Return True for queue IDs representing triage findings. + + Falls back to known review queue ID prefixes only when the issue payload + is absent. + """ + issue = (state.get("work_items") or state.get("issues", {})).get(issue_id) + if isinstance(issue, dict): + return is_triage_finding(issue) return issue_id.startswith("review::") or issue_id.startswith("concerns::") @@ -92,6 +101,7 @@ def _prune_stale_triage_meta( def _prune_stale_review_ids_from_plan( plan: PlanModel, + state: StateModel, *, live_open_review_ids: set[str], ) -> list[str]: @@ -106,7 +116,7 @@ def _prune_stale_review_ids_from_plan( { issue_id for issue_id in order - if _is_review_queue_id(issue_id) and issue_id not in live_open_review_ids + if _is_review_queue_id(issue_id, state) and issue_id not in live_open_review_ids } ) if not stale_ids: @@ -142,6 +152,7 @@ def sync_plan_after_review_import( state: StateModel, *, policy=None, + inject_triage: bool = True, ) -> ReviewImportSyncResult | None: """Sync plan queue after review import. Pure engine function — no I/O. @@ -154,6 +165,7 @@ def sync_plan_after_review_import( open_review_ids = compute_open_issue_ids(state) stale_pruned_from_queue = _prune_stale_review_ids_from_plan( plan, + state, live_open_review_ids=open_review_ids, ) new_ids = _review_issue_ids_for_import_sync( @@ -173,11 +185,14 @@ def sync_plan_after_review_import( order.append(issue_id) added.append(issue_id) - # Inject triage stages if needed (policy enables mid-cycle guard) - triage_result = sync_triage_needed(plan, state, policy=policy) - triage_injected_ids = list(getattr(triage_result, "injected", []) or []) - triage_injected = bool(triage_injected_ids) - triage_deferred = bool(triage_result and getattr(triage_result, "deferred", False)) + triage_injected_ids: list[str] = [] + triage_injected = False + triage_deferred = False + if inject_triage: + triage_result = sync_triage_needed(plan, state, policy=policy) + triage_injected_ids = list(getattr(triage_result, "injected", []) or []) + triage_injected = bool(triage_injected_ids) + triage_deferred = bool(triage_result and getattr(triage_result, "deferred", False)) return ReviewImportSyncResult( new_ids=new_ids, diff --git a/desloppify/engine/_plan/sync/triage.py b/desloppify/engine/_plan/sync/triage.py index b9f1f8b2..b520d4d2 100644 --- a/desloppify/engine/_plan/sync/triage.py +++ b/desloppify/engine/_plan/sync/triage.py @@ -67,7 +67,14 @@ def _inject_pending_triage_stages( Always appends to the back — new items never reorder existing queue. Returns list of injected stage IDs. """ - stage_names = ("observe", "reflect", "organize", "enrich", "sense-check", "commit") + stage_names = ( + "observe", + "reflect", + "organize", + "enrich", + "sense-check", + "commit", + ) existing = set(order) injected: list[str] = [] for sid, name in zip(TRIAGE_STAGE_IDS, stage_names, strict=False): diff --git a/desloppify/engine/_plan/sync/workflow.py b/desloppify/engine/_plan/sync/workflow.py index 5fc795d9..d3894f29 100644 --- a/desloppify/engine/_plan/sync/workflow.py +++ b/desloppify/engine/_plan/sync/workflow.py @@ -6,6 +6,9 @@ from pathlib import Path from typing import Any +from desloppify.engine._plan.refresh_lifecycle import ( + subjective_review_completed_for_scan, +) from desloppify.engine._plan.policy import stale as stale_policy_mod from desloppify.engine._plan.constants import ( SUBJECTIVE_PREFIX, @@ -99,11 +102,11 @@ def _build_pending_import_scores_meta( import_payload: dict[str, Any] | None, issues_only_audit: dict[str, Any] | None, ) -> PendingImportScoresMeta: - provenance = {} + packet_sha256 = "" if isinstance(import_payload, dict): raw_provenance = import_payload.get("provenance") if isinstance(raw_provenance, dict): - provenance = raw_provenance + packet_sha256 = str(raw_provenance.get("packet_sha256", "")).strip() recorded_file = ( str(import_file).strip() if isinstance(import_file, str) and import_file.strip() @@ -114,11 +117,13 @@ def _build_pending_import_scores_meta( timestamp = "" if isinstance(issues_only_audit, dict): timestamp = str(issues_only_audit.get("timestamp", "")).strip() + if not packet_sha256 and isinstance(issues_only_audit, dict): + packet_sha256 = str(issues_only_audit.get("packet_sha256", "")).strip() return PendingImportScoresMeta( timestamp=timestamp, import_file=recorded_file, normalized_import_file=_normalize_match_path(recorded_file) or "", - packet_sha256=str(provenance.get("packet_sha256", "")).strip(), + packet_sha256=packet_sha256, ) @@ -244,6 +249,38 @@ def _no_unscored( ) +def _subjective_review_current_for_cycle( + plan: PlanModel, + state: StateModel, + *, + policy: SubjectiveVisibility | None, +) -> bool: + """Return True when the current cycle no longer owes subjective review.""" + if not _no_unscored(state, policy): + return False + + refresh_state = _get_refresh_state(plan) + if refresh_state is None: + return True + + postflight_scan_count = refresh_state.get("postflight_scan_completed_at_scan_count") + try: + current_scan_count = int(state.get("scan_count", 0) or 0) + except (TypeError, ValueError): + current_scan_count = 0 + + if postflight_scan_count != current_scan_count: + return True + + if subjective_review_completed_for_scan(plan, scan_count=current_scan_count): + return True + + if policy is not None: + return not (policy.stale_ids or policy.under_target_ids) + + return True + + def _inject(plan: PlanModel, item_id: str) -> QueueSyncResult: """Inject *item_id* into the workflow prefix and clear stale skip entries.""" order = plan["queue_order"] @@ -256,6 +293,16 @@ def _inject(plan: PlanModel, item_id: str) -> QueueSyncResult: return QueueSyncResult(injected=[item_id]) +def clear_score_communicated_sentinel(plan: PlanModel) -> None: + """Clear the ``previous_plan_start_scores`` sentinel. + + Call this in import/scan pre-steps when a trusted import completes or + a cycle boundary resets. The sentinel gates ``sync_communicate_score_needed`` + — clearing it allows communicate-score to re-inject next cycle. + """ + plan.pop("previous_plan_start_scores", None) + + _EMPTY = QueueSyncResult @@ -306,7 +353,7 @@ def sync_create_plan_needed( return _EMPTY() if any(sid in order for sid in TRIAGE_IDS): return _EMPTY() - if not _no_unscored(state, policy): + if not _subjective_review_current_for_cycle(plan, state, policy=policy): return _EMPTY() if not has_objective_backlog(state, policy): @@ -395,18 +442,15 @@ def sync_communicate_score_needed( state: StateModel, *, policy: SubjectiveVisibility | None = None, - scores_just_imported: bool = False, current_scores: ScoreSnapshot | None = None, ) -> QueueSyncResult: """Inject ``workflow::communicate-score`` and rebaseline scores. Injects when: - - All initial subjective reviews are complete (no unscored dims), OR - scores were just imported (trusted/attested/override) + - All initial subjective reviews are complete (no unscored dims) - ``workflow::communicate-score`` is not already in the queue - Score has not already been communicated this cycle - (``previous_plan_start_scores`` absent), unless a trusted score import - explicitly refreshed the live score mid-cycle + (``previous_plan_start_scores`` absent) When injected and *current_scores* is provided, ``plan_start_scores`` is rebaselined to the current score so the score display unfreezes at @@ -422,9 +466,9 @@ def sync_communicate_score_needed( return _EMPTY() # Already communicated this cycle — previous_plan_start_scores is set # at injection time and cleared at cycle boundaries. - if "previous_plan_start_scores" in plan and not scores_just_imported: + if "previous_plan_start_scores" in plan: return _EMPTY() - if not scores_just_imported and not _no_unscored(state, policy): + if not _subjective_review_current_for_cycle(plan, state, policy=policy): return _EMPTY() if current_scores is not None: @@ -459,6 +503,7 @@ def _rebaseline_plan_start_scores( __all__ = [ "PendingImportScoresMeta", "ScoreSnapshot", + "clear_score_communicated_sentinel", "import_scores_meta_matches", "pending_import_scores_meta", "sync_communicate_score_needed", diff --git a/desloppify/engine/_plan/triage/apply.py b/desloppify/engine/_plan/triage/apply.py index e8e0953f..0f817787 100644 --- a/desloppify/engine/_plan/triage/apply.py +++ b/desloppify/engine/_plan/triage/apply.py @@ -4,6 +4,7 @@ from dataclasses import dataclass +from desloppify.engine._plan.cluster_semantics import EXECUTION_STATUS_ACTIVE from desloppify.engine._plan.policy.stale import review_issue_snapshot_hash from desloppify.engine._plan.schema import ( EPIC_PREFIX, @@ -12,7 +13,8 @@ ensure_plan_defaults, ) from desloppify.engine._plan.skip_policy import skip_kind_state_status -from desloppify.engine._state.schema import StateModel, utc_now +from desloppify.engine._state.issue_semantics import is_triage_finding +from desloppify.engine._state.schema import StateModel, ensure_state_defaults, utc_now from .dismiss import dismiss_triage_issues from .prompt import TriageResult @@ -67,6 +69,7 @@ def _update_existing_epic_cluster( existing["agent_safe"] = epic_data.get("agent_safe", False) existing["dependency_order"] = epic_data["dependency_order"] existing["action_steps"] = epic_data.get("action_steps", []) + existing["execution_status"] = EXECUTION_STATUS_ACTIVE existing["updated_at"] = now existing["triage_version"] = version existing["description"] = epic_data["thesis"] @@ -89,6 +92,7 @@ def _create_epic_cluster( "auto": True, "cluster_key": f"epic::{epic_name}", "action": f"desloppify plan focus {epic_name}", + "execution_status": EXECUTION_STATUS_ACTIVE, "user_modified": False, "created_at": now, "updated_at": now, @@ -169,9 +173,9 @@ def _set_triage_meta( current_hash = review_issue_snapshot_hash(state) open_review_ids = sorted( fid - for fid, issue in state.get("issues", {}).items() + for fid, issue in (state.get("work_items") or state.get("issues", {})).items() if issue.get("status") == "open" - and issue.get("detector") in ("review", "concerns") + and is_triage_finding(issue) ) plan["epic_triage_meta"] = { @@ -200,6 +204,7 @@ def apply_triage_to_plan( 4. Updates epic_triage_meta with snapshot hash """ ensure_plan_defaults(plan) + ensure_state_defaults(state) now = utc_now() result = TriageMutationResult() result.strategy_summary = triage.strategy_summary @@ -231,7 +236,7 @@ def apply_triage_to_plan( result.issues_dismissed += dismiss_count # Sync state status for dismissed issues so state is authoritative. - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) triaged_out_status = skip_kind_state_status("triaged_out") for fid in dismissed_ids: issue = issues.get(fid) diff --git a/desloppify/engine/_plan/triage/core.py b/desloppify/engine/_plan/triage/core.py index a1d659d8..9a2caf76 100644 --- a/desloppify/engine/_plan/triage/core.py +++ b/desloppify/engine/_plan/triage/core.py @@ -117,11 +117,11 @@ def triage_epics( si = collect_triage_input(plan, state) prompt = build_triage_prompt(si) - valid_ids = set(si.open_issues.keys()) + valid_ids = set(si.review_issues.keys()) if dry_run or deps is None or deps.llm_call is None: result = TriageMutationResult(dry_run=True) - result.strategy_summary = f"[dry-run] Prompt built with {len(si.open_issues)} issues" + result.strategy_summary = f"[dry-run] Prompt built with {len(si.review_issues)} issues" return result try: diff --git a/desloppify/engine/_plan/triage/playbook.py b/desloppify/engine/_plan/triage/playbook.py index 26564c81..69ca9234 100644 --- a/desloppify/engine/_plan/triage/playbook.py +++ b/desloppify/engine/_plan/triage/playbook.py @@ -9,7 +9,7 @@ ("reflect", "Form strategy & present to user"), ("organize", "Defer contradictions, cluster, & prioritize"), ("enrich", "Make steps executor-ready (detail, refs)"), - ("sense-check", "Verify accuracy & cross-cluster deps"), + ("sense-check", "Verify accuracy, structure & value"), ("commit", "Write strategy & confirm"), ) diff --git a/desloppify/engine/_plan/triage/prompt.py b/desloppify/engine/_plan/triage/prompt.py index 29674b95..0284df18 100644 --- a/desloppify/engine/_plan/triage/prompt.py +++ b/desloppify/engine/_plan/triage/prompt.py @@ -5,22 +5,26 @@ from dataclasses import dataclass, field from typing import Any +from desloppify.engine._plan.cluster_semantics import cluster_autofix_hint from desloppify.engine._plan.schema import ( Cluster, + EPIC_PREFIX, PlanModel, ensure_plan_defaults, triage_clusters, ) from desloppify.engine._plan.triage.snapshot import build_triage_snapshot +from desloppify.engine._state.issue_semantics import is_triage_finding from desloppify.engine._state.schema import StateModel -@dataclass +@dataclass(init=False) class TriageInput: """All data needed to produce/update triage clusters.""" - open_issues: dict[str, dict] # id -> issue (review + concerns) - mechanical_issues: dict[str, dict] # id -> issue (non-review, for context) + review_issues: dict[str, dict] # id -> issue (review + concerns) + objective_backlog_issues: dict[str, dict] # id -> issue (non-review, for context) + auto_clusters: dict[str, dict] # auto/ clusters available for promotion existing_clusters: dict[str, Cluster] dimension_scores: dict[str, Any] # for context new_since_last: set[str] # issue IDs new since last triage @@ -29,6 +33,63 @@ class TriageInput: triage_version: int # next version number resolved_issues: dict[str, dict] # full issue objects for resolved IDs completed_clusters: list[dict] # clusters completed since last triage + value_check_targets: list[str] | None + + def __init__( + self, + *, + review_issues: dict[str, dict] | None = None, + objective_backlog_issues: dict[str, dict] | None = None, + auto_clusters: dict[str, dict] | None = None, + existing_clusters: dict[str, Cluster], + dimension_scores: dict[str, Any], + new_since_last: set[str], + resolved_since_last: set[str], + previously_dismissed: list[str], + triage_version: int, + resolved_issues: dict[str, dict], + completed_clusters: list[dict], + value_check_targets: list[str] | None = None, + open_issues: dict[str, dict] | None = None, + mechanical_issues: dict[str, dict] | None = None, + ) -> None: + if review_issues is not None and open_issues is not None: + raise TypeError("Pass either review_issues or open_issues, not both.") + if objective_backlog_issues is not None and mechanical_issues is not None: + raise TypeError( + "Pass either objective_backlog_issues or mechanical_issues, not both." + ) + + self.review_issues = ( + review_issues + if review_issues is not None + else (open_issues if open_issues is not None else {}) + ) + self.objective_backlog_issues = ( + objective_backlog_issues + if objective_backlog_issues is not None + else (mechanical_issues if mechanical_issues is not None else {}) + ) + self.auto_clusters = auto_clusters if auto_clusters is not None else {} + self.existing_clusters = existing_clusters + self.dimension_scores = dimension_scores + self.new_since_last = new_since_last + self.resolved_since_last = resolved_since_last + self.previously_dismissed = previously_dismissed + self.triage_version = triage_version + self.resolved_issues = resolved_issues + self.completed_clusters = completed_clusters + self.value_check_targets = value_check_targets + + @property + def open_issues(self) -> dict[str, dict]: + """Backward-compatible alias for older triage callsites.""" + return self.review_issues + + @property + def mechanical_issues(self) -> dict[str, dict]: + """Backward-compatible alias for older triage callsites.""" + return self.objective_backlog_issues @dataclass class DismissedIssue: @@ -71,11 +132,11 @@ def _issue_dimension(issue: dict) -> str: def _recurring_dimensions( - open_issues: dict[str, dict], + review_issues: dict[str, dict], resolved_issues: dict[str, dict], ) -> dict[str, dict[str, list[str]]]: open_by_dim: dict[str, list[str]] = {} - for issue_id, issue in open_issues.items(): + for issue_id, issue in review_issues.items(): dimension = _issue_dimension(issue) if dimension: open_by_dim.setdefault(dimension, []).append(issue_id) @@ -103,7 +164,7 @@ def _split_open_issue_buckets( for issue_id, issue in issues.items(): if issue.get("status") != "open": continue - if issue.get("detector") in ("review", "concerns"): + if is_triage_finding(issue): open_review[issue_id] = issue continue open_mechanical[issue_id] = issue @@ -124,9 +185,14 @@ def _recent_completed_clusters(meta: dict, plan: PlanModel) -> list[dict]: def collect_triage_input(plan: PlanModel, state: StateModel) -> TriageInput: """Gather all data needed for the triage LLM prompt.""" ensure_plan_defaults(plan) - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) meta = plan.get("epic_triage_meta", {}) epics = triage_clusters(plan) + auto_clusters = { + name: cluster + for name, cluster in plan.get("clusters", {}).items() + if cluster.get("auto") and not name.startswith(EPIC_PREFIX) + } open_review, open_mechanical = _split_open_issue_buckets(issues) snapshot = build_triage_snapshot(plan, state) @@ -143,8 +209,9 @@ def collect_triage_input(plan: PlanModel, state: StateModel) -> TriageInput: } return TriageInput( - open_issues=open_review, - mechanical_issues=open_mechanical, + review_issues=open_review, + objective_backlog_issues=open_mechanical, + auto_clusters=auto_clusters, existing_clusters=dict(epics), dimension_scores=state.get("dimension_scores", {}), new_since_last=new_since, @@ -157,7 +224,8 @@ def collect_triage_input(plan: PlanModel, state: StateModel) -> TriageInput: _TRIAGE_SYSTEM_PROMPT = """\ You are maintaining the meta-plan for this codebase. Produce a coherent -prioritized strategy for all open review issues. +prioritized strategy for all open review issues, and decide which mechanical +backlog items should be promoted into the active queue now. Your plan should: - Cluster issues by ROOT CAUSE, not by dimension or detector @@ -171,8 +239,9 @@ def collect_triage_input(plan: PlanModel, state: StateModel) -> TriageInput: Available directions for clusters: delete, merge, flatten, enforce, simplify, decompose, extract, inline. Available plan tools (the agent executing your plan has access to these): -- `desloppify plan queue` — view the execution queue in priority order +- `desloppify plan queue` — view the explicit execution queue in priority order - `desloppify backlog` — inspect broader open work outside the execution queue +- `desloppify plan promote <id-or-pattern>` — move backlog work into the active queue - `desloppify plan focus <name>` — focus the queue on one cluster - `desloppify plan skip <id> --permanent --note "why" --attest "..."` — permanently dismiss - `desloppify plan skip <id> --note "revisit later"` — temporarily defer @@ -182,9 +251,10 @@ def collect_triage_input(plan: PlanModel, state: StateModel) -> TriageInput: - `desloppify scan` — re-scan after making changes to verify progress - `desloppify show review --status open` — see all open review issues -Your output defines the ENTIRE work plan. Issues not assigned to any cluster -will remain in the queue as individual items. Dismissed issues will be -removed from the queue with your stated reason. +Your output defines the active work plan for review findings and any explicitly +promoted backlog work. Mechanical backlog items you do not mention remain in +backlog by default. Dismissed issues will be removed from the queue with your +stated reason. Respond with a single JSON object matching this schema: { @@ -302,10 +372,10 @@ def _append_completed_clusters_section(parts: list[str], completed_clusters: lis def _append_recurring_dimensions_section( parts: list[str], - open_issues: dict[str, dict], + review_issues: dict[str, dict], resolved_issues: dict[str, dict], ) -> None: - recurring = _recurring_dimensions(open_issues, resolved_issues) + recurring = _recurring_dimensions(review_issues, resolved_issues) if not recurring: return parts.append( @@ -320,9 +390,9 @@ def _append_recurring_dimensions_section( parts.append("") -def _append_open_review_issues_section(parts: list[str], open_issues: dict[str, dict]) -> None: - parts.append(f"## All open review issues ({len(open_issues)})") - for issue_id, issue in sorted(open_issues.items()): +def _append_open_review_issues_section(parts: list[str], review_issues: dict[str, dict]) -> None: + parts.append(f"## All open review issues ({len(review_issues)})") + for issue_id, issue in sorted(review_issues.items()): detail = issue.get("detail", {}) if isinstance(issue.get("detail"), dict) else {} suggestion = detail.get("suggestion", "") dimension = detail.get("dimension", "") @@ -353,6 +423,132 @@ def _append_dimension_scores_section(parts: list[str], dimension_scores: dict[st parts.append("") +def _append_mechanical_backlog_section( + parts: list[str], + objective_backlog_issues: dict[str, dict], + auto_clusters: dict[str, dict], +) -> None: + if not objective_backlog_issues: + return + + clustered_ids: set[str] = set() + for cluster in auto_clusters.values(): + issue_ids = cluster.get("issue_ids", []) + if isinstance(issue_ids, list): + clustered_ids.update( + issue_id + for issue_id in issue_ids + if isinstance(issue_id, str) and issue_id in objective_backlog_issues + ) + + unclustered = { + issue_id: issue + for issue_id, issue in objective_backlog_issues.items() + if issue_id not in clustered_ids + } + clustered_issue_count = len(clustered_ids) + auto_cluster_count = sum( + 1 for cluster in auto_clusters.values() + if any( + isinstance(issue_id, str) and issue_id in objective_backlog_issues + for issue_id in cluster.get("issue_ids", []) + ) + ) + + parts.append( + "## Mechanical backlog " + f"({len(objective_backlog_issues)} items: {clustered_issue_count} in " + f"{auto_cluster_count} auto-clusters, {len(unclustered)} unclustered)" + ) + parts.append( + "These detector-created items stay in backlog unless you explicitly promote them into the active queue." + ) + parts.append("Silence means leave the item or cluster in backlog.") + + rendered_clusters: list[tuple[str, dict, int]] = [] + for name, cluster in auto_clusters.items(): + raw_issue_ids = cluster.get("issue_ids", []) + if not isinstance(raw_issue_ids, list): + continue + member_count = sum( + 1 for issue_id in raw_issue_ids + if isinstance(issue_id, str) and issue_id in objective_backlog_issues + ) + if member_count <= 0: + continue + rendered_clusters.append((name, cluster, member_count)) + + if rendered_clusters: + parts.append("### Auto-clusters") + parts.append( + "These are pre-grouped detector findings. Promote whole clusters with " + "`desloppify plan promote auto/<name>`." + ) + rendered_clusters.sort(key=lambda item: (-item[2], item[0])) + visible_clusters = rendered_clusters[:15] + for name, cluster, member_count in visible_clusters: + autofix_hint = _cluster_autofix_hint(cluster) + hint_suffix = f" [autofix: {autofix_hint}]" if autofix_hint else "" + summary = _cluster_backlog_summary(name, cluster, member_count) + parts.append(f"- {name} ({member_count} items){hint_suffix}") + parts.append(f" {summary}") + if len(rendered_clusters) > len(visible_clusters): + remaining = rendered_clusters[len(visible_clusters):] + remaining_issues = sum(item[2] for item in remaining) + parts.append( + f"- ... and {len(remaining)} more clusters ({remaining_issues} issues)" + ) + + if unclustered: + parts.append( + f"### Unclustered items ({len(unclustered)} items — needs human judgment or isolated findings)" + ) + parts.append( + "Promote individually with `desloppify plan promote <issue-id>`, or group related items into a manual cluster." + ) + sample_ids = sorted( + unclustered, + key=lambda issue_id: ( + _confidence_sort_key(unclustered[issue_id]), + issue_id, + ), + )[:10] + for issue_id in sample_ids: + issue = unclustered[issue_id] + confidence = str(issue.get("confidence", "medium")) + summary = str(issue.get("summary", "(no summary)")) + parts.append(f"- [{confidence}] {issue_id} — {summary}") + if len(unclustered) > len(sample_ids): + parts.append( + f"- ... and {len(unclustered) - len(sample_ids)} more unclustered items" + ) + + parts.append("Browse full backlog: `desloppify backlog`") + parts.append("Inspect a cluster: `desloppify plan cluster show auto/<name>`") + parts.append("Inspect an issue: `desloppify show <issue-id>`") + parts.append("") + + +def _cluster_autofix_hint(cluster: dict[str, Any]) -> str: + return cluster_autofix_hint(cluster) or "" + + +def _cluster_backlog_summary(name: str, cluster: dict[str, Any], member_count: int) -> str: + description = str(cluster.get("description") or "").strip() + if description: + return description + title = name.removeprefix("auto/").replace("-", " ") + if title: + return f"Address {member_count} {title} findings" + return f"Address {member_count} detector findings" + + +def _confidence_sort_key(issue: dict[str, Any]) -> int: + confidence = str(issue.get("confidence", "medium")).lower() + order = {"high": 0, "medium": 1, "low": 2} + return order.get(confidence, 1) + + def _append_previously_dismissed_section(parts: list[str], dismissed_ids: list[str]) -> None: if not dismissed_ids: return @@ -371,7 +567,7 @@ def build_triage_prompt(si: TriageInput) -> str: parts, title="New issues since last triage", issue_ids=si.new_since_last, - issues=si.open_issues, + issues=si.review_issues, ) _append_changed_issue_section( parts, @@ -380,9 +576,14 @@ def build_triage_prompt(si: TriageInput) -> str: ) _append_resolved_issue_context(parts, si.resolved_issues) _append_completed_clusters_section(parts, si.completed_clusters) - _append_recurring_dimensions_section(parts, si.open_issues, si.resolved_issues) - _append_open_review_issues_section(parts, si.open_issues) + _append_recurring_dimensions_section(parts, si.review_issues, si.resolved_issues) + _append_open_review_issues_section(parts, si.review_issues) _append_dimension_scores_section(parts, si.dimension_scores) + _append_mechanical_backlog_section( + parts, + si.objective_backlog_issues, + si.auto_clusters, + ) _append_previously_dismissed_section(parts, si.previously_dismissed) return "\n".join(parts) diff --git a/desloppify/engine/_plan/triage/snapshot.py b/desloppify/engine/_plan/triage/snapshot.py index c5dba950..a11589f4 100644 --- a/desloppify/engine/_plan/triage/snapshot.py +++ b/desloppify/engine/_plan/triage/snapshot.py @@ -4,12 +4,17 @@ from dataclasses import dataclass -from desloppify.engine._plan.constants import TRIAGE_IDS +from desloppify.engine._plan.cluster_membership import cluster_issue_ids +from desloppify.engine._plan.constants import TRIAGE_IDS, is_synthetic_id from desloppify.engine._plan.policy.stale import open_review_ids +from desloppify.engine._plan.schema import Cluster, PlanModel from desloppify.engine._plan.triage.playbook import TriageProgress, compute_triage_progress from desloppify.engine._state.schema import StateModel +_cluster_issue_ids = cluster_issue_ids + + def _normalized_issue_id_list(raw_ids: object) -> list[str]: normalized: list[str] = [] seen: set[str] = set() @@ -26,45 +31,18 @@ def _normalized_issue_id_list(raw_ids: object) -> list[str]: return normalized -def _cluster_issue_ids(cluster: dict[str, object]) -> list[str]: - ordered: list[str] = [] - seen: set[str] = set() - - def _append(raw_ids: object) -> None: - if not isinstance(raw_ids, list): - return - for raw_id in raw_ids: - if not isinstance(raw_id, str): - continue - issue_id = raw_id.strip() - if not issue_id or issue_id in seen: - continue - seen.add(issue_id) - ordered.append(issue_id) - - _append(cluster.get("issue_ids")) - steps = cluster.get("action_steps") - if isinstance(steps, list): - for step in steps: - if not isinstance(step, dict): - continue - _append(step.get("issue_refs")) - return ordered - - -def plan_review_ids(plan: dict) -> list[str]: +def plan_review_ids(plan: PlanModel) -> list[str]: """Return review/concerns IDs currently represented in queue_order.""" return [ issue_id for issue_id in plan.get("queue_order", []) if isinstance(issue_id, str) - and not issue_id.startswith("triage::") - and not issue_id.startswith("workflow::") + and not is_synthetic_id(issue_id) and (issue_id.startswith("review::") or issue_id.startswith("concerns::")) ] -def coverage_open_ids(plan: dict, state: StateModel) -> set[str]: +def coverage_open_ids(plan: PlanModel, state: StateModel) -> set[str]: """Return the frozen or live open review IDs covered by this triage run.""" meta = plan.get("epic_triage_meta", {}) active_ids = _normalized_issue_id_list(meta.get("active_triage_issue_ids")) @@ -77,7 +55,7 @@ def coverage_open_ids(plan: dict, state: StateModel) -> set[str]: return review_ids -def active_triage_issue_ids(plan: dict, state: StateModel | None = None) -> set[str]: +def active_triage_issue_ids(plan: PlanModel, state: StateModel | None = None) -> set[str]: """Return the frozen review issue set for the current triage run.""" meta = plan.get("epic_triage_meta", {}) active_ids = _normalized_issue_id_list(meta.get("active_triage_issue_ids")) @@ -88,12 +66,12 @@ def active_triage_issue_ids(plan: dict, state: StateModel | None = None) -> set[ return coverage_open_ids(plan, state) -def _explicit_active_triage_issue_ids(plan: dict) -> set[str]: +def _explicit_active_triage_issue_ids(plan: PlanModel) -> set[str]: meta = plan.get("epic_triage_meta", {}) return set(_normalized_issue_id_list(meta.get("active_triage_issue_ids"))) -def live_active_triage_issue_ids(plan: dict, state: StateModel | None = None) -> set[str]: +def live_active_triage_issue_ids(plan: PlanModel, state: StateModel | None = None) -> set[str]: """Return frozen triage IDs that are still open review issues in state.""" frozen_ids = active_triage_issue_ids(plan, state) if state is None or not frozen_ids: @@ -101,7 +79,7 @@ def live_active_triage_issue_ids(plan: dict, state: StateModel | None = None) -> return frozen_ids & open_review_ids(state) -def undispositioned_triage_issue_ids(plan: dict, state: StateModel | None = None) -> list[str]: +def undispositioned_triage_issue_ids(plan: PlanModel, state: StateModel | None = None) -> list[str]: """Return frozen triage issues still lacking cluster/skip/dismiss coverage.""" target_ids = live_active_triage_issue_ids(plan, state) if not target_ids: @@ -109,61 +87,51 @@ def undispositioned_triage_issue_ids(plan: dict, state: StateModel | None = None covered_ids: set[str] = set() for cluster in plan.get("clusters", {}).values(): - if not isinstance(cluster, dict) or cluster.get("auto"): + if cluster.get("auto"): continue covered_ids.update(_cluster_issue_ids(cluster)) skipped = plan.get("skipped", {}) - if isinstance(skipped, dict): - covered_ids.update( - issue_id for issue_id in skipped if isinstance(issue_id, str) - ) + covered_ids.update(issue_id for issue_id in skipped if isinstance(issue_id, str)) meta = plan.get("epic_triage_meta", {}) covered_ids.update(_normalized_issue_id_list(meta.get("dismissed_ids"))) dispositions = meta.get("issue_dispositions", {}) - if isinstance(dispositions, dict) and isinstance(skipped, dict): - for issue_id, disposition in dispositions.items(): - if ( - isinstance(issue_id, str) - and isinstance(disposition, dict) - and disposition.get("decision_source") == "observe_auto" - and issue_id in skipped - ): - covered_ids.add(issue_id) + for issue_id, disposition in dispositions.items(): + if disposition.get("decision_source") == "observe_auto" and issue_id in skipped: + covered_ids.add(issue_id) return sorted(issue_id for issue_id in target_ids if issue_id not in covered_ids) def triage_coverage( - plan: dict, + plan: PlanModel, open_review_ids: set[str] | None = None, -) -> tuple[int, int, dict[str, dict]]: +) -> tuple[int, int, dict[str, Cluster]]: """Return (organized, total, clusters) for review issues in triage.""" clusters = plan.get("clusters", {}) all_cluster_ids: set[str] = set() for cluster in clusters.values(): - if isinstance(cluster, dict): - all_cluster_ids.update(_cluster_issue_ids(cluster)) + all_cluster_ids.update(_cluster_issue_ids(cluster)) review_ids = list(open_review_ids) if open_review_ids is not None else plan_review_ids(plan) organized = sum(1 for issue_id in review_ids if issue_id in all_cluster_ids) return organized, len(review_ids), clusters -def manual_clusters_with_issues(plan: dict) -> list[str]: +def manual_clusters_with_issues(plan: PlanModel) -> list[str]: """Return manual clusters that currently own at least one issue.""" return [ name for name, cluster in plan.get("clusters", {}).items() - if isinstance(cluster, dict) and _cluster_issue_ids(cluster) and not cluster.get("auto") + if cluster_issue_ids(cluster) and not cluster.get("auto") ] -def find_cluster_for(issue_id: str, clusters: dict[str, dict]) -> str | None: +def find_cluster_for(issue_id: str, clusters: dict[str, Cluster]) -> str | None: """Return the owning cluster name for an issue ID, if any.""" for name, cluster in clusters.items(): - if isinstance(cluster, dict) and issue_id in _cluster_issue_ids(cluster): + if issue_id in cluster_issue_ids(cluster): return name return None @@ -185,7 +153,7 @@ class TriageSnapshot: triage_has_run: bool -def build_triage_snapshot(plan: dict, state: StateModel) -> TriageSnapshot: +def build_triage_snapshot(plan: PlanModel, state: StateModel) -> TriageSnapshot: """Build a canonical triage snapshot from plan and state.""" meta = plan.get("epic_triage_meta", {}) triaged_ids = set(_normalized_issue_id_list(meta.get("triaged_ids"))) diff --git a/desloppify/engine/_scoring/detection.py b/desloppify/engine/_scoring/detection.py index c18dfd45..a1249292 100644 --- a/desloppify/engine/_scoring/detection.py +++ b/desloppify/engine/_scoring/detection.py @@ -14,6 +14,7 @@ ScoreMode, detector_policy, ) +from desloppify.engine._state.issue_semantics import is_scoring_excluded_detector from desloppify.engine._state.schema import Issue # Tiered file-count cap thresholds for non-LOC file-based detectors. @@ -141,10 +142,9 @@ def detector_stats_by_mode( if potential <= 0: return {mode: (1.0, 0, 0.0) for mode in SCORING_MODES} - # Review and concern issues are scored via subjective assessments only — - # exclude them from the detection-side scoring pipeline so resolving these - # issues never changes the score directly. - if detector in ("review", "concerns"): + # Some detectors remain queue-visible but are intentionally excluded from + # detector-side scoring. + if is_scoring_excluded_detector(detector): return {mode: (1.0, 0, 0.0) for mode in SCORING_MODES} policy = detector_policy(detector) diff --git a/desloppify/engine/_scoring/policy/core.py b/desloppify/engine/_scoring/policy/core.py index 3e415b6a..8835a0b9 100644 --- a/desloppify/engine/_scoring/policy/core.py +++ b/desloppify/engine/_scoring/policy/core.py @@ -11,6 +11,7 @@ CONFIDENCE_WEIGHTS, HOLISTIC_MULTIPLIER, ) +from desloppify.engine._state.issue_semantics import is_scoring_excluded_detector from desloppify.engine.policy.zones import EXCLUDED_ZONE_VALUES ScoreMode = Literal["lenient", "strict", "verified_strict"] @@ -38,20 +39,6 @@ class DetectorScoringPolicy: SECURITY_EXCLUDED_ZONES = frozenset({"test", "config", "generated", "vendor"}) _DEFAULT_EXCLUDED_ZONES = frozenset(EXCLUDED_ZONE_VALUES) -# Non-objective detectors are tracked in state/queue but excluded from -# mechanical dimension scoring. -_NON_OBJECTIVE_DETECTORS = frozenset( - { - "concerns", - "review", - "subjective_review", - "uncalled_functions", - "unused_enums", - "signature", - "stale_wontfix", - } -) - # Keep policy details that are independent of tier/dimension wiring. _FILE_BASED_POLICY_DETECTORS = frozenset( {"smells", "dict_keys", "test_coverage", "security", "concerns", "review"} @@ -61,12 +48,18 @@ class DetectorScoringPolicy: "security": SECURITY_EXCLUDED_ZONES, } +# Legacy test/import surface for code that still expects subjective detectors +# to be grouped in scoring core. +_NON_OBJECTIVE_DETECTORS = frozenset( + {"review", "concerns", "subjective_review", "subjective_assessment"} +) + def _build_builtin_detector_scoring_policies() -> dict[str, DetectorScoringPolicy]: """Build baseline scoring policies from DetectorMeta plus policy overrides.""" policies: dict[str, DetectorScoringPolicy] = {} for detector, meta in DETECTORS.items(): - if detector in _NON_OBJECTIVE_DETECTORS: + if is_scoring_excluded_detector(detector): dimension: str | None = None tier: int | None = None else: diff --git a/desloppify/engine/_scoring/state_integration.py b/desloppify/engine/_scoring/state_integration.py index 99fc1089..a66bc603 100644 --- a/desloppify/engine/_scoring/state_integration.py +++ b/desloppify/engine/_scoring/state_integration.py @@ -205,7 +205,7 @@ def recompute_stats( ) -> None: """Recompute stats and canonical health scores from issues.""" ensure_state_defaults(state) - issues = path_scoped_issues(state["issues"], scan_path) + issues = path_scoped_issues(state.get("work_items") or state.get("issues", {}), scan_path) counters, tier_stats = _count_issues(issues) state["stats"] = { "total": sum(counters.values()), diff --git a/desloppify/engine/_scoring/subjective/core.py b/desloppify/engine/_scoring/subjective/core.py index f0b5e824..ca7ed9c6 100644 --- a/desloppify/engine/_scoring/subjective/core.py +++ b/desloppify/engine/_scoring/subjective/core.py @@ -3,15 +3,14 @@ from __future__ import annotations from desloppify.base.subjective_dimension_catalog import DISPLAY_NAMES -from desloppify.base.subjective_dimensions import default_dimension_keys +from desloppify.base.subjective_dimensions import ( + default_dimension_keys, + dimension_display_name, + dimension_weight, +) from desloppify.base.text_utils import is_numeric from desloppify.engine._scoring.policy.core import SUBJECTIVE_CHECKS -from desloppify.intelligence.review.dimensions.metadata import ( - dimension_display_name as metadata_dimension_display_name, -) -from desloppify.intelligence.review.dimensions.metadata import ( - dimension_weight as metadata_dimension_weight, -) +from desloppify.engine._state.issue_semantics import is_triage_finding def _display_fallback(dim_name: str) -> str: @@ -41,11 +40,11 @@ def _primary_lang_from_issues(issues: dict) -> str | None: def _dimension_display_name(dim_name: str, *, lang_name: str | None) -> str: - return str(metadata_dimension_display_name(dim_name, lang_name=lang_name)) + return str(dimension_display_name(dim_name, lang_name=lang_name)) def _dimension_weight(dim_name: str, *, lang_name: str | None) -> float: - return float(metadata_dimension_weight(dim_name, lang_name=lang_name)) + return float(dimension_weight(dim_name, lang_name=lang_name)) def _compute_dimension_score( @@ -174,7 +173,7 @@ def _subjective_issue_count( return sum( 1 for issue in issues.values() - if issue.get("detector") in ("review", "concerns") + if is_triage_finding(issue) and issue.get("status") in failure_set and _normalize_dimension_key(issue.get("detail", {}).get("dimension")) == dim_name ) diff --git a/desloppify/engine/_state/filtering.py b/desloppify/engine/_state/filtering.py index 6cc626c2..c1a4bc7b 100644 --- a/desloppify/engine/_state/filtering.py +++ b/desloppify/engine/_state/filtering.py @@ -17,6 +17,7 @@ ] from desloppify.base.discovery.file_paths import rel +from desloppify.engine._state.issue_semantics import ensure_work_item_semantics from desloppify.engine._state.schema import ( Issue, StateModel, @@ -113,12 +114,12 @@ def remove_ignored_issues(state: StateModel, pattern: str) -> int: ensure_state_defaults(state) matched_ids = [ issue_id - for issue_id, issue in state["issues"].items() + for issue_id, issue in state["work_items"].items() if is_ignored(issue_id, issue["file"], [pattern]) ] now = utc_now() for issue_id in matched_ids: - issue = state["issues"][issue_id] + issue = state["work_items"][issue_id] issue["suppressed"] = True issue["suppressed_at"] = now issue["suppression_pattern"] = pattern @@ -159,7 +160,7 @@ def make_issue( rfile = rel(file) issue_id = f"{detector}::{rfile}::{name}" if name else f"{detector}::{rfile}" now = utc_now() - return { + issue: Issue = { "id": issue_id, "detector": detector, "file": rfile, @@ -174,6 +175,8 @@ def make_issue( "resolved_at": None, "reopen_count": 0, } + ensure_work_item_semantics(issue) + return issue _HEX8_RE = re.compile(r'^[0-9a-f]{8}$') diff --git a/desloppify/engine/_state/issue_semantics.py b/desloppify/engine/_state/issue_semantics.py new file mode 100644 index 00000000..1da50dba --- /dev/null +++ b/desloppify/engine/_state/issue_semantics.py @@ -0,0 +1,225 @@ +"""Canonical work-item taxonomy and semantic helpers. + +This module owns the semantic meaning of persisted tracked work. Callers should +use these helpers instead of branching on detector strings or ID prefixes. + +Legacy ``issue`` names remain as aliases so the wider codebase can move +incrementally without losing semantic clarity. +""" + +from __future__ import annotations + +from typing import Any, Mapping, TypeAlias + +WorkItemKind: TypeAlias = str +WorkItemOrigin: TypeAlias = str + +MECHANICAL_DEFECT = "mechanical_defect" +REVIEW_DEFECT = "review_defect" +REVIEW_CONCERN = "review_concern" +ASSESSMENT_REQUEST = "assessment_request" + +SCAN_ORIGIN = "scan" +REVIEW_IMPORT_ORIGIN = "review_import" +SYNTHETIC_TASK_ORIGIN = "synthetic_task" + +WORK_ITEM_KINDS: frozenset[str] = frozenset( + { + MECHANICAL_DEFECT, + REVIEW_DEFECT, + REVIEW_CONCERN, + ASSESSMENT_REQUEST, + } +) +WORK_ITEM_ORIGINS: frozenset[str] = frozenset( + { + SCAN_ORIGIN, + REVIEW_IMPORT_ORIGIN, + SYNTHETIC_TASK_ORIGIN, + } +) + +_LEGACY_KIND_ALIASES: dict[str, str] = { + "mechanical_finding": MECHANICAL_DEFECT, + "review_finding": REVIEW_DEFECT, + "concern_finding": REVIEW_CONCERN, + "review_request": ASSESSMENT_REQUEST, +} +_LEGACY_ORIGIN_ALIASES: dict[str, str] = { + "synthetic_request": SYNTHETIC_TASK_ORIGIN, +} + +# Mechanical detectors that remain actionable work but stay excluded from +# detector-side scoring rules. +SCORING_EXCLUDED_DETECTORS: frozenset[str] = frozenset( + { + "concerns", + "review", + "subjective_review", + "uncalled_functions", + "unused_enums", + "signature", + "stale_wontfix", + } +) + + +def infer_work_item_kind( + detector: object, + *, + detail: Mapping[str, Any] | None = None, +) -> WorkItemKind: + """Infer a persisted work-item kind from detector/detail fields.""" + detector_name = str(detector or "").strip() + detail_dict = detail if isinstance(detail, Mapping) else {} + + if detector_name == "review": + return REVIEW_DEFECT + if detector_name == "concerns": + return REVIEW_CONCERN + if detector_name in {"subjective_review", "subjective_assessment", "holistic_review"}: + return ASSESSMENT_REQUEST + # Legacy imported confirmed concerns sometimes carried review-like detail; + # keep explicit concern markers mapped to concern findings. + if str(detail_dict.get("concern_verdict", "")).strip().lower() == "confirmed": + return REVIEW_CONCERN + return MECHANICAL_DEFECT + + +def infer_work_item_origin( + detector: object, + *, + detail: Mapping[str, Any] | None = None, +) -> WorkItemOrigin: + """Infer provenance for a persisted work item.""" + detector_name = str(detector or "").strip() + detail_dict = detail if isinstance(detail, Mapping) else {} + + if detector_name == "review": + return REVIEW_IMPORT_ORIGIN + if detector_name == "concerns": + verdict = str(detail_dict.get("concern_verdict", "")).strip().lower() + return REVIEW_IMPORT_ORIGIN if verdict == "confirmed" else SCAN_ORIGIN + if detector_name in {"subjective_review", "subjective_assessment", "holistic_review"}: + return SYNTHETIC_TASK_ORIGIN + return SCAN_ORIGIN + + +def normalized_work_item_kind(issue: Mapping[str, Any]) -> WorkItemKind: + """Return the canonical work-item kind, inferring from legacy data when needed.""" + raw_kind = str( + issue.get("work_item_kind", issue.get("issue_kind", "")) + ).strip() + if raw_kind in WORK_ITEM_KINDS: + return raw_kind + if raw_kind in _LEGACY_KIND_ALIASES: + return _LEGACY_KIND_ALIASES[raw_kind] + return infer_work_item_kind(issue.get("detector", ""), detail=_detail_dict(issue)) + + +def normalized_work_item_origin(issue: Mapping[str, Any]) -> WorkItemOrigin: + """Return the canonical work-item origin, inferring from legacy data when needed.""" + raw_origin = str(issue.get("origin", "")).strip() + if raw_origin in WORK_ITEM_ORIGINS: + return raw_origin + if raw_origin in _LEGACY_ORIGIN_ALIASES: + return _LEGACY_ORIGIN_ALIASES[raw_origin] + return infer_work_item_origin(issue.get("detector", ""), detail=_detail_dict(issue)) + + +def ensure_work_item_semantics(issue: dict[str, Any]) -> None: + """Populate canonical semantic fields in-place. + + Both ``work_item_kind`` and legacy ``issue_kind`` are written so current + persisted data and old state files remain readable while canonical runtime + semantics use the work-item terminology. + """ + kind = normalized_work_item_kind(issue) + origin = normalized_work_item_origin(issue) + issue["work_item_kind"] = kind + issue["issue_kind"] = kind + issue["origin"] = origin + + +def is_defect_work_item(issue: Mapping[str, Any]) -> bool: + return normalized_work_item_kind(issue) in { + MECHANICAL_DEFECT, + REVIEW_DEFECT, + REVIEW_CONCERN, + } + + +def is_objective_finding(issue: Mapping[str, Any]) -> bool: + return normalized_work_item_kind(issue) == MECHANICAL_DEFECT + + +def is_review_finding(issue: Mapping[str, Any]) -> bool: + return normalized_work_item_kind(issue) == REVIEW_DEFECT + + +def is_concern_finding(issue: Mapping[str, Any]) -> bool: + return normalized_work_item_kind(issue) == REVIEW_CONCERN + + +def is_review_work_item(issue: Mapping[str, Any]) -> bool: + return normalized_work_item_kind(issue) in {REVIEW_DEFECT, REVIEW_CONCERN} + + +def is_triage_finding(issue: Mapping[str, Any]) -> bool: + return is_review_work_item(issue) + + +def is_assessment_request(issue: Mapping[str, Any]) -> bool: + return normalized_work_item_kind(issue) == ASSESSMENT_REQUEST + + +def is_non_objective_issue(issue: Mapping[str, Any]) -> bool: + return not is_objective_finding(issue) + + +def counts_toward_objective_backlog(issue: Mapping[str, Any]) -> bool: + return is_objective_finding(issue) + + +def is_import_only_issue(issue: Mapping[str, Any]) -> bool: + return normalized_work_item_origin(issue) == REVIEW_IMPORT_ORIGIN + + +def is_scoring_excluded_detector(detector: object) -> bool: + detector_name = str(detector or "").strip() + return detector_name in SCORING_EXCLUDED_DETECTORS + + +def _detail_dict(issue: Mapping[str, Any]) -> Mapping[str, Any]: + detail = issue.get("detail", {}) + return detail if isinstance(detail, Mapping) else {} + + +__all__ = [ + "ASSESSMENT_REQUEST", + "MECHANICAL_DEFECT", + "REVIEW_CONCERN", + "REVIEW_DEFECT", + "REVIEW_IMPORT_ORIGIN", + "SCAN_ORIGIN", + "SCORING_EXCLUDED_DETECTORS", + "SYNTHETIC_TASK_ORIGIN", + "counts_toward_objective_backlog", + "ensure_work_item_semantics", + "infer_work_item_kind", + "infer_work_item_origin", + "is_assessment_request", + "is_concern_finding", + "is_defect_work_item", + "is_import_only_issue", + "is_non_objective_issue", + "is_objective_finding", + "is_review_finding", + "is_review_work_item", + "is_scoring_excluded_detector", + "is_triage_finding", + "normalized_work_item_kind", + "normalized_work_item_origin", + "WORK_ITEM_KINDS", + "WORK_ITEM_ORIGINS", +] diff --git a/desloppify/engine/_state/merge.py b/desloppify/engine/_state/merge.py index 8cdfa4e0..406a60df 100644 --- a/desloppify/engine/_state/merge.py +++ b/desloppify/engine/_state/merge.py @@ -11,6 +11,7 @@ ] from desloppify.base.registry import DETECTORS +from desloppify.engine._state.issue_semantics import ensure_work_item_semantics from desloppify.engine._state.merge_history import ( _append_scan_history, _build_merge_diff, @@ -37,11 +38,48 @@ from desloppify.base.registry import get_detector_meta +def _latest_trusted_assessment_import_timestamp(state: StateModel) -> str: + """Return the newest trusted assessment-import timestamp, if present.""" + for raw_entry in reversed(state.get("assessment_import_audit", []) or []): + if not isinstance(raw_entry, dict): + continue + if raw_entry.get("mode") not in {"trusted_internal", "attested_external"}: + continue + timestamp = str(raw_entry.get("timestamp", "")).strip() + if timestamp: + return timestamp + return "" + + +def _preserve_fresh_assessment_on_reconcile( + payload: dict[str, Any], + *, + previous_last_scan: str, + latest_trusted_import_ts: str, +) -> bool: + """Suppress immediate re-staling after a fresh trusted review import. + + A scan that runs directly after a trusted review import is reconciling the + issue inventory up to the code state that was just reviewed. If the + assessment was imported after the previous scan, we should not immediately + invalidate it based on that older scan delta. + """ + if previous_last_scan == "" or latest_trusted_import_ts == "": + return False + if latest_trusted_import_ts <= previous_last_scan: + return False + assessed_at = str(payload.get("assessed_at", "")).strip() + if assessed_at == "": + return False + return assessed_at >= latest_trusted_import_ts + + def _mark_stale_on_mechanical_change( state: StateModel, *, changed_detectors: set[str], now: str, + previous_last_scan: str, ) -> None: """Mark subjective assessments stale when mechanical issues change. @@ -73,6 +111,7 @@ def _mark_stale_on_mechanical_change( if not affected_dims: return + latest_trusted_import_ts = _latest_trusted_assessment_import_timestamp(state) for dimension in sorted(affected_dims): if dimension not in assessments: continue @@ -82,6 +121,12 @@ def _mark_stale_on_mechanical_change( # Don't overwrite if already stale if payload.get("needs_review_refresh"): continue + if _preserve_fresh_assessment_on_reconcile( + payload, + previous_last_scan=previous_last_scan, + latest_trusted_import_ts=latest_trusted_import_ts, + ): + continue payload["needs_review_refresh"] = True payload["refresh_reason"] = "mechanical_issues_changed" payload["stale_since"] = now @@ -101,6 +146,7 @@ class MergeScanOptions: include_slow: bool = True ignore: list[str] | None = None subjective_integrity_target: float | None = None + project_root: str | None = None def merge_scan( @@ -110,8 +156,12 @@ def merge_scan( ) -> ScanDiff: """Merge a fresh scan into existing state and return a diff summary.""" ensure_state_defaults(state) + for issue in current_issues: + if isinstance(issue, dict): + ensure_work_item_semantics(issue) resolved_options = options or MergeScanOptions() + previous_last_scan = str(state.get("last_scan", "") or "") now = utc_now() _record_scan_metadata( state, @@ -128,7 +178,7 @@ def merge_scan( codebase_metrics=resolved_options.codebase_metrics, ) - existing = state["issues"] + existing = state["work_items"] ignore_patterns = ( resolved_options.ignore if resolved_options.ignore is not None @@ -166,13 +216,17 @@ def merge_scan( lang=resolved_options.lang, scan_path=resolved_options.scan_path, exclude=resolved_options.exclude, + project_root=resolved_options.project_root, ) # Mark subjective assessments stale when mechanical issues changed. changed_detectors = upsert_changed | resolve_changed if changed_detectors: _mark_stale_on_mechanical_change( - state, changed_detectors=changed_detectors, now=now, + state, + changed_detectors=changed_detectors, + now=now, + previous_last_scan=previous_last_scan, ) _recompute_stats( diff --git a/desloppify/engine/_state/merge_issues.py b/desloppify/engine/_state/merge_issues.py index 61c1acc8..05c418b6 100644 --- a/desloppify/engine/_state/merge_issues.py +++ b/desloppify/engine/_state/merge_issues.py @@ -2,8 +2,14 @@ from __future__ import annotations +import os + from desloppify.base.discovery.file_paths import matches_exclusion from desloppify.engine._state.filtering import matched_ignore_pattern +from desloppify.engine._state.issue_semantics import ( + is_import_only_issue, + is_assessment_request, +) def find_suspect_detectors( @@ -25,14 +31,14 @@ def find_suspect_detectors( previous_open_by_detector.get(detector, 0) + 1 ) - # 'review' issues enter via `desloppify review --import`, not via scan phases. - # They are always suspect so the scan never auto-resolves them — regardless - # of current issue status (open, wontfix, etc.). - import_only_detectors = {"review"} - suspect: set[str] = set(import_only_detectors) + suspect: set[str] = { + str(issue.get("detector", "unknown")) + for issue in existing.values() + if isinstance(issue, dict) and is_import_only_issue(issue) + } for detector, previous_count in previous_open_by_detector.items(): - if detector in import_only_detectors: + if detector in suspect: continue if current_by_detector.get(detector, 0) > 0: continue @@ -76,13 +82,15 @@ def verify_disappeared( lang: str | None, scan_path: str | None, exclude: tuple[str, ...] = (), + project_root: str | None = None, ) -> tuple[int, int, int, set[str]]: """Update scan corroboration for issues absent from scan. Returns (resolved_count, skipped_other_lang, resolved_out_of_scope, changed_detectors). Queue-tracked work stays user-controlled: disappearing from scan does not - change an open issue to resolved. Manually resolved items can be marked as - scan-verified when they remain absent. + change an open issue to resolved — *unless* the source file no longer exists + on disk, in which case the issue is auto-resolved. Manually resolved items + can be marked as scan-verified when they remain absent. """ resolved = skipped_other_lang = resolved_out_of_scope = 0 resolved_detectors: set[str] = set() @@ -130,6 +138,21 @@ def verify_disappeared( continue if previous_status == "open": + # If the source file no longer exists on disk, auto-resolve: + # the issue cannot be actionable for a deleted file. + file_path = previous.get("file", "") + file_deleted = False + if project_root and file_path and file_path != ".": + file_deleted = not os.path.exists( + os.path.join(project_root, file_path) + ) + if not file_deleted: + continue + previous["status"] = "auto_resolved" + previous["resolved_at"] = now + previous["note"] = "Auto-resolved: source file no longer exists" + resolved_detectors.add(previous.get("detector", "unknown")) + resolved += 1 continue verification_note = ( @@ -213,11 +236,11 @@ def upsert_issues( previous["suppression_pattern"] = None if previous["status"] in ("fixed", "auto_resolved", "false_positive"): - # subjective_review issues are condition-based. When just + # Review-request issues are condition-based. When just # completed by an agent import, skip reopening to avoid a # resolve-then-reopen loop on the same scan cycle. if ( - detector == "subjective_review" + is_assessment_request(previous) and previous["status"] in {"fixed", "auto_resolved"} and (previous.get("resolution_attestation") or {}).get("kind") == "agent_import" ): diff --git a/desloppify/engine/_state/persistence.py b/desloppify/engine/_state/persistence.py index 932d0c8e..91522ba4 100644 --- a/desloppify/engine/_state/persistence.py +++ b/desloppify/engine/_state/persistence.py @@ -292,7 +292,11 @@ def save_state( state_path = path or _default_state_file() state_path.parent.mkdir(parents=True, exist_ok=True) - content = json.dumps(state, indent=2, default=json_default) + "\n" + serialized_state = { + key: value for key, value in state.items() if key != "issues" + } + serialized_state["work_items"] = dict((state.get("work_items") or state.get("issues", {}))) + content = json.dumps(serialized_state, indent=2, default=json_default) + "\n" if state_path.exists(): backup = state_path.with_suffix(".json.bak") @@ -327,7 +331,7 @@ def state_lock( Usage:: with state_lock(state_file) as state: - state["issues"]["foo"] = "fixed" + state["work_items"]["foo"] = "fixed" # state is saved automatically on clean exit """ state_path = path or _default_state_file() diff --git a/desloppify/engine/_state/recovery.py b/desloppify/engine/_state/recovery.py index 6373de70..9108e4a7 100644 --- a/desloppify/engine/_state/recovery.py +++ b/desloppify/engine/_state/recovery.py @@ -2,6 +2,7 @@ from __future__ import annotations +from desloppify.engine._state.issue_semantics import ensure_work_item_semantics from desloppify.engine._state.schema import ensure_state_defaults, scan_source @@ -93,7 +94,7 @@ def _hydrate_saved_issue_ids( issue_ids: list[str], ) -> dict: recovered = dict(state) - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) recovered_issues = dict(issues) if isinstance(issues, dict) else {} for issue_id in issue_ids: @@ -114,7 +115,9 @@ def _hydrate_saved_issue_ids( "recovered_from_plan": True, }, } + ensure_work_item_semantics(recovered_issues[issue_id]) + recovered["work_items"] = recovered_issues recovered["issues"] = recovered_issues recovered["scan_metadata"] = { "source": "plan_reconstruction", diff --git a/desloppify/engine/_state/resolution.py b/desloppify/engine/_state/resolution.py index af13f3e2..6a23dedf 100644 --- a/desloppify/engine/_state/resolution.py +++ b/desloppify/engine/_state/resolution.py @@ -12,6 +12,7 @@ from desloppify.base.text_utils import is_numeric from desloppify.engine._state.filtering import _matches_pattern +from desloppify.engine._state.issue_semantics import is_review_finding from desloppify.engine._state.schema import ( StateModel, ensure_state_defaults, @@ -72,7 +73,7 @@ def _mark_stale_assessments_on_review_resolve( touched_dimensions: set[str] = set() for issue in resolved_issues: - if issue.get("detector") != "review": + if not is_review_finding(issue): continue dimension = str(issue.get("detail", {}).get("dimension", "")).strip() if dimension: @@ -103,7 +104,7 @@ def match_issues( ensure_state_defaults(state) return [ issue - for issue_id, issue in state["issues"].items() + for issue_id, issue in state["work_items"].items() if not issue.get("suppressed") if (status_filter == "all" or issue["status"] == status_filter) and _matches_pattern(issue_id, issue, pattern) @@ -132,7 +133,7 @@ def _refresh_original_wontfix(issue: dict) -> None: original_issue_id = detail.get("original_issue_id") if not isinstance(original_issue_id, str) or not original_issue_id: return - original = state["issues"].get(original_issue_id) + original = state["work_items"].get(original_issue_id) if not isinstance(original, dict) or original.get("status") != "wontfix": return snapshot_scan_count = int(state.get("scan_count", 0) or 0) diff --git a/desloppify/engine/_state/schema.py b/desloppify/engine/_state/schema.py index c31b4934..05d13afc 100644 --- a/desloppify/engine/_state/schema.py +++ b/desloppify/engine/_state/schema.py @@ -8,6 +8,11 @@ from desloppify.base.discovery.paths import get_project_root from desloppify.base.enums import Status, canonical_issue_status, issue_status_tokens +from desloppify.engine._state.issue_semantics import ( + ensure_work_item_semantics, + WORK_ITEM_KINDS, + WORK_ITEM_ORIGINS, +) from desloppify.engine._state.schema_scores import ( json_default, ) @@ -18,6 +23,7 @@ DimensionScore, IgnoreIntegrityModel, Issue, + WorkItem, LangCapability, ReviewCacheModel, ScanMetadataModel, @@ -38,6 +44,7 @@ "AssessmentImportAuditEntry", "AttestationLogEntry", "Issue", + "WorkItem", "TierStats", "StateStats", "DimensionScore", @@ -85,7 +92,7 @@ def get_state_file() -> Path: return get_state_dir() / "state.json" -CURRENT_VERSION = 1 +CURRENT_VERSION = 3 def utc_now() -> str: @@ -95,6 +102,7 @@ def utc_now() -> str: def empty_state() -> StateModel: """Return a new empty state payload.""" + work_items: dict[str, WorkItem] = {} return { "version": CURRENT_VERSION, "created": utc_now(), @@ -105,7 +113,8 @@ def empty_state() -> StateModel: "strict_score": 0, "verified_strict_score": 0, "stats": {}, - "issues": {}, + "work_items": work_items, + "issues": work_items, "scan_coverage": {}, "score_confidence": {}, "scan_metadata": {"source": "empty"}, @@ -132,11 +141,13 @@ def _rename_key(d: dict, old: str, new: str) -> bool: def migrate_state_keys(state: StateModel | dict[str, Any]) -> None: """Migrate legacy key names in-place. - - ``"findings"`` → ``"issues"`` + - ``"findings"`` → ``"work_items"`` + - legacy ``"issues"`` → ``"work_items"`` - ``dimension_scores[dim]["issues"]`` → ``"failing"`` """ state_dict = cast(dict[str, Any], state) - _rename_key(state_dict, "findings", "issues") + _rename_key(state_dict, "findings", "work_items") + _rename_key(state_dict, "issues", "work_items") for ds in state_dict.get("dimension_scores", {}).values(): if isinstance(ds, dict): @@ -159,9 +170,12 @@ def _normalize_scan_metadata(state: StateModel | dict[str, Any]) -> None: normalized: ScanMetadataModel = {"source": source} if source == "plan_reconstruction": normalized["plan_queue_available"] = bool(metadata.get("plan_queue_available")) - issue_count = metadata.get("reconstructed_issue_count", 0) - if isinstance(issue_count, int) and not isinstance(issue_count, bool): - normalized["reconstructed_issue_count"] = max(0, issue_count) + work_item_count = metadata.get( + "reconstructed_work_item_count", + metadata.get("reconstructed_issue_count", 0), + ) + if isinstance(work_item_count, int) and not isinstance(work_item_count, bool): + normalized["reconstructed_issue_count"] = max(0, work_item_count) else: normalized["reconstructed_issue_count"] = 0 @@ -175,9 +189,18 @@ def ensure_state_defaults(state: StateModel | dict) -> None: mutable_state = cast(dict[str, Any], state) for key, value in empty_state().items(): mutable_state.setdefault(key, value) - - if not isinstance(state.get("issues"), dict): - state["issues"] = {} + version = mutable_state.get("version") + if not isinstance(version, int): + mutable_state["version"] = CURRENT_VERSION + elif version < CURRENT_VERSION: + mutable_state["version"] = CURRENT_VERSION + + if not isinstance(state.get("work_items"), dict): + legacy_items = state.get("issues") + state["work_items"] = legacy_items if isinstance(legacy_items, dict) else {} + # Keep the legacy alias available in-memory while internal call sites + # migrate, but make ``work_items`` the canonical storage. + state["issues"] = state["work_items"] if not isinstance(state.get("stats"), dict): state["stats"] = {} if not isinstance(state.get("scan_history"), list): @@ -190,7 +213,7 @@ def ensure_state_defaults(state: StateModel | dict) -> None: state["subjective_integrity"] = {} _normalize_scan_metadata(state) - all_issues = state["issues"] + all_issues = state["work_items"] to_remove: list[str] = [] for issue_id, issue in all_issues.items(): if not isinstance(issue, dict): @@ -199,6 +222,7 @@ def ensure_state_defaults(state: StateModel | dict) -> None: issue.setdefault("id", issue_id) issue.setdefault("detector", "unknown") + ensure_work_item_semantics(issue) issue.setdefault("file", "") issue.setdefault("tier", 3) issue.setdefault("confidence", "low") @@ -236,8 +260,8 @@ def ensure_state_defaults(state: StateModel | dict) -> None: def validate_state_invariants(state: StateModel) -> None: """Raise ValueError when core state invariants are violated.""" - if not isinstance(state.get("issues"), dict): - raise ValueError("state.issues must be a dict") + if not isinstance(state.get("work_items"), dict): + raise ValueError("state.work_items must be a dict") if not isinstance(state.get("stats"), dict): raise ValueError("state.stats must be a dict") metadata = state.get("scan_metadata") @@ -253,12 +277,22 @@ def validate_state_invariants(state: StateModel) -> None: "state.scan_metadata.reconstructed_issue_count must be a non-negative int" ) - all_issues = state["issues"] + all_issues = state["work_items"] for issue_id, issue in all_issues.items(): if not isinstance(issue, dict): raise ValueError(f"issue {issue_id!r} must be a dict") if issue.get("id") != issue_id: raise ValueError(f"issue id mismatch for {issue_id!r}") + issue_kind = issue.get("work_item_kind", issue.get("issue_kind")) + if issue_kind not in WORK_ITEM_KINDS: + raise ValueError( + f"issue {issue_id!r} has invalid work_item_kind {issue_kind!r}" + ) + origin = issue.get("origin") + if origin not in WORK_ITEM_ORIGINS: + raise ValueError( + f"issue {issue_id!r} has invalid origin {origin!r}" + ) if issue.get("status") not in _ALLOWED_ISSUE_STATUSES: raise ValueError( f"issue {issue_id!r} has invalid status {issue.get('status')!r}" diff --git a/desloppify/engine/_state/schema_types.py b/desloppify/engine/_state/schema_types.py index 79fc9b4c..e095c57d 100644 --- a/desloppify/engine/_state/schema_types.py +++ b/desloppify/engine/_state/schema_types.py @@ -7,6 +7,7 @@ from desloppify.engine._state.schema_types_issues import ( DimensionScore, Issue, + WorkItem, ScanHistoryEntry, ScoreConfidenceDetector, ScoreConfidenceModel, @@ -49,7 +50,8 @@ class StateModel(TypedDict, total=False): strict_score: Required[float] verified_strict_score: Required[float] stats: Required[StateStats] - issues: Required[dict[str, Issue]] + work_items: Required[dict[str, WorkItem]] + issues: NotRequired[dict[str, WorkItem]] dimension_scores: dict[str, DimensionScore] scan_path: str | None tool_hash: str @@ -101,6 +103,7 @@ class ScanDiff(TypedDict): "AssessmentImportAuditEntry", "AttestationLogEntry", "Issue", + "WorkItem", "TierStats", "StateStats", "DimensionScore", diff --git a/desloppify/engine/_state/schema_types_issues.py b/desloppify/engine/_state/schema_types_issues.py index dfb06654..7cbdc381 100644 --- a/desloppify/engine/_state/schema_types_issues.py +++ b/desloppify/engine/_state/schema_types_issues.py @@ -1,4 +1,4 @@ -"""Issue and score-related TypedDict models for persisted state payloads.""" +"""Work-item and score-related TypedDict models for persisted state payloads.""" from __future__ import annotations @@ -7,16 +7,19 @@ from desloppify.base.enums import Status -class Issue(TypedDict): - """The central data structure: a normalized issue from any detector.""" +class WorkItem(TypedDict): + """The central data structure: a normalized tracked work item.""" id: str detector: str + work_item_kind: str + issue_kind: str + origin: str file: str tier: int confidence: str summary: str - # Known detail shapes per detector (non-exhaustive, for reference): + # Known detail shapes per detector/work item (non-exhaustive, for reference): # # structural: {loc, complexity_score?, complexity_signals?: list[str], # name? (god class), ...god_class_metrics} @@ -56,6 +59,11 @@ class Issue(TypedDict): zone: NotRequired[str] +# Legacy type alias retained while the codebase finishes moving to +# work-item-first naming. +Issue = WorkItem + + class TierStats(TypedDict, total=False): open: int fixed: int @@ -134,6 +142,7 @@ class ScanHistoryEntry(TypedDict, total=False): __all__ = [ + "WorkItem", "Issue", "TierStats", "StateStats", diff --git a/desloppify/engine/_state/schema_types_review.py b/desloppify/engine/_state/schema_types_review.py index 32693797..406b1578 100644 --- a/desloppify/engine/_state/schema_types_review.py +++ b/desloppify/engine/_state/schema_types_review.py @@ -19,7 +19,6 @@ class SubjectiveAssessmentJudgment(TypedDict, total=False): """Reviewer's holistic judgment narrative for a subjective dimension.""" strengths: list[str] - issue_character: str dimension_character: str score_rationale: str @@ -68,6 +67,7 @@ class AssessmentImportAuditEntry(TypedDict, total=False): provisional_count: int attest: str import_file: str + packet_sha256: str class AttestationLogEntry(TypedDict, total=False): diff --git a/desloppify/engine/_work_queue/context.py b/desloppify/engine/_work_queue/context.py index d379dc34..6ab307af 100644 --- a/desloppify/engine/_work_queue/context.py +++ b/desloppify/engine/_work_queue/context.py @@ -15,8 +15,7 @@ target_strict_score_from_config, ) from desloppify.engine._plan.persistence import ( - has_living_plan, - load_plan, + resolve_plan_load_status as resolve_persisted_plan_load_status, ) from desloppify.engine.plan_state import PlanLoadStatus from desloppify.engine._state.schema import StateModel @@ -54,28 +53,7 @@ def resolve_plan_load_status( """Resolve plan loading and explicitly report degraded mode.""" if not isinstance(plan, _PlanAutoLoad): return PlanLoadStatus(plan=plan, degraded=False, error_kind=None) - - if not has_living_plan(): - return PlanLoadStatus(plan=None, degraded=False, error_kind=None) - - try: - resolved_plan = load_plan() - return PlanLoadStatus(plan=resolved_plan, degraded=False, error_kind=None) - except OSError as exc: - return PlanLoadStatus( - plan=None, - degraded=True, - error_kind=exc.__class__.__name__, - ) - except ( - ValueError, - UnicodeDecodeError, - ) as exc: - return PlanLoadStatus( - plan=None, - degraded=True, - error_kind=exc.__class__.__name__, - ) + return resolve_persisted_plan_load_status() def queue_context( diff --git a/desloppify/engine/_work_queue/helpers.py b/desloppify/engine/_work_queue/helpers.py index 6d180257..46de3f0c 100644 --- a/desloppify/engine/_work_queue/helpers.py +++ b/desloppify/engine/_work_queue/helpers.py @@ -8,6 +8,12 @@ from desloppify.base.enums import issue_status_tokens from desloppify.base.registry import DETECTORS +from desloppify.engine._plan.cluster_semantics import ACTION_TYPE_AUTO_FIX +from desloppify.engine._plan.constants import is_triage_id +from desloppify.engine._state.issue_semantics import ( + is_review_finding, + is_assessment_request, +) from desloppify.engine._state.schema import StateModel from desloppify.engine._work_queue.types import WorkQueueItem @@ -30,12 +36,11 @@ def status_matches(item_status: str, status_filter: str) -> bool: def is_subjective_issue(item: WorkQueueItem | dict[str, Any]) -> bool: - detector = item.get("detector") - return detector in {"subjective_assessment", "holistic_review", "subjective_review"} + return is_assessment_request(item) def is_review_issue(item: WorkQueueItem | dict[str, Any]) -> bool: - return item.get("detector") == "review" + return is_review_finding(item) def is_subjective_queue_item(item: WorkQueueItem | dict[str, Any]) -> bool: @@ -50,6 +55,15 @@ def is_subjective_queue_item(item: WorkQueueItem | dict[str, Any]) -> bool: return False +def is_auto_fix_item(item: WorkQueueItem | dict[str, Any]) -> bool: + """Return True when a queue item semantically represents auto-fix work.""" + action_type = str(item.get("action_type") or "").strip() + if action_type: + return action_type == ACTION_TYPE_AUTO_FIX + command = str(item.get("primary_command") or "").strip() + return command.startswith("desloppify autofix ") and "--dry-run" in command + + def review_issue_weight(item: WorkQueueItem | dict[str, Any]) -> float: """Return review issue weight aligned with issues list ordering.""" confidence = str(item.get("confidence", "low")).lower() @@ -115,7 +129,7 @@ def workflow_stage_name(item: WorkQueueItem | dict[str, Any]) -> str: return stage_name item_id = str(item.get("id", "")).strip() - if item_id.startswith("triage::"): + if is_triage_id(item_id): return item_id.split("::", 1)[1] return "" @@ -158,7 +172,7 @@ def primary_command_for_issue( ] if available_fixers: return f"desloppify autofix {available_fixers[0]} --dry-run" - if detector == "subjective_review": + if is_assessment_request(item): dim_key = (item.get("detail") or {}).get("dimension", "") if dim_key: return f"desloppify review --prepare --dimensions {dim_key}" @@ -170,6 +184,7 @@ def primary_command_for_issue( "ALL_STATUSES", "ATTEST_EXAMPLE", "detail_dict", + "is_auto_fix_item", "is_review_issue", "is_subjective_issue", "is_subjective_queue_item", diff --git a/desloppify/engine/_work_queue/inputs.py b/desloppify/engine/_work_queue/inputs.py index c5e860ed..543026b5 100644 --- a/desloppify/engine/_work_queue/inputs.py +++ b/desloppify/engine/_work_queue/inputs.py @@ -50,8 +50,9 @@ def gather_subjective_items( candidates = build_subjective_items( state, - state.get("issues", {}), + (state.get("work_items") or state.get("issues", {})), threshold=threshold, + plan=opts.context.plan if opts.context is not None else opts.plan, ) return [item for item in candidates if scope_matches(item, opts.scope)] diff --git a/desloppify/engine/_work_queue/issues.py b/desloppify/engine/_work_queue/issues.py index 17151df2..0d15c366 100644 --- a/desloppify/engine/_work_queue/issues.py +++ b/desloppify/engine/_work_queue/issues.py @@ -1,9 +1,10 @@ -"""State-backed work queue for review issues. +"""State-backed work queue for review work items. -Review issues live in state["issues"]. This module provides: -- Listing/sorting open review issues by impact -- Storing investigation notes on issues -- Expiring stale holistic issues during scan +Review work items live in the in-memory ``state["work_items"]`` map and serialize +as ``work_items`` on disk. This module provides: +- Listing/sorting open review work items by impact +- Storing investigation notes on review work items +- Expiring stale holistic review work items during scan """ from __future__ import annotations @@ -12,6 +13,7 @@ from datetime import UTC, datetime from desloppify.base.output.issues import issue_weight +from desloppify.engine._state.issue_semantics import is_review_finding from desloppify.engine._work_queue.helpers import detail_dict logger = logging.getLogger(__name__) @@ -24,6 +26,21 @@ ] +def _state_work_items(state: dict) -> dict: + work_items = state.get("work_items") + if isinstance(work_items, dict): + state["issues"] = work_items + return work_items + legacy_items = state.get("issues") + if isinstance(legacy_items, dict): + state["work_items"] = legacy_items + return legacy_items + empty: dict = {} + state["work_items"] = empty + state["issues"] = empty + return empty + + def impact_label(weight: float) -> str: """Convert weight to a human-readable impact label.""" try: @@ -38,12 +55,12 @@ def impact_label(weight: float) -> str: def list_open_review_issues(state: dict) -> list[dict]: - """Return open review issues sorted by impact (highest first).""" - issues = state.get("issues", {}) + """Return open review work items sorted by impact (highest first).""" + issues = _state_work_items(state) review = [ issue for issue in issues.values() - if issue.get("status") == "open" and issue.get("detector") == "review" + if issue.get("status") == "open" and is_review_finding(issue) ] def _sort_key(issue: dict) -> tuple[float, str]: @@ -55,8 +72,8 @@ def _sort_key(issue: dict) -> tuple[float, str]: def update_investigation(state: dict, issue_id: str, text: str) -> bool: - """Store investigation text on a issue. Returns False if not found/not open.""" - issue = state.get("issues", {}).get(issue_id) + """Store investigation text on a work item. Returns False if not found/not open.""" + issue = _state_work_items(state).get(issue_id) if not issue or issue.get("status") != "open": return False detail = detail_dict(issue) @@ -73,8 +90,8 @@ def mark_stale_holistic(state: dict, max_age_days: int = 30) -> list[str]: now = datetime.now(UTC) expired: list[str] = [] - for issue_id, issue in state.get("issues", {}).items(): - if issue.get("detector") != "review": + for issue_id, issue in _state_work_items(state).items(): + if not is_review_finding(issue): continue if issue.get("status") != "open": continue diff --git a/desloppify/engine/_work_queue/plan_order.py b/desloppify/engine/_work_queue/plan_order.py index cbd2da79..61e69d9f 100644 --- a/desloppify/engine/_work_queue/plan_order.py +++ b/desloppify/engine/_work_queue/plan_order.py @@ -4,7 +4,12 @@ from typing import Any -from desloppify.base.registry import DETECTORS +from desloppify.engine._plan.cluster_membership import cluster_issue_ids +from desloppify.engine._plan.cluster_semantics import ( + cluster_autofix_hint, + infer_cluster_action_type, + infer_cluster_execution_policy, +) from desloppify.engine.plan_ops import ( get_issue_description, get_issue_note, @@ -13,15 +18,6 @@ from desloppify.engine._work_queue.types import WorkQueueItem from desloppify.state_io import StateModel - -def _cluster_issue_ids(cluster: dict[str, Any]) -> list[str]: - """Return canonical cluster members after plan-load normalization.""" - issue_ids = cluster.get("issue_ids", []) - if not isinstance(issue_ids, list): - return [] - return [issue_id for issue_id in issue_ids if isinstance(issue_id, str) and issue_id] - - def new_item_ids(state: StateModel) -> set[str]: """Return issue IDs added in the most recent scan.""" scan_history = state.get("scan_history", []) @@ -32,7 +28,7 @@ def new_item_ids(state: StateModel) -> set[str]: return set() return { issue_id - for issue_id, issue in state.get("issues", {}).items() + for issue_id, issue in (state.get("work_items") or state.get("issues", {})).items() if issue.get("first_seen", "") >= threshold } @@ -56,7 +52,7 @@ def enrich_plan_metadata(items: list[WorkQueueItem], plan: dict) -> None: item["plan_cluster"] = { "name": cluster_name, "description": cluster_data.get("description"), - "total_items": len(_cluster_issue_ids(cluster_data)), + "total_items": len(cluster_issue_ids(cluster_data)), "action_steps": cluster_data.get("action_steps") or [], } @@ -118,7 +114,7 @@ def filter_cluster_focus( return items clusters: dict = plan.get("clusters", {}) cluster_data = clusters.get(effective_cluster, {}) - cluster_member_ids = set(_cluster_issue_ids(cluster_data)) + cluster_member_ids = set(cluster_issue_ids(cluster_data)) if not cluster_member_ids: return items return [item for item in items if item["id"] in cluster_member_ids] @@ -141,43 +137,26 @@ def stamp_positions(items: list[WorkQueueItem], plan: dict) -> None: item["plan_skip_reason"] = skip_reason -def action_type_for_detector(detector: str) -> str: - """Look up the action_type for a detector from the registry.""" - meta = DETECTORS.get(detector) - if meta: - return meta.action_type - return "manual_fix" - - def _build_cluster_meta( cluster_name: str, members: list[WorkQueueItem], cluster_data: dict[str, Any] ) -> WorkQueueItem: """Build a cluster meta-item from its member items.""" detector = members[0].get("detector", "") if members else "" - action = cluster_data.get("action") or "" - if "desloppify autofix" in action: - action_type = "auto_fix" - elif "desloppify move" in action: - action_type = "reorganize" - else: - action_type = action_type_for_detector(detector) - if action_type == "auto_fix" and "desloppify autofix" not in action: - action_type = "refactor" + action_type = infer_cluster_action_type(cluster_data, detector=detector) stored_desc = cluster_data.get("description") or "" - total_in_cluster = len(_cluster_issue_ids(cluster_data)) + total_in_cluster = len(cluster_issue_ids(cluster_data)) if stored_desc and total_in_cluster != len(members): summary = stored_desc.replace(str(total_in_cluster), str(len(members))) else: summary = stored_desc or f"{len(members)} issues" action = cluster_data.get("action") or "" - if "desloppify autofix" in action: + autofix_hint = cluster_autofix_hint(cluster_data, detector=detector) + if autofix_hint: primary_command = f"desloppify next --cluster {cluster_name} --count 10" - autofix_hint = action else: primary_command = action or f"desloppify next --cluster {cluster_name} --count 10" - autofix_hint = None estimated_impact = max( (m.get("estimated_impact", 0.0) for m in members), default=0.0 @@ -195,6 +174,11 @@ def _build_cluster_meta( "cluster_name": cluster_name, "cluster_auto": bool(cluster_data.get("auto")), "cluster_optional": bool(cluster_data.get("optional")), + "execution_policy": infer_cluster_execution_policy( + cluster_data, + detector=detector, + ), + "execution_status": cluster_data.get("execution_status", "review"), "confidence": "high", "detector": detector, "file": "", @@ -216,7 +200,7 @@ def collapse_clusters(items: list[WorkQueueItem], plan: dict) -> list[WorkQueueI fid_to_cluster: dict[str, str] = {} for name, cluster in clusters.items(): - for issue_id in _cluster_issue_ids(cluster): + for issue_id in cluster_issue_ids(cluster): # Manual clusters take priority when an issue is in both if issue_id not in fid_to_cluster or not cluster.get("auto"): fid_to_cluster[issue_id] = name diff --git a/desloppify/engine/_work_queue/ranking.py b/desloppify/engine/_work_queue/ranking.py index 8114f37a..851a5676 100644 --- a/desloppify/engine/_work_queue/ranking.py +++ b/desloppify/engine/_work_queue/ranking.py @@ -6,7 +6,7 @@ from typing import Any, cast from desloppify.base.registry import DETECTORS -from desloppify.engine.plan_triage import TRIAGE_STAGE_ORDER +from desloppify.engine._plan.constants import TRIAGE_STAGE_ORDER from desloppify.engine._scoring.results.health import compute_health_breakdown from desloppify.engine._scoring.results.impact import get_dimension_for_detector from desloppify.engine._state.filtering import path_scoped_issues @@ -128,10 +128,12 @@ def build_issue_items( status_filter: str, scope: str | None, chronic: bool, + forced_ids: set[str] | None = None, ) -> list[WorkQueueItem]: - scoped = path_scoped_issues(state.get("issues", {}), scan_path) + scoped = path_scoped_issues((state.get("work_items") or state.get("issues", {})), scan_path) subjective_scores = subjective_strict_scores(state) out: list[WorkQueueItem] = [] + forced_ids = forced_ids or set() for issue_id, issue in scoped.items(): if issue.get("suppressed"): @@ -146,7 +148,7 @@ def build_issue_items( # Evidence-only: skip issues below standalone confidence threshold detector = issue.get("detector", "") meta = DETECTORS.get(detector) - if meta and meta.standalone_threshold: + if issue_id not in forced_ids and meta and meta.standalone_threshold: threshold_rank = CONFIDENCE_ORDER.get(meta.standalone_threshold, 9) issue_rank = CONFIDENCE_ORDER.get(issue.get("confidence", "low"), 9) if issue_rank > threshold_rank: @@ -155,6 +157,7 @@ def build_issue_items( item = cast(WorkQueueItem, dict(issue)) item["id"] = issue_id item["kind"] = "issue" + item["action_type"] = meta.action_type if meta is not None else "manual_fix" item["is_review"] = is_review_issue(item) item["is_subjective"] = is_subjective_issue(item) item["review_weight"] = ( diff --git a/desloppify/engine/_work_queue/selection.py b/desloppify/engine/_work_queue/selection.py index 5c832b22..41888bfc 100644 --- a/desloppify/engine/_work_queue/selection.py +++ b/desloppify/engine/_work_queue/selection.py @@ -49,7 +49,12 @@ def select_queue_items( def items_for_visibility(*, snapshot, visibility: str) -> list[WorkQueueItem]: """Select the snapshot partition for one queue surface.""" if visibility == QueueVisibility.BACKLOG: - return [dict(item) for item in snapshot.backlog_items] + source_items = snapshot.backlog_items or snapshot.execution_items + return [ + dict(item) + for item in source_items + if item.get("kind") not in {"workflow_stage", "workflow_action"} + ] return [dict(item) for item in snapshot.execution_items] diff --git a/desloppify/engine/_work_queue/snapshot.py b/desloppify/engine/_work_queue/snapshot.py index 216d3437..f661c97c 100644 --- a/desloppify/engine/_work_queue/snapshot.py +++ b/desloppify/engine/_work_queue/snapshot.py @@ -4,19 +4,41 @@ from collections.abc import Iterable from dataclasses import dataclass -from typing import Any +from typing import Any, NamedTuple from desloppify.base.config import DEFAULT_TARGET_STRICT_SCORE +from desloppify.engine._plan.cluster_semantics import ( + cluster_is_active, +) from desloppify.engine._plan.constants import ( WORKFLOW_DEFERRED_DISPOSITION_ID, WORKFLOW_RUN_SCAN_ID, ) -from desloppify.engine._plan.policy.subjective import NON_OBJECTIVE_DETECTORS from desloppify.engine._plan.schema import ( - planned_objective_ids as _planned_objective_ids, + executable_objective_ids as _executable_objective_ids, + live_planned_queue_ids as _live_planned_queue_ids, +) +from desloppify.engine._plan.refresh_lifecycle import ( + LIFECYCLE_PHASE_ASSESSMENT_POSTFLIGHT, + LIFECYCLE_PHASE_EXECUTE, + LIFECYCLE_PHASE_REVIEW_INITIAL, + LIFECYCLE_PHASE_REVIEW_POSTFLIGHT, + LIFECYCLE_PHASE_REVIEW, + LIFECYCLE_PHASE_SCAN, + LIFECYCLE_PHASE_TRIAGE_POSTFLIGHT, + LIFECYCLE_PHASE_TRIAGE, + LIFECYCLE_PHASE_WORKFLOW_POSTFLIGHT, + LIFECYCLE_PHASE_WORKFLOW, + current_lifecycle_phase, + postflight_scan_pending, ) from desloppify.engine._plan.triage.snapshot import build_triage_snapshot from desloppify.engine._state.filtering import path_scoped_issues +from desloppify.engine._state.issue_semantics import ( + counts_toward_objective_backlog, + is_assessment_request, + is_triage_finding, +) from desloppify.engine._state.schema import StateModel from desloppify.engine._work_queue.ranking import build_issue_items from desloppify.engine._work_queue.synthetic import ( @@ -33,12 +55,31 @@ ) from desloppify.engine._work_queue.types import WorkQueueItem -PHASE_REVIEW_INITIAL = "review_initial" -PHASE_EXECUTE = "execute" -PHASE_SCAN = "scan" -PHASE_REVIEW_POSTFLIGHT = "review_postflight" -PHASE_WORKFLOW_POSTFLIGHT = "workflow_postflight" -PHASE_TRIAGE_POSTFLIGHT = "triage_postflight" +PHASE_REVIEW_INITIAL = LIFECYCLE_PHASE_REVIEW_INITIAL +PHASE_EXECUTE = LIFECYCLE_PHASE_EXECUTE +PHASE_SCAN = LIFECYCLE_PHASE_SCAN +PHASE_ASSESSMENT_POSTFLIGHT = LIFECYCLE_PHASE_ASSESSMENT_POSTFLIGHT +PHASE_REVIEW_POSTFLIGHT = LIFECYCLE_PHASE_REVIEW_POSTFLIGHT +PHASE_WORKFLOW_POSTFLIGHT = LIFECYCLE_PHASE_WORKFLOW_POSTFLIGHT +PHASE_TRIAGE_POSTFLIGHT = LIFECYCLE_PHASE_TRIAGE_POSTFLIGHT + +# Maps fine-grained phase → partition field that must be non-empty for it to be valid. +# Used by _phase_for_snapshot to trust the persisted phase. +_FINE_GRAINED_ITEM_MAP: dict[str, str | None] = { + PHASE_REVIEW_INITIAL: "initial_review_items", + PHASE_ASSESSMENT_POSTFLIGHT: "postflight_assessment_items", + PHASE_WORKFLOW_POSTFLIGHT: "postflight_workflow_items", + PHASE_TRIAGE_POSTFLIGHT: "triage_items", + PHASE_REVIEW_POSTFLIGHT: "postflight_review_items", + PHASE_SCAN: "scan_items", +} + +# Coarse phases that trigger postflight-sticky inference (legacy plans). +_COARSE_POSTFLIGHT_PHASES = { + LIFECYCLE_PHASE_REVIEW, + LIFECYCLE_PHASE_WORKFLOW, + LIFECYCLE_PHASE_TRIAGE, +} @dataclass(frozen=True) @@ -48,6 +89,7 @@ class QueueSnapshot: phase: str all_objective_items: tuple[WorkQueueItem, ...] all_initial_review_items: tuple[WorkQueueItem, ...] + all_postflight_assessment_items: tuple[WorkQueueItem, ...] all_postflight_review_items: tuple[WorkQueueItem, ...] all_scan_items: tuple[WorkQueueItem, ...] all_postflight_workflow_items: tuple[WorkQueueItem, ...] @@ -59,12 +101,17 @@ class QueueSnapshot: objective_execution_count: int objective_backlog_count: int subjective_initial_count: int + assessment_postflight_count: int subjective_postflight_count: int workflow_postflight_count: int triage_pending_count: int has_unplanned_objective_blockers: bool +# --------------------------------------------------------------------------- +# Internal helpers — option resolution, item classification +# --------------------------------------------------------------------------- + def _option_value(options: object | None, name: str, default: Any) -> Any: if options is None: return default @@ -90,7 +137,7 @@ def _is_fresh_boundary(plan: dict | None) -> bool: def _is_objective_item(item: WorkQueueItem, *, skipped_ids: set[str]) -> bool: return ( item.get("kind") in {"issue", "cluster"} - and item.get("detector", "") not in NON_OBJECTIVE_DETECTORS + and counts_toward_objective_backlog(item) and item.get("id", "") not in skipped_ids ) @@ -98,9 +145,85 @@ def _is_objective_item(item: WorkQueueItem, *, skipped_ids: set[str]) -> bool: def _review_issue_items(items: Iterable[WorkQueueItem]) -> list[WorkQueueItem]: return [ item for item in items - if item.get("detector", "") in {"review", "concerns", "subjective_review"} + if is_triage_finding(item) + ] + + +def _assessment_request_items(items: Iterable[WorkQueueItem]) -> list[WorkQueueItem]: + return [ + item for item in items + if is_assessment_request(item) + ] + + +def _active_cluster_issue_ids(plan: dict | None) -> set[str]: + """Return issue IDs owned by clusters that are active planned work.""" + if not isinstance(plan, dict): + return set() + active_ids: set[str] = set() + skipped_ids = set(plan.get("skipped", {}).keys()) + for cluster in plan.get("clusters", {}).values(): + if not isinstance(cluster, dict) or not cluster_is_active(cluster): + continue + for issue_id in cluster.get("issue_ids", []): + if isinstance(issue_id, str) and issue_id and issue_id not in skipped_ids: + active_ids.add(issue_id) + return active_ids + + +def _all_cluster_issue_ids(plan: dict | None) -> set[str]: + """Return issue IDs owned by any cluster (regardless of status).""" + if not isinstance(plan, dict): + return set() + all_ids: set[str] = set() + for cluster in plan.get("clusters", {}).values(): + if not isinstance(cluster, dict): + continue + for issue_id in cluster.get("issue_ids", []): + if isinstance(issue_id, str) and issue_id: + all_ids.add(issue_id) + return all_ids + + +def _merge_execution_candidates( + *, + all_issue_items: list[WorkQueueItem], + explicit_objective_items: list[WorkQueueItem], + plan: dict | None, + review_issue_ids: set[str], + assessment_request_ids: set[str], +) -> tuple[list[WorkQueueItem], list[WorkQueueItem]]: + """Merge queue-owned execution items with objective defaults.""" + explicit_queue_ids = _live_planned_queue_ids(plan) + active_cluster_ids = _active_cluster_issue_ids(plan) + + queued_non_review_items = [ + item + for item in all_issue_items + if item.get("id", "") in explicit_queue_ids + and item.get("id", "") not in assessment_request_ids + and ( + item.get("id", "") not in review_issue_ids + or item.get("id", "") in active_cluster_ids + ) ] + execution_candidates: list[WorkQueueItem] = [] + seen_execution_ids: set[str] = set() + for item in [*explicit_objective_items, *queued_non_review_items]: + item_id = str(item.get("id", "")) + if not item_id or item_id in seen_execution_ids: + continue + seen_execution_ids.add(item_id) + execution_candidates.append(item) + + anchored_execution_items = [ + item + for item in execution_candidates + if item.get("id", "") in explicit_queue_ids + ] + return execution_candidates, anchored_execution_items + def _executable_review_issue_items( plan: dict | None, @@ -126,8 +249,9 @@ def _subjective_partitions( *, scoped_issues: dict[str, dict], threshold: float, + plan: dict | None, ) -> tuple[list[WorkQueueItem], list[WorkQueueItem]]: - candidates = build_subjective_items(state, scoped_issues, threshold=threshold) + candidates = build_subjective_items(state, scoped_issues, threshold=threshold, plan=plan) initial = [item for item in candidates if item.get("initial_review")] postflight = [item for item in candidates if not item.get("initial_review")] return initial, postflight @@ -161,37 +285,225 @@ def _workflow_partitions( return scan_items, postflight_workflow, triage_items +# --------------------------------------------------------------------------- +# Phase resolution — persisted fine-grained phase with legacy fallback +# --------------------------------------------------------------------------- + +def _raw_persisted_phase(plan: dict | None) -> str | None: + """Read the raw lifecycle_phase string from the plan, without validation.""" + if not isinstance(plan, dict): + return None + refresh_state = plan.get("refresh_state") + if isinstance(refresh_state, dict): + phase = refresh_state.get("lifecycle_phase") + if isinstance(phase, str): + return phase + return None + + def _phase_for_snapshot( + plan: dict | None, *, fresh_boundary: bool, initial_review_items: list[WorkQueueItem], - objective_items: list[WorkQueueItem], + anchored_execution_items: list[WorkQueueItem], + explicit_queue_items: list[WorkQueueItem], scan_items: list[WorkQueueItem], + review_backlog_present: bool, + undispositioned_review_backlog_present: bool, + postflight_assessment_items: list[WorkQueueItem], postflight_review_items: list[WorkQueueItem], postflight_workflow_items: list[WorkQueueItem], triage_items: list[WorkQueueItem], ) -> str: + raw_phase = _raw_persisted_phase(plan) + + ordered_postflight_phase = _ordered_postflight_phase( + postflight_assessment_items=postflight_assessment_items, + postflight_review_items=postflight_review_items, + postflight_workflow_items=postflight_workflow_items, + triage_items=triage_items, + ) + + # ── Fine-grained persisted phase: trust it if items match ── + if raw_phase in { + PHASE_ASSESSMENT_POSTFLIGHT, + PHASE_REVIEW_POSTFLIGHT, + PHASE_WORKFLOW_POSTFLIGHT, + PHASE_TRIAGE_POSTFLIGHT, + } and ordered_postflight_phase is not None: + return ordered_postflight_phase + if raw_phase in _FINE_GRAINED_ITEM_MAP: + items_key = _FINE_GRAINED_ITEM_MAP[raw_phase] + items = locals().get(items_key) if items_key else None + if items_key is None or items: + return raw_phase + # Execute needs special check (two possible item lists). + if raw_phase == LIFECYCLE_PHASE_EXECUTE and (anchored_execution_items or explicit_queue_items): + return PHASE_EXECUTE + + # ── Legacy inference for coarse-phase or missing-phase plans ── + return _legacy_phase_inference( + plan, + fresh_boundary=fresh_boundary, + initial_review_items=initial_review_items, + anchored_execution_items=anchored_execution_items, + explicit_queue_items=explicit_queue_items, + scan_items=scan_items, + review_backlog_present=review_backlog_present, + undispositioned_review_backlog_present=undispositioned_review_backlog_present, + postflight_assessment_items=postflight_assessment_items, + postflight_review_items=postflight_review_items, + postflight_workflow_items=postflight_workflow_items, + triage_items=triage_items, + ) + + +def _ordered_postflight_phase( + *, + postflight_assessment_items: list[WorkQueueItem], + postflight_review_items: list[WorkQueueItem], + postflight_workflow_items: list[WorkQueueItem], + triage_items: list[WorkQueueItem], +) -> str | None: + """Return the earliest active postflight phase in fixed sequence order.""" + if postflight_assessment_items: + return PHASE_ASSESSMENT_POSTFLIGHT + if postflight_workflow_items: + return PHASE_WORKFLOW_POSTFLIGHT + if triage_items: + return PHASE_TRIAGE_POSTFLIGHT + if postflight_review_items: + return PHASE_REVIEW_POSTFLIGHT + return None + + +def _legacy_phase_inference( + plan: dict | None, + *, + fresh_boundary: bool, + initial_review_items: list[WorkQueueItem], + anchored_execution_items: list[WorkQueueItem], + explicit_queue_items: list[WorkQueueItem], + scan_items: list[WorkQueueItem], + review_backlog_present: bool, + undispositioned_review_backlog_present: bool, + postflight_assessment_items: list[WorkQueueItem], + postflight_review_items: list[WorkQueueItem], + postflight_workflow_items: list[WorkQueueItem], + triage_items: list[WorkQueueItem], +) -> str: + """Infer the phase from item partitions for plans without fine-grained phases. + + This handles old plans that persisted coarse phases ("review", "workflow", + "triage") or have no persisted phase at all. As plans are migrated to + fine-grained phases by the reconcile pipeline, this code path is exercised + less frequently. + """ + persisted_phase = ( + current_lifecycle_phase(plan) if isinstance(plan, dict) else None + ) + + # Coarse "postflight sticky" — old plans that persisted a coarse phase + # need priority-ordered inference within that phase family. + postflight_sticky = ( + isinstance(plan, dict) + and persisted_phase in _COARSE_POSTFLIGHT_PHASES + and not postflight_scan_pending(plan) + ) + if fresh_boundary and initial_review_items: return PHASE_REVIEW_INITIAL - if objective_items: + + if postflight_sticky: + result = _sticky_postflight_phase( + persisted_phase, + postflight_assessment_items=postflight_assessment_items, + postflight_review_items=postflight_review_items, + postflight_workflow_items=postflight_workflow_items, + triage_items=triage_items, + review_backlog_present=review_backlog_present, + ) + if result is not None: + return result + + if anchored_execution_items: return PHASE_EXECUTE if scan_items: return PHASE_SCAN - if postflight_review_items: - return PHASE_REVIEW_POSTFLIGHT + if explicit_queue_items and ( + not isinstance(plan, dict) or postflight_scan_pending(plan) + ): + return PHASE_EXECUTE + + # Postflight sequence: assessment → workflow → triage → review → execute. + if postflight_assessment_items: + return PHASE_ASSESSMENT_POSTFLIGHT if postflight_workflow_items: return PHASE_WORKFLOW_POSTFLIGHT + if undispositioned_review_backlog_present and postflight_review_items: + return PHASE_REVIEW_POSTFLIGHT + if explicit_queue_items: + return PHASE_EXECUTE + if postflight_review_items: + return PHASE_REVIEW_POSTFLIGHT if triage_items: return PHASE_TRIAGE_POSTFLIGHT return PHASE_SCAN +def _sticky_postflight_phase( + persisted_phase: str | None, + *, + postflight_assessment_items: list[WorkQueueItem], + postflight_review_items: list[WorkQueueItem], + postflight_workflow_items: list[WorkQueueItem], + triage_items: list[WorkQueueItem], + review_backlog_present: bool, +) -> str | None: + """Resolve phase within a coarse postflight-sticky context. + + Returns None if no items match, letting the caller fall through. + """ + if persisted_phase == LIFECYCLE_PHASE_WORKFLOW: + candidates = [ + (postflight_workflow_items, PHASE_WORKFLOW_POSTFLIGHT), + (triage_items, PHASE_TRIAGE_POSTFLIGHT), + (postflight_assessment_items, PHASE_ASSESSMENT_POSTFLIGHT), + (postflight_review_items, PHASE_REVIEW_POSTFLIGHT), + ] + elif persisted_phase == LIFECYCLE_PHASE_TRIAGE: + candidates = [ + (triage_items, PHASE_TRIAGE_POSTFLIGHT), + (postflight_workflow_items, PHASE_WORKFLOW_POSTFLIGHT), + (postflight_assessment_items, PHASE_ASSESSMENT_POSTFLIGHT), + (postflight_review_items, PHASE_REVIEW_POSTFLIGHT), + ] + else: + # Coarse "review" phase. + candidates = [ + (postflight_assessment_items, PHASE_ASSESSMENT_POSTFLIGHT), + (postflight_review_items, PHASE_REVIEW_POSTFLIGHT), + (postflight_workflow_items, PHASE_WORKFLOW_POSTFLIGHT), + (triage_items, PHASE_TRIAGE_POSTFLIGHT), + ] + for items, phase in candidates: + if items: + return phase + return None + + +# --------------------------------------------------------------------------- +# Execution item selection +# --------------------------------------------------------------------------- + def _execution_items_for_phase( phase: str, *, - objective_items: list[WorkQueueItem], + explicit_queue_items: list[WorkQueueItem], initial_review_items: list[WorkQueueItem], scan_items: list[WorkQueueItem], + postflight_assessment_items: list[WorkQueueItem], postflight_review_items: list[WorkQueueItem], postflight_workflow_items: list[WorkQueueItem], triage_items: list[WorkQueueItem], @@ -199,7 +511,7 @@ def _execution_items_for_phase( if phase == PHASE_REVIEW_INITIAL: return initial_review_items if phase == PHASE_EXECUTE: - return objective_items + return explicit_queue_items if phase == PHASE_SCAN: deferred_items = [ item for item in scan_items @@ -211,6 +523,8 @@ def _execution_items_for_phase( item for item in scan_items if item.get("id") == WORKFLOW_RUN_SCAN_ID ] + if phase == PHASE_ASSESSMENT_POSTFLIGHT: + return postflight_assessment_items if phase == PHASE_REVIEW_POSTFLIGHT: return postflight_review_items if phase == PHASE_WORKFLOW_POSTFLIGHT: @@ -220,23 +534,41 @@ def _execution_items_for_phase( return [] -def build_queue_snapshot( +# --------------------------------------------------------------------------- +# Item partition building +# --------------------------------------------------------------------------- + +class _Partitions(NamedTuple): + """All item lists computed from state + plan, before phase resolution.""" + + objective_items: list[WorkQueueItem] + explicit_objective_items: list[WorkQueueItem] + review_issue_items: list[WorkQueueItem] + initial_review_items: list[WorkQueueItem] + subjective_postflight_items: list[WorkQueueItem] + postflight_assessment_items: list[WorkQueueItem] + postflight_review_items: list[WorkQueueItem] + scan_items: list[WorkQueueItem] + postflight_workflow_items: list[WorkQueueItem] + triage_items: list[WorkQueueItem] + explicit_queue_items: list[WorkQueueItem] + anchored_execution_items: list[WorkQueueItem] + + +def _build_item_partitions( state: StateModel, *, - options: object | None = None, - plan: dict | None = None, - target_strict: float = DEFAULT_TARGET_STRICT_SCORE, -) -> QueueSnapshot: - """Build the canonical queue snapshot for the current state.""" - context = _option_value(options, "context", None) - effective_plan = context.plan if context is not None else ( - plan if plan is not None else _option_value(options, "plan", None) - ) - scan_path = _resolved_scan_path(options, state) + effective_plan: dict | None, + scan_path: str | None, + scope: object | None, + chronic: bool, + target_strict: float, +) -> _Partitions: + """Build all item partitions from state and plan.""" skipped_ids = set((effective_plan or {}).get("skipped", {}).keys()) - scoped_issues = path_scoped_issues(state.get("issues", {}), scan_path) - scope = _option_value(options, "scope", None) - chronic = bool(_option_value(options, "chronic", False)) + scoped_issues = path_scoped_issues( + (state.get("work_items") or state.get("issues", {})), scan_path, + ) all_issue_items = build_issue_items( state, @@ -244,115 +576,196 @@ def build_queue_snapshot( status_filter="open", scope=scope, chronic=chronic, + forced_ids=_live_planned_queue_ids(effective_plan), ) objective_items = [ item for item in all_issue_items if _is_objective_item(item, skipped_ids=skipped_ids) ] - planned_ids = _planned_objective_ids( + executable_objective_ids = _executable_objective_ids( {item.get("id", "") for item in objective_items}, effective_plan, ) - planned_objective_items = [ + all_clustered_ids = _all_cluster_issue_ids(effective_plan) + if ( + isinstance(effective_plan, dict) + and not _live_planned_queue_ids(effective_plan) + and all_clustered_ids & executable_objective_ids + ): + executable_objective_ids -= all_clustered_ids + explicit_objective_items = [ item for item in objective_items - if item.get("id", "") in planned_ids + if item.get("id", "") in executable_objective_ids ] + review_issue_items = _review_issue_items(all_issue_items) + assessment_request_items_list = _assessment_request_items(all_issue_items) executable_review_items = _executable_review_issue_items( - effective_plan, - state, - review_issue_items, + effective_plan, state, review_issue_items, + ) + review_issue_ids = {item.get("id", "") for item in review_issue_items} + assessment_request_ids = {item.get("id", "") for item in assessment_request_items_list} + + explicit_queue_items, anchored_execution_items = _merge_execution_candidates( + all_issue_items=all_issue_items, + explicit_objective_items=explicit_objective_items, + plan=effective_plan, + review_issue_ids=review_issue_ids, + assessment_request_ids=assessment_request_ids, ) + initial_review_items, subjective_postflight_items = _subjective_partitions( - state, - scoped_issues=scoped_issues, - threshold=target_strict, + state, scoped_issues=scoped_issues, threshold=target_strict, plan=effective_plan, ) - postflight_review_items = [*subjective_postflight_items, *executable_review_items] + # Suppress subjective dimension items when review issues already cover + # the same dimension — the review issues are more actionable. + postflight_assessment_items = [ + item + for item in subjective_postflight_items + if not ( + item.get("kind") == "subjective_dimension" + and int((item.get("detail") or {}).get("open_review_issues", 0)) > 0 + ) + ] + list(assessment_request_items_list) + postflight_review_items = list(executable_review_items) + scan_items, postflight_workflow_items, triage_items = _workflow_partitions( - effective_plan, - state, + effective_plan, state, ) - fresh_boundary = _is_fresh_boundary(effective_plan) - phase = _phase_for_snapshot( - fresh_boundary=fresh_boundary, + return _Partitions( + objective_items=objective_items, + explicit_objective_items=explicit_objective_items, + review_issue_items=review_issue_items, initial_review_items=initial_review_items, - objective_items=planned_objective_items, - scan_items=scan_items, + subjective_postflight_items=subjective_postflight_items, + postflight_assessment_items=postflight_assessment_items, postflight_review_items=postflight_review_items, - postflight_workflow_items=postflight_workflow_items, - triage_items=triage_items, - ) - execution_items = _execution_items_for_phase( - phase, - objective_items=planned_objective_items, - initial_review_items=initial_review_items, scan_items=scan_items, - postflight_review_items=postflight_review_items, postflight_workflow_items=postflight_workflow_items, triage_items=triage_items, + explicit_queue_items=explicit_queue_items, + anchored_execution_items=anchored_execution_items, ) - execution_ids = {item.get("id", "") for item in execution_items} - backlog_items = [ + +def _build_backlog( + p: _Partitions, + execution_ids: set[str], +) -> list[WorkQueueItem]: + return [ item for item in ( [ - *planned_objective_items, - *initial_review_items, - *subjective_postflight_items, - *review_issue_items, - *scan_items, - *postflight_workflow_items, - *triage_items, + *p.objective_items, + *p.initial_review_items, + *p.postflight_assessment_items, + *p.review_issue_items, + *p.scan_items, + *p.postflight_workflow_items, + *p.triage_items, ] ) if item.get("id", "") not in execution_ids ] + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def build_queue_snapshot( + state: StateModel, + *, + options: object | None = None, + plan: dict | None = None, + target_strict: float = DEFAULT_TARGET_STRICT_SCORE, +) -> QueueSnapshot: + """Build the canonical queue snapshot for the current state.""" + context = _option_value(options, "context", None) + effective_plan = context.plan if context is not None else ( + plan if plan is not None else _option_value(options, "plan", None) + ) + + p = _build_item_partitions( + state, + effective_plan=effective_plan, + scan_path=_resolved_scan_path(options, state), + scope=_option_value(options, "scope", None), + chronic=bool(_option_value(options, "chronic", False)), + target_strict=target_strict, + ) + + triage_snapshot = ( + build_triage_snapshot(effective_plan, state) + if isinstance(effective_plan, dict) + else None + ) + fresh_boundary = _is_fresh_boundary(effective_plan) + + phase = _phase_for_snapshot( + effective_plan, + fresh_boundary=fresh_boundary, + initial_review_items=p.initial_review_items, + anchored_execution_items=p.anchored_execution_items, + explicit_queue_items=p.explicit_queue_items, + scan_items=p.scan_items, + review_backlog_present=bool(p.review_issue_items), + undispositioned_review_backlog_present=bool( + triage_snapshot is not None and triage_snapshot.undispositioned_ids + ), + postflight_assessment_items=p.postflight_assessment_items, + postflight_review_items=p.postflight_review_items, + postflight_workflow_items=p.postflight_workflow_items, + triage_items=p.triage_items, + ) + execution_items = _execution_items_for_phase( + phase, + explicit_queue_items=p.explicit_queue_items, + initial_review_items=p.initial_review_items, + scan_items=p.scan_items, + postflight_assessment_items=p.postflight_assessment_items, + postflight_review_items=p.postflight_review_items, + postflight_workflow_items=p.postflight_workflow_items, + triage_items=p.triage_items, + ) + + execution_ids = {item.get("id", "") for item in execution_items} + backlog_items = _build_backlog(p, execution_ids) objective_backlog_count = sum( - 1 for item in planned_objective_items if item.get("id", "") not in execution_ids + 1 for item in p.objective_items if item.get("id", "") not in execution_ids ) - has_unplanned_objective_blockers = len(planned_objective_items) < len(objective_items) return QueueSnapshot( phase=phase, - all_objective_items=tuple(objective_items), - all_initial_review_items=tuple(initial_review_items), - all_postflight_review_items=tuple(postflight_review_items), - all_scan_items=tuple(scan_items), - all_postflight_workflow_items=tuple(postflight_workflow_items), - all_postflight_triage_items=tuple(triage_items), + all_objective_items=tuple(p.objective_items), + all_initial_review_items=tuple(p.initial_review_items), + all_postflight_assessment_items=tuple(p.postflight_assessment_items), + all_postflight_review_items=tuple(p.postflight_review_items), + all_scan_items=tuple(p.scan_items), + all_postflight_workflow_items=tuple(p.postflight_workflow_items), + all_postflight_triage_items=tuple(p.triage_items), execution_items=tuple(execution_items), backlog_items=tuple(backlog_items), - objective_in_scope_count=len(objective_items), - planned_objective_count=len(planned_objective_items), + objective_in_scope_count=len(p.objective_items), + planned_objective_count=len(p.explicit_objective_items), objective_execution_count=sum( 1 for item in execution_items if item.get("kind") in {"issue", "cluster"} - and item.get("detector", "") not in NON_OBJECTIVE_DETECTORS + and counts_toward_objective_backlog(item) ), objective_backlog_count=objective_backlog_count, - subjective_initial_count=len(initial_review_items), - subjective_postflight_count=len(postflight_review_items), - workflow_postflight_count=len(postflight_workflow_items), - triage_pending_count=len(triage_items), - has_unplanned_objective_blockers=has_unplanned_objective_blockers, + subjective_initial_count=len(p.initial_review_items), + assessment_postflight_count=len(p.postflight_assessment_items), + subjective_postflight_count=len(p.subjective_postflight_items), + workflow_postflight_count=len(p.postflight_workflow_items), + triage_pending_count=len(p.triage_items), + has_unplanned_objective_blockers=len(p.explicit_objective_items) < len(p.objective_items), ) -def coarse_phase_name(phase: str) -> str: - """Map internal queue phases to the persisted coarse lifecycle value.""" - if phase == PHASE_REVIEW_INITIAL or phase == PHASE_REVIEW_POSTFLIGHT: - return "review" - if phase == PHASE_WORKFLOW_POSTFLIGHT: - return "workflow" - if phase == PHASE_TRIAGE_POSTFLIGHT: - return "triage" - return phase - - __all__ = [ + "PHASE_ASSESSMENT_POSTFLIGHT", "PHASE_EXECUTE", "PHASE_REVIEW_INITIAL", "PHASE_REVIEW_POSTFLIGHT", @@ -361,5 +774,4 @@ def coarse_phase_name(phase: str) -> str: "PHASE_WORKFLOW_POSTFLIGHT", "QueueSnapshot", "build_queue_snapshot", - "coarse_phase_name", ] diff --git a/desloppify/engine/_work_queue/synthetic.py b/desloppify/engine/_work_queue/synthetic.py index 829ad3bb..e7997d7b 100644 --- a/desloppify/engine/_work_queue/synthetic.py +++ b/desloppify/engine/_work_queue/synthetic.py @@ -10,6 +10,7 @@ from desloppify.engine.plan_triage import TRIAGE_STAGE_SPECS from desloppify.engine._scoring.subjective.core import DISPLAY_NAMES +from desloppify.engine._state.issue_semantics import is_triage_finding from desloppify.engine._state.schema import StateModel from desloppify.engine._work_queue.helpers import ( detail_dict, @@ -28,6 +29,16 @@ confirmed_triage_stage_names, recorded_unconfirmed_triage_stage_names, ) +from desloppify.engine._plan.triage.snapshot import build_triage_snapshot +from desloppify.engine._plan.refresh_lifecycle import ( + LIFECYCLE_PHASE_REVIEW_INITIAL, + LIFECYCLE_PHASE_TRIAGE, + LIFECYCLE_PHASE_TRIAGE_POSTFLIGHT, + LIFECYCLE_PHASE_WORKFLOW, + LIFECYCLE_PHASE_WORKFLOW_POSTFLIGHT, + current_lifecycle_phase, + subjective_review_completed_for_scan, +) from desloppify.engine.plan_triage import ( TRIAGE_IDS, TRIAGE_STAGE_DEPENDENCIES, @@ -123,14 +134,24 @@ def build_triage_stage_items(plan: dict, state: dict) -> list[WorkQueueItem]: if sid in present_ids } present_names.update(recorded_unconfirmed) + triage_snapshot = build_triage_snapshot(plan, state) + recovery_needed = ( + not present_names + and bool(triage_snapshot.live_open_ids) + and triage_snapshot.triage_has_run + and triage_snapshot.is_triage_stale + ) + if recovery_needed: + present_names = {name for name, _sid in TRIAGE_STAGE_SPECS} + confirmed = set() if not present_names: return [] - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) open_review_count = sum( 1 for f in issues.values() if f.get("status") == "open" - and f.get("detector") in ("review", "concerns") + and is_triage_finding(f) ) label_map = dict(TRIAGE_STAGE_LABELS) @@ -177,7 +198,11 @@ def build_triage_stage_items(plan: dict, state: dict) -> list[WorkQueueItem]: def build_subjective_items( - state: dict, issues: dict, *, threshold: float = 100.0 + state: dict, + issues: dict, + *, + threshold: float = 100.0, + plan: dict | None = None, ) -> list[WorkQueueItem]: """Create synthetic subjective work items.""" dim_scores = state.get("dimension_scores", {}) or {} @@ -200,12 +225,57 @@ def build_subjective_items( for issue in issues.values(): if issue.get("status") != "open": continue - if issue.get("detector") == "review": + if is_triage_finding(issue): dim_key = str(detail_dict(issue).get("dimension", "")).strip().lower() if dim_key: review_open_by_dim[dim_key] = review_open_by_dim.get(dim_key, 0) + 1 items: list[WorkQueueItem] = [] + latest_trusted_audit_ts = "" + for raw_entry in reversed(state.get("assessment_import_audit", []) or []): + if not isinstance(raw_entry, dict): + continue + if raw_entry.get("mode") not in {"trusted_internal", "attested_external"}: + continue + latest_trusted_audit_ts = str(raw_entry.get("timestamp", "")).strip() + if latest_trusted_audit_ts: + break + current_phase = current_lifecycle_phase(plan) if isinstance(plan, dict) else None + current_scan_count = int(state.get("scan_count", 0) or 0) + postflight_scan_completed_this_scan = False + if isinstance(plan, dict): + refresh_state = plan.get("refresh_state") + if isinstance(refresh_state, dict): + postflight_scan_completed_this_scan = ( + refresh_state.get("postflight_scan_completed_at_scan_count") + == current_scan_count + ) + review_completed_this_scan = ( + subjective_review_completed_for_scan(plan, scan_count=current_scan_count) + if isinstance(plan, dict) + else False + ) + + def _suppressed_same_cycle_refresh(dimension_key: str, *, stale: bool) -> bool: + if not stale or latest_trusted_audit_ts == "": + return False + if current_phase not in { + LIFECYCLE_PHASE_WORKFLOW, LIFECYCLE_PHASE_WORKFLOW_POSTFLIGHT, + LIFECYCLE_PHASE_TRIAGE, LIFECYCLE_PHASE_TRIAGE_POSTFLIGHT, + }: + return False + assessments = state.get("subjective_assessments", {}) or {} + payload = assessments.get(dimension_key) + if not isinstance(payload, dict): + return False + refresh_reason = str(payload.get("refresh_reason", "")).strip() + if not refresh_reason.startswith("review_issue_"): + return False + assessed_at = str(payload.get("assessed_at", "")).strip() + if assessed_at == "": + return False + return assessed_at >= latest_trusted_audit_ts + def _prepare_command( cli_keys: list[str], *, @@ -223,9 +293,6 @@ def _prepare_command( if not name: continue strict_val = float(entry.get("strict", entry.get("score", 100.0))) - if strict_val >= threshold: - continue - dim_key = _canonical_subjective_dimension_key(name) aliases = set(_subjective_dimension_aliases(name)) cli_keys = [ @@ -241,14 +308,34 @@ def _prepare_command( or (strict_val <= 0.0 and int(entry.get("failing", 0)) == 0) ) is_stale = bool(entry.get("stale")) + is_below_target = strict_val < threshold + needs_review = ( + is_unassessed + or is_stale + or ( + is_below_target + and postflight_scan_completed_this_scan + and current_phase != LIFECYCLE_PHASE_REVIEW_INITIAL + and not review_completed_this_scan + ) + ) + if not needs_review: + continue + if _suppressed_same_cycle_refresh(dim_key, stale=is_stale): + continue + if not is_below_target and not is_unassessed: + continue # If review issues already exist for this dimension, triage/fix them # before suggesting another review refresh pass. if open_review > 0: primary_command = "desloppify show review --status open" else: primary_command = _prepare_command(cli_keys) - stale_tag = " [stale — re-review]" if is_stale else "" - summary = f"Subjective dimension below target: {name} ({strict_val:.1f}%){stale_tag}" + reason_tags = ["below target"] + if is_stale: + reason_tags.append("stale") + reasons = ", ".join(reason_tags) + summary = f"Subjective review needed: {name} ({strict_val:.1f}%) [{reasons}]" item: WorkQueueItem = { "id": f"subjective::{slugify(dim_key)}", "detector": "subjective_assessment", diff --git a/desloppify/engine/_work_queue/synthetic_workflow.py b/desloppify/engine/_work_queue/synthetic_workflow.py index 18149409..04a23bf1 100644 --- a/desloppify/engine/_work_queue/synthetic_workflow.py +++ b/desloppify/engine/_work_queue/synthetic_workflow.py @@ -59,7 +59,7 @@ def _confirm_attestation_hint(stage: str) -> str: if stage == "enrich": return "Steps are executor-ready..." if stage == "sense-check": - return "Content and structure verified..." + return "Content, structure and value verified..." return "..." diff --git a/desloppify/engine/_work_queue/types.py b/desloppify/engine/_work_queue/types.py index b0650cfa..995ef413 100644 --- a/desloppify/engine/_work_queue/types.py +++ b/desloppify/engine/_work_queue/types.py @@ -4,6 +4,8 @@ from typing import Any, Literal, TypeAlias, TypedDict +from desloppify.engine.plan_state import ActionStep + QueueItemKind: TypeAlias = Literal[ "issue", "cluster", @@ -19,7 +21,7 @@ class PlanClusterRef(TypedDict, total=False): name: str description: str | None total_items: int - action_steps: list[dict[str, Any]] + action_steps: list[ActionStep] class QueueItemBase(TypedDict): @@ -34,6 +36,9 @@ class QueueItemCommon(QueueItemBase, total=False): """Optional fields shared across multiple queue item variants.""" detector: str + work_item_kind: str + issue_kind: str + origin: str file: str confidence: str detail: dict[str, Any] @@ -54,6 +59,8 @@ class QueueItemCommon(QueueItemBase, total=False): estimated_impact: float primary_command: str action_type: str + execution_policy: str + execution_status: str explain: dict[str, Any] # Plan-order metadata @@ -79,6 +86,7 @@ class QueueItemCommon(QueueItemBase, total=False): epic_triage_meta: dict[str, Any] fixers: list[str] issue_ids: list[str] + work_items: dict[str, Any] issues: dict[str, Any] lang_capabilities: dict[str, Any] name: str @@ -93,8 +101,8 @@ class QueueItemCommon(QueueItemBase, total=False): triage_stages: dict[str, Any] -class IssueQueueItem(QueueItemCommon, total=False): - """Concrete queue item for a detector finding.""" +class WorkItemQueueItem(QueueItemCommon, total=False): + """Concrete queue item for one tracked work item.""" tier: int @@ -182,11 +190,12 @@ class SerializedQueueItem(TypedDict, total=False): members_truncated: bool members_sample_limit: int autofix_hint: str - action_steps: list[dict[str, Any]] + execution_policy: str + action_steps: list[ActionStep] WorkQueueItem: TypeAlias = ( - IssueQueueItem + WorkItemQueueItem | ClusterQueueItem | WorkflowStageItem | WorkflowActionItem @@ -197,7 +206,6 @@ class SerializedQueueItem(TypedDict, total=False): __all__ = [ "ClusterQueueItem", - "IssueQueueItem", "PlanClusterRef", "QueueItemBase", "QueueItemCommon", @@ -209,4 +217,5 @@ class SerializedQueueItem(TypedDict, total=False): "WorkflowStageItem", "WorkQueueGroups", "WorkQueueItem", + "WorkItemQueueItem", ] diff --git a/desloppify/engine/detectors/complexity.py b/desloppify/engine/detectors/complexity.py index a20e7d60..466d302f 100644 --- a/desloppify/engine/detectors/complexity.py +++ b/desloppify/engine/detectors/complexity.py @@ -1,5 +1,7 @@ """Complexity signal detection: configurable per-language complexity signals.""" +from __future__ import annotations + import inspect import logging import re @@ -77,3 +79,6 @@ def detect_complexity( ) continue return sorted(entries, key=lambda e: -e["score"]), len(files) + + +__all__ = ["detect_complexity"] diff --git a/desloppify/engine/detectors/coupling.py b/desloppify/engine/detectors/coupling.py index 58361953..4061412c 100644 --- a/desloppify/engine/detectors/coupling.py +++ b/desloppify/engine/detectors/coupling.py @@ -5,6 +5,8 @@ constitute "shared" vs "tools") are provided by the caller. """ +from __future__ import annotations + import logging from dataclasses import dataclass from pathlib import Path @@ -223,3 +225,11 @@ def detect_cross_tool_imports( violating_edges=violating_edges, eligible_edges=eligible_edges, ) + + +__all__ = [ + "CouplingEdgeCounts", + "detect_boundary_candidates", + "detect_coupling_violations", + "detect_cross_tool_imports", +] diff --git a/desloppify/engine/detectors/coverage/mapping_imports.py b/desloppify/engine/detectors/coverage/mapping_imports.py index f975d257..ddd46d6f 100644 --- a/desloppify/engine/detectors/coverage/mapping_imports.py +++ b/desloppify/engine/detectors/coverage/mapping_imports.py @@ -8,11 +8,13 @@ from desloppify.engine.hook_registry import get_lang_hook + def _load_lang_test_coverage_module(lang_name: str | None): """Load language-specific test coverage helpers from ``lang/<name>/test_coverage.py``.""" return get_lang_hook(lang_name, "test_coverage") or object() + def _infer_lang_name(test_files: set[str], production_files: set[str]) -> str | None: """Infer language from known file extensions when explicit lang is unavailable.""" paths = list(test_files) + list(production_files) @@ -40,6 +42,33 @@ def _infer_lang_name(test_files: set[str], production_files: set[str]) -> str | return None + +def _discover_additional_test_mapping_files( + test_files: set[str], + production_files: set[str], + lang_name: str | None = None, +) -> set[str]: + """Allow language hooks to contribute mapping-only files for coverage discovery.""" + if lang_name is None: + lang_name = _infer_lang_name(test_files, production_files) + mod = _load_lang_test_coverage_module(lang_name) + discover = getattr(mod, "discover_test_mapping_files", None) + if not callable(discover): + return set() + + discovered = discover(test_files, production_files) + if not discovered: + return set() + + result: set[str] = set() + for path in discovered: + if not path: + continue + result.add(str(Path(path).resolve())) + return result + + + def _resolve_import( spec: str, test_path: str, @@ -53,6 +82,7 @@ def _resolve_import( return None + def _resolve_barrel_reexports( filepath: str, production_files: set[str], @@ -68,6 +98,7 @@ def _resolve_barrel_reexports( return set() + def _parse_test_imports( test_path: str, production_files: set[str], @@ -111,6 +142,7 @@ def _parse_test_imports( __all__ = [ + "_discover_additional_test_mapping_files", "_infer_lang_name", "_load_lang_test_coverage_module", "_parse_test_imports", diff --git a/desloppify/engine/detectors/gods.py b/desloppify/engine/detectors/gods.py index c105b36a..5290df45 100644 --- a/desloppify/engine/detectors/gods.py +++ b/desloppify/engine/detectors/gods.py @@ -1,5 +1,7 @@ """God class/component detection via configurable rule-based analysis.""" +from __future__ import annotations + def detect_gods(classes, rules, min_reasons: int = 2) -> tuple[list[dict], int]: """Find god classes/components — entities with too many responsibilities.""" @@ -23,3 +25,6 @@ def detect_gods(classes, rules, min_reasons: int = 2) -> tuple[list[dict], int]: } ) return sorted(entries, key=lambda e: -e["loc"]), len(classes) + + +__all__ = ["detect_gods"] diff --git a/desloppify/engine/detectors/large.py b/desloppify/engine/detectors/large.py index fc1f1a7b..cc7dcc8e 100644 --- a/desloppify/engine/detectors/large.py +++ b/desloppify/engine/detectors/large.py @@ -1,5 +1,7 @@ """Large file detection (LOC threshold).""" +from __future__ import annotations + import logging from pathlib import Path @@ -29,3 +31,6 @@ def detect_large_files( ) continue return sorted(entries, key=lambda e: -e["loc"]), len(files) + + +__all__ = ["detect_large_files"] diff --git a/desloppify/engine/detectors/naming.py b/desloppify/engine/detectors/naming.py index a4176349..4dd606d3 100644 --- a/desloppify/engine/detectors/naming.py +++ b/desloppify/engine/detectors/naming.py @@ -1,5 +1,7 @@ """Naming consistency analysis: flag directories with mixed filename conventions.""" +from __future__ import annotations + from collections import defaultdict from pathlib import Path @@ -84,3 +86,6 @@ def detect_naming_inconsistencies( ) return sorted(entries, key=lambda e: -e["minority_count"]), len(dir_files) + + +__all__ = ["detect_naming_inconsistencies"] diff --git a/desloppify/engine/detectors/passthrough.py b/desloppify/engine/detectors/passthrough.py index 332c8ca8..db57d0e1 100644 --- a/desloppify/engine/detectors/passthrough.py +++ b/desloppify/engine/detectors/passthrough.py @@ -4,6 +4,8 @@ the shared core that classifies parameters as passthrough vs direct-use. """ +from __future__ import annotations + import re from collections.abc import Callable @@ -52,3 +54,6 @@ def classify_params( else: direct.append(name) return passthrough, direct + + +__all__ = ["classify_params", "classify_passthrough_tier"] diff --git a/desloppify/engine/detectors/single_use.py b/desloppify/engine/detectors/single_use.py index 989cf318..fa398a86 100644 --- a/desloppify/engine/detectors/single_use.py +++ b/desloppify/engine/detectors/single_use.py @@ -1,5 +1,7 @@ """Single-use file detection (file-level importer heuristic for inlining candidates).""" +from __future__ import annotations + import logging from pathlib import Path @@ -84,3 +86,6 @@ def detect_single_use_abstractions( ) continue return sorted(entries, key=lambda e: -e["loc"]), total_candidates + + +__all__ = ["detect_single_use_abstractions"] diff --git a/desloppify/engine/detectors/test_coverage/detector.py b/desloppify/engine/detectors/test_coverage/detector.py index 1b7f9502..1080cb49 100644 --- a/desloppify/engine/detectors/test_coverage/detector.py +++ b/desloppify/engine/detectors/test_coverage/detector.py @@ -8,6 +8,9 @@ naming_based_mapping, transitive_coverage, ) +from desloppify.engine.detectors.coverage.mapping_imports import ( + _discover_additional_test_mapping_files, +) from desloppify.engine.policy.zones import FileZoneMap from .discovery import ( @@ -21,6 +24,7 @@ ) + def detect_test_coverage( graph: dict, zone_map: FileZoneMap, @@ -49,14 +53,23 @@ def detect_test_coverage( entries = _no_tests_issues(scorable, graph, lang_name, complexity_map) return entries, potential - directly_tested = set(inline_tested) + mapping_test_files = set(test_files) if test_files: + mapping_test_files |= _discover_additional_test_mapping_files( + test_files, + production_files, + lang_name, + ) + + directly_tested = set(inline_tested) + if mapping_test_files: directly_tested |= import_based_mapping( graph, - test_files, + mapping_test_files, production_files, lang_name, ) + if test_files: directly_tested |= naming_based_mapping(test_files, production_files, lang_name) transitively_tested = transitive_coverage(directly_tested, graph, production_files) diff --git a/desloppify/engine/hook_registry.py b/desloppify/engine/hook_registry.py index 8626cea4..113ae2e8 100644 --- a/desloppify/engine/hook_registry.py +++ b/desloppify/engine/hook_registry.py @@ -1,4 +1,4 @@ -"""Registry for optional language hook modules consumed by detectors.""" +"""Registry accessors for optional language hook modules consumed by detectors.""" from __future__ import annotations @@ -17,24 +17,20 @@ def register_lang_hooks( test_coverage: object | None = None, ) -> None: """Register optional detector hook modules for a language.""" - if test_coverage is not None: - registry_state.register_hook(lang_name, "test_coverage", test_coverage) + registry_state.register_lang_hooks( + lang_name, + test_coverage=test_coverage, + ) def _bootstrap_language_module(module: object) -> None: - """Run optional language-module bootstrap hook(s). - - Preference order: - 1) ``register_hooks`` (hook-only bootstrap, no language registry mutation) - 2) ``register`` (legacy fallback) - """ + """Run optional language-module bootstrap hook(s).""" register_hooks_fn = getattr(module, "register_hooks", None) if register_hooks_fn is not None: if not callable(register_hooks_fn): raise TypeError("Language module register_hooks entrypoint must be callable") register_hooks_fn() return - register_fn = getattr(module, "register", None) if register_fn is None: return @@ -43,54 +39,29 @@ def _bootstrap_language_module(module: object) -> None: register_fn() -def _load_language_module(module_name: str) -> object: - """Resolve language module from sys.modules or import it lazily.""" - module = sys.modules.get(module_name) - if module is not None: - return module - return importlib.import_module(module_name) - - -def _get_lang_hook( +def get_lang_hook( lang_name: str | None, hook_name: str, ) -> object | None: + """Get a previously-registered language hook module, lazy-loading if needed.""" if not lang_name: return None hook = registry_state.get_hook(lang_name, hook_name) if hook is not None: return hook + # Lazy bootstrap: import the language module and run its registration. module_name = f"desloppify.languages.{lang_name}" try: - module = _load_language_module(module_name) - except (ImportError, ValueError, TypeError, RuntimeError, OSError) as exc: - logger.debug( - "Unable to import language hook package %s: %s", lang_name, exc - ) - return None - - # Re-run explicit register() entrypoint to repopulate hook state after - # registry clears in tests/refresh flows. Avoid module reload side effects. - try: + module = sys.modules.get(module_name) or importlib.import_module(module_name) _bootstrap_language_module(module) except (ImportError, ValueError, TypeError, RuntimeError, OSError) as exc: - logger.debug( - "Unable to bootstrap language hook package %s: %s", lang_name, exc - ) + logger.debug("Unable to bootstrap language hooks for %s: %s", lang_name, exc) return None return registry_state.get_hook(lang_name, hook_name) -def get_lang_hook( - lang_name: str | None, - hook_name: str, -) -> object | None: - """Get a previously-registered language hook module.""" - return _get_lang_hook(lang_name, hook_name) - - def clear_lang_hooks() -> None: """Clear registered language hooks.""" registry_state.clear_hooks() diff --git a/desloppify/engine/plan_state.py b/desloppify/engine/plan_state.py index f00125bc..64fd3ce7 100644 --- a/desloppify/engine/plan_state.py +++ b/desloppify/engine/plan_state.py @@ -35,6 +35,7 @@ ) from desloppify.engine._plan.schema import ( ActionStep, + EpicTriageMeta, EPIC_PREFIX, PLAN_VERSION, VALID_EPIC_DIRECTIONS, @@ -57,6 +58,7 @@ "Cluster", "CommitRecord", "EPIC_PREFIX", + "EpicTriageMeta", "ExecutionLogEntry", "ItemOverride", "PLAN_FILE", diff --git a/desloppify/engine/plan_triage.py b/desloppify/engine/plan_triage.py index f389d6a3..4bff0cf8 100644 --- a/desloppify/engine/plan_triage.py +++ b/desloppify/engine/plan_triage.py @@ -59,7 +59,7 @@ TriageStartDecision, decide_triage_start, ) -from desloppify.engine._plan.sync.context import has_objective_backlog +from desloppify.engine._plan.sync.context import has_objective_backlog, is_mid_cycle from desloppify.engine.plan_state import PlanModel, ensure_plan_defaults @@ -80,16 +80,26 @@ def triage_phase_banner( resolved_snapshot = snapshot or build_triage_snapshot(plan, resolved_state) if not resolved_snapshot.has_triage_in_queue: + if ( + resolved_state + and resolved_snapshot.is_triage_stale + and is_mid_cycle(plan) + and has_objective_backlog(resolved_state, None) + ): + return ( + "TRIAGE PENDING — review issues changed since last triage and will " + "activate after objective work is complete." + ) undispositioned = len(resolved_snapshot.undispositioned_ids) if undispositioned: return ( "TRIAGE RECOVERY NEEDED — " - f"{undispositioned} review issue(s) still need cluster/skip dispositions. " + f"{undispositioned} review work item(s) still need cluster/skip dispositions. " f"{run_hint}" ) if resolved_snapshot.is_triage_stale or meta.get("triage_recommended"): return ( - "TRIAGE RECOMMENDED — review issues changed since last triage. " + "TRIAGE RECOMMENDED — review work items changed since last triage. " f"{run_hint}" ) return "" @@ -99,7 +109,7 @@ def triage_phase_banner( if undispositioned: return ( "TRIAGE PENDING — " - f"{undispositioned} review issue(s) still need cluster/skip dispositions after current work. " + f"{undispositioned} review work item(s) still need cluster/skip dispositions after current work. " f"{run_hint}" ) return ( @@ -108,12 +118,13 @@ def triage_phase_banner( ) progress = resolved_snapshot.progress if progress.completed_count: + total_stages = len(TRIAGE_STAGE_LABELS) return ( - f"TRIAGE MODE ({progress.completed_count}/6 stages recorded) — " + f"TRIAGE MODE ({progress.completed_count}/{total_stages} stages recorded) — " f"complete all stages to exit. {run_hint}" ) return ( - "TRIAGE MODE — review issues need analysis before fixing. " + "TRIAGE MODE — review work items need analysis before fixing. " f"{run_hint}" ) diff --git a/desloppify/engine/planning/render.py b/desloppify/engine/planning/render.py index 05f9f067..d76795fc 100644 --- a/desloppify/engine/planning/render.py +++ b/desloppify/engine/planning/render.py @@ -224,7 +224,7 @@ def generate_plan_md(state: PlanState, plan: dict | None = None) -> str: items, clusters, skipped, and superseded sections are rendered. When no plan exists, output is identical to the previous behavior. """ - issues = state["issues"] + issues = state.get("work_items") or state.get("issues", {}) stats = state.get("stats", {}) # Auto-load plan if not provided diff --git a/desloppify/engine/planning/scorecard_dimensions.py b/desloppify/engine/planning/scorecard_dimensions.py index 822cee70..38d2aee9 100644 --- a/desloppify/engine/planning/scorecard_dimensions.py +++ b/desloppify/engine/planning/scorecard_dimensions.py @@ -35,7 +35,7 @@ def _lang_from_capabilities(state: dict) -> str | None: def _lang_from_issues(state: dict) -> str | None: - issues = state.get("issues") + issues = state.get("work_items") if not isinstance(issues, dict): return None counts: dict[str, int] = {} diff --git a/desloppify/intelligence/integrity.py b/desloppify/intelligence/integrity.py index 17bb2377..c8bd78c2 100644 --- a/desloppify/intelligence/integrity.py +++ b/desloppify/intelligence/integrity.py @@ -8,6 +8,7 @@ from collections.abc import Iterable, Mapping +from desloppify.engine._state.issue_semantics import is_assessment_request from desloppify.engine._scoring.policy.core import ( SUBJECTIVE_TARGET_MATCH_TOLERANCE, matches_target_score, @@ -50,10 +51,7 @@ def _iter_issues( def is_subjective_review_open(issue: dict) -> bool: """Return True when a issue is an open subjective-review signal.""" - return ( - issue.get("status") == "open" - and issue.get("detector") == "subjective_review" - ) + return issue.get("status") == "open" and is_assessment_request(issue) def is_holistic_subjective_issue(issue: dict, *, issue_id: str = "") -> bool: @@ -76,7 +74,7 @@ def is_holistic_subjective_issue(issue: dict, *, issue_id: str = "") -> bool: return True # Dimension-level issues are codebase-wide by nature - if issue.get("detector") == "subjective_review" and detail.get("dimension"): + if is_assessment_request(issue) and detail.get("dimension"): return True return False diff --git a/desloppify/intelligence/narrative/action_engine.py b/desloppify/intelligence/narrative/action_engine.py index 005a5bd0..2396fc4f 100644 --- a/desloppify/intelligence/narrative/action_engine.py +++ b/desloppify/intelligence/narrative/action_engine.py @@ -75,7 +75,7 @@ def _fixer_has_applicable_issues( and not issue.get("suppressed") and issue.get("detector") == "smells" and issue.get("detail", {}).get("smell_id") == smell_id - for issue in state.get("issues", {}).values() + for issue in (state.get("work_items") or state.get("issues", {})).values() ) diff --git a/desloppify/intelligence/narrative/action_engine_routing.py b/desloppify/intelligence/narrative/action_engine_routing.py index dc123b50..396225b8 100644 --- a/desloppify/intelligence/narrative/action_engine_routing.py +++ b/desloppify/intelligence/narrative/action_engine_routing.py @@ -6,6 +6,12 @@ from typing import Any from desloppify.engine._scoring.results.core import get_dimension_for_detector +from desloppify.engine._state.issue_semantics import ( + ASSESSMENT_REQUEST, + REVIEW_DEFECT, + REVIEW_CONCERN, + infer_work_item_kind, +) from desloppify.intelligence.narrative._constants import DETECTOR_TOOLS from desloppify.intelligence.narrative.action_models import ActionItem @@ -53,24 +59,25 @@ def _build_refactor_entry( guidance = tool_info.get("guidance", "manual fix") adjusted_info = {**tool_info, "guidance": guidance} - if detector == "subjective_review": + work_item_kind = infer_work_item_kind(detector) + if work_item_kind == ASSESSMENT_REQUEST: command = "desloppify review --prepare" suffix = "s" if count != 1 else "" description = ( - f"{count} subjective dimension{suffix} need review — run holistic " + f"{count} assessment request{suffix} need review — run holistic " "review to refresh subjective scores" ) - elif detector == "review": + elif work_item_kind in {REVIEW_DEFECT, REVIEW_CONCERN}: command = "desloppify show review --status open" suffix = "s" if count != 1 else "" description = ( - f"{count} review issue{suffix} need investigation — " + f"{count} review work item{suffix} need investigation — " "run `desloppify show review --status open` to see them" ) adjusted_info = {**adjusted_info, "action_type": "refactor"} else: command = f"desloppify show {detector} --status open" - description = f"{count} {detector} issues — {guidance}" + description = f"{count} {detector} work items — {guidance}" return { "type": adjusted_info["action_type"], diff --git a/desloppify/intelligence/narrative/dimensions.py b/desloppify/intelligence/narrative/dimensions.py index 318eaff7..a77fc9a9 100644 --- a/desloppify/intelligence/narrative/dimensions.py +++ b/desloppify/intelligence/narrative/dimensions.py @@ -64,7 +64,7 @@ def _biggest_gap_dimensions(dim_scores: dict, state: StateModel) -> list[dict]: """Build summary entries for dimensions with the biggest strict gap.""" biggest_gap = [] scoped = path_scoped_issues( - state.get("issues", {}), state.get("scan_path") + (state.get("work_items") or state.get("issues", {})), state.get("scan_path") ) for name, ds in dim_scores.items(): lenient = ds["score"] diff --git a/desloppify/intelligence/narrative/headline.py b/desloppify/intelligence/narrative/headline.py index 67f870d4..13bd0c47 100644 --- a/desloppify/intelligence/narrative/headline.py +++ b/desloppify/intelligence/narrative/headline.py @@ -34,10 +34,10 @@ def compute_headline( uninvestigated = (open_by_detector or {}).get("review_uninvestigated", 0) if uninvestigated > 0: review_suffix = ( - f" ({review_count} review issue{s} \u2014 run `desloppify show review --status open`)" + f" ({review_count} review work item{s} \u2014 run `desloppify show review --status open`)" ) else: - review_suffix = f" ({review_count} review issue{s} pending)" + review_suffix = f" ({review_count} review work item{s} pending)" headline = _compute_headline_inner( phase, diff --git a/desloppify/intelligence/narrative/reminders.py b/desloppify/intelligence/narrative/reminders.py index 31fdf4df..c0f69211 100644 --- a/desloppify/intelligence/narrative/reminders.py +++ b/desloppify/intelligence/narrative/reminders.py @@ -45,7 +45,7 @@ def compute_reminders( strict_score = score_snapshot(state).strict reminder_history = state.get("reminder_history", {}) scoped_issues = path_scoped_issues( - state.get("issues", {}), state.get("scan_path") + (state.get("work_items") or state.get("issues", {})), state.get("scan_path") ) fp_rates = _compute_fp_rates(scoped_issues) diff --git a/desloppify/intelligence/narrative/reminders_rules_followup.py b/desloppify/intelligence/narrative/reminders_rules_followup.py index 307ebf11..e4d521c4 100644 --- a/desloppify/intelligence/narrative/reminders_rules_followup.py +++ b/desloppify/intelligence/narrative/reminders_rules_followup.py @@ -37,7 +37,7 @@ def _review_queue_reminders( { "type": "review_issues_pending", "message": ( - f"{len(uninvestigated)} review issue(s) need investigation. " + f"{len(uninvestigated)} review work item(s) need investigation. " "Run `desloppify show review --status open` to see the work queue." ), "command": "desloppify show review --status open", @@ -77,9 +77,10 @@ def _review_queue_reminders( def _has_open_issues(state: StateModel) -> bool: """True when any non-suppressed open issues remain in the queue.""" + issues = state.get("work_items") or state.get("issues", {}) return any( issue.get("status") == "open" and not issue.get("suppressed") - for issue in (state.get("issues") or {}).values() + for issue in issues.values() ) diff --git a/desloppify/intelligence/narrative/reminders_rules_primary.py b/desloppify/intelligence/narrative/reminders_rules_primary.py index a70de68a..74e83d93 100644 --- a/desloppify/intelligence/narrative/reminders_rules_primary.py +++ b/desloppify/intelligence/narrative/reminders_rules_primary.py @@ -109,7 +109,7 @@ def _wontfix_debt_reminders( return reminders stale_wontfix = [] - for issue in state.get("issues", {}).values(): + for issue in (state.get("work_items") or state.get("issues", {})).values(): if issue.get("status") != "wontfix": continue resolved_at = issue.get("resolved_at") diff --git a/desloppify/intelligence/narrative/signals.py b/desloppify/intelligence/narrative/signals.py index 92d0a8f7..f21f30fa 100644 --- a/desloppify/intelligence/narrative/signals.py +++ b/desloppify/intelligence/narrative/signals.py @@ -13,6 +13,10 @@ load_config as _load_config, ) from desloppify.base.discovery.paths import get_project_root +from desloppify.engine._state.issue_semantics import ( + is_review_finding, + is_assessment_request, +) from desloppify.intelligence.narrative._constants import STRUCTURAL_MERGE from desloppify.intelligence.narrative.types import ( BadgeStatus, @@ -102,15 +106,17 @@ def count_open_by_detector(issues: dict) -> dict[str, int]: if detector in STRUCTURAL_MERGE: detector = "structural" by_detector[detector] = by_detector.get(detector, 0) + 1 - if detector == "review" and issue.get("detail", {}).get("holistic"): + if is_review_finding(issue) and issue.get("detail", {}).get("holistic"): by_detector["review_holistic"] = by_detector.get("review_holistic", 0) + 1 + if is_assessment_request(issue): + by_detector["assessment_request"] = by_detector.get("assessment_request", 0) + 1 if by_detector.get("review", 0) > 0: by_detector["review_uninvestigated"] = sum( 1 for issue in issues.values() if issue.get("status") == "open" and not issue.get("suppressed") - and issue.get("detector") == "review" + and is_review_finding(issue) and not issue.get("detail", {}).get("investigation") ) return by_detector @@ -267,7 +273,7 @@ def history_for_lang(raw_history: list[dict], lang: str | None) -> list[dict]: def scoped_issues(state: StateModel) -> dict[str, Issue]: return path_scoped_issues( - state.get("issues", {}), state.get("scan_path") + (state.get("work_items") or state.get("issues", {})), state.get("scan_path") ) diff --git a/desloppify/intelligence/review/_prepare/issue_history.py b/desloppify/intelligence/review/_prepare/issue_history.py index 36c0a5c5..744d1f34 100644 --- a/desloppify/intelligence/review/_prepare/issue_history.py +++ b/desloppify/intelligence/review/_prepare/issue_history.py @@ -76,7 +76,7 @@ def _related_files(issue: dict[str, Any], *, limit: int = 6) -> list[str]: def _iter_review_issues(state: StateModel) -> list[dict[str, Any]]: - issues = state.get("issues") + issues = state.get("work_items") if not isinstance(issues, dict): return [] out: list[dict[str, Any]] = [] diff --git a/desloppify/intelligence/review/_prepare/remediation_engine.py b/desloppify/intelligence/review/_prepare/remediation_engine.py index 1a878b88..27113204 100644 --- a/desloppify/intelligence/review/_prepare/remediation_engine.py +++ b/desloppify/intelligence/review/_prepare/remediation_engine.py @@ -44,7 +44,7 @@ def render_empty_remediation_plan(state: StateModel, lang_name: str) -> str: def _collect_holistic_issues( state: StateModel, ) -> list[tuple[str, Issue]]: - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) return [ (issue_id, issue) for issue_id, issue in issues.items() diff --git a/desloppify/intelligence/review/context.py b/desloppify/intelligence/review/context.py index 31f31670..4e36e23b 100644 --- a/desloppify/intelligence/review/context.py +++ b/desloppify/intelligence/review/context.py @@ -27,7 +27,10 @@ from desloppify.intelligence.review.context_signals.migration import ( classify_error_strategy, ) -from desloppify.intelligence.review.context_builder import build_review_context_inner +from desloppify.intelligence.review.context_builder import ( + ReviewContextBuildServices, + build_review_context_inner, +) # ── Shared helpers ──────────────────────────────────────────────── @@ -101,18 +104,20 @@ def build_review_context( lang, state, ctx, - read_file_text_fn=read_file_text, - abs_path_fn=abs_path, - rel_fn=rel, - importer_count_fn=importer_count, - default_review_module_patterns_fn=default_review_module_patterns, - func_name_re=FUNC_NAME_RE, - class_name_re=CLASS_NAME_RE, - name_prefix_re=NAME_PREFIX_RE, - error_patterns=ERROR_PATTERNS, - gather_ai_debt_signals_fn=gather_ai_debt_signals, - gather_auth_context_fn=gather_auth_context, - classify_error_strategy_fn=classify_error_strategy, + ReviewContextBuildServices( + read_file_text=read_file_text, + abs_path=abs_path, + rel_path=rel, + importer_count=importer_count, + default_review_module_patterns=default_review_module_patterns, + gather_ai_debt_signals=gather_ai_debt_signals, + gather_auth_context=gather_auth_context, + classify_error_strategy=classify_error_strategy, + func_name_re=FUNC_NAME_RE, + class_name_re=CLASS_NAME_RE, + name_prefix_re=NAME_PREFIX_RE, + error_patterns=ERROR_PATTERNS, + ), ) finally: if not already_cached: diff --git a/desloppify/intelligence/review/context_builder.py b/desloppify/intelligence/review/context_builder.py index 2db017f5..7a1fac4a 100644 --- a/desloppify/intelligence/review/context_builder.py +++ b/desloppify/intelligence/review/context_builder.py @@ -4,44 +4,54 @@ import re from collections import Counter +from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path from desloppify.engine._state.schema import StateModel from desloppify.intelligence.review._context.models import ReviewContext +@dataclass(frozen=True) +class ReviewContextBuildServices: + """Typed dependency bundle for review-context assembly.""" + + read_file_text: Callable[[str], str | None] + abs_path: Callable[[str], str] + rel_path: Callable[[str], str] + importer_count: Callable[[dict[str, object]], int] + default_review_module_patterns: Callable[[str], list[str] | tuple[str, ...] | set[str]] + gather_ai_debt_signals: Callable[..., dict[str, object]] + gather_auth_context: Callable[..., dict[str, object]] + classify_error_strategy: Callable[[str], str] + func_name_re: re.Pattern[str] + class_name_re: re.Pattern[str] + name_prefix_re: re.Pattern[str] + error_patterns: dict[str, re.Pattern[str]] + + def build_review_context_inner( files: list[str], lang: object, state: StateModel, ctx: ReviewContext, - *, - read_file_text_fn, - abs_path_fn, - rel_fn, - importer_count_fn, - default_review_module_patterns_fn, - func_name_re, - class_name_re, - name_prefix_re, - error_patterns: dict[str, re.Pattern[str]], - gather_ai_debt_signals_fn, - gather_auth_context_fn, - classify_error_strategy_fn, + services: ReviewContextBuildServices, ) -> ReviewContext: """Inner context builder (runs with file cache enabled).""" file_contents: dict[str, str] = {} for filepath in files: - content = read_file_text_fn(abs_path_fn(filepath)) + content = services.read_file_text(services.abs_path(filepath)) if content is not None: file_contents[filepath] = content prefix_counter: Counter = Counter() total_names = 0 for content in file_contents.values(): - for name in func_name_re.findall(content) + class_name_re.findall(content): + for name in services.func_name_re.findall(content) + services.class_name_re.findall( + content + ): total_names += 1 - match = name_prefix_re.match(name) + match = services.name_prefix_re.match(name) if match: prefix_counter[match.group(1)] += 1 ctx.naming_vocabulary = { @@ -51,7 +61,7 @@ def build_review_context_inner( error_counts: Counter = Counter() for content in file_contents.values(): - for pattern_name, pattern in error_patterns.items(): + for pattern_name, pattern in services.error_patterns.items(): if pattern.search(content): error_counts[pattern_name] += 1 ctx.error_conventions = dict(error_counts) @@ -59,7 +69,7 @@ def build_review_context_inner( dir_patterns: dict[str, Counter] = {} module_pattern_fn = getattr(lang, "review_module_patterns_fn", None) if not callable(module_pattern_fn): - module_pattern_fn = default_review_module_patterns_fn + module_pattern_fn = services.default_review_module_patterns for filepath, content in file_contents.items(): parts = Path(filepath).parts if len(parts) < 2: @@ -68,7 +78,7 @@ def build_review_context_inner( counter = dir_patterns.setdefault(dir_name, Counter()) pattern_names = module_pattern_fn(content) if not isinstance(pattern_names, list | tuple | set): - pattern_names = default_review_module_patterns_fn(content) + pattern_names = services.default_review_module_patterns(content) for pattern_name in pattern_names: counter[pattern_name] += 1 if re.search(r"\bclass\s+\w+", content): @@ -83,9 +93,9 @@ def build_review_context_inner( graph = lang.dep_graph importer_counts = {} for filepath, entry in graph.items(): - count = importer_count_fn(entry) + count = services.importer_count(entry) if count > 0: - importer_counts[rel_fn(filepath)] = count + importer_counts[services.rel_path(filepath)] = count top = sorted(importer_counts.items(), key=lambda item: -item[1])[:20] ctx.import_graph_summary = {"top_imported": dict(top)} @@ -93,11 +103,11 @@ def build_review_context_inner( ctx.zone_distribution = lang.zone_map.counts() allowed_review_files = { - rel_fn(filepath) + services.rel_path(filepath) for filepath in file_contents if isinstance(filepath, str) and filepath } - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) by_file: dict[str, list[str]] = {} for issue in issues.values(): if issue.get("status") != "open": @@ -105,7 +115,7 @@ def build_review_context_inner( issue_file_raw = issue.get("file", "") if not isinstance(issue_file_raw, str) or not issue_file_raw: continue - issue_file = rel_fn(issue_file_raw) + issue_file = services.rel_path(issue_file_raw) if issue_file not in allowed_review_files: continue by_file.setdefault(issue_file, []).append( @@ -133,8 +143,8 @@ def build_review_context_inner( continue dir_name = parts[-2] + "/" counter = dir_functions.setdefault(dir_name, Counter()) - for name in func_name_re.findall(content): - match = name_prefix_re.match(name) + for name in services.func_name_re.findall(content): + match = services.name_prefix_re.match(name) if match: counter[match.group(1)] += 1 ctx.sibling_conventions = { @@ -143,14 +153,20 @@ def build_review_context_inner( if sum(c.values()) >= 3 } - ctx.ai_debt_signals = gather_ai_debt_signals_fn(file_contents, rel_fn=rel_fn) - ctx.auth_patterns = gather_auth_context_fn(file_contents, rel_fn=rel_fn) + ctx.ai_debt_signals = services.gather_ai_debt_signals( + file_contents, + rel_fn=services.rel_path, + ) + ctx.auth_patterns = services.gather_auth_context( + file_contents, + rel_fn=services.rel_path, + ) strategies: dict[str, str] = {} for filepath, content in file_contents.items(): - strategy = classify_error_strategy_fn(content) + strategy = services.classify_error_strategy(content) if strategy: - strategies[rel_fn(filepath)] = strategy + strategies[services.rel_path(filepath)] = strategy ctx.error_strategies = strategies ctx.normalize_sections(strict=True) @@ -158,5 +174,6 @@ def build_review_context_inner( __all__ = [ + "ReviewContextBuildServices", "build_review_context_inner", ] diff --git a/desloppify/intelligence/review/context_holistic/_accessors.py b/desloppify/intelligence/review/context_holistic/_accessors.py deleted file mode 100644 index 43bf0ecf..00000000 --- a/desloppify/intelligence/review/context_holistic/_accessors.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Compatibility wrapper for cluster accessors helpers.""" - -from .clusters.accessors import _get_detail, _get_signals, _safe_num - -__all__ = ["_get_detail", "_get_signals", "_safe_num"] diff --git a/desloppify/intelligence/review/context_holistic/_clusters_complexity.py b/desloppify/intelligence/review/context_holistic/_clusters_complexity.py deleted file mode 100644 index e2c328ed..00000000 --- a/desloppify/intelligence/review/context_holistic/_clusters_complexity.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Compatibility wrapper for complexity evidence cluster helpers.""" - -from .clusters.complexity import _build_complexity_hotspots - -__all__ = ["_build_complexity_hotspots"] diff --git a/desloppify/intelligence/review/context_holistic/_clusters_consistency.py b/desloppify/intelligence/review/context_holistic/_clusters_consistency.py deleted file mode 100644 index ab608008..00000000 --- a/desloppify/intelligence/review/context_holistic/_clusters_consistency.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Compatibility wrapper for consistency evidence cluster helpers.""" - -from .clusters.consistency import _build_duplicate_clusters, _build_naming_drift - -__all__ = ["_build_duplicate_clusters", "_build_naming_drift"] diff --git a/desloppify/intelligence/review/context_holistic/_clusters_dependency.py b/desloppify/intelligence/review/context_holistic/_clusters_dependency.py deleted file mode 100644 index 374f65a1..00000000 --- a/desloppify/intelligence/review/context_holistic/_clusters_dependency.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Compatibility wrapper for dependency evidence cluster helpers.""" - -from .clusters.dependency import ( - _build_boundary_violations, - _build_dead_code, - _build_deferred_import_density, - _build_private_crossings, -) - -__all__ = [ - "_build_boundary_violations", - "_build_dead_code", - "_build_deferred_import_density", - "_build_private_crossings", -] diff --git a/desloppify/intelligence/review/context_holistic/_clusters_error_state.py b/desloppify/intelligence/review/context_holistic/_clusters_error_state.py deleted file mode 100644 index 21dadd4d..00000000 --- a/desloppify/intelligence/review/context_holistic/_clusters_error_state.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Compatibility wrapper for error-state evidence cluster helpers.""" - -from .clusters.error_state import _build_error_hotspots, _build_mutable_globals - -__all__ = ["_build_error_hotspots", "_build_mutable_globals"] diff --git a/desloppify/intelligence/review/context_holistic/_clusters_organization.py b/desloppify/intelligence/review/context_holistic/_clusters_organization.py deleted file mode 100644 index c89a77a0..00000000 --- a/desloppify/intelligence/review/context_holistic/_clusters_organization.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Compatibility wrapper for organization evidence cluster helpers.""" - -from .clusters.organization import _build_flat_dir_issues, _build_large_file_distribution - -__all__ = ["_build_flat_dir_issues", "_build_large_file_distribution"] diff --git a/desloppify/intelligence/review/context_holistic/_clusters_security.py b/desloppify/intelligence/review/context_holistic/_clusters_security.py deleted file mode 100644 index 3f50e4f3..00000000 --- a/desloppify/intelligence/review/context_holistic/_clusters_security.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Compatibility wrapper for security evidence cluster helpers.""" - -from .clusters.security import ( - _build_security_hotspots, - _build_signal_density, - _build_systemic_patterns, -) - -__all__ = [ - "_build_security_hotspots", - "_build_signal_density", - "_build_systemic_patterns", -] diff --git a/desloppify/intelligence/review/context_holistic/clusters/__init__.py b/desloppify/intelligence/review/context_holistic/clusters/__init__.py index 2d66ff8c..a9b3e8be 100644 --- a/desloppify/intelligence/review/context_holistic/clusters/__init__.py +++ b/desloppify/intelligence/review/context_holistic/clusters/__init__.py @@ -1,38 +1 @@ -"""Canonical mechanical evidence cluster builders for holistic review.""" - -from .accessors import _get_detail, _get_signals, _safe_num -from .complexity import _build_complexity_hotspots -from .consistency import _build_duplicate_clusters, _build_naming_drift -from .dependency import ( - _build_boundary_violations, - _build_dead_code, - _build_deferred_import_density, - _build_private_crossings, -) -from .error_state import _build_error_hotspots, _build_mutable_globals -from .organization import _build_flat_dir_issues, _build_large_file_distribution -from .security import ( - _build_security_hotspots, - _build_signal_density, - _build_systemic_patterns, -) - -__all__ = [ - "_build_boundary_violations", - "_build_complexity_hotspots", - "_build_dead_code", - "_build_deferred_import_density", - "_build_duplicate_clusters", - "_build_error_hotspots", - "_build_flat_dir_issues", - "_build_large_file_distribution", - "_build_mutable_globals", - "_build_naming_drift", - "_build_private_crossings", - "_build_security_hotspots", - "_build_signal_density", - "_build_systemic_patterns", - "_get_detail", - "_get_signals", - "_safe_num", -] +"""Holistic review cluster builders package.""" diff --git a/desloppify/intelligence/review/context_holistic/mechanical.py b/desloppify/intelligence/review/context_holistic/mechanical.py index e38d27d6..ad72c657 100644 --- a/desloppify/intelligence/review/context_holistic/mechanical.py +++ b/desloppify/intelligence/review/context_holistic/mechanical.py @@ -53,7 +53,7 @@ def gather_mechanical_evidence( allowed_files: set[str] | list[str] | tuple[str, ...] | None = None, ) -> dict[str, Any]: """Aggregate open issues into evidence clusters for holistic review.""" - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) if not issues: return {} allowed_scope = _normalize_allowed_files(allowed_files) diff --git a/desloppify/intelligence/review/context_holistic/selection/contexts.py b/desloppify/intelligence/review/context_holistic/selection/contexts.py index a1194962..1b34c4b3 100644 --- a/desloppify/intelligence/review/context_holistic/selection/contexts.py +++ b/desloppify/intelligence/review/context_holistic/selection/contexts.py @@ -210,7 +210,7 @@ def dependencies_context( allowed_files: set[str] | None = None, ) -> dict[str, Any]: cycle_issues = [] - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) if not isinstance(issues, dict): issues = {} for issue in issues.values(): @@ -242,7 +242,7 @@ def testing_context( tc_issues = { issue["file"] - for issue in state.get("issues", {}).values() + for issue in (state.get("work_items") or state.get("issues", {})).values() if issue.get("detector") == "test_coverage" and issue.get("status") == "open" and in_allowed_files(issue.get("file", ""), allowed_files) diff --git a/desloppify/intelligence/review/context_signals/auth.py b/desloppify/intelligence/review/context_signals/auth.py index 9b27b009..d7f11378 100644 --- a/desloppify/intelligence/review/context_signals/auth.py +++ b/desloppify/intelligence/review/context_signals/auth.py @@ -65,6 +65,27 @@ ".tsx", } ) +_NON_RUNTIME_AUTH_SEGMENTS = frozenset( + { + "doc", + "docs", + "example", + "examples", + "guide", + "guidance", + "prompt", + "prompts", + "template", + "templates", + } +) +_NON_RUNTIME_AUTH_BASENAMES = ( + "agents", + "prompt", + "prompts", + "query", + "readme", +) # Table name pattern: matches unquoted, "double-quoted", `backtick`, or [bracket] names, # optionally preceded by a schema qualifier (e.g. public.users, "auth"."profiles"). _SQL_IDENT = r'(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|\w+)' @@ -276,10 +297,29 @@ def _segment_has_auth_enforcement(segment: str) -> bool: return bool(_NEGATED_AUTH_BRANCH_RE.search(segment) and _AUTH_DENIAL_RE.search(segment)) +def is_auth_runtime_path(filepath: str) -> bool: + """Return True when a path looks like executable auth-bearing source.""" + lower = filepath.lower().replace("\\", "/") + if not any(lower.endswith(ext) for ext in _AUTH_SOURCE_EXTENSIONS): + return False + parts = [part for part in lower.split("/") if part] + if any(part in _NON_RUNTIME_AUTH_SEGMENTS for part in parts[:-1]): + return False + basename = parts[-1] if parts else lower + stem, _, _ext = basename.partition(".") + if stem in _NON_RUNTIME_AUTH_BASENAMES: + return False + return True + + def _is_auth_source_file(filepath: str) -> bool: - """Return True when a file path should be scanned for executable auth signals.""" - lower = filepath.lower() - return any(lower.endswith(ext) for ext in _AUTH_SOURCE_EXTENSIONS) + """Backward-compatible wrapper for auth runtime-path filtering.""" + return is_auth_runtime_path(filepath) -__all__ = ["AuthorizationSignals", "RouteAuthCoverage", "gather_auth_context"] +__all__ = [ + "AuthorizationSignals", + "RouteAuthCoverage", + "gather_auth_context", + "is_auth_runtime_path", +] diff --git a/desloppify/intelligence/review/importing/assessments.py b/desloppify/intelligence/review/importing/assessments.py index 34f6d55e..07d01227 100644 --- a/desloppify/intelligence/review/importing/assessments.py +++ b/desloppify/intelligence/review/importing/assessments.py @@ -24,34 +24,24 @@ def _clean_judgment(raw: dict[str, Any]) -> dict[str, Any] | None: if isinstance(s, str) and str(s).strip() ] - # Accept dimension_character (new) or issue_character (legacy) dimension_character = "" dc = raw.get("dimension_character") if isinstance(dc, str) and dc.strip(): dimension_character = dc.strip() - issue_character = "" - ic = raw.get("issue_character") - if isinstance(ic, str) and ic.strip(): - issue_character = ic.strip() - score_rationale = "" sr = raw.get("score_rationale") if isinstance(sr, str) and sr.strip(): score_rationale = sr.strip() - if not strengths and not dimension_character and not issue_character and not score_rationale: + if not strengths and not dimension_character and not score_rationale: return None result: dict[str, Any] = {} if strengths: result["strengths"] = strengths - # Store dimension_character, falling back to issue_character - effective_dim_char = dimension_character or issue_character - if effective_dim_char: - result["dimension_character"] = effective_dim_char - if issue_character and not dimension_character: - result["issue_character"] = issue_character + if dimension_character: + result["dimension_character"] = dimension_character if score_rationale: result["score_rationale"] = score_rationale return result @@ -69,7 +59,7 @@ def store_assessments( *assessments*: ``{dim_name: score}`` or ``{dim_name: {score, ...}}``. *source*: ``"per_file"`` or ``"holistic"``. - *dimension_judgment*: optional ``{dim_name: {strengths, issue_character, score_rationale}}``. + *dimension_judgment*: optional ``{dim_name: {strengths, dimension_character, score_rationale}}``. Holistic assessments overwrite per-file for the same dimension. Per-file assessments don't overwrite holistic. diff --git a/desloppify/intelligence/review/importing/holistic.py b/desloppify/intelligence/review/importing/holistic.py index 3c3474c8..decc57f9 100644 --- a/desloppify/intelligence/review/importing/holistic.py +++ b/desloppify/intelligence/review/importing/holistic.py @@ -5,8 +5,9 @@ from pathlib import Path from typing import Any -from desloppify import state as state_mod from desloppify.engine.concerns import cleanup_stale_dismissals, generate_concerns +from desloppify.engine._state.merge import MergeScanOptions, merge_scan +from desloppify.engine._state.schema import StateModel, utc_now from desloppify.engine.scoring import HOLISTIC_POTENTIAL from desloppify.intelligence.review.dimensions import normalize_dimension_name from desloppify.intelligence.review.dimensions.data import load_dimensions_for_lang @@ -53,11 +54,11 @@ def parse_holistic_import_payload( def import_holistic_issues( issues_data: ReviewImportPayload, - state: state_mod.StateModel, + state: StateModel, lang_name: str, *, project_root: Path | str | None = None, - utc_now_fn=state_mod.utc_now, + utc_now_fn=utc_now, ) -> dict[str, Any]: """Import holistic (codebase-wide) issues into state.""" payload: ReviewImportEnvelope = parse_review_import_payload( @@ -147,10 +148,10 @@ def import_holistic_issues( if potentials.get("concerns", 0) > 0: merge_potentials_dict["concerns"] = potentials["concerns"] - diff = state_mod.merge_scan( + diff = merge_scan( state, review_issues, - options=state_mod.MergeScanOptions( + options=MergeScanOptions( lang=lang_name, potentials=merge_potentials_dict, merge_potentials=True, diff --git a/desloppify/intelligence/review/importing/holistic_cache.py b/desloppify/intelligence/review/importing/holistic_cache.py index c0667051..597508d9 100644 --- a/desloppify/intelligence/review/importing/holistic_cache.py +++ b/desloppify/intelligence/review/importing/holistic_cache.py @@ -132,8 +132,11 @@ def resolve_holistic_coverage_issues( assessed = _assessed_dimension_keys(state) if not assessed: return + work_items = state.get("work_items") or state.get("issues", {}) + state["work_items"] = work_items + state["issues"] = work_items now = utc_now_fn() - for issue in state.get("issues", {}).values(): + for issue in work_items.values(): if issue.get("status") != "open": continue if issue.get("detector") != "subjective_review": diff --git a/desloppify/intelligence/review/importing/payload.py b/desloppify/intelligence/review/importing/payload.py index a92d24d0..22be877a 100644 --- a/desloppify/intelligence/review/importing/payload.py +++ b/desloppify/intelligence/review/importing/payload.py @@ -79,15 +79,9 @@ def parse_review_import_payload( if not isinstance(data, dict): raise ValueError(f"{mode_name} review import payload must be a JSON object") - missing_issues_error = f"{mode_name} review import payload must contain 'issues'" - key_error = normalize_legacy_findings_alias( - data, - missing_issues_error=missing_issues_error, - ) - if key_error is not None: - raise ValueError(key_error) - issues_list = data.get("issues") + if issues_list is None: + raise ValueError(f"{mode_name} review import payload must contain 'issues'") if not isinstance(issues_list, list): raise ValueError(f"{mode_name} review import payload 'issues' must be a list") for idx, entry in enumerate(issues_list): diff --git a/desloppify/intelligence/review/importing/resolution.py b/desloppify/intelligence/review/importing/resolution.py index f479d5ac..a1404ff7 100644 --- a/desloppify/intelligence/review/importing/resolution.py +++ b/desloppify/intelligence/review/importing/resolution.py @@ -18,8 +18,11 @@ def auto_resolve_review_issues( utc_now_fn=utc_now, ) -> None: """Mark stale open review issues fixed when an explicit import supersedes them.""" + work_items = state.get("work_items") or state.get("issues", {}) + state["work_items"] = work_items + state["issues"] = work_items diff.setdefault("auto_resolved", 0) - for issue_id, issue in state.get("issues", {}).items(): + for issue_id, issue in work_items.items(): if issue_id in new_ids or issue.get("status") != "open": continue if not should_resolve(issue): diff --git a/desloppify/intelligence/review/prepare_batches_builders.py b/desloppify/intelligence/review/prepare_batches_builders.py index 7059b233..f21f7928 100644 --- a/desloppify/intelligence/review/prepare_batches_builders.py +++ b/desloppify/intelligence/review/prepare_batches_builders.py @@ -28,7 +28,7 @@ def _count_findings_for_dimensions( if not relevant: return {}, {} - issues = state.get("issues") + issues = state.get("work_items") if not isinstance(issues, dict): return {}, {} diff --git a/desloppify/intelligence/review/prepare_batches_collectors_structure.py b/desloppify/intelligence/review/prepare_batches_collectors_structure.py index 28190c41..7d255785 100644 --- a/desloppify/intelligence/review/prepare_batches_collectors_structure.py +++ b/desloppify/intelligence/review/prepare_batches_collectors_structure.py @@ -3,6 +3,7 @@ from __future__ import annotations from desloppify.intelligence.review._context.models import HolisticContext +from desloppify.intelligence.review.context_signals.auth import is_auth_runtime_path from .prepare_batches_core import _collect_unique_files, _representative_files_for_directory @@ -50,6 +51,8 @@ def _authorization_files( for rpath, info in sorted(route_auth_coverage.items()): if not isinstance(rpath, str) or not isinstance(info, dict): continue + if not is_auth_runtime_path(rpath): + continue without_auth = _to_int(info.get("without_auth", 0)) with_auth = _to_int(info.get("with_auth", 0)) if without_auth > 0: @@ -80,6 +83,8 @@ def _authorization_files( sibling_module_counts[module] = sibling_module_counts.get(module, 0) + 1 for rpath in auth_ctx.get("service_role_usage", []): + if not isinstance(rpath, str) or not is_auth_runtime_path(rpath): + continue auth_files.append({"file": rpath}) rls_coverage = auth_ctx.get("rls_coverage", {}) rls_files = rls_coverage.get("files", {}) @@ -87,6 +92,8 @@ def _authorization_files( for file_paths in rls_files.values(): if isinstance(file_paths, list): for filepath in file_paths: + if not isinstance(filepath, str) or not is_auth_runtime_path(filepath): + continue auth_files.append({"file": filepath}) return _collect_unique_files([auth_files], max_files=max_files) diff --git a/desloppify/intelligence/review/prepare_holistic_scope.py b/desloppify/intelligence/review/prepare_holistic_scope.py index 3c07b294..b9714f2f 100644 --- a/desloppify/intelligence/review/prepare_holistic_scope.py +++ b/desloppify/intelligence/review/prepare_holistic_scope.py @@ -124,8 +124,9 @@ def filter_batches_to_file_scope( if issue_focus is not None: batch["historical_issue_focus"] = issue_focus - # Keep any batch that has dimensions to review - if batch.get("dimensions"): + # Dimensions come from the original batch payload; this scope filter only trims + # file-linked fields such as concern signals and historical issue focus. + if raw_batch.get("dimensions"): scoped_batches.append(batch) return scoped_batches diff --git a/desloppify/intelligence/review/selection.py b/desloppify/intelligence/review/selection.py index 24e146b5..22f65c76 100644 --- a/desloppify/intelligence/review/selection.py +++ b/desloppify/intelligence/review/selection.py @@ -149,7 +149,7 @@ def _compute_review_priority(filepath: str, lang, state: dict) -> int: score += ic * 10 # Already has programmatic issues (compound value — review will be richer) - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) n_issues = sum( 1 for f in issues.values() if f.get("file") == rpath and f["status"] == "open" ) diff --git a/desloppify/intelligence/review/selection_cache.py b/desloppify/intelligence/review/selection_cache.py index 42a2eeaa..ca29b005 100644 --- a/desloppify/intelligence/review/selection_cache.py +++ b/desloppify/intelligence/review/selection_cache.py @@ -13,7 +13,7 @@ def get_file_issues(state: dict, filepath: str) -> list[dict]: """Get existing open issues for a file (summaries for context).""" rpath = rel(filepath) - issues = state.get("issues", {}) + issues = (state.get("work_items") or state.get("issues", {})) return [ {"detector": issue["detector"], "summary": issue["summary"], "id": issue["id"]} for issue in issues.values() diff --git a/desloppify/languages/README.md b/desloppify/languages/README.md index 1a52647e..6faafc23 100644 --- a/desloppify/languages/README.md +++ b/desloppify/languages/README.md @@ -1,6 +1,6 @@ # Languages -Desloppify supports 28 languages through a plugin system with two tiers: **full plugins** (7) with hand-written detectors and subjective review, and **generic plugins** (21) that wrap external linters and optionally use tree-sitter for AST analysis. +Desloppify supports 29 languages through a plugin system with two tiers: **full plugins** (8) with hand-written detectors and subjective review, and **generic plugins** (21) that wrap external linters and optionally use tree-sitter for AST analysis. ## Full Plugins @@ -47,6 +47,7 @@ These are single-file plugins (~20-40 lines) that call `generic_lang()` with ext | Erlang | `erlang/` | dialyzer | functions, imports | | OCaml | `ocaml/` | ocaml compiler | functions, modules, imports | | F# | `fsharp/` | dotnet build | functions, imports | +| Julia | `julia/` | JuliaFormatter | functions, imports | Example: `ruby/__init__.py` — wraps rubocop and tree-sitter import/function support as a generic plugin. diff --git a/desloppify/languages/_framework/base/phase_builders.py b/desloppify/languages/_framework/base/phase_builders.py index a977186f..ab00dabb 100644 --- a/desloppify/languages/_framework/base/phase_builders.py +++ b/desloppify/languages/_framework/base/phase_builders.py @@ -2,7 +2,10 @@ from __future__ import annotations +from functools import partial + from .shared_phases import ( + _detect_security_issues_default, phase_boilerplate_duplication, phase_dupes, phase_security, @@ -37,7 +40,11 @@ def factory() -> DetectorPhase: exclusive_detector="desloppify.engine.detectors.test_coverage", ), "security": _make_detector_phase_factory( - "Security", phase_security, + "Security", + partial( + phase_security, + detect_security_issues=_detect_security_issues_default, + ), exclusive_detector="desloppify.engine.detectors.security", ), "signature": _make_detector_phase_factory( diff --git a/desloppify/languages/_framework/base/shared_phases.py b/desloppify/languages/_framework/base/shared_phases.py index 32307f77..e6d04b09 100644 --- a/desloppify/languages/_framework/base/shared_phases.py +++ b/desloppify/languages/_framework/base/shared_phases.py @@ -2,12 +2,15 @@ from __future__ import annotations +from collections.abc import Callable from pathlib import Path -from typing import Any from desloppify.base.discovery.paths import get_project_root from desloppify.base.output.terminal import log -from desloppify.languages._framework.base.types import LangRuntimeContract +from desloppify.engine.detectors.security.detector import ( + detect_security_issues as _detect_security_issues_default, +) +from desloppify.languages._framework.base.types import DetectorEntry, LangRuntimeContract from desloppify.state_io import Issue from .shared_phases_helpers import ( @@ -16,11 +19,10 @@ _find_external_test_files, _log_phase_summary as _log_phase_summary_impl, ) -from . import shared_phases_review as shared_phases_review_mod from .shared_phases_review import ( - detect_security_issues as _detect_security_issues_default, phase_boilerplate_duplication, phase_dupes, + phase_security as _phase_security_review, phase_private_imports, phase_signature, phase_subjective_review, @@ -32,9 +34,6 @@ run_structural_phase, ) -detect_security_issues = _detect_security_issues_default - - def find_external_test_files(path: Path, lang: LangRuntimeContract) -> set[str]: """Compatibility wrapper with patchable get_project_root dependency.""" return _find_external_test_files(path, lang, get_project_root_fn=get_project_root) @@ -42,7 +41,7 @@ def find_external_test_files(path: Path, lang: LangRuntimeContract) -> set[str]: def _entries_to_issues( detector: str, - entries: list[dict[str, Any]], + entries: list[DetectorEntry], *, default_name: str = "", include_zone: bool = False, @@ -59,9 +58,9 @@ def _entries_to_issues( def _filter_boilerplate_entries_by_zone( - entries: list[dict[str, Any]], + entries: list[DetectorEntry], zone_map, -) -> list[dict[str, Any]]: +) -> list[DetectorEntry]: """Compatibility wrapper for boilerplate zone filtering.""" return _filter_boilerplate_entries_by_zone_impl(entries, zone_map) @@ -71,19 +70,24 @@ def _log_phase_summary(label: str, results: list[Issue], potential: int, unit: s _log_phase_summary_impl(label, results, potential, unit, log_fn=log) -def phase_security(path: Path, lang: LangRuntimeContract) -> tuple[list[Issue], dict[str, int]]: - """Compatibility wrapper with patchable security detector dependency.""" - original = shared_phases_review_mod.detect_security_issues - shared_phases_review_mod.detect_security_issues = detect_security_issues - try: - return shared_phases_review_mod.phase_security(path, lang) - finally: - shared_phases_review_mod.detect_security_issues = original +def phase_security( + path: Path, + lang: LangRuntimeContract, + *, + detect_security_issues: Callable[..., tuple[list[DetectorEntry], int]] = ( + _detect_security_issues_default + ), +) -> tuple[list[Issue], dict[str, int]]: + """Compatibility wrapper with an explicit security detector dependency.""" + return _phase_security_review( + path, + lang, + detect_security_issues=detect_security_issues, + ) __all__ = [ "_filter_boilerplate_entries_by_zone", - "detect_security_issues", "find_external_test_files", "make_structural_coupling_phase_pair", "phase_boilerplate_duplication", diff --git a/desloppify/languages/_framework/base/shared_phases_helpers.py b/desloppify/languages/_framework/base/shared_phases_helpers.py index 8a7cde57..e3496e1a 100644 --- a/desloppify/languages/_framework/base/shared_phases_helpers.py +++ b/desloppify/languages/_framework/base/shared_phases_helpers.py @@ -11,20 +11,24 @@ from desloppify.base.output.terminal import log from desloppify.engine._state.filtering import make_issue from desloppify.engine.policy.zones import should_skip_issue -from desloppify.languages._framework.base.types import DetectorCoverageStatus, LangRuntimeContract +from desloppify.languages._framework.base.types import ( + DetectorCoverageStatus, + DetectorEntry, + LangRuntimeContract, +) from desloppify.state_io import Issue def _filter_boilerplate_entries_by_zone( - entries: list[dict[str, Any]], + entries: list[DetectorEntry], zone_map, -) -> list[dict[str, Any]]: +) -> list[DetectorEntry]: """Keep only in-scope, zone-allowed boilerplate clusters.""" if zone_map is None: return entries known_files = set(zone_map.all_files()) - filtered: list[dict[str, Any]] = [] + filtered: list[DetectorEntry] = [] skipped = 0 for entry in entries: locations = entry.get("locations", []) @@ -83,7 +87,7 @@ def _find_external_test_files( def _entries_to_issues( detector: str, - entries: list[dict[str, Any]], + entries: list[DetectorEntry], *, default_name: str = "", include_zone: bool = False, diff --git a/desloppify/languages/_framework/base/shared_phases_review.py b/desloppify/languages/_framework/base/shared_phases_review.py index f54e01bd..a77419d3 100644 --- a/desloppify/languages/_framework/base/shared_phases_review.py +++ b/desloppify/languages/_framework/base/shared_phases_review.py @@ -2,17 +2,20 @@ from __future__ import annotations +from collections.abc import Callable from pathlib import Path from desloppify.base.discovery.file_paths import rel from desloppify.base.output.terminal import log from desloppify.engine.detectors.dupes import detect_duplicates from desloppify.engine.detectors.jscpd_adapter import detect_with_jscpd -from desloppify.engine.detectors.security.detector import detect_security_issues +from desloppify.engine.detectors.security.detector import ( + detect_security_issues as _detect_security_issues_default, +) from desloppify.engine.detectors.test_coverage.detector import detect_test_coverage from desloppify.engine._state.filtering import make_issue from desloppify.engine.policy.zones import EXCLUDED_ZONES, filter_entries -from desloppify.languages._framework.base.types import LangRuntimeContract +from desloppify.languages._framework.base.types import DetectorEntry, LangRuntimeContract from desloppify.languages._framework.issue_factories import make_dupe_issues from desloppify.state_io import Issue @@ -24,6 +27,10 @@ _record_detector_coverage, ) +# Compatibility export for language phase modules that still import the raw +# security detector symbol from this module. +detect_security_issues = _detect_security_issues_default + def phase_dupes(path: Path, lang: LangRuntimeContract) -> tuple[list[Issue], dict[str, int]]: """Shared phase runner: detect duplicate functions via lang.extract_functions.""" @@ -90,7 +97,14 @@ def phase_boilerplate_duplication( return issues, {"boilerplate_duplication": distinct_files} -def phase_security(path: Path, lang: LangRuntimeContract) -> tuple[list[Issue], dict[str, int]]: +def phase_security( + path: Path, + lang: LangRuntimeContract, + *, + detect_security_issues: Callable[..., tuple[list[DetectorEntry], int]] = ( + _detect_security_issues_default + ), +) -> tuple[list[Issue], dict[str, int]]: """Shared phase: detect security issues (cross-language + lang-specific).""" zone_map = lang.zone_map files = lang.file_finder(path) if lang.file_finder else [] diff --git a/desloppify/languages/_framework/base/types.py b/desloppify/languages/_framework/base/types.py index d61dbd62..39725a07 100644 --- a/desloppify/languages/_framework/base/types.py +++ b/desloppify/languages/_framework/base/types.py @@ -17,6 +17,7 @@ from desloppify.languages._framework.base.types_shared import ( BoundaryRule, CoverageStatus, + DetectorEntry, DetectorCoverageRecord, DetectorCoverageStatus, FixerConfig, @@ -47,7 +48,7 @@ class DetectorPhase: """ label: str - run: Callable[[Path, LangRuntimeContract], tuple[list[dict[str, Any]], dict[str, int]]] + run: Callable[[Path, LangRuntimeContract], tuple[list[DetectorEntry], dict[str, int]]] slow: bool = False @@ -71,7 +72,9 @@ class LangRuntimeContract(Protocol): get_area: Callable[[str], str] | None build_dep_graph: DepGraphBuilder detect_lang_security_detailed: Callable[[list[str], FileZoneMap | None], LangSecurityResult] - detect_private_imports: Callable[[dict, FileZoneMap | None], tuple[list[dict], int]] + detect_private_imports: Callable[ + [dict, FileZoneMap | None], tuple[list[DetectorEntry], int] + ] large_threshold: int complexity_threshold: int props_threshold: int @@ -238,7 +241,7 @@ def detect_lang_security_detailed( def detect_private_imports( self, graph: dict, zone_map: FileZoneMap | None - ) -> tuple[list[dict], int]: + ) -> tuple[list[DetectorEntry], int]: """Language-specific private-import detection. Override in subclasses.""" return [], 0 @@ -251,6 +254,7 @@ def scan_coverage_prerequisites(self) -> list[DetectorCoverageStatus]: "BoundaryRule", "CoverageStatus", "DepGraphBuilder", + "DetectorEntry", "DetectorCoverageRecord", "DetectorCoverageStatus", "DetectorPhase", diff --git a/desloppify/languages/_framework/base/types_shared.py b/desloppify/languages/_framework/base/types_shared.py index f9403cfc..df9a9f92 100644 --- a/desloppify/languages/_framework/base/types_shared.py +++ b/desloppify/languages/_framework/base/types_shared.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path -from typing import Literal, TypedDict +from typing import Any, Literal, NotRequired, TypedDict CoverageStatus = Literal["full", "reduced"] @@ -24,6 +24,18 @@ class DetectorCoverageRecord(TypedDict, total=False): reason: str +class DetectorEntry(TypedDict): + """Shared normalized detector-entry shape across framework boundaries.""" + + file: str + tier: int + confidence: str + summary: str + line: NotRequired[int] + detail: NotRequired[dict[str, Any]] + name: NotRequired[str] + + class ScanCoverageRecord(TypedDict, total=False): """Persisted scan-level coverage snapshot for one language run.""" @@ -52,7 +64,7 @@ class DetectorCoverageStatus: class LangSecurityResult: """Normalized return shape for language-specific security hooks.""" - entries: list[dict] + entries: list[DetectorEntry] files_scanned: int coverage: DetectorCoverageStatus | None = None @@ -100,6 +112,7 @@ class LangValueSpec: __all__ = [ "BoundaryRule", "CoverageStatus", + "DetectorEntry", "DetectorCoverageRecord", "DetectorCoverageStatus", "FixerConfig", diff --git a/desloppify/languages/_framework/generic_support/__init__.py b/desloppify/languages/_framework/generic_support/__init__.py index 11e4b1c8..7a2485ca 100644 --- a/desloppify/languages/_framework/generic_support/__init__.py +++ b/desloppify/languages/_framework/generic_support/__init__.py @@ -1,50 +1 @@ -"""Canonical generic-plugin support subpackage for language framework helpers.""" - -from .capabilities import ( - SHARED_PHASE_LABELS, - capability_report, - empty_dep_graph, - generic_zone_rules, - make_file_finder, - noop_extract_functions, -) -from .core import ( - GenericLangOptions, - generic_lang, - make_tool_phase, - parse_cargo, - parse_eslint, - parse_gnu, - parse_golangci, - parse_json, - parse_rubocop, -) -from .registration import ( - _build_generic_phases, - _register_generic_tool_specs, - _resolve_generic_extractors, -) -from .structural import _make_coupling_phase, _make_structural_phase - -__all__ = [ - "GenericLangOptions", - "SHARED_PHASE_LABELS", - "_build_generic_phases", - "_make_coupling_phase", - "_make_structural_phase", - "_register_generic_tool_specs", - "_resolve_generic_extractors", - "capability_report", - "empty_dep_graph", - "generic_lang", - "generic_zone_rules", - "make_file_finder", - "make_tool_phase", - "noop_extract_functions", - "parse_cargo", - "parse_eslint", - "parse_gnu", - "parse_golangci", - "parse_json", - "parse_rubocop", -] +"""Generic-plugin support package for the language framework.""" diff --git a/desloppify/languages/_framework/generic_support/core.py b/desloppify/languages/_framework/generic_support/core.py index 47f01950..4727a821 100644 --- a/desloppify/languages/_framework/generic_support/core.py +++ b/desloppify/languages/_framework/generic_support/core.py @@ -135,7 +135,7 @@ def generic_lang( # Register language-specific test coverage hooks if provided. if opts.test_coverage_module is not None: - from desloppify.engine.hook_registry import register_lang_hooks + from desloppify.languages._framework.registry.state import register_lang_hooks register_lang_hooks(name, test_coverage=opts.test_coverage_module) diff --git a/desloppify/languages/_framework/registry/__init__.py b/desloppify/languages/_framework/registry/__init__.py index 09dbd0eb..8d27c653 100644 --- a/desloppify/languages/_framework/registry/__init__.py +++ b/desloppify/languages/_framework/registry/__init__.py @@ -18,6 +18,7 @@ is_registered, record_load_error, register, + register_lang_hooks, register_hook, remove, set_load_attempted, @@ -42,6 +43,7 @@ "raise_load_errors", "record_load_error", "register", + "register_lang_hooks", "register_full_plugin", "register_hook", "register_lang_class", diff --git a/desloppify/languages/_framework/registry/registration.py b/desloppify/languages/_framework/registry/registration.py index 5d1ebb8a..742ba72f 100644 --- a/desloppify/languages/_framework/registry/registration.py +++ b/desloppify/languages/_framework/registry/registration.py @@ -6,8 +6,6 @@ from pathlib import Path from typing import TypeVar -from desloppify.engine.hook_registry import register_lang_hooks - from . import state from ..base.types import LangConfig from .resolution import make_lang_config @@ -53,7 +51,7 @@ def register_full_plugin( test_coverage: object, ) -> None: """Register a full language plugin with uniform hooks + duplicate guard.""" - register_lang_hooks(name, test_coverage=test_coverage) + state.register_lang_hooks(name, test_coverage=test_coverage) if state.is_registered(name): return register_lang_class(name, config_cls) diff --git a/desloppify/languages/_framework/registry/state.py b/desloppify/languages/_framework/registry/state.py index 2452d484..1fce35f0 100644 --- a/desloppify/languages/_framework/registry/state.py +++ b/desloppify/languages/_framework/registry/state.py @@ -14,6 +14,7 @@ "get", "all_items", "all_keys", + "register_lang_hooks", "register_hook", "get_hook", "clear_hooks", @@ -64,6 +65,16 @@ def all_keys() -> list[str]: return list(_STATE.registry.keys()) +def register_lang_hooks( + lang_name: str, + *, + test_coverage: object | None = None, +) -> None: + """Register optional detector hook modules for a language.""" + if test_coverage is not None: + register_hook(lang_name, "test_coverage", test_coverage) + + def register_hook(lang_name: str, hook_name: str, hook: object) -> None: """Register a language hook inside the shared runtime state.""" hooks = _STATE.hooks.setdefault(lang_name, {}) diff --git a/desloppify/languages/_framework/runtime_support/__init__.py b/desloppify/languages/_framework/runtime_support/__init__.py index 8df2c5f6..1d6e09ab 100644 --- a/desloppify/languages/_framework/runtime_support/__init__.py +++ b/desloppify/languages/_framework/runtime_support/__init__.py @@ -1,19 +1 @@ -"""Canonical runtime-support subpackage for language framework helpers.""" - -from .accessors import LangRunStateAccessors -from .runtime import ( - LangRun, - LangRunOverrides, - LangRuntimeContract, - LangRuntimeState, - make_lang_run, -) - -__all__ = [ - "LangRun", - "LangRunOverrides", - "LangRunStateAccessors", - "LangRuntimeContract", - "LangRuntimeState", - "make_lang_run", -] +"""Runtime-support package for language framework helpers.""" diff --git a/desloppify/languages/_framework/treesitter/__init__.py b/desloppify/languages/_framework/treesitter/__init__.py index 85b9abba..0f5dad64 100644 --- a/desloppify/languages/_framework/treesitter/__init__.py +++ b/desloppify/languages/_framework/treesitter/__init__.py @@ -6,6 +6,9 @@ - ``specs``: language-spec catalogs and variants - ``imports``: import graph + resolver/cache helpers - ``analysis``: detectors/extractors/complexity helpers + +Underscore-prefixed modules at this package root remain compatibility shims only. +New code and direct tests should import from the grouped namespaces above. """ from __future__ import annotations diff --git a/desloppify/languages/_framework/treesitter/analysis/unused_imports.py b/desloppify/languages/_framework/treesitter/analysis/unused_imports.py index ea2727a8..f0689991 100644 --- a/desloppify/languages/_framework/treesitter/analysis/unused_imports.py +++ b/desloppify/languages/_framework/treesitter/analysis/unused_imports.py @@ -147,27 +147,28 @@ def _extract_import_name(import_path: str) -> str: "MyApp::Model::User" -> "User" "Data.List" -> "List" """ - # Strip common path separators and take the last segment. - for sep in ("::", ".", "/", "\\"): - if sep in import_path: - parts = import_path.split(sep) - # Filter out empty segments and take the last. - parts = [p for p in parts if p] + candidate = import_path.strip() + for sep in ("/", "\\"): + if sep in candidate: + parts = [p for p in candidate.split(sep) if p] if parts: - name = parts[-1] - # Strip file extensions. - for ext in (".go", ".rs", ".rb", ".py", ".js", ".jsx", ".ts", - ".tsx", ".java", ".kt", ".cs", ".fs", ".ml", - ".ex", ".erl", ".hs", ".lua", ".zig", ".pm", - ".sh", ".pl", ".scala", ".swift", ".php", - ".dart", ".mjs", ".cjs"): - if name.endswith(ext): - name = name[:-len(ext)] - break - return name - - # No separator — the path itself is the name. - return import_path + candidate = parts[-1] + + for ext in (".go", ".rs", ".rb", ".py", ".js", ".jsx", ".ts", + ".tsx", ".java", ".kt", ".cs", ".fs", ".ml", + ".ex", ".erl", ".hs", ".lua", ".zig", ".pm", + ".sh", ".pl", ".scala", ".swift", ".php", + ".dart", ".mjs", ".cjs", ".h", ".hh", ".hpp"): + if candidate.endswith(ext): + return candidate[:-len(ext)] + + for sep in ("::", "."): + if sep in candidate: + parts = [p for p in candidate.split(sep) if p] + if parts: + return parts[-1] + + return candidate __all__ = ["detect_unused_imports"] diff --git a/desloppify/languages/csharp/__init__.py b/desloppify/languages/csharp/__init__.py index 4af4c6c6..6ba2cf0c 100644 --- a/desloppify/languages/csharp/__init__.py +++ b/desloppify/languages/csharp/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from desloppify.base.discovery.paths import get_area -from desloppify.engine.hook_registry import register_lang_hooks from desloppify.languages._framework.base.phase_builders import ( detector_phase_security, detector_phase_signature, @@ -11,6 +10,7 @@ shared_subjective_duplicates_tail, ) from desloppify.languages._framework.registry.registration import register_full_plugin +from desloppify.languages._framework.registry.state import register_lang_hooks from desloppify.languages._framework.base.types import ( DetectorPhase, LangConfig, diff --git a/desloppify/languages/cxx/__init__.py b/desloppify/languages/cxx/__init__.py index 14db8afb..0698eeb8 100644 --- a/desloppify/languages/cxx/__init__.py +++ b/desloppify/languages/cxx/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from desloppify.base.discovery.paths import get_area -from desloppify.engine.hook_registry import register_lang_hooks from desloppify.languages._framework.base.phase_builders import ( detector_phase_security, detector_phase_signature, @@ -17,6 +16,7 @@ ) from desloppify.languages._framework.generic_parts.tool_factories import make_tool_phase from desloppify.languages._framework.registry.registration import register_full_plugin +from desloppify.languages._framework.registry.state import register_lang_hooks from desloppify.languages._framework.treesitter.phases import all_treesitter_phases from desloppify.languages.cxx import test_coverage as cxx_test_coverage_hooks from desloppify.languages.cxx._helpers import build_cxx_dep_graph @@ -49,6 +49,11 @@ def detect_lang_security_detailed(self, files, zone_map) -> LangSecurityResult: return detect_cxx_security(files, zone_map) def __init__(self): + tree_sitter_phases = [ + phase for phase in all_treesitter_phases("cpp") + if phase.label != "Unused imports" + ] + super().__init__( name="cxx", extensions=CXX_EXTENSIONS, @@ -61,7 +66,7 @@ def __init__(self): DetectorPhase("Structural analysis", phase_structural), DetectorPhase("Coupling + cycles + orphaned", phase_coupling), DetectorPhase("cppcheck", phase_cppcheck_issue), - *all_treesitter_phases("cpp"), + *tree_sitter_phases, detector_phase_signature(), detector_phase_test_coverage(), detector_phase_security(), diff --git a/desloppify/languages/cxx/detectors/security.py b/desloppify/languages/cxx/detectors/security.py index b2c10b9e..2d457085 100644 --- a/desloppify/languages/cxx/detectors/security.py +++ b/desloppify/languages/cxx/detectors/security.py @@ -659,7 +659,13 @@ def detect_cxx_security( tool_results.append(_run_clang_tidy(scan_root, scoped_files)) tool_results.append(_run_cppcheck(scan_root, scoped_files)) - tool_entries = [entry for result in tool_results for entry in result.entries] + scoped_file_set = {str(Path(filepath).resolve()) for filepath in scoped_files} + tool_entries = [ + entry + for result in tool_results + for entry in result.entries + if str(Path(str(entry.get("file", ""))).resolve()) in scoped_file_set + ] covered_files = { filepath for result in tool_results diff --git a/desloppify/languages/cxx/test_coverage.py b/desloppify/languages/cxx/test_coverage.py index e36e6149..653086c0 100644 --- a/desloppify/languages/cxx/test_coverage.py +++ b/desloppify/languages/cxx/test_coverage.py @@ -15,10 +15,17 @@ BARREL_BASENAMES: set[str] = set() _INCLUDE_RE = re.compile(r'(?m)^\s*#include\s*[<"]([^>"]+)[>"]') _SOURCE_EXTENSIONS = (".c", ".cc", ".cpp", ".cxx") +_HEADER_EXTENSIONS = (".h", ".hh", ".hpp") +_CMAKE_COMMENT_RE = re.compile(r"(?m)#.*$") +_CMAKE_COMMAND_RE = re.compile(r"\b(?:add_executable|add_library|target_sources)\s*\(", re.IGNORECASE) +_CMAKE_SOURCE_SPEC_RE = re.compile( + r'"([^"\n]+\.(?:cpp|cxx|cc|c|hpp|hh|h))"|([^\s()"]+\.(?:cpp|cxx|cc|c|hpp|hh|h))', + re.IGNORECASE, +) _TESTABLE_LOGIC_RE = re.compile( r"\b(?:class|struct|enum|namespace)\b|^\s*(?:inline\s+|static\s+)?[A-Za-z_]\w*(?:[\s*&:<>]+[A-Za-z_]\w*)*\s+\w+\s*\(", re.MULTILINE, - ) +) def has_testable_logic(filepath: str, content: str) -> bool: @@ -31,6 +38,7 @@ def has_testable_logic(filepath: str, content: str) -> bool: return bool(_TESTABLE_LOGIC_RE.search(content)) + def _match_candidate(candidate: Path, production_files: set[str]) -> str | None: resolved = str(candidate.resolve()) normalized = {str(Path(path).resolve()): path for path in production_files} @@ -39,6 +47,7 @@ def _match_candidate(candidate: Path, production_files: set[str]) -> str | None: return None + def resolve_import_spec( spec: str, test_path: str, @@ -67,15 +76,74 @@ def resolve_import_spec( return None + def resolve_barrel_reexports(filepath: str, production_files: set[str]) -> set[str]: """C/C++ has no barrel-file re-export expansion.""" del filepath, production_files return set() + +def _unique_preserving_order(specs: list[str]) -> list[str]: + seen: set[str] = set() + ordered: list[str] = [] + for spec in specs: + cleaned = (spec or "").strip() + if not cleaned or cleaned in seen: + continue + seen.add(cleaned) + ordered.append(cleaned) + return ordered + + + +def _parse_cmake_source_specs(content: str) -> list[str]: + if not _CMAKE_COMMAND_RE.search(content): + return [] + stripped = _CMAKE_COMMENT_RE.sub("", content) + specs: list[str] = [] + for quoted, bare in _CMAKE_SOURCE_SPEC_RE.findall(stripped): + spec = quoted or bare + if spec: + specs.append(spec) + return _unique_preserving_order(specs) + + + def parse_test_import_specs(content: str) -> list[str]: - """Return include-like specs from test content.""" - return [match.group(1).strip() for match in _INCLUDE_RE.finditer(content)] + """Return include-like specs from test content and test build files.""" + include_specs = [match.group(1).strip() for match in _INCLUDE_RE.finditer(content)] + cmake_specs = _parse_cmake_source_specs(content) + return _unique_preserving_order(include_specs + cmake_specs) + + + +def _iter_test_tree_ancestors(test_file: Path) -> list[Path]: + ancestors = [test_file.parent, *test_file.parents] + stop_at: int | None = None + for index, ancestor in enumerate(ancestors): + if ancestor.name.lower() in {"tests", "test"}: + stop_at = index + break + if stop_at is None: + return [] + return ancestors[: stop_at + 1] + + + +def discover_test_mapping_files(test_files: set[str], production_files: set[str]) -> set[str]: + """Find CMake/Make build files that define test target sources within test trees.""" + del production_files + discovered: set[str] = set() + for test_path in sorted(test_files): + test_file = Path(test_path).resolve() + for ancestor in _iter_test_tree_ancestors(test_file): + for build_file in ("CMakeLists.txt", "Makefile"): + candidate = ancestor / build_file + if candidate.is_file(): + discovered.add(str(candidate.resolve())) + return discovered + def map_test_to_source(test_path: str, production_set: set[str]) -> str | None: @@ -104,6 +172,7 @@ def map_test_to_source(test_path: str, production_set: set[str]) -> str | None: return None + def strip_test_markers(basename: str) -> str | None: """Strip common C/C++ test-name markers to derive source basename.""" stem, ext = os.path.splitext(basename) @@ -120,6 +189,7 @@ def strip_test_markers(basename: str) -> str | None: return None + def strip_comments(content: str) -> str: """Strip C-style comments while preserving string literals.""" return strip_c_style_comments(content) diff --git a/desloppify/languages/cxx/tests/test_coverage.py b/desloppify/languages/cxx/tests/test_coverage.py index e4ab2a29..e5909d4e 100644 --- a/desloppify/languages/cxx/tests/test_coverage.py +++ b/desloppify/languages/cxx/tests/test_coverage.py @@ -1,6 +1,24 @@ from __future__ import annotations +from pathlib import Path + import desloppify.languages.cxx.test_coverage as cxx_cov +from desloppify.engine.detectors.test_coverage.detector import detect_test_coverage +from desloppify.engine.policy.zones import FileZoneMap, Zone, ZoneRule + + +def _make_zone_map(file_list: list[str]) -> FileZoneMap: + rules = [ + ZoneRule(Zone.TEST, ["test_", ".test.", ".spec.", "/tests/", "\\tests\\", "/__tests__/", "\\__tests__\\"]), + ] + return FileZoneMap(file_list, rules) + + +def _write(tmp_path: Path, rel_path: str, content: str) -> str: + target = tmp_path / rel_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + return str(target) def test_strip_test_markers_for_cxx(): @@ -14,6 +32,21 @@ def test_parse_test_import_specs_extracts_includes(): assert cxx_cov.parse_test_import_specs(content) == ["widget.hpp", "gtest/gtest.h"] +def test_parse_test_import_specs_extracts_cmake_sources(): + content = """ +add_executable(WidgetBehaviorTest + widget_behavior.cpp + ../src/widget.cpp + ../src/widget.hpp +) +""" + assert cxx_cov.parse_test_import_specs(content) == [ + "widget_behavior.cpp", + "../src/widget.cpp", + "../src/widget.hpp", + ] + + def test_has_testable_logic_accepts_function_definitions_without_regex_crash(): assert cxx_cov.has_testable_logic("widget.cpp", "int widget() { return 1; }\n") is True assert cxx_cov.has_testable_logic("widget_test.cpp", "int widget() { return 1; }\n") is False @@ -30,9 +63,9 @@ def test_map_test_to_source_and_resolve_import_spec(tmp_path): source.parent.mkdir(parents=True) test_file.parent.mkdir(parents=True) - source.write_text("int widget() { return 1; }\n") - header.write_text("int widget();\n") - test_file.write_text('#include "../src/widget.hpp"\n') + source.write_text("int widget() { return 1; }\n", encoding="utf-8") + header.write_text("int widget();\n", encoding="utf-8") + test_file.write_text('#include "../src/widget.hpp"\n', encoding="utf-8") production = {str(source.resolve()), str(header.resolve())} @@ -41,3 +74,50 @@ def test_map_test_to_source_and_resolve_import_spec(tmp_path): cxx_cov.resolve_import_spec("../src/widget.hpp", str(test_file), production) == str(header.resolve()) ) + + +def test_discover_test_mapping_files_finds_cmakelists_within_test_tree(tmp_path): + test_file = tmp_path / "tests" / "kernel_parity" / "widget_behavior.cpp" + cmake_file = tmp_path / "tests" / "CMakeLists.txt" + nested_cmake = tmp_path / "tests" / "kernel_parity" / "CMakeLists.txt" + test_file.parent.mkdir(parents=True) + test_file.write_text("// test\n", encoding="utf-8") + cmake_file.write_text("add_executable(WidgetBehaviorTest widget_behavior.cpp ../src/widget.cpp)\n", encoding="utf-8") + nested_cmake.write_text("add_library(ParityHelpers ../src/widget.hpp)\n", encoding="utf-8") + + discovered = cxx_cov.discover_test_mapping_files({str(test_file.resolve())}, set()) + + assert discovered == {str(cmake_file.resolve()), str(nested_cmake.resolve())} + + +def test_detect_test_coverage_uses_cmake_test_sources_for_direct_mapping(tmp_path): + prod = _write(tmp_path, "src/widget.cpp", "int widget() { return 1; }\n" * 12) + test_file = _write( + tmp_path, + "tests/widget_behavior.cpp", + '#include <gtest/gtest.h>\n\nTEST(WidgetBehavior, Smoke) {\n EXPECT_EQ(1, 1);\n}\n', + ) + _write( + tmp_path, + "tests/CMakeLists.txt", + "add_executable(WidgetBehaviorTest\n" + " widget_behavior.cpp\n" + " ../src/widget.cpp\n" + ")\n", + ) + + zone_map = _make_zone_map([prod, test_file]) + graph = { + prod: {"imports": set(), "importer_count": 0}, + test_file: {"imports": set(), "importer_count": 0}, + } + + entries, potential = detect_test_coverage(graph, zone_map, "cxx") + + assert potential > 0 + untested = [ + entry + for entry in entries + if entry["file"] == prod and entry["detail"]["kind"] in {"untested_module", "untested_critical"} + ] + assert untested == [] diff --git a/desloppify/languages/cxx/tests/test_init.py b/desloppify/languages/cxx/tests/test_init.py index 66393f0f..ee242290 100644 --- a/desloppify/languages/cxx/tests/test_init.py +++ b/desloppify/languages/cxx/tests/test_init.py @@ -38,3 +38,9 @@ def fake_all_treesitter_phases(spec_name: str): assert captured["spec_name"] == "cpp" assert "Tree-sitter sentinel" in labels + + +def test_cxx_excludes_unused_imports_phase(): + cfg = cxx_mod.CxxConfig() + labels = {phase.label for phase in cfg.phases} + assert "Unused imports" not in labels diff --git a/desloppify/languages/cxx/tests/test_security.py b/desloppify/languages/cxx/tests/test_security.py index 2c52f1e4..d18aa408 100644 --- a/desloppify/languages/cxx/tests/test_security.py +++ b/desloppify/languages/cxx/tests/test_security.py @@ -500,15 +500,15 @@ def _fake_run_tool_result(cmd, path, parser, **_kwargs): } -def test_normalize_tool_entries_ignores_cppcheck_syntax_error_with_fontsystem_name(): +def test_normalize_tool_entries_ignores_cppcheck_syntax_error_with_projectish_name(): entries = security_mod._normalize_tool_entries( [ { - "file": r"D:/repo/FontSystem.h", + "file": r"D:/repo/WidgetCatalog.h", "line": 9, "severity": "error", "check_id": "syntaxError", - "message": "Code 'namespaceFontSystem{' is invalid C code.", + "message": "Code 'namespaceWidgetCatalog{' is invalid C code.", "source": "cppcheck", } ] @@ -568,3 +568,46 @@ def test_cxx_config_security_hook_returns_lang_result(tmp_path): assert result.files_scanned == 1 assert result.entries assert result.entries[0]["detail"]["kind"] == "command_injection" + + +def test_detect_cxx_security_ignores_findings_outside_scoped_files( + tmp_path, + monkeypatch, + ): + source = tmp_path / "src" / "unsafe.cpp" + source.parent.mkdir(parents=True) + source.write_text("int main() { return 0; }\n") + (tmp_path / "compile_commands.json").write_text("[]\n") + + external_header = tmp_path / "vendor" / "external.hpp" + + def _fake_which(cmd: str) -> str | None: + return "C:/tools/clang-tidy.exe" if cmd == "clang-tidy" else None + + def _fake_run_tool_result(cmd, path, parser, **_kwargs): + assert str(path.resolve()) == str(tmp_path.resolve()) + output = ( + f"{source}:4:5: warning: call to 'strcpy' is insecure because it can overflow " + "[clang-analyzer-security.insecureAPI.strcpy]\n" + f"{external_header}:18:3: warning: declaration uses reserved identifier " + "[cert-dcl37-c]\n" + ) + return ToolRunResult(entries=parser(output, path), status="ok", returncode=1) + + monkeypatch.setattr( + security_mod, + "shutil", + SimpleNamespace(which=_fake_which), + raising=False, + ) + monkeypatch.setattr( + security_mod, + "run_tool_result", + _fake_run_tool_result, + raising=False, + ) + + result = detect_cxx_security([str(source.resolve())], zone_map=None) + + assert len(result.entries) == 1 + assert result.entries[0]["file"] == str(source.resolve()) diff --git a/desloppify/languages/dart/__init__.py b/desloppify/languages/dart/__init__.py index 19f5e814..ef19ea57 100644 --- a/desloppify/languages/dart/__init__.py +++ b/desloppify/languages/dart/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from desloppify.base.discovery.paths import get_area -from desloppify.engine.hook_registry import register_lang_hooks from desloppify.engine.policy.zones import COMMON_ZONE_RULES, Zone, ZoneRule from desloppify.languages._framework.base.phase_builders import ( detector_phase_security, @@ -13,6 +12,7 @@ ) from desloppify.languages._framework.base.types import DetectorPhase, LangConfig from desloppify.languages._framework.registry.registration import register_full_plugin +from desloppify.languages._framework.registry.state import register_lang_hooks from desloppify.languages._framework.treesitter.phases import all_treesitter_phases from desloppify.languages.dart import test_coverage as dart_test_coverage_hooks from desloppify.languages.dart.commands import get_detect_commands diff --git a/desloppify/languages/gdscript/__init__.py b/desloppify/languages/gdscript/__init__.py index 95d86ccc..b7616dff 100644 --- a/desloppify/languages/gdscript/__init__.py +++ b/desloppify/languages/gdscript/__init__.py @@ -4,7 +4,6 @@ from desloppify.base.discovery.paths import get_area from desloppify.engine.policy.zones import COMMON_ZONE_RULES, Zone, ZoneRule -from desloppify.engine.hook_registry import register_lang_hooks from desloppify.languages._framework.base.phase_builders import ( detector_phase_security, detector_phase_signature, @@ -13,6 +12,7 @@ ) from desloppify.languages._framework.base.types import DetectorPhase, LangConfig from desloppify.languages._framework.registry.registration import register_full_plugin +from desloppify.languages._framework.registry.state import register_lang_hooks from desloppify.languages._framework.treesitter.phases import all_treesitter_phases from desloppify.languages.gdscript import test_coverage as gdscript_test_coverage_hooks from desloppify.languages.gdscript.commands import get_detect_commands diff --git a/desloppify/languages/go/__init__.py b/desloppify/languages/go/__init__.py index a0302755..5a7a878d 100644 --- a/desloppify/languages/go/__init__.py +++ b/desloppify/languages/go/__init__.py @@ -7,7 +7,6 @@ from __future__ import annotations from desloppify.base.discovery.paths import get_area -from desloppify.engine.hook_registry import register_lang_hooks from desloppify.languages._framework.base.phase_builders import ( detector_phase_security, detector_phase_signature, @@ -17,6 +16,7 @@ from desloppify.languages._framework.base.types import DetectorPhase, LangConfig from desloppify.languages._framework.generic_support.core import make_tool_phase from desloppify.languages._framework.registry.registration import register_full_plugin +from desloppify.languages._framework.registry.state import register_lang_hooks from desloppify.languages._framework.treesitter.phases import all_treesitter_phases from desloppify.languages.go import test_coverage as go_test_coverage_hooks from desloppify.languages.go.commands import get_detect_commands diff --git a/desloppify/languages/python/__init__.py b/desloppify/languages/python/__init__.py index 5a7389f1..503acc99 100644 --- a/desloppify/languages/python/__init__.py +++ b/desloppify/languages/python/__init__.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING, Any from desloppify.base.discovery.source import find_py_files -from desloppify.engine.hook_registry import register_lang_hooks from desloppify.engine.policy.zones import COMMON_ZONE_RULES, Zone, ZoneRule from desloppify.languages._framework.base.phase_builders import ( detector_phase_security, @@ -14,6 +13,7 @@ shared_subjective_duplicates_tail, ) from desloppify.languages._framework.registry.registration import register_full_plugin +from desloppify.languages._framework.registry.state import register_lang_hooks from desloppify.languages._framework.base.shared_phases import phase_private_imports from desloppify.languages._framework.base.types import ( DetectorCoverageStatus, diff --git a/desloppify/languages/python/detectors/unused.py b/desloppify/languages/python/detectors/unused.py index 8d58ad7e..e1306882 100644 --- a/desloppify/languages/python/detectors/unused.py +++ b/desloppify/languages/python/detectors/unused.py @@ -4,6 +4,7 @@ import json import re +import shutil import subprocess # nosec B404 import sys from pathlib import Path @@ -151,17 +152,29 @@ def _try_ruff(path: Path, category: str) -> list[dict] | None: return [] exclude_dirs = _collect_exclude_dirs(path) - cmd = [ - sys.executable, - "-m", - "ruff", - "check", - "--select", - ",".join(select), - "--output-format", - "json", - "--no-fix", - ] + ruff_executable = shutil.which("ruff") + if ruff_executable: + cmd = [ + ruff_executable, + "check", + "--select", + ",".join(select), + "--output-format", + "json", + "--no-fix", + ] + else: + cmd = [ + sys.executable, + "-m", + "ruff", + "check", + "--select", + ",".join(select), + "--output-format", + "json", + "--no-fix", + ] if exclude_dirs: cmd.extend(["--exclude", ",".join(exclude_dirs)]) cmd.append(str(path)) @@ -177,6 +190,8 @@ def _try_ruff(path: Path, category: str) -> list[dict] | None: except (FileNotFoundError, subprocess.TimeoutExpired): return None + if result.returncode not in (0, 1): + return None if not result.stdout.strip(): return [] diff --git a/desloppify/languages/rust/__init__.py b/desloppify/languages/rust/__init__.py index 54541889..e5a4a08d 100644 --- a/desloppify/languages/rust/__init__.py +++ b/desloppify/languages/rust/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from desloppify.base.discovery.paths import get_area -from desloppify.engine.hook_registry import register_lang_hooks from desloppify.engine.policy.zones import COMMON_ZONE_RULES, Zone, ZoneRule from desloppify.languages._framework.base.phase_builders import ( detector_phase_security, @@ -12,6 +11,7 @@ ) from desloppify.languages._framework.base.types import DetectorPhase, LangConfig from desloppify.languages._framework.registry.registration import register_full_plugin +from desloppify.languages._framework.registry.state import register_lang_hooks from desloppify.languages._framework.treesitter.phases import all_treesitter_phases from desloppify.languages.rust import test_coverage as rust_test_coverage_hooks from desloppify.languages.rust._fixers import get_rust_fixers diff --git a/desloppify/languages/rust/_fixers.py b/desloppify/languages/rust/_fixers.py index d79ffb02..0f638439 100644 --- a/desloppify/languages/rust/_fixers.py +++ b/desloppify/languages/rust/_fixers.py @@ -6,13 +6,15 @@ from desloppify.base.discovery.file_paths import rel, resolve_path, safe_write_text from desloppify.languages._framework.base.types import FixResult, FixerConfig -from desloppify.languages.rust.detectors.custom import ( +from desloppify.languages.rust.detectors.api import ( + detect_import_hygiene, + replace_same_crate_imports, +) +from desloppify.languages.rust.detectors.cargo_policy import ( add_missing_features_to_manifest, detect_doctest_hygiene, detect_feature_hygiene, - detect_import_hygiene, ensure_readme_doctest_harness, - replace_same_crate_imports, ) diff --git a/desloppify/languages/rust/commands.py b/desloppify/languages/rust/commands.py index e360f888..bf5ee37c 100644 --- a/desloppify/languages/rust/commands.py +++ b/desloppify/languages/rust/commands.py @@ -51,7 +51,6 @@ CLIPPY_WARNING_CMD as RUST_CLIPPY_CMD, parse_cargo_errors, parse_clippy_messages, - parse_rustdoc_messages, run_rustdoc_result, ) diff --git a/desloppify/languages/rust/detectors/custom.py b/desloppify/languages/rust/detectors/custom.py deleted file mode 100644 index 27745a4c..00000000 --- a/desloppify/languages/rust/detectors/custom.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Compatibility exports for legacy Rust detector imports.""" - -from .api import ( - detect_error_boundaries, - detect_future_proofing, - detect_import_hygiene, - detect_public_api_conventions, - detect_thread_safety_contracts, - replace_same_crate_imports, -) -from .cargo_policy import ( - add_missing_features_to_manifest, - detect_doctest_hygiene, - detect_feature_hygiene, - ensure_readme_doctest_harness, - iter_missing_features, - missing_readme_doctest_harnesses, -) -from .safety import ( - detect_async_locking, - detect_drop_safety, - detect_unsafe_api_usage, -) - -__all__ = [ - "add_missing_features_to_manifest", - "detect_async_locking", - "detect_doctest_hygiene", - "detect_drop_safety", - "detect_error_boundaries", - "detect_feature_hygiene", - "detect_future_proofing", - "detect_import_hygiene", - "detect_public_api_conventions", - "detect_thread_safety_contracts", - "detect_unsafe_api_usage", - "ensure_readme_doctest_harness", - "iter_missing_features", - "missing_readme_doctest_harnesses", - "replace_same_crate_imports", -] diff --git a/desloppify/languages/rust/fixers/__init__.py b/desloppify/languages/rust/fixers/__init__.py index 57ead586..5767c7d0 100644 --- a/desloppify/languages/rust/fixers/__init__.py +++ b/desloppify/languages/rust/fixers/__init__.py @@ -1,5 +1 @@ -"""Rust fixers package.""" - -from desloppify.languages.rust._fixers import get_rust_fixers - -__all__ = ["get_rust_fixers"] +"""Rust fixer package.""" diff --git a/desloppify/languages/rust/phases.py b/desloppify/languages/rust/phases.py index 74664fbd..73c7c6be 100644 --- a/desloppify/languages/rust/phases.py +++ b/desloppify/languages/rust/phases.py @@ -6,6 +6,8 @@ from pathlib import Path from desloppify.base.output.terminal import log +from desloppify.engine._state.filtering import make_issue +from desloppify.engine._state.schema_types_issues import Issue from desloppify.engine.detectors.base import ComplexitySignal from desloppify.engine.detectors.graph import detect_cycles from desloppify.engine.detectors.orphaned import ( @@ -50,8 +52,6 @@ parse_clippy_messages, run_rustdoc_result, ) -from desloppify.state import Issue -from desloppify.state import make_issue RUST_CLIPPY_LABEL = "cargo clippy" RUST_CHECK_LABEL = "cargo check" diff --git a/desloppify/languages/rust/phases_smells.py b/desloppify/languages/rust/phases_smells.py index f56cf55b..537f5c0a 100644 --- a/desloppify/languages/rust/phases_smells.py +++ b/desloppify/languages/rust/phases_smells.py @@ -5,12 +5,12 @@ from pathlib import Path from desloppify.base.output.terminal import log +from desloppify.engine._state.schema_types_issues import Issue from desloppify.engine.policy.zones import adjust_potential from desloppify.languages._framework.base.smell_contracts import normalize_smell_entries from desloppify.languages._framework.base.types import LangRuntimeContract from desloppify.languages._framework.issue_factories import make_smell_issues from desloppify.languages.rust.detectors.smells import detect_smells -from desloppify.state import Issue def phase_smells(path: Path, lang: LangRuntimeContract) -> tuple[list[Issue], dict[str, int]]: diff --git a/desloppify/languages/rust/tests/test_custom.py b/desloppify/languages/rust/tests/test_custom.py index bf613bb9..972457b6 100644 --- a/desloppify/languages/rust/tests/test_custom.py +++ b/desloppify/languages/rust/tests/test_custom.py @@ -12,16 +12,20 @@ fix_readme_doctests, ) from desloppify.languages.rust.phases import phase_signature -from desloppify.languages.rust.detectors.custom import ( - detect_async_locking, - detect_doctest_hygiene, - detect_drop_safety, +from desloppify.languages.rust.detectors.api import ( detect_error_boundaries, - detect_feature_hygiene, detect_future_proofing, detect_import_hygiene, detect_public_api_conventions, detect_thread_safety_contracts, +) +from desloppify.languages.rust.detectors.cargo_policy import ( + detect_doctest_hygiene, + detect_feature_hygiene, +) +from desloppify.languages.rust.detectors.safety import ( + detect_async_locking, + detect_drop_safety, detect_unsafe_api_usage, ) diff --git a/desloppify/languages/typescript/__init__.py b/desloppify/languages/typescript/__init__.py index 9451b60e..aedfcb57 100644 --- a/desloppify/languages/typescript/__init__.py +++ b/desloppify/languages/typescript/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from desloppify.base.discovery.paths import get_area -from desloppify.engine.hook_registry import register_lang_hooks from desloppify.languages._framework.base.phase_builders import ( detector_phase_security, detector_phase_signature, @@ -17,6 +16,7 @@ LangSecurityResult, ) from desloppify.languages._framework.registry.registration import register_full_plugin +from desloppify.languages._framework.registry.state import register_lang_hooks from desloppify.languages.typescript import test_coverage as ts_test_coverage_hooks from desloppify.languages.typescript._fixers import get_ts_fixers import desloppify.languages.typescript.commands as ts_commands_mod diff --git a/desloppify/languages/typescript/detectors/patterns/__init__.py b/desloppify/languages/typescript/detectors/patterns/__init__.py index 924d515d..4bdd78d0 100644 --- a/desloppify/languages/typescript/detectors/patterns/__init__.py +++ b/desloppify/languages/typescript/detectors/patterns/__init__.py @@ -1,7 +1 @@ -"""Pattern-analysis detectors and CLI helpers for TypeScript.""" - -from .analysis import detect_pattern_anomalies -from .catalog import PATTERN_FAMILIES -from .cli import cmd_patterns - -__all__ = ["PATTERN_FAMILIES", "cmd_patterns", "detect_pattern_anomalies"] +"""TypeScript pattern detector package.""" diff --git a/desloppify/languages/typescript/detectors/react/__init__.py b/desloppify/languages/typescript/detectors/react/__init__.py index cf7e0f49..255ae7fb 100644 --- a/desloppify/languages/typescript/detectors/react/__init__.py +++ b/desloppify/languages/typescript/detectors/react/__init__.py @@ -1,12 +1 @@ -"""React-specific TypeScript detectors grouped by concern.""" - -from .context import detect_context_nesting -from .hook_bloat import detect_boolean_state_explosion, detect_hook_return_bloat -from .state_sync import detect_state_sync - -__all__ = [ - "detect_boolean_state_explosion", - "detect_context_nesting", - "detect_hook_return_bloat", - "detect_state_sync", -] +"""TypeScript React detector package.""" diff --git a/desloppify/languages/typescript/detectors/smells/helpers_blocks.py b/desloppify/languages/typescript/detectors/smells/helpers_blocks.py deleted file mode 100644 index c3b7c3b4..00000000 --- a/desloppify/languages/typescript/detectors/smells/helpers_blocks.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Block parsing and code-text projection helpers for TS smell detectors.""" - -from __future__ import annotations - -from desloppify.languages.typescript.syntax.scanner import scan_code - - -def _track_brace_body( - lines: list[str], start_line: int, *, max_scan: int = 2000 -) -> int | None: - """Find the closing brace matching the first opening brace from start_line.""" - depth = 0 - found_open = False - for line_idx in range(start_line, min(start_line + max_scan, len(lines))): - for _, ch, in_string in scan_code(lines[line_idx]): - if in_string: - continue - if ch == "{": - depth += 1 - found_open = True - elif ch == "}": - depth -= 1 - if found_open and depth == 0: - return line_idx - return None - - -def _find_block_end(content: str, brace_start: int, max_scan: int = 5000) -> int | None: - """Find the closing brace position in a content string from an opening brace.""" - depth = 0 - for ci, ch, in_s in scan_code( - content, brace_start, min(brace_start + max_scan, len(content)) - ): - if in_s: - continue - if ch == "{": - depth += 1 - elif ch == "}": - depth -= 1 - if depth == 0: - return ci - return None - - -def _extract_block_body( - content: str, brace_start: int, max_scan: int = 5000 -) -> str | None: - """Return text between ``{`` at brace_start and its matching ``}``.""" - end = _find_block_end(content, brace_start, max_scan) - if end is None: - return None - return content[brace_start + 1 : end] - - -def _content_line_info(content: str, pos: int) -> tuple[int, str]: - """Return ``(line_no, stripped snippet[:100])`` for a position in content.""" - line_no = content[:pos].count("\n") + 1 - line_start = content.rfind("\n", 0, pos) + 1 - line_end = content.find("\n", pos) - if line_end == -1: - line_end = len(content) - return line_no, content[line_start:line_end].strip()[:100] - - -def _code_text(text: str) -> str: - """Blank string literals and ``//`` comments to spaces, preserving positions.""" - out = list(text) - in_line_comment = False - prev_code_idx = -2 - prev_code_ch = "" - for i, ch, in_s in scan_code(text): - if ch == "\n": - in_line_comment = False - prev_code_ch = "" - continue - if in_line_comment: - out[i] = " " - continue - if in_s: - out[i] = " " - continue - if ch == "/" and prev_code_ch == "/" and prev_code_idx == i - 1: - out[prev_code_idx] = " " - out[i] = " " - in_line_comment = True - prev_code_ch = "" - continue - prev_code_idx = i - prev_code_ch = ch - return "".join(out) - - -__all__ = [ - "_code_text", - "_content_line_info", - "_extract_block_body", - "_find_block_end", - "_track_brace_body", - "scan_code", -] diff --git a/desloppify/languages/typescript/detectors/smells/helpers_line_state.py b/desloppify/languages/typescript/detectors/smells/helpers_line_state.py deleted file mode 100644 index 22367157..00000000 --- a/desloppify/languages/typescript/detectors/smells/helpers_line_state.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Line-state scanners for block comments and template literals.""" - -from __future__ import annotations - - -def _scan_template_content( - line: str, start: int, brace_depth: int = 0 -) -> tuple[int, bool, int]: - """Scan template literal content from *start* in *line*.""" - j = start - while j < len(line): - ch = line[j] - if ch == "\\" and j + 1 < len(line): - j += 2 - continue - if ch == "$" and j + 1 < len(line) and line[j + 1] == "{": - brace_depth += 1 - j += 2 - continue - if ch == "}" and brace_depth > 0: - brace_depth -= 1 - j += 1 - continue - if ch == "`" and brace_depth == 0: - return (j + 1, True, brace_depth) - j += 1 - return (j, False, brace_depth) - - -def _scan_code_line(line: str) -> tuple[bool, bool, int]: - """Scan a normal code line for block comment or template literal start.""" - j = 0 - in_str = None - while j < len(line): - ch = line[j] - - if in_str and ch == "\\" and j + 1 < len(line): - j += 2 - continue - - if in_str: - if ch == in_str: - in_str = None - j += 1 - continue - - if ch == "/" and j + 1 < len(line) and line[j + 1] == "/": - break - - if ch == "/" and j + 1 < len(line) and line[j + 1] == "*": - close = line.find("*/", j + 2) - if close != -1: - j = close + 2 - continue - return (True, False, 0) - - if ch == "`": - end_pos, found_close, depth = _scan_template_content(line, j + 1) - if found_close: - j = end_pos - continue - return (False, True, depth) - - if ch in ("'", '"'): - in_str = ch - j += 1 - continue - - j += 1 - - return (False, False, 0) - - -def _build_ts_line_state(lines: list[str]) -> dict[int, str]: - """Build a map of lines inside block comments or template literals.""" - state: dict[int, str] = {} - in_block_comment = False - in_template = False - template_brace_depth = 0 - - for i, line in enumerate(lines): - if in_block_comment: - state[i] = "block_comment" - if "*/" in line: - in_block_comment = False - continue - - if in_template: - state[i] = "template_literal" - _, found_close, template_brace_depth = _scan_template_content( - line, 0, template_brace_depth - ) - if found_close: - in_template = False - continue - - in_block_comment, in_template, depth = _scan_code_line(line) - if in_template: - template_brace_depth = depth - - return state - - -__all__ = [ - "_build_ts_line_state", - "_scan_code_line", - "_scan_template_content", -] diff --git a/desloppify/tests/ci/test_ci_contracts.py b/desloppify/tests/ci/test_ci_contracts.py index a8080f45..1d9cee1f 100644 --- a/desloppify/tests/ci/test_ci_contracts.py +++ b/desloppify/tests/ci/test_ci_contracts.py @@ -129,7 +129,7 @@ def test_ci_contracts_target_includes_phase_order_invariant() -> None: text = MAKEFILE.read_text() assert ( 'pytest -q desloppify/tests/commands/test_lifecycle_transitions.py ' - '-k "subjective_then_score_then_triage"' + '-k "assessment_then_score_when_no_review_followup"' ) in text diff --git a/desloppify/tests/commands/autofix/test_apply_retro_direct.py b/desloppify/tests/commands/autofix/test_apply_retro_direct.py index 71043abf..0a7756c0 100644 --- a/desloppify/tests/commands/autofix/test_apply_retro_direct.py +++ b/desloppify/tests/commands/autofix/test_apply_retro_direct.py @@ -111,7 +111,7 @@ def test_cascade_unused_import_cleanup_resolves_cascade_issues(monkeypatch, caps lang=lang, ) - issue = state["issues"]["unused::src/a.ts::Foo"] + issue = state["work_items"]["unused::src/a.ts::Foo"] assert issue["status"] == "fixed" assert "cascade-unused-imports" in str(issue["note"]) diff --git a/desloppify/tests/commands/helpers/test_guardrails.py b/desloppify/tests/commands/helpers/test_guardrails.py new file mode 100644 index 00000000..f0916bed --- /dev/null +++ b/desloppify/tests/commands/helpers/test_guardrails.py @@ -0,0 +1,93 @@ +"""Regression tests for triage guardrails around deferred mid-cycle triage.""" + +from __future__ import annotations + +import pytest + +from desloppify.app.commands.helpers.guardrails import ( + require_triage_current_or_exit, + triage_guardrail_messages, + triage_guardrail_status, +) +from desloppify.base.exception_sets import CommandError +from desloppify.engine._plan.schema import empty_plan + + +def _pending_triage_plan() -> dict: + plan = empty_plan() + plan["plan_start_scores"] = {"strict": 72.0} + plan["epic_triage_meta"] = {"triaged_ids": ["review::old"]} + return plan + + +def _pending_triage_state() -> dict: + return { + "issues": { + "obj::1": { + "id": "obj::1", + "status": "open", + "detector": "complexity", + "summary": "Objective work still open", + }, + "review::old": { + "id": "review::old", + "status": "open", + "detector": "review", + "summary": "Previously triaged review issue", + "detail": {"dimension": "naming"}, + }, + "review::new": { + "id": "review::new", + "status": "open", + "detector": "review", + "summary": "New review issue", + "detail": {"dimension": "naming"}, + }, + } + } + + +def test_triage_guardrail_status_marks_pending_behind_objective_backlog() -> None: + result = triage_guardrail_status( + plan=_pending_triage_plan(), + state=_pending_triage_state(), + ) + + assert result.is_stale is True + assert result.pending_behind_objective_backlog is True + assert result.new_ids == {"review::new"} + + +def test_triage_guardrail_messages_use_pending_copy_when_triage_is_deferred() -> None: + messages = triage_guardrail_messages( + plan=_pending_triage_plan(), + state=_pending_triage_state(), + ) + + assert any("activate after the current objective backlog is clear" in msg for msg in messages) + assert any(msg.startswith("TRIAGE PENDING") for msg in messages) + assert not any("Run the staged triage runner" in msg for msg in messages) + + +def test_require_triage_current_allows_objective_resolve_while_pending(capsys) -> None: + require_triage_current_or_exit( + state=_pending_triage_state(), + plan=_pending_triage_plan(), + patterns=["obj::1"], + attest="", + ) + + out = capsys.readouterr().out + assert "TRIAGE PENDING" in out + + +def test_require_triage_current_blocks_review_resolve_while_pending() -> None: + with pytest.raises(CommandError) as exc_info: + require_triage_current_or_exit( + state=_pending_triage_state(), + plan=_pending_triage_plan(), + patterns=["review::new"], + attest="", + ) + + assert "triage is pending behind the current objective backlog" in str(exc_info.value) diff --git a/desloppify/tests/commands/plan/test_cluster_guard.py b/desloppify/tests/commands/plan/test_cluster_guard.py index 5da82de7..187aa529 100644 --- a/desloppify/tests/commands/plan/test_cluster_guard.py +++ b/desloppify/tests/commands/plan/test_cluster_guard.py @@ -2,7 +2,7 @@ from __future__ import annotations -from desloppify.app.commands.plan.override_resolve_helpers import ( +from desloppify.app.commands.plan.override.resolve_helpers import ( _CLUSTER_INDIVIDUAL_THRESHOLD, check_cluster_guard as _check_cluster_guard, ) diff --git a/desloppify/tests/commands/plan/test_cluster_ops_direct.py b/desloppify/tests/commands/plan/test_cluster_ops_direct.py index a64abcd8..9a7e1e8e 100644 --- a/desloppify/tests/commands/plan/test_cluster_ops_direct.py +++ b/desloppify/tests/commands/plan/test_cluster_ops_direct.py @@ -9,12 +9,12 @@ import pytest import desloppify.app.commands.plan.cluster.dispatch as cluster_dispatch_mod -import desloppify.app.commands.plan.cluster_ops_display as cluster_display_mod -import desloppify.app.commands.plan.cluster_ops_manage as cluster_manage_mod -import desloppify.app.commands.plan.cluster_ops_reorder as cluster_reorder_mod -import desloppify.app.commands.plan.cluster_steps as cluster_steps_mod -import desloppify.app.commands.plan.cluster_update as cluster_update_mod -import desloppify.app.commands.plan.cluster_update_flow as cluster_update_flow_mod +import desloppify.app.commands.plan.cluster.ops_display as cluster_display_mod +import desloppify.app.commands.plan.cluster.ops_manage as cluster_manage_mod +import desloppify.app.commands.plan.cluster.ops_reorder as cluster_reorder_mod +import desloppify.app.commands.plan.cluster.steps as cluster_steps_mod +import desloppify.app.commands.plan.cluster.update as cluster_update_mod +import desloppify.app.commands.plan.cluster.update_flow as cluster_update_flow_mod from desloppify.base.exception_sets import CommandError diff --git a/desloppify/tests/commands/plan/test_cluster_ux.py b/desloppify/tests/commands/plan/test_cluster_ux.py index df8bafd2..9a2bb019 100644 --- a/desloppify/tests/commands/plan/test_cluster_ux.py +++ b/desloppify/tests/commands/plan/test_cluster_ux.py @@ -5,7 +5,7 @@ import argparse import desloppify.app.commands.plan.cluster.dispatch as cluster_mod -import desloppify.app.commands.plan.cluster_update as cluster_update_mod +import desloppify.app.commands.plan.cluster.update as cluster_update_mod from desloppify.engine._plan.schema import empty_plan # --------------------------------------------------------------------------- diff --git a/desloppify/tests/commands/plan/test_plan_override_transactions.py b/desloppify/tests/commands/plan/test_plan_override_transactions.py index 5ebd9278..c8e7b22c 100644 --- a/desloppify/tests/commands/plan/test_plan_override_transactions.py +++ b/desloppify/tests/commands/plan/test_plan_override_transactions.py @@ -9,8 +9,8 @@ from desloppify import state as state_mod from desloppify.app.commands.helpers.command_runtime import CommandRuntime -from desloppify.app.commands.plan import override_io -from desloppify.app.commands.plan import override_skip +from desloppify.app.commands.plan.override import io as override_io +from desloppify.app.commands.plan.override import skip as override_skip from desloppify.base.exception_sets import CommandError from desloppify.engine.plan_state import empty_plan, load_plan, save_plan from desloppify.engine.plan_ops import skip_items @@ -31,7 +31,7 @@ def _seed_state() -> tuple[dict, str]: summary="sample", ) issue_id = issue["id"] - state["issues"][issue_id] = issue + state["work_items"][issue_id] = issue return state, issue_id diff --git a/desloppify/tests/commands/plan/test_plan_overrides_direct.py b/desloppify/tests/commands/plan/test_plan_overrides_direct.py index ef7e8a71..b9fb9676 100644 --- a/desloppify/tests/commands/plan/test_plan_overrides_direct.py +++ b/desloppify/tests/commands/plan/test_plan_overrides_direct.py @@ -8,12 +8,13 @@ import pytest -import desloppify.app.commands.plan.override_io as override_io_mod -import desloppify.app.commands.plan.override_misc as override_misc_mod -import desloppify.app.commands.plan.override_resolve_cmd as override_resolve_cmd_mod -import desloppify.app.commands.plan.override_resolve_helpers as resolve_helpers_mod -import desloppify.app.commands.plan.override_resolve_workflow as resolve_workflow_mod -import desloppify.app.commands.plan.override_skip as override_skip_mod +import desloppify.app.commands.plan.override.io as override_io_mod +import desloppify.app.commands.plan.override.misc as override_misc_mod +import desloppify.app.commands.plan.override.resolve_cmd as override_resolve_cmd_mod +import desloppify.app.commands.plan.override.resolve_helpers as resolve_helpers_mod +import desloppify.app.commands.plan.override.resolve_workflow as resolve_workflow_mod +import desloppify.app.commands.plan.override.skip as override_skip_mod +import desloppify.app.commands.plan.reorder_handlers as reorder_handlers_mod from desloppify.base.exception_sets import CommandError @@ -243,6 +244,77 @@ def test_resolve_workflow_patterns_scan_gate_blocks_without_new_scan(monkeypatch assert any(action == "scan_gate_blocked" for action, _ in logs) +def test_resolve_workflow_patterns_reconciles_when_create_plan_drains_queue(monkeypatch) -> None: + plan = { + "queue_order": [resolve_workflow_mod.WORKFLOW_CREATE_PLAN_ID], + "epic_triage_meta": { + "triage_stages": {}, + "last_completed_at": "2026-03-09T00:00:00+00:00", + }, + "scan_count_at_plan_start": 9, + } + seen: list[object] = [] + + monkeypatch.setattr(resolve_workflow_mod, "load_plan", lambda: plan) + monkeypatch.setattr(resolve_workflow_mod, "blocked_triage_stages", lambda _plan: {}) + monkeypatch.setattr(resolve_workflow_mod, "save_plan", lambda *_a, **_k: seen.append("save")) + monkeypatch.setattr(resolve_workflow_mod, "state_path", lambda _args: Path("state.json")) + monkeypatch.setattr( + resolve_workflow_mod.state_mod, + "load_state", + lambda _path: {"scan_count": 10, "config": {"target_strict_score": 96}}, + ) + monkeypatch.setattr( + resolve_workflow_mod, + "append_log_entry", + lambda *_a, **_k: None, + ) + monkeypatch.setattr(resolve_workflow_mod, "live_planned_queue_empty", lambda _plan: True) + monkeypatch.setattr(resolve_workflow_mod, "has_open_review_issues", lambda _state: True) + monkeypatch.setattr( + resolve_workflow_mod, + "ensure_active_triage_issue_ids", + lambda _plan, _state: seen.append(("active_triage", True)), + ) + monkeypatch.setattr( + resolve_workflow_mod, + "inject_triage_stages", + lambda _plan: seen.append(("inject_triage", True)), + ) + monkeypatch.setattr( + resolve_workflow_mod, + "set_lifecycle_phase", + lambda _plan, phase: seen.append(("phase", phase)) or True, + ) + monkeypatch.setattr( + resolve_workflow_mod, + "target_strict_score_from_config", + lambda config: seen.append(("target", config)) or 96.0, + ) + monkeypatch.setattr( + resolve_workflow_mod, + "reconcile_plan", + lambda _plan, _state, *, target_strict: seen.append( + ("reconcile", list(_plan.get("queue_order", [])), target_strict) + ), + ) + + args = argparse.Namespace(force_resolve=False, state=None, lang=None, path=".", exclude=None) + outcome = resolve_workflow_mod.resolve_workflow_patterns( + args, + synthetic_ids=[resolve_workflow_mod.WORKFLOW_CREATE_PLAN_ID], + real_patterns=[], + note="Detailed workflow completion note", + ) + + assert outcome.status == "handled" + assert ("active_triage", True) in seen + assert ("inject_triage", True) in seen + assert ("phase", resolve_workflow_mod.LIFECYCLE_PHASE_TRIAGE_POSTFLIGHT) in seen + assert ("target", {"target_strict_score": 96}) not in seen + assert not any(isinstance(item, tuple) and item[:1] == ("reconcile",) for item in seen) + + def test_cmd_plan_resolve_workflow_gate_integration_paths(monkeypatch, capsys) -> None: """Command-level workflow gating smoke: triage block, short forced note, scan gate.""" current_plan: dict = {} @@ -374,6 +446,40 @@ def test_override_misc_focus_and_scan_gate_paths(monkeypatch, capsys) -> None: assert plan["scan_gate_skipped"] is True +def test_plan_promote_moves_backlog_items_into_queue(monkeypatch, capsys) -> None: + plan = {"queue_order": [], "clusters": {}} + runtime = SimpleNamespace(state={"issues": {"unused::a": {"status": "open"}}}) + moved: list[tuple[list[str], str, str | None]] = [] + + monkeypatch.setattr(reorder_handlers_mod, "command_runtime", lambda _args: runtime) + monkeypatch.setattr(reorder_handlers_mod, "require_issue_inventory", lambda _state: True) + monkeypatch.setattr(reorder_handlers_mod, "load_plan", lambda: plan) + monkeypatch.setattr(reorder_handlers_mod, "save_plan", lambda *_a, **_k: None) + monkeypatch.setattr(reorder_handlers_mod, "append_log_entry", lambda *_a, **_k: None) + monkeypatch.setattr( + reorder_handlers_mod, + "resolve_ids_from_patterns", + lambda *_a, **_k: ["unused::a"], + ) + + def _move_items(plan_obj, issue_ids, position, target=None, offset=None): + moved.append((list(issue_ids), position, target)) + plan_obj["queue_order"].extend(issue_ids) + return len(issue_ids) + + monkeypatch.setattr(reorder_handlers_mod, "move_items", _move_items) + + reorder_handlers_mod.cmd_plan_promote( + argparse.Namespace(patterns=["unused"], position="top", target=None) + ) + out = capsys.readouterr().out + + assert "Promoted 1 item(s)" in out + assert moved == [(["unused::a"], "top", None)] + assert plan["queue_order"] == ["unused::a"] + assert plan["promoted_ids"] == ["unused::a"] + + def test_override_skip_helpers_and_commands(monkeypatch, capsys) -> None: monkeypatch.setattr(override_skip_mod, "skip_kind_requires_attestation", lambda _kind: True) monkeypatch.setattr( diff --git a/desloppify/tests/commands/plan/test_saved_plan_recovery.py b/desloppify/tests/commands/plan/test_saved_plan_recovery.py index e455f6fd..e6835020 100644 --- a/desloppify/tests/commands/plan/test_saved_plan_recovery.py +++ b/desloppify/tests/commands/plan/test_saved_plan_recovery.py @@ -35,7 +35,7 @@ def test_load_state_recovers_runtime_state_from_saved_plan(tmp_path: Path) -> No state = load_state(tmp_path / "state-typescript.json") - assert "review::src/foo.ts::abcd1234" in state["issues"] + assert "review::src/foo.ts::abcd1234" in state["work_items"] assert state["scan_metadata"] == { "source": "plan_reconstruction", "plan_queue_available": True, @@ -46,7 +46,7 @@ def test_load_state_recovers_runtime_state_from_saved_plan(tmp_path: Path) -> No def test_load_state_drops_stale_reconstructed_state_without_live_plan(tmp_path: Path) -> None: """Persisted plan-derived state should clear when the live plan disappears.""" state = empty_state() - state["issues"] = { + state["work_items"] = { "review::src/foo.ts::abcd1234": { "id": "review::src/foo.ts::abcd1234", "status": "open", @@ -72,7 +72,7 @@ def test_load_state_keeps_existing_state_when_saved_plan_load_is_degraded( ) -> None: """State recovery should treat saved-plan load failures as degraded, not silently rebuild.""" state = empty_state() - state["issues"] = { + state["work_items"] = { "review::src/foo.ts::abcd1234": { "id": "review::src/foo.ts::abcd1234", "status": "open", @@ -238,5 +238,5 @@ def test_cmd_plan_repair_state_rebuilds_persisted_state( "plan_queue_available": True, "reconstructed_issue_count": 1, } - assert "review::src/foo.ts::abcd1234" in repaired["issues"] + assert "review::src/foo.ts::abcd1234" in repaired["work_items"] assert "Rebuilt state-typescript.json from plan.json" in capsys.readouterr().out diff --git a/desloppify/tests/commands/plan/test_triage_attestation.py b/desloppify/tests/commands/plan/test_triage_attestation.py index 3f419225..2fa2f99f 100644 --- a/desloppify/tests/commands/plan/test_triage_attestation.py +++ b/desloppify/tests/commands/plan/test_triage_attestation.py @@ -46,6 +46,9 @@ def _plan_with_stages(*stage_names: str, confirmed: bool = False) -> dict: "timestamp": "2025-06-01T00:00:00Z", "issue_count": 5, } + if name == "observe": + stages[name]["dimension_names"] = ["naming"] + stages[name]["dimension_counts"] = {"naming": 5} if confirmed: stages[name]["confirmed_at"] = "2025-06-01T00:01:00Z" stages[name]["confirmed_text"] = "I have thoroughly reviewed all the issues in this stage" @@ -188,6 +191,33 @@ def test_validate_organize_accepts_cluster(self): ) assert err is None + def test_validate_organize_accepts_substantive_work_product_without_cluster_name(self): + """Organize can pass without an exact cluster name when the attestation describes the organized work.""" + err = validate_attestation( + "I organized all review issues into clusters with clear priority ordering, action steps, and dependency decisions grounded in the code.", + "organize", + cluster_names=["fix-naming", "reduce-coupling"], + ) + assert err is None + + def test_validate_enrich_accepts_substantive_work_product_without_cluster_name(self): + """Enrich can pass without an exact cluster name when executor-ready details are described.""" + err = validate_attestation( + "The planned steps are executor-ready with concrete file paths, issue refs, detailed instructions, and effort tags verified against the codebase.", + "enrich", + cluster_names=["fix-naming", "reduce-coupling"], + ) + assert err is None + + def test_validate_sense_check_accepts_substantive_work_product_without_cluster_name(self): + """Sense-check can pass without an exact cluster name when the verification work is explicit.""" + err = validate_attestation( + "I verified content and structure, checked cross-cluster dependencies, and confirmed value decisions are safe and accurately recorded.", + "sense-check", + cluster_names=["fix-naming", "reduce-coupling"], + ) + assert err is None + def test_validate_no_data_passes(self): """When no dimensions/clusters provided, validation passes (nothing to check).""" err = validate_attestation( diff --git a/desloppify/tests/commands/plan/test_triage_auto_start.py b/desloppify/tests/commands/plan/test_triage_auto_start.py index d91fe318..915cd1da 100644 --- a/desloppify/tests/commands/plan/test_triage_auto_start.py +++ b/desloppify/tests/commands/plan/test_triage_auto_start.py @@ -3,9 +3,11 @@ from __future__ import annotations import argparse +from pathlib import Path import desloppify.app.commands.plan.triage.command as triage_mod from desloppify.app.commands.plan.triage import helpers as triage_helpers +import desloppify.app.commands.plan.triage.workflow as triage_workflow_mod from desloppify.app.commands.plan.triage.services import TriageServices from desloppify.engine._plan.schema import empty_plan from desloppify.engine._plan.constants import TRIAGE_IDS, TRIAGE_STAGE_IDS @@ -48,6 +50,11 @@ def _fake_args(**overrides) -> argparse.Namespace: "note": None, "start": False, "dry_run": False, + "run_stages": False, + "runner": "codex", + "report_file": None, + "only_stages": None, + "stage_prompt": None, } defaults.update(overrides) return argparse.Namespace(**defaults) @@ -190,3 +197,50 @@ def test_inject_triage_stages_clears_skipped_entries(self): assert "triage::enrich" not in plan["skipped"] assert "triage::sense-check" not in plan["skipped"] assert "review::z.py::x9" in plan["skipped"] + + def test_cmd_plan_triage_run_stages_reads_report_file_before_runner_dispatch( + self, + monkeypatch, + tmp_path: Path, + capsys, + ) -> None: + plan = empty_plan() + state = _state_with_issues("r1", "r2", "r3") + _patch_triage(monkeypatch, plan, state) + monkeypatch.setattr(triage_mod, "require_issue_inventory", lambda _state: True) + + report_file = tmp_path / "sense-check.txt" + report_file.write_text( + "This report came from a file and should be loaded before staged runner dispatch.", + encoding="utf-8", + ) + + seen: dict[str, object] = {} + + def fake_run_codex_pipeline(args, *, stages_to_run, services): + seen["report"] = args.report + seen["report_file"] = args.report_file + seen["stages_to_run"] = stages_to_run + seen["services"] = services + run_dir = tmp_path / ".desloppify" / "triage_runs" / "fake-run" + run_dir.mkdir(parents=True, exist_ok=True) + (run_dir / "run_summary.json").write_text("{}", encoding="utf-8") + print(f"runner wrote: {run_dir}") + + monkeypatch.setattr(triage_workflow_mod, "run_codex_pipeline", fake_run_codex_pipeline) + + args = _fake_args( + run_stages=True, + runner="codex", + report=None, + report_file=str(report_file), + only_stages="observe", + ) + triage_mod.cmd_plan_triage(args) + + out = capsys.readouterr().out + assert seen["report"] == report_file.read_text(encoding="utf-8") + assert seen["report_file"] == str(report_file) + assert seen["stages_to_run"] == ["observe"] + assert seen["services"] is not None + assert "runner wrote:" in out diff --git a/desloppify/tests/commands/plan/test_triage_confirmations_direct.py b/desloppify/tests/commands/plan/test_triage_confirmations_direct.py index c5acc778..5ce2f101 100644 --- a/desloppify/tests/commands/plan/test_triage_confirmations_direct.py +++ b/desloppify/tests/commands/plan/test_triage_confirmations_direct.py @@ -27,3 +27,16 @@ def test_validate_attestation_reflect_accepts_dimension_or_cluster_reference() - cluster_names=["cluster-core"], ) assert error is None + + +def test_validate_attestation_enrich_accepts_executor_ready_work_product() -> None: + error = confirmations_mod.validate_attestation( + ( + "The planned steps are executor-ready with concrete file paths, " + "issue refs, detailed instructions, and effort tags verified " + "against the codebase." + ), + "enrich", + cluster_names=["cluster-core"], + ) + assert error is None diff --git a/desloppify/tests/commands/plan/test_triage_dependency_guard.py b/desloppify/tests/commands/plan/test_triage_dependency_guard.py index a69a1480..6f7dedbb 100644 --- a/desloppify/tests/commands/plan/test_triage_dependency_guard.py +++ b/desloppify/tests/commands/plan/test_triage_dependency_guard.py @@ -4,9 +4,9 @@ import argparse -import desloppify.app.commands.plan.override_resolve_cmd as override_mod -import desloppify.app.commands.plan.override_resolve_workflow as override_workflow_mod -from desloppify.app.commands.plan.override_resolve_helpers import blocked_triage_stages as _blocked_triage_stages +import desloppify.app.commands.plan.override.resolve_cmd as override_mod +import desloppify.app.commands.plan.override.resolve_workflow as override_workflow_mod +from desloppify.app.commands.plan.override.resolve_helpers import blocked_triage_stages as _blocked_triage_stages from desloppify.engine._plan.schema import empty_plan from desloppify.engine._plan.constants import TRIAGE_STAGE_IDS @@ -65,7 +65,14 @@ def test_observe_confirmed_unblocks_reflect(self): assert blocked["triage::organize"] == ["triage::reflect"] def test_all_confirmed_returns_empty(self): - plan = _plan_with_triage_stages("observe", "reflect", "organize", "enrich", "sense-check", "commit") + plan = _plan_with_triage_stages( + "observe", + "reflect", + "organize", + "enrich", + "sense-check", + "commit", + ) assert _blocked_triage_stages(plan) == {} def test_no_triage_in_queue_returns_empty(self): @@ -110,6 +117,7 @@ def test_plan_resolve_allows_unblocked_triage_stage(monkeypatch, capsys): plan = _plan_with_triage_stages("observe") # observe confirmed monkeypatch.setattr(override_workflow_mod, "load_plan", lambda *a, **kw: plan) + monkeypatch.setattr(override_workflow_mod, "live_planned_queue_empty", lambda _plan: False) saved_plans = [] monkeypatch.setattr(override_workflow_mod, "save_plan", lambda p, *a, **kw: saved_plans.append(p)) @@ -127,6 +135,7 @@ def test_plan_resolve_force_resolve_overrides_block(monkeypatch, capsys): plan = _plan_with_triage_stages() # nothing confirmed monkeypatch.setattr(override_workflow_mod, "load_plan", lambda *a, **kw: plan) + monkeypatch.setattr(override_workflow_mod, "live_planned_queue_empty", lambda _plan: False) saved_plans = [] monkeypatch.setattr(override_workflow_mod, "save_plan", lambda p, *a, **kw: saved_plans.append(p)) @@ -145,6 +154,7 @@ def test_plan_resolve_observe_is_never_blocked(monkeypatch, capsys): plan = _plan_with_triage_stages() # nothing confirmed monkeypatch.setattr(override_workflow_mod, "load_plan", lambda *a, **kw: plan) + monkeypatch.setattr(override_workflow_mod, "live_planned_queue_empty", lambda _plan: False) saved_plans = [] monkeypatch.setattr(override_workflow_mod, "save_plan", lambda p, *a, **kw: saved_plans.append(p)) diff --git a/desloppify/tests/commands/plan/test_triage_display_direct.py b/desloppify/tests/commands/plan/test_triage_display_direct.py index 130973ff..8515750f 100644 --- a/desloppify/tests/commands/plan/test_triage_display_direct.py +++ b/desloppify/tests/commands/plan/test_triage_display_direct.py @@ -198,7 +198,7 @@ def test_triage_phase_banner_reports_recovery_gap() -> None: banner = triage_mod.triage_phase_banner(plan, state=state) assert "TRIAGE RECOVERY NEEDED" in banner - assert "3 review issue(s)" in banner + assert "3 review work item(s)" in banner def test_action_guidance_blocks_enrich_until_organize_confirmed(monkeypatch, capsys) -> None: diff --git a/desloppify/tests/commands/plan/test_triage_logging.py b/desloppify/tests/commands/plan/test_triage_logging.py index 0e6adadb..4a22b440 100644 --- a/desloppify/tests/commands/plan/test_triage_logging.py +++ b/desloppify/tests/commands/plan/test_triage_logging.py @@ -163,7 +163,14 @@ def test_reflect_stage_logs_entry(self, monkeypatch, capsys): class TestCompleteLogging: def test_complete_logs_entry(self, monkeypatch, capsys): - plan = _plan_with_stages("observe", "reflect", "organize", "enrich", "sense-check", confirmed=True) + plan = _plan_with_stages( + "observe", + "reflect", + "organize", + "enrich", + "sense-check", + confirmed=True, + ) plan["clusters"]["fix-names"] = { "name": "fix-names", "description": "Fix naming", diff --git a/desloppify/tests/commands/plan/test_triage_plan_state_access_direct.py b/desloppify/tests/commands/plan/test_triage_plan_state_access_direct.py index 9d5f9f2f..0f0a6a37 100644 --- a/desloppify/tests/commands/plan/test_triage_plan_state_access_direct.py +++ b/desloppify/tests/commands/plan/test_triage_plan_state_access_direct.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import get_type_hints + import desloppify.app.commands.plan.triage.plan_state_access as plan_state_access_mod from desloppify.app.commands.plan.triage.plan_state_access import ( ensure_cluster_map, @@ -11,6 +13,8 @@ ensure_triage_meta, normalized_issue_id_list, ) +from desloppify.engine._work_queue.types import PlanClusterRef, SerializedQueueItem +from desloppify.engine.plan_state import ActionStep, Cluster def test_plan_state_access_initializes_missing_collections() -> None: @@ -71,3 +75,15 @@ def test_plan_state_access_exports_expected_helpers() -> None: assert "ensure_triage_meta" in plan_state_access_mod.__all__ assert "ensure_execution_log" in plan_state_access_mod.__all__ assert "normalized_issue_id_list" in plan_state_access_mod.__all__ + + +def test_plan_schema_and_queue_types_cover_runtime_cluster_payloads() -> None: + action_step_hints = get_type_hints(ActionStep) + cluster_hints = get_type_hints(Cluster) + plan_cluster_hints = get_type_hints(PlanClusterRef) + serialized_queue_hints = get_type_hints(SerializedQueueItem) + + assert "effort" in action_step_hints + assert "depends_on_clusters" in cluster_hints + assert "action_steps" in plan_cluster_hints + assert "action_steps" in serialized_queue_hints diff --git a/desloppify/tests/commands/plan/test_triage_runner.py b/desloppify/tests/commands/plan/test_triage_runner.py index f78dd42b..dcea4c99 100644 --- a/desloppify/tests/commands/plan/test_triage_runner.py +++ b/desloppify/tests/commands/plan/test_triage_runner.py @@ -32,8 +32,8 @@ def _make_triage_input(n_issues: int = 5) -> TriageInput: "detail": {"dimension": f"dim_{i % 3}", "suggestion": "Fix it"}, } return TriageInput( - open_issues=issues, - mechanical_issues={}, + review_issues=issues, + objective_backlog_issues={}, existing_clusters={}, dimension_scores={"dim_0": {"score": 70, "strict": 65, "failing": 2}}, new_since_last=set(), @@ -391,6 +391,101 @@ def test_validate_enrich_vague_detail(tmp_path: Path) -> None: assert "vague" in msg +def test_validate_enrich_ignores_out_of_scope_clusters_for_frozen_triage(tmp_path: Path) -> None: + """Runner enrich validation should only inspect clusters tied to the active triage session.""" + (tmp_path / "src").mkdir() + (tmp_path / "src" / "current.ts").write_text("export {}") + plan = _plan_with_stages(enrich={"report": "x" * 150}) + plan["epic_triage_meta"]["active_triage_issue_ids"] = ["review::current::issue"] + plan["clusters"] = { + "current": { + "issue_ids": ["review::current::issue"], + "description": "current batch", + "action_steps": [ + { + "title": "fix current", + "detail": "Update src/current.ts to simplify the active path and remove duplication. " + "x" * 30, + "effort": "small", + "issue_refs": ["review::current::issue"], + } + ], + }, + "legacy": { + "issue_ids": ["review::legacy::issue"], + "description": "old batch", + "action_steps": [ + { + "title": "old vague step", + "detail": "", + "effort": "small", + } + ], + }, + } + state = { + "issues": { + "review::current::issue": {"status": "open", "detector": "review"}, + "review::legacy::issue": {"status": "open", "detector": "review"}, + } + } + ok, msg = validate_stage("enrich", plan, state, tmp_path) + assert ok, msg + + +def test_validate_sense_check_ignores_out_of_scope_clusters_for_frozen_triage(tmp_path: Path) -> None: + """Decision-ledger coverage should only include live targets from the active triage session.""" + (tmp_path / "src").mkdir() + (tmp_path / "src" / "current.ts").write_text("export {}") + plan = _plan_with_stages( + **{ + "sense-check": { + "report": "\n".join( + [ + "Verified src/current.ts lines 1-10: the active cluster is concrete, safe, and removes duplication without adding indirection. " + "x" * 20, + "## Decision Ledger", + "- current -> keep", + ] + ) + } + } + ) + plan["epic_triage_meta"]["active_triage_issue_ids"] = ["review::current::issue"] + plan["clusters"] = { + "current": { + "issue_ids": ["review::current::issue"], + "description": "current batch", + "action_steps": [ + { + "title": "fix current", + "detail": "Update src/current.ts to simplify the active path and remove duplication. " + "x" * 30, + "effort": "small", + "issue_refs": ["review::current::issue"], + } + ], + }, + "legacy": { + "issue_ids": ["review::legacy::issue"], + "description": "old batch", + "action_steps": [ + { + "title": "old valid step", + "detail": "Update src/legacy.ts to simplify the old path and remove duplication. " + "x" * 30, + "effort": "small", + "issue_refs": ["review::legacy::issue"], + } + ], + }, + } + state = { + "issues": { + "review::current::issue": {"status": "open", "detector": "review"}, + "review::legacy::issue": {"status": "open", "detector": "review"}, + } + } + ok, msg = validate_stage("sense-check", plan, state, tmp_path) + assert ok, msg + + # ---------- Underspecified steps (AND→OR fix) ---------- @@ -553,6 +648,7 @@ def test_validate_completion_self_dependency(tmp_path: Path) -> None: organize={"report": "x" * 150, "confirmed_at": "t"}, enrich={"report": "x" * 150, "confirmed_at": "t"}, **{"sense-check": {"report": "x" * 150, "confirmed_at": "t"}}, + **{"value-check": {"report": "x" * 150, "confirmed_at": "t"}}, ) plan["clusters"] = { "self-dep": { @@ -574,6 +670,7 @@ def test_validate_completion_surfaces_all_trivial_cluster_advisory(tmp_path: Pat organize={"report": "x" * 150, "confirmed_at": "t"}, enrich={"report": "x" * 150, "confirmed_at": "t"}, **{"sense-check": {"report": "x" * 150, "confirmed_at": "t"}}, + **{"value-check": {"report": "x" * 150, "confirmed_at": "t"}}, ) plan["clusters"] = { "all-trivial": { @@ -606,12 +703,60 @@ def test_validate_completion_allows_zero_issue_noop(tmp_path: Path) -> None: organize={"report": "x" * 150, "confirmed_at": "t"}, enrich={"report": "x" * 150, "confirmed_at": "t"}, **{"sense-check": {"report": "x" * 150, "confirmed_at": "t"}}, + **{"value-check": {"report": "x" * 150, "confirmed_at": "t"}}, ) ok, msg = validate_completion(plan, {"issues": {}}, tmp_path) assert ok assert msg == "" +def test_validate_completion_ignores_out_of_scope_clusters_for_frozen_triage(tmp_path: Path) -> None: + """Completion should only validate clusters tied to the frozen triage issue set.""" + plan = _plan_with_stages( + observe={"report": "x" * 150, "confirmed_at": "t"}, + reflect={"report": "x" * 150, "confirmed_at": "t"}, + organize={"report": "x" * 150, "confirmed_at": "t"}, + enrich={"report": "x" * 150, "confirmed_at": "t"}, + **{"sense-check": {"report": "x" * 150, "confirmed_at": "t"}}, + **{"value-check": {"report": "x" * 150, "confirmed_at": "t"}}, + ) + plan["epic_triage_meta"]["active_triage_issue_ids"] = ["review::current::issue"] + plan["clusters"] = { + "current": { + "issue_ids": ["review::current::issue"], + "description": "current batch", + "action_steps": [ + { + "title": "fix current", + "detail": "Update src/current.ts to simplify the active path and remove duplication. " + "x" * 30, + "effort": "small", + "issue_refs": ["review::current::issue"], + } + ], + }, + "legacy-self-dep": { + "issue_ids": ["review::legacy::issue"], + "description": "old batch", + "action_steps": [ + { + "title": "old step", + "detail": "", + "effort": "small", + } + ], + "depends_on_clusters": ["legacy-self-dep"], + }, + } + state = { + "issues": { + "review::current::issue": {"status": "open", "detector": "review"}, + "review::legacy::issue": {"status": "open", "detector": "review"}, + } + } + ok, msg = validate_completion(plan, state, tmp_path) + assert ok, msg + + def test_validate_stage_organize_allows_zero_issue_noop(tmp_path: Path) -> None: plan = _plan_with_stages(organize={"report": "x" * 150}) ok, msg = validate_stage("organize", plan, {"issues": {}}, tmp_path, triage_input=_make_triage_input(0)) @@ -662,6 +807,8 @@ def test_sense_check_prompt_includes_cluster_data(tmp_path: Path) -> None: assert str(tmp_path) in prompt assert "LINE NUMBERS" in prompt assert "STALENESS" in prompt + assert "ONLY reference file paths that already exist on disk" in prompt + assert "do NOT invent a future filename" in prompt def test_sense_check_structure_prompt_includes_clusters(tmp_path: Path) -> None: @@ -691,6 +838,7 @@ def test_sense_check_structure_prompt_includes_clusters(tmp_path: Path) -> None: assert "depends_on: cluster-a" in prompt assert "SHARED FILES" in prompt assert "CIRCULAR DEPS" in prompt + assert "Do NOT add cascade steps that point at speculative future files" in prompt # ---------- Sense-check validation ---------- @@ -722,7 +870,18 @@ def test_sense_check_validation_ok(tmp_path: Path) -> None: """Sense-check passes when report is long enough and all enrich checks pass.""" (tmp_path / "src").mkdir() (tmp_path / "src" / "foo.ts").write_text("export {}") - plan = _plan_with_stages(**{"sense-check": {"report": "Verified test-cluster steps: src/foo.ts lines 10-20 match description. Effort tags accurate. " + "x" * 60}}) + plan = _plan_with_stages( + **{ + "sense-check": { + "report": ( + "## Decision Ledger\n" + "- test-cluster -> keep\n\n" + "Verified test-cluster steps: src/foo.ts lines 10-20 match description. " + "Effort tags accurate. " + "x" * 60 + ) + } + } + ) plan["clusters"] = { "test-cluster": { "issue_ids": ["review::a::b"], @@ -741,6 +900,47 @@ def test_sense_check_validation_ok(tmp_path: Path) -> None: assert ok +def test_sense_check_validation_uses_frozen_value_targets(tmp_path: Path) -> None: + """Validation should accept decision-ledger targets captured before value-pass pruning.""" + (tmp_path / "src").mkdir() + (tmp_path / "src" / "foo.ts").write_text("export {}") + plan = _plan_with_stages( + **{ + "sense-check": { + "report": ( + "## Decision Ledger\n" + "- kept-cluster -> keep\n" + "- pruned-cluster -> skip\n" + "- review::.::holistic::cross_module_architecture::private_framework_boundary_still_leaks -> skip\n\n" + "Verified kept-cluster in src/foo.ts lines 1-10 and recorded why the pruned targets were removed during the value pass. " + + "x" * 80 + ), + "value_targets": [ + "kept-cluster", + "pruned-cluster", + "review::.::holistic::cross_module_architecture::private_framework_boundary_still_leaks", + ], + } + } + ) + plan["clusters"] = { + "kept-cluster": { + "issue_ids": ["review::a::b"], + "description": "active", + "action_steps": [ + { + "title": "fix", + "detail": "Update src/foo.ts to simplify the active path and remove duplication. " + "x" * 40, + "effort": "small", + "issue_refs": ["review::a::b"], + } + ], + } + } + ok, msg = validate_stage("sense-check", plan, {}, tmp_path) + assert ok, msg + + def test_sense_check_stage_in_pipeline_order() -> None: """sense-check must appear between enrich and commit in TRIAGE_STAGE_IDS.""" from desloppify.engine._plan.constants import TRIAGE_STAGE_IDS diff --git a/desloppify/tests/commands/plan/test_triage_split_modules_direct.py b/desloppify/tests/commands/plan/test_triage_split_modules_direct.py index 1f9bdc80..0e8b8bd8 100644 --- a/desloppify/tests/commands/plan/test_triage_split_modules_direct.py +++ b/desloppify/tests/commands/plan/test_triage_split_modules_direct.py @@ -61,10 +61,28 @@ def _make_stage_context( def test_completion_policy_helpers_cover_success_and_fail_paths(monkeypatch, capsys) -> None: - monkeypatch.setattr(completion_policy_mod, "manual_clusters_with_issues", lambda _plan: ["c1"]) - monkeypatch.setattr(completion_policy_mod, "unenriched_clusters", lambda _plan: []) + monkeypatch.setattr( + completion_policy_mod, + "scoped_manual_clusters_with_issues", + lambda _plan, _state=None: ["c1"], + ) + monkeypatch.setattr(completion_policy_mod, "active_triage_issue_scope", lambda _plan, _state=None: None) + monkeypatch.setattr( + completion_policy_mod, + "open_review_ids_from_state", + lambda _state: {"review::a.py::id1"}, + ) + monkeypatch.setattr(completion_policy_mod, "triage_coverage", lambda _plan, open_review_ids: (1, 1, [])) + monkeypatch.setattr(completion_policy_mod, "unenriched_clusters", lambda _plan, _state=None: []) monkeypatch.setattr(completion_policy_mod, "unclustered_review_issues", lambda _plan, _state: []) assert completion_policy_mod._completion_clusters_valid({"clusters": {}}, state={}) is True + readiness = completion_policy_mod.evaluate_completion_readiness( + {"clusters": {}}, + state={}, + ) + assert readiness.ok is True + assert readiness.organized == 1 + assert readiness.total == 1 assert completion_policy_mod._resolve_completion_strategy("keep", meta={}) == "keep" assert completion_policy_mod._resolve_completion_strategy(None, meta={}) is None @@ -113,6 +131,26 @@ def test_completion_policy_helpers_cover_success_and_fail_paths(monkeypatch, cap assert "Strategy too short" in out +def test_runner_validate_completion_uses_shared_completion_boundary(monkeypatch, tmp_path: Path) -> None: + import desloppify.app.commands.plan.triage.runner.stage_validation as stage_validation_mod + + monkeypatch.setattr( + stage_validation_mod, + "evaluate_completion_readiness", + lambda _plan, _state, require_confirmed_stages=False: completion_policy_mod.CompletionReadiness( + ok=True, + message="Advisory: shared boundary hit", + organized=2, + total=2, + ), + ) + + ok, message = stage_validation_mod.validate_completion({}, {}, tmp_path) + + assert ok is True + assert message == "Advisory: shared boundary hit" + + def test_completion_stage_helpers_include_gate_and_auto_confirm_defaults(monkeypatch, capsys) -> None: plan = {"clusters": {"a": {"issue_ids": ["id1"], "action_steps": []}}} @@ -306,6 +344,27 @@ def test_confirmation_pipeline_can_skip_stale_issue_ref_warnings(monkeypatch) -> assert report.warnings == [] +def test_confirmation_pipeline_threads_triage_issue_scope(monkeypatch) -> None: + captured: dict[str, object] = {} + + def _fake_evaluate(plan, repo_root, **kwargs): + del plan, repo_root + captured.update(kwargs) + return confirmations_enrich_mod._ConfirmationCheckReport(failures=[], warnings=[]) + + monkeypatch.setattr(confirmations_enrich_mod, "evaluate_enrich_quality", _fake_evaluate) + monkeypatch.setattr("desloppify.base.discovery.paths.get_project_root", lambda: Path(".")) + + report = confirmations_enrich_mod._collect_enrich_level_confirmation_checks( + {"clusters": {}}, + include_stale_issue_ref_warning=True, + triage_issue_ids={"review::current::issue"}, + ) + + assert report.failures == [] + assert captured["triage_issue_ids"] == {"review::current::issue"} + + def test_validate_attestation_rules() -> None: assert confirmations_basic_mod.validate_attestation("mentions naming", "observe", dimensions=["Naming"]) is None err = confirmations_basic_mod.validate_attestation( @@ -446,6 +505,50 @@ def test_lifecycle_ensure_triage_started_handles_active_blocked_and_started() -> ) +def test_lifecycle_ensure_triage_started_uses_plan_aware_backlog_for_workflow_only_queue() -> None: + saved: list[dict] = [] + services = SimpleNamespace( + save_plan=lambda plan: saved.append(dict(plan)), + append_log_entry=lambda *_a, **_k: None, + ) + + plan = { + "queue_order": ["workflow::create-plan"], + "plan_start_scores": {"strict": 86.0}, + "epic_triage_meta": {}, + } + state = { + "issues": { + "smells::src/a.py::x": { + "id": "smells::src/a.py::x", + "detector": "smells", + "status": "open", + "file": "src/a.py", + "tier": 3, + "confidence": "high", + "summary": "objective issue still in state", + "detail": {}, + } + }, + "dimension_scores": {}, + "subjective_assessments": {}, + } + + outcome = triage_lifecycle_mod.ensure_triage_started( + plan, + services=services, + state=state, + deps=triage_lifecycle_mod.TriageLifecycleDeps( + has_triage_in_queue=lambda _plan: False, + inject_triage_stages=lambda plan: plan.setdefault("queue_order", []).append("triage::observe"), + colorize=lambda text, _style: text, + ), + ) + + assert outcome.status == "started" + assert "triage::observe" in plan["queue_order"] + + def test_pipeline_context_helpers_load_prior_reports_directly() -> None: plan = { "epic_triage_meta": { @@ -629,7 +732,12 @@ def test_orchestrator_observe_helpers_and_dry_run(monkeypatch, tmp_path, capsys) def test_orchestrator_sense_dry_run(monkeypatch, tmp_path, capsys) -> None: - monkeypatch.setattr(orchestrator_sense_mod, "manual_clusters_with_issues", lambda _plan: ["cluster-a"]) + monkeypatch.setattr( + orchestrator_sense_mod, + "scoped_manual_clusters_with_issues", + lambda _plan, _state=None: ["cluster-a"], + ) + monkeypatch.setattr(orchestrator_sense_mod, "triage_scoped_plan", lambda plan, _state=None: plan) monkeypatch.setattr( orchestrator_sense_mod, "build_sense_check_content_prompt", @@ -658,7 +766,12 @@ def test_orchestrator_sense_dry_run(monkeypatch, tmp_path, capsys) -> None: def test_orchestrator_sense_non_dry_run_merges_outputs(monkeypatch, tmp_path, capsys) -> None: - monkeypatch.setattr(orchestrator_sense_mod, "manual_clusters_with_issues", lambda _plan: ["cluster-a"]) + monkeypatch.setattr( + orchestrator_sense_mod, + "scoped_manual_clusters_with_issues", + lambda _plan, _state=None: ["cluster-a"], + ) + monkeypatch.setattr(orchestrator_sense_mod, "triage_scoped_plan", lambda plan, _state=None: plan) monkeypatch.setattr( orchestrator_sense_mod, "build_sense_check_content_prompt", @@ -730,11 +843,16 @@ def fake_run_parallel_batches( assert "content:cluster-a" in result.merged_output assert "structure" in result.merged_output out = capsys.readouterr().out - assert "merged 2 batch outputs" in out + assert "merged 3 batch outputs" in out def test_orchestrator_sense_non_dry_run_reports_parallel_failures(monkeypatch, tmp_path, capsys) -> None: - monkeypatch.setattr(orchestrator_sense_mod, "manual_clusters_with_issues", lambda _plan: ["cluster-a"]) + monkeypatch.setattr( + orchestrator_sense_mod, + "scoped_manual_clusters_with_issues", + lambda _plan, _state=None: ["cluster-a"], + ) + monkeypatch.setattr(orchestrator_sense_mod, "triage_scoped_plan", lambda plan, _state=None: plan) monkeypatch.setattr( orchestrator_sense_mod, "build_sense_check_content_prompt", @@ -776,7 +894,12 @@ def test_orchestrator_sense_non_dry_run_reports_parallel_failures(monkeypatch, t def test_orchestrator_sense_apply_updates_sequences_and_reloads_plan(monkeypatch, tmp_path) -> None: - monkeypatch.setattr(orchestrator_sense_mod, "manual_clusters_with_issues", lambda _plan: ["cluster-a"]) + monkeypatch.setattr( + orchestrator_sense_mod, + "scoped_manual_clusters_with_issues", + lambda _plan, _state=None: ["cluster-a"], + ) + monkeypatch.setattr(orchestrator_sense_mod, "triage_scoped_plan", lambda plan, _state=None: plan) content_modes: list[str] = [] structure_modes: list[str] = [] @@ -834,6 +957,8 @@ def fake_run_parallel_batches( phase_order.append("content") if "structure" in labels: phase_order.append("structure") + if "value" in labels: + phase_order.append("value") for task in tasks.values(): assert task().ok return [] @@ -855,6 +980,11 @@ def fake_reload_plan(): "build_sense_check_structure_prompt", fake_structure_prompt, ) + monkeypatch.setattr( + orchestrator_sense_mod, + "build_sense_check_value_prompt", + lambda **_kwargs: "value prompt", + ) monkeypatch.setattr(orchestrator_sense_mod, "run_triage_stage", fake_run_triage_stage) monkeypatch.setattr(orchestrator_sense_mod, "run_parallel_batches", fake_run_parallel_batches) @@ -885,8 +1015,94 @@ def fake_reload_plan(): assert content_modes == ["self_record"] assert structure_modes == ["self_record", "self_record"] assert structure_versions[-1] == "after-content" - assert reload_calls["count"] == 1 - assert phase_order == ["content", "structure"] + assert reload_calls["count"] == 2 + assert phase_order == ["content", "structure", "value"] + + +def test_orchestrator_sense_scopes_content_batches_to_active_triage(monkeypatch, tmp_path) -> None: + content_clusters: list[str] = [] + structure_versions: list[dict] = [] + + monkeypatch.setattr( + orchestrator_sense_mod, + "scoped_manual_clusters_with_issues", + lambda _plan, _state=None: ["current"], + ) + monkeypatch.setattr( + orchestrator_sense_mod, + "triage_scoped_plan", + lambda plan, _state=None: { + **plan, + "clusters": {"current": plan["clusters"]["current"]}, + }, + ) + + def fake_content_prompt(*, cluster_name, plan, **_kwargs): + content_clusters.append(cluster_name) + assert set(plan.get("clusters", {})) == {"current"} + return "content prompt" + + def fake_structure_prompt(*, plan, **_kwargs): + structure_versions.append(plan) + assert set(plan.get("clusters", {})) == {"current"} + return "structure prompt" + + monkeypatch.setattr(orchestrator_sense_mod, "build_sense_check_content_prompt", fake_content_prompt) + monkeypatch.setattr(orchestrator_sense_mod, "build_sense_check_structure_prompt", fake_structure_prompt) + monkeypatch.setattr(orchestrator_sense_mod, "build_sense_check_value_prompt", lambda **_kwargs: "value prompt") + + def fake_run_triage_stage( + *, + prompt, + repo_root, + output_file, + log_file, + timeout_seconds, + validate_output_fn, + ): + del prompt, repo_root, log_file, timeout_seconds + output_file.write_text("ok", encoding="utf-8") + assert validate_output_fn(output_file) + return codex_runner_mod.TriageStageRunResult(exit_code=0) + + monkeypatch.setattr(orchestrator_sense_mod, "run_triage_stage", fake_run_triage_stage) + monkeypatch.setattr( + orchestrator_sense_mod, + "run_parallel_batches", + lambda **kwargs: [task() for task in kwargs["tasks"].values()] and [], + ) + + prompts_dir = tmp_path / "prompts" + output_dir = tmp_path / "out" + logs_dir = tmp_path / "logs" + prompts_dir.mkdir() + output_dir.mkdir() + logs_dir.mkdir() + + result = orchestrator_sense_mod.run_sense_check( + plan={ + "clusters": { + "current": {"issue_ids": ["review::current::issue"], "action_steps": []}, + "legacy": {"issue_ids": ["review::legacy::issue"], "action_steps": []}, + } + }, + state={ + "issues": { + "review::current::issue": {"status": "open", "detector": "review"}, + "review::legacy::issue": {"status": "open", "detector": "review"}, + } + }, + repo_root=tmp_path, + prompts_dir=prompts_dir, + output_dir=output_dir, + logs_dir=logs_dir, + timeout_seconds=60, + dry_run=False, + ) + + assert result.ok + assert content_clusters == ["current"] + assert len(structure_versions) == 1 def test_default_sense_handler_enables_apply_update_mode(monkeypatch, tmp_path: Path) -> None: @@ -913,6 +1129,7 @@ def fake_run_sense_check(**kwargs): cli_command="/tmp/run_desloppify.sh", append_run_log=lambda _line: None, services=SimpleNamespace(load_plan=lambda: {"clusters": {}}), + state=None, ) handler = orchestrator_pipeline_execution_mod.DEFAULT_STAGE_HANDLERS["sense-check"] assert handler.run_parallel is not None diff --git a/desloppify/tests/commands/plan/test_triage_stage_policy_direct.py b/desloppify/tests/commands/plan/test_triage_stage_policy_direct.py index ddf1e0b0..7380b638 100644 --- a/desloppify/tests/commands/plan/test_triage_stage_policy_direct.py +++ b/desloppify/tests/commands/plan/test_triage_stage_policy_direct.py @@ -61,7 +61,7 @@ def test_compute_triage_progress_blocks_sense_check_until_enrich_confirmed() -> assert progress.current_stage is None assert progress.next_command == "desloppify plan triage --confirm enrich" assert progress.blocked_reason == ( - "Verify accuracy & cross-cluster deps blocked until Make steps executor-ready (detail, refs) is confirmed." + "Verify accuracy, structure & value blocked until Make steps executor-ready (detail, refs) is confirmed." ) 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 009d1559..bb466dde 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 @@ -211,7 +211,7 @@ def test_record_sense_stage_and_run_stage_sense_check(tmp_path, capsys, monkeypa services = _Services(plan=plan) args = argparse.Namespace(report="Verified all steps: src/services/main.ts lines 10-50 match descriptions. Structure and content accurate. " + "y" * 30) - def _record_sense(stages: dict, *, report: str, existing_stage, is_reuse): + def _record_sense(stages: dict, *, report: str, existing_stage, is_reuse, value_targets=None): stages["sense-check"] = { "stage": "sense-check", "report": report, diff --git a/desloppify/tests/commands/plan/test_triage_stage_rendering.py b/desloppify/tests/commands/plan/test_triage_stage_rendering.py index e29835f7..d2488df9 100644 --- a/desloppify/tests/commands/plan/test_triage_stage_rendering.py +++ b/desloppify/tests/commands/plan/test_triage_stage_rendering.py @@ -14,7 +14,8 @@ def test_print_observe_report_requirement_emits_guidance(monkeypatch, capsys) -> out = capsys.readouterr().out assert "--report is required for --stage observe" in out - assert "Do NOT just list issue IDs" in out + assert "Verify the queued issues one by one against the code" in out + assert "Cite the files you read" in out def test_print_complete_summary_emits_stage_details(monkeypatch, capsys) -> None: @@ -48,7 +49,7 @@ def test_print_complete_summary_emits_stage_details(monkeypatch, capsys) -> None assert "Triage summary" in out assert "Observe: 5 issues analysed" in out assert "cluster-alpha: 2 steps" in out - assert "Sense-check: content & structure verified" in out + assert "Sense-check: content, structure & value verified" in out def test_print_new_issues_since_last_lists_ids_and_summaries(monkeypatch, capsys) -> None: diff --git a/desloppify/tests/commands/plan/test_triage_tooling_fixes.py b/desloppify/tests/commands/plan/test_triage_tooling_fixes.py index f0b594aa..5b391fc4 100644 --- a/desloppify/tests/commands/plan/test_triage_tooling_fixes.py +++ b/desloppify/tests/commands/plan/test_triage_tooling_fixes.py @@ -5,7 +5,7 @@ import argparse from pathlib import Path -from desloppify.app.commands.plan import cluster_update as cluster_update_mod +from desloppify.app.commands.plan.cluster import update as cluster_update_mod from desloppify.app.commands.plan.triage.validation.core import ( _cluster_file_overlaps, diff --git a/desloppify/tests/commands/plan/test_workflow_gates.py b/desloppify/tests/commands/plan/test_workflow_gates.py index cb7e8b61..5184a8c3 100644 --- a/desloppify/tests/commands/plan/test_workflow_gates.py +++ b/desloppify/tests/commands/plan/test_workflow_gates.py @@ -13,9 +13,9 @@ import argparse -import desloppify.app.commands.plan.override_resolve_cmd as resolve_mod -import desloppify.app.commands.plan.override_resolve_workflow as resolve_workflow_mod -import desloppify.app.commands.plan.override_misc as misc_mod +import desloppify.app.commands.plan.override.misc as misc_mod +import desloppify.app.commands.plan.override.resolve_cmd as resolve_mod +import desloppify.app.commands.plan.override.resolve_workflow as resolve_workflow_mod from desloppify.engine._plan.schema import empty_plan from desloppify.engine._plan.constants import ( WORKFLOW_CREATE_PLAN_ID, diff --git a/desloppify/tests/commands/resolve/test_cmd_resolve.py b/desloppify/tests/commands/resolve/test_cmd_resolve.py index 084f2c54..a3a42ffc 100644 --- a/desloppify/tests/commands/resolve/test_cmd_resolve.py +++ b/desloppify/tests/commands/resolve/test_cmd_resolve.py @@ -161,7 +161,7 @@ def test_resolve_successful(self, monkeypatch, capsys): "last_scan": "2025-01-01", } monkeypatch.setattr(resolve_mod, "load_state", lambda sp: fake_state) - monkeypatch.setattr(state_persistence_mod.state_compat, "save_state", lambda state, sp: None) + monkeypatch.setattr(state_persistence_mod, "save_state", lambda state, sp: None) monkeypatch.setattr( state_mod, "resolve_issues", @@ -204,7 +204,7 @@ def test_wontfix_shows_strict_cost_warning(self, monkeypatch, capsys): "last_scan": "2025-01-01", } monkeypatch.setattr(resolve_mod, "load_state", lambda sp: fake_state) - monkeypatch.setattr(state_persistence_mod.state_compat, "save_state", lambda state, sp: None) + monkeypatch.setattr(state_persistence_mod, "save_state", lambda state, sp: None) monkeypatch.setattr( state_mod, "resolve_issues", @@ -246,7 +246,7 @@ def test_reopen_without_attestation_allowed(self, monkeypatch, capsys): "last_scan": "2025-01-01", } monkeypatch.setattr(resolve_mod, "load_state", lambda sp: fake_state) - monkeypatch.setattr(state_persistence_mod.state_compat, "save_state", lambda state, sp: None) + monkeypatch.setattr(state_persistence_mod, "save_state", lambda state, sp: None) monkeypatch.setattr( state_mod, "resolve_issues", @@ -292,7 +292,7 @@ def test_resolve_save_state_error_exits(self, monkeypatch, capsys): lambda state, pattern, status, note, **kwargs: ["f1"], ) monkeypatch.setattr( - state_persistence_mod.state_compat, + state_persistence_mod, "save_state", lambda state, sp: (_ for _ in ()).throw(OSError("disk full")), ) @@ -379,7 +379,7 @@ def test_suppress_save_state_error_exits(self, monkeypatch, capsys): state_path=Path("/tmp/fake.json"), ) monkeypatch.setattr( - state_persistence_mod.state_compat, + state_persistence_mod, "save_state", lambda state, sp: (_ for _ in ()).throw(OSError("readonly")), ) @@ -441,7 +441,7 @@ def test_state_persistence_helpers_delegate_and_wrap_os_errors(self, monkeypatch saved: dict[str, object] = {} monkeypatch.setattr( - state_persistence_mod.state_compat, + state_persistence_mod, "save_state", lambda state, state_file: saved.update(state=state, state_file=state_file), ) @@ -463,7 +463,7 @@ def test_state_persistence_helpers_delegate_and_wrap_os_errors(self, monkeypatch assert saved["config"] is config monkeypatch.setattr( - state_persistence_mod.state_compat, + state_persistence_mod, "save_state", lambda *_args: (_ for _ in ()).throw(OSError("readonly")), ) diff --git a/desloppify/tests/commands/resolve/test_living_plan_direct.py b/desloppify/tests/commands/resolve/test_living_plan_direct.py index a6b0a75b..739507c1 100644 --- a/desloppify/tests/commands/resolve/test_living_plan_direct.py +++ b/desloppify/tests/commands/resolve/test_living_plan_direct.py @@ -71,6 +71,86 @@ def test_update_living_plan_after_resolve_fixed_flow(monkeypatch, capsys) -> Non assert "add" in calls and "clear" in calls and "save" in calls +def test_update_living_plan_after_resolve_reconciles_when_queue_drains(monkeypatch) -> None: + plan = { + "queue_order": ["workflow::create-plan"], + "overrides": {}, + "clusters": {}, + } + state = {"config": {"target_strict_score": 97}} + seen: list[object] = [] + + monkeypatch.setattr(living_plan_mod, "has_living_plan", lambda _p=None: True) + monkeypatch.setattr(living_plan_mod, "load_plan", lambda _p=None: plan) + + def _purge(_plan, _ids): + _plan["queue_order"] = [] + return 1 + + monkeypatch.setattr(living_plan_mod, "purge_ids", _purge) + monkeypatch.setattr(living_plan_mod, "auto_complete_steps", lambda _plan: []) + monkeypatch.setattr(living_plan_mod, "append_log_entry", lambda *_a, **_k: None) + monkeypatch.setattr(living_plan_mod, "add_uncommitted_issues", lambda *_a, **_k: None) + monkeypatch.setattr(living_plan_mod, "clear_postflight_scan_completion", lambda *_a, **_k: None) + monkeypatch.setattr(living_plan_mod, "save_plan", lambda _plan, _p=None: None) + monkeypatch.setattr(living_plan_mod, "live_planned_queue_empty", lambda _plan: True) + monkeypatch.setattr( + living_plan_mod, + "target_strict_score_from_config", + lambda config: seen.append(("target", config)) or 97.0, + ) + monkeypatch.setattr( + living_plan_mod, + "reconcile_plan", + lambda _plan, _state, *, target_strict: seen.append( + ("reconcile", target_strict, _state) + ), + ) + + living_plan_mod.update_living_plan_after_resolve( + args=_args(status="fixed", note="done"), + all_resolved=["workflow::create-plan"], + attestation="attest", + state=state, + ) + + assert ("target", state["config"]) in seen + assert ("reconcile", 97.0, state) in seen + + +def test_update_living_plan_after_resolve_skips_reconcile_without_state(monkeypatch) -> None: + plan = { + "queue_order": ["a"], + "overrides": {}, + "clusters": {}, + } + seen: list[str] = [] + + monkeypatch.setattr(living_plan_mod, "has_living_plan", lambda _p=None: True) + monkeypatch.setattr(living_plan_mod, "load_plan", lambda _p=None: plan) + monkeypatch.setattr(living_plan_mod, "purge_ids", lambda _plan, _ids: 1) + monkeypatch.setattr(living_plan_mod, "auto_complete_steps", lambda _plan: []) + monkeypatch.setattr(living_plan_mod, "append_log_entry", lambda *_a, **_k: None) + monkeypatch.setattr(living_plan_mod, "add_uncommitted_issues", lambda *_a, **_k: None) + monkeypatch.setattr(living_plan_mod, "clear_postflight_scan_completion", lambda *_a, **_k: None) + monkeypatch.setattr(living_plan_mod, "save_plan", lambda _plan, _p=None: None) + monkeypatch.setattr(living_plan_mod, "live_planned_queue_empty", lambda _plan: True) + monkeypatch.setattr( + living_plan_mod, + "reconcile_plan", + lambda *_a, **_k: seen.append("reconcile"), + ) + + living_plan_mod.update_living_plan_after_resolve( + args=_args(status="fixed", note="done"), + all_resolved=["a"], + attestation="attest", + state=None, + ) + + assert seen == [] + + def test_update_living_plan_after_resolve_handles_plan_exceptions(monkeypatch, capsys) -> None: monkeypatch.setattr(living_plan_mod, "has_living_plan", lambda _p=None: True) monkeypatch.setattr( diff --git a/desloppify/tests/commands/review/test_review_batch_core_direct.py b/desloppify/tests/commands/review/test_review_batch_core_direct.py index cdfef37b..d4aaaa94 100644 --- a/desloppify/tests/commands/review/test_review_batch_core_direct.py +++ b/desloppify/tests/commands/review/test_review_batch_core_direct.py @@ -256,7 +256,7 @@ def test_normalize_batch_result_rejects_low_score_without_same_dimension_issue() "dimension_judgment": { "logic_clarity": { "strengths": ["handlers keep domain names consistent"], - "issue_character": "Predicate logic drifts between equivalent paths.", + "dimension_character": "Predicate logic drifts between equivalent paths.", "score_rationale": ( "The core decision paths are understandable, but equivalent handlers " "encode different branching logic and create behavioral drift. " @@ -289,7 +289,7 @@ def test_normalize_batch_result_accepts_low_score_with_same_dimension_issue(): "dimension_judgment": { "logic_clarity": { "strengths": ["predicate naming is mostly descriptive"], - "issue_character": "Control-flow choices are easy to follow but inconsistent.", + "dimension_character": "Control-flow choices are easy to follow but inconsistent.", "score_rationale": ( "Branch structure is readable in isolation, yet equivalent handlers " "use incompatible predicate logic that undermines coherence. " @@ -335,7 +335,7 @@ def test_normalize_batch_result_accepts_dismissed_concern_entries() -> None: "dimension_judgment": { "logic_clarity": { "strengths": ["the reviewer checked the signal and explained the outcome"], - "issue_character": "Most concerns are real, but some detector signals are intentionally acceptable seams.", + "dimension_character": "Most concerns are real, but some detector signals are intentionally acceptable seams.", "score_rationale": ( "The code remains understandable, and the review includes explicit adjudication " "of detector concerns instead of silently dropping them. That keeps the score " @@ -389,7 +389,7 @@ def test_normalize_batch_result_accepts_legacy_findings_alias(): "dimension_judgment": { "logic_clarity": { "strengths": ["legacy payload shape is still parseable"], - "issue_character": "The contract is clear but legacy paths increase ambiguity.", + "dimension_character": "The contract is clear but legacy paths increase ambiguity.", "score_rationale": ( "The importer retains strong structural expectations, but alias handling " "adds historical complexity that can obscure canonical usage. " @@ -472,7 +472,7 @@ def test_normalize_batch_result_rejects_incomplete_dimension_judgment_entry(): "dimension_judgment": { "logic_clarity": { "strengths": ["handler structure is predictable"], - "issue_character": "some divergence exists", + "dimension_character": "some divergence exists", } }, "issues": [ @@ -512,7 +512,7 @@ def test_normalize_batch_result_accepts_legacy_unreported_risk_key(): "dimension_judgment": { "logic_clarity": { "strengths": ["legacy and canonical fields are reconciled"], - "issue_character": "Compatibility handling is explicit but adds branching cost.", + "dimension_character": "Compatibility handling is explicit but adds branching cost.", "score_rationale": ( "Normalization logic remains understandable because compatibility keys are " "handled in one place, but each legacy path increases cognitive load. " @@ -558,7 +558,7 @@ def test_normalize_batch_result_normalizes_context_updates() -> None: "dimension_judgment": { "logic_clarity": { "strengths": ["keeps valid updates"], - "issue_character": "context updates are accepted when structured", + "dimension_character": "context updates are accepted when structured", "score_rationale": ( "The payload should preserve valid additions and header-based mutations " "while dropping malformed entries." diff --git a/desloppify/tests/commands/review/test_review_batch_execution_helpers_direct.py b/desloppify/tests/commands/review/test_review_batch_execution_helpers_direct.py index 8e002c8f..0bef2018 100644 --- a/desloppify/tests/commands/review/test_review_batch_execution_helpers_direct.py +++ b/desloppify/tests/commands/review/test_review_batch_execution_helpers_direct.py @@ -146,11 +146,14 @@ def test_collect_and_reconcile_results_marks_failure_modes(tmp_path: Path) -> No output_files = {0: out0, 1: tmp_path / "out1.json", 2: tmp_path / "out2.json"} batch_status: dict[str, dict[str, object]] = {} batch_results, successful, failures, failure_set = results_mod.collect_and_reconcile_results( - collect_batch_results_fn=lambda **_kwargs: ([{"ok": True}], [1, 2]), - selected_indexes=[0, 1, 2], + collect_batch_results_fn=lambda _request: ([{"ok": True}], [1, 2]), + request=orchestrator_mod.review_batches_mod.CollectBatchResultsRequest( + selected_indexes=[0, 1, 2], + failures=[1], + output_files=output_files, + allowed_dims={"design_coherence"}, + ), execution_failures=[1], - output_files=output_files, - packet={"dimensions": ["design_coherence"]}, batch_positions={0: 1, 1: 2, 2: 3}, batch_status=batch_status, ) @@ -390,7 +393,7 @@ def test_do_import_run_recollects_batches_from_selected_indexes(tmp_path: Path, ) assert len(collect_calls) == 1 - assert collect_calls[0]["selected_indexes"] == [0, 1] + assert collect_calls[0]["request"].selected_indexes == [0, 1] def test_try_load_prepared_packet_accepts_matching_contract(tmp_path: Path, monkeypatch) -> None: diff --git a/desloppify/tests/commands/review/test_review_batch_execution_phases_direct.py b/desloppify/tests/commands/review/test_review_batch_execution_phases_direct.py index 86bfe66b..c959f451 100644 --- a/desloppify/tests/commands/review/test_review_batch_execution_phases_direct.py +++ b/desloppify/tests/commands/review/test_review_batch_execution_phases_direct.py @@ -74,8 +74,8 @@ def test_prepare_batch_run_returns_none_for_dry_run(tmp_path: Path) -> None: "dimension_prompts": {"design_coherence": "prompt"}, } - def prepare_run_artifacts_fn(**kwargs): - run_dir = kwargs["run_root"] / kwargs["stamp"] + def prepare_run_artifacts_fn(request): + run_dir = request.run_root / request.stamp logs_dir = run_dir / "logs" prompts_dir = run_dir / "prompts" results_dir = run_dir / "results" @@ -91,7 +91,7 @@ def prepare_run_artifacts_fn(**kwargs): deps = SimpleNamespace( colorize_fn=lambda text, _tone=None: text, run_stamp_fn=lambda: "stamp", - load_or_prepare_packet_fn=lambda *_a, **_k: ( + load_or_prepare_packet_fn=lambda _request: ( packet, tmp_path / "packet.json", tmp_path / "prompt.json", diff --git a/desloppify/tests/commands/review/test_review_importing_support_direct.py b/desloppify/tests/commands/review/test_review_importing_support_direct.py index 7f5292c0..618ce857 100644 --- a/desloppify/tests/commands/review/test_review_importing_support_direct.py +++ b/desloppify/tests/commands/review/test_review_importing_support_direct.py @@ -9,6 +9,7 @@ import desloppify.app.commands.review.importing.cmd as import_cmd_mod import desloppify.app.commands.review.importing.flags as flags_mod +import desloppify.app.commands.review.importing.output as import_output_mod import desloppify.app.commands.review.importing.plan_sync as plan_sync_mod import desloppify.app.commands.review.importing.results as results_mod import desloppify.engine._plan.constants as plan_constants_mod @@ -37,33 +38,25 @@ def _patch_basic_plan_sync_runtime( monkeypatch: pytest.MonkeyPatch, *, plan: dict, - policy: SimpleNamespace | None = None, ) -> None: - effective_policy = policy or _empty_visibility_policy() monkeypatch.setattr(plan_sync_mod, "has_living_plan", lambda _path=None: True) monkeypatch.setattr(plan_sync_mod, "load_plan", lambda _path=None: plan) monkeypatch.setattr(plan_sync_mod, "save_plan", lambda _plan, _path=None: None) monkeypatch.setattr( plan_sync_mod, - "compute_subjective_visibility", - lambda *_a, **_k: effective_policy, + "live_planned_queue_empty", + lambda _plan: True, ) - monkeypatch.setattr(plan_sync_mod, "ScoreSnapshot", _score_snapshot) monkeypatch.setattr( plan_sync_mod, - "sync_communicate_score_needed", - lambda _plan, _state, **_kwargs: _no_changes(), + "reconcile_plan", + lambda _plan, _state, target_strict: plan_sync_mod.ReconcileResult(), ) monkeypatch.setattr( plan_sync_mod, "sync_import_scores_needed", lambda _plan, _state, assessment_mode, **_kwargs: _no_changes(), ) - monkeypatch.setattr( - plan_sync_mod, - "sync_create_plan_needed", - lambda _plan, _state, policy=None: _no_changes(), - ) monkeypatch.setattr(plan_sync_mod, "append_log_entry", lambda *_a, **_k: None) @@ -127,6 +120,34 @@ def test_sync_plan_after_import_no_living_plan(monkeypatch) -> None: ) +def test_sync_plan_after_import_marks_subjective_review_complete_for_current_scan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + plan = { + "queue_order": [], + "refresh_state": {"postflight_scan_completed_at_scan_count": 7}, + } + _patch_basic_plan_sync_runtime(monkeypatch, plan=plan) + + state = { + "scan_count": 7, + "subjective_assessments": { + "naming_quality": {"score": 82.0}, + }, + } + outcome = plan_sync_mod.sync_plan_after_import( + state=state, + diff={"new": 0, "reopened": 0, "auto_resolved": 0}, + assessment_mode="trusted_internal", + request=_sync_request( + import_payload={"assessments": {"Naming Quality": 82}}, + ), + ) + + assert outcome.status == "synced" + assert plan["refresh_state"]["subjective_review_completed_at_scan_count"] == 7 + + def test_print_review_import_sync_reports_new_ids_and_triage_commands(capsys) -> None: state = { "issues": { @@ -144,16 +165,35 @@ def test_print_review_import_sync_reports_new_ids_and_triage_commands(capsys) -> state, result, workflow_injected=False, + triage_injected=True, + outcome=plan_sync_mod.PlanImportSyncOutcome(status="synced"), ) out = capsys.readouterr().out - assert "2 new review issue(s) added to queue" in out + assert "2 new review work item(s) added to queue" in out assert "Alpha summary" in out - assert "stale review issue(s) removed from queue" in out + assert "stale review work item(s) removed from queue" in out assert plan_sync_mod.TRIAGE_CMD_RUN_STAGES_CODEX in out assert plan_sync_mod.TRIAGE_CMD_RUN_STAGES_CLAUDE in out +def test_print_open_review_summary_avoids_duplicate_count_phrase(capsys) -> None: + next_command = import_output_mod.print_open_review_summary( + { + "issues": { + "review::alpha": {"status": "open", "detector": "review"}, + "review::beta": {"status": "open", "detector": "review"}, + } + }, + colorize_fn=lambda text, _tone: text, + ) + + out = capsys.readouterr().out + assert "2 review work items open total" in out + assert "(2 review work items open total)" not in out + assert next_command == "desloppify show review --status open" + + def test_sync_plan_after_import_scopes_living_plan_to_state_file(monkeypatch, tmp_path) -> None: seen: dict[str, object] = {} @@ -189,30 +229,31 @@ def test_sync_plan_after_import_handles_plan_exceptions(monkeypatch, capsys) -> ) monkeypatch.setattr(plan_sync_mod, "PLAN_LOAD_EXCEPTIONS", (OSError,)) - plan_sync_mod.sync_plan_after_import( + outcome = plan_sync_mod.sync_plan_after_import( state={}, diff={"new": 1, "reopened": 0}, assessment_mode="issues_only", ) out = capsys.readouterr().out - assert "skipped plan sync after review import" in out + assert "Plan sync degraded" in out + assert outcome.status == "degraded" def test_sync_plan_after_import_runs_review_sync_for_auto_resolved_deltas(monkeypatch) -> None: plan: dict = {"queue_order": []} - seen = {"import_called": False, "stale_called": False} + seen = {"import_called": False, "reconcile_called": False} _patch_basic_plan_sync_runtime(monkeypatch, plan=plan) - def fake_import_sync(_plan, _state, policy=None): + def fake_import_sync(_plan, _state, inject_triage=False): seen["import_called"] = True return None - def fake_stale_sync(_plan, _state, policy=None, **_kw): - seen["stale_called"] = True - return SimpleNamespace(changes=False, injected=[], pruned=[]) - monkeypatch.setattr(plan_sync_mod, "sync_plan_after_review_import", fake_import_sync) - monkeypatch.setattr(plan_sync_mod, "sync_subjective_dimensions", fake_stale_sync) + monkeypatch.setattr( + plan_sync_mod, + "reconcile_plan", + lambda *_a, **_k: seen.__setitem__("reconcile_called", True) or plan_sync_mod.ReconcileResult(), + ) plan_sync_mod.sync_plan_after_import( state={}, @@ -221,7 +262,7 @@ def fake_stale_sync(_plan, _state, policy=None, **_kw): ) assert seen["import_called"] is True - assert seen["stale_called"] is True + assert seen["reconcile_called"] is True def test_import_holistic_issues_ignores_unknown_context_update_dimensions() -> None: @@ -233,7 +274,7 @@ def test_import_holistic_issues_ignores_unknown_context_update_dimensions() -> N "dimension_judgment": { "naming_quality": { "strengths": ["Names are mostly precise."], - "issue_character": "Minor drift remains localized.", + "dimension_character": "Minor drift remains localized.", "score_rationale": "The naming is mostly dependable with a few rough edges.", } }, @@ -262,27 +303,28 @@ def test_sync_plan_after_import_logs_triage_provenance(monkeypatch) -> None: entries: list[tuple[str, dict]] = [] _patch_basic_plan_sync_runtime(monkeypatch, plan=plan) - monkeypatch.setattr( - plan_sync_mod, - "sync_subjective_dimensions", - lambda _plan, _state, policy=None, **_kw: SimpleNamespace( - changes=False, - injected=[], - pruned=[], - ), - ) monkeypatch.setattr( plan_sync_mod, "sync_plan_after_review_import", - lambda _plan, _state, policy=None: SimpleNamespace( + lambda _plan, _state, inject_triage=False: SimpleNamespace( new_ids={"review::x"}, added_to_queue=["review::x"], - triage_injected=True, stale_pruned_from_queue=[], - triage_injected_ids=["triage::observe", "triage::reflect"], + triage_injected=False, + triage_injected_ids=[], triage_deferred=False, ), ) + reconcile_result = plan_sync_mod.ReconcileResult( + triage=plan_constants_mod.QueueSyncResult( + injected=["triage::observe", "triage::reflect"], + ) + ) + monkeypatch.setattr( + plan_sync_mod, + "reconcile_plan", + lambda *_a, **_k: reconcile_result, + ) monkeypatch.setattr( plan_sync_mod, "append_log_entry", @@ -302,6 +344,7 @@ def test_sync_plan_after_import_logs_triage_provenance(monkeypatch) -> None: assert detail["triage_injected_ids"] == ["triage::observe", "triage::reflect"] assert detail["triage_deferred"] is False assert detail["stale_pruned_from_queue"] == [] + assert detail["sync_status"] == "synced" def test_sync_plan_after_import_keeps_workflow_before_triage(monkeypatch) -> None: @@ -314,60 +357,39 @@ def test_sync_plan_after_import_keeps_workflow_before_triage(monkeypatch) -> Non monkeypatch.setattr(plan_sync_mod, "has_living_plan", lambda _path=None: True) monkeypatch.setattr(plan_sync_mod, "load_plan", lambda _path=None: plan) monkeypatch.setattr(plan_sync_mod, "save_plan", lambda _plan, _path=None: None) - monkeypatch.setattr( - plan_sync_mod, - "sync_subjective_dimensions", - lambda _plan, _state, policy=None, **_kw: SimpleNamespace( - changes=False, - injected=[], - pruned=[], - ), - ) - monkeypatch.setattr( - plan_sync_mod, - "ScoreSnapshot", - lambda **kwargs: SimpleNamespace(**kwargs), - ) - - def fake_communicate(_plan, _state, **_kwargs): - _plan["queue_order"].append("workflow::communicate-score") - plan_constants_mod.normalize_queue_workflow_and_triage_prefix(_plan["queue_order"]) - return SimpleNamespace(changes=True) - - def fake_create_plan(_plan, _state, policy=None): - _plan["queue_order"].append("workflow::create-plan") - plan_constants_mod.normalize_queue_workflow_and_triage_prefix(_plan["queue_order"]) - return SimpleNamespace(changes=True) - - def fake_review_import(_plan, _state, policy=None): - _plan["queue_order"].extend(["review::x", "triage::observe"]) + def fake_review_import(_plan, _state, inject_triage=False): + _plan["queue_order"].append("review::x") return SimpleNamespace( new_ids={"review::x"}, added_to_queue=["review::x"], - triage_injected=True, stale_pruned_from_queue=[], - triage_injected_ids=["triage::observe"], + triage_injected=False, + triage_injected_ids=[], triage_deferred=False, ) - monkeypatch.setattr(plan_sync_mod, "sync_communicate_score_needed", fake_communicate) monkeypatch.setattr( plan_sync_mod, "sync_import_scores_needed", lambda _plan, _state, assessment_mode, **_kwargs: SimpleNamespace(changes=False), ) - monkeypatch.setattr( - plan_sync_mod, - "compute_subjective_visibility", - lambda *_a, **_k: SimpleNamespace( - has_objective_backlog=False, - unscored_ids=frozenset(), - stale_ids=frozenset(), - under_target_ids=frozenset(), - ), - ) - monkeypatch.setattr(plan_sync_mod, "sync_create_plan_needed", fake_create_plan) monkeypatch.setattr(plan_sync_mod, "sync_plan_after_review_import", fake_review_import) + def fake_reconcile(_plan, _state, target_strict): + _plan["queue_order"].extend( + ["workflow::communicate-score", "workflow::create-plan", "triage::observe"] + ) + plan_constants_mod.normalize_queue_workflow_and_triage_prefix(_plan["queue_order"]) + return plan_sync_mod.ReconcileResult( + communicate_score=plan_constants_mod.QueueSyncResult( + injected=["workflow::communicate-score"] + ), + create_plan=plan_constants_mod.QueueSyncResult( + injected=["workflow::create-plan"] + ), + triage=plan_constants_mod.QueueSyncResult(injected=["triage::observe"]), + ) + monkeypatch.setattr(plan_sync_mod, "reconcile_plan", fake_reconcile) + monkeypatch.setattr(plan_sync_mod, "live_planned_queue_empty", lambda _plan: True) monkeypatch.setattr( plan_sync_mod, "append_log_entry", @@ -375,7 +397,7 @@ def fake_review_import(_plan, _state, policy=None): ) plan_sync_mod.sync_plan_after_import( - state={"issues": {"review::x": {"summary": "new review issue"}}}, + state={"issues": {"review::x": {"summary": "new review work item"}}}, diff={"new": 1, "reopened": 0}, assessment_mode="trusted_internal", ) @@ -396,37 +418,16 @@ def fake_review_import(_plan, _state, policy=None): def test_sync_plan_after_import_reuses_plan_aware_policy(monkeypatch) -> None: plan: dict = {"queue_order": []} - policy = SimpleNamespace( - has_objective_backlog=False, - unscored_ids=frozenset(), - stale_ids=frozenset(), - under_target_ids=frozenset(), - ) seen: dict[str, object] = {} - _patch_basic_plan_sync_runtime(monkeypatch, plan=plan, policy=policy) + _patch_basic_plan_sync_runtime(monkeypatch, plan=plan) - def fake_compute_policy(_state, *, target_strict, plan): + def fake_reconcile(_plan, _state, target_strict): seen["target_strict"] = target_strict - seen["plan"] = plan - return policy - - def fake_create_plan(_plan, _state, *, policy=None): - seen["create_plan_policy"] = policy - return SimpleNamespace(changes=False) - - def fake_review_import(_plan, _state, *, policy=None): - seen["import_policy"] = policy - return None - - def fake_stale_sync(_plan, _state, *, policy=None, **_kw): - seen["stale_policy"] = policy - return SimpleNamespace(changes=False, injected=[], pruned=[]) + seen["plan"] = _plan + return plan_sync_mod.ReconcileResult() - monkeypatch.setattr(plan_sync_mod, "compute_subjective_visibility", fake_compute_policy) - monkeypatch.setattr(plan_sync_mod, "sync_create_plan_needed", fake_create_plan) - monkeypatch.setattr(plan_sync_mod, "sync_plan_after_review_import", fake_review_import) - monkeypatch.setattr(plan_sync_mod, "sync_subjective_dimensions", fake_stale_sync) + monkeypatch.setattr(plan_sync_mod, "reconcile_plan", fake_reconcile) plan_sync_mod.sync_plan_after_import( state={}, @@ -437,9 +438,6 @@ def fake_stale_sync(_plan, _state, *, policy=None, **_kw): assert seen["target_strict"] == 97.0 assert seen["plan"] is plan - assert seen["create_plan_policy"] is policy - assert seen["import_policy"] is policy - assert seen["stale_policy"] is policy def test_sync_plan_after_import_preserves_scan_phase_for_temporary_skips( @@ -462,7 +460,7 @@ def test_sync_plan_after_import_preserves_scan_phase_for_temporary_skips( monkeypatch.setattr( plan_sync_mod, "sync_plan_after_review_import", - lambda _plan, _state, policy=None: SimpleNamespace( + lambda _plan, _state, inject_triage=False: SimpleNamespace( new_ids={"review::new"}, added_to_queue=["review::new"], triage_injected=False, @@ -473,12 +471,13 @@ def test_sync_plan_after_import_preserves_scan_phase_for_temporary_skips( ) monkeypatch.setattr( plan_sync_mod, - "sync_subjective_dimensions", - lambda _plan, _state, policy=None, **_kw: _no_changes(injected=[], pruned=[]), + "reconcile_plan", + lambda _plan, _state, target_strict: _plan["refresh_state"].__setitem__("lifecycle_phase", "scan") + or plan_sync_mod.ReconcileResult(lifecycle_phase="scan", lifecycle_phase_changed=True), ) plan_sync_mod.sync_plan_after_import( - state={"issues": {"review::new": {"summary": "new review issue"}}}, + state={"issues": {"review::new": {"summary": "new review work item"}}}, diff={"new": 1, "reopened": 0}, assessment_mode="issues_only", ) @@ -487,40 +486,77 @@ def test_sync_plan_after_import_preserves_scan_phase_for_temporary_skips( assert saved -def test_sync_plan_after_import_does_not_purge_subjective_ids(monkeypatch) -> None: +def test_sync_plan_after_import_prunes_covered_subjective_ids(monkeypatch) -> None: plan: dict = {"queue_order": ["subjective::naming_quality", "review::existing"]} - purge_calls: list[list[str]] = [] _patch_basic_plan_sync_runtime(monkeypatch, plan=plan) monkeypatch.setattr( plan_sync_mod, "sync_plan_after_review_import", - lambda _plan, _state, policy=None: SimpleNamespace( + lambda _plan, _state, inject_triage=False: SimpleNamespace( new_ids={"review::new"}, added_to_queue=["review::new"], triage_injected=False, stale_pruned_from_queue=[], + covered_subjective_pruned_from_queue=[], triage_injected_ids=[], triage_deferred=False, ), ) + + plan_sync_mod.sync_plan_after_import( + state={"issues": {"review::new": {"summary": "new review work item"}}}, + diff={"new": 1, "reopened": 0}, + assessment_mode="trusted_internal", + request=_sync_request( + import_payload={"assessments": {"Naming quality": 80}, "issues": []}, + ), + ) + + assert "subjective::naming_quality" not in plan["queue_order"] + + +def test_sync_plan_after_import_uses_pre_import_boundary_for_reconcile(monkeypatch) -> None: + plan: dict = {"queue_order": []} + seen = {"reconcile_called": False} + + _patch_basic_plan_sync_runtime(monkeypatch, plan=plan) + monkeypatch.setattr( + plan_sync_mod, + "sync_plan_after_review_import", + lambda _plan, _state, inject_triage=False: ( + _plan["queue_order"].append("review::new") + or SimpleNamespace( + new_ids={"review::new"}, + added_to_queue=["review::new"], + triage_injected=False, + stale_pruned_from_queue=[], + covered_subjective_pruned_from_queue=[], + triage_injected_ids=[], + triage_deferred=False, + ) + ), + ) monkeypatch.setattr( plan_sync_mod, - "sync_subjective_dimensions", - lambda _plan, _state, policy=None, **_kw: _no_changes(injected=[], pruned=[]), + "reconcile_plan", + lambda *_a, **_k: seen.__setitem__("reconcile_called", True) + or plan_sync_mod.ReconcileResult(), ) plan_sync_mod.sync_plan_after_import( - state={"issues": {"review::new": {"summary": "new review issue"}}}, + state={"issues": {"review::new": {"summary": "new review work item"}}}, diff={"new": 1, "reopened": 0}, - assessment_mode="issues_only", + assessment_mode="trusted_internal", + request=_sync_request( + import_payload={"assessments": {"Naming quality": 80}, "issues": []}, + ), ) - assert purge_calls == [] - assert "subjective::naming_quality" in plan["queue_order"] + assert seen["reconcile_called"] is True -def test_sync_plan_after_import_rebuilds_subjective_clusters_for_assessment_only_import( +def test_sync_plan_after_import_skips_mid_cycle_reconcile_for_assessment_only_import( monkeypatch, ) -> None: plan: dict = { @@ -596,17 +632,15 @@ def test_sync_plan_after_import_rebuilds_subjective_clusters_for_assessment_only "naming_quality": {"score": 78.0, "needs_review_refresh": True}, }, } - policy = SimpleNamespace( - has_objective_backlog=False, - objective_count=0, - unscored_ids=frozenset(), - stale_ids=frozenset( - {"subjective::design_coherence", "subjective::naming_quality"} - ), - under_target_ids=frozenset(), + _patch_basic_plan_sync_runtime(monkeypatch, plan=plan) + reconcile_calls: list[tuple[dict, dict, float]] = [] + monkeypatch.setattr( + plan_sync_mod, + "reconcile_plan", + lambda _plan, _state, target_strict: reconcile_calls.append((_plan, _state, target_strict)) + or plan_sync_mod.ReconcileResult(), ) - - _patch_basic_plan_sync_runtime(monkeypatch, plan=plan, policy=policy) + monkeypatch.setattr(plan_sync_mod, "live_planned_queue_empty", lambda _plan: False) monkeypatch.setattr( plan_sync_mod, "append_log_entry", @@ -628,12 +662,9 @@ def test_sync_plan_after_import_rebuilds_subjective_clusters_for_assessment_only ), ) - assert "auto/initial-review" not in plan["clusters"] - assert "auto/stale-review" in plan["clusters"] - assert set(plan["clusters"]["auto/stale-review"]["issue_ids"]) == { - "subjective::design_coherence", - "subjective::naming_quality", - } + assert reconcile_calls == [] + assert plan["queue_order"] == [] + assert plan["clusters"]["auto/initial-review"]["issue_ids"] == [] def test_refresh_scorecard_after_import_only_for_trusted_assessments(monkeypatch) -> None: @@ -796,23 +827,20 @@ def test_plan_sync_source_preserves_scoped_sync_pipeline_contract() -> None: assert "plan_path = None" in src assert "plan_path_for_state(Path(state_file))" in src assert "if not has_living_plan(plan_path):" in src + assert 'return PlanImportSyncOutcome(status="skipped")' in src assert "plan = load_plan(plan_path)" in src - assert "policy = compute_subjective_visibility(" in src - assert "snapshot = score_snapshot(state)" in src - assert "current_scores = ScoreSnapshot(" in src - assert 'trusted_score_import = assessment_mode in {"trusted_internal", "attested_external"}' in src - assert "communicate_result = sync_communicate_score_needed(" in src - assert "import_scores_result = sync_import_scores_needed(" in src - assert "create_plan_result = sync_create_plan_needed(" in src + assert 'trusted = assessment_mode in {"trusted_internal", "attested_external"}' in src assert "sync_inputs = _build_import_sync_inputs(diff, import_payload)" in src - assert "mutations = _PlanImportMutations()" in src - assert "_record_workflow_change(" in src - assert "_sync_review_delta(" in src - assert "_sync_subjective_queue_after_import(" in src - assert "_append_workflow_log_entries(" in src + assert "was_boundary_ready = live_planned_queue_empty(plan)" in src + assert "transition = _apply_import_plan_transitions(" in src + assert "import_result = transition.import_result" in src + assert "covered_pruned = transition.covered_pruned" in src + assert "import_scores_result = transition.import_scores_result" in src + assert "result = transition.reconcile_result" in src assert "_append_review_import_sync_log(" in src assert "save_plan(plan, plan_path)" in src assert "_print_review_import_sync(" in src + assert 'return PlanImportSyncOutcome(status="degraded", message=message)' in src def test_results_source_preserves_query_and_narrative_contract() -> None: diff --git a/desloppify/tests/commands/review/test_review_merge_command_direct.py b/desloppify/tests/commands/review/test_review_merge_command_direct.py index 92ca8413..0fa0dd40 100644 --- a/desloppify/tests/commands/review/test_review_merge_command_direct.py +++ b/desloppify/tests/commands/review/test_review_merge_command_direct.py @@ -55,7 +55,7 @@ def test_do_merge_dry_run_reports_groups_without_persisting( related_files=["desloppify/app/commands/helpers/runtime.py"], ) state = state_mod.empty_state() - state["issues"] = {first["id"]: first, second["id"]: second} + state["work_items"] = {first["id"]: first, second["id"]: second} runtime = _runtime_with_state(state, tmp_path / "state.json") monkeypatch.setattr(merge_mod, "command_runtime", lambda _args: runtime) @@ -67,8 +67,8 @@ def test_do_merge_dry_run_reports_groups_without_persisting( args = argparse.Namespace(similarity=0.3, dry_run=True) merge_mod.do_merge(args) - assert state["issues"][first["id"]]["status"] == "open" - assert state["issues"][second["id"]]["status"] == "open" + assert state["work_items"][first["id"]]["status"] == "open" + assert state["work_items"][second["id"]]["status"] == "open" assert "Dry run only" in capsys.readouterr().out @@ -84,7 +84,7 @@ def test_do_merge_marks_duplicates_and_persists_state(monkeypatch, tmp_path: Pat related_files=["desloppify/app/commands/resolve/plan_load.py"], ) state = state_mod.empty_state() - state["issues"] = {first["id"]: first, second["id"]: second} + state["work_items"] = {first["id"]: first, second["id"]: second} runtime = _runtime_with_state(state, tmp_path / "state.json") saved: list[tuple[dict, Path | None]] = [] query_payloads: list[dict] = [] @@ -106,9 +106,9 @@ def test_do_merge_marks_duplicates_and_persists_state(monkeypatch, tmp_path: Pat assert saved == [(state, runtime.state_path)] assert query_payloads and query_payloads[0]["duplicates_merged"] == 1 - open_issues = [issue for issue in state["issues"].values() if issue["status"] == "open"] + open_issues = [issue for issue in state["work_items"].values() if issue["status"] == "open"] auto_resolved = [ - issue for issue in state["issues"].values() if issue["status"] == "auto_resolved" + issue for issue in state["work_items"].values() if issue["status"] == "auto_resolved" ] assert len(open_issues) == 1 assert len(auto_resolved) == 1 diff --git a/desloppify/tests/commands/review/test_review_preflight.py b/desloppify/tests/commands/review/test_review_preflight.py index 223e1219..cf3038f9 100644 --- a/desloppify/tests/commands/review/test_review_preflight.py +++ b/desloppify/tests/commands/review/test_review_preflight.py @@ -147,7 +147,7 @@ def test_blocked_when_open_objective_items(capsys): def test_subjective_review_backlog_blocks_preflight(capsys): """Open review findings in scored dimensions block rerun preflight.""" state = _state_with_prior_review() - state["issues"] = { + state["work_items"] = { "review::naming": _review_issue( issue_id="review::naming", dimension="naming_quality", @@ -168,7 +168,7 @@ def test_subjective_review_backlog_blocks_preflight(capsys): def test_subjective_concerns_backlog_blocks_preflight(capsys): """Open concerns findings in scored dimensions block rerun preflight.""" state = _state_with_prior_review() - state["issues"] = { + state["work_items"] = { "concerns::naming": _concern_issue( issue_id="concerns::naming", dimension="naming_quality", @@ -189,7 +189,7 @@ def test_subjective_concerns_backlog_blocks_preflight(capsys): def test_subjective_review_backlog_is_dimension_filtered(): """Rerun targeting one scored dimension ignores review backlog in others.""" state = _state_with_prior_review() - state["issues"] = { + state["work_items"] = { "review::logic": _review_issue( issue_id="review::logic", dimension="logic_clarity", diff --git a/desloppify/tests/commands/review/test_review_runner_batch_split_direct.py b/desloppify/tests/commands/review/test_review_runner_batch_split_direct.py index 0fc5fb59..7d5c7a1a 100644 --- a/desloppify/tests/commands/review/test_review_runner_batch_split_direct.py +++ b/desloppify/tests/commands/review/test_review_runner_batch_split_direct.py @@ -157,13 +157,24 @@ def test_core_normalize_helpers_and_batch_normalization() -> None: "naming_quality", { "strengths": ["clear modules"], - "issue_character": "mostly local", + "dimension_character": "mostly local", "score_rationale": "x" * 60, }, log_fn=lambda _msg: None, ) assert judgment is not None - assert judgment["issue_character"] == "mostly local" + assert judgment["dimension_character"] == "mostly local" + + aliased_judgment = core_normalize_mod._validate_dimension_judgment( + "logic_clarity", + { + "dimension_character": "judgment character is required", + "score_rationale": "y" * 60, + }, + log_fn=lambda _msg: None, + ) + assert aliased_judgment is not None + assert aliased_judgment["dimension_character"] == "judgment character is required" quality = core_normalize_mod._compute_batch_quality( assessments={"naming_quality": 80.0}, @@ -212,7 +223,7 @@ def test_core_normalize_helpers_and_batch_normalization() -> None: "dimension_judgment": { "naming_quality": { "strengths": ["Naming conventions are mostly consistent."], - "issue_character": "Inconsistency is isolated to a few ambiguous identifiers.", + "dimension_character": "Inconsistency is isolated to a few ambiguous identifiers.", "score_rationale": ( "Most modules use descriptive names and consistent style, but a handful of " "generic names still obscure intent at handoff points. " diff --git a/desloppify/tests/commands/review/test_review_runner_helpers_direct.py b/desloppify/tests/commands/review/test_review_runner_helpers_direct.py index d525b736..146da040 100644 --- a/desloppify/tests/commands/review/test_review_runner_helpers_direct.py +++ b/desloppify/tests/commands/review/test_review_runner_helpers_direct.py @@ -8,6 +8,7 @@ import desloppify.app.commands.review.batch.prompt_template as prompt_template_mod import desloppify.app.commands.review.runner_parallel as runner_helpers_mod +from desloppify.app.commands.review.batch.execution import CollectBatchResultsRequest def test_execute_batches_parallel_emits_heartbeat_event() -> None: @@ -75,10 +76,12 @@ def test_collect_batch_results_recovers_from_log_stdout_payload(tmp_path: Path) ) batch_results, failures = runner_helpers_mod.collect_batch_results( - selected_indexes=[0], - failures=[0], - output_files={0: raw_path}, - allowed_dims={"logic_clarity"}, + request=CollectBatchResultsRequest( + selected_indexes=[0], + failures=[0], + output_files={0: raw_path}, + allowed_dims={"logic_clarity"}, + ), extract_payload_fn=lambda raw: json.loads(raw), normalize_result_fn=lambda parsed, _allowed: ( parsed.get("assessments", {}), @@ -102,10 +105,12 @@ def test_collect_batch_results_marks_failure_on_normalize_error(tmp_path: Path) raw_path.write_text(json.dumps({"assessments": {"logic_clarity": 50.0}, "issues": []})) batch_results, failures = runner_helpers_mod.collect_batch_results( - selected_indexes=[0], - failures=[], - output_files={0: raw_path}, - allowed_dims={"logic_clarity"}, + request=CollectBatchResultsRequest( + selected_indexes=[0], + failures=[], + output_files={0: raw_path}, + allowed_dims={"logic_clarity"}, + ), extract_payload_fn=lambda raw: json.loads(raw), normalize_result_fn=lambda _parsed, _allowed: (_ for _ in ()).throw( ValueError("normalize failed") diff --git a/desloppify/tests/commands/scan/test_plan_reconcile.py b/desloppify/tests/commands/scan/test_plan_reconcile.py index 3d35c1a8..617cd1f3 100644 --- a/desloppify/tests/commands/scan/test_plan_reconcile.py +++ b/desloppify/tests/commands/scan/test_plan_reconcile.py @@ -178,7 +178,7 @@ def test_supersedes_resolved_issue(self): "issue-1": _make_issue(status="resolved"), "issue-2": _make_issue(status="open"), }) - from desloppify.engine._plan.reconcile import reconcile_plan_after_scan + from desloppify.engine._plan.scan_issue_reconcile import reconcile_plan_after_scan result = reconcile_plan_after_scan(plan, state) assert "issue-1" in result.superseded assert "issue-1" in plan["superseded"] @@ -191,7 +191,7 @@ def test_supersedes_disappeared_issue(self): plan["queue_order"] = ["gone-id"] plan["overrides"] = {"gone-id": {"issue_id": "gone-id"}} state = _make_state(issues={}) - from desloppify.engine._plan.reconcile import reconcile_plan_after_scan + from desloppify.engine._plan.scan_issue_reconcile import reconcile_plan_after_scan result = reconcile_plan_after_scan(plan, state) assert "gone-id" in result.superseded @@ -202,83 +202,118 @@ def test_no_changes_when_all_alive(self): state = _make_state(issues={ "issue-1": _make_issue(status="open"), }) - changed = reconcile_mod._apply_plan_reconciliation( - plan, state, reconcile_mod.reconcile_plan_after_scan, - ) + changed = reconcile_mod._apply_plan_reconciliation(plan, state) assert changed is False def test_skips_when_no_user_content(self): plan = empty_plan() state = _make_state() - changed = reconcile_mod._apply_plan_reconciliation( - plan, state, reconcile_mod.reconcile_plan_after_scan, - ) + changed = reconcile_mod._apply_plan_reconciliation(plan, state) assert changed is False # --------------------------------------------------------------------------- -# Tests: _sync_subjective_dimensions_display (merged helper) +# Tests: _display_reconcile_results # --------------------------------------------------------------------------- -class TestSyncSubjectiveDimensionsDisplay: +class TestDisplayReconcileResults: - def test_injects_ids(self): + def test_reports_subjective_injection(self, capsys): plan = empty_plan() - plan["queue_order"] = ["issue-1"] - - def mock_sync(p, s): - result = QueueSyncResult() - result.injected = ["subjective::naming"] - p["queue_order"].append("subjective::naming") - return result - - changed = reconcile_mod._sync_subjective_dimensions_display(plan, {}, mock_sync) - assert changed is True - assert "subjective::naming" in plan["queue_order"] + reconcile_mod._display_reconcile_results( + reconcile_mod.ReconcileResult( + subjective=QueueSyncResult(injected=["subjective::naming"]) + ), + plan, + mid_cycle=False, + ) + captured = capsys.readouterr() + assert "1 subjective" in captured.out def test_reports_resurfaced(self, capsys): plan = empty_plan() - - def mock_sync(p, s): - result = QueueSyncResult() - result.resurfaced = ["subjective::naming"] - return result - - reconcile_mod._sync_subjective_dimensions_display(plan, {}, mock_sync) + reconcile_mod._display_reconcile_results( + reconcile_mod.ReconcileResult( + subjective=QueueSyncResult(resurfaced=["subjective::naming"]) + ), + plan, + mid_cycle=False, + ) captured = capsys.readouterr() assert "resurfaced" in captured.out.lower() def test_reports_pruned(self, capsys): plan = empty_plan() + reconcile_mod._display_reconcile_results( + reconcile_mod.ReconcileResult( + subjective=QueueSyncResult(pruned=["subjective::naming"]) + ), + plan, + mid_cycle=False, + ) + captured = capsys.readouterr() + assert "refreshed" in captured.out.lower() or "removed" in captured.out.lower() - def mock_sync(p, s): - result = QueueSyncResult() - result.pruned = ["subjective::naming"] - return result + def test_reports_create_plan(self, capsys): + plan = empty_plan() + reconcile_mod._display_reconcile_results( + reconcile_mod.ReconcileResult( + create_plan=QueueSyncResult(injected=["workflow::create-plan"]) + ), + plan, + mid_cycle=False, + ) + captured = capsys.readouterr() + assert "create-plan" in captured.out - changed = reconcile_mod._sync_subjective_dimensions_display(plan, {}, mock_sync) - assert changed is True + def test_reports_mid_cycle_skip(self, capsys): + plan = empty_plan() + reconcile_mod._display_reconcile_results( + reconcile_mod.ReconcileResult(), + plan, + mid_cycle=True, + ) captured = capsys.readouterr() - assert "refreshed" in captured.out.lower() or "removed" in captured.out.lower() + assert "mid-cycle scan" in captured.out + + +# --------------------------------------------------------------------------- +# Tests: _is_mid_cycle_scan +# --------------------------------------------------------------------------- + +class TestIsMidCycleScan: - def test_reports_injected(self, capsys): + def test_false_when_cycle_not_active(self): plan = empty_plan() + state = _make_state() - def mock_sync(p, s): - result = QueueSyncResult() - result.injected = ["subjective::naming"] - return result + assert reconcile_mod._is_mid_cycle_scan(plan, state) is False - reconcile_mod._sync_subjective_dimensions_display(plan, {}, mock_sync) - captured = capsys.readouterr() - assert "1 subjective" in captured.out + def test_false_when_only_synthetic_or_skipped_items_remain(self): + plan = empty_plan() + plan["plan_start_scores"] = {"strict": 80.0} + plan["queue_order"] = [ + "workflow::communicate-score", + "issue-1", + ] + plan["skipped"] = { + "issue-1": { + "issue_id": "issue-1", + "kind": "temporary", + "skipped_at_scan": 1, + } + } + state = _make_state() - def test_no_change_when_nothing(self): + assert reconcile_mod._is_mid_cycle_scan(plan, state) is False + + def test_true_when_substantive_queue_item_remains(self): plan = empty_plan() - changed = reconcile_mod._sync_subjective_dimensions_display( - plan, {}, lambda p, s: QueueSyncResult(), - ) - assert changed is False + plan["plan_start_scores"] = {"strict": 80.0} + plan["queue_order"] = ["workflow::communicate-score", "issue-1"] + state = _make_state() + + assert reconcile_mod._is_mid_cycle_scan(plan, state) is True # --------------------------------------------------------------------------- @@ -341,5 +376,3 @@ def test_clears_when_queue_empty(self, monkeypatch): assert state["_plan_start_scores_for_reveal"]["strict"] == 70.0 log_actions = [e["action"] for e in plan["execution_log"]] assert "clear_start_scores" in log_actions - - diff --git a/desloppify/tests/commands/scan/test_scan_preflight.py b/desloppify/tests/commands/scan/test_scan_preflight.py index 08e7a6f8..d1fa4c6d 100644 --- a/desloppify/tests/commands/scan/test_scan_preflight.py +++ b/desloppify/tests/commands/scan/test_scan_preflight.py @@ -164,12 +164,46 @@ def test_queue_with_only_workflow_items_blocks_scan(): "desloppify.app.commands.scan.preflight.plan_aware_queue_breakdown", return_value=breakdown, ), + patch( + "desloppify.app.commands.scan.preflight._only_run_scan_workflow_remaining", + return_value=False, + ), pytest.raises(CommandError), ): mock_state_mod.load_state.return_value = {"issues": {}} scan_queue_preflight(args) +def test_queue_with_only_run_scan_workflow_allows_scan(): + """The synthetic workflow::run-scan item must not block scan execution.""" + from desloppify.app.commands.helpers.queue_progress import QueueBreakdown + + args = SimpleNamespace(profile=None, force_rescan=False, state=None, lang="python") + plan = {"plan_start_scores": {"strict": 80.0}} + breakdown = QueueBreakdown(queue_total=1, workflow=1) + with ( + patch( + "desloppify.app.commands.scan.preflight.resolve_plan_load_status", + return_value=_plan_status(plan), + ), + patch( + "desloppify.app.commands.scan.preflight.state_path", + return_value="/tmp/test-state.json", + ), + patch("desloppify.app.commands.scan.preflight.state_mod") as mock_state_mod, + patch( + "desloppify.app.commands.scan.preflight.plan_aware_queue_breakdown", + return_value=breakdown, + ), + patch( + "desloppify.app.commands.scan.preflight._only_run_scan_workflow_remaining", + return_value=True, + ), + ): + mock_state_mod.load_state.return_value = {"issues": {}} + scan_queue_preflight(args) + + # ── --force-rescan ────────────────────────────────────────── diff --git a/desloppify/tests/commands/test_cli.py b/desloppify/tests/commands/test_cli.py index a4487a2e..780fe619 100644 --- a/desloppify/tests/commands/test_cli.py +++ b/desloppify/tests/commands/test_cli.py @@ -879,17 +879,16 @@ class DummyCfg: assert "deno.json" in markers assert "custom.lock" in markers - def test_lang_config_markers_raises_for_broken_plugin(self, monkeypatch): + def test_lang_config_markers_skips_broken_plugin(self, monkeypatch): monkeypatch.setattr("desloppify.languages.framework.available_langs", lambda: ["dummy"]) monkeypatch.setattr( "desloppify.languages.framework.get_lang", lambda _name: (_ for _ in ()).throw(ImportError("broken plugin")), ) - with pytest.raises(lang_helpers_mod.LangResolutionError) as exc: - lang_helpers_mod._lang_config_markers() - - assert "failed to load" in str(exc.value) + markers = lang_helpers_mod._lang_config_markers() + assert "deno.json" not in markers + assert "custom.lock" not in markers def test_lang_config_markers_refresh_after_plugin_change(self, monkeypatch): class FirstCfg: diff --git a/desloppify/tests/commands/test_cmd_autofix.py b/desloppify/tests/commands/test_cmd_autofix.py index f7cce16f..860f79ae 100644 --- a/desloppify/tests/commands/test_cmd_autofix.py +++ b/desloppify/tests/commands/test_cmd_autofix.py @@ -80,9 +80,10 @@ class TestResolveFixerResults: """_resolve_fixer_results marks matching issues as fixed.""" def _make_state_with_issues(self, *issues): - state = {"issues": {}} + work_items: dict[str, dict] = {} + state = {"work_items": work_items, "issues": work_items} for fid, status in issues: - state["issues"][fid] = { + work_items[fid] = { "id": fid, "status": status, "detector": "unused", @@ -104,8 +105,8 @@ def test_resolves_matching_open_issues(self, monkeypatch): results = [{"file": "a.ts", "removed": ["foo"]}] resolved = _resolve_fixer_results(state, results, "unused", "unused-imports") assert resolved == ["unused::a.ts::foo"] - assert state["issues"]["unused::a.ts::foo"]["status"] == "fixed" - assert state["issues"]["unused::a.ts::bar"]["status"] == "open" + assert state["work_items"]["unused::a.ts::foo"]["status"] == "fixed" + assert state["work_items"]["unused::a.ts::bar"]["status"] == "open" def test_skips_already_fixed(self, monkeypatch): monkeypatch.setattr(fix_apply_mod, "rel", lambda p: p) @@ -131,7 +132,7 @@ def test_adds_auto_fix_note(self, monkeypatch): state = self._make_state_with_issues(("unused::a.ts::foo", "open")) results = [{"file": "a.ts", "removed": ["foo"]}] _resolve_fixer_results(state, results, "unused", "unused-imports") - note = state["issues"]["unused::a.ts::foo"]["note"] + note = state["work_items"]["unused::a.ts::foo"]["note"] assert "auto-fixed" in note assert "unused-imports" in note @@ -142,7 +143,7 @@ def test_multiple_files(self, monkeypatch): ("unused::a.ts::foo", "open"), ("unused::b.ts::bar", "open"), ) - state["issues"]["unused::b.ts::bar"]["file"] = "b.ts" + state["work_items"]["unused::b.ts::bar"]["file"] = "b.ts" results = [ {"file": "a.ts", "removed": ["foo"]}, diff --git a/desloppify/tests/commands/test_cmd_exclude.py b/desloppify/tests/commands/test_cmd_exclude.py index c3f7061c..9de519d1 100644 --- a/desloppify/tests/commands/test_cmd_exclude.py +++ b/desloppify/tests/commands/test_cmd_exclude.py @@ -91,8 +91,8 @@ def test_cmd_exclude_prunes_matching_issues_and_plan( exclude_mod.cmd_exclude(_args(".claude", runtime)) - assert removed_id not in state["issues"] - assert kept_id in state["issues"] + assert removed_id not in state["work_items"] + assert kept_id in state["work_items"] assert state["subjective_assessments"]["naming_quality"]["score"] == 81 assert saved_state["path"] == state_file diff --git a/desloppify/tests/commands/test_cmd_move.py b/desloppify/tests/commands/test_cmd_move.py index d2efb096..3ad2967d 100644 --- a/desloppify/tests/commands/test_cmd_move.py +++ b/desloppify/tests/commands/test_cmd_move.py @@ -88,7 +88,7 @@ def test_no_ext(self): def test_full_path(self): assert detect_lang_from_ext("/src/components/Button.tsx") == "typescript" - def test_raises_when_registered_plugin_fails_to_load(self, monkeypatch): + def test_skips_registered_plugin_when_metadata_load_fails(self, monkeypatch): import desloppify.app.commands.move.language as move_lang_mod move_lang_mod._ext_to_lang_map.cache_clear() @@ -99,14 +99,11 @@ def test_raises_when_registered_plugin_fails_to_load(self, monkeypatch): ) monkeypatch.setattr( move_lang_mod, - "load_lang_config", - lambda _name: (_ for _ in ()).throw(CommandError("broken plugin")), + "load_lang_config_metadata", + lambda _name: None, ) - with pytest.raises(CommandError) as exc: - detect_lang_from_ext("foo.py") - - assert "broken plugin" in str(exc.value) + assert detect_lang_from_ext("foo.py") is None move_lang_mod._ext_to_lang_map.cache_clear() @@ -287,7 +284,7 @@ def _fake_import(module_name: str): raise AssertionError(f"unexpected import request: {module_name}") monkeypatch.setattr( - "desloppify.app.commands.move.language.importlib.import_module", + "desloppify.app.commands.helpers.dynamic_loaders.importlib.import_module", _fake_import, ) assert load_lang_move_module("python") is scaffold @@ -303,7 +300,7 @@ def _fake_import(module_name: str): raise AssertionError(f"unexpected import request: {module_name}") monkeypatch.setattr( - "desloppify.app.commands.move.language.importlib.import_module", + "desloppify.app.commands.helpers.dynamic_loaders.importlib.import_module", _fake_import, ) diff --git a/desloppify/tests/commands/test_direct_coverage_modules.py b/desloppify/tests/commands/test_direct_coverage_modules.py index 0105c2d4..9a2f172c 100644 --- a/desloppify/tests/commands/test_direct_coverage_modules.py +++ b/desloppify/tests/commands/test_direct_coverage_modules.py @@ -80,8 +80,7 @@ import desloppify.languages.rust.move as rust_move_mod import desloppify.languages.rust.phases_smells as rust_phases_smells_mod import desloppify.languages.typescript.detectors.smells.detector_safety as ts_smell_detectors_safety -import desloppify.languages.typescript.detectors.smells.helpers_blocks as ts_smell_blocks_mod -import desloppify.languages.typescript.detectors.smells.helpers_line_state as ts_smell_line_state_mod +import desloppify.languages.typescript.detectors.smells.helpers as ts_smell_helpers_mod import desloppify.languages.typescript.detectors.deps.runtime as ts_deps_runtime import desloppify.languages.typescript.extractors_components as ts_extractors_components from desloppify.engine._work_queue.models import QueueBuildOptions, QueueVisibility @@ -432,15 +431,15 @@ def test_typescript_split_smell_helpers_have_direct_coverage(): " }", "}", ] - assert ts_smell_blocks_mod._track_brace_body(lines, 0) == 5 - body = ts_smell_blocks_mod._extract_block_body("if (ok) { keep(); }", 8) + assert ts_smell_helpers_mod._track_brace_body(lines, 0) == 5 + body = ts_smell_helpers_mod._extract_block_body("if (ok) { keep(); }", 8) assert body == " keep(); " - masked = ts_smell_blocks_mod._code_text('const x = "message"; // hi') + masked = ts_smell_helpers_mod._code_text('const x = "message"; // hi') assert "message" not in masked - assert ts_smell_line_state_mod._scan_template_content("x`${a}`", 1, 0)[1] is True - assert ts_smell_line_state_mod._scan_code_line("/* open comment") == (True, False, 0) - states = ts_smell_line_state_mod._build_ts_line_state( + assert ts_smell_helpers_mod._scan_template_content("x`${a}`", 1, 0)[1] is True + assert ts_smell_helpers_mod._scan_code_line("/* open comment") == (True, False, 0) + states = ts_smell_helpers_mod._build_ts_line_state( [ "const a = 1;", "/* block", diff --git a/desloppify/tests/commands/test_lifecycle_transitions.py b/desloppify/tests/commands/test_lifecycle_transitions.py index 567800c0..7f0dfbdb 100644 --- a/desloppify/tests/commands/test_lifecycle_transitions.py +++ b/desloppify/tests/commands/test_lifecycle_transitions.py @@ -1,7 +1,8 @@ """Integration tests for lifecycle transitions through reconcile → work queue. Exercises the full lifecycle by walking through each stage: - scan → initial reviews → communicate-score → create-plan → triage → objectives + scan → initial reviews → objectives → postflight scan → subjective review + → communicate-score/create-plan → triage Between scans, items are completed via ``purge_ids`` (what ``plan resolve`` does) and the queue is re-checked without reconciling. ``reconcile`` only @@ -77,8 +78,10 @@ def _build_state( overall_score: float | None = None, objective_score: float | None = None, ) -> dict: + work_items = {i["id"]: dict(i) for i in issues} state: dict = { - "issues": {i["id"]: dict(i) for i in issues}, + "work_items": work_items, + "issues": work_items, "scan_count": 1, "dimension_scores": {}, "subjective_assessments": {}, @@ -112,15 +115,15 @@ def _queue_ids(state: dict, plan: dict) -> list[str]: return [item["id"] for item in result["items"]] -def _spoof_reviews_complete(state: dict) -> None: +def _spoof_reviews_complete(state: dict, *, score: float = 100.0) -> None: """Mutate state in place: replace all placeholder dims with scored ones.""" for key in DIM_KEYS: - display, dim_entry, assessment = _scored_dim_entries(key, 75.0) + display, dim_entry, assessment = _scored_dim_entries(key, score) state["dimension_scores"][display] = dim_entry state["subjective_assessments"][key] = assessment - state["strict_score"] = 75.0 - state["overall_score"] = 75.0 - state["objective_score"] = 80.0 + state["strict_score"] = score + state["overall_score"] = score + state["objective_score"] = score def _complete_endgame_subjective_reruns(state: dict) -> None: @@ -137,9 +140,11 @@ def _complete_endgame_subjective_reruns(state: dict) -> None: def _add_review_issues(state: dict) -> None: """Mutate state in place: add review detector issues that trigger triage.""" + work_items = state.setdefault("work_items", state.get("issues", {})) + state["issues"] = work_items for key in ("naming_quality", "logic_clarity"): fid = f"review-{key}" - state["issues"][fid] = { + work_items[fid] = { "id": fid, "detector": "review", "file": "src/app.py", "status": "open", "detail": {"dimension": key}, } @@ -197,8 +202,9 @@ class TestScanAfterReviewsInjectsWorkflow: def test_workflow_items_injected_on_next_scan(self, monkeypatch): """Scan after completing reviews injects communicate-score and create-plan. - Workflow items are postflight — they only become visible after objectives - are drained. + Postflight is exclusive once the scan boundary is crossed: + score/workflow surfaces first, and only then does execute backlog + reappear. """ state = _build_state(OBJECTIVE_ISSUES, [_placeholder_dim_entries(k) for k in DIM_KEYS]) plan = _reconcile(state, empty_plan(), monkeypatch) @@ -211,35 +217,23 @@ def test_workflow_items_injected_on_next_scan(self, monkeypatch): # --- Next scan (reconcile) --- plan = _reconcile(state, plan, monkeypatch) - ids = _queue_ids(state, plan) - # Objective items visible while they exist - assert "obj-1" in ids - # Workflow items are postflight — gated behind objective items - assert WORKFLOW_COMMUNICATE_SCORE_ID not in ids - - # After completing objectives, postflight sequence begins. - # Subjective reruns come before workflow items in the lifecycle. - state["issues"]["obj-1"]["status"] = "fixed" - state["issues"]["obj-2"]["status"] = "fixed" - ids = _queue_ids(state, plan) - # Subjective reruns visible first (stale assessments) - assert any(fid.startswith("subjective::") for fid in ids), f"Expected subjective: {ids}" - - # After completing subjective reruns, workflow items become visible - _complete_endgame_subjective_reruns(state) ids = _queue_ids(state, plan) assert WORKFLOW_COMMUNICATE_SCORE_ID in ids assert WORKFLOW_CREATE_PLAN_ID in ids assert ids.index(WORKFLOW_COMMUNICATE_SCORE_ID) < ids.index(WORKFLOW_CREATE_PLAN_ID) + purge_ids(plan, [WORKFLOW_COMMUNICATE_SCORE_ID, WORKFLOW_CREATE_PLAN_ID]) + ids = _queue_ids(state, plan) + assert "obj-1" in ids and "obj-2" in ids + # --------------------------------------------------------------------------- -# Phase-order contract: subjective -> score -> triage (after objective drains) +# Phase-order contract: assessment -> score -> triage -> review (after objective drains) # --------------------------------------------------------------------------- class TestPhaseOrderInvariant: - def test_subjective_then_score_then_triage(self): + def test_assessment_then_score_when_no_review_followup(self): """Endgame queue order is fixed once objective backlog is drained.""" state = _build_state( [], @@ -258,19 +252,23 @@ def test_subjective_then_score_then_triage(self): # Mark postflight scan as done so it doesn't block plan["refresh_state"] = {"postflight_scan_completed_at_scan_count": 1} - # Subjective reruns must block score + triage until completed. + # Subjective follow-up surfaces before workflow items. ids = _queue_ids(state, plan) assert ids == ["subjective::naming_quality"] - # After subjective rerun completion, score workflow appears (triage gated behind it). + # After subjective follow-up completion, workflow appears. + ids = _queue_ids(state, plan) state["subjective_assessments"]["naming_quality"]["needs_review_refresh"] = False state["subjective_assessments"]["naming_quality"]["score"] = 100.0 state["dimension_scores"][DIM_DISPLAY["naming_quality"]]["score"] = 100.0 state["dimension_scores"][DIM_DISPLAY["naming_quality"]]["strict"] = 100.0 ids = _queue_ids(state, plan) - assert WORKFLOW_COMMUNICATE_SCORE_ID in ids - # Triage is gated behind workflow items in lifecycle - assert "triage::observe" not in ids + assert ids == [WORKFLOW_COMMUNICATE_SCORE_ID] + + # After workflow completion, triage becomes visible. + purge_ids(plan, [WORKFLOW_COMMUNICATE_SCORE_ID]) + ids = _queue_ids(state, plan) + assert ids == ["triage::observe"] # --------------------------------------------------------------------------- @@ -280,7 +278,7 @@ def test_subjective_then_score_then_triage(self): class TestTriageInjectedOnScan: def test_triage_after_review_issues_on_scan(self, monkeypatch): - """Scan with new review issues injects triage, but objectives stay unblocked.""" + """Review-driven triage waits behind score workflow after review import.""" state = _build_state(OBJECTIVE_ISSUES, [_placeholder_dim_entries(k) for k in DIM_KEYS]) plan = _reconcile(state, empty_plan(), monkeypatch) @@ -295,35 +293,27 @@ def test_triage_after_review_issues_on_scan(self, monkeypatch): ids = _queue_ids(state, plan) assert not any(fid.startswith("triage::") for fid in ids), ids - assert "obj-1" in ids and "obj-2" in ids + assert WORKFLOW_COMMUNICATE_SCORE_ID in ids + assert WORKFLOW_CREATE_PLAN_ID in ids # Triage stages are still injected in plan order. assert all(sid in plan["queue_order"] for sid in TRIAGE_STAGE_IDS) - # Once objective queue drains, lifecycle enters postflight. - # Review issues are non-objective, but they stay behind triage. - state["issues"]["obj-1"]["status"] = "fixed" - state["issues"]["obj-2"]["status"] = "fixed" - ids = _queue_ids(state, plan) - # Subjective reruns surface first. - assert any(fid.startswith("subjective::") for fid in ids), ids - - # After subjective reruns complete, workflow items surface before triage. - _complete_endgame_subjective_reruns(state) - ids = _queue_ids(state, plan) - workflow_ids = [fid for fid in ids if fid.startswith("workflow::")] - assert len(workflow_ids) > 0, f"Expected workflow items: {ids}" - - # After completing workflow items, triage becomes visible. - purge_ids(plan, workflow_ids) + # After completing workflow items, triage becomes visible before execute resumes. + purge_ids(plan, [WORKFLOW_COMMUNICATE_SCORE_ID, WORKFLOW_CREATE_PLAN_ID]) ids = _queue_ids(state, plan) triage_ids = [fid for fid in ids if fid.startswith("triage::")] assert len(triage_ids) == len(TRIAGE_STAGE_IDS), ids # After triage completes for the live review issue set, the findings surface. - plan.setdefault("epic_triage_meta", {})["triaged_ids"] = sorted( - fid for fid in state["issues"] if state["issues"][fid].get("detector") == "review" + triage_meta = plan.setdefault("epic_triage_meta", {}) + triage_meta["triaged_ids"] = sorted( + fid for fid in state["work_items"] if state["work_items"][fid].get("detector") == "review" ) + triage_meta["triage_stages"] = { + stage_id.removeprefix("triage::"): {"confirmed_at": "2026-03-13T00:00:00+00:00"} + for stage_id in TRIAGE_STAGE_IDS + } purge_ids(plan, TRIAGE_STAGE_IDS) ids = _queue_ids(state, plan) review_ids = [fid for fid in ids if fid.startswith("review")] @@ -356,34 +346,23 @@ def test_golden_path(self, monkeypatch): assert "obj-1" in ids, f"Post-reviews (no scan): {ids}" assert WORKFLOW_COMMUNICATE_SCORE_ID not in ids, f"Post-reviews (no scan): {ids}" - # ── Scan 2: workflow items injected but gated behind objectives ── + # ── Scan 2: score workflow surfaces before execute resumes ── plan = _reconcile(state, plan, monkeypatch) ids = _queue_ids(state, plan) - # Workflow items are postflight — only visible after objectives drain - assert "obj-1" in ids, f"Scan 2: {ids}" - assert WORKFLOW_COMMUNICATE_SCORE_ID not in ids, f"Scan 2: {ids}" - - # ── Between scans: complete objectives to unlock postflight ── - state["issues"]["obj-1"]["status"] = "fixed" - state["issues"]["obj-2"]["status"] = "fixed" - ids = _queue_ids(state, plan) - # Subjective reruns come first in postflight - assert any(fid.startswith("subjective::") for fid in ids), f"Post-objectives: {ids}" + assert WORKFLOW_COMMUNICATE_SCORE_ID in ids, f"Scan 2: {ids}" + assert WORKFLOW_CREATE_PLAN_ID in ids, f"Scan 2: {ids}" - # ── Complete subjective reruns to unlock workflow ── - _complete_endgame_subjective_reruns(state) - ids = _queue_ids(state, plan) - assert WORKFLOW_COMMUNICATE_SCORE_ID in ids, f"Post-subjective: {ids}" - assert WORKFLOW_CREATE_PLAN_ID in ids, f"Post-subjective: {ids}" - - # ── Complete workflow items ── + # ── Complete workflow items; clear cycle baseline so execute resumes ── purge_ids(plan, [WORKFLOW_COMMUNICATE_SCORE_ID, WORKFLOW_CREATE_PLAN_ID]) + plan["plan_start_scores"] = {} ids = _queue_ids(state, plan) - assert not any(fid.startswith("workflow::") for fid in ids), f"Post-workflow: {ids}" + assert "obj-1" in ids and "obj-2" in ids, f"Post-workflow: {ids}" # ── Scan 3: add review issues + reopen objectives for mid-cycle test ── - state["issues"]["obj-1"]["status"] = "open" - state["issues"]["obj-2"]["status"] = "open" + state["work_items"]["obj-1"]["status"] = "open" + state["work_items"]["obj-2"]["status"] = "open" + # Place objectives in queue_order so the plan is mid-cycle (non-empty). + plan["queue_order"] = ["obj-1", "obj-2"] _add_review_issues(state) plan = _reconcile(state, plan, monkeypatch) ids = _queue_ids(state, plan) @@ -392,13 +371,12 @@ def test_golden_path(self, monkeypatch): assert not any(sid in plan["queue_order"] for sid in TRIAGE_STAGE_IDS), ( f"Triage should be deferred mid-cycle: {plan['queue_order']}" ) - assert plan["epic_triage_meta"].get("triage_recommended"), "triage_recommended flag expected" assert "obj-1" in ids and "obj-2" in ids, f"Scan 3: {ids}" # ── Complete objective queue → rescan injects triage in plan, but # postflight still starts with workflow items ── - state["issues"]["obj-1"]["status"] = "fixed" - state["issues"]["obj-2"]["status"] = "fixed" + state["work_items"]["obj-1"]["status"] = "fixed" + state["work_items"]["obj-2"]["status"] = "fixed" plan = _reconcile(state, plan, monkeypatch) ids = _queue_ids(state, plan) workflow_ids = [fid for fid in ids if fid.startswith("workflow::")] @@ -414,9 +392,14 @@ def test_golden_path(self, monkeypatch): ) # ── Complete triage → review findings finally become executable ── - plan.setdefault("epic_triage_meta", {})["triaged_ids"] = sorted( - fid for fid in state["issues"] if state["issues"][fid].get("detector") == "review" + triage_meta = plan.setdefault("epic_triage_meta", {}) + triage_meta["triaged_ids"] = sorted( + fid for fid in state["work_items"] if state["work_items"][fid].get("detector") == "review" ) + triage_meta["triage_stages"] = { + stage_id.removeprefix("triage::"): {"confirmed_at": "2026-03-13T00:00:00+00:00"} + for stage_id in TRIAGE_STAGE_IDS + } purge_ids(plan, list(TRIAGE_STAGE_IDS)) ids = _queue_ids(state, plan) assert not any(fid.startswith("triage::") for fid in ids), f"Post-triage: {ids}" diff --git a/desloppify/tests/commands/test_next_queue_flow_direct.py b/desloppify/tests/commands/test_next_queue_flow_direct.py index 20bf8eef..6dadb559 100644 --- a/desloppify/tests/commands/test_next_queue_flow_direct.py +++ b/desloppify/tests/commands/test_next_queue_flow_direct.py @@ -273,10 +273,9 @@ def test_build_and_render_backlog_queue_uses_real_backlog_policy(capsys) -> None ) out = capsys.readouterr().out - assert "Run post-flight scan" in out + assert "Unplanned issue" in out assert "Planned issue" not in out - assert "Unplanned issue" not in out - assert written[0]["items"][0]["id"] == "workflow::run-scan" + assert written[0]["items"][0]["id"] == unplanned["id"] def test_build_and_render_backlog_queue_hides_execution_prompt(capsys) -> None: diff --git a/desloppify/tests/commands/test_next_render.py b/desloppify/tests/commands/test_next_render.py index 4274e4f2..61304e57 100644 --- a/desloppify/tests/commands/test_next_render.py +++ b/desloppify/tests/commands/test_next_render.py @@ -63,7 +63,7 @@ def _cluster_item( def _workflow_stage_item( *, id: str = "triage::observe", - summary: str = "Observe patterns in review issues", + summary: str = "Observe patterns in review work items", stage_name: str = "observe", is_blocked: bool = False, blocked_by: list[str] | None = None, @@ -272,8 +272,9 @@ def test_render_auto_fix_type_label(monkeypatch, capsys) -> None: monkeypatch.setattr(render_mod, "colorize", lambda t, _s: t) item = _issue_item( - primary_command="desloppify autofix unused_import --dry-run", + primary_command='desloppify plan resolve "unused_import::a" --note "done" --confirm', detector="unused_import", + action_type="auto_fix", ) render_mod.render_terminal_items( [item], {}, {}, group="item", explain=False, @@ -431,7 +432,7 @@ def test_render_workflow_stage_with_review_issues(monkeypatch, capsys) -> None: [item], {}, {}, group="item", explain=False, ) out = capsys.readouterr().out - assert "12 review issues" in out + assert "12 review work items" in out def test_render_workflow_action(monkeypatch, capsys) -> None: diff --git a/desloppify/tests/commands/test_postflight_lifecycle_integration.py b/desloppify/tests/commands/test_postflight_lifecycle_integration.py new file mode 100644 index 00000000..54b8e7f4 --- /dev/null +++ b/desloppify/tests/commands/test_postflight_lifecycle_integration.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from desloppify.base.subjective_dimensions import DISPLAY_NAMES +from desloppify.engine._plan.operations.lifecycle import purge_ids +from desloppify.engine._plan.refresh_lifecycle import ( + LIFECYCLE_PHASE_REVIEW_INITIAL, + LIFECYCLE_PHASE_WORKFLOW_POSTFLIGHT, + current_lifecycle_phase, + mark_postflight_scan_completed, +) +from desloppify.engine._plan.schema import empty_plan +from desloppify.engine._plan.sync import reconcile_plan +from desloppify.engine._work_queue.snapshot import ( + PHASE_REVIEW_INITIAL, + PHASE_WORKFLOW_POSTFLIGHT, + build_queue_snapshot, +) + + +def _placeholder_state() -> dict: + display = DISPLAY_NAMES["naming_quality"] + return { + "issues": {}, + "work_items": {}, + "dimension_scores": { + display: { + "score": 0, + "strict": 0, + "failing": 0, + "checks": 0, + "detectors": { + "subjective_assessment": { + "placeholder": True, + "dimension_key": "naming_quality", + } + }, + } + }, + "subjective_assessments": { + "naming_quality": { + "score": 0, + "placeholder": True, + } + }, + "assessment_import_audit": [], + } + + +def _mark_review_complete(state: dict) -> None: + display = DISPLAY_NAMES["naming_quality"] + state["dimension_scores"][display] = { + "score": 88.0, + "strict": 88.0, + "failing": 0, + "checks": 1, + "detectors": { + "subjective_assessment": { + "placeholder": False, + "dimension_key": "naming_quality", + } + }, + } + state["subjective_assessments"]["naming_quality"] = { + "score": 88.0, + "placeholder": False, + "assessed_at": "2026-03-13T12:00:00+00:00", + } + state["issues"]["unused::src/app.ts::x"] = { + "id": "unused::src/app.ts::x", + "detector": "unused", + "status": "open", + "file": "src/app.ts", + "tier": 1, + "confidence": "high", + "summary": "unused import", + "detail": {}, + } + state["work_items"] = state["issues"] + + +def test_postflight_progresses_review_then_workflow() -> None: + state = _placeholder_state() + plan = empty_plan() + + reconcile_plan(plan, state, target_strict=95.0) + initial_snapshot = build_queue_snapshot(state, plan=plan) + + assert current_lifecycle_phase(plan) == LIFECYCLE_PHASE_REVIEW_INITIAL + assert initial_snapshot.phase == PHASE_REVIEW_INITIAL + assert [item["id"] for item in initial_snapshot.execution_items] == [ + "subjective::naming_quality" + ] + + _mark_review_complete(state) + purge_ids(plan, ["subjective::naming_quality"]) + mark_postflight_scan_completed(plan, scan_count=1) + reconcile_plan(plan, state, target_strict=95.0) + workflow_snapshot = build_queue_snapshot(state, plan=plan) + + assert current_lifecycle_phase(plan) == LIFECYCLE_PHASE_WORKFLOW_POSTFLIGHT + assert workflow_snapshot.phase == PHASE_WORKFLOW_POSTFLIGHT + assert not any(fid.startswith("subjective::") for fid in plan["queue_order"]) + assert all(item["id"].startswith("workflow::") for item in workflow_snapshot.execution_items) diff --git a/desloppify/tests/commands/test_queue_count_consistency.py b/desloppify/tests/commands/test_queue_count_consistency.py index 6cb64dfa..13f6786e 100644 --- a/desloppify/tests/commands/test_queue_count_consistency.py +++ b/desloppify/tests/commands/test_queue_count_consistency.py @@ -38,8 +38,10 @@ def _issue( def _state_with_issues(*issues: dict) -> dict: + work_items = {f["id"]: f for f in issues} return { - "issues": {f["id"]: f for f in issues}, + "work_items": work_items, + "issues": work_items, "scan_count": 5, } @@ -581,19 +583,21 @@ class TestClusterFocusDoesNotTriggerRunScan: def _make_state_and_plan(self): """Two open issues, one in cluster 'auth', one outside.""" - state: dict = { - "issues": { - "f1": { - "id": "f1", "detector": "unused", "status": "open", - "file": "src/auth.ts", "tier": 1, "confidence": "high", - "summary": "in cluster", "detail": {}, - }, - "f2": { - "id": "f2", "detector": "unused", "status": "open", - "file": "src/utils.ts", "tier": 1, "confidence": "high", - "summary": "outside cluster", "detail": {}, - }, + work_items = { + "f1": { + "id": "f1", "detector": "unused", "status": "open", + "file": "src/auth.ts", "tier": 1, "confidence": "high", + "summary": "in cluster", "detail": {}, + }, + "f2": { + "id": "f2", "detector": "unused", "status": "open", + "file": "src/utils.ts", "tier": 1, "confidence": "high", + "summary": "outside cluster", "detail": {}, }, + } + state: dict = { + "work_items": work_items, + "issues": work_items, "scan_count": 5, } plan = { @@ -632,7 +636,7 @@ def test_no_run_scan_when_items_exist_outside_cluster(self): ) state, plan = self._make_state_and_plan() - state["issues"]["f1"]["status"] = "resolved" + state["work_items"]["f1"]["status"] = "resolved" result = build_work_queue( state, options=QueueBuildOptions(status="open", count=None, plan=plan), @@ -651,7 +655,7 @@ def test_breakdown_not_affected_by_active_cluster(self): ) state, plan = self._make_state_and_plan() - state["issues"]["f1"]["status"] = "resolved" + state["work_items"]["f1"]["status"] = "resolved" breakdown = plan_aware_queue_breakdown(state, plan=plan) assert breakdown.objective_actionable >= 1, ( "active_cluster must not hide items from lifecycle breakdown" @@ -669,8 +673,8 @@ def test_run_scan_injected_when_globally_empty(self): ) state, plan = self._make_state_and_plan() - state["issues"]["f1"]["status"] = "resolved" - state["issues"]["f2"]["status"] = "resolved" + state["work_items"]["f1"]["status"] = "resolved" + state["work_items"]["f2"]["status"] = "resolved" result = build_work_queue( state, options=QueueBuildOptions(status="open", count=None, plan=plan), diff --git a/desloppify/tests/commands/test_queue_order_guard.py b/desloppify/tests/commands/test_queue_order_guard.py index 7f627f5a..5ca38898 100644 --- a/desloppify/tests/commands/test_queue_order_guard.py +++ b/desloppify/tests/commands/test_queue_order_guard.py @@ -58,9 +58,9 @@ def _get_guard_fn(): # --------------------------------------------------------------------------- def _state_with_issues(*ids: str) -> dict: - issues = {} + work_items = {} for fid in ids: - issues[fid] = { + work_items[fid] = { "id": fid, "status": "open", "detector": "unused", @@ -69,7 +69,7 @@ def _state_with_issues(*ids: str) -> dict: "confidence": "high", "summary": f"Issue {fid}", } - return {"issues": issues, "scan_count": 5} + return {"work_items": work_items, "issues": work_items, "scan_count": 5} def _setup_plan(tmp_path, monkeypatch, queue_order: list[str], clusters: dict | None = None): @@ -224,7 +224,7 @@ def test_guard_skips_resolved_ids_in_queue_order(tmp_path, monkeypatch): """Resolved (non-open) IDs at the front of queue_order should not block.""" state = _state_with_issues("a", "b") # Mark "a" as fixed — it's still in state but no longer open - state["issues"]["a"]["status"] = "fixed" + state["work_items"]["a"]["status"] = "fixed" _setup_plan(tmp_path, monkeypatch, ["a", "b"]) # "b" should be next since "a" is resolved diff --git a/desloppify/tests/commands/test_transitive_engine.py b/desloppify/tests/commands/test_transitive_engine.py index c56df7dc..50652577 100644 --- a/desloppify/tests/commands/test_transitive_engine.py +++ b/desloppify/tests/commands/test_transitive_engine.py @@ -131,14 +131,15 @@ def test_merge_new_issues(self, mock_recompute): diff = merge_scan(state, issues, MergeScanOptions(lang="python")) assert diff["new"] == 1 assert diff["total_current"] == 1 - assert "smells::foo.py::debug_tag" in state["issues"] + assert "smells::foo.py::debug_tag" in state["work_items"] @patch.object(merge_mod, "_recompute_stats") - def test_merge_keeps_disappeared_open_issue_open(self, mock_recompute): - """Old open issues not in current scan stay open until manually resolved.""" + def test_merge_keeps_disappeared_open_issue_when_file_exists(self, mock_recompute, tmp_path): + """Open issues whose file still exists stay open even if absent from scan.""" mock_recompute.return_value = None + (tmp_path / "old.py").write_text("# still here") state = self._make_state() - state["issues"]["smells::old.py::leftover"] = { + state["work_items"]["smells::old.py::leftover"] = { "id": "smells::old.py::leftover", "detector": "smells", "file": "old.py", @@ -155,10 +156,40 @@ def test_merge_keeps_disappeared_open_issue_open(self, mock_recompute): } state["stats"]["open"] = 1 diff = merge_scan( - state, [], MergeScanOptions(lang="python", force_resolve=True) + state, [], MergeScanOptions(lang="python", force_resolve=True, project_root=str(tmp_path)) ) assert diff["auto_resolved"] == 0 - assert state["issues"]["smells::old.py::leftover"]["status"] == "open" + assert state["work_items"]["smells::old.py::leftover"]["status"] == "open" + + @patch.object(merge_mod, "_recompute_stats") + def test_merge_auto_resolves_issue_when_file_deleted(self, mock_recompute, tmp_path): + """Open issues for files that no longer exist on disk are auto-resolved.""" + mock_recompute.return_value = None + # tmp_path exists but old.py does NOT — simulates a deleted file + state = self._make_state() + state["work_items"]["smells::old.py::leftover"] = { + "id": "smells::old.py::leftover", + "detector": "smells", + "file": "old.py", + "tier": 2, + "confidence": "high", + "summary": "Old issue", + "detail": {}, + "status": "open", + "note": None, + "first_seen": "2026-01-01T00:00:00+00:00", + "last_seen": "2026-01-01T00:00:00+00:00", + "resolved_at": None, + "reopen_count": 0, + } + state["stats"]["open"] = 1 + diff = merge_scan( + state, [], MergeScanOptions(lang="python", force_resolve=True, project_root=str(tmp_path)) + ) + assert diff["auto_resolved"] == 1 + item = state["work_items"]["smells::old.py::leftover"] + assert item["status"] == "auto_resolved" + assert "no longer exists" in item["note"] @patch.object(merge_mod, "_recompute_stats") def test_merge_with_ignore_patterns(self, mock_recompute): @@ -190,7 +221,7 @@ def test_merge_with_ignore_patterns(self, mock_recompute): assert diff["ignored"] == 1 assert diff["raw_issues"] == 1 # Issue is inserted but suppressed: - f = state["issues"]["smells::vendor/lib.py::debug"] + f = state["work_items"]["smells::vendor/lib.py::debug"] assert f["suppressed"] is True @patch.object(merge_mod, "_recompute_stats") @@ -496,19 +527,19 @@ def test_config_show(self): class TestFixerHelpLines: - @patch("desloppify.app.cli_support.parser_groups_admin.get_lang") - def test_fixer_help_lines_with_fixers(self, mock_get_lang): + @patch("desloppify.app.cli_support.parser_groups_admin.load_lang_config") + def test_fixer_help_lines_with_fixers(self, mock_load_lang_config): mock_lang = MagicMock() mock_lang.fixers = {"unused": MagicMock(), "logs": MagicMock()} - mock_get_lang.return_value = mock_lang + mock_load_lang_config.return_value = mock_lang lines = parser_admin_mod._fixer_help_lines(["python"]) assert len(lines) == 1 # one lang line assert "logs, unused" in lines[0] - @patch("desloppify.app.cli_support.parser_groups_admin.get_lang") - def test_fixer_help_lines_import_error(self, mock_get_lang): - mock_get_lang.side_effect = ImportError("no such lang") + @patch("desloppify.app.cli_support.parser_groups_admin.load_lang_config") + def test_fixer_help_lines_import_error(self, mock_load_lang_config): + mock_load_lang_config.side_effect = ImportError("no such lang") lines = parser_admin_mod._fixer_help_lines(["bogus"]) assert "failed to load" in lines[0] @@ -519,9 +550,9 @@ def test_fix_parser_args(self): parser = argparse.ArgumentParser() sub = parser.add_subparsers(dest="command") with patch( - "desloppify.app.cli_support.parser_groups_admin.get_lang" - ) as mock_get_lang: - mock_get_lang.side_effect = ImportError() + "desloppify.app.cli_support.parser_groups_admin.load_lang_config" + ) as mock_load: + mock_load.side_effect = ImportError() parser_admin_mod._add_autofix_parser(sub, ["python"]) args = parser.parse_args( diff --git a/desloppify/tests/core/test_hook_registry.py b/desloppify/tests/core/test_hook_registry.py index 1c83e1d4..1edb53ba 100644 --- a/desloppify/tests/core/test_hook_registry.py +++ b/desloppify/tests/core/test_hook_registry.py @@ -54,11 +54,13 @@ def _fake_import_module(name: str, package: str | None = None): monkeypatch.setattr(registry_mod.importlib, "import_module", _fake_import_module) + # First attempt fails (transient import error), hook returns None. assert get_lang_hook("retrylang", "test_coverage") is None + # Second attempt succeeds via lazy bootstrap. assert get_lang_hook("retrylang", "test_coverage") is sentinel -def test_get_lang_hook_bootstraps_module_register_entrypoint(monkeypatch) -> None: +def test_register_lang_hooks_supports_module_register_entrypoint(monkeypatch) -> None: clear_lang_hooks_for_tests() sentinel = object() real_import_module = importlib.import_module @@ -77,5 +79,5 @@ def _fake_import_module(name: str, package: str | None = None): monkeypatch.setattr(registry_mod.importlib, "import_module", _fake_import_module) + # Lazy bootstrap should call register() which registers the hook. assert get_lang_hook("bootstraplang", "test_coverage") is sentinel - diff --git a/desloppify/tests/engine/test_planning_public_contract_direct.py b/desloppify/tests/engine/test_planning_public_contract_direct.py index 8544e0da..13e6ba95 100644 --- a/desloppify/tests/engine/test_planning_public_contract_direct.py +++ b/desloppify/tests/engine/test_planning_public_contract_direct.py @@ -17,7 +17,7 @@ def test_runtime_modules_use_canonical_state_and_plan_surfaces() -> None: ["from desloppify.state_io import StateModel, save_state"], ["from desloppify.state import"], ), - "app/commands/plan/override_io.py": ( + "app/commands/plan/override/io.py": ( ["from desloppify.state_io import StateModel, get_state_file, save_state"], ["from desloppify import state as state_mod"], ), diff --git a/desloppify/tests/engine/test_queue_context.py b/desloppify/tests/engine/test_queue_context.py index 56c25212..9c732c4c 100644 --- a/desloppify/tests/engine/test_queue_context.py +++ b/desloppify/tests/engine/test_queue_context.py @@ -2,6 +2,7 @@ from __future__ import annotations +from types import SimpleNamespace from unittest.mock import patch import pytest @@ -62,11 +63,13 @@ def test_auto_load_plan_from_disk(self): """Default sentinel triggers load_plan().""" fake_plan = {"queue_order": ["f1"]} with patch( - "desloppify.engine._work_queue.context.has_living_plan", - return_value=True, - ), patch( - "desloppify.engine._work_queue.context.load_plan", - return_value=fake_plan, + "desloppify.engine._work_queue.context.resolve_persisted_plan_load_status", + return_value=SimpleNamespace( + plan=fake_plan, + degraded=False, + error_kind=None, + recovery=None, + ), ): ctx = queue_context(_minimal_state()) assert ctx.plan is fake_plan @@ -76,11 +79,13 @@ def test_auto_load_plan_from_disk(self): def test_auto_load_plan_handles_failure(self): """When load_plan() raises, plan is None.""" with patch( - "desloppify.engine._work_queue.context.has_living_plan", - return_value=True, - ), patch( - "desloppify.engine._work_queue.context.load_plan", - side_effect=OSError("no plan file"), + "desloppify.engine._work_queue.context.resolve_persisted_plan_load_status", + return_value=SimpleNamespace( + plan=None, + degraded=True, + error_kind="OSError", + recovery="fresh_start", + ), ): ctx = queue_context(_minimal_state()) assert ctx.plan is None @@ -90,8 +95,13 @@ def test_auto_load_plan_handles_failure(self): def test_auto_load_without_plan_file_not_degraded(self): """Missing living plan is not treated as degraded mode.""" with patch( - "desloppify.engine._work_queue.context.has_living_plan", - return_value=False, + "desloppify.engine._work_queue.context.resolve_persisted_plan_load_status", + return_value=SimpleNamespace( + plan=None, + degraded=False, + error_kind=None, + recovery=None, + ), ): ctx = queue_context(_minimal_state()) assert ctx.plan is None diff --git a/desloppify/tests/engine/test_sync_split_modules_direct.py b/desloppify/tests/engine/test_sync_split_modules_direct.py index e1f1b323..58f3a87a 100644 --- a/desloppify/tests/engine/test_sync_split_modules_direct.py +++ b/desloppify/tests/engine/test_sync_split_modules_direct.py @@ -6,7 +6,9 @@ import desloppify.engine._plan.auto_cluster_sync_issue as auto_cluster_sync_mod import desloppify.engine._plan.constants as plan_constants_mod -import desloppify.engine._plan.reconcile_review_import as reconcile_import_mod +import desloppify.engine._plan.sync as sync_pkg_mod +import desloppify.engine._plan.scan_issue_reconcile as scan_reconcile_mod +import desloppify.engine._plan.sync.review_import as reconcile_import_mod import desloppify.engine._plan.schema.helpers as schema_helpers_mod import desloppify.engine._plan.sync.auto_prune as sync_auto_prune_mod import desloppify.engine._plan.sync.context as sync_context_mod @@ -16,6 +18,7 @@ import desloppify.engine._plan.triage.playbook as triage_playbook_mod import desloppify.engine._scoring.state_integration_subjective as scoring_subjective_mod import desloppify.engine._work_queue.snapshot as snapshot_mod +import desloppify.engine._work_queue.synthetic as synthetic_mod def test_sync_context_helpers_cover_policy_and_fallback_paths() -> None: @@ -229,6 +232,21 @@ def test_reconcile_review_import_prunes_stale_triage_recovery_metadata( assert plan["epic_triage_meta"]["undispositioned_issue_count"] == 1 +def test_reconcile_module_exports_scan_reconcile_only() -> None: + assert scan_reconcile_mod.__all__ == [ + "ReconcileResult", + "reconcile_plan_after_scan", + ] + assert not hasattr(scan_reconcile_mod, "sync_plan_after_review_import") + assert not hasattr(scan_reconcile_mod, "ReviewImportSyncResult") + + +def test_sync_package_includes_review_import_subdomain() -> None: + assert "review_import" in sync_pkg_mod.__all__ + assert hasattr(reconcile_import_mod, "sync_plan_after_review_import") + assert hasattr(reconcile_import_mod, "ReviewImportSyncResult") + + def test_schema_migration_helpers_cover_legacy_cleanup() -> None: assert ( schema_helpers_mod._has_synthesis_artifacts( @@ -476,8 +494,7 @@ def test_sync_workflow_helpers_inject_expected_items(monkeypatch) -> None: r4 = sync_workflow_mod.sync_communicate_score_needed( plan, state, - policy=SimpleNamespace(unscored_ids={"subjective::x"}, has_objective_backlog=True), - scores_just_imported=True, + policy=SimpleNamespace(unscored_ids=set(), has_objective_backlog=True), ) assert r4.injected == ["workflow::communicate-score"] @@ -584,6 +601,7 @@ def test_pending_import_scores_meta_ignores_malformed_refresh_state() -> None: "timestamp": "2026-03-10T10:00:00+00:00", "mode": "issues_only", "import_file": "/tmp/review.json", + "packet_sha256": "hash-from-audit", } ] } @@ -592,9 +610,10 @@ def test_pending_import_scores_meta_ignores_malformed_refresh_state() -> None: assert meta is not None assert meta.import_file == "/tmp/review.json" + assert meta.packet_sha256 == "hash-from-audit" -def test_sync_communicate_score_reinjects_after_trusted_score_import() -> None: +def test_sync_communicate_score_reinjects_after_trusted_score_import_when_sentinel_cleared() -> None: plan = { "queue_order": ["triage::observe"], "plan_start_scores": { @@ -603,13 +622,11 @@ def test_sync_communicate_score_reinjects_after_trusted_score_import() -> None: "objective": 80.0, "verified": 80.0, }, - "previous_plan_start_scores": {"strict": 65.0}, } result = sync_workflow_mod.sync_communicate_score_needed( plan, state={"issues": {}}, - scores_just_imported=True, current_scores=sync_workflow_mod.ScoreSnapshot( strict=74.5, overall=74.5, @@ -710,14 +727,148 @@ def test_queue_snapshot_enforces_phase_boundaries() -> None: }, plan={"queue_order": ["triage::observe"], "plan_start_scores": {"strict": 75.0}}, ) - assert execute.phase == snapshot_mod.PHASE_EXECUTE - assert [item["id"] for item in execute.execution_items] == ["unused::a"] + assert execute.phase == snapshot_mod.PHASE_SCAN + assert [item["id"] for item in execute.execution_items] == ["workflow::run-scan"] backlog_ids = {item["id"] for item in execute.backlog_items} assert "triage::observe" in backlog_ids - assert "subjective::naming_quality" in backlog_ids + assert "unused::a" in backlog_ids + + +def test_queue_snapshot_keeps_executing_real_queue_items_before_postflight_scan() -> None: + state = { + "issues": { + "unused::a": { + "id": "unused::a", + "detector": "unused", + "status": "open", + "file": "src/a.py", + "tier": 1, + "confidence": "high", + "summary": "unused import", + "detail": {}, + } + } + } + plan = { + "queue_order": ["unused::a", "workflow::communicate-score", "triage::observe"], + "plan_start_scores": {"strict": 80.0}, + "refresh_state": {"lifecycle_phase": "execute"}, + } + snapshot = snapshot_mod.build_queue_snapshot(state, plan=plan) -def test_queue_snapshot_orders_scan_review_and_workflow_postflight() -> None: + assert snapshot.phase == snapshot_mod.PHASE_EXECUTE + assert [item["id"] for item in snapshot.execution_items] == ["unused::a"] + + +def test_queue_snapshot_does_not_execute_autofix_cluster_without_queue_ownership() -> None: + state = { + "issues": { + "unused::a": { + "id": "unused::a", + "detector": "unused", + "status": "open", + "file": "src/a.py", + "tier": 1, + "confidence": "high", + "summary": "unused import", + "detail": {}, + } + } + } + plan = { + "queue_order": [], + "clusters": { + "auto/unused": { + "issue_ids": ["unused::a"], + "auto": True, + "action": "desloppify autofix unused-imports --dry-run", + "action_type": "auto_fix", + "execution_policy": "ephemeral_autopromote", + } + }, + "plan_start_scores": {"strict": 80.0}, + } + + snapshot = snapshot_mod.build_queue_snapshot(state, plan=plan) + + assert snapshot.phase == snapshot_mod.PHASE_SCAN + assert "unused::a" not in [item["id"] for item in snapshot.execution_items] + assert "unused::a" in {item["id"] for item in snapshot.backlog_items} + + +def test_queue_snapshot_legacy_autofix_cluster_stays_backlog_without_queue_ownership() -> None: + state = { + "issues": { + "unused::a": { + "id": "unused::a", + "detector": "unused", + "status": "open", + "file": "src/a.py", + "tier": 1, + "confidence": "high", + "summary": "unused import", + "detail": {}, + } + } + } + plan = { + "queue_order": [], + "clusters": { + "auto/unused": { + "name": "auto/unused", + "issue_ids": ["unused::a"], + "auto": True, + "action": "desloppify autofix unused-imports --dry-run", + } + }, + "plan_start_scores": {"strict": 80.0}, + } + + snapshot = snapshot_mod.build_queue_snapshot(state, plan=plan) + + assert snapshot.phase == snapshot_mod.PHASE_SCAN + assert "unused::a" not in [item["id"] for item in snapshot.execution_items] + assert "unused::a" in {item["id"] for item in snapshot.backlog_items} + + +def test_queue_snapshot_non_autofix_auto_cluster_does_not_execute_without_queueing() -> None: + state = { + "issues": { + "dict_keys::a": { + "id": "dict_keys::a", + "detector": "dict_keys", + "status": "open", + "file": "src/a.py", + "tier": 1, + "confidence": "high", + "summary": "dict key mismatch", + "detail": {}, + } + } + } + plan = { + "queue_order": [], + "clusters": { + "auto/dict_keys": { + "issue_ids": ["dict_keys::a"], + "auto": True, + "action": "review and refactor each issue", + "action_type": "refactor", + "execution_policy": "planned_only", + } + }, + "plan_start_scores": {"strict": 80.0}, + } + + snapshot = snapshot_mod.build_queue_snapshot(state, plan=plan) + + assert snapshot.phase != snapshot_mod.PHASE_EXECUTE + assert "dict_keys::a" not in [item["id"] for item in snapshot.execution_items] + assert "dict_keys::a" in {item["id"] for item in snapshot.backlog_items} + + +def test_queue_snapshot_orders_scan_assessment_workflow_and_triage_postflight() -> None: review_state = { "issues": { "review::src/a.py::naming": { @@ -732,6 +883,47 @@ def test_queue_snapshot_orders_scan_review_and_workflow_postflight() -> None: } } } + assessment_state = { + "issues": { + "review::src/a.py::naming": { + "id": "review::src/a.py::naming", + "detector": "review", + "status": "open", + "file": "src/a.py", + "tier": 1, + "confidence": "high", + "summary": "review finding", + "detail": {"dimension": "naming_quality"}, + }, + "subjective_review::naming_quality": { + "id": "subjective_review::naming_quality", + "detector": "subjective_review", + "status": "open", + "file": "src/a.py", + "tier": 1, + "confidence": "high", + "summary": "rerun request", + "detail": {"dimension": "naming_quality"}, + }, + }, + "dimension_scores": { + "Naming quality": { + "score": 70.0, + "strict": 70.0, + "failing": 1, + "detectors": { + "subjective_assessment": {"dimension_key": "naming_quality"}, + }, + }, + }, + "subjective_assessments": { + "naming_quality": { + "score": 70.0, + "needs_review_refresh": True, + "stale_since": "2026-01-01T00:00:00+00:00", + }, + }, + } scan_plan = { "queue_order": ["workflow::run-scan", "workflow::communicate-score", "triage::observe"], "plan_start_scores": {"strict": 80.0}, @@ -761,6 +953,56 @@ def test_queue_snapshot_orders_scan_review_and_workflow_postflight() -> None: assert triage_snapshot.phase == snapshot_mod.PHASE_TRIAGE_POSTFLIGHT assert [item["id"] for item in triage_snapshot.execution_items] == ["triage::observe"] + assessment_with_review_snapshot = snapshot_mod.build_queue_snapshot( + assessment_state, + plan={ + "queue_order": [], + "plan_start_scores": {"strict": 80.0}, + "refresh_state": {"postflight_scan_completed_at_scan_count": 1}, + "epic_triage_meta": {"triaged_ids": ["review::src/a.py::naming"]}, + }, + ) + assert assessment_with_review_snapshot.phase == snapshot_mod.PHASE_ASSESSMENT_POSTFLIGHT + # Subjective dimension item is suppressed when review issues cover the + # same dimension — the assessment request alone surfaces. + assert [item["id"] for item in assessment_with_review_snapshot.execution_items] == [ + "subjective_review::naming_quality", + ] + assert "review::src/a.py::naming" in { + item["id"] for item in assessment_with_review_snapshot.backlog_items + } + assert "subjective::naming_quality" not in { + item["id"] for item in assessment_with_review_snapshot.backlog_items + } + + assessment_only_snapshot = snapshot_mod.build_queue_snapshot( + { + key: value + for key, value in assessment_state.items() + if key != "issues" + } + | { + "issues": { + "subjective_review::naming_quality": assessment_state["issues"][ + "subjective_review::naming_quality" + ] + } + }, + plan={ + "queue_order": [], + "plan_start_scores": {"strict": 80.0}, + "refresh_state": {"postflight_scan_completed_at_scan_count": 1}, + }, + ) + assert assessment_only_snapshot.phase == snapshot_mod.PHASE_ASSESSMENT_POSTFLIGHT + assert [item["id"] for item in assessment_only_snapshot.execution_items] == [ + "subjective::naming_quality", + "subjective_review::naming_quality", + ] + assert "review::src/a.py::naming" not in { + item["id"] for item in assessment_only_snapshot.backlog_items + } + post_triage_snapshot = snapshot_mod.build_queue_snapshot( review_state, plan={ @@ -774,7 +1016,25 @@ def test_queue_snapshot_orders_scan_review_and_workflow_postflight() -> None: assert [item["id"] for item in post_triage_snapshot.execution_items] == ["review::src/a.py::naming"] workflow_snapshot = snapshot_mod.build_queue_snapshot( - {"issues": {}}, + { + "dimension_scores": { + "Naming quality": { + "score": 100.0, + "strict": 100.0, + "failing": 0, + "detectors": { + "subjective_assessment": {"dimension_key": "naming_quality"}, + }, + } + }, + "subjective_assessments": { + "naming_quality": { + "score": 100.0, + "needs_review_refresh": False, + } + }, + "issues": {}, + }, plan={ "queue_order": ["workflow::communicate-score", "triage::observe"], "refresh_state": {"postflight_scan_completed_at_scan_count": 1}, @@ -783,6 +1043,60 @@ def test_queue_snapshot_orders_scan_review_and_workflow_postflight() -> None: assert workflow_snapshot.phase == snapshot_mod.PHASE_WORKFLOW_POSTFLIGHT assert [item["id"] for item in workflow_snapshot.execution_items] == ["workflow::communicate-score"] + assessment_beats_workflow_snapshot = snapshot_mod.build_queue_snapshot( + assessment_state, + plan={ + "queue_order": ["workflow::communicate-score", "triage::observe"], + "refresh_state": { + "postflight_scan_completed_at_scan_count": 1, + "lifecycle_phase": "workflow", + }, + }, + ) + assert assessment_beats_workflow_snapshot.phase == snapshot_mod.PHASE_WORKFLOW_POSTFLIGHT + assert [item["id"] for item in assessment_beats_workflow_snapshot.execution_items] == [ + "workflow::communicate-score", + ] + + +def test_build_subjective_items_suppresses_same_cycle_review_refresh_during_workflow() -> None: + items = synthetic_mod.build_subjective_items( + { + "assessment_import_audit": [ + {"timestamp": "2026-03-13T04:19:00+00:00", "mode": "trusted_internal"} + ], + "dimension_scores": { + "Naming quality": { + "score": 70.0, + "strict": 70.0, + "failing": 1, + "detectors": { + "subjective_assessment": {"dimension_key": "naming_quality"}, + }, + } + }, + "subjective_assessments": { + "naming_quality": { + "score": 70.0, + "assessed_at": "2026-03-13T04:19:00+00:00", + "needs_review_refresh": True, + "refresh_reason": "review_issue_wontfix", + "stale_since": "2026-03-13T04:39:00+00:00", + } + }, + }, + {}, + threshold=95.0, + plan={ + "refresh_state": { + "lifecycle_phase": "workflow", + "postflight_scan_completed_at_scan_count": 1, + } + }, + ) + + assert items == [] + def test_queue_snapshot_prefers_deferred_disposition_over_run_scan() -> None: plan = { @@ -809,11 +1123,14 @@ def test_queue_snapshot_prefers_deferred_disposition_over_run_scan() -> None: assert [item["id"] for item in snapshot.execution_items] == ["workflow::deferred-disposition"] -def test_coarse_phase_name_collapses_internal_review_workflow_and_triage() -> None: - assert snapshot_mod.coarse_phase_name(snapshot_mod.PHASE_REVIEW_INITIAL) == "review" - assert snapshot_mod.coarse_phase_name(snapshot_mod.PHASE_REVIEW_POSTFLIGHT) == "review" - assert snapshot_mod.coarse_phase_name(snapshot_mod.PHASE_WORKFLOW_POSTFLIGHT) == "workflow" - assert snapshot_mod.coarse_phase_name(snapshot_mod.PHASE_TRIAGE_POSTFLIGHT) == "triage" +def test_coarse_lifecycle_phase_collapses_internal_review_workflow_and_triage() -> None: + from desloppify.engine._plan.refresh_lifecycle import COARSE_PHASE_MAP + + assert COARSE_PHASE_MAP[snapshot_mod.PHASE_REVIEW_INITIAL] == "review" + assert COARSE_PHASE_MAP[snapshot_mod.PHASE_ASSESSMENT_POSTFLIGHT] == "review" + assert COARSE_PHASE_MAP[snapshot_mod.PHASE_REVIEW_POSTFLIGHT] == "review" + assert COARSE_PHASE_MAP[snapshot_mod.PHASE_WORKFLOW_POSTFLIGHT] == "workflow" + assert COARSE_PHASE_MAP[snapshot_mod.PHASE_TRIAGE_POSTFLIGHT] == "triage" def test_triage_playbook_commands_cover_runner_and_stage_validation() -> None: diff --git a/desloppify/tests/intelligence/test_review_import_prepare_split_direct.py b/desloppify/tests/intelligence/test_review_import_prepare_split_direct.py index e7424157..f32ff6a6 100644 --- a/desloppify/tests/intelligence/test_review_import_prepare_split_direct.py +++ b/desloppify/tests/intelligence/test_review_import_prepare_split_direct.py @@ -96,7 +96,7 @@ def test_holistic_cache_update_and_resolution_helpers(monkeypatch) -> None: ) # naming_quality is assessed, so its issue should be resolved assert diff["auto_resolved"] == 1 - assert state["issues"]["subjective_review::.::naming_quality"]["status"] == "fixed" + assert state["work_items"]["subjective_review::.::naming_quality"]["status"] == "fixed" # resolve_reviewed_file_coverage_issues is now a no-op diff = {"auto_resolved": 0} @@ -168,7 +168,7 @@ def test_issue_flow_build_collect_and_auto_resolve_paths(monkeypatch) -> None: full_sweep_included=False, ) assert diff["auto_resolved"] == 1 - assert state["issues"]["review::old"]["status"] == "fixed" + assert state["work_items"]["review::old"]["status"] == "fixed" def test_resolution_and_state_helper_utilities() -> None: @@ -188,7 +188,7 @@ def test_resolution_and_state_helper_utilities() -> None: utc_now_fn=lambda: "2026-03-09T00:00:00+00:00", ) assert diff["auto_resolved"] == 1 - assert state["issues"]["id1"]["status"] == "fixed" + assert state["work_items"]["id1"]["status"] == "fixed" cache = state_helpers_mod.ensure_review_file_cache({}) assert cache == {} @@ -359,6 +359,31 @@ def test_authorization_collector_uses_module_fallback_for_with_auth_siblings() - assert "ui/home.py" not in files +def test_authorization_collector_excludes_guidance_like_runtime_paths() -> None: + ctx = SimpleNamespace( + authorization={ + "route_auth_coverage": { + "guidance/auth_examples.py": { + "handlers": 1, + "with_auth": 0, + "without_auth": 1, + }, + "src/routes/admin.py": {"handlers": 1, "with_auth": 0, "without_auth": 1}, + }, + "service_role_usage": ["prompts/security_prompt.ts", "src/lib/supabase.ts"], + "rls_coverage": { + "files": { + "accounts": ["docs/rls_examples.sql", "db/schema.sql"], + } + }, + } + ) + + files = collectors_structure_mod._authorization_files(ctx, max_files=10) + + assert files == ["src/routes/admin.py", "src/lib/supabase.ts", "db/schema.sql"] + + def test_prepare_batches_core_path_normalization_rules() -> None: assert prepare_batches_core_mod._normalize_file_path(" src/app.py ") == "src/app.py" assert prepare_batches_core_mod._normalize_file_path('"README",') == "README" diff --git a/desloppify/tests/lang/common/test_framework_shared_phases_and_structural_split_direct.py b/desloppify/tests/lang/common/test_framework_shared_phases_and_structural_split_direct.py index ce4adb9e..33644875 100644 --- a/desloppify/tests/lang/common/test_framework_shared_phases_and_structural_split_direct.py +++ b/desloppify/tests/lang/common/test_framework_shared_phases_and_structural_split_direct.py @@ -89,10 +89,18 @@ def test_phase_security_records_default_coverage_when_missing(monkeypatch) -> No ), ) + monkeypatch.setattr(review_mod, "filter_entries", lambda _zones, entries, _detector: entries) monkeypatch.setattr( review_mod, - "detect_security_issues", - lambda _files, _zones, _name, scan_root: ( + "_entries_to_issues", + lambda detector, entries, **_kwargs: [{"detector": detector, "file": e["file"]} for e in entries], + ) + monkeypatch.setattr(review_mod, "_log_phase_summary", lambda *_args, **_kwargs: None) + + issues, potentials = review_mod.phase_security( + Path("."), + lang, + detect_security_issues=lambda _files, _zones, _name, scan_root: ( [ { "file": str(scan_root / "src" / "cross.py"), @@ -105,15 +113,6 @@ def test_phase_security_records_default_coverage_when_missing(monkeypatch) -> No 2, ), ) - monkeypatch.setattr(review_mod, "filter_entries", lambda _zones, entries, _detector: entries) - monkeypatch.setattr( - review_mod, - "_entries_to_issues", - lambda detector, entries, **_kwargs: [{"detector": detector, "file": e["file"]} for e in entries], - ) - monkeypatch.setattr(review_mod, "_log_phase_summary", lambda *_args, **_kwargs: None) - - issues, potentials = review_mod.phase_security(Path("."), lang) assert len(issues) == 2 assert potentials == {"security": 5} diff --git a/desloppify/tests/lang/common/test_shared_phases_security.py b/desloppify/tests/lang/common/test_shared_phases_security.py index 28aa9705..cf23396e 100644 --- a/desloppify/tests/lang/common/test_shared_phases_security.py +++ b/desloppify/tests/lang/common/test_shared_phases_security.py @@ -4,7 +4,6 @@ from types import SimpleNamespace -import desloppify.languages._framework.base.shared_phases as shared_phases_mod from desloppify.languages._framework.base.shared_phases import phase_security from desloppify.languages._framework.base.types import LangSecurityResult @@ -22,23 +21,21 @@ def _lang_stub(*, files_scanned: int): ) -def test_phase_security_uses_max_scan_count_with_lang_security_result(monkeypatch, tmp_path): - monkeypatch.setattr( - shared_phases_mod, - "detect_security_issues", - lambda _files, _zone, _lang, **_kwargs: ([], 2), +def test_phase_security_uses_max_scan_count_with_lang_security_result(tmp_path): + issues, potentials = phase_security( + tmp_path, + _lang_stub(files_scanned=7), + detect_security_issues=lambda _files, _zone, _lang, **_kwargs: ([], 2), ) - issues, potentials = phase_security(tmp_path, _lang_stub(files_scanned=7)) assert issues == [] assert potentials == {"security": 7} -def test_phase_security_keeps_cross_lang_scan_count_when_larger(monkeypatch, tmp_path): - monkeypatch.setattr( - shared_phases_mod, - "detect_security_issues", - lambda _files, _zone, _lang, **_kwargs: ([], 9), +def test_phase_security_keeps_cross_lang_scan_count_when_larger(tmp_path): + issues, potentials = phase_security( + tmp_path, + _lang_stub(files_scanned=3), + detect_security_issues=lambda _files, _zone, _lang, **_kwargs: ([], 9), ) - issues, potentials = phase_security(tmp_path, _lang_stub(files_scanned=3)) assert issues == [] assert potentials == {"security": 9} diff --git a/desloppify/tests/lang/common/test_treesitter.py b/desloppify/tests/lang/common/test_treesitter.py index f63e0335..1daf74ea 100644 --- a/desloppify/tests/lang/common/test_treesitter.py +++ b/desloppify/tests/lang/common/test_treesitter.py @@ -146,10 +146,10 @@ def c_file(tmp_path): class TestGoExtraction: def test_extract_functions(self, go_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC functions = ts_extract_functions(tmp_path, GO_SPEC, [go_file]) # Tiny() should be filtered (< 3 lines normalized) @@ -159,10 +159,10 @@ def test_extract_functions(self, go_file, tmp_path): assert "Add" in names def test_function_line_numbers(self, go_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC functions = ts_extract_functions(tmp_path, GO_SPEC, [go_file]) hello = next(f for f in functions if f.name == "Hello") @@ -170,10 +170,10 @@ def test_function_line_numbers(self, go_file, tmp_path): assert hello.end_line == 10 def test_function_params(self, go_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC functions = ts_extract_functions(tmp_path, GO_SPEC, [go_file]) hello = next(f for f in functions if f.name == "Hello") @@ -184,10 +184,10 @@ def test_function_params(self, go_file, tmp_path): assert "b" in add.params def test_body_hash_deterministic(self, go_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC functions1 = ts_extract_functions(tmp_path, GO_SPEC, [go_file]) functions2 = ts_extract_functions(tmp_path, GO_SPEC, [go_file]) @@ -195,10 +195,10 @@ def test_body_hash_deterministic(self, go_file, tmp_path): assert f1.body_hash == f2.body_hash def test_normalization_strips_comments(self, go_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC functions = ts_extract_functions(tmp_path, GO_SPEC, [go_file]) hello = next(f for f in functions if f.name == "Hello") @@ -208,10 +208,10 @@ def test_normalization_strips_comments(self, go_file, tmp_path): assert "return" in hello.normalized def test_normalization_strips_log_calls(self, go_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC functions = ts_extract_functions(tmp_path, GO_SPEC, [go_file]) hello = next(f for f in functions if f.name == "Hello") @@ -220,20 +220,20 @@ def test_normalization_strips_log_calls(self, go_file, tmp_path): class TestRustExtraction: def test_extract_functions(self, rust_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_compiled import RUST_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import RUST_SPEC functions = ts_extract_functions(tmp_path, RUST_SPEC, [rust_file]) names = [f.name for f in functions] assert "hello" in names def test_normalization_strips_println(self, rust_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_compiled import RUST_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import RUST_SPEC functions = ts_extract_functions(tmp_path, RUST_SPEC, [rust_file]) hello = next(f for f in functions if f.name == "hello") @@ -242,10 +242,10 @@ def test_normalization_strips_println(self, rust_file, tmp_path): class TestRubyExtraction: def test_extract_methods(self, ruby_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_scripting import RUBY_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import RUBY_SPEC functions = ts_extract_functions(tmp_path, RUBY_SPEC, [ruby_file]) names = [f.name for f in functions] @@ -255,10 +255,10 @@ def test_extract_methods(self, ruby_file, tmp_path): class TestJavaExtraction: def test_extract_methods(self, java_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_compiled import JAVA_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import JAVA_SPEC functions = ts_extract_functions(tmp_path, JAVA_SPEC, [java_file]) names = [f.name for f in functions] @@ -268,10 +268,10 @@ def test_extract_methods(self, java_file, tmp_path): class TestCExtraction: def test_extract_functions(self, c_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_compiled import C_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import C_SPEC functions = ts_extract_functions(tmp_path, C_SPEC, [c_file]) names = [f.name for f in functions] @@ -284,50 +284,50 @@ def test_extract_functions(self, c_file, tmp_path): class TestClassExtraction: def test_go_struct(self, go_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_classes, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC classes = ts_extract_classes(tmp_path, GO_SPEC, [go_file]) names = [c.name for c in classes] assert "MyStruct" in names def test_rust_struct(self, rust_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_classes, ) - from desloppify.languages._framework.treesitter._specs_compiled import RUST_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import RUST_SPEC classes = ts_extract_classes(tmp_path, RUST_SPEC, [rust_file]) names = [c.name for c in classes] assert "MyStruct" in names def test_java_class(self, java_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_classes, ) - from desloppify.languages._framework.treesitter._specs_compiled import JAVA_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import JAVA_SPEC classes = ts_extract_classes(tmp_path, JAVA_SPEC, [java_file]) names = [c.name for c in classes] assert "MyClass" in names def test_ruby_class(self, ruby_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_classes, ) - from desloppify.languages._framework.treesitter._specs_scripting import RUBY_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import RUBY_SPEC classes = ts_extract_classes(tmp_path, RUBY_SPEC, [ruby_file]) names = [c.name for c in classes] assert "MyClass" in names def test_no_class_query_returns_empty(self, go_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_classes, ) - from desloppify.languages._framework.treesitter._specs_scripting import BASH_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import BASH_SPEC classes = ts_extract_classes(tmp_path, BASH_SPEC, [go_file]) assert classes == [] @@ -338,14 +338,14 @@ def test_no_class_query_returns_empty(self, go_file, tmp_path): class TestGoImportResolver: def test_stdlib_returns_none(self): - from desloppify.languages._framework.treesitter._import_resolvers_backend import ( + from desloppify.languages._framework.treesitter.imports.resolvers_backend import ( resolve_go_import, ) assert resolve_go_import("fmt", "/src/main.go", "/src") is None def test_external_pkg_returns_none(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_backend import ( + from desloppify.languages._framework.treesitter.imports.resolvers_backend import ( resolve_go_import, ) @@ -353,10 +353,10 @@ def test_external_pkg_returns_none(self, tmp_path): assert resolve_go_import("github.com/foo/bar", "/src/main.go", str(tmp_path)) is None def test_local_import_resolves(self, tmp_path): - from desloppify.languages._framework.treesitter._import_cache import ( + from desloppify.languages._framework.treesitter.imports.resolver_cache import ( reset_import_cache, ) - from desloppify.languages._framework.treesitter._import_resolvers_backend import ( + from desloppify.languages._framework.treesitter.imports.resolvers_backend import ( resolve_go_import, ) @@ -380,14 +380,14 @@ def test_local_import_resolves(self, tmp_path): class TestRustImportResolver: def test_external_crate_returns_none(self): - from desloppify.languages._framework.treesitter._import_resolvers_backend import ( + from desloppify.languages._framework.treesitter.imports.resolvers_backend import ( resolve_rust_import, ) assert resolve_rust_import("std::io::Read", "/src/main.rs", "/project") is None def test_crate_import_resolves(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_backend import ( + from desloppify.languages._framework.treesitter.imports.resolvers_backend import ( resolve_rust_import, ) @@ -402,7 +402,7 @@ def test_crate_import_resolves(self, tmp_path): class TestRubyImportResolver: def test_relative_require(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_scripts import ( + from desloppify.languages._framework.treesitter.imports.resolvers_scripts import ( resolve_ruby_import, ) @@ -414,7 +414,7 @@ def test_relative_require(self, tmp_path): assert result.endswith("helper.rb") def test_absolute_require_in_lib(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_scripts import ( + from desloppify.languages._framework.treesitter.imports.resolvers_scripts import ( resolve_ruby_import, ) @@ -429,7 +429,7 @@ def test_absolute_require_in_lib(self, tmp_path): class TestCxxIncludeResolver: def test_relative_include(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_backend import ( + from desloppify.languages._framework.treesitter.imports.resolvers_backend import ( resolve_cxx_include, ) @@ -441,7 +441,7 @@ def test_relative_include(self, tmp_path): assert result.endswith("local.h") def test_nonexistent_returns_none(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_backend import ( + from desloppify.languages._framework.treesitter.imports.resolvers_backend import ( resolve_cxx_include, ) @@ -456,13 +456,13 @@ def test_nonexistent_returns_none(self, tmp_path): class TestDepGraphBuilder: def test_go_dep_graph(self, tmp_path): - from desloppify.languages._framework.treesitter._import_cache import ( + from desloppify.languages._framework.treesitter.imports.resolver_cache import ( reset_import_cache, ) - from desloppify.languages._framework.treesitter._import_graph import ( + from desloppify.languages._framework.treesitter.imports.graph import ( ts_build_dep_graph, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC reset_import_cache() @@ -485,10 +485,10 @@ def test_go_dep_graph(self, tmp_path): reset_import_cache() def test_no_import_query_returns_empty(self, tmp_path): - from desloppify.languages._framework.treesitter._import_graph import ( + from desloppify.languages._framework.treesitter.imports.graph import ( ts_build_dep_graph, ) - from desloppify.languages._framework.treesitter._specs_scripting import BASH_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import BASH_SPEC graph = ts_build_dep_graph(tmp_path, BASH_SPEC, []) assert graph == {} @@ -499,14 +499,14 @@ def test_no_import_query_returns_empty(self, tmp_path): class TestNormalize: def test_strips_comments(self, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( _get_parser, _make_query, _run_query, _unwrap_node, ) - from desloppify.languages._framework.treesitter._normalize import normalize_body - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.imports.normalize import normalize_body + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC source = b"""package main func Hello() string { @@ -558,7 +558,7 @@ def test_generic_lang_stubs_without_treesitter(self): ts_mod._AVAILABLE = False try: from desloppify.languages._framework.generic_support.core import generic_lang - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC cfg = generic_lang( name="_test_no_ts", @@ -582,10 +582,10 @@ def test_generic_lang_stubs_without_treesitter(self): def test_file_read_error_skipped(self, tmp_path): """Files that can't be read are silently skipped.""" - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC bad_path = str(tmp_path / "nonexistent.go") functions = ts_extract_functions(tmp_path, GO_SPEC, [bad_path]) @@ -641,7 +641,7 @@ class TestSpecValidation: """Verify that all specs can actually create queries without errors.""" def _test_spec(self, spec): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( _get_parser, _make_query, ) @@ -661,107 +661,107 @@ def _test_spec(self, spec): assert q is not None def test_go_spec(self): - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC self._test_spec(GO_SPEC) def test_rust_spec(self): - from desloppify.languages._framework.treesitter._specs_compiled import RUST_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import RUST_SPEC self._test_spec(RUST_SPEC) def test_ruby_spec(self): - from desloppify.languages._framework.treesitter._specs_scripting import RUBY_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import RUBY_SPEC self._test_spec(RUBY_SPEC) def test_java_spec(self): - from desloppify.languages._framework.treesitter._specs_compiled import JAVA_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import JAVA_SPEC self._test_spec(JAVA_SPEC) def test_kotlin_spec(self): - from desloppify.languages._framework.treesitter._specs_compiled import KOTLIN_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import KOTLIN_SPEC self._test_spec(KOTLIN_SPEC) def test_csharp_spec(self): - from desloppify.languages._framework.treesitter._specs_compiled import CSHARP_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import CSHARP_SPEC self._test_spec(CSHARP_SPEC) def test_swift_spec(self): - from desloppify.languages._framework.treesitter._specs_compiled import SWIFT_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import SWIFT_SPEC self._test_spec(SWIFT_SPEC) def test_php_spec(self): - from desloppify.languages._framework.treesitter._specs_compiled import PHP_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import PHP_SPEC self._test_spec(PHP_SPEC) def test_c_spec(self): - from desloppify.languages._framework.treesitter._specs_compiled import C_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import C_SPEC self._test_spec(C_SPEC) def test_cpp_spec(self): - from desloppify.languages._framework.treesitter._specs_compiled import CPP_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import CPP_SPEC self._test_spec(CPP_SPEC) def test_scala_spec(self): - from desloppify.languages._framework.treesitter._specs_compiled import SCALA_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import SCALA_SPEC self._test_spec(SCALA_SPEC) def test_elixir_spec(self): - from desloppify.languages._framework.treesitter._specs_functional import ELIXIR_SPEC + from desloppify.languages._framework.treesitter.specs.functional import ELIXIR_SPEC self._test_spec(ELIXIR_SPEC) def test_haskell_spec(self): - from desloppify.languages._framework.treesitter._specs_functional import HASKELL_SPEC + from desloppify.languages._framework.treesitter.specs.functional import HASKELL_SPEC self._test_spec(HASKELL_SPEC) def test_bash_spec(self): - from desloppify.languages._framework.treesitter._specs_scripting import BASH_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import BASH_SPEC self._test_spec(BASH_SPEC) def test_lua_spec(self): - from desloppify.languages._framework.treesitter._specs_scripting import LUA_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import LUA_SPEC self._test_spec(LUA_SPEC) def test_perl_spec(self): - from desloppify.languages._framework.treesitter._specs_scripting import PERL_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import PERL_SPEC self._test_spec(PERL_SPEC) def test_clojure_spec(self): - from desloppify.languages._framework.treesitter._specs_functional import CLOJURE_SPEC + from desloppify.languages._framework.treesitter.specs.functional import CLOJURE_SPEC self._test_spec(CLOJURE_SPEC) def test_zig_spec(self): - from desloppify.languages._framework.treesitter._specs_scripting import ZIG_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import ZIG_SPEC self._test_spec(ZIG_SPEC) def test_nim_spec(self): - from desloppify.languages._framework.treesitter._specs_scripting import NIM_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import NIM_SPEC self._test_spec(NIM_SPEC) def test_powershell_spec(self): - from desloppify.languages._framework.treesitter._specs_scripting import POWERSHELL_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import POWERSHELL_SPEC self._test_spec(POWERSHELL_SPEC) def test_gdscript_spec(self): - from desloppify.languages._framework.treesitter._specs_scripting import GDSCRIPT_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import GDSCRIPT_SPEC self._test_spec(GDSCRIPT_SPEC) def test_dart_spec(self): - from desloppify.languages._framework.treesitter._specs_compiled import DART_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import DART_SPEC self._test_spec(DART_SPEC) def test_js_spec(self): - from desloppify.languages._framework.treesitter._specs_scripting import JS_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import JS_SPEC self._test_spec(JS_SPEC) def test_erlang_spec(self): - from desloppify.languages._framework.treesitter._specs_functional import ERLANG_SPEC + from desloppify.languages._framework.treesitter.specs.functional import ERLANG_SPEC self._test_spec(ERLANG_SPEC) def test_ocaml_spec(self): - from desloppify.languages._framework.treesitter._specs_functional import OCAML_SPEC + from desloppify.languages._framework.treesitter.specs.functional import OCAML_SPEC self._test_spec(OCAML_SPEC) def test_fsharp_spec(self): - from desloppify.languages._framework.treesitter._specs_functional import FSHARP_SPEC + from desloppify.languages._framework.treesitter.specs.functional import FSHARP_SPEC self._test_spec(FSHARP_SPEC) @@ -776,7 +776,7 @@ def test_cache_hit(self, go_file, tmp_path): disable_parse_cache, enable_parse_cache, ) - from desloppify.languages._framework.treesitter._extractors import _get_parser + from desloppify.languages._framework.treesitter.analysis.extractors import _get_parser parser, _language = _get_parser("go") with runtime_scope(make_runtime_context()): @@ -798,7 +798,7 @@ def test_cache_disabled(self, go_file, tmp_path): current_parse_tree_cache, disable_parse_cache, ) - from desloppify.languages._framework.treesitter._extractors import _get_parser + from desloppify.languages._framework.treesitter.analysis.extractors import _get_parser with runtime_scope(make_runtime_context()): disable_parse_cache() @@ -833,7 +833,7 @@ def test_cache_cleanup(self): class TestBashSourceResolver: def test_resolve_relative(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_scripts import ( + from desloppify.languages._framework.treesitter.imports.resolvers_scripts import ( resolve_bash_source, ) @@ -845,7 +845,7 @@ def test_resolve_relative(self, tmp_path): assert result.endswith("helper.sh") def test_resolve_with_ext_added(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_scripts import ( + from desloppify.languages._framework.treesitter.imports.resolvers_scripts import ( resolve_bash_source, ) @@ -857,7 +857,7 @@ def test_resolve_with_ext_added(self, tmp_path): assert result.endswith("lib.sh") def test_nonexistent_returns_none(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_scripts import ( + from desloppify.languages._framework.treesitter.imports.resolvers_scripts import ( resolve_bash_source, ) @@ -869,7 +869,7 @@ def test_nonexistent_returns_none(self, tmp_path): class TestPerlImportResolver: def test_local_module(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_scripts import ( + from desloppify.languages._framework.treesitter.imports.resolvers_scripts import ( resolve_perl_import, ) @@ -884,7 +884,7 @@ def test_local_module(self, tmp_path): assert result.endswith("User.pm") def test_pragma_skipped(self): - from desloppify.languages._framework.treesitter._import_resolvers_scripts import ( + from desloppify.languages._framework.treesitter.imports.resolvers_scripts import ( resolve_perl_import, ) @@ -892,7 +892,7 @@ def test_pragma_skipped(self): assert resolve_perl_import("warnings", "/src/app.pl", "/src") is None def test_stdlib_prefix_skipped(self): - from desloppify.languages._framework.treesitter._import_resolvers_scripts import ( + from desloppify.languages._framework.treesitter.imports.resolvers_scripts import ( resolve_perl_import, ) @@ -902,7 +902,7 @@ def test_stdlib_prefix_skipped(self): class TestZigImportResolver: def test_local_import(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_functional import ( + from desloppify.languages._framework.treesitter.imports.resolvers_functional import ( resolve_zig_import, ) @@ -914,7 +914,7 @@ def test_local_import(self, tmp_path): assert result.endswith("utils.zig") def test_std_skipped(self): - from desloppify.languages._framework.treesitter._import_resolvers_functional import ( + from desloppify.languages._framework.treesitter.imports.resolvers_functional import ( resolve_zig_import, ) @@ -924,7 +924,7 @@ def test_std_skipped(self): class TestHaskellImportResolver: def test_local_module(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_functional import ( + from desloppify.languages._framework.treesitter.imports.resolvers_functional import ( resolve_haskell_import, ) @@ -939,7 +939,7 @@ def test_local_module(self, tmp_path): assert result.endswith("Module.hs") def test_stdlib_skipped(self): - from desloppify.languages._framework.treesitter._import_resolvers_functional import ( + from desloppify.languages._framework.treesitter.imports.resolvers_functional import ( resolve_haskell_import, ) @@ -950,7 +950,7 @@ def test_stdlib_skipped(self): class TestErlangIncludeResolver: def test_relative_include(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_functional import ( + from desloppify.languages._framework.treesitter.imports.resolvers_functional import ( resolve_erlang_include, ) @@ -962,7 +962,7 @@ def test_relative_include(self, tmp_path): assert result.endswith("header.hrl") def test_include_dir(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_functional import ( + from desloppify.languages._framework.treesitter.imports.resolvers_functional import ( resolve_erlang_include, ) @@ -979,7 +979,7 @@ def test_include_dir(self, tmp_path): class TestOcamlImportResolver: def test_local_module(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_functional import ( + from desloppify.languages._framework.treesitter.imports.resolvers_functional import ( resolve_ocaml_import, ) @@ -994,7 +994,7 @@ def test_local_module(self, tmp_path): assert result.endswith("mymodule.ml") def test_stdlib_skipped(self): - from desloppify.languages._framework.treesitter._import_resolvers_functional import ( + from desloppify.languages._framework.treesitter.imports.resolvers_functional import ( resolve_ocaml_import, ) @@ -1004,7 +1004,7 @@ def test_stdlib_skipped(self): class TestFsharpImportResolver: def test_local_module(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_functional import ( + from desloppify.languages._framework.treesitter.imports.resolvers_functional import ( resolve_fsharp_import, ) @@ -1019,7 +1019,7 @@ def test_local_module(self, tmp_path): assert result.endswith("MyModule.fs") def test_stdlib_skipped(self): - from desloppify.languages._framework.treesitter._import_resolvers_functional import ( + from desloppify.languages._framework.treesitter.imports.resolvers_functional import ( resolve_fsharp_import, ) @@ -1029,7 +1029,7 @@ def test_stdlib_skipped(self): class TestSwiftImportResolver: def test_local_module_path(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_backend import ( + from desloppify.languages._framework.treesitter.imports.resolvers_backend import ( resolve_swift_import, ) @@ -1046,7 +1046,7 @@ def test_local_module_path(self, tmp_path): assert result.endswith("Client.swift") def test_external_module_returns_none(self): - from desloppify.languages._framework.treesitter._import_resolvers_backend import ( + from desloppify.languages._framework.treesitter.imports.resolvers_backend import ( resolve_swift_import, ) @@ -1055,7 +1055,7 @@ def test_external_module_returns_none(self): class TestJsImportResolver: def test_relative_import(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_scripts import ( + from desloppify.languages._framework.treesitter.imports.resolvers_scripts import ( resolve_js_import, ) @@ -1067,7 +1067,7 @@ def test_relative_import(self, tmp_path): assert result.endswith("utils.js") def test_npm_package_returns_none(self): - from desloppify.languages._framework.treesitter._import_resolvers_scripts import ( + from desloppify.languages._framework.treesitter.imports.resolvers_scripts import ( resolve_js_import, ) @@ -1075,7 +1075,7 @@ def test_npm_package_returns_none(self): assert resolve_js_import("lodash/fp", "/src/main.js", "/src") is None def test_jsx_extension(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_scripts import ( + from desloppify.languages._framework.treesitter.imports.resolvers_scripts import ( resolve_js_import, ) @@ -1087,7 +1087,7 @@ def test_jsx_extension(self, tmp_path): assert result.endswith("App.jsx") def test_index_resolution(self, tmp_path): - from desloppify.languages._framework.treesitter._import_resolvers_scripts import ( + from desloppify.languages._framework.treesitter.imports.resolvers_scripts import ( resolve_js_import, ) @@ -1130,10 +1130,10 @@ class Calculator { return str(f) def test_function_extraction(self, js_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_scripting import JS_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import JS_SPEC functions = ts_extract_functions(tmp_path, JS_SPEC, [js_file]) names = [f.name for f in functions] @@ -1142,20 +1142,20 @@ def test_function_extraction(self, js_file, tmp_path): assert "multiply" in names def test_class_extraction(self, js_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_classes, ) - from desloppify.languages._framework.treesitter._specs_scripting import JS_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import JS_SPEC classes = ts_extract_classes(tmp_path, JS_SPEC, [js_file]) names = [c.name for c in classes] assert "Calculator" in names def test_normalization_strips_console(self, js_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_scripting import JS_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import JS_SPEC functions = ts_extract_functions(tmp_path, JS_SPEC, [js_file]) greet = next(f for f in functions if f.name == "greet") diff --git a/desloppify/tests/lang/common/test_treesitter_analysis_direct.py b/desloppify/tests/lang/common/test_treesitter_analysis_direct.py index eccdfee2..c06e2e2a 100644 --- a/desloppify/tests/lang/common/test_treesitter_analysis_direct.py +++ b/desloppify/tests/lang/common/test_treesitter_analysis_direct.py @@ -186,6 +186,8 @@ def test_unused_import_helpers_and_detection(monkeypatch) -> None: assert unused_imports_mod._extract_alias(go_alias) == "pkg" assert unused_imports_mod._extract_import_name("pkg/module") == "module" assert unused_imports_mod._extract_import_name("crate::Thing") == "Thing" + assert unused_imports_mod._extract_import_name("WidgetCatalog.hpp") == "WidgetCatalog" + assert unused_imports_mod._extract_import_name("vendor/json.hpp") == "json" import_node = FakeNode( "import_statement", diff --git a/desloppify/tests/lang/common/test_treesitter_complexity_and_integration.py b/desloppify/tests/lang/common/test_treesitter_complexity_and_integration.py index 2f62db98..c3cbe82a 100644 --- a/desloppify/tests/lang/common/test_treesitter_complexity_and_integration.py +++ b/desloppify/tests/lang/common/test_treesitter_complexity_and_integration.py @@ -18,11 +18,11 @@ def test_nesting_depth(self, tmp_path): disable_parse_cache, enable_parse_cache, ) - from desloppify.languages._framework.treesitter._complexity_nesting import ( + from desloppify.languages._framework.treesitter.analysis.complexity_nesting import ( compute_nesting_depth_ts, ) - from desloppify.languages._framework.treesitter._extractors import _get_parser - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.analysis.extractors import _get_parser + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC code = """\ package main @@ -54,11 +54,11 @@ def test_nesting_depth_flat_file(self, tmp_path): disable_parse_cache, enable_parse_cache, ) - from desloppify.languages._framework.treesitter._complexity_nesting import ( + from desloppify.languages._framework.treesitter.analysis.complexity_nesting import ( compute_nesting_depth_ts, ) - from desloppify.languages._framework.treesitter._extractors import _get_parser - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.analysis.extractors import _get_parser + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC code = """\ package main @@ -85,10 +85,10 @@ def test_long_functions_compute(self, tmp_path): disable_parse_cache, enable_parse_cache, ) - from desloppify.languages._framework.treesitter._complexity_function_metrics import ( + from desloppify.languages._framework.treesitter.analysis.complexity_function_metrics import ( make_long_functions_compute, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC # Create a function with > 80 lines. body_lines = "\n".join(f" x{i} := {i}" for i in range(90)) @@ -113,10 +113,10 @@ def test_long_functions_no_big_fn(self, tmp_path): disable_parse_cache, enable_parse_cache, ) - from desloppify.languages._framework.treesitter._complexity_function_metrics import ( + from desloppify.languages._framework.treesitter.analysis.complexity_function_metrics import ( make_long_functions_compute, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC code = "package main\n\nfunc small() {\n x := 1\n}\n" f = tmp_path / "small.go" @@ -160,10 +160,10 @@ def erlang_file(self, tmp_path): return str(f) def test_function_extraction(self, erlang_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_functional import ERLANG_SPEC + from desloppify.languages._framework.treesitter.specs.functional import ERLANG_SPEC functions = ts_extract_functions(tmp_path, ERLANG_SPEC, [erlang_file]) # Erlang functions — at least some should be extracted. @@ -195,10 +195,10 @@ def ocaml_file(self, tmp_path): return str(f) def test_function_extraction(self, ocaml_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_functional import OCAML_SPEC + from desloppify.languages._framework.treesitter.specs.functional import OCAML_SPEC functions = ts_extract_functions(tmp_path, OCAML_SPEC, [ocaml_file]) assert len(functions) >= 1 @@ -225,10 +225,10 @@ def fsharp_file(self, tmp_path): return str(f) def test_function_extraction(self, fsharp_file, tmp_path): - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_functional import FSHARP_SPEC + from desloppify.languages._framework.treesitter.specs.functional import FSHARP_SPEC functions = ts_extract_functions(tmp_path, FSHARP_SPEC, [fsharp_file]) # F# let bindings may or may not match — depends on grammar details. @@ -284,10 +284,10 @@ def test_cyclomatic_simple(self, tmp_path): disable_parse_cache, enable_parse_cache, ) - from desloppify.languages._framework.treesitter._complexity_function_metrics import ( + from desloppify.languages._framework.treesitter.analysis.complexity_function_metrics import ( make_cyclomatic_complexity_compute, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC code = """\ package main @@ -326,10 +326,10 @@ def test_cyclomatic_trivial(self, tmp_path): disable_parse_cache, enable_parse_cache, ) - from desloppify.languages._framework.treesitter._complexity_function_metrics import ( + from desloppify.languages._framework.treesitter.analysis.complexity_function_metrics import ( make_cyclomatic_complexity_compute, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC code = "package main\n\nfunc simple() {\n x := 1\n _ = x\n}\n" f = tmp_path / "simple.go" @@ -351,10 +351,10 @@ def test_many_params(self, tmp_path): disable_parse_cache, enable_parse_cache, ) - from desloppify.languages._framework.treesitter._complexity_function_metrics import ( + from desloppify.languages._framework.treesitter.analysis.complexity_function_metrics import ( make_max_params_compute, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC code = """\ package main @@ -384,10 +384,10 @@ def test_nested_callbacks(self, tmp_path): disable_parse_cache, enable_parse_cache, ) - from desloppify.languages._framework.treesitter._complexity_nesting import ( + from desloppify.languages._framework.treesitter.analysis.complexity_nesting import ( make_callback_depth_compute, ) - from desloppify.languages._framework.treesitter._specs_scripting import JS_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import JS_SPEC code = """\ const nested = () => { @@ -418,7 +418,7 @@ def test_nested_callbacks(self, tmp_path): class TestEmptyCatches: def test_detect_empty_catch_python(self, tmp_path): - from desloppify.languages._framework.treesitter._smells import ( + from desloppify.languages._framework.treesitter.analysis.smells import ( detect_empty_catches, ) @@ -447,7 +447,7 @@ def test_detect_empty_catch_python(self, tmp_path): def test_detect_nonempty_catch(self, tmp_path): from desloppify.languages._framework.treesitter import TreeSitterLangSpec - from desloppify.languages._framework.treesitter._smells import ( + from desloppify.languages._framework.treesitter.analysis.smells import ( detect_empty_catches, ) @@ -469,10 +469,10 @@ def test_detect_nonempty_catch(self, tmp_path): assert len(entries) == 0 def test_detect_empty_catch_js(self, tmp_path): - from desloppify.languages._framework.treesitter._smells import ( + from desloppify.languages._framework.treesitter.analysis.smells import ( detect_empty_catches, ) - from desloppify.languages._framework.treesitter._specs_scripting import JS_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import JS_SPEC code = """\ try { @@ -489,10 +489,10 @@ def test_detect_empty_catch_js(self, tmp_path): class TestUnreachableCode: def test_detect_after_return(self, tmp_path): - from desloppify.languages._framework.treesitter._smells import ( + from desloppify.languages._framework.treesitter.analysis.smells import ( detect_unreachable_code, ) - from desloppify.languages._framework.treesitter._specs_scripting import JS_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import JS_SPEC code = """\ function foo() { @@ -508,10 +508,10 @@ def test_detect_after_return(self, tmp_path): assert entries[0]["after"] == "return_statement" def test_no_unreachable(self, tmp_path): - from desloppify.languages._framework.treesitter._smells import ( + from desloppify.languages._framework.treesitter.analysis.smells import ( detect_unreachable_code, ) - from desloppify.languages._framework.treesitter._specs_scripting import JS_SPEC + from desloppify.languages._framework.treesitter.specs.scripting import JS_SPEC code = """\ function foo(x) { @@ -536,7 +536,7 @@ def test_cohesive_file_no_flags(self, tmp_path): from desloppify.languages._framework.treesitter.analysis.cohesion import ( detect_responsibility_cohesion, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC # Create a file with connected functions (all call each other). code = "package main\n\n" @@ -558,7 +558,7 @@ def test_disconnected_singletons_not_flagged(self, tmp_path): from desloppify.languages._framework.treesitter.analysis.cohesion import ( detect_responsibility_cohesion, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC # All-singleton file (toolkit pattern) — should NOT be flagged. code = "package main\n\n" @@ -579,7 +579,7 @@ def test_mixed_responsibilities_flagged(self, tmp_path): from desloppify.languages._framework.treesitter.analysis.cohesion import ( detect_responsibility_cohesion, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC # File with 3+ distinct groups of interrelated functions — # genuinely mixed responsibilities. @@ -617,8 +617,8 @@ def test_mixed_responsibilities_flagged(self, tmp_path): class TestUnusedImports: def test_unused_import_detected(self, tmp_path): - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC - from desloppify.languages._framework.treesitter._unused_imports import ( + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC + from desloppify.languages._framework.treesitter.analysis.unused_imports import ( detect_unused_imports, ) @@ -643,8 +643,8 @@ def test_unused_import_detected(self, tmp_path): assert "fmt" not in names def test_no_unused_imports(self, tmp_path): - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC - from desloppify.languages._framework.treesitter._unused_imports import ( + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC + from desloppify.languages._framework.treesitter.analysis.unused_imports import ( detect_unused_imports, ) @@ -665,8 +665,8 @@ def test_no_unused_imports(self, tmp_path): def test_go_aliased_import_not_false_positive(self, tmp_path): """Go-style aliased imports (alias before path) should use the alias name.""" - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC - from desloppify.languages._framework.treesitter._unused_imports import ( + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC + from desloppify.languages._framework.treesitter.analysis.unused_imports import ( detect_unused_imports, ) @@ -695,8 +695,8 @@ def test_go_aliased_import_not_false_positive(self, tmp_path): def test_go_aliased_import_unused(self, tmp_path): """Go-style aliased import that IS unused should be detected.""" - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC - from desloppify.languages._framework.treesitter._unused_imports import ( + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC + from desloppify.languages._framework.treesitter.analysis.unused_imports import ( detect_unused_imports, ) @@ -724,7 +724,7 @@ def test_go_aliased_import_unused(self, tmp_path): def test_no_import_query_returns_empty(self, tmp_path): from desloppify.languages._framework.treesitter import TreeSitterLangSpec - from desloppify.languages._framework.treesitter._unused_imports import ( + from desloppify.languages._framework.treesitter.analysis.unused_imports import ( detect_unused_imports, ) @@ -744,10 +744,10 @@ def test_no_import_query_returns_empty(self, tmp_path): class TestSignatureVariance: def test_detects_variance(self, tmp_path): from desloppify.engine.detectors.signature import detect_signature_variance - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC # Create 3 files with same function name but different params. for i in range(3): @@ -765,10 +765,10 @@ def test_detects_variance(self, tmp_path): def test_no_variance_when_identical(self, tmp_path): from desloppify.engine.detectors.signature import detect_signature_variance - from desloppify.languages._framework.treesitter._extractors import ( + from desloppify.languages._framework.treesitter.analysis.extractors import ( ts_extract_functions, ) - from desloppify.languages._framework.treesitter._specs_compiled import GO_SPEC + from desloppify.languages._framework.treesitter.specs.compiled import GO_SPEC # Create 3 files with identical function signatures. for i in range(3): diff --git a/desloppify/tests/narrative/test_action_engine_routing_direct.py b/desloppify/tests/narrative/test_action_engine_routing_direct.py index 0c2a8f2b..4158fe7a 100644 --- a/desloppify/tests/narrative/test_action_engine_routing_direct.py +++ b/desloppify/tests/narrative/test_action_engine_routing_direct.py @@ -62,7 +62,7 @@ def test_build_refactor_entry_handles_special_detectors(monkeypatch) -> None: lambda _detector, _count: 0.8, ) assert subjective["command"] == "desloppify review --prepare" - assert "subjective dimension" in subjective["description"] + assert "assessment request" in subjective["description"] review = routing_mod._build_refactor_entry( "review", @@ -79,7 +79,7 @@ def test_build_refactor_entry_handles_special_detectors(monkeypatch) -> None: 2, lambda _detector, _count: 1.2, ) - assert generic["description"] == "2 smells issues — clean up" + assert generic["description"] == "2 smells work items — clean up" def test_append_refactor_actions_and_debt_action(monkeypatch) -> None: @@ -136,5 +136,3 @@ def test_assign_priorities_and_cluster_annotation() -> None: assert auto["clusters"] == ["clusterA"] assert auto["command"] == "desloppify next" assert "cluster(s)" in auto["description"] - - diff --git a/desloppify/tests/narrative/test_narrative_actions.py b/desloppify/tests/narrative/test_narrative_actions.py index 9921d925..8f1c068c 100644 --- a/desloppify/tests/narrative/test_narrative_actions.py +++ b/desloppify/tests/narrative/test_narrative_actions.py @@ -189,7 +189,7 @@ def test_smells_with_no_useeffect_issues_gets_manual_fix(self, empty_state): """When smells issues exist but none are dead_useeffect, no auto-fix for it.""" # State has a non-useeffect smell but by_detector still shows smells count state = dict(empty_state) - state["issues"] = { + state["work_items"] = { "smells::server.ts::debug_tag": { "status": "open", "detector": "smells", @@ -214,7 +214,7 @@ def test_smells_with_no_useeffect_issues_gets_manual_fix(self, empty_state): def test_smells_with_dead_useeffect_issue_gets_auto_fix(self, empty_state): """When a dead_useeffect issue exists, dead-useeffect fixer is suggested.""" state = dict(empty_state) - state["issues"] = { + state["work_items"] = { "smells::app.tsx::dead_useeffect": { "status": "open", "detector": "smells", @@ -238,7 +238,7 @@ def test_smells_with_dead_useeffect_issue_gets_auto_fix(self, empty_state): def test_smells_with_empty_if_chain_issue_gets_correct_fixer(self, empty_state): """When only empty_if_chain issues exist, empty-if-chain fixer is suggested.""" state = dict(empty_state) - state["issues"] = { + state["work_items"] = { "smells::util.ts::empty_if_chain": { "status": "open", "detector": "smells", diff --git a/desloppify/tests/narrative/test_narrative_strategy_and_review.py b/desloppify/tests/narrative/test_narrative_strategy_and_review.py index c9e2db42..26233fbb 100644 --- a/desloppify/tests/narrative/test_narrative_strategy_and_review.py +++ b/desloppify/tests/narrative/test_narrative_strategy_and_review.py @@ -884,7 +884,7 @@ def test_can_parallelize_false_single_lane(self): class TestReviewHeadline: - """Headline should mention review issues in all phases.""" + """Headline should mention review work items in all phases.""" def test_review_suffix_in_middle_grind(self): """Review suffix should appear even during middle_grind (not just maintenance).""" @@ -902,10 +902,10 @@ def test_review_suffix_in_middle_grind(self): open_by_detector=by_det, ) assert headline is not None - assert "review issue" in headline.lower() + assert "review work item" in headline.lower() def test_review_suffix_with_uninvestigated(self): - """Uninvestigated review issues should mention show review.""" + """Uninvestigated review work items should mention show review.""" by_det = {"review": 2, "review_uninvestigated": 2} headline = compute_headline( "maintenance", @@ -923,7 +923,7 @@ def test_review_suffix_with_uninvestigated(self): assert "desloppify show review" in headline def test_review_suffix_all_investigated(self): - """When all review issues are investigated, show 'pending' not 'issues'.""" + """When all review work items are investigated, show 'pending' not 'issues'.""" by_det = {"review": 2, "review_uninvestigated": 0} headline = compute_headline( "maintenance", @@ -957,7 +957,7 @@ def test_no_review_suffix_when_zero(self): ) # Should not mention review at all if headline: - assert "review issue" not in headline.lower() + assert "review work item" not in headline.lower() class TestReviewUninvestigatedCount: @@ -990,15 +990,17 @@ class TestReviewReminders: """Review-related reminders: pending issues + re-review needed.""" def _base_state(self): - return { - "issues": { - "r1": {"status": "open", "detector": "review", "detail": {}}, - "r2": { - "status": "open", - "detector": "review", - "detail": {"investigation": "done"}, - }, + work_items = { + "r1": {"status": "open", "detector": "review", "detail": {}}, + "r2": { + "status": "open", + "detector": "review", + "detail": {"investigation": "done"}, }, + } + return { + "work_items": work_items, + "issues": work_items, "reminder_history": {}, } @@ -1010,12 +1012,12 @@ def test_review_issues_pending_reminder(self): types = [r["type"] for r in reminders] assert "review_issues_pending" in types msg = next(r for r in reminders if r["type"] == "review_issues_pending") - assert "1 review issue" in msg["message"] + assert "1 review work item" in msg["message"] assert "desloppify show review" in msg["message"] def test_no_review_pending_when_all_investigated(self): state = self._base_state() - state["issues"]["r1"]["detail"]["investigation"] = "done too" + state["work_items"]["r1"]["detail"]["investigation"] = "done too" reminders, _ = compute_reminders( state, "typescript", "middle_grind", {}, [], {}, {}, "scan" ) @@ -1052,7 +1054,7 @@ def test_no_rereview_without_assessments(self): class TestStrategyReviewHint: - """Strategy hint should mention review issues when issue_queue action exists.""" + """Strategy hint should mention review work items when issue_queue action exists.""" def test_review_appended_to_hint(self): issues = _issues_dict( diff --git a/desloppify/tests/narrative/test_recovered_state_headline.py b/desloppify/tests/narrative/test_recovered_state_headline.py index 83333004..14b2192f 100644 --- a/desloppify/tests/narrative/test_recovered_state_headline.py +++ b/desloppify/tests/narrative/test_recovered_state_headline.py @@ -9,7 +9,7 @@ def test_compute_narrative_does_not_claim_first_scan_for_reconstructed_state() -> None: state = empty_state() state["stats"] = {"open": 2} - state["issues"] = { + state["work_items"] = { "review::src/foo.ts::abcd1234": { "id": "review::src/foo.ts::abcd1234", "status": "open", diff --git a/desloppify/tests/plan/test_auto_cluster.py b/desloppify/tests/plan/test_auto_cluster.py index 30b53089..845bbaad 100644 --- a/desloppify/tests/plan/test_auto_cluster.py +++ b/desloppify/tests/plan/test_auto_cluster.py @@ -6,6 +6,10 @@ _repair_ghost_cluster_refs, auto_cluster_issues, ) +from desloppify.engine._plan.cluster_semantics import ( + EXECUTION_POLICY_EPHEMERAL_AUTOPROMOTE, + EXECUTION_POLICY_PLANNED_ONLY, +) from desloppify.engine._plan.cluster_strategy import ( cluster_name_from_key, grouping_key, @@ -50,7 +54,7 @@ def _state_with(*issues: dict) -> dict: fmap = {} for f in issues: fmap[f["id"]] = f - return {"issues": fmap, "scan_count": 5} + return {"work_items": fmap, "issues": fmap, "scan_count": 5} # --------------------------------------------------------------------------- @@ -134,6 +138,8 @@ def test_auto_cluster_creates_cluster_from_issues(): assert cluster["auto"] is True assert set(cluster["issue_ids"]) == {"u1", "u2", "u3"} assert cluster["action"] is not None # should have fix command + assert cluster["action_type"] == "auto_fix" + assert cluster["execution_policy"] == EXECUTION_POLICY_EPHEMERAL_AUTOPROMOTE def test_auto_cluster_skips_singletons(): @@ -246,6 +252,26 @@ def test_remove_from_cluster_clears_focus_when_cluster_becomes_non_actionable(): assert plan["active_cluster"] is None +def test_remove_from_cluster_also_clears_step_issue_refs(): + plan = empty_plan() + ensure_plan_defaults(plan) + create_cluster(plan, "manual/cleanup") + add_to_cluster(plan, "manual/cleanup", ["u1", "u2"]) + plan["clusters"]["manual/cleanup"]["action_steps"] = [ + {"title": "Fix first issue", "issue_refs": ["u1"]}, + {"title": "Fix both issues", "issue_refs": ["u1", "u2"]}, + {"title": "Keep second issue", "issue_refs": ["u2"]}, + ] + + removed = remove_from_cluster(plan, "manual/cleanup", ["u1"]) + + assert removed == 1 + assert plan["clusters"]["manual/cleanup"]["issue_ids"] == ["u2"] + assert plan["clusters"]["manual/cleanup"]["action_steps"][0]["issue_refs"] == [] + assert plan["clusters"]["manual/cleanup"]["action_steps"][1]["issue_refs"] == ["u2"] + assert plan["clusters"]["manual/cleanup"]["action_steps"][2]["issue_refs"] == ["u2"] + + def test_auto_cluster_deletes_stale(): plan = empty_plan() state = _state_with( @@ -385,9 +411,27 @@ def test_ensure_plan_defaults_adds_cluster_fields(): assert cluster["auto"] is False assert cluster["cluster_key"] == "" assert cluster["action"] is None + assert cluster["action_type"] == "manual_fix" + assert cluster["execution_policy"] == EXECUTION_POLICY_PLANNED_ONLY assert cluster["user_modified"] is False +def test_ensure_plan_defaults_backfills_legacy_autofix_cluster_semantics(): + plan = empty_plan() + plan["clusters"]["auto/unused"] = { + "name": "auto/unused", + "auto": True, + "issue_ids": ["u1"], + "action": "desloppify autofix unused-imports --dry-run", + } + + ensure_plan_defaults(plan) + + cluster = plan["clusters"]["auto/unused"] + assert cluster["action_type"] == "auto_fix" + assert cluster["execution_policy"] == EXECUTION_POLICY_EPHEMERAL_AUTOPROMOTE + + # --------------------------------------------------------------------------- # Integration: build_work_queue with collapse # --------------------------------------------------------------------------- @@ -1150,7 +1194,7 @@ def test_judgment_required_issues_still_in_state(): auto_cluster_issues(plan, state) # smells issues still exist in state - assert "s1" in state["issues"] - assert "s2" in state["issues"] + assert "s1" in state["work_items"] + assert "s2" in state["work_items"] # unused auto-clusters normally assert "auto/unused" in plan["clusters"] diff --git a/desloppify/tests/plan/test_epic_triage.py b/desloppify/tests/plan/test_epic_triage.py index 5ca2ae61..daab2e66 100644 --- a/desloppify/tests/plan/test_epic_triage.py +++ b/desloppify/tests/plan/test_epic_triage.py @@ -30,9 +30,9 @@ def _state_with_review_issues(*ids: str) -> dict: """Build minimal state with open review issues.""" - issues = {} + work_items = {} for fid in ids: - issues[fid] = { + work_items[fid] = { "status": "open", "detector": "review", "file": "test.py", @@ -41,11 +41,12 @@ def _state_with_review_issues(*ids: str) -> dict: "tier": 2, "detail": {"dimension": "abstraction_fitness"}, } - return {"issues": issues, "scan_count": 5, "dimension_scores": {}} + return {"work_items": work_items, "issues": work_items, "scan_count": 5, "dimension_scores": {}} def _state_empty() -> dict: - return {"issues": {}, "scan_count": 1, "dimension_scores": {}} + work_items: dict[str, dict] = {} + return {"work_items": work_items, "issues": work_items, "scan_count": 1, "dimension_scores": {}} # --------------------------------------------------------------------------- @@ -174,7 +175,7 @@ def test_re_triggers_on_resolved_issue(self): plan = empty_plan() plan["epic_triage_meta"] = {"issue_snapshot_hash": h} # Resolve r2 - state["issues"]["r2"]["status"] = "fixed" + state["work_items"]["r2"]["status"] = "fixed" result = sync_triage_needed(plan, state) assert result.injected @@ -529,14 +530,23 @@ def test_unconfirmed_recorded_stage_remains_pending(self): def test_missing_queue_id_for_unconfirmed_stage_is_still_rendered(self): plan = empty_plan() - plan["queue_order"] = ["triage::enrich", "triage::sense-check", "triage::commit"] + plan["queue_order"] = [ + "triage::enrich", + "triage::sense-check", + "triage::commit", + ] plan["epic_triage_meta"] = { "triage_stages": {"organize": {"report": "cluster plan"}}, } state = _state_with_review_issues("r1") items = build_triage_stage_items(plan, state) ids = [it["id"] for it in items] - assert ids == ["triage::organize", "triage::enrich", "triage::sense-check", "triage::commit"] + assert ids == [ + "triage::organize", + "triage::enrich", + "triage::sense-check", + "triage::commit", + ] enrich = next(it for it in items if it["id"] == "triage::enrich") assert enrich["blocked_by"] == ["triage::organize"] @@ -549,12 +559,12 @@ class TestCollectTriageInput: def test_collects_open_review_issues(self): plan = empty_plan() state = _state_with_review_issues("r1", "r2") - state["issues"]["u1"] = {"status": "open", "detector": "unused"} + state["work_items"]["u1"] = {"status": "open", "detector": "unused"} si = collect_triage_input(plan, state) - assert len(si.open_issues) == 2 - assert "r1" in si.open_issues - assert len(si.mechanical_issues) == 1 - assert "u1" in si.mechanical_issues + assert len(si.review_issues) == 2 + assert "r1" in si.review_issues + assert len(si.objective_backlog_issues) == 1 + assert "u1" in si.objective_backlog_issues def test_includes_existing_clusters(self): plan = empty_plan() @@ -566,6 +576,35 @@ def test_includes_existing_clusters(self): si = collect_triage_input(plan, state) assert "epic/test" in si.existing_clusters + def test_collects_non_epic_auto_clusters_separately(self): + plan = empty_plan() + plan["clusters"]["epic/test"] = { + "name": "epic/test", + "thesis": "test", + "direction": "delete", + "issue_ids": [], + "auto": True, + "cluster_key": "epic::epic/test", + } + plan["clusters"]["auto/unused-imports"] = { + "name": "auto/unused-imports", + "issue_ids": ["u1"], + "auto": True, + "description": "Remove unused imports", + "action": "desloppify autofix import-cleanup --dry-run", + } + state = _state_with_review_issues("r1") + state["work_items"]["u1"] = {"status": "open", "detector": "unused"} + + si = collect_triage_input(plan, state) + + assert "epic/test" in si.existing_clusters + assert "auto/unused-imports" not in si.existing_clusters + assert "auto/unused-imports" in si.auto_clusters + assert si.auto_clusters["auto/unused-imports"]["action"] == ( + "desloppify autofix import-cleanup --dry-run" + ) + def test_tracks_new_since_last(self): plan = empty_plan() plan["epic_triage_meta"] = {"triaged_ids": ["r1"]} diff --git a/desloppify/tests/plan/test_epic_triage_prompt_direct.py b/desloppify/tests/plan/test_epic_triage_prompt_direct.py index fe06832e..c8e8647d 100644 --- a/desloppify/tests/plan/test_epic_triage_prompt_direct.py +++ b/desloppify/tests/plan/test_epic_triage_prompt_direct.py @@ -26,8 +26,8 @@ def test_build_triage_prompt_includes_completed_clusters_and_resolved_issue_cont dimension="abstraction_fitness", ) triage_input = TriageInput( - open_issues={open_id: open_issue}, - mechanical_issues={}, + review_issues={open_id: open_issue}, + objective_backlog_issues={}, existing_clusters={}, dimension_scores={}, new_since_last={open_id}, @@ -70,8 +70,8 @@ def test_build_triage_prompt_renders_recurring_dimension_summary() -> None: dimension="naming_quality", ) triage_input = TriageInput( - open_issues={open_id: open_issue}, - mechanical_issues={}, + review_issues={open_id: open_issue}, + objective_backlog_issues={}, existing_clusters={}, dimension_scores={}, new_since_last=set(), @@ -90,3 +90,54 @@ def test_build_triage_prompt_renders_recurring_dimension_summary() -> None: assert "## Potential recurring dimensions (resolved issues still have open peers)" in prompt assert "- api_surface_coherence: 1 open / 1 recently resolved" in prompt assert "naming_quality: 1 open" not in prompt + + +def test_build_triage_prompt_includes_mechanical_backlog_context() -> None: + open_id, open_issue = _issue( + "review::open::1111aaaa", + summary="Open API drift", + dimension="api_surface_coherence", + ) + triage_input = TriageInput( + review_issues={open_id: open_issue}, + objective_backlog_issues={ + "unused::src/a.py::dead": { + "detector": "unused", + "summary": "Unused export", + "file": "src/a.py", + "confidence": "high", + }, + "test_coverage::src/b.py::miss": { + "detector": "test_coverage", + "summary": "Missing behavioral coverage", + "file": "src/b.py", + "confidence": "medium", + }, + }, + auto_clusters={ + "auto/unused-imports": { + "auto": True, + "issue_ids": ["unused::src/a.py::dead"], + "description": "Remove 1 unused import issue", + "action": "desloppify autofix import-cleanup --dry-run", + } + }, + existing_clusters={}, + dimension_scores={}, + new_since_last=set(), + resolved_since_last=set(), + previously_dismissed=[], + triage_version=1, + resolved_issues={}, + completed_clusters=[], + ) + + prompt = build_triage_prompt(triage_input) + + assert "## Mechanical backlog (2 items: 1 in 1 auto-clusters, 1 unclustered)" in prompt + assert "### Auto-clusters" in prompt + assert "- auto/unused-imports (1 items) [autofix: desloppify autofix import-cleanup --dry-run]" in prompt + assert "Remove 1 unused import issue" in prompt + assert "### Unclustered items (1 items — needs human judgment or isolated findings)" in prompt + assert "- [medium] test_coverage::src/b.py::miss — Missing behavioral coverage" in prompt + assert "Inspect a cluster: `desloppify plan cluster show auto/<name>`" in prompt diff --git a/desloppify/tests/plan/test_epic_triage_reconcile_and_migration.py b/desloppify/tests/plan/test_epic_triage_reconcile_and_migration.py index f2f98924..b79fcba9 100644 --- a/desloppify/tests/plan/test_epic_triage_reconcile_and_migration.py +++ b/desloppify/tests/plan/test_epic_triage_reconcile_and_migration.py @@ -3,7 +3,7 @@ from __future__ import annotations from desloppify.engine._plan.triage.core import TriageResult, apply_triage_to_plan -from desloppify.engine._plan.reconcile import reconcile_plan_after_scan +from desloppify.engine._plan.scan_issue_reconcile import reconcile_plan_after_scan from desloppify.engine._plan.schema import empty_plan, ensure_plan_defaults, triage_clusters diff --git a/desloppify/tests/plan/test_persistence_runtime_paths.py b/desloppify/tests/plan/test_persistence_runtime_paths.py index 99a0d926..33729363 100644 --- a/desloppify/tests/plan/test_persistence_runtime_paths.py +++ b/desloppify/tests/plan/test_persistence_runtime_paths.py @@ -32,3 +32,35 @@ def test_plan_persistence_honors_monkeypatched_plan_file(monkeypatch, tmp_path): assert custom_plan_file.exists() assert loaded["queue_order"] == ["review::b.py::issue-2"] + + +def test_resolve_plan_load_status_marks_backup_recovery_degraded(tmp_path, capsys): + plan_file = tmp_path / "plan.json" + backup_file = tmp_path / "plan.json.bak" + plan_file.write_text("{not json", encoding="utf-8") + backup_file.write_text( + '{"version": 8, "created": "2026-01-01T00:00:00+00:00", "updated": "2026-01-01T00:00:00+00:00", "queue_order": ["review::a.py::issue-1"], "deferred": [], "skipped": {}, "active_cluster": null, "overrides": {}, "clusters": {}, "superseded": {}, "promoted_ids": [], "plan_start_scores": {}, "refresh_state": {}, "execution_log": [], "epic_triage_meta": {}, "commit_log": [], "uncommitted_issues": [], "commit_tracking_branch": null}\n', + encoding="utf-8", + ) + + status = persistence_mod.resolve_plan_load_status(plan_file) + + assert status.degraded is True + assert status.recovery == "backup" + assert status.error_kind == "JSONDecodeError" + assert status.plan is not None + assert status.plan["queue_order"] == ["review::a.py::issue-1"] + assert "recovered from backup" in capsys.readouterr().err + + +def test_resolve_plan_load_status_marks_fresh_start_when_recovery_fails(tmp_path, capsys): + plan_file = tmp_path / "plan.json" + plan_file.write_text("{not json", encoding="utf-8") + + status = persistence_mod.resolve_plan_load_status(plan_file) + + assert status.degraded is True + assert status.recovery == "fresh_start" + assert status.error_kind == "JSONDecodeError" + assert status.plan == empty_plan() + assert "starting fresh" in capsys.readouterr().err.lower() diff --git a/desloppify/tests/plan/test_phase_cleanup.py b/desloppify/tests/plan/test_phase_cleanup.py new file mode 100644 index 00000000..e163affb --- /dev/null +++ b/desloppify/tests/plan/test_phase_cleanup.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from desloppify.engine._plan.refresh_lifecycle import ( + LIFECYCLE_PHASE_EXECUTE, + LIFECYCLE_PHASE_REVIEW_POSTFLIGHT, + LIFECYCLE_PHASE_WORKFLOW_POSTFLIGHT, +) +from desloppify.engine._plan.schema import empty_plan +from desloppify.engine._plan.sync.phase_cleanup import prune_synthetic_for_phase + + +def test_workflow_cleanup_prunes_only_subjective_items() -> None: + plan = empty_plan() + plan["queue_order"] = [ + "subjective::naming_quality", + "workflow::communicate-score", + "triage::observe", + "unused::src/a.ts::x", + ] + plan["overrides"] = { + "subjective::naming_quality": {"issue_id": "subjective::naming_quality"}, + "workflow::communicate-score": {"issue_id": "workflow::communicate-score"}, + } + plan["clusters"] = { + "mixed": { + "name": "mixed", + "issue_ids": [ + "subjective::naming_quality", + "workflow::communicate-score", + "unused::src/a.ts::x", + ], + } + } + + pruned = prune_synthetic_for_phase(plan, LIFECYCLE_PHASE_WORKFLOW_POSTFLIGHT) + + assert pruned == ["subjective::naming_quality"] + assert plan["queue_order"] == [ + "workflow::communicate-score", + "triage::observe", + "unused::src/a.ts::x", + ] + assert "subjective::naming_quality" not in plan["overrides"] + assert plan["clusters"]["mixed"]["issue_ids"] == [ + "workflow::communicate-score", + "unused::src/a.ts::x", + ] + + +def test_review_postflight_cleanup_prunes_subjective_and_workflow() -> None: + plan = empty_plan() + plan["queue_order"] = [ + "subjective::naming_quality", + "workflow::communicate-score", + "review::src/a.ts::naming", + ] + + pruned = prune_synthetic_for_phase(plan, LIFECYCLE_PHASE_REVIEW_POSTFLIGHT) + + assert pruned == [ + "subjective::naming_quality", + "workflow::communicate-score", + ] + assert plan["queue_order"] == ["review::src/a.ts::naming"] + + +def test_execute_cleanup_prunes_all_synthetic_prefixes() -> None: + plan = empty_plan() + plan["queue_order"] = [ + "subjective::naming_quality", + "workflow::communicate-score", + "triage::observe", + "unused::src/a.ts::x", + ] + + pruned = prune_synthetic_for_phase(plan, LIFECYCLE_PHASE_EXECUTE) + + assert pruned == [ + "subjective::naming_quality", + "workflow::communicate-score", + "triage::observe", + ] + assert plan["queue_order"] == ["unused::src/a.ts::x"] diff --git a/desloppify/tests/plan/test_planned_objective_ids.py b/desloppify/tests/plan/test_planned_objective_ids.py deleted file mode 100644 index cb5a0746..00000000 --- a/desloppify/tests/plan/test_planned_objective_ids.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Tests for plan-tracked objective ID selection.""" - -from __future__ import annotations - -from desloppify.engine._plan.schema import planned_objective_ids - - -def test_planned_objective_ids_returns_all_when_plan_tracks_nothing() -> None: - all_ids = {"issue-1", "issue-2"} - - assert planned_objective_ids(all_ids, {"queue_order": [], "clusters": {}}) == all_ids - - -def test_planned_objective_ids_returns_overlap_when_live_tracked_ids_exist() -> None: - all_ids = {"issue-1", "issue-2", "issue-3"} - plan = { - "queue_order": ["issue-2"], - "clusters": {"c1": {"issue_ids": ["issue-3"], "action_steps": []}}, - "skipped": {}, - "overrides": {}, - } - - assert planned_objective_ids(all_ids, plan) == {"issue-2", "issue-3"} - - -def test_planned_objective_ids_returns_empty_when_tracked_ids_are_stale() -> None: - all_ids = {"issue-1", "issue-2"} - plan = { - "queue_order": ["missing-issue"], - "clusters": {"c1": {"issue_ids": ["missing-cluster-issue"], "action_steps": []}}, - "skipped": {}, - "overrides": {}, - } - - assert planned_objective_ids(all_ids, plan) == set() - - -def test_planned_objective_ids_ignores_synthetic_and_skipped_tracking() -> None: - all_ids = {"issue-1", "issue-2"} - plan = { - "queue_order": ["workflow::create-plan"], - "clusters": {}, - "skipped": {"issue-1": {"kind": "temporary"}}, - "overrides": {}, - } - - assert planned_objective_ids(all_ids, plan) == all_ids diff --git a/desloppify/tests/plan/test_reconcile.py b/desloppify/tests/plan/test_reconcile.py index 4e7d4b28..c2b0a695 100644 --- a/desloppify/tests/plan/test_reconcile.py +++ b/desloppify/tests/plan/test_reconcile.py @@ -3,7 +3,7 @@ from __future__ import annotations from desloppify.engine._plan.operations.cluster import add_to_cluster, create_cluster -from desloppify.engine._plan.reconcile import reconcile_plan_after_scan +from desloppify.engine._plan.scan_issue_reconcile import reconcile_plan_after_scan from desloppify.engine._plan.schema import empty_plan, ensure_plan_defaults # --------------------------------------------------------------------------- @@ -144,3 +144,24 @@ def test_reconcile_no_log_when_no_changes(): log = plan.get("execution_log", []) reconcile_entries = [e for e in log if e["action"] == "reconcile"] assert len(reconcile_entries) == 0 + + +def test_reconcile_prunes_existing_superseded_references(): + """Already-superseded IDs should not linger in queue_order or clusters.""" + plan = _plan_with_queue("a", "b") + ensure_plan_defaults(plan) + plan["superseded"]["a"] = { + "original_id": "a", + "status": "superseded", + "superseded_at": "2026-01-01T00:00:00+00:00", + } + plan["promoted_ids"] = ["a"] + create_cluster(plan, "my-cluster") + add_to_cluster(plan, "my-cluster", ["a", "b"]) + + result = reconcile_plan_after_scan(plan, _state_with_issues("b")) + + assert result.changes > 0 + assert "a" not in plan["queue_order"] + assert "a" not in plan["promoted_ids"] + assert "a" not in plan["clusters"]["my-cluster"]["issue_ids"] diff --git a/desloppify/tests/plan/test_reconcile_pipeline.py b/desloppify/tests/plan/test_reconcile_pipeline.py new file mode 100644 index 00000000..db37507b --- /dev/null +++ b/desloppify/tests/plan/test_reconcile_pipeline.py @@ -0,0 +1,578 @@ +"""Direct tests for the shared reconcile pipeline and queue ownership rules. + +Covers the gate matrix from centralize-postflight-pipeline.md: +- Boundary detection (fresh, mid-cycle, queue-clear) +- Phase isolation (promoted vs unpromoted clusters) +- Phantom resurrection / stuck queue guards +- Second reconcile is no-op (idempotency) +- Sentinel helper encapsulation +- workflow_injected_ids aggregation +""" + +from __future__ import annotations + +from desloppify.engine._plan.auto_cluster import auto_cluster_issues +from desloppify.engine._plan.constants import ( + WORKFLOW_COMMUNICATE_SCORE_ID, + WORKFLOW_CREATE_PLAN_ID, +) +from desloppify.engine._plan.schema import empty_plan +from desloppify.engine._plan.sync import live_planned_queue_empty, reconcile_plan +from desloppify.engine._plan.sync.workflow import clear_score_communicated_sentinel +from desloppify.engine._work_queue.snapshot import ( + PHASE_ASSESSMENT_POSTFLIGHT, + PHASE_EXECUTE, + PHASE_REVIEW_POSTFLIGHT, + PHASE_TRIAGE_POSTFLIGHT, + PHASE_WORKFLOW_POSTFLIGHT, + PHASE_SCAN, + build_queue_snapshot, +) + + +def _issue(issue_id: str, detector: str = "unused") -> dict: + return { + "id": issue_id, + "detector": detector, + "status": "open", + "file": "src/app.py", + "tier": 1, + "confidence": "high", + "summary": issue_id, + "detail": {}, + } + + +# --------------------------------------------------------------------------- +# Boundary detection +# --------------------------------------------------------------------------- + + +def test_live_planned_queue_empty_uses_queue_order_only() -> None: + """Overrides/clusters in plan do NOT expand the live queue.""" + plan = empty_plan() + plan["clusters"] = { + "manual/review": { + "name": "manual/review", + "issue_ids": ["unused::a"], + "execution_status": "active", + } + } + plan["overrides"] = { + "unused::a": { + "issue_id": "unused::a", + "cluster": "manual/review", + } + } + + assert live_planned_queue_empty(plan) is True + + +def test_live_planned_queue_not_empty_with_substantive_item() -> None: + plan = empty_plan() + plan["queue_order"] = ["unused::a"] + + assert live_planned_queue_empty(plan) is False + + +def test_live_planned_queue_empty_ignores_synthetic_items() -> None: + plan = empty_plan() + plan["queue_order"] = [ + "workflow::communicate-score", + "subjective::naming", + "triage::stage-1", + ] + + assert live_planned_queue_empty(plan) is True + + +def test_live_planned_queue_empty_ignores_skipped_items() -> None: + plan = empty_plan() + plan["queue_order"] = ["unused::a"] + plan["skipped"] = {"unused::a": {"reason": "manual"}} + + assert live_planned_queue_empty(plan) is True + + +def test_reconcile_plan_noops_when_live_queue_not_empty() -> None: + """Mid-cycle: pipeline is a no-op, no gates fire.""" + state = {"issues": {"unused::a": _issue("unused::a")}} + plan = empty_plan() + plan["queue_order"] = ["unused::a"] + plan["plan_start_scores"] = {"strict": 80.0} + + result = reconcile_plan(plan, state, target_strict=95.0) + + assert result.dirty is False + assert result.workflow_injected_ids == [] + assert plan["queue_order"] == ["unused::a"] + + +def test_reconcile_plan_second_call_is_noop() -> None: + """Idempotency: calling reconcile_plan twice at the same boundary + produces no additional mutations.""" + state = {"issues": {}} + plan = empty_plan() + + reconcile_plan(plan, state, target_strict=95.0) + queue_after_first = list(plan.get("queue_order", [])) + log_after_first = list(plan.get("log", [])) + + result2 = reconcile_plan(plan, state, target_strict=95.0) + + assert plan.get("queue_order", []) == queue_after_first + # Log may have lifecycle entries but should not grow on second call + assert len(plan.get("log", [])) == len(log_after_first) + # Second result should show no new dirty changes beyond lifecycle + # (lifecycle is always computed but should match, so not changed) + assert result2.auto_cluster_changes == 0 + assert result2.workflow_injected_ids == [] + + +def test_reconcile_plan_holds_workflow_until_current_scan_subjective_review_completes() -> None: + """Postflight review must run before communicate-score/create-plan.""" + state = { + "issues": {"unused::a": _issue("unused::a")}, + "scan_count": 19, + "dimension_scores": { + "Naming quality": { + "score": 82.0, + "strict": 82.0, + "failing": 0, + "checks": 1, + "detectors": { + "subjective_assessment": {"dimension_key": "naming_quality"}, + }, + } + }, + "subjective_assessments": { + "naming_quality": {"score": 82.0, "placeholder": False} + }, + } + plan = empty_plan() + plan["refresh_state"] = {"postflight_scan_completed_at_scan_count": 19} + + result = reconcile_plan(plan, state, target_strict=95.0) + + assert "subjective::naming_quality" in plan["queue_order"] + assert WORKFLOW_COMMUNICATE_SCORE_ID not in plan["queue_order"] + assert WORKFLOW_CREATE_PLAN_ID not in plan["queue_order"] + assert result.workflow_injected_ids == [] + + plan["queue_order"] = [ + issue_id + for issue_id in plan["queue_order"] + if issue_id != "subjective::naming_quality" + ] + plan["refresh_state"]["subjective_review_completed_at_scan_count"] = 19 + + result = reconcile_plan(plan, state, target_strict=95.0) + + assert WORKFLOW_COMMUNICATE_SCORE_ID in plan["queue_order"] + assert WORKFLOW_CREATE_PLAN_ID in plan["queue_order"] + assert result.workflow_injected_ids == [ + WORKFLOW_COMMUNICATE_SCORE_ID, + WORKFLOW_CREATE_PLAN_ID, + ] + + +# --------------------------------------------------------------------------- +# Mid-cycle auto-clustering guard +# --------------------------------------------------------------------------- + + +def test_auto_cluster_issues_is_noop_mid_cycle() -> None: + state = { + "issues": { + "unused::a": _issue("unused::a"), + "unused::b": _issue("unused::b"), + } + } + plan = empty_plan() + plan["queue_order"] = ["unused::a"] + plan["plan_start_scores"] = {"strict": 80.0} + + changes = auto_cluster_issues(plan, state) + + assert changes == 0 + assert plan["clusters"] == {} + + +# --------------------------------------------------------------------------- +# Phase isolation: promoted vs unpromoted clusters +# --------------------------------------------------------------------------- + + +def test_queue_snapshot_executes_review_items_promoted_into_active_cluster() -> None: + """Active cluster with items in queue_order → EXECUTE phase.""" + state = { + "issues": { + "review::a": _issue("review::a", detector="review"), + } + } + plan = empty_plan() + plan["queue_order"] = ["review::a"] + plan["plan_start_scores"] = {"strict": 80.0} + plan["epic_triage_meta"] = { + "triaged_ids": ["review::a"], + "issue_snapshot_hash": "stable", + } + plan["clusters"] = { + "epic/review": { + "name": "epic/review", + "issue_ids": ["review::a"], + "execution_status": "active", + } + } + + snapshot = build_queue_snapshot(state, plan=plan) + + assert snapshot.phase == PHASE_EXECUTE + assert [item["id"] for item in snapshot.execution_items] == ["review::a"] + + +def test_queue_snapshot_keeps_unpromoted_review_cluster_in_postflight() -> None: + """Review cluster (execution_status: review) → postflight, not execute.""" + state = { + "issues": { + "review::a": _issue("review::a", detector="review"), + } + } + plan = empty_plan() + plan["epic_triage_meta"] = { + "triaged_ids": ["review::a"], + "issue_snapshot_hash": "stable", + } + plan["refresh_state"] = {"postflight_scan_completed_at_scan_count": 1} + plan["clusters"] = { + "manual/review": { + "name": "manual/review", + "issue_ids": ["review::a"], + "execution_status": "review", + } + } + + snapshot = build_queue_snapshot(state, plan=plan) + + assert live_planned_queue_empty(plan) is True + assert snapshot.phase == PHASE_REVIEW_POSTFLIGHT + assert [item["id"] for item in snapshot.execution_items] == ["review::a"] + + +def test_phase_isolation_mixed_objective_and_unpromoted_review() -> None: + """Objective work in queue + unpromoted review findings → only objective + items in execution, review stays postflight.""" + state = { + "issues": { + "unused::obj": _issue("unused::obj"), + "review::rev": _issue("review::rev", detector="review"), + } + } + plan = empty_plan() + plan["queue_order"] = ["unused::obj"] + plan["plan_start_scores"] = {"strict": 80.0} + plan["epic_triage_meta"] = { + "triaged_ids": ["review::rev"], + "issue_snapshot_hash": "stable", + } + plan["clusters"] = { + "manual/review": { + "name": "manual/review", + "issue_ids": ["review::rev"], + "execution_status": "review", + } + } + + snapshot = build_queue_snapshot(state, plan=plan) + + assert snapshot.phase == PHASE_EXECUTE + execution_ids = [item["id"] for item in snapshot.execution_items] + assert "unused::obj" in execution_ids + assert "review::rev" not in execution_ids + + +def test_postflight_phase_stays_exclusive_when_new_execute_items_exist() -> None: + """Fresh execute work discovered during postflight stays backlog-only until postflight ends.""" + state = { + "issues": { + "unused::obj": _issue("unused::obj"), + }, + "dimension_scores": { + "Naming quality": { + "score": 70.0, + "strict": 70.0, + "failing": 1, + "detectors": { + "subjective_assessment": {"dimension_key": "naming_quality"}, + }, + }, + }, + "subjective_assessments": { + "naming_quality": {"score": 70.0, "needs_review_refresh": True}, + }, + } + plan = empty_plan() + plan["queue_order"] = [ + "unused::obj", + "workflow::communicate-score", + "triage::observe", + ] + plan["refresh_state"] = { + "postflight_scan_completed_at_scan_count": 5, + "lifecycle_phase": "review", + } + plan["plan_start_scores"] = {"strict": 70.0, "overall": 70.0} + + snapshot = build_queue_snapshot(state, plan=plan) + + assert snapshot.phase == PHASE_ASSESSMENT_POSTFLIGHT + assert [item["id"] for item in snapshot.execution_items] == ["subjective::naming_quality"] + assert "unused::obj" in [item["id"] for item in snapshot.backlog_items] + + +def test_postflight_execute_items_reappear_after_postflight_drains() -> None: + """Once postflight items are done, queued execute work becomes live again.""" + state = { + "issues": { + "unused::obj": _issue("unused::obj"), + }, + } + plan = empty_plan() + plan["queue_order"] = ["unused::obj"] + plan["refresh_state"] = { + "postflight_scan_completed_at_scan_count": 5, + "lifecycle_phase": "workflow", + } + + snapshot = build_queue_snapshot(state, plan=plan) + + assert snapshot.phase == PHASE_EXECUTE + assert [item["id"] for item in snapshot.execution_items] == ["unused::obj"] + + +def test_sticky_postflight_advances_through_remaining_subphases() -> None: + """Sticky postflight respects the fixed ordered sequence while active.""" + state = {"issues": {}} + + workflow_plan = empty_plan() + workflow_plan["queue_order"] = ["workflow::communicate-score", "triage::observe"] + workflow_plan["refresh_state"] = { + "postflight_scan_completed_at_scan_count": 5, + "lifecycle_phase": "workflow", + } + workflow_snapshot = build_queue_snapshot(state, plan=workflow_plan) + assert workflow_snapshot.phase == PHASE_WORKFLOW_POSTFLIGHT + + triage_plan = empty_plan() + triage_plan["queue_order"] = ["triage::observe"] + triage_plan["refresh_state"] = { + "postflight_scan_completed_at_scan_count": 5, + "lifecycle_phase": "triage", + } + triage_snapshot = build_queue_snapshot(state, plan=triage_plan) + assert triage_snapshot.phase == PHASE_TRIAGE_POSTFLIGHT + + +# --------------------------------------------------------------------------- +# Phantom resurrection guard +# --------------------------------------------------------------------------- + + +def test_phantom_resurrection_guard_overrides_not_in_queue() -> None: + """Items in overrides/clusters but NOT in queue_order must NOT be treated + as live queue work.""" + plan = empty_plan() + plan["queue_order"] = [] # explicitly empty + plan["overrides"] = { + "unused::ghost": { + "issue_id": "unused::ghost", + "cluster": "manual/ghost", + } + } + plan["clusters"] = { + "manual/ghost": { + "name": "manual/ghost", + "issue_ids": ["unused::ghost"], + "execution_status": "active", + } + } + + assert live_planned_queue_empty(plan) is True + + state = {"issues": {"unused::ghost": _issue("unused::ghost")}} + snapshot = build_queue_snapshot(state, plan=plan) + + # Phase should NOT be EXECUTE — queue is empty + assert snapshot.phase != PHASE_EXECUTE or not any( + item["id"] == "unused::ghost" for item in snapshot.execution_items + if not item.get("kind") == "cluster" + ) + + +# --------------------------------------------------------------------------- +# Stuck queue guard +# --------------------------------------------------------------------------- + + +def test_stuck_queue_guard_removed_item_stays_gone() -> None: + """Item removed from queue_order but still in overrides/clusters is NOT + resurrected into the live queue.""" + plan = empty_plan() + plan["queue_order"] = ["unused::kept"] + plan["overrides"] = { + "unused::removed": { + "issue_id": "unused::removed", + "cluster": "manual/old", + } + } + plan["clusters"] = { + "manual/old": { + "name": "manual/old", + "issue_ids": ["unused::removed"], + "execution_status": "active", + } + } + + assert live_planned_queue_empty(plan) is False + + state = { + "issues": { + "unused::kept": _issue("unused::kept"), + "unused::removed": _issue("unused::removed"), + } + } + snapshot = build_queue_snapshot(state, plan=plan) + + execution_ids = {item["id"] for item in snapshot.execution_items} + assert "unused::kept" in execution_ids + # The removed item should not reappear in execution + assert "unused::removed" not in execution_ids + + +# --------------------------------------------------------------------------- +# Sentinel helper +# --------------------------------------------------------------------------- + + +def test_clear_score_communicated_sentinel() -> None: + """Helper removes the sentinel key; missing key is a no-op.""" + plan = empty_plan() + plan["previous_plan_start_scores"] = {"strict": 80.0} + + clear_score_communicated_sentinel(plan) + assert "previous_plan_start_scores" not in plan + + # Second call is a no-op (no KeyError) + clear_score_communicated_sentinel(plan) + assert "previous_plan_start_scores" not in plan + + +def test_sentinel_blocks_communicate_score_reinjection() -> None: + """When the sentinel is set, communicate-score does not re-inject.""" + from desloppify.engine._plan.sync.workflow import sync_communicate_score_needed + + plan = empty_plan() + plan["previous_plan_start_scores"] = {"strict": 80.0} + state: dict = {"issues": {}} + + result = sync_communicate_score_needed(plan, state) + assert not result.changes + + # After clearing sentinel, gate may fire + clear_score_communicated_sentinel(plan) + assert "previous_plan_start_scores" not in plan + + +# --------------------------------------------------------------------------- +# workflow_injected_ids aggregation +# --------------------------------------------------------------------------- + + +def test_workflow_injected_ids_aggregates_both_gates() -> None: + from desloppify.engine._plan.constants import QueueSyncResult + from desloppify.engine._plan.sync.pipeline import ReconcileResult + + result = ReconcileResult( + communicate_score=QueueSyncResult( + injected=[WORKFLOW_COMMUNICATE_SCORE_ID], + ), + create_plan=QueueSyncResult( + injected=[WORKFLOW_CREATE_PLAN_ID], + ), + ) + + ids = result.workflow_injected_ids + assert WORKFLOW_COMMUNICATE_SCORE_ID in ids + assert WORKFLOW_CREATE_PLAN_ID in ids + assert len(ids) == 2 + + +def test_workflow_injected_ids_empty_when_no_gates_fire() -> None: + from desloppify.engine._plan.sync.pipeline import ReconcileResult + + result = ReconcileResult() + assert result.workflow_injected_ids == [] + + +# --------------------------------------------------------------------------- +# Lifecycle does not persist when phase unchanged +# --------------------------------------------------------------------------- + + +def test_lifecycle_does_not_persist_when_unchanged() -> None: + """If the resolved phase matches the current lifecycle_phase value, + lifecycle_phase_changed should be False.""" + state = {"issues": {}} + plan = empty_plan() + + # First call sets lifecycle + result1 = reconcile_plan(plan, state, target_strict=95.0) + assert result1.lifecycle_phase_changed is True + + # Second call at same boundary — phase unchanged + result2 = reconcile_plan(plan, state, target_strict=95.0) + assert result2.lifecycle_phase == result1.lifecycle_phase + assert result2.lifecycle_phase_changed is False + + +# --------------------------------------------------------------------------- +# Pipeline does not touch scan-specific state +# --------------------------------------------------------------------------- + + +def test_pipeline_does_not_seed_plan_start_scores() -> None: + """reconcile_plan never writes plan_start_scores — that's scan-specific.""" + state = {"issues": {}} + plan = empty_plan() + assert not plan.get("plan_start_scores") + + reconcile_plan(plan, state, target_strict=95.0) + + # Should still be empty/falsy — pipeline doesn't seed it + assert not plan.get("plan_start_scores") + + +def test_pipeline_does_not_mark_postflight_scan_complete() -> None: + """reconcile_plan never sets postflight_scan_completed_at_scan_count.""" + state = {"issues": {}} + plan = empty_plan() + + reconcile_plan(plan, state, target_strict=95.0) + + refresh = plan.get("refresh_state", {}) + assert "postflight_scan_completed_at_scan_count" not in refresh + + +# --------------------------------------------------------------------------- +# Fresh boundary behavior +# --------------------------------------------------------------------------- + + +def test_fresh_boundary_empty_state_resolves_scan_phase() -> None: + """Empty state + no plan_start_scores → fresh boundary → scan phase.""" + state = {"issues": {}} + plan = empty_plan() + + snapshot = build_queue_snapshot(state, plan=plan) + + assert snapshot.phase == PHASE_SCAN diff --git a/desloppify/tests/plan/test_refresh_lifecycle.py b/desloppify/tests/plan/test_refresh_lifecycle.py index 0d33efee..3be8d289 100644 --- a/desloppify/tests/plan/test_refresh_lifecycle.py +++ b/desloppify/tests/plan/test_refresh_lifecycle.py @@ -1,16 +1,15 @@ from __future__ import annotations from desloppify.engine._plan.refresh_lifecycle import ( + coarse_lifecycle_phase, clear_postflight_scan_completion, current_lifecycle_phase, LIFECYCLE_PHASE_EXECUTE, LIFECYCLE_PHASE_REVIEW, + LIFECYCLE_PHASE_REVIEW_POSTFLIGHT, LIFECYCLE_PHASE_SCAN, - LIFECYCLE_PHASE_TRIAGE, - LIFECYCLE_PHASE_WORKFLOW, mark_postflight_scan_completed, postflight_scan_pending, - sync_lifecycle_phase, ) from desloppify.engine._plan.schema import empty_plan @@ -47,6 +46,20 @@ def test_clearing_completion_for_real_issue_requires_new_scan() -> None: changed = clear_postflight_scan_completion( plan, issue_ids=["unused::src/app.ts::thing"], + state={ + "issues": { + "unused::src/app.ts::thing": { + "id": "unused::src/app.ts::thing", + "detector": "unused", + "status": "open", + "file": "src/app.ts", + "tier": 1, + "confidence": "high", + "summary": "unused import", + "detail": {}, + } + } + }, ) assert changed is True @@ -54,6 +67,33 @@ def test_clearing_completion_for_real_issue_requires_new_scan() -> None: assert current_lifecycle_phase(plan) == LIFECYCLE_PHASE_EXECUTE +def test_clearing_completion_for_review_issue_keeps_current_scan_boundary() -> None: + plan = empty_plan() + mark_postflight_scan_completed(plan, scan_count=5) + + changed = clear_postflight_scan_completion( + plan, + issue_ids=["review::src/app.ts::naming"], + state={ + "issues": { + "review::src/app.ts::naming": { + "id": "review::src/app.ts::naming", + "detector": "review", + "status": "open", + "file": "src/app.ts", + "tier": 1, + "confidence": "high", + "summary": "naming issue", + "detail": {"dimension": "naming_quality"}, + } + } + }, + ) + + assert changed is False + assert postflight_scan_pending(plan) is False + + def test_current_lifecycle_phase_falls_back_for_legacy_plans() -> None: plan = empty_plan() assert current_lifecycle_phase(plan) == LIFECYCLE_PHASE_SCAN @@ -63,68 +103,8 @@ def test_current_lifecycle_phase_falls_back_for_legacy_plans() -> None: assert current_lifecycle_phase(plan) == LIFECYCLE_PHASE_EXECUTE -def test_sync_lifecycle_phase_persists_explicit_phase_order() -> None: +def test_coarse_lifecycle_phase_maps_fine_phases() -> None: plan = empty_plan() + plan["refresh_state"] = {"lifecycle_phase": LIFECYCLE_PHASE_REVIEW_POSTFLIGHT} - phase, changed = sync_lifecycle_phase( - plan, - has_initial_reviews=True, - has_objective_backlog=False, - has_postflight_review=False, - has_postflight_workflow=False, - has_triage=False, - has_deferred=False, - ) - assert changed is True - assert phase == LIFECYCLE_PHASE_REVIEW - assert current_lifecycle_phase(plan) == LIFECYCLE_PHASE_REVIEW - - mark_postflight_scan_completed(plan, scan_count=5) - phase, changed = sync_lifecycle_phase( - plan, - has_initial_reviews=False, - has_objective_backlog=False, - has_postflight_review=False, - has_postflight_workflow=True, - has_triage=False, - has_deferred=False, - ) - assert changed is True - assert phase == LIFECYCLE_PHASE_WORKFLOW - - phase, changed = sync_lifecycle_phase( - plan, - has_initial_reviews=False, - has_objective_backlog=False, - has_postflight_review=False, - has_postflight_workflow=False, - has_triage=True, - has_deferred=False, - ) - assert changed is True - assert phase == LIFECYCLE_PHASE_TRIAGE - - phase, changed = sync_lifecycle_phase( - plan, - has_initial_reviews=False, - has_objective_backlog=True, - has_postflight_review=False, - has_postflight_workflow=False, - has_triage=False, - has_deferred=False, - ) - assert changed is True - assert phase == LIFECYCLE_PHASE_EXECUTE - - plan["refresh_state"].pop("postflight_scan_completed_at_scan_count", None) - phase, changed = sync_lifecycle_phase( - plan, - has_initial_reviews=False, - has_objective_backlog=False, - has_postflight_review=False, - has_postflight_workflow=False, - has_triage=False, - has_deferred=False, - ) - assert changed is True - assert phase == LIFECYCLE_PHASE_SCAN + assert coarse_lifecycle_phase(plan) == LIFECYCLE_PHASE_REVIEW diff --git a/desloppify/tests/plan/test_skip.py b/desloppify/tests/plan/test_skip.py index b4926b4a..f62e9ca8 100644 --- a/desloppify/tests/plan/test_skip.py +++ b/desloppify/tests/plan/test_skip.py @@ -14,7 +14,7 @@ skip_items, unskip_items, ) -from desloppify.engine._plan.reconcile import reconcile_plan_after_scan +from desloppify.engine._plan.scan_issue_reconcile import reconcile_plan_after_scan from desloppify.engine._plan.schema import ( empty_plan, ensure_plan_defaults, diff --git a/desloppify/tests/plan/test_stale_dimensions.py b/desloppify/tests/plan/test_stale_dimensions.py index 679f8469..0bbcb0a5 100644 --- a/desloppify/tests/plan/test_stale_dimensions.py +++ b/desloppify/tests/plan/test_stale_dimensions.py @@ -2,7 +2,7 @@ from __future__ import annotations -from desloppify.engine._plan.reconcile import reconcile_plan_after_scan +from desloppify.engine._plan.scan_issue_reconcile import reconcile_plan_after_scan from desloppify.engine._plan.schema import empty_plan from desloppify.engine._plan.sync.dimensions import sync_subjective_dimensions @@ -39,8 +39,10 @@ def _state_with_stale_dimensions(*dim_keys: str, score: float = 50.0) -> dict: "refresh_reason": "mechanical_issues_changed", "stale_since": "2025-01-01T00:00:00+00:00", } + work_items: dict[str, dict] = {} return { - "issues": {}, + "work_items": work_items, + "issues": work_items, "scan_count": 5, "dimension_scores": dim_scores, "subjective_assessments": assessments, @@ -69,8 +71,10 @@ def _state_with_unscored_dimensions(*dim_keys: str) -> dict: "source": "scan_reset_subjective", "placeholder": True, } + work_items: dict[str, dict] = {} return { - "issues": {}, + "work_items": work_items, + "issues": work_items, "scan_count": 1, "dimension_scores": dim_scores, "subjective_assessments": assessments, @@ -98,8 +102,10 @@ def _state_with_under_target_dimensions(*dim_keys: str, score: float = 50.0) -> "score": score, "needs_review_refresh": False, } + work_items: dict[str, dict] = {} return { - "issues": {}, + "work_items": work_items, + "issues": work_items, "scan_count": 5, "dimension_scores": dim_scores, "subjective_assessments": assessments, @@ -206,7 +212,8 @@ def test_unscored_sync_does_not_prune_stale_ids(): def test_unscored_no_injection_when_no_dimension_scores(): plan = _plan_with_queue() - state = {"issues": {}, "scan_count": 1} + work_items: dict[str, dict] = {} + state = {"work_items": work_items, "issues": work_items, "scan_count": 1} result = sync_subjective_dimensions(plan, state) assert result.injected == [] @@ -267,7 +274,7 @@ def test_no_injection_when_queue_has_real_items(): plan = _plan_with_queue("some_issue::file.py::abc123") state = _state_with_stale_dimensions("design_coherence") # Add an actual open objective issue to state (source of truth) - state["issues"]["some_issue::file.py::abc123"] = { + state["work_items"]["some_issue::file.py::abc123"] = { "id": "some_issue::file.py::abc123", "status": "open", "detector": "smells", @@ -286,7 +293,7 @@ def test_stale_ids_evicted_when_objective_backlog_exists(): "some_issue::file.py::abc123", ) state = _state_with_stale_dimensions("design_coherence", "error_consistency") - state["issues"]["some_issue::file.py::abc123"] = { + state["work_items"]["some_issue::file.py::abc123"] = { "id": "some_issue::file.py::abc123", "status": "open", "detector": "smells", @@ -306,7 +313,7 @@ def test_stale_ids_inject_when_backlog_clears(): """Stale IDs inject when objective backlog clears.""" plan = _plan_with_queue("some_issue::file.py::abc123") state = _state_with_stale_dimensions("design_coherence") - state["issues"]["some_issue::file.py::abc123"] = { + state["work_items"]["some_issue::file.py::abc123"] = { "id": "some_issue::file.py::abc123", "status": "open", "detector": "smells", @@ -317,7 +324,7 @@ def test_stale_ids_inject_when_backlog_clears(): assert r1.injected == [] # Objective backlog clears - state["issues"]["some_issue::file.py::abc123"]["status"] = "done" + state["work_items"]["some_issue::file.py::abc123"]["status"] = "done" r2 = sync_subjective_dimensions(plan, state) assert "subjective::design_coherence" in r2.injected @@ -579,7 +586,7 @@ def test_under_target_evicted_mid_cycle_with_objective_backlog(): stale=[], under_target=["naming_quality"], ) - state["issues"]["some_issue::file.py::abc123"] = { + state["work_items"]["some_issue::file.py::abc123"] = { "id": "some_issue::file.py::abc123", "status": "open", "detector": "smells", @@ -602,7 +609,7 @@ def test_under_target_reinjected_after_objective_backlog_clears(): stale=[], under_target=["naming_quality", "error_handling"], ) - state["issues"]["some_issue::file.py::abc123"] = { + state["work_items"]["some_issue::file.py::abc123"] = { "id": "some_issue::file.py::abc123", "status": "open", "detector": "smells", @@ -614,7 +621,7 @@ def test_under_target_reinjected_after_objective_backlog_clears(): assert "subjective::naming_quality" not in plan["queue_order"] # Step 2: objective backlog clears - state["issues"]["some_issue::file.py::abc123"]["status"] = "done" + state["work_items"]["some_issue::file.py::abc123"]["status"] = "done" # Step 3: under_target IDs injected r2 = sync_subjective_dimensions(plan, state) @@ -634,7 +641,7 @@ def test_escalation_mid_cycle_reinserts_evicted_ids(): plan = _plan_with_queue("some_issue::file.py::abc123") plan["plan_start_scores"] = {"strict": 50.0} # mid-cycle state = _state_with_under_target_dimensions("naming_quality") - state["issues"] = { + state["work_items"] = { "some_issue::file.py::abc123": { "id": "some_issue::file.py::abc123", "status": "open", diff --git a/desloppify/tests/plan/test_stale_dimensions_cycle_and_queue_order.py b/desloppify/tests/plan/test_stale_dimensions_cycle_and_queue_order.py index 4262c367..71441567 100644 --- a/desloppify/tests/plan/test_stale_dimensions_cycle_and_queue_order.py +++ b/desloppify/tests/plan/test_stale_dimensions_cycle_and_queue_order.py @@ -49,7 +49,7 @@ def test_cycle_completed_injects_stale_despite_objective_backlog(): """After a completed cycle, stale dims inject even with new objective issues.""" plan = _plan_with_queue("some_issue::file.py::abc123") state = _state_with_stale_dimensions("design_coherence", "error_consistency") - state["issues"]["some_issue::file.py::abc123"] = { + state["work_items"]["some_issue::file.py::abc123"] = { "id": "some_issue::file.py::abc123", "status": "open", "detector": "smells", @@ -73,7 +73,7 @@ def test_cycle_completed_appends_to_back(): """Post-cycle stale injection appends to back, preserving existing order.""" plan = _plan_with_queue("issue_a", "issue_b") state = _state_with_stale_dimensions("design_coherence") - state["issues"]["issue_a"] = { + state["work_items"]["issue_a"] = { "id": "issue_a", "status": "open", "detector": "smells", } @@ -90,7 +90,7 @@ def test_cycle_completed_injects_under_target_dims(): # Dimension is below target but NOT stale (no needs_review_refresh) state = _state_with_stale_dimensions("design_coherence") state["subjective_assessments"]["design_coherence"]["needs_review_refresh"] = False - state["issues"]["some_issue::file.py::abc123"] = { + state["work_items"]["some_issue::file.py::abc123"] = { "id": "some_issue::file.py::abc123", "status": "open", "detector": "smells", @@ -121,7 +121,8 @@ def test_under_target_injected_when_no_objective_backlog(): def test_cycle_completed_no_stale_dims_no_injection(): """cycle_just_completed has no effect when no stale dims exist.""" plan = _plan_with_queue("some_issue::file.py::abc123") - state = {"issues": {}, "scan_count": 5} + work_items: dict[str, dict] = {} + state = {"work_items": work_items, "issues": work_items, "scan_count": 5} result = sync_subjective_dimensions(plan, state, cycle_just_completed=True) assert result.injected == [] @@ -164,10 +165,12 @@ def test_triage_appends_to_back(): plan = _plan_with_queue("issue_a", "issue_b") plan["epic_triage_meta"] = {"issue_snapshot_hash": "old_hash"} + work_items = { + "review::file.py::abc": {"status": "open", "detector": "review"}, + } state = { - "issues": { - "review::file.py::abc": {"status": "open", "detector": "review"}, - }, + "work_items": work_items, + "issues": work_items, "scan_count": 5, } diff --git a/desloppify/tests/plan/test_subjective_policy.py b/desloppify/tests/plan/test_subjective_policy.py index 90655a81..2dab3184 100644 --- a/desloppify/tests/plan/test_subjective_policy.py +++ b/desloppify/tests/plan/test_subjective_policy.py @@ -106,7 +106,10 @@ def test_objective_issues_counted(): _issue("u2", "unused"), _issue("r1", "review"), # non-objective ) - policy = compute_subjective_visibility(state) + policy = compute_subjective_visibility( + state, + plan={"queue_order": ["u1", "u2", "r1"], "skipped": {}}, + ) assert policy.has_objective_backlog is True assert policy.objective_count == 2 @@ -115,7 +118,10 @@ def test_suppressed_issues_excluded(): state = _state_with_issues( _issue("u1", "unused", suppressed=True), ) - policy = compute_subjective_visibility(state) + policy = compute_subjective_visibility( + state, + plan={"queue_order": ["u1"], "skipped": {}}, + ) assert policy.has_objective_backlog is False assert policy.objective_count == 0 @@ -124,7 +130,10 @@ def test_closed_issues_excluded(): state = _state_with_issues( _issue("u1", "unused", status="resolved"), ) - policy = compute_subjective_visibility(state) + policy = compute_subjective_visibility( + state, + plan={"queue_order": ["u1"], "skipped": {}}, + ) assert policy.has_objective_backlog is False @@ -135,7 +144,10 @@ def test_non_objective_detectors_excluded(): _issue("sr1", "subjective_review"), _issue("sa1", "subjective_assessment"), ) - policy = compute_subjective_visibility(state) + policy = compute_subjective_visibility( + state, + plan={"queue_order": ["r1", "c1", "sr1", "sa1"], "skipped": {}}, + ) assert policy.has_objective_backlog is False assert policy.objective_count == 0 diff --git a/desloppify/tests/plan/test_triage_phase_banner.py b/desloppify/tests/plan/test_triage_phase_banner.py index ca6c7fad..76e1fd06 100644 --- a/desloppify/tests/plan/test_triage_phase_banner.py +++ b/desloppify/tests/plan/test_triage_phase_banner.py @@ -26,6 +26,22 @@ def test_banner_pending_when_objective_backlog_exists(): assert banner.startswith("TRIAGE PENDING") +def test_banner_pending_when_stale_triage_is_deferred_behind_objective_backlog(): + plan = empty_plan() + plan["plan_start_scores"] = {"strict": 72.0} + plan["epic_triage_meta"] = {"triaged_ids": ["review::old"]} + state = { + "issues": { + "obj-1": {"id": "obj-1", "status": "open", "detector": "complexity"}, + "review::old": {"id": "review::old", "status": "open", "detector": "review"}, + "review::new": {"id": "review::new", "status": "open", "detector": "review"}, + } + } + + banner = triage_phase_banner(plan, state) + assert banner.startswith("TRIAGE PENDING") + + def test_banner_mode_when_no_objective_backlog(): plan = empty_plan() plan["queue_order"] = list(TRIAGE_STAGE_IDS) diff --git a/desloppify/tests/plan/test_unified_status_lifecycle.py b/desloppify/tests/plan/test_unified_status_lifecycle.py index b9596d35..8538b159 100644 --- a/desloppify/tests/plan/test_unified_status_lifecycle.py +++ b/desloppify/tests/plan/test_unified_status_lifecycle.py @@ -17,7 +17,7 @@ skip_items, unskip_items, ) -from desloppify.engine._plan.reconcile import reconcile_plan_after_scan +from desloppify.engine._plan.scan_issue_reconcile import reconcile_plan_after_scan from desloppify.engine._plan.schema import empty_plan from desloppify.engine._plan.skip_policy import ( skip_kind_needs_state_reopen, @@ -212,9 +212,9 @@ def test_triage_apply_sets_triaged_out_in_state(self): result = apply_triage_to_plan(plan, state, triage, trigger="test") assert result.issues_dismissed == 1 # State status should be updated - assert state["issues"]["b"]["status"] == "triaged_out" + assert state["work_items"]["b"]["status"] == "triaged_out" # Non-dismissed issue should stay open - assert state["issues"]["a"]["status"] == "open" + assert state["work_items"]["a"]["status"] == "open" # --------------------------------------------------------------------------- @@ -299,9 +299,9 @@ def test_reconcile_syncs_open_skipped_to_deferred(self): "scan_count": 5, } reconcile_plan_after_scan(plan, state) - assert state["issues"]["a"]["status"] == "deferred" - assert state["issues"]["b"]["status"] == "triaged_out" - assert state["issues"]["c"]["status"] == "open" + assert state["work_items"]["a"]["status"] == "deferred" + assert state["work_items"]["b"]["status"] == "triaged_out" + assert state["work_items"]["c"]["status"] == "open" def test_reconcile_does_not_re_sync_already_correct(self): plan = empty_plan() @@ -315,7 +315,7 @@ def test_reconcile_does_not_re_sync_already_correct(self): "scan_count": 5, } reconcile_plan_after_scan(plan, state) - assert state["issues"]["a"]["status"] == "deferred" + assert state["work_items"]["a"]["status"] == "deferred" def test_reconcile_resurfaces_and_reopens(self): plan = empty_plan() @@ -336,7 +336,7 @@ def test_reconcile_resurfaces_and_reopens(self): result = reconcile_plan_after_scan(plan, state) assert "a" in result.resurfaced # State should be reopened from deferred back to open - assert state["issues"]["a"]["status"] == "open" + assert state["work_items"]["a"]["status"] == "open" # --------------------------------------------------------------------------- @@ -444,7 +444,7 @@ def test_triage_dismiss_unskip_roundtrip(self): dismissed_issues=[DismissedIssue(issue_id="x", reason="not needed")], ) apply_triage_to_plan(plan, state, triage, trigger="test") - assert state["issues"]["x"]["status"] == "triaged_out" + assert state["work_items"]["x"]["status"] == "triaged_out" assert "x" in plan["skipped"] # Unskip diff --git a/desloppify/tests/review/batch/test_split_modules_direct.py b/desloppify/tests/review/batch/test_split_modules_direct.py index 35bbd719..fcccce73 100644 --- a/desloppify/tests/review/batch/test_split_modules_direct.py +++ b/desloppify/tests/review/batch/test_split_modules_direct.py @@ -47,12 +47,12 @@ def test_import_shared_extract_reviewed_files_deduplicates(): assert reviewed == ["a.py", "b.py"] -def test_import_shared_parse_payload_accepts_legacy_findings_alias(): - parsed = parse_review_import_payload( - {"findings": [{"summary": "legacy payload"}]}, - mode_name="Holistic", - ) - assert parsed.issues == [{"summary": "legacy payload"}] +def test_import_shared_parse_payload_requires_canonical_issues_key(): + with pytest.raises(ValueError, match="must contain 'issues'"): + parse_review_import_payload( + {"findings": [{"summary": "legacy payload"}]}, + mode_name="Holistic", + ) def test_store_assessments_keeps_holistic_precedence(): diff --git a/desloppify/tests/review/context/test_context_builder_direct.py b/desloppify/tests/review/context/test_context_builder_direct.py index 8662dd5a..7514faa7 100644 --- a/desloppify/tests/review/context/test_context_builder_direct.py +++ b/desloppify/tests/review/context/test_context_builder_direct.py @@ -6,7 +6,10 @@ from types import SimpleNamespace from desloppify.intelligence.review._context.models import ReviewContext -from desloppify.intelligence.review.context_builder import build_review_context_inner +from desloppify.intelligence.review.context_builder import ( + ReviewContextBuildServices, + build_review_context_inner, +) class _ZoneMap: @@ -51,21 +54,23 @@ def test_build_review_context_inner_populates_sections() -> None: lang, state, ReviewContext(), - read_file_text_fn=lambda path: content_by_path.get(path), - abs_path_fn=lambda path: path, - rel_fn=lambda path: path, - importer_count_fn=lambda entry: entry.get("importers", 0), - default_review_module_patterns_fn=lambda content: ["service"] if "def" in content else [], - func_name_re=re.compile(r"def\s+([A-Za-z_]\w*)"), - class_name_re=re.compile(r"class\s+([A-Za-z_]\w*)"), - name_prefix_re=re.compile(r"([a-z]+)"), - error_patterns={ - "has_try": re.compile(r"\btry\b"), - "has_raise": re.compile(r"\braise\b"), - }, - gather_ai_debt_signals_fn=lambda file_contents, rel_fn: {"files": sorted(file_contents)}, - gather_auth_context_fn=lambda file_contents, rel_fn: {"auth_files": len(file_contents)}, - classify_error_strategy_fn=lambda content: "raises" if "raise" in content else "returns", + ReviewContextBuildServices( + read_file_text=lambda path: content_by_path.get(path), + abs_path=lambda path: path, + rel_path=lambda path: path, + importer_count=lambda entry: entry.get("importers", 0), + default_review_module_patterns=lambda content: ["service"] if "def" in content else [], + gather_ai_debt_signals=lambda file_contents, rel_fn: {"files": sorted(file_contents)}, + gather_auth_context=lambda file_contents, rel_fn: {"auth_files": len(file_contents)}, + classify_error_strategy=lambda content: "raises" if "raise" in content else "returns", + func_name_re=re.compile(r"def\s+([A-Za-z_]\w*)"), + class_name_re=re.compile(r"class\s+([A-Za-z_]\w*)"), + name_prefix_re=re.compile(r"([a-z]+)"), + error_patterns={ + "has_try": re.compile(r"\btry\b"), + "has_raise": re.compile(r"\braise\b"), + }, + ), ) assert ctx.naming_vocabulary["total_names"] == 3 @@ -94,20 +99,24 @@ def test_build_review_context_inner_falls_back_to_default_module_patterns() -> N lang, {"issues": {}}, ReviewContext(), - read_file_text_fn=lambda _path: "def run_task():\n return 1\n", - abs_path_fn=lambda path: path, - rel_fn=lambda path: path, - importer_count_fn=lambda _entry: 0, - default_review_module_patterns_fn=lambda _content: ["fallback_pattern", "fallback_pattern"], - func_name_re=re.compile(r"def\s+([A-Za-z_]\w*)"), - class_name_re=re.compile(r"class\s+([A-Za-z_]\w*)"), - name_prefix_re=re.compile(r"([a-z]+)"), - error_patterns={}, - gather_ai_debt_signals_fn=lambda _file_contents, rel_fn: {}, - gather_auth_context_fn=lambda _file_contents, rel_fn: {}, - classify_error_strategy_fn=lambda _content: "", + ReviewContextBuildServices( + read_file_text=lambda _path: "def run_task():\n return 1\n", + abs_path=lambda path: path, + rel_path=lambda path: path, + importer_count=lambda _entry: 0, + default_review_module_patterns=lambda _content: [ + "fallback_pattern", + "fallback_pattern", + ], + gather_ai_debt_signals=lambda _file_contents, rel_fn: {}, + gather_auth_context=lambda _file_contents, rel_fn: {}, + classify_error_strategy=lambda _content: "", + func_name_re=re.compile(r"def\s+([A-Za-z_]\w*)"), + class_name_re=re.compile(r"class\s+([A-Za-z_]\w*)"), + name_prefix_re=re.compile(r"([a-z]+)"), + error_patterns={}, + ), ) assert ctx.module_patterns == {} assert ctx.codebase_stats["avg_file_loc"] == 2 - diff --git a/desloppify/tests/review/context/test_context_holistic_accessors_direct.py b/desloppify/tests/review/context/test_context_holistic_accessors_direct.py index 2fd731f3..774de20f 100644 --- a/desloppify/tests/review/context/test_context_holistic_accessors_direct.py +++ b/desloppify/tests/review/context/test_context_holistic_accessors_direct.py @@ -2,7 +2,7 @@ from __future__ import annotations -from desloppify.intelligence.review.context_holistic._accessors import ( +from desloppify.intelligence.review.context_holistic.clusters.accessors import ( _get_detail, _get_signals, _safe_num, @@ -30,4 +30,3 @@ def test_safe_num_accepts_numeric_but_rejects_bool_and_other_types() -> None: assert _safe_num(2.5) == 2.5 assert _safe_num(True, default=9.0) == 9.0 assert _safe_num("3", default=1.5) == 1.5 - diff --git a/desloppify/tests/review/context/test_context_holistic_clusters_complexity_direct.py b/desloppify/tests/review/context/test_context_holistic_clusters_complexity_direct.py index ee663af2..d509b8e3 100644 --- a/desloppify/tests/review/context/test_context_holistic_clusters_complexity_direct.py +++ b/desloppify/tests/review/context/test_context_holistic_clusters_complexity_direct.py @@ -2,7 +2,7 @@ from __future__ import annotations -from desloppify.intelligence.review.context_holistic._clusters_complexity import ( +from desloppify.intelligence.review.context_holistic.clusters.complexity import ( _build_complexity_hotspots, ) @@ -62,4 +62,3 @@ def test_build_complexity_hotspots_limits_to_top_twenty() -> None: assert len(hotspots) == 20 assert hotspots[0]["file"] == "src/f29.py" assert hotspots[-1]["file"] == "src/f10.py" - diff --git a/desloppify/tests/review/context/test_context_holistic_clusters_consistency_direct.py b/desloppify/tests/review/context/test_context_holistic_clusters_consistency_direct.py index f519c429..140c3f77 100644 --- a/desloppify/tests/review/context/test_context_holistic_clusters_consistency_direct.py +++ b/desloppify/tests/review/context/test_context_holistic_clusters_consistency_direct.py @@ -2,7 +2,7 @@ from __future__ import annotations -from desloppify.intelligence.review.context_holistic._clusters_consistency import ( +from desloppify.intelligence.review.context_holistic.clusters.consistency import ( _build_duplicate_clusters, _build_naming_drift, ) @@ -46,4 +46,3 @@ def test_build_naming_drift_groups_by_directory_and_counts_outliers() -> None: assert drift[0]["minority_count"] == 2 assert "src/app/FooBar.py" in drift[0]["outliers"] assert drift[1]["directory"] == "src/lib/" - diff --git a/desloppify/tests/review/context/test_context_holistic_clusters_dependency_direct.py b/desloppify/tests/review/context/test_context_holistic_clusters_dependency_direct.py index cb322d2a..567728ff 100644 --- a/desloppify/tests/review/context/test_context_holistic_clusters_dependency_direct.py +++ b/desloppify/tests/review/context/test_context_holistic_clusters_dependency_direct.py @@ -2,7 +2,7 @@ from __future__ import annotations -from desloppify.intelligence.review.context_holistic._clusters_dependency import ( +from desloppify.intelligence.review.context_holistic.clusters.dependency import ( _build_boundary_violations, _build_dead_code, _build_deferred_import_density, diff --git a/desloppify/tests/review/context/test_context_holistic_clusters_error_state_direct.py b/desloppify/tests/review/context/test_context_holistic_clusters_error_state_direct.py index c82b5e34..c0704253 100644 --- a/desloppify/tests/review/context/test_context_holistic_clusters_error_state_direct.py +++ b/desloppify/tests/review/context/test_context_holistic_clusters_error_state_direct.py @@ -2,7 +2,7 @@ from __future__ import annotations -from desloppify.intelligence.review.context_holistic._clusters_error_state import ( +from desloppify.intelligence.review.context_holistic.clusters.error_state import ( _build_error_hotspots, _build_mutable_globals, ) diff --git a/desloppify/tests/review/context/test_context_holistic_clusters_organization_direct.py b/desloppify/tests/review/context/test_context_holistic_clusters_organization_direct.py index b079512b..5f759f4c 100644 --- a/desloppify/tests/review/context/test_context_holistic_clusters_organization_direct.py +++ b/desloppify/tests/review/context/test_context_holistic_clusters_organization_direct.py @@ -2,7 +2,7 @@ from __future__ import annotations -from desloppify.intelligence.review.context_holistic._clusters_organization import ( +from desloppify.intelligence.review.context_holistic.clusters.organization import ( _build_flat_dir_issues, _build_large_file_distribution, ) diff --git a/desloppify/tests/review/context/test_context_holistic_clusters_security_direct.py b/desloppify/tests/review/context/test_context_holistic_clusters_security_direct.py index e65bb100..de57443a 100644 --- a/desloppify/tests/review/context/test_context_holistic_clusters_security_direct.py +++ b/desloppify/tests/review/context/test_context_holistic_clusters_security_direct.py @@ -4,7 +4,7 @@ from collections import Counter -from desloppify.intelligence.review.context_holistic._clusters_security import ( +from desloppify.intelligence.review.context_holistic.clusters.security import ( _build_security_hotspots, _build_signal_density, _build_systemic_patterns, diff --git a/desloppify/tests/review/context/test_holistic_review.py b/desloppify/tests/review/context/test_holistic_review.py index 8a0adfdc..5c1894bf 100644 --- a/desloppify/tests/review/context/test_holistic_review.py +++ b/desloppify/tests/review/context/test_holistic_review.py @@ -526,7 +526,7 @@ def test_prepare_holistic_review_filters_out_of_scope_batch_files( lang = _mock_lang([in_scope_file]) lang.name = "python" state = empty_state() - state["issues"] = { + state["work_items"] = { "in_scope_structural": { "id": "in_scope_structural", "detector": "structural", @@ -616,7 +616,7 @@ def test_basic_import(self): diff = _call_import_holistic_issues(issues_data, state, "python") assert diff["new"] == 1 - issues = list(state["issues"].values()) + issues = list(state["work_items"].values()) assert len(issues) == 1 f = issues[0] assert f["file"] == "." @@ -639,7 +639,7 @@ def test_invalid_dimension_rejected(self): diff = _call_import_holistic_issues(issues_data, state, "python") assert diff["new"] == 0 - assert len(state["issues"]) == 0 + assert len(state["work_items"]) == 0 def test_missing_fields_rejected(self): state = empty_state() @@ -677,7 +677,7 @@ def test_multiple_issues(self): diff = _call_import_holistic_issues(issues_data, state, "python") assert diff["new"] == 2 - assert len(state["issues"]) == 2 + assert len(state["work_items"]) == 2 def test_holistic_cache_updated(self): state = empty_state() @@ -734,7 +734,7 @@ def test_reviewed_files_auto_resolves_per_file_coverage_markers(self, tmp_path): state = empty_state() coverage_id = "subjective_review::.::high_level_elegance" - state["issues"][coverage_id] = { + state["work_items"][coverage_id] = { "id": coverage_id, "detector": "subjective_review", "file": ".", @@ -763,7 +763,7 @@ def test_reviewed_files_auto_resolves_per_file_coverage_markers(self, tmp_path): diff = _call_import_holistic_issues(payload, state, "python", project_root=tmp_path) assert diff["auto_resolved"] >= 1 - assert state["issues"][coverage_id]["status"] == "fixed" + assert state["work_items"][coverage_id]["status"] == "fixed" def test_holistic_potential_added(self): state = empty_state() @@ -800,7 +800,7 @@ def test_issue_id_contains_holistic(self): _call_import_holistic_issues(issues_data, state, "python") - fid = list(state["issues"].keys())[0] + fid = list(state["work_items"].keys())[0] assert "holistic" in fid def test_positive_observation_skipped(self): @@ -841,7 +841,7 @@ def test_positive_observation_skipped(self): # Only the actual defect should be imported assert diff["new"] == 1 assert diff.get("skipped", 0) == 2 - issues = list(state["issues"].values()) + issues = list(state["work_items"].values()) assert len(issues) == 1 assert "vague_name" in issues[0]["id"] diff --git a/desloppify/tests/review/context/test_holistic_review_dimensions_and_structure.py b/desloppify/tests/review/context/test_holistic_review_dimensions_and_structure.py index f702f91b..9885e957 100644 --- a/desloppify/tests/review/context/test_holistic_review_dimensions_and_structure.py +++ b/desloppify/tests/review/context/test_holistic_review_dimensions_and_structure.py @@ -91,7 +91,7 @@ def _state_with_holistic_issues(*issues_args): state["objective_score"] = 45.0 state["strict_score"] = 38.0 for fid, conf, dim, summary in issues_args: - state["issues"][fid] = { + state["work_items"][fid] = { "id": fid, "file": ".", "status": "open", @@ -242,7 +242,7 @@ def test_resolved_issues_excluded(self): ), ) # Add a resolved issue that should NOT appear - state["issues"]["review::.::holistic::test::def"] = { + state["work_items"]["review::.::holistic::test::def"] = { "id": "review::.::holistic::test::def", "file": ".", "status": "fixed", diff --git a/desloppify/tests/review/context/test_issue_history_context.py b/desloppify/tests/review/context/test_issue_history_context.py index 49dd466e..004d9f6d 100644 --- a/desloppify/tests/review/context/test_issue_history_context.py +++ b/desloppify/tests/review/context/test_issue_history_context.py @@ -75,7 +75,7 @@ def test_issue_history_returns_flat_recent_issues(): note="blocked by migration dependency", resolved_at="2026-02-24T12:00:00+00:00", ) - state["issues"] = { + state["work_items"] = { f_open["id"]: f_open, f_fixed["id"]: f_fixed, f_wontfix["id"]: f_wontfix, @@ -122,7 +122,7 @@ def test_issue_history_strips_auto_resolve_notes(): note="not reported in latest holistic re-import", resolved_at="2026-02-24T11:00:00+00:00", ) - state["issues"] = {f["id"]: f} + state["work_items"] = {f["id"]: f} history = build_issue_history_context(state) assert history["recent_issues"][0]["note"] == "" @@ -130,7 +130,7 @@ def test_issue_history_strips_auto_resolve_notes(): def test_issue_history_respects_max_issues(): state = empty_state() - state["issues"] = {} + state["work_items"] = {} for idx in range(10): f = _review_issue( issue_id=f"review::.::holistic::abstraction_fitness::issue_{idx}", @@ -139,7 +139,7 @@ def test_issue_history_respects_max_issues(): summary=f"Issue number {idx}", last_seen=f"2026-02-{20 + idx % 5}T10:00:00+00:00", ) - state["issues"][f["id"]] = f + state["work_items"][f["id"]] = f history = build_issue_history_context( state, options=ReviewHistoryOptions(max_issues=5) @@ -164,7 +164,7 @@ def test_issue_history_sorted_by_last_seen(): last_seen="2026-02-24T10:00:00+00:00", ) state = empty_state() - state["issues"] = {f_old["id"]: f_old, f_new["id"]: f_new} + state["work_items"] = {f_old["id"]: f_old, f_new["id"]: f_new} history = build_issue_history_context(state) issues = history["recent_issues"] @@ -181,7 +181,7 @@ def test_issue_history_empty_state(): def test_prepare_holistic_review_optional_issue_history_payload(): state = empty_state() - state["issues"] = { + state["work_items"] = { "review::.::holistic::error_consistency::mixed_error_channels_console_vs_pipeline": _review_issue( issue_id="review::.::holistic::error_consistency::mixed_error_channels_console_vs_pipeline", dimension="error_consistency", diff --git a/desloppify/tests/review/context/test_mechanical_evidence.py b/desloppify/tests/review/context/test_mechanical_evidence.py index 58ac1085..cd1aa1fd 100644 --- a/desloppify/tests/review/context/test_mechanical_evidence.py +++ b/desloppify/tests/review/context/test_mechanical_evidence.py @@ -495,6 +495,36 @@ def test_no_change_doesnt_mark_stale(self): dc = state["subjective_assessments"]["design_coherence"] assert "needs_review_refresh" not in dc + def test_fresh_trusted_import_survives_immediate_reconcile_scan(self): + from desloppify.engine._state.merge import merge_scan + from desloppify.engine._state.schema import empty_state + + state = empty_state() + state["last_scan"] = "2026-03-13T13:09:25+00:00" + state["assessment_import_audit"] = [ + { + "mode": "trusted_internal", + "timestamp": "2026-03-13T15:14:29+00:00", + } + ] + state["subjective_assessments"] = { + "design_coherence": { + "score": 79.0, + "assessed_at": "2026-03-13T15:14:29+00:00", + "source": "holistic", + }, + } + + new_issues = [ + _issue(id="s1", detector="structural", file="big.py", detail={"loc": 500}), + ] + merge_scan(state, new_issues) + + dc = state["subjective_assessments"]["design_coherence"] + assert "needs_review_refresh" not in dc + assert "refresh_reason" not in dc + assert "stale_since" not in dc + def test_already_stale_not_overwritten(self): from desloppify.engine._state.merge import merge_scan from desloppify.engine._state.schema import empty_state @@ -589,7 +619,7 @@ def test_auto_resolved_detector_marks_its_dimensions_stale(self): # Manually resolve the issue so verify_disappeared will process it # (open issues are now user-controlled and skip verification) - state["issues"]["structural::big.py::large_file"]["status"] = "fixed" + state["work_items"]["structural::big.py::large_file"]["status"] = "fixed" # Second scan: structural issue absent → scan-verified, detector changed merge_scan(state, [], MergeScanOptions(force_resolve=True)) diff --git a/desloppify/tests/review/context/test_review_context_signals_direct.py b/desloppify/tests/review/context/test_review_context_signals_direct.py index 5b969b8b..d84727b9 100644 --- a/desloppify/tests/review/context/test_review_context_signals_direct.py +++ b/desloppify/tests/review/context/test_review_context_signals_direct.py @@ -103,6 +103,17 @@ def test_gather_auth_context_ignores_non_source_guidance_files(): assert result == {} +def test_gather_auth_context_ignores_runtime_extensions_in_guidance_paths() -> None: + file_contents = { + "guidance/auth_examples.py": "@app.get('/docs')\ndef route():\n request.user\n", + "prompts/security_prompt.ts": "const k = service_role; createClient(url, k)", + "src/routes/admin.py": "@app.get('/admin')\ndef route():\n return 1\n", + } + result = signal_auth_mod.gather_auth_context(file_contents, rel_fn=lambda p: p) + assert list(result["route_auth_coverage"]) == ["src/routes/admin.py"] + assert "service_role_usage" not in result + + def test_gather_auth_context_counts_public_route_markers_separately(): file_contents = { "api.py": ( diff --git a/desloppify/tests/review/import_scoring/test_review_import_scoring.py b/desloppify/tests/review/import_scoring/test_review_import_scoring.py index df494370..3c54ae03 100644 --- a/desloppify/tests/review/import_scoring/test_review_import_scoring.py +++ b/desloppify/tests/review/import_scoring/test_review_import_scoring.py @@ -21,7 +21,7 @@ def test_import_valid_issues(self, empty_state, sample_issues_data): diff = import_review_issues(_as_review_payload(sample_issues_data), empty_state, "typescript") assert diff["new"] == 3 # Check issues were added to state - issues = empty_state["issues"] + issues = empty_state["work_items"] assert len(issues) == 3 # Check issue IDs follow the pattern ids = list(issues.keys()) @@ -55,7 +55,7 @@ def test_import_validates_confidence(self, empty_state): } ] import_review_issues(_as_review_payload(data), empty_state, "typescript") - issue = list(empty_state["issues"].values())[0] + issue = list(empty_state["work_items"].values())[0] assert issue["confidence"] == "low" def test_import_validates_dimension(self, empty_state): @@ -109,7 +109,7 @@ def test_import_preserves_wontfix_issues(self, empty_state, sample_issues_data): # First import import_review_issues(_as_review_payload(sample_issues_data), empty_state, "typescript") # Mark one as wontfix - for f in empty_state["issues"].values(): + for f in empty_state["work_items"].values(): if "naming_quality" in f["id"]: f["status"] = "wontfix" f["note"] = "intentionally generic" @@ -117,25 +117,25 @@ def test_import_preserves_wontfix_issues(self, empty_state, sample_issues_data): # Second import with same issues import_review_issues(_as_review_payload(sample_issues_data), empty_state, "typescript") # Wontfix should NOT be auto-resolved (it's still in current issues) - assert any(f["status"] == "wontfix" for f in empty_state["issues"].values()) + assert any(f["status"] == "wontfix" for f in empty_state["work_items"].values()) # The issue still exists assert any( - "naming_quality" in f["id"] for f in empty_state["issues"].values() + "naming_quality" in f["id"] for f in empty_state["work_items"].values() ) def test_import_sets_lang(self, empty_state, sample_issues_data): import_review_issues(_as_review_payload(sample_issues_data), empty_state, "python") - for f in empty_state["issues"].values(): + for f in empty_state["work_items"].values(): assert f["lang"] == "python" def test_import_sets_tier_3(self, empty_state, sample_issues_data): import_review_issues(_as_review_payload(sample_issues_data), empty_state, "typescript") - for f in empty_state["issues"].values(): + for f in empty_state["work_items"].values(): assert f["tier"] == 3 def test_import_stores_detail(self, empty_state, sample_issues_data): import_review_issues(_as_review_payload(sample_issues_data), empty_state, "typescript") - for f in empty_state["issues"].values(): + for f in empty_state["work_items"].values(): assert "dimension" in f["detail"] assert "suggestion" in f["detail"] @@ -164,7 +164,7 @@ def test_id_collision_different_summaries(self, empty_state): # Same file + dimension + identifier = same issue ID, even with different summaries. # The second entry overwrites the first (last-writer wins during import). assert diff["new"] == 1 - assert len(empty_state["issues"]) == 1 + assert len(empty_state["work_items"]) == 1 def test_id_stable_for_same_summary(self, empty_state): """Same summary should produce the same issue ID (stable identifier).""" @@ -178,12 +178,12 @@ def test_id_stable_for_same_summary(self, empty_state): } ] import_review_issues(_as_review_payload(data), empty_state, "typescript") - ids_first = set(empty_state["issues"].keys()) + ids_first = set(empty_state["work_items"].keys()) # Import again — should match same IDs (no new issues) diff = import_review_issues(_as_review_payload(data), empty_state, "typescript") assert diff["new"] == 0 - assert set(empty_state["issues"].keys()) == ids_first + assert set(empty_state["work_items"].keys()) == ids_first # ── Scoring integration tests ───────────────────────────────────── @@ -201,7 +201,7 @@ def test_review_issues_appear_in_scoring(self, empty_state, sample_issues_data): } potentials = {"review": 2} dim_scores = compute_dimension_scores( - empty_state["issues"], potentials, subjective_assessments=assessments + empty_state["work_items"], potentials, subjective_assessments=assessments ) assert "Naming quality" in dim_scores assert dim_scores["Naming quality"]["score"] == 75.0 @@ -215,7 +215,7 @@ def test_review_issues_not_auto_resolved_by_scan( import_review_issues(_as_review_payload(sample_issues_data), empty_state, "typescript") review_ids = { f["id"] - for f in empty_state["issues"].values() + for f in empty_state["work_items"].values() if f["detector"] == "review" } @@ -231,8 +231,8 @@ def test_review_issues_not_auto_resolved_by_scan( # Review issues should still be open (not auto-resolved) for fid in review_ids: - if fid in empty_state["issues"]: - assert empty_state["issues"][fid]["status"] == "open" + if fid in empty_state["work_items"]: + assert empty_state["work_items"][fid]["status"] == "open" def test_review_in_file_based_detectors(self): assert "review" in FILE_BASED_DETECTORS @@ -267,7 +267,7 @@ def test_import_new_format_with_assessments(self): } diff = import_review_issues(_as_review_payload(data), state, "typescript") assert diff["new"] == 1 - assert len(state["issues"]) == 1 + assert len(state["work_items"]) == 1 assessments = state["subjective_assessments"] assert "naming_quality" in assessments assert assessments["naming_quality"]["score"] == 75 diff --git a/desloppify/tests/review/review_commands_cases.py b/desloppify/tests/review/review_commands_cases.py index f68df3df..0158e4fd 100644 --- a/desloppify/tests/review/review_commands_cases.py +++ b/desloppify/tests/review/review_commands_cases.py @@ -169,12 +169,11 @@ def mock_save(state, sp): lang = MagicMock() lang.name = "typescript" - # save_state is imported lazily: from ..state import save_state - with patch("desloppify.state.save_state", mock_save): + with patch("desloppify.app.commands.review.importing.cmd.save_state", mock_save): _do_import(str(issues_file), empty_state, lang, "fake_sp") assert saved["sp"] == "fake_sp" - assert len(empty_state["issues"]) == 1 + assert len(empty_state["work_items"]) == 1 def test_do_prepare_prints_narrative_reminders(self, mock_lang_with_zones, empty_state, tmp_path, capsys): from unittest.mock import MagicMock, patch @@ -318,7 +317,7 @@ def mock_save(state, sp): lang = MagicMock() lang.name = "typescript" - with patch("desloppify.state.save_state", mock_save): + with patch("desloppify.app.commands.review.importing.cmd.save_state", mock_save): _do_import( str(issues_file), empty_state, @@ -592,6 +591,7 @@ def test_attested_external_import_applies_durable_assessment( audit = empty_state.get("assessment_import_audit", []) assert audit and audit[-1]["mode"] == "attested_external" assert audit[-1]["attested_external"] is True + assert audit[-1]["packet_sha256"] == packet_hash def test_do_validate_import_reports_mode_without_state_mutation( self, empty_state, tmp_path, capsys @@ -706,12 +706,12 @@ def test_do_import_fails_closed_on_skipped_issues(self, empty_state, tmp_path): lang = MagicMock() lang.name = "typescript" - with patch("desloppify.state.save_state") as mock_save: + with patch("desloppify.app.commands.review.importing.cmd.save_state") as mock_save: with pytest.raises(CommandError): _do_import(str(issues_file), empty_state, lang, "sp") assert mock_save.called is False assert empty_state.get("subjective_assessments", {}) == {} - assert empty_state.get("issues", {}) == {} + assert empty_state.get("work_items", {}) == {} def test_do_import_allow_partial_persists_when_overridden( self, empty_state, tmp_path @@ -736,7 +736,7 @@ def test_do_import_allow_partial_persists_when_overridden( lang = MagicMock() lang.name = "typescript" - with patch("desloppify.state.save_state") as mock_save: + with patch("desloppify.app.commands.review.importing.cmd.save_state") as mock_save: _do_import( str(issues_file), empty_state, @@ -969,7 +969,7 @@ def fake_subprocess_run( "dimension_judgment": { "high_level_elegance": { "strengths": ["consistent module boundaries"], - "issue_character": "structural coupling between subsystems", + "dimension_character": "structural coupling between subsystems", "score_rationale": "Orchestration seams cross module boundaries creating coupling that impacts maintainability.", }, }, @@ -1001,7 +1001,7 @@ def fake_subprocess_run( "dimension_judgment": { "mid_level_elegance": { "strengths": ["clear module separation"], - "issue_character": "adapter protocol inconsistency across sibling modules", + "dimension_character": "adapter protocol inconsistency across sibling modules", "score_rationale": "Handoff adapters diverge between sibling modules making the integration boundary harder to reason about.", }, }, @@ -1033,7 +1033,7 @@ def fake_subprocess_run( "dimension_judgment": { "high_level_elegance": { "strengths": ["orchestration seams are mostly aligned"], - "issue_character": "brittle edge seams in module boundary handling", + "dimension_character": "brittle edge seams in module boundary handling", "score_rationale": "Most orchestration boundaries are consistent but edge seams remain brittle enough to risk regressions.", }, }, @@ -1065,7 +1065,7 @@ def fake_subprocess_run( "dimension_judgment": { "low_level_elegance": { "strengths": ["concise local internals"], - "issue_character": "repetitive branching boilerplate in local flow", + "dimension_character": "repetitive branching boilerplate in local flow", "score_rationale": "Local function bodies are concise but repetitive branching patterns add unnecessary cognitive load.", }, }, @@ -1208,7 +1208,7 @@ def fake_subprocess_run( "dimension_judgment": { "mid_level_elegance": { "strengths": ["seam boundaries are explicit"], - "issue_character": "seam convention drift across adjacent modules", + "dimension_character": "seam convention drift across adjacent modules", "score_rationale": "Adjacent modules use mostly explicit seams but conventions drift enough to cause confusion at boundaries.", } }, @@ -1326,7 +1326,7 @@ def fake_subprocess_run( "dimension_judgment": { "mid_level_elegance": { "strengths": ["aligned seam conventions"], - "issue_character": "minor inconsistencies in seam boundary alignment", + "dimension_character": "minor inconsistencies in seam boundary alignment", "score_rationale": "Seam conventions are mostly aligned but minor inconsistencies remain at module boundaries.", } }, @@ -1441,7 +1441,7 @@ def test_do_run_batches_recovers_missing_raw_output_from_log( "dimension_judgment": { "mid_level_elegance": { "strengths": ["clear hook separation"], - "issue_character": "overlapping orchestration seams across sibling hooks", + "dimension_character": "overlapping orchestration seams across sibling hooks", "score_rationale": "Domain seams are split across sibling hooks causing overlapping orchestration that complicates reasoning.", } }, @@ -1597,7 +1597,7 @@ def fake_subprocess_run( "dimension_judgment": { "mid_level_elegance": { "strengths": ["explicit seam boundaries"], - "issue_character": "seam style drift across nearby modules", + "dimension_character": "seam style drift across nearby modules", "score_rationale": "Seam interfaces are explicit but style differences across nearby modules reduce consistency.", } }, @@ -1798,7 +1798,7 @@ def fake_subprocess_run( "dimension_judgment": { "abstraction_fitness": { "strengths": ["interfaces are mostly honest about their contracts"], - "issue_character": "excessive wrapper indirection before reaching domain logic", + "dimension_character": "excessive wrapper indirection before reaching domain logic", "score_rationale": "Three wrapper layers before domain calls add significant indirection cost that outweighs abstraction leverage.", }, }, @@ -2310,7 +2310,7 @@ def test_holistic_auto_resolve_on_reimport(self): diff1 = import_holistic_issues(_as_review_payload(data1), state, "typescript") assert diff1["new"] == 2 open_ids = [ - fid for fid, f in state["issues"].items() if f["status"] == "open" + fid for fid, f in state["work_items"].items() if f["status"] == "open" ] assert len(open_ids) == 2 @@ -2333,7 +2333,7 @@ def test_holistic_auto_resolve_on_reimport(self): # The 2 old issues should be marked fixed by the import. assert diff2["auto_resolved"] >= 2 still_open = [ - fid for fid, f in state["issues"].items() if f["status"] == "open" + fid for fid, f in state["work_items"].items() if f["status"] == "open" ] assert len(still_open) == 1 @@ -2365,7 +2365,7 @@ def test_partial_holistic_reimport_only_resolves_imported_dimensions(self): diff1 = import_holistic_issues(_as_review_payload(data1), state, "typescript") assert diff1["new"] == 2 - by_summary = {f["summary"]: fid for fid, f in state["issues"].items()} + by_summary = {f["summary"]: fid for fid, f in state["work_items"].items()} cross_mod_id = by_summary["too central"] abstraction_id = by_summary["dumping ground"] @@ -2389,8 +2389,8 @@ def test_partial_holistic_reimport_only_resolves_imported_dimensions(self): diff2 = import_holistic_issues(_as_review_payload(data2), state, "typescript") assert diff2["new"] == 1 assert diff2["auto_resolved"] >= 1 - assert state["issues"][abstraction_id]["status"] == "fixed" - assert state["issues"][cross_mod_id]["status"] == "open" + assert state["work_items"][abstraction_id]["status"] == "fixed" + assert state["work_items"][cross_mod_id]["status"] == "open" def test_per_file_auto_resolve_on_reimport(self): state = build_empty_state() @@ -2433,7 +2433,7 @@ def test_per_file_auto_resolve_on_reimport(self): # The comment_quality issue should be marked fixed by the explicit import. resolved = [ f - for f in state["issues"].values() + for f in state["work_items"].values() if f["status"] == "fixed" and "not reported in latest per-file" in (f.get("note") or "") ] @@ -2457,7 +2457,7 @@ def test_holistic_does_not_resolve_per_file(self): } import_review_issues(_as_review_payload(per_file), state, "typescript") per_file_ids = [ - fid for fid, f in state["issues"].items() if f["status"] == "open" + fid for fid, f in state["work_items"].items() if f["status"] == "open" ] assert len(per_file_ids) == 1 @@ -2465,4 +2465,4 @@ def test_holistic_does_not_resolve_per_file(self): holistic = {"issues": []} import_holistic_issues(_as_review_payload(holistic), state, "typescript") # Per-file issue should still be open - assert state["issues"][per_file_ids[0]]["status"] == "open" + assert state["work_items"][per_file_ids[0]]["status"] == "open" diff --git a/desloppify/tests/review/review_commands_runner_cases.py b/desloppify/tests/review/review_commands_runner_cases.py index 2b6e5d8a..b766ba44 100644 --- a/desloppify/tests/review/review_commands_runner_cases.py +++ b/desloppify/tests/review/review_commands_runner_cases.py @@ -15,6 +15,7 @@ import desloppify.app.commands.review.runner_parallel as runner_parallel_mod import desloppify.app.commands.runner.codex_batch as runner_process_mod from desloppify.app.commands.review.batch.orchestrator import do_run_batches +from desloppify.app.commands.review.batch.execution import CollectBatchResultsRequest from desloppify.base.exception_sets import CommandError runner_helpers_mod = SimpleNamespace( @@ -67,10 +68,12 @@ def normalize_result(payload, _allowed_dims): return payload.get("assessments", {}), payload.get("issues", []), notes, {}, {}, {} batch_results, failures = runner_helpers_mod.collect_batch_results( - selected_indexes=[0], - failures=[0], - output_files={0: output_file}, - allowed_dims={"logic_clarity"}, + request=CollectBatchResultsRequest( + selected_indexes=[0], + failures=[0], + output_files={0: output_file}, + allowed_dims={"logic_clarity"}, + ), extract_payload_fn=lambda raw: json.loads(raw), normalize_result_fn=normalize_result, ) @@ -115,10 +118,12 @@ def extract_payload(raw: str) -> dict[str, object] | None: return None batch_results, failures = runner_helpers_mod.collect_batch_results( - selected_indexes=[0], - failures=[], - output_files={0: raw_path}, - allowed_dims={"logic_clarity"}, + request=CollectBatchResultsRequest( + selected_indexes=[0], + failures=[], + output_files={0: raw_path}, + allowed_dims={"logic_clarity"}, + ), extract_payload_fn=extract_payload, normalize_result_fn=lambda payload, _allowed: ( # noqa: ARG005 payload.get("assessments", {}), @@ -479,6 +484,102 @@ def test_do_run_batches_scan_after_import_exits_on_failed_followup( assert exc_info.value.exit_code == 7 + def test_do_run_batches_success_path_imports_merged_results( + self, empty_state, tmp_path + ): + packet = { + "command": "review", + "mode": "holistic", + "language": "typescript", + "dimensions": ["high_level_elegance"], + "investigation_batches": [ + { + "name": "Batch A", + "dimensions": ["high_level_elegance"], + "files_to_read": ["src/a.ts"], + "why": "A", + } + ], + } + packet_path = tmp_path / "packet.json" + packet_path.write_text(json.dumps(packet)) + + args = MagicMock() + args.path = str(tmp_path) + args.dimensions = None + args.runner = "codex" + args.parallel = False + args.dry_run = False + args.packet = str(packet_path) + args.only_batches = None + args.scan_after_import = False + args.allow_partial = True + args.save_run_log = True + args.run_log_file = None + + review_packet_dir = tmp_path / ".desloppify" / "review_packets" + runs_dir = tmp_path / ".desloppify" / "subagents" / "runs" + + lang = MagicMock() + lang.name = "typescript" + + with ( + patch( + "desloppify.app.commands.review.runtime_paths.PROJECT_ROOT", + tmp_path, + ), + patch( + "desloppify.app.commands.review.runtime_paths.REVIEW_PACKET_DIR", + review_packet_dir, + ), + patch( + "desloppify.app.commands.review.runtime_paths.SUBAGENT_RUNS_DIR", + runs_dir, + ), + patch( + "desloppify.app.commands.review.batch.execution_phases.scored_dimensions_for_lang", + return_value=["high_level_elegance"], + ), + patch( + "desloppify.app.commands.review.batch.orchestrator.execute_batches", + return_value=[], + ), + patch( + "desloppify.app.commands.review.batch.orchestrator.collect_batch_results", + return_value=( + [ + runner_parallel_mod.BatchResult( + batch_index=1, + assessments={"high_level_elegance": 84.0}, + dimension_notes={}, + issues=[], + quality={}, + ) + ], + [], + ), + ), + patch( + "desloppify.app.commands.review.batch.orchestrator._merge_batch_results", + return_value={ + "assessments": {"high_level_elegance": 84.0}, + "dimension_notes": {}, + "issues": [], + "review_quality": {}, + }, + ), + patch( + "desloppify.app.commands.review.batch.orchestrator.run_followup_scan", + ) as run_followup_scan, + patch( + "desloppify.app.commands.review.batch.orchestrator._do_import", + ) as do_import, + ): + do_run_batches(args, empty_state, lang, "fake_sp", config={}) + + do_import.assert_called_once() + run_followup_scan.assert_not_called() + def test_do_run_batches_keyboard_interrupt_writes_partial_summary( self, empty_state, tmp_path ): @@ -552,4 +653,3 @@ def test_do_run_batches_keyboard_interrupt_writes_partial_summary( run_log_path = Path(summary_payload["run_log"]) run_log_text = run_log_path.read_text() assert "run-interrupted reason=keyboard_interrupt" in run_log_text - diff --git a/desloppify/tests/review/review_coverage_cases.py b/desloppify/tests/review/review_coverage_cases.py index c4094caa..6fa2c62e 100644 --- a/desloppify/tests/review/review_coverage_cases.py +++ b/desloppify/tests/review/review_coverage_cases.py @@ -387,7 +387,7 @@ def test_same_identifier_collapses_with_evidence_lines(self): _ = _call_import_review_issues(issues_data, state, "python") # Same file+dimension+identifier → same issue ID (last writer wins) - ids = list(state["issues"].keys()) + ids = list(state["work_items"].keys()) assert len(ids) == 1 def test_same_identifier_collapses_without_evidence_lines(self): @@ -417,7 +417,7 @@ def test_same_identifier_collapses_without_evidence_lines(self): state = empty_state() _ = _call_import_review_issues(issues_data, state, "python") - ids = list(state["issues"].keys()) + ids = list(state["work_items"].keys()) assert len(ids) == 1 def test_same_issue_same_id(self): @@ -436,7 +436,7 @@ def test_same_issue_same_id(self): ] state = empty_state() _call_import_review_issues(issues_data, state, "python") - id1 = list(state["issues"].keys())[0] + id1 = list(state["work_items"].keys())[0] # Re-import same issue state2 = empty_state() @@ -485,7 +485,7 @@ def test_new_dimensions_accepted_by_import(self): ] state = empty_state() _call_import_review_issues(issues_data, state, "python") - assert len(state["issues"]) == 1, f"Issue for {dim} was rejected" + assert len(state["work_items"]) == 1, f"Issue for {dim} was rejected" # ── Registry and scoring integration ───────────────────────────── diff --git a/desloppify/tests/review/review_misc_cases.py b/desloppify/tests/review/review_misc_cases.py index c35f89d4..fad5ccb7 100644 --- a/desloppify/tests/review/review_misc_cases.py +++ b/desloppify/tests/review/review_misc_cases.py @@ -235,7 +235,7 @@ def test_headline_includes_review_in_maintenance(self): open_by_detector={"review": 3}, ) assert headline is not None - assert "review issue" in headline.lower() + assert "review work item" in headline.lower() def test_headline_no_review_in_early_momentum(self): headline = compute_headline( diff --git a/desloppify/tests/review/review_misc_cases_headline_bugfix.py b/desloppify/tests/review/review_misc_cases_headline_bugfix.py index 7f3a9748..aef6a551 100644 --- a/desloppify/tests/review/review_misc_cases_headline_bugfix.py +++ b/desloppify/tests/review/review_misc_cases_headline_bugfix.py @@ -9,7 +9,7 @@ class TestHeadlineBugFix: def test_headline_no_typeerror_when_headline_none_with_review_suffix(self): """Regression: None + review_suffix shouldn't TypeError.""" # Force: no security prefix, headline_inner returns None, review_suffix non-empty - # stagnation + review issues + conditions that make headline_inner return None + # stagnation + review work items + conditions that make headline_inner return None result = compute_headline( "stagnation", {}, @@ -41,5 +41,5 @@ def test_headline_review_only_no_security_no_inner(self): open_by_detector={"review": 3}, ) if result is not None: - assert "review issue" in result.lower() + assert "review work item" in result.lower() assert "3" in result diff --git a/desloppify/tests/review/review_submodules_cases.py b/desloppify/tests/review/review_submodules_cases.py index f37334d4..07f4b6a6 100644 --- a/desloppify/tests/review/review_submodules_cases.py +++ b/desloppify/tests/review/review_submodules_cases.py @@ -130,7 +130,7 @@ def test_empty_state(self, empty_state): assert get_file_issues(empty_state, "src/foo.ts") == [] def test_finds_matching(self, empty_state): - empty_state["issues"] = { + empty_state["work_items"] = { "f1": { "detector": "smells", "file": "src/foo.ts", diff --git a/desloppify/tests/review/review_submodules_import_and_remediation_cases.py b/desloppify/tests/review/review_submodules_import_and_remediation_cases.py index d337c2a5..7d60a99f 100644 --- a/desloppify/tests/review/review_submodules_import_and_remediation_cases.py +++ b/desloppify/tests/review/review_submodules_import_and_remediation_cases.py @@ -48,7 +48,7 @@ def test_valid_issue(self, empty_state): # Issue should be in state assert any( f.get("detector") == "review" - for f in empty_state.get("issues", {}).values() + for f in empty_state.get("work_items", {}).values() ) def test_skips_missing_fields(self, empty_state): @@ -80,7 +80,7 @@ def test_normalizes_invalid_confidence(self, empty_state): } ] _ = import_review_issues(_as_review_payload(data), empty_state, "typescript") - issues = list(empty_state.get("issues", {}).values()) + issues = list(empty_state.get("work_items", {}).values()) review_issues = [f for f in issues if f.get("detector") == "review"] assert len(review_issues) == 1 assert review_issues[0]["confidence"] == "low" @@ -117,7 +117,7 @@ def test_auto_resolves_missing_issues(self, empty_state): detail={"dimension": "naming_quality"}, ) old["lang"] = "typescript" - empty_state["issues"][old["id"]] = old + empty_state["work_items"][old["id"]] = old # Import new issues for same file, but different issue data = [ { @@ -130,7 +130,7 @@ def test_auto_resolves_missing_issues(self, empty_state): ] _ = import_review_issues(_as_review_payload(data), empty_state, "typescript") # Old issue should be marked fixed by the explicit import. - assert empty_state["issues"][old["id"]]["status"] == "fixed" + assert empty_state["work_items"][old["id"]]["status"] == "fixed" class TestImportHolisticIssues: @@ -147,7 +147,7 @@ def test_valid_holistic(self, empty_state): } ] import_holistic_issues(_as_review_payload(data), empty_state, "typescript") - issues = list(empty_state.get("issues", {}).values()) + issues = list(empty_state.get("work_items", {}).values()) holistic = [f for f in issues if f.get("detail", {}).get("holistic")] assert len(holistic) == 1 @@ -247,7 +247,7 @@ def test_with_issues(self, empty_state): "reasoning": "Reduces coupling", }, ) - empty_state["issues"][f["id"]] = f + empty_state["work_items"][f["id"]] = f empty_state["objective_score"] = 85.0 empty_state["strict_score"] = 84.0 empty_state["potentials"] = {"typescript": {"review": 50}} diff --git a/desloppify/tests/review/shared_review_fixtures.py b/desloppify/tests/review/shared_review_fixtures.py index 8b84a34e..fee8cf52 100644 --- a/desloppify/tests/review/shared_review_fixtures.py +++ b/desloppify/tests/review/shared_review_fixtures.py @@ -25,7 +25,7 @@ def empty_state(): @pytest.fixture def state_with_issues(): state = build_empty_state() - state["issues"] = { + state["work_items"] = { "unused::src/foo.ts::bar": { "id": "unused::src/foo.ts::bar", "detector": "unused", diff --git a/desloppify/tests/review/test_work_queue_issues_direct.py b/desloppify/tests/review/test_work_queue_issues_direct.py index 17cd93c3..d90a4a2f 100644 --- a/desloppify/tests/review/test_work_queue_issues_direct.py +++ b/desloppify/tests/review/test_work_queue_issues_direct.py @@ -48,7 +48,7 @@ def test_update_investigation_persists_detail_and_timestamp() -> None: updated = issues_mod.update_investigation(state, "review::a", "looked into this") assert updated is True - detail = state["issues"]["review::a"]["detail"] + detail = state["work_items"]["review::a"]["detail"] assert detail["existing"] == "value" assert detail["investigation"] == "looked into this" datetime.fromisoformat(detail["investigated_at"]) @@ -96,8 +96,8 @@ def test_mark_stale_holistic_marks_old_entries_stale_only() -> None: expired = issues_mod.mark_stale_holistic(state, max_age_days=30) assert expired == ["review::stale"] - stale_issue = state["issues"]["review::stale"] + stale_issue = state["work_items"]["review::stale"] assert stale_issue["status"] == "open" assert stale_issue["note"].startswith("holistic review stale") - assert state["issues"]["review::fresh"]["status"] == "open" - assert state["issues"]["review::bad-time"]["status"] == "open" + assert state["work_items"]["review::fresh"]["status"] == "open" + assert state["work_items"]["review::bad-time"]["status"] == "open" diff --git a/desloppify/tests/review/test_work_queue_plan_order_and_triage.py b/desloppify/tests/review/test_work_queue_plan_order_and_triage.py index d26d3ae8..e539d058 100644 --- a/desloppify/tests/review/test_work_queue_plan_order_and_triage.py +++ b/desloppify/tests/review/test_work_queue_plan_order_and_triage.py @@ -120,12 +120,13 @@ def test_plan_ordered_stale_subjective_gated_with_objective_backlog(): } } - # Without plan: stale subjective item is gated + # Without an explicit queue, pre-triage objective work still blocks stale + # subjective review items from surfacing. queue_no_plan = build_work_queue(state, count=None, include_subjective=True) subj_no_plan = [ i["id"] for i in queue_no_plan["items"] if i["id"].startswith("subjective::") ] - assert len(subj_no_plan) == 0 + assert subj_no_plan == [] # With plan that includes the stale dim in queue_order: still gated plan = empty_plan() @@ -256,6 +257,206 @@ def test_legacy_force_visible_triage_stage_is_ignored_during_execute(): assert ids == ["smells::src/a.py::x"] +def test_stale_triage_surfaces_observe_instead_of_empty_queue(): + """When new review findings exist after triage, next should surface triage recovery.""" + from desloppify.engine._plan.schema import empty_plan + + review_issue = _issue( + "review::.::holistic::design_coherence::needs_triage", + detector="review", + tier=4, + confidence="high", + detail={"dimension": "design_coherence", "holistic": True}, + ) + state = _state([review_issue]) + state["scan_count"] = 7 + + plan = empty_plan() + plan["queue_order"] = [review_issue["id"]] + plan["refresh_state"] = { + "lifecycle_phase": "review_postflight", + "postflight_scan_completed_at_scan_count": 7, + } + plan["epic_triage_meta"] = { + "triaged_ids": ["review::.::holistic::older::already_triaged"], + "triage_stages": { + "observe": {"confirmed_at": "2026-03-13T14:00:00+00:00"}, + "reflect": {"confirmed_at": "2026-03-13T14:01:00+00:00"}, + }, + } + + queue = build_work_queue(state, count=None, include_subjective=True, plan=plan) + ids = [item["id"] for item in queue["items"]] + assert ids[0] == "triage::observe" + + +def test_postflight_synthetic_queue_keeps_objective_backlog_suppressed(): + """Synthetic-only postflight work must not reactivate implicit execute mode.""" + from desloppify.engine._plan.schema import empty_plan + + state = _state( + [ + _issue("smells::src/a.py::x", detector="smells", tier=3), + _issue("smells::src/b.py::x", detector="smells", tier=3), + ], + dimension_scores={ + "Naming quality": { + "score": 82.0, + "strict": 82.0, + "failing": 1, + "detectors": { + "subjective_assessment": {"dimension_key": "naming_quality"}, + }, + }, + "Design coherence": { + "score": 73.0, + "strict": 73.0, + "failing": 1, + "detectors": { + "subjective_assessment": {"dimension_key": "design_coherence"}, + }, + }, + }, + ) + state["subjective_assessments"] = { + "naming_quality": {"score": 82.0}, + "design_coherence": { + "score": 73.0, + "needs_review_refresh": True, + "stale_since": "2026-01-01T00:00:00+00:00", + }, + } + + plan = empty_plan() + plan["queue_order"] = ["workflow::communicate-score", "workflow::create-plan"] + plan["refresh_state"] = {"postflight_scan_completed_at_scan_count": 15} + + queue = build_work_queue( + state, count=None, include_subjective=True, plan=plan, + ) + ids = [item["id"] for item in queue["items"]] + assert all(fid.startswith("subjective::") for fid in ids) + + +def test_explicit_planned_issue_bypasses_standalone_threshold_filter(): + """Explicit queue_order items must still surface even when naturally filtered.""" + from desloppify.engine._plan.schema import empty_plan + + state = _state([ + _issue( + "facade::src/a.py", + detector="facade", + file="src/a.py", + tier=2, + confidence="medium", + ), + ]) + plan = empty_plan() + plan["queue_order"] = ["facade::src/a.py"] + + queue = build_work_queue(state, count=None, include_subjective=True, plan=plan) + + assert [item["id"] for item in queue["items"]] == ["facade::src/a.py"] + + +def test_triaged_review_findings_stay_postflight_while_objective_work_remains(): + """Completed triage should not mix review findings into execute.""" + from desloppify.engine._plan.schema import empty_plan + + state = _state( + [ + _issue("smells::src/a.py::x", detector="smells", tier=3), + _issue( + "review::src/a.py::naming", + detector="review", + tier=1, + confidence="high", + detail={"dimension": "naming_quality"}, + ), + ] + ) + plan = empty_plan() + plan["plan_start_scores"] = {"strict": 80.0} + plan["queue_order"] = ["review::src/a.py::naming", "smells::src/a.py::x"] + plan["epic_triage_meta"] = { + "triaged_ids": ["review::src/a.py::naming"], + "last_completed_at": "2026-03-13T00:00:00+00:00", + } + plan["refresh_state"] = {"postflight_scan_completed_at_scan_count": 1} + + queue = build_execution_queue( + state, + options=QueueBuildOptions( + count=None, + include_subjective=False, + plan=plan, + ), + ) + ids = [item["id"] for item in queue["items"]] + assert ids == ["smells::src/a.py::x"] + + +def test_postflight_assessment_precedes_review_findings(): + """Postflight subjective reruns gate later review execution work.""" + from desloppify.engine._plan.schema import empty_plan + + state = _state( + [ + _issue( + "review::src/a.py::naming", + detector="review", + tier=1, + confidence="high", + detail={"dimension": "naming_quality"}, + ), + _issue( + "subjective_review::naming_quality", + detector="subjective_review", + tier=1, + confidence="high", + detail={"dimension": "naming_quality"}, + ), + ], + dimension_scores={ + "Naming quality": { + "score": 70.0, + "strict": 70.0, + "failing": 1, + "detectors": { + "subjective_assessment": {"dimension_key": "naming_quality"}, + }, + }, + }, + ) + state["subjective_assessments"] = { + "naming_quality": { + "score": 70.0, + "needs_review_refresh": True, + "stale_since": "2026-01-01T00:00:00+00:00", + } + } + plan = empty_plan() + plan["plan_start_scores"] = {"strict": 80.0} + plan["epic_triage_meta"] = { + "triaged_ids": ["review::src/a.py::naming"], + "last_completed_at": "2026-03-13T00:00:00+00:00", + } + plan["refresh_state"] = {"postflight_scan_completed_at_scan_count": 1} + + queue = build_execution_queue( + state, + options=QueueBuildOptions( + count=None, + include_subjective=True, + plan=plan, + ), + ) + ids = [item["id"] for item in queue["items"]] + # Subjective dimension item is suppressed when review issues cover the + # same dimension — the assessment request alone surfaces. + assert ids == ["subjective_review::naming_quality"] + + def test_execution_queue_excludes_unplanned_objective_items(): """Unplanned objective items don't appear in execution — only planned items do.""" from desloppify.engine._plan.schema import empty_plan @@ -282,7 +483,7 @@ def test_execution_queue_excludes_unplanned_objective_items(): def test_backlog_queue_excludes_execution_objective_items(): - """Backlog should exclude objective work already admitted to execution.""" + """Backlog should exclude execution items and synthetic workflow helpers.""" from desloppify.engine._plan.schema import empty_plan state = _state( @@ -304,8 +505,8 @@ def test_backlog_queue_excludes_execution_objective_items(): ) ids = [item["id"] for item in queue["items"]] assert "smells::src/a.py::planned" not in ids - assert "smells::src/b.py::unplanned" not in ids - assert "workflow::run-scan" in ids + assert "smells::src/b.py::unplanned" in ids + assert "workflow::run-scan" not in ids def test_unplanned_objective_items_dont_block_postflight(): @@ -444,7 +645,13 @@ def test_wontfixed_issues_excluded_from_queue(): ] ) - queue = build_work_queue(state, count=None, include_subjective=False) + queue = build_backlog_queue( + state, + options=QueueBuildOptions( + count=None, + include_subjective=False, + ), + ) ids = {item["id"] for item in queue["items"]} assert "a" in ids assert "d" in ids @@ -523,7 +730,7 @@ def test_triage_stages_hidden_during_initial_reviews(): def test_subjective_phase_precedes_score_and_triage_when_objective_drained(): - """With no objective backlog, stale/under-target subjective reruns come first.""" + """Subjective reruns stay ahead of workflow and triage once postflight begins.""" state = _state( [], dimension_scores={ @@ -588,3 +795,49 @@ def test_triage_stages_sort_after_workflow_in_natural_ranking(): wf_key = item_sort_key(workflow_item) tr_key = item_sort_key(triage_item) assert wf_key < tr_key, "workflow actions should sort before triage stages" + + +def test_fresh_under_target_postflight_review_preempts_persisted_workflow() -> None: + """Fresh below-target postflight review surfaces before queued workflow items.""" + state = _state( + [], + dimension_scores={ + "Naming quality": { + "score": 82.0, + "strict": 82.0, + "failing": 0, + "checks": 1, + "detectors": { + "subjective_assessment": { + "dimension_key": "naming_quality", + "placeholder": False, + }, + }, + }, + }, + ) + state["scan_count"] = 19 + state["subjective_assessments"] = { + "naming_quality": { + "score": 82.0, + "placeholder": False, + } + } + plan = { + "queue_order": ["workflow::communicate-score", "workflow::create-plan"], + "queue_skipped": {}, + "refresh_state": { + "postflight_scan_completed_at_scan_count": 19, + "lifecycle_phase": "workflow_postflight", + }, + } + + queue = build_work_queue(state, count=None, include_subjective=True, plan=plan) + assert [item["id"] for item in queue["items"]] == ["subjective::naming_quality"] + + plan["refresh_state"]["subjective_review_completed_at_scan_count"] = 19 + queue = build_work_queue(state, count=None, include_subjective=True, plan=plan) + assert [item["id"] for item in queue["items"]] == [ + "workflow::communicate-score", + "workflow::create-plan", + ] diff --git a/desloppify/tests/review/work_queue_cases.py b/desloppify/tests/review/work_queue_cases.py index 7240f4bd..af65a8e5 100644 --- a/desloppify/tests/review/work_queue_cases.py +++ b/desloppify/tests/review/work_queue_cases.py @@ -33,8 +33,10 @@ def _issue( def _state(issues: list[dict], *, dimension_scores: dict | None = None) -> dict: + work_items = {f["id"]: f for f in issues} return { - "issues": {f["id"]: f for f in issues}, + "work_items": work_items, + "issues": work_items, "dimension_scores": dimension_scores or {}, } @@ -230,12 +232,10 @@ def test_subjective_item_uses_show_review_when_matching_review_issues_exist(): queue = build_work_queue( state, count=None, include_subjective=True, subjective_threshold=95 ) - subj = next( - item for item in queue["items"] if item["kind"] == "subjective_dimension" - ) - assert subj["id"] == "subjective::mid_level_elegance" - assert subj["primary_command"] == "desloppify show review --status open" - assert subj["detail"]["open_review_issues"] == 1 + item = queue["items"][0] + assert item["id"] == "review::.::holistic::mid_level_elegance::split" + assert item["kind"] == "issue" + assert all(entry["kind"] != "subjective_dimension" for entry in queue["items"]) def test_stale_subjective_item_uses_show_review_when_matching_review_issues_exist(): @@ -272,12 +272,10 @@ def test_stale_subjective_item_uses_show_review_when_matching_review_issues_exis queue = build_work_queue( state, count=None, include_subjective=True, subjective_threshold=95 ) - subj = next( - item for item in queue["items"] if item["kind"] == "subjective_dimension" - ) - assert "[stale — re-review]" in subj["summary"] - assert subj["primary_command"] == "desloppify show review --status open" - assert subj["detail"]["open_review_issues"] == 1 + item = queue["items"][0] + assert item["id"] == "review::.::holistic::initialization_coupling::abc12345" + assert item["kind"] == "issue" + assert all(entry["kind"] != "subjective_dimension" for entry in queue["items"]) def test_unassessed_subjective_item_points_to_holistic_refresh(): @@ -604,6 +602,48 @@ def test_stale_subjective_appear_when_no_objective_backlog(): assert "subjective::naming_quality" in ids +def test_under_target_subjective_appear_when_no_objective_backlog(): + """Current below-target dimensions surface alongside stale review work.""" + state = _state( + [], + dimension_scores={ + "Naming quality": { + "score": 70.0, + "strict": 70.0, + "failing": 1, + "detectors": { + "subjective_assessment": {"dimension_key": "naming_quality"}, + }, + }, + "Design coherence": { + "score": 68.0, + "strict": 68.0, + "failing": 1, + "detectors": { + "subjective_assessment": {"dimension_key": "design_coherence"}, + }, + "stale": True, + }, + }, + ) + state["subjective_assessments"] = { + "naming_quality": { + "score": 70.0, + "needs_review_refresh": False, + }, + "design_coherence": { + "score": 68.0, + "needs_review_refresh": True, + "stale_since": "2026-01-01T00:00:00+00:00", + }, + } + + queue = build_work_queue(state, count=None, include_subjective=True) + ids = {item["id"] for item in queue["items"]} + + assert all(fid.startswith("subjective::") for fid in ids) + + def test_unassessed_subjective_visible_with_objective_backlog(): """When initial reviews exist, only they are shown — objective items hidden. @@ -772,7 +812,7 @@ def test_evidence_only_issue_still_in_state(): issues = [_issue("props::src/a.tsx::big", detector="props", confidence="low")] state = _state(issues) # Issue exists in state - assert "props::src/a.tsx::big" in state["issues"] + assert "props::src/a.tsx::big" in state["work_items"] # But not in queue queue = build_work_queue(state, count=None, include_subjective=False) assert len(queue["items"]) == 0 diff --git a/desloppify/tests/state/test_state.py b/desloppify/tests/state/test_state.py index dbe157e7..ad519e81 100644 --- a/desloppify/tests/state/test_state.py +++ b/desloppify/tests/state/test_state.py @@ -5,6 +5,8 @@ from desloppify.engine._state import filtering as state_query_mod +from desloppify.engine._state.issue_semantics import MECHANICAL_DEFECT, SCAN_ORIGIN +from desloppify.engine._state.schema import CURRENT_VERSION from desloppify.state import ( MergeScanOptions, apply_issue_noise_budget, @@ -238,6 +240,8 @@ def test_default_field_values(self, monkeypatch): assert f["tier"] == 2 assert f["confidence"] == "medium" assert f["summary"] == "sum" + assert f["issue_kind"] == MECHANICAL_DEFECT + assert f["origin"] == SCAN_ORIGIN # --------------------------------------------------------------------------- @@ -248,7 +252,7 @@ def test_default_field_values(self, monkeypatch): class TestEmptyState: def test_structure(self): s = empty_state() - assert s["version"] == 1 + assert s["version"] == CURRENT_VERSION assert s["last_scan"] is None assert s["scan_count"] == 0 assert "config" not in s # config moved to config.json @@ -269,7 +273,7 @@ def test_structure(self): class TestLoadState: def test_nonexistent_file_returns_empty_state(self, tmp_path): s = load_state(tmp_path / "missing.json") - assert s["version"] == 1 + assert s["version"] == CURRENT_VERSION assert s["issues"] == {} def test_valid_json_returns_parsed_data(self, tmp_path): @@ -290,6 +294,18 @@ def test_legacy_payload_gets_normalized(self, tmp_path): assert s["scan_count"] == 0 assert s["stats"] == {} assert s["issues"]["x"]["status"] == "open" + assert s["issues"]["x"]["issue_kind"] == MECHANICAL_DEFECT + assert s["issues"]["x"]["origin"] == SCAN_ORIGIN + validate_state_invariants(s) + + def test_work_items_payload_gets_normalized(self, tmp_path): + p = tmp_path / "state.json" + p.write_text( + json.dumps({"version": 2, "work_items": {"x": {"id": "x", "tier": 3}}}) + ) + s = load_state(p) + assert s["issues"]["x"]["status"] == "open" + assert s["issues"]["x"]["work_item_kind"] == MECHANICAL_DEFECT validate_state_invariants(s) def test_corrupt_json_tries_backup(self, tmp_path): @@ -306,7 +322,7 @@ def test_corrupt_json_no_backup_returns_empty(self, tmp_path): p = tmp_path / "state.json" p.write_text("{bad json!!") s = load_state(p) - assert s["version"] == 1 + assert s["version"] == CURRENT_VERSION assert s["issues"] == {} def test_corrupt_json_renames_file(self, tmp_path): @@ -322,7 +338,7 @@ def test_corrupt_json_and_corrupt_backup_returns_empty(self, tmp_path): backup.write_text("{also bad") s = load_state(p) - assert s["version"] == 1 + assert s["version"] == CURRENT_VERSION assert s["issues"] == {} @@ -338,7 +354,9 @@ def test_creates_file_and_writes_valid_json(self, tmp_path): save_state(st, p) assert p.exists() loaded = json.loads(p.read_text()) - assert loaded["version"] == 1 + assert loaded["version"] == CURRENT_VERSION + assert "work_items" in loaded + assert "issues" not in loaded def test_creates_backup_of_previous(self, tmp_path): p = tmp_path / "state.json" @@ -370,6 +388,7 @@ def test_atomic_write_produces_valid_json(self, tmp_path): loaded = json.loads(p.read_text()) assert loaded["custom_set"] == [1, 2, 3] # sorted assert loaded["custom_path"] == "/tmp/hello" + assert "work_items" in loaded def test_invalid_status_gets_normalized_before_save(self, tmp_path): p = tmp_path / "state.json" @@ -378,7 +397,7 @@ def test_invalid_status_gets_normalized_before_save(self, tmp_path): ensure_state_defaults(st) save_state(st, p) loaded = json.loads(p.read_text()) - assert loaded["issues"]["x"]["status"] == "open" + assert loaded["work_items"]["x"]["status"] == "open" # --------------------------------------------------------------------------- @@ -603,19 +622,31 @@ def test_mechanical_auto_resolved_still_reopened(self): class TestMissingIssuesResolved: """Issues present in state but absent from scan stay user-controlled.""" - def test_missing_issue_stays_open(self): - """An open issue that disappears from scan remains open until resolved.""" + def test_missing_issue_stays_open_when_file_exists(self, tmp_path): + """An open issue whose file still exists stays open until resolved.""" + (tmp_path / "a.py").write_text("# exists") st = empty_state() old = _make_raw_issue("det::a.py::fn", detector="det", file="a.py") old["lang"] = "python" st["issues"]["det::a.py::fn"] = old - # Merge an empty scan — the old issue stays open. - diff = merge_scan(st, [], MergeScanOptions(lang="python", force_resolve=True)) + diff = merge_scan(st, [], MergeScanOptions(lang="python", force_resolve=True, project_root=str(tmp_path))) assert diff["auto_resolved"] == 0 assert st["issues"]["det::a.py::fn"]["status"] == "open" assert st["issues"]["det::a.py::fn"]["resolved_at"] is None + def test_missing_issue_auto_resolved_when_file_deleted(self, tmp_path): + """An open issue for a deleted file is auto-resolved on rescan.""" + st = empty_state() + old = _make_raw_issue("det::a.py::fn", detector="det", file="a.py") + old["lang"] = "python" + st["issues"]["det::a.py::fn"] = old + + diff = merge_scan(st, [], MergeScanOptions(lang="python", force_resolve=True, project_root=str(tmp_path))) + assert diff["auto_resolved"] == 1 + assert st["issues"]["det::a.py::fn"]["status"] == "auto_resolved" + assert "no longer exists" in st["issues"]["det::a.py::fn"]["note"] + def test_missing_fixed_issue_gets_scan_verified(self): """A manually fixed issue stays fixed and gains scan corroboration.""" st = empty_state() @@ -852,4 +883,3 @@ def test_zero_active_checks_with_assessments_keeps_subjective_scoring(self): # Overall/strict are dragged down by the low assessment score. assert st["overall_score"] < 100.0 assert st["strict_score"] < 100.0 - diff --git a/desloppify/tests/state/test_state_internal_direct.py b/desloppify/tests/state/test_state_internal_direct.py index 547ee288..4cca4f90 100644 --- a/desloppify/tests/state/test_state_internal_direct.py +++ b/desloppify/tests/state/test_state_internal_direct.py @@ -5,6 +5,7 @@ import json import desloppify.engine._state.filtering as filtering_mod +import desloppify.engine._state.issue_semantics as issue_semantics_mod import desloppify.engine._state.noise as noise_mod import desloppify.engine._state.persistence as persistence_mod import desloppify.engine._state.resolution as resolution_mod @@ -74,6 +75,64 @@ def test_load_state_missing_and_backup_fallback(tmp_path): assert recovered["strict_score"] == 0 +def test_issue_semantics_normalize_legacy_detector_rows(): + review_issue = {"id": "review::src/a.py::naming", "detector": "review", "detail": {}} + concern_issue = {"id": "concerns::src/a.py::dup", "detector": "concerns", "detail": {}} + request_issue = { + "id": "subjective_review::.::holistic_unreviewed", + "detector": "subjective_review", + "detail": {}, + } + mechanical_issue = {"id": "unused::src/a.py::x", "detector": "unused", "detail": {}} + + issue_semantics_mod.ensure_work_item_semantics(review_issue) + issue_semantics_mod.ensure_work_item_semantics(concern_issue) + issue_semantics_mod.ensure_work_item_semantics(request_issue) + issue_semantics_mod.ensure_work_item_semantics(mechanical_issue) + + assert review_issue["work_item_kind"] == issue_semantics_mod.REVIEW_DEFECT + assert review_issue["issue_kind"] == issue_semantics_mod.REVIEW_DEFECT + assert review_issue["origin"] == issue_semantics_mod.REVIEW_IMPORT_ORIGIN + assert concern_issue["work_item_kind"] == issue_semantics_mod.REVIEW_CONCERN + assert concern_issue["issue_kind"] == issue_semantics_mod.REVIEW_CONCERN + assert request_issue["work_item_kind"] == issue_semantics_mod.ASSESSMENT_REQUEST + assert request_issue["issue_kind"] == issue_semantics_mod.ASSESSMENT_REQUEST + assert request_issue["origin"] == issue_semantics_mod.SYNTHETIC_TASK_ORIGIN + assert mechanical_issue["work_item_kind"] == issue_semantics_mod.MECHANICAL_DEFECT + assert mechanical_issue["issue_kind"] == issue_semantics_mod.MECHANICAL_DEFECT + assert mechanical_issue["origin"] == issue_semantics_mod.SCAN_ORIGIN + + +def test_validate_state_invariants_rejects_invalid_issue_semantics(): + state = schema_mod.empty_state() + state["work_items"] = { + "bad": { + "id": "bad", + "detector": "unused", + "file": "src/a.py", + "tier": 2, + "confidence": "high", + "summary": "bad", + "detail": {}, + "status": "open", + "note": None, + "first_seen": "2025-01-01T00:00:00+00:00", + "last_seen": "2025-01-01T00:00:00+00:00", + "resolved_at": None, + "reopen_count": 0, + "issue_kind": "not_real", + "origin": issue_semantics_mod.SCAN_ORIGIN, + } + } + + try: + schema_mod.validate_state_invariants(state) + except ValueError as exc: + assert "work_item_kind" in str(exc) + else: + raise AssertionError("validate_state_invariants should reject invalid issue_kind") + + def test_state_persistence_defaults_follow_runtime_project_root(tmp_path): from desloppify.base.runtime_state import RuntimeContext, runtime_scope @@ -122,7 +181,7 @@ def test_match_and_resolve_issues_updates_state(): ) hidden_issue["suppressed"] = True - state["issues"] = { + state["work_items"] = { open_issue["id"]: open_issue, hidden_issue["id"]: hidden_issue, } @@ -140,7 +199,7 @@ def test_match_and_resolve_issues_updates_state(): ) assert resolved_ids == [open_issue["id"]] - resolved = state["issues"][open_issue["id"]] + resolved = state["work_items"][open_issue["id"]] assert resolved["status"] == "fixed" assert resolved["note"] == "done" assert resolved["resolved_at"] is not None @@ -195,7 +254,7 @@ def test_resolve_fixed_review_marks_assessment_stale_preserves_score(): summary="naming issue", detail={"dimension": "naming_quality"}, ) - state["issues"] = {review_issue["id"]: review_issue} + state["work_items"] = {review_issue["id"]: review_issue} state["subjective_assessments"] = { "naming_quality": {"score": 82, "source": "holistic"}, "logic_clarity": {"score": 74, "source": "holistic"}, @@ -233,7 +292,7 @@ def test_resolve_wontfix_review_marks_assessment_stale(): summary="naming issue", detail={"dimension": "naming_quality"}, ) - state["issues"] = {review_issue["id"]: review_issue} + state["work_items"] = {review_issue["id"]: review_issue} state["subjective_assessments"] = { "naming_quality": {"score": 82, "source": "holistic"} } @@ -265,7 +324,7 @@ def test_resolve_false_positive_review_marks_assessment_stale(): summary="naming issue", detail={"dimension": "naming_quality"}, ) - state["issues"] = {review_issue["id"]: review_issue} + state["work_items"] = {review_issue["id"]: review_issue} state["subjective_assessments"] = { "naming_quality": {"score": 82, "source": "holistic"} } @@ -295,7 +354,7 @@ def test_resolve_non_review_issue_does_not_mark_stale(): confidence="high", summary="unused name", ) - state["issues"] = {issue["id"]: issue} + state["work_items"] = {issue["id"]: issue} state["subjective_assessments"] = { "naming_quality": {"score": 82, "source": "holistic"} } @@ -325,7 +384,7 @@ def test_resolve_wontfix_captures_snapshot_metadata(): summary="large module", detail={"loc": 210, "complexity_score": 42}, ) - state["issues"] = {issue["id"]: issue} + state["work_items"] = {issue["id"]: issue} resolution_mod.resolve_issues( state, @@ -335,7 +394,7 @@ def test_resolve_wontfix_captures_snapshot_metadata(): attestation="I have actually reviewed this and I am not gaming the score.", ) - resolved = state["issues"][issue["id"]] + resolved = state["work_items"][issue["id"]] assert resolved["status"] == "wontfix" assert resolved["wontfix_scan_count"] == 17 assert resolved["wontfix_snapshot"]["scan_count"] == 17 @@ -370,7 +429,7 @@ def test_resolve_stale_wontfix_refreshes_original_wontfix_snapshot(): summary="stale wontfix", detail={"original_issue_id": original["id"], "reasons": ["scan_decay"]}, ) - state["issues"] = { + state["work_items"] = { original["id"]: original, stale["id"]: stale, } @@ -383,7 +442,7 @@ def test_resolve_stale_wontfix_refreshes_original_wontfix_snapshot(): attestation="I have actually re-reviewed this wontfix and I am not gaming the score.", ) - refreshed = state["issues"][original["id"]] + refreshed = state["work_items"][original["id"]] assert refreshed["status"] == "wontfix" assert refreshed["wontfix_scan_count"] == 24 assert refreshed["wontfix_snapshot"]["scan_count"] == 24 @@ -406,7 +465,7 @@ def test_resolve_open_reopens_non_open_issue_and_increments_reopen_count(): issue["resolved_at"] = "2026-01-01T10:00:00+00:00" issue["note"] = "fixed earlier" issue["reopen_count"] = 2 - state["issues"] = {issue["id"]: issue} + state["work_items"] = {issue["id"]: issue} resolved_ids = resolution_mod.resolve_issues( state, @@ -417,7 +476,7 @@ def test_resolve_open_reopens_non_open_issue_and_increments_reopen_count(): ) assert resolved_ids == [issue["id"]] - reopened = state["issues"][issue["id"]] + reopened = state["work_items"][issue["id"]] assert reopened["status"] == "open" assert reopened["resolved_at"] is None assert reopened["note"] == "needs deeper fix" diff --git a/desloppify/tests/state/test_suppression_scoring.py b/desloppify/tests/state/test_suppression_scoring.py index e4eed052..5d0eadcc 100644 --- a/desloppify/tests/state/test_suppression_scoring.py +++ b/desloppify/tests/state/test_suppression_scoring.py @@ -163,7 +163,7 @@ def test_fixed_stays_fixed(self): state = _minimal_state(issues) removed = remove_ignored_issues(state, "src/a.ts") assert removed == 1 - f = state["issues"]["unused::src/a.ts::foo"] + f = state["work_items"]["unused::src/a.ts::foo"] assert f["suppressed"] is True assert f["status"] == "fixed" # NOT reopened to "open" @@ -177,7 +177,7 @@ def test_auto_resolved_stays_auto_resolved(self): } state = _minimal_state(issues) remove_ignored_issues(state, "src/a.ts") - f = state["issues"]["unused::src/a.ts::bar"] + f = state["work_items"]["unused::src/a.ts::bar"] assert f["suppressed"] is True assert f["status"] == "auto_resolved" @@ -191,7 +191,7 @@ def test_false_positive_stays_false_positive(self): } state = _minimal_state(issues) remove_ignored_issues(state, "src/a.ts") - f = state["issues"]["unused::src/a.ts::baz"] + f = state["work_items"]["unused::src/a.ts::baz"] assert f["suppressed"] is True assert f["status"] == "false_positive" @@ -218,15 +218,15 @@ def test_directory_pattern_matches_descendants(self): removed_worktrees = remove_ignored_issues(state, ".claude/worktrees") assert removed_worktrees == 1 assert ( - state["issues"]["security::.claude/worktrees/a/file.py::b101"]["suppressed"] + state["work_items"]["security::.claude/worktrees/a/file.py::b101"]["suppressed"] is True ) - assert state["issues"]["security::.claude/file.py::b101"]["suppressed"] is False + assert state["work_items"]["security::.claude/file.py::b101"]["suppressed"] is False removed_claude = remove_ignored_issues(state, ".claude") assert removed_claude == 2 - assert state["issues"]["security::.claude/file.py::b101"]["suppressed"] is True - assert state["issues"]["security::src/app.py::b101"]["suppressed"] is False + assert state["work_items"]["security::.claude/file.py::b101"]["suppressed"] is True + assert state["work_items"]["security::src/app.py::b101"]["suppressed"] is False # --------------------------------------------------------------------------- @@ -295,21 +295,21 @@ def test_suppressed_issues_invisible_to_scoring(self): # Simulate ignore: suppress the issue remove_ignored_issues(state, "src/a.ts") - f = state["issues"]["unused::src/a.ts::foo"] + f = state["work_items"]["unused::src/a.ts::foo"] assert f["suppressed"] is True assert f["status"] == "fixed" # preserved # _count_issues should not see it - counters, _ = _count_issues(state["issues"]) + counters, _ = _count_issues(state["work_items"]) assert counters.get("open", 0) == 0 assert counters.get("fixed", 0) == 0 # suppressed => invisible # _iter_scoring_candidates should not yield it candidates = list( - _iter_scoring_candidates("unused", state["issues"], frozenset()) + _iter_scoring_candidates("unused", state["work_items"], frozenset()) ) assert candidates == [] # open_scope_breakdown should not count it - breakdown = open_scope_breakdown(state["issues"], ".") + breakdown = open_scope_breakdown(state["work_items"], ".") assert breakdown["global"] == 0 diff --git a/desloppify/tests/workflows/test_tweet_release_script.py b/desloppify/tests/workflows/test_tweet_release_script.py new file mode 100644 index 00000000..ca3cfdea --- /dev/null +++ b/desloppify/tests/workflows/test_tweet_release_script.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + + +SCRIPT_PATH = ( + Path(__file__).resolve().parents[3] + / ".github" + / "workflows" + / "scripts" + / "tweet_release.py" +) + + +def _load_tweet_release_module(monkeypatch: pytest.MonkeyPatch): + anthropic_stub = SimpleNamespace(Anthropic=lambda: SimpleNamespace(messages=None)) + tweepy_stub = SimpleNamespace( + OAuth1UserHandler=lambda *args, **kwargs: None, + API=lambda auth: None, + Client=lambda **kwargs: None, + errors=SimpleNamespace(TwitterServerError=RuntimeError), + ) + _RequestException = type("RequestException", (OSError,), {}) + requests_stub = SimpleNamespace( + get=lambda *args, **kwargs: None, + post=lambda *args, **kwargs: None, + RequestException=_RequestException, + ConnectionError=type("ConnectionError", (_RequestException,), {}), + Timeout=type("Timeout", (_RequestException,), {}), + HTTPError=type("HTTPError", (_RequestException,), {}), + exceptions=SimpleNamespace(RequestException=_RequestException), + ) + monkeypatch.setitem(sys.modules, "anthropic", anthropic_stub) + monkeypatch.setitem(sys.modules, "tweepy", tweepy_stub) + monkeypatch.setitem(sys.modules, "requests", requests_stub) + + module_name = f"tweet_release_test_{uuid4().hex}" + spec = importlib.util.spec_from_file_location(module_name, SCRIPT_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_generate_image_uses_timeout_and_wraps_request_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_tweet_release_module(monkeypatch) + seen: dict[str, object] = {} + + def fake_post(*args, **kwargs): + seen["timeout"] = kwargs["timeout"] + raise module.requests.Timeout("too slow") + + monkeypatch.setattr(module.requests, "post", fake_post) + + with pytest.raises(module.ReleaseTweetError, match="fal.ai request failed"): + module.generate_image("prompt", "key") + + assert seen["timeout"] == module.REQUEST_TIMEOUT_SECONDS + + +def test_generate_tweet_and_prompt_wraps_bad_claude_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_tweet_release_module(monkeypatch) + + class _Messages: + @staticmethod + def create(**_kwargs): + return SimpleNamespace(content=[SimpleNamespace(text="{not json}")]) + + monkeypatch.setattr( + module.anthropic, + "Anthropic", + lambda: SimpleNamespace(messages=_Messages()), + ) + + with pytest.raises(module.ReleaseTweetError, match="Anthropic returned invalid JSON payload"): + module.generate_tweet_and_prompt("v1.2.3", ["Feature"], "https://example.com/release") + + +def test_download_image_uses_timeout_and_wraps_network_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_tweet_release_module(monkeypatch) + seen: dict[str, object] = {} + + def fake_get(*args, **kwargs): + seen["timeout"] = kwargs["timeout"] + raise module.requests.ConnectionError("network down") + + monkeypatch.setattr(module.requests, "get", fake_get) + + with pytest.raises(module.ReleaseTweetError, match="image download failed"): + module.download_image("https://example.com/image.png") + + assert seen["timeout"] == module.REQUEST_TIMEOUT_SECONDS + + +def test_main_posts_trimmed_tweet_and_cleans_up( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + module = _load_tweet_release_module(monkeypatch) + image_path = tmp_path / "release.png" + image_path.write_bytes(b"image") + posted: dict[str, str] = {} + + monkeypatch.setenv("RELEASE_TAG", "v1.2.3") + monkeypatch.setenv("RELEASE_BODY", "## First\n## Second") + monkeypatch.setenv("RELEASE_URL", "https://example.com/release") + monkeypatch.setenv("FAL_KEY", "fal-key") + monkeypatch.setattr( + module, + "generate_tweet_and_prompt", + lambda *_args: { + "tweet": "Introducing desloppify v1.2.3!\n" + "\n".join("- feature" for _ in range(80)), + "image_prompt": "draw a release board", + }, + ) + monkeypatch.setattr(module, "generate_image", lambda *_args: "https://example.com/img.png") + monkeypatch.setattr(module, "download_image", lambda *_args: str(image_path)) + + def fake_post(tweet_text: str, image_file: str, reply_text: str) -> None: + posted["tweet"] = tweet_text + posted["image"] = image_file + posted["reply"] = reply_text + + monkeypatch.setattr(module, "post_tweet_with_reply", fake_post) + + module.main() + + assert posted["image"] == str(image_path) + assert len(posted["tweet"]) <= 280 + assert posted["reply"] == "Release notes: https://example.com/release" + assert not image_path.exists() + + +def test_post_tweet_with_reply_wraps_media_upload_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_tweet_release_module(monkeypatch) + + class _Api: + @staticmethod + def media_upload(_image_path): + raise RuntimeError("upload boom") + + monkeypatch.setenv("TWITTER_API_KEY", "key") + monkeypatch.setenv("TWITTER_API_SECRET", "secret") + monkeypatch.setenv("TWITTER_ACCESS_TOKEN", "token") + monkeypatch.setenv("TWITTER_ACCESS_SECRET", "access-secret") + monkeypatch.setattr(module.tweepy, "API", lambda _auth: _Api()) + + with pytest.raises(module.ReleaseTweetError, match="Twitter media upload failed: upload boom"): + module.post_tweet_with_reply("tweet", "/tmp/image.png", "reply") + + +def test_post_tweet_with_reply_wraps_non_retryable_create_tweet_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_tweet_release_module(monkeypatch) + + class _Media: + media_id = "m1" + + class _Api: + @staticmethod + def media_upload(_image_path): + return _Media() + + class _Client: + @staticmethod + def create_tweet(**_kwargs): + raise RuntimeError("tweet boom") + + monkeypatch.setenv("TWITTER_API_KEY", "key") + monkeypatch.setenv("TWITTER_API_SECRET", "secret") + monkeypatch.setenv("TWITTER_ACCESS_TOKEN", "token") + monkeypatch.setenv("TWITTER_ACCESS_SECRET", "access-secret") + monkeypatch.setattr( + module.tweepy, + "errors", + SimpleNamespace(TwitterServerError=type("TwitterServerError", (Exception,), {})), + ) + monkeypatch.setattr(module.tweepy, "API", lambda _auth: _Api()) + monkeypatch.setattr(module.tweepy, "Client", lambda **_kwargs: _Client()) + + with pytest.raises(module.ReleaseTweetError, match="Twitter create_tweet failed: tweet boom"): + module.post_tweet_with_reply("tweet", "/tmp/image.png", "reply") + + +def test_main_exits_cleanly_on_bounded_release_failure( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + module = _load_tweet_release_module(monkeypatch) + + monkeypatch.setenv("RELEASE_TAG", "v1.2.3") + monkeypatch.setenv("RELEASE_BODY", "## First") + monkeypatch.setenv("RELEASE_URL", "https://example.com/release") + monkeypatch.setenv("FAL_KEY", "fal-key") + monkeypatch.setattr( + module, + "generate_tweet_and_prompt", + lambda *_args: { + "tweet": "Introducing desloppify v1.2.3!", + "image_prompt": "draw a release board", + }, + ) + monkeypatch.setattr( + module, + "generate_image", + lambda *_args: (_ for _ in ()).throw(module.ReleaseTweetError("fal.ai request failed")), + ) + + with pytest.raises(SystemExit) as exc_info: + module.main() + + assert exc_info.value.code == 1 + assert "Release tweet failed: fal.ai request failed" in capsys.readouterr().err diff --git a/docs/DEVELOPMENT_PHILOSOPHY.md b/docs/DEVELOPMENT_PHILOSOPHY.md index 6f466506..b4dca4f1 100644 --- a/docs/DEVELOPMENT_PHILOSOPHY.md +++ b/docs/DEVELOPMENT_PHILOSOPHY.md @@ -32,7 +32,7 @@ This is the thing we care about most. If an agent can game the score to 100 with ## Language-agnostic -The scoring model and the core engine don't know about any specific language. Language-specific stuff lives in plugins. The principles and scoring intent stay the same whether you're scanning TypeScript, Python, or Rust. Currently 28 languages, and the plugin framework makes adding more straightforward. +The scoring model and the core engine don't know about any specific language. Language-specific stuff lives in plugins. The principles and scoring intent stay the same whether you're scanning TypeScript, Python, or Rust. Currently 29 languages, and the plugin framework makes adding more straightforward. ## Architectural boundaries diff --git a/docs/SKILL.md b/docs/SKILL.md index 1d07dd1d..704fe88f 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -5,7 +5,7 @@ description: > about code quality, technical debt, dead code, large files, god classes, duplicate functions, code smells, naming issues, import cycles, or coupling problems. Also use when asked for a health score, what to fix next, or to - create a cleanup plan. Supports 28 languages. + create a cleanup plan. Supports 29 languages. allowed-tools: Bash(desloppify *) --- diff --git a/pyproject.toml b/pyproject.toml index e27d6e6d..f5d4ebe2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "desloppify" -version = "0.9.8" +version = "0.9.9" description = "Multi-language codebase health scanner and technical debt tracker" readme = "README.md" requires-python = ">=3.11" diff --git a/test_release_image.png b/test_release_image.png new file mode 100644 index 00000000..6658c5b7 Binary files /dev/null and b/test_release_image.png differ diff --git a/website/index.html b/website/index.html index f9602a3a..3be29121 100644 --- a/website/index.html +++ b/website/index.html @@ -33,7 +33,7 @@ <h1>desloppify</h1> <p class="subtitle"> Give your AI coding agent a north star. Desloppify scans, scores, and systematically improves code quality — mechanical issues and subjective ones — - across 28 languages. The score resists gaming. The only way up is to actually + across 29 languages. The score resists gaming. The only way up is to actually make the code better. </p> <div class="hero-actions"> @@ -43,7 +43,7 @@ <h1>desloppify</h1> <div class="hero-badges"> <img src="https://img.shields.io/pypi/v/desloppify" alt="PyPI version"> <img src="https://img.shields.io/badge/python-3.11%2B-blue" alt="Python 3.11+"> - <img src="https://img.shields.io/badge/languages-28-green" alt="28 languages"> + <img src="https://img.shields.io/badge/languages-29-green" alt="29 languages"> </div> </div> <canvas id="hero-canvas" aria-hidden="true"></canvas>