diff --git a/cli/python/base_setup/ide.py b/cli/python/base_setup/ide.py index 5e68a20..357c72e 100644 --- a/cli/python/base_setup/ide.py +++ b/cli/python/base_setup/ide.py @@ -5,6 +5,7 @@ import subprocess import sys import tempfile +from dataclasses import dataclass from pathlib import Path import base_cli @@ -115,6 +116,49 @@ def log_ide_preference_warnings(ctx: base_cli.Context, checks: list[ArtifactChec ctx.log.warning("Fix: %s", check.fix) +@dataclass +class IdeDiagnosticSnapshot: + definition: IdeDefinition + _cli_available: bool | None = None + _installed_extensions: set[str] | None = None + _extension_error: ArtifactError | None = None + _settings_file: Path | None = None + _current_settings: dict[str, object] | None = None + _settings_error: ArtifactError | None = None + + def cli_available(self) -> bool: + if self._cli_available is None: + self._cli_available = process.command_exists(self.definition.cli) + return self._cli_available + + def installed_extensions(self) -> set[str]: + if self._installed_extensions is None and self._extension_error is None: + try: + self._installed_extensions = list_ide_extensions(self.definition) + except ArtifactError as exc: + self._extension_error = exc + if self._extension_error is not None: + raise self._extension_error + assert self._installed_extensions is not None + return self._installed_extensions + + def settings_file(self) -> Path: + if self._settings_file is None: + self._settings_file = ide_settings_file(self.definition) + return self._settings_file + + def current_settings(self) -> dict[str, object]: + if self._current_settings is None and self._settings_error is None: + try: + self._current_settings = read_ide_settings(self.definition) + except ArtifactError as exc: + self._settings_error = exc + if self._settings_error is not None: + raise self._settings_error + assert self._current_settings is not None + return self._current_settings + + def reconcile_ide_installs(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> None: for ide_name, ide_config in manifest.ide.items(): definition = IDE_DEFINITIONS[ide_name] @@ -210,15 +254,22 @@ def check_ide_extensions(manifest: BaseManifest) -> list[ArtifactCheck]: if not ide_config.extensions: continue definition = IDE_DEFINITIONS[ide_name] + snapshot = IdeDiagnosticSnapshot(definition) checks.extend( - check_ide_extension(manifest.project_name, definition, extension) + check_ide_extension(manifest.project_name, definition, extension, snapshot=snapshot) for extension in ide_config.extensions ) return checks -def check_ide_extension(project: str, definition: IdeDefinition, extension: str) -> ArtifactCheck: - if not process.command_exists(definition.cli): +def check_ide_extension( + project: str, + definition: IdeDefinition, + extension: str, + snapshot: IdeDiagnosticSnapshot | None = None, +) -> ArtifactCheck: + snapshot = snapshot or IdeDiagnosticSnapshot(definition) + if not snapshot.cli_available(): return ArtifactCheck( name=extension, ok=False, @@ -234,7 +285,7 @@ def check_ide_extension(project: str, definition: IdeDefinition, extension: str) ) try: - installed_extensions = list_ide_extensions(definition) + installed_extensions = snapshot.installed_extensions() except ArtifactError as exc: return ArtifactCheck( name=extension, @@ -356,9 +407,10 @@ def check_ide_settings(manifest: BaseManifest) -> list[ArtifactCheck]: if not ide_config.settings: continue definition = IDE_DEFINITIONS[ide_name] + snapshot = IdeDiagnosticSnapshot(definition) resolved_settings = resolve_ide_settings(manifest.project_name, ide_config.settings) checks.extend( - check_ide_setting(manifest.project_name, definition, key, value) + check_ide_setting(manifest.project_name, definition, key, value, snapshot=snapshot) for key, value in resolved_settings.items() ) return checks @@ -369,10 +421,12 @@ def check_ide_setting( definition: IdeDefinition, key: str, expected_value: object, + snapshot: IdeDiagnosticSnapshot | None = None, ) -> ArtifactCheck: - settings_file = ide_settings_file(definition) + snapshot = snapshot or IdeDiagnosticSnapshot(definition) + settings_file = snapshot.settings_file() try: - current_settings = read_ide_settings(definition) + current_settings = snapshot.current_settings() except ArtifactError as exc: return ArtifactCheck( name=f"{definition.label} setting: {key}", diff --git a/cli/python/base_setup/tests/test_ide_extensions.py b/cli/python/base_setup/tests/test_ide_extensions.py index b035316..4d5c9dd 100644 --- a/cli/python/base_setup/tests/test_ide_extensions.py +++ b/cli/python/base_setup/tests/test_ide_extensions.py @@ -141,6 +141,34 @@ def test_list_ide_extensions_includes_stderr_on_failure(self) -> None: + def test_check_ide_extensions_reuses_probe_for_all_extensions_in_ide(self) -> None: + manifest = BaseManifest( + path=Path("base_manifest.yaml"), + project_name="demo", + brewfile=None, + artifacts=(), + ide={ + "vscode": IdeConfig( + install=False, + extensions=("ms-python.python", "github.copilot"), + settings={}, + ) + }, + ) + + with mock.patch("base_setup.process.command_exists", return_value=True) as command_exists, mock.patch( + "base_setup.ide.list_ide_extensions", + return_value={"ms-python.python"}, + ) as list_extensions: + checks = ide.check_ide_extensions(manifest) + + command_exists.assert_called_once_with("code") + list_extensions.assert_called_once_with(ide.IDE_DEFINITIONS["vscode"]) + self.assertEqual([check.name for check in checks], ["ms-python.python", "github.copilot"]) + self.assertEqual([check.ok for check in checks], [True, False]) + + + def test_check_ide_extension_reports_installed_extension(self) -> None: definition = ide.IDE_DEFINITIONS["vscode"] diff --git a/cli/python/base_setup/tests/test_ide_settings.py b/cli/python/base_setup/tests/test_ide_settings.py index 7ca2011..2eeb2f8 100644 --- a/cli/python/base_setup/tests/test_ide_settings.py +++ b/cli/python/base_setup/tests/test_ide_settings.py @@ -243,3 +243,42 @@ def test_check_ide_settings_includes_manifest_settings(self) -> None: self.assertEqual(len(checks), 1) self.assertTrue(checks[0].ok) + + + + def test_check_ide_settings_reuses_probe_for_all_settings_in_ide(self) -> None: + definition = ide.IDE_DEFINITIONS["vscode"] + settings_file = Path("/tmp/vscode-settings.json") + manifest = BaseManifest( + path=Path("base_manifest.yaml"), + project_name="demo", + brewfile=None, + artifacts=(), + ide={ + "vscode": IdeConfig( + install=False, + extensions=(), + settings={ + "editor.formatOnSave": True, + "editor.rulers": [100], + }, + ) + }, + ) + + with mock.patch("base_setup.ide.ide_settings_file", return_value=settings_file) as settings_path, mock.patch( + "base_setup.ide.read_ide_settings", + return_value={ + "editor.formatOnSave": True, + "editor.rulers": [100], + }, + ) as read_settings: + checks = ide.check_ide_settings(manifest) + + settings_path.assert_called_once_with(definition) + read_settings.assert_called_once_with(definition) + self.assertEqual( + [check.name for check in checks], + ["VS Code setting: editor.formatOnSave", "VS Code setting: editor.rulers"], + ) + self.assertTrue(all(check.ok for check in checks)) diff --git a/docs/superpowers/plans/2026-06-09-ide-diagnostic-cache.md b/docs/superpowers/plans/2026-06-09-ide-diagnostic-cache.md new file mode 100644 index 0000000..b800ee4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-ide-diagnostic-cache.md @@ -0,0 +1,104 @@ +# IDE Diagnostic Cache Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans +> to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for +> tracking. + +**Goal:** Cache read-only IDE probes once per IDE during check/doctor +diagnostics while preserving one finding per declared extension or setting. + +**Architecture:** Keep finding construction in `cli/python/base_setup/ide.py`. +Introduce an internal `IdeDiagnosticSnapshot` that lazily caches CLI +availability, extension listings, settings path, and parsed settings for a +single IDE within one diagnostic collection pass. + +**Tech Stack:** Python standard library dataclasses plus the existing Base +diagnostic helpers. + +--- + +## File Structure + +- Modify `cli/python/base_setup/ide.py`: add `IdeDiagnosticSnapshot`, use it in + IDE extension/settings check collection. +- Modify `cli/python/base_setup/tests/test_ide_extensions.py`: add RED coverage + for extension probe reuse. +- Modify `cli/python/base_setup/tests/test_ide_settings.py`: add RED coverage + for settings probe reuse. +- Add `docs/superpowers/specs/2026-06-09-ide-diagnostic-cache-design.md`: design + record. +- Add `docs/superpowers/plans/2026-06-09-ide-diagnostic-cache.md`: this plan. + +## Task 1: Failing Extension Cache Test + +**Files:** +- Modify: `cli/python/base_setup/tests/test_ide_extensions.py` + +- [ ] Add a test with one IDE and two declared extensions. +- [ ] Patch `base_setup.process.command_exists` and + `base_setup.ide.list_ide_extensions`. +- [ ] Assert both probes are called once. +- [ ] Assert two findings are still returned in manifest order. +- [ ] Run the focused test and verify it fails before implementation. + +## Task 2: Failing Settings Cache Test + +**Files:** +- Modify: `cli/python/base_setup/tests/test_ide_settings.py` + +- [ ] Add a test with one IDE and two declared settings. +- [ ] Patch `base_setup.ide.ide_settings_file` and + `base_setup.ide.read_ide_settings`. +- [ ] Assert both probes are called once. +- [ ] Assert two findings are still returned in manifest order. +- [ ] Run the focused test and verify it fails before implementation. + +## Task 3: Snapshot Implementation + +**Files:** +- Modify: `cli/python/base_setup/ide.py` + +- [ ] Import `dataclass`. +- [ ] Add `IdeDiagnosticSnapshot` with lazy cached methods for CLI + availability, installed extensions, settings file, and current settings. +- [ ] Update `check_ide_extensions()` to create and reuse one snapshot per IDE. +- [ ] Update `check_ide_extension()` to accept an optional snapshot while keeping + existing direct-call behavior. +- [ ] Update `check_ide_settings()` to create and reuse one snapshot per IDE. +- [ ] Update `check_ide_setting()` to accept an optional snapshot while keeping + existing direct-call behavior. + +## Task 4: Validation + +- [ ] Run focused IDE extension tests: + +```bash +PYTHONPATH=lib/python:cli/python /Users/rameshhp/.base.d/base/.venv/bin/python -m unittest cli/python/base_setup/tests/test_ide_extensions.py +``` + +- [ ] Run focused IDE settings tests: + +```bash +PYTHONPATH=lib/python:cli/python /Users/rameshhp/.base.d/base/.venv/bin/python -m unittest cli/python/base_setup/tests/test_ide_settings.py +``` + +- [ ] Run the full Base validation suite: + +```bash +env -u BASE_HOME ./bin/base-test +``` + +- [ ] Run whitespace validation: + +```bash +git diff --check +``` + +## Task 5: Publish + +- [ ] Commit the implementation. +- [ ] Push the branch. +- [ ] Open a PR closing #509. +- [ ] Watch CI. +- [ ] Merge when checks are green. +- [ ] Sync local `master` and remove the #509 worktree. diff --git a/docs/superpowers/specs/2026-06-09-ide-diagnostic-cache-design.md b/docs/superpowers/specs/2026-06-09-ide-diagnostic-cache-design.md new file mode 100644 index 0000000..142570f --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-ide-diagnostic-cache-design.md @@ -0,0 +1,85 @@ +# IDE Diagnostic Cache Design + +Issue: #509 + +## Goal + +When `basectl check` or `basectl doctor` evaluates IDE requirements, Base should +probe each IDE once per diagnostic run and then produce the same per-extension +and per-setting findings from that cached state. + +## Scope + +This change is limited to read-only diagnostics for supported IDEs: + +- extension checks using ` --list-extensions` +- user settings checks using the IDE `settings.json` + +Setup behavior remains unchanged. Base will not persist diagnostic state across +runs, and it will not broaden IDE ownership beyond workstation readiness. + +## Current Problem + +`check_ide_extensions()` delegates to `check_ide_extension()` once per declared +extension. Each call checks CLI availability and lists installed extensions. + +`check_ide_settings()` delegates to `check_ide_setting()` once per declared +setting. Each call resolves the settings file and reads/parses the JSON. + +That keeps finding generation simple, but it repeats expensive and potentially +noisy probes when a manifest declares several extensions or settings for the +same IDE. + +## Design + +Add an in-memory `IdeDiagnosticSnapshot` for one IDE during one diagnostic +collection pass. + +The snapshot will lazily cache: + +- whether the IDE CLI is available on `PATH` +- the installed extension set or extension-listing error +- the resolved settings file path +- parsed settings JSON or settings-read error + +`check_ide_extensions()` will create one snapshot per IDE that declares +extensions, then pass that snapshot to each per-extension finding builder. + +`check_ide_settings()` will create one snapshot per IDE that declares settings, +then pass that snapshot to each per-setting finding builder. + +The individual `check_ide_extension()` and `check_ide_setting()` helpers will +keep their public calling shape by creating a fresh snapshot when no snapshot is +provided. That preserves focused unit tests and any direct internal callers +while allowing collection-level code to reuse probe results. + +## Output Contract + +The output remains one `ArtifactCheck` per declared extension and one +`ArtifactCheck` per declared setting. Existing finding IDs, messages, fixes, and +ordering should remain stable except that repeated probes are eliminated. + +## Error Behavior + +If extension listing fails, every extension declared for that IDE receives the +same `BASE-P111` finding generated from the cached error. + +If the settings file cannot be parsed, every setting declared for that IDE +receives the same `BASE-P120` finding generated from the cached error. + +If the IDE CLI is missing, every extension declared for that IDE receives the +same `BASE-P110` finding generated from the cached CLI availability result. + +## Tests + +Add focused tests that prove: + +- extension diagnostics call `process.command_exists()` and + `list_ide_extensions()` once for one IDE with multiple extensions +- settings diagnostics call `ide_settings_file()` and `read_ide_settings()` once + for one IDE with multiple settings +- the generated finding list still contains one finding per manifest entry in + deterministic order + +Run the focused IDE tests first, then the full `env -u BASE_HOME ./bin/base-test` +suite.