From 3df629a9a53b8cf9f7ea4fd348d81f550f6ca454 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 8 Jul 2026 12:02:07 +0200 Subject: [PATCH] Add local (in-repo) project support to the regression matrix A repos.yaml entry may now set 'kind: local' + 'source: ' instead of 'git'/'head'. Local projects are vendored, buildable projects already present in the checkout: the workflow skips the git clone and points run_analysis at the source directory, where 'opentaint compile' (the autobuilder) builds them exactly like a cloned project. The analysis runner, cache key, and compare logic are unchanged. generate_matrix now emits 'kind'/'source' per cell (git projects get kind=git, source=""; local projects get git="", head="local"), and validates required fields per kind. The analyze job guards its clone step on kind=='git' and resolves --project-dir accordingly. --- .github/workflows/regression.yaml | 3 +- scripts/generate_matrix.py | 56 ++++++++++++++++++++++++-- tests/test_scripts.py | 66 +++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 4 deletions(-) diff --git a/.github/workflows/regression.yaml b/.github/workflows/regression.yaml index 261c0fc..cd28abd 100644 --- a/.github/workflows/regression.yaml +++ b/.github/workflows/regression.yaml @@ -282,6 +282,7 @@ jobs: path: build - run: chmod +x build/opentaint - name: Clone project + if: matrix.kind == 'git' run: | git clone --filter=blob:none "${{ matrix.git }}" project-root git -C project-root checkout "${{ matrix.head }}" @@ -302,7 +303,7 @@ jobs: set +e python scripts/run_analysis.py \ --build-dir build \ - --project-dir project-root \ + --project-dir "${{ matrix.kind == 'git' && 'project-root' || matrix.source }}" \ --results-dir results-bundle \ --max-memory "${{ matrix.max_memory }}" \ --timeout "${{ matrix.compilation_timeout }}" \ diff --git a/scripts/generate_matrix.py b/scripts/generate_matrix.py index 09a72da..bb201c3 100755 --- a/scripts/generate_matrix.py +++ b/scripts/generate_matrix.py @@ -5,10 +5,21 @@ restricts the project set (substring match against project name). Optional --misses-only restricts to (project, ref) pairs flagged as cache misses. +A project entry is either: + +* ``kind: git`` (the default) — cloned from ``git`` at ``head`` by the + workflow, then built by the autobuilder (``opentaint compile``). +* ``kind: local`` — a buildable project vendored in this repo at ``source``. + The workflow skips the clone and points the runner at ``source`` directly; + ``opentaint compile`` builds it exactly like a cloned project. Local cells + carry ``git: ""`` and the constant ``head: "local"`` (kit-content changes + invalidate the cache via the test-system SHA, already part of every key). + Output JSON shape (printed to stdout): {"include": [ - {"project": "spring-petclinic", "git": "...", "head": "...", + {"project": "spring-petclinic", "kind": "git", + "git": "...", "head": "...", "source": "", "java_version": "17", "max_memory": "8G", "compilation_timeout": "1200", "ref_kind": "base", "analyzer_sha": ""}, ... @@ -27,6 +38,12 @@ DEFAULT_JAVA = "17" DEFAULT_MEMORY = "8G" DEFAULT_COMPILATION_TIMEOUT = "1200" +DEFAULT_KIND = "git" + +# Cache-key head for local projects. A constant (no `/` or space, so it is a +# valid cache_key component); kit-content changes still invalidate the cache +# through the test-system SHA, which is part of every key. +LOCAL_HEAD_SENTINEL = "local" def _matches_filter(name: str, patterns: list[str]) -> bool: @@ -50,6 +67,36 @@ def _normalise_scan_flags(raw) -> list[str]: return [str(token) for token in raw] +def _resolve_identity(repo: dict) -> tuple[str, str, str, str]: + """Return ``(kind, git, head, source)`` for one repo entry, validating the + fields required by its kind. + + * ``git`` projects must supply ``git`` and ``head``. + * ``local`` projects must supply ``source``; they carry ``git: ""`` and the + constant ``head`` sentinel. + + Raises ``ValueError`` on an unknown kind or a missing required field, so a + misconfigured entry fails the workflow fast instead of producing a matrix + cell that breaks further downstream. + """ + name = repo.get("name", "") + kind = str(repo.get("kind", DEFAULT_KIND)) + if kind == "git": + git = repo.get("git") + head = repo.get("head") + if not git or not head: + raise ValueError( + f"{name}: git project requires both 'git' and 'head'") + return kind, str(git), str(head), "" + if kind == "local": + source = repo.get("source") + if not source: + raise ValueError(f"{name}: local project requires 'source'") + return kind, "", LOCAL_HEAD_SENTINEL, str(source) + raise ValueError( + f"{name}: unknown kind {kind!r} (expected 'git' or 'local')") + + def _load_misses(path: str | None) -> set[tuple[str, str]]: if not path: return set() @@ -71,13 +118,16 @@ def build_matrix(repos_path: Path, base_sha: str, new_sha: str, name = repo["name"] if not _matches_filter(name, projects_filter): continue + kind, git, head, source = _resolve_identity(repo) for ref_kind, sha in refs: if misses_only and (name, ref_kind) not in misses: continue include.append({ "project": name, - "git": repo["git"], - "head": repo["head"], + "kind": kind, + "git": git, + "head": head, + "source": source, "java_version": str(repo.get("java-version", DEFAULT_JAVA)), "max_memory": str(repo.get("max-memory", DEFAULT_MEMORY)), "compilation_timeout": str( diff --git a/tests/test_scripts.py b/tests/test_scripts.py index 12601ea..3903a2a 100644 --- a/tests/test_scripts.py +++ b/tests/test_scripts.py @@ -303,6 +303,72 @@ def test_matrix_scan_flags_rejects_non_list(tmp_path): generate_matrix.build_matrix(repos, "AAA", "AAA", [], None) +# ── generate_matrix: local (kind) projects ─────────────────────────────────── + +def test_matrix_git_entry_emits_kind_and_empty_source(tmp_path): + repos = _write_repos(tmp_path, ( + "repositories:\n" + " - name: demo\n" + " git: https://example.com/demo.git\n" + " head: deadbeef\n" + )) + m = generate_matrix.build_matrix(repos, "AAA", "AAA", [], None) + cell = m["include"][0] + assert cell["kind"] == "git" + assert cell["git"] == "https://example.com/demo.git" + assert cell["head"] == "deadbeef" + assert cell["source"] == "" + + +def test_matrix_local_entry_shape(tmp_path): + repos = _write_repos(tmp_path, ( + "repositories:\n" + " - name: repro-01\n" + " kind: local\n" + " source: projects/repro-kits/01-reflect-method-invoke\n" + " java-version: 21\n" + )) + m = generate_matrix.build_matrix(repos, "AAA", "AAA", [], None) + cell = m["include"][0] + assert cell["kind"] == "local" + assert cell["source"] == "projects/repro-kits/01-reflect-method-invoke" + # Local cells clone nothing and use the constant head sentinel. + assert cell["git"] == "" + assert cell["head"] == generate_matrix.LOCAL_HEAD_SENTINEL == "local" + assert cell["java_version"] == "21" + + +def test_matrix_local_missing_source_raises(tmp_path): + repos = _write_repos(tmp_path, ( + "repositories:\n" + " - name: repro-01\n" + " kind: local\n" + )) + with pytest.raises(ValueError): + generate_matrix.build_matrix(repos, "AAA", "AAA", [], None) + + +def test_matrix_git_missing_head_raises(tmp_path): + repos = _write_repos(tmp_path, ( + "repositories:\n" + " - name: demo\n" + " git: https://example.com/demo.git\n" + )) + with pytest.raises(ValueError): + generate_matrix.build_matrix(repos, "AAA", "AAA", [], None) + + +def test_matrix_unknown_kind_raises(tmp_path): + repos = _write_repos(tmp_path, ( + "repositories:\n" + " - name: demo\n" + " kind: svn\n" + " source: whatever\n" + )) + with pytest.raises(ValueError): + generate_matrix.build_matrix(repos, "AAA", "AAA", [], None) + + # ── run_analysis: scan-flag expansion ──────────────────────────────── def test_expand_scan_flags_substitutes_ext(tmp_path):