Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 61 additions & 7 deletions cli/python/base_setup/ide.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path

import base_cli
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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}",
Expand Down
28 changes: 28 additions & 0 deletions cli/python/base_setup/tests/test_ide_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
39 changes: 39 additions & 0 deletions cli/python/base_setup/tests/test_ide_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
104 changes: 104 additions & 0 deletions docs/superpowers/plans/2026-06-09-ide-diagnostic-cache.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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 `<ide-cli> --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.
Loading