diff --git a/CHANGELOG.md b/CHANGELOG.md index e7b1866e..63162f75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ All notable changes to this project will be documented in this file. --- +## [0.42.6] - 2026-03-26 + +### Fixed + +- `specfact init ide` multi-source export writes prompts to a **flat** layout under the IDE export root (for example `.github/prompts/` or `.cursor/commands/`) so editors and agents can discover `specfact*.prompt.md` (or equivalent) without per-source subfolders. +- Prompt catalog: **core** omits template basenames already provided by an installed module, avoiding duplicate exports when both ship the same filename. +- Re-export removes legacy per-source segment directories and prunes stale flat `specfact*` exports when the selected sources change. +- Tests: import `pytest` for `MonkeyPatch` annotations in init IDE prompt selection tests (Ruff F821). + +--- + ## [0.42.5] - 2026-03-25 ### Added diff --git a/openspec/changes/init-ide-prompt-source-selection/TDD_EVIDENCE.md b/openspec/changes/init-ide-prompt-source-selection/TDD_EVIDENCE.md index af3baded..5ea436b2 100644 --- a/openspec/changes/init-ide-prompt-source-selection/TDD_EVIDENCE.md +++ b/openspec/changes/init-ide-prompt-source-selection/TDD_EVIDENCE.md @@ -10,3 +10,9 @@ - Command: `hatch run pytest tests/unit/modules/init/test_init_ide_prompt_selection.py tests/unit/utils/test_ide_setup.py tests/e2e/test_init_command.py -q` - Status: green after `ide_setup` catalog + namespaced copy, `init ide` wiring, startup_checks rglob, and e2e path updates. + +## Follow-up: flat export + core/module dedupe (2026-03-26) + +- **Change:** Multi-source export uses a flat IDE folder; core omits template basenames covered by any module; legacy per-source subfolders are removed on export. +- **Tests:** `hatch test tests/unit/utils/test_ide_setup.py tests/unit/modules/init/test_init_ide_prompt_selection.py -v` — all passed. +- **Contract:** `hatch run contract-test` — PASS. diff --git a/openspec/changes/init-ide-prompt-source-selection/specs/init-ide-prompt-source-selection/spec.md b/openspec/changes/init-ide-prompt-source-selection/specs/init-ide-prompt-source-selection/spec.md index e1b00dc3..535a42f4 100644 --- a/openspec/changes/init-ide-prompt-source-selection/specs/init-ide-prompt-source-selection/spec.md +++ b/openspec/changes/init-ide-prompt-source-selection/specs/init-ide-prompt-source-selection/spec.md @@ -51,11 +51,16 @@ Non-interactive `specfact init ide` SHALL accept a comma-separated prompt source - **WHEN** a user passes a prompt source token that is not `all`, not `core`, and not an installed module id with prompt resources - **THEN** the command fails with actionable guidance describing the invalid token and the available prompt sources. -### Requirement: Exported Prompt Files Must Preserve Source Provenance +### Requirement: Exported Prompt Files Must Be IDE-Discoverable With Deterministic Single Sourcing -Exported prompt files SHALL preserve module/core provenance so collisions are deterministic and later command-surface migrations do not silently overwrite unrelated prompts. +Exported prompts for VS Code / Copilot (under ``.github/prompts/``) and other multi-source IDE targets SHALL use a **flat** layout (no per-source subfolders) so editors and agents can discover ``specfact*.prompt.md`` (or equivalent) at the export root. -#### Scenario: Multiple sources expose similarly named prompts -- **WHEN** `core` and one or more installed modules expose prompt files with overlapping basenames or command affinity -- **THEN** the exported IDE-facing output preserves which source owns each prompt -- **AND** the collision outcome is deterministic and visible to the user. +#### Scenario: Core defers to modules on overlapping template basenames +- **WHEN** `core` and one or more installed modules expose the same source filename (e.g. ``specfact.01-import.md``) +- **THEN** the prompt catalog SHALL list that basename only under the owning module source +- **AND** `core` SHALL NOT duplicate that basename so exports are single-sourced. + +#### Scenario: Multiple module sources expose the same basename +- **WHEN** two installed modules expose the same template basename +- **THEN** the merged export uses a deterministic last-wins rule by sorted source id (later id overwrites earlier) +- **AND** the flat export contains exactly one file per output basename. diff --git a/pyproject.toml b/pyproject.toml index 3f7a2a96..d9f7f66f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.42.5" +version = "0.42.6" 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 278e4fa8..b9cb503c 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.42.5", + version="0.42.6", 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 9f78d9d9..123f4b38 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.5" +__version__ = "0.42.6" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index 065da8ad..9d7aa16c 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.5" +__version__ = "0.42.6" __all__ = ["__version__"] diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index 3a9f2719..558eab8f 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.17 +version: 0.1.18 commands: - init category: core @@ -17,5 +17,5 @@ publisher: description: Initialize SpecFact workspace and bootstrap local configuration. license: Apache-2.0 integrity: - checksum: sha256:589ec7f24e7d0129cf61ad972e9f505c744ae14bd84db32cc241bc5013834163 - signature: 9hiFgBYZZwCTMBRDBgly07v5JejhDHWXyX3/tTkTYOjjtkFT7xjZs2Pyea7l1mt2dhUZ/Tet4bdDRXyeSNcYCg== + checksum: sha256:218801ddd11b02e90e386a3019685add0d14a9a09d246ef958c05f53c9b46a72 + signature: o9QdwF5+ASt8dJy5D38PgMy7pysqZUJqOaDHHjcRPXoI1dGpeSyOKjJOOtboqoH9qN2a+4nhZX6S5NGp8hlPCA== diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py index 0fa41ea8..814f2ce4 100644 --- a/src/specfact_cli/modules/init/src/commands.py +++ b/src/specfact_cli/modules/init/src/commands.py @@ -79,7 +79,7 @@ def copy_templates_to_ide( *, prompts_by_source: dict[str, list[Path]] | None = None, ) -> tuple[list[Path], Path | None]: - """Discover prompt templates and copy them; use ``prompts_by_source`` for namespaced multi-source export.""" + """Discover prompt templates and copy them; use ``prompts_by_source`` for multi-source flat export.""" if prompts_by_source is not None: return copy_prompts_by_source_to_ide(repo_path, ide, prompts_by_source, force) return _copy_template_files_to_ide(repo_path, ide, discover_prompt_template_files(repo_path), force) diff --git a/src/specfact_cli/utils/ide_setup.py b/src/specfact_cli/utils/ide_setup.py index d4d7a6aa..60497394 100644 --- a/src/specfact_cli/utils/ide_setup.py +++ b/src/specfact_cli/utils/ide_setup.py @@ -195,6 +195,18 @@ def _core_prompt_template_paths(repo_path: Path, include_package_fallback: bool) return [] +def _core_prompts_excluding_module_basenames( + core_files: list[Path], + module_catalog: dict[str, list[Path]], +) -> list[Path]: + """Drop core templates whose basename is already provided by an installed module (single source of truth).""" + module_basenames: set[str] = set() + for paths in module_catalog.values(): + for p in paths: + module_basenames.add(p.name) + return [p for p in core_files if p.name not in module_basenames] + + def _module_prompt_sources_catalog(repo_path: Path) -> dict[str, list[Path]]: from specfact_cli.registry.module_packages import CORE_MODULE_ORDER, discover_package_metadata @@ -249,12 +261,17 @@ def discover_prompt_sources_catalog( Core templates come from the repo checkout or the installed ``specfact_cli`` package. Module templates are discovered from effective module roots (builtin, project, user, marketplace, custom). + + When a module ships a template with the same source filename as core (e.g. ``specfact.01-import.md``), + the module copy wins: core does not list that basename so exports stay single-sourced. """ - catalog: dict[str, list[Path]] = {} + module_catalog = _module_prompt_sources_catalog(repo_path) core_files = _core_prompt_template_paths(repo_path, include_package_fallback) - if core_files: - catalog[PROMPT_SOURCE_CORE] = list(core_files) - catalog.update(_module_prompt_sources_catalog(repo_path)) + core_filtered = _core_prompts_excluding_module_basenames(core_files, module_catalog) + catalog: dict[str, list[Path]] = {} + if core_filtered: + catalog[PROMPT_SOURCE_CORE] = list(core_filtered) + catalog.update(module_catalog) return catalog @@ -378,71 +395,75 @@ def _output_filename_for_template(template_path: Path, format_type: str) -> str: return template_path.name -def _safe_resolved_segment_dir(repo_path: Path, ide: str, segment: str) -> Path | None: - """Return ``repo_path / ide_folder / segment`` resolved, or ``None`` if it escapes the IDE export root.""" - config = IDE_CONFIG[ide] - base = (repo_path / str(config["folder"])).resolve() - segment_dir = (base / segment).resolve() - try: - segment_dir.relative_to(base) - except ValueError: - return None - return segment_dir +def _merge_prompt_export_outputs_by_basename( + prompts_by_source: dict[str, list[Path]], + format_type: str, +) -> dict[str, Path]: + """Map IDE output filename to source template; later sources win over core on basename collisions.""" + ordered = sorted( + prompts_by_source.items(), + key=lambda item: (item[0] != PROMPT_SOURCE_CORE, item[0]), + ) + out: dict[str, Path] = {} + for _source_id, paths in ordered: + for p in paths: + out[_output_filename_for_template(p, format_type)] = p + return out -def _prune_segment_exports_not_in_expected( - repo_path: Path, - ide: str, - segment: str, - template_paths: list[Path], -) -> None: - """Remove files under ``ide_folder/segment`` that are not part of this export (same filenames as copy).""" - if not template_paths: - return +def _cleanup_legacy_multisource_segment_dirs(repo_path: Path, ide: str) -> None: + """Remove per-source subfolders from older multi-source exports (layout is now flat under the IDE root).""" config = IDE_CONFIG[ide] - format_type = str(config["format"]) - segment_dir = _safe_resolved_segment_dir(repo_path, ide, segment) - if segment_dir is None or not segment_dir.is_dir(): + base = (repo_path / str(config["folder"])).resolve() + if not base.is_dir(): return - expected_resolved: set[Path] = { - (segment_dir / _output_filename_for_template(tp, format_type)).resolve() for tp in template_paths - } - for p in list(segment_dir.iterdir()): - if not p.is_file(): + for child in list(base.iterdir()): + if not child.is_dir(): continue - if p.resolve() not in expected_resolved: - try: - p.unlink() - console.print(f"[dim]Removed stale prompt export:[/dim] {p}") - except OSError as exc: - console.print(f"[yellow]Could not remove stale export {p}:[/yellow] {exc}") + if child.name != PROMPT_SOURCE_CORE and "__" not in child.name: + continue + try: + child.resolve().relative_to(base) + except ValueError: + continue + try: + shutil.rmtree(child) + console.print(f"[dim]Removed legacy prompt export segment:[/dim] {child}") + except OSError as exc: + console.print(f"[yellow]Could not remove legacy segment {child}:[/yellow] {exc}") + + +def _flat_export_glob_pattern_for_prune(format_type: str) -> str: + """Glob for SpecFact-managed flat exports; must stay aligned with ``_output_filename_for_template``.""" + if format_type == "prompt.md": + return "specfact*.prompt.md" + if format_type == "toml": + return "specfact*.toml" + return "specfact*.md" -def _remove_unselected_prompt_export_segments( +def _prune_flat_specfact_exports_not_in_expected( repo_path: Path, ide: str, - prompts_by_source: dict[str, list[Path]], + expected_output_names: set[str], ) -> None: - """Remove on-disk segment directories under the IDE export root that are not in this selective export.""" + """Remove prior flat ``specfact*`` exports that are not part of this merged export.""" config = IDE_CONFIG[ide] + format_type = str(config["format"]) base = (repo_path / str(config["folder"])).resolve() - selected_segments = {source_id_to_path_segment(sid) for sid in prompts_by_source} if not base.is_dir(): return - for child in list(base.iterdir()): - if not child.is_dir(): - continue - try: - child.resolve().relative_to(base) - except ValueError: + pattern = _flat_export_glob_pattern_for_prune(format_type) + for p in base.glob(pattern): + if not p.is_file(): continue - if child.name in selected_segments: + if p.name in expected_output_names: continue try: - shutil.rmtree(child) - console.print(f"[dim]Removed unselected export segment:[/dim] {child}") + p.unlink() + console.print(f"[dim]Removed stale prompt export:[/dim] {p}") except OSError as exc: - console.print(f"[yellow]Could not remove segment {child}:[/yellow] {exc}") + console.print(f"[yellow]Could not remove stale export {p}:[/yellow] {exc}") def _copy_template_files_to_ide( @@ -507,7 +528,7 @@ def expected_ide_prompt_export_paths( *, prompt_source_ids: frozenset[str] | None = None, ) -> list[Path]: - """Return expected on-disk paths for exported IDE prompts (source-namespaced layout). + """Return expected on-disk paths for exported IDE prompts (flat layout under the IDE export folder). If ``prompt_source_ids`` is set (from ``.specfact/ide-prompt-export.yaml``), only those sources are expected—matching a selective ``init ide --prompts`` run. Otherwise the full discovered catalog is used. @@ -518,12 +539,8 @@ def expected_ide_prompt_export_paths( catalog = discover_prompt_sources_catalog(repo_path) if prompt_source_ids is not None: catalog = {k: v for k, v in catalog.items() if k in prompt_source_ids} - paths: list[Path] = [] - for sid, templates in sorted(catalog.items(), key=lambda item: (item[0] != PROMPT_SOURCE_CORE, item[0])): - segment = source_id_to_path_segment(sid) - for template_path in templates: - paths.append(base / segment / _output_filename_for_template(template_path, format_type)) - return paths + merged = _merge_prompt_export_outputs_by_basename(catalog, format_type) + return [base / name for name in sorted(merged.keys())] @beartype @@ -551,13 +568,12 @@ def count_outdated_ide_prompt_exports( catalog = discover_prompt_sources_catalog(repo_path) if prompt_source_ids is not None: catalog = {k: v for k, v in catalog.items() if k in prompt_source_ids} + merged = _merge_prompt_export_outputs_by_basename(catalog, format_type) outdated = 0 - for sid, paths in catalog.items(): - segment = source_id_to_path_segment(sid) - for src in paths: - dest = base / segment / _output_filename_for_template(src, format_type) - if src.exists() and dest.exists() and dest.stat().st_mtime < src.stat().st_mtime: - outdated += 1 + for dest_name, src in merged.items(): + dest = base / dest_name + if src.exists() and dest.exists() and dest.stat().st_mtime < src.stat().st_mtime: + outdated += 1 return outdated @@ -581,25 +597,18 @@ def copy_prompts_by_source_to_ide( prompts_by_source: dict[str, list[Path]], force: bool = False, ) -> tuple[list[Path], Path | None]: - """Copy prompts grouped by source id into source-namespaced subfolders under the IDE export directory.""" - all_copied: list[Path] = [] - _remove_unselected_prompt_export_segments(repo_path, ide, prompts_by_source) - ordered = sorted( - prompts_by_source.items(), - key=lambda item: (item[0] != PROMPT_SOURCE_CORE, item[0]), + """Copy prompts from multiple sources into a single flat folder under the IDE export directory.""" + config = IDE_CONFIG[ide] + format_type = str(config["format"]) + _cleanup_legacy_multisource_segment_dirs(repo_path, ide) + merged = _merge_prompt_export_outputs_by_basename(prompts_by_source, format_type) + _prune_flat_specfact_exports_not_in_expected(repo_path, ide, set(merged.keys())) + template_list = list(merged.values()) + all_copied, _settings = _copy_template_files_to_ide( + repo_path, ide, template_list, force, source_segment=None, write_settings=False ) - for source_id, paths in ordered: - if not paths: - continue - segment = source_id_to_path_segment(source_id) - _prune_segment_exports_not_in_expected(repo_path, ide, segment, paths) - copied, _settings = _copy_template_files_to_ide( - repo_path, ide, paths, force, source_segment=segment, write_settings=False - ) - all_copied.extend(copied) settings_path: Path | None = None - config = IDE_CONFIG[ide] settings_file = config.get("settings_file") if settings_file and isinstance(settings_file, str): settings_path = create_vscode_settings(repo_path, settings_file, prompts_by_source=prompts_by_source) @@ -764,27 +773,16 @@ def copy_templates_to_ide( def _vscode_prompt_recommendation_paths_from_sources(prompts_by_source: dict[str, list[Path]]) -> list[str]: - """Build `.github/prompts/...` recommendation strings matching namespaced IDE export layout.""" - prompt_files: list[str] = [] - for source_id, paths in sorted( - prompts_by_source.items(), - key=lambda item: (item[0] != PROMPT_SOURCE_CORE, item[0]), - ): - segment = source_id_to_path_segment(source_id) - for template_path in paths: - prompt_files.append(f".github/prompts/{segment}/{template_path.stem}.prompt.md") - return prompt_files + """Build `.github/prompts/...` recommendation strings matching flat IDE export layout.""" + merged = _merge_prompt_export_outputs_by_basename(prompts_by_source, "prompt.md") + return [f".github/prompts/{name}" for name in sorted(merged.keys())] def _vscode_prompt_paths_from_full_catalog(repo_path: Path) -> list[str]: - """Recommendation paths for the full discovered prompt catalog (namespaced segments).""" + """Recommendation paths for the full discovered prompt catalog (flat under ``.github/prompts/``).""" catalog = discover_prompt_sources_catalog(repo_path) - out: list[str] = [] - for source_id, paths in sorted(catalog.items(), key=lambda item: (item[0] != PROMPT_SOURCE_CORE, item[0])): - segment = source_id_to_path_segment(source_id) - for template_path in paths: - out.append(f".github/prompts/{segment}/{template_path.stem}.prompt.md") - return out + merged = _merge_prompt_export_outputs_by_basename(catalog, "prompt.md") + return [f".github/prompts/{name}" for name in sorted(merged.keys())] def _finalize_vscode_prompt_recommendation_paths(repo_path: Path, prompt_files: list[str]) -> list[str]: @@ -883,7 +881,7 @@ def create_vscode_settings( prompts_by_source: When set (e.g. from ``copy_prompts_by_source_to_ide``), recommendations list only templates from that export; prior **SpecFact-managed** ``.github/prompts/`` entries (paths whose filename looks like ``specfact*.prompt.md``) are removed so selective ``--prompts`` runs do not - leave stale exports; other ``.github/prompts/`` entries and paths outside that folder are preserved. + leave stale recommendations; other ``.github/prompts/`` entries and paths outside that folder are preserved. When ``None``, recommendations follow the full discovered catalog (or legacy flat fallbacks). diff --git a/tests/e2e/test_init_command.py b/tests/e2e/test_init_command.py index 11d47d5a..0c229a2f 100644 --- a/tests/e2e/test_init_command.py +++ b/tests/e2e/test_init_command.py @@ -2,6 +2,7 @@ import os +import pytest from typer.testing import CliRunner from specfact_cli.cli import app @@ -10,6 +11,14 @@ runner = CliRunner() +@pytest.fixture(autouse=True) +def _isolate_user_prompt_modules_for_init_e2e(monkeypatch: pytest.MonkeyPatch) -> None: + """Do not pick up ~/.specfact/modules prompt bundles; tests use repo ``resources/prompts`` only.""" + import specfact_cli.utils.ide_setup as ide_setup_module + + monkeypatch.setattr(ide_setup_module, "_module_prompt_sources_catalog", lambda _rp: {}) + + class TestInitCommandE2E: """End-to-end tests for specfact init command.""" @@ -40,8 +49,8 @@ def test_init_auto_detect_cursor(self, tmp_path, monkeypatch): assert "Cursor" in result.stdout assert ".cursor/commands/" in result.stdout - # Verify templates were copied (namespaced by source: core/) - cursor_dir = tmp_path / ".cursor" / "commands" / "core" + # Verify templates were copied (flat layout under the IDE export root) + cursor_dir = tmp_path / ".cursor" / "commands" assert cursor_dir.exists() assert (cursor_dir / "specfact.01-import.md").exists() assert (cursor_dir / "specfact.02-plan.md").exists() @@ -64,8 +73,8 @@ def test_init_explicit_cursor(self, tmp_path): assert "Cursor" in result.stdout assert ".cursor/commands/" in result.stdout - # Verify template was copied (namespaced: core/) - cursor_dir = tmp_path / ".cursor" / "commands" / "core" + # Verify template was copied (flat layout) + cursor_dir = tmp_path / ".cursor" / "commands" assert cursor_dir.exists() assert (cursor_dir / "specfact.01-import.md").exists() @@ -87,8 +96,8 @@ def test_init_explicit_vscode(self, tmp_path): assert "VS Code" in result.stdout assert ".github/prompts/" in result.stdout - # Verify template was copied (namespaced: core/) - prompts_dir = tmp_path / ".github" / "prompts" / "core" + # Verify template was copied (flat layout) + prompts_dir = tmp_path / ".github" / "prompts" assert prompts_dir.exists() assert (prompts_dir / "specfact.01-import.prompt.md").exists() @@ -114,8 +123,8 @@ def test_init_explicit_copilot(self, tmp_path): assert "GitHub Copilot" in result.stdout assert ".github/prompts/" in result.stdout - # Verify template was copied (namespaced: core/) - prompts_dir = tmp_path / ".github" / "prompts" / "core" + # Verify template was copied (flat layout) + prompts_dir = tmp_path / ".github" / "prompts" assert prompts_dir.exists() assert (prompts_dir / "specfact.01-import.prompt.md").exists() @@ -127,8 +136,8 @@ def test_init_skips_existing_files_without_force(self, tmp_path): (templates_dir / "specfact.01-import.md").write_text("---\ndescription: Analyze\n---\nContent") (templates_dir / "specfact.02-plan.md").write_text("---\ndescription: Plan Init\n---\nContent") - # Pre-create one exported file (namespaced: core/) but not all - cursor_dir = tmp_path / ".cursor" / "commands" / "core" + # Pre-create one exported file (flat path) but not all + cursor_dir = tmp_path / ".cursor" / "commands" cursor_dir.mkdir(parents=True) (cursor_dir / "specfact.01-import.md").write_text("existing content") @@ -156,8 +165,8 @@ def test_init_overwrites_with_force(self, tmp_path): templates_dir.mkdir(parents=True) (templates_dir / "specfact.01-import.md").write_text("---\ndescription: Analyze\n---\nNew content") - # Pre-create one file under the namespaced core/ export path - cursor_dir = tmp_path / ".cursor" / "commands" / "core" + # Pre-create one file under the flat export path + cursor_dir = tmp_path / ".cursor" / "commands" cursor_dir.mkdir(parents=True) (cursor_dir / "specfact.01-import.md").write_text("existing content") @@ -246,8 +255,8 @@ def test_init_auto_detect_vscode(self, tmp_path, monkeypatch): assert "VS Code" in result.stdout or "vscode" in result.stdout.lower() assert ".github/prompts/" in result.stdout - # Verify templates were copied (namespaced: core/) - prompts_dir = tmp_path / ".github" / "prompts" / "core" + # Verify templates were copied (flat layout) + prompts_dir = tmp_path / ".github" / "prompts" assert prompts_dir.exists() assert (prompts_dir / "specfact.01-import.prompt.md").exists() @@ -279,8 +288,8 @@ def test_init_auto_detect_claude(self, tmp_path, monkeypatch): assert result.exit_code == 0 assert "Claude Code" in result.stdout or "claude" in result.stdout.lower() - # Verify templates were copied (namespaced: core/) - claude_dir = tmp_path / ".claude" / "commands" / "core" + # Verify templates were copied (flat layout) + claude_dir = tmp_path / ".claude" / "commands" assert claude_dir.exists() assert (claude_dir / "specfact.01-import.md").exists() diff --git a/tests/unit/modules/init/test_init_ide_prompt_selection.py b/tests/unit/modules/init/test_init_ide_prompt_selection.py index 4c4ff4fe..fe81b7c7 100644 --- a/tests/unit/modules/init/test_init_ide_prompt_selection.py +++ b/tests/unit/modules/init/test_init_ide_prompt_selection.py @@ -4,6 +4,7 @@ from pathlib import Path +import pytest from typer.testing import CliRunner from specfact_cli.cli import app @@ -21,7 +22,13 @@ def test_source_id_to_path_segment_sanitizes_slashes() -> None: assert source_id_to_path_segment("core") == "core" -def test_discover_prompt_sources_catalog_includes_core_from_repo(tmp_path: Path) -> None: +def test_discover_prompt_sources_catalog_includes_core_from_repo( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import specfact_cli.utils.ide_setup as ide_setup_module + + monkeypatch.setattr(ide_setup_module, "_module_prompt_sources_catalog", lambda _rp: {}) + prompts = tmp_path / "resources" / "prompts" prompts.mkdir(parents=True) p1 = prompts / "specfact.01-import.md" @@ -33,7 +40,31 @@ def test_discover_prompt_sources_catalog_includes_core_from_repo(tmp_path: Path) assert p1 in catalog[PROMPT_SOURCE_CORE] -def test_copy_prompts_by_source_to_ide_namespaces_by_source(tmp_path: Path) -> None: +def test_discover_prompt_sources_catalog_omits_core_when_module_has_same_basename(tmp_path: Path) -> None: + core = tmp_path / "resources" / "prompts" + core.mkdir(parents=True) + p_core = core / "specfact.01-import.md" + p_core.write_text("---\n---\n# core\n", encoding="utf-8") + + package_dir = tmp_path / ".specfact" / "modules" / "specfact-codebase" + prompt_dir = package_dir / "resources" / "prompts" + prompt_dir.mkdir(parents=True) + (package_dir / "module-package.yaml").write_text( + "name: nold-ai/specfact-codebase\nversion: '0.1.0'\ncommands: [codebase]\ncategory: codebase\n" + "bundle_group_command: code\n", + encoding="utf-8", + ) + p_mod = prompt_dir / "specfact.01-import.md" + p_mod.write_text("---\n---\n# mod\n", encoding="utf-8") + + catalog = discover_prompt_sources_catalog(tmp_path, include_package_fallback=False) + + assert PROMPT_SOURCE_CORE not in catalog + assert "nold-ai/specfact-codebase" in catalog + assert p_mod in catalog["nold-ai/specfact-codebase"] + + +def test_copy_prompts_by_source_to_ide_exports_flat_under_ide_root(tmp_path: Path) -> None: prompts = tmp_path / "resources" / "prompts" prompts.mkdir(parents=True) f1 = prompts / "specfact.01-import.md" @@ -47,11 +78,13 @@ def test_copy_prompts_by_source_to_ide_namespaces_by_source(tmp_path: Path) -> N by_source = {PROMPT_SOURCE_CORE: [f1], "nold-ai/specfact-backlog": [f2]} copied, _settings = copy_prompts_by_source_to_ide(tmp_path, "cursor", by_source, force=True) - assert (tmp_path / ".cursor" / "commands" / "core" / "specfact.01-import.md") in copied - assert (tmp_path / ".cursor" / "commands" / "nold-ai__specfact-backlog" / "specfact.backlog-add.md") in copied + cmd = tmp_path / ".cursor" / "commands" + assert (cmd / "specfact.01-import.md") in copied + assert (cmd / "specfact.backlog-add.md") in copied + assert not (cmd / "core").exists() -def test_copy_prompts_by_source_to_ide_prunes_stale_in_selected_segment(tmp_path: Path) -> None: +def test_copy_prompts_by_source_to_ide_prunes_stale_in_flat_export(tmp_path: Path) -> None: """Re-exporting a subset of core templates removes outputs that are no longer expected.""" prompts = tmp_path / "resources" / "prompts" prompts.mkdir(parents=True) @@ -60,18 +93,18 @@ def test_copy_prompts_by_source_to_ide_prunes_stale_in_selected_segment(tmp_path f2 = prompts / "specfact.02-plan.md" f2.write_text("---\ndescription: B\n---\n# B\n", encoding="utf-8") - core_dir = tmp_path / ".cursor" / "commands" / "core" + cmd_dir = tmp_path / ".cursor" / "commands" copy_prompts_by_source_to_ide(tmp_path, "cursor", {PROMPT_SOURCE_CORE: [f1, f2]}, force=True) - assert (core_dir / "specfact.01-import.md").is_file() - assert (core_dir / "specfact.02-plan.md").is_file() + assert (cmd_dir / "specfact.01-import.md").is_file() + assert (cmd_dir / "specfact.02-plan.md").is_file() copy_prompts_by_source_to_ide(tmp_path, "cursor", {PROMPT_SOURCE_CORE: [f1]}, force=True) - assert (core_dir / "specfact.01-import.md").is_file() - assert not (core_dir / "specfact.02-plan.md").exists() + assert (cmd_dir / "specfact.01-import.md").is_file() + assert not (cmd_dir / "specfact.02-plan.md").exists() -def test_copy_prompts_by_source_to_ide_removes_unselected_catalog_segment(tmp_path: Path) -> None: - """Selective export removes IDE segment dirs for catalog sources not in this run.""" +def test_copy_prompts_by_source_to_ide_removes_unselected_module_exports_from_flat(tmp_path: Path) -> None: + """Selective export removes flat outputs from catalog sources not in this run.""" prompts = tmp_path / "resources" / "prompts" prompts.mkdir(parents=True) f1 = prompts / "specfact.01-import.md" @@ -88,18 +121,18 @@ def test_copy_prompts_by_source_to_ide_removes_unselected_catalog_segment(tmp_pa f2 = prompt_dir / "specfact.backlog-add.md" f2.write_text("---\ndescription: B\n---\n# B\n", encoding="utf-8") - mod_seg = tmp_path / ".cursor" / "commands" / "nold-ai__specfact-backlog" + cmd_dir = tmp_path / ".cursor" / "commands" copy_prompts_by_source_to_ide( tmp_path, "cursor", {PROMPT_SOURCE_CORE: [f1], "nold-ai/specfact-backlog": [f2]}, force=True, ) - assert (mod_seg / "specfact.backlog-add.md").is_file() + assert (cmd_dir / "specfact.backlog-add.md").is_file() copy_prompts_by_source_to_ide(tmp_path, "cursor", {PROMPT_SOURCE_CORE: [f1]}, force=True) - assert not mod_seg.exists() - assert (tmp_path / ".cursor" / "commands" / "core" / "specfact.01-import.md").is_file() + assert (cmd_dir / "specfact.01-import.md").is_file() + assert not (cmd_dir / "specfact.backlog-add.md").exists() def test_parse_prompts_option_all_expands_to_full_catalog() -> None: diff --git a/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py b/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py index b27a1780..1d4e4992 100644 --- a/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py +++ b/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py @@ -128,6 +128,11 @@ def test_resolve_templates_dir_uses_package_fallback_when_repo_templates_missing fallback_templates.mkdir(parents=True) monkeypatch.setattr(init_commands, "find_package_resources_path", lambda *_args: fallback_templates) monkeypatch.setattr("importlib.resources.files", lambda *_args: (_ for _ in ()).throw(RuntimeError("boom"))) + monkeypatch.setattr( + init_commands, + "discover_prompt_template_files", + lambda repo_path, include_package_fallback=False: [], + ) resolved = init_commands._resolve_templates_dir(tmp_path) diff --git a/tests/unit/utils/test_ide_setup.py b/tests/unit/utils/test_ide_setup.py index c6a7402e..e1fd9146 100644 --- a/tests/unit/utils/test_ide_setup.py +++ b/tests/unit/utils/test_ide_setup.py @@ -10,6 +10,7 @@ from specfact_cli.utils.ide_setup import ( PROMPT_SOURCE_CORE, SPECFACT_COMMANDS, + _flat_export_glob_pattern_for_prune, _is_specfact_github_prompt_path, copy_templates_to_ide, create_vscode_settings, @@ -240,8 +241,14 @@ def test_copy_templates_copies_non_core_prompt_ids_when_discovered(self, tmp_pat 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: +def test_discover_prompt_template_files_falls_back_to_repo_resources( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Prompt discovery falls back to repo-local resources when no installed module resources exist.""" + import specfact_cli.utils.ide_setup as ide_setup_module + + monkeypatch.setattr(ide_setup_module, "_module_prompt_sources_catalog", lambda _rp: {}) + templates_dir = tmp_path / "resources" / "prompts" templates_dir.mkdir(parents=True) prompt_file = templates_dir / "specfact.01-import.md" @@ -310,6 +317,13 @@ def test_discover_prompt_template_files_deduplicates_prompt_ids_by_filename( assert discovered == [first_prompt] +def test_flat_export_glob_pattern_for_prune_matches_output_formats() -> None: + """Prune globs must align with ``_output_filename_for_template`` (including Gemini/Qwen ``*.toml``).""" + assert _flat_export_glob_pattern_for_prune("prompt.md") == "specfact*.prompt.md" + assert _flat_export_glob_pattern_for_prune("toml") == "specfact*.toml" + assert _flat_export_glob_pattern_for_prune("md") == "specfact*.md" + + def test_is_specfact_github_prompt_path_only_specfact_named_prompts() -> None: """Strip targets only ``specfact*.prompt.md`` under ``.github/prompts/`` (after path normalization).""" assert _is_specfact_github_prompt_path(".github/prompts/core/specfact.01-import.prompt.md") @@ -352,7 +366,7 @@ def test_create_vscode_settings_selective_export_replaces_stale_github_prompt_pa assert ".github/prompts/nold-ai__mod/specfact.extra.prompt.md" not in recs assert ".github/prompts/custom/team-owned.prompt.md" in recs assert ".other/custom.prompt.md" in recs - assert ".github/prompts/core/specfact.01-import.prompt.md" in recs + assert ".github/prompts/specfact.01-import.prompt.md" in recs def test_specfact_commands_excludes_backlog_prompt_ids() -> None: @@ -396,4 +410,5 @@ def test_expected_ide_prompt_export_paths_respects_prompt_source_subset( ) assert len(full_paths) == 2 assert len(subset_paths) == 1 - assert "core" in subset_paths[0].parts + assert subset_paths[0].name == "c.md" + assert "core" not in subset_paths[0].parts