diff --git a/.github/workflows/docusaurus-publish.yml b/.github/workflows/docusaurus-publish.yml index 0472d10..bdf1d7f 100644 --- a/.github/workflows/docusaurus-publish.yml +++ b/.github/workflows/docusaurus-publish.yml @@ -73,20 +73,8 @@ jobs: PR_BRANCH: maintenance/dependency-audit PR_TITLE: 'Docusaurus maintenance: security audit and dependency upgrades' VALIDATION_PASSED: ${{ inputs.validation-passed }} - PR_FOOTER: 'The audit workflow performs the frozen install and production - build; - - review their results above before merging. Failed candidates are drafts - - and fail this workflow. Successful reruns leave existing draft status - - for human review. Normal PR checks remain enabled. Post-merge CI and deployment - - use the normal workflows. Nothing is merged automatically. - - - Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ - github.run_id }} + PR_FOOTER: '[Workflow run](${{ github.server_url }}/${{ github.repository + }}/actions/runs/${{ github.run_id }}) ' uses: $/docusaurus/publish diff --git a/docusaurus/legacy-regressions.json b/docusaurus/legacy-regressions.json index dcd70c3..0e49c14 100644 --- a/docusaurus/legacy-regressions.json +++ b/docusaurus/legacy-regressions.json @@ -2,7 +2,7 @@ "tests/test_dependency_audit.py": { "source_sha256": "54f474d342ff76ce731cf25a20742697248028b8b4821cf5be3621a41fcca4e7", "transferred_path": "docusaurus/tests/test_dependency_audit.py", - "transferred_sha256": "07404b1fe4d46b052a1bb79395652a86c98b8fad3e64e7ff1309813131879322" + "transferred_sha256": "a5a2c76b7c35719059e2f613a2cc6740913b7dfaa3402a7e36a65287a4664755" }, "tests/test_dependency_audit_invariants.py": { "source_sha256": "229cdaa093eac2696febd15e72786886b6c2619ef27e79b4153d4f39f07b3b27", diff --git a/docusaurus/runtime/templates/dependency_audit.py b/docusaurus/runtime/templates/dependency_audit.py index 786952c..58a3d7a 100644 --- a/docusaurus/runtime/templates/dependency_audit.py +++ b/docusaurus/runtime/templates/dependency_audit.py @@ -1099,7 +1099,13 @@ def format_audit(data: dict | str, lock: dict) -> str: if isinstance(via, dict): findings.append( f" - [{via['title']}]({via['url']}); " - f"{via['severity']}; affected range `{via['range']}`." + f"{via['severity']}; " + + ( + f"locked `{name}@{versions(lock, via)}`; " + if source == "pnpm" + else "" + ) + + f"affected range `{via['range']}`." + (f" {via['fixStatus']}" if via.get("fixStatus") else "") ) else: @@ -1161,6 +1167,119 @@ def format_remaining_audit(data: dict | str, lock: dict) -> str: ) +def format_audit_summary(before: dict | str, after: dict | str, source: str) -> str: + if isinstance(after, str): + return f"**Security:** {after}" + counts = after["metadata"]["vulnerabilities"] + total = sum(counts.get(level, 0) for level in SEVERITY_ORDER) + label = "registry advisories" if source == "pnpm" else "affected package entries" + if total == 1: + label = "registry advisory" if source == "pnpm" else "affected package entry" + remaining = ", ".join( + f"{counts[level]} {level}" for level in SEVERITY_ORDER if counts.get(level) + ) + + def entries(data): + if isinstance(data, str): + return [] + return [ + (detail["url"] if source == "pnpm" else name, name, detail) + for name, vulnerability in data.get("vulnerabilities", {}).items() + for detail in ( + [via for via in vulnerability.get("via", []) if isinstance(via, dict)] + if source == "pnpm" + else [vulnerability] + ) + ] + + old, current = entries(before), entries(after) + old_ids, current_ids = {row[0] for row in old}, {row[0] for row in current} + comparable = ( + isinstance(before, dict) + and ( + bool(old) + or not any( + before["metadata"]["vulnerabilities"].get(level, 0) + for level in SEVERITY_ORDER + ) + ) + and (bool(current) or total == 0) + ) + resolved_ids = old_ids - current_ids if comparable else set() + new_ids = current_ids - old_ids if comparable else set() + + def names(rows, selected): + packages = sorted({name for identity, name, _ in rows if identity in selected}) + return ", ".join(f"`{name}`" for name in packages[:5]) + ( + f", and {len(packages) - 5} more packages" if len(packages) > 5 else "" + ) + + resolved = ( + f"{len(resolved_ids)} ({names(old, resolved_ids)})" + if resolved_ids + else "none" + if comparable + else "unconfirmed; initial or detailed audit evidence unavailable" + ) + summary = f"**Resolved:** {resolved}. **Remaining:** {total} {label}" + ( + f" ({remaining})." if remaining else "." + ) + if new_ids: + summary += f" **New:** {len(new_ids)} ({names(current, new_ids)})." + + def reason(detail): + status = detail.get("fixStatus", "") + if "Blocked by retained override" in status: + return "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: + return f"Blocked by the `{parents[0].rsplit('@', 1)[0]}` version constraint." + return "No published patch fits all checked parent constraints." + if status.startswith("No published stable patch"): + return "No published patch matches the fix range." + if status.startswith("No patched version declared"): + return "The registry supplies no patched version." + if "could not be fully verified" in status: + return "Patch compatibility still needs verification." + if status.startswith("Published patch:"): + return "A published patch is available; selection needs review." + candidate = detail.get("fixAvailable") + if isinstance(candidate, dict) and candidate.get("isSemVerMajor"): + return "The offered fix requires a major upgrade." + if source == "npm" and not candidate: + return "No fix offered by npm." + return "Fix availability or resolution needs manual review." + + groups: dict = {} + for identity, name, detail in current: + groups.setdefault(name, {})[identity] = detail + if groups: + summary += "\n\n| Still open | Why |\n| --- | --- |\n" + ordered = sorted( + groups, + key=lambda name: ( + min( + SEVERITY_ORDER.get(detail["severity"], 5) + for detail in groups[name].values() + ), + name, + ), + ) + for name in ordered[:5]: + details = list(groups[name].values()) + severities = ", ".join( + f"{sum(detail['severity'] == level for detail in details)} {level}" + for level in SEVERITY_ORDER + if any(detail["severity"] == level for detail in details) + ) + reasons = " ".join(dict.fromkeys(reason(detail) for detail in details)) + summary += f"| `{name}` ({severities}) | {reasons} |\n" + if len(ordered) > 5: + summary += f"\n{len(ordered) - 5} more affected packages are listed in the audit details." + return summary + + def eligible_version(directory, name, latest, snapshot, notes, deadline): """Select a stable release within the deployment's publication-age policy.""" result = run(directory, "view", name, "time", "--json", deadline=deadline) @@ -1507,60 +1626,67 @@ def prepare(directory: Path, *, validation: dict | None = None) -> str: "parent packages. These are not counts of distinct advisories or " "demonstrated exploit paths." ) - return ( - "# Documentation dependency audit\n\n" - f"Project: `{directory}`; package manager: `{package_manager}`\n\n" - f"Run: {datetime.now(timezone.utc).isoformat()}\n\n" - "This is a best-effort upgrade drive. Remaining vulnerabilities are accepted " - "between maintenance reviews; this report does not certify that the project is " - "free of vulnerabilities. Review compatibility and validation before merging.\n\n" - "## Security findings and fixes\n\n" - "### Changes to affected dependencies\n\n" - + ( - "Initial audit evidence was unavailable; any retained security-fix " - "changes are unconfirmed and cannot be attributed to affected " - "dependencies. The final audit below describes only the final " - "dependency state.\n\n" - if not initial_audit_available - else "" + change_sections = [] + if security_changes: + change_sections.append( + "## Security findings and fixes\n\n" + "\n".join(security_changes) ) - + ( - "\n".join(security_changes) - or ( - "Affected dependency changes cannot be confirmed because initial " - "audit evidence was unavailable." - if not initial_audit_available - else "No upgrades to affected dependencies resolved." - ) + other_changes = [line for name, line in changes if name not in affected] + if other_changes: + change_sections.append( + "## Other stable dependency upgrades\n\n" + "\n".join(other_changes) ) - + "\n\n" - f"{count_explanation} Fix candidates can require manual migrations.\n\n" - "## Other stable dependency upgrades\n\n" - + ( - "\n".join(line for name, line in changes if name not in affected) - or "No other direct dependency upgrades resolved." + if locked_updates: + change_sections.append( + "## Locked dependency changes\n\n" + "\n".join(locked_updates) ) - + "\n\n" - "## Locked dependency changes\n\n" - + ("\n".join(locked_updates) or "No locked dependency version changes.") - + "\n\n" - f"## Remaining advisories\n\n{format_remaining_audit(after, after_lock)}\n\n" + report = ( + f"`{directory}` ยท `{package_manager}`\n\n" + f"- **Changes:** {len(changes)} direct / {len(locked_updates)} locked dependencies.\n" + f"- {format_audit_summary(before, after, package_manager)}\n\n" f"## Production build\n\n{build_status}\n\n" - "## Remaining work\n\n" - "Review retained overrides and peer constraints, transitive dependency fixes, " - "and any major-version migration requirements. Build and installation logs " - "are available in the workflow run.\n\n" + ) + if not initial_audit_available: + report += "Initial audit evidence was unavailable; retained security-fix changes are unconfirmed.\n\n" + if change_sections: + report += ( + "
\nDependency changes\n\n" + + "\n\n".join(change_sections) + + "\n\n
\n\n" + ) + before_audit = format_audit(before, before_lock) + after_audit = format_audit(after, after_lock) + # Keep the complete final findings once, including npm propagated packages. + remaining = ( + ( + "Registry advisory counts: " + if package_manager == "pnpm" + else "Affected package entries: " + ) + + after_audit + if isinstance(after, dict) and after.get("vulnerabilities") + else format_remaining_audit(after, after_lock) + ) + report += ( + f"
\nFull before/after {package_manager} audit\n\n" + f"## Remaining advisories\n\n{remaining}\n\n" + ( - "\n".join(f"- {note}" for note in notes) - or "No additional tool errors recorded." + "Before and after findings are unchanged.\n\n" + if before_audit == after_audit + else f"### Before\n\n{before_audit}\n\n" + ) + + "
\n\n" + "
\nAudit evidence\n\n" + f"Run: {datetime.now(timezone.utc).isoformat()}\n\n" + f"{count_explanation}\n\n" + + ( + "\n".join(f"- {note}" for note in dict.fromkeys(notes)) + "\n\n" + if notes + else "" ) - + "\n" - f"\n
\nFull before/after {package_manager} audit\n\n" - f"### Before\n\n{format_audit(before, before_lock)}\n\n" - f"### After\n\n{format_audit(after, after_lock)}\n\n" - "
\n" - f"\n
\nCandidate dependency files (SHA-256)\n\n{fingerprints}\n\n
\n" + + f"### Candidate dependency files (SHA-256)\n\n{fingerprints}\n\n
\n" ) + return report def main() -> None: diff --git a/docusaurus/tests/test_dependency_audit.py b/docusaurus/tests/test_dependency_audit.py index 7f5d7b4..b82ed8e 100644 --- a/docusaurus/tests/test_dependency_audit.py +++ b/docusaurus/tests/test_dependency_audit.py @@ -96,7 +96,7 @@ def test_upgrade_keeps_manual_constraints_and_reports_unfixed_findings( assert "high: 1, total: 1" in report assert "npm fix candidate not offered by npm" in report assert "## Production build\n\nPassed." in report - assert "does not certify" in report + assert "**Remaining:** 1 affected package entry (1 high)" in report assert "--force" not in [arg for command in commands for arg in command] @@ -110,7 +110,7 @@ def test_failed_resolution_restores_original_files(npm_project, monkeypatch): for name, content in original.items(): assert (npm_project / name).read_bytes() == content assert "from the initial security fix attempt" in report - assert "No other direct dependency upgrades resolved" in report + assert "**Changes:** 0 direct / 0 locked dependencies." in report assert sum(command[:2] == ["audit", "fix"] for command in commands) == 1 @@ -408,7 +408,7 @@ def test_shared_time_budget_stops_commands_and_writes_partial_report( assert commands == [["audit", "--json", "--audit", "--package-lock-only"]] assert "Could not look up renderer" in report assert "Not run: npm frozen install failed (exit 124)" in report - assert "### After\n\nAudit unavailable (exit 124)." in report + assert "**Security:** Audit unavailable (exit 124)." in report assert "time budget was exhausted" in report @@ -486,7 +486,7 @@ def test_cli_entrypoint_prepares_selected_project(npm_project, monkeypatch): ["dependency-audit", "--directory", ".", "--report", str(report)], ) runpy.run_path(audit_module["__file__"], run_name="__main__") - assert "Security findings and fixes" in report.read_text() + assert "**Remaining:**" in report.read_text() assert not (npm_project / "DEPENDENCY-AUDIT.md").exists() manifest = json.loads((npm_project / "package.json").read_text()) assert manifest["dependencies"]["renderer"] == "2.0.0" diff --git a/docusaurus/tests/test_docusaurus_standard.py b/docusaurus/tests/test_docusaurus_standard.py index eae314d..0addaea 100644 --- a/docusaurus/tests/test_docusaurus_standard.py +++ b/docusaurus/tests/test_docusaurus_standard.py @@ -110,6 +110,96 @@ def invoke(command, **kwargs): } +@pytest.mark.parametrize("changed", [False, True], ids=["unchanged", "changed"]) +def test_pnpm_report_keeps_advisory_versions_in_complete_audit( + pnpm_project, monkeypatch, changed +): + before_versions = ("1.0.0", "2.0.0") + after_versions = ("1.1.0", "2.1.0") if changed else before_versions + lockfile = pnpm_project / "pnpm-lock.yaml" + lock = yaml.safe_load(lockfile.read_bytes()) + lock["packages"] = { + f"renderer@{version}": {} for version in (*before_versions, *after_versions) + } + lockfile.write_text(yaml.safe_dump(lock)) + + def findings(versions): + details = [ + ("First advisory", "first", versions[0]), + ("Second advisory", "second", versions[1]), + ] + advisories = { + key: { + "module_name": "renderer", + "severity": "high", + "title": title, + "url": f"https://github.com/advisories/{key}", + "vulnerable_versions": "*", + "patched_versions": "<0.0.0", + "findings": [{"version": version}], + } + for title, key, version in details + } + return { + "metadata": {"vulnerabilities": {"high": 2, "total": 2}}, + "advisories": advisories, + "package_manager": "pnpm", + "vulnerabilities": { + "renderer": { + "severity": "high", + "range": "*", + "nodes": [f"renderer@{version}" for _, _, version in details], + "via": [ + { + "title": title, + "url": f"https://github.com/advisories/{key}", + "severity": "high", + "range": "*", + "nodes": [f"renderer@{version}"], + "patchedRange": "<0.0.0", + } + for title, key, version in details + ], + "fixAvailable": False, + } + }, + } + + audit_results = iter( + [findings(before_versions), findings(after_versions), findings(after_versions)] + ) + + def fake_audit(*args, **kwargs): + return next(audit_results) + + module_globals = prepare.__globals__ + monkeypatch.setitem(module_globals, "audit", fake_audit) + monkeypatch.setitem( + module_globals, + "fix_security", + lambda *args, **kwargs: subprocess.CompletedProcess([], 0, "", ""), + ) + monkeypatch.setitem( + module_globals, "describe_pnpm_fixes", lambda *args, **kwargs: None + ) + + def fake_run(directory, *args, **kwargs): + stdout = '"1.0.0"' if args[:1] == ("view",) else "" + return subprocess.CompletedProcess(args, 0, stdout, "") + + monkeypatch.setitem(module_globals, "run", fake_run) + report = prepare(pnpm_project) + + for title, before_version, after_version in ( + ("First advisory", before_versions[0], after_versions[0]), + ("Second advisory", before_versions[1], after_versions[1]), + ): + lines = [line for line in report.splitlines() if f"[{title}]" in line] + assert lines + assert any(f"locked `renderer@{before_version}`" in line for line in lines) + assert any(f"locked `renderer@{after_version}`" in line for line in lines) + + def fake_npm(monkeypatch, *, install=0, build=0, audit_error=False): def invoke(command, *, cwd, **kwargs): args = command[4:] @@ -445,7 +535,7 @@ def invoke(command, *, cwd, **kwargs): return subprocess.CompletedProcess(command, 0, stdout, "") monkeypatch.setattr(subprocess, "run", invoke) - summary = prepare(project).split("
")[0] + summary = prepare(project).split("Full before/after")[0] if changed: old = ", ".join(sorted({"1.0.0", initial_patched})) @@ -456,7 +546,7 @@ def invoke(command, *, cwd, **kwargs): assert "No upgrades to affected dependencies resolved." not in summary else: assert "- leaf:" not in summary - assert "No upgrades to affected dependencies resolved." in summary + assert "## Security findings and fixes" not in summary @pytest.mark.parametrize("package_manager", ["npm", "pnpm"]) @@ -540,7 +630,7 @@ def invoke(command, *, cwd, **kwargs): return subprocess.CompletedProcess(command, 0, stdout, "") monkeypatch.setattr(subprocess, "run", invoke) - summary = prepare(project).split("
")[0] + summary = prepare(project).split("Full before/after")[0] if changed: assert ( "leaf: dependency paths changed; installed versions `1.0.0, 2.0.0`" @@ -550,7 +640,7 @@ def invoke(command, *, cwd, **kwargs): assert "No upgrades to affected dependencies resolved." not in summary else: assert "- leaf:" not in summary - assert "No upgrades to affected dependencies resolved." in summary + assert "## Security findings and fixes" not in summary @pytest.mark.parametrize("package_manager", ["npm", "pnpm"]) @@ -659,12 +749,11 @@ def invoke(command, *, cwd, **kwargs): return subprocess.CompletedProcess(command, 0, stdout, "") monkeypatch.setattr(subprocess, "run", invoke) - summary = prepare(project).split("
")[0] - security, other = summary.split("## Other stable dependency upgrades") - other = other.split("## Locked dependency changes")[0] + summary = prepare(project).split("Full before/after")[0] + security = summary.split("## Locked dependency changes")[0] final = "3.0.0" if patched else "2.0.0" assert f"- renderer: `1.0.0` โ†’ `{final}`;" in security - assert "- renderer:" not in other + assert "## Other stable dependency upgrades" not in summary assert ( "still reported" in security if not patched @@ -720,7 +809,7 @@ def invoke(command, *, cwd, **kwargs): return subprocess.CompletedProcess(command, 0, stdout, "") monkeypatch.setattr(subprocess, "run", invoke) - summary = prepare(pnpm_project).split("
")[0] + summary = prepare(pnpm_project).split("Full before/after")[0] result = json.loads(manifest_path.read_text()) assert result["dependencies"]["renderer"] == "2.0.0" assert result[section]["second"] == "2.0.0" @@ -763,16 +852,148 @@ def test_summary_precedes_collapsed_full_audit(npm_project, monkeypatch): report = prepare(npm_project) summary, details = report.split("
", 1) - assert "renderer: `1.0.0` โ†’ `2.0.0`" in summary - assert "## Remaining advisories" in summary + assert "**Changes:** 1 direct / 0 locked dependencies." in summary + assert "**Resolved:** none. **Remaining:** 0 affected package entries." in summary assert "## Production build\n\nPassed." in summary - assert summary.index("Security findings") < summary.index("Other stable") + assert len(summary) < 600 + assert "renderer: `1.0.0` โ†’ `2.0.0`" in details + assert "No upgrades to affected dependencies" not in report + assert "No additional tool errors recorded" not in report + assert "This is a best-effort" not in report assert "### Before" not in summary and "### After" not in summary - assert "### Before" in details and "### After" in details + assert "Before and after findings are unchanged." in details assert details.endswith("
\n") assert not (npm_project / "dependency-audit.md").exists() +@pytest.mark.parametrize( + "package_manager, label", + [("pnpm", "registry advisories"), ("npm", "affected package entries")], +) +def test_compact_security_summary_keeps_severity_and_count_meaning( + package_manager, label +): + summarize = audit_module["format_audit_summary"] + before = {"metadata": {"vulnerabilities": {"critical": 3, "high": 2, "total": 5}}} + after = { + "metadata": { + "vulnerabilities": {"critical": 2, "high": 1, "moderate": 1, "total": 4} + } + } + result = summarize(before, after, package_manager) + assert f"**Remaining:** 4 {label} (2 critical, 1 high, 1 moderate)." in result + assert "**Resolved:** unconfirmed" in result + assert "Audit unavailable" in summarize( + before, "Audit unavailable (exit 1).", package_manager + ) + + +def test_unchanged_findings_are_rendered_once(npm_project, monkeypatch): + fake_npm(monkeypatch) + report = prepare(npm_project) + assert report.count("No known vulnerabilities reported by npm.") == 1 + assert report.count("
") == report.count("
") + + +@pytest.fixture +def security_findings(): + def make(rows): + data = {"metadata": {"vulnerabilities": {}}, "vulnerabilities": {}} + counts = data["metadata"]["vulnerabilities"] + for name, identity, severity, status in rows: + counts[severity] = counts.get(severity, 0) + 1 + package = data["vulnerabilities"].setdefault(name, {"via": []}) + package["via"].append( + { + "url": f"https://github.com/advisories/{identity}", + "severity": severity, + "fixStatus": status, + } + ) + return data + + return make + + +def test_security_outcomes_distinguish_resolved_and_new_with_equal_counts( + security_findings, +): + before = security_findings([("old-package", "old", "high", "")]) + after = security_findings([("new-package", "new", "high", "")]) + result = audit_module["format_audit_summary"](before, after, "pnpm") + assert "**Resolved:** 1 (`old-package`)" in result + assert "**New:** 1 (`new-package`)" in result + assert "**Remaining:** 1 registry advisory" in result + + +def test_security_outcomes_keep_shared_advisory_open_after_path_change( + security_findings, +): + before = security_findings([("old-path", "same", "critical", "")]) + after = security_findings([("new-path", "same", "critical", "")]) + result = audit_module["format_audit_summary"](before, after, "pnpm") + assert "**Resolved:** none." in result + assert "**New:**" not in result + + +def test_security_outcomes_report_partial_resolution_for_one_package(security_findings): + first = ("leaf", "first", "high", "") + second = ("leaf", "second", "high", "") + result = audit_module["format_audit_summary"]( + security_findings([first, second]), security_findings([first]), "pnpm" + ) + assert "**Resolved:** 1 (`leaf`)" in result + assert "**Remaining:** 1 registry advisory" in result + assert "| `leaf` (1 high) |" in result + + +def test_security_outcomes_keep_missing_initial_evidence_unconfirmed(security_findings): + result = audit_module["format_audit_summary"]( + "Audit unavailable", security_findings([]), "pnpm" + ) + assert "**Resolved:** unconfirmed" in result + assert "**Resolved:** none" not in result + + +def test_security_outcomes_summarize_concrete_blockers(security_findings): + constraints = "Published patches exist, but no single patch satisfies all checked parent constraints. Blocked by existing parent constraints: `@docusaurus/core@3.10.2` requires `tinypool ^1.0.2`." + before = security_findings( + [ + ("tinypool", "first", "critical", constraints), + ("tinypool", "second", "critical", constraints), + ( + "braces", + "third", + "high", + "No published stable patch matches registry range `>=3.0.4`.", + ), + ] + ) + result = audit_module["format_audit_summary"](before, before, "pnpm") + assert ( + "| `tinypool` (2 critical) | Blocked by the `@docusaurus/core` version constraint. |" + in result + ) + assert "No published patch matches the fix range." in result + assert result.count("| `tinypool`") == 1 + + +def test_security_outcomes_bound_the_visible_package_list(security_findings): + data = security_findings([(f"package-{i}", str(i), "high", "") for i in range(7)]) + result = audit_module["format_audit_summary"](data, data, "pnpm") + assert "**Remaining:** 7 registry advisories" in result + assert "2 more affected packages are listed in the audit details." in result + assert result.count("| `package-") == 5 + + +def test_publisher_footer_is_only_a_run_link(): + workflow = load_workflow() + footer = workflow["jobs"]["pull-request"]["steps"][-1]["env"]["PR_FOOTER"] + assert footer.strip() == ( + "[Workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" + ) + + @pytest.mark.parametrize("install, build", [(1, 0), (0, 1)]) def test_summary_keeps_failed_validation_visible( npm_project, monkeypatch, install, build @@ -827,7 +1048,8 @@ def invoke(command, *, cwd, **kwargs): return subprocess.CompletedProcess(command, 0, "", "") monkeypatch.setattr(subprocess, "run", invoke) - summary = prepare(pnpm_project).split("
")[0] + report = prepare(pnpm_project) + summary = report.split("
")[0] assert "Initial audit evidence was unavailable" in summary assert "retained security-fix changes are unconfirmed" in summary @@ -835,7 +1057,7 @@ def invoke(command, *, cwd, **kwargs): assert "No additional tool errors recorded." not in summary assert ( "## Remaining advisories\n\nNo known vulnerabilities reported by pnpm." - in summary + in report )