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
specfact_cli/commands/sync.py
CHANGED
|
@@ -7,136 +7,66 @@ repository changes, and SpecFact plans.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import os
|
|
10
11
|
import shutil
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any
|
|
13
14
|
|
|
14
15
|
import typer
|
|
16
|
+
from beartype import beartype
|
|
17
|
+
from icontract import ensure, require
|
|
15
18
|
from rich.console import Console
|
|
16
19
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
17
20
|
|
|
18
|
-
from specfact_cli.models.plan import PlanBundle
|
|
21
|
+
from specfact_cli.models.plan import Feature, PlanBundle
|
|
19
22
|
from specfact_cli.sync.speckit_sync import SpecKitSync
|
|
23
|
+
from specfact_cli.telemetry import telemetry
|
|
20
24
|
|
|
21
25
|
|
|
22
26
|
app = typer.Typer(help="Synchronize Spec-Kit artifacts and repository changes")
|
|
23
27
|
console = Console()
|
|
24
28
|
|
|
25
29
|
|
|
26
|
-
def
|
|
27
|
-
"""
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
# Merge with existing plan if it exists
|
|
51
|
-
features_updated = 0
|
|
52
|
-
features_added = 0
|
|
53
|
-
|
|
54
|
-
if existing_bundle:
|
|
55
|
-
feature_keys_existing = {f.key for f in existing_bundle.features}
|
|
56
|
-
|
|
57
|
-
for feature in converted_bundle.features:
|
|
58
|
-
if feature.key in feature_keys_existing:
|
|
59
|
-
existing_idx = next(i for i, f in enumerate(existing_bundle.features) if f.key == feature.key)
|
|
60
|
-
existing_bundle.features[existing_idx] = feature
|
|
61
|
-
features_updated += 1
|
|
62
|
-
else:
|
|
63
|
-
existing_bundle.features.append(feature)
|
|
64
|
-
features_added += 1
|
|
65
|
-
|
|
66
|
-
# Update product themes
|
|
67
|
-
themes_existing = set(existing_bundle.product.themes)
|
|
68
|
-
themes_new = set(converted_bundle.product.themes)
|
|
69
|
-
existing_bundle.product.themes = list(themes_existing | themes_new)
|
|
70
|
-
|
|
71
|
-
# Write merged bundle
|
|
72
|
-
generator = PlanGenerator()
|
|
73
|
-
generator.generate(existing_bundle, plan_path)
|
|
74
|
-
return existing_bundle, features_updated, features_added
|
|
75
|
-
# Write new bundle
|
|
76
|
-
generator = PlanGenerator()
|
|
77
|
-
generator.generate(converted_bundle, plan_path)
|
|
78
|
-
return converted_bundle, 0, len(converted_bundle.features)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
@app.command("spec-kit")
|
|
82
|
-
def sync_spec_kit(
|
|
83
|
-
repo: Path = typer.Option(
|
|
84
|
-
Path("."),
|
|
85
|
-
"--repo",
|
|
86
|
-
help="Path to repository",
|
|
87
|
-
exists=True,
|
|
88
|
-
file_okay=False,
|
|
89
|
-
dir_okay=True,
|
|
90
|
-
),
|
|
91
|
-
bidirectional: bool = typer.Option(
|
|
92
|
-
False,
|
|
93
|
-
"--bidirectional",
|
|
94
|
-
help="Enable bidirectional sync (Spec-Kit ↔ SpecFact)",
|
|
95
|
-
),
|
|
96
|
-
plan: Path | None = typer.Option(
|
|
97
|
-
None,
|
|
98
|
-
"--plan",
|
|
99
|
-
help="Path to SpecFact plan bundle for SpecFact → Spec-Kit conversion (default: .specfact/plans/main.bundle.yaml)",
|
|
100
|
-
),
|
|
101
|
-
overwrite: bool = typer.Option(
|
|
102
|
-
False,
|
|
103
|
-
"--overwrite",
|
|
104
|
-
help="Overwrite existing Spec-Kit artifacts (delete all existing before sync)",
|
|
105
|
-
),
|
|
106
|
-
watch: bool = typer.Option(
|
|
107
|
-
False,
|
|
108
|
-
"--watch",
|
|
109
|
-
help="Watch mode for continuous sync",
|
|
110
|
-
),
|
|
111
|
-
interval: int = typer.Option(
|
|
112
|
-
5,
|
|
113
|
-
"--interval",
|
|
114
|
-
help="Watch interval in seconds (default: 5)",
|
|
115
|
-
min=1,
|
|
116
|
-
),
|
|
30
|
+
def _is_test_mode() -> bool:
|
|
31
|
+
"""Check if running in test mode."""
|
|
32
|
+
# Check for TEST_MODE environment variable
|
|
33
|
+
if os.environ.get("TEST_MODE") == "true":
|
|
34
|
+
return True
|
|
35
|
+
# Check if running under pytest (common patterns)
|
|
36
|
+
import sys
|
|
37
|
+
|
|
38
|
+
return any("pytest" in arg or "test" in arg.lower() for arg in sys.argv) or "pytest" in sys.modules
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@beartype
|
|
42
|
+
@require(lambda repo: repo.exists(), "Repository path must exist")
|
|
43
|
+
@require(lambda repo: repo.is_dir(), "Repository path must be a directory")
|
|
44
|
+
@require(lambda bidirectional: isinstance(bidirectional, bool), "Bidirectional must be bool")
|
|
45
|
+
@require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path")
|
|
46
|
+
@require(lambda overwrite: isinstance(overwrite, bool), "Overwrite must be bool")
|
|
47
|
+
@ensure(lambda result: result is None, "Must return None")
|
|
48
|
+
def _perform_sync_operation(
|
|
49
|
+
repo: Path,
|
|
50
|
+
bidirectional: bool,
|
|
51
|
+
plan: Path | None,
|
|
52
|
+
overwrite: bool,
|
|
117
53
|
) -> None:
|
|
118
54
|
"""
|
|
119
|
-
|
|
55
|
+
Perform sync operation without watch mode.
|
|
120
56
|
|
|
121
|
-
|
|
122
|
-
with SpecFact plan bundles and protocols.
|
|
57
|
+
This is extracted to avoid recursion when called from watch mode callback.
|
|
123
58
|
|
|
124
|
-
|
|
125
|
-
|
|
59
|
+
Args:
|
|
60
|
+
repo: Path to repository
|
|
61
|
+
bidirectional: Enable bidirectional sync
|
|
62
|
+
plan: Path to SpecFact plan bundle
|
|
63
|
+
overwrite: Overwrite existing Spec-Kit artifacts
|
|
126
64
|
"""
|
|
127
65
|
from specfact_cli.importers.speckit_converter import SpecKitConverter
|
|
128
66
|
from specfact_cli.importers.speckit_scanner import SpecKitScanner
|
|
129
67
|
from specfact_cli.utils.structure import SpecFactStructure
|
|
130
68
|
from specfact_cli.validators.schema import validate_plan_bundle
|
|
131
69
|
|
|
132
|
-
console.print(f"[bold cyan]Syncing Spec-Kit artifacts from:[/bold cyan] {repo}")
|
|
133
|
-
|
|
134
|
-
# Watch mode (not implemented yet)
|
|
135
|
-
if watch:
|
|
136
|
-
console.print("[yellow]→ Watch mode enabled (not implemented yet)[/yellow]")
|
|
137
|
-
console.print(f"[dim]Would watch for changes every {interval} seconds[/dim]")
|
|
138
|
-
raise typer.Exit(0)
|
|
139
|
-
|
|
140
70
|
# Step 1: Detect Spec-Kit repository
|
|
141
71
|
scanner = SpecKitScanner(repo)
|
|
142
72
|
if not scanner.is_speckit_repo():
|
|
@@ -146,6 +76,64 @@ def sync_spec_kit(
|
|
|
146
76
|
|
|
147
77
|
console.print("[bold green]✓[/bold green] Detected Spec-Kit repository")
|
|
148
78
|
|
|
79
|
+
# Step 1.5: Validate constitution exists and is not empty
|
|
80
|
+
has_constitution, constitution_error = scanner.has_constitution()
|
|
81
|
+
if not has_constitution:
|
|
82
|
+
console.print("[bold red]✗[/bold red] Constitution required")
|
|
83
|
+
console.print(f"[red]{constitution_error}[/red]")
|
|
84
|
+
console.print("\n[bold yellow]Next Steps:[/bold yellow]")
|
|
85
|
+
console.print("1. Run 'specfact constitution bootstrap --repo .' to auto-generate constitution")
|
|
86
|
+
console.print("2. Or run '/speckit.constitution' command in your AI assistant")
|
|
87
|
+
console.print("3. Then run 'specfact sync spec-kit' again")
|
|
88
|
+
raise typer.Exit(1)
|
|
89
|
+
|
|
90
|
+
# Check if constitution is minimal and suggest bootstrap
|
|
91
|
+
constitution_path = repo / ".specify" / "memory" / "constitution.md"
|
|
92
|
+
if constitution_path.exists():
|
|
93
|
+
from specfact_cli.commands.constitution import is_constitution_minimal
|
|
94
|
+
|
|
95
|
+
if is_constitution_minimal(constitution_path):
|
|
96
|
+
# Auto-generate in test mode, prompt in interactive mode
|
|
97
|
+
# Check for test environment (TEST_MODE or PYTEST_CURRENT_TEST)
|
|
98
|
+
is_test_env = os.environ.get("TEST_MODE") == "true" or os.environ.get("PYTEST_CURRENT_TEST") is not None
|
|
99
|
+
if is_test_env:
|
|
100
|
+
# Auto-generate bootstrap constitution in test mode
|
|
101
|
+
from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher
|
|
102
|
+
|
|
103
|
+
enricher = ConstitutionEnricher()
|
|
104
|
+
enriched_content = enricher.bootstrap(repo, constitution_path)
|
|
105
|
+
constitution_path.write_text(enriched_content, encoding="utf-8")
|
|
106
|
+
else:
|
|
107
|
+
# Check if we're in an interactive environment
|
|
108
|
+
import sys
|
|
109
|
+
|
|
110
|
+
is_interactive = (hasattr(sys.stdin, "isatty") and sys.stdin.isatty()) and sys.stdin.isatty()
|
|
111
|
+
if is_interactive:
|
|
112
|
+
console.print("[yellow]⚠[/yellow] Constitution is minimal (essentially empty)")
|
|
113
|
+
suggest_bootstrap = typer.confirm(
|
|
114
|
+
"Generate bootstrap constitution from repository analysis?",
|
|
115
|
+
default=True,
|
|
116
|
+
)
|
|
117
|
+
if suggest_bootstrap:
|
|
118
|
+
from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher
|
|
119
|
+
|
|
120
|
+
console.print("[dim]Generating bootstrap constitution...[/dim]")
|
|
121
|
+
enricher = ConstitutionEnricher()
|
|
122
|
+
enriched_content = enricher.bootstrap(repo, constitution_path)
|
|
123
|
+
constitution_path.write_text(enriched_content, encoding="utf-8")
|
|
124
|
+
console.print("[bold green]✓[/bold green] Bootstrap constitution generated")
|
|
125
|
+
console.print("[dim]Review and adjust as needed before syncing[/dim]")
|
|
126
|
+
else:
|
|
127
|
+
console.print(
|
|
128
|
+
"[dim]Skipping bootstrap. Run 'specfact constitution bootstrap' manually if needed[/dim]"
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
# Non-interactive mode: skip prompt
|
|
132
|
+
console.print("[yellow]⚠[/yellow] Constitution is minimal (essentially empty)")
|
|
133
|
+
console.print("[dim]Run 'specfact constitution bootstrap --repo .' to generate constitution[/dim]")
|
|
134
|
+
|
|
135
|
+
console.print("[bold green]✓[/bold green] Constitution found and validated")
|
|
136
|
+
|
|
149
137
|
# Step 2: Detect SpecFact structure
|
|
150
138
|
specfact_exists = (repo / SpecFactStructure.ROOT).exists()
|
|
151
139
|
|
|
@@ -168,10 +156,28 @@ def sync_spec_kit(
|
|
|
168
156
|
console=console,
|
|
169
157
|
) as progress:
|
|
170
158
|
# Step 3: Scan Spec-Kit artifacts
|
|
171
|
-
task = progress.add_task("[cyan]
|
|
159
|
+
task = progress.add_task("[cyan]Scanning Spec-Kit artifacts...[/cyan]", total=None)
|
|
160
|
+
# Keep description showing current activity (spinner will show automatically)
|
|
161
|
+
progress.update(task, description="[cyan]Scanning Spec-Kit artifacts...[/cyan]")
|
|
172
162
|
features = scanner.discover_features()
|
|
163
|
+
# Update with final status after completion
|
|
173
164
|
progress.update(task, description=f"[green]✓[/green] Found {len(features)} features in specs/")
|
|
174
165
|
|
|
166
|
+
# Step 3.5: Validate Spec-Kit artifacts for unidirectional sync
|
|
167
|
+
if not bidirectional and len(features) == 0:
|
|
168
|
+
console.print("[bold red]✗[/bold red] No Spec-Kit features found")
|
|
169
|
+
console.print(
|
|
170
|
+
"[red]Unidirectional sync (Spec-Kit → SpecFact) requires at least one feature specification.[/red]"
|
|
171
|
+
)
|
|
172
|
+
console.print("\n[bold yellow]Next Steps:[/bold yellow]")
|
|
173
|
+
console.print("1. Run '/speckit.specify' command in your AI assistant to create feature specifications")
|
|
174
|
+
console.print("2. Optionally run '/speckit.plan' and '/speckit.tasks' to create complete artifacts")
|
|
175
|
+
console.print("3. Then run 'specfact sync spec-kit' again")
|
|
176
|
+
console.print(
|
|
177
|
+
"\n[dim]Note: For bidirectional sync, Spec-Kit artifacts are optional if syncing from SpecFact → Spec-Kit[/dim]"
|
|
178
|
+
)
|
|
179
|
+
raise typer.Exit(1)
|
|
180
|
+
|
|
175
181
|
# Step 4: Sync based on mode
|
|
176
182
|
specfact_changes: dict[str, Any] = {}
|
|
177
183
|
conflicts: list[dict[str, Any]] = []
|
|
@@ -180,10 +186,55 @@ def sync_spec_kit(
|
|
|
180
186
|
if bidirectional:
|
|
181
187
|
# Bidirectional sync: Spec-Kit → SpecFact and SpecFact → Spec-Kit
|
|
182
188
|
# Step 5.1: Spec-Kit → SpecFact (unidirectional sync)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
189
|
+
# Skip expensive conversion if no Spec-Kit features found (optimization)
|
|
190
|
+
if len(features) == 0:
|
|
191
|
+
task = progress.add_task("[cyan]📝[/cyan] Converting Spec-Kit → SpecFact...", total=None)
|
|
192
|
+
progress.update(
|
|
193
|
+
task,
|
|
194
|
+
description="[green]✓[/green] Skipped (no Spec-Kit features found)",
|
|
195
|
+
)
|
|
196
|
+
console.print("[dim] - Skipped Spec-Kit → SpecFact (no features in specs/)[/dim]")
|
|
197
|
+
# Use existing plan bundle if available, otherwise create minimal empty one
|
|
198
|
+
from specfact_cli.utils.structure import SpecFactStructure
|
|
199
|
+
from specfact_cli.validators.schema import validate_plan_bundle
|
|
200
|
+
|
|
201
|
+
# Use get_default_plan_path() to find the active plan (checks config or falls back to main.bundle.yaml)
|
|
202
|
+
plan_path = SpecFactStructure.get_default_plan_path(repo)
|
|
203
|
+
if plan_path.exists():
|
|
204
|
+
# Show progress while loading plan bundle
|
|
205
|
+
progress.update(task, description="[cyan]Parsing plan bundle YAML...[/cyan]")
|
|
206
|
+
validation_result = validate_plan_bundle(plan_path)
|
|
207
|
+
if isinstance(validation_result, tuple):
|
|
208
|
+
is_valid, _error, bundle = validation_result
|
|
209
|
+
if is_valid and bundle:
|
|
210
|
+
# Show progress during validation (Pydantic validation can be slow for large bundles)
|
|
211
|
+
progress.update(
|
|
212
|
+
task, description=f"[cyan]Validating {len(bundle.features)} features...[/cyan]"
|
|
213
|
+
)
|
|
214
|
+
merged_bundle = bundle
|
|
215
|
+
progress.update(
|
|
216
|
+
task,
|
|
217
|
+
description=f"[green]✓[/green] Loaded plan bundle ({len(bundle.features)} features)",
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
# Fallback: create minimal bundle via converter (but skip expensive parsing)
|
|
221
|
+
progress.update(task, description="[cyan]Creating plan bundle from Spec-Kit...[/cyan]")
|
|
222
|
+
merged_bundle = _sync_speckit_to_specfact(repo, converter, scanner, progress, task)[0]
|
|
223
|
+
else:
|
|
224
|
+
progress.update(task, description="[cyan]Creating plan bundle from Spec-Kit...[/cyan]")
|
|
225
|
+
merged_bundle = _sync_speckit_to_specfact(repo, converter, scanner, progress, task)[0]
|
|
226
|
+
else:
|
|
227
|
+
progress.update(task, description="[cyan]Creating plan bundle from Spec-Kit...[/cyan]")
|
|
228
|
+
merged_bundle = _sync_speckit_to_specfact(repo, converter, scanner, progress, task)[0]
|
|
229
|
+
features_updated = 0
|
|
230
|
+
features_added = 0
|
|
231
|
+
else:
|
|
232
|
+
task = progress.add_task("[cyan]Converting Spec-Kit → SpecFact...[/cyan]", total=None)
|
|
233
|
+
# Show current activity (spinner will show automatically)
|
|
234
|
+
progress.update(task, description="[cyan]Converting Spec-Kit → SpecFact...[/cyan]")
|
|
235
|
+
merged_bundle, features_updated, features_added = _sync_speckit_to_specfact(
|
|
236
|
+
repo, converter, scanner, progress
|
|
237
|
+
)
|
|
187
238
|
|
|
188
239
|
if features_updated > 0 or features_added > 0:
|
|
189
240
|
progress.update(
|
|
@@ -199,55 +250,79 @@ def sync_spec_kit(
|
|
|
199
250
|
)
|
|
200
251
|
|
|
201
252
|
# Step 5.2: SpecFact → Spec-Kit (reverse conversion)
|
|
202
|
-
task = progress.add_task("[cyan]
|
|
253
|
+
task = progress.add_task("[cyan]Converting SpecFact → Spec-Kit...[/cyan]", total=None)
|
|
254
|
+
# Show current activity (spinner will show automatically)
|
|
255
|
+
progress.update(task, description="[cyan]Detecting SpecFact changes...[/cyan]")
|
|
203
256
|
|
|
204
|
-
# Detect SpecFact changes
|
|
257
|
+
# Detect SpecFact changes (for tracking/incremental sync, but don't block conversion)
|
|
205
258
|
specfact_changes = sync.detect_specfact_changes(repo)
|
|
206
259
|
|
|
207
|
-
if
|
|
208
|
-
|
|
209
|
-
|
|
260
|
+
# Use the merged_bundle we already loaded, or load it if not available
|
|
261
|
+
# We convert even if no "changes" detected, as long as plan bundle exists and has features
|
|
262
|
+
plan_bundle_to_convert: PlanBundle | None = None
|
|
263
|
+
|
|
264
|
+
# Prefer using merged_bundle if it has features (already loaded above)
|
|
265
|
+
if merged_bundle and len(merged_bundle.features) > 0:
|
|
266
|
+
plan_bundle_to_convert = merged_bundle
|
|
267
|
+
else:
|
|
268
|
+
# Fallback: load plan bundle from file if merged_bundle is empty or None
|
|
210
269
|
if plan:
|
|
211
270
|
plan_path = plan if plan.is_absolute() else repo / plan
|
|
212
271
|
else:
|
|
213
|
-
|
|
272
|
+
# Use get_default_plan_path() to find the active plan (checks config or falls back to main.bundle.yaml)
|
|
273
|
+
plan_path = SpecFactStructure.get_default_plan_path(repo)
|
|
214
274
|
|
|
215
275
|
if plan_path.exists():
|
|
276
|
+
progress.update(task, description="[cyan]Loading plan bundle...[/cyan]")
|
|
216
277
|
validation_result = validate_plan_bundle(plan_path)
|
|
217
278
|
if isinstance(validation_result, tuple):
|
|
218
279
|
is_valid, _error, plan_bundle = validation_result
|
|
219
|
-
if is_valid and plan_bundle:
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
280
|
+
if is_valid and plan_bundle and len(plan_bundle.features) > 0:
|
|
281
|
+
plan_bundle_to_convert = plan_bundle
|
|
282
|
+
|
|
283
|
+
# Convert if we have a plan bundle with features
|
|
284
|
+
if plan_bundle_to_convert and len(plan_bundle_to_convert.features) > 0:
|
|
285
|
+
# Handle overwrite mode
|
|
286
|
+
if overwrite:
|
|
287
|
+
progress.update(task, description="[cyan]Removing existing artifacts...[/cyan]")
|
|
288
|
+
# Delete existing Spec-Kit artifacts before conversion
|
|
289
|
+
specs_dir = repo / "specs"
|
|
290
|
+
if specs_dir.exists():
|
|
291
|
+
console.print("[yellow]⚠[/yellow] Overwrite mode: Removing existing Spec-Kit artifacts...")
|
|
292
|
+
shutil.rmtree(specs_dir)
|
|
293
|
+
specs_dir.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
console.print("[green]✓[/green] Existing artifacts removed")
|
|
295
|
+
|
|
296
|
+
# Convert SpecFact plan bundle to Spec-Kit markdown
|
|
297
|
+
total_features = len(plan_bundle_to_convert.features)
|
|
298
|
+
progress.update(
|
|
299
|
+
task,
|
|
300
|
+
description=f"[cyan]Converting plan bundle to Spec-Kit format (0 of {total_features})...[/cyan]",
|
|
301
|
+
)
|
|
231
302
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
303
|
+
# Progress callback to update during conversion
|
|
304
|
+
def update_progress(current: int, total: int) -> None:
|
|
305
|
+
progress.update(
|
|
306
|
+
task,
|
|
307
|
+
description=f"[cyan]Converting plan bundle to Spec-Kit format ({current} of {total})...[/cyan]",
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
features_converted_speckit = converter.convert_to_speckit(plan_bundle_to_convert, update_progress)
|
|
311
|
+
progress.update(
|
|
312
|
+
task,
|
|
313
|
+
description=f"[green]✓[/green] Converted {features_converted_speckit} features to Spec-Kit",
|
|
314
|
+
)
|
|
315
|
+
mode_text = "overwritten" if overwrite else "generated"
|
|
316
|
+
console.print(
|
|
317
|
+
f"[dim] - {mode_text.capitalize()} spec.md, plan.md, tasks.md for {features_converted_speckit} features[/dim]"
|
|
318
|
+
)
|
|
319
|
+
# Warning about Constitution Check gates
|
|
320
|
+
console.print(
|
|
321
|
+
"[yellow]⚠[/yellow] [dim]Note: Constitution Check gates in plan.md are set to PENDING - review and check gates based on your project's actual state[/dim]"
|
|
322
|
+
)
|
|
249
323
|
else:
|
|
250
|
-
progress.update(task, description="[green]✓[/green] No
|
|
324
|
+
progress.update(task, description="[green]✓[/green] No features to convert to Spec-Kit")
|
|
325
|
+
features_converted_speckit = 0
|
|
251
326
|
|
|
252
327
|
# Detect conflicts between both directions
|
|
253
328
|
speckit_changes = sync.detect_speckit_changes(repo)
|
|
@@ -260,7 +335,9 @@ def sync_spec_kit(
|
|
|
260
335
|
console.print("[bold green]✓[/bold green] No conflicts detected")
|
|
261
336
|
else:
|
|
262
337
|
# Unidirectional sync: Spec-Kit → SpecFact
|
|
263
|
-
task = progress.add_task("[cyan]
|
|
338
|
+
task = progress.add_task("[cyan]Converting to SpecFact format...[/cyan]", total=None)
|
|
339
|
+
# Show current activity (spinner will show automatically)
|
|
340
|
+
progress.update(task, description="[cyan]Converting to SpecFact format...[/cyan]")
|
|
264
341
|
|
|
265
342
|
merged_bundle, features_updated, features_added = _sync_speckit_to_specfact(
|
|
266
343
|
repo, converter, scanner, progress
|
|
@@ -294,16 +371,24 @@ def sync_spec_kit(
|
|
|
294
371
|
if bidirectional:
|
|
295
372
|
console.print("[bold cyan]Sync Summary (Bidirectional):[/bold cyan]")
|
|
296
373
|
console.print(f" - Spec-Kit → SpecFact: Updated {features_updated}, Added {features_added} features")
|
|
297
|
-
if
|
|
374
|
+
# Always show conversion result (we convert if plan bundle exists, not just when changes detected)
|
|
375
|
+
if features_converted_speckit > 0:
|
|
298
376
|
console.print(
|
|
299
377
|
f" - SpecFact → Spec-Kit: {features_converted_speckit} features converted to Spec-Kit markdown"
|
|
300
378
|
)
|
|
301
379
|
else:
|
|
302
|
-
console.print(" - SpecFact → Spec-Kit: No
|
|
380
|
+
console.print(" - SpecFact → Spec-Kit: No features to convert")
|
|
303
381
|
if conflicts:
|
|
304
382
|
console.print(f" - Conflicts: {len(conflicts)} detected and resolved")
|
|
305
383
|
else:
|
|
306
384
|
console.print(" - Conflicts: None detected")
|
|
385
|
+
|
|
386
|
+
# Post-sync validation suggestion
|
|
387
|
+
if features_converted_speckit > 0:
|
|
388
|
+
console.print()
|
|
389
|
+
console.print("[bold cyan]Next Steps:[/bold cyan]")
|
|
390
|
+
console.print(" Run '/speckit.analyze' to validate artifact consistency and quality")
|
|
391
|
+
console.print(" This will check for ambiguities, duplications, and constitution alignment")
|
|
307
392
|
else:
|
|
308
393
|
console.print("[bold cyan]Sync Summary (Unidirectional):[/bold cyan]")
|
|
309
394
|
if features:
|
|
@@ -313,10 +398,368 @@ def sync_spec_kit(
|
|
|
313
398
|
console.print(f" - Added: {features_added} new features")
|
|
314
399
|
console.print(" - Direction: Spec-Kit → SpecFact")
|
|
315
400
|
|
|
401
|
+
# Post-sync validation suggestion
|
|
402
|
+
console.print()
|
|
403
|
+
console.print("[bold cyan]Next Steps:[/bold cyan]")
|
|
404
|
+
console.print(" Run '/speckit.analyze' to validate artifact consistency and quality")
|
|
405
|
+
console.print(" This will check for ambiguities, duplications, and constitution alignment")
|
|
406
|
+
|
|
316
407
|
console.print()
|
|
317
408
|
console.print("[bold green]✓[/bold green] Sync complete!")
|
|
318
409
|
|
|
319
410
|
|
|
411
|
+
def _sync_speckit_to_specfact(
|
|
412
|
+
repo: Path, converter: Any, scanner: Any, progress: Any, task: int | None = None
|
|
413
|
+
) -> tuple[PlanBundle, int, int]:
|
|
414
|
+
"""
|
|
415
|
+
Sync Spec-Kit artifacts to SpecFact format.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
repo: Repository path
|
|
419
|
+
converter: SpecKitConverter instance
|
|
420
|
+
scanner: SpecKitScanner instance
|
|
421
|
+
progress: Rich Progress instance
|
|
422
|
+
task: Optional progress task ID to update
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
Tuple of (merged_bundle, features_updated, features_added)
|
|
426
|
+
"""
|
|
427
|
+
from specfact_cli.generators.plan_generator import PlanGenerator
|
|
428
|
+
from specfact_cli.utils.structure import SpecFactStructure
|
|
429
|
+
from specfact_cli.validators.schema import validate_plan_bundle
|
|
430
|
+
|
|
431
|
+
plan_path = SpecFactStructure.get_default_plan_path(repo)
|
|
432
|
+
existing_bundle: PlanBundle | None = None
|
|
433
|
+
|
|
434
|
+
if plan_path.exists():
|
|
435
|
+
if task is not None:
|
|
436
|
+
progress.update(task, description="[cyan]Validating existing plan bundle...[/cyan]")
|
|
437
|
+
validation_result = validate_plan_bundle(plan_path)
|
|
438
|
+
if isinstance(validation_result, tuple):
|
|
439
|
+
is_valid, _error, bundle = validation_result
|
|
440
|
+
if is_valid and bundle:
|
|
441
|
+
existing_bundle = bundle
|
|
442
|
+
# Deduplicate existing features by normalized key (clean up duplicates from previous syncs)
|
|
443
|
+
from specfact_cli.utils.feature_keys import normalize_feature_key
|
|
444
|
+
|
|
445
|
+
seen_normalized_keys: set[str] = set()
|
|
446
|
+
deduplicated_features: list[Feature] = []
|
|
447
|
+
for existing_feature in existing_bundle.features:
|
|
448
|
+
normalized_key = normalize_feature_key(existing_feature.key)
|
|
449
|
+
if normalized_key not in seen_normalized_keys:
|
|
450
|
+
seen_normalized_keys.add(normalized_key)
|
|
451
|
+
deduplicated_features.append(existing_feature)
|
|
452
|
+
|
|
453
|
+
duplicates_removed = len(existing_bundle.features) - len(deduplicated_features)
|
|
454
|
+
if duplicates_removed > 0:
|
|
455
|
+
existing_bundle.features = deduplicated_features
|
|
456
|
+
# Write back deduplicated bundle immediately to clean up the plan file
|
|
457
|
+
from specfact_cli.generators.plan_generator import PlanGenerator
|
|
458
|
+
|
|
459
|
+
if task is not None:
|
|
460
|
+
progress.update(
|
|
461
|
+
task,
|
|
462
|
+
description=f"[cyan]Deduplicating {duplicates_removed} duplicate features and writing cleaned plan...[/cyan]",
|
|
463
|
+
)
|
|
464
|
+
generator = PlanGenerator()
|
|
465
|
+
generator.generate(existing_bundle, plan_path)
|
|
466
|
+
if task is not None:
|
|
467
|
+
progress.update(
|
|
468
|
+
task,
|
|
469
|
+
description=f"[green]✓[/green] Removed {duplicates_removed} duplicates, cleaned plan saved",
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# Convert Spec-Kit to SpecFact
|
|
473
|
+
if task is not None:
|
|
474
|
+
progress.update(task, description="[cyan]Converting Spec-Kit artifacts to SpecFact format...[/cyan]")
|
|
475
|
+
converted_bundle = converter.convert_plan(None if not existing_bundle else plan_path)
|
|
476
|
+
|
|
477
|
+
# Merge with existing plan if it exists
|
|
478
|
+
features_updated = 0
|
|
479
|
+
features_added = 0
|
|
480
|
+
|
|
481
|
+
if existing_bundle:
|
|
482
|
+
if task is not None:
|
|
483
|
+
progress.update(task, description="[cyan]Merging with existing plan bundle...[/cyan]")
|
|
484
|
+
# Use normalized keys for matching to handle different key formats (e.g., FEATURE-001 vs 001_FEATURE_NAME)
|
|
485
|
+
from specfact_cli.utils.feature_keys import normalize_feature_key
|
|
486
|
+
|
|
487
|
+
# Build a map of normalized_key -> (index, original_key) for existing features
|
|
488
|
+
normalized_key_map: dict[str, tuple[int, str]] = {}
|
|
489
|
+
for idx, existing_feature in enumerate(existing_bundle.features):
|
|
490
|
+
normalized_key = normalize_feature_key(existing_feature.key)
|
|
491
|
+
# If multiple features have the same normalized key, keep the first one
|
|
492
|
+
if normalized_key not in normalized_key_map:
|
|
493
|
+
normalized_key_map[normalized_key] = (idx, existing_feature.key)
|
|
494
|
+
|
|
495
|
+
for feature in converted_bundle.features:
|
|
496
|
+
normalized_key = normalize_feature_key(feature.key)
|
|
497
|
+
matched = False
|
|
498
|
+
|
|
499
|
+
# Try exact match first
|
|
500
|
+
if normalized_key in normalized_key_map:
|
|
501
|
+
existing_idx, original_key = normalized_key_map[normalized_key]
|
|
502
|
+
# Preserve the original key format from existing bundle
|
|
503
|
+
feature.key = original_key
|
|
504
|
+
existing_bundle.features[existing_idx] = feature
|
|
505
|
+
features_updated += 1
|
|
506
|
+
matched = True
|
|
507
|
+
else:
|
|
508
|
+
# Try prefix match for abbreviated vs full names
|
|
509
|
+
# (e.g., IDEINTEGRATION vs IDEINTEGRATIONSYSTEM)
|
|
510
|
+
# Only match if shorter is a PREFIX of longer with significant length difference
|
|
511
|
+
# AND at least one key has a numbered prefix (041_, 042-, etc.) indicating Spec-Kit origin
|
|
512
|
+
# This avoids false positives like SMARTCOVERAGE vs SMARTCOVERAGEMANAGER (both from code analysis)
|
|
513
|
+
for existing_norm_key, (existing_idx, original_key) in normalized_key_map.items():
|
|
514
|
+
shorter = min(normalized_key, existing_norm_key, key=len)
|
|
515
|
+
longer = max(normalized_key, existing_norm_key, key=len)
|
|
516
|
+
|
|
517
|
+
# Check if at least one key has a numbered prefix (Spec-Kit format)
|
|
518
|
+
import re
|
|
519
|
+
|
|
520
|
+
has_speckit_key = bool(
|
|
521
|
+
re.match(r"^\d{3}[_-]", feature.key) or re.match(r"^\d{3}[_-]", original_key)
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
# More conservative matching:
|
|
525
|
+
# 1. At least one key must have numbered prefix (Spec-Kit origin)
|
|
526
|
+
# 2. Shorter must be at least 10 chars
|
|
527
|
+
# 3. Longer must start with shorter (prefix match)
|
|
528
|
+
# 4. Length difference must be at least 6 chars
|
|
529
|
+
# 5. Shorter must be < 75% of longer (to ensure significant difference)
|
|
530
|
+
length_diff = len(longer) - len(shorter)
|
|
531
|
+
length_ratio = len(shorter) / len(longer) if len(longer) > 0 else 1.0
|
|
532
|
+
|
|
533
|
+
if (
|
|
534
|
+
has_speckit_key
|
|
535
|
+
and len(shorter) >= 10
|
|
536
|
+
and longer.startswith(shorter)
|
|
537
|
+
and length_diff >= 6
|
|
538
|
+
and length_ratio < 0.75
|
|
539
|
+
):
|
|
540
|
+
# Match found - use the existing key format (prefer full name if available)
|
|
541
|
+
if len(existing_norm_key) >= len(normalized_key):
|
|
542
|
+
# Existing key is longer (full name) - keep it
|
|
543
|
+
feature.key = original_key
|
|
544
|
+
else:
|
|
545
|
+
# New key is longer (full name) - use it but update existing
|
|
546
|
+
existing_bundle.features[existing_idx].key = feature.key
|
|
547
|
+
existing_bundle.features[existing_idx] = feature
|
|
548
|
+
features_updated += 1
|
|
549
|
+
matched = True
|
|
550
|
+
break
|
|
551
|
+
|
|
552
|
+
if not matched:
|
|
553
|
+
# New feature - add it
|
|
554
|
+
existing_bundle.features.append(feature)
|
|
555
|
+
features_added += 1
|
|
556
|
+
|
|
557
|
+
# Update product themes
|
|
558
|
+
themes_existing = set(existing_bundle.product.themes)
|
|
559
|
+
themes_new = set(converted_bundle.product.themes)
|
|
560
|
+
existing_bundle.product.themes = list(themes_existing | themes_new)
|
|
561
|
+
|
|
562
|
+
# Write merged bundle
|
|
563
|
+
if task is not None:
|
|
564
|
+
progress.update(task, description="[cyan]Writing plan bundle to disk...[/cyan]")
|
|
565
|
+
generator = PlanGenerator()
|
|
566
|
+
generator.generate(existing_bundle, plan_path)
|
|
567
|
+
return existing_bundle, features_updated, features_added
|
|
568
|
+
# Write new bundle
|
|
569
|
+
generator = PlanGenerator()
|
|
570
|
+
generator.generate(converted_bundle, plan_path)
|
|
571
|
+
return converted_bundle, 0, len(converted_bundle.features)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@app.command("spec-kit")
|
|
575
|
+
def sync_spec_kit(
|
|
576
|
+
repo: Path = typer.Option(
|
|
577
|
+
Path("."),
|
|
578
|
+
"--repo",
|
|
579
|
+
help="Path to repository",
|
|
580
|
+
exists=True,
|
|
581
|
+
file_okay=False,
|
|
582
|
+
dir_okay=True,
|
|
583
|
+
),
|
|
584
|
+
bidirectional: bool = typer.Option(
|
|
585
|
+
False,
|
|
586
|
+
"--bidirectional",
|
|
587
|
+
help="Enable bidirectional sync (Spec-Kit ↔ SpecFact)",
|
|
588
|
+
),
|
|
589
|
+
plan: Path | None = typer.Option(
|
|
590
|
+
None,
|
|
591
|
+
"--plan",
|
|
592
|
+
help="Path to SpecFact plan bundle for SpecFact → Spec-Kit conversion (default: .specfact/plans/main.bundle.yaml)",
|
|
593
|
+
),
|
|
594
|
+
overwrite: bool = typer.Option(
|
|
595
|
+
False,
|
|
596
|
+
"--overwrite",
|
|
597
|
+
help="Overwrite existing Spec-Kit artifacts (delete all existing before sync)",
|
|
598
|
+
),
|
|
599
|
+
watch: bool = typer.Option(
|
|
600
|
+
False,
|
|
601
|
+
"--watch",
|
|
602
|
+
help="Watch mode for continuous sync",
|
|
603
|
+
),
|
|
604
|
+
interval: int = typer.Option(
|
|
605
|
+
5,
|
|
606
|
+
"--interval",
|
|
607
|
+
help="Watch interval in seconds (default: 5)",
|
|
608
|
+
min=1,
|
|
609
|
+
),
|
|
610
|
+
ensure_speckit_compliance: bool = typer.Option(
|
|
611
|
+
False,
|
|
612
|
+
"--ensure-speckit-compliance",
|
|
613
|
+
help="Validate and auto-enrich plan bundle for Spec-Kit compliance before sync (ensures technology stack, testable acceptance criteria, comprehensive scenarios)",
|
|
614
|
+
),
|
|
615
|
+
) -> None:
|
|
616
|
+
"""
|
|
617
|
+
Sync changes between Spec-Kit artifacts and SpecFact.
|
|
618
|
+
|
|
619
|
+
Synchronizes markdown artifacts generated by Spec-Kit slash commands
|
|
620
|
+
with SpecFact plan bundles and protocols.
|
|
621
|
+
|
|
622
|
+
Example:
|
|
623
|
+
specfact sync spec-kit --repo . --bidirectional
|
|
624
|
+
"""
|
|
625
|
+
telemetry_metadata = {
|
|
626
|
+
"bidirectional": bidirectional,
|
|
627
|
+
"watch": watch,
|
|
628
|
+
"overwrite": overwrite,
|
|
629
|
+
"interval": interval,
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
with telemetry.track_command("sync.spec_kit", telemetry_metadata) as record:
|
|
633
|
+
console.print(f"[bold cyan]Syncing Spec-Kit artifacts from:[/bold cyan] {repo}")
|
|
634
|
+
|
|
635
|
+
# Ensure Spec-Kit compliance if requested
|
|
636
|
+
if ensure_speckit_compliance:
|
|
637
|
+
console.print("\n[cyan]🔍 Validating plan bundle for Spec-Kit compliance...[/cyan]")
|
|
638
|
+
from specfact_cli.utils.structure import SpecFactStructure
|
|
639
|
+
from specfact_cli.validators.schema import validate_plan_bundle
|
|
640
|
+
|
|
641
|
+
# Use provided plan path or default
|
|
642
|
+
plan_path = plan if plan else SpecFactStructure.get_default_plan_path(repo)
|
|
643
|
+
if not plan_path.is_absolute():
|
|
644
|
+
plan_path = repo / plan_path
|
|
645
|
+
|
|
646
|
+
if plan_path.exists():
|
|
647
|
+
validation_result = validate_plan_bundle(plan_path)
|
|
648
|
+
if isinstance(validation_result, tuple):
|
|
649
|
+
is_valid, _error, plan_bundle = validation_result
|
|
650
|
+
if is_valid and plan_bundle:
|
|
651
|
+
# Check for technology stack in constraints
|
|
652
|
+
has_tech_stack = bool(
|
|
653
|
+
plan_bundle.idea
|
|
654
|
+
and plan_bundle.idea.constraints
|
|
655
|
+
and any(
|
|
656
|
+
"Python" in c or "framework" in c.lower() or "database" in c.lower()
|
|
657
|
+
for c in plan_bundle.idea.constraints
|
|
658
|
+
)
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
if not has_tech_stack:
|
|
662
|
+
console.print("[yellow]⚠ Technology stack not found in constraints[/yellow]")
|
|
663
|
+
console.print("[dim]Technology stack will be extracted from constraints during sync[/dim]")
|
|
664
|
+
|
|
665
|
+
# Check for testable acceptance criteria
|
|
666
|
+
features_with_non_testable = []
|
|
667
|
+
for feature in plan_bundle.features:
|
|
668
|
+
for story in feature.stories:
|
|
669
|
+
testable_count = sum(
|
|
670
|
+
1
|
|
671
|
+
for acc in story.acceptance
|
|
672
|
+
if any(
|
|
673
|
+
keyword in acc.lower()
|
|
674
|
+
for keyword in ["must", "should", "verify", "validate", "ensure"]
|
|
675
|
+
)
|
|
676
|
+
)
|
|
677
|
+
if testable_count < len(story.acceptance) and len(story.acceptance) > 0:
|
|
678
|
+
features_with_non_testable.append((feature.key, story.key))
|
|
679
|
+
|
|
680
|
+
if features_with_non_testable:
|
|
681
|
+
console.print(
|
|
682
|
+
f"[yellow]⚠ Found {len(features_with_non_testable)} stories with non-testable acceptance criteria[/yellow]"
|
|
683
|
+
)
|
|
684
|
+
console.print("[dim]Acceptance criteria will be enhanced during sync[/dim]")
|
|
685
|
+
|
|
686
|
+
console.print("[green]✓ Plan bundle validation complete[/green]")
|
|
687
|
+
else:
|
|
688
|
+
console.print("[yellow]⚠ Plan bundle validation failed, but continuing with sync[/yellow]")
|
|
689
|
+
else:
|
|
690
|
+
console.print("[yellow]⚠ Could not validate plan bundle, but continuing with sync[/yellow]")
|
|
691
|
+
else:
|
|
692
|
+
console.print("[yellow]⚠ Plan bundle not found, skipping compliance check[/yellow]")
|
|
693
|
+
|
|
694
|
+
# Resolve repo path to ensure it's absolute and valid (do this once at the start)
|
|
695
|
+
resolved_repo = repo.resolve()
|
|
696
|
+
if not resolved_repo.exists():
|
|
697
|
+
console.print(f"[red]Error:[/red] Repository path does not exist: {resolved_repo}")
|
|
698
|
+
raise typer.Exit(1)
|
|
699
|
+
if not resolved_repo.is_dir():
|
|
700
|
+
console.print(f"[red]Error:[/red] Repository path is not a directory: {resolved_repo}")
|
|
701
|
+
raise typer.Exit(1)
|
|
702
|
+
|
|
703
|
+
# Watch mode implementation
|
|
704
|
+
if watch:
|
|
705
|
+
from specfact_cli.sync.watcher import FileChange, SyncWatcher
|
|
706
|
+
|
|
707
|
+
console.print("[bold cyan]Watch mode enabled[/bold cyan]")
|
|
708
|
+
console.print(f"[dim]Watching for changes every {interval} seconds[/dim]\n")
|
|
709
|
+
|
|
710
|
+
@beartype
|
|
711
|
+
@require(lambda changes: isinstance(changes, list), "Changes must be a list")
|
|
712
|
+
@require(
|
|
713
|
+
lambda changes: all(hasattr(c, "change_type") for c in changes),
|
|
714
|
+
"All changes must have change_type attribute",
|
|
715
|
+
)
|
|
716
|
+
@ensure(lambda result: result is None, "Must return None")
|
|
717
|
+
def sync_callback(changes: list[FileChange]) -> None:
|
|
718
|
+
"""Handle file changes and trigger sync."""
|
|
719
|
+
spec_kit_changes = [c for c in changes if c.change_type == "spec_kit"]
|
|
720
|
+
specfact_changes = [c for c in changes if c.change_type == "specfact"]
|
|
721
|
+
|
|
722
|
+
if spec_kit_changes or specfact_changes:
|
|
723
|
+
console.print(f"[cyan]Detected {len(changes)} change(s), syncing...[/cyan]")
|
|
724
|
+
# Perform one-time sync (bidirectional if enabled)
|
|
725
|
+
try:
|
|
726
|
+
# Re-validate resolved_repo before use (may have been cleaned up)
|
|
727
|
+
if not resolved_repo.exists():
|
|
728
|
+
console.print(f"[yellow]⚠[/yellow] Repository path no longer exists: {resolved_repo}\n")
|
|
729
|
+
return
|
|
730
|
+
if not resolved_repo.is_dir():
|
|
731
|
+
console.print(
|
|
732
|
+
f"[yellow]⚠[/yellow] Repository path is no longer a directory: {resolved_repo}\n"
|
|
733
|
+
)
|
|
734
|
+
return
|
|
735
|
+
# Use resolved_repo from outer scope (already resolved and validated)
|
|
736
|
+
_perform_sync_operation(
|
|
737
|
+
repo=resolved_repo,
|
|
738
|
+
bidirectional=bidirectional,
|
|
739
|
+
plan=plan,
|
|
740
|
+
overwrite=overwrite,
|
|
741
|
+
)
|
|
742
|
+
console.print("[green]✓[/green] Sync complete\n")
|
|
743
|
+
except Exception as e:
|
|
744
|
+
console.print(f"[red]✗[/red] Sync failed: {e}\n")
|
|
745
|
+
|
|
746
|
+
# Use resolved_repo for watcher (already resolved and validated)
|
|
747
|
+
watcher = SyncWatcher(resolved_repo, sync_callback, interval=interval)
|
|
748
|
+
watcher.watch()
|
|
749
|
+
record({"watch_mode": True})
|
|
750
|
+
return
|
|
751
|
+
|
|
752
|
+
# Perform sync operation (extracted to avoid recursion in watch mode)
|
|
753
|
+
# Use resolved_repo (already resolved and validated above)
|
|
754
|
+
_perform_sync_operation(
|
|
755
|
+
repo=resolved_repo,
|
|
756
|
+
bidirectional=bidirectional,
|
|
757
|
+
plan=plan,
|
|
758
|
+
overwrite=overwrite,
|
|
759
|
+
)
|
|
760
|
+
record({"sync_completed": True})
|
|
761
|
+
|
|
762
|
+
|
|
320
763
|
@app.command("repository")
|
|
321
764
|
def sync_repository(
|
|
322
765
|
repo: Path = typer.Option(
|
|
@@ -362,47 +805,119 @@ def sync_repository(
|
|
|
362
805
|
"""
|
|
363
806
|
from specfact_cli.sync.repository_sync import RepositorySync
|
|
364
807
|
|
|
365
|
-
|
|
808
|
+
telemetry_metadata = {
|
|
809
|
+
"watch": watch,
|
|
810
|
+
"interval": interval,
|
|
811
|
+
"confidence": confidence,
|
|
812
|
+
}
|
|
366
813
|
|
|
367
|
-
|
|
368
|
-
|
|
814
|
+
with telemetry.track_command("sync.repository", telemetry_metadata) as record:
|
|
815
|
+
console.print(f"[bold cyan]Syncing repository changes from:[/bold cyan] {repo}")
|
|
369
816
|
|
|
370
|
-
|
|
817
|
+
# Resolve repo path to ensure it's absolute and valid (do this once at the start)
|
|
818
|
+
resolved_repo = repo.resolve()
|
|
819
|
+
if not resolved_repo.exists():
|
|
820
|
+
console.print(f"[red]Error:[/red] Repository path does not exist: {resolved_repo}")
|
|
821
|
+
raise typer.Exit(1)
|
|
822
|
+
if not resolved_repo.is_dir():
|
|
823
|
+
console.print(f"[red]Error:[/red] Repository path is not a directory: {resolved_repo}")
|
|
824
|
+
raise typer.Exit(1)
|
|
371
825
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
console.print(f"[dim]Would watch for changes every {interval} seconds[/dim]")
|
|
375
|
-
raise typer.Exit(0)
|
|
826
|
+
if target is None:
|
|
827
|
+
target = resolved_repo / ".specfact"
|
|
376
828
|
|
|
377
|
-
|
|
378
|
-
SpinnerColumn(),
|
|
379
|
-
TextColumn("[progress.description]{task.description}"),
|
|
380
|
-
console=console,
|
|
381
|
-
) as progress:
|
|
382
|
-
# Step 1: Detect code changes
|
|
383
|
-
task = progress.add_task("Detecting code changes...", total=None)
|
|
384
|
-
result = sync.sync_repository_changes(repo)
|
|
385
|
-
progress.update(task, description=f"✓ Detected {len(result.code_changes)} code changes")
|
|
829
|
+
sync = RepositorySync(resolved_repo, target, confidence_threshold=confidence)
|
|
386
830
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
task = progress.add_task("Updating plan artifacts...", total=None)
|
|
390
|
-
total_features = sum(update.get("features", 0) for update in result.plan_updates)
|
|
391
|
-
progress.update(task, description=f"✓ Updated plan artifacts ({total_features} features)")
|
|
831
|
+
if watch:
|
|
832
|
+
from specfact_cli.sync.watcher import FileChange, SyncWatcher
|
|
392
833
|
|
|
393
|
-
|
|
834
|
+
console.print("[bold cyan]Watch mode enabled[/bold cyan]")
|
|
835
|
+
console.print(f"[dim]Watching for changes every {interval} seconds[/dim]\n")
|
|
836
|
+
|
|
837
|
+
@beartype
|
|
838
|
+
@require(lambda changes: isinstance(changes, list), "Changes must be a list")
|
|
839
|
+
@require(
|
|
840
|
+
lambda changes: all(hasattr(c, "change_type") for c in changes),
|
|
841
|
+
"All changes must have change_type attribute",
|
|
842
|
+
)
|
|
843
|
+
@ensure(lambda result: result is None, "Must return None")
|
|
844
|
+
def sync_callback(changes: list[FileChange]) -> None:
|
|
845
|
+
"""Handle file changes and trigger sync."""
|
|
846
|
+
code_changes = [c for c in changes if c.change_type == "code"]
|
|
847
|
+
|
|
848
|
+
if code_changes:
|
|
849
|
+
console.print(f"[cyan]Detected {len(code_changes)} code change(s), syncing...[/cyan]")
|
|
850
|
+
# Perform repository sync
|
|
851
|
+
try:
|
|
852
|
+
# Re-validate resolved_repo before use (may have been cleaned up)
|
|
853
|
+
if not resolved_repo.exists():
|
|
854
|
+
console.print(f"[yellow]⚠[/yellow] Repository path no longer exists: {resolved_repo}\n")
|
|
855
|
+
return
|
|
856
|
+
if not resolved_repo.is_dir():
|
|
857
|
+
console.print(
|
|
858
|
+
f"[yellow]⚠[/yellow] Repository path is no longer a directory: {resolved_repo}\n"
|
|
859
|
+
)
|
|
860
|
+
return
|
|
861
|
+
# Use resolved_repo from outer scope (already resolved and validated)
|
|
862
|
+
result = sync.sync_repository_changes(resolved_repo)
|
|
863
|
+
if result.status == "success":
|
|
864
|
+
console.print("[green]✓[/green] Repository sync complete\n")
|
|
865
|
+
elif result.status == "deviation_detected":
|
|
866
|
+
console.print(f"[yellow]⚠[/yellow] Deviations detected: {len(result.deviations)}\n")
|
|
867
|
+
else:
|
|
868
|
+
console.print(f"[red]✗[/red] Sync failed: {result.status}\n")
|
|
869
|
+
except Exception as e:
|
|
870
|
+
console.print(f"[red]✗[/red] Sync failed: {e}\n")
|
|
871
|
+
|
|
872
|
+
# Use resolved_repo for watcher (already resolved and validated)
|
|
873
|
+
watcher = SyncWatcher(resolved_repo, sync_callback, interval=interval)
|
|
874
|
+
watcher.watch()
|
|
875
|
+
record({"watch_mode": True})
|
|
876
|
+
return
|
|
877
|
+
|
|
878
|
+
# Use resolved_repo (already resolved and validated above)
|
|
879
|
+
# Disable Progress in test mode to avoid LiveError conflicts
|
|
880
|
+
if _is_test_mode():
|
|
881
|
+
# In test mode, just run the sync without Progress
|
|
882
|
+
result = sync.sync_repository_changes(resolved_repo)
|
|
883
|
+
else:
|
|
884
|
+
with Progress(
|
|
885
|
+
SpinnerColumn(),
|
|
886
|
+
TextColumn("[progress.description]{task.description}"),
|
|
887
|
+
console=console,
|
|
888
|
+
) as progress:
|
|
889
|
+
# Step 1: Detect code changes
|
|
890
|
+
task = progress.add_task("Detecting code changes...", total=None)
|
|
891
|
+
result = sync.sync_repository_changes(resolved_repo)
|
|
892
|
+
progress.update(task, description=f"✓ Detected {len(result.code_changes)} code changes")
|
|
893
|
+
|
|
894
|
+
# Step 2: Show plan updates
|
|
895
|
+
if result.plan_updates:
|
|
896
|
+
task = progress.add_task("Updating plan artifacts...", total=None)
|
|
897
|
+
total_features = sum(update.get("features", 0) for update in result.plan_updates)
|
|
898
|
+
progress.update(task, description=f"✓ Updated plan artifacts ({total_features} features)")
|
|
899
|
+
|
|
900
|
+
# Step 3: Show deviations
|
|
901
|
+
if result.deviations:
|
|
902
|
+
task = progress.add_task("Tracking deviations...", total=None)
|
|
903
|
+
progress.update(task, description=f"✓ Found {len(result.deviations)} deviations")
|
|
904
|
+
|
|
905
|
+
# Record sync results
|
|
906
|
+
record(
|
|
907
|
+
{
|
|
908
|
+
"code_changes": len(result.code_changes),
|
|
909
|
+
"plan_updates": len(result.plan_updates) if result.plan_updates else 0,
|
|
910
|
+
"deviations": len(result.deviations) if result.deviations else 0,
|
|
911
|
+
}
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
# Report results
|
|
915
|
+
console.print(f"[bold cyan]Code Changes:[/bold cyan] {len(result.code_changes)}")
|
|
916
|
+
if result.plan_updates:
|
|
917
|
+
console.print(f"[bold cyan]Plan Updates:[/bold cyan] {len(result.plan_updates)}")
|
|
394
918
|
if result.deviations:
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
if result.plan_updates:
|
|
401
|
-
console.print(f"[bold cyan]Plan Updates:[/bold cyan] {len(result.plan_updates)}")
|
|
402
|
-
if result.deviations:
|
|
403
|
-
console.print(f"[yellow]⚠[/yellow] Found {len(result.deviations)} deviations from manual plan")
|
|
404
|
-
console.print("[dim]Run 'specfact plan compare' for detailed deviation report[/dim]")
|
|
405
|
-
else:
|
|
406
|
-
console.print("[bold green]✓[/bold green] No deviations detected")
|
|
407
|
-
|
|
408
|
-
console.print("[bold green]✓[/bold green] Repository sync complete!")
|
|
919
|
+
console.print(f"[yellow]⚠[/yellow] Found {len(result.deviations)} deviations from manual plan")
|
|
920
|
+
console.print("[dim]Run 'specfact plan compare' for detailed deviation report[/dim]")
|
|
921
|
+
else:
|
|
922
|
+
console.print("[bold green]✓[/bold green] No deviations detected")
|
|
923
|
+
console.print("[bold green]✓[/bold green] Repository sync complete!")
|