Skip to content

Commit c655b0e

Browse files
committed
fix: address thirteenth round of Copilot PR review feedback
- Add frontmatter composition test: verify core+preset command layers with frontmatter produce only one frontmatter block in composed output - Defer resolve_with_source: only call when collect_all_layers returns empty, avoiding redundant filesystem walk - Reuse CommandRegistrar.parse_frontmatter: replace local _strip_frontmatter helper with _split_frontmatter that delegates to the shared parse_frontmatter method - Fix removed_cmd_names collection: gather ALL command names before filtering out skill agents so reconciliation covers all affected commands including those only registered for skill-based agents - Preserve trailing newlines in bash: use sentinel character technique (cat + printf x + strip) to avoid command substitution stripping
1 parent c11b2f4 commit c655b0e

4 files changed

Lines changed: 73 additions & 23 deletions

File tree

scripts/bash/common.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,9 @@ except Exception:
536536
local path="${layer_paths[$i]}"
537537
local strat="${layer_strategies[$i]}"
538538
local layer_content
539-
layer_content=$(cat "$path")
539+
# Preserve trailing newlines: append sentinel, then strip it
540+
layer_content=$(cat "$path"; printf x)
541+
layer_content="${layer_content%x}"
540542

541543
if [ "$started" = false ]; then
542544
if [ "$strat" = "replace" ]; then

src/specify_cli/__init__.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2597,7 +2597,6 @@ def preset_resolve(
25972597

25982598
resolver = PresetResolver(project_root)
25992599
layers = resolver.collect_all_layers(template_name)
2600-
result = resolver.resolve_with_source(template_name)
26012600

26022601
if layers:
26032602
# Use the highest-priority layer for display because the final output
@@ -2618,12 +2617,15 @@ def preset_resolve(
26182617
if strategy_label == "replace":
26192618
strategy_label = "base"
26202619
console.print(f" {i + 1}. [{strategy_label}] {layer['source']}{layer['path']}")
2621-
elif result:
2622-
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
2623-
console.print(f" [dim](from: {result['source']})[/dim]")
26242620
else:
2625-
console.print(f" [yellow]{template_name}[/yellow]: not found")
2626-
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
2621+
# No layers found — fall back to resolve_with_source for non-composition cases
2622+
result = resolver.resolve_with_source(template_name)
2623+
if result:
2624+
console.print(f" [bold]{template_name}[/bold]: {result['path']}")
2625+
console.print(f" [dim](from: {result['source']})[/dim]")
2626+
else:
2627+
console.print(f" [yellow]{template_name}[/yellow]: not found")
2628+
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
26272629

26282630

26292631
@preset_app.command("info")

src/specify_cli/presets.py

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1361,6 +1361,13 @@ def remove(self, pack_id: str) -> bool:
13611361
registered_skills = metadata.get("registered_skills", []) if metadata else []
13621362
registered_commands = metadata.get("registered_commands", {}) if metadata else {}
13631363
pack_dir = self.presets_dir / pack_id
1364+
1365+
# Collect ALL command names before filtering for reconciliation,
1366+
# so commands registered only for skill-based agents are also reconciled.
1367+
removed_cmd_names = set()
1368+
for cmd_names in registered_commands.values():
1369+
removed_cmd_names.update(cmd_names)
1370+
13641371
if registered_skills:
13651372
self._unregister_skills(registered_skills, pack_dir)
13661373
try:
@@ -1375,10 +1382,6 @@ def remove(self, pack_id: str) -> bool:
13751382
}
13761383

13771384
# Unregister non-skill command files from AI agents.
1378-
# Collect all command names for post-removal reconciliation.
1379-
removed_cmd_names = set()
1380-
for cmd_names in registered_commands.values():
1381-
removed_cmd_names.update(cmd_names)
13821385
if registered_commands:
13831386
self._unregister_commands(registered_commands)
13841387

@@ -2415,19 +2418,19 @@ def resolve_content(
24152418
is_command = template_type == "command"
24162419
top_frontmatter_text = None
24172420

2418-
def _strip_frontmatter(text: str) -> tuple:
2419-
"""Return (frontmatter_text_with_fences, body) or (None, text)."""
2420-
if not text.startswith("---"):
2421-
return None, text
2422-
end = text.find("---", 3)
2423-
if end == -1:
2424-
return None, text
2425-
fm_block = text[:end + 3]
2426-
body = text[end + 3:].strip()
2427-
return fm_block, body
2421+
def _split_frontmatter(text: str) -> tuple:
2422+
"""Return (frontmatter_block_with_fences, body) or (None, text)."""
2423+
from .agents import CommandRegistrar
2424+
fm_dict, body = CommandRegistrar.parse_frontmatter(text)
2425+
if fm_dict:
2426+
# Re-extract the raw frontmatter block (text before body)
2427+
end = text.find("---", 3)
2428+
fm_block = text[:end + 3]
2429+
return fm_block, body
2430+
return None, text
24282431

24292432
if is_command:
2430-
fm, body = _strip_frontmatter(content)
2433+
fm, body = _split_frontmatter(content)
24312434
if fm:
24322435
top_frontmatter_text = fm
24332436
content = body
@@ -2438,7 +2441,7 @@ def _strip_frontmatter(text: str) -> tuple:
24382441
strategy = layer["strategy"]
24392442

24402443
if is_command:
2441-
fm, layer_body = _strip_frontmatter(layer_content)
2444+
fm, layer_body = _split_frontmatter(layer_content)
24422445
layer_content = layer_body
24432446
# Track the highest-priority frontmatter seen
24442447
if fm:

tests/test_presets.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3460,6 +3460,49 @@ def test_resolve_content_command_type(self, project_dir, temp_dir, valid_pack_da
34603460
assert "Core Plan Command" in content
34613461
assert "Additional Instructions" in content
34623462

3463+
def test_resolve_content_command_frontmatter_stripping(self, project_dir, temp_dir, valid_pack_data):
3464+
"""Test that command composition strips frontmatter from lower layers
3465+
and reattaches only the highest-priority frontmatter."""
3466+
# Create core command with frontmatter
3467+
commands_dir = project_dir / ".specify" / "templates" / "commands"
3468+
commands_dir.mkdir(parents=True, exist_ok=True)
3469+
(commands_dir / "check.md").write_text(
3470+
"---\ndescription: Core check command\n---\nCore body content\n"
3471+
)
3472+
3473+
pack_data = {**valid_pack_data}
3474+
pack_data["preset"] = {**valid_pack_data["preset"], "id": "fm-test", "name": "FmTest"}
3475+
pack_data["provides"] = {
3476+
"templates": [{
3477+
"type": "command",
3478+
"name": "speckit.check",
3479+
"file": "commands/speckit.check.md",
3480+
"strategy": "append",
3481+
}]
3482+
}
3483+
pack_dir = temp_dir / "fm-test"
3484+
pack_dir.mkdir()
3485+
with open(pack_dir / "preset.yml", 'w') as f:
3486+
yaml.dump(pack_data, f)
3487+
(pack_dir / "commands").mkdir()
3488+
(pack_dir / "commands" / "speckit.check.md").write_text(
3489+
"---\ndescription: Preset check override\n---\nPreset body content\n"
3490+
)
3491+
3492+
manager = PresetManager(project_dir)
3493+
manager.install_from_directory(pack_dir, "0.1.5")
3494+
3495+
resolver = PresetResolver(project_dir)
3496+
content = resolver.resolve_content("speckit.check", "command")
3497+
assert content is not None
3498+
# Should have the preset (highest-priority) frontmatter
3499+
assert "Preset check override" in content
3500+
# Should have both bodies
3501+
assert "Core body content" in content
3502+
assert "Preset body content" in content
3503+
# Core frontmatter should NOT appear in the body
3504+
assert content.count("---") == 2 # only one frontmatter block (opening + closing)
3505+
34633506
def test_resolve_content_blank_line_separator(self, project_dir, temp_dir, valid_pack_data):
34643507
"""Test that prepend/append use blank line separator."""
34653508
pack_data = {**valid_pack_data}

0 commit comments

Comments
 (0)