From 6e99e1eaac57fc51c0c3d43cd48546a93a1bd348 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 29 Jul 2026 17:09:54 +0900 Subject: [PATCH] fix(ci): prefetch trusted pnpm store for coverage --- .../workflows/opencode-review-dispatch.yml | 47 ++- .../materialize_base_javascript_packages.py | 198 ++++++++++ scripts/ci/test_strix_quick_gate.sh | 5 +- ...st_materialize_base_javascript_packages.py | 350 ++++++++++++++++++ tests/test_opencode_agent_contract.py | 24 +- 5 files changed, 617 insertions(+), 7 deletions(-) create mode 100644 scripts/ci/materialize_base_javascript_packages.py create mode 100644 tests/test_materialize_base_javascript_packages.py diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 97ca96667..3ff9f4a22 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -499,7 +499,10 @@ jobs: # environment even after shell variables are unset. Execute all # pull-request-controlled tests in Docker's default private PID namespace with a # read-only trusted tree and no host Docker socket. The image is - # pinned to the reviewed linux/amd64 manifest digest. + # pinned to the reviewed linux/amd64 manifest digest. JavaScript + # registry access is likewise restricted to exact package inputs + # extracted from the live-validated base commit; the PR-head sandbox + # consumes only the resulting offline store. if [ "${OPENCODE_COVERAGE_SANDBOXED:-0}" != "1" ]; then host_github_output="$GITHUB_OUTPUT" sandbox_result_dir="${RUNNER_TEMP}/opencode-coverage-sandbox-result" @@ -541,6 +544,10 @@ jobs: --repo-root "$COVERAGE_SOURCE_WORKDIR" \ --base-sha "$PR_BASE_SHA" \ --output-dir "$coverage_build_dir/base-python-requirements" + python3 -I "$GITHUB_WORKSPACE/scripts/ci/materialize_base_javascript_packages.py" \ + --repo-root "$COVERAGE_SOURCE_WORKDIR" \ + --base-sha "$PR_BASE_SHA" \ + --output-dir "$coverage_build_dir/base-javascript-packages" cat >"$coverage_build_dir/Dockerfile" <<'DOCKERFILE' FROM docker.io/library/python:3.14-slim@sha256:b877e50bd90de10af8d82c57a022fc2e0dc731c5320d762a27986facfc3355c1 ENV DEBIAN_FRONTEND=noninteractive @@ -587,6 +594,25 @@ jobs: && ln -s /opt/pnpm/bin/pnpm.cjs /usr/local/bin/pnpm \ && test "$(/usr/local/bin/pnpm --version)" = "11.5.3" \ && rm -f /tmp/pnpm.tgz + COPY base-javascript-packages /tmp/base-javascript-packages + RUN set -eu; \ + mkdir -p /opt/pnpm-store; \ + jq -r '.[] | [.directory, .package_manager] | @tsv' \ + /tmp/base-javascript-packages/manifest.json \ + | while IFS="$(printf '\t')" read -r project_dir package_manager; do \ + [ -n "$project_dir" ] || continue; \ + if [ "$package_manager" != "pnpm@11.5.3" ]; then \ + printf 'Unsupported trusted base package manager: %s\n' "$package_manager" >&2; \ + exit 1; \ + fi; \ + cd "/tmp/base-javascript-packages/${project_dir}"; \ + pnpm fetch \ + --frozen-lockfile \ + --ignore-scripts \ + --store-dir /opt/pnpm-store; \ + done; \ + chmod -R a+rX /opt/pnpm-store; \ + rm -rf /tmp/base-javascript-packages COPY requirements-opencode-review-ci-hashes.txt /tmp/requirements-opencode-review-ci-hashes.txt RUN python3 -m pip install \ --break-system-packages \ @@ -1047,7 +1073,12 @@ jobs: fi ;; pnpm) - run_and_capture "JavaScript/TypeScript dependencies (pnpm install, lifecycle hooks disabled)" pnpm install --frozen-lockfile --ignore-scripts + run_and_capture "JavaScript/TypeScript dependencies (pnpm offline install, lifecycle hooks disabled)" \ + pnpm install \ + --offline \ + --frozen-lockfile \ + --ignore-scripts \ + --store-dir /opt/pnpm-store ;; yarn) run_and_capture "JavaScript/TypeScript dependencies (yarn install, lifecycle hooks disabled)" yarn install --immutable --mode=skip-builds @@ -1188,7 +1219,7 @@ jobs: check_javascript_coverage_thresholds() { local summary_list summary_list="$(mktemp /tmp/javascript-coverage-summaries.XXXXXX)" - find . \ + find "$COVERAGE_SOURCE_WORKDIR" \ \( -path '*/coverage/coverage-summary.json' -o -path '*/coverage/coverage-final.json' \) \ -type f \ -not -path '*/node_modules/*' \ @@ -1206,7 +1237,7 @@ jobs: run_and_capture "JavaScript/TypeScript coverage threshold" \ python3 "$GITHUB_WORKSPACE/scripts/ci/javascript_coverage_gate.py" \ - --repo-root . \ + --repo-root "$COVERAGE_SOURCE_WORKDIR" \ --base-sha "$PR_BASE_SHA" \ --head-sha "$PR_HEAD_SHA" \ --summary-list "$summary_list" @@ -1494,6 +1525,7 @@ jobs: javascript_package_dirs="$(javascript_coverage_package_dirs)" if [ -n "$javascript_package_dirs" ]; then measured_any=1 + javascript_coverage_ran_any=0 while IFS= read -r package_dir; do [ -n "$package_dir" ] || continue pushd "$package_dir" >/dev/null @@ -1557,10 +1589,13 @@ jobs: fi if [ "$javascript_coverage_ran" -eq 1 ]; then - check_javascript_coverage_thresholds + javascript_coverage_ran_any=1 fi popd >/dev/null done <<<"$javascript_package_dirs" + if [ "$javascript_coverage_ran_any" -eq 1 ]; then + check_javascript_coverage_thresholds + fi fi if has_changed_tracked_files '*.R' '*.r' 'DESCRIPTION' 'renv.lock'; then @@ -1932,6 +1967,7 @@ jobs: ContextualWisdomLab/.github:opencode.jsonc | \ ContextualWisdomLab/.github:scripts/ci/changed_file_syntax_gate.py | \ ContextualWisdomLab/.github:scripts/ci/javascript_coverage_gate.py | \ + ContextualWisdomLab/.github:scripts/ci/materialize_base_javascript_packages.py | \ ContextualWisdomLab/.github:scripts/ci/opencode_review_approve_gate.sh | \ ContextualWisdomLab/.github:scripts/ci/pr_head_replay_guard.py | \ ContextualWisdomLab/.github:scripts/ci/pr_review_merge_scheduler.py | \ @@ -1941,6 +1977,7 @@ jobs: ContextualWisdomLab/.github:scripts/ci/validate_opencode_failed_check_review.sh | \ ContextualWisdomLab/.github:tests/test_changed_file_syntax_gate.py | \ ContextualWisdomLab/.github:tests/test_javascript_coverage_gate.py | \ + ContextualWisdomLab/.github:tests/test_materialize_base_javascript_packages.py | \ ContextualWisdomLab/.github:tests/test_opencode_agent_contract.py | \ ContextualWisdomLab/.github:tests/test_opencode_model_pool_runner.py | \ ContextualWisdomLab/.github:tests/test_pr_head_replay_guard.py | \ diff --git a/scripts/ci/materialize_base_javascript_packages.py b/scripts/ci/materialize_base_javascript_packages.py new file mode 100644 index 000000000..7defba36f --- /dev/null +++ b/scripts/ci/materialize_base_javascript_packages.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Materialize pnpm locks from a validated pull-request base commit.""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import subprocess +import sys +from typing import Any + + +SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") +PNPM_SPEC_RE = re.compile(r"^pnpm@[0-9]+\.[0-9]+\.[0-9]+(?:[+-][A-Za-z0-9._+-]+)?$") +PNPM_BASE_INPUT_NAMES = ("package.json", "pnpm-workspace.yaml", ".pnpmfile.cjs") + + +def _git(repo_root: pathlib.Path, *args: str) -> bytes: + """Run one read-only git command in the materialized repository.""" + completed = subprocess.run( + ["git", "-C", str(repo_root), *args], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if completed.returncode != 0: + stderr = completed.stderr.decode("utf-8", errors="replace").strip() + raise RuntimeError(f"git {args[0]} failed: {stderr}") + return completed.stdout + + +def _regular_base_paths(repo_root: pathlib.Path, base_sha: str) -> set[str]: + """Return regular blob paths from the exact validated base commit.""" + entries = _git(repo_root, "ls-tree", "-r", "-z", "--full-tree", base_sha) + paths: set[str] = set() + for raw_entry in entries.split(b"\0"): + if not raw_entry: + continue + metadata, separator, raw_path = raw_entry.partition(b"\t") + if not separator: + raise RuntimeError("git ls-tree returned a malformed entry") + fields = metadata.split() + if len(fields) != 3: + raise RuntimeError("git ls-tree returned malformed metadata") + mode, object_type, _object_id = ( + field.decode("ascii", errors="strict") for field in fields + ) + path = raw_path.decode("utf-8", errors="surrogateescape") + candidate = pathlib.PurePosixPath(path) + if ( + object_type == "blob" + and mode.startswith("100") + and not candidate.is_absolute() + and ".." not in candidate.parts + ): + paths.add(path) + return paths + + +def base_pnpm_projects( + repo_root: pathlib.Path, base_sha: str +) -> list[tuple[str, str, dict[str, bytes]]]: + """Return exact base pnpm inputs grouped by lockfile directory.""" + if not SHA_RE.fullmatch(base_sha): + raise ValueError("base SHA must be exactly 40 hexadecimal characters") + + repo_root = repo_root.resolve() + regular_paths = _regular_base_paths(repo_root, base_sha) + projects: list[tuple[str, str, dict[str, bytes]]] = [] + for lock_path in sorted( + path + for path in regular_paths + if pathlib.PurePosixPath(path).name == "pnpm-lock.yaml" + ): + lock = pathlib.PurePosixPath(lock_path) + project_root = lock.parent + package_path = str(project_root / "package.json") + if package_path not in regular_paths: + raise ValueError( + f"trusted base pnpm lock {lock_path} has no regular sibling package.json" + ) + try: + package_data: Any = json.loads( + _git(repo_root, "show", f"{base_sha}:{package_path}").decode("utf-8") + ) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError( + f"trusted base package manifest {package_path} is invalid JSON: {exc}" + ) from exc + if not isinstance(package_data, dict): + raise ValueError( + f"trusted base package manifest {package_path} must be a JSON object" + ) + package_manager = package_data.get("packageManager") + if not isinstance(package_manager, str) or not PNPM_SPEC_RE.fullmatch( + package_manager + ): + raise ValueError( + f"trusted base package manifest {package_path} must declare an exact pnpm packageManager version" + ) + lock_content = _git(repo_root, "show", f"{base_sha}:{lock_path}") + if not lock_content.strip(): + raise ValueError(f"trusted base pnpm lock {lock_path} is empty") + + base_inputs = { + input_name: _git( + repo_root, + "show", + f"{base_sha}:{project_root / input_name}", + ) + for input_name in PNPM_BASE_INPUT_NAMES + if str(project_root / input_name) in regular_paths + } + base_inputs["pnpm-lock.yaml"] = lock_content + + patches_root = project_root / "patches" + for base_path in sorted(regular_paths): + candidate = pathlib.PurePosixPath(base_path) + if candidate == patches_root or patches_root not in candidate.parents: + continue + relative_path = str(candidate.relative_to(project_root)) + base_inputs[relative_path] = _git( + repo_root, "show", f"{base_sha}:{base_path}" + ) + + projects.append((lock_path, package_manager, base_inputs)) + return projects + + +def materialize( + repo_root: pathlib.Path, + base_sha: str, + output_dir: pathlib.Path, +) -> list[dict[str, str]]: + """Write base pnpm inputs under generated paths safe for a Docker context.""" + if output_dir.exists() and output_dir.is_symlink(): + raise ValueError("output directory must not be a symlink") + output_dir.mkdir(parents=True, exist_ok=True) + + manifest: list[dict[str, str]] = [] + for index, (source_path, package_manager, base_inputs) in enumerate( + base_pnpm_projects(repo_root, base_sha) + ): + directory = f"project-{index:03d}" + project_dir = output_dir / directory + project_dir.mkdir() + for relative_path, content in sorted(base_inputs.items()): + destination = project_dir / relative_path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(content) + manifest.append( + { + "directory": directory, + "package_manager": package_manager, + "source": source_path, + } + ) + + (output_dir / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return manifest + + +def main(argv: list[str] | None = None) -> int: + """Materialize base pnpm locks and report the trusted inputs.""" + parser = argparse.ArgumentParser() + parser.add_argument("--repo-root", required=True, type=pathlib.Path) + parser.add_argument("--base-sha", required=True) + parser.add_argument("--output-dir", required=True, type=pathlib.Path) + args = parser.parse_args(argv) + + try: + manifest = materialize(args.repo_root, args.base_sha, args.output_dir) + except (OSError, RuntimeError, ValueError) as exc: + print( + f"::error::Could not materialize base JavaScript package locks: {exc}", + file=sys.stderr, + ) + return 1 + + if manifest: + for entry in manifest: + print( + "Materialized trusted base pnpm lock " + f"{entry['source']} for {entry['package_manager']} " + f"as {entry['directory']}/pnpm-lock.yaml." + ) + else: + print("No tracked pnpm-lock.yaml files exist at the validated base SHA.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 06e99981b..8e5e982ed 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -908,7 +908,10 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "or fall back to npm" "coverage evidence logs package-runner activation failures instead of silently using npm" assert_file_not_contains "$workflow_file" '@latest' "coverage evidence refuses mutable package-manager toolchains" assert_file_contains "$workflow_file" "npm ci --ignore-scripts" "coverage dependency installation suppresses npm lifecycle hooks" - assert_file_contains "$workflow_file" "pnpm install --frozen-lockfile --ignore-scripts" "coverage dependency installation suppresses pnpm lifecycle hooks" + assert_file_contains "$workflow_file" "pnpm offline install" "coverage dependency installation uses a prefetched trusted pnpm store" + assert_file_contains "$workflow_file" "--offline" "coverage dependency installation refuses pnpm registry access" + assert_file_contains "$workflow_file" "--ignore-scripts" "coverage dependency installation suppresses pnpm lifecycle hooks" + assert_file_contains "$workflow_file" "--store-dir /opt/pnpm-store" "coverage dependency installation uses the trusted pnpm store" assert_file_contains "$workflow_file" "yarn install --immutable --mode=skip-builds" "coverage dependency installation suppresses Yarn build hooks" assert_file_contains "$workflow_file" "PR-selected dependency manifests are never resolved" "coverage refuses PR-controlled Python dependency resolution entirely" assert_file_contains "$REPO_ROOT/.github/workflows/strix.yml" 'STRIX_EXECUTABLE_PATH=%s' "Strix workflow captures the pinned installation executable before scanning" diff --git a/tests/test_materialize_base_javascript_packages.py b/tests/test_materialize_base_javascript_packages.py new file mode 100644 index 000000000..2534f906f --- /dev/null +++ b/tests/test_materialize_base_javascript_packages.py @@ -0,0 +1,350 @@ +from __future__ import annotations + +import json +import runpy +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts.ci import materialize_base_javascript_packages as materializer + + +def git(repo: Path, *args: str) -> str: + """Run git in a temporary fixture repository.""" + return subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def fixture_repo(tmp_path: Path) -> tuple[Path, str]: + """Create a repository whose head mutates the trusted base package inputs.""" + repo = tmp_path / "repo" + repo.mkdir() + git(repo, "init") + git(repo, "config", "user.name", "Test") + git(repo, "config", "user.email", "test@example.invalid") + + frontend = repo / "frontend" + frontend.mkdir() + (frontend / "package.json").write_text( + json.dumps({"packageManager": "pnpm@11.5.3"}) + "\n", + encoding="utf-8", + ) + (frontend / "pnpm-lock.yaml").write_text( + "lockfileVersion: '9.0'\n" + "patchedDependencies:\n" + " base@1.0.0: base-hash\n" + "packages:\n" + " base@1.0.0: {}\n", + encoding="utf-8", + ) + (frontend / "pnpm-workspace.yaml").write_text( + "patchedDependencies:\n base@1.0.0: patches/base.patch\n", + encoding="utf-8", + ) + (frontend / ".pnpmfile.cjs").write_text( + "module.exports = { hooks: {} };\n", + encoding="utf-8", + ) + patches = frontend / "patches" + patches.mkdir() + (patches / "base.patch").write_text("trusted base patch\n", encoding="utf-8") + git(repo, "add", ".") + git(repo, "commit", "-m", "base") + base_sha = git(repo, "rev-parse", "HEAD") + + (frontend / "package.json").write_text( + json.dumps({"packageManager": "pnpm@99.0.0"}) + "\n", + encoding="utf-8", + ) + (frontend / "pnpm-lock.yaml").write_text( + "lockfileVersion: '9.0'\npackages:\n head@2.0.0: {}\n", + encoding="utf-8", + ) + (frontend / "pnpm-workspace.yaml").write_text( + "patchedDependencies:\n head@2.0.0: patches/head.patch\n", + encoding="utf-8", + ) + (frontend / ".pnpmfile.cjs").write_text( + "throw new Error('untrusted head hook');\n", + encoding="utf-8", + ) + (patches / "base.patch").unlink() + (patches / "head.patch").write_text("untrusted head patch\n", encoding="utf-8") + git(repo, "add", ".") + git(repo, "commit", "-m", "head") + return repo, base_sha + + +def test_materializes_only_exact_base_pnpm_inputs(tmp_path: Path) -> None: + """PR-modified package metadata cannot enter the networked build context.""" + repo, base_sha = fixture_repo(tmp_path) + output = tmp_path / "output" + + manifest = materializer.materialize(repo, base_sha, output) + + assert manifest == [ + { + "directory": "project-000", + "package_manager": "pnpm@11.5.3", + "source": "frontend/pnpm-lock.yaml", + } + ] + assert "base@1.0.0" in (output / "project-000" / "pnpm-lock.yaml").read_text( + encoding="utf-8" + ) + assert "head@2.0.0" not in (output / "project-000" / "pnpm-lock.yaml").read_text( + encoding="utf-8" + ) + assert (output / "project-000" / "package.json").read_text( + encoding="utf-8" + ) == '{"packageManager": "pnpm@11.5.3"}\n' + assert "base@1.0.0" in (output / "project-000" / "pnpm-workspace.yaml").read_text( + encoding="utf-8" + ) + assert "hooks: {}" in (output / "project-000" / ".pnpmfile.cjs").read_text( + encoding="utf-8" + ) + assert (output / "project-000" / "patches" / "base.patch").read_text( + encoding="utf-8" + ) == "trusted base patch\n" + assert not (output / "project-000" / "patches" / "head.patch").exists() + assert ( + json.loads((output / "manifest.json").read_text(encoding="utf-8")) == manifest + ) + + +def test_rejects_invalid_base_sha(tmp_path: Path) -> None: + """Git options and symbolic refs cannot cross the exact-SHA boundary.""" + with pytest.raises(ValueError, match="40 hexadecimal"): + materializer.base_pnpm_projects(tmp_path, "--help") + + +def test_git_failure_preserves_command_reason(tmp_path: Path) -> None: + """Read-only git failures retain the actionable stderr detail.""" + with pytest.raises(RuntimeError, match="git rev-parse failed"): + materializer._git(tmp_path, "rev-parse", "HEAD") + + +@pytest.mark.parametrize( + ("tree_output", "message"), + [ + (b"malformed\0", "malformed entry"), + (b"100644 blob\tfile\0", "malformed metadata"), + ], +) +def test_rejects_malformed_git_tree_entries( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + tree_output: bytes, + message: str, +) -> None: + """Malformed git output cannot be interpreted as trusted base input.""" + + def fake_git(_repo_root: Path, *_args: str) -> bytes: + return tree_output + + monkeypatch.setattr(materializer, "_git", fake_git) + with pytest.raises(RuntimeError, match=message): + materializer.base_pnpm_projects(tmp_path, "a" * 40) + + +def test_rejects_lock_without_sibling_package_manifest(tmp_path: Path) -> None: + """A lock without an exact package-manager declaration fails closed.""" + repo = tmp_path / "repo" + repo.mkdir() + git(repo, "init") + git(repo, "config", "user.name", "Test") + git(repo, "config", "user.email", "test@example.invalid") + (repo / "pnpm-lock.yaml").write_text("lockfileVersion: '9.0'\n", encoding="utf-8") + git(repo, "add", ".") + git(repo, "commit", "-m", "base") + + with pytest.raises(ValueError, match="no regular sibling package.json"): + materializer.base_pnpm_projects(repo, git(repo, "rev-parse", "HEAD")) + + +@pytest.mark.parametrize( + ("package_content", "lock_content", "message"), + [ + (b"not-json", b"lockfileVersion: '9.0'\n", "invalid JSON"), + (b"[]", b"lockfileVersion: '9.0'\n", "must be a JSON object"), + ( + b'{"packageManager":"pnpm@11.5.3"}', + b"\n", + "pnpm lock frontend/pnpm-lock.yaml is empty", + ), + ], +) +def test_rejects_invalid_base_package_inputs( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + package_content: bytes, + lock_content: bytes, + message: str, +) -> None: + """Malformed base manifests and empty locks fail before materialization.""" + regular_paths = {"frontend/package.json", "frontend/pnpm-lock.yaml"} + monkeypatch.setattr( + materializer, + "_regular_base_paths", + lambda *_args: regular_paths, + ) + + def fake_git(_repo_root: Path, _command: str, object_spec: str) -> bytes: + if object_spec.endswith(":frontend/package.json"): + return package_content + if object_spec.endswith(":frontend/pnpm-lock.yaml"): + return lock_content + raise AssertionError(f"unexpected git object: {object_spec}") + + monkeypatch.setattr(materializer, "_git", fake_git) + with pytest.raises(ValueError, match=message): + materializer.base_pnpm_projects(tmp_path, "a" * 40) + + +def test_rejects_mutable_or_non_pnpm_package_manager(tmp_path: Path) -> None: + """Only an exact pnpm runner specification may populate the trusted store.""" + repo, base_sha = fixture_repo(tmp_path) + base_package = repo / "frontend" / "package.json" + git(repo, "checkout", base_sha, "--", "frontend/package.json") + base_package.write_text( + json.dumps({"packageManager": "pnpm@latest"}) + "\n", encoding="utf-8" + ) + git(repo, "add", ".") + git(repo, "commit", "-m", "mutable base") + + with pytest.raises(ValueError, match="exact pnpm packageManager"): + materializer.base_pnpm_projects(repo, git(repo, "rev-parse", "HEAD")) + + +def test_rejects_symlink_output_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A symlink cannot redirect trusted materialization outside its context.""" + target = tmp_path / "target" + target.mkdir() + output = tmp_path / "output" + output.symlink_to(target, target_is_directory=True) + monkeypatch.setattr(materializer, "base_pnpm_projects", lambda *_args: []) + + with pytest.raises(ValueError, match="must not be a symlink"): + materializer.materialize(tmp_path, "a" * 40, output) + + +def test_main_reports_materialized_lock( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """The CLI identifies the exact trusted base source and runner.""" + monkeypatch.setattr( + materializer, + "materialize", + lambda *_args: [ + { + "directory": "project-000", + "package_manager": "pnpm@11.5.3", + "source": "frontend/pnpm-lock.yaml", + } + ], + ) + + assert ( + materializer.main( + [ + "--repo-root", + str(tmp_path), + "--base-sha", + "a" * 40, + "--output-dir", + str(tmp_path / "output"), + ] + ) + == 0 + ) + assert ( + "Materialized trusted base pnpm lock frontend/pnpm-lock.yaml " + "for pnpm@11.5.3 as project-000/pnpm-lock.yaml." in capsys.readouterr().out + ) + + +def test_main_reports_empty_base( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """The CLI distinguishes an empty trusted base from extraction failure.""" + monkeypatch.setattr(materializer, "materialize", lambda *_args: []) + assert ( + materializer.main( + [ + "--repo-root", + str(tmp_path), + "--base-sha", + "a" * 40, + "--output-dir", + str(tmp_path / "output"), + ] + ) + == 0 + ) + assert "No tracked pnpm-lock.yaml files exist" in capsys.readouterr().out + + +def test_main_preserves_failure_reason( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Materialization failures remain diagnosable and fail closed.""" + + def fail_materialize(_repo_root: Path, _base_sha: str, _output_dir: Path) -> None: + raise OSError("fixture failure") + + monkeypatch.setattr(materializer, "materialize", fail_materialize) + assert ( + materializer.main( + [ + "--repo-root", + str(tmp_path), + "--base-sha", + "a" * 40, + "--output-dir", + str(tmp_path / "output"), + ] + ) + == 1 + ) + assert ( + "::error::Could not materialize base JavaScript package locks: fixture failure" + in capsys.readouterr().err + ) + + +def test_script_entrypoint_exits_through_main( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The executable script propagates the fail-closed CLI status.""" + module_path = Path(materializer.__file__) + monkeypatch.setattr( + sys, + "argv", + [ + str(module_path), + "--repo-root", + str(tmp_path), + "--base-sha", + "invalid", + "--output-dir", + str(tmp_path / "output"), + ], + ) + with pytest.raises(SystemExit) as raised: + runpy.run_path(str(module_path), run_name="__main__") + assert raised.value.code == 1 diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 4ca3ef4c2..8636afae8 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -366,6 +366,16 @@ def test_opencode_target_coverage_materializes_only_after_authorized_dispatch(): ) in measure_step assert "ln -s /opt/pnpm/bin/pnpm.cjs /usr/local/bin/pnpm" in measure_step assert 'test "$(/usr/local/bin/pnpm --version)" = "11.5.3"' in measure_step + assert "materialize_base_javascript_packages.py" in measure_step + assert "COPY base-javascript-packages /tmp/base-javascript-packages" in measure_step + assert "pnpm fetch" in measure_step + assert "--store-dir /opt/pnpm-store" in measure_step + assert "pnpm offline install" in measure_step + assert "--offline" in measure_step + assert 'find "$COVERAGE_SOURCE_WORKDIR"' in measure_step + assert '--repo-root "$COVERAGE_SOURCE_WORKDIR"' in measure_step + assert "javascript_coverage_ran_any=1" in measure_step + assert measure_step.count("check_javascript_coverage_thresholds") == 2 assert "--require-hashes" in measure_step assert 'coverage_tool_image="opencode-coverage-tools:${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"' in measure_step assert "The networked build context contains only this" in measure_step @@ -983,6 +993,10 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): "ContextualWisdomLab/.github:scripts/ci/javascript_coverage_gate.py | \\" in workflow ) + assert ( + "ContextualWisdomLab/.github:scripts/ci/materialize_base_javascript_packages.py | \\" + in workflow + ) assert ( "ContextualWisdomLab/.github:scripts/ci/opencode_review_approve_gate.sh | \\" in workflow @@ -992,6 +1006,10 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): "ContextualWisdomLab/.github:tests/test_javascript_coverage_gate.py | \\" in workflow ) + assert ( + "ContextualWisdomLab/.github:tests/test_materialize_base_javascript_packages.py | \\" + in workflow + ) assert "tests/test_opencode_agent_contract.py | \\" in workflow assert ( "ContextualWisdomLab/appguardrail:scripts/ci/collect_org_security_failures.py" @@ -1658,7 +1676,11 @@ def test_opencode_privileged_review_security_boundaries_are_fail_closed(): assert "read directly from the live-validated base SHA" in measure assert 'chmod 0444 "$implementation_changed_files"' in measure assert "npm ci --ignore-scripts" in coverage_job - assert "pnpm install --frozen-lockfile --ignore-scripts" in coverage_job + assert "pnpm install \\" in coverage_job + assert "--offline" in coverage_job + assert "--frozen-lockfile" in coverage_job + assert "--ignore-scripts" in coverage_job + assert "--store-dir /opt/pnpm-store" in coverage_job assert "yarn install --immutable --mode=skip-builds" in coverage_job assert 'corepack prepare "${runner}@latest"' not in coverage_job assert "https://sh.rustup.rs" not in coverage_job