diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7137a9b9..78c069bd2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,10 +11,13 @@ permissions: jobs: test: runs-on: ubuntu-latest + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Install Tauri system deps run: | sudo apt-get update @@ -40,14 +43,287 @@ jobs: node-version: 20.19.0 - run: npm ci - run: npm test + - name: Frontend production coverage + id: frontend-coverage + run: npm run coverage + - name: Build bounded frontend coverage diagnostic + if: failure() && steps.frontend-coverage.outcome == 'failure' + run: | + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'; + + const sha = process.env.HEAD_SHA ?? ''; + const repository = process.env.GITHUB_REPOSITORY ?? ''; + if (!/^[0-9a-f]{40}$/.test(sha) || repository.length === 0) { + throw new Error('frontend coverage diagnostic identity is invalid'); + } + + const finalCoverage = JSON.parse( + readFileSync('coverage/coverage-final.json', 'utf8'), + ); + const summaryCoverage = JSON.parse( + readFileSync('coverage/coverage-summary.json', 'utf8'), + ); + const frontendMarker = '/src/'; + const frontendRepositoryPath = (filename) => { + const normalized = String(filename ?? '').replaceAll('\\', '/'); + if (normalized.startsWith('src/')) return normalized; + const markerIndex = normalized.lastIndexOf(frontendMarker); + if (markerIndex < 0) return null; + return `src/${normalized.slice(markerIndex + frontendMarker.length)}`; + }; + const uncoveredCount = (values) => values.filter((value) => value === 0).length; + const frontend_uncovered_line_numbers = (file) => { + const lines = new Set(); + for (const [statementId, count] of Object.entries(file?.s ?? {})) { + if (count !== 0) continue; + const line = file?.statementMap?.[statementId]?.start?.line; + if (Number.isSafeInteger(line) && line > 0) lines.add(line); + } + return [...lines].sort((left, right) => left - right).slice(0, 40); + }; + const frontend_top_uncovered_files = Object.entries(finalCoverage) + .map(([filename, file]) => { + const path = frontendRepositoryPath(filename); + if (!path || !file || typeof file !== 'object') return null; + const statements = Object.values(file.s ?? {}); + const functions = Object.values(file.f ?? {}); + const branches = Object.values(file.b ?? {}).flatMap((value) => + Array.isArray(value) ? value : [], + ); + return { + path, + uncovered_statements: uncoveredCount(statements), + uncovered_branches: uncoveredCount(branches), + uncovered_functions: uncoveredCount(functions), + uncovered_line_numbers: frontend_uncovered_line_numbers(file), + }; + }) + .filter((entry) => entry && ( + entry.uncovered_statements > 0 || + entry.uncovered_branches > 0 || + entry.uncovered_functions > 0 || + entry.uncovered_line_numbers.length > 0 + )) + .sort((left, right) => { + const leftGap = left.uncovered_statements + left.uncovered_branches + + left.uncovered_functions + left.uncovered_line_numbers.length; + const rightGap = right.uncovered_statements + right.uncovered_branches + + right.uncovered_functions + right.uncovered_line_numbers.length; + return rightGap - leftGap || left.path.localeCompare(right.path); + }) + .slice(0, 20); + const diagnostic = { + schema_version: 1, + head_sha: sha, + repository, + totals: summaryCoverage?.total ?? null, + frontend_top_uncovered_files, + }; + writeFileSync( + 'frontend-coverage-diagnostic.json', + `${JSON.stringify(diagnostic, null, 2)}\n`, + ); + console.error(`frontend-coverage-diagnostic=${JSON.stringify(diagnostic)}`); + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (summaryPath) { + appendFileSync( + summaryPath, + `### Frontend coverage diagnostic for \`${sha}\`\n\n\`\`\`json\n${JSON.stringify(diagnostic, null, 2)}\n\`\`\`\n`, + ); + } + NODE + - name: Upload bounded frontend coverage diagnostic + if: failure() && steps.frontend-coverage.outcome == 'failure' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: frontend-coverage-diagnostic-${{ env.HEAD_SHA }} + path: frontend-coverage-diagnostic.json + if-no-files-found: error + - run: npm run check - run: npm run build + coverage-evidence: + runs-on: ubuntu-latest + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Install Tauri system deps + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: nightly-2026-08-07 + components: llvm-tools-preview + - uses: taiki-e/install-action@6c6fd71fe4fb72c3697d269963d0e15df8adedad + with: + tool: cargo-llvm-cov + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: src-tauri + - name: Measure exact-head Rust coverage + run: cargo llvm-cov --manifest-path src-tauri/Cargo.toml --branch --json --output-path coverage.json --no-cfg-coverage --no-cfg-coverage-nightly + - name: Build exact-head coverage evidence + run: | + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'; + + const sha = process.env.HEAD_SHA ?? ''; + const repository = process.env.GITHUB_REPOSITORY ?? ''; + if (!/^[0-9a-f]{40}$/.test(sha) || repository.length === 0) { + throw new Error('coverage evidence identity is invalid'); + } + + const report = JSON.parse(readFileSync('coverage.json', 'utf8')); + const coverageData = report?.data?.[0]; + const totals = coverageData?.totals; + const safeGap = (value) => { + const count = value?.count; + const covered = value?.covered; + return Number.isSafeInteger(count) && + Number.isSafeInteger(covered) && + count >= covered && + covered >= 0 + ? count - covered + : 0; + }; + const repositoryPath = (filename) => { + const normalized = String(filename ?? '').replaceAll('\\', '/'); + const marker = '/src-tauri/'; + const markerIndex = normalized.lastIndexOf(marker); + if (markerIndex < 0) return null; + return `src-tauri/${normalized.slice(markerIndex + marker.length)}`; + }; + const uncoveredLineNumbers = (segments) => { + const lines = new Set(); + for (const segment of Array.isArray(segments) ? segments : []) { + if ( + Array.isArray(segment) && + Number.isSafeInteger(segment[0]) && + segment[0] > 0 && + segment[2] === 0 && + segment[3] === true && + segment[4] === true && + segment[5] !== true + ) { + lines.add(segment[0]); + } + } + return [...lines].sort((left, right) => left - right).slice(0, 40); + }; + const top_uncovered_files = (coverageData?.files ?? []) + .map((file) => { + const path = repositoryPath(file?.filename); + const summary = file?.summary; + if (!path) return null; + return { + path, + uncovered_regions: safeGap(summary?.regions), + uncovered_branches: safeGap(summary?.branches), + uncovered_functions: safeGap(summary?.functions), + uncovered_lines: safeGap(summary?.lines), + uncovered_line_numbers: uncoveredLineNumbers(file?.segments), + }; + }) + .filter((entry) => entry && ( + entry.uncovered_regions > 0 || + entry.uncovered_branches > 0 || + entry.uncovered_functions > 0 || + entry.uncovered_lines > 0 + )) + .sort((left, right) => { + const leftGap = left.uncovered_regions + left.uncovered_branches + + left.uncovered_functions + left.uncovered_lines; + const rightGap = right.uncovered_regions + right.uncovered_branches + + right.uncovered_functions + right.uncovered_lines; + return rightGap - leftGap || left.path.localeCompare(right.path); + }) + .slice(0, 20); + const diagnostic = { + schema_version: 1, + head_sha: sha, + repository, + regions: totals?.regions ?? null, + branches: totals?.branches ?? null, + functions: totals?.functions ?? null, + lines: totals?.lines ?? null, + top_uncovered_files, + }; + writeFileSync( + 'coverage-diagnostic.json', + `${JSON.stringify(diagnostic, null, 2)}\n`, + ); + console.error(`coverage-diagnostic=${JSON.stringify(diagnostic)}`); + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (summaryPath) { + appendFileSync( + summaryPath, + `### Coverage diagnostic for \`${sha}\`\n\n\`\`\`json\n${JSON.stringify(diagnostic, null, 2)}\n\`\`\`\n`, + ); + } + + const metric = (name, value) => { + if ( + !value || + !Number.isFinite(value.count) || + !Number.isFinite(value.covered) || + !Number.isFinite(value.percent) || + value.count <= 0 || + value.covered !== value.count || + value.percent !== 100 + ) { + throw new Error(`${name} coverage is not exactly 100%`); + } + return value.percent; + }; + + const evidence = { + schema_version: 1, + head_sha: sha, + commit_sha: sha, + repository, + trust_tier: 'ci-verified', + ci_server: 'github-actions', + ci_workflow: 'Test', + coverage_command: 'cargo llvm-cov', + statement_coverage: metric('statement/region', totals?.regions), + branch_coverage: metric('branch', totals?.branches), + function_coverage: metric('function', totals?.functions), + line_coverage: metric('line', totals?.lines), + passed: true, + }; + + writeFileSync( + 'coverage-evidence.json', + `${JSON.stringify(evidence, null, 2)}\n`, + ); + NODE + - name: Upload bounded coverage diagnostic + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-diagnostic-${{ env.HEAD_SHA }} + path: coverage-diagnostic.json + if-no-files-found: error + - name: Upload coverage evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-evidence + path: coverage-evidence.json + if-no-files-found: error + llm-engine-build: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Install build deps (llama.cpp native + tauri) run: | sudo apt-get update diff --git a/CHANGELOG.md b/CHANGELOG.md index c6bb4c939..e46a13667 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Changed +- Measure exact-head Rust production statement-equivalent region, branch, function, and line coverage in CI; require every metric to be exactly 100%, emit privacy-bounded failure diagnostics, and publish success evidence only for the immutable pull-request head that actually passed. - Replace generator-era Cargo package metadata with the DiskSage product description, MIT license expression, canonical source repository URL, and `publish = false` registry-publication boundary; deliberately omit Cargo's deprecated `authors` field, verify publication refusal through Cargo's versioned parsed metadata rather than substring matching, and regression-test commented/out-of-table decoys together with the retained acquisition metadata and doctoring evidence. - Require a fresh, exact, human-attributed approval and rationale for cloud copy-only and existing-copy adoption actions, with a 15-minute authorization lifetime bound to the candidate, destination, provider, account scope, and review fingerprint. - Return the candidate-specific cloud copy approval action, exact confirmation phrase, and maximum approval age from the Rust plan contract; the frontend only displays and submits that backend-authored phrase and fails closed when it is missing or does not match the candidate action. diff --git a/docs/development/coverage-evidence.md b/docs/development/coverage-evidence.md new file mode 100644 index 000000000..3f7867384 --- /dev/null +++ b/docs/development/coverage-evidence.md @@ -0,0 +1,44 @@ +# Exact-head coverage evidence + +DiskSage treats code coverage as durable CI evidence rather than a locally asserted percentage. The `Test` workflow measures Rust production coverage on the exact pull-request head and emits a machine-readable `coverage-evidence` artifact only when all required metrics are exactly 100%. + +## Why the workflow checks out the exact head + +GitHub pull-request workflows may otherwise execute against a synthetic merge commit. That is useful for integration testing, but it is not sufficient for a review gate that claims a result about one immutable pull-request head. The coverage job therefore checks out `${{ github.event.pull_request.head.sha || github.sha }}` explicitly and copies the same value into `HEAD_SHA`. + +The evidence builder rejects a missing or malformed SHA. Both `head_sha` and `commit_sha` in `coverage-evidence.json` must equal that exact 40-character commit identifier. The repository identity is taken from `GITHUB_REPOSITORY`, not from user-controlled test output. + +## What is measured + +The workflow uses `cargo llvm-cov` with LLVM source-based instrumentation. Branch coverage is requested explicitly with `--branch`; because cargo-llvm-cov documents branch coverage as unstable, the workflow uses an immutable dated Rust nightly with `llvm-tools-preview` instead of silently falling back to a toolchain that cannot measure the required metric. + +The workflow passes `--no-cfg-coverage` and `--no-cfg-coverage-nightly`, so cargo-llvm-cov does not define its normal `cfg(coverage)` or `cfg(coverage_nightly)` build configurations. Production code guarded by `#[cfg(not(coverage))]` therefore remains in the measured graph rather than disappearing merely because coverage is being collected. This keeps the gate aligned with the production-behavior requirement; it also means unreachable GUI or command boundaries must be made realistically testable rather than hidden from measurement. + +The JSON summary is the only source for the emitted percentages. The evidence builder reads LLVM's aggregate totals and requires all of the following to be present, finite, non-empty, fully covered, and exactly 100%: + +- statement coverage: LLVM region coverage, used as the statement-equivalent source-based metric; +- branch coverage: LLVM branch totals; +- function coverage: LLVM function totals; and +- line coverage: LLVM line totals. + +The workflow never manufactures a percentage from a successful test exit status. Missing totals, zero denominators, partial coverage, malformed JSON, or identity drift stop the job before the success artifact can be uploaded. + +## Evidence and failure diagnostics + +A valid `coverage-evidence.json` has schema version `1` and records the immutable head, repository, CI trust tier, server, workflow name, coverage command, four exact percentages, and `passed: true`. The organization review workflow independently downloads this artifact from the successful `Test` run for the same head and revalidates the contract. + +The success artifact is uploaded with `if-no-files-found: error`. GitHub Actions artifacts persist workflow outputs such as test and coverage results after the producing job completes, which lets the organization-level reviewer consume evidence without granting the coverage job repository-write permission. + +When any metric is below 100%, the job still writes the bounded `coverage-diagnostic.json` containing only the exact head/repository identity and aggregate region, branch, function, and line totals. The same bounded diagnostic is emitted to the job log and `GITHUB_STEP_SUMMARY` before validation throws, then uploaded with `if: always()`. This makes the first failing coverage boundary directly observable without exposing source contents, local paths, secrets, test fixtures, or command output, while the success-only `coverage-evidence.json` remains fail closed. + +## Fail-closed operating rule + +A missing `coverage-evidence` artifact is not equivalent to passing coverage. A queued, cancelled, failed, stale-head, malformed, or less-than-100% measurement is also not passing. Engineers must add realistic tests or remove genuinely unreachable production code; they must not lower thresholds, hard-code percentages, exclude reachable production behavior merely to satisfy the gate, or reuse an artifact from an older head. + +## References + +GitHub. (2026). *Store and share data with workflow artifacts*. GitHub Docs. https://docs.github.com/en/actions/tutorials/store-and-share-data + +GitHub. (2026). *Workflow artifacts*. GitHub Docs. https://docs.github.com/en/actions/concepts/workflows-and-actions/workflow-artifacts + +Taiki Endo. (2026). *cargo-llvm-cov: Cargo subcommand to easily use LLVM source-based code coverage* [Computer software]. GitHub. https://github.com/taiki-e/cargo-llvm-cov diff --git a/src-tauri/tests/duplicate_audit_coverage.rs b/src-tauri/tests/duplicate_audit_coverage.rs new file mode 100644 index 000000000..440b640bc --- /dev/null +++ b/src-tauri/tests/duplicate_audit_coverage.rs @@ -0,0 +1,136 @@ +//! Coverage-focused integration regressions for exact duplicate audit fail-closed boundaries. +//! +//! These tests exercise public production behavior that exact-head coverage diagnostics showed was +//! still unobserved. They deliberately add no mutation authority and keep filesystem fixtures local +//! to temporary directories. + +use disksage_lib::duplicate_audit::{ + collect_exact_duplicate_audit, exact_duplicate_audit_integrity_valid, + summarize_exact_duplicate_audit, MAX_ENTRIES, +}; +use std::path::Path; + +#[test] +fn rejects_invalid_roots_and_limits_before_scanning() { + assert_eq!( + collect_exact_duplicate_audit(Path::new("relative"), 1, 1, 10).unwrap_err(), + "duplicate-audit-root-must-be-absolute" + ); + + let root = tempfile::tempdir().unwrap(); + assert_eq!( + collect_exact_duplicate_audit(root.path(), 1, 0, 10).unwrap_err(), + "duplicate-audit-min-bytes-out-of-range" + ); + assert_eq!( + collect_exact_duplicate_audit(root.path(), 1, 1, 0).unwrap_err(), + "duplicate-audit-max-entries-out-of-range" + ); + assert_eq!( + collect_exact_duplicate_audit(root.path(), 1, 1, MAX_ENTRIES + 1).unwrap_err(), + "duplicate-audit-max-entries-out-of-range" + ); + + let missing = root.path().join("missing"); + assert_eq!( + collect_exact_duplicate_audit(&missing, 1, 1, 10).unwrap_err(), + "duplicate-audit-root-unavailable" + ); + + let file_root = root.path().join("not-a-directory.bin"); + std::fs::write(&file_root, b"content").unwrap(); + assert_eq!( + collect_exact_duplicate_audit(&file_root, 1, 1, 10).unwrap_err(), + "duplicate-audit-root-unsafe" + ); +} + +#[test] +fn below_threshold_and_unique_sizes_do_not_trigger_hash_or_delete_authority() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("tiny.bin"), b"x").unwrap(); + std::fs::write(root.path().join("unique-a.bin"), b"1234").unwrap(); + std::fs::write(root.path().join("unique-b.bin"), b"123456").unwrap(); + + let report = collect_exact_duplicate_audit(root.path(), 42, 2, 100).unwrap(); + assert!(report.evidence_complete); + assert_eq!(report.file_count, 3); + assert_eq!(report.size_collision_candidate_count, 0); + assert_eq!(report.content_hashed_file_count, 0); + assert_eq!(report.cluster_count, 0); + assert_eq!(report.duplicate_file_count, 0); + assert_eq!(report.logical_duplicate_bytes, 0); + assert_eq!(report.logical_redundant_bytes, 0); + assert!(exact_duplicate_audit_integrity_valid(&report)); + + let summary = summarize_exact_duplicate_audit(&report); + assert!(!summary.requires_human_canonical_selection); + assert!(!summary.automatic_delete_allowed); + assert!(!summary.mutation_performed); +} + +#[test] +fn directory_depth_limit_marks_evidence_incomplete_without_descending() { + let root = tempfile::tempdir().unwrap(); + let mut cursor = root.path().to_path_buf(); + for index in 0..=64 { + cursor = cursor.join(format!("depth-{index}")); + std::fs::create_dir(&cursor).unwrap(); + } + std::fs::write(cursor.join("hidden.bin"), b"not-observed").unwrap(); + + let report = collect_exact_duplicate_audit(root.path(), 42, 1, 1_000).unwrap(); + assert!(!report.evidence_complete); + assert_eq!( + report.issue_counts.get("duplicate-audit-depth-limit-reached"), + Some(&1) + ); + assert_eq!(report.file_count, 0); + assert_eq!(report.cluster_count, 0); + assert!(exact_duplicate_audit_integrity_valid(&report)); +} + +#[test] +fn integrity_rejects_public_report_authority_and_count_tampering() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("a.bin"), b"same").unwrap(); + std::fs::write(root.path().join("b.bin"), b"same").unwrap(); + let report = collect_exact_duplicate_audit(root.path(), 42, 1, 100).unwrap(); + assert!(exact_duplicate_audit_integrity_valid(&report)); + + let mut tampered = report.clone(); + tampered.schema_version += 1; + assert!(!exact_duplicate_audit_integrity_valid(&tampered)); + + let mut tampered = report.clone(); + tampered.min_bytes = 0; + assert!(!exact_duplicate_audit_integrity_valid(&tampered)); + + let mut tampered = report.clone(); + tampered.max_entries = 0; + assert!(!exact_duplicate_audit_integrity_valid(&tampered)); + + let mut tampered = report.clone(); + tampered.production_metadata_evaluated = false; + assert!(!exact_duplicate_audit_integrity_valid(&tampered)); + + let mut tampered = report.clone(); + tampered.production_date_policy = "filesystem-only".into(); + assert!(!exact_duplicate_audit_integrity_valid(&tampered)); + + let mut tampered = report.clone(); + tampered.physical_reclaimable_bytes = Some(1); + assert!(!exact_duplicate_audit_integrity_valid(&tampered)); + + let mut tampered = report.clone(); + tampered.exact_content_match_is_delete_approval = true; + assert!(!exact_duplicate_audit_integrity_valid(&tampered)); + + let mut tampered = report.clone(); + tampered.mutation_performed = true; + assert!(!exact_duplicate_audit_integrity_valid(&tampered)); + + let mut tampered = report; + tampered.cluster_count += 1; + assert!(!exact_duplicate_audit_integrity_valid(&tampered)); +} diff --git a/src/lib/coverageEvidenceWorkflow.test.ts b/src/lib/coverageEvidenceWorkflow.test.ts new file mode 100644 index 000000000..dd02b70d6 --- /dev/null +++ b/src/lib/coverageEvidenceWorkflow.test.ts @@ -0,0 +1,104 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const workflow = readFileSync( + new URL('../../.github/workflows/test.yml', import.meta.url), + 'utf8', +); + +describe('Test workflow coverage evidence contract', () => { + it('binds coverage evidence to the exact pull-request head', () => { + expect(workflow).toContain( + 'ref: ${{ github.event.pull_request.head.sha || github.sha }}', + ); + expect(workflow).toContain( + 'HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}', + ); + }); + + it('binds every checkout-bearing Test job to the exact current head', () => { + const exactHeadCheckout = + 'ref: ${{ github.event.pull_request.head.sha || github.sha }}'; + expect(workflow.split(exactHeadCheckout).length - 1).toBe(3); + }); + + it('measures Rust branch coverage instead of synthesizing percentages', () => { + expect(workflow).toContain('tool: cargo-llvm-cov'); + expect(workflow).toContain( + 'cargo llvm-cov --manifest-path src-tauri/Cargo.toml --branch --json --output-path coverage.json', + ); + expect(workflow).not.toContain('--summary-only'); + expect(workflow).toContain('coverage.json'); + expect(workflow).toContain('coverage-evidence.json'); + }); + + it('measures the production Rust graph instead of cfg-pruned substitutes', () => { + expect(workflow).toContain('--no-cfg-coverage'); + expect(workflow).toContain('--no-cfg-coverage-nightly'); + }); + + it('preserves bounded exact-head metric diagnostics when the 100% gate fails', () => { + expect(workflow).toContain('coverage-diagnostic.json'); + expect(workflow).toContain('name: coverage-diagnostic-${{ env.HEAD_SHA }}'); + expect(workflow).toContain('path: coverage-diagnostic.json'); + expect(workflow).toContain('if: always()'); + expect(workflow).toContain('regions: totals?.regions ?? null'); + expect(workflow).toContain('branches: totals?.branches ?? null'); + expect(workflow).toContain('functions: totals?.functions ?? null'); + expect(workflow).toContain('lines: totals?.lines ?? null'); + }); + + it('identifies the largest exact-head Rust coverage gaps without leaking runner paths', () => { + expect(workflow).toContain('top_uncovered_files'); + expect(workflow).toContain("const marker = '/src-tauri/'"); + expect(workflow).toContain("return `src-tauri/${normalized.slice(markerIndex + marker.length)}`"); + expect(workflow).toContain('.slice(0, 20)'); + expect(workflow).toContain('uncovered_regions'); + expect(workflow).toContain('uncovered_branches'); + expect(workflow).toContain('uncovered_functions'); + expect(workflow).toContain('uncovered_lines'); + }); + + it('preserves bounded repository-relative uncovered line numbers for test targeting', () => { + expect(workflow).toContain('uncovered_line_numbers'); + expect(workflow).toContain('const uncoveredLineNumbers = (segments) =>'); + expect(workflow).toContain('Array.isArray(segment)'); + expect(workflow).toContain('segment[2] === 0'); + expect(workflow).toContain('.slice(0, 40)'); + }); + + it('surfaces the same bounded diagnostic in logs and the GitHub step summary', () => { + expect(workflow).toContain("console.error(`coverage-diagnostic=${JSON.stringify(diagnostic)}`)"); + expect(workflow).toContain('process.env.GITHUB_STEP_SUMMARY'); + expect(workflow).toMatch(/appendFileSync\(\s*summaryPath,/u); + expect(workflow).toContain('Coverage diagnostic for \\`${sha}\\`'); + }); + + it('preserves bounded frontend diagnostics when the all-production threshold fails', () => { + expect(workflow).toContain('name: Build bounded frontend coverage diagnostic'); + expect(workflow).toContain("readFileSync('coverage/coverage-final.json', 'utf8')"); + expect(workflow).toContain("readFileSync('coverage/coverage-summary.json', 'utf8')"); + expect(workflow).toContain('frontend_top_uncovered_files'); + expect(workflow).toContain('frontend_uncovered_line_numbers'); + expect(workflow).toContain("const frontendMarker = '/src/'"); + expect(workflow).toContain('frontend-coverage-diagnostic.json'); + expect(workflow).toContain('frontend-coverage-diagnostic-${{ env.HEAD_SHA }}'); + expect(workflow).toContain('path: frontend-coverage-diagnostic.json'); + }); + + it('runs frontend diagnostics after the failing coverage step', () => { + expect(workflow).toContain( + "if: failure() && steps.frontend-coverage.outcome == 'failure'", + ); + }); + + it('uploads fail-closed evidence under the organization contract name', () => { + expect(workflow).toContain('name: coverage-evidence'); + expect(workflow).toContain('path: coverage-evidence.json'); + expect(workflow).toContain('if-no-files-found: error'); + expect(workflow).toContain('statement_coverage'); + expect(workflow).toContain('branch_coverage'); + expect(workflow).toContain('function_coverage'); + expect(workflow).toContain('line_coverage'); + }); +}); diff --git a/src/lib/frontendCoverageScope.test.ts b/src/lib/frontendCoverageScope.test.ts new file mode 100644 index 000000000..127ac6604 --- /dev/null +++ b/src/lib/frontendCoverageScope.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const config = readFileSync(new URL("../../vitest.config.ts", import.meta.url), "utf8"); + +describe("frontend production coverage scope", () => { + it("measures every source-controlled production TypeScript module", () => { + expect(config).toContain('include: ["src/lib/**/*.ts", "src/routes/**/*.ts"]'); + expect(config).toContain('exclude: ["**/*.test.ts", "**/*.d.ts"]'); + for (const legacyAllowlistEntry of [ + "src/lib/api.ts", + "src/lib/treemap.ts", + "src/lib/fmt.ts", + "src/lib/dupeGuard.ts", + "src/lib/verdictBadge.ts", + ]) { + expect(config).not.toContain(` "${legacyAllowlistEntry}",`); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 198e3dcb8..99ebd050e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,19 +5,12 @@ export default defineConfig({ include: ["src/**/*.test.ts"], coverage: { provider: "v8", - // ponytail: 커버리지는 헤드리스로 검증 가능한 순수 로직과 mockable Tauri API 래퍼만 측정. - // Svelte 컴포넌트는 GUI·통합 검증 영역 (cargo test + 수동 체크리스트) - include: [ - "src/lib/api.ts", - "src/lib/treemap.ts", - "src/lib/fmt.ts", - "src/lib/dupeGuard.ts", - "src/lib/verdictBadge.ts", - ], + // Measure every source-controlled production TypeScript module. Test files, + // generated declarations, and Svelte component markup are excluded because + // they have separate deterministic contract and build verification paths. + include: ["src/lib/**/*.ts", "src/routes/**/*.ts"], + exclude: ["**/*.test.ts", "**/*.d.ts"], reporter: ["text", "json", "json-summary"], - // ponytail: 위 include 5개 순수 로직 파일은 헤드리스로 완전 검증 가능하므로 - // 네 지표 모두 100%로 고정한다. 이 게이트는 scope를 넓히지 않는다 — - // Svelte 컴포넌트는 여전히 cargo test + 수동 체크리스트로 검증한다. thresholds: { statements: 100, branches: 100,