From 968c09bb10df39217e0e6b991a774a3bfc4b9ff7 Mon Sep 17 00:00:00 2001 From: mac Date: Sun, 4 Oct 2026 02:41:59 -0700 Subject: [PATCH] feat(presets): resolve and materialize script artifacts Assisted-by: Codex (model: GPT-6 family, supervised) --- docs/reference/presets.md | 14 +- src/specify_cli/presets/command_resolve.py | 72 ++++++++- .../presets/test_command_resolve.py | 147 ++++++++++++++++++ 3 files changed, 226 insertions(+), 7 deletions(-) diff --git a/docs/reference/presets.md b/docs/reference/presets.md index d51d3ace8a..e6759b2ed7 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -113,9 +113,21 @@ Shows detailed information about an installed or available preset, including its ```bash specify preset resolve +specify preset resolve --kind script +specify preset resolve --kind script --output ``` -Shows which file will be used for a given name by tracing the full resolution stack. Useful for debugging when multiple presets provide the same file. +Shows which file will be used for a given name by tracing the full resolution +stack. By default, dotted names resolve as commands and other names resolve as +templates. Use `--kind script` to inspect a script artifact, including a +manifest-declared file such as an `.mjs` module. + +For a script that uses a composition strategy, the reported layer path is not +the composed output. Pass `--output ` to write the effective script to +that location. The command creates missing parent directories and overwrites +an existing destination file. Relative module imports in the written output +resolve from the output file's directory; referenced files are not copied. +`--output` is only valid with `--kind script`. ## Enable / Disable a Preset diff --git a/src/specify_cli/presets/command_resolve.py b/src/specify_cli/presets/command_resolve.py index 7256307160..326426038e 100644 --- a/src/specify_cli/presets/command_resolve.py +++ b/src/specify_cli/presets/command_resolve.py @@ -3,6 +3,8 @@ from __future__ import annotations import re +from pathlib import Path +from typing import Literal import typer from rich.markup import escape as _escape_markup @@ -16,12 +18,26 @@ def preset_resolve( template_name: str = typer.Argument( ..., help="Template name to resolve (e.g., spec-template)" ), + kind: Literal["command", "script", "template"] | None = typer.Option( + None, + "--kind", + help="Artifact kind (defaults to command for dotted names, template otherwise)", + ), + output: Path | None = typer.Option( # noqa: B008 + None, + "--output", + help=( + "Write the effective script to this path " + "(required to materialize composition)" + ), + ), ): - """Show which template will be resolved for a given name.""" + """Show which artifact wins, or materialize an effective script.""" from .. import _require_specify_project from . import PresetResolver - is_command = "." in template_name + template_type = kind or ("command" if "." in template_name else "template") + is_command = template_type == "command" valid_name = ( re.fullmatch(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+", template_name) if is_command @@ -36,9 +52,49 @@ def preset_resolve( ) raise typer.Exit(1) + if output is not None and template_type != "script": + console.print("[red]Error:[/red] --output can only be used with --kind script") + raise typer.Exit(1) + project_root = _require_specify_project() resolver = PresetResolver(project_root) - template_type = "command" if is_command else "template" + + if output is not None: + try: + content = resolver.resolve_content(template_name, "script") + except Exception as exc: + console.print( + f"[red]Error:[/red] Could not resolve script: " + f"{_escape_markup(str(exc))}" + ) + raise typer.Exit(1) from exc + if content is None: + console.print( + f"[yellow]{_escape_markup(str(template_name))}[/yellow]: " + "not found or cannot be composed" + ) + raise typer.Exit(1) + + output_path = output.expanduser() + if not output_path.is_absolute(): + output_path = Path.cwd() / output_path + output_path = output_path.resolve() + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(content, encoding="utf-8") + except OSError as exc: + console.print( + f"[red]Error:[/red] Could not write script to " + f"{_escape_markup(str(output_path))}: {_escape_markup(str(exc))}" + ) + raise typer.Exit(1) from exc + + console.print( + f" [bold]{_escape_markup(str(template_name))}[/bold]: " + f"{_escape_markup(str(output_path))}" + ) + console.print(" [dim](effective script written to --output path)[/dim]") + return layers = resolver.collect_all_layers(template_name, template_type) safe_template_name = _escape_markup(str(template_name)) @@ -56,9 +112,7 @@ def preset_resolve( f"{_escape_markup(str(display_layer['source']))})[/dim]" ) - has_composition = layers[0]["strategy"] != "replace" and any( - layer["strategy"] != "replace" for layer in layers - ) + has_composition = any(layer["strategy"] != "replace" for layer in layers) if has_composition: # Verify composition is actually possible try: @@ -77,6 +131,12 @@ def preset_resolve( console.print( " [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]" ) + if template_type == "script": + console.print( + " [dim]Use --output PATH to write the effective composed " + "script. Relative imports in the output resolve from that " + "file's directory.[/dim]" + ) console.print("\n [bold]Composition chain:[/bold]") # Compute the effective base: first replace layer scanning from # highest priority (matching resolve_content top-down logic). diff --git a/tests/specify_cli/presets/test_command_resolve.py b/tests/specify_cli/presets/test_command_resolve.py index 42e55d572a..2693245110 100644 --- a/tests/specify_cli/presets/test_command_resolve.py +++ b/tests/specify_cli/presets/test_command_resolve.py @@ -70,6 +70,48 @@ def _install( manager.install_from_directory(src, "9.9.9", priority) return manager + def _install_script( + self, + temp_dir, + project_dir, + content, + *, + strategy="replace", + pack_id="script-pack", + priority=10, + ): + """Install a manifest-declared JavaScript script artifact.""" + src = temp_dir / f"src-{pack_id}" + (src / "adapters").mkdir(parents=True) + (src / "adapters" / "page-adapter.mjs").write_text(content) + (src / "preset.yml").write_text( + yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": pack_id, + "name": pack_id, + "version": "1.0.0", + "description": "JavaScript script fixture", + }, + "requires": {"speckit_version": ">=0.0.1"}, + "provides": { + "templates": [ + { + "type": "script", + "name": "designer-page-adapter", + "file": "adapters/page-adapter.mjs", + "strategy": strategy, + } + ] + }, + } + ) + ) + manager = PresetManager(project_dir) + manager.install_from_directory(src, "9.9.9", priority) + return manager + def _invoke(self, project_dir, args): from unittest.mock import patch @@ -106,6 +148,111 @@ def test_resolve_accepts_dotted_command_name(self, project_dir): assert result.exit_code == 0, (result.output, result.exception) assert "constitution.md" in "".join(strip_ansi(result.output).split()) + def test_resolve_script_kind_finds_manifest_declared_module( + self, temp_dir, project_dir + ): + self._install_script(temp_dir, project_dir, "export default 'adapter';\n") + + default_result = self._invoke( + project_dir, ["preset", "resolve", "designer-page-adapter"] + ) + assert default_result.exit_code == 0 + assert "not found" in strip_ansi(default_result.output) + + result = self._invoke( + project_dir, + ["preset", "resolve", "designer-page-adapter", "--kind", "script"], + ) + + assert result.exit_code == 0, (result.output, result.exception) + output = "".join(strip_ansi(result.output).split()) + expected_path = ( + project_dir / ".specify/presets/script-pack/adapters/page-adapter.mjs" + ) + assert str(expected_path) in output + + def test_resolve_materializes_composed_script_at_requested_path( + self, temp_dir, project_dir + ): + self._install_script( + temp_dir, + project_dir, + "import './helper.mjs';\n$CORE_SCRIPT\n", + strategy="wrap", + pack_id="wrapper-pack", + priority=5, + ) + self._install_script( + temp_dir, + project_dir, + "export default 'base';\n", + pack_id="base-pack", + priority=10, + ) + output_path = project_dir / "generated" / "adapter.mjs" + + result = self._invoke( + project_dir, + [ + "preset", + "resolve", + "designer-page-adapter", + "--kind", + "script", + "--output", + str(output_path), + ], + ) + + assert result.exit_code == 0, (result.output, result.exception) + assert output_path.read_text() == ( + "import './helper.mjs';\nexport default 'base';\n\n" + ) + assert str(output_path.resolve()) in "".join(strip_ansi(result.output).split()) + + def test_resolve_script_composition_without_output_explains_materialization( + self, temp_dir, project_dir + ): + self._install_script( + temp_dir, + project_dir, + "$CORE_SCRIPT\n", + strategy="wrap", + pack_id="wrapper-pack", + priority=5, + ) + self._install_script( + temp_dir, + project_dir, + "export default 'base';\n", + pack_id="base-pack", + priority=10, + ) + + result = self._invoke( + project_dir, + ["preset", "resolve", "designer-page-adapter", "--kind", "script"], + ) + + assert result.exit_code == 0, (result.output, result.exception) + output = strip_ansi(result.output) + assert "Use--outputPATH" in "".join(output.split()) + compact_output = "".join(output.split()) + assert ( + "Relativeimportsintheoutputresolvefromthatfile'sdirectory" in compact_output + ) + + def test_resolve_output_requires_script_kind(self, project_dir): + result = self._invoke( + project_dir, + ["preset", "resolve", "spec-template", "--output", "out.mjs"], + ) + + assert result.exit_code == 1 + assert "--output can only be used with --kind script" in strip_ansi( + result.output + ) + def test_resolve_rejects_empty_command_segments(self, project_dir): """Dotted command identifiers cannot contain empty path-like segments.""" result = self._invoke(