diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ecfe4b..e1acad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and Base versions are tracked in the repo-root `VERSION` file. ### Added +- Added read-only workspace manifest support for `basectl workspace status`, + `check`, and `doctor` with `--manifest `. - Added opt-in project Git `origin` reachability diagnostics with `basectl check|doctor --remote-network`. - Added an explicit `ai` prerequisite profile for Codex CLI and Claude Code diff --git a/README.md b/README.md index e2142a7..33d12b5 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,7 @@ basectl projects list basectl projects list --format json basectl workspace status basectl workspace status --format json +basectl workspace status --manifest ~/work/workspace.yaml basectl workspace check basectl workspace doctor ``` @@ -353,6 +354,11 @@ validity and whether the Base-managed project virtual environment is present. Check and doctor run project diagnostics across discovered projects and keep invalid project manifests visible as per-project findings. +Use `--manifest ` with `basectl workspace status`, `check`, or `doctor` +to include expected repositories from a local workspace manifest. Missing +required repositories are errors, missing optional repositories are warnings, +and Base-managed projects outside the manifest stay visible as warnings. + Start a new Base-managed repository with: ```bash diff --git a/cli/bash/commands/basectl/README.md b/cli/bash/commands/basectl/README.md index a16e6e4..0b0b10d 100644 --- a/cli/bash/commands/basectl/README.md +++ b/cli/bash/commands/basectl/README.md @@ -106,9 +106,12 @@ such command directories exist. Optional utility CLIs such as `caff` and when configured, otherwise `$BASE_HOME`'s parent, and prints discovered project names and paths. - `basectl workspace status` reports a read-only workspace summary across - discovered projects. + discovered projects, or across expected repositories when `--manifest ` + is supplied. - `basectl workspace check` and `basectl workspace doctor` run read-only - project checks and diagnostics across discovered projects. + project checks and diagnostics across discovered projects. With + `--manifest `, they also report missing expected repositories and + discovered Base-managed projects outside the manifest. - `basectl version` prints the installed Base version from the repo-root `VERSION` file. - basectl-specific bootstrap subcommands live under `cli/bash/commands/basectl/subcommands/`. - basectl tests live under `cli/bash/commands/basectl/tests/`. diff --git a/cli/bash/commands/basectl/subcommands/workspace.sh b/cli/bash/commands/basectl/subcommands/workspace.sh index 4b80c9e..e270a3f 100644 --- a/cli/bash/commands/basectl/subcommands/workspace.sh +++ b/cli/bash/commands/basectl/subcommands/workspace.sh @@ -10,11 +10,12 @@ Usage: Options: --workspace Workspace directory to scan. Defaults to workspace.root, then BASE_HOME's parent. + --manifest Local workspace manifest describing expected repositories. --format Output format for the workspace command: text or json. -v Enable DEBUG logging for this subcommand. -h, --help Show this help text. -Show read-only status, check, or doctor output for Base-managed projects in the workspace. +Show read-only status, check, or doctor output for repositories in the workspace. EOF } diff --git a/cli/bash/commands/basectl/tests/completions.bats b/cli/bash/commands/basectl/tests/completions.bats index ea53dd7..f18bea1 100644 --- a/cli/bash/commands/basectl/tests/completions.bats +++ b/cli/bash/commands/basectl/tests/completions.bats @@ -164,7 +164,7 @@ EOF [[ "$output" == *"run_options=--workspace --dry-run --list"* ]] [[ "$output" == *"projects_options=--workspace --format"* ]] [[ "$output" == *"workspace_commands=status check doctor"* ]] - [[ "$output" == *"workspace_options=--workspace --format"* ]] + [[ "$output" == *"workspace_options=--workspace --manifest --format"* ]] [[ "$output" == *"onboard_options=--profile --dry-run --yes --no-profile"* ]] [[ "$output" == *"onboard_profiles=dev sre ai dev,sre dev,ai sre,ai dev,sre,ai"* ]] [[ "$output" == *"clean_options=--older-than --keep-last --dry-run"* ]] diff --git a/cli/bash/commands/basectl/tests/workspace.bats b/cli/bash/commands/basectl/tests/workspace.bats index b2bf5c0..23d1fc6 100644 --- a/cli/bash/commands/basectl/tests/workspace.bats +++ b/cli/bash/commands/basectl/tests/workspace.bats @@ -6,8 +6,10 @@ load ./basectl_helpers.bash @test "basectl workspace status delegates to the Python projects layer" { local python_bin="$TEST_HOME/.base.d/base/.venv/bin/python" local workspace="$TEST_TMPDIR/workspace" + local manifest="$TEST_TMPDIR/workspace.yaml" mkdir -p "$(dirname "$python_bin")" "$workspace/base" + touch "$manifest" cat > "$python_bin" <<'EOF' #!/usr/bin/env bash if [[ "${1:-}" == "-m" && "${2:-}" == "base_projects" && "${3:-}" == "status" ]]; then @@ -25,10 +27,10 @@ EOF HOME="$TEST_HOME" \ PATH="/usr/bin:/bin:/usr/sbin:/sbin" \ BASE_TEST_WORKSPACE_STATUS_STATE="$TEST_TMPDIR/workspace-status-state" \ - "$BASE_REPO_ROOT/bin/basectl" workspace status --workspace "$workspace" --format json + "$BASE_REPO_ROOT/bin/basectl" workspace status --workspace "$workspace" --manifest "$manifest" --format json [ "$status" -eq 0 ] - [ "$output" = "ARGS=--workspace $workspace --format json" ] + [ "$output" = "ARGS=--workspace $workspace --manifest $manifest --format json" ] [ "$(cat "$TEST_TMPDIR/workspace-status-state")" = "BASE_PROJECT=base" ] } @@ -91,6 +93,7 @@ EOF [[ "$output" == *"Usage:"* ]] [[ "$output" == *"basectl workspace [options]"* ]] [[ "$output" == *"--workspace "* ]] + [[ "$output" == *"--manifest "* ]] [[ "$output" == *"--format "* ]] run_basectl workspace check --help diff --git a/cli/python/base_projects/engine.py b/cli/python/base_projects/engine.py index 5c14f03..4cc4560 100644 --- a/cli/python/base_projects/engine.py +++ b/cli/python/base_projects/engine.py @@ -12,21 +12,25 @@ import base_cli from base_cli.config import read_user_config -from base_cli.paths import base_cache_root, base_state_root +from base_cli.paths import base_cache_root from base_cli.paths import discover_manifest from base_projects.build_targets import build_targets_project_from_args from base_projects.build_targets import list_build_targets_from_args -from base_setup.checks import ArtifactCheck -from base_setup.checks import DIAGNOSTIC_JSON_SCHEMA_VERSION -from base_setup.checks import check_to_doctor_json -from base_setup.checks import check_to_json -from base_setup.checks import checks_status -from base_setup.checks import doctor_status -from base_setup.checks import print_doctor_finding +from base_projects.workspace_manifest import WorkspaceManifestError +from base_projects.workspace_reports import ManifestEntry +from base_projects.workspace_reports import ProjectDiscoveryError +from base_projects.workspace_reports import print_workspace_check +from base_projects.workspace_reports import print_workspace_doctor +from base_projects.workspace_reports import print_workspace_status +from base_projects.workspace_reports import resolve_workspace_manifest +from base_projects.workspace_reports import workspace_check_to_json +from base_projects.workspace_reports import workspace_doctor_to_json +from base_projects.workspace_reports import workspace_error_count +from base_projects.workspace_reports import workspace_manifest_entries +from base_projects.workspace_reports import workspace_project_check_results +from base_projects.workspace_reports import workspace_project_statuses +from base_projects.workspace_reports import workspace_status_to_json from base_setup.demo import resolve_demo_script_path -from base_setup.engine import manifest_checks -from base_setup.engine import pre_venv_manifest_checks -from base_setup.engine import read_default_manifest from base_setup.errors import ArtifactError from base_setup.manifest import BaseManifest, ManifestError, TestConfig, read_manifest @@ -41,34 +45,6 @@ class Project: manifest_path: Path -@dataclass(frozen=True) -class ManifestEntry: - path: Path - mtime_ns: int - size: int - - -@dataclass(frozen=True) -class WorkspaceProjectStatus: - name: str - root: Path - manifest_path: Path - status: str - venv: str - manifest: str - issues: tuple[str, ...] - - -@dataclass(frozen=True) -class WorkspaceProjectCheckResult: - name: str - root: Path - manifest_path: Path - manifest: str - status: str - checks: tuple[ArtifactCheck, ...] - - def main(argv: list[str] | None = None) -> int: result = app.click_command.main(args=argv, standalone_mode=False) return int(result or 0) @@ -81,14 +57,16 @@ def main(argv: list[str] | None = None) -> int: help="Workspace directory to scan. Defaults to workspace.root, then BASE_HOME's parent.", ) @base_cli.option("--format", "output_format", default="text", help="Output format: text or json.") +@base_cli.option("--manifest", "workspace_manifest", help="Local workspace manifest to read.") def run( ctx: base_cli.Context, arguments: tuple[str, ...], workspace: str | None, output_format: str, + workspace_manifest: str | None, ) -> int: try: - return dispatch_projects_command(ctx, arguments, workspace, output_format) + return dispatch_projects_command(ctx, arguments, workspace, output_format, workspace_manifest) except ProjectUsageError as exc: ctx.log.error(str(exc)) return 2 @@ -99,18 +77,29 @@ def dispatch_projects_command( arguments: tuple[str, ...], workspace: str | None, output_format: str, + workspace_manifest: str | None = None, ) -> int: command = arguments[0] if arguments else "list" command_arguments = arguments[1:] if arguments else () resolver = resolve_named_project handlers = { "list": lambda: list_projects_from_args(ctx, command_arguments, workspace, output_format), - "status": lambda: workspace_status_from_args(ctx, command_arguments, workspace, output_format), + "status": lambda: workspace_status_from_args( + ctx, + command_arguments, + workspace, + output_format, + workspace_manifest, + ), "check": lambda: require_no_args_and_run( - "check", command_arguments, lambda: workspace_check_command(ctx, workspace, output_format) + "check", + command_arguments, + lambda: workspace_check_command(ctx, workspace, output_format, workspace_manifest), ), "doctor": lambda: require_no_args_and_run( - "doctor", command_arguments, lambda: workspace_doctor_command(ctx, workspace, output_format) + "doctor", + command_arguments, + lambda: workspace_doctor_command(ctx, workspace, output_format, workspace_manifest), ), "current": lambda: current_project_from_args(ctx, command_arguments), "manifest": lambda: manifest_project_from_args(ctx, command_arguments), @@ -172,9 +161,10 @@ def workspace_status_from_args( arguments: tuple[str, ...], workspace: str | None, output_format: str, + workspace_manifest: str | None, ) -> int: require_argument_count("status", arguments, 0, 0) - return workspace_status_command(ctx, workspace, output_format) + return workspace_status_command(ctx, workspace, output_format, workspace_manifest) def current_project_from_args(ctx: base_cli.Context, arguments: tuple[str, ...]) -> int: @@ -247,62 +237,80 @@ def list_projects_command(ctx: base_cli.Context, workspace: str | None, output_f return 0 -def workspace_status_command(ctx: base_cli.Context, workspace: str | None, output_format: str = "text") -> int: +def workspace_status_command( + ctx: base_cli.Context, + workspace: str | None, + output_format: str = "text", + workspace_manifest: str | None = None, +) -> int: if output_format not in ("text", "json"): ctx.log.error("Unsupported output format '%s'. Expected one of: text, json.", output_format) return 2 try: workspace_root = resolve_workspace_root(ctx, workspace) - statuses = workspace_project_statuses(workspace_root) - except ProjectDiscoveryError as exc: + manifest = resolve_workspace_manifest(workspace_manifest) + statuses = workspace_project_statuses(workspace_root, manifest) + except (ProjectDiscoveryError, WorkspaceManifestError) as exc: ctx.log.error(str(exc)) return 1 if output_format == "json": - print(json.dumps(workspace_status_to_json(workspace_root, statuses), separators=(",", ":"))) + print(json.dumps(workspace_status_to_json(workspace_root, statuses, manifest), separators=(",", ":"))) else: - print_workspace_status(workspace_root, statuses) + print_workspace_status(workspace_root, statuses, manifest) return 1 if any(project.status == "error" for project in statuses) else 0 -def workspace_check_command(ctx: base_cli.Context, workspace: str | None, output_format: str = "text") -> int: +def workspace_check_command( + ctx: base_cli.Context, + workspace: str | None, + output_format: str = "text", + workspace_manifest: str | None = None, +) -> int: if output_format not in ("text", "json"): ctx.log.error("Unsupported output format '%s'. Expected one of: text, json.", output_format) return 2 try: workspace_root = resolve_workspace_root(ctx, workspace) - results = workspace_project_check_results(ctx, workspace_root) - except (ProjectDiscoveryError, ManifestError) as exc: + manifest = resolve_workspace_manifest(workspace_manifest) + results = workspace_project_check_results(ctx, workspace_root, manifest) + except (ProjectDiscoveryError, ManifestError, WorkspaceManifestError) as exc: ctx.log.error(str(exc)) return 1 if output_format == "json": - print(json.dumps(workspace_check_to_json(workspace_root, results), separators=(",", ":"))) + print(json.dumps(workspace_check_to_json(workspace_root, results, manifest), separators=(",", ":"))) else: - print_workspace_check(workspace_root, results) + print_workspace_check(workspace_root, results, manifest) return 1 if any(result.status == "error" for result in results) else 0 -def workspace_doctor_command(ctx: base_cli.Context, workspace: str | None, output_format: str = "text") -> int: +def workspace_doctor_command( + ctx: base_cli.Context, + workspace: str | None, + output_format: str = "text", + workspace_manifest: str | None = None, +) -> int: if output_format not in ("text", "json"): ctx.log.error("Unsupported output format '%s'. Expected one of: text, json.", output_format) return 2 try: workspace_root = resolve_workspace_root(ctx, workspace) - results = workspace_project_check_results(ctx, workspace_root) - except (ProjectDiscoveryError, ManifestError) as exc: + manifest = resolve_workspace_manifest(workspace_manifest) + results = workspace_project_check_results(ctx, workspace_root, manifest) + except (ProjectDiscoveryError, ManifestError, WorkspaceManifestError) as exc: ctx.log.error(str(exc)) return 1 if output_format == "json": - print(json.dumps(workspace_doctor_to_json(workspace_root, results), separators=(",", ":"))) + print(json.dumps(workspace_doctor_to_json(workspace_root, results, manifest), separators=(",", ":"))) else: - print_workspace_doctor(workspace_root, results) + print_workspace_doctor(workspace_root, results, manifest) return min(workspace_error_count(results), 125) @@ -531,10 +539,6 @@ def manifest_project_command(ctx: base_cli.Context, manifest: str | None) -> int return 0 -class ProjectDiscoveryError(RuntimeError): - pass - - def resolve_workspace_root(ctx: base_cli.Context, workspace: str | None) -> Path: if workspace: return Path(workspace).expanduser().resolve() @@ -549,253 +553,6 @@ def resolve_workspace_root(ctx: base_cli.Context, workspace: str | None) -> Path return ctx.base_home.parent.resolve() -def workspace_project_statuses(workspace_root: Path) -> tuple[WorkspaceProjectStatus, ...]: - return tuple(workspace_project_status(entry) for entry in workspace_manifest_entries(workspace_root)) - - -def workspace_project_status(entry: ManifestEntry) -> WorkspaceProjectStatus: - root = entry.path.parent.resolve() - try: - manifest = read_manifest(entry.path) - except ManifestError as exc: - return WorkspaceProjectStatus( - name=root.name, - root=root, - manifest_path=entry.path.resolve(), - status="error", - venv="unknown", - manifest="invalid", - issues=(str(exc),), - ) - - venv_dir = project_venv_dir(manifest.project_name) - if project_venv_ready(venv_dir): - return WorkspaceProjectStatus( - name=manifest.project_name, - root=root, - manifest_path=entry.path.resolve(), - status="ok", - venv="ready", - manifest="valid", - issues=(), - ) - - return WorkspaceProjectStatus( - name=manifest.project_name, - root=root, - manifest_path=entry.path.resolve(), - status="warn", - venv="missing", - manifest="valid", - issues=(f"project virtual environment missing at {venv_dir}",), - ) - - -def project_venv_dir(project_name: str) -> Path: - return base_state_root() / project_name / ".venv" - - -def project_venv_ready(venv_dir: Path) -> bool: - return (venv_dir / "bin" / "python").is_file() - - -def workspace_project_check_results( - ctx: base_cli.Context, - workspace_root: Path, -) -> tuple[WorkspaceProjectCheckResult, ...]: - default_manifest = read_default_manifest(ctx) - return tuple( - workspace_project_check_result(entry, default_manifest) - for entry in workspace_manifest_entries(workspace_root) - ) - - -def workspace_project_check_result( - entry: ManifestEntry, - default_manifest: BaseManifest, -) -> WorkspaceProjectCheckResult: - root = entry.path.parent.resolve() - manifest_path = entry.path.resolve() - try: - manifest = read_manifest(entry.path) - except ManifestError as exc: - checks = (invalid_manifest_check(str(exc)),) - return WorkspaceProjectCheckResult( - name=root.name, - root=root, - manifest_path=manifest_path, - manifest="invalid", - status="error", - checks=checks, - ) - - venv_check = project_venv_check(manifest.project_name) - if venv_check.ok: - checks = (venv_check,) + manifest_checks(default_manifest, manifest) - else: - checks = pre_venv_manifest_checks(manifest) + (venv_check,) - - return WorkspaceProjectCheckResult( - name=manifest.project_name, - root=root, - manifest_path=manifest_path, - manifest="valid", - status=checks_status(checks), - checks=checks, - ) - - -def invalid_manifest_check(message: str) -> ArtifactCheck: - return ArtifactCheck( - name="project_manifest", - ok=False, - message=message, - fix="Fix base_manifest.yaml syntax and schema.", - status="error", - finding_id="BASE-P002", - ) - - -def project_venv_check(project_name: str) -> ArtifactCheck: - venv_dir = project_venv_dir(project_name) - if project_venv_ready(venv_dir): - return ArtifactCheck( - name="project_virtualenv", - ok=True, - message=f"Project virtual environment is ready at '{venv_dir}'.", - fix="", - finding_id="BASE-P050", - ) - - return ArtifactCheck( - name="project_virtualenv", - ok=False, - message=f"Project virtual environment is missing or incomplete at '{venv_dir}'.", - fix=f"Run 'basectl setup {project_name} --recreate-venv' to recreate the project virtual environment.", - status="error", - finding_id="BASE-P050", - ) - - -def workspace_error_count(results: tuple[WorkspaceProjectCheckResult, ...]) -> int: - return sum(1 for result in results for check in result.checks if doctor_status(check) == "error") - - -def workspace_status_to_json(workspace_root: Path, statuses: tuple[WorkspaceProjectStatus, ...]) -> dict[str, Any]: - return { - "workspace": str(workspace_root), - "project_count": len(statuses), - "projects": [ - { - "name": status.name, - "status": status.status, - "path": str(status.root), - "manifest_path": str(status.manifest_path), - "venv": status.venv, - "manifest": status.manifest, - "issues": list(status.issues), - } - for status in statuses - ], - } - - -def workspace_check_to_json(workspace_root: Path, results: tuple[WorkspaceProjectCheckResult, ...]) -> dict[str, Any]: - return workspace_checks_to_json(workspace_root, results, doctor=False) - - -def workspace_doctor_to_json(workspace_root: Path, results: tuple[WorkspaceProjectCheckResult, ...]) -> dict[str, Any]: - return workspace_checks_to_json(workspace_root, results, doctor=True) - - -def workspace_checks_to_json( - workspace_root: Path, - results: tuple[WorkspaceProjectCheckResult, ...], - doctor: bool, -) -> dict[str, Any]: - return { - "schema_version": DIAGNOSTIC_JSON_SCHEMA_VERSION, - "workspace": str(workspace_root), - "status": checks_status(tuple(check for result in results for check in result.checks)), - "project_count": len(results), - "projects": [ - { - "name": result.name, - "status": result.status, - "path": str(result.root), - "manifest_path": str(result.manifest_path), - "manifest": result.manifest, - "checks": [workspace_check_item_to_json(check, doctor) for check in result.checks], - } - for result in results - ], - } - - -def workspace_check_item_to_json(check: ArtifactCheck, doctor: bool) -> dict[str, str]: - if doctor: - return check_to_doctor_json(check) - return check_to_json(check) - - -def print_workspace_status(workspace_root: Path, statuses: tuple[WorkspaceProjectStatus, ...]) -> None: - print(f"Workspace: {workspace_root} ({len(statuses)} projects)") - print() - if not statuses: - print("No Base-managed projects discovered.") - return - - print(f"{'PROJECT':<20} {'STATUS':<6} {'VENV':<8} {'MANIFEST':<8} {'LAST CHECK':<10} PATH") - for status in statuses: - print( - f"{status.name:<20} " - f"{status.status:<6} " - f"{status.venv:<8} " - f"{status.manifest:<8} " - f"{'-':<10} " - f"{status.root}" - ) - - attention_count = sum(1 for status in statuses if status.status != "ok") - if attention_count: - print(f"\n{attention_count} project(s) need attention. Run 'basectl doctor ' for details.") - else: - print("\nAll discovered projects look ok.") - - -def print_workspace_check(workspace_root: Path, results: tuple[WorkspaceProjectCheckResult, ...]) -> None: - print(f"Workspace check: {workspace_root} ({len(results)} projects)") - print_workspace_check_results(results) - - -def print_workspace_doctor(workspace_root: Path, results: tuple[WorkspaceProjectCheckResult, ...]) -> None: - print(f"\nWorkspace doctor: {workspace_root} ({len(results)} projects)") - print_workspace_check_results(results) - - -def print_workspace_check_results(results: tuple[WorkspaceProjectCheckResult, ...]) -> None: - if not results: - print("\nNo Base-managed projects discovered.") - return - - for result in results: - print(f"\nProject: {result.name} [{result.status}]") - print(f"Path: {result.root}") - for check in result.checks: - print_doctor_finding(doctor_status(check), check.finding_id, check.name, check.message, check.fix) - - error_count = workspace_error_count(results) - if error_count: - print(f"\nWorkspace has {error_count} error finding(s).") - return - - warn_count = sum(1 for result in results for check in result.checks if doctor_status(check) == "warn") - if warn_count: - print(f"\nWorkspace has {warn_count} warning finding(s).") - else: - print("\nAll discovered projects passed.") - - def resolve_named_project(ctx: base_cli.Context, project_name: str, workspace: str | None) -> Project: if workspace is None and project_name == "base" and ctx.base_home is not None: return read_project(ctx.base_home / "base_manifest.yaml") @@ -843,29 +600,6 @@ def discover_projects_cached(ctx: base_cli.Context, workspace_root: Path) -> tup return projects -def workspace_manifest_entries(workspace_root: Path) -> tuple[ManifestEntry, ...]: - if not workspace_root.is_dir(): - raise ProjectDiscoveryError(f"Workspace '{workspace_root}' is not a directory.") - - entries: list[ManifestEntry] = [] - for candidate in sorted(workspace_root.iterdir(), key=lambda path: path.name): - if not candidate.is_dir(): - continue - manifest_path = candidate / "base_manifest.yaml" - if not manifest_path.is_file(): - continue - stat_result = manifest_path.stat() - entries.append( - ManifestEntry( - path=manifest_path, - mtime_ns=stat_result.st_mtime_ns, - size=stat_result.st_size, - ) - ) - - return tuple(entries) - - def find_project(workspace_root: Path, project_name: str) -> Project: projects = discover_projects(workspace_root) return find_project_in_projects(projects, workspace_root, project_name) diff --git a/cli/python/base_projects/tests/test_workspace_checks.py b/cli/python/base_projects/tests/test_workspace_checks.py index 427c695..79e3c82 100644 --- a/cli/python/base_projects/tests/test_workspace_checks.py +++ b/cli/python/base_projects/tests/test_workspace_checks.py @@ -20,6 +20,32 @@ def write_manifest(project_root: Path, name: str) -> None: ) +def write_workspace_manifest(path: Path, repos: str | None = None) -> None: + path.write_text( + "\n".join( + [ + "schema_version: 1", + "workspace:", + " name: demo-suite", + "repos:", + repos + or "\n".join( + [ + " - name: base", + " - name: docs", + " - name: api", + " required: true", + " - name: optional-tool", + " required: false", + ] + ), + "", + ] + ), + encoding="utf-8", + ) + + def write_default_manifest(base_home: Path) -> None: default_manifest = base_home / "lib" / "base" / "default_manifest.yaml" default_manifest.parent.mkdir(parents=True) @@ -45,6 +71,125 @@ def invoke_engine(args: list[str], base_home: Path, home: Path) -> tuple[int, st class WorkspaceCheckTests(unittest.TestCase): + def test_workspace_check_manifest_reports_expected_missing_and_extra_repositories(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + home = root / "home" + workspace = root / "workspace" + base_home = root / "base" + manifest_path = root / "workspace.yaml" + home.mkdir() + base_home.mkdir() + write_default_manifest(base_home) + write_workspace_manifest(manifest_path) + write_manifest(workspace / "base", "base") + (workspace / "docs").mkdir(parents=True) + write_manifest(workspace / "extra", "extra") + for project_name in ("base", "extra"): + python_bin = home / ".base.d" / project_name / ".venv" / "bin" / "python" + python_bin.parent.mkdir(parents=True) + python_bin.write_text("#!/usr/bin/env python\n", encoding="utf-8") + + status, stdout, stderr = invoke_engine( + ["check", "--workspace", str(workspace), "--manifest", str(manifest_path)], + base_home, + home, + ) + + self.assertEqual(status, 1) + self.assertEqual(stderr, "") + self.assertIn(f"Workspace check: {workspace.resolve()} (5 repositories)", stdout) + self.assertIn(f"Workspace manifest: {manifest_path.resolve()} (demo-suite)", stdout) + self.assertIn("Repository: base [ok]", stdout) + self.assertIn("Repository: docs [ok]", stdout) + self.assertIn("Repository: api [error]", stdout) + self.assertIn("Repository: optional-tool [warn]", stdout) + self.assertIn("Repository: extra [warn]", stdout) + self.assertIn("BASE-W010", stdout) + self.assertIn("BASE-W011", stdout) + self.assertIn("BASE-W012", stdout) + self.assertIn("Workspace has 1 error finding(s).", stdout) + + def test_workspace_check_manifest_supports_json_format(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + home = root / "home" + workspace = root / "workspace" + base_home = root / "base" + manifest_path = root / "workspace.yaml" + home.mkdir() + base_home.mkdir() + write_default_manifest(base_home) + write_workspace_manifest(manifest_path) + write_manifest(workspace / "base", "base") + (workspace / "docs").mkdir(parents=True) + write_manifest(workspace / "extra", "extra") + for project_name in ("base", "extra"): + python_bin = home / ".base.d" / project_name / ".venv" / "bin" / "python" + python_bin.parent.mkdir(parents=True) + python_bin.write_text("#!/usr/bin/env python\n", encoding="utf-8") + + status, stdout, stderr = invoke_engine( + [ + "check", + "--workspace", + str(workspace), + "--manifest", + str(manifest_path), + "--format", + "json", + ], + base_home, + home, + ) + + payload = json.loads(stdout) + projects_by_repo = {project["repository"]: project for project in payload["projects"]} + self.assertEqual(status, 1) + self.assertEqual(stderr, "") + self.assertEqual(payload["schema_version"], 1) + self.assertEqual(payload["status"], "error") + self.assertEqual(payload["workspace_manifest"]["name"], "demo-suite") + self.assertEqual(payload["repository_count"], 5) + self.assertEqual(projects_by_repo["base"]["checks"][0]["id"], "BASE-W010") + self.assertEqual(projects_by_repo["base"]["checks"][0]["status"], "ok") + self.assertEqual(projects_by_repo["docs"]["checks"][0]["id"], "BASE-W012") + self.assertEqual(projects_by_repo["docs"]["checks"][0]["status"], "ok") + self.assertEqual(projects_by_repo["api"]["status"], "error") + self.assertEqual(projects_by_repo["api"]["checks"][0]["id"], "BASE-W010") + self.assertEqual(projects_by_repo["api"]["checks"][0]["status"], "error") + self.assertEqual(projects_by_repo["api"]["checks"][0]["details"]["required"], True) + self.assertEqual(projects_by_repo["optional-tool"]["status"], "warn") + self.assertEqual(projects_by_repo["optional-tool"]["checks"][0]["status"], "warn") + self.assertEqual(projects_by_repo["extra"]["checks"][0]["id"], "BASE-W011") + self.assertEqual(projects_by_repo["extra"]["checks"][0]["status"], "warn") + + def test_workspace_check_manifest_reports_invalid_project_manifest(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + home = root / "home" + workspace = root / "workspace" + base_home = root / "base" + manifest_path = root / "workspace.yaml" + broken_root = workspace / "broken" + home.mkdir() + base_home.mkdir() + write_default_manifest(base_home) + write_workspace_manifest(manifest_path, repos=" - name: broken") + broken_root.mkdir(parents=True) + (broken_root / "base_manifest.yaml").write_text("project: [", encoding="utf-8") + + status, stdout, stderr = invoke_engine( + ["check", "--workspace", str(workspace), "--manifest", str(manifest_path)], + base_home, + home, + ) + + self.assertEqual(status, 1) + self.assertEqual(stderr, "") + self.assertIn("Repository: broken [error]", stdout) + self.assertIn("BASE-P002", stdout) + def test_workspace_check_reports_project_findings_and_invalid_manifests(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) diff --git a/cli/python/base_projects/tests/test_workspace_manifest.py b/cli/python/base_projects/tests/test_workspace_manifest.py new file mode 100644 index 0000000..4724a9e --- /dev/null +++ b/cli/python/base_projects/tests/test_workspace_manifest.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from base_projects.workspace_manifest import WorkspaceManifestError, read_workspace_manifest + + +def write_workspace_manifest(path: Path, body: str) -> None: + path.write_text(body, encoding="utf-8") + + +class WorkspaceManifestParserTests(unittest.TestCase): + def test_reads_workspace_manifest_with_defaults(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "workspace.yaml" + write_workspace_manifest( + path, + """ +schema_version: 1 +workspace: + name: demo-workspace +repos: + - name: base + url: git@github.com:codeforester/base.git + default_branch: master + - name: optional-tool + required: false +""", + ) + + manifest = read_workspace_manifest(path) + + self.assertEqual(manifest.path, path.resolve()) + self.assertEqual(manifest.schema_version, 1) + self.assertEqual(manifest.name, "demo-workspace") + self.assertEqual([repo.name for repo in manifest.repos], ["base", "optional-tool"]) + self.assertTrue(manifest.repos[0].required) + self.assertEqual(manifest.repos[0].url, "git@github.com:codeforester/base.git") + self.assertEqual(manifest.repos[0].default_branch, "master") + self.assertFalse(manifest.repos[1].required) + self.assertIsNone(manifest.repos[1].url) + + def test_requires_schema_version(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "workspace.yaml" + write_workspace_manifest(path, "workspace:\n name: demo\nrepos: []\n") + + with self.assertRaisesRegex(WorkspaceManifestError, "schema_version is required"): + read_workspace_manifest(path) + + def test_rejects_newer_schema_version(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "workspace.yaml" + write_workspace_manifest(path, "schema_version: 99\nworkspace:\n name: demo\nrepos: []\n") + + with self.assertRaisesRegex(WorkspaceManifestError, "newer than supported schema version 1"): + read_workspace_manifest(path) + + def test_requires_workspace_name(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "workspace.yaml" + write_workspace_manifest(path, "schema_version: 1\nworkspace: {}\nrepos: []\n") + + with self.assertRaisesRegex(WorkspaceManifestError, "workspace.name is required"): + read_workspace_manifest(path) + + def test_rejects_unknown_keys(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "workspace.yaml" + write_workspace_manifest( + path, + """ +schema_version: 1 +workspace: + name: demo +repos: [] +clone: true +""", + ) + + with self.assertRaisesRegex(WorkspaceManifestError, "unsupported top-level keys: clone"): + read_workspace_manifest(path) + + def test_rejects_duplicate_repo_names(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "workspace.yaml" + write_workspace_manifest( + path, + """ +schema_version: 1 +workspace: + name: demo +repos: + - name: base + - name: base +""", + ) + + with self.assertRaisesRegex(WorkspaceManifestError, "duplicate repo names: base"): + read_workspace_manifest(path) + + def test_rejects_repo_names_that_are_paths(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "workspace.yaml" + write_workspace_manifest( + path, + """ +schema_version: 1 +workspace: + name: demo +repos: + - name: nested/base +""", + ) + + with self.assertRaisesRegex(WorkspaceManifestError, "repos\\[1\\].name must be a directory name"): + read_workspace_manifest(path) + + def test_rejects_non_boolean_required(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "workspace.yaml" + write_workspace_manifest( + path, + """ +schema_version: 1 +workspace: + name: demo +repos: + - name: base + required: "yes" +""", + ) + + with self.assertRaisesRegex(WorkspaceManifestError, "repos\\[1\\].required must be a boolean"): + read_workspace_manifest(path) diff --git a/cli/python/base_projects/tests/test_workspace_status_manifest.py b/cli/python/base_projects/tests/test_workspace_status_manifest.py new file mode 100644 index 0000000..f58126b --- /dev/null +++ b/cli/python/base_projects/tests/test_workspace_status_manifest.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import io +import json +import os +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest import mock + +from base_projects import engine + + +def write_manifest(project_root: Path, name: str) -> None: + project_root.mkdir(parents=True) + (project_root / "base_manifest.yaml").write_text( + f"project:\n name: {name}\nartifacts: []\n", + encoding="utf-8", + ) + + +def write_workspace_manifest(path: Path) -> None: + path.write_text( + "\n".join( + [ + "schema_version: 1", + "workspace:", + " name: demo-suite", + "repos:", + " - name: base", + " url: git@github.com:codeforester/base.git", + " default_branch: master", + " - name: docs", + " - name: api", + " required: true", + " - name: optional-tool", + " required: false", + "", + ] + ), + encoding="utf-8", + ) + + +def invoke_engine(args: list[str], base_home: Path, home: Path) -> tuple[int, str, str]: + stdout = io.StringIO() + stderr = io.StringIO() + env = { + "HOME": str(home), + "BASE_HOME": str(base_home), + "BASE_PROJECT": "", + "BASE_PROJECT_MANIFEST": "", + } + with mock.patch.dict(os.environ, env): + with redirect_stdout(stdout), redirect_stderr(stderr): + status = engine.main(args) + return status, stdout.getvalue(), stderr.getvalue() + + +class WorkspaceStatusManifestTests(unittest.TestCase): + def test_workspace_status_manifest_reports_expected_missing_and_extra_repositories(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + home = root / "home" + workspace = root / "workspace" + base_home = root / "base" + manifest_path = root / "workspace.yaml" + home.mkdir() + base_home.mkdir() + write_workspace_manifest(manifest_path) + write_manifest(workspace / "base", "base") + (workspace / "docs").mkdir(parents=True) + write_manifest(workspace / "extra", "extra") + python_bin = home / ".base.d" / "base" / ".venv" / "bin" / "python" + python_bin.parent.mkdir(parents=True) + python_bin.write_text("#!/usr/bin/env python\n", encoding="utf-8") + + status, stdout, stderr = invoke_engine( + ["status", "--workspace", str(workspace), "--manifest", str(manifest_path)], + base_home, + home, + ) + + self.assertEqual(status, 1) + self.assertEqual(stderr, "") + self.assertIn(f"Workspace: {workspace.resolve()} (5 repositories)", stdout) + self.assertIn(f"Workspace manifest: {manifest_path.resolve()} (demo-suite)", stdout) + self.assertIn("base ok", stdout) + self.assertIn("docs ok", stdout) + self.assertIn("api error", stdout) + self.assertIn("optional-tool warn", stdout) + self.assertIn("extra warn", stdout) + self.assertIn("3 repositories need attention", stdout) + + def test_workspace_status_manifest_supports_json_format(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + home = root / "home" + workspace = root / "workspace" + base_home = root / "base" + manifest_path = root / "workspace.yaml" + home.mkdir() + base_home.mkdir() + write_workspace_manifest(manifest_path) + write_manifest(workspace / "base", "base") + (workspace / "docs").mkdir(parents=True) + python_bin = home / ".base.d" / "base" / ".venv" / "bin" / "python" + python_bin.parent.mkdir(parents=True) + python_bin.write_text("#!/usr/bin/env python\n", encoding="utf-8") + + status, stdout, stderr = invoke_engine( + [ + "status", + "--workspace", + str(workspace), + "--manifest", + str(manifest_path), + "--format", + "json", + ], + base_home, + home, + ) + + payload = json.loads(stdout) + projects_by_repo = {project["repository"]: project for project in payload["projects"]} + self.assertEqual(status, 1) + self.assertEqual(stderr, "") + self.assertEqual(payload["workspace"], str(workspace.resolve())) + self.assertEqual(payload["workspace_manifest"]["path"], str(manifest_path.resolve())) + self.assertEqual(payload["workspace_manifest"]["name"], "demo-suite") + self.assertEqual(payload["repository_count"], 4) + self.assertEqual(projects_by_repo["base"]["status"], "ok") + self.assertEqual(projects_by_repo["base"]["required"], True) + self.assertEqual(projects_by_repo["base"]["repo"], "present") + self.assertEqual(projects_by_repo["base"]["url"], "git@github.com:codeforester/base.git") + self.assertEqual(projects_by_repo["docs"]["manifest"], "missing") + self.assertEqual(projects_by_repo["docs"]["venv"], "not_applicable") + self.assertEqual(projects_by_repo["api"]["status"], "error") + self.assertEqual(projects_by_repo["api"]["repo"], "missing") + self.assertEqual(projects_by_repo["optional-tool"]["status"], "warn") + self.assertEqual(projects_by_repo["optional-tool"]["required"], False) diff --git a/cli/python/base_projects/workspace_manifest.py b/cli/python/base_projects/workspace_manifest.py new file mode 100644 index 0000000..d391955 --- /dev/null +++ b/cli/python/base_projects/workspace_manifest.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +try: + import yaml +except ImportError as exc: + yaml = None + _yaml_import_error = exc +else: + _yaml_import_error = None + + +CURRENT_WORKSPACE_MANIFEST_SCHEMA_VERSION = 1 + + +class WorkspaceManifestError(ValueError): + pass + + +@dataclass(frozen=True) +class WorkspaceManifestRepo: + name: str + url: str | None = None + default_branch: str | None = None + required: bool = True + + +@dataclass(frozen=True) +class WorkspaceManifest: + path: Path + name: str + repos: tuple[WorkspaceManifestRepo, ...] + schema_version: int = CURRENT_WORKSPACE_MANIFEST_SCHEMA_VERSION + + +def read_workspace_manifest(path: Path) -> WorkspaceManifest: + if yaml is None: + raise WorkspaceManifestError( + "PyYAML is required to read workspace manifests. " + "Run 'basectl setup' to install Base Python bootstrap dependencies." + ) from _yaml_import_error + + resolved_path = path.expanduser().resolve() + try: + data = yaml.safe_load(resolved_path.read_text(encoding="utf-8")) + except OSError as exc: + raise WorkspaceManifestError(f"{resolved_path}: unable to read workspace manifest: {exc}") from exc + except yaml.YAMLError as exc: + raise WorkspaceManifestError(f"{resolved_path}: invalid YAML: {exc}") from exc + + if not isinstance(data, dict): + raise WorkspaceManifestError(f"{resolved_path}: workspace manifest must be a YAML mapping.") + + allowed_top_level = {"schema_version", "workspace", "repos"} + unknown_top_level = sorted(set(data) - allowed_top_level) + if unknown_top_level: + raise WorkspaceManifestError( + f"{resolved_path}: unsupported top-level keys: {', '.join(unknown_top_level)}." + ) + + schema_version = _read_schema_version(resolved_path, data.get("schema_version")) + workspace_name = _read_workspace_name(resolved_path, data.get("workspace")) + repos = _read_repos(resolved_path, data.get("repos")) + + return WorkspaceManifest( + path=resolved_path, + name=workspace_name, + repos=repos, + schema_version=schema_version, + ) + + +def _read_schema_version(path: Path, schema_version_data: Any) -> int: + if schema_version_data is None: + raise WorkspaceManifestError(f"{path}: schema_version is required.") + if isinstance(schema_version_data, bool) or not isinstance(schema_version_data, int): + raise WorkspaceManifestError(f"{path}: schema_version must be an integer.") + if schema_version_data < 1: + raise WorkspaceManifestError(f"{path}: schema_version must be greater than or equal to 1.") + if schema_version_data > CURRENT_WORKSPACE_MANIFEST_SCHEMA_VERSION: + raise WorkspaceManifestError( + f"{path}: schema_version {schema_version_data} is newer than supported schema version " + f"{CURRENT_WORKSPACE_MANIFEST_SCHEMA_VERSION}. Upgrade Base to read this workspace manifest." + ) + return schema_version_data + + +def _read_workspace_name(path: Path, workspace_data: Any) -> str: + if not isinstance(workspace_data, dict): + raise WorkspaceManifestError(f"{path}: workspace must be a mapping.") + + allowed_workspace_keys = {"name"} + unknown_workspace_keys = sorted(set(workspace_data) - allowed_workspace_keys) + if unknown_workspace_keys: + raise WorkspaceManifestError(f"{path}: unsupported workspace keys: {', '.join(unknown_workspace_keys)}.") + + name = workspace_data.get("name") + if not isinstance(name, str) or not name.strip(): + raise WorkspaceManifestError(f"{path}: workspace.name is required.") + return name.strip() + + +def _read_repos(path: Path, repos_data: Any) -> tuple[WorkspaceManifestRepo, ...]: + if not isinstance(repos_data, list): + raise WorkspaceManifestError(f"{path}: repos is required and must be a list.") + + repos = tuple(_read_repo(path, index, repo_data) for index, repo_data in enumerate(repos_data, start=1)) + return _validate_unique_repo_names(path, repos) + + +def _read_repo(path: Path, index: int, repo_data: Any) -> WorkspaceManifestRepo: + if not isinstance(repo_data, dict): + raise WorkspaceManifestError(f"{path}: repos[{index}] must be a mapping.") + + allowed_repo_keys = {"name", "url", "default_branch", "required"} + unknown_repo_keys = sorted(set(repo_data) - allowed_repo_keys) + if unknown_repo_keys: + raise WorkspaceManifestError( + f"{path}: repos[{index}] has unsupported keys: {', '.join(unknown_repo_keys)}." + ) + + name = _read_repo_name(path, index, repo_data.get("name")) + url = _read_optional_string(path, f"repos[{index}].url", repo_data.get("url")) + default_branch = _read_optional_string( + path, + f"repos[{index}].default_branch", + repo_data.get("default_branch"), + ) + required = _read_required(path, index, repo_data.get("required")) + + return WorkspaceManifestRepo( + name=name, + url=url, + default_branch=default_branch, + required=required, + ) + + +def _read_repo_name(path: Path, index: int, name_data: Any) -> str: + if not isinstance(name_data, str) or not name_data.strip(): + raise WorkspaceManifestError(f"{path}: repos[{index}].name is required.") + + name = name_data.strip() + if name in (".", "..") or "/" in name or "\\" in name: + raise WorkspaceManifestError(f"{path}: repos[{index}].name must be a directory name, not a path.") + return name + + +def _read_optional_string(path: Path, field: str, value: Any) -> str | None: + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise WorkspaceManifestError(f"{path}: {field} must be a non-empty string when provided.") + return value.strip() + + +def _read_required(path: Path, index: int, required_data: Any) -> bool: + if required_data is None: + return True + if not isinstance(required_data, bool): + raise WorkspaceManifestError(f"{path}: repos[{index}].required must be a boolean.") + return required_data + + +def _validate_unique_repo_names( + path: Path, + repos: tuple[WorkspaceManifestRepo, ...], +) -> tuple[WorkspaceManifestRepo, ...]: + seen: set[str] = set() + duplicates: list[str] = [] + for repo in repos: + if repo.name in seen: + duplicates.append(repo.name) + else: + seen.add(repo.name) + if duplicates: + raise WorkspaceManifestError(f"{path}: duplicate repo names: {', '.join(sorted(set(duplicates)))}.") + return repos diff --git a/cli/python/base_projects/workspace_reports.py b/cli/python/base_projects/workspace_reports.py new file mode 100644 index 0000000..85a2316 --- /dev/null +++ b/cli/python/base_projects/workspace_reports.py @@ -0,0 +1,781 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from dataclasses import replace +from pathlib import Path +from typing import Any + +import base_cli +from base_cli.paths import base_state_root +from base_projects.workspace_manifest import WorkspaceManifest +from base_projects.workspace_manifest import WorkspaceManifestRepo +from base_projects.workspace_manifest import read_workspace_manifest +from base_setup.checks import ArtifactCheck +from base_setup.checks import DIAGNOSTIC_JSON_SCHEMA_VERSION +from base_setup.checks import check_to_doctor_json +from base_setup.checks import check_to_json +from base_setup.checks import checks_status +from base_setup.checks import doctor_status +from base_setup.checks import print_doctor_finding +from base_setup.engine import manifest_checks +from base_setup.engine import pre_venv_manifest_checks +from base_setup.engine import read_default_manifest +from base_setup.manifest import BaseManifest, ManifestError, read_manifest + + +class ProjectDiscoveryError(RuntimeError): + pass + + +@dataclass(frozen=True) +class ManifestEntry: + path: Path + mtime_ns: int + size: int + + +@dataclass(frozen=True) +class WorkspaceProjectStatus: + name: str + root: Path + manifest_path: Path | None + status: str + venv: str + manifest: str + issues: tuple[str, ...] + expected: bool = False + required: bool = False + repo: str = "present" + repository: str | None = None + url: str | None = None + default_branch: str | None = None + + +@dataclass(frozen=True) +class WorkspaceProjectCheckResult: + name: str + root: Path + manifest_path: Path | None + manifest: str + status: str + checks: tuple[ArtifactCheck, ...] + expected: bool = False + required: bool = False + repo: str = "present" + repository: str | None = None + url: str | None = None + default_branch: str | None = None + + +def resolve_workspace_manifest(workspace_manifest: str | None) -> WorkspaceManifest | None: + if workspace_manifest is None: + return None + return read_workspace_manifest(Path(workspace_manifest)) + + +def workspace_project_statuses( + workspace_root: Path, + workspace_manifest: WorkspaceManifest | None = None, +) -> tuple[WorkspaceProjectStatus, ...]: + if workspace_manifest is None: + return tuple(workspace_project_status(entry) for entry in workspace_manifest_entries(workspace_root)) + return workspace_manifest_project_statuses(workspace_root, workspace_manifest) + + +def workspace_manifest_project_statuses( + workspace_root: Path, + workspace_manifest: WorkspaceManifest, +) -> tuple[WorkspaceProjectStatus, ...]: + entries_by_repo = { + entry.path.parent.resolve().name: entry + for entry in workspace_manifest_entries(workspace_root) + } + statuses: list[WorkspaceProjectStatus] = [] + + for repo in workspace_manifest.repos: + entry = entries_by_repo.pop(repo.name, None) + statuses.append(workspace_expected_repo_status(workspace_root, repo, entry)) + + for repo_name in sorted(entries_by_repo): + statuses.append(workspace_extra_project_status(entries_by_repo[repo_name])) + + return tuple(statuses) + + +def workspace_expected_repo_status( + workspace_root: Path, + repo: WorkspaceManifestRepo, + entry: ManifestEntry | None, +) -> WorkspaceProjectStatus: + root = (workspace_root / repo.name).resolve() + if entry is not None: + status = workspace_project_status(entry) + return attach_status_repo_metadata(status, repo) + if root.exists(): + return WorkspaceProjectStatus( + name=repo.name, + root=root, + manifest_path=None, + status="ok", + venv="not_applicable", + manifest="missing", + issues=(), + expected=True, + required=repo.required, + repo="present", + repository=repo.name, + url=repo.url, + default_branch=repo.default_branch, + ) + + return WorkspaceProjectStatus( + name=repo.name, + root=root, + manifest_path=None, + status="error" if repo.required else "warn", + venv="unknown", + manifest="unknown", + issues=(missing_repo_message(repo, root),), + expected=True, + required=repo.required, + repo="missing", + repository=repo.name, + url=repo.url, + default_branch=repo.default_branch, + ) + + +def attach_status_repo_metadata( + status: WorkspaceProjectStatus, + repo: WorkspaceManifestRepo, +) -> WorkspaceProjectStatus: + return replace( + status, + expected=True, + required=repo.required, + repo="present", + repository=repo.name, + url=repo.url, + default_branch=repo.default_branch, + ) + + +def workspace_extra_project_status(entry: ManifestEntry) -> WorkspaceProjectStatus: + status = workspace_project_status(entry) + return replace( + status, + status=most_severe_status(status.status, "warn"), + issues=status.issues + ("discovered Base-managed project is not listed in the workspace manifest",), + expected=False, + required=False, + repo="present", + repository=entry.path.parent.resolve().name, + ) + + +def workspace_project_status(entry: ManifestEntry) -> WorkspaceProjectStatus: + root = entry.path.parent.resolve() + try: + manifest = read_manifest(entry.path) + except ManifestError as exc: + return WorkspaceProjectStatus( + name=root.name, + root=root, + manifest_path=entry.path.resolve(), + status="error", + venv="unknown", + manifest="invalid", + issues=(str(exc),), + ) + + venv_dir = project_venv_dir(manifest.project_name) + if project_venv_ready(venv_dir): + return WorkspaceProjectStatus( + name=manifest.project_name, + root=root, + manifest_path=entry.path.resolve(), + status="ok", + venv="ready", + manifest="valid", + issues=(), + ) + + return WorkspaceProjectStatus( + name=manifest.project_name, + root=root, + manifest_path=entry.path.resolve(), + status="warn", + venv="missing", + manifest="valid", + issues=(f"project virtual environment missing at {venv_dir}",), + ) + + +def project_venv_dir(project_name: str) -> Path: + return base_state_root() / project_name / ".venv" + + +def project_venv_ready(venv_dir: Path) -> bool: + return (venv_dir / "bin" / "python").is_file() + + +def workspace_project_check_results( + ctx: base_cli.Context, + workspace_root: Path, + workspace_manifest: WorkspaceManifest | None = None, +) -> tuple[WorkspaceProjectCheckResult, ...]: + default_manifest = read_default_manifest(ctx) + if workspace_manifest is None: + return tuple( + workspace_project_check_result(entry, default_manifest) + for entry in workspace_manifest_entries(workspace_root) + ) + return workspace_manifest_project_check_results(workspace_root, workspace_manifest, default_manifest) + + +def workspace_manifest_project_check_results( + workspace_root: Path, + workspace_manifest: WorkspaceManifest, + default_manifest: BaseManifest, +) -> tuple[WorkspaceProjectCheckResult, ...]: + entries_by_repo = { + entry.path.parent.resolve().name: entry + for entry in workspace_manifest_entries(workspace_root) + } + results: list[WorkspaceProjectCheckResult] = [] + + for repo in workspace_manifest.repos: + entry = entries_by_repo.pop(repo.name, None) + results.append(workspace_expected_repo_check_result(workspace_root, repo, entry, default_manifest)) + + for repo_name in sorted(entries_by_repo): + results.append(workspace_extra_project_check_result(entries_by_repo[repo_name], default_manifest)) + + return tuple(results) + + +def workspace_expected_repo_check_result( + workspace_root: Path, + repo: WorkspaceManifestRepo, + entry: ManifestEntry | None, + default_manifest: BaseManifest, +) -> WorkspaceProjectCheckResult: + root = (workspace_root / repo.name).resolve() + if entry is not None: + result = workspace_project_check_result(entry, default_manifest) + checks = (workspace_repo_presence_check(repo, root, present=True),) + result.checks + return attach_check_result_repo_metadata( + result, + repo, + checks=checks, + status=checks_status(checks), + ) + if root.exists(): + checks = (workspace_non_base_repo_check(repo, root),) + return WorkspaceProjectCheckResult( + name=repo.name, + root=root, + manifest_path=None, + manifest="missing", + status=checks_status(checks), + checks=checks, + expected=True, + required=repo.required, + repo="present", + repository=repo.name, + url=repo.url, + default_branch=repo.default_branch, + ) + + checks = (workspace_repo_presence_check(repo, root, present=False),) + return WorkspaceProjectCheckResult( + name=repo.name, + root=root, + manifest_path=None, + manifest="unknown", + status=checks_status(checks), + checks=checks, + expected=True, + required=repo.required, + repo="missing", + repository=repo.name, + url=repo.url, + default_branch=repo.default_branch, + ) + + +def attach_check_result_repo_metadata( + result: WorkspaceProjectCheckResult, + repo: WorkspaceManifestRepo, + checks: tuple[ArtifactCheck, ...], + status: str, +) -> WorkspaceProjectCheckResult: + return replace( + result, + status=status, + checks=checks, + expected=True, + required=repo.required, + repo="present", + repository=repo.name, + url=repo.url, + default_branch=repo.default_branch, + ) + + +def workspace_extra_project_check_result( + entry: ManifestEntry, + default_manifest: BaseManifest, +) -> WorkspaceProjectCheckResult: + result = workspace_project_check_result(entry, default_manifest) + checks = (workspace_extra_project_check(result),) + result.checks + return replace( + result, + status=checks_status(checks), + checks=checks, + expected=False, + required=False, + repo="present", + repository=entry.path.parent.resolve().name, + ) + + +def workspace_project_check_result( + entry: ManifestEntry, + default_manifest: BaseManifest, +) -> WorkspaceProjectCheckResult: + root = entry.path.parent.resolve() + manifest_path = entry.path.resolve() + try: + manifest = read_manifest(entry.path) + except ManifestError as exc: + checks = (invalid_manifest_check(str(exc)),) + return WorkspaceProjectCheckResult( + name=root.name, + root=root, + manifest_path=manifest_path, + manifest="invalid", + status="error", + checks=checks, + ) + + venv_check = project_venv_check(manifest.project_name) + if venv_check.ok: + checks = (venv_check,) + manifest_checks(default_manifest, manifest) + else: + checks = pre_venv_manifest_checks(manifest) + (venv_check,) + + return WorkspaceProjectCheckResult( + name=manifest.project_name, + root=root, + manifest_path=manifest_path, + manifest="valid", + status=checks_status(checks), + checks=checks, + ) + + +def invalid_manifest_check(message: str) -> ArtifactCheck: + return ArtifactCheck( + name="project_manifest", + ok=False, + message=message, + fix="Fix base_manifest.yaml syntax and schema.", + status="error", + finding_id="BASE-P002", + ) + + +def workspace_repo_presence_check(repo: WorkspaceManifestRepo, root: Path, present: bool) -> ArtifactCheck: + if present: + return ArtifactCheck( + name="workspace_repository_presence", + ok=True, + message=f"Repository '{repo.name}' is present at '{root}'.", + fix="", + finding_id="BASE-W010", + details=workspace_repo_check_details(repo, root, present=True), + ) + + status = "error" if repo.required else "warn" + return ArtifactCheck( + name="workspace_repository_presence", + ok=False, + message=missing_repo_message(repo, root), + fix=missing_repo_fix(repo, root), + status=status, + finding_id="BASE-W010", + details=workspace_repo_check_details(repo, root, present=False), + ) + + +def workspace_extra_project_check(result: WorkspaceProjectCheckResult) -> ArtifactCheck: + repository = result.repository or result.root.name + return ArtifactCheck( + name="workspace_manifest_membership", + ok=False, + message=f"Discovered Base-managed project '{repository}' is not listed in the workspace manifest.", + fix=f"Add '{repository}' to the workspace manifest if it belongs in this workspace.", + status="warn", + finding_id="BASE-W011", + details={ + "repository": repository, + "path": str(result.root), + "expected": False, + "present": True, + }, + ) + + +def workspace_non_base_repo_check(repo: WorkspaceManifestRepo, root: Path) -> ArtifactCheck: + return ArtifactCheck( + name="workspace_project_manifest", + ok=True, + message=( + f"Repository '{repo.name}' is present at '{root}' but does not contain base_manifest.yaml; " + "project diagnostics skipped." + ), + fix="", + finding_id="BASE-W012", + details=workspace_repo_check_details(repo, root, present=True), + ) + + +def workspace_repo_check_details(repo: WorkspaceManifestRepo, root: Path, present: bool) -> dict[str, Any]: + details: dict[str, Any] = { + "repository": repo.name, + "path": str(root), + "required": repo.required, + "present": present, + } + if repo.url is not None: + details["url"] = repo.url + if repo.default_branch is not None: + details["default_branch"] = repo.default_branch + return details + + +def missing_repo_message(repo: WorkspaceManifestRepo, root: Path) -> str: + requirement = "Required" if repo.required else "Optional" + return f"{requirement} repository '{repo.name}' is missing at '{root}'." + + +def missing_repo_fix(repo: WorkspaceManifestRepo, root: Path) -> str: + if repo.url: + return f"Clone '{repo.url}' into '{root}'." + return f"Create or clone repository '{repo.name}' into '{root}'." + + +def most_severe_status(*statuses: str) -> str: + if "error" in statuses: + return "error" + if "warn" in statuses: + return "warn" + return "ok" + + +def project_venv_check(project_name: str) -> ArtifactCheck: + venv_dir = project_venv_dir(project_name) + if project_venv_ready(venv_dir): + return ArtifactCheck( + name="project_virtualenv", + ok=True, + message=f"Project virtual environment is ready at '{venv_dir}'.", + fix="", + finding_id="BASE-P050", + ) + + return ArtifactCheck( + name="project_virtualenv", + ok=False, + message=f"Project virtual environment is missing or incomplete at '{venv_dir}'.", + fix=f"Run 'basectl setup {project_name} --recreate-venv' to recreate the project virtual environment.", + status="error", + finding_id="BASE-P050", + ) + + +def workspace_error_count(results: tuple[WorkspaceProjectCheckResult, ...]) -> int: + return sum(1 for result in results for check in result.checks if doctor_status(check) == "error") + + +def workspace_status_to_json( + workspace_root: Path, + statuses: tuple[WorkspaceProjectStatus, ...], + workspace_manifest: WorkspaceManifest | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "workspace": str(workspace_root), + "project_count": workspace_project_count(statuses, workspace_manifest), + "projects": [workspace_status_item_to_json(status, workspace_manifest) for status in statuses], + } + add_workspace_manifest_json(payload, statuses, workspace_manifest) + return payload + + +def workspace_status_item_to_json( + status: WorkspaceProjectStatus, + workspace_manifest: WorkspaceManifest | None, +) -> dict[str, Any]: + item: dict[str, Any] = { + "name": status.name, + "status": status.status, + "path": str(status.root), + "manifest_path": str(status.manifest_path) if status.manifest_path is not None else None, + "venv": status.venv, + "manifest": status.manifest, + "issues": list(status.issues), + } + if workspace_manifest is not None: + item.update(workspace_manifest_item_metadata(status)) + return item + + +def workspace_check_to_json( + workspace_root: Path, + results: tuple[WorkspaceProjectCheckResult, ...], + workspace_manifest: WorkspaceManifest | None = None, +) -> dict[str, Any]: + return workspace_checks_to_json(workspace_root, results, doctor=False, workspace_manifest=workspace_manifest) + + +def workspace_doctor_to_json( + workspace_root: Path, + results: tuple[WorkspaceProjectCheckResult, ...], + workspace_manifest: WorkspaceManifest | None = None, +) -> dict[str, Any]: + return workspace_checks_to_json(workspace_root, results, doctor=True, workspace_manifest=workspace_manifest) + + +def workspace_checks_to_json( + workspace_root: Path, + results: tuple[WorkspaceProjectCheckResult, ...], + doctor: bool, + workspace_manifest: WorkspaceManifest | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "schema_version": DIAGNOSTIC_JSON_SCHEMA_VERSION, + "workspace": str(workspace_root), + "status": checks_status(tuple(check for result in results for check in result.checks)), + "project_count": workspace_project_count(results, workspace_manifest), + "projects": [workspace_check_result_to_json(result, doctor, workspace_manifest) for result in results], + } + add_workspace_manifest_json(payload, results, workspace_manifest) + return payload + + +def workspace_check_result_to_json( + result: WorkspaceProjectCheckResult, + doctor: bool, + workspace_manifest: WorkspaceManifest | None, +) -> dict[str, Any]: + item: dict[str, Any] = { + "name": result.name, + "status": result.status, + "path": str(result.root), + "manifest_path": str(result.manifest_path) if result.manifest_path is not None else None, + "manifest": result.manifest, + "checks": [workspace_check_item_to_json(check, doctor) for check in result.checks], + } + if workspace_manifest is not None: + item.update(workspace_manifest_item_metadata(result)) + return item + + +def workspace_manifest_item_metadata( + item: WorkspaceProjectStatus | WorkspaceProjectCheckResult, +) -> dict[str, Any]: + metadata: dict[str, Any] = { + "repository": item.repository or item.root.name, + "expected": item.expected, + "required": item.required, + "repo": item.repo, + } + if item.url is not None: + metadata["url"] = item.url + if item.default_branch is not None: + metadata["default_branch"] = item.default_branch + return metadata + + +def workspace_project_count( + items: tuple[WorkspaceProjectStatus, ...] | tuple[WorkspaceProjectCheckResult, ...], + workspace_manifest: WorkspaceManifest | None, +) -> int: + if workspace_manifest is None: + return len(items) + return sum(1 for item in items if item.manifest in ("valid", "invalid")) + + +def add_workspace_manifest_json( + payload: dict[str, Any], + items: tuple[WorkspaceProjectStatus, ...] | tuple[WorkspaceProjectCheckResult, ...], + workspace_manifest: WorkspaceManifest | None, +) -> None: + if workspace_manifest is None: + return + payload["workspace_manifest"] = { + "path": str(workspace_manifest.path), + "name": workspace_manifest.name, + "schema_version": workspace_manifest.schema_version, + } + payload["repository_count"] = len(items) + + +def workspace_check_item_to_json(check: ArtifactCheck, doctor: bool) -> dict[str, Any]: + if doctor: + return check_to_doctor_json(check) + return check_to_json(check) + + +def print_workspace_status( + workspace_root: Path, + statuses: tuple[WorkspaceProjectStatus, ...], + workspace_manifest: WorkspaceManifest | None = None, +) -> None: + if workspace_manifest is not None: + print_manifest_workspace_status(workspace_root, statuses, workspace_manifest) + return + + print(f"Workspace: {workspace_root} ({len(statuses)} projects)") + print() + if not statuses: + print("No Base-managed projects discovered.") + return + + print(f"{'PROJECT':<20} {'STATUS':<6} {'VENV':<8} {'MANIFEST':<8} {'LAST CHECK':<10} PATH") + for status in statuses: + print( + f"{status.name:<20} " + f"{status.status:<6} " + f"{status.venv:<8} " + f"{status.manifest:<8} " + f"{'-':<10} " + f"{status.root}" + ) + + attention_count = sum(1 for status in statuses if status.status != "ok") + if attention_count: + print(f"\n{attention_count} project(s) need attention. Run 'basectl doctor ' for details.") + else: + print("\nAll discovered projects look ok.") + + +def print_manifest_workspace_status( + workspace_root: Path, + statuses: tuple[WorkspaceProjectStatus, ...], + workspace_manifest: WorkspaceManifest, +) -> None: + print(f"Workspace: {workspace_root} ({len(statuses)} repositories)") + print(f"Workspace manifest: {workspace_manifest.path} ({workspace_manifest.name})") + print() + if not statuses: + print("No repositories reported by the workspace manifest.") + return + + print(f"{'REPOSITORY':<20} {'STATUS':<6} {'REQUIRED':<8} {'REPO':<8} {'VENV':<14} {'MANIFEST':<8} PATH") + for status in statuses: + print( + f"{status.repository or status.root.name:<20} " + f"{status.status:<6} " + f"{yes_no(status.required):<8} " + f"{status.repo:<8} " + f"{status.venv:<14} " + f"{status.manifest:<8} " + f"{status.root}" + ) + + attention_count = sum(1 for status in statuses if status.status != "ok") + if attention_count: + print(f"\n{attention_count} repositories need attention. Run 'basectl workspace doctor' for details.") + else: + print("\nAll workspace repositories look ok.") + + +def print_workspace_check( + workspace_root: Path, + results: tuple[WorkspaceProjectCheckResult, ...], + workspace_manifest: WorkspaceManifest | None = None, +) -> None: + item_name = "repositories" if workspace_manifest is not None else "projects" + print(f"Workspace check: {workspace_root} ({len(results)} {item_name})") + if workspace_manifest is not None: + print(f"Workspace manifest: {workspace_manifest.path} ({workspace_manifest.name})") + print_workspace_check_results(results, workspace_manifest) + + +def print_workspace_doctor( + workspace_root: Path, + results: tuple[WorkspaceProjectCheckResult, ...], + workspace_manifest: WorkspaceManifest | None = None, +) -> None: + item_name = "repositories" if workspace_manifest is not None else "projects" + print(f"\nWorkspace doctor: {workspace_root} ({len(results)} {item_name})") + if workspace_manifest is not None: + print(f"Workspace manifest: {workspace_manifest.path} ({workspace_manifest.name})") + print_workspace_check_results(results, workspace_manifest) + + +def print_workspace_check_results( + results: tuple[WorkspaceProjectCheckResult, ...], + workspace_manifest: WorkspaceManifest | None = None, +) -> None: + if not results: + if workspace_manifest is None: + print("\nNo Base-managed projects discovered.") + else: + print("\nNo repositories reported by the workspace manifest.") + return + + label = "Repository" if workspace_manifest is not None else "Project" + for result in results: + name = result.repository or result.name + print(f"\n{label}: {name} [{result.status}]") + print(f"Path: {result.root}") + for check in result.checks: + print_doctor_finding(doctor_status(check), check.finding_id, check.name, check.message, check.fix) + + error_count = workspace_error_count(results) + if error_count: + print(f"\nWorkspace has {error_count} error finding(s).") + return + + warn_count = sum(1 for result in results for check in result.checks if doctor_status(check) == "warn") + if warn_count: + print(f"\nWorkspace has {warn_count} warning finding(s).") + elif workspace_manifest is not None: + print("\nAll workspace repositories passed.") + else: + print("\nAll discovered projects passed.") + + +def yes_no(value: bool) -> str: + return "yes" if value else "no" + + +def workspace_manifest_entries(workspace_root: Path) -> tuple[ManifestEntry, ...]: + if not workspace_root.is_dir(): + raise ProjectDiscoveryError(f"Workspace '{workspace_root}' is not a directory.") + + entries: list[ManifestEntry] = [] + for candidate in sorted(workspace_root.iterdir(), key=lambda path: path.name): + if not candidate.is_dir(): + continue + manifest_path = candidate / "base_manifest.yaml" + if not manifest_path.is_file(): + continue + stat_result = manifest_path.stat() + entries.append( + ManifestEntry( + path=manifest_path, + mtime_ns=stat_result.st_mtime_ns, + size=stat_result.st_size, + ) + ) + + return tuple(entries) + + +def dumps_json(payload: dict[str, Any]) -> str: + return json.dumps(payload, separators=(",", ":")) diff --git a/docs/README.md b/docs/README.md index 95fcb64..8a247d3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -67,8 +67,8 @@ reference. The filename should answer "what is this about?" - [Remote Installer Policy](remote-installer-policy.md) defines the allowed remote shell installer URLs, opt-in boundaries, dry-run behavior, and logging expectations for setup paths. -- [Workspace Manifest](workspace-manifest.md) defines the future team-shared - repo-set contract and its relationship to discovered local projects. +- [Workspace Manifest](workspace-manifest.md) defines the local team-shared + repo-set contract and `basectl workspace --manifest` reporting behavior. - [Setup Hooks Boundary](setup-hooks.md) records why Base does not support arbitrary manifest setup hooks yet. - [`basectl onboard`](basectl-onboard.md) captures the guided setup experience @@ -78,7 +78,7 @@ reference. The filename should answer "what is this about?" - [`basectl check` parallelism](check-parallelism.md) records the evaluation and implementation constraints for parallel check probes. - [Doctor Finding IDs](doctor-findings.md) is the stable reference for - `BASE-D*`, `BASE-P*`, and `BASE-H*` finding identifiers emitted by + `BASE-D*`, `BASE-P*`, `BASE-H*`, and `BASE-W*` finding identifiers emitted by `basectl doctor --format json`. - [Base-managed demo project](base-managed-demo-project.md) defines the proof project criteria for showing Base's complete workspace workflow. diff --git a/docs/architecture.md b/docs/architecture.md index bb85313..30cb742 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -536,15 +536,14 @@ parent manifest rather than auto-discovering everything. ### Workspace manifest -A workspace manifest is a future team-shared repo-set contract. It is distinct -from each project's `base_manifest.yaml`: the workspace manifest says which +A workspace manifest is a team-shared repo-set contract. It is distinct from +each project's `base_manifest.yaml`: the workspace manifest says which repositories should belong together, while project manifests say how each repository participates in Base. -The initial workspace commands operate on discovered local projects only. A -future `--manifest ` option can add expected-repository awareness without -changing the current discovered-project behavior. See -[Workspace Manifest](workspace-manifest.md). +Workspace commands operate on discovered local projects by default. Supplying +`--manifest ` adds expected-repository awareness without changing the +default discovered-project behavior. See [Workspace Manifest](workspace-manifest.md). ### Caching project definitions @@ -583,9 +582,12 @@ basectl workspace doctor Workspace commands are intentionally read-only. `basectl workspace status` reports project manifest state, virtual environment state, and Git state across discovered projects, including invalid manifests without stopping the whole -scan. `basectl workspace check` and `basectl workspace doctor` run project -checks and diagnostics across discovered projects. JSON output is part of the -contract so automation and future CI smoke checks can use the same data. +scan. With `--manifest `, workspace commands also report missing required +repositories, missing optional repositories, and discovered Base-managed +projects outside the expected repo set. `basectl workspace check` and +`basectl workspace doctor` run project checks and diagnostics across discovered +projects. JSON output is part of the contract so automation and future CI smoke +checks can use the same data. Future workspace commands should follow the same principles: diff --git a/docs/doctor-findings.md b/docs/doctor-findings.md index aec9787..aad3dec 100644 --- a/docs/doctor-findings.md +++ b/docs/doctor-findings.md @@ -68,6 +68,7 @@ Doctor commands use the same diagnostic item fields. The top-level | `BASE-D` | Base runtime and developer-prerequisite findings | | `BASE-P` | Project manifest, artifact, IDE, and command-delegation findings | | `BASE-H` | Project health declaration findings | +| `BASE-W` | Workspace manifest and multi-repository findings | ## Base Runtime Findings @@ -155,6 +156,26 @@ probe network remote reachability. `git ls-remote` call, reports sanitized provider and transport details, and does not print credential-bearing remote URLs. +## Workspace Findings + +| ID | Finding | +| --- | --- | +| `BASE-W010` | Expected workspace repository presence | +| `BASE-W011` | Discovered Base-managed project outside the workspace manifest | +| `BASE-W012` | Present expected repository without a Base project manifest | + +`BASE-W010` is emitted for every expected repository when workspace check or +doctor runs with `--manifest `. It is `error` when a required repository +is missing, `warn` when an optional repository is missing, and `ok` when the +repository is present. + +`BASE-W011` reports local Base-managed projects that were discovered under the +workspace root but are not listed in the supplied workspace manifest. + +`BASE-W012` reports expected repositories that are present locally but do not +contain `base_manifest.yaml`. This is an `ok` finding because workspace +manifests do not require every repository to be Base-managed. + ## Health Findings | ID | Finding | diff --git a/docs/superpowers/plans/2026-06-09-workspace-manifests.md b/docs/superpowers/plans/2026-06-09-workspace-manifests.md new file mode 100644 index 0000000..74b495c --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-workspace-manifests.md @@ -0,0 +1,55 @@ +# Workspace Manifests Implementation Plan + +Issue: #511 +Branch: feature/511-20260609-workspace-manifests + +## Scope + +Add first runtime support for explicit local workspace manifests: + +```bash +basectl workspace status --manifest +basectl workspace check --manifest +basectl workspace doctor --manifest +``` + +The implementation is read-only. It must not clone, pull, reset, rewrite, or +otherwise mutate repositories. + +## V1 Decisions + +- `--manifest` is optional. Without it, existing discovered-only behavior and + output stay compatible. +- Workspace manifests are local files only. +- `schema_version` is required and must be `1`. +- `workspace.name` is required. +- `repos` is a required list. +- `repos[].name` is required and is the direct child directory name under the + workspace root. +- `repos[].required` defaults to `true`. +- `repos[].url` and `repos[].default_branch` are optional report metadata. +- A required repo missing from disk is an error. +- An optional repo missing from disk is a warning. +- A present expected repo without `base_manifest.yaml` is allowed and reported + as present with Base project diagnostics not applicable. +- A discovered Base-managed project not listed in the manifest is a warning. +- Invalid local project manifests are reported per project without stopping the + workspace scan. + +## Steps + +1. Add workspace manifest parser tests for valid manifests, defaults, unsupported + schema versions, missing required fields, duplicate repo names, invalid repo + names, unknown keys, and non-boolean `required`. +2. Implement `base_projects.workspace_manifest` with small immutable data + classes and `read_workspace_manifest(path)`. +3. Add `--manifest ` to the Python command surface and shell help. +4. Add manifest-aware status/check/doctor planning that combines expected repos + and discovered projects. +5. Add stable workspace finding IDs for missing required repos, missing optional + repos, extra discovered projects, and present non-Base expected repos. +6. Extend JSON and text output only when `--manifest` is supplied, keeping the + no-manifest shape stable. +7. Update docs and changelog. +8. Validate targeted tests, BATS wrapper tests, `git diff --check`, and + `env -u BASE_HOME ./bin/base-test`. diff --git a/docs/workspace-manifest.md b/docs/workspace-manifest.md index a72d5ac..345f227 100644 --- a/docs/workspace-manifest.md +++ b/docs/workspace-manifest.md @@ -1,11 +1,12 @@ # Workspace Manifest Base uses "workspace" in a precise way: a workspace is a local directory that -contains sibling repositories. A workspace manifest is a future optional file +contains sibling repositories. A workspace manifest is an optional local file that describes which repositories are expected to belong to that workspace. -This document defines the model before implementation. It does not add runtime -behavior by itself. +Read-only workspace commands can use a manifest when the user supplies +`--manifest `. Without that flag, workspace commands keep their +discovered-project behavior. ## Vocabulary @@ -24,19 +25,20 @@ A Base-managed project is a discovered repository with a `base_manifest.yaml`. The project manifest remains the source of truth for that repository's setup, activation, commands, tests, demo, IDE requirements, and health declarations. -A workspace manifest is a future team-shared contract that lists repositories -that should exist in a workspace. It answers "which repos belong together?", -not "how does each repo set itself up?" +A workspace manifest is a team-shared contract that lists repositories that +should exist in a workspace. It answers "which repos belong together?", not +"how does each repo set itself up?" An expected repository is listed in the workspace manifest. It may or may not exist locally yet. A discovered project exists locally and has `base_manifest.yaml`. It may or may -not be listed in a future workspace manifest. +not be listed in a workspace manifest. ## Current Behavior -Current workspace commands operate on discovered local repositories only: +Workspace commands operate on discovered local repositories when no manifest is +supplied: ```bash basectl projects list @@ -45,10 +47,9 @@ basectl workspace check basectl workspace doctor ``` -They do not read a workspace manifest and do not report missing expected -repositories. That is intentional. The discovered-project model is useful today -and does not require Base to make team onboarding, clone, update, or trust -decisions. +With `--manifest `, the same commands also report expected repositories, +missing required and optional repositories, and discovered Base-managed +projects outside the manifest. ## Design Goal @@ -69,7 +70,7 @@ Each repository still owns its own `base_manifest.yaml`. The workspace manifest must not duplicate project setup, test, run, activation, demo, or health contracts. -## Candidate Shape +## Manifest Shape ```yaml schema_version: 1 @@ -94,17 +95,18 @@ repos: required: true ``` -`schema_version` should be required before implementation. Versioning the -contract early lets future Base versions reject unsupported workspace manifest -shapes with clear upgrade guidance. +`schema_version` is required. Versioning the contract early lets future Base +versions reject unsupported workspace manifest shapes with clear upgrade +guidance. `workspace.name` is a human-facing name for reports and onboarding output. `repos[].name` is the local directory name under the workspace root and the stable identifier used in reports. -`repos[].url` is a Git clone URL. Base should pass it to Git when clone support -exists later; Base should not parse credentials or manage authentication. +`repos[].url` is optional v1 report metadata for a Git clone URL. Base may pass +it to Git when clone support exists later; Base should not parse credentials or +manage authentication. `repos[].default_branch` is advisory metadata for reports and future clone validation. It should default to the remote's default branch when omitted, but @@ -116,7 +118,7 @@ status reports without failing the whole workspace when they are absent. ## Location -The first implementation should support an explicit local file: +The v1 implementation supports an explicit local file: ```bash basectl workspace status --manifest ~/work/workspace.yaml @@ -214,11 +216,20 @@ The workspace manifest should not: - require every repository in the workspace to share one language stack - introduce nested project discovery or manifest inheritance -## Implementation Sequence +## V1 Runtime Behavior -1. Keep current commands working against discovered local projects. -2. Add parser and validation support for a local workspace manifest. -3. Add `--manifest ` to read-only workspace commands. -4. Report expected, missing, discovered, and extra repositories. -5. Design explicit clone/onboard behavior only after read-only reporting proves - useful. +`basectl workspace status --manifest ` reports one row per expected +repository, plus discovered Base-managed projects that are outside the manifest. +Missing required repositories are errors. Missing optional repositories are +warnings. Present repositories without `base_manifest.yaml` are allowed and +reported with project diagnostics skipped. + +`basectl workspace check --manifest ` and +`basectl workspace doctor --manifest ` include normal project diagnostics +for present Base-managed projects. They also emit stable workspace findings for +repository presence, outside-manifest discovered projects, and present +repositories without a Base project manifest. + +The read-only v1 implementation is intentionally the foundation for future +clone/onboard behavior. Explicit clone or update commands should be designed +only after this reporting model proves useful. diff --git a/lib/shell/completions/basectl_completion.sh b/lib/shell/completions/basectl_completion.sh index fbac3da..3ac2782 100644 --- a/lib/shell/completions/basectl_completion.sh +++ b/lib/shell/completions/basectl_completion.sh @@ -93,7 +93,7 @@ _base_basectl_completion() { if ((COMP_CWORD == 2)); then _base_basectl_completion_compgen "status check doctor" "$cur" else - _base_basectl_completion_compgen "--workspace --format -v -h --help" "$cur" + _base_basectl_completion_compgen "--workspace --manifest --format -v -h --help" "$cur" fi ;; setup) diff --git a/lib/shell/completions/basectl_completion.zsh b/lib/shell/completions/basectl_completion.zsh index bf6a567..88c1ed4 100644 --- a/lib/shell/completions/basectl_completion.zsh +++ b/lib/shell/completions/basectl_completion.zsh @@ -63,6 +63,7 @@ _base_basectl_completion() { workspace) _arguments '1:workspace command:(status check doctor)' \ '--workspace[Workspace directory to scan]:path:_files' \ + '--manifest[Local workspace manifest]:path:_files' \ '--format[Output format]:format:(text json)' \ '-v[Enable DEBUG logging]' '(-h --help)'{-h,--help}'[Show help text]' ;;