From 86feb9c2dd5f778e4141104ab0915fbc5fb06e2e Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Thu, 7 May 2026 23:33:33 +0200 Subject: [PATCH 1/4] fix runtime module discovery reliability --- .github/workflows/pr-orchestrator.yml | 6 + CHANGELOG.md | 13 + openspec/CHANGE_ORDER.md | 1 + .../.openspec.yaml | 2 + .../TDD_EVIDENCE.md | 43 +++ .../proposal.md | 46 +++ .../environment-manager-detection/spec.md | 43 +++ .../spec.md | 22 ++ .../specs/module-installation/spec.md | 14 + .../specs/module-owned-ide-prompts/spec.md | 12 + .../runtime-01-discovery-reliability/tasks.md | 33 ++ pyproject.toml | 3 +- scripts/runtime_discovery_smoke.py | 308 ++++++++++++++++++ setup.py | 2 +- src/__init__.py | 2 +- src/specfact_cli/__init__.py | 2 +- src/specfact_cli/cli.py | 5 + .../modules/init/module-package.yaml | 5 +- src/specfact_cli/modules/init/src/commands.py | 19 +- .../module_registry/module-package.yaml | 5 +- .../modules/module_registry/src/commands.py | 4 + .../registry/module_availability.py | 4 + src/specfact_cli/registry/module_packages.py | 64 +++- src/specfact_cli/utils/env_manager.py | 203 +++++++++--- tests/e2e/test_init_command.py | 51 +++ .../scripts/test_runtime_discovery_smoke.py | 58 ++++ .../registry/test_module_packages.py | 128 ++++++++ tests/unit/utils/test_env_manager.py | 38 ++- 28 files changed, 1068 insertions(+), 68 deletions(-) create mode 100644 openspec/changes/runtime-01-discovery-reliability/.openspec.yaml create mode 100644 openspec/changes/runtime-01-discovery-reliability/TDD_EVIDENCE.md create mode 100644 openspec/changes/runtime-01-discovery-reliability/proposal.md create mode 100644 openspec/changes/runtime-01-discovery-reliability/specs/environment-manager-detection/spec.md create mode 100644 openspec/changes/runtime-01-discovery-reliability/specs/installed-runtime-module-discovery/spec.md create mode 100644 openspec/changes/runtime-01-discovery-reliability/specs/module-installation/spec.md create mode 100644 openspec/changes/runtime-01-discovery-reliability/specs/module-owned-ide-prompts/spec.md create mode 100644 openspec/changes/runtime-01-discovery-reliability/tasks.md create mode 100644 scripts/runtime_discovery_smoke.py create mode 100644 tests/integration/scripts/test_runtime_discovery_smoke.py diff --git a/.github/workflows/pr-orchestrator.yml b/.github/workflows/pr-orchestrator.yml index 18653d8f..62afa967 100644 --- a/.github/workflows/pr-orchestrator.yml +++ b/.github/workflows/pr-orchestrator.yml @@ -306,6 +306,12 @@ jobs: run: | mkdir -p logs/tests/junit logs/tests/coverage logs/tests/workflows + - name: Runtime discovery smoke test + if: needs.changes.outputs.skip_tests_dev_to_main != 'true' + shell: bash + run: | + python scripts/runtime_discovery_smoke.py --launcher direct --launcher pip-editable --launcher uvx + - name: Set run_unit_coverage (or skip for dev→main) id: detect-unit shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 52de3d90..fd45e6b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ All notable changes to this project will be documented in this file. --- +## [0.46.19] - 2026-05-07 + +### Fixed + +- **Runtime module discovery**: load installed module dependency `src` roots + reliably, classify load failures in availability diagnostics, and detect + environment managers in rootless monorepos. +- **Runtime discovery CI smoke**: add direct, pip-editable, and uvx install + path coverage for module install, upgrade command availability, init, and + installed `specfact code` command loading. + +--- + ## [0.46.18] - 2026-05-04 ### Fixed diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index 49828c29..82535a77 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -77,6 +77,7 @@ User-facing CLI behavior assertions and acceptance-test surface. | Order | Change | Issue | Blocked by | |---|---|---|---| +| 0 | `runtime-01-discovery-reliability` | [#552](https://github.com/nold-ai/specfact-cli/issues/552), [#553](https://github.com/nold-ai/specfact-cli/issues/553), [#554](https://github.com/nold-ai/specfact-cli/issues/554) | — | | 1 | `cli-val-03-misuse-safety-proof` | [#281](https://github.com/nold-ai/specfact-cli/issues/281) | — | | 2 | `cli-val-04-acceptance-test-runner` | [#282](https://github.com/nold-ai/specfact-cli/issues/282) | cli-val-03 | diff --git a/openspec/changes/runtime-01-discovery-reliability/.openspec.yaml b/openspec/changes/runtime-01-discovery-reliability/.openspec.yaml new file mode 100644 index 00000000..8d87be18 --- /dev/null +++ b/openspec/changes/runtime-01-discovery-reliability/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-07 diff --git a/openspec/changes/runtime-01-discovery-reliability/TDD_EVIDENCE.md b/openspec/changes/runtime-01-discovery-reliability/TDD_EVIDENCE.md new file mode 100644 index 00000000..1f1ad88c --- /dev/null +++ b/openspec/changes/runtime-01-discovery-reliability/TDD_EVIDENCE.md @@ -0,0 +1,43 @@ +# TDD Evidence: runtime-01-discovery-reliability + +## Scope Decision + +- `#552` and `#554` remain in `nold-ai/specfact-cli`: the installed `specfact-codebase` artifact is present, but core runtime loading and diagnostics decide whether `specfact code` is registered and importable. +- `#553` remains in `nold-ai/specfact-cli`: environment-manager detection and `specfact init ide` option handling are core CLI behavior. +- No transfer to `nold-ai/specfact-cli-modules` is required unless implementation proves signed module manifests or payloads must change. + +## GitHub Readiness + +- Parent feature: `#353 [Feature] Marketplace Module Distribution`. +- Change user story: `#557 [Story] Runtime Discovery Reliability for Installed Modules and Monorepos`. +- Source bug reports: `#552`, `#553`, and `#554`. +- Labels and SpecFact CLI project assignment were present on the reported issues; `#557` was created with `openspec`, `change-proposal`, `marketplace`, `dependencies`, and `module-system` labels and assigned to the SpecFact CLI project with `Todo` status. +- Corrected hierarchy on 2026-05-07: removed direct sub-issue links from bug reports to epic `#285`; linked `#557` under feature `#353`; linked `#557` as blocking `#552`, `#553`, and `#554`; commented source tracking back to all three bug reports. + +## Failing Evidence + +- `hatch run pytest tests/unit/specfact_cli/registry/test_module_packages.py::test_installed_group_loader_adds_enabled_dependency_module_src_roots tests/unit/specfact_cli/registry/test_module_packages.py::test_lazy_loader_failure_is_recorded_for_availability_diagnostics tests/unit/utils/test_env_manager.py::TestDetectEnvManager::test_detect_uv_from_path_when_no_project_markers tests/unit/utils/test_env_manager.py::TestDetectEnvManager::test_detect_uv_from_rootless_monorepo_pyproject tests/unit/utils/test_env_manager.py::TestDetectEnvManager::test_detect_uv_from_second_level_monorepo_lock tests/e2e/test_init_command.py::TestInitCommandE2E::test_init_no_warning_with_explicit_uv_env_manager tests/e2e/test_init_command.py::TestInitCommandE2E::test_init_no_warning_with_rootless_monorepo_uv -q` +- Result before production edits: 7 failed. Failures covered installed module dependency `src/` importability, lazy loader failure diagnostics, PATH/monorepo environment detection, and explicit `init ide --env-manager uv`. + +## Passing Evidence + +- `hatch run pytest tests/unit/specfact_cli/registry/test_module_packages.py::test_installed_group_loader_adds_enabled_dependency_module_src_roots tests/unit/specfact_cli/registry/test_module_packages.py::test_lazy_loader_failure_is_recorded_for_availability_diagnostics tests/unit/utils/test_env_manager.py::TestDetectEnvManager::test_detect_uv_from_path_when_no_project_markers tests/unit/utils/test_env_manager.py::TestDetectEnvManager::test_detect_uv_from_rootless_monorepo_pyproject tests/unit/utils/test_env_manager.py::TestDetectEnvManager::test_detect_uv_from_second_level_monorepo_lock tests/e2e/test_init_command.py::TestInitCommandE2E::test_init_no_warning_with_explicit_uv_env_manager tests/e2e/test_init_command.py::TestInitCommandE2E::test_init_no_warning_with_rootless_monorepo_uv -q` -> 7 passed. +- `hatch run pytest tests/unit/specfact_cli/registry/test_module_packages.py tests/unit/specfact_cli/registry/test_module_availability.py -q` -> 50 passed. +- `hatch run pytest tests/e2e/test_init_command.py -q` -> 20 passed, 2 warnings. +- `hatch run pytest tests/unit/utils/test_env_manager.py -q` -> 34 passed. +- `hatch run pytest tests/integration/test_bundle_install.py::test_installing_spec_bundle_auto_installs_project_dependency tests/integration/test_bundle_install.py::test_installing_spec_bundle_skips_dependency_when_already_present tests/unit/modules/module_registry/test_commands.py::test_install_command_project_scope_reenable_uses_selected_repo tests/unit/modules/module_registry/test_commands.py::test_install_command_project_scope_installs_to_project_modules_root tests/unit/modules/module_registry/test_official_tier_display.py::test_module_install_reports_verified_official_tier -q` -> 5 passed. +- `hatch run env HOME=/tmp/specfact-test-home-runtime-01 pytest tests/integration/test_core_slimming.py::test_fresh_install_cli_app_registered_commands_only_three_core tests/integration/test_core_slimming.py::test_stale_flat_shim_plan_exits_with_install_instructions tests/unit/cli/test_lean_help_output.py::test_stale_lazy_flat_shim_prints_install_guidance tests/unit/registry/test_category_groups.py::test_bootstrap_with_category_grouping_enabled_registers_group_commands tests/unit/registry/test_category_groups.py::test_bootstrap_with_category_grouping_disabled_still_has_no_flat_shims -q` -> 5 passed. +- Added `scripts/runtime_discovery_smoke.py` and Hatch script `runtime-discovery-smoke` for CI-capable real-world coverage. The script creates an isolated HOME, builds a rootless monorepo fixture from `specfact-cli-demo` when available, adds multiple package-level `pyproject.toml`/lock markers, serves a local file-backed marketplace from `specfact-cli-modules`, installs `nold-ai/specfact-project`, `nold-ai/specfact-codebase`, and `nold-ai/specfact-code-review`, checks upgrade command availability, runs `specfact init ide` with auto and explicit `--env-manager uv`, and verifies installed `specfact code`, `code review run`, and `code import` command loading. +- `.github/workflows/pr-orchestrator.yml` now runs `python scripts/runtime_discovery_smoke.py --launcher direct --launcher pip-editable --launcher uvx` so installer, module discovery, init, and environment-manager regressions fail fast in CI across Hatch/current-interpreter, pip editable, and uvx launch paths. +- `hatch run pytest tests/integration/scripts/test_runtime_discovery_smoke.py -q` -> 1 passed. +- `hatch run runtime-discovery-smoke --modules-repo /home/dom/git/nold-ai/specfact-cli-modules --demo-repo /home/dom/git/nold-ai/specfact-demo-repo --launcher direct` -> passed against a real demo-repo copy and sibling module artifacts. +- `hatch run runtime-discovery-smoke --modules-repo /home/dom/git/nold-ai/specfact-cli-modules --demo-repo /home/dom/git/nold-ai/specfact-demo-repo --launcher pip-editable` -> passed with a temporary editable install and isolated module HOME. +- `hatch run runtime-discovery-smoke --modules-repo /home/dom/git/nold-ai/specfact-cli-modules --demo-repo /home/dom/git/nold-ai/specfact-demo-repo --launcher uvx` -> passed with `uvx --from ` and isolated module HOME. +- `openspec validate runtime-01-discovery-reliability --strict` -> valid. +- `hatch run format` -> all checks passed. +- `hatch run type-check` -> 0 errors, 1572 existing repository-wide warnings. +- Touched-file `ruff format --check`, `ruff check`, and `pylint` -> clean; Pylint rated touched files 10.00/10. +- `hatch run workflows-lint` -> passed. +- `hatch run contract-test` -> no modified files detected; cached results used. +- `hatch run smart-test-auto` attempted a full baseline because no incremental baseline existed; it failed in the local developer HOME due pre-existing installed user modules being discovered by clean-runtime tests. The same failed subset passed with an isolated HOME, and all change-targeted suites passed. +- SpecFact code review: `SPECFACT_MODULES_ROOTS=/home/dom/git/nold-ai/specfact-cli-modules/packages hatch run python -m specfact_cli.cli code review run --json --out .specfact/code-review.runtime-01.changed.json --scope changed` -> 0 blocking findings; 499 warnings remain, dominated by existing repository-wide type-safety warnings. New contract warnings introduced by this change were fixed before the final run. diff --git a/openspec/changes/runtime-01-discovery-reliability/proposal.md b/openspec/changes/runtime-01-discovery-reliability/proposal.md new file mode 100644 index 00000000..c4e2ca41 --- /dev/null +++ b/openspec/changes/runtime-01-discovery-reliability/proposal.md @@ -0,0 +1,46 @@ +## Why + +Three user bug reports show clean installed-runtime discovery gaps in SpecFact CLI 0.46.18: + +- `#552` and `#554`: `nold-ai/specfact-codebase` is installed and enabled, but `specfact code` can report that the module is not installed or can show only timing output instead of command help. +- `#553`: `specfact init ide` reports no compatible environment manager in rootless monorepos even when `uv` is available on `PATH` and package-level `pyproject.toml` files exist. + +The failures are core runtime issues, not module ownership issues. Installed module command loading, missing-command diagnostics, and environment-manager detection live in `specfact-cli`. + +## What Changes + +- **EXTEND** installed module runtime loading so lazy command import makes all enabled discovered module `src/` roots importable before loading a module command app. +- **EXTEND** missing command diagnostics so an installed-but-unloadable module reports the real runtime/import cause instead of a false "not installed" message. +- **NEW** environment-manager detection behavior for rootless monorepos and PATH-only tool availability. +- **EXTEND** `specfact init ide` with `--env-manager ` while keeping automatic detection as the default. + +## Capabilities + +### Modified Capabilities + +- `installed-runtime-module-discovery` +- `module-installation` +- `module-owned-ide-prompts` + +### New Capabilities + +- `environment-manager-detection` + +## Impact + +- Affected code: module discovery/command loading, module availability diagnostics, environment-manager detection, and `init ide` option wiring. +- Affected tests: targeted unit/e2e tests for installed module runtime loading, missing command diagnostics, monorepo environment detection, and `init ide --env-manager`. +- GitHub scope: fixes `#552`, `#553`, and `#554`; all remain in `nold-ai/specfact-cli` and are blocked by dedicated user-story issue `#557`, which is tracked under feature parent `#353`. + +--- + +## Source Tracking + + +- **Parent Feature**: [#353](https://github.com/nold-ai/specfact-cli/issues/353) +- **Change User Story**: [#557](https://github.com/nold-ai/specfact-cli/issues/557) +- **GitHub Issues**: [#552](https://github.com/nold-ai/specfact-cli/issues/552), [#553](https://github.com/nold-ai/specfact-cli/issues/553), [#554](https://github.com/nold-ai/specfact-cli/issues/554) +- **Issue Relationships**: `#557` blocks `#552`, `#553`, and `#554`; no direct user bug report is nested under an epic or feature. +- **Repository**: nold-ai/specfact-cli +- **Last Synced Status**: GitHub story and dependencies synced +- **Sanitized**: false diff --git a/openspec/changes/runtime-01-discovery-reliability/specs/environment-manager-detection/spec.md b/openspec/changes/runtime-01-discovery-reliability/specs/environment-manager-detection/spec.md new file mode 100644 index 00000000..b738d4cd --- /dev/null +++ b/openspec/changes/runtime-01-discovery-reliability/specs/environment-manager-detection/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Environment Manager Detection Supports Rootless Monorepos + +The system SHALL detect supported Python environment managers in repositories that do not have a root-level Python project file but do contain package-level project files. + +#### Scenario: Rootless monorepo with uv package files + +- **GIVEN** a repository root has no `pyproject.toml` +- **AND** a first-level package directory contains `pyproject.toml` +- **AND** `uv` is available on `PATH` +- **WHEN** environment manager detection runs for the repository root +- **THEN** the detected manager is `uv` +- **AND** the command prefix is `uv run` +- **AND** `specfact init ide` does not report "No Compatible Environment Manager Detected" + +#### Scenario: Rootless monorepo with nested uv lock + +- **GIVEN** a repository root has no `pyproject.toml` +- **AND** a first-level or second-level package directory contains `uv.lock` or `uv.toml` +- **AND** `uv` is available on `PATH` +- **WHEN** environment manager detection runs for the repository root +- **THEN** the detected manager is `uv` + +### Requirement: Environment Manager Detection Falls Back To PATH Tools + +When no project marker identifies an environment manager, the system SHALL detect supported tools available on `PATH` before returning `unknown`. + +#### Scenario: PATH-only uv detection + +- **GIVEN** a repository has no supported root or package-level Python project marker +- **AND** `uv` is available on `PATH` +- **WHEN** environment manager detection runs +- **THEN** the detected manager is `uv` +- **AND** the command prefix is `uv run` + +#### Scenario: No markers or tools remain unknown + +- **GIVEN** a repository has no supported Python project marker +- **AND** no supported environment manager executable is available on `PATH` +- **WHEN** environment manager detection runs +- **THEN** the detected manager remains `unknown` +- **AND** existing direct-invocation fallback behavior is preserved diff --git a/openspec/changes/runtime-01-discovery-reliability/specs/installed-runtime-module-discovery/spec.md b/openspec/changes/runtime-01-discovery-reliability/specs/installed-runtime-module-discovery/spec.md new file mode 100644 index 00000000..b2319a00 --- /dev/null +++ b/openspec/changes/runtime-01-discovery-reliability/specs/installed-runtime-module-discovery/spec.md @@ -0,0 +1,22 @@ +## MODIFIED Requirements + +### Requirement: Module Discovery Roots + +The system SHALL discover and load module packages consistently between development and installed runtime contexts. + +#### Scenario: Installed runtime loads dependent module packages + +- **GIVEN** user-scope modules `nold-ai/specfact-project` and `nold-ai/specfact-codebase` are installed and enabled +- **AND** no sibling `specfact-cli-modules` source checkout contributes bundle paths to `sys.path` +- **WHEN** the user invokes `specfact code --help` +- **THEN** the codebase module command app loads from the installed module artifact +- **AND** imports of installed dependency packages such as `specfact_project` resolve without manual `PYTHONPATH` +- **AND** the command help includes codebase subcommands such as `import`, `analyze`, `drift`, `validate`, and `repro` + +#### Scenario: Development source paths do not mask installed-runtime validation + +- **GIVEN** tests configure explicit installed module roots +- **AND** development-only sibling module source paths are disabled for that runtime +- **WHEN** module command loading is validated +- **THEN** success depends on the installed module artifacts under the configured roots +- **AND** missing installed dependencies fail the validation instead of being satisfied by a sibling checkout diff --git a/openspec/changes/runtime-01-discovery-reliability/specs/module-installation/spec.md b/openspec/changes/runtime-01-discovery-reliability/specs/module-installation/spec.md new file mode 100644 index 00000000..3306b979 --- /dev/null +++ b/openspec/changes/runtime-01-discovery-reliability/specs/module-installation/spec.md @@ -0,0 +1,14 @@ +## MODIFIED Requirements + +### Requirement: Missing Command Diagnostics Explain Installed-Unavailable Causes + +When a known module-provided command group is not registered, the system SHALL distinguish an absent module from an installed module that is unavailable for another local reason. + +#### Scenario: Missing command provider fails during lazy command load + +- **GIVEN** a known command group is provided by an installed and enabled module +- **AND** the module command app cannot be imported because a runtime dependency or module package import is missing +- **WHEN** the user invokes the command group +- **THEN** the CLI SHALL report that the module is installed but unavailable +- **AND** the diagnostic SHALL include the failing load reason when it can be captured without retrying destructive installation +- **AND** the diagnostic SHALL NOT report only that the module is not installed diff --git a/openspec/changes/runtime-01-discovery-reliability/specs/module-owned-ide-prompts/spec.md b/openspec/changes/runtime-01-discovery-reliability/specs/module-owned-ide-prompts/spec.md new file mode 100644 index 00000000..c4ff17ad --- /dev/null +++ b/openspec/changes/runtime-01-discovery-reliability/specs/module-owned-ide-prompts/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: IDE prompt export SHALL use installed module resources + +`specfact init ide` SHALL discover prompt templates from installed module packages and their packaged resource directories. The export flow SHALL not depend on workflow prompt files stored under the core CLI package for bundle-owned commands. + +#### Scenario: IDE setup accepts explicit environment manager + +- **GIVEN** prompt templates are available for export +- **WHEN** the user runs `specfact init ide --env-manager uv` +- **THEN** IDE prompt export uses the selected `uv` environment manager metadata for dependency setup decisions +- **AND** the command does not emit the "No Compatible Environment Manager Detected" warning for that explicit manager diff --git a/openspec/changes/runtime-01-discovery-reliability/tasks.md b/openspec/changes/runtime-01-discovery-reliability/tasks.md new file mode 100644 index 00000000..d27bc8ea --- /dev/null +++ b/openspec/changes/runtime-01-discovery-reliability/tasks.md @@ -0,0 +1,33 @@ +# Tasks: runtime-01-discovery-reliability + +## 1. Readiness and spec validation + +- [x] 1.1 Confirm issues `#552`, `#553`, and `#554` are correctly scoped to `specfact-cli`, not `specfact-cli-modules`, and record that decision in `TDD_EVIDENCE.md`. +- [x] 1.2 Confirm public GitHub metadata is complete: dedicated user-story issue `#557`, feature parent `#353`, labels, project assignment, issue dependencies, and Todo/not-in-progress status. +- [x] 1.3 Validate the OpenSpec change with `openspec validate runtime-01-discovery-reliability --strict`. + +## 2. Failing-first tests + +- [x] 2.1 Add tests for clean installed module runtime loading with temp installed `specfact-project` and `specfact-codebase` modules, no sibling module source path, and `specfact code` exposing `import`, `analyze`, `drift`, `validate`, and `repro`. +- [x] 2.2 Add tests proving module load/import failures are classified as installed-unavailable instead of absent. +- [x] 2.3 Add tests for rootless monorepo environment detection with `uv` on `PATH`, package-level `pyproject.toml`/`uv.lock`, and explicit `init ide --env-manager uv`. +- [x] 2.4 Run the targeted tests before production edits and record failing evidence in `TDD_EVIDENCE.md`. + +## 3. Runtime discovery fixes + +- [x] 3.1 Add a focused helper that prepends enabled discovered module `src/` roots to `sys.path` before lazy-loading installed module command apps. +- [x] 3.2 Preserve existing development behavior but prevent sibling `specfact-cli-modules` source paths from hiding installed-runtime test failures. +- [x] 3.3 Capture lazy loader failures in availability diagnostics so known module commands distinguish absent, disabled, skipped, and load-failed providers. + +## 4. Environment manager fixes + +- [x] 4.1 Extend environment-manager detection to scan rootless monorepo package directories up to two levels deep. +- [x] 4.2 Add PATH fallback detection for supported tools, preferring `uv`, then `hatch`, `poetry`, and `pip`. +- [x] 4.3 Add `specfact init ide --env-manager ` and use the explicit manager when provided. + +## 5. Passing evidence and quality gates + +- [x] 5.1 Re-run targeted tests and record passing evidence in `TDD_EVIDENCE.md`. +- [x] 5.2 Run required quality gates for touched scope: formatting, type-check, lint, contract-test, smart-test or targeted equivalent, and SpecFact code review JSON. +- [x] 5.3 Add a CI-capable runtime discovery smoke script that exercises module install, upgrade/init-adjacent discovery, rootless monorepo environment-manager detection, and installed `specfact code` command loading against a real demo checkout. +- [x] 5.4 Update task checkboxes and prepare the branch for PR to `dev`. diff --git a/pyproject.toml b/pyproject.toml index 7d0b1d06..1dd737dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.46.18" +version = "0.46.19" description = "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with validation and contract enforcement for new projects and long-lived codebases." readme = "README.md" requires-python = ">=3.11" @@ -283,6 +283,7 @@ verify-removal-gate = [ "hatch run verify-modules-signature", ] export-change-github = "python scripts/export-change-to-github.py {args}" +runtime-discovery-smoke = "python scripts/runtime_discovery_smoke.py {args}" # Contract-First Smart Test System Scripts contract-test = "python tools/contract_first_smart_test.py run --level auto {args}" diff --git a/scripts/runtime_discovery_smoke.py b/scripts/runtime_discovery_smoke.py new file mode 100644 index 00000000..baec6b56 --- /dev/null +++ b/scripts/runtime_discovery_smoke.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""Fail-fast real-world smoke checks for runtime discovery and init behavior.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import logging +import os +import shutil +import subprocess +import sys +import tarfile +import tempfile +import venv +from pathlib import Path +from typing import Any, cast + +import yaml +from beartype import beartype +from icontract import ensure + + +REPO_ROOT = Path(__file__).resolve().parents[1] +MODULE_IDS = ("nold-ai/specfact-project", "nold-ai/specfact-codebase", "nold-ai/specfact-code-review") +NO_ENV_WARNING = "No Compatible Environment Manager Detected" +LOGGER = logging.getLogger("runtime-discovery-smoke") + + +def _run(command: list[str], *, cwd: Path, env: dict[str, str], timeout: int = 120) -> subprocess.CompletedProcess[str]: + LOGGER.info("+ %s", " ".join(command)) + result = subprocess.run( + command, + cwd=cwd, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=timeout, + check=False, + ) + if result.stdout: + LOGGER.info("%s", result.stdout.rstrip()) + if result.returncode != 0: + raise RuntimeError(f"Command failed with exit code {result.returncode}: {' '.join(command)}") + return result + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _create_rootless_monorepo_demo(workspace: Path, template: Path | None) -> Path: + demo = workspace / "specfact-cli-demo-rootless-monorepo" + if template is not None: + shutil.copytree(template, demo) + else: + demo.mkdir(parents=True) + _write(demo / "README.md", "# SpecFact runtime discovery smoke demo\n") + + _write( + demo / "backend" / "pyproject.toml", + """[project] +name = "demo-backend" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [] + +[tool.uv] +package = false +""", + ) + _write( + demo / "backend" / "uv.lock", + """version = 1 +revision = 3 +requires-python = ">=3.12" +""", + ) + _write( + demo / "worker" / "pyproject.toml", + """[project] +name = "demo-worker" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [] + +[tool.hatch.envs.default] +dependencies = [] +""", + ) + _write(demo / "tools" / "data-job" / "requirements.txt", "click\n") + (demo / "backend" / ".venv").mkdir(parents=True, exist_ok=True) + (demo / "worker" / ".venv").mkdir(parents=True, exist_ok=True) + return demo + + +def _resolve_modules_repo(configured: str | None) -> Path: + candidates: list[Path] = [] + if configured: + candidates.append(Path(configured).expanduser()) + env_repo = os.environ.get("SPECFACT_MODULES_REPO", "").strip() + if env_repo: + candidates.append(Path(env_repo).expanduser()) + candidates.extend( + [ + REPO_ROOT.parent / "specfact-cli-modules", + REPO_ROOT.parents[2] / "specfact-cli-modules", + Path.cwd().parent / "specfact-cli-modules", + ] + ) + for candidate in candidates: + packages = candidate / "packages" + if all((packages / module_id.split("/", 1)[1] / "module-package.yaml").exists() for module_id in MODULE_IDS): + return candidate.resolve() + searched = ", ".join(str(path) for path in candidates) + raise RuntimeError(f"Could not find specfact-cli-modules with required packages. Searched: {searched}") + + +def _module_payload_for_checksum(package_dir: Path) -> bytes: + from specfact_cli.registry.module_installer import _module_artifact_payload_signed + + return _module_artifact_payload_signed(package_dir) + + +def _build_local_registry(workspace: Path, modules_repo: Path) -> Path: + registry = workspace / "local-registry" + modules_dir = registry / "modules" + staging_dir = registry / "staging" + modules_dir.mkdir(parents=True) + staging_dir.mkdir(parents=True) + entries: list[dict[str, Any]] = [] + + for module_id in MODULE_IDS: + bundle_name = module_id.split("/", 1)[1] + source_dir = modules_repo / "packages" / bundle_name + staged_dir = staging_dir / bundle_name + shutil.copytree(source_dir, staged_dir) + + manifest_path = staged_dir / "module-package.yaml" + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + if not isinstance(manifest, dict): + raise RuntimeError(f"Invalid module manifest: {manifest_path}") + manifest_data = cast(dict[str, Any], manifest) + manifest_data["integrity"] = { + "checksum": f"sha256:{hashlib.sha256(_module_payload_for_checksum(staged_dir)).hexdigest()}" + } + manifest_path.write_text(yaml.safe_dump(manifest_data, sort_keys=False, allow_unicode=False), encoding="utf-8") + + archive_path = modules_dir / f"{bundle_name}-{manifest_data['version']}.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + archive.add(staged_dir, arcname=bundle_name) + entries.append( + { + "id": module_id, + "latest_version": str(manifest_data["version"]), + "download_url": archive_path.resolve().as_uri(), + "checksum_sha256": hashlib.sha256(archive_path.read_bytes()).hexdigest(), + "tier": manifest_data.get("tier", "official"), + "publisher": manifest_data.get("publisher", {}), + "bundle_dependencies": manifest_data.get("bundle_dependencies", []), + "description": manifest_data.get("description", ""), + } + ) + + index_path = registry / "index.json" + index_path.write_text(json.dumps({"modules": entries}, indent=2), encoding="utf-8") + return index_path + + +def _create_pip_editable_launcher(workspace: Path) -> list[str]: + venv_dir = workspace / "pip-editable-venv" + try: + venv.EnvBuilder(with_pip=True, system_site_packages=True).create(venv_dir) + except subprocess.CalledProcessError: + virtualenv = shutil.which("virtualenv") + if virtualenv is None: + raise + _run([virtualenv, "--system-site-packages", str(venv_dir)], cwd=REPO_ROOT, env=os.environ.copy(), timeout=120) + python = venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + _run( + [str(python), "-m", "pip", "install", "-e", str(REPO_ROOT)], + cwd=REPO_ROOT, + env=os.environ.copy(), + timeout=180, + ) + specfact = venv_dir / ("Scripts/specfact.exe" if os.name == "nt" else "bin/specfact") + return [str(specfact)] + + +def _launcher_command(name: str, workspace: Path) -> list[str]: + if name == "direct": + return [sys.executable, "-m", "specfact_cli.cli"] + if name == "pip-editable": + return _create_pip_editable_launcher(workspace) + if name == "console": + return ["specfact"] + if name == "uvx": + return ["uvx", "--from", str(REPO_ROOT), "specfact"] + raise ValueError(f"Unsupported launcher: {name}") + + +def _assert_no_env_warning(result: subprocess.CompletedProcess[str]) -> None: + if NO_ENV_WARNING in result.stdout: + raise AssertionError(f"Unexpected environment-manager warning in output:\n{result.stdout}") + + +def _install_marketplace_modules(cli: list[str], demo: Path, env: dict[str, str]) -> None: + for module_id in MODULE_IDS: + _run( + [ + *cli, + "module", + "install", + module_id, + "--source", + "marketplace", + "--scope", + "user", + "--reinstall", + ], + cwd=demo, + env=env, + ) + + +def _smoke_launcher(name: str, workspace: Path, demo: Path, index_path: Path, modules_repo: Path) -> None: + home = workspace / f"home-{name}" + home.mkdir(parents=True) + cli = _launcher_command(name, workspace / f"launcher-{name}") + env = os.environ.copy() + env.update( + { + "HOME": str(home), + "SPECFACT_MODULES_REPO": str(modules_repo), + "SPECFACT_REGISTRY_INDEX_URL": str(index_path), + "SPECFACT_ALLOW_UNSIGNED": "1", + "SPECFACT_NO_TIMING": "1", + "PYTHONUNBUFFERED": "1", + } + ) + + _install_marketplace_modules(cli, demo, env) + list_result = _run([*cli, "module", "list"], cwd=demo, env=env) + for module_id in MODULE_IDS: + if module_id not in list_result.stdout: + raise AssertionError(f"{module_id} missing from module list output") + + _run([*cli, "upgrade", "--help"], cwd=demo, env=env) + _run([*cli, "module", "upgrade", "--help"], cwd=demo, env=env) + + init_result = _run([*cli, "init", "ide", "--ide", "cursor", "--repo", str(demo), "--force"], cwd=demo, env=env) + _assert_no_env_warning(init_result) + explicit_result = _run( + [*cli, "init", "ide", "--ide", "cursor", "--repo", str(demo), "--force", "--env-manager", "uv"], + cwd=demo, + env=env, + ) + _assert_no_env_warning(explicit_result) + + help_result = _run([*cli, "code", "--help"], cwd=demo, env=env) + for token in ("review", "import", "analyze", "drift", "validate", "repro"): + if token not in help_result.stdout: + raise AssertionError(f"`specfact code --help` did not include {token!r}") + _run([*cli, "code", "review", "run", "--help"], cwd=demo, env=env) + _run([*cli, "code", "import", "--help"], cwd=demo, env=env) + LOGGER.info("runtime-discovery smoke passed for launcher=%s", name) + + +@beartype +@ensure(lambda result: result in (0, 1), "exit code must be 0 or 1") +def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(message)s") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--modules-repo", help="Path to sibling specfact-cli-modules checkout") + parser.add_argument( + "--demo-repo", type=Path, help="Optional demo repo checkout to copy before adding monorepo markers" + ) + parser.add_argument( + "--launcher", + action="append", + choices=("direct", "pip-editable", "console", "uvx"), + help="Launcher to test. Repeatable. Defaults to direct.", + ) + parser.add_argument("--keep-workspace", action="store_true", help="Keep the temporary workspace for debugging") + args = parser.parse_args() + + modules_repo = _resolve_modules_repo(args.modules_repo) + workspace_obj = tempfile.TemporaryDirectory(prefix="specfact-runtime-discovery-smoke-") + workspace = Path(workspace_obj.name) + try: + demo = _create_rootless_monorepo_demo(workspace, args.demo_repo) + index_path = _build_local_registry(workspace, modules_repo) + for launcher in args.launcher or ["direct"]: + _smoke_launcher(launcher, workspace, demo, index_path, modules_repo) + if args.keep_workspace: + LOGGER.info("Kept workspace: %s", workspace) + workspace_obj = None # type: ignore[assignment] + return 0 + finally: + if workspace_obj is not None: + workspace_obj.cleanup() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/setup.py b/setup.py index abba2dc9..9cf48ca8 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.46.18", + version="0.46.19", description=( "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with " "validation and contract enforcement for new projects and long-lived codebases." diff --git a/src/__init__.py b/src/__init__.py index f06c87d0..1f744e48 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -3,4 +3,4 @@ """ # Package version: keep in sync with pyproject.toml, setup.py, src/specfact_cli/__init__.py -__version__ = "0.46.18" +__version__ = "0.46.19" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index 0b948a1b..b85421a9 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -45,6 +45,6 @@ def _bootstrap_bundle_paths() -> None: _bootstrap_bundle_paths() -__version__ = "0.46.18" +__version__ = "0.46.19" __all__ = ["__version__"] diff --git a/src/specfact_cli/cli.py b/src/specfact_cli/cli.py index 1f84446c..a4c8bc37 100644 --- a/src/specfact_cli/cli.py +++ b/src/specfact_cli/cli.py @@ -178,6 +178,11 @@ def resolve_command( _print_missing_bundle_command_help(invoked) raise SystemExit(1) from None raise + except ValueError: + if invoked in KNOWN_BUNDLE_GROUP_OR_SHIM_NAMES: + _print_missing_bundle_command_help(invoked) + raise SystemExit(1) from None + raise _name, cmd, remaining = result if cmd is not None or not remaining: return result diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index 2489e667..aaed0552 100644 --- a/src/specfact_cli/modules/init/module-package.yaml +++ b/src/specfact_cli/modules/init/module-package.yaml @@ -1,5 +1,5 @@ name: init -version: 0.1.31 +version: 0.1.32 commands: - init category: core @@ -17,5 +17,4 @@ publisher: description: Initialize SpecFact workspace and bootstrap local configuration. license: Apache-2.0 integrity: - checksum: sha256:0f7bc54a823bea14033fcb143ecb6c83d2bca2b5da661f03a0b545100acebe5b - signature: dTatkqgBUtti4tL/pmcFBZY9bsJ61gY/V0lP9gZU8Y5W3YWK+wpgRx1oewlAmfKzkxca2NhalKcjLACQjTNvAA== + checksum: sha256:a80f310a627d5ead684c79302993b45181ca5802b9994af38a4b75676f6467be diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py index 532a9619..0fbdbd08 100644 --- a/src/specfact_cli/modules/init/src/commands.py +++ b/src/specfact_cli/modules/init/src/commands.py @@ -26,7 +26,13 @@ from specfact_cli.runtime import debug_print, is_non_interactive from specfact_cli.telemetry import telemetry from specfact_cli.utils.contract_predicates import repo_path_exists, repo_path_is_dir -from specfact_cli.utils.env_manager import EnvManager, EnvManagerInfo, build_tool_command, detect_env_manager +from specfact_cli.utils.env_manager import ( + EnvManager, + EnvManagerInfo, + build_tool_command, + detect_env_manager, + env_info_from_tool_choice, +) from specfact_cli.utils.ide_setup import ( IDE_CONFIG, PROMPT_SOURCE_CORE, @@ -610,6 +616,11 @@ def init_ide( "--ide", help="IDE type (cursor, vscode, copilot, claude, gemini, qwen, opencode, windsurf, kilocode, auggie, roo, codebuddy, amp, q, auto)", ), + env_manager: EnvManager = typer.Option( + EnvManager.AUTO, + "--env-manager", + help="Environment manager override: auto, uv, hatch, poetry, or pip", + ), prompts: str | None = typer.Option( None, "--prompts", @@ -638,7 +649,11 @@ def init_ide( console.print(f"[cyan]IDE:[/cyan] {ide_name} ({selected_ide})") console.print() - env_info = detect_env_manager(repo_path) + env_info = ( + detect_env_manager(repo_path) + if env_manager is EnvManager.AUTO + else env_info_from_tool_choice(env_manager, repo_path) + ) if env_info.manager == EnvManager.UNKNOWN: console.print( Panel( diff --git a/src/specfact_cli/modules/module_registry/module-package.yaml b/src/specfact_cli/modules/module_registry/module-package.yaml index a9ad0321..f48253de 100644 --- a/src/specfact_cli/modules/module_registry/module-package.yaml +++ b/src/specfact_cli/modules/module_registry/module-package.yaml @@ -1,5 +1,5 @@ name: module-registry -version: 0.1.23 +version: 0.1.24 commands: - module category: core @@ -17,5 +17,4 @@ publisher: description: 'Manage modules: search, list, show, install, and upgrade.' license: Apache-2.0 integrity: - checksum: sha256:f500281d2249d712be23a1b25b5660374694dfa47634f60ae4378eb2cdb753ca - signature: cunuat95bD44IcNUBs35NTlHYOR8atydVz4ZyZTjIzwJL6Wz5YOfBYGq/WtgDnOFv3KplfvhRZbTo3CwmS/KBQ== + checksum: sha256:bc1293efa20676657f440f5c8f21583fcf7c5c2138d754e0262934417c92cfbb diff --git a/src/specfact_cli/modules/module_registry/src/commands.py b/src/specfact_cli/modules/module_registry/src/commands.py index 9ef4cf18..b1be2ee3 100644 --- a/src/specfact_cli/modules/module_registry/src/commands.py +++ b/src/specfact_cli/modules/module_registry/src/commands.py @@ -5,6 +5,7 @@ import inspect import os import shutil +import tempfile from collections.abc import Callable, Iterator from contextlib import contextmanager from dataclasses import dataclass @@ -187,7 +188,10 @@ def _normalize_project_repo(repo: Path | None) -> Path | None: if repo is None: return None repo_path = repo.resolve() + temp_root = Path(tempfile.gettempdir()).resolve() for candidate in [repo_path, *repo_path.parents]: + if candidate == temp_root and candidate != repo_path: + return repo_path if (candidate / ".git").exists(): return candidate return repo_path diff --git a/src/specfact_cli/registry/module_availability.py b/src/specfact_cli/registry/module_availability.py index e2afa1e0..dd257d30 100644 --- a/src/specfact_cli/registry/module_availability.py +++ b/src/specfact_cli/registry/module_availability.py @@ -14,6 +14,7 @@ from specfact_cli.registry.module_packages import ( _check_core_compatibility, _validate_module_dependencies, + get_module_load_failure_reason, merge_module_state, ) from specfact_cli.registry.module_state import read_modules_state @@ -104,6 +105,9 @@ def _recovery_command(status: ModuleAvailabilityStatus, module_id: str) -> str: def _skip_reason(entry: DiscoveredModule, enabled_map: dict[str, bool]) -> str: meta = entry.metadata + load_failure = get_module_load_failure_reason(meta.name, None) + if load_failure: + return load_failure if not _check_core_compatibility(meta, cli_version): return f"requires {meta.core_compatibility}, cli is {cli_version}" deps_ok, missing = _validate_module_dependencies(meta, enabled_map) diff --git a/src/specfact_cli/registry/module_packages.py b/src/specfact_cli/registry/module_packages.py index a4245fee..6e02f134 100644 --- a/src/specfact_cli/registry/module_packages.py +++ b/src/specfact_cli/registry/module_packages.py @@ -14,6 +14,7 @@ import importlib.util import os import sys +import tempfile from dataclasses import dataclass from pathlib import Path from typing import Any, cast @@ -106,6 +107,8 @@ class _PackageRegistrationContext: PROTOCOL_INTERFACE_BINDINGS: tuple[str, ...] = ("runtime_interface", "commands_interface", "commands") BRIDGE_REGISTRY = BridgeRegistry() BUILTIN_MODULES_ROOT = (Path(__file__).resolve().parents[1] / "modules").resolve() +_ACTIVE_MODULE_SRC_DIRS: list[Path] = [] +_MODULE_LOAD_FAILURES: dict[tuple[str, str], str] = {} def _normalized_module_name(package_name: str) -> str: @@ -180,16 +183,16 @@ def _add_root(path: Path) -> None: def get_workspace_modules_root(base_path: Path | None = None) -> Path | None: """Return nearest workspace-local .specfact/modules root from base path upward.""" start = base_path.resolve() if base_path is not None else Path.cwd().resolve() + temp_root = Path(tempfile.gettempdir()).resolve() for candidate in [start, *start.parents]: + if candidate == temp_root and candidate != start: + return None + workspace_modules_root = candidate / ".specfact" / "modules" + if workspace_modules_root.exists(): + return workspace_modules_root git_dir = candidate / ".git" if git_dir.exists(): - workspace_modules_root = candidate / ".specfact" / "modules" - if workspace_modules_root.exists(): - return workspace_modules_root return None - workspace_modules_root = start / ".specfact" / "modules" - if workspace_modules_root.exists(): - return workspace_modules_root return None @@ -601,10 +604,50 @@ def _resolve_command_loader_path( return load_path, submodule_locations +def _remember_active_module_src(package_dir: Path) -> None: + """Remember an eligible installed module source root for lazy cross-module imports.""" + src_dir = package_dir / "src" + if not src_dir.is_dir(): + return + resolved = src_dir.resolve() + if resolved not in _ACTIVE_MODULE_SRC_DIRS: + _ACTIVE_MODULE_SRC_DIRS.append(resolved) + + +def _prepend_active_module_src_roots() -> None: + """Prepend eligible installed module source roots before loading a command app.""" + for src_dir in reversed(_ACTIVE_MODULE_SRC_DIRS): + src = str(src_dir) + if src not in sys.path: + sys.path.insert(0, src) + + +def _record_module_load_failure(package_name: str, command_name: str, reason: str) -> None: + _MODULE_LOAD_FAILURES[(package_name, command_name)] = reason + _MODULE_LOAD_FAILURES[(package_name, "*")] = reason + + +def _package_name_non_empty(package_name: str) -> bool: + return bool(package_name.strip()) + + +@beartype +@require(_package_name_non_empty, "package name must be non-empty") +@ensure(lambda result: result is None or isinstance(result, str), "result must be a string or None") +def get_module_load_failure_reason(package_name: str, command_name: str | None = None) -> str | None: + """Return the latest lazy-load failure for a module, if one was captured.""" + if command_name is not None: + specific = _MODULE_LOAD_FAILURES.get((package_name, command_name)) + if specific: + return specific + return _MODULE_LOAD_FAILURES.get((package_name, "*")) + + def _make_package_loader(package_dir: Path, package_name: str, command_name: str) -> Any: """Return a callable that loads the package's app (from src/app.py or src//__init__.py).""" def loader() -> Any: + _prepend_active_module_src_roots() src_dir = package_dir / "src" if str(src_dir) not in sys.path: sys.path.insert(0, str(src_dir)) @@ -622,11 +665,13 @@ def loader() -> Any: try: spec.loader.exec_module(mod) except (ImportError, ModuleNotFoundError, OSError) as exc: - raise ValueError( + message = ( "Runtime compatibility error while loading " f"module '{package_name}' command '{command_name}' from {package_dir}: {exc}. " f"Reinstall the module and run SpecFact with the same Python interpreter ({sys.executable})." - ) from exc + ) + _record_module_load_failure(package_name, command_name, message) + raise ValueError(message) from exc command_attr = f"{_normalized_module_name(command_name)}_app" app = getattr(mod, command_attr, None) if app is None: @@ -1331,6 +1376,7 @@ def _register_one_package_if_eligible(package_dir: Path, meta: Any, reg: _Packag _register_schema_extensions_safe(meta, reg.logger) _register_service_bridges_safe(meta, reg.bridge_owner_map, reg.logger) _record_protocol_compliance_result(package_dir, meta, reg.logger, reg.counters) + _remember_active_module_src(package_dir) _register_commands_for_package(package_dir, meta, reg.category_grouping_enabled, reg.logger) @@ -1382,6 +1428,8 @@ def register_module_package_commands( disable_ids = disable_ids or [] if allow_unsigned is None: allow_unsigned = os.environ.get("SPECFACT_ALLOW_UNSIGNED", "").strip().lower() in ("1", "true", "yes") + _ACTIVE_MODULE_SRC_DIRS.clear() + _MODULE_LOAD_FAILURES.clear() is_test_mode = os.environ.get("TEST_MODE") == "true" or os.environ.get("PYTEST_CURRENT_TEST") is not None packages = discover_all_package_metadata() packages = sorted(packages, key=_package_sort_key) diff --git a/src/specfact_cli/utils/env_manager.py b/src/specfact_cli/utils/env_manager.py index dff9f65b..ab7fc4b0 100644 --- a/src/specfact_cli/utils/env_manager.py +++ b/src/specfact_cli/utils/env_manager.py @@ -23,6 +23,7 @@ class EnvManager(StrEnum): """Python environment manager types.""" + AUTO = "auto" HATCH = "hatch" POETRY = "poetry" UV = "uv" @@ -40,6 +41,62 @@ class EnvManagerInfo: message: str | None = None +_MONOREPO_SKIP_DIRS = { + ".git", + ".hg", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".venv", + "__pycache__", + "build", + "dist", + "node_modules", + "venv", +} + + +def _env_info_for_available_tool(manager: EnvManager, message: str) -> EnvManagerInfo: + if manager is EnvManager.HATCH: + return EnvManagerInfo(manager=manager, available=True, command_prefix=["hatch", "run"], message=message) + if manager is EnvManager.POETRY: + return EnvManagerInfo(manager=manager, available=True, command_prefix=["poetry", "run"], message=message) + if manager is EnvManager.UV: + return EnvManagerInfo(manager=manager, available=True, command_prefix=["uv", "run"], message=message) + if manager is EnvManager.PIP: + return EnvManagerInfo(manager=manager, available=True, command_prefix=[], message=message) + return EnvManagerInfo(manager=EnvManager.UNKNOWN, available=True, command_prefix=[], message=message) + + +@beartype +@require(lambda manager: isinstance(manager, EnvManager), "manager must be an EnvManager") +@ensure(lambda result: isinstance(result, EnvManagerInfo), "result must be EnvManagerInfo") +def env_info_from_tool_choice(manager: EnvManager, repo_path: Path | None = None) -> EnvManagerInfo: + """Build EnvManagerInfo for an explicit user-selected manager.""" + _ = repo_path + if manager is EnvManager.AUTO: + raise ValueError("auto is not an explicit environment manager") + if manager is EnvManager.UNKNOWN: + return EnvManagerInfo( + manager=EnvManager.UNKNOWN, + available=True, + command_prefix=[], + message="No environment manager detected, using direct tool invocation", + ) + executable = "pip" if manager is EnvManager.PIP else manager.value + available = shutil.which(executable) is not None + info = _env_info_for_available_tool(manager, f"Using explicit {manager.value} environment manager") + if available: + return info + return EnvManagerInfo( + manager=manager, + available=False, + command_prefix=[], + message=f"Explicit environment manager {manager.value} not found in PATH", + ) + + def _env_info_from_pyproject_toml(pyproject_toml: Path) -> EnvManagerInfo | None: try: import tomllib @@ -97,6 +154,96 @@ def _env_info_from_pyproject_toml(pyproject_toml: Path) -> EnvManagerInfo | None return None +def _env_info_from_lock_markers(path: Path) -> EnvManagerInfo | None: + uv_lock = path / "uv.lock" + uv_toml = path / "uv.toml" + poetry_lock = path / "poetry.lock" + requirements_txt = path / "requirements.txt" + setup_py = path / "setup.py" + + if uv_lock.exists() or uv_toml.exists(): + uv_available = shutil.which("uv") is not None + if uv_available: + return _env_info_for_available_tool(EnvManager.UV, "Detected uv.lock or uv.toml") + return EnvManagerInfo( + manager=EnvManager.UV, + available=False, + command_prefix=[], + message="Detected uv.lock/uv.toml but uv not found in PATH", + ) + if poetry_lock.exists(): + poetry_available = shutil.which("poetry") is not None + if poetry_available: + return _env_info_for_available_tool(EnvManager.POETRY, "Detected poetry.lock") + return EnvManagerInfo( + manager=EnvManager.POETRY, + available=False, + command_prefix=[], + message="Detected poetry.lock but poetry not found in PATH", + ) + if requirements_txt.exists() or setup_py.exists(): + return EnvManagerInfo( + manager=EnvManager.PIP, + available=True, + command_prefix=[], + message="Detected requirements.txt or setup.py (pip-based project)", + ) + return None + + +def _iter_monorepo_candidate_dirs(repo_path: Path, max_depth: int = 2) -> list[Path]: + candidates: list[Path] = [] + queue: list[tuple[Path, int]] = [(repo_path, 0)] + seen: set[Path] = set() + while queue: + current, depth = queue.pop(0) + try: + resolved = current.resolve() + except OSError: + continue + if resolved in seen: + continue + seen.add(resolved) + if depth > 0: + candidates.append(current) + if depth >= max_depth: + continue + try: + children = sorted(child for child in current.iterdir() if child.is_dir()) + except OSError: + continue + for child in children: + if child.name in _MONOREPO_SKIP_DIRS or child.name.startswith("."): + continue + queue.append((child, depth + 1)) + return candidates + + +def _env_info_from_monorepo_markers(repo_path: Path) -> EnvManagerInfo | None: + pyproject_candidates: list[Path] = [] + for candidate in _iter_monorepo_candidate_dirs(repo_path): + pyproject = candidate / "pyproject.toml" + if pyproject.exists(): + pyproject_hit = _env_info_from_pyproject_toml(pyproject) + if pyproject_hit is not None: + return pyproject_hit + pyproject_candidates.append(pyproject) + marker_hit = _env_info_from_lock_markers(candidate) + if marker_hit is not None: + return marker_hit + if pyproject_candidates and shutil.which("uv") is not None: + return _env_info_for_available_tool(EnvManager.UV, "Detected monorepo pyproject.toml and uv in PATH") + return None + + +def _env_info_from_path_fallback() -> EnvManagerInfo | None: + for manager in (EnvManager.UV, EnvManager.HATCH, EnvManager.POETRY, EnvManager.PIP): + executable = "pip" if manager is EnvManager.PIP else manager.value + if shutil.which(executable) is not None: + return _env_info_for_available_tool(manager, f"Detected {executable} in PATH") + return None + + @beartype @require(repo_path_exists, "Repository path must exist") @require(repo_path_is_dir, "Repository path must be a directory") @@ -121,61 +268,23 @@ def detect_env_manager(repo_path: Path) -> EnvManagerInfo: EnvManagerInfo with detected manager and command prefix """ pyproject_toml = repo_path / "pyproject.toml" - uv_lock = repo_path / "uv.lock" - uv_toml = repo_path / "uv.toml" - poetry_lock = repo_path / "poetry.lock" - requirements_txt = repo_path / "requirements.txt" - setup_py = repo_path / "setup.py" - if pyproject_toml.exists(): pyproject_hit = _env_info_from_pyproject_toml(pyproject_toml) if pyproject_hit is not None: return pyproject_hit - # 2. Check for uv.lock or uv.toml - if uv_lock.exists() or uv_toml.exists(): - uv_available = shutil.which("uv") is not None - if uv_available: - return EnvManagerInfo( - manager=EnvManager.UV, - available=True, - command_prefix=["uv", "run"], - message="Detected uv.lock or uv.toml", - ) - return EnvManagerInfo( - manager=EnvManager.UV, - available=False, - command_prefix=[], - message="Detected uv.lock/uv.toml but uv not found in PATH", - ) + marker_hit = _env_info_from_lock_markers(repo_path) + if marker_hit is not None: + return marker_hit - # 3. Check for poetry.lock - if poetry_lock.exists(): - poetry_available = shutil.which("poetry") is not None - if poetry_available: - return EnvManagerInfo( - manager=EnvManager.POETRY, - available=True, - command_prefix=["poetry", "run"], - message="Detected poetry.lock", - ) - return EnvManagerInfo( - manager=EnvManager.POETRY, - available=False, - command_prefix=[], - message="Detected poetry.lock but poetry not found in PATH", - ) + monorepo_hit = _env_info_from_monorepo_markers(repo_path) + if monorepo_hit is not None: + return monorepo_hit - # 4. Check for requirements.txt or setup.py (pip-based) - if requirements_txt.exists() or setup_py.exists(): - return EnvManagerInfo( - manager=EnvManager.PIP, - available=True, - command_prefix=[], # Direct invocation (assumes globally installed) - message="Detected requirements.txt or setup.py (pip-based project)", - ) + path_hit = _env_info_from_path_fallback() + if path_hit is not None: + return path_hit - # 5. Fallback: assume direct invocation (pip/global tools) return EnvManagerInfo( manager=EnvManager.UNKNOWN, available=True, diff --git a/tests/e2e/test_init_command.py b/tests/e2e/test_init_command.py index 0c229a2f..424964a7 100644 --- a/tests/e2e/test_init_command.py +++ b/tests/e2e/test_init_command.py @@ -295,6 +295,8 @@ def test_init_auto_detect_claude(self, tmp_path, monkeypatch): def test_init_warns_when_no_environment_manager(self, tmp_path, monkeypatch): """Test init command shows warning when no environment manager is detected.""" + monkeypatch.setattr("shutil.which", lambda _name: None) + # Create templates directory structure templates_dir = tmp_path / "resources" / "prompts" templates_dir.mkdir(parents=True) @@ -532,3 +534,52 @@ def test_init_no_warning_with_uv_project(self, tmp_path, monkeypatch): assert result.exit_code == 0 # Should NOT show warning assert "No Compatible Environment Manager Detected" not in result.stdout + + def test_init_no_warning_with_explicit_uv_env_manager(self, tmp_path, monkeypatch): + """Explicit env manager selection should bypass unknown auto-detection warnings.""" + templates_dir = tmp_path / "resources" / "prompts" + templates_dir.mkdir(parents=True) + (templates_dir / "specfact.01-import.md").write_text("---\ndescription: Analyze\n---\nContent") + + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke( + app, + [ + "init", + "ide", + "--ide", + "cursor", + "--repo", + str(tmp_path), + "--env-manager", + "uv", + "--force", + ], + ) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + assert "No Compatible Environment Manager Detected" not in result.stdout + + def test_init_no_warning_with_rootless_monorepo_uv(self, tmp_path, monkeypatch): + """Rootless monorepo package markers plus uv on PATH should avoid the unknown warning.""" + templates_dir = tmp_path / "resources" / "prompts" + templates_dir.mkdir(parents=True) + (templates_dir / "specfact.01-import.md").write_text("---\ndescription: Analyze\n---\nContent") + backend = tmp_path / "backend" + backend.mkdir() + (backend / "pyproject.toml").write_text("[project]\nname = 'backend'\n", encoding="utf-8") + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) + + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + assert "No Compatible Environment Manager Detected" not in result.stdout diff --git a/tests/integration/scripts/test_runtime_discovery_smoke.py b/tests/integration/scripts/test_runtime_discovery_smoke.py new file mode 100644 index 00000000..405b21d4 --- /dev/null +++ b/tests/integration/scripts/test_runtime_discovery_smoke.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +def _resolve_modules_repo() -> Path | None: + configured = os.environ.get("SPECFACT_MODULES_REPO", "").strip() + candidates: list[Path] = [] + if configured: + candidates.append(Path(configured).expanduser()) + candidates.extend( + [ + REPO_ROOT.parent / "specfact-cli-modules", + REPO_ROOT.parents[2] / "specfact-cli-modules", + ] + ) + for candidate in candidates: + packages = candidate / "packages" + required = ("specfact-project", "specfact-codebase", "specfact-code-review") + if all((packages / module / "module-package.yaml").exists() for module in required): + return candidate.resolve() + return None + + +@pytest.mark.integration +@pytest.mark.timeout(180) +def test_runtime_discovery_smoke_direct_launcher() -> None: + modules_repo = _resolve_modules_repo() + if modules_repo is None: + pytest.skip("specfact-cli-modules checkout not available") + + result = subprocess.run( + [ + sys.executable, + str(REPO_ROOT / "scripts" / "runtime_discovery_smoke.py"), + "--modules-repo", + str(modules_repo), + "--launcher", + "direct", + ], + cwd=REPO_ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=180, + check=False, + ) + + assert result.returncode == 0, result.stdout + assert "runtime-discovery smoke passed for launcher=direct" in result.stdout diff --git a/tests/unit/specfact_cli/registry/test_module_packages.py b/tests/unit/specfact_cli/registry/test_module_packages.py index ac936d0a..ecb4bf11 100644 --- a/tests/unit/specfact_cli/registry/test_module_packages.py +++ b/tests/unit/specfact_cli/registry/test_module_packages.py @@ -9,6 +9,7 @@ import logging import os +import sys from collections.abc import Generator from pathlib import Path @@ -147,6 +148,133 @@ def test_make_package_loader_wraps_runtime_import_errors_with_compatibility_guid assert "same Python interpreter" in message +def _write_runtime_package( + package_dir: Path, + *, + manifest: str, + files: dict[str, str], +) -> None: + package_dir.mkdir(parents=True) + (package_dir / "module-package.yaml").write_text(manifest, encoding="utf-8") + for rel_path, content in files.items(): + path = package_dir / rel_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def test_installed_group_loader_adds_enabled_dependency_module_src_roots( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Installed command loading should resolve imports from enabled installed module dependencies.""" + from specfact_cli.registry import module_packages as module_packages_impl + + project_dir = tmp_path / "specfact-project" + codebase_dir = tmp_path / "specfact-codebase" + _write_runtime_package( + project_dir, + manifest=""" +name: nold-ai/specfact-project +version: '0.41.9' +commands: [project] +category: project +bundle: specfact-project +bundle_group_command: project +""", + files={"src/specfact_project/__init__.py": "VALUE = 'project-loaded'\n"}, + ) + _write_runtime_package( + codebase_dir, + manifest=""" +name: nold-ai/specfact-codebase +version: '0.41.9' +commands: [code] +category: codebase +bundle: specfact-codebase +bundle_group_command: code +""", + files={ + "src/specfact_codebase/code/app.py": """ +import typer +import specfact_project + +app = typer.Typer(name='code') +for command_name in ('import', 'analyze', 'drift', 'validate', 'repro'): + app.add_typer(typer.Typer(name=command_name), name=command_name) +""".strip() + + "\n", + }, + ) + + monkeypatch.setattr( + sys, + "path", + [entry for entry in sys.path if "specfact-cli-modules" not in entry and str(tmp_path) not in entry], + ) + sys.modules.pop("specfact_project", None) + metadata_by_name = {meta.name: meta for _package_dir, meta in discover_package_metadata(tmp_path)} + monkeypatch.setattr( + module_packages_impl, + "discover_all_package_metadata", + lambda: [ + (project_dir, metadata_by_name["nold-ai/specfact-project"]), + (codebase_dir, metadata_by_name["nold-ai/specfact-codebase"]), + ], + ) + monkeypatch.setattr(module_packages_impl, "verify_module_artifact", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module_packages_impl, "read_modules_state", dict) + monkeypatch.setattr(module_packages_impl, "_check_protocol_compliance_from_source", lambda *_args, **_kwargs: []) + + module_packages_impl.register_module_package_commands() + + code_app = CommandRegistry.get_typer("code") + group_names = {group.name for group in getattr(code_app, "registered_groups", []) if getattr(group, "name", None)} + assert {"import", "analyze", "drift", "validate", "repro"}.issubset(group_names) + + +def test_lazy_loader_failure_is_recorded_for_availability_diagnostics( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Lazy import failures should be available to missing-command diagnostics as installed-unavailable.""" + from specfact_cli.registry import module_packages as module_packages_impl + from specfact_cli.registry.module_availability import ModuleAvailabilityStatus, classify_module_availability + from specfact_cli.registry.module_discovery import DiscoveredModule + + codebase_dir = tmp_path / "specfact-codebase" + _write_runtime_package( + codebase_dir, + manifest=""" +name: nold-ai/specfact-codebase +version: '0.41.9' +commands: [code] +category: codebase +bundle: specfact-codebase +bundle_group_command: code +""", + files={"src/specfact_codebase/code/app.py": "import missing_dependency_for_codebase\n"}, + ) + meta = discover_package_metadata(tmp_path)[0][1] + + monkeypatch.setattr(sys, "path", [entry for entry in sys.path if str(tmp_path) not in entry]) + monkeypatch.setattr(module_packages_impl, "discover_all_package_metadata", lambda: [(codebase_dir, meta)]) + monkeypatch.setattr(module_packages_impl, "verify_module_artifact", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module_packages_impl, "read_modules_state", dict) + monkeypatch.setattr(module_packages_impl, "_check_protocol_compliance_from_source", lambda *_args, **_kwargs: []) + monkeypatch.setattr( + "specfact_cli.registry.module_availability.discover_all_modules_for_project_with_shadowed", + lambda _: [DiscoveredModule(codebase_dir, meta, "user")], + ) + monkeypatch.setattr("specfact_cli.registry.module_availability.read_modules_state", dict) + + module_packages_impl.register_module_package_commands() + with pytest.raises(ValueError, match="missing_dependency_for_codebase"): + CommandRegistry.get_typer("code") + + availability = classify_module_availability(module_id="nold-ai/specfact-codebase", command_name="code") + + assert availability.status is ModuleAvailabilityStatus.SKIPPED + assert "missing_dependency_for_codebase" in availability.reason + + def test_merge_module_state_new_modules_enabled(): """New discovered modules get enabled: true.""" discovered = [("new_one", "1.0.0")] diff --git a/tests/unit/utils/test_env_manager.py b/tests/unit/utils/test_env_manager.py index 70d7c48b..d38514a1 100644 --- a/tests/unit/utils/test_env_manager.py +++ b/tests/unit/utils/test_env_manager.py @@ -156,13 +156,49 @@ def test_detect_pip_from_setup_py(self, tmp_path: Path): def test_detect_unknown_fallback(self, tmp_path: Path): """Test fallback to unknown when no manager detected.""" - info = detect_env_manager(tmp_path) + with patch("shutil.which", return_value=None): + info = detect_env_manager(tmp_path) assert info.manager == EnvManager.UNKNOWN assert info.available is True assert info.command_prefix == [] assert info.message is not None and "No environment manager detected" in info.message + def test_detect_uv_from_path_when_no_project_markers(self, tmp_path: Path): + """PATH-only uv should be treated as a compatible manager before unknown fallback.""" + with patch("shutil.which", side_effect=lambda name: "/usr/bin/uv" if name == "uv" else None): + info = detect_env_manager(tmp_path) + + assert info.manager == EnvManager.UV + assert info.available is True + assert info.command_prefix == ["uv", "run"] + + def test_detect_uv_from_rootless_monorepo_pyproject(self, tmp_path: Path): + """Rootless monorepos should detect uv when package pyprojects exist and uv is on PATH.""" + package = tmp_path / "backend" + package.mkdir() + (package / "pyproject.toml").write_text("[project]\nname = 'backend'\n", encoding="utf-8") + + with patch("shutil.which", side_effect=lambda name: "/usr/bin/uv" if name == "uv" else None): + info = detect_env_manager(tmp_path) + + assert info.manager == EnvManager.UV + assert info.available is True + assert info.command_prefix == ["uv", "run"] + + def test_detect_uv_from_second_level_monorepo_lock(self, tmp_path: Path): + """Rootless monorepos should scan up to two levels for uv lock markers.""" + service = tmp_path / "apps" / "worker" + service.mkdir(parents=True) + (service / "uv.lock").write_text("version = 1\n", encoding="utf-8") + + with patch("shutil.which", side_effect=lambda name: "/usr/bin/uv" if name == "uv" else None): + info = detect_env_manager(tmp_path) + + assert info.manager == EnvManager.UV + assert info.available is True + assert info.command_prefix == ["uv", "run"] + def test_detect_priority_hatch_over_poetry(self, tmp_path: Path): """Test that hatch takes priority over poetry when both present.""" pyproject = tmp_path / "pyproject.toml" From cb567fd91d5466a83038fbace772d7b335c99a19 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Fri, 8 May 2026 00:04:23 +0200 Subject: [PATCH 2/4] fix runtime discovery review findings --- CHANGELOG.md | 5 ++ scripts/runtime_discovery_smoke.py | 16 +++-- src/specfact_cli/cli.py | 4 +- .../modules/init/module-package.yaml | 2 +- src/specfact_cli/modules/init/src/commands.py | 13 +++- .../registry/module_availability.py | 8 ++- src/specfact_cli/registry/module_packages.py | 71 ++++++++++++++++++- src/specfact_cli/utils/env_manager.py | 4 +- src/specfact_cli/utils/ide_setup.py | 6 +- tests/e2e/test_init_command.py | 5 ++ .../scripts/test_runtime_discovery_smoke.py | 30 ++++++++ .../registry/test_module_availability.py | 11 +++ .../registry/test_module_packages.py | 59 +++++++++++++++ tests/unit/utils/test_env_manager.py | 18 +++++ tests/unit/utils/test_ide_setup.py | 3 +- 15 files changed, 237 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd45e6b2..631e0709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ All notable changes to this project will be documented in this file. ## [0.46.19] - 2026-05-07 +### Added + +- **IDE initialization environment selection**: `specfact init ide --env-manager ` + now lets users explicitly select the environment manager used for IDE setup. + ### Fixed - **Runtime module discovery**: load installed module dependency `src` roots diff --git a/scripts/runtime_discovery_smoke.py b/scripts/runtime_discovery_smoke.py index baec6b56..f983ba4f 100644 --- a/scripts/runtime_discovery_smoke.py +++ b/scripts/runtime_discovery_smoke.py @@ -107,10 +107,11 @@ def _resolve_modules_repo(configured: str | None) -> Path: candidates.extend( [ REPO_ROOT.parent / "specfact-cli-modules", - REPO_ROOT.parents[2] / "specfact-cli-modules", Path.cwd().parent / "specfact-cli-modules", ] ) + if len(REPO_ROOT.parents) > 2: + candidates.append(REPO_ROOT.parents[2] / "specfact-cli-modules") for candidate in candidates: packages = candidate / "packages" if all((packages / module_id.split("/", 1)[1] / "module-package.yaml").exists() for module_id in MODULE_IDS): @@ -173,12 +174,12 @@ def _build_local_registry(workspace: Path, modules_repo: Path) -> Path: def _create_pip_editable_launcher(workspace: Path) -> list[str]: venv_dir = workspace / "pip-editable-venv" try: - venv.EnvBuilder(with_pip=True, system_site_packages=True).create(venv_dir) + venv.EnvBuilder(with_pip=True).create(venv_dir) except subprocess.CalledProcessError: virtualenv = shutil.which("virtualenv") if virtualenv is None: raise - _run([virtualenv, "--system-site-packages", str(venv_dir)], cwd=REPO_ROOT, env=os.environ.copy(), timeout=120) + _run([virtualenv, str(venv_dir)], cwd=REPO_ROOT, env=os.environ.copy(), timeout=120) python = venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") _run( [str(python), "-m", "pip", "install", "-e", str(REPO_ROOT)], @@ -288,8 +289,12 @@ def main() -> int: args = parser.parse_args() modules_repo = _resolve_modules_repo(args.modules_repo) - workspace_obj = tempfile.TemporaryDirectory(prefix="specfact-runtime-discovery-smoke-") - workspace = Path(workspace_obj.name) + workspace_obj: tempfile.TemporaryDirectory[str] | None = None + if args.keep_workspace: + workspace = Path(tempfile.mkdtemp(prefix="specfact-runtime-discovery-smoke-")) + else: + workspace_obj = tempfile.TemporaryDirectory(prefix="specfact-runtime-discovery-smoke-") + workspace = Path(workspace_obj.name) try: demo = _create_rootless_monorepo_demo(workspace, args.demo_repo) index_path = _build_local_registry(workspace, modules_repo) @@ -297,7 +302,6 @@ def main() -> int: _smoke_launcher(launcher, workspace, demo, index_path, modules_repo) if args.keep_workspace: LOGGER.info("Kept workspace: %s", workspace) - workspace_obj = None # type: ignore[assignment] return 0 finally: if workspace_obj is not None: diff --git a/src/specfact_cli/cli.py b/src/specfact_cli/cli.py index a4c8bc37..3d9271d7 100644 --- a/src/specfact_cli/cli.py +++ b/src/specfact_cli/cli.py @@ -178,10 +178,10 @@ def resolve_command( _print_missing_bundle_command_help(invoked) raise SystemExit(1) from None raise - except ValueError: + except ValueError as exc: if invoked in KNOWN_BUNDLE_GROUP_OR_SHIM_NAMES: _print_missing_bundle_command_help(invoked) - raise SystemExit(1) from None + raise SystemExit(1) from exc raise _name, cmd, remaining = result if cmd is not None or not remaining: diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index aaed0552..214d797b 100644 --- a/src/specfact_cli/modules/init/module-package.yaml +++ b/src/specfact_cli/modules/init/module-package.yaml @@ -1,5 +1,5 @@ name: init -version: 0.1.32 +version: 0.1.33 commands: - init category: core diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py index 0fbdbd08..ca849db9 100644 --- a/src/specfact_cli/modules/init/src/commands.py +++ b/src/specfact_cli/modules/init/src/commands.py @@ -654,6 +654,12 @@ def init_ide( if env_manager is EnvManager.AUTO else env_info_from_tool_choice(env_manager, repo_path) ) + if env_manager is not EnvManager.AUTO and not env_info.available: + console.print(Panel(f"[bold red]{env_info.message}[/bold red]", border_style="red")) + raise typer.Exit(1) + if env_info.manager is not EnvManager.UNKNOWN: + console.print(f"[cyan]Environment manager:[/cyan] {env_info.manager.value}") + console.print() if env_info.manager == EnvManager.UNKNOWN: console.print( Panel( @@ -688,7 +694,12 @@ def init_ide( copied_files, settings_path = copy_templates_to_ide( repo_path, selected_ide, force, prompts_by_source=selected_catalog ) - write_ide_prompt_export_state(repo_path, selected_ide, sorted(selected_catalog.keys())) + write_ide_prompt_export_state( + repo_path, + selected_ide, + sorted(selected_catalog.keys()), + env_manager=env_info.manager.value, + ) _copy_backlog_field_mapping_templates(repo_path, force, console) console.print() diff --git a/src/specfact_cli/registry/module_availability.py b/src/specfact_cli/registry/module_availability.py index dd257d30..6d7cc891 100644 --- a/src/specfact_cli/registry/module_availability.py +++ b/src/specfact_cli/registry/module_availability.py @@ -197,7 +197,13 @@ def classify_module_availability( command_name: str | None = None, base_path: Path | None = None, ) -> ModuleAvailability: - """Classify module availability using manifests and modules.json only.""" + """ + Classify module availability from discovery state and process load failures. + + Decisions use manifests/modules.json plus the process-scoped lazy-load failure + registry exposed by get_module_load_failure_reason and backed by + _MODULE_LOAD_FAILURES. + """ discovered = discover_all_modules_for_project_with_shadowed(base_path) matches = _availability_matches(discovered, module_id=module_id, command_name=command_name) requested_id = module_id or command_name or "" diff --git a/src/specfact_cli/registry/module_packages.py b/src/specfact_cli/registry/module_packages.py index 6e02f134..e00169c9 100644 --- a/src/specfact_cli/registry/module_packages.py +++ b/src/specfact_cli/registry/module_packages.py @@ -13,8 +13,11 @@ import importlib import importlib.util import os +import site import sys +import sysconfig import tempfile +from contextlib import suppress from dataclasses import dataclass from pathlib import Path from typing import Any, cast @@ -144,6 +147,51 @@ def _is_builtin_module_package(package_dir: Path) -> bool: return False +def _is_under_directory(path: Path, parent: Path) -> bool: + try: + path.resolve().relative_to(parent.resolve()) + return True + except ValueError: + return False + + +def _installed_package_search_roots() -> tuple[Path, ...]: + """Return interpreter-managed roots that can contain installed packages.""" + roots: list[Path] = [] + for key in ("purelib", "platlib"): + raw_path = sysconfig.get_paths().get(key) + if raw_path: + roots.append(Path(raw_path)) + with suppress(AttributeError): + roots.extend(Path(path) for path in site.getsitepackages()) + return tuple(dict.fromkeys(root.resolve() for root in roots if root.exists())) + + +def _has_installed_distribution_metadata(package_dir: Path) -> bool: + """Return True when package_dir has adjacent Python distribution metadata.""" + parent = package_dir.parent + normalized = package_dir.name.replace("-", "_").lower() + metadata_dirs = [*parent.glob("*.dist-info"), *parent.glob("*.egg-info")] + return any(path.name.replace("-", "_").lower().startswith(f"{normalized}-") for path in metadata_dirs) + + +def _is_managed_specfact_module_package(package_dir: Path) -> bool: + """Return True for modules installed under a SpecFact-managed modules root.""" + parts = package_dir.resolve().parts + return ".specfact" in parts and "modules" in parts + + +def _is_installed_module_package(package_dir: Path) -> bool: + """Return True when package_dir represents an installed package, not a source checkout.""" + if _is_builtin_module_package(package_dir): + return False + if _is_managed_specfact_module_package(package_dir): + return True + if _has_installed_distribution_metadata(package_dir): + return True + return any(_is_under_directory(package_dir, root) for root in _installed_package_search_roots()) + + @beartype @ensure(lambda result: isinstance(result, list), "Must return a list of paths") def get_modules_roots() -> list[Path]: @@ -606,12 +654,14 @@ def _resolve_command_loader_path( def _remember_active_module_src(package_dir: Path) -> None: """Remember an eligible installed module source root for lazy cross-module imports.""" + if not _is_installed_module_package(package_dir): + return src_dir = package_dir / "src" if not src_dir.is_dir(): return resolved = src_dir.resolve() if resolved not in _ACTIVE_MODULE_SRC_DIRS: - _ACTIVE_MODULE_SRC_DIRS.append(resolved) + _ACTIVE_MODULE_SRC_DIRS.insert(0, resolved) def _prepend_active_module_src_roots() -> None: @@ -627,6 +677,18 @@ def _record_module_load_failure(package_name: str, command_name: str, reason: st _MODULE_LOAD_FAILURES[(package_name, "*")] = reason +def _clear_module_load_failure(package_name: str, command_name: str) -> None: + _MODULE_LOAD_FAILURES.pop((package_name, command_name), None) + _MODULE_LOAD_FAILURES.pop((package_name, "*"), None) + + +@beartype +@ensure(lambda result: result is None, "must return None") +def clear_module_load_failures() -> None: + """Clear process-scoped lazy module load failure diagnostics.""" + _MODULE_LOAD_FAILURES.clear() + + def _package_name_non_empty(package_name: str) -> bool: return bool(package_name.strip()) @@ -664,7 +726,7 @@ def loader() -> Any: sys.modules[spec.name] = mod try: spec.loader.exec_module(mod) - except (ImportError, ModuleNotFoundError, OSError) as exc: + except Exception as exc: message = ( "Runtime compatibility error while loading " f"module '{package_name}' command '{command_name}' from {package_dir}: {exc}. " @@ -677,7 +739,10 @@ def loader() -> Any: if app is None: app = getattr(mod, "app", None) if app is None: - raise ValueError(f"Package {package_dir.name} has no '{command_attr}' or 'app' attribute") + message = f"Package {package_dir.name} has no '{command_attr}' or 'app' attribute" + _record_module_load_failure(package_name, command_name, message) + raise ValueError(message) + _clear_module_load_failure(package_name, command_name) return app return loader diff --git a/src/specfact_cli/utils/env_manager.py b/src/specfact_cli/utils/env_manager.py index ab7fc4b0..fc08d83c 100644 --- a/src/specfact_cli/utils/env_manager.py +++ b/src/specfact_cli/utils/env_manager.py @@ -237,7 +237,7 @@ def _env_info_from_monorepo_markers(repo_path: Path) -> EnvManagerInfo | None: def _env_info_from_path_fallback() -> EnvManagerInfo | None: - for manager in (EnvManager.UV, EnvManager.HATCH, EnvManager.POETRY, EnvManager.PIP): + for manager in (EnvManager.UV, EnvManager.PIP): executable = "pip" if manager is EnvManager.PIP else manager.value if shutil.which(executable) is not None: return _env_info_for_available_tool(manager, f"Detected {executable} in PATH") @@ -259,7 +259,7 @@ def detect_env_manager(repo_path: Path) -> EnvManagerInfo: 4. Check for uv.lock or uv.toml → uv 5. Check for poetry.lock → poetry 6. Check for requirements.txt or setup.py → pip - 7. Check if tools are globally available → pip (fallback) + 7. Check if uv or pip are globally available → uv or pip fallback Args: repo_path: Path to the repository root diff --git a/src/specfact_cli/utils/ide_setup.py b/src/specfact_cli/utils/ide_setup.py index d585145c..702b18b5 100644 --- a/src/specfact_cli/utils/ide_setup.py +++ b/src/specfact_cli/utils/ide_setup.py @@ -835,7 +835,9 @@ def _finalize_vscode_prompt_recommendation_paths( @require(lambda ide: isinstance(ide, str) and len(ide) > 0, "ide must be non-empty") @require(lambda source_ids: isinstance(source_ids, list) and all(isinstance(s, str) for s in source_ids), "bad sources") @ensure(lambda result: result is None, "Must return None") -def write_ide_prompt_export_state(repo_path: Path, ide: str, source_ids: list[str]) -> None: +def write_ide_prompt_export_state( + repo_path: Path, ide: str, source_ids: list[str], env_manager: str | None = None +) -> None: """Persist last ``init ide`` source selection for audit/outdated checks on ``specfact init``.""" specfact_dir = repo_path / ".specfact" specfact_dir.mkdir(parents=True, exist_ok=True) @@ -844,6 +846,8 @@ def write_ide_prompt_export_state(repo_path: Path, ide: str, source_ids: list[st "ide": ide, "prompt_sources": sorted(source_ids), } + if env_manager: + payload["env_manager"] = env_manager out = specfact_dir / IDE_PROMPT_EXPORT_STATE_FILE out.write_text(yaml.safe_dump(payload, sort_keys=False, allow_unicode=False), encoding="utf-8") diff --git a/tests/e2e/test_init_command.py b/tests/e2e/test_init_command.py index 424964a7..2cf31213 100644 --- a/tests/e2e/test_init_command.py +++ b/tests/e2e/test_init_command.py @@ -540,6 +540,7 @@ def test_init_no_warning_with_explicit_uv_env_manager(self, tmp_path, monkeypatc templates_dir = tmp_path / "resources" / "prompts" templates_dir.mkdir(parents=True) (templates_dir / "specfact.01-import.md").write_text("---\ndescription: Analyze\n---\nContent") + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) old_cwd = os.getcwd() try: @@ -563,6 +564,8 @@ def test_init_no_warning_with_explicit_uv_env_manager(self, tmp_path, monkeypatc assert result.exit_code == 0 assert "No Compatible Environment Manager Detected" not in result.stdout + assert "Environment manager:" in result.stdout + assert "uv" in result.stdout def test_init_no_warning_with_rootless_monorepo_uv(self, tmp_path, monkeypatch): """Rootless monorepo package markers plus uv on PATH should avoid the unknown warning.""" @@ -583,3 +586,5 @@ def test_init_no_warning_with_rootless_monorepo_uv(self, tmp_path, monkeypatch): assert result.exit_code == 0 assert "No Compatible Environment Manager Detected" not in result.stdout + assert "Environment manager:" in result.stdout + assert "uv" in result.stdout diff --git a/tests/integration/scripts/test_runtime_discovery_smoke.py b/tests/integration/scripts/test_runtime_discovery_smoke.py index 405b21d4..d02e1a77 100644 --- a/tests/integration/scripts/test_runtime_discovery_smoke.py +++ b/tests/integration/scripts/test_runtime_discovery_smoke.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import shutil import subprocess import sys from pathlib import Path @@ -56,3 +57,32 @@ def test_runtime_discovery_smoke_direct_launcher() -> None: assert result.returncode == 0, result.stdout assert "runtime-discovery smoke passed for launcher=direct" in result.stdout + + +def test_runtime_discovery_smoke_keep_workspace_preserves_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import scripts.runtime_discovery_smoke as smoke + + captured: dict[str, Path] = {} + + def create_demo(workspace: Path, _template: Path | None) -> Path: + demo = workspace / "demo" + demo.mkdir() + return demo + + def smoke_launcher(_name: str, workspace: Path, _demo: Path, _index_path: Path, _modules_repo: Path) -> None: + captured["workspace"] = workspace + + monkeypatch.setattr(smoke, "_resolve_modules_repo", lambda _configured: tmp_path) + monkeypatch.setattr(smoke, "_create_rootless_monorepo_demo", create_demo) + monkeypatch.setattr(smoke, "_build_local_registry", lambda workspace, _modules_repo: workspace / "index.json") + monkeypatch.setattr(smoke, "_smoke_launcher", smoke_launcher) + monkeypatch.setattr(sys, "argv", ["runtime_discovery_smoke.py", "--keep-workspace"]) + + try: + assert smoke.main() == 0 + assert captured["workspace"].exists() + finally: + if "workspace" in captured: + shutil.rmtree(captured["workspace"], ignore_errors=True) diff --git a/tests/unit/specfact_cli/registry/test_module_availability.py b/tests/unit/specfact_cli/registry/test_module_availability.py index fcb2207d..31d92236 100644 --- a/tests/unit/specfact_cli/registry/test_module_availability.py +++ b/tests/unit/specfact_cli/registry/test_module_availability.py @@ -2,11 +2,22 @@ from __future__ import annotations +from collections.abc import Generator from pathlib import Path +import pytest + from specfact_cli.models.module_package import ModulePackageMetadata from specfact_cli.registry.module_availability import ModuleAvailabilityStatus, classify_module_availability from specfact_cli.registry.module_discovery import DiscoveredModule +from specfact_cli.registry.module_packages import clear_module_load_failures + + +@pytest.fixture(autouse=True) +def _clear_lazy_load_failures() -> Generator[None, None, None]: + clear_module_load_failures() + yield + clear_module_load_failures() def test_classify_installed_disabled_module_reports_disabled(monkeypatch) -> None: diff --git a/tests/unit/specfact_cli/registry/test_module_packages.py b/tests/unit/specfact_cli/registry/test_module_packages.py index ecb4bf11..ff4d830f 100644 --- a/tests/unit/specfact_cli/registry/test_module_packages.py +++ b/tests/unit/specfact_cli/registry/test_module_packages.py @@ -25,6 +25,7 @@ ) from specfact_cli.registry import CommandRegistry from specfact_cli.registry.module_packages import ( + clear_module_load_failures, discover_package_metadata, get_installed_bundles, get_modules_root, @@ -38,8 +39,10 @@ @pytest.fixture(autouse=True) def _reset_registry() -> Generator[None, None, None]: CommandRegistry._clear_for_testing() + clear_module_load_failures() yield CommandRegistry._clear_for_testing() + clear_module_load_failures() def test_get_modules_root_under_specfact_cli(): @@ -148,6 +151,62 @@ def test_make_package_loader_wraps_runtime_import_errors_with_compatibility_guid assert "same Python interpreter" in message +def test_make_package_loader_records_non_import_runtime_failures(tmp_path: Path) -> None: + """Lazy loader diagnostics should cache any import-time execution failure.""" + from specfact_cli.registry import module_packages as module_packages_impl + + package_dir = tmp_path / "specfact-backlog" + nested_app = package_dir / "src" / "specfact_backlog" / "backlog" / "app.py" + nested_app.parent.mkdir(parents=True, exist_ok=True) + nested_app.write_text("raise RuntimeError('boom')\n", encoding="utf-8") + + loader = module_packages_impl._make_package_loader(package_dir, "nold-ai/specfact-backlog", "backlog") + + with pytest.raises(ValueError, match="Runtime compatibility error"): + loader() + + reason = module_packages_impl.get_module_load_failure_reason("nold-ai/specfact-backlog", "backlog") + assert reason is not None + assert "boom" in reason + + +def test_make_package_loader_records_missing_app_and_clears_on_success(tmp_path: Path) -> None: + """Missing app diagnostics should not persist after a later successful lazy load.""" + from specfact_cli.registry import module_packages as module_packages_impl + + package_dir = tmp_path / "specfact-backlog" + nested_app = package_dir / "src" / "specfact_backlog" / "backlog" / "app.py" + nested_app.parent.mkdir(parents=True, exist_ok=True) + nested_app.write_text("value = 1\n", encoding="utf-8") + loader = module_packages_impl._make_package_loader(package_dir, "nold-ai/specfact-backlog", "backlog") + + with pytest.raises(ValueError, match="has no 'backlog_app' or 'app' attribute"): + loader() + assert module_packages_impl.get_module_load_failure_reason("nold-ai/specfact-backlog") is not None + + nested_app.write_text("import typer\napp = typer.Typer(name='backlog')\n", encoding="utf-8") + assert loader() is not None + + assert module_packages_impl.get_module_load_failure_reason("nold-ai/specfact-backlog") is None + + +def test_remember_active_module_src_only_tracks_installed_modules(tmp_path: Path) -> None: + """Source checkout modules must not be prepended as active installed dependency roots.""" + from specfact_cli.registry import module_packages as module_packages_impl + + source_package = tmp_path / "source-checkout" / "specfact-codebase" + (source_package / "src").mkdir(parents=True) + installed_package = tmp_path / ".specfact" / "modules" / "specfact-code-review" + (installed_package / "src").mkdir(parents=True) + module_packages_impl._ACTIVE_MODULE_SRC_DIRS.clear() + + module_packages_impl._remember_active_module_src(source_package) + assert module_packages_impl._ACTIVE_MODULE_SRC_DIRS == [] + + module_packages_impl._remember_active_module_src(installed_package) + assert [(installed_package / "src").resolve()] == module_packages_impl._ACTIVE_MODULE_SRC_DIRS + + def _write_runtime_package( package_dir: Path, *, diff --git a/tests/unit/utils/test_env_manager.py b/tests/unit/utils/test_env_manager.py index d38514a1..79a79d5a 100644 --- a/tests/unit/utils/test_env_manager.py +++ b/tests/unit/utils/test_env_manager.py @@ -173,6 +173,24 @@ def test_detect_uv_from_path_when_no_project_markers(self, tmp_path: Path): assert info.available is True assert info.command_prefix == ["uv", "run"] + def test_path_only_hatch_and_poetry_do_not_select_project_scoped_managers(self, tmp_path: Path): + """PATH-only Hatch/Poetry should not be selected without repository markers.""" + with patch( + "shutil.which", + side_effect=lambda name: f"/usr/bin/{name}" if name in {"hatch", "poetry"} else None, + ): + info = detect_env_manager(tmp_path) + + assert info.manager == EnvManager.UNKNOWN + + def test_path_only_pip_remains_final_fallback(self, tmp_path: Path): + """PATH-only pip remains safe because it does not imply project-scoped execution.""" + with patch("shutil.which", side_effect=lambda name: "/usr/bin/pip" if name == "pip" else None): + info = detect_env_manager(tmp_path) + + assert info.manager == EnvManager.PIP + assert info.command_prefix == [] + def test_detect_uv_from_rootless_monorepo_pyproject(self, tmp_path: Path): """Rootless monorepos should detect uv when package pyprojects exist and uv is on PATH.""" package = tmp_path / "backend" diff --git a/tests/unit/utils/test_ide_setup.py b/tests/unit/utils/test_ide_setup.py index bfe8aebb..e4e701d3 100644 --- a/tests/unit/utils/test_ide_setup.py +++ b/tests/unit/utils/test_ide_setup.py @@ -379,9 +379,10 @@ def test_specfact_commands_excludes_backlog_prompt_ids() -> None: def test_write_and_load_ide_prompt_export_state_roundtrip(tmp_path: Path) -> None: """Persisted source ids round-trip for init audit when IDE matches.""" - write_ide_prompt_export_state(tmp_path, "cursor", ["core", "nold-ai/specfact-backlog"]) + write_ide_prompt_export_state(tmp_path, "cursor", ["core", "nold-ai/specfact-backlog"], env_manager="uv") loaded = load_ide_prompt_export_source_ids(tmp_path, "cursor") assert loaded == frozenset({"core", "nold-ai/specfact-backlog"}) + assert "env_manager: uv" in (tmp_path / ".specfact" / "ide-prompt-export.yaml").read_text(encoding="utf-8") def test_load_ide_prompt_export_source_ids_mismatched_ide_returns_none(tmp_path: Path) -> None: From 47ebc42b95389eb24c7891c1394425add59f339a Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Fri, 8 May 2026 00:12:51 +0200 Subject: [PATCH 3/4] fix installed module loader test fixture --- tests/unit/specfact_cli/registry/test_module_packages.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/specfact_cli/registry/test_module_packages.py b/tests/unit/specfact_cli/registry/test_module_packages.py index ff4d830f..77743a85 100644 --- a/tests/unit/specfact_cli/registry/test_module_packages.py +++ b/tests/unit/specfact_cli/registry/test_module_packages.py @@ -227,8 +227,9 @@ def test_installed_group_loader_adds_enabled_dependency_module_src_roots( """Installed command loading should resolve imports from enabled installed module dependencies.""" from specfact_cli.registry import module_packages as module_packages_impl - project_dir = tmp_path / "specfact-project" - codebase_dir = tmp_path / "specfact-codebase" + installed_modules_root = tmp_path / ".specfact" / "modules" + project_dir = installed_modules_root / "specfact-project" + codebase_dir = installed_modules_root / "specfact-codebase" _write_runtime_package( project_dir, manifest=""" @@ -270,7 +271,7 @@ def test_installed_group_loader_adds_enabled_dependency_module_src_roots( [entry for entry in sys.path if "specfact-cli-modules" not in entry and str(tmp_path) not in entry], ) sys.modules.pop("specfact_project", None) - metadata_by_name = {meta.name: meta for _package_dir, meta in discover_package_metadata(tmp_path)} + metadata_by_name = {meta.name: meta for _package_dir, meta in discover_package_metadata(installed_modules_root)} monkeypatch.setattr( module_packages_impl, "discover_all_package_metadata", From 0e7f8dbb7e11d0a8425efad1630ca8a59f232905 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Fri, 8 May 2026 00:27:03 +0200 Subject: [PATCH 4/4] fix review feedback on runtime discovery state isolation --- scripts/runtime_discovery_smoke.py | 12 +++++- src/specfact_cli/registry/module_packages.py | 22 ++++++++-- .../scripts/test_runtime_discovery_smoke.py | 36 ++++++++++++++++- .../registry/test_module_packages.py | 40 ++++++++++++++++++- 4 files changed, 101 insertions(+), 9 deletions(-) diff --git a/scripts/runtime_discovery_smoke.py b/scripts/runtime_discovery_smoke.py index f983ba4f..3d298735 100644 --- a/scripts/runtime_discovery_smoke.py +++ b/scripts/runtime_discovery_smoke.py @@ -23,6 +23,9 @@ REPO_ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = REPO_ROOT / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) MODULE_IDS = ("nold-ai/specfact-project", "nold-ai/specfact-codebase", "nold-ai/specfact-code-review") NO_ENV_WARNING = "No Compatible Environment Manager Detected" LOGGER = logging.getLogger("runtime-discovery-smoke") @@ -232,9 +235,13 @@ def _smoke_launcher(name: str, workspace: Path, demo: Path, index_path: Path, mo home.mkdir(parents=True) cli = _launcher_command(name, workspace / f"launcher-{name}") env = os.environ.copy() + python_path = str(SRC_ROOT) + if env.get("PYTHONPATH"): + python_path = os.pathsep.join([python_path, env["PYTHONPATH"]]) env.update( { "HOME": str(home), + "PYTHONPATH": python_path, "SPECFACT_MODULES_REPO": str(modules_repo), "SPECFACT_REGISTRY_INDEX_URL": str(index_path), "SPECFACT_ALLOW_UNSIGNED": "1", @@ -296,10 +303,11 @@ def main() -> int: workspace_obj = tempfile.TemporaryDirectory(prefix="specfact-runtime-discovery-smoke-") workspace = Path(workspace_obj.name) try: - demo = _create_rootless_monorepo_demo(workspace, args.demo_repo) index_path = _build_local_registry(workspace, modules_repo) for launcher in args.launcher or ["direct"]: - _smoke_launcher(launcher, workspace, demo, index_path, modules_repo) + launcher_workspace = workspace / f"run-{launcher}" + demo = _create_rootless_monorepo_demo(launcher_workspace, args.demo_repo) + _smoke_launcher(launcher, launcher_workspace, demo, index_path, modules_repo) if args.keep_workspace: LOGGER.info("Kept workspace: %s", workspace) return 0 diff --git a/src/specfact_cli/registry/module_packages.py b/src/specfact_cli/registry/module_packages.py index e00169c9..0a07dd7a 100644 --- a/src/specfact_cli/registry/module_packages.py +++ b/src/specfact_cli/registry/module_packages.py @@ -13,6 +13,7 @@ import importlib import importlib.util import os +import re import site import sys import sysconfig @@ -170,9 +171,9 @@ def _installed_package_search_roots() -> tuple[Path, ...]: def _has_installed_distribution_metadata(package_dir: Path) -> bool: """Return True when package_dir has adjacent Python distribution metadata.""" parent = package_dir.parent - normalized = package_dir.name.replace("-", "_").lower() + normalized = re.sub(r"[-_.]+", "_", package_dir.name).lower() metadata_dirs = [*parent.glob("*.dist-info"), *parent.glob("*.egg-info")] - return any(path.name.replace("-", "_").lower().startswith(f"{normalized}-") for path in metadata_dirs) + return any(re.sub(r"[-_.]+", "_", path.name).lower().startswith(f"{normalized}_") for path in metadata_dirs) def _is_managed_specfact_module_package(package_dir: Path) -> bool: @@ -679,7 +680,20 @@ def _record_module_load_failure(package_name: str, command_name: str, reason: st def _clear_module_load_failure(package_name: str, command_name: str) -> None: _MODULE_LOAD_FAILURES.pop((package_name, command_name), None) - _MODULE_LOAD_FAILURES.pop((package_name, "*"), None) + has_remaining_failure = any( + registered_package == package_name and registered_command != "*" + for registered_package, registered_command in _MODULE_LOAD_FAILURES + ) + if not has_remaining_failure: + _MODULE_LOAD_FAILURES.pop((package_name, "*"), None) + + +def _clear_active_module_src_dirs() -> None: + for src_dir in _ACTIVE_MODULE_SRC_DIRS: + src = str(src_dir) + while src in sys.path: + sys.path.remove(src) + _ACTIVE_MODULE_SRC_DIRS.clear() @beartype @@ -1493,7 +1507,7 @@ def register_module_package_commands( disable_ids = disable_ids or [] if allow_unsigned is None: allow_unsigned = os.environ.get("SPECFACT_ALLOW_UNSIGNED", "").strip().lower() in ("1", "true", "yes") - _ACTIVE_MODULE_SRC_DIRS.clear() + _clear_active_module_src_dirs() _MODULE_LOAD_FAILURES.clear() is_test_mode = os.environ.get("TEST_MODE") == "true" or os.environ.get("PYTEST_CURRENT_TEST") is not None packages = discover_all_package_metadata() diff --git a/tests/integration/scripts/test_runtime_discovery_smoke.py b/tests/integration/scripts/test_runtime_discovery_smoke.py index d02e1a77..eb3fa33c 100644 --- a/tests/integration/scripts/test_runtime_discovery_smoke.py +++ b/tests/integration/scripts/test_runtime_discovery_smoke.py @@ -68,7 +68,7 @@ def test_runtime_discovery_smoke_keep_workspace_preserves_directory( def create_demo(workspace: Path, _template: Path | None) -> Path: demo = workspace / "demo" - demo.mkdir() + demo.mkdir(parents=True) return demo def smoke_launcher(_name: str, workspace: Path, _demo: Path, _index_path: Path, _modules_repo: Path) -> None: @@ -86,3 +86,37 @@ def smoke_launcher(_name: str, workspace: Path, _demo: Path, _index_path: Path, finally: if "workspace" in captured: shutil.rmtree(captured["workspace"], ignore_errors=True) + + +def test_runtime_discovery_smoke_uses_fresh_demo_per_launcher(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import scripts.runtime_discovery_smoke as smoke + + demos: list[Path] = [] + registry_builds: list[Path] = [] + + def create_demo(workspace: Path, _template: Path | None) -> Path: + demo = workspace / "demo" + demo.mkdir(parents=True) + demos.append(demo) + return demo + + def build_registry(workspace: Path, _modules_repo: Path) -> Path: + registry_builds.append(workspace) + return workspace / "index.json" + + monkeypatch.setattr(smoke, "_resolve_modules_repo", lambda _configured: tmp_path) + monkeypatch.setattr(smoke, "_create_rootless_monorepo_demo", create_demo) + monkeypatch.setattr(smoke, "_build_local_registry", build_registry) + monkeypatch.setattr(smoke, "_smoke_launcher", lambda _name, _workspace, _demo, _index_path, _modules_repo: None) + monkeypatch.setattr( + sys, + "argv", + ["runtime_discovery_smoke.py", "--launcher", "direct", "--launcher", "console"], + ) + + assert smoke.main() == 0 + + assert len(registry_builds) == 1 + assert len(demos) == 2 + assert len({demo.resolve() for demo in demos}) == 2 + assert str(REPO_ROOT / "src") in smoke.sys.path diff --git a/tests/unit/specfact_cli/registry/test_module_packages.py b/tests/unit/specfact_cli/registry/test_module_packages.py index 77743a85..f4dd7bc8 100644 --- a/tests/unit/specfact_cli/registry/test_module_packages.py +++ b/tests/unit/specfact_cli/registry/test_module_packages.py @@ -23,7 +23,7 @@ VersionedModuleDependency, VersionedPipDependency, ) -from specfact_cli.registry import CommandRegistry +from specfact_cli.registry import CommandRegistry, module_packages as module_packages_impl from specfact_cli.registry.module_packages import ( clear_module_load_failures, discover_package_metadata, @@ -39,9 +39,11 @@ @pytest.fixture(autouse=True) def _reset_registry() -> Generator[None, None, None]: CommandRegistry._clear_for_testing() + module_packages_impl._ACTIVE_MODULE_SRC_DIRS.clear() clear_module_load_failures() yield CommandRegistry._clear_for_testing() + module_packages_impl._ACTIVE_MODULE_SRC_DIRS.clear() clear_module_load_failures() @@ -207,6 +209,40 @@ def test_remember_active_module_src_only_tracks_installed_modules(tmp_path: Path assert [(installed_package / "src").resolve()] == module_packages_impl._ACTIVE_MODULE_SRC_DIRS +def test_distribution_metadata_detection_normalizes_separator_variants(tmp_path: Path) -> None: + """Installed package metadata checks should match dash, underscore, and dot variants.""" + package_dir = tmp_path / "specfact-codebase" + package_dir.mkdir() + (tmp_path / "specfact_codebase-0.41.9.dist-info").mkdir() + + assert module_packages_impl._has_installed_distribution_metadata(package_dir) + + +def test_clear_module_load_failure_preserves_other_command_package_failure() -> None: + """A recovered command should not clear package-wide diagnostics while sibling command failures remain.""" + module_packages_impl._record_module_load_failure("nold-ai/specfact-codebase", "code", "code failed") + module_packages_impl._record_module_load_failure("nold-ai/specfact-codebase", "analyze", "analyze failed") + + module_packages_impl._clear_module_load_failure("nold-ai/specfact-codebase", "code") + + assert module_packages_impl.get_module_load_failure_reason("nold-ai/specfact-codebase", "code") == "analyze failed" + assert module_packages_impl.get_module_load_failure_reason("nold-ai/specfact-codebase") == "analyze failed" + + +def test_register_module_package_commands_removes_stale_active_src_from_sys_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Registry re-registration should remove stale injected module src roots before rebuilding them.""" + stale_src = Path("/tmp/specfact-stale-module-src").resolve() + module_packages_impl._ACTIVE_MODULE_SRC_DIRS.append(stale_src) + monkeypatch.setattr(sys, "path", [str(stale_src), str(stale_src), *sys.path]) + monkeypatch.setattr(module_packages_impl, "discover_all_package_metadata", list) + + module_packages_impl.register_module_package_commands() + + assert str(stale_src) not in sys.path + + def _write_runtime_package( package_dir: Path, *, @@ -270,7 +306,7 @@ def test_installed_group_loader_adds_enabled_dependency_module_src_roots( "path", [entry for entry in sys.path if "specfact-cli-modules" not in entry and str(tmp_path) not in entry], ) - sys.modules.pop("specfact_project", None) + monkeypatch.delitem(sys.modules, "specfact_project", raising=False) metadata_by_name = {meta.name: meta for _package_dir, meta in discover_package_metadata(installed_modules_root)} monkeypatch.setattr( module_packages_impl,