Skip to content

Commit 5b7ad9c

Browse files
tbitcsoz-agent
andcommitted
feat: specsmith architect command (#49), audit --fix generates missing docs
New 'specsmith architect' command: - Scans project for modules, languages, deps, git history, existing docs - Interactive interview: components, purposes, interfaces, data flow, deployment - Generates rich docs/architecture.md with all detected + user-provided info - References existing architecture docs in non-standard locations - --non-interactive flag for auto-generation without prompts Enhanced audit --fix: - Missing docs/architecture.md: auto-generates from project scan - Missing docs/REQUIREMENTS.md: creates stub with REQ-CORE-001 - Missing docs/TEST_SPEC.md: creates stub - All recommended files now marked as fixable in audit output Also fixes: audit now finds architecture docs in subdirectories (e.g. docs/architecture/DESIGN.md) Closes #49 Co-Authored-By: Oz <oz-agent@warp.dev>
1 parent 1b1e7ae commit 5b7ad9c

3 files changed

Lines changed: 255 additions & 0 deletions

File tree

src/specsmith/architect.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
3+
"""Architect — scan project and generate architecture documentation."""
4+
5+
from __future__ import annotations
6+
7+
from pathlib import Path
8+
9+
10+
def scan_project_structure(root: Path) -> dict[str, object]: # noqa: C901
11+
"""Scan a project and extract architecture-relevant information.
12+
13+
Returns a dict with modules, entry_points, languages, dependencies,
14+
git_summary, and existing_docs.
15+
"""
16+
from specsmith.importer import (
17+
_extract_git_commits,
18+
_extract_git_contributors,
19+
_extract_readme_summary,
20+
_parse_dependencies,
21+
detect_project,
22+
)
23+
24+
result = detect_project(root)
25+
commits = _extract_git_commits(root)
26+
contributors = _extract_git_contributors(root)
27+
readme = _extract_readme_summary(root)
28+
deps = _parse_dependencies(root)
29+
30+
# Find existing architecture docs
31+
existing_arch: list[str] = []
32+
docs_dir = root / "docs"
33+
if docs_dir.is_dir():
34+
for p in docs_dir.rglob("*"):
35+
if p.is_file() and "architecture" in p.name.lower():
36+
existing_arch.append(str(p.relative_to(root)))
37+
38+
return {
39+
"name": root.name,
40+
"languages": result.languages,
41+
"primary_language": result.primary_language,
42+
"secondary_languages": result.secondary_languages,
43+
"build_system": result.build_system,
44+
"test_framework": result.test_framework,
45+
"modules": result.modules,
46+
"entry_points": result.entry_points,
47+
"dependencies": deps,
48+
"readme_summary": readme,
49+
"recent_commits": commits[:10],
50+
"contributors": contributors,
51+
"existing_arch_docs": existing_arch,
52+
"file_count": result.file_count,
53+
"inferred_type": result.inferred_type.value if result.inferred_type else "unknown",
54+
}
55+
56+
57+
def generate_architecture(
58+
root: Path,
59+
*,
60+
components: list[dict[str, str]] | None = None,
61+
data_flow: str = "",
62+
deployment: str = "",
63+
scan: dict[str, object] | None = None,
64+
) -> Path:
65+
"""Generate docs/architecture.md from scan data + user input.
66+
67+
Returns the path to the generated file.
68+
"""
69+
if scan is None:
70+
scan = scan_project_structure(root)
71+
72+
name = str(scan.get("name", root.name))
73+
langs: dict[str, int] = dict(scan.get("languages", {}) or {}) # type: ignore[call-overload]
74+
primary = str(scan.get("primary_language", "unknown"))
75+
secondary: list[str] = list(scan.get("secondary_languages", []) or []) # type: ignore[call-overload]
76+
lang_list = [primary] + secondary
77+
lang_display = ", ".join(str(l) for l in lang_list if l) # noqa: E741
78+
79+
doc = f"# Architecture — {name}\n\n"
80+
81+
# Overview
82+
doc += "## Overview\n\n"
83+
readme = scan.get("readme_summary", "")
84+
if readme:
85+
doc += f"{readme}\n\n"
86+
doc += f"- **Languages**: {lang_display}\n"
87+
doc += f"- **Build system**: {scan.get('build_system', 'not detected')}\n"
88+
doc += f"- **Test framework**: {scan.get('test_framework', 'not detected')}\n"
89+
doc += f"- **Project type**: {scan.get('inferred_type', 'unknown')}\n"
90+
doc += f"- **Files**: {scan.get('file_count', 0)}\n\n"
91+
92+
# Components
93+
if components:
94+
doc += "## Components\n\n"
95+
for comp in components:
96+
doc += f"### {comp.get('name', 'unnamed')}\n"
97+
if comp.get("purpose"):
98+
doc += f"- **Purpose**: {comp['purpose']}\n"
99+
if comp.get("interfaces"):
100+
doc += f"- **Interfaces**: {comp['interfaces']}\n"
101+
if comp.get("dependencies"):
102+
doc += f"- **Dependencies**: {comp['dependencies']}\n"
103+
doc += "\n"
104+
elif scan.get("modules"):
105+
doc += "## Modules\n\n"
106+
for mod in list(scan.get("modules", []) or []): # type: ignore[call-overload]
107+
doc += f"### {mod}\n- **Purpose**: [Describe {mod} purpose]\n\n"
108+
109+
# Data flow
110+
if data_flow:
111+
doc += f"## Data Flow\n\n{data_flow}\n\n"
112+
else:
113+
doc += "## Data Flow\n\n[Describe how data flows between components]\n\n"
114+
115+
# Dependencies
116+
deps: list[str] = list(scan.get("dependencies", []) or []) # type: ignore[call-overload]
117+
if deps:
118+
doc += "## External Dependencies\n\n"
119+
for dep in deps[:30]:
120+
doc += f"- `{dep}`\n"
121+
doc += "\n"
122+
123+
# Entry points
124+
eps: list[str] = list(scan.get("entry_points", []) or []) # type: ignore[call-overload]
125+
if eps:
126+
doc += "## Entry Points\n\n"
127+
for ep in eps:
128+
doc += f"- `{ep}`\n"
129+
doc += "\n"
130+
131+
# Language distribution
132+
if langs and len(langs) > 1:
133+
doc += "## Language Distribution\n\n"
134+
for lang_name, count in sorted(langs.items(), key=lambda x: -x[1]):
135+
doc += f"- {lang_name}: {count} files\n"
136+
doc += "\n"
137+
138+
# Deployment
139+
if deployment:
140+
doc += f"## Deployment\n\n{deployment}\n\n"
141+
142+
# Existing architecture references
143+
existing: list[str] = list(scan.get("existing_arch_docs", []) or []) # type: ignore[call-overload]
144+
if existing:
145+
doc += "## Related Documents\n\n"
146+
for ref in existing:
147+
doc += f"- [{ref}]({ref})\n"
148+
doc += "\n"
149+
150+
# Write
151+
arch_path = root / "docs" / "architecture.md"
152+
arch_path.parent.mkdir(parents=True, exist_ok=True)
153+
arch_path.write_text(doc, encoding="utf-8")
154+
return arch_path

src/specsmith/auditor.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ def check_governance_files(root: Path) -> list[AuditResult]:
132132
name=f"recommended:{f}",
133133
passed=found,
134134
message=f"Recommended file {f} {'exists' if found else 'missing'}",
135+
fixable=not found,
135136
)
136137
)
137138

@@ -565,4 +566,42 @@ def run_auto_fix(root: Path, report: AuditReport) -> list[str]:
565566
except Exception: # noqa: BLE001
566567
pass # Best-effort
567568

569+
# Fix missing recommended files
570+
elif result.name == "recommended:docs/architecture.md" and not result.passed:
571+
from specsmith.architect import generate_architecture
572+
573+
try:
574+
generate_architecture(root)
575+
fixed.append("Generated docs/architecture.md from project scan")
576+
except Exception: # noqa: BLE001
577+
# Fallback stub
578+
path = root / "docs" / "architecture.md"
579+
path.parent.mkdir(parents=True, exist_ok=True)
580+
path.write_text(
581+
f"# Architecture — {root.name}\n\n"
582+
"[Run `specsmith architect` to populate]\n",
583+
encoding="utf-8",
584+
)
585+
fixed.append("Created stub docs/architecture.md")
586+
587+
elif result.name == "recommended:docs/REQUIREMENTS.md" and not result.passed:
588+
path = root / "docs" / "REQUIREMENTS.md"
589+
path.parent.mkdir(parents=True, exist_ok=True)
590+
path.write_text(
591+
"# Requirements\n\nNo requirements defined yet.\n\n"
592+
"## REQ-CORE-001\n- **Component**: core\n"
593+
"- **Status**: Draft\n- **Description**: [Define]\n",
594+
encoding="utf-8",
595+
)
596+
fixed.append("Created stub docs/REQUIREMENTS.md")
597+
598+
elif result.name == "recommended:docs/TEST_SPEC.md" and not result.passed:
599+
path = root / "docs" / "TEST_SPEC.md"
600+
path.parent.mkdir(parents=True, exist_ok=True)
601+
path.write_text(
602+
"# Test Specification\n\nNo tests defined yet.\n",
603+
encoding="utf-8",
604+
)
605+
fixed.append("Created stub docs/TEST_SPEC.md")
606+
568607
return fixed

src/specsmith/cli.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,68 @@ def _run_guided_architecture(cfg: ProjectConfig, target: Path) -> list[Path]:
646646
return created
647647

648648

649+
@main.command()
650+
@click.option("--project-dir", type=click.Path(exists=True), default=".", help="Project root.")
651+
@click.option("--non-interactive", is_flag=True, default=False, help="Skip prompts, auto-generate.")
652+
def architect(project_dir: str, non_interactive: bool) -> None:
653+
"""Generate or enrich architecture documentation.
654+
655+
Scans the project for modules, languages, dependencies, git history,
656+
then optionally interviews you about components and data flow.
657+
"""
658+
from specsmith.architect import generate_architecture, scan_project_structure
659+
660+
root = Path(project_dir).resolve()
661+
console.print(f"[bold]Scanning[/bold] {root}...\n")
662+
scan = scan_project_structure(root)
663+
664+
modules: list[str] = list(scan.get("modules", []) or []) # type: ignore[call-overload]
665+
deps_list: list[str] = list(scan.get("dependencies", []) or []) # type: ignore[call-overload]
666+
eps_list: list[str] = list(scan.get("entry_points", []) or []) # type: ignore[call-overload]
667+
existing: list[str] = list(scan.get("existing_arch_docs", []) or []) # type: ignore[call-overload]
668+
669+
console.print(f" Languages: {scan.get('primary_language', '?')}")
670+
console.print(f" Modules: {', '.join(modules) or 'none'}")
671+
console.print(f" Dependencies: {len(deps_list)}")
672+
console.print(f" Entry points: {', '.join(eps_list) or 'none'}")
673+
if existing:
674+
console.print(f" Existing arch docs: {', '.join(existing)}")
675+
console.print()
676+
677+
components: list[dict[str, str]] | None = None
678+
data_flow = ""
679+
deployment = ""
680+
681+
if not non_interactive:
682+
console.print("[bold]Architecture Interview[/bold]\n")
683+
comp_str = click.prompt(
684+
"Major components (comma-separated)",
685+
default=", ".join(modules or ["core"]),
686+
)
687+
components = []
688+
for name in [c.strip() for c in comp_str.split(",") if c.strip()]:
689+
purpose = click.prompt(f" {name} purpose", default="")
690+
interfaces = click.prompt(f" {name} interfaces", default="")
691+
components.append({"name": name, "purpose": purpose, "interfaces": interfaces})
692+
693+
data_flow = click.prompt("\nData flow description", default="")
694+
deployment = click.prompt("Deployment notes", default="")
695+
696+
path = generate_architecture(
697+
root, components=components, data_flow=data_flow, deployment=deployment, scan=scan
698+
)
699+
rel = path.relative_to(root)
700+
console.print(f"\n[green]\u2713[/green] Generated {rel}")
701+
if existing:
702+
console.print(
703+
f" [yellow]Note:[/yellow] Existing docs at {', '.join(existing)} "
704+
"are referenced but not merged. Review manually."
705+
)
706+
console.print(
707+
" [dim]Run \"specsmith audit --project-dir .\" to verify governance health.[/dim]"
708+
)
709+
710+
649711
# ---------------------------------------------------------------------------
650712
# Ledger subcommands
651713
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)