Skip to content

Commit 11d33aa

Browse files
committed
Fail closed on resolver input errors
Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c
1 parent 9151bfe commit 11d33aa

4 files changed

Lines changed: 82 additions & 17 deletions

File tree

scripts/bash/common.sh

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,10 @@ resolve_template_content() {
598598
# Priority 1: Project overrides (always "replace")
599599
local override="$base/overrides/${template_name}.md"
600600
if [ -f "$override" ]; then
601-
cat "$override"
601+
if ! cat "$override"; then
602+
echo "Error: failed to read template layer $override" >&2
603+
return 2
604+
fi
602605
return 0
603606
fi
604607

@@ -685,13 +688,16 @@ try:
685688
for t in templates:
686689
if not isinstance(t, dict):
687690
raise ValueError('manifest template entries must be mappings')
691+
file_value = t.get('file', '')
692+
strategy = t.get('strategy', 'replace')
693+
if not isinstance(file_value, str):
694+
raise ValueError('manifest template file must be a string')
695+
if not isinstance(strategy, str):
696+
raise ValueError('manifest template strategy must be a string')
697+
for t in templates:
688698
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
689699
file_value = t.get('file', '')
690700
strategy = t.get('strategy', 'replace')
691-
if not isinstance(file_value, str):
692-
raise ValueError('manifest template file must be a string')
693-
if not isinstance(strategy, str):
694-
raise ValueError('manifest template strategy must be a string')
695701
print('found\t' + strategy + '\t' + file_value)
696702
sys.exit(0)
697703
print('absent\treplace\t')
@@ -791,12 +797,18 @@ except Exception as exc:
791797
# If the top (highest-priority) layer is replace, it wins entirely —
792798
# lower layers are irrelevant regardless of their strategies.
793799
if [ "${layer_strategies[0]}" = "replace" ]; then
794-
cat "${layer_paths[0]}"
800+
if ! cat "${layer_paths[0]}"; then
801+
echo "Error: failed to read template layer ${layer_paths[0]}" >&2
802+
return 2
803+
fi
795804
return 0
796805
fi
797806

798807
if [ "$has_composition" = false ]; then
799-
cat "${layer_paths[0]}"
808+
if ! cat "${layer_paths[0]}"; then
809+
echo "Error: failed to read template layer ${layer_paths[0]}" >&2
810+
return 2
811+
fi
800812
return 0
801813
fi
802814

@@ -818,15 +830,21 @@ except Exception as exc:
818830

819831
# Read the base content; compose layers above the base (higher priority)
820832
local content
821-
content=$(cat "${layer_paths[$base_idx]}"; printf x)
833+
if ! content=$(cat "${layer_paths[$base_idx]}"; status=$?; printf x; exit "$status"); then
834+
echo "Error: failed to read template layer ${layer_paths[$base_idx]}" >&2
835+
return 2
836+
fi
822837
content="${content%x}"
823838

824839
for (( i=base_idx-1; i>=0; i-- )); do
825840
local path="${layer_paths[$i]}"
826841
local strat="${layer_strategies[$i]}"
827842
local layer_content
828843
# Preserve trailing newlines
829-
layer_content=$(cat "$path"; printf x)
844+
if ! layer_content=$(cat "$path"; status=$?; printf x; exit "$status"); then
845+
echo "Error: failed to read template layer $path" >&2
846+
return 2
847+
fi
830848
layer_content="${layer_content%x}"
831849

832850
case "$strat" in

scripts/powershell/common.ps1

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -611,13 +611,16 @@ try:
611611
for t in templates:
612612
if not isinstance(t, dict):
613613
raise ValueError('manifest template entries must be mappings')
614+
file_value = t.get('file', '')
615+
strategy = t.get('strategy', 'replace')
616+
if not isinstance(file_value, str):
617+
raise ValueError('manifest template file must be a string')
618+
if not isinstance(strategy, str):
619+
raise ValueError('manifest template strategy must be a string')
620+
for t in templates:
614621
if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
615622
file_value = t.get('file', '')
616623
strategy = t.get('strategy', 'replace')
617-
if not isinstance(file_value, str):
618-
raise ValueError('manifest template file must be a string')
619-
if not isinstance(strategy, str):
620-
raise ValueError('manifest template strategy must be a string')
621624
print('found\t' + strategy + '\t' + file_value)
622625
sys.exit(0)
623626
print('absent\treplace\t')

scripts/python/common.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -365,17 +365,20 @@ def _preset_template_layer(
365365
for entry in templates:
366366
if not isinstance(entry, dict):
367367
raise ValueError("manifest template entries must be mappings")
368+
file_value = entry.get("file", "")
369+
strategy = entry.get("strategy", "replace")
370+
if not isinstance(file_value, str):
371+
raise ValueError("manifest template file must be a string")
372+
if not isinstance(strategy, str):
373+
raise ValueError("manifest template strategy must be a string")
374+
for entry in templates:
368375
if (
369376
entry.get("name") != template_name
370377
or entry.get("type", "template") != "template"
371378
):
372379
continue
373380
file_value = entry.get("file", "")
374381
strategy = entry.get("strategy", "replace")
375-
if not isinstance(file_value, str):
376-
raise ValueError("manifest template file must be a string")
377-
if not isinstance(strategy, str):
378-
raise ValueError("manifest template strategy must be a string")
379382
relative = Path(file_value)
380383
if (
381384
not relative

tests/test_resolve_template_python_parity.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
import os
67
from pathlib import Path
78

89
import pytest
@@ -470,6 +471,34 @@ def test_all_variants_fail_when_yaml_parser_is_unavailable(
470471
assert all(result.stdout == "" for result in results)
471472

472473

474+
@requires_bash
475+
def test_bash_fails_when_override_read_fails(tmp_path: Path) -> None:
476+
repo = make_repo(tmp_path)
477+
install_scripts(repo, SCRIPT)
478+
override = repo / ".specify" / "templates" / "overrides"
479+
override.mkdir(parents=True)
480+
(override / f"{TEMPLATE}.md").write_text("# Override\n", encoding="utf-8")
481+
shim_dir = tmp_path / "bin"
482+
shim_dir.mkdir()
483+
cat_shim = shim_dir / "cat"
484+
cat_shim.write_text(
485+
"#!/bin/sh\n"
486+
"case \"$1\" in\n"
487+
" */.specify/templates/overrides/*) exit 1 ;;\n"
488+
"esac\n"
489+
"exec /bin/cat \"$@\"\n",
490+
encoding="utf-8",
491+
)
492+
cat_shim.chmod(0o755)
493+
env = clean_env()
494+
env["PATH"] = f"{shim_dir}{os.pathsep}{env.get('PATH', '')}"
495+
496+
result = run(bash_cmd(repo, SCRIPT, TEMPLATE, "--json"), repo, env)
497+
498+
assert result.returncode != 0
499+
assert result.stdout == ""
500+
501+
473502
@requires_bash
474503
@pytest.mark.parametrize(
475504
"manifest_content",
@@ -491,6 +520,17 @@ def test_all_variants_fail_when_yaml_parser_is_unavailable(
491520
name: {TEMPLATE}
492521
file: templates/{TEMPLATE}.md
493522
strategy: 123
523+
""",
524+
f"""provides:
525+
templates:
526+
- type: template
527+
name: {TEMPLATE}
528+
file: templates/{TEMPLATE}.md
529+
strategy: wrap
530+
- type: template
531+
name: unrelated-template
532+
file: null
533+
strategy: append
494534
""",
495535
],
496536
ids=[
@@ -500,6 +540,7 @@ def test_all_variants_fail_when_yaml_parser_is_unavailable(
500540
"non_list_templates",
501541
"non_string_file",
502542
"non_string_strategy",
543+
"malformed_entry_after_match",
503544
],
504545
)
505546
def test_all_variants_fail_for_malformed_preset_manifest(

0 commit comments

Comments
 (0)