From a35d921223ed636e6e597146a01395b3b48d6598 Mon Sep 17 00:00:00 2001 From: Svilen Stefanov Date: Sat, 11 Jul 2026 02:55:09 +0200 Subject: [PATCH 1/4] feat: migrate to Core's git-free incremental (#401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core's content-versioning stack removed the git-ref incremental API: run_full/ run_incremental now take (RunPaths, RunContext), run_incremental drops base_ref/target_ref/source_sha (it diffs a fingerprint.json sidecar against the checkout itself), and metadata.commit_hash is gone. Without this the action TypeErrors on every analysis once #401 lands. engine_adapter.py: - Migrate every run_full/run_incremental call to (RunPaths, RunContext) via a _run_ctx() helper; drop base_ref/target_ref from run_incremental and the head subcommand/argparser. - run_seed also writes fingerprint.json (hash_repo_source_files), the baseline git-free incremental diffs against. - run_analyze (sync): drop the commit_hash trustworthiness gate β€” a present analysis.json runs incremental (Core self-falls-back to full on a missing sidecar/cache); full only on force_full or no baseline. action.yml (sync mode is naturally git-free β€” analyze latest, commit back): - Seed the sync workdir from the committed .codeboarding/ directly (analysis.json + fingerprint.json), gated on both files' presence; drop the baseline-SHA worktree seed and the commit_hash lookup. - Commit fingerprint.json back alongside analysis.json + the pkl so the next sync's baseline is self-contained; drop the now-redundant SHA-keyed sync static-analysis cache (the pkl travels via git). - Review mode stays git-based (a PR review compares two commits): its base seed now also emits fingerprint.json; bump the base cache key v2 -> v3. Tests: update engine_adapter/sync stubs for the new Core symbols; rewrite the head/analyze tests for the (RunPaths, RunContext) call shape and the git-free baseline gate. 171 passing. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- action.yml | 134 +++++++-------------------------- scripts/engine_adapter.py | 130 ++++++++++++-------------------- tests/test_engine_adapter.py | 53 +++++++++---- tests/test_sync_subcommands.py | 89 ++++++++++++---------- 4 files changed, 166 insertions(+), 240 deletions(-) diff --git a/action.yml b/action.yml index bbaf8ec..084d1df 100644 --- a/action.yml +++ b/action.yml @@ -833,7 +833,7 @@ runs: # inputs. So a free-tier run (oidc, forced Gemini) and a BYO OpenRouter-key # run with no model pinned would share a key yet produce different base # analyses; the mode discriminator keeps them from reusing each other's cache. - key: cb-base-v2-${{ runner.os }}-${{ steps.base.outputs.baseline_sha }}-d${{ steps.resolve_depth.outputs.depth }}-${{ inputs.codeboarding_version }}-${{ steps.llm.outputs.mode }}-${{ inputs.llm_provider }}-${{ inputs.agent_model }}-${{ inputs.parsing_model }} + key: cb-base-v3-${{ runner.os }}-${{ steps.base.outputs.baseline_sha }}-d${{ steps.resolve_depth.outputs.depth }}-${{ inputs.codeboarding_version }}-${{ steps.llm.outputs.mode }}-${{ inputs.llm_provider }}-${{ inputs.agent_model }}-${{ inputs.parsing_model }} - name: Reassert committed base analysis after cache restore if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.base.outputs.committed == 'true' @@ -967,7 +967,7 @@ runs: # inputs. So a free-tier run (oidc, forced Gemini) and a BYO OpenRouter-key # run with no model pinned would share a key yet produce different base # analyses; the mode discriminator keeps them from reusing each other's cache. - key: cb-base-v2-${{ runner.os }}-${{ steps.base.outputs.baseline_sha }}-d${{ steps.resolve_depth.outputs.depth }}-${{ inputs.codeboarding_version }}-${{ steps.llm.outputs.mode }}-${{ inputs.llm_provider }}-${{ inputs.agent_model }}-${{ inputs.parsing_model }} + key: cb-base-v3-${{ runner.os }}-${{ steps.base.outputs.baseline_sha }}-d${{ steps.resolve_depth.outputs.depth }}-${{ inputs.codeboarding_version }}-${{ steps.llm.outputs.mode }}-${{ inputs.llm_provider }}-${{ inputs.agent_model }}-${{ inputs.parsing_model }} - name: Analyze PR head (incremental from base) if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' @@ -1020,8 +1020,6 @@ runs: --name "$REPO_NAME" --run-id "$RUN_ID_HEAD" --depth "$DEPTH" - --base-ref "$BASELINE_SHA" - --target-ref "$HEAD_SHA" --source-sha "$HEAD_SHA" ) # Raise the depth ceiling for licensed/BYO-key runs (controls the @@ -1087,93 +1085,30 @@ runs: echo "arch_file=$ARCHITECTURE_FILE" } >> "$GITHUB_OUTPUT" - if [ -f "$OUTPUT_DIR/analysis.json" ]; then + # Git-free baseline: the committed .codeboarding/ is self-contained + # (analysis.json for component ids + fingerprint.json for change detection, + # plus the optional static_analysis.pkl warm-start the prior sync committed). + # Incremental needs analysis.json AND fingerprint.json; a missing pkl only + # costs a cold LSP pass, so it doesn't gate baseline-present. + if [ -f "$OUTPUT_DIR/analysis.json" ] && [ -f "$OUTPUT_DIR/fingerprint.json" ]; then cp "$OUTPUT_DIR/analysis.json" "$ANALYSIS_DIR/analysis.json" + cp "$OUTPUT_DIR/fingerprint.json" "$ANALYSIS_DIR/fingerprint.json" if [ -f "$OUTPUT_DIR/static_analysis.pkl" ] && [ -f "$OUTPUT_DIR/static_analysis.sha" ]; then cp "$OUTPUT_DIR/static_analysis.pkl" "$ANALYSIS_DIR/static_analysis.pkl" cp "$OUTPUT_DIR/static_analysis.sha" "$ANALYSIS_DIR/static_analysis.sha" - echo "committed_cache=true" >> "$GITHUB_OUTPUT" - else - echo "committed_cache=false" >> "$GITHUB_OUTPUT" - fi - # baseline-info parses metadata.commit_hash and emits it ONLY when it is - # present and SHA-shaped, so an unusable/injected value never reaches - # GITHUB_OUTPUT, the cache key, or git. (Pure stdlib β€” no engine venv.) - BASELINE_SHA="$(python3 "$ACTION_PATH/scripts/engine_adapter.py" baseline-info \ - --analysis "$ANALYSIS_DIR/analysis.json" | sed -n 's/^commit_hash=//p')" - if [ -n "$BASELINE_SHA" ]; then - echo "baseline_present=true" >> "$GITHUB_OUTPUT" - echo "baseline_sha=$BASELINE_SHA" >> "$GITHUB_OUTPUT" - echo "Using committed baseline generated for $BASELINE_SHA." - else - rm -f "$ANALYSIS_DIR/analysis.json" "$ANALYSIS_DIR/static_analysis.pkl" "$ANALYSIS_DIR/static_analysis.sha" - echo "baseline_present=false" >> "$GITHUB_OUTPUT" - echo "baseline_sha=none" >> "$GITHUB_OUTPUT" - echo "committed_cache=false" >> "$GITHUB_OUTPUT" - echo "::warning::Committed analysis.json has no usable metadata.commit_hash; a full analysis will run." fi + echo "baseline_present=true" >> "$GITHUB_OUTPUT" + echo "Using committed baseline (analysis.json + fingerprint.json)." else echo "baseline_present=false" >> "$GITHUB_OUTPUT" - echo "baseline_sha=none" >> "$GITHUB_OUTPUT" - echo "committed_cache=false" >> "$GITHUB_OUTPUT" - echo "No committed baseline found; a full analysis will run." - fi - - - name: Restore sync static-analysis cache - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' && steps.sync_seed.outputs.baseline_present == 'true' && steps.sync_seed.outputs.committed_cache != 'true' - id: sync_cache - uses: actions/cache/restore@v4 - with: - path: | - ${{ runner.temp }}/cb-sync/static_analysis.pkl - ${{ runner.temp }}/cb-sync/static_analysis.sha - key: cb-sync-${{ runner.os }}-${{ inputs.codeboarding_version }}-d${{ steps.resolve_depth.outputs.depth }}-${{ steps.llm.outputs.cache_provider }}-${{ steps.llm.outputs.cache_agent_model }}-${{ steps.llm.outputs.cache_parsing_model }}-${{ steps.sync_seed.outputs.baseline_sha }} - - # Same rationale as the review pipeline's seed step: the committed - # analysis.json supplies component ids, but the incremental path also needs - # the baseline static_analysis.pkl (LSP + clustering, no LLM). Fail-open. - - name: Seed sync static-analysis cache at baseline SHA - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' && steps.sync_seed.outputs.baseline_present == 'true' && steps.sync_seed.outputs.committed_cache != 'true' && steps.sync_cache.outputs.cache-hit != 'true' - id: sync_seedcache - continue-on-error: true - shell: bash - env: - # The seed subcommand defaults to github_action attribution; sync-mode - # telemetry must report as sync (setdefault never clobbers this). - CODEBOARDING_SOURCE: 'sync' - CACHING_DOCUMENTATION: 'false' - ENABLE_MONITORING: 'false' - ACTION_PATH: ${{ github.action_path }} - TARGET_REPO: ${{ github.workspace }}/target-repo - ANALYSIS_DIR: ${{ steps.sync_seed.outputs.cb_dir }} - BASELINE_SHA: ${{ steps.sync_seed.outputs.baseline_sha }} - run: | - set -euo pipefail - echo "seed_ok=false" >> "$GITHUB_OUTPUT" - if ! git -C "$TARGET_REPO" cat-file -e "${BASELINE_SHA}^{commit}" 2>/dev/null; then - echo "::warning::Baseline commit $BASELINE_SHA is not reachable; analysis will fall back to full if needed." - exit 0 + echo "No committed baseline (analysis.json + fingerprint.json); a full analysis will run." fi - # Clean up any stale registration before re-adding (rm -rf alone leaves a - # dangling worktree entry that makes a retry's `worktree add` fail). - BASE_SRC="${RUNNER_TEMP}/cb-sync-base-src" - git -C "$TARGET_REPO" worktree remove --force "$BASE_SRC" 2>/dev/null || true - git -C "$TARGET_REPO" worktree prune - rm -rf "$BASE_SRC" - git -C "$TARGET_REPO" worktree add --detach "$BASE_SRC" "$BASELINE_SHA" - if python "$ACTION_PATH/scripts/engine_adapter.py" seed \ - --repo "$BASE_SRC" \ - --out "$ANALYSIS_DIR" \ - --source-sha "$BASELINE_SHA" \ - && [ -f "$ANALYSIS_DIR/static_analysis.pkl" ] && [ -f "$ANALYSIS_DIR/static_analysis.sha" ]; then - echo "seed_ok=true" >> "$GITHUB_OUTPUT" - echo "::notice::Seeded static-analysis cache for $BASELINE_SHA." - else - rm -f "$ANALYSIS_DIR/static_analysis.pkl" "$ANALYSIS_DIR/static_analysis.sha" - echo "::warning::Static-analysis seeding failed; analysis may fall back to full." - fi - git -C "$TARGET_REPO" worktree remove --force "$BASE_SRC" 2>/dev/null || true + # Git-free sync needs no baseline-SHA worktree seed: the committed + # fingerprint.json is the change-detection baseline and the committed + # static_analysis.pkl (staged by the prior sync) is the warm-start cache. + # The first-ever run has no committed baseline and runs full, which writes + # all three artifacts for the next run to reuse. - name: Analyze target repository (sync) if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' @@ -1484,30 +1419,9 @@ runs: echo "docs_dir=$RENDER_DIR" >> "$GITHUB_OUTPUT" echo "architecture_file=$ARCHITECTURE_FILE" >> "$GITHUB_OUTPUT" - - name: Check sync static-analysis cache files - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' - id: sync_cachecheck - shell: bash - env: - ANALYSIS_DIR: ${{ steps.sync_seed.outputs.cb_dir }} - run: | - set -euo pipefail - if [ -f "$ANALYSIS_DIR/static_analysis.pkl" ] && [ -f "$ANALYSIS_DIR/static_analysis.sha" ]; then - echo "has_cache=true" >> "$GITHUB_OUTPUT" - else - echo "has_cache=false" >> "$GITHUB_OUTPUT" - echo "::notice::No static-analysis pkl/sha pair to cache." - fi - - - name: Save sync static-analysis cache - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' && steps.sync_cachecheck.outputs.has_cache == 'true' - continue-on-error: true - uses: actions/cache/save@v4 - with: - path: | - ${{ runner.temp }}/cb-sync/static_analysis.pkl - ${{ runner.temp }}/cb-sync/static_analysis.sha - key: cb-sync-${{ runner.os }}-${{ inputs.codeboarding_version }}-d${{ steps.resolve_depth.outputs.depth }}-${{ steps.llm.outputs.cache_provider }}-${{ steps.llm.outputs.cache_agent_model }}-${{ steps.llm.outputs.cache_parsing_model }}-${{ steps.guard.outputs.target_sha }} + # Sync no longer saves the static-analysis pkl to the Actions cache: it is + # committed back to .codeboarding/ alongside analysis.json + fingerprint.json, + # a more durable cross-runner baseline than the ephemeral, SHA-keyed cache. - name: Commit and push synced architecture if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'sync' @@ -1553,6 +1467,13 @@ runs: fi cp "${rendered[@]}" "$OUTPUT_DIR/" cp "$ANALYSIS_DIR/analysis.json" "$OUTPUT_DIR/analysis.json" + # Commit the whole-tree fingerprint so the next sync's git-free incremental + # has its change-detection baseline (analysis.json alone can't drive it). + if [ -f "$ANALYSIS_DIR/fingerprint.json" ]; then + cp "$ANALYSIS_DIR/fingerprint.json" "$OUTPUT_DIR/fingerprint.json" + else + rm -f "$OUTPUT_DIR/fingerprint.json" + fi if [ -f "$ANALYSIS_DIR/static_analysis.pkl" ] && [ -f "$ANALYSIS_DIR/static_analysis.sha" ]; then cp "$ANALYSIS_DIR/static_analysis.pkl" "$OUTPUT_DIR/static_analysis.pkl" cp "$ANALYSIS_DIR/static_analysis.sha" "$OUTPUT_DIR/static_analysis.sha" @@ -1577,6 +1498,7 @@ runs: stage_paths+=("$1") fi } + add_if_present_or_tracked "$OUTPUT_DIR/fingerprint.json" add_if_present_or_tracked "$OUTPUT_DIR/static_analysis.pkl" add_if_present_or_tracked "$OUTPUT_DIR/static_analysis.sha" [ -f "$OUTPUT_DIR/codeboarding_version.json" ] && stage_paths+=("$OUTPUT_DIR/codeboarding_version.json") diff --git a/scripts/engine_adapter.py b/scripts/engine_adapter.py index 190cfca..85a02b9 100644 --- a/scripts/engine_adapter.py +++ b/scripts/engine_adapter.py @@ -12,7 +12,7 @@ base --repo P --out D --name N --run-id ID --depth K --source-sha SHA seed --repo P --out D --source-sha SHA - head --repo P --out D --name N --run-id ID --depth K --base-ref B --target-ref T --source-sha SHA + head --repo P --out D --name N --run-id ID --depth K --source-sha SHA health --artifact-dir D --repo P --name N --issues-out FILE validate-base --analysis F --expected-sha SHA [--expected-depth K] baseline-info --analysis F @@ -36,10 +36,10 @@ baseline's ``commit_hash=`` (empty unless present and SHA-shaped). ``head`` (review) and ``analyze`` (sync) are the SAME incremental-or-full -operation via the shared ``_incremental_or_full`` helper; they differ only in -where the base ref comes from (the PR target-branch SHA vs the committed analysis.json) -and in that ``analyze`` is baseline-aware (full when the baseline is missing, -lacks a commit_hash, or ``--force-full`` is set) and prints +operation via the shared ``_incremental_or_full`` helper. Change detection is +git-free: Core diffs the current checkout against the seeded ``fingerprint.json`` +sidecar, so neither passes a base/target ref. ``analyze`` is baseline-aware (full +when the committed analysis.json is missing or ``--force-full`` is set) and prints ``analysis_mode=full|incremental`` on stdout for the action to grep. Once an analysis.json exists, its metadata.depth_level is the source of truth for incremental and fallback-full depth; ``--depth`` is the cold-start/force-full @@ -70,16 +70,21 @@ # resolving the review depth), so a hard import here would break that step. The # analysis subcommands that DO need the engine fail loudly when these are None. try: + from agents.content_hash import hash_repo_source_files from codeboarding_workflows.analysis import BaselineUnavailableError, run_full, run_incremental from codeboarding_workflows.rendering import render_docs + from diagram_analysis import RunContext, RunPaths from diagram_analysis.exceptions import IncrementalCacheMissingError + from diagram_analysis.io_utils import write_fingerprint from static_analyzer import get_static_analysis from static_analyzer.analysis_cache import StaticAnalysisCache from static_analyzer.cluster_helpers import build_all_cluster_results except Exception: # engine package not installed (metadata-only subcommands don't need it) BaselineUnavailableError = IncrementalCacheMissingError = _MissingEngine = type("_MissingEngine", (Exception,), {}) run_full = run_incremental = render_docs = None + RunContext = RunPaths = None get_static_analysis = StaticAnalysisCache = build_all_cluster_results = None + hash_repo_source_files = write_fingerprint = None try: from health.models import Severity @@ -146,6 +151,14 @@ def _log_path(output_dir: str, filename: str) -> str: return str(Path(output_dir) / filename) +def _run_ctx(repo_path: str, output_dir: str, repo_name: str, run_id: str, log_name: str): + """Build the (RunPaths, RunContext) pair Core's run_full/run_incremental take.""" + repo_dir = Path(repo_path) + run_paths = RunPaths(repo_path=repo_dir, output_dir=Path(output_dir), project_name=repo_name) + run_context = RunContext(run_id=run_id, log_path=_log_path(output_dir, log_name), repo_dir=repo_dir) + return run_paths, run_context + + def _clear_dir(path: Path) -> None: path.mkdir(parents=True, exist_ok=True) for child in path.iterdir(): @@ -310,15 +323,8 @@ def validate_base_analysis( def run_base(repo_path: str, output_dir: str, repo_name: str, run_id: str, depth: int, source_sha: str) -> None: - res = run_full( - repo_name=repo_name, - repo_path=Path(repo_path), - output_dir=Path(output_dir), - run_id=run_id, - log_path=_log_path(output_dir, "cb-base.log"), - depth_level=depth, - source_sha=source_sha, - ) + run_paths, run_context = _run_ctx(repo_path, output_dir, repo_name, run_id, "cb-base.log") + res = run_full(run_paths, run_context, depth_level=depth, source_sha=source_sha) print(f"Base analysis written: {res}") @@ -338,12 +344,18 @@ def run_seed(repo_path: str, output_dir: str, source_sha: str) -> None: persists the pkl on LSP teardown, before clustering β€” saving only there would recreate the pkl-without-cluster-baseline state this fixes. + Also writes the whole-tree ``fingerprint.json`` sidecar: git-free incremental + diffs the head checkout against it, so a committed baseline that only carries + analysis.json + pkl would otherwise leave incremental with no baseline and + force a full run. + Errors propagate; the action step treats a failed seed as fail-open (the head run falls back to a full analysis, today's behavior). """ results = get_static_analysis(Path(repo_path), cache_dir=Path(output_dir), source_sha=source_sha) cluster_results = build_all_cluster_results(results) StaticAnalysisCache(Path(output_dir), Path(repo_path)).save(results, source_sha=source_sha) + write_fingerprint(Path(output_dir), hash_repo_source_files(Path(repo_path))) summary = ", ".join(f"{lang}={len(cr.clusters)}" for lang, cr in sorted(cluster_results.items())) print(f"Seeded static-analysis baseline in {output_dir} (clusters: {summary or 'none'})") @@ -355,30 +367,24 @@ def _incremental_or_full( repo_name: str, run_id: str, depth: int, - base_ref: str, - target_ref: str, source_sha: str, log_name: str, licensed: bool, ) -> str: - """Run incremental from *base_ref*; on a cache/baseline miss, clear the - output dir and run a full analysis. Returns "incremental" or "full". + """Run incremental against the seeded baseline; on a cache/baseline miss, + clear the output dir and run a full analysis. Returns "incremental" or "full". + + Change detection is git-free: Core diffs the current checkout against the + seeded ``fingerprint.json`` sidecar itself, so no base/target ref is passed. + ``source_sha`` still tags the full fallback's static-analysis cache. Shared by the review head path and the sync analyze path so the fallback rule (which exceptions degrade to full, and the clear-before-full) lives in exactly one place β€” a bug fixed here is fixed for both modes. """ + run_paths, run_context = _run_ctx(repo_path, output_dir, repo_name, run_id, log_name) try: - res = run_incremental( - repo_path=Path(repo_path), - output_dir=Path(output_dir), - project_name=repo_name, - run_id=run_id, - log_path=_log_path(output_dir, log_name), - base_ref=base_ref, - target_ref=target_ref, - source_sha=source_sha, - ) + res = run_incremental(run_paths, run_context) print(f"Analysis written: {res}") return "incremental" except (IncrementalCacheMissingError, BaselineUnavailableError) as exc: @@ -386,15 +392,7 @@ def _incremental_or_full( out_path = Path(output_dir) fallback_depth = _analysis_depth_or_default(out_path, licensed, depth) _clear_dir(out_path) - res = run_full( - repo_name=repo_name, - repo_path=Path(repo_path), - output_dir=out_path, - run_id=run_id, - log_path=_log_path(output_dir, log_name), - depth_level=fallback_depth, - source_sha=source_sha, - ) + res = run_full(run_paths, run_context, depth_level=fallback_depth, source_sha=source_sha) print(f"Analysis written: {res}") return "full" @@ -405,13 +403,11 @@ def run_head( repo_name: str, run_id: str, depth: int, - base_ref: str, - target_ref: str, source_sha: str, force_full: bool = False, licensed: bool = False, ) -> None: - """Review PR head: incremental from the PR target branch tip, full on a cache miss. + """Review PR head: incremental against the seeded baseline, full on a cache miss. Print the selected mode explicitly so GitHub Action logs make it obvious whether review used the incremental path or fell back to a full analysis. @@ -419,15 +415,8 @@ def run_head( if force_full: out_path = Path(output_dir) _clear_dir(out_path) - res = run_full( - repo_name=repo_name, - repo_path=Path(repo_path), - output_dir=out_path, - run_id=run_id, - log_path=_log_path(output_dir, "cb-head.log"), - depth_level=depth, - source_sha=source_sha, - ) + run_paths, run_context = _run_ctx(repo_path, output_dir, repo_name, run_id, "cb-head.log") + res = run_full(run_paths, run_context, depth_level=depth, source_sha=source_sha) print(f"Analysis written: {res}") print("head_analysis_mode=full") return @@ -438,8 +427,6 @@ def run_head( repo_name=repo_name, run_id=run_id, depth=depth, - base_ref=base_ref, - target_ref=target_ref, source_sha=source_sha, log_name="cb-head.log", licensed=licensed, @@ -457,32 +444,22 @@ def run_analyze( force_full: bool = False, licensed: bool = False, ) -> str: - """Sync analysis: incremental from the committed baseline, full when the - baseline is absent or untrusted (no commit_hash) or ``force_full`` is set. - Prints ``analysis_mode=full|incremental`` on stdout β€” the action greps that - line, so it must be printed exactly once per run. - - The baseline's depth_level is resolved (clamped/defaulted, never None) for the - incremental run; whether to do a full rebuild is decided by trustworthiness - (commit_hash), NOT by the depth. The engine's incremental path needs a usable - cache + base commit, not a particular depth, and falls back to full itself if - the cache is missing β€” so a present baseline with an odd depth_level still runs - incremental at a sane depth rather than forcing a costly full rebuild. + """Sync analysis: incremental against the committed baseline, full when the + baseline is absent or ``force_full`` is set. Prints ``analysis_mode=full|incremental`` + on stdout β€” the action greps that line, so it must be printed exactly once per run. + + Change detection is git-free: the baseline is trusted when its ``fingerprint.json`` + sidecar is present (``_incremental_or_full`` falls back to full itself if the + sidecar or cache is missing), so there's no ``commit_hash`` gate. The baseline's + depth_level is resolved (clamped/defaulted, never None) for the incremental run. """ out_path = Path(output_dir) def full(reason: str, analysis_depth: int) -> str: print(f"{reason}; running full analysis.") _clear_dir(out_path) - res = run_full( - repo_name=repo_name, - repo_path=Path(repo_path), - output_dir=out_path, - run_id=run_id, - log_path=_log_path(output_dir, "cb-sync.log"), - depth_level=analysis_depth, - source_sha=source_sha, - ) + run_paths, run_context = _run_ctx(repo_path, output_dir, repo_name, run_id, "cb-sync.log") + res = run_full(run_paths, run_context, depth_level=analysis_depth, source_sha=source_sha) print(f"Analysis written: {res}") print("analysis_mode=full") return "full" @@ -495,19 +472,12 @@ def full(reason: str, analysis_depth: int) -> str: return full("No baseline analysis.json found", min(depth, _max_depth(licensed))) baseline_depth = _resolve_depth(metadata, licensed) - - base_ref = _metadata_commit(metadata) - if not base_ref: - return full("Baseline metadata.commit_hash is missing", baseline_depth) - mode = _incremental_or_full( repo_path=repo_path, output_dir=output_dir, repo_name=repo_name, run_id=run_id, depth=baseline_depth, - base_ref=base_ref, - target_ref=source_sha, source_sha=source_sha, log_name="cb-sync.log", licensed=licensed, @@ -622,7 +592,7 @@ def main(argv=None) -> int: s.add_argument(a, required=True) h = sub.add_parser("head") - for a in ("--repo", "--out", "--name", "--run-id", "--base-ref", "--target-ref", "--source-sha"): + for a in ("--repo", "--out", "--name", "--run-id", "--source-sha"): h.add_argument(a, required=True) h.add_argument("--depth", required=True, type=int, choices=depth_choices) h.add_argument("--licensed", action="store_true", help="Licensed/BYO-key run (raises the depth ceiling).") @@ -675,8 +645,6 @@ def main(argv=None) -> int: args.name, args.run_id, args.depth, - args.base_ref, - args.target_ref, args.source_sha, # action.yml adds --force-full for EMPTY_BASE PRs (no comparison baseline). args.force_full, diff --git a/tests/test_engine_adapter.py b/tests/test_engine_adapter.py index ad64fec..5de6934 100644 --- a/tests/test_engine_adapter.py +++ b/tests/test_engine_adapter.py @@ -45,17 +45,30 @@ def save(self, *args, **kwargs): pass +class _RunPaths: + def __init__(self, repo_path=None, output_dir=None, project_name=None): + self.repo_path, self.output_dir, self.project_name = repo_path, output_dir, project_name + + +class _RunContext: + def __init__(self, run_id=None, log_path=None, repo_dir=None): + self.run_id, self.log_path, self.repo_dir = run_id, log_path, repo_dir + + analysis = _preload( "codeboarding_workflows.analysis", - run_full=lambda **kwargs: "OUT", - run_incremental=lambda **kwargs: "OUT", + run_full=lambda *a, **k: "OUT", + run_incremental=lambda *a, **k: "OUT", BaselineUnavailableError=_InitialBaselineUnavailableError, ) pkg = _preload("codeboarding_workflows") pkg.analysis = analysis exc = _preload("diagram_analysis.exceptions", IncrementalCacheMissingError=_InitialIncrementalCacheMissingError) -da = _preload("diagram_analysis") +da = _preload("diagram_analysis", RunPaths=_RunPaths, RunContext=_RunContext) da.exceptions = exc +_preload("diagram_analysis.io_utils", write_fingerprint=lambda *a, **k: None) +_preload("agents.content_hash", hash_repo_source_files=lambda *a, **k: {}) +_preload("agents") _preload("codeboarding_workflows.rendering", render_docs=lambda *args, **kwargs: None) _preload("health.models", Severity=_InitialSeverity) _preload("health.runner", run_health_checks=lambda *args, **kwargs: None) @@ -68,10 +81,13 @@ def save(self, *args, **kwargs): import engine_adapter # noqa: E402 _STUBBED = [ + "agents", + "agents.content_hash", "codeboarding_workflows", "codeboarding_workflows.analysis", "diagram_analysis", "diagram_analysis.exceptions", + "diagram_analysis.io_utils", "health", "health.models", "health.runner", @@ -83,11 +99,13 @@ def save(self, *args, **kwargs): class _Rec: def __init__(self, ret="OUT", raises=None): - self.calls = [] + self.calls = [] # kwargs of each call + self.args = [] # positional args of each call self._ret, self._raises = ret, raises def __call__(self, *a, **k): self.calls.append(k) + self.args.append(a) if self._raises: raise self._raises("boom") return self._ret @@ -137,11 +155,13 @@ def test_base_calls_run_full(self): self._install(run_full=rf) engine_adapter.run_base("/repo", "/out", "myrepo", "rid-base", 2, "abc123") self.assertEqual(len(rf.calls), 1) - k = rf.calls[0] - self.assertEqual(k["repo_name"], "myrepo") - self.assertEqual(str(k["repo_path"]), "/repo") - self.assertEqual(k["depth_level"], 2) - self.assertEqual(k["source_sha"], "abc123") + run_paths, run_context = rf.args[0] + self.assertEqual(run_paths.project_name, "myrepo") + self.assertEqual(str(run_paths.repo_path), "/repo") + self.assertEqual(str(run_paths.output_dir), "/out") + self.assertEqual(run_context.run_id, "rid-base") + self.assertEqual(rf.calls[0]["depth_level"], 2) + self.assertEqual(rf.calls[0]["source_sha"], "abc123") def test_main_parses_depth_as_int(self): rf = _Rec() @@ -242,11 +262,14 @@ def test_head_uses_incremental(self): self._install(run_full=rf, run_incremental=ri) buf = StringIO() with redirect_stdout(buf): - engine_adapter.run_head("/repo", "/out", "r", "rid", 1, "base", "head", "head") + engine_adapter.run_head("/repo", "/out", "r", "rid", 1, "head") self.assertEqual(len(ri.calls), 1) self.assertEqual(len(rf.calls), 0) # no fallback - self.assertEqual(ri.calls[0]["base_ref"], "base") - self.assertEqual(ri.calls[0]["target_ref"], "head") + # Git-free: no base/target ref β€” Core diffs the seeded fingerprint itself. + run_paths, run_context = ri.args[0] + self.assertEqual(str(run_paths.repo_path), "/repo") + self.assertEqual(str(run_paths.output_dir), "/out") + self.assertEqual(run_context.run_id, "rid") self.assertIn("head_analysis_mode=incremental", buf.getvalue()) def test_head_force_full_skips_incremental(self): @@ -257,7 +280,7 @@ def test_head_force_full_skips_incremental(self): buf = StringIO() with redirect_stdout(buf): - engine_adapter.run_head("/repo", out, "r", "rid", 2, "empty", "head", "head", force_full=True) + engine_adapter.run_head("/repo", out, "r", "rid", 2, "head", force_full=True) self.assertEqual(len(ri.calls), 0) self.assertEqual(len(rf.calls), 1) @@ -279,7 +302,7 @@ def test_head_falls_back_to_full_on_cache_miss(self): (Path(out) / "health" / "stale.json").write_text("{}") buf = StringIO() with redirect_stdout(buf): - engine_adapter.run_head("/repo", out, "r", "rid", 3, "base", "head", "head") + engine_adapter.run_head("/repo", out, "r", "rid", 3, "head") self.assertEqual(len(rf.calls), 1) # fell back to full self.assertEqual(rf.calls[0]["depth_level"], 3) self.assertFalse((Path(out) / "stale.json").exists()) # head dir wiped before full @@ -293,7 +316,7 @@ def test_head_falls_back_to_full_on_baseline_unavailable(self): analysis.run_incremental = _Rec(raises=BaseUnavail) engine_adapter.run_full = analysis.run_full engine_adapter.run_incremental = analysis.run_incremental - engine_adapter.run_head("/repo", tempfile.mkdtemp(), "r", "rid", 1, "base", "head", "head") + engine_adapter.run_head("/repo", tempfile.mkdtemp(), "r", "rid", 1, "head") self.assertEqual(len(rf.calls), 1) # BaselineUnavailableError also triggers the full re-run diff --git a/tests/test_sync_subcommands.py b/tests/test_sync_subcommands.py index bceec64..f6a4f1b 100644 --- a/tests/test_sync_subcommands.py +++ b/tests/test_sync_subcommands.py @@ -47,10 +47,20 @@ def save(self, *args, **kwargs): pass +class _RunPaths: + def __init__(self, repo_path=None, output_dir=None, project_name=None): + self.repo_path, self.output_dir, self.project_name = repo_path, output_dir, project_name + + +class _RunContext: + def __init__(self, run_id=None, log_path=None, repo_dir=None): + self.run_id, self.log_path, self.repo_dir = run_id, log_path, repo_dir + + analysis = _preload( "codeboarding_workflows.analysis", - run_full=lambda **kwargs: "OUT", - run_incremental=lambda **kwargs: "OUT", + run_full=lambda *a, **k: "OUT", + run_incremental=lambda *a, **k: "OUT", BaselineUnavailableError=_InitialBaselineUnavailableError, ) pkg = _preload("codeboarding_workflows") @@ -58,8 +68,11 @@ def save(self, *args, **kwargs): rendering = _preload("codeboarding_workflows.rendering", render_docs=lambda *args, **kwargs: None) pkg.rendering = rendering exc = _preload("diagram_analysis.exceptions", IncrementalCacheMissingError=_InitialIncrementalCacheMissingError) -da = _preload("diagram_analysis") +da = _preload("diagram_analysis", RunPaths=_RunPaths, RunContext=_RunContext) da.exceptions = exc +_preload("diagram_analysis.io_utils", write_fingerprint=lambda *a, **k: None) +_preload("agents.content_hash", hash_repo_source_files=lambda *a, **k: {}) +_preload("agents") _preload("health.models", Severity=_InitialSeverity) _preload("health.runner", run_health_checks=lambda *args, **kwargs: None) _preload("health") @@ -71,11 +84,14 @@ def save(self, *args, **kwargs): import engine_adapter # noqa: E402 _STUBBED = [ + "agents", + "agents.content_hash", "codeboarding_workflows", "codeboarding_workflows.analysis", "codeboarding_workflows.rendering", "diagram_analysis", "diagram_analysis.exceptions", + "diagram_analysis.io_utils", "static_analyzer", "static_analyzer.analysis_cache", "static_analyzer.cluster_helpers", @@ -153,27 +169,28 @@ def test_no_baseline_runs_full(self): self.assertEqual(mode, "full") self.assertEqual(len(rf.calls), 1) self.assertEqual(len(ri.calls), 0) - kwargs = rf.calls[0][1] - self.assertEqual(kwargs["repo_name"], "myrepo") - self.assertEqual(str(kwargs["repo_path"]), "/repo") - self.assertEqual(kwargs["depth_level"], 2) - self.assertEqual(kwargs["source_sha"], "head123") + run_paths, run_context = rf.calls[0][0] + self.assertEqual(run_paths.project_name, "myrepo") + self.assertEqual(str(run_paths.repo_path), "/repo") + self.assertEqual(rf.calls[0][1]["depth_level"], 2) + self.assertEqual(rf.calls[0][1]["source_sha"], "head123") - def test_incremental_uses_metadata_commit_as_base_ref(self): + def test_committed_baseline_runs_incremental(self): + # Git-free: a committed analysis.json is the baseline; incremental runs + # (Core diffs the committed fingerprint itself), with no commit_hash gate. rf, ri = _Rec(), _Rec() self._install(run_full=rf, run_incremental=ri) out = tempfile.mkdtemp() - _write_analysis(out, commit="metadata-base", depth=2) + _write_analysis(out, depth=2) mode = engine_adapter.run_analyze("/repo", out, "myrepo", "rid", "head123", 2) self.assertEqual(mode, "incremental") self.assertEqual(len(rf.calls), 0) self.assertEqual(len(ri.calls), 1) - kwargs = ri.calls[0][1] - self.assertEqual(kwargs["base_ref"], "metadata-base") - self.assertEqual(kwargs["target_ref"], "head123") - self.assertEqual(kwargs["source_sha"], "head123") + run_paths, run_context = ri.calls[0][0] + self.assertEqual(str(run_paths.repo_path), "/repo") + self.assertEqual(run_context.run_id, "rid") def test_deeper_baseline_still_runs_incremental(self): rf, ri = _Rec(), _Rec() @@ -194,7 +211,7 @@ def test_deeper_baseline_still_runs_incremental(self): def test_deep_baseline_runs_incremental_regardless_of_tier(self): # A committed depth-7 baseline still runs incremental (the depth value - # doesn't gate incremental β€” only commit_hash does); on the free tier the + # doesn't gate incremental β€” baseline presence does); on the free tier the # depth is clamped for any eventual run, but incremental is unaffected. rf, ri = _Rec(), _Rec() self._install(run_full=rf, run_incremental=ri) @@ -207,20 +224,19 @@ def test_deep_baseline_runs_incremental_regardless_of_tier(self): self.assertEqual(len(rf.calls), 0) self.assertEqual(len(ri.calls), 1) - def test_over_cap_baseline_depth_clamped_on_full_fallback(self): - # When a full rebuild happens (here: baseline has no commit_hash), the - # resolved depth is clamped to the tier ceiling: free clamps 7 -> 3, - # licensed keeps 7. + def test_over_cap_depth_clamped_on_forced_full(self): + # When a full run happens (here: force_full), the requested depth is + # clamped to the tier ceiling: free clamps 7 -> 3, licensed keeps 7. for licensed, expected in ((False, 3), (True, 7)): with self.subTest(licensed=licensed): rf, ri = _Rec(), _Rec() self._install(run_full=rf, run_incremental=ri) out = Path(tempfile.mkdtemp()) - out.joinpath("analysis.json").write_text( - json.dumps({"metadata": {"depth_level": 7}}), encoding="utf-8" # no commit_hash -> full - ) + _write_analysis(out, depth=2) - mode = engine_adapter.run_analyze("/repo", str(out), "myrepo", "rid", "head123", 2, licensed=licensed) + mode = engine_adapter.run_analyze( + "/repo", str(out), "myrepo", "rid", "head123", 7, force_full=True, licensed=licensed + ) self.assertEqual(mode, "full") self.assertEqual(rf.calls[0][1]["depth_level"], expected) @@ -240,27 +256,25 @@ def test_shallower_baseline_runs_incremental(self): self.assertEqual(len(rf.calls), 0) self.assertEqual(len(ri.calls), 1) - def test_missing_depth_with_valid_commit_runs_incremental(self): - # A missing/unparseable depth_level is no longer a reason to force a full - # rebuild: incremental needs a usable cache + base commit (commit_hash), - # not a particular depth. With a valid commit_hash we run incremental; - # the depth defaults to 2 for the run (and the engine itself falls back to - # full if the cache is actually absent). + def test_missing_depth_still_runs_incremental(self): + # A missing/unparseable depth_level is not a reason to force a full: the + # baseline (analysis.json) is present, so incremental runs; the depth + # resolves to a default and Core falls back to full itself if the cache + # is actually absent. rf, ri = _Rec(), _Rec() self._install(run_full=rf, run_incremental=ri) out = Path(tempfile.mkdtemp()) - out.joinpath("analysis.json").write_text( - json.dumps({"metadata": {"commit_hash": "metadata-base"}}), encoding="utf-8" - ) + out.joinpath("analysis.json").write_text(json.dumps({"metadata": {}}), encoding="utf-8") mode = engine_adapter.run_analyze("/repo", str(out), "myrepo", "rid", "head123", 3) self.assertEqual(mode, "incremental") self.assertEqual(len(rf.calls), 0) self.assertEqual(len(ri.calls), 1) - self.assertEqual(ri.calls[0][1]["base_ref"], "metadata-base") - def test_missing_commit_in_baseline_runs_full_at_baseline_depth(self): + def test_baseline_without_commit_still_runs_incremental(self): + # commit_hash is gone from #401 metadata, so its absence no longer forces + # a full rebuild β€” a present analysis.json runs incremental git-free. rf, ri = _Rec(), _Rec() self._install(run_full=rf, run_incremental=ri) out = Path(tempfile.mkdtemp()) @@ -268,10 +282,9 @@ def test_missing_commit_in_baseline_runs_full_at_baseline_depth(self): mode = engine_adapter.run_analyze("/repo", str(out), "myrepo", "rid", "head123", 1) - self.assertEqual(mode, "full") - self.assertEqual(len(rf.calls), 1) - self.assertEqual(len(ri.calls), 0) - self.assertEqual(rf.calls[0][1]["depth_level"], 3) + self.assertEqual(mode, "incremental") + self.assertEqual(len(ri.calls), 1) + self.assertEqual(len(rf.calls), 0) def test_falls_back_to_full_on_cache_miss(self): analysis, IncMiss, _ = self._install() From 254c66e6a43e4c0c1c40bfeef10ff3553fdb4b2c Mon Sep 17 00:00:00 2001 From: Svilen Stefanov Date: Mon, 13 Jul 2026 13:22:43 +0200 Subject: [PATCH 2/4] fix: fail loudly when the engine is missing or too old The best-effort engine import degrades every engine symbol to None, so an installed codeboarding package that predates Core #401 (no RunPaths/RunContext) made the analysis subcommands die with a cryptic "'NoneType' object is not callable" deep in _run_ctx. Guard base/seed/head/analyze/render at dispatch with a clear message naming the missing git-free API and the codeboarding_version pin to fix. Metadata-only subcommands, concat, and best-effort health are unaffected. Makes true the module's own claim that these subcommands "fail loudly when these are None". Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/engine_adapter.py | 23 +++++++++++++++++++++++ tests/test_engine_adapter.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/scripts/engine_adapter.py b/scripts/engine_adapter.py index 85a02b9..585526c 100644 --- a/scripts/engine_adapter.py +++ b/scripts/engine_adapter.py @@ -93,6 +93,27 @@ Severity = None run_health_checks = None +# The engine imports above are all-or-nothing: on failure every engine symbol is +# None. The metadata-only subcommands (baseline-info/baseline-depth/validate-base), +# concat, and best-effort health run fine without the engine; the analysis +# subcommands do NOT. Guard those so a missing OR too-old engine fails with a +# clear, actionable message instead of a cryptic "'NoneType' object is not +# callable" deep in the run. "Too old" is the live case: an engine that predates +# Core #401 has no RunPaths/RunContext, so those symbols import as None here. +_ENGINE_COMMANDS = ("base", "seed", "head", "analyze", "render") + + +def _require_engine(cmd: str) -> None: + if RunPaths is not None: + return + raise RuntimeError( + f"The '{cmd}' subcommand needs the CodeBoarding analysis engine, but the installed " + "'codeboarding' package is missing or too old: it does not export the git-free " + "content-versioning API (RunPaths/RunContext, added in Core #401). Pin the action's " + "codeboarding_version input to a release that includes it." + ) + + # A committed analysis.json's commit_hash is trusted only as far as a SHA shape: # it flows into GITHUB_OUTPUT, cache keys, and git refs, so anything else must # be rejected before it reaches the action shell. @@ -633,6 +654,8 @@ def main(argv=None) -> int: args = p.parse_args(argv) source = "sync" if args.cmd in ("analyze", "render", "concat") else "github_action" os.environ.setdefault("CODEBOARDING_SOURCE", source) + if args.cmd in _ENGINE_COMMANDS: + _require_engine(args.cmd) try: if args.cmd == "base": run_base(args.repo, args.out, args.name, args.run_id, args.depth, args.source_sha) diff --git a/tests/test_engine_adapter.py b/tests/test_engine_adapter.py index 5de6934..2fd0372 100644 --- a/tests/test_engine_adapter.py +++ b/tests/test_engine_adapter.py @@ -849,5 +849,38 @@ def test_main_no_sentinel_on_other_error(self): self.assertFalse(sentinel.exists(), "non-quota errors must not write the sentinel") +class TestEngineRequired(_Base): + """A missing/too-old engine (RunPaths imported as None) fails the analysis + subcommands with a clear message, while metadata-only subcommands still run.""" + + def _argv(self, cmd): + run = ["--repo", "/r", "--out", "/o", "--name", "n", "--run-id", "id", "--source-sha", "s"] + return { + "base": [cmd, *run, "--depth", "2"], + "seed": [cmd, "--repo", "/r", "--out", "/o", "--source-sha", "s"], + "head": [cmd, *run, "--depth", "2"], + "analyze": [cmd, *run, "--depth", "2"], + "render": [cmd, "--analysis", "/a.json", "--out", "/o", "--repo-name", "n", "--repo-ref", "r"], + }[cmd] + + def test_engine_commands_fail_clearly_when_engine_missing(self): + for cmd in engine_adapter._ENGINE_COMMANDS: + with self.subTest(cmd=cmd), patch.object(engine_adapter, "RunPaths", None): + with self.assertRaises(RuntimeError) as ctx: + engine_adapter.main(self._argv(cmd)) + msg = str(ctx.exception) + self.assertIn(cmd, msg) + self.assertIn("too old", msg) + self.assertIn("codeboarding_version", msg) + + def test_metadata_command_runs_without_engine(self): + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "analysis.json" + path.write_text(json.dumps({"metadata": {"commit_hash": "abc1234"}})) + with patch.object(engine_adapter, "RunPaths", None), redirect_stdout(StringIO()): + rc = engine_adapter.main(["baseline-info", "--analysis", str(path)]) + self.assertEqual(rc, 0) + + if __name__ == "__main__": unittest.main() From a6b2350ab87c51b10b2e2b9b94e10bbd74461498 Mon Sep 17 00:00:00 2001 From: Svilen Stefanov Date: Mon, 13 Jul 2026 13:22:43 +0200 Subject: [PATCH 3/4] ci: pin dogfood review to Core main to test unreleased #401 API TEMPORARY, revert before merge. This PR targets Core's git-free API (#401), which is on Core main but not yet on PyPI, so the pinned codeboarding_version default (0.12.5) can't exercise it. Install the engine from Core main in the dogfood review job so this PR validates the migration end to end. Drop this before merge so the action ships a released version, never a git ref. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/codeboarding.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/codeboarding.yml b/.github/workflows/codeboarding.yml index 40b61d7..fbe569f 100644 --- a/.github/workflows/codeboarding.yml +++ b/.github/workflows/codeboarding.yml @@ -116,3 +116,10 @@ jobs: with: github_token: ${{ steps.codeboarding-app-token-client.outputs.token || steps.codeboarding-app-token-app.outputs.token || github.token }} llm_api_key: ${{ secrets.OPENROUTER_API_KEY }} + # TEMPORARY β€” revert before merge. This PR targets Core's git-free API + # (#401), which is on Core `main` but not yet on PyPI. Install the engine + # from `main` so this dogfood review job validates the migration end to + # end against the unreleased API. Before merging, DELETE this line so the + # action installs the released `codeboarding_version` default β€” never + # ship a git ref to users. + codeboarding_version: ' @ git+https://github.com/CodeBoarding/CodeBoarding@main' From 652406eedeb3d8ea3d08bd21b9fbfdca91ba4bbe Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Mon, 13 Jul 2026 21:08:47 +0200 Subject: [PATCH 4/4] fix: rebuild incompatible analysis baselines --- .github/workflows/codeboarding.yml | 7 -- README.md | 6 +- action.yml | 16 ++--- scripts/engine_adapter.py | 104 +++++++++++++++++++++++------ tests/test_engine_adapter.py | 93 ++++++++++++++++++++++++-- tests/test_sync_subcommands.py | 39 +++++++++++ 6 files changed, 219 insertions(+), 46 deletions(-) diff --git a/.github/workflows/codeboarding.yml b/.github/workflows/codeboarding.yml index fbe569f..40b61d7 100644 --- a/.github/workflows/codeboarding.yml +++ b/.github/workflows/codeboarding.yml @@ -116,10 +116,3 @@ jobs: with: github_token: ${{ steps.codeboarding-app-token-client.outputs.token || steps.codeboarding-app-token-app.outputs.token || github.token }} llm_api_key: ${{ secrets.OPENROUTER_API_KEY }} - # TEMPORARY β€” revert before merge. This PR targets Core's git-free API - # (#401), which is on Core `main` but not yet on PyPI. Install the engine - # from `main` so this dogfood review job validates the migration end to - # end against the unreleased API. Before merging, DELETE this line so the - # action installs the released `codeboarding_version` default β€” never - # ship a git ref to users. - codeboarding_version: ' @ git+https://github.com/CodeBoarding/CodeBoarding@main' diff --git a/README.md b/README.md index e010119..9a92def 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ jobs: Behavior worth knowing: -- The first run on a branch is a full analysis at depth 2 by default; subsequent runs reuse the committed baseline and run incrementally when they can (the `analysis_mode` output tells you which happened). Once an `analysis.json` exists, its recorded `metadata.depth_level` is preserved for incremental runs and fallback-full recovery. +- The first run on a branch is a full analysis at depth 2 by default; subsequent runs reuse the committed baseline and run incrementally when they can (the `analysis_mode` output tells you which happened). Once an `analysis.json` exists, its recorded `metadata.depth_level` is preserved for incremental runs and fallback-full recovery. Baselines in a format the installed engine can no longer loadβ€”including the pre-0.13.0 formatβ€”are rebuilt automatically with a full analysis at that preserved depth. - The commit is skipped when nothing meaningful changed (an empty diff, or only `generated_at`/timestamp fields). The push retries a few times with fetch+rebase and fails open, so a race with another push never fails your CI. - Tag pushes are skipped. `pull_request` events soft-skip in sync mode, so a mistakenly shared workflow can never push docs from a PR run. - The bot commit carries **no `[skip ci]`** β€” on a squash-merge that marker leaks into the merge commit and would skip the very sync run (and release tooling, CI) the merge should trigger. The regen loop is instead prevented by the `paths-ignore` list above **and** by the action skipping re-analysis of its own bot commit, so a merge to `main` reliably triggers a fresh incremental sync. @@ -264,7 +264,7 @@ Review mode does not need `contents: write`: PR-specific generated files are sto | `mode` | both | `review` | `review` posts the PR architecture-diff comment; `sync` analyzes on push and commits the architecture (`analysis.json` + rendered docs) to `target_branch`, keeping it versioned and current. | | `github_token` | both | `${{ github.token }}` | Token for GitHub API calls; in review mode it posts or updates the PR comment. | | `push_token` | sync | `${{ github.token }}` | Token used for sync-mode pushes to `target_branch`. The workflow token can push when the workflow grants `permissions: contents: write`. Separate from `github_token` so commenting can use a GitHub App token while the push uses the workflow token. | -| `codeboarding_version` | both | `0.12.5` | CodeBoarding PyPI package version used as the analysis engine. Pin for reproducibility. | +| `codeboarding_version` | both | `0.13.0` | CodeBoarding PyPI package version used as the analysis engine. Pin for reproducibility. | | `depth_level` | both | empty (`2` for cold starts) | Analysis depth for first analysis and `force_full` rebuilds. Max depends on tier: **3** on the free hosted tier, **10** with a CodeBoarding license or your own `llm_api_key`. Once `.codeboarding/analysis.json` exists, its `metadata.depth_level` is the source of truth: sync runs incremental at the baseline depth, and review analyzes the PR head at the committed baseline depth so the diff is apples-to-apples (clamped to the tier max). | | `render_depth` | review | `1` | Display depth for the PR diagram. Keep `1` for a clean top-level view. | | `diagram_direction` | review | `LR` | Mermaid direction: `LR`, `TD`, `TB`, `RL`, or `BT`. | @@ -315,7 +315,7 @@ Full local pipeline: ```bash export OPENROUTER_API_KEY=sk-or-... -python -m pip install codeboarding==0.12.5 +python -m pip install codeboarding==0.13.0 codeboarding-setup --auto-install-npm scripts/run_local.sh --repo /path/to/repo --base --head ``` diff --git a/action.yml b/action.yml index 084d1df..b78be32 100644 --- a/action.yml +++ b/action.yml @@ -34,7 +34,7 @@ inputs: codeboarding_version: description: 'CodeBoarding PyPI package version used as the analysis engine. Pin for reproducibility; set to a newer released version to opt into newer engine releases.' required: false - default: '0.12.5' + default: '0.13.0' depth_level: description: 'Analysis depth for cold-start or force_full rebuilds. Max depends on tier: 3 on the free hosted tier, 10 with a CodeBoarding license or your own llm_api_key. Once .codeboarding/analysis.json exists, its metadata.depth_level is the source of truth: sync runs incremental at the baseline depth, and review analyzes the PR head at the committed baseline depth so the diff is apples-to-apples (clamped to the tier max). Empty (default): 2 for cold starts.' required: false @@ -794,14 +794,6 @@ runs: echo "Using committed .codeboarding/analysis.json at target branch commit ${BASE_SHA}." else rm -f "${BASE_DIR}/analysis.json" - if [ "$FORK_COMPARE" = "true" ]; then - printf '%s\n' '{"metadata": {"baseline": "empty"}, "components": [], "components_relations": []}' > "${BASE_DIR}/analysis.json" - echo "committed=false" >> $GITHUB_OUTPUT - echo "empty_base=true" >> $GITHUB_OUTPUT - echo "baseline_sha=empty" >> $GITHUB_OUTPUT - echo "Fork comparison branch baseline at ${BASE_SHA} is unusable; using an empty baseline so PR architecture renders as new." - exit 0 - fi echo "committed=false" >> $GITHUB_OUTPUT echo "baseline_sha=$BASE_SHA" >> $GITHUB_OUTPUT echo "Committed baseline at target branch commit ${BASE_SHA} is unusable; will generate a fresh target analysis." @@ -1480,8 +1472,12 @@ runs: else rm -f "$OUTPUT_DIR/static_analysis.pkl" "$OUTPUT_DIR/static_analysis.sha" fi + # Core 0.13.0 no longer emits the legacy version sidecar. Remove a stale + # tracked copy while migrating the accompanying analysis.json format. if [ -f "$ANALYSIS_DIR/codeboarding_version.json" ]; then cp "$ANALYSIS_DIR/codeboarding_version.json" "$OUTPUT_DIR/codeboarding_version.json" + else + rm -f "$OUTPUT_DIR/codeboarding_version.json" fi if [ -f "$ANALYSIS_DIR/health/health_report.json" ]; then cp "$ANALYSIS_DIR/health/health_report.json" "$OUTPUT_DIR/health/health_report.json" @@ -1501,7 +1497,7 @@ runs: add_if_present_or_tracked "$OUTPUT_DIR/fingerprint.json" add_if_present_or_tracked "$OUTPUT_DIR/static_analysis.pkl" add_if_present_or_tracked "$OUTPUT_DIR/static_analysis.sha" - [ -f "$OUTPUT_DIR/codeboarding_version.json" ] && stage_paths+=("$OUTPUT_DIR/codeboarding_version.json") + add_if_present_or_tracked "$OUTPUT_DIR/codeboarding_version.json" [ -f "$OUTPUT_DIR/health/health_report.json" ] && stage_paths+=("$OUTPUT_DIR/health/health_report.json") if [ "$WRITE_ARCHITECTURE_MD" = "true" ]; then stage_paths+=("docs/development/architecture.md") diff --git a/scripts/engine_adapter.py b/scripts/engine_adapter.py index 585526c..32ef5cb 100644 --- a/scripts/engine_adapter.py +++ b/scripts/engine_adapter.py @@ -4,9 +4,10 @@ package installed by the action (``codeboarding_workflows`` etc.); this module just turns the action's shell steps into typed, tested calls into it. The engine imports are best-effort at module load, so this file imports fine without the -package present β€” the metadata-only subcommands (``baseline-info``, -``baseline-depth``, ``validate-base``) run with the stdlib alone, and the tests -stub the engine modules to assert we call the engine with the right args. +package present β€” the metadata-only subcommands (``baseline-info`` and +``baseline-depth``) run with the stdlib alone, while ``validate-base`` uses the +installed Core model to check schema compatibility. The tests stub the engine +modules to assert we call the engine with the right args. Subcommands (all paths/refs come in as argv, never interpolated into source): @@ -38,12 +39,14 @@ ``head`` (review) and ``analyze`` (sync) are the SAME incremental-or-full operation via the shared ``_incremental_or_full`` helper. Change detection is git-free: Core diffs the current checkout against the seeded ``fingerprint.json`` -sidecar, so neither passes a base/target ref. ``analyze`` is baseline-aware (full -when the committed analysis.json is missing or ``--force-full`` is set) and prints -``analysis_mode=full|incremental`` on stdout for the action to grep. Once an -analysis.json exists, its metadata.depth_level is the source of truth for -incremental and fallback-full depth; ``--depth`` is the cold-start/force-full -depth. +sidecar, so neither passes a base/target ref. The helper asks the installed +Core's ``UnifiedAnalysisJson`` model to validate the baseline and rebuilds with +a full analysis when the model cannot load it (including the pre-0.13.0 format). +``analyze`` is baseline-aware (full when the committed analysis.json is missing +or ``--force-full`` is set) and prints ``analysis_mode=full|incremental`` on +stdout for the action to grep. Once a compatible analysis.json exists, its +metadata.depth_level is the source of truth for incremental and fallback-full +depth; ``--depth`` is the cold-start/force-full depth. ``render`` writes per-component markdown with root name ``overview``; ``concat`` joins overview.md first plus the remaining *.md (sorted) into one architecture file. @@ -64,10 +67,9 @@ from pathlib import Path # The engine packages are imported best-effort so the metadata-only subcommands -# (``baseline-info``, ``baseline-depth``, ``validate-base``) run with the stdlib -# alone β€” they parse a committed analysis.json and never touch the engine. The -# action invokes them BEFORE the engine package is pip-installed (e.g. while -# resolving the review depth), so a hard import here would break that step. The +# (``baseline-info`` and ``baseline-depth``) run with the stdlib alone β€” they +# parse a committed analysis.json and never touch the engine. Keeping imports +# best-effort also gives analysis commands a clear error for an old package. The # analysis subcommands that DO need the engine fail loudly when these are None. try: from agents.content_hash import hash_repo_source_files @@ -93,19 +95,32 @@ Severity = None run_health_checks = None +try: + from diagram_analysis.analysis_json import UnifiedAnalysisJson +except Exception: # unavailable only for metadata commands run without Core installed + UnifiedAnalysisJson = None + # The engine imports above are all-or-nothing: on failure every engine symbol is -# None. The metadata-only subcommands (baseline-info/baseline-depth/validate-base), -# concat, and best-effort health run fine without the engine; the analysis +# None. The metadata-only subcommands (baseline-info/baseline-depth), concat, +# and best-effort health run fine without the engine; analysis and validation # subcommands do NOT. Guard those so a missing OR too-old engine fails with a # clear, actionable message instead of a cryptic "'NoneType' object is not # callable" deep in the run. "Too old" is the live case: an engine that predates # Core #401 has no RunPaths/RunContext, so those symbols import as None here. -_ENGINE_COMMANDS = ("base", "seed", "head", "analyze", "render") +_ENGINE_COMMANDS = ("base", "seed", "head", "validate-base", "analyze", "render") def _require_engine(cmd: str) -> None: - if RunPaths is not None: + if cmd == "validate-base" and UnifiedAnalysisJson is not None: return + if cmd != "validate-base" and RunPaths is not None: + return + if cmd == "validate-base": + raise RuntimeError( + "The 'validate-base' subcommand needs the CodeBoarding analysis engine, but the installed " + "'codeboarding' package is missing or too old: it does not export the unified analysis " + "model used to validate analysis.json. Pin the action's codeboarding_version input to 0.13.0 or newer." + ) raise RuntimeError( f"The '{cmd}' subcommand needs the CodeBoarding analysis engine, but the installed " "'codeboarding' package is missing or too old: it does not export the git-free " @@ -189,15 +204,20 @@ def _clear_dir(path: Path) -> None: child.unlink() -def _load_metadata(analysis_path: Path) -> dict | None: +def _load_analysis(analysis_path: Path) -> dict | None: try: data = json.loads(analysis_path.read_text(encoding="utf-8")) except FileNotFoundError: return None except (OSError, json.JSONDecodeError): return {} - if not isinstance(data, dict): - return {} + return data if isinstance(data, dict) else {} + + +def _load_metadata(analysis_path: Path) -> dict | None: + data = _load_analysis(analysis_path) + if data is None: + return None metadata = data.get("metadata") return metadata if isinstance(metadata, dict) else {} @@ -253,6 +273,29 @@ def _metadata_commit(metadata: dict) -> str: return value if isinstance(value, str) else "" +def _analysis_model_error(data: dict) -> str | None: + """Return an error when the installed Core model cannot load *data* losslessly. + + Pydantic models may accept an older document by ignoring unknown fields and + filling new defaults. A model round-trip catches that schema drift without + teaching the action about any particular Core field. + """ + if UnifiedAnalysisJson is None: + raise RuntimeError( + "Validating analysis.json compatibility requires the installed CodeBoarding engine model " + "(diagram_analysis.analysis_json.UnifiedAnalysisJson)." + ) + try: + model = UnifiedAnalysisJson.model_validate(data) + normalized = model.model_dump(mode="json", exclude_none=True) + except Exception as exc: + detail = str(exc).splitlines()[0] if str(exc) else type(exc).__name__ + return f"Installed CodeBoarding model could not load baseline analysis.json ({detail})" + if normalized != data: + return "Installed CodeBoarding model could not load baseline analysis.json without schema changes" + return None + + def baseline_info(analysis_path: Path) -> str: """Return the committed baseline's commit_hash when present and SHA-shaped, else "". Sync mode uses this (via the ``baseline-info`` subcommand) instead @@ -294,6 +337,11 @@ def validate_base_analysis( but the sync commit itself necessarily has a newer SHA because it adds the generated artifacts. Treat that metadata SHA as provenance, not freshness. + A baseline that the installed Core model cannot load is rejected so review + mode generates a fresh target analysis before running the PR-head + incremental analysis. This handles the 0.13.0 format break without + duplicating Core's schema or coupling the action to individual JSON fields. + When ``expected_depth`` is given, a baseline whose metadata.depth_level parses as an int and is DEEPER than expected is rejected (review mode regenerates instead of diffing against a deeper analysis). A shallower @@ -323,6 +371,10 @@ def validate_base_analysis( if not isinstance(metadata, dict): return False, "Baseline analysis metadata is missing." + model_error = _analysis_model_error(data) + if model_error: + return False, f"{model_error}; a full analysis is required." + actual_sha = metadata.get("commit_hash") if isinstance(actual_sha, str) and actual_sha: if actual_sha == expected_sha: @@ -404,13 +456,23 @@ def _incremental_or_full( exactly one place β€” a bug fixed here is fixed for both modes. """ run_paths, run_context = _run_ctx(repo_path, output_dir, repo_name, run_id, log_name) + out_path = Path(output_dir) + analysis = _load_analysis(out_path / "analysis.json") + model_error = "Baseline analysis.json is missing or unreadable" if not analysis else _analysis_model_error(analysis) + if model_error: + print(f"Incremental unavailable ({model_error}); running full analysis.") + fallback_depth = _analysis_depth_or_default(out_path, licensed, depth) + _clear_dir(out_path) + res = run_full(run_paths, run_context, depth_level=fallback_depth, source_sha=source_sha) + print(f"Analysis written: {res}") + return "full" + try: res = run_incremental(run_paths, run_context) print(f"Analysis written: {res}") return "incremental" except (IncrementalCacheMissingError, BaselineUnavailableError) as exc: print(f"Incremental unavailable ({exc}); running full analysis.") - out_path = Path(output_dir) fallback_depth = _analysis_depth_or_default(out_path, licensed, depth) _clear_dir(out_path) res = run_full(run_paths, run_context, depth_level=fallback_depth, source_sha=source_sha) diff --git a/tests/test_engine_adapter.py b/tests/test_engine_adapter.py index 2fd0372..d6a8763 100644 --- a/tests/test_engine_adapter.py +++ b/tests/test_engine_adapter.py @@ -55,6 +55,29 @@ def __init__(self, run_id=None, log_path=None, repo_dir=None): self.run_id, self.log_path, self.repo_dir = run_id, log_path, repo_dir +class _InitialUnifiedAnalysisJson: + def __init__(self, data): + self.data = data + + @classmethod + def model_validate(cls, data): + return cls(data) + + def model_dump(self, **kwargs): + return self.data + + +class _RejectingUnifiedAnalysisJson: + @classmethod + def model_validate(cls, data): + raise ValueError("incompatible analysis schema") + + +class _LossyUnifiedAnalysisJson(_InitialUnifiedAnalysisJson): + def model_dump(self, **kwargs): + return {"normalized": True} + + analysis = _preload( "codeboarding_workflows.analysis", run_full=lambda *a, **k: "OUT", @@ -66,6 +89,7 @@ def __init__(self, run_id=None, log_path=None, repo_dir=None): exc = _preload("diagram_analysis.exceptions", IncrementalCacheMissingError=_InitialIncrementalCacheMissingError) da = _preload("diagram_analysis", RunPaths=_RunPaths, RunContext=_RunContext) da.exceptions = exc +_preload("diagram_analysis.analysis_json", UnifiedAnalysisJson=_InitialUnifiedAnalysisJson) _preload("diagram_analysis.io_utils", write_fingerprint=lambda *a, **k: None) _preload("agents.content_hash", hash_repo_source_files=lambda *a, **k: {}) _preload("agents") @@ -86,6 +110,7 @@ def __init__(self, run_id=None, log_path=None, repo_dir=None): "codeboarding_workflows", "codeboarding_workflows.analysis", "diagram_analysis", + "diagram_analysis.analysis_json", "diagram_analysis.exceptions", "diagram_analysis.io_utils", "health", @@ -119,6 +144,15 @@ def _mod(name, **attrs): return m +def _write_model_valid_analysis(output_dir, **metadata): + path = Path(output_dir) + path.mkdir(parents=True, exist_ok=True) + (path / "analysis.json").write_text( + json.dumps({"metadata": metadata}), + encoding="utf-8", + ) + + class _Base(unittest.TestCase): def tearDown(self): for n in _STUBBED: @@ -260,15 +294,17 @@ def test_main_accepts_depth_four(self): def test_head_uses_incremental(self): ri, rf = _Rec(), _Rec() self._install(run_full=rf, run_incremental=ri) + out = tempfile.mkdtemp() + _write_model_valid_analysis(out, depth_level=1) buf = StringIO() with redirect_stdout(buf): - engine_adapter.run_head("/repo", "/out", "r", "rid", 1, "head") + engine_adapter.run_head("/repo", out, "r", "rid", 1, "head") self.assertEqual(len(ri.calls), 1) self.assertEqual(len(rf.calls), 0) # no fallback # Git-free: no base/target ref β€” Core diffs the seeded fingerprint itself. run_paths, run_context = ri.args[0] self.assertEqual(str(run_paths.repo_path), "/repo") - self.assertEqual(str(run_paths.output_dir), "/out") + self.assertEqual(str(run_paths.output_dir), out) self.assertEqual(run_context.run_id, "rid") self.assertIn("head_analysis_mode=incremental", buf.getvalue()) @@ -297,6 +333,7 @@ def test_head_falls_back_to_full_on_cache_miss(self): engine_adapter.run_full = analysis.run_full engine_adapter.run_incremental = analysis.run_incremental out = tempfile.mkdtemp() + _write_model_valid_analysis(out, depth_level=3) (Path(out) / "stale.json").write_text("{}") # must be wiped before the full run (Path(out) / "health").mkdir() (Path(out) / "health" / "stale.json").write_text("{}") @@ -316,9 +353,33 @@ def test_head_falls_back_to_full_on_baseline_unavailable(self): analysis.run_incremental = _Rec(raises=BaseUnavail) engine_adapter.run_full = analysis.run_full engine_adapter.run_incremental = analysis.run_incremental - engine_adapter.run_head("/repo", tempfile.mkdtemp(), "r", "rid", 1, "head") + out = tempfile.mkdtemp() + _write_model_valid_analysis(out, depth_level=1) + engine_adapter.run_head("/repo", out, "r", "rid", 1, "head") self.assertEqual(len(rf.calls), 1) # BaselineUnavailableError also triggers the full re-run + def test_head_rebuilds_analysis_rejected_by_core_model(self): + ri, rf = _Rec(), _Rec() + self._install(run_full=rf, run_incremental=ri) + out = Path(tempfile.mkdtemp()) + (out / "analysis.json").write_text( + json.dumps({"metadata": {"commit_hash": "abc123", "depth_level": 3}}), + encoding="utf-8", + ) + (out / "stale.json").write_text("{}", encoding="utf-8") + buf = StringIO() + + with patch.object(engine_adapter, "UnifiedAnalysisJson", _RejectingUnifiedAnalysisJson): + with redirect_stdout(buf): + engine_adapter.run_head("/repo", str(out), "r", "rid", 1, "head") + + self.assertEqual(len(ri.calls), 0) + self.assertEqual(len(rf.calls), 1) + self.assertEqual(rf.calls[0]["depth_level"], 3) + self.assertFalse((out / "stale.json").exists()) + self.assertIn("could not load baseline analysis.json", buf.getvalue()) + self.assertIn("head_analysis_mode=full", buf.getvalue()) + class TestValidateBase(_Base): def test_validate_base_accepts_matching_commit(self): @@ -425,6 +486,22 @@ def test_validate_base_accepts_missing_commit(self): self.assertTrue(ok) self.assertIn("commit_hash", message) + def test_validate_base_rejects_lossy_model_load(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "analysis.json" + path.write_text( + json.dumps({"metadata": {"commit_hash": "abc123", "depth_level": 2}}), + encoding="utf-8", + ) + + with patch.object(engine_adapter, "UnifiedAnalysisJson", _LossyUnifiedAnalysisJson): + ok, message = engine_adapter.validate_base_analysis(path, "abc123") + + self.assertFalse(ok) + self.assertIn("could not load baseline analysis.json", message) + self.assertIn("without schema changes", message) + self.assertIn("full analysis", message) + def test_main_validate_base_exit_codes(self): with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "analysis.json" @@ -518,7 +595,8 @@ def test_validate_base_depth_checked_on_drift_path(self): self.assertIn("deeper", message) def test_validate_base_accepts_legacy_baseline_without_depth(self): - # Missing or unparseable depth_level -> accept (baselines predate the field). + # Missing or unparseable depth_level remains acceptable when the + # installed Core model accepts the document. for metadata in ( {"commit_hash": "abc123"}, {"commit_hash": "abc123", "depth_level": "not-a-number"}, @@ -859,13 +937,18 @@ def _argv(self, cmd): "base": [cmd, *run, "--depth", "2"], "seed": [cmd, "--repo", "/r", "--out", "/o", "--source-sha", "s"], "head": [cmd, *run, "--depth", "2"], + "validate-base": [cmd, "--analysis", "/a.json", "--expected-sha", "abc123"], "analyze": [cmd, *run, "--depth", "2"], "render": [cmd, "--analysis", "/a.json", "--out", "/o", "--repo-name", "n", "--repo-ref", "r"], }[cmd] def test_engine_commands_fail_clearly_when_engine_missing(self): for cmd in engine_adapter._ENGINE_COMMANDS: - with self.subTest(cmd=cmd), patch.object(engine_adapter, "RunPaths", None): + with ( + self.subTest(cmd=cmd), + patch.object(engine_adapter, "RunPaths", None), + patch.object(engine_adapter, "UnifiedAnalysisJson", None), + ): with self.assertRaises(RuntimeError) as ctx: engine_adapter.main(self._argv(cmd)) msg = str(ctx.exception) diff --git a/tests/test_sync_subcommands.py b/tests/test_sync_subcommands.py index f6a4f1b..6b878c7 100644 --- a/tests/test_sync_subcommands.py +++ b/tests/test_sync_subcommands.py @@ -57,6 +57,23 @@ def __init__(self, run_id=None, log_path=None, repo_dir=None): self.run_id, self.log_path, self.repo_dir = run_id, log_path, repo_dir +class _InitialUnifiedAnalysisJson: + def __init__(self, data): + self.data = data + + @classmethod + def model_validate(cls, data): + return cls(data) + + def model_dump(self, **kwargs): + return self.data + + +class _LossyUnifiedAnalysisJson(_InitialUnifiedAnalysisJson): + def model_dump(self, **kwargs): + return {"normalized": True} + + analysis = _preload( "codeboarding_workflows.analysis", run_full=lambda *a, **k: "OUT", @@ -70,6 +87,7 @@ def __init__(self, run_id=None, log_path=None, repo_dir=None): exc = _preload("diagram_analysis.exceptions", IncrementalCacheMissingError=_InitialIncrementalCacheMissingError) da = _preload("diagram_analysis", RunPaths=_RunPaths, RunContext=_RunContext) da.exceptions = exc +_preload("diagram_analysis.analysis_json", UnifiedAnalysisJson=_InitialUnifiedAnalysisJson) _preload("diagram_analysis.io_utils", write_fingerprint=lambda *a, **k: None) _preload("agents.content_hash", hash_repo_source_files=lambda *a, **k: {}) _preload("agents") @@ -90,6 +108,7 @@ def __init__(self, run_id=None, log_path=None, repo_dir=None): "codeboarding_workflows.analysis", "codeboarding_workflows.rendering", "diagram_analysis", + "diagram_analysis.analysis_json", "diagram_analysis.exceptions", "diagram_analysis.io_utils", "static_analyzer", @@ -192,6 +211,26 @@ def test_committed_baseline_runs_incremental(self): self.assertEqual(str(run_paths.repo_path), "/repo") self.assertEqual(run_context.run_id, "rid") + def test_incompatible_baseline_runs_full_at_baseline_depth(self): + rf, ri = _Rec(), _Rec() + self._install(run_full=rf, run_incremental=ri) + out = Path(tempfile.mkdtemp()) + _write_analysis(out, depth=3) + (out / "stale.json").write_text("{}", encoding="utf-8") + buf = StringIO() + + with patch.object(engine_adapter, "UnifiedAnalysisJson", _LossyUnifiedAnalysisJson): + with redirect_stdout(buf): + mode = engine_adapter.run_analyze("/repo", str(out), "myrepo", "rid", "head123", 1) + + self.assertEqual(mode, "full") + self.assertEqual(len(ri.calls), 0) + self.assertEqual(len(rf.calls), 1) + self.assertEqual(rf.calls[0][1]["depth_level"], 3) + self.assertFalse((out / "stale.json").exists()) + self.assertIn("could not load baseline analysis.json", buf.getvalue()) + self.assertEqual(self._markers(buf), ["analysis_mode=full"]) + def test_deeper_baseline_still_runs_incremental(self): rf, ri = _Rec(), _Rec() self._install(run_full=rf, run_incremental=ri)