diff --git a/plugins/codex-security/references/final-report.md b/plugins/codex-security/references/final-report.md index 7cc973d3d..3f8298bb4 100644 --- a/plugins/codex-security/references/final-report.md +++ b/plugins/codex-security/references/final-report.md @@ -14,7 +14,11 @@ The final readable output is a deterministic projection of `scan-manifest.json`, When writing `findings.json` alongside this readable output, populate the optional structured details in `finding-detail-fields.md` from the same validated evidence. Do not parse the rendered report back into finding data. -Use `report.md` as the primary readable entry point. Explain report-relevant artifact paths in the report itself, especially in `Scope`, `Reviewed Surfaces`, and `Open Questions And Follow Up`. +Use `report.md` as the primary readable entry point. Explain report-relevant artifact paths in the report itself, especially in `Scope`, `Reviewed Surfaces`, `Deferred Requested Work`, and `Open Questions And Follow Up`. + +Keep unfinished requested work and essential in-scope proof gaps in `coverage.deferred`. +The report presents these separately from optional `coverage.openQuestions` follow-up. +Neither section changes the scan execution outcome. In the final response, link the generated markdown report path as the primary readable artifact. diff --git a/plugins/codex-security/scripts/report_projection.py b/plugins/codex-security/scripts/report_projection.py index 364628997..3efa11da5 100644 --- a/plugins/codex-security/scripts/report_projection.py +++ b/plugins/codex-security/scripts/report_projection.py @@ -987,10 +987,10 @@ def build_report_markdown( + " |" ) open_questions = coverage.get("openQuestions", []) - questions = list(open_questions) if isinstance(open_questions, list) else [] + deferred_questions = [] deferred = coverage.get("deferred", []) if isinstance(deferred, list): - questions.extend( + deferred_questions = [ { "question": item.get("reason", "Deferred review requires follow-up."), "followUpPrompt": " ".join( @@ -1007,9 +1007,14 @@ def build_report_markdown( } for item in deferred if isinstance(item, dict) - ) - if questions: - lines.extend(["", "## Open Questions And Follow Up", ""]) + ] + for heading, questions in ( + ("Deferred Requested Work", deferred_questions), + ("Open Questions And Follow Up", open_questions), + ): + if not isinstance(questions, list) or not questions: + continue + lines.extend(["", f"## {heading}", ""]) for question in questions: if not isinstance(question, dict): continue diff --git a/plugins/codex-security/scripts/workbench_db.py b/plugins/codex-security/scripts/workbench_db.py index 52b9bdfc3..ab522465f 100644 --- a/plugins/codex-security/scripts/workbench_db.py +++ b/plugins/codex-security/scripts/workbench_db.py @@ -1962,6 +1962,12 @@ def parse_scan_recipe(value: str, repository: Path) -> dict[str, Any]: return recipe +def coverage_summary_for_history(scan: sqlite3.Row) -> dict[str, Any]: + return scan_history.coverage_summary_for_history( + _WORKBENCH_DB_CONTEXT, scan, require_canonical_scan_directory + ) + + def coverage_for_comparison(scan: sqlite3.Row) -> dict[str, Any]: return saved_results.coverage_for_comparison(_WORKBENCH_DB_CONTEXT, scan) @@ -2729,6 +2735,17 @@ def scan_context( return context +def get_scan(connection: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]: + result = scan_context(connection, args.scan_id, args.occurrence_id) + if result["scan"]["progress"]["status"] != "running": + scan = require_scan(connection, args.scan_id) + try: + result["scan"] = dict(result["scan"], coverage=coverage_summary_for_history(scan)) + except (OSError, RuntimeError, SystemExit): + pass # Historical artifacts may no longer be available or verifiable. + return result + + def list_findings(connection: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]: scan = require_scan(connection, args.scan_id) backfill_legacy_finding_details(connection, scan) @@ -3476,7 +3493,7 @@ def main(*, before_archive: Callable[[], None] | None = None) -> None: elif args.command == "start-headless-standard-scan": result = _start_prompt_driven_scan(connection, args, headless_standard=True) elif args.command == "get-scan": - result = scan_context(connection, args.scan_id, args.occurrence_id) + result = get_scan(connection, args) elif args.command == "rename-scan": result = scan_history.rename_scan( connection, require_scan(connection, args.scan_id), args.name diff --git a/plugins/codex-security/scripts/workbench_scan_history.py b/plugins/codex-security/scripts/workbench_scan_history.py index 33cccd12c..d27c8079d 100644 --- a/plugins/codex-security/scripts/workbench_scan_history.py +++ b/plugins/codex-security/scripts/workbench_scan_history.py @@ -2,6 +2,7 @@ import argparse import fnmatch +import hashlib import json import os import sqlite3 @@ -14,7 +15,12 @@ # Some plugin hosts launch Python with safe-path isolation enabled. sys.path.insert(0, str(Path(__file__).resolve().parent)) -from finalize_scan_contract import ContractError, _prepare_scan_finalization +from finalize_scan_contract import ( + ContractError, + _prepare_scan_finalization, + _read_scan_local_json_bytes, + _read_sealed_scan, +) from report_projection import SEVERITY_ORDER from workbench_constants import ARTIFACTS, FINDINGS_PAGE_MAX from workbench_scan_start import scan_target_identity @@ -138,6 +144,65 @@ def cli_scan_resume( return result +def coverage_summary_for_history( + wb: Any, scan: sqlite3.Row, canonical_scan_directory: Callable[[Path], Path] +) -> dict[str, Any]: + scan_dir = canonical_scan_directory(Path(scan["scan_dir"])) + coverage_ref = ARTIFACTS["coverage"] + try: + if scan["seal_manifest_digest"] is None: + # Older workbench rows can have valid seals without a pinned digest. + manifest, _, coverage, _ = _read_sealed_scan(scan_dir, None, "history coverage") + wb.verify_manifest_binding(scan, manifest) + if coverage["mode"] != wb.expected_coverage_mode(scan): + raise ContractError("coverage.mode: must match the workbench scan") + else: + # Completion already validated the full scan. History only needs the + # pinned manifest and its coverage artifact, not findings or receipts. + manifest, manifest_bytes = _read_scan_local_json_bytes( + scan_dir, ARTIFACTS["manifest"], ARTIFACTS["manifest"] + ) + if ( + f"sha256:{hashlib.sha256(manifest_bytes).hexdigest()}" + != scan["seal_manifest_digest"] + ): + raise ContractError("The sealed scan manifest changed after completion.") + sealed_scan = manifest["scan"] + if ( + sealed_scan["id"] != scan["id"] + or not sealed_scan.get("sealedAt") + or sealed_scan["coverageRef"] != coverage_ref + ): + raise ContractError("The sealed coverage does not belong to this scan.") + coverage, coverage_bytes = _read_scan_local_json_bytes( + scan_dir, coverage_ref, coverage_ref + ) + expected = next( + ( + artifact["sha256"] + for artifact in sealed_scan["artifacts"] + if artifact["path"] == coverage_ref + ), + None, + ) + if ( + hashlib.sha256(coverage_bytes).hexdigest() != expected + or coverage.get("scanId") != scan["id"] + ): + raise ContractError("The sealed coverage changed after completion.") + except ContractError as exc: + raise SystemExit(str(exc)) from exc + return { + key: coverage[key] + for key in ( + "mode", + "includePaths", + "excludePaths", + "explicitExclusions", + ) + } + + def _windows_path_key(value: str) -> str: return os.path.normcase(os.path.realpath(value)) diff --git a/plugins/codex-security/tests/test_report_projection.py b/plugins/codex-security/tests/test_report_projection.py index 7d493e875..fa60043c3 100644 --- a/plugins/codex-security/tests/test_report_projection.py +++ b/plugins/codex-security/tests/test_report_projection.py @@ -842,7 +842,8 @@ def test_projection_preserves_follow_up_without_classifying_its_reason(reason: s markdown = PROJECTION.build_report_markdown(manifest, findings, coverage) assert "No findings were reported." in markdown - assert "## Open Questions And Follow Up" in markdown + assert "## Deferred Requested Work" in markdown + assert "## Open Questions And Follow Up" not in markdown assert reason in markdown assert "Review deferred unit candidate-parser" in markdown assert "Paths: src/parser.py." in markdown @@ -857,7 +858,7 @@ def test_finding_summary_link_uses_stable_markdown_anchor() -> None: assert '' in markdown -def test_projection_keeps_deferred_follow_up_with_open_questions() -> None: +def test_projection_separates_deferred_work_from_open_questions() -> None: manifest, findings, coverage = canonical_documents() coverage["openQuestions"] = [{"question": "Is production authentication enabled?"}] @@ -877,6 +878,12 @@ def test_projection_keeps_deferred_follow_up_with_open_questions() -> None: assert "Review deferred unit parser-review" in markdown assert "Paths: src/parser.py." in markdown assert "Surfaces: parser-surface." in markdown + deferred_section, optional_section = markdown.split("## Open Questions And Follow Up") + assert "## Deferred Requested Work" in deferred_section + assert "Parser review incomplete." in deferred_section + assert "Is production authentication enabled?" not in deferred_section + assert "Is production authentication enabled?" in optional_section + assert "Parser review incomplete." not in optional_section def test_projection_includes_surface_evidence_receipts() -> None: diff --git a/plugins/codex-security/tests/test_workbench_scan_history.py b/plugins/codex-security/tests/test_workbench_scan_history.py index ec9b67a9f..f571cf65b 100644 --- a/plugins/codex-security/tests/test_workbench_scan_history.py +++ b/plugins/codex-security/tests/test_workbench_scan_history.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +import hashlib import json import shutil import sqlite3 @@ -275,6 +276,120 @@ def create_cli_scan( return launched +@pytest.mark.parametrize("artifact", ["findings.json", "coverage.json", "scan-manifest.json"]) +@pytest.mark.parametrize("mutation", ["remove", "modify"]) +def test_get_scan_reads_pinned_coverage_independently_of_unrelated_artifacts( + tmp_path: Path, artifact: str, mutation: str +) -> None: + state_dir = tmp_path / "state" + repository = tmp_path / "repository" + repository.mkdir() + scan = create_cli_scan(state_dir, tmp_path / "results", repository, finding=True) + scan_dir = Path(scan["scanDir"]) + before = get_scan(state_dir, scan["scanId"])["scan"] + assert before["coverage"]["includePaths"] == ["."] + artifact_path = scan_dir / artifact + if mutation == "remove": + artifact_path.unlink() + else: + artifact_path.write_bytes(artifact_path.read_bytes() + b"\n") + + after = get_scan(state_dir, scan["scanId"])["scan"] + assert after["progress"]["status"] == "complete" + assert after["findings"] == before["findings"] + if artifact == "findings.json": + assert after["coverage"] == before["coverage"] + else: + assert "coverage" not in after + + +@pytest.mark.parametrize("finding", [False, True]) +@pytest.mark.parametrize("follow_up", [False, True]) +def test_get_scan_reads_legacy_sealed_history_coverage_without_writes( + tmp_path: Path, finding: bool, follow_up: bool +) -> None: + state_dir = tmp_path / "state" + repository = tmp_path / "repository" + repository.mkdir() + scan = create_cli_scan( + state_dir, tmp_path / "results", repository, finding=finding, follow_up=follow_up + ) + scan_dir = Path(scan["scanDir"]) + artifacts = {path: path.read_bytes() for path in scan_dir.rglob("*") if path.is_file()} + with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: + assert connection.execute( + "SELECT COUNT(*) FROM finding_occurrences WHERE scan_id = ? AND details_json = '{}'", + (scan["scanId"],), + ).fetchone() == (0,) + connection.execute( + "UPDATE scans SET seal_manifest_digest = NULL WHERE id = ?", (scan["scanId"],) + ) + + assert get_scan(state_dir, scan["scanId"])["scan"]["coverage"] == { + "mode": "repository", + "includePaths": ["."], + "excludePaths": [], + "explicitExclusions": [], + } + assert {path: path.read_bytes() for path in scan_dir.rglob("*") if path.is_file()} == artifacts + with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: + assert connection.execute( + "SELECT seal_manifest_digest FROM scans WHERE id = ?", (scan["scanId"],) + ).fetchone() == (None,) + + +@pytest.mark.parametrize( + "corruption", + ["coverage", "findings", "missing-coverage", "missing-manifest", "unsealed", "owner", "mode"], +) +def test_get_scan_rejects_unverifiable_legacy_sealed_history_coverage( + tmp_path: Path, corruption: str +) -> None: + state_dir = tmp_path / "state" + repository = tmp_path / "repository" + repository.mkdir() + scan = create_cli_scan(state_dir, tmp_path / "results", repository, finding=False) + scan_dir = Path(scan["scanDir"]) + with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: + connection.execute( + "UPDATE scans SET seal_manifest_digest = NULL WHERE id = ?", (scan["scanId"],) + ) + manifest_path = scan_dir / "scan-manifest.json" + manifest = json.loads(manifest_path.read_text()) + if corruption in {"coverage", "findings"}: + path = scan_dir / f"{corruption}.json" + path.write_bytes(path.read_bytes() + b"\n") + elif corruption.startswith("missing-"): + ( + scan_dir + / ("coverage.json" if corruption == "missing-coverage" else "scan-manifest.json") + ).unlink() + elif corruption == "unsealed": + del manifest["scan"]["sealedAt"] + del manifest["scan"]["artifacts"] + manifest_path.write_text(json.dumps(manifest)) + elif corruption == "owner": + manifest["scan"]["target"]["targetId"] = "unrelated-target" + manifest_path.write_text(json.dumps(manifest)) + else: + coverage_path = scan_dir / "coverage.json" + coverage = json.loads(coverage_path.read_text()) + coverage["mode"] = "deep_repository" + coverage_path.write_text(json.dumps(coverage)) + for artifact in manifest["scan"]["artifacts"]: + if artifact["path"] == "coverage.json": + artifact["sha256"] = hashlib.sha256(coverage_path.read_bytes()).hexdigest() + manifest_path.write_text(json.dumps(manifest)) + artifacts = {path: path.read_bytes() for path in scan_dir.rglob("*") if path.is_file()} + + assert "coverage" not in get_scan(state_dir, scan["scanId"])["scan"] + assert {path: path.read_bytes() for path in scan_dir.rglob("*") if path.is_file()} == artifacts + with sqlite3.connect(state_dir / "workbench.sqlite3") as connection: + assert connection.execute( + "SELECT seal_manifest_digest FROM scans WHERE id = ?", (scan["scanId"],) + ).fetchone() == (None,) + + def test_cli_scan_lifecycle_persists_recipes_lineage_and_filtered_history(tmp_path: Path) -> None: state_dir = tmp_path / "state" repository = tmp_path / "repository" diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 606c65d47..b0782d5d5 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -190,6 +190,10 @@ directory must be private to you (`chmod 700`). | `threatModel` / `threatModelPath` | Saved threat model and document path; either can be `null`. | | `cost` | Estimated model usage cost, or `null` when unavailable. | +Review the requested paths and exclusions alongside the execution outcome. +`coverage.deferred` records unfinished requested work, while +`coverage.openQuestions` records optional follow-up separately. + Review coverage alongside findings. A report with no findings does not establish that the repository is secure. Reports and logs can contain source code, vulnerability details, and credentials; review them before sharing. diff --git a/sdk/typescript/docs/cli.md b/sdk/typescript/docs/cli.md index b92fdff53..e7341b7be 100644 --- a/sdk/typescript/docs/cli.md +++ b/sdk/typescript/docs/cli.md @@ -344,6 +344,11 @@ codex-security scan . --working-tree codex-security scan . --output-dir /path/outside/repository/results --dry-run ``` +`--path` selects files or directories. The scan summary and saved scan details +show requested paths and exclusions. `coverage.deferred` records unfinished +requested work separately from optional `coverage.openQuestions` follow-up. +Human-readable output quotes ambiguous paths, while JSON retains their original strings. + `--diff` scans committed changes; `--working-tree` scans staged and unstaged changes. Deep scans support whole repositories and path scopes. Working-tree snapshots include untracked nested Git repositories. Initialized submodules must diff --git a/sdk/typescript/scripts/package-dist-files.mjs b/sdk/typescript/scripts/package-dist-files.mjs index f4f5c4432..f013bd029 100644 --- a/sdk/typescript/scripts/package-dist-files.mjs +++ b/sdk/typescript/scripts/package-dist-files.mjs @@ -25,6 +25,7 @@ const distModules = [ "contract", "cost", "cost-model", + "coverage-presentation", "custom-validation", "custom-validation-prompt", "custom-publish", diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 31b820dd5..eb6b5d035 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -49,6 +49,7 @@ import { promisify, stripVTControlCharacters } from "node:util"; import { Cli, z } from "incur"; import Ajv2020, { type ValidateFunction } from "ajv/dist/2020.js"; import { formatCliHelp } from "./cli-help.js"; +import { formatCoverageScope } from "./coverage-presentation.js"; import { scanLogsJson } from "./cli-scan-logs-json.js"; import { ARTIFACT_EXPORT_FILENAMES, @@ -9511,6 +9512,7 @@ function printScanSummary( ? "" : ` ${paint("THREAT MODEL", "1;36")} ${paint(errorMessage(threatModelPath), 4)}\n`) + `\n ${paint("FINDINGS", 1)} ${paint(`${findingCount}${findingSummary === "" ? "" : ` (${findingSummary})`}`, findingColor)}\n` + + ` ${paint("SCOPE", 1)} ${formatCoverageScope(result.coverage)}\n` + (deepScanStop === undefined ? "" : ` ${paint("STOPPED", 1)} ${deepScanStop.reason}\n`) + diff --git a/sdk/typescript/src/coverage-presentation.ts b/sdk/typescript/src/coverage-presentation.ts new file mode 100644 index 000000000..a1f6c3dbb --- /dev/null +++ b/sdk/typescript/src/coverage-presentation.ts @@ -0,0 +1,52 @@ +import type { CoverageDocument } from "./models.js"; + +export type CoverageSummary = Pick< + CoverageDocument, + "mode" | "includePaths" | "excludePaths" +> & + Partial>; + +export function formatScopePath(path: string): string { + if (path.length > 0 && !/[\s,;'"\\\u0000-\u001f\u007f-\u009f]/u.test(path)) { + return path; + } + return JSON.stringify(path).replace( + /[\u007f-\u009f]/gu, + (character) => + `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); +} + +function scopePathParts(paths: readonly string[], finalSuffix = ""): string[] { + return paths.map( + (path, index) => + `${formatScopePath(path)}${index < paths.length - 1 ? "," : finalSuffix}`, + ); +} + +export function formatCoverageScopeParts(coverage: CoverageSummary): string[] { + const mode = + coverage.mode === "scoped_path" + ? "scoped paths" + : coverage.mode.replaceAll("_", " "); + const exclusions = [ + ...new Set([ + ...coverage.excludePaths, + ...(coverage.explicitExclusions ?? []).map(({ pattern }) => pattern), + ]), + ]; + const suffix = exclusions.length > 0 ? ";" : ""; + return [ + `${mode}:`, + ...(coverage.includePaths.length > 0 + ? scopePathParts(coverage.includePaths, suffix) + : [`(no included paths)${suffix}`]), + ...(exclusions.length > 0 + ? ["excluding", ...scopePathParts(exclusions)] + : []), + ]; +} + +export function formatCoverageScope(coverage: CoverageSummary): string { + return formatCoverageScopeParts(coverage).join(" "); +} diff --git a/sdk/typescript/src/scan-history-renderer.ts b/sdk/typescript/src/scan-history-renderer.ts index 1e76fdf30..2f731c7f8 100644 --- a/sdk/typescript/src/scan-history-renderer.ts +++ b/sdk/typescript/src/scan-history-renderer.ts @@ -1,6 +1,10 @@ import { basename, relative } from "node:path"; import stringWidth from "string-width"; import type { JsonObject } from "./config.js"; +import { + formatCoverageScopeParts, + type CoverageSummary, +} from "./coverage-presentation.js"; export type HistoryCommand = "list" | "show" | "findings" | "compare" | "match-all"; @@ -81,11 +85,17 @@ export function renderScanHistory( ` ${accent("━".repeat(width - 4))}`, ]; - const wrap = (value: string, indent: number, prefix?: string): void => { + const wrap = ( + value: string | readonly string[], + indent: number, + prefix?: string, + ): void => { const available = width - indent - 2; let line = ""; let lineWidth = 0; - for (const word of clean(value).split(/\s+/)) { + const words = + typeof value === "string" ? clean(value).split(/\s+/) : value.map(clean); + for (const word of words) { if (line.length > 0 && lineWidth + stringWidth(word) + 1 > available) { lines.push(`${prefix || " ".repeat(indent)}${line}`); prefix = undefined; @@ -266,6 +276,14 @@ export function renderScanHistory( } else if (typeof result["updatedAt"] === "string") { lines.push(` ${strong("UPDATED")} ${clean(result["updatedAt"])}`); } + const canonicalCoverage = result["coverage"] as CoverageSummary | undefined; + if (canonicalCoverage) { + wrap( + formatCoverageScopeParts(canonicalCoverage), + 11, + ` ${strong("SCOPE")} `, + ); + } if (result["failureMessage"]) { wrap(String(result["failureMessage"]), 11, ` ${paint("ERROR", 31)} `); } @@ -305,7 +323,9 @@ export function renderScanHistory( const recipe = result["recipe"] as JsonObject | undefined; const target = recipe?.["target"] as JsonObject | undefined; if (target) { - lines.push(` ${strong("SCOPE")} ${clean(JSON.stringify(target))}`); + lines.push( + ` ${strong("SAVED SCOPE")} ${clean(JSON.stringify(target))}`, + ); } const deepScan = recipe?.["deepScan"] as JsonObject | undefined; if (deepScan) { diff --git a/sdk/typescript/tests-ts/cli.test.ts b/sdk/typescript/tests-ts/cli.test.ts index c6bbd8317..53c5ec110 100644 --- a/sdk/typescript/tests-ts/cli.test.ts +++ b/sdk/typescript/tests-ts/cli.test.ts @@ -4192,6 +4192,7 @@ describe("CLI", () => { ` REPORT ${result.reportPath}`, "", " FINDINGS 1 (1 high)", + " SCOPE repository: .", " ELAPSED 6m 37s", " TOKENS unavailable uncached input, 200 cache reads, unavailable cache writes, 30 output, 1,280 total", " RESULTS /tmp/scan", diff --git a/sdk/typescript/tests-ts/coverage-presentation.test.ts b/sdk/typescript/tests-ts/coverage-presentation.test.ts new file mode 100644 index 000000000..1ebe96d3b --- /dev/null +++ b/sdk/typescript/tests-ts/coverage-presentation.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { + formatCoverageScope, + formatScopePath, +} from "../src/coverage-presentation.js"; + +describe("coverage scope presentation", () => { + test("distinguishes included paths from exclusions and preserves Unicode", () => { + expect( + formatCoverageScope({ + mode: "scoped_path", + includePaths: ["src; excluding tests", "src/cafe\u0301.ts"], + excludePaths: ["generated/**"], + explicitExclusions: [ + { pattern: "generated/**", reason: "Generated source" }, + { pattern: "fixtures", reason: "Fixtures" }, + ], + }), + ).toBe( + 'scoped paths: "src; excluding tests", src/cafe\u0301.ts; excluding generated/**, fixtures', + ); + }); + + test("quotes whitespace and escapes terminal controls without changing path data", () => { + for (const path of [ + "src/a b.ts", + "src/trailing ", + 'src/"quoted"', + "src/line\nfeed", + "src/\u001b[31m", + "src/\u009b31m", + ]) { + const encoded = formatScopePath(path); + expect(encoded).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/u); + expect(JSON.parse(encoded)).toBe(path); + } + expect(formatScopePath("src/parser.ts")).toBe("src/parser.ts"); + }); + + test("shows an empty included scope without implying whole-repository coverage", () => { + expect( + formatCoverageScope({ + mode: "diff", + includePaths: [], + excludePaths: [], + }), + ).toBe("diff: (no included paths)"); + }); +}); diff --git a/sdk/typescript/tests-ts/scan-history-renderer.test.ts b/sdk/typescript/tests-ts/scan-history-renderer.test.ts index 0460fa977..1779fa34d 100644 --- a/sdk/typescript/tests-ts/scan-history-renderer.test.ts +++ b/sdk/typescript/tests-ts/scan-history-renderer.test.ts @@ -3,6 +3,37 @@ import { describe, expect, test } from "bun:test"; import { renderScanHistory } from "../src/scan-history-renderer.js"; describe("scan history renderer", () => { + test("shows canonical scope separately from the saved recipe without changing execution status", () => { + const scan = { + targetPath: "/synthetic/repository", + scanId: "synthetic-scan", + mode: "standard", + progress: { status: "complete" }, + findings: [], + recipe: { target: { kind: "paths", paths: ["src"] } }, + }; + const text = renderScanHistory( + { + ...scan, + coverage: { + mode: "scoped_path", + includePaths: ["src/a b.ts", "src/trailing "], + excludePaths: ["generated/**"], + explicitExclusions: [{ pattern: "fixtures", reason: "Fixtures" }], + }, + }, + "show", + { color: false, columns: 120 }, + ); + expect(text).toContain("COMPLETE"); + expect(text).toContain('scoped paths: "src/a b.ts", "src/trailing ";'); + expect(text).toContain("excluding generated/**, fixtures"); + expect(text).toContain("SAVED SCOPE"); + const historical = renderScanHistory(scan, "show", { color: false }); + expect(historical).toContain("COMPLETE"); + expect(historical).not.toContain("scoped paths:"); + }); + test("counts singleton sides when the other side is grouped", () => { const text = renderScanHistory( {