196 lines
7.1 KiB
Python
196 lines
7.1 KiB
Python
# #region MergeSpec [C:2] [TYPE Module]
|
|
# @LAYER Infra
|
|
|
|
# #region merge_spec [C:2] [TYPE Module]
|
|
# @LAYER Infra
|
|
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
REVIEW_PROMPT = (
|
|
"Another LLM created this feature package. Your task: conduct an independent "
|
|
"orthogonal spec review. Evaluate readiness, find contradictions, gaps, "
|
|
"implementation risks, and prepare a structured report with corrections. "
|
|
"Focus on spec review, not rewriting the implementation."
|
|
)
|
|
|
|
# Canonical artifact order for the merged output.
|
|
# Each stage: (match_type, value, section_label)
|
|
# match_type "exact" = exact filename match
|
|
# match_type "prefix" = directory prefix — matches all files under that dir
|
|
CANONICAL_MD_STAGES = (
|
|
# Layer 1: Requirements & UX narrative
|
|
("exact", "spec.md", "SPEC — Feature Specification"),
|
|
("exact", "ux_reference.md", "UX REFERENCE — Interaction Narrative"),
|
|
("prefix", "checklists/", "CHECKLISTS — Requirements Quality"),
|
|
|
|
# Layer 2: UX Design (from /speckit.ux)
|
|
("exact", "contracts/ux/alternatives.md", "UX ALTERNATIVES — Design Space Explored"),
|
|
("exact", "contracts/ux/decisions.md", "UX DECISIONS — Final Choices"),
|
|
("exact", "contracts/ux/screen-models.md", "UX SCREEN MODELS — Model Inventory"),
|
|
("exact", "contracts/ux/api-ux.md", "UX API CONTRACT — Endpoints & Shapes"),
|
|
("prefix", "contracts/ux/", "UX DESIGN — Per-Screen Contracts"),
|
|
("exact", "contracts/ux/design-tokens.md", None), # force-first within prefix
|
|
|
|
# Layer 3: Implementation Plan
|
|
("exact", "plan.md", "PLAN — Implementation Plan"),
|
|
("exact", "research.md", "RESEARCH — Technical Decisions"),
|
|
("exact", "data-model.md", "DATA MODEL — Entities & Relations"),
|
|
("prefix", "contracts/", "CONTRACTS — Module & Function Contracts"),
|
|
("exact", "quickstart.md", "QUICKSTART — Dev Onboarding"),
|
|
|
|
# Layer 4: Traceability
|
|
("exact", "traceability.md", "TRACEABILITY — Requirements Matrix"),
|
|
|
|
# Layer 5: Execution
|
|
("exact", "tasks.md", "TASKS — Implementation Tasks"),
|
|
)
|
|
|
|
ORDER_HINT = (
|
|
"Artifact order: spec → ux_reference → checklists → "
|
|
"UX (alternatives → decisions → screen-models → api-ux → per-screen → design-tokens) → "
|
|
"plan → research → data-model → contracts → quickstart → traceability → tasks → remaining."
|
|
)
|
|
|
|
|
|
def relative_key(path: Path, root: Path) -> str:
|
|
return path.relative_to(root).as_posix()
|
|
|
|
|
|
def ordered_markdown_files(target_dir: Path) -> list[Path]:
|
|
"""Order markdown files according to CANONICAL_MD_STAGES."""
|
|
markdown_files = [path for path in target_dir.rglob("*.md") if path.is_file()]
|
|
remaining = {relative_key(path, target_dir): path for path in markdown_files}
|
|
|
|
ordered: list[Path] = []
|
|
# Track which stages have already been matched to avoid duplicates
|
|
seen = set()
|
|
|
|
for stage_type, stage_value, _section_label in CANONICAL_MD_STAGES:
|
|
if stage_type == "exact":
|
|
# Exact: only match if the file exists and hasn't been collected yet
|
|
rel = f"contracts/ux/{stage_value}" if stage_value.startswith("contracts/ux/") else stage_value
|
|
if stage_value.startswith("contracts/ux/"):
|
|
path = remaining.pop(stage_value, None)
|
|
else:
|
|
path = remaining.pop(stage_value, None)
|
|
if path is not None and path not in seen:
|
|
ordered.append(path)
|
|
seen.add(path)
|
|
continue
|
|
|
|
# Prefix: match all files whose relative path starts with stage_value
|
|
stage_matches = sorted(
|
|
[
|
|
path
|
|
for rel_path, path in remaining.items()
|
|
if rel_path.startswith(stage_value)
|
|
],
|
|
key=lambda p: relative_key(p, target_dir),
|
|
)
|
|
for path in stage_matches:
|
|
if path not in seen:
|
|
ordered.append(path)
|
|
seen.add(path)
|
|
rel = relative_key(path, target_dir)
|
|
remaining.pop(rel, None)
|
|
|
|
# Remaining files — append at end
|
|
remaining_paths = sorted(remaining.values(), key=lambda p: relative_key(p, target_dir))
|
|
for path in remaining_paths:
|
|
if path not in seen:
|
|
ordered.append(path)
|
|
|
|
return ordered
|
|
|
|
|
|
def merge_specs(feature_number: str) -> str | None:
|
|
"""Merge all spec artifacts for a feature into one review file."""
|
|
specs_dir = Path("specs")
|
|
if not specs_dir.exists():
|
|
print("Error: 'specs' directory not found.")
|
|
return None
|
|
|
|
# Find directory starting with feature number
|
|
target_dir = None
|
|
for item in specs_dir.iterdir():
|
|
if item.is_dir() and item.name.startswith(f"{feature_number}-"):
|
|
target_dir = item
|
|
break
|
|
|
|
if not target_dir:
|
|
print(f"Error: No directory found for feature number '{feature_number}' in 'specs/'.")
|
|
return None
|
|
|
|
feature_name = target_dir.name
|
|
now = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
output_filename = f"{feature_name}-{now}.md"
|
|
|
|
content_blocks = [
|
|
REVIEW_PROMPT,
|
|
"",
|
|
"=" * 80,
|
|
ORDER_HINT,
|
|
f"Feature: {feature_name}",
|
|
f"Generated: {datetime.now().isoformat()}",
|
|
"=" * 80,
|
|
"",
|
|
]
|
|
|
|
files_to_merge = ordered_markdown_files(target_dir)
|
|
|
|
for file_path in files_to_merge:
|
|
relative_path = file_path.relative_to(target_dir)
|
|
try:
|
|
content = file_path.read_text(encoding="utf-8")
|
|
except Exception as e:
|
|
print(f"Skipping {file_path}: {e}")
|
|
continue
|
|
|
|
# Determine section label
|
|
section_label = str(relative_path)
|
|
for stage_type, stage_value, label in CANONICAL_MD_STAGES:
|
|
if label is None:
|
|
continue
|
|
if stage_type == "exact" and str(relative_path) == stage_value:
|
|
section_label = label
|
|
break
|
|
if stage_type == "prefix" and str(relative_path).startswith(stage_value):
|
|
section_label = f"{label} — {relative_path.name}"
|
|
break
|
|
|
|
content_blocks.append("")
|
|
content_blocks.append("-" * 60)
|
|
content_blocks.append(f"## {section_label}")
|
|
content_blocks.append(f"Source: {relative_path}")
|
|
content_blocks.append("-" * 60)
|
|
content_blocks.append("")
|
|
content_blocks.append(content)
|
|
content_blocks.append("")
|
|
|
|
# Summary
|
|
content_blocks.append("")
|
|
content_blocks.append("=" * 80)
|
|
content_blocks.append(f"Files merged: {len(files_to_merge)}")
|
|
content_blocks.append("=" * 80)
|
|
|
|
merged = "\n".join(content_blocks)
|
|
output_filename = output_filename
|
|
Path(output_filename).write_text(merged, encoding="utf-8")
|
|
|
|
print(f"Successfully created: {output_filename} ({len(files_to_merge)} files)")
|
|
return output_filename
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
print("Usage: python merge_spec.py <feature_number>")
|
|
sys.exit(1)
|
|
|
|
merge_specs(sys.argv[1])
|
|
|
|
# #endregion merge_spec
|
|
|
|
# #endregion MergeSpec
|