specfact-cli 0.6.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- specfact_cli/__init__.py +14 -0
- specfact_cli/agents/__init__.py +24 -0
- specfact_cli/agents/analyze_agent.py +391 -0
- specfact_cli/agents/base.py +95 -0
- specfact_cli/agents/plan_agent.py +202 -0
- specfact_cli/agents/registry.py +176 -0
- specfact_cli/agents/sync_agent.py +133 -0
- specfact_cli/analyzers/__init__.py +12 -0
- specfact_cli/analyzers/ambiguity_scanner.py +592 -0
- specfact_cli/analyzers/code_analyzer.py +1228 -0
- specfact_cli/analyzers/contract_extractor.py +419 -0
- specfact_cli/analyzers/control_flow_analyzer.py +281 -0
- specfact_cli/analyzers/requirement_extractor.py +337 -0
- specfact_cli/analyzers/test_pattern_extractor.py +330 -0
- specfact_cli/cli.py +264 -0
- specfact_cli/commands/__init__.py +7 -0
- specfact_cli/commands/constitution.py +261 -0
- specfact_cli/commands/enforce.py +96 -0
- specfact_cli/commands/import_cmd.py +694 -0
- specfact_cli/commands/init.py +143 -0
- specfact_cli/commands/plan.py +2398 -0
- specfact_cli/commands/repro.py +214 -0
- specfact_cli/commands/sync.py +744 -0
- specfact_cli/common/__init__.py +25 -0
- specfact_cli/common/logger_setup.py +654 -0
- specfact_cli/common/logging_utils.py +41 -0
- specfact_cli/common/text_utils.py +52 -0
- specfact_cli/common/utils.py +48 -0
- specfact_cli/comparators/__init__.py +11 -0
- specfact_cli/comparators/plan_comparator.py +391 -0
- specfact_cli/enrichers/constitution_enricher.py +765 -0
- specfact_cli/enrichers/plan_enricher.py +268 -0
- specfact_cli/generators/__init__.py +14 -0
- specfact_cli/generators/plan_generator.py +105 -0
- specfact_cli/generators/protocol_generator.py +115 -0
- specfact_cli/generators/report_generator.py +200 -0
- specfact_cli/generators/workflow_generator.py +120 -0
- specfact_cli/importers/__init__.py +7 -0
- specfact_cli/importers/speckit_converter.py +1051 -0
- specfact_cli/importers/speckit_scanner.py +776 -0
- specfact_cli/models/__init__.py +33 -0
- specfact_cli/models/deviation.py +105 -0
- specfact_cli/models/enforcement.py +150 -0
- specfact_cli/models/plan.py +139 -0
- specfact_cli/models/protocol.py +28 -0
- specfact_cli/modes/__init__.py +19 -0
- specfact_cli/modes/detector.py +126 -0
- specfact_cli/modes/router.py +153 -0
- specfact_cli/resources/mappings/node-async.yaml +49 -0
- specfact_cli/resources/mappings/python-async.yaml +47 -0
- specfact_cli/resources/mappings/speckit-default.yaml +82 -0
- specfact_cli/resources/prompts/specfact-enforce.md +185 -0
- specfact_cli/resources/prompts/specfact-import-from-code.md +597 -0
- specfact_cli/resources/prompts/specfact-plan-add-feature.md +188 -0
- specfact_cli/resources/prompts/specfact-plan-add-story.md +212 -0
- specfact_cli/resources/prompts/specfact-plan-compare.md +571 -0
- specfact_cli/resources/prompts/specfact-plan-init.md +531 -0
- specfact_cli/resources/prompts/specfact-plan-promote.md +352 -0
- specfact_cli/resources/prompts/specfact-plan-review.md +869 -0
- specfact_cli/resources/prompts/specfact-plan-select.md +401 -0
- specfact_cli/resources/prompts/specfact-plan-update-feature.md +234 -0
- specfact_cli/resources/prompts/specfact-plan-update-idea.md +211 -0
- specfact_cli/resources/prompts/specfact-repro.md +268 -0
- specfact_cli/resources/prompts/specfact-sync.md +457 -0
- specfact_cli/resources/schemas/deviation.schema.json +61 -0
- specfact_cli/resources/schemas/plan.schema.json +204 -0
- specfact_cli/resources/schemas/protocol.schema.json +53 -0
- specfact_cli/resources/semgrep/async.yml +285 -0
- specfact_cli/resources/templates/github-action.yml.j2 +140 -0
- specfact_cli/resources/templates/plan.bundle.yaml.j2 +141 -0
- specfact_cli/resources/templates/pr-template.md.j2 +58 -0
- specfact_cli/resources/templates/protocol.yaml.j2 +24 -0
- specfact_cli/resources/templates/telemetry.yaml.example +35 -0
- specfact_cli/sync/__init__.py +21 -0
- specfact_cli/sync/repository_sync.py +279 -0
- specfact_cli/sync/speckit_sync.py +388 -0
- specfact_cli/sync/watcher.py +268 -0
- specfact_cli/telemetry.py +440 -0
- specfact_cli/utils/__init__.py +58 -0
- specfact_cli/utils/console.py +70 -0
- specfact_cli/utils/enrichment_parser.py +445 -0
- specfact_cli/utils/feature_keys.py +212 -0
- specfact_cli/utils/git.py +241 -0
- specfact_cli/utils/github_annotations.py +399 -0
- specfact_cli/utils/ide_setup.py +389 -0
- specfact_cli/utils/prompts.py +180 -0
- specfact_cli/utils/structure.py +674 -0
- specfact_cli/utils/yaml_utils.py +200 -0
- specfact_cli/validators/__init__.py +20 -0
- specfact_cli/validators/fsm.py +262 -0
- specfact_cli/validators/repro_checker.py +780 -0
- specfact_cli/validators/schema.py +196 -0
- specfact_cli-0.6.3.dist-info/METADATA +456 -0
- specfact_cli-0.6.3.dist-info/RECORD +97 -0
- specfact_cli-0.6.3.dist-info/WHEEL +4 -0
- specfact_cli-0.6.3.dist-info/entry_points.txt +2 -0
- specfact_cli-0.6.3.dist-info/licenses/LICENSE.md +202 -0
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Import command - Import codebases and Spec-Kit projects to contract-driven format.
|
|
3
|
+
|
|
4
|
+
This module provides commands for importing existing codebases (brownfield) and
|
|
5
|
+
Spec-Kit projects and converting them to SpecFact contract-driven format.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from beartype import beartype
|
|
14
|
+
from icontract import ensure, require
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
17
|
+
|
|
18
|
+
from specfact_cli.telemetry import telemetry
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(help="Import codebases and Spec-Kit projects to contract format")
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_valid_repo_path(path: Path) -> bool:
|
|
26
|
+
"""Check if path exists and is a directory."""
|
|
27
|
+
return path.exists() and path.is_dir()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_valid_output_path(path: Path | None) -> bool:
|
|
31
|
+
"""Check if output path exists if provided."""
|
|
32
|
+
return path is None or path.exists()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _count_python_files(repo: Path) -> int:
|
|
36
|
+
"""Count Python files for anonymized telemetry metrics."""
|
|
37
|
+
return sum(1 for _ in repo.rglob("*.py"))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command("from-spec-kit")
|
|
41
|
+
def from_spec_kit(
|
|
42
|
+
repo: Path = typer.Option(
|
|
43
|
+
Path("."),
|
|
44
|
+
"--repo",
|
|
45
|
+
help="Path to Spec-Kit repository",
|
|
46
|
+
exists=True,
|
|
47
|
+
file_okay=False,
|
|
48
|
+
dir_okay=True,
|
|
49
|
+
),
|
|
50
|
+
dry_run: bool = typer.Option(
|
|
51
|
+
False,
|
|
52
|
+
"--dry-run",
|
|
53
|
+
help="Preview changes without writing files",
|
|
54
|
+
),
|
|
55
|
+
write: bool = typer.Option(
|
|
56
|
+
False,
|
|
57
|
+
"--write",
|
|
58
|
+
help="Write changes to disk",
|
|
59
|
+
),
|
|
60
|
+
out_branch: str = typer.Option(
|
|
61
|
+
"feat/specfact-migration",
|
|
62
|
+
"--out-branch",
|
|
63
|
+
help="Feature branch name for migration",
|
|
64
|
+
),
|
|
65
|
+
report: Path | None = typer.Option(
|
|
66
|
+
None,
|
|
67
|
+
"--report",
|
|
68
|
+
help="Path to write import report",
|
|
69
|
+
),
|
|
70
|
+
force: bool = typer.Option(
|
|
71
|
+
False,
|
|
72
|
+
"--force",
|
|
73
|
+
help="Overwrite existing files",
|
|
74
|
+
),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Convert Spec-Kit project to SpecFact contract format.
|
|
78
|
+
|
|
79
|
+
This command scans a Spec-Kit repository, parses its structure,
|
|
80
|
+
and generates equivalent SpecFact contracts, protocols, and plans.
|
|
81
|
+
|
|
82
|
+
Example:
|
|
83
|
+
specfact import from-spec-kit --repo ./my-project --write
|
|
84
|
+
"""
|
|
85
|
+
from specfact_cli.importers.speckit_converter import SpecKitConverter
|
|
86
|
+
from specfact_cli.importers.speckit_scanner import SpecKitScanner
|
|
87
|
+
from specfact_cli.utils.structure import SpecFactStructure
|
|
88
|
+
|
|
89
|
+
telemetry_metadata = {
|
|
90
|
+
"dry_run": dry_run,
|
|
91
|
+
"write": write,
|
|
92
|
+
"force": force,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
with telemetry.track_command("import.from_spec_kit", telemetry_metadata) as record:
|
|
96
|
+
console.print(f"[bold cyan]Importing Spec-Kit project from:[/bold cyan] {repo}")
|
|
97
|
+
|
|
98
|
+
# Scan Spec-Kit structure
|
|
99
|
+
scanner = SpecKitScanner(repo)
|
|
100
|
+
|
|
101
|
+
if not scanner.is_speckit_repo():
|
|
102
|
+
console.print("[bold red]✗[/bold red] Not a Spec-Kit repository")
|
|
103
|
+
console.print("[dim]Expected: .specify/ directory[/dim]")
|
|
104
|
+
raise typer.Exit(1)
|
|
105
|
+
|
|
106
|
+
structure = scanner.scan_structure()
|
|
107
|
+
|
|
108
|
+
if dry_run:
|
|
109
|
+
console.print("[yellow]→ Dry run mode - no files will be written[/yellow]")
|
|
110
|
+
console.print("\n[bold]Detected Structure:[/bold]")
|
|
111
|
+
console.print(f" - Specs Directory: {structure.get('specs_dir', 'Not found')}")
|
|
112
|
+
console.print(f" - Memory Directory: {structure.get('specify_memory_dir', 'Not found')}")
|
|
113
|
+
if structure.get("feature_dirs"):
|
|
114
|
+
console.print(f" - Features Found: {len(structure['feature_dirs'])}")
|
|
115
|
+
if structure.get("memory_files"):
|
|
116
|
+
console.print(f" - Memory Files: {len(structure['memory_files'])}")
|
|
117
|
+
record({"dry_run": True, "features_found": len(structure.get("feature_dirs", []))})
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
if not write:
|
|
121
|
+
console.print("[yellow]→ Use --write to actually convert files[/yellow]")
|
|
122
|
+
console.print("[dim]Use --dry-run to preview changes[/dim]")
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
# Ensure SpecFact structure exists
|
|
126
|
+
SpecFactStructure.ensure_structure(repo)
|
|
127
|
+
|
|
128
|
+
with Progress(
|
|
129
|
+
SpinnerColumn(),
|
|
130
|
+
TextColumn("[progress.description]{task.description}"),
|
|
131
|
+
console=console,
|
|
132
|
+
) as progress:
|
|
133
|
+
# Step 1: Discover features from markdown artifacts
|
|
134
|
+
task = progress.add_task("Discovering Spec-Kit features...", total=None)
|
|
135
|
+
features = scanner.discover_features()
|
|
136
|
+
if not features:
|
|
137
|
+
console.print("[bold red]✗[/bold red] No features found in Spec-Kit repository")
|
|
138
|
+
console.print("[dim]Expected: specs/*/spec.md files[/dim]")
|
|
139
|
+
raise typer.Exit(1)
|
|
140
|
+
progress.update(task, description=f"✓ Discovered {len(features)} features")
|
|
141
|
+
|
|
142
|
+
# Step 2: Convert protocol
|
|
143
|
+
task = progress.add_task("Converting protocol...", total=None)
|
|
144
|
+
converter = SpecKitConverter(repo)
|
|
145
|
+
protocol = None
|
|
146
|
+
plan_bundle = None
|
|
147
|
+
try:
|
|
148
|
+
protocol = converter.convert_protocol()
|
|
149
|
+
progress.update(task, description=f"✓ Protocol converted ({len(protocol.states)} states)")
|
|
150
|
+
|
|
151
|
+
# Step 3: Convert plan
|
|
152
|
+
task = progress.add_task("Converting plan bundle...", total=None)
|
|
153
|
+
plan_bundle = converter.convert_plan()
|
|
154
|
+
progress.update(task, description=f"✓ Plan converted ({len(plan_bundle.features)} features)")
|
|
155
|
+
|
|
156
|
+
# Step 4: Generate Semgrep rules
|
|
157
|
+
task = progress.add_task("Generating Semgrep rules...", total=None)
|
|
158
|
+
_semgrep_path = converter.generate_semgrep_rules() # Not used yet
|
|
159
|
+
progress.update(task, description="✓ Semgrep rules generated")
|
|
160
|
+
|
|
161
|
+
# Step 5: Generate GitHub Action workflow
|
|
162
|
+
task = progress.add_task("Generating GitHub Action workflow...", total=None)
|
|
163
|
+
repo_name = repo.name if isinstance(repo, Path) else None
|
|
164
|
+
_workflow_path = converter.generate_github_action(repo_name=repo_name) # Not used yet
|
|
165
|
+
progress.update(task, description="✓ GitHub Action workflow generated")
|
|
166
|
+
|
|
167
|
+
except Exception as e:
|
|
168
|
+
console.print(f"[bold red]✗[/bold red] Conversion failed: {e}")
|
|
169
|
+
raise typer.Exit(1) from e
|
|
170
|
+
|
|
171
|
+
# Generate report
|
|
172
|
+
if report and protocol and plan_bundle:
|
|
173
|
+
report_content = f"""# Spec-Kit Import Report
|
|
174
|
+
|
|
175
|
+
## Repository: {repo}
|
|
176
|
+
|
|
177
|
+
## Summary
|
|
178
|
+
- **States Found**: {len(protocol.states)}
|
|
179
|
+
- **Transitions**: {len(protocol.transitions)}
|
|
180
|
+
- **Features Extracted**: {len(plan_bundle.features)}
|
|
181
|
+
- **Total Stories**: {sum(len(f.stories) for f in plan_bundle.features)}
|
|
182
|
+
|
|
183
|
+
## Generated Files
|
|
184
|
+
- **Protocol**: `.specfact/protocols/workflow.protocol.yaml`
|
|
185
|
+
- **Plan Bundle**: `.specfact/plans/main.bundle.yaml`
|
|
186
|
+
- **Semgrep Rules**: `.semgrep/async-anti-patterns.yml`
|
|
187
|
+
- **GitHub Action**: `.github/workflows/specfact-gate.yml`
|
|
188
|
+
|
|
189
|
+
## States
|
|
190
|
+
{chr(10).join(f"- {state}" for state in protocol.states)}
|
|
191
|
+
|
|
192
|
+
## Features
|
|
193
|
+
{chr(10).join(f"- {f.title} ({f.key})" for f in plan_bundle.features)}
|
|
194
|
+
"""
|
|
195
|
+
report.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
report.write_text(report_content, encoding="utf-8")
|
|
197
|
+
console.print(f"[dim]Report written to: {report}[/dim]")
|
|
198
|
+
|
|
199
|
+
console.print("[bold green]✓[/bold green] Import complete!")
|
|
200
|
+
console.print("[dim]Protocol: .specfact/protocols/workflow.protocol.yaml[/dim]")
|
|
201
|
+
console.print("[dim]Plan: .specfact/plans/main.bundle.yaml[/dim]")
|
|
202
|
+
console.print("[dim]Semgrep Rules: .semgrep/async-anti-patterns.yml[/dim]")
|
|
203
|
+
console.print("[dim]GitHub Action: .github/workflows/specfact-gate.yml[/dim]")
|
|
204
|
+
|
|
205
|
+
# Record import results
|
|
206
|
+
if protocol and plan_bundle:
|
|
207
|
+
record(
|
|
208
|
+
{
|
|
209
|
+
"states_found": len(protocol.states),
|
|
210
|
+
"transitions": len(protocol.transitions),
|
|
211
|
+
"features_extracted": len(plan_bundle.features),
|
|
212
|
+
"total_stories": sum(len(f.stories) for f in plan_bundle.features),
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.command("from-code")
|
|
218
|
+
@require(lambda repo: _is_valid_repo_path(repo), "Repo path must exist and be directory")
|
|
219
|
+
@require(lambda confidence: 0.0 <= confidence <= 1.0, "Confidence must be 0.0-1.0")
|
|
220
|
+
@ensure(lambda out: _is_valid_output_path(out), "Output path must exist if provided")
|
|
221
|
+
@beartype
|
|
222
|
+
def from_code(
|
|
223
|
+
repo: Path = typer.Option(
|
|
224
|
+
Path("."),
|
|
225
|
+
"--repo",
|
|
226
|
+
help="Path to repository to import",
|
|
227
|
+
exists=True,
|
|
228
|
+
file_okay=False,
|
|
229
|
+
dir_okay=True,
|
|
230
|
+
),
|
|
231
|
+
name: str | None = typer.Option(
|
|
232
|
+
None,
|
|
233
|
+
"--name",
|
|
234
|
+
help="Custom plan name (will be sanitized for filesystem, default: 'auto-derived')",
|
|
235
|
+
),
|
|
236
|
+
out: Path | None = typer.Option(
|
|
237
|
+
None,
|
|
238
|
+
"--out",
|
|
239
|
+
help="Output plan bundle path (default: .specfact/plans/<name>-<timestamp>.bundle.yaml)",
|
|
240
|
+
),
|
|
241
|
+
shadow_only: bool = typer.Option(
|
|
242
|
+
False,
|
|
243
|
+
"--shadow-only",
|
|
244
|
+
help="Shadow mode - observe without enforcing",
|
|
245
|
+
),
|
|
246
|
+
report: Path | None = typer.Option(
|
|
247
|
+
None,
|
|
248
|
+
"--report",
|
|
249
|
+
help="Path to write analysis report (default: .specfact/reports/brownfield/analysis-<timestamp>.md)",
|
|
250
|
+
),
|
|
251
|
+
confidence: float = typer.Option(
|
|
252
|
+
0.5,
|
|
253
|
+
"--confidence",
|
|
254
|
+
min=0.0,
|
|
255
|
+
max=1.0,
|
|
256
|
+
help="Minimum confidence score for features",
|
|
257
|
+
),
|
|
258
|
+
key_format: str = typer.Option(
|
|
259
|
+
"classname",
|
|
260
|
+
"--key-format",
|
|
261
|
+
help="Feature key format: 'classname' (FEATURE-CLASSNAME) or 'sequential' (FEATURE-001)",
|
|
262
|
+
),
|
|
263
|
+
enrichment: Path | None = typer.Option(
|
|
264
|
+
None,
|
|
265
|
+
"--enrichment",
|
|
266
|
+
help="Path to Markdown enrichment report from LLM (applies missing features, confidence adjustments, business context)",
|
|
267
|
+
),
|
|
268
|
+
enrich_for_speckit: bool = typer.Option(
|
|
269
|
+
False,
|
|
270
|
+
"--enrich-for-speckit",
|
|
271
|
+
help="Automatically enrich plan for Spec-Kit compliance (runs plan review, adds testable acceptance criteria, ensures ≥2 stories per feature)",
|
|
272
|
+
),
|
|
273
|
+
entry_point: Path | None = typer.Option(
|
|
274
|
+
None,
|
|
275
|
+
"--entry-point",
|
|
276
|
+
help="Subdirectory path for partial analysis (relative to repo root). Analyzes only files within this directory and subdirectories.",
|
|
277
|
+
),
|
|
278
|
+
) -> None:
|
|
279
|
+
"""
|
|
280
|
+
Import plan bundle from existing codebase (one-way import).
|
|
281
|
+
|
|
282
|
+
Analyzes code structure using AI-first semantic understanding or AST-based fallback
|
|
283
|
+
to generate a plan bundle that represents the current system.
|
|
284
|
+
|
|
285
|
+
Supports dual-stack enrichment workflow: apply LLM-generated enrichment report
|
|
286
|
+
to refine the auto-detected plan bundle (add missing features, adjust confidence scores,
|
|
287
|
+
add business context).
|
|
288
|
+
|
|
289
|
+
Example:
|
|
290
|
+
specfact import from-code --repo . --out brownfield-plan.yaml
|
|
291
|
+
specfact import from-code --repo . --enrichment enrichment-report.md
|
|
292
|
+
"""
|
|
293
|
+
from specfact_cli.agents.analyze_agent import AnalyzeAgent
|
|
294
|
+
from specfact_cli.agents.registry import get_agent
|
|
295
|
+
from specfact_cli.cli import get_current_mode
|
|
296
|
+
from specfact_cli.modes import get_router
|
|
297
|
+
|
|
298
|
+
mode = get_current_mode()
|
|
299
|
+
|
|
300
|
+
# Route command based on mode
|
|
301
|
+
router = get_router()
|
|
302
|
+
routing_result = router.route("import from-code", mode, {"repo": str(repo), "confidence": confidence})
|
|
303
|
+
|
|
304
|
+
python_file_count = _count_python_files(repo)
|
|
305
|
+
|
|
306
|
+
from specfact_cli.generators.plan_generator import PlanGenerator
|
|
307
|
+
from specfact_cli.utils.structure import SpecFactStructure
|
|
308
|
+
from specfact_cli.validators.schema import validate_plan_bundle
|
|
309
|
+
|
|
310
|
+
# Ensure .specfact structure exists in the repository being imported
|
|
311
|
+
SpecFactStructure.ensure_structure(repo)
|
|
312
|
+
|
|
313
|
+
# Use default paths if not specified (relative to repo)
|
|
314
|
+
# If enrichment is provided, try to derive original plan path and create enriched copy
|
|
315
|
+
original_plan_path: Path | None = None
|
|
316
|
+
if enrichment and enrichment.exists():
|
|
317
|
+
original_plan_path = SpecFactStructure.get_plan_bundle_from_enrichment(enrichment, base_path=repo)
|
|
318
|
+
if original_plan_path:
|
|
319
|
+
# Create enriched plan path with clear label
|
|
320
|
+
out = SpecFactStructure.get_enriched_plan_path(original_plan_path, base_path=repo)
|
|
321
|
+
else:
|
|
322
|
+
# Enrichment provided but original plan not found, use default naming
|
|
323
|
+
out = SpecFactStructure.get_timestamped_brownfield_report(repo, name=name)
|
|
324
|
+
elif out is None:
|
|
325
|
+
out = SpecFactStructure.get_timestamped_brownfield_report(repo, name=name)
|
|
326
|
+
|
|
327
|
+
if report is None:
|
|
328
|
+
report = SpecFactStructure.get_brownfield_analysis_path(repo)
|
|
329
|
+
|
|
330
|
+
console.print(f"[bold cyan]Importing repository:[/bold cyan] {repo}")
|
|
331
|
+
console.print(f"[dim]Confidence threshold: {confidence}[/dim]")
|
|
332
|
+
|
|
333
|
+
if shadow_only:
|
|
334
|
+
console.print("[yellow]→ Shadow mode - observe without enforcement[/yellow]")
|
|
335
|
+
|
|
336
|
+
telemetry_metadata = {
|
|
337
|
+
"mode": mode.value,
|
|
338
|
+
"execution_mode": routing_result.execution_mode,
|
|
339
|
+
"files_analyzed": python_file_count,
|
|
340
|
+
"shadow_mode": shadow_only,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
with telemetry.track_command("import.from_code", telemetry_metadata) as record_event:
|
|
344
|
+
try:
|
|
345
|
+
# If enrichment is provided and original plan exists, load it instead of analyzing
|
|
346
|
+
if enrichment and original_plan_path and original_plan_path.exists():
|
|
347
|
+
console.print(f"[dim]Loading original plan for enrichment: {original_plan_path.name}[/dim]")
|
|
348
|
+
import yaml
|
|
349
|
+
|
|
350
|
+
from specfact_cli.models.plan import PlanBundle
|
|
351
|
+
|
|
352
|
+
with original_plan_path.open() as f:
|
|
353
|
+
plan_data = yaml.safe_load(f)
|
|
354
|
+
plan_bundle = PlanBundle.model_validate(plan_data)
|
|
355
|
+
total_stories = sum(len(f.stories) for f in plan_bundle.features)
|
|
356
|
+
console.print(
|
|
357
|
+
f"[green]✓[/green] Loaded original plan: {len(plan_bundle.features)} features, {total_stories} stories"
|
|
358
|
+
)
|
|
359
|
+
else:
|
|
360
|
+
# Use AI-first approach in CoPilot mode, fallback to AST in CI/CD mode
|
|
361
|
+
if routing_result.execution_mode == "agent":
|
|
362
|
+
console.print("[dim]Mode: CoPilot (AI-first import)[/dim]")
|
|
363
|
+
# Get agent for this command
|
|
364
|
+
agent = get_agent("import from-code")
|
|
365
|
+
if agent and isinstance(agent, AnalyzeAgent):
|
|
366
|
+
# Build context for agent
|
|
367
|
+
context = {
|
|
368
|
+
"workspace": str(repo),
|
|
369
|
+
"current_file": None, # TODO: Get from IDE in Phase 4.2+
|
|
370
|
+
"selection": None, # TODO: Get from IDE in Phase 4.2+
|
|
371
|
+
}
|
|
372
|
+
# Inject context (for future LLM integration)
|
|
373
|
+
_enhanced_context = agent.inject_context(context)
|
|
374
|
+
# Use AI-first import
|
|
375
|
+
console.print("\n[cyan]🤖 AI-powered import (semantic understanding)...[/cyan]")
|
|
376
|
+
plan_bundle = agent.analyze_codebase(repo, confidence=confidence, plan_name=name)
|
|
377
|
+
console.print("[green]✓[/green] AI import complete")
|
|
378
|
+
else:
|
|
379
|
+
# Fallback to AST if agent not available
|
|
380
|
+
console.print("[yellow]⚠ Agent not available, falling back to AST-based import[/yellow]")
|
|
381
|
+
from specfact_cli.analyzers.code_analyzer import CodeAnalyzer
|
|
382
|
+
|
|
383
|
+
console.print(
|
|
384
|
+
"\n[yellow]⏱️ Note: This analysis may take 2+ minutes for large codebases[/yellow]"
|
|
385
|
+
)
|
|
386
|
+
if entry_point:
|
|
387
|
+
console.print(f"[cyan]🔍 Analyzing codebase (scoped to {entry_point})...[/cyan]\n")
|
|
388
|
+
else:
|
|
389
|
+
console.print("[cyan]🔍 Analyzing codebase (AST-based fallback)...[/cyan]\n")
|
|
390
|
+
analyzer = CodeAnalyzer(
|
|
391
|
+
repo,
|
|
392
|
+
confidence_threshold=confidence,
|
|
393
|
+
key_format=key_format,
|
|
394
|
+
plan_name=name,
|
|
395
|
+
entry_point=entry_point,
|
|
396
|
+
)
|
|
397
|
+
plan_bundle = analyzer.analyze()
|
|
398
|
+
else:
|
|
399
|
+
# CI/CD mode: use AST-based import (no LLM available)
|
|
400
|
+
console.print("[dim]Mode: CI/CD (AST-based import)[/dim]")
|
|
401
|
+
from specfact_cli.analyzers.code_analyzer import CodeAnalyzer
|
|
402
|
+
|
|
403
|
+
console.print("\n[yellow]⏱️ Note: This analysis may take 2+ minutes for large codebases[/yellow]")
|
|
404
|
+
if entry_point:
|
|
405
|
+
console.print(f"[cyan]🔍 Analyzing codebase (scoped to {entry_point})...[/cyan]\n")
|
|
406
|
+
else:
|
|
407
|
+
console.print("[cyan]🔍 Analyzing codebase...[/cyan]\n")
|
|
408
|
+
analyzer = CodeAnalyzer(
|
|
409
|
+
repo,
|
|
410
|
+
confidence_threshold=confidence,
|
|
411
|
+
key_format=key_format,
|
|
412
|
+
plan_name=name,
|
|
413
|
+
entry_point=entry_point,
|
|
414
|
+
)
|
|
415
|
+
plan_bundle = analyzer.analyze()
|
|
416
|
+
|
|
417
|
+
console.print(f"[green]✓[/green] Found {len(plan_bundle.features)} features")
|
|
418
|
+
console.print(f"[green]✓[/green] Detected themes: {', '.join(plan_bundle.product.themes)}")
|
|
419
|
+
|
|
420
|
+
# Show summary
|
|
421
|
+
total_stories = sum(len(f.stories) for f in plan_bundle.features)
|
|
422
|
+
console.print(f"[green]✓[/green] Total stories: {total_stories}\n")
|
|
423
|
+
|
|
424
|
+
record_event({"features_detected": len(plan_bundle.features), "stories_detected": total_stories})
|
|
425
|
+
|
|
426
|
+
# Apply enrichment if provided
|
|
427
|
+
if enrichment:
|
|
428
|
+
if not enrichment.exists():
|
|
429
|
+
console.print(f"[bold red]✗ Enrichment report not found: {enrichment}[/bold red]")
|
|
430
|
+
raise typer.Exit(1)
|
|
431
|
+
|
|
432
|
+
console.print(f"\n[cyan]📝 Applying enrichment from: {enrichment}[/cyan]")
|
|
433
|
+
from specfact_cli.utils.enrichment_parser import EnrichmentParser, apply_enrichment
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
parser = EnrichmentParser()
|
|
437
|
+
enrichment_report = parser.parse(enrichment)
|
|
438
|
+
plan_bundle = apply_enrichment(plan_bundle, enrichment_report)
|
|
439
|
+
|
|
440
|
+
# Report enrichment results
|
|
441
|
+
if enrichment_report.missing_features:
|
|
442
|
+
console.print(
|
|
443
|
+
f"[green]✓[/green] Added {len(enrichment_report.missing_features)} missing features"
|
|
444
|
+
)
|
|
445
|
+
if enrichment_report.confidence_adjustments:
|
|
446
|
+
console.print(
|
|
447
|
+
f"[green]✓[/green] Adjusted confidence for {len(enrichment_report.confidence_adjustments)} features"
|
|
448
|
+
)
|
|
449
|
+
if enrichment_report.business_context.get("priorities") or enrichment_report.business_context.get(
|
|
450
|
+
"constraints"
|
|
451
|
+
):
|
|
452
|
+
console.print("[green]✓[/green] Applied business context")
|
|
453
|
+
|
|
454
|
+
# Update enrichment metrics
|
|
455
|
+
record_event(
|
|
456
|
+
{
|
|
457
|
+
"enrichment_applied": True,
|
|
458
|
+
"features_added": len(enrichment_report.missing_features),
|
|
459
|
+
"confidence_adjusted": len(enrichment_report.confidence_adjustments),
|
|
460
|
+
}
|
|
461
|
+
)
|
|
462
|
+
except Exception as e:
|
|
463
|
+
console.print(f"[bold red]✗ Failed to apply enrichment: {e}[/bold red]")
|
|
464
|
+
raise typer.Exit(1) from e
|
|
465
|
+
|
|
466
|
+
# Generate plan file
|
|
467
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
468
|
+
generator = PlanGenerator()
|
|
469
|
+
generator.generate(plan_bundle, out)
|
|
470
|
+
|
|
471
|
+
console.print("[bold green]✓ Import complete![/bold green]")
|
|
472
|
+
if enrichment and original_plan_path and original_plan_path.exists():
|
|
473
|
+
console.print(f"[dim]Original plan: {original_plan_path.name}[/dim]")
|
|
474
|
+
console.print(f"[dim]Enriched plan: {out.name}[/dim]")
|
|
475
|
+
else:
|
|
476
|
+
console.print(f"[dim]Plan bundle written to: {out}[/dim]")
|
|
477
|
+
|
|
478
|
+
# Suggest constitution bootstrap for brownfield imports
|
|
479
|
+
specify_dir = repo / ".specify" / "memory"
|
|
480
|
+
constitution_path = specify_dir / "constitution.md"
|
|
481
|
+
if not constitution_path.exists() or (
|
|
482
|
+
constitution_path.exists()
|
|
483
|
+
and constitution_path.read_text(encoding="utf-8").strip() in ("", "# Constitution")
|
|
484
|
+
):
|
|
485
|
+
# Auto-generate in test mode, prompt in interactive mode
|
|
486
|
+
import os
|
|
487
|
+
|
|
488
|
+
# Check for test environment (TEST_MODE or PYTEST_CURRENT_TEST)
|
|
489
|
+
is_test_env = os.environ.get("TEST_MODE") == "true" or os.environ.get("PYTEST_CURRENT_TEST") is not None
|
|
490
|
+
if is_test_env:
|
|
491
|
+
# Auto-generate bootstrap constitution in test mode
|
|
492
|
+
from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher
|
|
493
|
+
|
|
494
|
+
specify_dir.mkdir(parents=True, exist_ok=True)
|
|
495
|
+
enricher = ConstitutionEnricher()
|
|
496
|
+
enriched_content = enricher.bootstrap(repo, constitution_path)
|
|
497
|
+
constitution_path.write_text(enriched_content, encoding="utf-8")
|
|
498
|
+
else:
|
|
499
|
+
# Check if we're in an interactive environment
|
|
500
|
+
import sys
|
|
501
|
+
|
|
502
|
+
is_interactive = (hasattr(sys.stdin, "isatty") and sys.stdin.isatty()) and sys.stdin.isatty()
|
|
503
|
+
if is_interactive:
|
|
504
|
+
console.print()
|
|
505
|
+
console.print(
|
|
506
|
+
"[bold cyan]💡 Tip:[/bold cyan] Generate project constitution for Spec-Kit integration"
|
|
507
|
+
)
|
|
508
|
+
suggest_constitution = typer.confirm(
|
|
509
|
+
"Generate bootstrap constitution from repository analysis?",
|
|
510
|
+
default=True,
|
|
511
|
+
)
|
|
512
|
+
if suggest_constitution:
|
|
513
|
+
from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher
|
|
514
|
+
|
|
515
|
+
console.print("[dim]Generating bootstrap constitution...[/dim]")
|
|
516
|
+
specify_dir.mkdir(parents=True, exist_ok=True)
|
|
517
|
+
enricher = ConstitutionEnricher()
|
|
518
|
+
enriched_content = enricher.bootstrap(repo, constitution_path)
|
|
519
|
+
constitution_path.write_text(enriched_content, encoding="utf-8")
|
|
520
|
+
console.print("[bold green]✓[/bold green] Bootstrap constitution generated")
|
|
521
|
+
console.print(f"[dim]Review and adjust: {constitution_path}[/dim]")
|
|
522
|
+
console.print(
|
|
523
|
+
"[dim]Then run 'specfact sync spec-kit' to sync with Spec-Kit artifacts[/dim]"
|
|
524
|
+
)
|
|
525
|
+
else:
|
|
526
|
+
# Non-interactive mode: skip prompt
|
|
527
|
+
console.print()
|
|
528
|
+
console.print(
|
|
529
|
+
"[dim]💡 Tip: Run 'specfact constitution bootstrap --repo .' to generate constitution[/dim]"
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
# Enrich for Spec-Kit compliance if requested
|
|
533
|
+
if enrich_for_speckit:
|
|
534
|
+
console.print("\n[cyan]🔧 Enriching plan for Spec-Kit compliance...[/cyan]")
|
|
535
|
+
try:
|
|
536
|
+
from specfact_cli.analyzers.ambiguity_scanner import AmbiguityScanner
|
|
537
|
+
|
|
538
|
+
# Run plan review to identify gaps
|
|
539
|
+
console.print("[dim]Running plan review to identify gaps...[/dim]")
|
|
540
|
+
scanner = AmbiguityScanner()
|
|
541
|
+
_ambiguity_report = scanner.scan(plan_bundle) # Scanned but not used in auto-enrichment
|
|
542
|
+
|
|
543
|
+
# Add missing stories for features with only 1 story
|
|
544
|
+
features_with_one_story = [f for f in plan_bundle.features if len(f.stories) == 1]
|
|
545
|
+
if features_with_one_story:
|
|
546
|
+
console.print(
|
|
547
|
+
f"[yellow]⚠ Found {len(features_with_one_story)} features with only 1 story[/yellow]"
|
|
548
|
+
)
|
|
549
|
+
console.print("[dim]Adding edge case stories for better Spec-Kit compliance...[/dim]")
|
|
550
|
+
|
|
551
|
+
for feature in features_with_one_story:
|
|
552
|
+
# Generate edge case story based on feature title
|
|
553
|
+
edge_case_title = f"As a user, I receive error handling for {feature.title.lower()}"
|
|
554
|
+
edge_case_acceptance = [
|
|
555
|
+
"Must verify error conditions are handled gracefully",
|
|
556
|
+
"Must validate error messages are clear and actionable",
|
|
557
|
+
"Must ensure system recovers from errors",
|
|
558
|
+
]
|
|
559
|
+
|
|
560
|
+
# Find next story number - extract from existing story keys
|
|
561
|
+
existing_story_nums = []
|
|
562
|
+
for s in feature.stories:
|
|
563
|
+
# Story keys are like STORY-CLASSNAME-001 or STORY-001
|
|
564
|
+
parts = s.key.split("-")
|
|
565
|
+
if len(parts) >= 2:
|
|
566
|
+
# Get the last part which should be the number
|
|
567
|
+
last_part = parts[-1]
|
|
568
|
+
if last_part.isdigit():
|
|
569
|
+
existing_story_nums.append(int(last_part))
|
|
570
|
+
|
|
571
|
+
next_story_num = max(existing_story_nums) + 1 if existing_story_nums else 2
|
|
572
|
+
|
|
573
|
+
# Extract class name from feature key (FEATURE-CLASSNAME -> CLASSNAME)
|
|
574
|
+
feature_key_parts = feature.key.split("-")
|
|
575
|
+
if len(feature_key_parts) >= 2:
|
|
576
|
+
class_name = feature_key_parts[-1] # Get last part (CLASSNAME)
|
|
577
|
+
story_key = f"STORY-{class_name}-{next_story_num:03d}"
|
|
578
|
+
else:
|
|
579
|
+
# Fallback if feature key format is unexpected
|
|
580
|
+
story_key = f"STORY-{next_story_num:03d}"
|
|
581
|
+
|
|
582
|
+
from specfact_cli.models.plan import Story
|
|
583
|
+
|
|
584
|
+
edge_case_story = Story(
|
|
585
|
+
key=story_key,
|
|
586
|
+
title=edge_case_title,
|
|
587
|
+
acceptance=edge_case_acceptance,
|
|
588
|
+
story_points=3,
|
|
589
|
+
value_points=None,
|
|
590
|
+
confidence=0.8,
|
|
591
|
+
scenarios=None,
|
|
592
|
+
contracts=None,
|
|
593
|
+
)
|
|
594
|
+
feature.stories.append(edge_case_story)
|
|
595
|
+
|
|
596
|
+
# Regenerate plan with new stories
|
|
597
|
+
generator = PlanGenerator()
|
|
598
|
+
generator.generate(plan_bundle, out)
|
|
599
|
+
console.print(
|
|
600
|
+
f"[green]✓ Added edge case stories to {len(features_with_one_story)} features[/green]"
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
# Ensure testable acceptance criteria
|
|
604
|
+
features_updated = 0
|
|
605
|
+
for feature in plan_bundle.features:
|
|
606
|
+
for story in feature.stories:
|
|
607
|
+
# Check if acceptance criteria are testable
|
|
608
|
+
testable_count = sum(
|
|
609
|
+
1
|
|
610
|
+
for acc in story.acceptance
|
|
611
|
+
if any(
|
|
612
|
+
keyword in acc.lower()
|
|
613
|
+
for keyword in ["must", "should", "verify", "validate", "ensure"]
|
|
614
|
+
)
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
if testable_count < len(story.acceptance) and len(story.acceptance) > 0:
|
|
618
|
+
# Enhance acceptance criteria to be more testable
|
|
619
|
+
enhanced_acceptance = []
|
|
620
|
+
for acc in story.acceptance:
|
|
621
|
+
if not any(
|
|
622
|
+
keyword in acc.lower()
|
|
623
|
+
for keyword in ["must", "should", "verify", "validate", "ensure"]
|
|
624
|
+
):
|
|
625
|
+
# Convert to testable format
|
|
626
|
+
if acc.startswith(("User can", "System can")):
|
|
627
|
+
enhanced_acceptance.append(f"Must verify {acc.lower()}")
|
|
628
|
+
else:
|
|
629
|
+
enhanced_acceptance.append(f"Must verify {acc}")
|
|
630
|
+
else:
|
|
631
|
+
enhanced_acceptance.append(acc)
|
|
632
|
+
|
|
633
|
+
story.acceptance = enhanced_acceptance
|
|
634
|
+
features_updated += 1
|
|
635
|
+
|
|
636
|
+
if features_updated > 0:
|
|
637
|
+
# Regenerate plan with enhanced acceptance criteria
|
|
638
|
+
generator = PlanGenerator()
|
|
639
|
+
generator.generate(plan_bundle, out)
|
|
640
|
+
console.print(f"[green]✓ Enhanced acceptance criteria for {features_updated} stories[/green]")
|
|
641
|
+
|
|
642
|
+
console.print("[green]✓ Spec-Kit enrichment complete[/green]")
|
|
643
|
+
|
|
644
|
+
except Exception as e:
|
|
645
|
+
console.print(f"[yellow]⚠ Spec-Kit enrichment failed: {e}[/yellow]")
|
|
646
|
+
console.print("[dim]Plan is still valid, but may need manual enrichment[/dim]")
|
|
647
|
+
|
|
648
|
+
# Validate generated plan
|
|
649
|
+
is_valid, error, _ = validate_plan_bundle(out)
|
|
650
|
+
if is_valid:
|
|
651
|
+
console.print("[green]✓ Plan validation passed[/green]")
|
|
652
|
+
else:
|
|
653
|
+
console.print(f"[yellow]⚠ Plan validation warning: {error}[/yellow]")
|
|
654
|
+
|
|
655
|
+
# Generate report
|
|
656
|
+
report_content = f"""# Brownfield Import Report
|
|
657
|
+
|
|
658
|
+
## Repository: {repo}
|
|
659
|
+
|
|
660
|
+
## Summary
|
|
661
|
+
- **Features Found**: {len(plan_bundle.features)}
|
|
662
|
+
- **Total Stories**: {total_stories}
|
|
663
|
+
- **Detected Themes**: {", ".join(plan_bundle.product.themes)}
|
|
664
|
+
- **Confidence Threshold**: {confidence}
|
|
665
|
+
"""
|
|
666
|
+
if enrichment and original_plan_path and original_plan_path.exists():
|
|
667
|
+
report_content += f"""
|
|
668
|
+
## Enrichment Applied
|
|
669
|
+
- **Original Plan**: `{original_plan_path}`
|
|
670
|
+
- **Enriched Plan**: `{out}`
|
|
671
|
+
- **Enrichment Report**: `{enrichment}`
|
|
672
|
+
"""
|
|
673
|
+
report_content += f"""
|
|
674
|
+
## Output Files
|
|
675
|
+
- **Plan Bundle**: `{out}`
|
|
676
|
+
- **Import Report**: `{report}`
|
|
677
|
+
|
|
678
|
+
## Features
|
|
679
|
+
|
|
680
|
+
"""
|
|
681
|
+
for feature in plan_bundle.features:
|
|
682
|
+
report_content += f"### {feature.title} ({feature.key})\n"
|
|
683
|
+
report_content += f"- **Stories**: {len(feature.stories)}\n"
|
|
684
|
+
report_content += f"- **Confidence**: {feature.confidence}\n"
|
|
685
|
+
report_content += f"- **Outcomes**: {', '.join(feature.outcomes)}\n\n"
|
|
686
|
+
|
|
687
|
+
# Type guard: report is guaranteed to be Path after line 323
|
|
688
|
+
assert report is not None, "Report path must be set"
|
|
689
|
+
report.write_text(report_content)
|
|
690
|
+
console.print(f"[dim]Report written to: {report}[/dim]")
|
|
691
|
+
|
|
692
|
+
except Exception as e:
|
|
693
|
+
console.print(f"[bold red]✗ Import failed:[/bold red] {e}")
|
|
694
|
+
raise typer.Exit(1) from e
|