From 6825cffcb1cd750a3a1adc86d5461863dcea0d3b Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Tue, 24 Mar 2026 22:19:34 +0100 Subject: [PATCH 1/3] fix: harden cross-platform runtime and IDE resource discovery --- README.md | 2 + docs/guides/ide-integration.md | 11 +- docs/guides/troubleshooting.md | 9 +- .../TDD_EVIDENCE.md | 39 +++ .../tasks.md | 30 +- .../modules/init/module-package.yaml | 6 +- src/specfact_cli/modules/init/src/commands.py | 53 ++-- src/specfact_cli/registry/module_packages.py | 9 +- src/specfact_cli/runtime.py | 3 +- src/specfact_cli/utils/ide_setup.py | 261 ++++++++++++++---- src/specfact_cli/utils/terminal.py | 40 +++ .../modules/init/test_resource_resolution.py | 49 ++++ .../registry/test_module_packages.py | 20 ++ tests/unit/utils/test_ide_setup.py | 143 ++++++---- tests/unit/utils/test_terminal.py | 70 +++++ 15 files changed, 602 insertions(+), 143 deletions(-) create mode 100644 openspec/changes/packaging-02-cross-platform-runtime-and-module-resources/TDD_EVIDENCE.md create mode 100644 tests/unit/modules/init/test_resource_resolution.py diff --git a/README.md b/README.md index 91fca227..8c448afe 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ specfact init ide --ide cursor specfact init ide --ide vscode ``` +`specfact init ide` discovers prompt resources from installed workflow modules and exports them to your IDE. If module prompt payloads are not installed yet, the CLI uses packaged fallback resources. + ### Run Your First Flow ```bash diff --git a/docs/guides/ide-integration.md b/docs/guides/ide-integration.md index 54ba292a..091c84a8 100644 --- a/docs/guides/ide-integration.md +++ b/docs/guides/ide-integration.md @@ -11,7 +11,7 @@ permalink: /guides/ide-integration/ **CLI-First Approach**: SpecFact works offline, requires no account, and integrates with your existing workflow. Works with VS Code, Cursor, GitHub Actions, pre-commit hooks, or any IDE. No platform to learn, no vendor lock-in. -**Terminal Output**: The CLI automatically detects embedded terminals (Cursor, VS Code) and CI/CD environments, adapting output formatting automatically. Progress indicators work in all environments - see [Troubleshooting](troubleshooting.md#terminal-output-issues) for details. +**Terminal Output**: The CLI automatically detects embedded terminals (Cursor, VS Code) and CI/CD environments, adapts formatting automatically, and falls back to ASCII-safe rendering when the active terminal encoding cannot display UTF-8 symbols. See [Troubleshooting](troubleshooting.md#terminal-output-issues) for details. --- @@ -64,7 +64,7 @@ specfact init ide --ide cursor --install-deps **What it does:** 1. Detects your IDE (or uses `--ide` flag) -2. Copies prompt templates from `resources/prompts/` to IDE-specific location +2. Discovers prompt templates from installed workflow modules first, then copies them to the IDE-specific location 3. Creates/updates VS Code settings if needed 4. Makes slash commands available in your IDE 5. Optionally installs required packages for contract enhancement (if `--install-deps` is provided): @@ -105,10 +105,11 @@ The IDE automatically recognizes these commands and provides enhanced prompts. Slash commands are **markdown prompt templates** (not executable CLI commands). They: -1. **Live in your repository** - Templates are stored in `resources/prompts/` (packaged with SpecFact CLI) -2. **Get copied to IDE locations** - `specfact init` copies them to IDE-specific directories +1. **Are owned by installed workflow modules** - Bundle-specific prompts ship with their corresponding module packages +2. **Get copied to IDE locations** - `specfact init ide` discovers installed module resources and copies them to IDE-specific directories 3. **Registered automatically** - The IDE reads these files and makes them available as slash commands -4. **Provide enhanced prompts** - Templates include detailed instructions for the AI assistant +4. **Fall back safely during transition** - If no installed module prompt payloads are present yet, SpecFact can still use packaged core fallback resources +5. **Provide enhanced prompts** - Templates include detailed instructions for the AI assistant ### Template Format diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index e7dc04f7..b5240e63 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -542,7 +542,12 @@ The CLI automatically detects terminal capabilities in this order: - Interactive TTY with animations → **GRAPHICAL** mode - Non-interactive → **BASIC** mode -5. **Default Fallback**: +5. **Encoding Safety Detection**: + - UTF-8 capable streams keep Unicode icons and Rich box drawing + - Legacy encodings (for example `cp1252`) disable emoji and switch to ASCII-safe box rendering + - Unicode-unsafe text streams are reconfigured with replacement error handling to avoid hard crashes during output + +6. **Default Fallback**: - If uncertain → **BASIC** mode (safe, readable output) ### Terminal Modes @@ -553,6 +558,8 @@ The CLI supports three terminal modes (auto-selected based on detection): - **BASIC** - Plain text, no animations, simple progress updates for CI/CD and embedded terminals - **MINIMAL** - Minimal output for test mode +Rich output also downgrades symbol rendering when the active terminal encoding is not UTF-8-safe. This keeps Windows legacy consoles and other non-UTF-8 terminals readable instead of failing on icon output. + ### Environment Variables (Optional Overrides) You can override auto-detection using standard environment variables: diff --git a/openspec/changes/packaging-02-cross-platform-runtime-and-module-resources/TDD_EVIDENCE.md b/openspec/changes/packaging-02-cross-platform-runtime-and-module-resources/TDD_EVIDENCE.md new file mode 100644 index 00000000..f54e68a9 --- /dev/null +++ b/openspec/changes/packaging-02-cross-platform-runtime-and-module-resources/TDD_EVIDENCE.md @@ -0,0 +1,39 @@ +# TDD Evidence + +## Pre-Implementation Failing Run + +- Timestamp: 2026-03-24T21:32:21+01:00 +- Command: + +```bash +HATCH_DATA_DIR=/tmp/hatch-data HATCH_CACHE_DIR=/tmp/hatch-cache VIRTUALENV_OVERRIDE_APP_DATA=/tmp/virtualenv-appdata hatch run pytest tests/unit/utils/test_terminal.py tests/unit/utils/test_ide_setup.py tests/unit/modules/init/test_resource_resolution.py tests/unit/specfact_cli/registry/test_module_packages.py -q +``` + +- Result: failed during test collection. +- Failure summary: + - `tests/unit/utils/test_terminal.py` could not import `ensure_output_stream_safety` from `specfact_cli.utils.terminal`. + - `tests/unit/utils/test_ide_setup.py` could not import `discover_prompt_template_files` from `specfact_cli.utils.ide_setup`. + +## Post-Implementation Passing Run + +- Timestamp: 2026-03-24T22:07:38+01:00 +- Command: + +```bash +HATCH_DATA_DIR=/tmp/hatch-data HATCH_CACHE_DIR=/tmp/hatch-cache VIRTUALENV_OVERRIDE_APP_DATA=/tmp/virtualenv-appdata hatch run pytest tests/unit/utils/test_terminal.py tests/unit/utils/test_ide_setup.py tests/unit/modules/init/test_resource_resolution.py tests/unit/specfact_cli/registry/test_module_packages.py -q +``` + +- Result: passed. +- Summary: 83 tests passed, covering terminal encoding fallback, runtime compatibility diagnostics, repo-scoped module discovery, duplicate prompt-id handling, and backlog field mapping resource resolution. + +## Final Review Gate + +- Timestamp: 2026-03-24T22:07:18+01:00 +- Command: + +```bash +HATCH_DATA_DIR=/tmp/hatch-data HATCH_CACHE_DIR=/tmp/hatch-cache VIRTUALENV_OVERRIDE_APP_DATA=/tmp/virtualenv-appdata hatch run specfact code review run src/specfact_cli/utils/terminal.py src/specfact_cli/runtime.py src/specfact_cli/utils/ide_setup.py src/specfact_cli/modules/init/src/commands.py src/specfact_cli/registry/module_packages.py --exclude-tests +``` + +- Result: passed. +- Summary: `specfact code review run` completed with no findings on the shipped production files. diff --git a/openspec/changes/packaging-02-cross-platform-runtime-and-module-resources/tasks.md b/openspec/changes/packaging-02-cross-platform-runtime-and-module-resources/tasks.md index 1535453c..5631fdcb 100644 --- a/openspec/changes/packaging-02-cross-platform-runtime-and-module-resources/tasks.md +++ b/openspec/changes/packaging-02-cross-platform-runtime-and-module-resources/tasks.md @@ -1,27 +1,27 @@ ## 1. Spec And Scope Alignment -- [ ] 1.1 Finalize spec deltas for runtime portability and module-owned IDE prompt resources. -- [ ] 1.2 Confirm the prompt ownership boundary against `init-ide-prompt-source-selection` so this change provides the discovery foundation without duplicating prompt-selection UX work. -- [ ] 1.3 Integrate with `specfact-cli-modules/packaging-01-bundle-resource-payloads` for bundle-packaged prompts and other module-owned resource payloads. +- [x] 1.1 Finalize spec deltas for runtime portability and module-owned IDE prompt resources. +- [x] 1.2 Confirm the prompt ownership boundary against `init-ide-prompt-source-selection` so this change provides the discovery foundation without duplicating prompt-selection UX work. +- [x] 1.3 Integrate with `specfact-cli-modules/packaging-01-bundle-resource-payloads` for bundle-packaged prompts and other module-owned resource payloads. ## 2. Test-First Coverage -- [ ] 2.1 Add failing tests for help/startup rendering on non-UTF-8 terminal encodings with ASCII-safe fallback behavior. -- [ ] 2.2 Add failing tests for actionable runtime/interpreter compatibility errors during backlog automation or similar programmatic invocation. -- [ ] 2.3 Add failing tests for module-owned prompt discovery and `specfact init ide` export behavior from installed module resource directories. -- [ ] 2.4 Add failing tests for module-owned non-prompt resource lookup, starting with backlog field mapping templates. -- [ ] 2.5 Record failing test evidence in `TDD_EVIDENCE.md`. +- [x] 2.1 Add failing tests for help/startup rendering on non-UTF-8 terminal encodings with ASCII-safe fallback behavior. +- [x] 2.2 Add failing tests for actionable runtime/interpreter compatibility errors during backlog automation or similar programmatic invocation. +- [x] 2.3 Add failing tests for module-owned prompt discovery and `specfact init ide` export behavior from installed module resource directories. +- [x] 2.4 Add failing tests for module-owned non-prompt resource lookup, starting with backlog field mapping templates. +- [x] 2.5 Record failing test evidence in `TDD_EVIDENCE.md`. ## 3. Runtime And Resource Implementation -- [ ] 3.1 Implement terminal encoding detection and Unicode/icon fallback in the shared runtime/terminal configuration path. -- [ ] 3.2 Replace brittle path-injection behavior with installation-scoped runtime/module resolution and explicit compatibility diagnostics. -- [ ] 3.3 Refactor `specfact init ide` to build a prompt catalog from installed module resource locations rather than `specfact_cli/resources/prompts`. -- [ ] 3.4 Refactor core init/install resource copying to resolve module-owned templates, starting with backlog field mapping templates, from installed bundle packages. +- [x] 3.1 Implement terminal encoding detection and Unicode/icon fallback in the shared runtime/terminal configuration path. +- [x] 3.2 Replace brittle path-injection behavior with installation-scoped runtime/module resolution and explicit compatibility diagnostics. +- [x] 3.3 Refactor `specfact init ide` to build a prompt catalog from installed module resource locations rather than `specfact_cli/resources/prompts`. +- [x] 3.4 Refactor core init/install resource copying to resolve module-owned templates, starting with backlog field mapping templates, from installed bundle packages. - [ ] 3.5 Remove or relocate bundle-owned prompt/resources from core packaging so ownership matches installed modules. ## 4. Validation And Documentation -- [ ] 4.1 Re-run the new tests and record passing evidence in `TDD_EVIDENCE.md`. -- [ ] 4.2 Update docs and README guidance for cross-platform terminal behavior, supported automation invocation, and module-owned prompt resources. -- [ ] 4.3 Run `openspec validate packaging-02-cross-platform-runtime-and-module-resources --strict`. +- [x] 4.1 Re-run the new tests and record passing evidence in `TDD_EVIDENCE.md`. +- [x] 4.2 Update docs and README guidance for cross-platform terminal behavior, supported automation invocation, and module-owned prompt resources. +- [x] 4.3 Run `openspec validate packaging-02-cross-platform-runtime-and-module-resources --strict`. diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index 7358a9f7..220f587c 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.10 +version: 0.1.11 commands: - init category: core @@ -17,5 +17,5 @@ publisher: description: Initialize SpecFact workspace and bootstrap local configuration. license: Apache-2.0 integrity: - checksum: sha256:e95ce8c81cc16aac931b977f78c0e4652a68f3d2e81aa09ce496d4753698d231 - signature: UaFkWSDeevp4et+OM5aKrEk2E+lnTP3idTJg1K0tmFn8bF9tgU1fnnswKSQtn1wL6VEP8lv7XIDxeKxVZP2SDg== + checksum: sha256:47c95739e78c8958f14c3c4988fc15ad2b535ac3b07c4425204f79a9822a9ac0 + signature: 39uhz13xHUa3RMeYpIU6AKr63q5hYzZOxlrl8IDDrShl8s+hcZ0k5bp/4S3MASiJ7UxxCosJkI2t2bIrFmWJCA== diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py index 81a23325..c1051cbc 100644 --- a/src/specfact_cli/modules/init/src/commands.py +++ b/src/specfact_cli/modules/init/src/commands.py @@ -28,9 +28,10 @@ from specfact_cli.utils.env_manager import EnvManager, EnvManagerInfo, build_tool_command, detect_env_manager from specfact_cli.utils.ide_setup import ( IDE_CONFIG, - SPECFACT_COMMANDS, - copy_templates_to_ide, + _copy_template_files_to_ide, + _discover_module_resource_dirs, detect_ide, + discover_prompt_template_files, find_package_resources_path, ) @@ -51,6 +52,15 @@ def _resolve_field_mapping_templates_dir(repo_path: Path) -> Path | None: """Locate backlog field mapping templates (dev checkout or installed package).""" + for resource_root in _discover_module_resource_dirs( + "resources/templates/backlog/field_mappings", + repo_path=repo_path, + categories={"backlog"}, + ): + installed_templates_dir = (resource_root / "templates" / "backlog" / "field_mappings").resolve() + if installed_templates_dir.exists(): + return installed_templates_dir + dev_templates_dir = (repo_path / "resources" / "templates" / "backlog" / "field_mappings").resolve() if dev_templates_dir.exists(): return dev_templates_dir @@ -261,6 +271,10 @@ def _select_module_ids_interactive(action: str, modules_list: list[dict[str, Any def _resolve_templates_dir(repo_path: Path) -> Path | None: """Resolve templates directory from repo checkout or installed package.""" + prompt_files = discover_prompt_template_files(repo_path) + if prompt_files: + return prompt_files[0].parent + dev_templates_dir = (repo_path / "resources" / "prompts").resolve() if dev_templates_dir.exists(): return dev_templates_dir @@ -283,24 +297,24 @@ def _resolve_templates_dir(repo_path: Path) -> Path | None: return find_package_resources_path("specfact_cli", "resources/prompts") -def _expected_ide_prompt_basenames(format_type: str) -> list[str]: +def _expected_ide_prompt_basenames(repo_path: Path, format_type: str) -> list[str]: + prompt_files = discover_prompt_template_files(repo_path) if format_type == "prompt.md": - return [f"{cmd}.prompt.md" for cmd in SPECFACT_COMMANDS] + return [f"{path.stem}.prompt.md" for path in prompt_files] if format_type == "toml": - return [f"{cmd}.toml" for cmd in SPECFACT_COMMANDS] - return [f"{cmd}.md" for cmd in SPECFACT_COMMANDS] + return [f"{path.stem}.toml" for path in prompt_files] + return [path.name for path in prompt_files] -def _count_outdated_ide_prompts(ide_dir: Path, templates_dir: Path, format_type: str) -> int: +def _count_outdated_ide_prompts(ide_dir: Path, prompt_files: list[Path], format_type: str) -> int: outdated = 0 - for cmd in SPECFACT_COMMANDS: - src = templates_dir / f"{cmd}.md" + for src in prompt_files: if format_type == "prompt.md": - dest = ide_dir / f"{cmd}.prompt.md" + dest = ide_dir / f"{src.stem}.prompt.md" elif format_type == "toml": - dest = ide_dir / f"{cmd}.toml" + dest = ide_dir / f"{src.stem}.toml" else: - dest = ide_dir / f"{cmd}.md" + dest = ide_dir / src.name if src.exists() and dest.exists() and dest.stat().st_mtime < src.stat().st_mtime: outdated += 1 return outdated @@ -312,7 +326,7 @@ def _audit_prompt_installation(repo_path: Path) -> None: config = IDE_CONFIG[detected_ide] ide_dir = repo_path / str(config["folder"]) format_type = str(config["format"]) - expected_files = _expected_ide_prompt_basenames(format_type) + expected_files = _expected_ide_prompt_basenames(repo_path, format_type) if not ide_dir.exists(): console.print( @@ -322,8 +336,8 @@ def _audit_prompt_installation(repo_path: Path) -> None: return missing = [name for name in expected_files if not (ide_dir / name).exists()] - templates_dir = _resolve_templates_dir(repo_path) - outdated = _count_outdated_ide_prompts(ide_dir, templates_dir, format_type) if templates_dir else 0 + prompt_files = discover_prompt_template_files(repo_path) + outdated = _count_outdated_ide_prompts(ide_dir, prompt_files, format_type) if prompt_files else 0 if not missing and outdated == 0: console.print(f"[green]Prompt status:[/green] {detected_ide} prompts are present and up to date.") @@ -559,13 +573,14 @@ def init_ide( if install_deps: _install_contract_enhancement_dependencies(repo_path, env_info) - templates_dir = _resolve_templates_dir(repo_path) - if not templates_dir or not templates_dir.exists(): + prompt_files = discover_prompt_template_files(repo_path) + if not prompt_files: console.print("[red]Error:[/red] Templates directory not found.") raise typer.Exit(1) - console.print(f"[cyan]Templates:[/cyan] {templates_dir}") - copied_files, settings_path = copy_templates_to_ide(repo_path, selected_ide, templates_dir, force) + template_sources = ", ".join(sorted({str(path.parent) for path in prompt_files})) + console.print(f"[cyan]Templates:[/cyan] {template_sources}") + copied_files, settings_path = _copy_template_files_to_ide(repo_path, selected_ide, prompt_files, force) _copy_backlog_field_mapping_templates(repo_path, force, console) console.print() diff --git a/src/specfact_cli/registry/module_packages.py b/src/specfact_cli/registry/module_packages.py index da1b3177..6b6a95d6 100644 --- a/src/specfact_cli/registry/module_packages.py +++ b/src/specfact_cli/registry/module_packages.py @@ -578,7 +578,14 @@ def loader() -> Any: raise ValueError(f"Cannot load from {package_dir.name}") mod = importlib.util.module_from_spec(spec) sys.modules[spec.name] = mod - spec.loader.exec_module(mod) + try: + spec.loader.exec_module(mod) + except (ImportError, ModuleNotFoundError, OSError) as exc: + raise ValueError( + "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 command_attr = f"{_normalized_module_name(command_name)}_app" app = getattr(mod, command_attr, None) if app is None: diff --git a/src/specfact_cli/runtime.py b/src/specfact_cli/runtime.py index b7afabc1..073f0227 100644 --- a/src/specfact_cli/runtime.py +++ b/src/specfact_cli/runtime.py @@ -28,7 +28,7 @@ ) from specfact_cli.modes import OperationalMode from specfact_cli.utils.structured_io import StructuredFormat -from specfact_cli.utils.terminal import detect_terminal_capabilities, get_console_config +from specfact_cli.utils.terminal import detect_terminal_capabilities, ensure_output_stream_safety, get_console_config DEBUG_LOG_DATEFMT = "%Y-%m-%d %H:%M:%S" @@ -201,6 +201,7 @@ def get_configured_console() -> Console: (e.g. CliRunner's captured stdout after invoke() ends). """ mode = get_terminal_mode() + ensure_output_stream_safety() if _is_test_env(): config = get_console_config() diff --git a/src/specfact_cli/utils/ide_setup.py b/src/specfact_cli/utils/ide_setup.py index 33e97f7f..6d98b9f4 100644 --- a/src/specfact_cli/utils/ide_setup.py +++ b/src/specfact_cli/utils/ide_setup.py @@ -133,6 +133,211 @@ ] +def _iter_prompt_template_files(templates_dir: Path) -> list[Path]: + """Return prompt template files from a single directory in stable order.""" + if not templates_dir.exists() or not templates_dir.is_dir(): + return [] + return sorted(path for path in templates_dir.glob("specfact*.md") if path.is_file()) + + +def _module_discovery_roots(repo_path: Path | None) -> list[tuple[Path, str]]: + """Return module roots to inspect across builtin, repo-local, and configured locations.""" + from specfact_cli.registry.module_packages import get_modules_root, get_workspace_modules_root + + discovery_roots: list[tuple[Path, str]] = [] + + def _add_discovery_root(path: Path | None, source: str) -> None: + if path is None: + return + resolved = path.resolve() + if any(existing_root.resolve() == resolved for existing_root, _source in discovery_roots): + return + discovery_roots.append((path, source)) + + _add_discovery_root(get_modules_root(), "builtin") + if repo_path is not None: + _add_discovery_root((repo_path / ".specfact" / "modules").resolve(), "workspace") + _add_discovery_root(get_workspace_modules_root(repo_path), "workspace") + + extra_roots = os.environ.get("SPECFACT_MODULES_ROOTS", "") + for raw_root in extra_roots.split(os.pathsep): + candidate = raw_root.strip() + if not candidate: + continue + candidate_path = Path(candidate).expanduser() + if candidate_path.exists(): + _add_discovery_root(candidate_path, "custom") + + return discovery_roots + + +def _matches_requested_categories( + resolved_package_dir: Path, + candidate: Path, + metadata: Any | None, + requested_categories: set[str] | None, +) -> bool: + """Return whether the package should be considered for the requested categories.""" + if requested_categories is None: + return True + if metadata is not None: + return (metadata.category or "").lower() in requested_categories + candidate_hint = f"{resolved_package_dir.name} {candidate}".lower() + return any(category in candidate_hint for category in requested_categories) + + +def _discover_resource_dirs_from_root( + modules_root: Path, + source: str, + resource_subpath: str, + requested_categories: set[str] | None, + seen: set[Path], +) -> list[Path]: + """Discover module resource directories beneath a single module root.""" + from specfact_cli.registry.module_packages import discover_package_metadata + + if not modules_root.exists() or not modules_root.is_dir(): + return [] + + parsed_metadata = { + package_dir.resolve(): metadata + for package_dir, metadata in discover_package_metadata(modules_root, source=source) + } + discovered_dirs: list[Path] = [] + for package_dir in sorted(path for path in modules_root.iterdir() if path.is_dir()): + resolved_package_dir = package_dir.resolve() + metadata = parsed_metadata.get(resolved_package_dir) + resource_root = _package_resource_dir( + resolved_package_dir, metadata, resource_subpath, requested_categories, seen + ) + if resource_root is None: + continue + + seen.add(resource_root) + discovered_dirs.append(resource_root) + + return discovered_dirs + + +def _package_resource_dir( + resolved_package_dir: Path, + metadata: Any | None, + resource_subpath: str, + requested_categories: set[str] | None, + seen: set[Path], +) -> Path | None: + """Return the package resource root when the package should contribute the requested resource.""" + from specfact_cli.registry.module_packages import CORE_MODULE_ORDER + + if metadata is not None and metadata.name in CORE_MODULE_ORDER: + return None + + resource_root = (resolved_package_dir / "resources").resolve() + candidate = (resolved_package_dir / resource_subpath).resolve() + if not resource_root.exists() or not candidate.exists() or resource_root in seen: + return None + if not _matches_requested_categories(resolved_package_dir, candidate, metadata, requested_categories): + return None + return resource_root + + +@beartype +@ensure( + lambda result: isinstance(result, list) and all(isinstance(path, Path) and path.exists() for path in result), + "Must return existing resource directories", +) +def _discover_module_resource_dirs( + resource_subpath: str, repo_path: Path | None = None, categories: set[str] | None = None +) -> list[Path]: + """Discover installed module resource roots that contain the requested subpath.""" + requested_categories = {category.lower() for category in categories} if categories else None + seen: set[Path] = set() + discovered_dirs: list[Path] = [] + for modules_root, source in _module_discovery_roots(repo_path): + discovered_dirs.extend( + _discover_resource_dirs_from_root(modules_root, source, resource_subpath, requested_categories, seen) + ) + return discovered_dirs + + +@beartype +@ensure( + lambda result: isinstance(result, list) and all(isinstance(path, Path) for path in result), + "Must return list of Paths", +) +def discover_prompt_template_files(repo_path: Path) -> list[Path]: + """Return prompt templates from installed modules, falling back to core checkout resources.""" + prompt_files: list[Path] = [] + seen_names: set[str] = set() + + for resource_root in _discover_module_resource_dirs("resources/prompts", repo_path=repo_path): + for prompt_file in _iter_prompt_template_files(resource_root / "prompts"): + if prompt_file.name in seen_names: + continue + seen_names.add(prompt_file.name) + prompt_files.append(prompt_file) + + if prompt_files: + return prompt_files + + fallback_dirs = [ + (repo_path / "resources" / "prompts").resolve(), + find_package_resources_path("specfact_cli", "resources/prompts"), + ] + for fallback_dir in fallback_dirs: + if fallback_dir is None: + continue + fallback_files = _iter_prompt_template_files(fallback_dir) + if fallback_files: + return fallback_files + return [] + + +def _output_filename_for_template(template_path: Path, format_type: str) -> str: + """Map source markdown templates to IDE-specific filenames.""" + if format_type == "prompt.md": + return f"{template_path.stem}.prompt.md" + if format_type == "toml": + return f"{template_path.stem}.toml" + return template_path.name + + +def _copy_template_files_to_ide( + repo_path: Path, ide: str, template_files: list[Path], force: bool = False +) -> tuple[list[Path], Path | None]: + """Copy a concrete list of prompt template files to the IDE target location.""" + config = IDE_CONFIG[ide] + ide_folder = str(config["folder"]) + format_type = str(config["format"]) + settings_file = config.get("settings_file") + if settings_file is not None and not isinstance(settings_file, str): + settings_file = None + + ide_dir = repo_path / ide_folder + ide_dir.mkdir(parents=True, exist_ok=True) + + copied_files: list[Path] = [] + + for template_path in template_files: + template_data = read_template(template_path) + processed_content = process_template(template_data["content"], template_data["description"], format_type) # type: ignore[arg-type] + output_path = ide_dir / _output_filename_for_template(template_path, format_type) + + if output_path.exists() and not force: + console.print(f"[yellow]Skipping:[/yellow] {output_path} (already exists, use --force to overwrite)") + continue + + output_path.write_text(processed_content, encoding="utf-8") + copied_files.append(output_path) + console.print(f"[green]Copied:[/green] {output_path}") + + settings_path = None + if settings_file and isinstance(settings_file, str): + settings_path = create_vscode_settings(repo_path, settings_file) + + return copied_files, settings_path + + @beartype @require(lambda ide: ide in IDE_CONFIG or ide == "auto", "IDE must be valid or 'auto'") def detect_ide(ide: str = "auto") -> str: @@ -286,56 +491,7 @@ def copy_templates_to_ide( >>> len(copied) > 0 True """ - config = IDE_CONFIG[ide] - ide_folder = str(config["folder"]) - format_type = str(config["format"]) - settings_file = config.get("settings_file") - if settings_file is not None and not isinstance(settings_file, str): - settings_file = None - - # Create IDE directory - ide_dir = repo_path / ide_folder - ide_dir.mkdir(parents=True, exist_ok=True) - - copied_files: list[Path] = [] - - # Copy each template - for command in SPECFACT_COMMANDS: - template_path = templates_dir / f"{command}.md" - if not template_path.exists(): - console.print(f"[yellow]Warning:[/yellow] Template not found: {template_path}") - continue - - # Read and process template - template_data = read_template(template_path) - processed_content = process_template(template_data["content"], template_data["description"], format_type) # type: ignore[arg-type] - - # Determine output filename - if format_type == "prompt.md": - output_filename = f"{command}.prompt.md" - elif format_type == "toml": - output_filename = f"{command}.toml" - else: - output_filename = f"{command}.md" - - output_path = ide_dir / output_filename - - # Check if file exists - if output_path.exists() and not force: - console.print(f"[yellow]Skipping:[/yellow] {output_path} (already exists, use --force to overwrite)") - continue - - # Write processed template - output_path.write_text(processed_content, encoding="utf-8") - copied_files.append(output_path) - console.print(f"[green]Copied:[/green] {output_path}") - - # Handle VS Code settings if needed - settings_path = None - if settings_file and isinstance(settings_file, str): - settings_path = create_vscode_settings(repo_path, settings_file) - - return (copied_files, settings_path) + return _copy_template_files_to_ide(repo_path, ide, _iter_prompt_template_files(templates_dir), force) @beartype @@ -365,7 +521,10 @@ def create_vscode_settings(repo_path: Path, settings_file: str) -> Path | None: settings_dir.mkdir(parents=True, exist_ok=True) # Generate prompt file recommendations - prompt_files = [f".github/prompts/{cmd}.prompt.md" for cmd in SPECFACT_COMMANDS] + discovered_prompts = discover_prompt_template_files(repo_path) + prompt_files = [f".github/prompts/{template_path.stem}.prompt.md" for template_path in discovered_prompts] + if not prompt_files: + prompt_files = [f".github/prompts/{cmd}.prompt.md" for cmd in SPECFACT_COMMANDS] # Load existing settings or create new if settings_path.exists(): diff --git a/src/specfact_cli/utils/terminal.py b/src/specfact_cli/utils/terminal.py index 82c84ca7..bda5043e 100644 --- a/src/specfact_cli/utils/terminal.py +++ b/src/specfact_cli/utils/terminal.py @@ -51,6 +51,26 @@ class TerminalCapabilities: supports_animations: bool is_interactive: bool is_ci: bool + supports_unicode: bool = True + stream_encoding: str | None = None + + +def _get_stream_encoding(stream: Any) -> str | None: + """Return normalized stream encoding when available.""" + encoding = getattr(stream, "encoding", None) + return str(encoding) if encoding else None + + +def _stream_supports_unicode(stream: Any) -> bool: + """Check whether the stream encoding can emit common SpecFact symbols.""" + encoding = _get_stream_encoding(stream) + if encoding is None: + return True + try: + "✓⚠".encode(encoding) + except (LookupError, UnicodeEncodeError): + return False + return True @beartype @@ -75,6 +95,8 @@ def detect_terminal_capabilities() -> TerminalCapabilities: is_test_mode = os.environ.get("TEST_MODE") == "true" or os.environ.get("PYTEST_CURRENT_TEST") is not None is_tty = _stdout_is_tty() supports_color = _compute_supports_color(no_color, force_color, is_tty, is_ci) + stream_encoding = _get_stream_encoding(sys.stdout) + supports_unicode = _stream_supports_unicode(sys.stdout) # Determine animation support # Animations require interactive TTY and not CI/CD, and not test mode @@ -88,9 +110,23 @@ def detect_terminal_capabilities() -> TerminalCapabilities: supports_animations=supports_animations, is_interactive=is_interactive, is_ci=is_ci, + supports_unicode=supports_unicode, + stream_encoding=stream_encoding, ) +@beartype +@ensure(lambda result: result is None, "Function returns None") +def ensure_output_stream_safety(stdout: Any | None = None, stderr: Any | None = None) -> None: + """Switch unicode-unsafe text streams to replacement mode to avoid hard encode failures.""" + for stream in (sys.stdout if stdout is None else stdout, sys.stderr if stderr is None else stderr): + if stream is None or _stream_supports_unicode(stream): + continue + reconfigure = getattr(stream, "reconfigure", None) + if callable(reconfigure): + reconfigure(errors="replace") + + @beartype @ensure(lambda result: isinstance(result, dict), "Must return dict") def get_console_config() -> dict[str, Any]: @@ -121,6 +157,10 @@ def get_console_config() -> dict[str, Any]: if sys.platform == "win32": config["legacy_windows"] = True + if not caps.supports_unicode: + config["emoji"] = False + config["safe_box"] = True + # In test mode, don't explicitly set file=sys.stdout when using Typer's CliRunner # CliRunner needs to capture output itself, so we let it use the default file # Only set file=sys.stdout if we're not in a CliRunner test context diff --git a/tests/unit/modules/init/test_resource_resolution.py b/tests/unit/modules/init/test_resource_resolution.py new file mode 100644 index 00000000..0b9b0b1e --- /dev/null +++ b/tests/unit/modules/init/test_resource_resolution.py @@ -0,0 +1,49 @@ +"""Tests for init resource resolution from installed module packages.""" + +from __future__ import annotations + +from pathlib import Path + +from specfact_cli.modules.init.src import commands as init_commands +from specfact_cli.utils import ide_setup + + +def test_resolve_field_mapping_templates_dir_prefers_installed_backlog_bundle(monkeypatch, tmp_path: Path) -> None: + """Backlog field mapping templates should resolve from the installed backlog bundle.""" + installed_dir = tmp_path / "installed-backlog" / "resources" / "templates" / "backlog" / "field_mappings" + installed_dir.mkdir(parents=True) + (installed_dir / "ado_default.yaml").write_text("framework: default\n", encoding="utf-8") + + monkeypatch.setattr( + init_commands, + "_discover_module_resource_dirs", + lambda resource_subpath, repo_path=None, categories=None: ( + [installed_dir.parent.parent.parent] + if resource_subpath == "resources/templates/backlog/field_mappings" and categories == {"backlog"} + else [] + ), + ) + + resolved = init_commands._resolve_field_mapping_templates_dir(tmp_path) + + assert resolved == installed_dir + + +def test_discover_prompt_template_files_uses_installed_module_resources(monkeypatch, tmp_path: Path) -> None: + """Prompt discovery should source templates from installed module resources.""" + prompt_dir = tmp_path / "installed-codebase" / "resources" / "prompts" + prompt_dir.mkdir(parents=True) + prompt_file = prompt_dir / "specfact.04-sdd.md" + prompt_file.write_text("---\ndescription: SDD\n---\n# SDD\n", encoding="utf-8") + + monkeypatch.setattr( + ide_setup, + "_discover_module_resource_dirs", + lambda resource_subpath, repo_path=None, categories=None: ( + [prompt_dir.parent] if resource_subpath == "resources/prompts" else [] + ), + ) + + discovered = ide_setup.discover_prompt_template_files(tmp_path) + + assert discovered == [prompt_file] diff --git a/tests/unit/specfact_cli/registry/test_module_packages.py b/tests/unit/specfact_cli/registry/test_module_packages.py index 391ae827..77135e45 100644 --- a/tests/unit/specfact_cli/registry/test_module_packages.py +++ b/tests/unit/specfact_cli/registry/test_module_packages.py @@ -126,6 +126,26 @@ def test_make_package_loader_supports_namespaced_nested_command_app(tmp_path: Pa assert getattr(getattr(app, "info", None), "name", None) == "backlog" +def test_make_package_loader_wraps_runtime_import_errors_with_compatibility_guidance(tmp_path: Path) -> None: + """Module load failures should surface SpecFact compatibility guidance instead of raw import noise.""" + 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("import missing_compiled_dependency\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") as exc_info: + loader() + + message = str(exc_info.value) + assert "missing_compiled_dependency" in message + assert str(package_dir) in message + assert "same Python interpreter" in message + + 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_ide_setup.py b/tests/unit/utils/test_ide_setup.py index 29294c35..acc65769 100644 --- a/tests/unit/utils/test_ide_setup.py +++ b/tests/unit/utils/test_ide_setup.py @@ -1,9 +1,16 @@ """Unit tests for IDE setup utilities.""" +from __future__ import annotations + +from pathlib import Path + +import pytest + from specfact_cli.utils.ide_setup import ( SPECFACT_COMMANDS, copy_templates_to_ide, detect_ide, + discover_prompt_template_files, process_template, read_template, ) @@ -12,13 +19,13 @@ class TestDetectIDE: """Test IDE detection logic.""" - def test_detect_ide_explicit(self): + def test_detect_ide_explicit(self) -> None: """Test explicit IDE selection.""" assert detect_ide("cursor") == "cursor" assert detect_ide("vscode") == "vscode" assert detect_ide("copilot") == "copilot" - def test_detect_ide_cursor_from_env(self, monkeypatch): + def test_detect_ide_cursor_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test Cursor detection from environment variables.""" monkeypatch.setenv("CURSOR_AGENT", "1") assert detect_ide("auto") == "cursor" @@ -35,17 +42,15 @@ def test_detect_ide_cursor_from_env(self, monkeypatch): monkeypatch.setenv("CHROME_DESKTOP", "cursor.desktop") assert detect_ide("auto") == "cursor" - def test_detect_ide_cursor_priority_over_vscode(self, monkeypatch): + def test_detect_ide_cursor_priority_over_vscode(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test Cursor detection takes priority over VS Code.""" - # Set both Cursor and VS Code variables monkeypatch.setenv("CURSOR_AGENT", "1") monkeypatch.setenv("VSCODE_PID", "12345") assert detect_ide("auto") == "cursor" - def test_detect_ide_vscode_from_env(self, monkeypatch): + def test_detect_ide_vscode_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test VS Code detection from environment variables.""" - # Ensure Cursor variables are not set monkeypatch.delenv("CURSOR_AGENT", raising=False) monkeypatch.delenv("CURSOR_TRACE_ID", raising=False) monkeypatch.delenv("CURSOR_PID", raising=False) @@ -58,9 +63,8 @@ def test_detect_ide_vscode_from_env(self, monkeypatch): monkeypatch.setenv("VSCODE_INJECTION", "test") assert detect_ide("auto") == "vscode" - def test_detect_ide_claude_from_env(self, monkeypatch): + def test_detect_ide_claude_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test Claude Code detection from environment variables.""" - # Ensure other IDE variables are not set monkeypatch.delenv("CURSOR_AGENT", raising=False) monkeypatch.delenv("CURSOR_TRACE_ID", raising=False) monkeypatch.delenv("CURSOR_PID", raising=False) @@ -71,9 +75,8 @@ def test_detect_ide_claude_from_env(self, monkeypatch): monkeypatch.setenv("CLAUDE_PID", "12345") assert detect_ide("auto") == "claude" - def test_detect_ide_defaults_to_vscode(self, monkeypatch): + def test_detect_ide_defaults_to_vscode(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test detection defaults to VS Code when no IDE detected.""" - # Remove all IDE variables monkeypatch.delenv("CURSOR_AGENT", raising=False) monkeypatch.delenv("CURSOR_TRACE_ID", raising=False) monkeypatch.delenv("CURSOR_PID", raising=False) @@ -89,7 +92,7 @@ def test_detect_ide_defaults_to_vscode(self, monkeypatch): class TestReadTemplate: """Test template reading functionality.""" - def test_read_template_with_frontmatter(self, tmp_path): + def test_read_template_with_frontmatter(self, tmp_path: Path) -> None: """Test reading template with YAML frontmatter.""" template_file = tmp_path / "test.md" template_file.write_text("---\ndescription: Test description\n---\n\n# Template Content\nSome content here.") @@ -100,7 +103,7 @@ def test_read_template_with_frontmatter(self, tmp_path): assert "# Template Content" in result["content"] assert "Some content here" in result["content"] - def test_read_template_without_frontmatter(self, tmp_path): + def test_read_template_without_frontmatter(self, tmp_path: Path) -> None: """Test reading template without YAML frontmatter.""" template_file = tmp_path / "test.md" template_file.write_text("# Template Content\nSome content here.") @@ -115,7 +118,7 @@ def test_read_template_without_frontmatter(self, tmp_path): class TestProcessTemplate: """Test template processing functionality.""" - def test_process_template_markdown(self): + def test_process_template_markdown(self) -> None: """Test processing template for Markdown format.""" content = "# Title\n$ARGUMENTS\nSome content" result = process_template(content, "Test description", "md") @@ -124,17 +127,17 @@ def test_process_template_markdown(self): assert "$ARGUMENTS" in result assert "Some content" in result - def test_process_template_toml(self): + def test_process_template_toml(self) -> None: """Test processing template for TOML format.""" content = "# Title\n$ARGUMENTS\nSome content" result = process_template(content, "Test description", "toml") assert 'description = "Test description"' in result assert 'prompt = """' in result - assert "{{args}}" in result # $ARGUMENTS replaced with {{args}} + assert "{{args}}" in result assert "# Title" in result - def test_process_template_prompt_md(self): + def test_process_template_prompt_md(self) -> None: """Test processing template for prompt.md format.""" content = "# Title\n$ARGUMENTS\nSome content" result = process_template(content, "Test description", "prompt.md") @@ -147,99 +150,77 @@ def test_process_template_prompt_md(self): class TestCopyTemplatesToIDE: """Test template copying functionality.""" - def test_copy_templates_to_cursor(self, tmp_path): + def test_copy_templates_to_cursor(self, tmp_path: Path) -> None: """Test copying templates to Cursor directory.""" - # Create templates directory templates_dir = tmp_path / "resources" / "prompts" templates_dir.mkdir(parents=True) (templates_dir / "specfact.01-import.md").write_text("---\ndescription: Analyze\n---\n# Analyze\n$ARGUMENTS") - # Copy templates copied_files, settings_path = copy_templates_to_ide(tmp_path, "cursor", templates_dir, force=True) - # Verify files were copied assert len(copied_files) == 1 - assert settings_path is None # Cursor doesn't use settings file + assert settings_path is None cursor_dir = tmp_path / ".cursor" / "commands" assert cursor_dir.exists() assert (cursor_dir / "specfact.01-import.md").exists() - # Verify content content = (cursor_dir / "specfact.01-import.md").read_text() assert "# Analyze" in content assert "$ARGUMENTS" in content - def test_copy_templates_to_vscode(self, tmp_path): + def test_copy_templates_to_vscode(self, tmp_path: Path) -> None: """Test copying templates to VS Code directory with settings.""" - # Create templates directory templates_dir = tmp_path / "resources" / "prompts" templates_dir.mkdir(parents=True) (templates_dir / "specfact.01-import.md").write_text("---\ndescription: Analyze\n---\n# Analyze\n$ARGUMENTS") - # Copy templates copied_files, settings_path = copy_templates_to_ide(tmp_path, "vscode", templates_dir, force=True) - # Verify files were copied assert len(copied_files) == 1 assert settings_path is not None assert settings_path.exists() - # Verify template copied with .prompt.md extension prompts_dir = tmp_path / ".github" / "prompts" assert prompts_dir.exists() assert (prompts_dir / "specfact.01-import.prompt.md").exists() - - # Verify VS Code settings created assert (tmp_path / ".vscode" / "settings.json").exists() - def test_copy_templates_skips_existing_without_force(self, tmp_path): + def test_copy_templates_skips_existing_without_force(self, tmp_path: Path) -> None: """Test copying templates skips existing files without force.""" - # Create templates directory templates_dir = tmp_path / "resources" / "prompts" templates_dir.mkdir(parents=True) (templates_dir / "specfact.01-import.md").write_text("---\ndescription: Analyze\n---\n# Analyze\n$ARGUMENTS") - # Pre-create file cursor_dir = tmp_path / ".cursor" / "commands" cursor_dir.mkdir(parents=True) (cursor_dir / "specfact.01-import.md").write_text("existing") - # Try to copy without force copied_files, _settings_path = copy_templates_to_ide(tmp_path, "cursor", templates_dir, force=False) - # Should skip existing file assert len(copied_files) == 0 - - # Verify existing file was not overwritten assert (cursor_dir / "specfact.01-import.md").read_text() == "existing" - def test_copy_templates_overwrites_with_force(self, tmp_path): + def test_copy_templates_overwrites_with_force(self, tmp_path: Path) -> None: """Test copying templates overwrites existing files with force.""" - # Create templates directory templates_dir = tmp_path / "resources" / "prompts" templates_dir.mkdir(parents=True) (templates_dir / "specfact.01-import.md").write_text( "---\ndescription: Analyze\n---\n# New Content\n$ARGUMENTS" ) - # Pre-create file cursor_dir = tmp_path / ".cursor" / "commands" cursor_dir.mkdir(parents=True) (cursor_dir / "specfact.01-import.md").write_text("existing") - # Copy with force copied_files, _settings_path = copy_templates_to_ide(tmp_path, "cursor", templates_dir, force=True) - # Should have copied file assert len(copied_files) == 1 - - # Verify file was overwritten content = (cursor_dir / "specfact.01-import.md").read_text() assert "New Content" in content or "# New Content" in content - def test_copy_templates_only_installs_prompt_ids_from_core_command_list(self, tmp_path): - """Core export only copies prompts explicitly listed in the core command registry.""" + def test_copy_templates_copies_non_core_prompt_ids_when_discovered(self, tmp_path: Path) -> None: + """Discovered module prompt files are copied even when they are not in the legacy core list.""" templates_dir = tmp_path / "resources" / "prompts" templates_dir.mkdir(parents=True) (templates_dir / "specfact.backlog-add.md").write_text( @@ -248,8 +229,76 @@ def test_copy_templates_only_installs_prompt_ids_from_core_command_list(self, tm copied_files, _settings_path = copy_templates_to_ide(tmp_path, "cursor", templates_dir, force=True) - assert not any(path.name == "specfact.backlog-add.md" for path in copied_files) - assert not (tmp_path / ".cursor" / "commands" / "specfact.backlog-add.md").exists() + assert any(path.name == "specfact.backlog-add.md" for path in copied_files) + assert (tmp_path / ".cursor" / "commands" / "specfact.backlog-add.md").exists() + + +def test_discover_prompt_template_files_falls_back_to_repo_resources(tmp_path: Path) -> None: + """Prompt discovery falls back to repo-local resources when no installed module resources exist.""" + templates_dir = tmp_path / "resources" / "prompts" + templates_dir.mkdir(parents=True) + prompt_file = templates_dir / "specfact.01-import.md" + prompt_file.write_text("---\ndescription: Analyze\n---\n# Analyze\n", encoding="utf-8") + + discovered = discover_prompt_template_files(tmp_path) + + assert discovered == [prompt_file] + + +def test_discover_prompt_template_files_prefers_target_repo_workspace_modules( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Module discovery should follow the requested repo path, not the caller's current working directory.""" + repo_path = tmp_path / "target-repo" + repo_path.mkdir() + (repo_path / ".git").mkdir() + + package_dir = repo_path / ".specfact" / "modules" / "specfact-backlog" + prompt_dir = package_dir / "resources" / "prompts" + prompt_dir.mkdir(parents=True) + (package_dir / "module-package.yaml").write_text( + "name: nold-ai/specfact-backlog\nversion: '0.1.0'\ncommands: [backlog]\ncategory: backlog\nbundle_group_command: backlog\n", + encoding="utf-8", + ) + prompt_file = prompt_dir / "specfact.backlog-add.md" + prompt_file.write_text("---\ndescription: Backlog add\n---\n# Backlog Add\n", encoding="utf-8") + + unrelated_cwd = tmp_path / "other-cwd" + unrelated_cwd.mkdir() + monkeypatch.chdir(unrelated_cwd) + + discovered = discover_prompt_template_files(repo_path) + + assert discovered == [prompt_file] + + +def test_discover_prompt_template_files_deduplicates_prompt_ids_by_filename( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Duplicate prompt ids from multiple module roots should keep the first discovered prompt.""" + first_dir = tmp_path / "module-a" / "resources" / "prompts" + second_dir = tmp_path / "module-b" / "resources" / "prompts" + first_dir.mkdir(parents=True) + second_dir.mkdir(parents=True) + + first_prompt = first_dir / "specfact.backlog-add.md" + second_prompt = second_dir / "specfact.backlog-add.md" + first_prompt.write_text("---\ndescription: First\n---\n# First\n", encoding="utf-8") + second_prompt.write_text("---\ndescription: Second\n---\n# Second\n", encoding="utf-8") + + import specfact_cli.utils.ide_setup as ide_setup_module + + monkeypatch.setattr( + ide_setup_module, + "_discover_module_resource_dirs", + lambda resource_subpath, repo_path=None, categories=None: ( + [first_dir.parent, second_dir.parent] if resource_subpath == "resources/prompts" else [] + ), + ) + + discovered = discover_prompt_template_files(tmp_path) + + assert discovered == [first_prompt] def test_specfact_commands_excludes_backlog_prompt_ids() -> None: diff --git a/tests/unit/utils/test_terminal.py b/tests/unit/utils/test_terminal.py index 5925810c..f892c1fc 100644 --- a/tests/unit/utils/test_terminal.py +++ b/tests/unit/utils/test_terminal.py @@ -12,6 +12,7 @@ from specfact_cli.utils.terminal import ( TerminalCapabilities, detect_terminal_capabilities, + ensure_output_stream_safety, get_console_config, get_progress_config, print_progress, @@ -70,6 +71,20 @@ def test_detect_non_interactive(self) -> None: caps = detect_terminal_capabilities() assert caps.is_interactive is False + def test_detect_legacy_encoding_marks_unicode_unsafe(self) -> None: + """Legacy terminal encodings should be treated as unicode-unsafe.""" + + class _FakeStdout: + encoding = "cp1252" + + @staticmethod + def isatty() -> bool: + return True + + with patch.dict(os.environ, {}, clear=True), patch("sys.stdout", _FakeStdout()): + caps = detect_terminal_capabilities() + assert caps.supports_unicode is False + class TestGetConsoleConfig: """Test console configuration generation.""" @@ -112,6 +127,61 @@ def test_console_config_width(self) -> None: config = get_console_config() assert config["width"] == 80 + def test_console_config_disables_emoji_when_unicode_unsafe(self) -> None: + """Unicode-unsafe terminals should disable emoji and keep safe box rendering.""" + with patch("specfact_cli.utils.terminal.detect_terminal_capabilities") as mock_detect: + mock_detect.return_value = TerminalCapabilities( + supports_color=True, + supports_animations=False, + is_interactive=True, + is_ci=False, + supports_unicode=False, + stream_encoding="cp1252", + ) + config = get_console_config() + assert config["emoji"] is False + assert config["safe_box"] is True + + +class TestEnsureOutputStreamSafety: + """Test output stream safety normalization.""" + + def test_reconfigures_unicode_unsafe_streams_with_replace_errors(self) -> None: + """Unicode-unsafe text streams should be reconfigured to replace encoding errors.""" + + class _FakeStream: + encoding = "cp1252" + + def __init__(self) -> None: + self.calls: list[dict[str, str]] = [] + + def reconfigure(self, **kwargs: str) -> None: + self.calls.append(kwargs) + + fake_stdout = _FakeStream() + fake_stderr = _FakeStream() + ensure_output_stream_safety(fake_stdout, fake_stderr) + assert fake_stdout.calls == [{"errors": "replace"}] + assert fake_stderr.calls == [{"errors": "replace"}] + + def test_skips_reconfigure_for_unicode_safe_streams(self) -> None: + """Unicode-safe streams should not be reconfigured.""" + + class _FakeStream: + encoding = "utf-8" + + def __init__(self) -> None: + self.calls: list[dict[str, str]] = [] + + def reconfigure(self, **kwargs: str) -> None: + self.calls.append(kwargs) + + fake_stdout = _FakeStream() + fake_stderr = _FakeStream() + ensure_output_stream_safety(fake_stdout, fake_stderr) + assert fake_stdout.calls == [] + assert fake_stderr.calls == [] + class TestGetProgressConfig: """Test progress configuration generation.""" From f20781a2aa8d2c982820ec01a3f17ee88c55a3d7 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Tue, 24 Mar 2026 22:24:00 +0100 Subject: [PATCH 2/3] fix: bump patch version to 0.42.4 --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- setup.py | 2 +- src/__init__.py | 2 +- src/specfact_cli/__init__.py | 2 +- 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 977c9518..7d16d822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ All notable changes to this project will be documented in this file. **Important:** Changes need to be documented below this block as this is the header section. Each section should be separated by a horizontal rule. Newer changelog entries need to be added on top of prior ones to keep the history chronological with most recent changes first. +--- + +## [0.42.4] - 2026-03-24 + +### Fixed + +- Hardened terminal output handling for non-UTF-8 environments so Rich output degrades safely on Windows, Linux, and macOS terminals that cannot render Unicode symbols or box drawing characters. +- Updated `specfact init ide` to discover prompt templates and backlog field mapping resources from installed module locations first, with path-based fallback behavior that remains compatible across different install methods such as Hatch, pip, pipx, and uv. +- Improved bundled module runtime compatibility failures to surface actionable interpreter and reinstall guidance instead of opaque import/load errors. + --- ## [0.42.3] - 2026-03-23 diff --git a/pyproject.toml b/pyproject.toml index 70ce4e21..c3909a05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.42.3" +version = "0.42.4" 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" diff --git a/setup.py b/setup.py index 8ad956a4..2e7cb588 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.42.3", + version="0.42.4", 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 4620d402..274f9d1e 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.42.3" +__version__ = "0.42.4" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index 2945e7fa..e76d2fd4 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -42,6 +42,6 @@ def _bootstrap_bundle_paths() -> None: _bootstrap_bundle_paths() -__version__ = "0.42.3" +__version__ = "0.42.4" __all__ = ["__version__"] From d5c96093a9ad20b49aec1dc9dadc5a22ba875c12 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Tue, 24 Mar 2026 22:38:34 +0100 Subject: [PATCH 3/3] fix: restore init lifecycle compatibility --- src/specfact_cli/analyzers/code_analyzer.py | 61 +++++++++++-------- .../modules/init/module-package.yaml | 6 +- src/specfact_cli/modules/init/src/commands.py | 20 +----- src/specfact_cli/utils/ide_setup.py | 12 ++-- 4 files changed, 49 insertions(+), 50 deletions(-) diff --git a/src/specfact_cli/analyzers/code_analyzer.py b/src/specfact_cli/analyzers/code_analyzer.py index c9817113..f121b1f3 100644 --- a/src/specfact_cli/analyzers/code_analyzer.py +++ b/src/specfact_cli/analyzers/code_analyzer.py @@ -644,26 +644,8 @@ def _analyze_file_parallel(self, file_path: Path) -> dict[str, Any]: # Extract classes as features for node in ast.walk(tree): if isinstance(node, ast.ClassDef): - # For sequential keys, use placeholder (will be fixed after all features collected) - # For classname keys, we can generate immediately - current_count = 0 if self.key_format == "sequential" else len(self.features) - - # Extract Semgrep evidence for confidence scoring - class_start_line = node.lineno if hasattr(node, "lineno") else None - class_end_line = node.end_lineno if hasattr(node, "end_lineno") else None - semgrep_evidence = self._extract_semgrep_evidence( - semgrep_findings, node.name, class_start_line, class_end_line - ) - - # Create feature with Semgrep evidence included in confidence calculation - feature = self._extract_feature_from_class_parallel( - node, file_path, current_count, semgrep_evidence - ) - if feature: - # Enhance feature with detailed Semgrep findings (outcomes, constraints, themes) - self._enhance_feature_with_semgrep( - feature, semgrep_findings, file_path, node.name, class_start_line, class_end_line - ) + feature = self._extract_feature_for_results(node, file_path, semgrep_findings) + if feature is not None: results["features"].append(feature) except (SyntaxError, UnicodeDecodeError): @@ -672,6 +654,31 @@ def _analyze_file_parallel(self, file_path: Path) -> dict[str, Any]: return results + def _extract_feature_for_results( + self, + node: ast.ClassDef, + file_path: Path, + semgrep_findings: list[dict[str, Any]], + ) -> Feature | None: + """Extract one feature while isolating per-class enhancement failures.""" + current_count = 0 if self.key_format == "sequential" else len(self.features) + class_start_line = node.lineno if hasattr(node, "lineno") else None + class_end_line = node.end_lineno if hasattr(node, "end_lineno") else None + semgrep_evidence = self._extract_semgrep_evidence(semgrep_findings, node.name, class_start_line, class_end_line) + + feature = self._extract_feature_from_class_parallel(node, file_path, current_count, semgrep_evidence) + if feature is None: + return None + + try: + self._enhance_feature_with_semgrep( + feature, semgrep_findings, file_path, node.name, class_start_line, class_end_line + ) + except Exception as exc: + console.print(f"[dim]⚠ Warning: Skipped Semgrep enhancement for {file_path}:{node.name}: {exc}[/dim]") + + return feature + def _merge_analysis_results(self, results: dict[str, Any]) -> None: """Merge parallel analysis results into instance variables.""" # Merge themes @@ -1327,10 +1334,16 @@ def _extract_story_artifacts( if not methods: return None, None primary_method = methods[0] - scenarios = self.control_flow_analyzer.extract_scenarios_from_method( - primary_method, class_name, primary_method.name - ) - contracts = self.contract_extractor.extract_function_contracts(primary_method) + try: + scenarios = self.control_flow_analyzer.extract_scenarios_from_method( + primary_method, class_name, primary_method.name + ) + except Exception: + scenarios = None + try: + contracts = self.contract_extractor.extract_function_contracts(primary_method) + except Exception: + contracts = None return scenarios, contracts def _generate_story_title(self, group_name: str, class_name: str) -> str: diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index 220f587c..92539227 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.11 +version: 0.1.12 commands: - init category: core @@ -17,5 +17,5 @@ publisher: description: Initialize SpecFact workspace and bootstrap local configuration. license: Apache-2.0 integrity: - checksum: sha256:47c95739e78c8958f14c3c4988fc15ad2b535ac3b07c4425204f79a9822a9ac0 - signature: 39uhz13xHUa3RMeYpIU6AKr63q5hYzZOxlrl8IDDrShl8s+hcZ0k5bp/4S3MASiJ7UxxCosJkI2t2bIrFmWJCA== + checksum: sha256:e93721d256bf67944a6a783dea42c8bfa15d5130212f747193f6df8b2b19eaad + signature: UKbbJ+H+Zeo+na5du/EN+YG8cymPdnYpKW8cZ28xjcDl+9VD520LF7aDXeyqC9UlCooAcWdOr0bGxBmibqsxCA== diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py index c1051cbc..42364d80 100644 --- a/src/specfact_cli/modules/init/src/commands.py +++ b/src/specfact_cli/modules/init/src/commands.py @@ -23,7 +23,7 @@ from specfact_cli.registry.module_installer import USER_MODULES_ROOT as INIT_USER_MODULES_ROOT from specfact_cli.registry.module_packages import get_discovered_modules_for_state from specfact_cli.registry.module_state import write_modules_state -from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode, is_non_interactive +from specfact_cli.runtime import debug_print, is_non_interactive from specfact_cli.telemetry import telemetry from specfact_cli.utils.env_manager import EnvManager, EnvManagerInfo, build_tool_command, detect_env_manager from specfact_cli.utils.ide_setup import ( @@ -48,6 +48,7 @@ install_bundles_for_init = first_run_selection.install_bundles_for_init is_first_run = first_run_selection.is_first_run +copy_templates_to_ide = _copy_template_files_to_ide def _resolve_field_mapping_templates_dir(repo_path: Path) -> Path | None: @@ -271,29 +272,14 @@ def _select_module_ids_interactive(action: str, modules_list: list[dict[str, Any def _resolve_templates_dir(repo_path: Path) -> Path | None: """Resolve templates directory from repo checkout or installed package.""" - prompt_files = discover_prompt_template_files(repo_path) + prompt_files = discover_prompt_template_files(repo_path, include_package_fallback=False) if prompt_files: return prompt_files[0].parent dev_templates_dir = (repo_path / "resources" / "prompts").resolve() if dev_templates_dir.exists(): return dev_templates_dir - try: - import importlib.resources - resources_ref = importlib.resources.files("specfact_cli") - templates_ref = resources_ref / "resources" / "prompts" - package_templates_dir = Path(str(templates_ref)).resolve() - if package_templates_dir.exists(): - return package_templates_dir - except Exception as exc: - if is_debug_mode(): - debug_log_operation( - "template_resolution", - "importlib.resources(specfact_cli/resources/prompts)", - "error", - error=repr(exc), - ) return find_package_resources_path("specfact_cli", "resources/prompts") diff --git a/src/specfact_cli/utils/ide_setup.py b/src/specfact_cli/utils/ide_setup.py index 6d98b9f4..ed732762 100644 --- a/src/specfact_cli/utils/ide_setup.py +++ b/src/specfact_cli/utils/ide_setup.py @@ -265,8 +265,8 @@ def _discover_module_resource_dirs( lambda result: isinstance(result, list) and all(isinstance(path, Path) for path in result), "Must return list of Paths", ) -def discover_prompt_template_files(repo_path: Path) -> list[Path]: - """Return prompt templates from installed modules, falling back to core checkout resources.""" +def discover_prompt_template_files(repo_path: Path, include_package_fallback: bool = True) -> list[Path]: + """Return prompt templates from installed modules, then repo resources, then optional package fallback.""" prompt_files: list[Path] = [] seen_names: set[str] = set() @@ -280,10 +280,10 @@ def discover_prompt_template_files(repo_path: Path) -> list[Path]: if prompt_files: return prompt_files - fallback_dirs = [ - (repo_path / "resources" / "prompts").resolve(), - find_package_resources_path("specfact_cli", "resources/prompts"), - ] + fallback_dirs: list[Path | None] = [(repo_path / "resources" / "prompts").resolve()] + if include_package_fallback: + fallback_dirs.append(find_package_resources_path("specfact_cli", "resources/prompts")) + for fallback_dir in fallback_dirs: if fallback_dir is None: continue