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.
Files changed (66) hide show
  1. specfact_cli/__init__.py +1 -1
  2. specfact_cli/agents/analyze_agent.py +2 -3
  3. specfact_cli/analyzers/__init__.py +2 -1
  4. specfact_cli/analyzers/ambiguity_scanner.py +601 -0
  5. specfact_cli/analyzers/code_analyzer.py +462 -30
  6. specfact_cli/analyzers/constitution_evidence_extractor.py +491 -0
  7. specfact_cli/analyzers/contract_extractor.py +419 -0
  8. specfact_cli/analyzers/control_flow_analyzer.py +281 -0
  9. specfact_cli/analyzers/requirement_extractor.py +337 -0
  10. specfact_cli/analyzers/test_pattern_extractor.py +330 -0
  11. specfact_cli/cli.py +151 -206
  12. specfact_cli/commands/constitution.py +281 -0
  13. specfact_cli/commands/enforce.py +42 -34
  14. specfact_cli/commands/import_cmd.py +481 -152
  15. specfact_cli/commands/init.py +224 -55
  16. specfact_cli/commands/plan.py +2133 -547
  17. specfact_cli/commands/repro.py +100 -78
  18. specfact_cli/commands/sync.py +701 -186
  19. specfact_cli/enrichers/constitution_enricher.py +765 -0
  20. specfact_cli/enrichers/plan_enricher.py +294 -0
  21. specfact_cli/importers/speckit_converter.py +364 -48
  22. specfact_cli/importers/speckit_scanner.py +65 -0
  23. specfact_cli/models/plan.py +42 -0
  24. specfact_cli/resources/mappings/node-async.yaml +49 -0
  25. specfact_cli/resources/mappings/python-async.yaml +47 -0
  26. specfact_cli/resources/mappings/speckit-default.yaml +82 -0
  27. specfact_cli/resources/prompts/specfact-enforce.md +185 -0
  28. specfact_cli/resources/prompts/specfact-import-from-code.md +626 -0
  29. specfact_cli/resources/prompts/specfact-plan-add-feature.md +188 -0
  30. specfact_cli/resources/prompts/specfact-plan-add-story.md +212 -0
  31. specfact_cli/resources/prompts/specfact-plan-compare.md +571 -0
  32. specfact_cli/resources/prompts/specfact-plan-init.md +531 -0
  33. specfact_cli/resources/prompts/specfact-plan-promote.md +352 -0
  34. specfact_cli/resources/prompts/specfact-plan-review.md +1276 -0
  35. specfact_cli/resources/prompts/specfact-plan-select.md +401 -0
  36. specfact_cli/resources/prompts/specfact-plan-update-feature.md +242 -0
  37. specfact_cli/resources/prompts/specfact-plan-update-idea.md +211 -0
  38. specfact_cli/resources/prompts/specfact-repro.md +268 -0
  39. specfact_cli/resources/prompts/specfact-sync.md +497 -0
  40. specfact_cli/resources/schemas/deviation.schema.json +61 -0
  41. specfact_cli/resources/schemas/plan.schema.json +204 -0
  42. specfact_cli/resources/schemas/protocol.schema.json +53 -0
  43. specfact_cli/resources/templates/github-action.yml.j2 +140 -0
  44. specfact_cli/resources/templates/plan.bundle.yaml.j2 +141 -0
  45. specfact_cli/resources/templates/pr-template.md.j2 +58 -0
  46. specfact_cli/resources/templates/protocol.yaml.j2 +24 -0
  47. specfact_cli/resources/templates/telemetry.yaml.example +35 -0
  48. specfact_cli/sync/__init__.py +10 -1
  49. specfact_cli/sync/watcher.py +268 -0
  50. specfact_cli/telemetry.py +440 -0
  51. specfact_cli/utils/acceptance_criteria.py +127 -0
  52. specfact_cli/utils/enrichment_parser.py +445 -0
  53. specfact_cli/utils/feature_keys.py +12 -3
  54. specfact_cli/utils/ide_setup.py +170 -0
  55. specfact_cli/utils/structure.py +179 -2
  56. specfact_cli/utils/yaml_utils.py +33 -0
  57. specfact_cli/validators/repro_checker.py +22 -1
  58. specfact_cli/validators/schema.py +15 -4
  59. specfact_cli-0.6.8.dist-info/METADATA +456 -0
  60. specfact_cli-0.6.8.dist-info/RECORD +99 -0
  61. {specfact_cli-0.4.2.dist-info → specfact_cli-0.6.8.dist-info}/entry_points.txt +1 -0
  62. specfact_cli-0.6.8.dist-info/licenses/LICENSE.md +202 -0
  63. specfact_cli-0.4.2.dist-info/METADATA +0 -370
  64. specfact_cli-0.4.2.dist-info/RECORD +0 -62
  65. specfact_cli-0.4.2.dist-info/licenses/LICENSE.md +0 -61
  66. {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
- console.print(f"[bold cyan]Importing Spec-Kit project from:[/bold cyan] {repo}")
83
-
84
- # Scan Spec-Kit structure
85
- scanner = SpecKitScanner(repo)
89
+ telemetry_metadata = {
90
+ "dry_run": dry_run,
91
+ "write": write,
92
+ "force": force,
93
+ }
86
94
 
87
- if not scanner.is_speckit_repo():
88
- console.print("[bold red][/bold red] Not a Spec-Kit repository")
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
- structure = scanner.scan_structure()
98
+ # Scan Spec-Kit structure
99
+ scanner = SpecKitScanner(repo)
93
100
 
94
- if dry_run:
95
- console.print("[yellow] Dry run mode - no files will be written[/yellow]")
96
- console.print("\n[bold]Detected Structure:[/bold]")
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
- # Step 3: Convert plan
137
- task = progress.add_task("Converting plan bundle...", total=None)
138
- plan_bundle = converter.convert_plan()
139
- progress.update(task, description=f" Plan converted ({len(plan_bundle.features)} features)")
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
- # Step 4: Generate Semgrep rules
142
- task = progress.add_task("Generating Semgrep rules...", total=None)
143
- _semgrep_path = converter.generate_semgrep_rules() # Not used yet
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
- report.parent.mkdir(parents=True, exist_ok=True)
181
- report.write_text(report_content, encoding="utf-8")
182
- console.print(f"[dim]Report written to: {report}[/dim]")
183
-
184
- console.print("[bold green]✓[/bold green] Import complete!")
185
- console.print("[dim]Protocol: .specfact/protocols/workflow.protocol.yaml[/dim]")
186
- console.print("[dim]Plan: .specfact/plans/main.bundle.yaml[/dim]")
187
- console.print("[dim]Semgrep Rules: .semgrep/async-anti-patterns.yml[/dim]")
188
- console.print("[dim]GitHub Action: .github/workflows/specfact-gate.yml[/dim]")
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
- if out is None:
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
- try:
279
- # Use AI-first approach in CoPilot mode, fallback to AST in CI/CD mode
280
- if routing_result.execution_mode == "agent":
281
- console.print("[dim]Mode: CoPilot (AI-first import)[/dim]")
282
- # Get agent for this command
283
- agent = get_agent("import from-code")
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
- # Generate plan file
322
- out.parent.mkdir(parents=True, exist_ok=True)
323
- generator = PlanGenerator()
324
- generator.generate(plan_bundle, out)
325
-
326
- console.print("[bold green]✓ Import complete![/bold green]")
327
- console.print(f"[dim]Plan bundle written to: {out}[/dim]")
328
-
329
- # Validate generated plan
330
- is_valid, error, _ = validate_plan_bundle(out)
331
- if is_valid:
332
- console.print("[green]✓ Plan validation passed[/green]")
333
- else:
334
- console.print(f"[yellow]⚠ Plan validation warning: {error}[/yellow]")
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
- # Generate report
337
- report_content = f"""# Brownfield Import Report
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
- for feature in plan_bundle.features:
355
- report_content += f"### {feature.title} ({feature.key})\n"
356
- report_content += f"- **Stories**: {len(feature.stories)}\n"
357
- report_content += f"- **Confidence**: {feature.confidence}\n"
358
- report_content += f"- **Outcomes**: {', '.join(feature.outcomes)}\n\n"
359
-
360
- report.write_text(report_content)
361
- console.print(f"[dim]Report written to: {report}[/dim]")
362
-
363
- except Exception as e:
364
- console.print(f"[bold red]✗ Import failed:[/bold red] {e}")
365
- raise typer.Exit(1) from e
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