From f37ec4f2a513c063563a3488f1e905309efd7c0b Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Sun, 25 Oct 2026 05:53:37 +0800 Subject: [PATCH] [node-maintenance-repair/5] Render bounded repair choices and folded evidence Add revision-bound repair choices to the shared report renderer. Derive up to twenty exact pnpm same-minor patch attempts from published versions that satisfy all applicable advisories and checked parent constraints. Keep uncertain or incompatible pins unavailable for automatic attempts. Show distinct needs-review and blocked states, folded evidence directly under the affected row, and instructions to submit /repair selected in the PR conversation. Match the App consent grammar and fingerprints while preserving platform and package reports. Consumer publication binding and real dependency repair remain later delivery slices. Validation: 1,346 Python tests, 37 shared JavaScript fixtures, Ruff, Pyrefly, actual GitHub GFM rendering, and a clean independent full review. --- docs/node-maintenance.md | 18 ++ node/runtime/repair_choices.py | 195 +++++++++++++ node/runtime/templates/dependency_audit.py | 161 +++++++++-- node/tests/test_maintenance_options.py | 2 +- node/tests/test_node_standard.py | 39 +-- node/tests/test_security_reporting.py | 2 +- node/tests/test_selected_repairs.py | 303 +++++++++++++++++++++ 7 files changed, 682 insertions(+), 38 deletions(-) create mode 100644 node/runtime/repair_choices.py create mode 100644 node/tests/test_selected_repairs.py diff --git a/docs/node-maintenance.md b/docs/node-maintenance.md index 5b307f6..4d1fd31 100644 --- a/docs/node-maintenance.md +++ b/docs/node-maintenance.md @@ -300,6 +300,24 @@ recovery before the next attempt. Production repair and report integration remain later delivery slices. Nothing merges automatically. See the [service reference](node-maintenance-operator.md) for current limits. +### Reading repair choices + +๐ŸŸก **Needs review** means a decision or explicit pin needs attention. +๐Ÿ”ด **Blocked** means the available evidence does not support a compatible fix. +A check mark identifies a proposed applied fix; the report's validation section +states whether the project checks passed. Status icons do not indicate severity; +the severity column reports that separately. + +An eligible pin has a folded assessment immediately below its package row. It +shows the published candidate, checked parent constraints and advisory links. +**Likely feasible** means that an automatic attempt appears possible. It does +not mean repair validation passed or that the dependency was upgraded. + +The report component can produce the exact selection block bound to a known PR +head and base. Unbound audit reports do not offer an active repair command. The +published caller integration and actual dependency repair remain unreleased; +the harmless pilot's success does not change the package table to upgraded. + ## Toolchain selection The basic setup follows project declarations. Explicit or inherited constraints diff --git a/node/runtime/repair_choices.py b/node/runtime/repair_choices.py new file mode 100644 index 0000000..d45b2dd --- /dev/null +++ b/node/runtime/repair_choices.py @@ -0,0 +1,195 @@ +"""Bounded repair choices shared by the report and App consent contract.""" + +import hashlib +import json +import re + +START = "" +END = "" +PACKAGE = r"(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*" +VERSION = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)" +SHA = r"[a-f0-9]{40}" +LINE = re.compile( + rf"- \[([ xX])\] Allow `({PACKAGE})` `({VERSION}) โ†’ ({VERSION})` by updating its explicit pin\." +) + + +def _version_tuple(value): + if not isinstance(value, str) or not re.fullmatch(VERSION, value): + return None + return tuple(value.split(".")) + + +def _patch_key(version): + return len(version[2]), version[2] + + +def validate(items): + """Return canonical sorted items; an empty selection is valid.""" + if not isinstance(items, list) or len(items) > 20: + raise ValueError("Select at most twenty fixes") + result, names = [], set() + for item in items: + if not isinstance(item, dict) or set(item) != {"name", "from", "to"}: + raise ValueError("Invalid selection fields") + name = item["name"] + if ( + not isinstance(name, str) + or not re.fullmatch(PACKAGE, name) + or len(name) > 214 + or name in {"node_modules", "favicon.ico"} + or name in names + ): + raise ValueError("Invalid or duplicate package") + if any( + not isinstance(item[key], str) or not re.fullmatch(VERSION, item[key]) + for key in ("from", "to") + ): + raise ValueError("Versions must be exact stable versions") + old, new = (item[key].split(".") for key in ("from", "to")) + if old[:2] != new[:2] or (len(old[2]), old[2]) >= (len(new[2]), new[2]): + raise ValueError("Only increasing same-minor patches are allowed") + names.add(name) + result.append({"name": name, "from": item["from"], "to": item["to"]}) + return sorted(result, key=lambda item: item["name"]) + + +def parse(body): + if not isinstance(body, str) or body.count(START) != 1 or body.count(END) != 1: + raise ValueError("Expected one selection block") + lines = re.split(r"\r?\n", body) + try: + start, end = lines.index(START), lines.index(END) + except ValueError as error: + raise ValueError("Selection markers must occupy their own lines") from error + if end <= start: + raise ValueError("Selection markers are out of order") + all_items, selected = [], [] + for line in lines[start + 1 : end]: + match = LINE.fullmatch(line) + if match is None: + raise ValueError("Invalid selection line") + item = dict(zip(("name", "from", "to"), match.groups()[1:])) + all_items.append(item) + if match[1].lower() == "x": + selected.append(item) + # App validation covers unchecked entries and duplicates too. Only checked + # entries count against the twenty-selection limit. + names = set() + for item in all_items: + validate([item]) + if item["name"] in names: + raise ValueError("Duplicate package") + names.add(item["name"]) + return validate(selected) + + +def revision_marker(head, base): + if any( + not isinstance(value, str) or not re.fullmatch(SHA, value) + for value in (head, base) + ): + raise ValueError("Exact head and base revisions are required") + return f"" + + +def fingerprint(pr, head, base, items): + if type(pr) is not int or not 0 < pr <= 9_007_199_254_740_991: + raise ValueError("Invalid PR number") + revision_marker(head, base) + payload = json.dumps( + [pr, head, base, validate(items)], separators=(",", ":"), ensure_ascii=False + ) + return hashlib.sha256(payload.encode()).hexdigest() + + +def candidates(data): + """Use the audit engine's verified evidence, never a parsed status label.""" + if not isinstance(data, dict) or data.get("package_manager") != "pnpm": + return [] + result = [] + for name, finding in sorted(data.get("vulnerabilities", {}).items()): + details = finding.get("via", []) + if not details or not all(isinstance(detail, dict) for detail in details): + continue + # The audit engine retains an eligible version set for every advisory. + # Intersect those sets before choosing one patch so a lower version from + # one advisory cannot hide a later patch that satisfies all advisories. + if any("_verifiedPatchedVersions" in detail for detail in details): + evidence = [] + repair_from = [] + for detail in details: + versions = detail.get("_verifiedRepairVersions") + source = detail.get("_repairFrom") + if not isinstance(versions, list) or not versions: + evidence = [] + break + parsed = {_version_tuple(version) for version in versions} + if None in parsed: + evidence = [] + break + if _version_tuple(source) is None: + evidence = [] + break + evidence.append(set(versions)) + repair_from.append(source) + if not evidence or len(set(repair_from)) != 1: + continue + common = set.intersection(*evidence) + old_version = _version_tuple(repair_from[0]) + eligible = [ + version + for version in common + if (parsed_version := _version_tuple(version)) is not None + and parsed_version[:2] == old_version[:2] + and _patch_key(parsed_version) > _patch_key(old_version) + ] + if not eligible: + continue + try: + items = validate( + [ + { + "name": name, + "from": repair_from[0], + "to": min( + eligible, + key=lambda version: _patch_key(_version_tuple(version)), + ), + } + ] + ) + except ValueError: + continue + result.extend(items) + continue + return result[:20] + + +def render(items, *, head, base): + """Render consent only with the exact revisions of the published PR.""" + items = validate(items) + marker = revision_marker(head, base) + if not items: + return "" + return ( + "\n## Choose fixes to try\n\n" + "These patches appear compatible with the checked parent constraints. " + "Repair validation has not run. Selecting a fix permits one attempt; " + "it does not approve merging.\n\n" + + START + + "\n" + + "\n".join( + f"- [ ] Allow `{item['name']}` `{item['from']} โ†’ {item['to']}` by updating its explicit pin." + for item in items + ) + + "\n" + + END + + "\n" + + marker + + "\n\n### Run selected fixes\n\n" + "Select the fixes above, then post a **new comment** containing only " + "`/repair selected` and click GitHub's **Comment** button. " + "The bot acknowledges the request and updates the same reply with the result. " + "Checkbox edits alone start no work. Nothing merges automatically.\n" + ) diff --git a/node/runtime/templates/dependency_audit.py b/node/runtime/templates/dependency_audit.py index ded2727..19bd2f3 100644 --- a/node/runtime/templates/dependency_audit.py +++ b/node/runtime/templates/dependency_audit.py @@ -3,6 +3,7 @@ import argparse import copy import hashlib +import html import json import re import runpy @@ -17,6 +18,7 @@ LOCAL_PATCHES = runpy.run_path(str(Path(__file__).parents[1] / "local_patches.py")) NODE_RELEASES = runpy.run_path(str(Path(__file__).parents[1] / "node_releases.py")) +REPAIR_CHOICES = runpy.run_path(str(Path(__file__).parents[1] / "repair_choices.py")) SEVERITY_ORDER = {"critical": 0, "high": 1, "moderate": 2, "low": 3, "info": 4} DIRECTORY_PATTERN = re.compile(r"[A-Za-z0-9_./ -]+") @@ -1127,9 +1129,13 @@ def published(specifier: str) -> set[str] | None: vulnerability["fixAvailable"] = False for detail in vulnerability["via"]: detail.pop("fixVersion", None) + detail.pop("_repairCandidate", None) + detail.pop("_repairFrom", None) + detail.pop("_verifiedRepairVersions", None) + detail.pop("_checkedParentConstraints", None) + detail.pop("_verifiedPatchedVersions", None) if collect_proof: detail.pop("_verifiedVulnerableVersions", None) - detail.pop("_verifiedPatchedVersions", None) patched = detail.get("patchedRange") vulnerable_range = detail.get("range") if collect_proof and isinstance(vulnerable_range, str) and vulnerable_range: @@ -1159,10 +1165,13 @@ def published(specifier: str) -> set[str] | None: f"No published stable patch matches registry range `{patched}`." ) continue - if collect_proof: - detail["_verifiedPatchedVersions"] = sorted(patches) + # Candidate selection needs the same verified publication set that + # the audit report used. Keep this evidence internal; the normal + # report continues to use fixStatus/fixVersion below. + detail["_verifiedPatchedVersions"] = sorted(patches) vulnerability["fixAvailable"] = True blocked = [] + checked_parents = [] unknown = False compatible_patches = None for parent, snapshot in lock.get("snapshots", {}).items(): @@ -1191,6 +1200,7 @@ def published(specifier: str) -> set[str] | None: if not isinstance(constraint, str): unknown = True continue + checked_parents.append(f"{parent} declares {name}: {constraint}") intersections = [ published(f"{name}@{parent_range.strip()} {patch_range.strip()}") for parent_range in constraint.split("||") @@ -1212,6 +1222,8 @@ def published(specifier: str) -> set[str] | None: if compatible_patches is None else compatible_patches & parent_patches ) + detail["_checkedParentConstraints"] = sorted(set(checked_parents)) + parent_compatible_patches = compatible_patches override = overrides.get(name) if isinstance(overrides, dict) else None override_status = "" override_patches = None @@ -1286,6 +1298,22 @@ def published(specifier: str) -> set[str] | None: candidates = compatible_patches if candidates is None: candidates = override_patches or patches + # A repair updates one exact, unqualified override. Its current + # value must not constrain the candidate set: that is the pin being + # repaired. Parent-compatible published versions remain eligible, + # while unknown evidence is withheld below. + if ( + isinstance(override, str) + and stable_version(override) is not None + and not unknown + and not qualified + ): + repair_versions = parent_compatible_patches + if repair_versions is None: + repair_versions = patches + if repair_versions: + detail["_repairFrom"] = override + detail["_verifiedRepairVersions"] = sorted(repair_versions) version = min(candidates, key=lambda v: tuple(map(int, v.split(".")))) if not unknown and not override_status: detail["fixVersion"] = version @@ -2403,8 +2431,6 @@ def package_advisory_links(before, after, name, source, resolved, pending): def audit_fix_reason(detail, source): status = detail.get("fixStatus", "") - if "Blocked by retained override" in status: - return "review", "A retained override blocks the published patch." if "no single patch satisfies all" in status: parents = sorted(set(re.findall(r"`([^`]+)` requires `[^`]+`", status))) if len(parents) == 1: @@ -2413,6 +2439,8 @@ def audit_fix_reason(detail, source): f"Blocked by the `{parents[0].rsplit('@', 1)[0]}` version constraint.", ) return "blocked", "No published patch fits all checked parent constraints." + if "Blocked by retained override" in status: + return "review", "A retained override blocks the published patch." if status.startswith("No published stable patch"): return "blocked", "No published patch matches the fix range." if status.startswith("No patched version declared"): @@ -2478,6 +2506,8 @@ def format_audit_summary( reported_parents=None, locks=None, summary=None, + repair_options=None, + repair_revision=None, ) -> str: if isinstance(after, str): return "" if fixes_only else f"โšช **Security:** {after}" @@ -2518,6 +2548,13 @@ def complete(data, rows): ) fixed_packages, review_packages, blocked_packages = set(), set(), set() new_packages, unconfirmed_packages = set(), set() + repair_options = REPAIR_CHOICES["validate"](repair_options or []) + repair_names = {item["name"] for item in repair_options} + selection_block = ( + REPAIR_CHOICES["render"](repair_options, **repair_revision) + if repair_revision is not None + else "" + ) for name in old_groups.keys() | groups.keys(): previous, pending = old_groups.get(name, {}), groups.get(name, {}) fixed = len(resolved_ids.intersection(previous)) @@ -2534,7 +2571,7 @@ def complete(data, rows): outcomes = [audit_fix_reason(detail, source) for detail in details] statuses = [detail.get("fixStatus", "") for detail in details] pinned = any("Blocked by retained override" in status for status in statuses) - review = any(kind == "review" for kind, _ in outcomes) + review = pinned or any(kind == "review" for kind, _ in outcomes) blocked = any(kind == "blocked" for kind, _ in outcomes) or any( "no single patch satisfies all" in status for status in statuses ) @@ -2610,6 +2647,8 @@ def complete(data, rows): + ", ".join(f"`{other}`" for other in locations) + "." ) + if name in repair_names: + action = "Likely feasible. Repair validation has not run. " + action new_count = len(new_ids.intersection(pending)) if new_count: new_packages.add(name) @@ -2646,12 +2685,12 @@ def complete(data, rows): counts.insert(0, "fixes unconfirmed") if comparable and not pending_count and not unconfirmed_disappeared: badge, rank, action = "๐ŸŸข", 0, "Resolved." - elif review: - badge, rank = "๐ŸŸ ", 1 elif new_count: badge, rank = "๐Ÿ”ด", 2 elif blocked: badge, rank = "", 2 + elif review: + badge, rank = "๐ŸŸ ", 1 else: badge, rank = "โšช", 3 severity_details = details + unconfirmed_details + list(previous.values()) @@ -2678,16 +2717,33 @@ def complete(data, rows): ) label = { "๐ŸŸข": "โœ… Fix proposed. ", - "๐ŸŸ ": "๐ŸŸ  Needs review. ", + "๐ŸŸ ": "๐ŸŸก Needs review. ", "๐Ÿ”ด": "๐Ÿ”ด New findings. ", "โšช": "โšช Needs verification. ", - "": "Blocked. ", + "": "๐Ÿ”ด Blocked. ", }[badge] + if blocked and review: + label += "๐ŸŸก Needs review. " rows.append( ( (rank, severity, name), - f"| `{name}` | {version_change} | {severity_label} | {' ยท '.join(counts)} | {label}{action.strip()} | " - f"{package_advisory_links(before, after, name, source, resolved_ids.intersection(previous) if fixes_only else set(previous), set() if fixes_only else set(pending))} |", + [ + f"`{name}`", + version_change, + severity_label, + " ยท ".join(counts), + label + action.strip(), + package_advisory_links( + before, + after, + name, + source, + resolved_ids.intersection(previous) + if fixes_only + else set(previous), + set() if fixes_only else set(pending), + ), + ], ) ) @@ -2708,9 +2764,9 @@ def packages(number): headline += f" ยท ๐Ÿ”ด **New findings in {packages(len(new_packages))}**" if review_packages: verb = "needs" if len(review_packages) == 1 else "need" - headline += f" ยท ๐ŸŸ  **{packages(len(review_packages))} {verb} your review**" + headline += f" ยท ๐ŸŸก **{packages(len(review_packages))} {verb} your review**" if blocked_packages: - headline += f" ยท **Fixes blocked for {packages(len(blocked_packages))}**" + headline += f" ยท ๐Ÿ”ด **Fixes blocked for {packages(len(blocked_packages))}**" if unconfirmed_packages: verb = "needs" if len(unconfirmed_packages) == 1 else "need" headline += ( @@ -2724,13 +2780,82 @@ def packages(number): ) if rows: unit = "Advisories" if source == "pnpm" else "Affected package entries" + if repair_options: + + def cell(value): + escaped = html.escape(html.unescape(value)) + escaped = re.sub(r"`([^`]+)`", r"\1", escaped) + return re.sub( + r"\[([^\]]+)\]\((https?://[^ )]+)\)", + r'\1', + escaped, + ) + + options = {item["name"]: item for item in repair_options} + table = [ + "\n" + + "".join( + f"" + for value in ( + "Package", + "Update", + "Severity", + unit, + "Action / blocker", + "Security evidence", + ) + ) + + "\n" + ] + for (_, _, name), columns in sorted(rows): + table.append( + "" + + "".join(f"" for value in columns) + + "" + ) + if name not in options: + continue + item = options[name] + evidence = [] + for detail in groups.get(name, {}).values(): + evidence.extend( + html.escape(value) + for value in detail.get("_checkedParentConstraints", []) + ) + url = detail.get("url", "") + if isinstance(url, str) and re.fullmatch( + r"https?://[^\s<>()\[\]|`]+", url + ): + evidence.append( + f'{html.escape(detail.get("title", "Advisory"))}' + ) + table.append( + '" + ) + return ( + headline + + "\n\n" + + "\n".join(table) + + "\n\n
{value}
{cell(value)}
Pin repair assessment for ' + + html.escape(name) + + "

A compatible published patch was found: " + + html.escape(item["to"]) + + ". Repair validation has not run. " + "An attempt must pass a frozen install, the configured project checks and a fresh audit before publication.

" + "

The existing pin remains unchanged. Historical rationale has not been collected automatically.

    " + + "".join(f"
  • {value}
  • " for value in dict.fromkeys(evidence)) + + "
\n" + + selection_block + ) headline += ( f"\n\n| Package | Update | Severity | {unit} | Action / blocker | Security evidence |" + "\n| --- | --- | --- | --- | --- | --- |" + "\n" ) - headline += "\n".join(row for _, row in sorted(rows)) - return headline + headline += "\n".join( + "| " + " | ".join(columns) + " |" for _, columns in sorted(rows) + ) + return headline + selection_block def format_platform_security(platform, passed): @@ -2797,7 +2922,7 @@ def format_security_overview( ) if review_count: parts.append( - f"๐ŸŸ  {review_count} package" + f"๐ŸŸก {review_count} package" + (" needs" if review_count == 1 else "s need") + " your review" ) @@ -3705,6 +3830,7 @@ def published_patch_versions(specifier, *, all_versions=False): else None ) package_metrics = {} + repair_options = REPAIR_CHOICES["candidates"](after) package_report = format_audit_summary( before, after, @@ -3713,6 +3839,7 @@ def published_patch_versions(specifier, *, all_versions=False): reported_parents=reported_parents, locks=(before_lock, after_lock), summary=package_metrics, + repair_options=repair_options, ) background_blockers = bool(routine_only and blocked_findings) if background_blockers: diff --git a/node/tests/test_maintenance_options.py b/node/tests/test_maintenance_options.py index d9054b2..82fcbc3 100644 --- a/node/tests/test_maintenance_options.py +++ b/node/tests/test_maintenance_options.py @@ -2222,7 +2222,7 @@ def audit(*args, **kwargs): assert "worker" in visible assert "Validation script:" not in visible assert "Validation script:" in details - assert "๐Ÿ”ด" not in report + assert "๐Ÿ”ด **New findings" not in report def test_new_security_findings_stay_visible_above_collapsed_details(site, monkeypatch): diff --git a/node/tests/test_node_standard.py b/node/tests/test_node_standard.py index 4b2da47..8b12b7e 100644 --- a/node/tests/test_node_standard.py +++ b/node/tests/test_node_standard.py @@ -901,7 +901,7 @@ def test_compact_security_summary_keeps_severity_and_count_meaning( result = summarize(before, after, package_manager) assert "Fixes unconfirmed" in result assert "Resolution unconfirmed" in result - assert "๐ŸŸ " not in result and "| Package |" not in result + assert "๐ŸŸก" not in result and "| Package |" not in result # Full findings retain the registry's units and severities. detailed = audit_module["format_remaining_audit"]( { @@ -992,7 +992,7 @@ def run(command, **kwargs): "**0 platform security updates ยท 1 routine dependency update ยท 0 package security fixes ยท Validation passed**" ) assert "1 affected package entry is blocked, unchanged by this PR." in visible - assert "๐Ÿ”ด" not in report + assert "๐Ÿ”ด **New findings" not in report else: assert "routine dependency" not in visible assert "0 security fixes ยท 1 affected package entry remains" in visible @@ -1083,7 +1083,7 @@ def test_security_outcomes_report_partial_resolution_for_one_package(security_fi assert "| `leaf` | Not recorded | High | 1 fixed ยท 1 pending |" in result assert result.count("`leaf`") == 1 assert all(f"`aaa-{i}`" in result for i in range(7)) - assert "๐ŸŸ " not in result + assert "๐ŸŸก" not in result def test_security_outcomes_keep_missing_initial_evidence_unconfirmed(security_findings): @@ -1110,12 +1110,13 @@ def test_security_outcomes_summarize_concrete_blockers(security_findings): ) result = audit_module["format_audit_summary"](data, data, "pnpm") assert ( - "| `tinypool` | Not recorded | Critical | 2 pending | Blocked. Blocked by the `@docusaurus/core` version constraint. |" + "| `tinypool` | Not recorded | Critical | 2 pending | ๐Ÿ”ด Blocked. Blocked by the `@docusaurus/core` version constraint. |" in result ) assert "No published patch matches the fix range." in result assert result.count("`tinypool`") == 1 - assert "๐Ÿ”ด" not in result + assert "๐Ÿ”ด Blocked." in result + assert "๐Ÿ”ด **New findings" not in result background = audit_module["format_blocked_findings"](data, "pnpm") assert "| Package | Blocked advisories | Reason |" in background assert ( @@ -2050,7 +2051,7 @@ def test_attention_lists_all_blocking_pins_before_other_findings(security_findin ) assert result.index("`pinned-6`") < result.index("`aaa-parent`") assert result.count("| `pinned-") == 7 - assert "| `pinned-0` | Not recorded | High | 2 pending | ๐ŸŸ  Needs review." in result + assert "| `pinned-0` | Not recorded | High | 2 pending | ๐ŸŸก Needs review." in result assert ( "Pin `1.0.0` blocks fix `1.2.0`; review removing or updating the pin." in result ) @@ -2076,7 +2077,7 @@ def test_attention_escapes_disjunctive_pin_and_preserves_scoped_name(security_fi data = security_findings([("@scope/leaf", "one", "high", status)]) result = audit_module["format_audit_summary"](data, data, "pnpm") assert ( - "| `@scope/leaf` | Not recorded | High | 1 pending | ๐ŸŸ  Needs review. Pin `1.0.0 || 2.0.0` blocks fix `2.0.1`" + "| `@scope/leaf` | Not recorded | High | 1 pending | ๐ŸŸก Needs review. Pin `1.0.0 || 2.0.0` blocks fix `2.0.1`" in result ) @@ -2084,7 +2085,7 @@ def test_attention_escapes_disjunctive_pin_and_preserves_scoped_name(security_fi def test_attention_is_absent_when_no_blocking_pin_was_verified(security_findings): data = security_findings([("leaf", "one", "high", "Could not verify publication.")]) result = audit_module["format_audit_summary"](data, data, "pnpm") - assert "๐ŸŸ " not in result and "your review" not in result + assert "๐ŸŸก" not in result and "your review" not in result assert ( "| `leaf` | Not recorded | High | 1 pending | โšช Needs verification." in result ) @@ -2109,7 +2110,7 @@ def test_single_table_keeps_other_advisories_for_a_pinned_package_visible( result = audit_module["format_audit_summary"](data, data, "pnpm") assert ( result.count( - "| `leaf` | Not recorded | Critical | 2 pending | ๐ŸŸ  Needs review." + "| `leaf` | Not recorded | Critical | 2 pending | ๐Ÿ”ด Blocked. ๐ŸŸก Needs review." ) == 1 ) @@ -2301,7 +2302,7 @@ def test_new_parent_is_background_only_with_unchanged_advisory_and_paths( before, after, "npm", fixed_ids=set(), reported_parents=parents ) if change in ("none", "middle-version"): - assert "๐Ÿ”ด" not in report + assert "๐Ÿ”ด **New findings" not in report assert "Newly reported parent of an existing finding in `leaf`" in report assert "affected versions and installed paths are unchanged" in report else: @@ -2616,7 +2617,7 @@ def test_security_headline_separates_fixes_pin_decisions_and_all_blockers( ) assert ( result.splitlines()[0] - == "๐ŸŸข **Fixes in 1 package** ยท ๐ŸŸ  **1 package needs your review** ยท **Fixes blocked for 7 packages**" + == "๐ŸŸข **Fixes in 1 package** ยท ๐ŸŸก **1 package needs your review** ยท ๐Ÿ”ด **Fixes blocked for 7 packages**" ) assert result.count("| `blocked-") == 7 @@ -2630,7 +2631,7 @@ def test_security_headline_does_not_call_unknown_fixes_blocked(security_findings result.splitlines()[0] == "โšช **No security fixes** ยท โšช **1 package needs verification**" ) - assert "Fixes blocked" not in result and "๐ŸŸ " not in result + assert "Fixes blocked" not in result and "๐ŸŸก" not in result def test_security_headline_does_not_infer_fixes_from_totals(): @@ -2638,7 +2639,7 @@ def test_security_headline_does_not_infer_fixes_from_totals(): after = {"metadata": {"vulnerabilities": {"critical": 1}}} result = audit_module["format_audit_summary"](before, after, "pnpm") assert result.splitlines()[0] == "โšช **Fixes unconfirmed**" - assert "Fixes in 1" not in result and "๐ŸŸ " not in result + assert "Fixes in 1" not in result and "๐ŸŸก" not in result @pytest.mark.parametrize("initial_unavailable", [False, True]) @@ -2731,13 +2732,13 @@ def update(command, **kwargs): ) -def test_partial_package_is_orange_only_when_pending_work_needs_user_decision( +def test_partial_package_is_yellow_only_when_pending_work_needs_user_decision( security_findings, ): for status, badge in [ ( "Published patch: `leaf@1.2.0`. Blocked by retained override `pnpm-workspace.yaml overrides.leaf` = `1.0.0`.", - "๐ŸŸ ", + "๐ŸŸก", ), ("No patched version declared by the registry.", ""), ("Publication could not be fully verified.", "โšช"), @@ -2750,8 +2751,8 @@ def test_partial_package_is_orange_only_when_pending_work_needs_user_decision( ) assert "| `leaf` | Not recorded | High | 1 fixed ยท 1 pending |" in result assert result.count("| `leaf`") == 1 - assert ("๐ŸŸ  Needs review." in result) == (badge == "๐ŸŸ ") - assert ("your review" in result) == (badge == "๐ŸŸ ") + assert ("๐ŸŸก Needs review." in result) == (badge == "๐ŸŸก") + assert ("your review" in result) == (badge == "๐ŸŸก") def test_package_advisory_counts_deduplicate_repeated_identity(security_findings): @@ -2768,7 +2769,7 @@ def test_missing_initial_evidence_cannot_color_a_package_resolved(security_findi ) result = audit_module["format_audit_summary"]("Audit unavailable", current, "pnpm") assert "| `leaf` | Not recorded | High | fixes unconfirmed ยท 1 pending |" in result - assert "๐ŸŸข" not in result and "๐ŸŸ " not in result + assert "๐ŸŸข" not in result and "๐ŸŸก" not in result def test_npm_major_upgrade_requires_user_attention(): @@ -2780,7 +2781,7 @@ def test_npm_major_upgrade_requires_user_attention(): } result = audit_module["format_audit_summary"](data, data, "npm") assert ( - "| `leaf` | Not recorded | High | 1 pending | ๐ŸŸ  Needs review. The offered fix requires a major upgrade." + "| `leaf` | Not recorded | High | 1 pending | ๐ŸŸก Needs review. The offered fix requires a major upgrade." in result ) assert "your review" in result diff --git a/node/tests/test_security_reporting.py b/node/tests/test_security_reporting.py index da652ff..e7050d1 100644 --- a/node/tests/test_security_reporting.py +++ b/node/tests/test_security_reporting.py @@ -98,7 +98,7 @@ def test_package_severity_is_independent_from_review_status_and_versions(): in text ) assert ( - "| `package-b` | `2.0.0` (unchanged) | High | 1 pending | ๐ŸŸ  Needs review." + "| `package-b` | `2.0.0` (unchanged) | High | 1 pending | ๐ŸŸก Needs review." in text ) assert summary == {"fixed_packages": 1, "review_count": 1} diff --git a/node/tests/test_selected_repairs.py b/node/tests/test_selected_repairs.py new file mode 100644 index 0000000..62bf52f --- /dev/null +++ b/node/tests/test_selected_repairs.py @@ -0,0 +1,303 @@ +"""Selection/report contract tests; no repair execution or publication.""" + +import copy +import json +import runpy +from pathlib import Path +from types import SimpleNamespace + +import pytest +import yaml + +ROOT = Path(__file__).parents[2] +CHOICES = runpy.run_path(str(ROOT / "node/runtime/repair_choices.py")) +ENGINE = runpy.run_path(str(ROOT / "node/runtime/templates/dependency_audit.py")) +FIXTURES = json.loads((ROOT / "repair-app/selection-fixtures.json").read_text()) +ITEM = {"name": "launch-editor", "from": "2.14.1", "to": "2.14.2"} +REVISION = {"head": "a" * 40, "base": "b" * 40} + + +@pytest.mark.parametrize("case", FIXTURES["cases"], ids=lambda case: case["name"]) +def test_shared_app_fixtures(case): + if case.get("invalid"): + with pytest.raises(ValueError): + CHOICES["parse"](case["body"]) + else: + result = CHOICES["parse"](case["body"]) + assert result == case["items"] + assert ( + CHOICES["fingerprint"](FIXTURES["pr"]["number"], **REVISION, items=result) + == case["fingerprint"] + ) + + +def test_render_is_exact_consent_and_needs_real_revision_context(): + body = CHOICES["render"]([ITEM], **REVISION) + assert CHOICES["parse"](body) == [] + assert CHOICES["parse"](body.replace("[ ]", "[x]")) == [ITEM] + assert body.count("