diff --git a/.agents/skills/map-plan/SKILL.md b/.agents/skills/map-plan/SKILL.md index e820879b..68251ebe 100644 --- a/.agents/skills/map-plan/SKILL.md +++ b/.agents/skills/map-plan/SKILL.md @@ -98,26 +98,18 @@ shell_command: - Outcome `map-fast`: recommend `$map-fast` and STOP. - Outcome `map-plan`: continue below. -**Scale Advisory (standard mode only, when no `--light`/`--deep` flag was given):** After recording a `map-plan` outcome, run: +**Scale Advisory (standard mode only, when no `--light`/`--deep` flag was given):** After recording a `map-plan` outcome, estimate the task scope and run: ``` shell_command: - cmd: | - python3 -c " -import sys; sys.path.insert(0, 'src') -from pathlib import Path -from mapify_cli.config.project_config import load_map_config -cfg = load_map_config(Path('.')) -print('scale_auto:', cfg.scale_auto) -print('small:', cfg.scale_small_max_files, cfg.scale_small_max_lines) -print('medium:', cfg.scale_medium_max_files, cfg.scale_medium_max_lines) -" -``` - -If `scale_auto: true`, use your estimate of the task scope to surface an advisory (do not block): -- Estimated ≤ small thresholds → advise re-invoking as `$map-plan --light`. -- Estimated > medium thresholds → advise re-invoking as `$map-plan --deep`. -- Otherwise (medium range) → no advisory; standard mode is appropriate. + cmd: python3 .map/scripts/classify_scope.py --files ESTIMATED_FILES --lines ESTIMATED_LINES +``` + +If `auto_enabled: true` in the JSON result, surface an advisory based on `bracket` (do not block): +- `small` → advise re-invoking as `$map-plan --light`. +- `large` → advise re-invoking as `$map-plan --deep`. +- `trivial` → advise `$map-fast` instead. +- `medium` → no advisory; standard mode is appropriate. --- diff --git a/.claude/skills/map-plan/SKILL.md b/.claude/skills/map-plan/SKILL.md index 5a310036..7562f95c 100644 --- a/.claude/skills/map-plan/SKILL.md +++ b/.claude/skills/map-plan/SKILL.md @@ -120,27 +120,18 @@ Outcomes: - `map-wayfind`: too foggy to specify — you cannot write sharp acceptance criteria because several core decisions are still unresolved and entangled (not merely "needs a little research"). Recommend `/map-wayfind chart ""` to resolve the decision frontier first, then return with `/map-plan --wayfind `. STOP. (This is the inverse of the Wayfinding Handoff pre-flight: that consumes a finished map; this sends you to create one.) - `map-plan`: continue. -**Scale Advisory (standard mode only, when no `--light`/`--deep` flag was given):** After recording a `map-plan` outcome, query the project's scale-adaptive config: +**Scale Advisory (standard mode only, when no `--light`/`--deep` flag was given):** After recording a `map-plan` outcome, estimate the task's scope (files to change, lines to add/modify) and run: ```bash -python3 -c " -import sys; sys.path.insert(0, 'src') -from pathlib import Path -from mapify_cli.config.project_config import load_map_config -cfg = load_map_config(Path('.')) -print('scale_auto:', cfg.scale_auto) -print('trivial:', cfg.scale_trivial_max_files, cfg.scale_trivial_max_lines) -print('small:', cfg.scale_small_max_files, cfg.scale_small_max_lines) -print('medium:', cfg.scale_medium_max_files, cfg.scale_medium_max_lines) -" +python3 .map/scripts/classify_scope.py --files ESTIMATED_FILES --lines ESTIMATED_LINES ``` -If `scale_auto: true`, use your estimate of the task's scope to surface an advisory (do not block on this): +If `auto_enabled: true` in the JSON result, surface an advisory based on `bracket` (do not block): -- Estimated ≤ trivial thresholds → advise `/map-fast` instead (already covered by the `map-fast` outcome above). -- Estimated ≤ small thresholds → advise re-invoking as `/map-plan --light` for a lighter-weight plan. -- Estimated > medium thresholds → advise re-invoking as `/map-plan --deep` for full research + architecture review. -- Otherwise (medium range) → no advisory; standard mode is appropriate. +- `trivial` → advise `/map-fast` instead (already covered by the `map-fast` outcome above). +- `small` → advise re-invoking as `/map-plan --light` for a lighter-weight plan. +- `large` → advise re-invoking as `/map-plan --deep` for full research + architecture review. +- `medium` → no advisory; standard mode is appropriate. The advisory is informational — the user's flags or your judgment prevail. If the user accepts, add the flag and restart; otherwise continue in standard mode. diff --git a/.map/scripts/classify_scope.py b/.map/scripts/classify_scope.py new file mode 100644 index 00000000..e83c1fb2 --- /dev/null +++ b/.map/scripts/classify_scope.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""classify_scope.py — Scale-adaptive scope classifier for MAP workflows (#287). + +Reads scale thresholds from .map/config.yaml (dotted-key notation) and +classifies a change estimate into one of four brackets: trivial, small, +medium, or large, then returns the recommended MAP workflow depth. + +Usage: + python3 .map/scripts/classify_scope.py --files 5 --lines 120 + +Output (JSON, exit 0): + {"bracket": "small", "recommended_workflow": "map-plan-light", + "estimated_files": 5, "estimated_lines": 120, "auto_enabled": true} + +Config keys read from .map/config.yaml (dotted notation): + scale.auto (default: true) + scale.thresholds.trivial.max_files (default: 3) + scale.thresholds.trivial.max_lines (default: 50) + scale.thresholds.small.max_files (default: 10) + scale.thresholds.small.max_lines (default: 200) + scale.thresholds.medium.max_files (default: 30) + scale.thresholds.medium.max_lines (default: 1000) +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + + +_BRACKET_WORKFLOW: dict[str, str] = { + "trivial": "map-fast", + "small": "map-plan-light", + "medium": "map-efficient", + "large": "map-efficient+map-tdd", +} + +_DEFAULTS: dict[str, str] = { + "scale.auto": "true", + "scale.thresholds.trivial.max_files": "3", + "scale.thresholds.trivial.max_lines": "50", + "scale.thresholds.small.max_files": "10", + "scale.thresholds.small.max_lines": "200", + "scale.thresholds.medium.max_files": "30", + "scale.thresholds.medium.max_lines": "1000", +} + + +def _read_config(project_dir: Path) -> dict[str, str]: + """Read .map/config.yaml scalar values without external dependencies. + + Supports the same dotted-key YAML subset used by load_map_config(). + """ + config_path = project_dir / ".map" / "config.yaml" + if not config_path.is_file(): + return {} + try: + lines = config_path.read_text(encoding="utf-8").splitlines() + except OSError: + return {} + values: dict[str, str] = {} + for raw in lines: + line = raw.strip() + if not line or line.startswith("#") or ":" not in line: + continue + key, value = line.split(":", 1) + key = key.strip() + if not key or not re.fullmatch(r"[A-Za-z0-9_.]+", key): + continue + value = value.split("#", 1)[0].strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: + value = value[1:-1] + values[key] = value + return values + + +def classify( + estimated_files: int, + estimated_lines: int, + project_dir: Path | None = None, +) -> dict[str, object]: + """Classify scope and return a result dict matching ScopeClassification fields.""" + raw = _read_config(project_dir or Path(".")) + + def _int(key: str) -> int: + return int(raw.get(key, _DEFAULTS[key])) + + def _bool(key: str) -> bool: + return raw.get(key, _DEFAULTS[key]).lower() not in {"false", "0", "no"} + + auto = _bool("scale.auto") + trivial_files = _int("scale.thresholds.trivial.max_files") + trivial_lines = _int("scale.thresholds.trivial.max_lines") + small_files = _int("scale.thresholds.small.max_files") + small_lines = _int("scale.thresholds.small.max_lines") + medium_files = _int("scale.thresholds.medium.max_files") + medium_lines = _int("scale.thresholds.medium.max_lines") + + if estimated_files <= trivial_files and estimated_lines <= trivial_lines: + bracket = "trivial" + elif estimated_files <= small_files and estimated_lines <= small_lines: + bracket = "small" + elif estimated_files <= medium_files and estimated_lines <= medium_lines: + bracket = "medium" + else: + bracket = "large" + + return { + "bracket": bracket, + "recommended_workflow": _BRACKET_WORKFLOW[bracket], + "estimated_files": estimated_files, + "estimated_lines": estimated_lines, + "auto_enabled": auto, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Classify MAP workflow scope from estimated change size.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument("--files", type=int, required=True, + help="Estimated number of files to change (>= 0)") + parser.add_argument("--lines", type=int, required=True, + help="Estimated lines to add/modify (>= 0)") + parser.add_argument("--project-dir", default=".", + help="Project root (default: current directory)") + args = parser.parse_args(argv) + + if args.files < 0 or args.lines < 0: + print("error: --files and --lines must be >= 0", file=sys.stderr) + return 1 + + result = classify(args.files, args.lines, Path(args.project_dir)) + print(json.dumps(result)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 04d0d1f5..9acfc32a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -395,7 +395,7 @@ LLM cannot fabricate input — the same layered-defense posture MAP uses elsewhe - **Token Budget Decisions**: `.map//token_budget.json` records active prompt-path budget decisions from Actor context and review prompt builders only; it does not log dormant REGISTRY/FOCUS experiments. Each entry names the prompt path, configured budget, estimated tokens before/after enforcement, clipped sections, and source artifacts. - **Constraint Typing**: `blueprint.json` separates non-negotiable `hard_constraints` from negotiable `soft_constraints`; hard constraints must be covered through `coverage_map` and bracketed validation criteria, while soft constraints need either coverage or explicit tradeoff rationale. - **Provider Differences**: Workflow intent is shared, but orchestration mechanics differ between Claude Code (`.claude/`) and Codex CLI (`.codex/`). -- **Scale-Adaptive Intelligence** (`src/mapify_cli/scope_classifier.py`, issue #287): deterministic, no-LLM classification of an estimated change into one of four scale brackets that routes to the appropriate MAP workflow depth. `classify_scope(estimated_files, estimated_lines, *, config)` returns a frozen `ScopeClassification` carrying `bracket` (a `ScopeBracket` enum), `recommended_workflow`, `estimated_files`, `estimated_lines`, and `auto_enabled`. Four brackets with default inclusive-upper-bound thresholds: TRIVIAL (≤3 files AND ≤50 lines → `map-fast`), SMALL (≤10/200 → `map-plan-light`), MEDIUM (≤30/1000 → `map-efficient`), LARGE (otherwise → `map-efficient+map-tdd`). Thresholds and the `scale.auto` toggle are read from `MapConfig` (7 new `scale_*` fields) and surfaced in `.map/config.yaml` via dotted-key aliases (`scale.auto`, `scale.thresholds.{trivial,small,medium}.{max_files,max_lines}`). `generate_default_config()` documents the full `scale` section (commented-out defaults). Remaining slices (#287): Jinja skill-template integration for `--light`/`--deep` flags, `--force-full`/`--force-fast` overrides, and entry-point calls that surface the recommendation at workflow start. +- **Scale-Adaptive Intelligence** (`src/mapify_cli/scope_classifier.py`, issue #287, **COMPLETE** PRs #366/#368/#369): deterministic, no-LLM classification of an estimated change into one of four scale brackets that routes to the appropriate MAP workflow depth. `classify_scope(estimated_files, estimated_lines, *, config)` returns a frozen `ScopeClassification` (bracket, recommended_workflow, estimated_files, estimated_lines, auto_enabled). Brackets: TRIVIAL (≤3f/50l → `map-fast`), SMALL (≤10/200 → `map-plan-light`), MEDIUM (≤30/1000 → `map-efficient`), LARGE (otherwise → `map-efficient+map-tdd`). Thresholds and `scale.auto` read from `MapConfig` (7 `scale_*` fields) via dotted-key aliases in `.map/config.yaml`. `map-plan` skill (both Claude and Codex variants) integrates a **Scale Advisory** block that calls `python3 .map/scripts/classify_scope.py --files N --lines N` after workflow-fit gate; result is JSON with bracket/recommended_workflow/auto_enabled. The `classify_scope.py` script ships via `mapify init` to `.map/scripts/`, is stdlib-only (no mapify_cli import), reads `.map/config.yaml` directly, and validates `--files`/`--lines` ≥ 0. The `map-plan` skill also ships `--light` (2-5 minimal subtasks, no discovery/spec-review/architecture-review), `--deep` (extended discovery, architecture review via monitor agent in Step 4.5), `--force-full` (alias for --deep), and `--force-fast` (recommend map-fast and stop) modes; both Claude (Task syntax) and Codex (spawn_agent syntax) variants updated. ### Prompt Layering & Prefix Caching (#231) @@ -2558,6 +2558,24 @@ cat .map/workflow_logs/feat_auth_20251023_143022.json | jq '.subtasks[].agents.e --- +## Open Issues (for next session) + +All remaining open issues are enhancements (no bugs as of 2026-07-18). Prioritized by concreteness: + +### #291 — SpecKit-style preset composition (layered template resolution) +Adds `.map/presets/` directory, 4-tier resolution (project → preset → extension → core), composition strategies (prepend/append/wrap/replace), `mapify preset add/remove/enable/disable/list/resolve` commands, and extension hook lifecycle. First slice: directory structure, YAML manifest format, and `mapify preset list`. Key constraint: must not bypass the `make check-render` single-source invariant — presets should compose at render time, not by editing generated trees. + +### #363 — Architecture deepening report (`/map-architecture` skill) +New opt-in skill that ranks codebase areas by recent git hotspot + design friction, generates a candidate report (HTML or Markdown+Mermaid under `.map//architecture-report/`), and holds off implementation until the user picks a candidate. First slice: create `src/mapify_cli/templates_src/skills/map-architecture/SKILL.md.jinja` + register in skill-rules.json. No Python code in slice 1 — just the workflow instructions. + +### #353 — Eval-gated prompt profile canary and rollback +Local-first prompt lifecycle: versioned profiles under `.map/prompt-profiles/`, `active.json` pointer, `mapify prompt-profile list/diff/activate/rollback` commands. Integrates with skill-eval (trigger changes) and trajectory eval (#351, for body changes). First slice: manifest format + `mapify prompt-profile list`. Key constraint: do not edit generated trees as the durable authoring source. + +### #339 — GRACE semantic code-contract anchor eval +Eval harness comparing inline code contracts vs prompt-injected vs no-anchor variants for bug-fix workflows. Variants: baseline, inline (LEX/MIN), prompt-injected (INJ), stale (LIE). First slice: fixture structure + report schema (JSON). No external model calls required for slice 1. + +--- + ## References - [MAP Paper - Nature Communications](https://github.com/Shanka123/MAP) diff --git a/src/mapify_cli/__init__.py b/src/mapify_cli/__init__.py index 7d435a05..182b458f 100644 --- a/src/mapify_cli/__init__.py +++ b/src/mapify_cli/__init__.py @@ -25,6 +25,7 @@ __version__ = "3.22.0" +import json import os import subprocess import sys @@ -173,6 +174,13 @@ def create_ssl_context(): app.add_typer(governance_app, name="governance") +preset_app = typer.Typer( + name="preset", + help="Manage MAP workflow customization presets.", +) + +app.add_typer(preset_app, name="preset") + def version_callback(value: bool): """Callback to show version and exit.""" @@ -1762,6 +1770,143 @@ def uninstall( console.print("[dim]Nothing to do.[/dim]") +# --------------------------------------------------------------------------- +# Preset management commands (#291) +# --------------------------------------------------------------------------- + +_PRESET_MANIFEST_KEYS = ("id", "title", "version") + + +def _presets_dir(project_dir: Path) -> Path: + return project_dir / ".map" / "presets" + + +def _read_preset_manifest(preset_path: Path) -> dict[str, Any] | None: + manifest_path = preset_path / "manifest.json" + if not manifest_path.is_file(): + return None + try: + return json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + +@preset_app.command("list") +def preset_list( + project_path: Optional[Path] = typer.Argument( + None, + help="Project root directory (defaults to current directory).", + ), + output_json: bool = typer.Option(False, "--json", help="Output as JSON."), +) -> None: + """List installed MAP presets in a project's .map/presets/ directory.""" + target = project_path or Path.cwd() + presets_root = _presets_dir(target) + + if not presets_root.is_dir(): + if output_json: + typer.echo(json.dumps({"presets": []})) + else: + console.print("[dim]No presets installed. Use 'mapify preset add --from ' to install one.[/dim]") + return + + presets: list[dict[str, Any]] = [] + for entry in sorted(presets_root.iterdir()): + if not entry.is_dir(): + continue + manifest = _read_preset_manifest(entry) + if manifest is None: + presets.append({"id": entry.name, "title": entry.name, "version": "?", "description": "(no manifest)"}) + else: + presets.append({ + "id": manifest.get("id", entry.name), + "title": manifest.get("title", entry.name), + "version": manifest.get("version", "?"), + "description": manifest.get("description", ""), + }) + + if output_json: + typer.echo(json.dumps({"presets": presets})) + return + + if not presets: + console.print("[dim]No presets installed. Use 'mapify preset add --from ' to install one.[/dim]") + return + + table = Table(title="Installed MAP Presets", show_header=True, header_style="bold cyan") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Title") + table.add_column("Version", style="dim") + table.add_column("Description", style="dim") + for p in presets: + table.add_row(p["id"], p["title"], p["version"], p.get("description", "")) + console.print(table) + + +@preset_app.command("add") +def preset_add( + from_path: Path = typer.Option( + ..., + "--from", + help="Path to a preset directory containing manifest.json.", + show_default=False, + ), + project_path: Optional[Path] = typer.Argument( + None, + help="Project root directory (defaults to current directory).", + ), + force: bool = typer.Option(False, "--force", "-f", help="Overwrite if already installed."), +) -> None: + """Install a MAP preset from a local directory into .map/presets/. + + The source directory must contain a manifest.json with at least: id, title, version. + """ + if not from_path.is_dir(): + console.print(f"[red]Error:[/red] '{from_path}' is not a directory.") + raise typer.Exit(1) + + manifest = _read_preset_manifest(from_path) + if manifest is None: + console.print( + f"[red]Error:[/red] No valid manifest.json found in '{from_path}'.\n" + "A preset directory must contain manifest.json with 'id', 'title', and 'version' keys." + ) + raise typer.Exit(1) + + missing = [k for k in _PRESET_MANIFEST_KEYS if k not in manifest] + if missing: + console.print( + f"[red]Error:[/red] manifest.json is missing required keys: {', '.join(missing)}" + ) + raise typer.Exit(1) + + preset_id: str = manifest["id"] + if not preset_id or "/" in preset_id or "\\" in preset_id or ".." in preset_id: + console.print( + f"[red]Error:[/red] Invalid preset id '{preset_id}' — must be a plain name (no path separators)." + ) + raise typer.Exit(1) + + target = project_path or Path.cwd() + dest = _presets_dir(target) / preset_id + + if dest.exists() and not force: + console.print( + f"[yellow]Preset '{preset_id}' is already installed.[/yellow] " + "Use --force to overwrite." + ) + raise typer.Exit(1) + + if dest.exists(): + shutil.rmtree(dest) + + shutil.copytree(str(from_path), str(dest)) + console.print( + f"[green]Preset '{preset_id}'[/green] ({manifest.get('title', preset_id)} " + f"v{manifest.get('version', '?')}) installed to {dest}." + ) + + # Research localization eval commands diff --git a/src/mapify_cli/templates/codex/skills/map-plan/SKILL.md b/src/mapify_cli/templates/codex/skills/map-plan/SKILL.md index e820879b..68251ebe 100644 --- a/src/mapify_cli/templates/codex/skills/map-plan/SKILL.md +++ b/src/mapify_cli/templates/codex/skills/map-plan/SKILL.md @@ -98,26 +98,18 @@ shell_command: - Outcome `map-fast`: recommend `$map-fast` and STOP. - Outcome `map-plan`: continue below. -**Scale Advisory (standard mode only, when no `--light`/`--deep` flag was given):** After recording a `map-plan` outcome, run: +**Scale Advisory (standard mode only, when no `--light`/`--deep` flag was given):** After recording a `map-plan` outcome, estimate the task scope and run: ``` shell_command: - cmd: | - python3 -c " -import sys; sys.path.insert(0, 'src') -from pathlib import Path -from mapify_cli.config.project_config import load_map_config -cfg = load_map_config(Path('.')) -print('scale_auto:', cfg.scale_auto) -print('small:', cfg.scale_small_max_files, cfg.scale_small_max_lines) -print('medium:', cfg.scale_medium_max_files, cfg.scale_medium_max_lines) -" -``` - -If `scale_auto: true`, use your estimate of the task scope to surface an advisory (do not block): -- Estimated ≤ small thresholds → advise re-invoking as `$map-plan --light`. -- Estimated > medium thresholds → advise re-invoking as `$map-plan --deep`. -- Otherwise (medium range) → no advisory; standard mode is appropriate. + cmd: python3 .map/scripts/classify_scope.py --files ESTIMATED_FILES --lines ESTIMATED_LINES +``` + +If `auto_enabled: true` in the JSON result, surface an advisory based on `bracket` (do not block): +- `small` → advise re-invoking as `$map-plan --light`. +- `large` → advise re-invoking as `$map-plan --deep`. +- `trivial` → advise `$map-fast` instead. +- `medium` → no advisory; standard mode is appropriate. --- diff --git a/src/mapify_cli/templates/map/scripts/classify_scope.py b/src/mapify_cli/templates/map/scripts/classify_scope.py new file mode 100644 index 00000000..e83c1fb2 --- /dev/null +++ b/src/mapify_cli/templates/map/scripts/classify_scope.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""classify_scope.py — Scale-adaptive scope classifier for MAP workflows (#287). + +Reads scale thresholds from .map/config.yaml (dotted-key notation) and +classifies a change estimate into one of four brackets: trivial, small, +medium, or large, then returns the recommended MAP workflow depth. + +Usage: + python3 .map/scripts/classify_scope.py --files 5 --lines 120 + +Output (JSON, exit 0): + {"bracket": "small", "recommended_workflow": "map-plan-light", + "estimated_files": 5, "estimated_lines": 120, "auto_enabled": true} + +Config keys read from .map/config.yaml (dotted notation): + scale.auto (default: true) + scale.thresholds.trivial.max_files (default: 3) + scale.thresholds.trivial.max_lines (default: 50) + scale.thresholds.small.max_files (default: 10) + scale.thresholds.small.max_lines (default: 200) + scale.thresholds.medium.max_files (default: 30) + scale.thresholds.medium.max_lines (default: 1000) +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + + +_BRACKET_WORKFLOW: dict[str, str] = { + "trivial": "map-fast", + "small": "map-plan-light", + "medium": "map-efficient", + "large": "map-efficient+map-tdd", +} + +_DEFAULTS: dict[str, str] = { + "scale.auto": "true", + "scale.thresholds.trivial.max_files": "3", + "scale.thresholds.trivial.max_lines": "50", + "scale.thresholds.small.max_files": "10", + "scale.thresholds.small.max_lines": "200", + "scale.thresholds.medium.max_files": "30", + "scale.thresholds.medium.max_lines": "1000", +} + + +def _read_config(project_dir: Path) -> dict[str, str]: + """Read .map/config.yaml scalar values without external dependencies. + + Supports the same dotted-key YAML subset used by load_map_config(). + """ + config_path = project_dir / ".map" / "config.yaml" + if not config_path.is_file(): + return {} + try: + lines = config_path.read_text(encoding="utf-8").splitlines() + except OSError: + return {} + values: dict[str, str] = {} + for raw in lines: + line = raw.strip() + if not line or line.startswith("#") or ":" not in line: + continue + key, value = line.split(":", 1) + key = key.strip() + if not key or not re.fullmatch(r"[A-Za-z0-9_.]+", key): + continue + value = value.split("#", 1)[0].strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: + value = value[1:-1] + values[key] = value + return values + + +def classify( + estimated_files: int, + estimated_lines: int, + project_dir: Path | None = None, +) -> dict[str, object]: + """Classify scope and return a result dict matching ScopeClassification fields.""" + raw = _read_config(project_dir or Path(".")) + + def _int(key: str) -> int: + return int(raw.get(key, _DEFAULTS[key])) + + def _bool(key: str) -> bool: + return raw.get(key, _DEFAULTS[key]).lower() not in {"false", "0", "no"} + + auto = _bool("scale.auto") + trivial_files = _int("scale.thresholds.trivial.max_files") + trivial_lines = _int("scale.thresholds.trivial.max_lines") + small_files = _int("scale.thresholds.small.max_files") + small_lines = _int("scale.thresholds.small.max_lines") + medium_files = _int("scale.thresholds.medium.max_files") + medium_lines = _int("scale.thresholds.medium.max_lines") + + if estimated_files <= trivial_files and estimated_lines <= trivial_lines: + bracket = "trivial" + elif estimated_files <= small_files and estimated_lines <= small_lines: + bracket = "small" + elif estimated_files <= medium_files and estimated_lines <= medium_lines: + bracket = "medium" + else: + bracket = "large" + + return { + "bracket": bracket, + "recommended_workflow": _BRACKET_WORKFLOW[bracket], + "estimated_files": estimated_files, + "estimated_lines": estimated_lines, + "auto_enabled": auto, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Classify MAP workflow scope from estimated change size.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument("--files", type=int, required=True, + help="Estimated number of files to change (>= 0)") + parser.add_argument("--lines", type=int, required=True, + help="Estimated lines to add/modify (>= 0)") + parser.add_argument("--project-dir", default=".", + help="Project root (default: current directory)") + args = parser.parse_args(argv) + + if args.files < 0 or args.lines < 0: + print("error: --files and --lines must be >= 0", file=sys.stderr) + return 1 + + result = classify(args.files, args.lines, Path(args.project_dir)) + print(json.dumps(result)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/mapify_cli/templates/skills/map-plan/SKILL.md b/src/mapify_cli/templates/skills/map-plan/SKILL.md index 5a310036..7562f95c 100644 --- a/src/mapify_cli/templates/skills/map-plan/SKILL.md +++ b/src/mapify_cli/templates/skills/map-plan/SKILL.md @@ -120,27 +120,18 @@ Outcomes: - `map-wayfind`: too foggy to specify — you cannot write sharp acceptance criteria because several core decisions are still unresolved and entangled (not merely "needs a little research"). Recommend `/map-wayfind chart ""` to resolve the decision frontier first, then return with `/map-plan --wayfind `. STOP. (This is the inverse of the Wayfinding Handoff pre-flight: that consumes a finished map; this sends you to create one.) - `map-plan`: continue. -**Scale Advisory (standard mode only, when no `--light`/`--deep` flag was given):** After recording a `map-plan` outcome, query the project's scale-adaptive config: +**Scale Advisory (standard mode only, when no `--light`/`--deep` flag was given):** After recording a `map-plan` outcome, estimate the task's scope (files to change, lines to add/modify) and run: ```bash -python3 -c " -import sys; sys.path.insert(0, 'src') -from pathlib import Path -from mapify_cli.config.project_config import load_map_config -cfg = load_map_config(Path('.')) -print('scale_auto:', cfg.scale_auto) -print('trivial:', cfg.scale_trivial_max_files, cfg.scale_trivial_max_lines) -print('small:', cfg.scale_small_max_files, cfg.scale_small_max_lines) -print('medium:', cfg.scale_medium_max_files, cfg.scale_medium_max_lines) -" +python3 .map/scripts/classify_scope.py --files ESTIMATED_FILES --lines ESTIMATED_LINES ``` -If `scale_auto: true`, use your estimate of the task's scope to surface an advisory (do not block on this): +If `auto_enabled: true` in the JSON result, surface an advisory based on `bracket` (do not block): -- Estimated ≤ trivial thresholds → advise `/map-fast` instead (already covered by the `map-fast` outcome above). -- Estimated ≤ small thresholds → advise re-invoking as `/map-plan --light` for a lighter-weight plan. -- Estimated > medium thresholds → advise re-invoking as `/map-plan --deep` for full research + architecture review. -- Otherwise (medium range) → no advisory; standard mode is appropriate. +- `trivial` → advise `/map-fast` instead (already covered by the `map-fast` outcome above). +- `small` → advise re-invoking as `/map-plan --light` for a lighter-weight plan. +- `large` → advise re-invoking as `/map-plan --deep` for full research + architecture review. +- `medium` → no advisory; standard mode is appropriate. The advisory is informational — the user's flags or your judgment prevail. If the user accepts, add the flag and restart; otherwise continue in standard mode. diff --git a/src/mapify_cli/templates_src/codex/skills/map-plan/SKILL.md.jinja b/src/mapify_cli/templates_src/codex/skills/map-plan/SKILL.md.jinja index e820879b..68251ebe 100644 --- a/src/mapify_cli/templates_src/codex/skills/map-plan/SKILL.md.jinja +++ b/src/mapify_cli/templates_src/codex/skills/map-plan/SKILL.md.jinja @@ -98,26 +98,18 @@ shell_command: - Outcome `map-fast`: recommend `$map-fast` and STOP. - Outcome `map-plan`: continue below. -**Scale Advisory (standard mode only, when no `--light`/`--deep` flag was given):** After recording a `map-plan` outcome, run: +**Scale Advisory (standard mode only, when no `--light`/`--deep` flag was given):** After recording a `map-plan` outcome, estimate the task scope and run: ``` shell_command: - cmd: | - python3 -c " -import sys; sys.path.insert(0, 'src') -from pathlib import Path -from mapify_cli.config.project_config import load_map_config -cfg = load_map_config(Path('.')) -print('scale_auto:', cfg.scale_auto) -print('small:', cfg.scale_small_max_files, cfg.scale_small_max_lines) -print('medium:', cfg.scale_medium_max_files, cfg.scale_medium_max_lines) -" -``` - -If `scale_auto: true`, use your estimate of the task scope to surface an advisory (do not block): -- Estimated ≤ small thresholds → advise re-invoking as `$map-plan --light`. -- Estimated > medium thresholds → advise re-invoking as `$map-plan --deep`. -- Otherwise (medium range) → no advisory; standard mode is appropriate. + cmd: python3 .map/scripts/classify_scope.py --files ESTIMATED_FILES --lines ESTIMATED_LINES +``` + +If `auto_enabled: true` in the JSON result, surface an advisory based on `bracket` (do not block): +- `small` → advise re-invoking as `$map-plan --light`. +- `large` → advise re-invoking as `$map-plan --deep`. +- `trivial` → advise `$map-fast` instead. +- `medium` → no advisory; standard mode is appropriate. --- diff --git a/src/mapify_cli/templates_src/map/scripts/classify_scope.py.jinja b/src/mapify_cli/templates_src/map/scripts/classify_scope.py.jinja new file mode 100644 index 00000000..e83c1fb2 --- /dev/null +++ b/src/mapify_cli/templates_src/map/scripts/classify_scope.py.jinja @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""classify_scope.py — Scale-adaptive scope classifier for MAP workflows (#287). + +Reads scale thresholds from .map/config.yaml (dotted-key notation) and +classifies a change estimate into one of four brackets: trivial, small, +medium, or large, then returns the recommended MAP workflow depth. + +Usage: + python3 .map/scripts/classify_scope.py --files 5 --lines 120 + +Output (JSON, exit 0): + {"bracket": "small", "recommended_workflow": "map-plan-light", + "estimated_files": 5, "estimated_lines": 120, "auto_enabled": true} + +Config keys read from .map/config.yaml (dotted notation): + scale.auto (default: true) + scale.thresholds.trivial.max_files (default: 3) + scale.thresholds.trivial.max_lines (default: 50) + scale.thresholds.small.max_files (default: 10) + scale.thresholds.small.max_lines (default: 200) + scale.thresholds.medium.max_files (default: 30) + scale.thresholds.medium.max_lines (default: 1000) +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + + +_BRACKET_WORKFLOW: dict[str, str] = { + "trivial": "map-fast", + "small": "map-plan-light", + "medium": "map-efficient", + "large": "map-efficient+map-tdd", +} + +_DEFAULTS: dict[str, str] = { + "scale.auto": "true", + "scale.thresholds.trivial.max_files": "3", + "scale.thresholds.trivial.max_lines": "50", + "scale.thresholds.small.max_files": "10", + "scale.thresholds.small.max_lines": "200", + "scale.thresholds.medium.max_files": "30", + "scale.thresholds.medium.max_lines": "1000", +} + + +def _read_config(project_dir: Path) -> dict[str, str]: + """Read .map/config.yaml scalar values without external dependencies. + + Supports the same dotted-key YAML subset used by load_map_config(). + """ + config_path = project_dir / ".map" / "config.yaml" + if not config_path.is_file(): + return {} + try: + lines = config_path.read_text(encoding="utf-8").splitlines() + except OSError: + return {} + values: dict[str, str] = {} + for raw in lines: + line = raw.strip() + if not line or line.startswith("#") or ":" not in line: + continue + key, value = line.split(":", 1) + key = key.strip() + if not key or not re.fullmatch(r"[A-Za-z0-9_.]+", key): + continue + value = value.split("#", 1)[0].strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: + value = value[1:-1] + values[key] = value + return values + + +def classify( + estimated_files: int, + estimated_lines: int, + project_dir: Path | None = None, +) -> dict[str, object]: + """Classify scope and return a result dict matching ScopeClassification fields.""" + raw = _read_config(project_dir or Path(".")) + + def _int(key: str) -> int: + return int(raw.get(key, _DEFAULTS[key])) + + def _bool(key: str) -> bool: + return raw.get(key, _DEFAULTS[key]).lower() not in {"false", "0", "no"} + + auto = _bool("scale.auto") + trivial_files = _int("scale.thresholds.trivial.max_files") + trivial_lines = _int("scale.thresholds.trivial.max_lines") + small_files = _int("scale.thresholds.small.max_files") + small_lines = _int("scale.thresholds.small.max_lines") + medium_files = _int("scale.thresholds.medium.max_files") + medium_lines = _int("scale.thresholds.medium.max_lines") + + if estimated_files <= trivial_files and estimated_lines <= trivial_lines: + bracket = "trivial" + elif estimated_files <= small_files and estimated_lines <= small_lines: + bracket = "small" + elif estimated_files <= medium_files and estimated_lines <= medium_lines: + bracket = "medium" + else: + bracket = "large" + + return { + "bracket": bracket, + "recommended_workflow": _BRACKET_WORKFLOW[bracket], + "estimated_files": estimated_files, + "estimated_lines": estimated_lines, + "auto_enabled": auto, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Classify MAP workflow scope from estimated change size.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument("--files", type=int, required=True, + help="Estimated number of files to change (>= 0)") + parser.add_argument("--lines", type=int, required=True, + help="Estimated lines to add/modify (>= 0)") + parser.add_argument("--project-dir", default=".", + help="Project root (default: current directory)") + args = parser.parse_args(argv) + + if args.files < 0 or args.lines < 0: + print("error: --files and --lines must be >= 0", file=sys.stderr) + return 1 + + result = classify(args.files, args.lines, Path(args.project_dir)) + print(json.dumps(result)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/mapify_cli/templates_src/skills/map-plan/SKILL.md.jinja b/src/mapify_cli/templates_src/skills/map-plan/SKILL.md.jinja index 5a310036..7562f95c 100644 --- a/src/mapify_cli/templates_src/skills/map-plan/SKILL.md.jinja +++ b/src/mapify_cli/templates_src/skills/map-plan/SKILL.md.jinja @@ -120,27 +120,18 @@ Outcomes: - `map-wayfind`: too foggy to specify — you cannot write sharp acceptance criteria because several core decisions are still unresolved and entangled (not merely "needs a little research"). Recommend `/map-wayfind chart ""` to resolve the decision frontier first, then return with `/map-plan --wayfind `. STOP. (This is the inverse of the Wayfinding Handoff pre-flight: that consumes a finished map; this sends you to create one.) - `map-plan`: continue. -**Scale Advisory (standard mode only, when no `--light`/`--deep` flag was given):** After recording a `map-plan` outcome, query the project's scale-adaptive config: +**Scale Advisory (standard mode only, when no `--light`/`--deep` flag was given):** After recording a `map-plan` outcome, estimate the task's scope (files to change, lines to add/modify) and run: ```bash -python3 -c " -import sys; sys.path.insert(0, 'src') -from pathlib import Path -from mapify_cli.config.project_config import load_map_config -cfg = load_map_config(Path('.')) -print('scale_auto:', cfg.scale_auto) -print('trivial:', cfg.scale_trivial_max_files, cfg.scale_trivial_max_lines) -print('small:', cfg.scale_small_max_files, cfg.scale_small_max_lines) -print('medium:', cfg.scale_medium_max_files, cfg.scale_medium_max_lines) -" +python3 .map/scripts/classify_scope.py --files ESTIMATED_FILES --lines ESTIMATED_LINES ``` -If `scale_auto: true`, use your estimate of the task's scope to surface an advisory (do not block on this): +If `auto_enabled: true` in the JSON result, surface an advisory based on `bracket` (do not block): -- Estimated ≤ trivial thresholds → advise `/map-fast` instead (already covered by the `map-fast` outcome above). -- Estimated ≤ small thresholds → advise re-invoking as `/map-plan --light` for a lighter-weight plan. -- Estimated > medium thresholds → advise re-invoking as `/map-plan --deep` for full research + architecture review. -- Otherwise (medium range) → no advisory; standard mode is appropriate. +- `trivial` → advise `/map-fast` instead (already covered by the `map-fast` outcome above). +- `small` → advise re-invoking as `/map-plan --light` for a lighter-weight plan. +- `large` → advise re-invoking as `/map-plan --deep` for full research + architecture review. +- `medium` → no advisory; standard mode is appropriate. The advisory is informational — the user's flags or your judgment prevail. If the user accepts, add the flag and restart; otherwise continue in standard mode. diff --git a/tests/test_classify_scope_script.py b/tests/test_classify_scope_script.py new file mode 100644 index 00000000..fe973f1b --- /dev/null +++ b/tests/test_classify_scope_script.py @@ -0,0 +1,249 @@ +"""Tests for the standalone .map/scripts/classify_scope.py script (#287). + +The script is rendered from templates_src/map/scripts/classify_scope.py.jinja +and ships with 'mapify init' so it must work without importing mapify_cli. + +Covers: + SC1 — default thresholds (no config file) produce correct brackets + SC2 — config file with dotted-key overrides is read correctly + SC3 — boundary conditions (at-ceiling vs one-over) + SC4 — auto_enabled flag respected from config + SC5 — subprocess CLI invocation returns valid JSON (exit 0) + SC6 — subprocess CLI rejects negative inputs (exit 1) + SC7 — rendered script exists in all three output paths +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +# Locate the rendered script under the dev tree (.map/scripts/) +_REPO_ROOT = Path(__file__).parent.parent +_SCRIPT = _REPO_ROOT / ".map" / "scripts" / "classify_scope.py" + + +def _run(files: int, lines: int, project_dir: Path | None = None) -> dict: + """Invoke the script as a subprocess and return parsed JSON.""" + cmd = [sys.executable, str(_SCRIPT), "--files", str(files), "--lines", str(lines)] + if project_dir is not None: + cmd += ["--project-dir", str(project_dir)] + proc = subprocess.run(cmd, capture_output=True, text=True) + assert proc.returncode == 0, f"script failed:\n{proc.stderr}" + return json.loads(proc.stdout) + + +def _write_config(tmp_path: Path, body: str) -> None: + (tmp_path / ".map").mkdir(parents=True, exist_ok=True) + (tmp_path / ".map" / "config.yaml").write_text(body, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# SC1 — default thresholds +# --------------------------------------------------------------------------- + + +class TestSc1DefaultThresholds: + def test_zero_zero_is_trivial(self): + result = _run(0, 0) + assert result["bracket"] == "trivial" + assert result["recommended_workflow"] == "map-fast" + + def test_trivial_at_ceiling(self): + result = _run(3, 50) + assert result["bracket"] == "trivial" + + def test_small_exceeds_trivial_files(self): + result = _run(4, 10) + assert result["bracket"] == "small" + assert result["recommended_workflow"] == "map-plan-light" + + def test_small_at_ceiling(self): + result = _run(10, 200) + assert result["bracket"] == "small" + + def test_medium_exceeds_small_files(self): + result = _run(11, 10) + assert result["bracket"] == "medium" + assert result["recommended_workflow"] == "map-efficient" + + def test_medium_at_ceiling(self): + result = _run(30, 1000) + assert result["bracket"] == "medium" + + def test_large_exceeds_medium(self): + result = _run(31, 10) + assert result["bracket"] == "large" + assert result["recommended_workflow"] == "map-efficient+map-tdd" + + def test_large_lines_exceed_medium(self): + result = _run(1, 1001) + assert result["bracket"] == "large" + + +# --------------------------------------------------------------------------- +# SC2 — dotted-key config overrides +# --------------------------------------------------------------------------- + + +class TestSc2ConfigOverrides: + def test_trivial_max_files_override(self, tmp_path: Path): + _write_config(tmp_path, "scale.thresholds.trivial.max_files: 1\n") + assert _run(1, 10, tmp_path)["bracket"] == "trivial" + assert _run(2, 10, tmp_path)["bracket"] == "small" + + def test_trivial_max_lines_override(self, tmp_path: Path): + _write_config(tmp_path, "scale.thresholds.trivial.max_lines: 20\n") + assert _run(1, 20, tmp_path)["bracket"] == "trivial" + assert _run(1, 21, tmp_path)["bracket"] == "small" + + def test_small_max_files_override(self, tmp_path: Path): + _write_config(tmp_path, "scale.thresholds.small.max_files: 5\n") + assert _run(5, 10, tmp_path)["bracket"] == "small" + assert _run(6, 10, tmp_path)["bracket"] == "medium" + + def test_medium_max_files_override(self, tmp_path: Path): + _write_config(tmp_path, "scale.thresholds.medium.max_files: 20\n") + assert _run(20, 100, tmp_path)["bracket"] == "medium" + assert _run(21, 100, tmp_path)["bracket"] == "large" + + def test_absent_config_uses_defaults(self, tmp_path: Path): + result = _run(3, 50, tmp_path) + assert result["bracket"] == "trivial" + + def test_multiple_overrides(self, tmp_path: Path): + _write_config( + tmp_path, + "scale.thresholds.trivial.max_files: 2\n" + "scale.thresholds.trivial.max_lines: 30\n" + ) + assert _run(2, 30, tmp_path)["bracket"] == "trivial" + assert _run(3, 30, tmp_path)["bracket"] == "small" + assert _run(2, 31, tmp_path)["bracket"] == "small" + + +# --------------------------------------------------------------------------- +# SC3 — boundary conditions +# --------------------------------------------------------------------------- + + +class TestSc3BoundaryConditions: + @pytest.mark.parametrize( + "files, lines, expected", + [ + (3, 50, "trivial"), + (4, 50, "small"), + (3, 51, "small"), + (10, 200, "small"), + (11, 200, "medium"), + (10, 201, "medium"), + (30, 1000, "medium"), + (31, 1000, "large"), + (30, 1001, "large"), + ], + ) + def test_boundary(self, files: int, lines: int, expected: str): + assert _run(files, lines)["bracket"] == expected + + +# --------------------------------------------------------------------------- +# SC4 — auto_enabled flag +# --------------------------------------------------------------------------- + + +class TestSc4AutoEnabled: + def test_auto_true_by_default(self): + assert _run(1, 10)["auto_enabled"] is True + + def test_auto_false_from_config(self, tmp_path: Path): + _write_config(tmp_path, "scale.auto: false\n") + assert _run(1, 10, tmp_path)["auto_enabled"] is False + + def test_auto_true_explicit_in_config(self, tmp_path: Path): + _write_config(tmp_path, "scale.auto: true\n") + assert _run(1, 10, tmp_path)["auto_enabled"] is True + + +# --------------------------------------------------------------------------- +# SC5 — subprocess JSON output contract +# --------------------------------------------------------------------------- + + +class TestSc5JsonOutput: + def test_required_fields_present(self): + result = _run(5, 100) + for key in ("bracket", "recommended_workflow", "estimated_files", "estimated_lines", "auto_enabled"): + assert key in result, f"missing key: {key}" + + def test_estimated_fields_echo_inputs(self): + result = _run(7, 123) + assert result["estimated_files"] == 7 + assert result["estimated_lines"] == 123 + + def test_recommended_workflow_nonempty_string(self): + for files, lines in [(1, 10), (5, 100), (15, 400), (50, 2000)]: + r = _run(files, lines) + assert isinstance(r["recommended_workflow"], str) and r["recommended_workflow"] + + +# --------------------------------------------------------------------------- +# SC6 — invalid input rejected +# --------------------------------------------------------------------------- + + +class TestSc6InvalidInput: + def test_negative_files_exits_nonzero(self): + proc = subprocess.run( + [sys.executable, str(_SCRIPT), "--files", "-1", "--lines", "10"], + capture_output=True, text=True, + ) + assert proc.returncode != 0 + + def test_negative_lines_exits_nonzero(self): + proc = subprocess.run( + [sys.executable, str(_SCRIPT), "--files", "1", "--lines", "-1"], + capture_output=True, text=True, + ) + assert proc.returncode != 0 + + def test_missing_files_arg_exits_nonzero(self): + proc = subprocess.run( + [sys.executable, str(_SCRIPT), "--lines", "10"], + capture_output=True, text=True, + ) + assert proc.returncode != 0 + + def test_missing_lines_arg_exits_nonzero(self): + proc = subprocess.run( + [sys.executable, str(_SCRIPT), "--files", "5"], + capture_output=True, text=True, + ) + assert proc.returncode != 0 + + +# --------------------------------------------------------------------------- +# SC7 — rendered script exists in all output paths +# --------------------------------------------------------------------------- + + +class TestSc7RenderedScriptExists: + _EXPECTED_PATHS = [ + _REPO_ROOT / ".map" / "scripts" / "classify_scope.py", + _REPO_ROOT / "src" / "mapify_cli" / "templates" / "map" / "scripts" / "classify_scope.py", + ] + + @pytest.mark.parametrize("script_path", _EXPECTED_PATHS) + def test_script_exists(self, script_path: Path): + assert script_path.exists(), f"rendered script missing: {script_path}" + + def test_rendered_script_is_executable_python(self): + proc = subprocess.run( + [sys.executable, str(_SCRIPT), "--help"], + capture_output=True, text=True, + ) + assert proc.returncode == 0 + assert "--files" in proc.stdout or "--files" in proc.stderr diff --git a/tests/test_preset_commands.py b/tests/test_preset_commands.py new file mode 100644 index 00000000..7cb7c4e2 --- /dev/null +++ b/tests/test_preset_commands.py @@ -0,0 +1,332 @@ +"""Tests for 'mapify preset' commands (#291 — Slice 1). + +Covers: + PC1 — preset list: empty project, no presets installed + PC2 — preset list: one or more valid presets + PC3 — preset list: preset with missing manifest (graceful degradation) + PC4 — preset list: --json flag + PC5 — preset add --from: valid preset installed correctly + PC6 — preset add --from: invalid source (not a dir, missing manifest, bad id) + PC7 — preset add --from: --force overwrites existing preset + PC8 — preset add --from: refused without --force when preset already exists +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from typer.testing import CliRunner + +from mapify_cli import app + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_preset_dir(tmp_path: Path, name: str, manifest: dict) -> Path: + """Create a preset source directory with a manifest.json.""" + src = tmp_path / f"_src_{name}" + src.mkdir() + (src / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + return src + + +def _installed_preset(project: Path, preset_id: str) -> Path: + return project / ".map" / "presets" / preset_id + + +# --------------------------------------------------------------------------- +# PC1 — preset list: empty project +# --------------------------------------------------------------------------- + + +class TestPc1PresetListEmpty: + def test_no_presets_dir_exits_zero(self, tmp_path: Path): + result = runner.invoke(app, ["preset", "list", str(tmp_path)]) + assert result.exit_code == 0 + + def test_no_presets_dir_prints_guidance(self, tmp_path: Path): + result = runner.invoke(app, ["preset", "list", str(tmp_path)]) + assert "No presets installed" in result.output + + def test_empty_presets_dir_exits_zero(self, tmp_path: Path): + (tmp_path / ".map" / "presets").mkdir(parents=True) + result = runner.invoke(app, ["preset", "list", str(tmp_path)]) + assert result.exit_code == 0 + + def test_empty_presets_dir_prints_guidance(self, tmp_path: Path): + (tmp_path / ".map" / "presets").mkdir(parents=True) + result = runner.invoke(app, ["preset", "list", str(tmp_path)]) + assert "No presets installed" in result.output + + +# --------------------------------------------------------------------------- +# PC2 — preset list: installed presets appear in output +# --------------------------------------------------------------------------- + + +class TestPc2PresetListWithPresets: + def _install(self, project: Path, name: str, manifest: dict) -> None: + dest = project / ".map" / "presets" / name + dest.mkdir(parents=True) + (dest / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + + def test_single_preset_shown(self, tmp_path: Path): + self._install(tmp_path, "lean", {"id": "lean", "title": "Lean Workflow", "version": "1.0.0"}) + result = runner.invoke(app, ["preset", "list", str(tmp_path)]) + assert result.exit_code == 0 + assert "lean" in result.output + assert "Lean Workflow" in result.output + assert "1.0.0" in result.output + + def test_multiple_presets_all_shown(self, tmp_path: Path): + for pid in ("lean", "enterprise", "security"): + self._install(tmp_path, pid, {"id": pid, "title": pid.title(), "version": "0.1.0"}) + result = runner.invoke(app, ["preset", "list", str(tmp_path)]) + assert result.exit_code == 0 + for pid in ("lean", "enterprise", "security"): + assert pid in result.output + + def test_description_shown_when_present(self, tmp_path: Path): + self._install(tmp_path, "lean", { + "id": "lean", "title": "Lean", "version": "1.0.0", + "description": "Simplified workflow for small teams", + }) + result = runner.invoke(app, ["preset", "list", str(tmp_path)]) + assert "Simplified workflow" in result.output + + +# --------------------------------------------------------------------------- +# PC3 — preset list: directory without manifest (graceful degradation) +# --------------------------------------------------------------------------- + + +class TestPc3PresetListMissingManifest: + def test_dir_without_manifest_still_listed(self, tmp_path: Path): + (tmp_path / ".map" / "presets" / "orphan").mkdir(parents=True) + result = runner.invoke(app, ["preset", "list", str(tmp_path)]) + assert result.exit_code == 0 + assert "orphan" in result.output + + def test_dir_with_invalid_manifest_still_listed(self, tmp_path: Path): + dest = tmp_path / ".map" / "presets" / "broken" + dest.mkdir(parents=True) + (dest / "manifest.json").write_text("not json {", encoding="utf-8") + result = runner.invoke(app, ["preset", "list", str(tmp_path)]) + assert result.exit_code == 0 + assert "broken" in result.output + + +# --------------------------------------------------------------------------- +# PC4 — preset list --json +# --------------------------------------------------------------------------- + + +class TestPc4PresetListJson: + def test_empty_returns_valid_json(self, tmp_path: Path): + result = runner.invoke(app, ["preset", "list", str(tmp_path), "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data == {"presets": []} + + def test_installed_preset_appears_in_json(self, tmp_path: Path): + dest = tmp_path / ".map" / "presets" / "lean" + dest.mkdir(parents=True) + (dest / "manifest.json").write_text( + json.dumps({"id": "lean", "title": "Lean", "version": "1.2.3"}), encoding="utf-8" + ) + result = runner.invoke(app, ["preset", "list", str(tmp_path), "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert len(data["presets"]) == 1 + assert data["presets"][0]["id"] == "lean" + assert data["presets"][0]["version"] == "1.2.3" + + def test_json_output_has_required_keys(self, tmp_path: Path): + dest = tmp_path / ".map" / "presets" / "enterprise" + dest.mkdir(parents=True) + (dest / "manifest.json").write_text( + json.dumps({"id": "enterprise", "title": "Enterprise", "version": "2.0.0", + "description": "Full gates"}), encoding="utf-8" + ) + result = runner.invoke(app, ["preset", "list", str(tmp_path), "--json"]) + data = json.loads(result.output) + preset = data["presets"][0] + for key in ("id", "title", "version", "description"): + assert key in preset, f"missing key: {key}" + + +# --------------------------------------------------------------------------- +# PC5 — preset add --from: valid install +# --------------------------------------------------------------------------- + + +class TestPc5PresetAddValid: + def test_installs_to_correct_path(self, tmp_path: Path): + project = tmp_path / "project" + project.mkdir() + src = _make_preset_dir(tmp_path, "lean", {"id": "lean", "title": "Lean", "version": "1.0.0"}) + result = runner.invoke(app, ["preset", "add", "--from", str(src), str(project)]) + assert result.exit_code == 0 + assert _installed_preset(project, "lean").is_dir() + + def test_manifest_copied_to_dest(self, tmp_path: Path): + project = tmp_path / "project" + project.mkdir() + src = _make_preset_dir(tmp_path, "sec", {"id": "sec", "title": "Security", "version": "0.5.0"}) + runner.invoke(app, ["preset", "add", "--from", str(src), str(project)]) + manifest_path = _installed_preset(project, "sec") / "manifest.json" + assert manifest_path.is_file() + data = json.loads(manifest_path.read_text()) + assert data["id"] == "sec" + + def test_extra_files_copied(self, tmp_path: Path): + project = tmp_path / "project" + project.mkdir() + src = _make_preset_dir(tmp_path, "lean", {"id": "lean", "title": "Lean", "version": "1.0.0"}) + (src / "templates").mkdir() + (src / "templates" / "custom.md").write_text("# Custom", encoding="utf-8") + runner.invoke(app, ["preset", "add", "--from", str(src), str(project)]) + assert (_installed_preset(project, "lean") / "templates" / "custom.md").is_file() + + def test_success_message_contains_preset_id(self, tmp_path: Path): + project = tmp_path / "project" + project.mkdir() + src = _make_preset_dir(tmp_path, "lean", {"id": "lean", "title": "Lean", "version": "1.0.0"}) + result = runner.invoke(app, ["preset", "add", "--from", str(src), str(project)]) + assert "lean" in result.output + + def test_creates_presets_dir_if_absent(self, tmp_path: Path): + project = tmp_path / "project" + project.mkdir() + src = _make_preset_dir(tmp_path, "lean", {"id": "lean", "title": "Lean", "version": "1.0.0"}) + presets_root = project / ".map" / "presets" + assert not presets_root.exists() + runner.invoke(app, ["preset", "add", "--from", str(src), str(project)]) + assert presets_root.is_dir() + + +# --------------------------------------------------------------------------- +# PC6 — preset add --from: invalid inputs +# --------------------------------------------------------------------------- + + +class TestPc6PresetAddInvalid: + def test_nonexistent_source_exits_nonzero(self, tmp_path: Path): + result = runner.invoke(app, ["preset", "add", "--from", "/no/such/path"]) + assert result.exit_code != 0 + + def test_file_not_dir_source_exits_nonzero(self, tmp_path: Path): + f = tmp_path / "file.txt" + f.write_text("hi") + result = runner.invoke(app, ["preset", "add", "--from", str(f)]) + assert result.exit_code != 0 + + def test_missing_manifest_exits_nonzero(self, tmp_path: Path): + src = tmp_path / "no_manifest" + src.mkdir() + result = runner.invoke(app, ["preset", "add", "--from", str(src)]) + assert result.exit_code != 0 + + def test_invalid_json_manifest_exits_nonzero(self, tmp_path: Path): + src = tmp_path / "bad_manifest" + src.mkdir() + (src / "manifest.json").write_text("not json", encoding="utf-8") + result = runner.invoke(app, ["preset", "add", "--from", str(src)]) + assert result.exit_code != 0 + + def test_missing_required_key_exits_nonzero(self, tmp_path: Path): + src = _make_preset_dir(tmp_path, "x", {"id": "x", "title": "X"}) + result = runner.invoke(app, ["preset", "add", "--from", str(src)]) + assert result.exit_code != 0 + + def test_invalid_preset_id_with_slash_exits_nonzero(self, tmp_path: Path): + src = tmp_path / "bad_id" + src.mkdir() + (src / "manifest.json").write_text( + json.dumps({"id": "a/b", "title": "Bad", "version": "1.0"}), encoding="utf-8" + ) + result = runner.invoke(app, ["preset", "add", "--from", str(src)]) + assert result.exit_code != 0 + + def test_invalid_preset_id_dotdot_exits_nonzero(self, tmp_path: Path): + src = tmp_path / "dotdot" + src.mkdir() + (src / "manifest.json").write_text( + json.dumps({"id": "..", "title": "Bad", "version": "1.0"}), encoding="utf-8" + ) + result = runner.invoke(app, ["preset", "add", "--from", str(src)]) + assert result.exit_code != 0 + + +# --------------------------------------------------------------------------- +# PC7 — preset add --force: overwrites existing preset +# --------------------------------------------------------------------------- + + +class TestPc7PresetAddForce: + def test_force_overwrites_existing(self, tmp_path: Path): + project = tmp_path / "project" + project.mkdir() + + src_v1 = _make_preset_dir(tmp_path, "lean_v1", {"id": "lean", "title": "Lean", "version": "1.0.0"}) + runner.invoke(app, ["preset", "add", "--from", str(src_v1), str(project)]) + assert (_installed_preset(project, "lean") / "manifest.json").is_file() + + src_v2 = _make_preset_dir(tmp_path, "lean_v2", {"id": "lean", "title": "Lean", "version": "2.0.0"}) + result = runner.invoke(app, ["preset", "add", "--from", str(src_v2), str(project), "--force"]) + assert result.exit_code == 0 + + data = json.loads((_installed_preset(project, "lean") / "manifest.json").read_text()) + assert data["version"] == "2.0.0" + + def test_force_with_nonexistent_preset_still_works(self, tmp_path: Path): + project = tmp_path / "project" + project.mkdir() + src = _make_preset_dir(tmp_path, "lean", {"id": "lean", "title": "Lean", "version": "1.0.0"}) + result = runner.invoke(app, ["preset", "add", "--from", str(src), str(project), "--force"]) + assert result.exit_code == 0 + assert _installed_preset(project, "lean").is_dir() + + +# --------------------------------------------------------------------------- +# PC8 — preset add: conflict without --force +# --------------------------------------------------------------------------- + + +class TestPc8PresetAddConflict: + def test_duplicate_without_force_exits_nonzero(self, tmp_path: Path): + project = tmp_path / "project" + project.mkdir() + src = _make_preset_dir(tmp_path, "lean", {"id": "lean", "title": "Lean", "version": "1.0.0"}) + runner.invoke(app, ["preset", "add", "--from", str(src), str(project)]) + result = runner.invoke(app, ["preset", "add", "--from", str(src), str(project)]) + assert result.exit_code != 0 + + def test_duplicate_without_force_suggests_force(self, tmp_path: Path): + project = tmp_path / "project" + project.mkdir() + src = _make_preset_dir(tmp_path, "lean", {"id": "lean", "title": "Lean", "version": "1.0.0"}) + runner.invoke(app, ["preset", "add", "--from", str(src), str(project)]) + result = runner.invoke(app, ["preset", "add", "--from", str(src), str(project)]) + assert "--force" in result.output or "force" in result.output.lower() + + def test_duplicate_does_not_corrupt_existing_preset(self, tmp_path: Path): + project = tmp_path / "project" + project.mkdir() + src = _make_preset_dir(tmp_path, "lean", {"id": "lean", "title": "Lean", "version": "1.0.0"}) + runner.invoke(app, ["preset", "add", "--from", str(src), str(project)]) + + src2 = tmp_path / "_src2" + src2.mkdir() + (src2 / "manifest.json").write_text( + json.dumps({"id": "lean", "title": "Lean", "version": "2.0.0"}), encoding="utf-8" + ) + runner.invoke(app, ["preset", "add", "--from", str(src2), str(project)]) + data = json.loads((_installed_preset(project, "lean") / "manifest.json").read_text()) + assert data["version"] == "1.0.0"