specfact-cli 0.4.2__py3-none-any.whl → 0.6.8__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 +1 -1
- specfact_cli/agents/analyze_agent.py +2 -3
- specfact_cli/analyzers/__init__.py +2 -1
- specfact_cli/analyzers/ambiguity_scanner.py +601 -0
- specfact_cli/analyzers/code_analyzer.py +462 -30
- specfact_cli/analyzers/constitution_evidence_extractor.py +491 -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 +151 -206
- specfact_cli/commands/constitution.py +281 -0
- specfact_cli/commands/enforce.py +42 -34
- specfact_cli/commands/import_cmd.py +481 -152
- specfact_cli/commands/init.py +224 -55
- specfact_cli/commands/plan.py +2133 -547
- specfact_cli/commands/repro.py +100 -78
- specfact_cli/commands/sync.py +701 -186
- specfact_cli/enrichers/constitution_enricher.py +765 -0
- specfact_cli/enrichers/plan_enricher.py +294 -0
- specfact_cli/importers/speckit_converter.py +364 -48
- specfact_cli/importers/speckit_scanner.py +65 -0
- specfact_cli/models/plan.py +42 -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 +626 -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 +1276 -0
- specfact_cli/resources/prompts/specfact-plan-select.md +401 -0
- specfact_cli/resources/prompts/specfact-plan-update-feature.md +242 -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 +497 -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/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 +10 -1
- specfact_cli/sync/watcher.py +268 -0
- specfact_cli/telemetry.py +440 -0
- specfact_cli/utils/acceptance_criteria.py +127 -0
- specfact_cli/utils/enrichment_parser.py +445 -0
- specfact_cli/utils/feature_keys.py +12 -3
- specfact_cli/utils/ide_setup.py +170 -0
- specfact_cli/utils/structure.py +179 -2
- specfact_cli/utils/yaml_utils.py +33 -0
- specfact_cli/validators/repro_checker.py +22 -1
- specfact_cli/validators/schema.py +15 -4
- specfact_cli-0.6.8.dist-info/METADATA +456 -0
- specfact_cli-0.6.8.dist-info/RECORD +99 -0
- {specfact_cli-0.4.2.dist-info → specfact_cli-0.6.8.dist-info}/entry_points.txt +1 -0
- specfact_cli-0.6.8.dist-info/licenses/LICENSE.md +202 -0
- specfact_cli-0.4.2.dist-info/METADATA +0 -370
- specfact_cli-0.4.2.dist-info/RECORD +0 -62
- specfact_cli-0.4.2.dist-info/licenses/LICENSE.md +0 -61
- {specfact_cli-0.4.2.dist-info → specfact_cli-0.6.8.dist-info}/WHEEL +0 -0
|
@@ -15,6 +15,8 @@ from icontract import ensure, require
|
|
|
15
15
|
from rich.console import Console
|
|
16
16
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
17
17
|
|
|
18
|
+
from specfact_cli.telemetry import telemetry
|
|
19
|
+
|
|
18
20
|
|
|
19
21
|
app = typer.Typer(help="Import codebases and Spec-Kit projects to contract format")
|
|
20
22
|
console = Console()
|
|
@@ -30,6 +32,11 @@ def _is_valid_output_path(path: Path | None) -> bool:
|
|
|
30
32
|
return path is None or path.exists()
|
|
31
33
|
|
|
32
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
|
+
|
|
33
40
|
@app.command("from-spec-kit")
|
|
34
41
|
def from_spec_kit(
|
|
35
42
|
repo: Path = typer.Option(
|
|
@@ -79,83 +86,91 @@ def from_spec_kit(
|
|
|
79
86
|
from specfact_cli.importers.speckit_scanner import SpecKitScanner
|
|
80
87
|
from specfact_cli.utils.structure import SpecFactStructure
|
|
81
88
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
89
|
+
telemetry_metadata = {
|
|
90
|
+
"dry_run": dry_run,
|
|
91
|
+
"write": write,
|
|
92
|
+
"force": force,
|
|
93
|
+
}
|
|
86
94
|
|
|
87
|
-
|
|
88
|
-
console.print("[bold
|
|
89
|
-
console.print("[dim]Expected: .specify/ directory[/dim]")
|
|
90
|
-
raise typer.Exit(1)
|
|
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}")
|
|
91
97
|
|
|
92
|
-
|
|
98
|
+
# Scan Spec-Kit structure
|
|
99
|
+
scanner = SpecKitScanner(repo)
|
|
93
100
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
console.print(f" - Specs Directory: {structure.get('specs_dir', 'Not found')}")
|
|
98
|
-
console.print(f" - Memory Directory: {structure.get('specify_memory_dir', 'Not found')}")
|
|
99
|
-
if structure.get("feature_dirs"):
|
|
100
|
-
console.print(f" - Features Found: {len(structure['feature_dirs'])}")
|
|
101
|
-
if structure.get("memory_files"):
|
|
102
|
-
console.print(f" - Memory Files: {len(structure['memory_files'])}")
|
|
103
|
-
return
|
|
104
|
-
|
|
105
|
-
if not write:
|
|
106
|
-
console.print("[yellow]→ Use --write to actually convert files[/yellow]")
|
|
107
|
-
console.print("[dim]Use --dry-run to preview changes[/dim]")
|
|
108
|
-
return
|
|
109
|
-
|
|
110
|
-
# Ensure SpecFact structure exists
|
|
111
|
-
SpecFactStructure.ensure_structure(repo)
|
|
112
|
-
|
|
113
|
-
with Progress(
|
|
114
|
-
SpinnerColumn(),
|
|
115
|
-
TextColumn("[progress.description]{task.description}"),
|
|
116
|
-
console=console,
|
|
117
|
-
) as progress:
|
|
118
|
-
# Step 1: Discover features from markdown artifacts
|
|
119
|
-
task = progress.add_task("Discovering Spec-Kit features...", total=None)
|
|
120
|
-
features = scanner.discover_features()
|
|
121
|
-
if not features:
|
|
122
|
-
console.print("[bold red]✗[/bold red] No features found in Spec-Kit repository")
|
|
123
|
-
console.print("[dim]Expected: specs/*/spec.md files[/dim]")
|
|
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]")
|
|
124
104
|
raise typer.Exit(1)
|
|
125
|
-
progress.update(task, description=f"✓ Discovered {len(features)} features")
|
|
126
|
-
|
|
127
|
-
# Step 2: Convert protocol
|
|
128
|
-
task = progress.add_task("Converting protocol...", total=None)
|
|
129
|
-
converter = SpecKitConverter(repo)
|
|
130
|
-
protocol = None
|
|
131
|
-
plan_bundle = None
|
|
132
|
-
try:
|
|
133
|
-
protocol = converter.convert_protocol()
|
|
134
|
-
progress.update(task, description=f"✓ Protocol converted ({len(protocol.states)} states)")
|
|
135
105
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
140
170
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
progress.update(task, description="✓ Semgrep rules generated")
|
|
145
|
-
|
|
146
|
-
# Step 5: Generate GitHub Action workflow
|
|
147
|
-
task = progress.add_task("Generating GitHub Action workflow...", total=None)
|
|
148
|
-
repo_name = repo.name if isinstance(repo, Path) else None
|
|
149
|
-
_workflow_path = converter.generate_github_action(repo_name=repo_name) # Not used yet
|
|
150
|
-
progress.update(task, description="✓ GitHub Action workflow generated")
|
|
151
|
-
|
|
152
|
-
except Exception as e:
|
|
153
|
-
console.print(f"[bold red]✗[/bold red] Conversion failed: {e}")
|
|
154
|
-
raise typer.Exit(1) from e
|
|
155
|
-
|
|
156
|
-
# Generate report
|
|
157
|
-
if report and protocol and plan_bundle:
|
|
158
|
-
report_content = f"""# Spec-Kit Import Report
|
|
171
|
+
# Generate report
|
|
172
|
+
if report and protocol and plan_bundle:
|
|
173
|
+
report_content = f"""# Spec-Kit Import Report
|
|
159
174
|
|
|
160
175
|
## Repository: {repo}
|
|
161
176
|
|
|
@@ -177,15 +192,26 @@ def from_spec_kit(
|
|
|
177
192
|
## Features
|
|
178
193
|
{chr(10).join(f"- {f.title} ({f.key})" for f in plan_bundle.features)}
|
|
179
194
|
"""
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
+
)
|
|
189
215
|
|
|
190
216
|
|
|
191
217
|
@app.command("from-code")
|
|
@@ -234,6 +260,21 @@ def from_code(
|
|
|
234
260
|
"--key-format",
|
|
235
261
|
help="Feature key format: 'classname' (FEATURE-CLASSNAME) or 'sequential' (FEATURE-001)",
|
|
236
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
|
+
),
|
|
237
278
|
) -> None:
|
|
238
279
|
"""
|
|
239
280
|
Import plan bundle from existing codebase (one-way import).
|
|
@@ -241,8 +282,13 @@ def from_code(
|
|
|
241
282
|
Analyzes code structure using AI-first semantic understanding or AST-based fallback
|
|
242
283
|
to generate a plan bundle that represents the current system.
|
|
243
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
|
+
|
|
244
289
|
Example:
|
|
245
290
|
specfact import from-code --repo . --out brownfield-plan.yaml
|
|
291
|
+
specfact import from-code --repo . --enrichment enrichment-report.md
|
|
246
292
|
"""
|
|
247
293
|
from specfact_cli.agents.analyze_agent import AnalyzeAgent
|
|
248
294
|
from specfact_cli.agents.registry import get_agent
|
|
@@ -255,6 +301,8 @@ def from_code(
|
|
|
255
301
|
router = get_router()
|
|
256
302
|
routing_result = router.route("import from-code", mode, {"repo": str(repo), "confidence": confidence})
|
|
257
303
|
|
|
304
|
+
python_file_count = _count_python_files(repo)
|
|
305
|
+
|
|
258
306
|
from specfact_cli.generators.plan_generator import PlanGenerator
|
|
259
307
|
from specfact_cli.utils.structure import SpecFactStructure
|
|
260
308
|
from specfact_cli.validators.schema import validate_plan_bundle
|
|
@@ -263,7 +311,17 @@ def from_code(
|
|
|
263
311
|
SpecFactStructure.ensure_structure(repo)
|
|
264
312
|
|
|
265
313
|
# Use default paths if not specified (relative to repo)
|
|
266
|
-
|
|
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:
|
|
267
325
|
out = SpecFactStructure.get_timestamped_brownfield_report(repo, name=name)
|
|
268
326
|
|
|
269
327
|
if report is None:
|
|
@@ -275,66 +333,327 @@ def from_code(
|
|
|
275
333
|
if shadow_only:
|
|
276
334
|
console.print("[yellow]→ Shadow mode - observe without enforcement[/yellow]")
|
|
277
335
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if agent and isinstance(agent, AnalyzeAgent):
|
|
285
|
-
# Build context for agent
|
|
286
|
-
context = {
|
|
287
|
-
"workspace": str(repo),
|
|
288
|
-
"current_file": None, # TODO: Get from IDE in Phase 4.2+
|
|
289
|
-
"selection": None, # TODO: Get from IDE in Phase 4.2+
|
|
290
|
-
}
|
|
291
|
-
# Inject context (for future LLM integration)
|
|
292
|
-
_enhanced_context = agent.inject_context(context)
|
|
293
|
-
# Use AI-first import
|
|
294
|
-
console.print("\n[cyan]🤖 AI-powered import (semantic understanding)...[/cyan]")
|
|
295
|
-
plan_bundle = agent.analyze_codebase(repo, confidence=confidence, plan_name=name)
|
|
296
|
-
console.print("[green]✓[/green] AI import complete")
|
|
297
|
-
else:
|
|
298
|
-
# Fallback to AST if agent not available
|
|
299
|
-
console.print("[yellow]⚠ Agent not available, falling back to AST-based import[/yellow]")
|
|
300
|
-
from specfact_cli.analyzers.code_analyzer import CodeAnalyzer
|
|
301
|
-
|
|
302
|
-
console.print("\n[cyan]🔍 Importing Python files (AST-based fallback)...[/cyan]")
|
|
303
|
-
analyzer = CodeAnalyzer(repo, confidence_threshold=confidence, key_format=key_format, plan_name=name)
|
|
304
|
-
plan_bundle = analyzer.analyze()
|
|
305
|
-
else:
|
|
306
|
-
# CI/CD mode: use AST-based import (no LLM available)
|
|
307
|
-
console.print("[dim]Mode: CI/CD (AST-based import)[/dim]")
|
|
308
|
-
from specfact_cli.analyzers.code_analyzer import CodeAnalyzer
|
|
309
|
-
|
|
310
|
-
console.print("\n[cyan]🔍 Importing Python files...[/cyan]")
|
|
311
|
-
analyzer = CodeAnalyzer(repo, confidence_threshold=confidence, key_format=key_format, plan_name=name)
|
|
312
|
-
plan_bundle = analyzer.analyze()
|
|
313
|
-
|
|
314
|
-
console.print(f"[green]✓[/green] Found {len(plan_bundle.features)} features")
|
|
315
|
-
console.print(f"[green]✓[/green] Detected themes: {', '.join(plan_bundle.product.themes)}")
|
|
316
|
-
|
|
317
|
-
# Show summary
|
|
318
|
-
total_stories = sum(len(f.stories) for f in plan_bundle.features)
|
|
319
|
-
console.print(f"[green]✓[/green] Total stories: {total_stories}\n")
|
|
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
|
+
}
|
|
320
342
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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]")
|
|
335
654
|
|
|
336
|
-
|
|
337
|
-
|
|
655
|
+
# Generate report
|
|
656
|
+
report_content = f"""# Brownfield Import Report
|
|
338
657
|
|
|
339
658
|
## Repository: {repo}
|
|
340
659
|
|
|
@@ -343,7 +662,15 @@ def from_code(
|
|
|
343
662
|
- **Total Stories**: {total_stories}
|
|
344
663
|
- **Detected Themes**: {", ".join(plan_bundle.product.themes)}
|
|
345
664
|
- **Confidence Threshold**: {confidence}
|
|
346
|
-
|
|
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"""
|
|
347
674
|
## Output Files
|
|
348
675
|
- **Plan Bundle**: `{out}`
|
|
349
676
|
- **Import Report**: `{report}`
|
|
@@ -351,15 +678,17 @@ def from_code(
|
|
|
351
678
|
## Features
|
|
352
679
|
|
|
353
680
|
"""
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|