diff --git a/README.md b/README.md index 78d7081..1d01f82 100644 --- a/README.md +++ b/README.md @@ -304,13 +304,36 @@ With the default `github.token`, the repository or organization must allow GitHu | `model` | both | empty | Default model for both analysis and parsing. | | `agent_model` | both | empty | Analysis-only override for `model`. | | `parsing_model` | both | empty | Parsing-only override for `model`. | +| `depth_cap` | both | `2` | Positive integer maximum analysis depth, including full-analysis fallbacks. Changing it rebuilds incompatible state. | | `github_token` | both | `${{ github.token }}` | Token for comments and sync delivery. | | `sync_strategy` | sync | `push` | `push` or `pull_request`. | | `target_branch` | sync | event branch | Branch receiving the baseline or rolling PR. | | `force_full` | sync | `false` | Ignore the committed baseline for this run. | | `warmstart_retention_days` | review | `1` | Days to keep the reusable analysis. Only the next run reads it. | -The `/codeboarding` command, comment heading, Mermaid direction (`LR`), hosted webview URL, rolling sync branch, commit message, and CodeBoarding 0.14.0 version are intentionally fixed rather than exposed as configuration. +The `/codeboarding` command, comment heading, Mermaid direction (`LR`), hosted webview URL, rolling sync branch, commit message, and CodeBoarding 0.14.1 version are intentionally fixed rather than exposed as configuration. + +Review mode needs no sync workflow or committed `.codeboarding` directory. If no +usable merge-base analysis exists, it runs full analysis there directly, then +seeds an incremental analysis of the PR head and publishes both states. Later +runs prefer compatible prior PR state for incremental updates, while the review +still compares the merge base with the current head. + +Set `depth_cap` in the action's `with:` block (for example, `depth_cap: 4`). +This configuration is authoritative: stored `metadata.depth_cap` is checked for +compatibility, not inherited, and legacy `metadata.depth_level` is not used as a +fallback. Missing or incompatible baseline depth triggers a rebuild. + +`metadata.depth_cap` records the configured maximum; `metadata.depth_level` +records the depth actually reached, which can be shallower. Comparing the cap +avoids rejecting valid state or reducing future rebuild depth when a run stops +early. The action input matches the metadata name and the engine receives only +`--depth-cap`. There are no old-name input aliases. Historical workflows using +the removed `depth_level` action input must switch to `depth_cap`. + +This action pins Core 0.14.1 for the `--depth-cap` CLI contract. Publish that Core +release before releasing the action. Earlier eShop evidence predates this final +breaking CLI migration. ## Outputs @@ -347,7 +370,7 @@ Run the local analysis pipeline: ```bash export OPENROUTER_API_KEY=sk-or-... -python -m pip install codeboarding==0.14.0 +python -m pip install codeboarding==0.14.1 tests/run_local.sh --repo /path/to/repo --base main --head feature ``` diff --git a/action.yml b/action.yml index cf4f57b..2d815f7 100644 --- a/action.yml +++ b/action.yml @@ -125,6 +125,10 @@ inputs: description: 'Optional parsing-model override. Takes precedence over model.' required: false default: '' + depth_cap: + description: 'Positive integer analysis depth cap, used for both review and sync, including full fallbacks.' + required: false + default: '2' github_token: description: 'Token used for comments and sync delivery.' required: false @@ -353,7 +357,7 @@ runs: if: steps.guard.outputs.skip != 'true' shell: bash run: | - python -m pip install --disable-pip-version-check 'codeboarding==0.14.0' + python -m pip install --disable-pip-version-check 'codeboarding==0.14.1' # Fail here, with the reason, rather than mid-analysis with a traceback: # pinning a release does not pin what its dependencies resolve to. Run # the console script, not `python -c`, which would put the analyzed @@ -382,6 +386,7 @@ runs: IS_FORK: ${{ steps.guard.outputs.is_fork }} LLM_PROVIDER: ${{ steps.llm.outputs.provider }} BACKEND_ID: ${{ steps.llm.outputs.backend_id }} + DEPTH_CAP: ${{ inputs.depth_cap }} MODEL: ${{ inputs.model }} AGENT_MODEL_INPUT: ${{ inputs.agent_model }} PARSING_MODEL_INPUT: ${{ inputs.parsing_model }} @@ -430,6 +435,7 @@ runs: CHECKOUT_DIR: ${{ github.workspace }}/.codeboarding-target STAGE_DIR: ${{ runner.temp }}/cb-state/${{ github.action }}/out FORCE_FULL: ${{ inputs.force_full }} + DEPTH_CAP: ${{ inputs.depth_cap }} MODEL: ${{ inputs.model }} AGENT_MODEL_INPUT: ${{ inputs.agent_model }} PARSING_MODEL_INPUT: ${{ inputs.parsing_model }} @@ -521,6 +527,7 @@ runs: CFG_HASH: ${{ steps.state.outputs.cfg_hash }} GIT_TOKEN: ${{ inputs.github_token }} GITHUB_SERVER_URL: ${{ github.server_url }} + DEPTH_CAP: ${{ inputs.depth_cap }} MODEL: ${{ inputs.model }} AGENT_MODEL_INPUT: ${{ inputs.agent_model }} PARSING_MODEL_INPUT: ${{ inputs.parsing_model }} diff --git a/docs/COMMIT_STRATEGY.md b/docs/COMMIT_STRATEGY.md index d1a9106..2849dfb 100644 --- a/docs/COMMIT_STRATEGY.md +++ b/docs/COMMIT_STRATEGY.md @@ -99,13 +99,16 @@ them: | Source | Engine cost | |---|---| -| the published `codeboarding-base--` artifact | none | -| no artifact — check out the merge base, seed from the baseline committed there, catch up | one incremental | -| no committed baseline either | full analysis | +| the published `codeboarding-base--` artifact with a compatible depth cap | none | +| no usable artifact — check out the merge base, seed from a compatible baseline committed there, catch up | one incremental, full if Core requires it | +| no compatible committed baseline either | full analysis directly, at the configured `depth_cap` | A trusted run that computed the base publishes it, so the next pull request forking from that commit gets the first row. +The configuration hash includes `depth_cap`. The workflow input controls depth +for both fresh and fallback analyses; stored legacy depth values never override it. + **Head**, first match wins: | Source | Covers | diff --git a/scripts/action/analyze.sh b/scripts/action/analyze.sh index d8e432e..5f246a0 100755 --- a/scripts/action/analyze.sh +++ b/scripts/action/analyze.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash # Runs incremental/full Core analysis and outputs the selected analysis paths and mode. set -euo pipefail +DEPTH_CAP="${DEPTH_CAP:-2}" +if [[ ! "$DEPTH_CAP" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::depth_cap must be a positive integer." + exit 1 +fi parse_output() { local output="$1" ANALYSIS_MODE="$(awk -F= '$1 == "analysis_mode" {print $2; exit}' <<< "$output")" @@ -20,23 +25,21 @@ incremental() { full() { local checkout="$1" output_dir="$2" depth="$3" output output="$(python3 "$ACTION_PATH/scripts/analyze_repository.py" full \ - --checkout "$checkout" --output-dir "$output_dir" --depth-level "$depth")" + --checkout "$checkout" --output-dir "$output_dir" --depth-cap "$depth")" parse_output "$output" if [ "$ANALYSIS_MODE" != full ] || [ ! -f "$ANALYSIS_PATH" ]; then echo "::error::Invalid full-analysis result." exit 1 fi } -# Core resolves depth from depth_cap, falling back to depth_level for baselines -# predating it. Use the same value everywhere: a run that stopped short of its -# cap must not be read as a scope change, and rebuilding at the realized depth -# would ratchet the configured depth down every time a full run happens. +# Metadata is only a compatibility check, never the source of configuration. +# Legacy baselines without a configured cap are rebuilt at the requested depth. depth_cap_from() { local analysis="$1" [ -f "$analysis" ] || return 0 python3 -c 'import json,sys metadata = json.load(open(sys.argv[1])).get("metadata", {}) -print(metadata.get("depth_cap", metadata.get("depth_level", "")))' "$analysis" 2>/dev/null || true +print(metadata.get("depth_cap", ""))' "$analysis" 2>/dev/null || true } seed_state() { @@ -120,16 +123,13 @@ analyze_sync() { local work="$RUNNER_TEMP/codeboarding-sync" state="$RUNNER_TEMP/codeboarding-sync/analysis" rm -rf "$work" seed_state "$CHECKOUT_DIR" "$state" - local depth - depth="$(depth_cap_from "$state/analysis.json")" - depth="${depth:-2}" - if [ "${FORCE_FULL,,}" = true ]; then - full "$CHECKOUT_DIR" "$state" "$depth" + if [ "${FORCE_FULL,,}" = true ] || [ "$(depth_cap_from "$state/analysis.json")" != "$DEPTH_CAP" ]; then + full "$CHECKOUT_DIR" "$state" "$DEPTH_CAP" else incremental "$CHECKOUT_DIR" "$state" if [ "$REQUIRES_FULL" = true ]; then - full "$CHECKOUT_DIR" "$state" "$depth" + full "$CHECKOUT_DIR" "$state" "$DEPTH_CAP" fi fi # Sync already computes the graph every review of this branch compares against, @@ -148,14 +148,12 @@ fetch_commit() { "${GITHUB_SERVER_URL%/}/${repository}.git" "$sha" --depth=1 } -# The artifact name pins the engine, the analysis scope and the models. Depth and -# lineage are read from the bundle itself, so they are checked here. +# The artifact name pins configuration; verify the stored cap and lineage too. warmstart_usable() { - local base_analysis="$1" bundle_cap base_cap + local base_analysis="$1" bundle_cap [ -f "${WARMSTART_DIR:-}/analysis.json" ] || return 1 bundle_cap="$(depth_cap_from "$WARMSTART_DIR/analysis.json")" - base_cap="$(depth_cap_from "$base_analysis")" - if [ -n "$bundle_cap" ] && [ -n "$base_cap" ] && [ "$bundle_cap" != "$base_cap" ]; then + if [ "$bundle_cap" != "$DEPTH_CAP" ]; then echo "::notice::Analysis depth changed since the last run; re-seeding from the base analysis." return 1 fi @@ -178,7 +176,7 @@ analyze_review() { # needs no engine run at all. Without one, the merge base is checked out and # analyzed from whatever baseline the repository committed there. local base_source=published - if [ -f "${BASE_DIR:-}/analysis.json" ]; then + if [ "$(depth_cap_from "${BASE_DIR:-}/analysis.json")" = "$DEPTH_CAP" ]; then mkdir -p "$base_state" cp -a "$BASE_DIR/." "$base_state/" else @@ -186,20 +184,18 @@ analyze_review() { fetch_commit "$REVIEW_BASE_REPO" "$REVIEW_BASE_SHA" git -C "$CHECKOUT_DIR" worktree add --detach "$base_checkout" "$REVIEW_BASE_SHA" >/dev/null seed_state "$base_checkout" "$base_state" - local base_depth - base_depth="$(depth_cap_from "$base_state/analysis.json")" - incremental "$base_checkout" "$base_state" + REQUIRES_FULL=true + if [ "$(depth_cap_from "$base_state/analysis.json")" = "$DEPTH_CAP" ]; then + incremental "$base_checkout" "$base_state" + fi if [ "$REQUIRES_FULL" = true ]; then - full "$base_checkout" "$base_state" "${base_depth:-2}" + full "$base_checkout" "$base_state" "$DEPTH_CAP" fi fi unset GIT_TOKEN local base_analysis="$base_state/analysis.json" [ -f "$base_analysis" ] || { echo "::error::Review baseline analysis is missing."; exit 1; } - local depth - depth="$(depth_cap_from "$base_analysis")" - depth="${depth:-2}" # Seed the head from this pull request's own last analysis when there is one, # so the run only covers commits pushed since it. @@ -219,7 +215,7 @@ analyze_review() { incremental "$CHECKOUT_DIR" "$head_state" if [ "$REQUIRES_FULL" = true ]; then - full "$CHECKOUT_DIR" "$head_state" "$depth" + full "$CHECKOUT_DIR" "$head_state" "$DEPTH_CAP" fi write_origin "$head_state" "$seed_source" "$chain_depth" "$(analysis_digest "$base_analysis")" diff --git a/scripts/action/state-names.sh b/scripts/action/state-names.sh index e91f3a0..3c7dec9 100755 --- a/scripts/action/state-names.sh +++ b/scripts/action/state-names.sh @@ -35,8 +35,8 @@ ignore_file="$CHECKOUT_DIR/.codeboarding/.codeboardingignore" # does. It carries no key: rotating a secret must not throw away reusable analysis. model_digest="$(printf '%s\n%s\n%s\n%s\n%s\n' \ "${LLM_PROVIDER:-}" "${BACKEND_ID:-}" "${MODEL:-}" "${AGENT_MODEL_INPUT:-}" "${PARSING_MODEL_INPUT:-}" | digest)" -cfg="$(printf '%s\n%s\n%s\n%s\n' \ - "$STATE_SCHEMA" "$engine_version" "$ignore_digest" "$model_digest" | digest)" +cfg="$(printf '%s\n%s\n%s\n%s\n%s\n' \ + "$STATE_SCHEMA" "$engine_version" "$ignore_digest" "$model_digest" "${DEPTH_CAP:-2}" | digest)" { echo "engine_version=$engine_version" diff --git a/scripts/action/supported-providers.json b/scripts/action/supported-providers.json index abb7c78..dc343ff 100644 --- a/scripts/action/supported-providers.json +++ b/scripts/action/supported-providers.json @@ -18,7 +18,7 @@ "a selection env (AWS_DEFAULT_REGION, OLLAMA_API_KEY) therefore cannot select a provider", "on its own, which is why ollama and litellm need their base URL and not just a key." ], - "engine": "0.14.0", + "engine": "0.14.1", "hosted_provider": "openrouter", "providers": { "openrouter": { diff --git a/scripts/analyze_repository.py b/scripts/analyze_repository.py index 6dab352..aba775a 100755 --- a/scripts/analyze_repository.py +++ b/scripts/analyze_repository.py @@ -95,7 +95,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("mode", choices=["incremental", "full"], help="Which CLI command to invoke") parser.add_argument("--checkout", required=True, help="Path to repository checkout") parser.add_argument("--output-dir", required=True, help="Action-owned output directory") - parser.add_argument("--depth-level", help="Depth passed to full analyses") + parser.add_argument("--depth-cap", help="Maximum hierarchy depth allowed for full analyses") args = parser.parse_args(argv) checkout = Path(args.checkout) @@ -112,8 +112,8 @@ def main(argv: list[str] | None = None) -> int: print(f"analysis_path={analysis_path or ''}") return 0 - if not args.depth_level: - raise SystemExit("--depth-level is required for mode=full") + if not args.depth_cap: + raise SystemExit("--depth-cap is required for mode=full") shutil.rmtree(output_dir) output_dir.mkdir(parents=True) command = [ @@ -123,8 +123,8 @@ def main(argv: list[str] | None = None) -> int: str(checkout), "--output-dir", str(output_dir), - "--depth-level", - args.depth_level, + "--depth-cap", + args.depth_cap, "--force", ] _run_command(command, output_dir) diff --git a/tests/run_local.sh b/tests/run_local.sh index 601a524..8234a19 100755 --- a/tests/run_local.sh +++ b/tests/run_local.sh @@ -7,7 +7,7 @@ set -euo pipefail # 2) REVIEW LOCAL (full local pipeline): # tests/run_local.sh --repo /path/to/repo --base --head # 3) REVIEW LOCAL against committed baseline only (if available): -# tests/run_local.sh --repo /path/to/repo --base --head --depth 2 +# tests/run_local.sh --repo /path/to/repo --base --head --depth-cap 2 # # Output: # diagram.md Mermaid payload posted by the action @@ -29,7 +29,7 @@ while [ $# -gt 0 ]; do --base-json) BASE_JSON="$2"; shift 2;; --head-json) HEAD_JSON="$2"; shift 2;; --out) OUT="$2"; shift 2;; - --depth) DEPTH="$2"; shift 2;; + --depth-cap) DEPTH="$2"; shift 2;; --direction) DIRECTION="$2"; shift 2;; --no-open) OPEN="no"; shift;; -h|--help) @@ -67,7 +67,7 @@ run_full() { python3 "$ACTION_DIR/scripts/analyze_repository.py" full \ --checkout "$checkout" \ --output-dir "$out_dir" \ - --depth-level "$DEPTH" + --depth-cap "$DEPTH" } if [ -n "$BASE_JSON" ] && [ -n "$HEAD_JSON" ]; then @@ -111,14 +111,10 @@ else BASELINE_DEPTH="" if [ -f "$BASE_DIR/.codeboarding/analysis.json" ]; then - BASELINE_DEPTH="$(python3 -c 'import json, sys; data = json.load(open(sys.argv[1])); print(data.get("metadata", {}).get("depth_level", ""))' "$BASE_DIR/.codeboarding/analysis.json" 2>/dev/null || true)" + BASELINE_DEPTH="$(python3 -c 'import json, sys; data = json.load(open(sys.argv[1])); print(data.get("metadata", {}).get("depth_cap", ""))' "$BASE_DIR/.codeboarding/analysis.json" 2>/dev/null || true)" fi - if [ -n "$BASELINE_DEPTH" ] && [[ "$BASELINE_DEPTH" =~ ^[0-9]+$ ]]; then - DEPTH="$BASELINE_DEPTH" - fi - - if [ -f "$BASE_DIR/.codeboarding/analysis.json" ]; then + if [ "$BASELINE_DEPTH" = "$DEPTH" ]; then cp -a "$BASE_DIR/.codeboarding/." "$BASE_STATE/" BASE_OUTPUT="$(run_inc "$BASE_DIR" "$BASE_STATE")" BASE_MODE="$(parse_value analysis_mode "$BASE_OUTPUT")" diff --git a/tests/test_action_inputs.py b/tests/test_action_inputs.py index ec5d246..eb7f21d 100644 --- a/tests/test_action_inputs.py +++ b/tests/test_action_inputs.py @@ -62,6 +62,14 @@ def test_llm_is_required_and_has_no_default(self) -> None: self.assertIn("required: true", block) self.assertNotIn("default:", block) + def test_depth_is_wired_to_state_identity_and_both_analysis_modes(self) -> None: + self.assertIn("default: '2'", self.inputs["depth_cap"]) + self.assertNotIn("depth_level", self.inputs) + for identifier in ("id: state", "id: sync_analyze", "id: review_analyze"): + start = ACTION.index(identifier) + block = ACTION[start : ACTION.index("\n run:", start)] + self.assertIn("DEPTH_CAP: ${{ inputs.depth_cap }}", block) + def test_the_inferred_credential_inputs_are_gone(self) -> None: """`llm_api_key`/`llm_provider` are what made a fallback expressible at all.""" for stale in ("llm_api_key", "llm_provider"): diff --git a/tests/test_action_state.py b/tests/test_action_state.py index e5060fe..7b5a8aa 100644 --- a/tests/test_action_state.py +++ b/tests/test_action_state.py @@ -25,11 +25,17 @@ log.write(json.dumps({ "mode": argv[0], "checkout": argv[argv.index("--local") + 1], - "depth": argv[argv.index("--depth-level") + 1] if "--depth-level" in argv else None, + "depth": argv[argv.index("--depth-cap") + 1] if "--depth-cap" in argv else None, }) + "\\n") analysis = os.path.join(output, "analysis.json") +metadata = json.load(open(analysis))["metadata"] if os.path.isfile(analysis) else {} +if argv[0] == "incremental" and os.environ.get("CB_REQUIRE_FULL") == "true": + print(json.dumps({"requiresFullAnalysis": True})) + sys.exit(0) +if argv[0] == "full": + metadata = {"depth_cap": int(argv[argv.index("--depth-cap") + 1])} with open(analysis, "w") as handle: - json.dump({"metadata": {"depth_level": 2}, "components": [], "components_relations": []}, handle) + json.dump({"metadata": metadata, "components": [], "components_relations": []}, handle) print(json.dumps({"requiresFullAnalysis": False, "analysis_path": analysis})) ''' @@ -41,7 +47,7 @@ def _digest(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest()[:16] -def _state(directory: Path, depth: int = 2, cap: int | None = None, **origin: object) -> Path: +def _state(directory: Path, depth: int = 2, cap: int | None = 2, **origin: object) -> Path: directory.mkdir(parents=True, exist_ok=True) metadata: dict[str, int] = {"depth_level": depth} if cap is not None: @@ -128,6 +134,7 @@ def test_analysis_scope_and_engine_version_change_the_identity(self) -> None: def test_model_selection_changes_the_identity(self) -> None: baseline = self._run() + self.assertNotEqual(baseline["cfg_hash"], self._run(DEPTH_CAP="4")["cfg_hash"]) self.assertNotEqual(baseline["cfg_hash"], self._run(MODEL="gpt-5")["cfg_hash"]) self.assertNotEqual(baseline["cfg_hash"], self._run(AGENT_MODEL_INPUT="gpt-5")["cfg_hash"]) self.assertNotEqual(baseline["cfg_hash"], self._run(PARSING_MODEL_INPUT="gpt-5")["cfg_hash"]) @@ -234,12 +241,93 @@ def test_a_published_base_without_a_stored_head_seeds_from_the_base(self) -> Non def test_depth_change_discards_the_stored_analysis(self) -> None: _state(self.base_dir, depth=2) - _state(self.warmstart_dir, depth=1, chain_depth=3) + _state(self.warmstart_dir, depth=1, cap=1, chain_depth=3) values = self._analyze() self.assertEqual(values["seed_source"], "base") + def _commit_base(self, cap: int | None = None, legacy: bool = False) -> str: + if cap is not None or legacy: + _state(self.checkout / ".codeboarding", depth=1, cap=cap) + (self.checkout / "code.py").write_text("pass\n") + for args in ( + ("init",), + ("add", "."), + ( + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "test: base", + ), + ): + subprocess.run(["git", "-C", str(self.checkout), *args], check=True, capture_output=True) + return subprocess.check_output(["git", "-C", str(self.checkout), "rev-parse", "HEAD"], text=True).strip() + + def test_missing_baseline_runs_full_then_incremental_at_configured_depth(self) -> None: + sha = self._commit_base() + values = self._analyze(REVIEW_BASE_SHA=sha, DEPTH_CAP="4") + calls = self._engine_calls() + self.assertEqual([c["mode"] for c in calls], ["full", "incremental"]) + self.assertEqual(calls[0]["depth"], "4") + self.assertEqual(calls[1]["checkout"], str(self.checkout)) + self.assertEqual(values["publish_base"], "true") + for kind in ("base", "warmstart"): + analysis = json.loads((self.stage_dir / kind / "analysis.json").read_text()) + self.assertEqual(analysis["metadata"]["depth_cap"], 4) + + def test_compatible_committed_baseline_runs_incrementally(self) -> None: + sha = self._commit_base(cap=4) + self._analyze(REVIEW_BASE_SHA=sha, DEPTH_CAP="4") + self.assertEqual([c["mode"] for c in self._engine_calls()], ["incremental", "incremental"]) + + def test_legacy_committed_depth_is_not_inherited(self) -> None: + sha = self._commit_base(legacy=True) + self._analyze(REVIEW_BASE_SHA=sha, DEPTH_CAP="4") + self.assertEqual([c["mode"] for c in self._engine_calls()], ["full", "incremental"]) + self.assertEqual(self._engine_calls()[0]["depth"], "4") + + def test_changed_configuration_rebuilds_the_base(self) -> None: + sha = self._commit_base(cap=2) + _state(self.base_dir, cap=2) + self._analyze(REVIEW_BASE_SHA=sha, DEPTH_CAP="4") + self.assertEqual([c["mode"] for c in self._engine_calls()], ["full", "incremental"]) + self.assertEqual(self._engine_calls()[0]["depth"], "4") + + def test_head_full_fallback_uses_configured_cap(self) -> None: + _state(self.base_dir, depth=1, cap=4) + self._analyze(DEPTH_CAP="4", CB_REQUIRE_FULL="true") + self.assertEqual([c["mode"] for c in self._engine_calls()], ["incremental", "full"]) + self.assertEqual(self._engine_calls()[1]["depth"], "4") + + def test_base_and_head_fallbacks_keep_configured_depth(self) -> None: + sha = self._commit_base(cap=4) + self._analyze(REVIEW_BASE_SHA=sha, DEPTH_CAP="4", CB_REQUIRE_FULL="true") + calls = self._engine_calls() + self.assertEqual([c["mode"] for c in calls], ["incremental", "full", "incremental", "full"]) + self.assertEqual([c["depth"] for c in calls if c["mode"] == "full"], ["4", "4"]) + + def test_invalid_depth_fails_before_analysis(self) -> None: + result = subprocess.run( + [str(ANALYZE)], + env={"PATH": os.environ["PATH"], "DEPTH_CAP": "-1"}, + capture_output=True, + text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("depth_cap must be a positive integer", result.stdout) + self.assertEqual(self._engine_calls(), []) + + def test_sync_without_baseline_uses_configured_depth_directly(self) -> None: + self._analyze(ANALYSIS_KIND="sync", FORCE_FULL="false", DEPTH_CAP="4") + self.assertEqual([c["mode"] for c in self._engine_calls()], ["full"]) + self.assertEqual(self._engine_calls()[0]["depth"], "4") + def test_a_run_that_stopped_short_of_its_cap_keeps_the_chain(self) -> None: # Core resolves incremental depth from depth_cap, so a realized # depth_level below the cap is not a scope change. diff --git a/tests/test_analyze_repository.py b/tests/test_analyze_repository.py index 66fdfc5..d394736 100644 --- a/tests/test_analyze_repository.py +++ b/tests/test_analyze_repository.py @@ -135,6 +135,8 @@ def test_main_full_uses_generated_analysis_file(self) -> None: stale_artifact.write_text("stale", encoding="utf-8") def fake_run(_args, output_dir): + self.assertIn("--depth-cap", _args) + self.assertNotIn("--depth-level", _args) (output_dir / "analysis.json").write_text("ok", encoding="utf-8") return "human-readable CLI output" @@ -147,7 +149,7 @@ def fake_run(_args, output_dir): str(checkout), "--output-dir", str(out_dir), - "--depth-level", + "--depth-cap", "1", ] ) @@ -155,6 +157,13 @@ def fake_run(_args, output_dir): self.assertIn(f"analysis_path={out_dir / 'analysis.json'}", stdout.getvalue()) self.assertFalse(stale_artifact.exists()) + def test_old_depth_input_is_rejected(self) -> None: + with patch("sys.stderr", io.StringIO()), patch.object(ar, "_run_command") as command: + with self.assertRaises(SystemExit) as raised: + ar.main(["full", "--checkout", ".", "--output-dir", ".", "--depth-level", "3"]) + self.assertEqual(raised.exception.code, 2) + command.assert_not_called() + def test_main_rejects_bad_cli_output(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp)