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
|
@@ -7,12 +7,15 @@ to SpecFact format (plans, protocols).
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable
|
|
10
12
|
from pathlib import Path
|
|
11
13
|
from typing import Any
|
|
12
14
|
|
|
13
15
|
from beartype import beartype
|
|
14
16
|
from icontract import ensure, require
|
|
15
17
|
|
|
18
|
+
from specfact_cli.analyzers.constitution_evidence_extractor import ConstitutionEvidenceExtractor
|
|
16
19
|
from specfact_cli.generators.plan_generator import PlanGenerator
|
|
17
20
|
from specfact_cli.generators.protocol_generator import ProtocolGenerator
|
|
18
21
|
from specfact_cli.generators.workflow_generator import WorkflowGenerator
|
|
@@ -43,6 +46,7 @@ class SpecKitConverter:
|
|
|
43
46
|
self.protocol_generator = ProtocolGenerator()
|
|
44
47
|
self.plan_generator = PlanGenerator()
|
|
45
48
|
self.workflow_generator = WorkflowGenerator()
|
|
49
|
+
self.constitution_extractor = ConstitutionEvidenceExtractor(repo_path)
|
|
46
50
|
self.mapping_file = mapping_file
|
|
47
51
|
|
|
48
52
|
@beartype
|
|
@@ -111,10 +115,10 @@ class SpecKitConverter:
|
|
|
111
115
|
# Discover features from markdown artifacts
|
|
112
116
|
discovered_features = self.scanner.discover_features()
|
|
113
117
|
|
|
114
|
-
# Extract features from markdown data
|
|
115
|
-
features = self._extract_features_from_markdown(discovered_features)
|
|
118
|
+
# Extract features from markdown data (empty list if no features found)
|
|
119
|
+
features = self._extract_features_from_markdown(discovered_features) if discovered_features else []
|
|
116
120
|
|
|
117
|
-
# Parse constitution for constraints
|
|
121
|
+
# Parse constitution for constraints (only if needed for idea creation)
|
|
118
122
|
structure = self.scanner.scan_structure()
|
|
119
123
|
memory_dir = Path(structure.get("specify_memory_dir", "")) if structure.get("specify_memory_dir") else None
|
|
120
124
|
constraints: list[str] = []
|
|
@@ -155,6 +159,7 @@ class SpecKitConverter:
|
|
|
155
159
|
product=product,
|
|
156
160
|
features=features,
|
|
157
161
|
metadata=None,
|
|
162
|
+
clarifications=None,
|
|
158
163
|
)
|
|
159
164
|
|
|
160
165
|
# Write to file if output path provided
|
|
@@ -259,6 +264,16 @@ class SpecKitConverter:
|
|
|
259
264
|
if (story_ref and story_ref in story_key) or not story_ref:
|
|
260
265
|
tasks.append(task.get("description", ""))
|
|
261
266
|
|
|
267
|
+
# Extract scenarios from Spec-Kit format (Primary, Alternate, Exception, Recovery)
|
|
268
|
+
scenarios = story_data.get("scenarios")
|
|
269
|
+
# Ensure scenarios dict has correct format (filter out empty lists)
|
|
270
|
+
if scenarios and isinstance(scenarios, dict):
|
|
271
|
+
# Filter out empty scenario lists
|
|
272
|
+
filtered_scenarios = {k: v for k, v in scenarios.items() if v and isinstance(v, list) and len(v) > 0}
|
|
273
|
+
scenarios = filtered_scenarios if filtered_scenarios else None
|
|
274
|
+
else:
|
|
275
|
+
scenarios = None
|
|
276
|
+
|
|
262
277
|
story = Story(
|
|
263
278
|
key=story_key,
|
|
264
279
|
title=story_title,
|
|
@@ -269,6 +284,8 @@ class SpecKitConverter:
|
|
|
269
284
|
tasks=tasks,
|
|
270
285
|
confidence=0.8, # High confidence from spec
|
|
271
286
|
draft=False,
|
|
287
|
+
scenarios=scenarios,
|
|
288
|
+
contracts=None,
|
|
272
289
|
)
|
|
273
290
|
stories.append(story)
|
|
274
291
|
|
|
@@ -357,7 +374,9 @@ class SpecKitConverter:
|
|
|
357
374
|
@require(lambda plan_bundle: isinstance(plan_bundle, PlanBundle), "Must be PlanBundle instance")
|
|
358
375
|
@ensure(lambda result: isinstance(result, int), "Must return int (number of features converted)")
|
|
359
376
|
@ensure(lambda result: result >= 0, "Result must be non-negative")
|
|
360
|
-
def convert_to_speckit(
|
|
377
|
+
def convert_to_speckit(
|
|
378
|
+
self, plan_bundle: PlanBundle, progress_callback: Callable[[int, int], None] | None = None
|
|
379
|
+
) -> int:
|
|
361
380
|
"""
|
|
362
381
|
Convert SpecFact plan bundle to Spec-Kit markdown artifacts.
|
|
363
382
|
|
|
@@ -365,27 +384,44 @@ class SpecKitConverter:
|
|
|
365
384
|
|
|
366
385
|
Args:
|
|
367
386
|
plan_bundle: SpecFact plan bundle to convert
|
|
387
|
+
progress_callback: Optional callback function(current, total) to report progress
|
|
368
388
|
|
|
369
389
|
Returns:
|
|
370
390
|
Number of features converted
|
|
371
391
|
"""
|
|
372
392
|
features_converted = 0
|
|
373
|
-
|
|
374
|
-
|
|
393
|
+
total_features = len(plan_bundle.features)
|
|
394
|
+
# Track used feature numbers to avoid duplicates
|
|
395
|
+
used_feature_nums: set[int] = set()
|
|
396
|
+
|
|
397
|
+
for idx, feature in enumerate(plan_bundle.features, start=1):
|
|
398
|
+
# Report progress if callback provided
|
|
399
|
+
if progress_callback:
|
|
400
|
+
progress_callback(idx, total_features)
|
|
375
401
|
# Generate feature directory name from key (FEATURE-001 -> 001-feature-name)
|
|
376
|
-
|
|
402
|
+
# Use number from key if available and not already used, otherwise use sequential index
|
|
403
|
+
extracted_num = self._extract_feature_number(feature.key)
|
|
404
|
+
if extracted_num == 0 or extracted_num in used_feature_nums:
|
|
405
|
+
# No number found in key, or number already used - use sequential numbering
|
|
406
|
+
# Find next available sequential number starting from idx
|
|
407
|
+
feature_num = idx
|
|
408
|
+
while feature_num in used_feature_nums:
|
|
409
|
+
feature_num += 1
|
|
410
|
+
else:
|
|
411
|
+
feature_num = extracted_num
|
|
412
|
+
used_feature_nums.add(feature_num)
|
|
377
413
|
feature_name = self._to_feature_dir_name(feature.title)
|
|
378
414
|
|
|
379
415
|
# Create feature directory
|
|
380
416
|
feature_dir = self.repo_path / "specs" / f"{feature_num:03d}-{feature_name}"
|
|
381
417
|
feature_dir.mkdir(parents=True, exist_ok=True)
|
|
382
418
|
|
|
383
|
-
# Generate spec.md
|
|
384
|
-
spec_content = self._generate_spec_markdown(feature)
|
|
419
|
+
# Generate spec.md (pass calculated feature_num to avoid recalculation)
|
|
420
|
+
spec_content = self._generate_spec_markdown(feature, feature_num=feature_num)
|
|
385
421
|
(feature_dir / "spec.md").write_text(spec_content, encoding="utf-8")
|
|
386
422
|
|
|
387
423
|
# Generate plan.md
|
|
388
|
-
plan_content = self._generate_plan_markdown(feature)
|
|
424
|
+
plan_content = self._generate_plan_markdown(feature, plan_bundle)
|
|
389
425
|
(feature_dir / "plan.md").write_text(plan_content, encoding="utf-8")
|
|
390
426
|
|
|
391
427
|
# Generate tasks.md
|
|
@@ -398,14 +434,29 @@ class SpecKitConverter:
|
|
|
398
434
|
|
|
399
435
|
@beartype
|
|
400
436
|
@require(lambda feature: isinstance(feature, Feature), "Must be Feature instance")
|
|
437
|
+
@require(
|
|
438
|
+
lambda feature_num: feature_num is None or feature_num > 0,
|
|
439
|
+
"Feature number must be None or positive",
|
|
440
|
+
)
|
|
401
441
|
@ensure(lambda result: isinstance(result, str), "Must return string")
|
|
402
442
|
@ensure(lambda result: len(result) > 0, "Result must be non-empty")
|
|
403
|
-
def _generate_spec_markdown(self, feature: Feature) -> str:
|
|
404
|
-
"""
|
|
443
|
+
def _generate_spec_markdown(self, feature: Feature, feature_num: int | None = None) -> str:
|
|
444
|
+
"""
|
|
445
|
+
Generate Spec-Kit spec.md content from SpecFact feature.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
feature: Feature to generate spec for
|
|
449
|
+
feature_num: Optional pre-calculated feature number (avoids recalculation with fallback)
|
|
450
|
+
"""
|
|
405
451
|
from datetime import datetime
|
|
406
452
|
|
|
407
453
|
# Extract feature branch from feature key (FEATURE-001 -> 001-feature-name)
|
|
408
|
-
feature_num
|
|
454
|
+
# Use provided feature_num if available, otherwise extract from key (with fallback to 1)
|
|
455
|
+
if feature_num is None:
|
|
456
|
+
feature_num = self._extract_feature_number(feature.key)
|
|
457
|
+
if feature_num == 0:
|
|
458
|
+
# Fallback: use 1 if no number found (shouldn't happen if called from convert_to_speckit)
|
|
459
|
+
feature_num = 1
|
|
409
460
|
feature_name = self._to_feature_dir_name(feature.title)
|
|
410
461
|
feature_branch = f"{feature_num:03d}-{feature_name}"
|
|
411
462
|
|
|
@@ -438,7 +489,17 @@ class SpecKitConverter:
|
|
|
438
489
|
lines.append(f"### User Story {idx} - {story.title} (Priority: {priority})")
|
|
439
490
|
lines.append(f"Users can {story.title}")
|
|
440
491
|
lines.append("")
|
|
441
|
-
|
|
492
|
+
# Extract priority rationale from story tags, feature outcomes, or use default
|
|
493
|
+
priority_rationale = "Core functionality"
|
|
494
|
+
if story.tags:
|
|
495
|
+
for tag in story.tags:
|
|
496
|
+
if tag.startswith(("priority:", "rationale:")):
|
|
497
|
+
priority_rationale = tag.split(":", 1)[1].strip()
|
|
498
|
+
break
|
|
499
|
+
if (not priority_rationale or priority_rationale == "Core functionality") and feature.outcomes:
|
|
500
|
+
# Try to extract from feature outcomes
|
|
501
|
+
priority_rationale = feature.outcomes[0] if len(feature.outcomes[0]) < 100 else "Core functionality"
|
|
502
|
+
lines.append(f"**Why this priority**: {priority_rationale}")
|
|
442
503
|
lines.append("")
|
|
443
504
|
|
|
444
505
|
# INVSEST criteria (CRITICAL for /speckit.analyze and /speckit.checklist)
|
|
@@ -461,26 +522,100 @@ class SpecKitConverter:
|
|
|
461
522
|
for acc_idx, acc in enumerate(story.acceptance, start=1):
|
|
462
523
|
# Parse Given/When/Then if available
|
|
463
524
|
if "Given" in acc and "When" in acc and "Then" in acc:
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
525
|
+
# Use regex to properly extract Given/When/Then parts
|
|
526
|
+
# This handles commas inside type hints (e.g., "dict[str, Any]")
|
|
527
|
+
gwt_pattern = r"Given\s+(.+?),\s*When\s+(.+?),\s*Then\s+(.+?)(?:$|,)"
|
|
528
|
+
match = re.search(gwt_pattern, acc, re.IGNORECASE | re.DOTALL)
|
|
529
|
+
if match:
|
|
530
|
+
given = match.group(1).strip()
|
|
531
|
+
when = match.group(2).strip()
|
|
532
|
+
then = match.group(3).strip()
|
|
533
|
+
else:
|
|
534
|
+
# Fallback to simple split if regex fails
|
|
535
|
+
parts = acc.split(", ")
|
|
536
|
+
given = parts[0].replace("Given ", "").strip() if len(parts) > 0 else ""
|
|
537
|
+
when = parts[1].replace("When ", "").strip() if len(parts) > 1 else ""
|
|
538
|
+
then = parts[2].replace("Then ", "").strip() if len(parts) > 2 else ""
|
|
468
539
|
lines.append(f"{acc_idx}. **Given** {given}, **When** {when}, **Then** {then}")
|
|
469
540
|
|
|
470
541
|
# Categorize scenarios based on keywords
|
|
471
542
|
scenario_text = f"{given}, {when}, {then}"
|
|
472
543
|
acc_lower = acc.lower()
|
|
473
|
-
if any(keyword in acc_lower for keyword in ["error", "exception", "fail", "invalid"]):
|
|
544
|
+
if any(keyword in acc_lower for keyword in ["error", "exception", "fail", "invalid", "reject"]):
|
|
474
545
|
scenarios_exception.append(scenario_text)
|
|
475
|
-
elif any(keyword in acc_lower for keyword in ["recover", "retry", "fallback"]):
|
|
546
|
+
elif any(keyword in acc_lower for keyword in ["recover", "retry", "fallback", "retry"]):
|
|
476
547
|
scenarios_recovery.append(scenario_text)
|
|
477
|
-
elif any(
|
|
548
|
+
elif any(
|
|
549
|
+
keyword in acc_lower for keyword in ["alternate", "alternative", "different", "optional"]
|
|
550
|
+
):
|
|
478
551
|
scenarios_alternate.append(scenario_text)
|
|
479
552
|
else:
|
|
480
553
|
scenarios_primary.append(scenario_text)
|
|
481
554
|
else:
|
|
482
|
-
|
|
483
|
-
|
|
555
|
+
# Convert simple acceptance to Given/When/Then format for better scenario extraction
|
|
556
|
+
acc_lower = acc.lower()
|
|
557
|
+
|
|
558
|
+
# Generate Given/When/Then from simple acceptance
|
|
559
|
+
if "must" in acc_lower or "should" in acc_lower or "will" in acc_lower:
|
|
560
|
+
# Extract action and outcome
|
|
561
|
+
if "verify" in acc_lower or "validate" in acc_lower:
|
|
562
|
+
action = (
|
|
563
|
+
acc.replace("Must verify", "")
|
|
564
|
+
.replace("Must validate", "")
|
|
565
|
+
.replace("Should verify", "")
|
|
566
|
+
.replace("Should validate", "")
|
|
567
|
+
.strip()
|
|
568
|
+
)
|
|
569
|
+
given = "user performs action"
|
|
570
|
+
when = f"system {action}"
|
|
571
|
+
then = f"{action} succeeds"
|
|
572
|
+
elif "handle" in acc_lower or "display" in acc_lower:
|
|
573
|
+
action = (
|
|
574
|
+
acc.replace("Must handle", "")
|
|
575
|
+
.replace("Must display", "")
|
|
576
|
+
.replace("Should handle", "")
|
|
577
|
+
.replace("Should display", "")
|
|
578
|
+
.strip()
|
|
579
|
+
)
|
|
580
|
+
given = "error condition occurs"
|
|
581
|
+
when = "system processes error"
|
|
582
|
+
then = f"system {action}"
|
|
583
|
+
else:
|
|
584
|
+
# Generic conversion
|
|
585
|
+
given = "user interacts with system"
|
|
586
|
+
when = "action is performed"
|
|
587
|
+
then = acc.replace("Must", "").replace("Should", "").replace("Will", "").strip()
|
|
588
|
+
|
|
589
|
+
lines.append(f"{acc_idx}. **Given** {given}, **When** {when}, **Then** {then}")
|
|
590
|
+
|
|
591
|
+
# Categorize based on keywords
|
|
592
|
+
scenario_text = f"{given}, {when}, {then}"
|
|
593
|
+
if any(
|
|
594
|
+
keyword in acc_lower
|
|
595
|
+
for keyword in ["error", "exception", "fail", "invalid", "reject", "handle error"]
|
|
596
|
+
):
|
|
597
|
+
scenarios_exception.append(scenario_text)
|
|
598
|
+
elif any(keyword in acc_lower for keyword in ["recover", "retry", "fallback"]):
|
|
599
|
+
scenarios_recovery.append(scenario_text)
|
|
600
|
+
elif any(
|
|
601
|
+
keyword in acc_lower
|
|
602
|
+
for keyword in ["alternate", "alternative", "different", "optional"]
|
|
603
|
+
):
|
|
604
|
+
scenarios_alternate.append(scenario_text)
|
|
605
|
+
else:
|
|
606
|
+
scenarios_primary.append(scenario_text)
|
|
607
|
+
else:
|
|
608
|
+
# Keep original format but still categorize
|
|
609
|
+
lines.append(f"{acc_idx}. {acc}")
|
|
610
|
+
acc_lower = acc.lower()
|
|
611
|
+
if any(keyword in acc_lower for keyword in ["error", "exception", "fail", "invalid"]):
|
|
612
|
+
scenarios_exception.append(acc)
|
|
613
|
+
elif any(keyword in acc_lower for keyword in ["recover", "retry", "fallback"]):
|
|
614
|
+
scenarios_recovery.append(acc)
|
|
615
|
+
elif any(keyword in acc_lower for keyword in ["alternate", "alternative", "different"]):
|
|
616
|
+
scenarios_alternate.append(acc)
|
|
617
|
+
else:
|
|
618
|
+
scenarios_primary.append(acc)
|
|
484
619
|
|
|
485
620
|
lines.append("")
|
|
486
621
|
|
|
@@ -546,9 +681,12 @@ class SpecKitConverter:
|
|
|
546
681
|
return "\n".join(lines)
|
|
547
682
|
|
|
548
683
|
@beartype
|
|
549
|
-
@require(
|
|
684
|
+
@require(
|
|
685
|
+
lambda feature, plan_bundle: isinstance(feature, Feature) and isinstance(plan_bundle, PlanBundle),
|
|
686
|
+
"Must be Feature and PlanBundle instances",
|
|
687
|
+
)
|
|
550
688
|
@ensure(lambda result: isinstance(result, str), "Must return string")
|
|
551
|
-
def _generate_plan_markdown(self, feature: Feature) -> str:
|
|
689
|
+
def _generate_plan_markdown(self, feature: Feature, plan_bundle: PlanBundle) -> str:
|
|
552
690
|
"""Generate Spec-Kit plan.md content from SpecFact feature."""
|
|
553
691
|
lines = [f"# Implementation Plan: {feature.title}", ""]
|
|
554
692
|
lines.append("## Summary")
|
|
@@ -557,21 +695,49 @@ class SpecKitConverter:
|
|
|
557
695
|
|
|
558
696
|
lines.append("## Technical Context")
|
|
559
697
|
lines.append("")
|
|
560
|
-
|
|
698
|
+
|
|
699
|
+
# Extract technology stack from constraints
|
|
700
|
+
technology_stack = self._extract_technology_stack(feature, plan_bundle)
|
|
701
|
+
language_version = next((s for s in technology_stack if "Python" in s), "Python 3.11+")
|
|
702
|
+
|
|
703
|
+
lines.append(f"**Language/Version**: {language_version}")
|
|
561
704
|
lines.append("")
|
|
562
705
|
|
|
563
706
|
lines.append("**Primary Dependencies:**")
|
|
564
707
|
lines.append("")
|
|
565
|
-
#
|
|
566
|
-
|
|
567
|
-
|
|
708
|
+
# Extract dependencies from technology stack
|
|
709
|
+
dependencies = [
|
|
710
|
+
s
|
|
711
|
+
for s in technology_stack
|
|
712
|
+
if any(fw in s.lower() for fw in ["typer", "fastapi", "django", "flask", "pydantic", "sqlalchemy"])
|
|
713
|
+
]
|
|
714
|
+
if dependencies:
|
|
715
|
+
for dep in dependencies[:5]: # Limit to top 5
|
|
716
|
+
# Format: "FastAPI framework" -> "fastapi - Web framework"
|
|
717
|
+
dep_lower = dep.lower()
|
|
718
|
+
if "fastapi" in dep_lower:
|
|
719
|
+
lines.append("- `fastapi` - Web framework")
|
|
720
|
+
elif "django" in dep_lower:
|
|
721
|
+
lines.append("- `django` - Web framework")
|
|
722
|
+
elif "flask" in dep_lower:
|
|
723
|
+
lines.append("- `flask` - Web framework")
|
|
724
|
+
elif "typer" in dep_lower:
|
|
725
|
+
lines.append("- `typer` - CLI framework")
|
|
726
|
+
elif "pydantic" in dep_lower:
|
|
727
|
+
lines.append("- `pydantic` - Data validation")
|
|
728
|
+
elif "sqlalchemy" in dep_lower:
|
|
729
|
+
lines.append("- `sqlalchemy` - ORM")
|
|
730
|
+
else:
|
|
731
|
+
lines.append(f"- {dep}")
|
|
732
|
+
else:
|
|
733
|
+
lines.append("- `typer` - CLI framework")
|
|
734
|
+
lines.append("- `pydantic` - Data validation")
|
|
568
735
|
lines.append("")
|
|
569
736
|
|
|
570
737
|
lines.append("**Technology Stack:**")
|
|
571
738
|
lines.append("")
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
lines.append("- Pydantic for data validation")
|
|
739
|
+
for stack_item in technology_stack:
|
|
740
|
+
lines.append(f"- {stack_item}")
|
|
575
741
|
lines.append("")
|
|
576
742
|
|
|
577
743
|
lines.append("**Constraints:**")
|
|
@@ -588,23 +754,87 @@ class SpecKitConverter:
|
|
|
588
754
|
lines.append("- None at this time")
|
|
589
755
|
lines.append("")
|
|
590
756
|
|
|
757
|
+
# Check if contracts are defined in stories (for Article IX and contract definitions section)
|
|
758
|
+
contracts_defined = any(story.contracts for story in feature.stories if story.contracts)
|
|
759
|
+
|
|
591
760
|
# Constitution Check section (CRITICAL for /speckit.analyze)
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
761
|
+
# Extract evidence-based constitution status (Step 2.2)
|
|
762
|
+
try:
|
|
763
|
+
constitution_evidence = self.constitution_extractor.extract_all_evidence(self.repo_path)
|
|
764
|
+
constitution_section = self.constitution_extractor.generate_constitution_check_section(
|
|
765
|
+
constitution_evidence
|
|
766
|
+
)
|
|
767
|
+
lines.append(constitution_section)
|
|
768
|
+
except Exception:
|
|
769
|
+
# Fallback to basic constitution check if extraction fails
|
|
770
|
+
lines.append("## Constitution Check")
|
|
771
|
+
lines.append("")
|
|
772
|
+
lines.append("**Article VII (Simplicity)**:")
|
|
773
|
+
lines.append("- [ ] Evidence extraction pending")
|
|
774
|
+
lines.append("")
|
|
775
|
+
lines.append("**Article VIII (Anti-Abstraction)**:")
|
|
776
|
+
lines.append("- [ ] Evidence extraction pending")
|
|
777
|
+
lines.append("")
|
|
778
|
+
lines.append("**Article IX (Integration-First)**:")
|
|
779
|
+
if contracts_defined:
|
|
780
|
+
lines.append("- [x] Contracts defined?")
|
|
781
|
+
lines.append("- [ ] Contract tests written?")
|
|
782
|
+
else:
|
|
783
|
+
lines.append("- [ ] Contracts defined?")
|
|
784
|
+
lines.append("- [ ] Contract tests written?")
|
|
785
|
+
lines.append("")
|
|
786
|
+
lines.append("**Status**: PENDING")
|
|
787
|
+
lines.append("")
|
|
788
|
+
|
|
789
|
+
# Add contract definitions section if contracts exist (Step 2.1)
|
|
790
|
+
if contracts_defined:
|
|
791
|
+
lines.append("### Contract Definitions")
|
|
792
|
+
lines.append("")
|
|
793
|
+
for story in feature.stories:
|
|
794
|
+
if story.contracts:
|
|
795
|
+
lines.append(f"#### {story.title}")
|
|
796
|
+
lines.append("")
|
|
797
|
+
contracts = story.contracts
|
|
798
|
+
|
|
799
|
+
# Parameters
|
|
800
|
+
if contracts.get("parameters"):
|
|
801
|
+
lines.append("**Parameters:**")
|
|
802
|
+
for param in contracts["parameters"]:
|
|
803
|
+
param_type = param.get("type", "Any")
|
|
804
|
+
required = "required" if param.get("required", True) else "optional"
|
|
805
|
+
default = f" (default: {param.get('default')})" if param.get("default") is not None else ""
|
|
806
|
+
lines.append(f"- `{param['name']}`: {param_type} ({required}){default}")
|
|
807
|
+
lines.append("")
|
|
808
|
+
|
|
809
|
+
# Return type
|
|
810
|
+
if contracts.get("return_type"):
|
|
811
|
+
return_type = contracts["return_type"].get("type", "Any")
|
|
812
|
+
lines.append(f"**Return Type**: `{return_type}`")
|
|
813
|
+
lines.append("")
|
|
814
|
+
|
|
815
|
+
# Preconditions
|
|
816
|
+
if contracts.get("preconditions"):
|
|
817
|
+
lines.append("**Preconditions:**")
|
|
818
|
+
for precondition in contracts["preconditions"]:
|
|
819
|
+
lines.append(f"- {precondition}")
|
|
820
|
+
lines.append("")
|
|
821
|
+
|
|
822
|
+
# Postconditions
|
|
823
|
+
if contracts.get("postconditions"):
|
|
824
|
+
lines.append("**Postconditions:**")
|
|
825
|
+
for postcondition in contracts["postconditions"]:
|
|
826
|
+
lines.append(f"- {postcondition}")
|
|
827
|
+
lines.append("")
|
|
828
|
+
|
|
829
|
+
# Error contracts
|
|
830
|
+
if contracts.get("error_contracts"):
|
|
831
|
+
lines.append("**Error Contracts:**")
|
|
832
|
+
for error_contract in contracts["error_contracts"]:
|
|
833
|
+
exc_type = error_contract.get("exception_type", "Exception")
|
|
834
|
+
condition = error_contract.get("condition", "Error condition")
|
|
835
|
+
lines.append(f"- `{exc_type}`: {condition}")
|
|
836
|
+
lines.append("")
|
|
837
|
+
lines.append("")
|
|
608
838
|
|
|
609
839
|
# Phases section
|
|
610
840
|
lines.append("## Phase 0: Research")
|
|
@@ -724,6 +954,92 @@ class SpecKitConverter:
|
|
|
724
954
|
|
|
725
955
|
return "\n".join(lines)
|
|
726
956
|
|
|
957
|
+
@beartype
|
|
958
|
+
@require(lambda feature: isinstance(feature, Feature), "Must be Feature instance")
|
|
959
|
+
@require(lambda plan_bundle: isinstance(plan_bundle, PlanBundle), "Must be PlanBundle instance")
|
|
960
|
+
@ensure(lambda result: isinstance(result, list), "Must return list")
|
|
961
|
+
@ensure(lambda result: len(result) > 0, "Must have at least one stack item")
|
|
962
|
+
def _extract_technology_stack(self, feature: Feature, plan_bundle: PlanBundle) -> list[str]:
|
|
963
|
+
"""
|
|
964
|
+
Extract technology stack from feature and plan bundle constraints.
|
|
965
|
+
|
|
966
|
+
Args:
|
|
967
|
+
feature: Feature to extract stack from
|
|
968
|
+
plan_bundle: Plan bundle containing idea-level constraints
|
|
969
|
+
|
|
970
|
+
Returns:
|
|
971
|
+
List of technology stack items
|
|
972
|
+
"""
|
|
973
|
+
stack: list[str] = []
|
|
974
|
+
seen: set[str] = set()
|
|
975
|
+
|
|
976
|
+
# Extract from idea-level constraints (project-wide)
|
|
977
|
+
if plan_bundle.idea and plan_bundle.idea.constraints:
|
|
978
|
+
for constraint in plan_bundle.idea.constraints:
|
|
979
|
+
constraint_lower = constraint.lower()
|
|
980
|
+
|
|
981
|
+
# Extract Python version
|
|
982
|
+
if "python" in constraint_lower and constraint not in seen:
|
|
983
|
+
stack.append(constraint)
|
|
984
|
+
seen.add(constraint)
|
|
985
|
+
|
|
986
|
+
# Extract frameworks
|
|
987
|
+
for fw in ["fastapi", "django", "flask", "typer", "tornado", "bottle"]:
|
|
988
|
+
if fw in constraint_lower and constraint not in seen:
|
|
989
|
+
stack.append(constraint)
|
|
990
|
+
seen.add(constraint)
|
|
991
|
+
break
|
|
992
|
+
|
|
993
|
+
# Extract databases
|
|
994
|
+
for db in ["postgres", "postgresql", "mysql", "sqlite", "redis", "mongodb", "cassandra"]:
|
|
995
|
+
if db in constraint_lower and constraint not in seen:
|
|
996
|
+
stack.append(constraint)
|
|
997
|
+
seen.add(constraint)
|
|
998
|
+
break
|
|
999
|
+
|
|
1000
|
+
# Extract from feature-level constraints (feature-specific)
|
|
1001
|
+
if feature.constraints:
|
|
1002
|
+
for constraint in feature.constraints:
|
|
1003
|
+
constraint_lower = constraint.lower()
|
|
1004
|
+
|
|
1005
|
+
# Skip if already added from idea constraints
|
|
1006
|
+
if constraint in seen:
|
|
1007
|
+
continue
|
|
1008
|
+
|
|
1009
|
+
# Extract frameworks
|
|
1010
|
+
for fw in ["fastapi", "django", "flask", "typer", "tornado", "bottle"]:
|
|
1011
|
+
if fw in constraint_lower:
|
|
1012
|
+
stack.append(constraint)
|
|
1013
|
+
seen.add(constraint)
|
|
1014
|
+
break
|
|
1015
|
+
|
|
1016
|
+
# Extract databases
|
|
1017
|
+
for db in ["postgres", "postgresql", "mysql", "sqlite", "redis", "mongodb", "cassandra"]:
|
|
1018
|
+
if db in constraint_lower:
|
|
1019
|
+
stack.append(constraint)
|
|
1020
|
+
seen.add(constraint)
|
|
1021
|
+
break
|
|
1022
|
+
|
|
1023
|
+
# Extract testing tools
|
|
1024
|
+
for test in ["pytest", "unittest", "nose", "tox"]:
|
|
1025
|
+
if test in constraint_lower:
|
|
1026
|
+
stack.append(constraint)
|
|
1027
|
+
seen.add(constraint)
|
|
1028
|
+
break
|
|
1029
|
+
|
|
1030
|
+
# Extract deployment tools
|
|
1031
|
+
for deploy in ["docker", "kubernetes", "aws", "gcp", "azure"]:
|
|
1032
|
+
if deploy in constraint_lower:
|
|
1033
|
+
stack.append(constraint)
|
|
1034
|
+
seen.add(constraint)
|
|
1035
|
+
break
|
|
1036
|
+
|
|
1037
|
+
# Default fallback if nothing extracted
|
|
1038
|
+
if not stack:
|
|
1039
|
+
stack = ["Python 3.11+", "Typer for CLI", "Pydantic for data validation"]
|
|
1040
|
+
|
|
1041
|
+
return stack
|
|
1042
|
+
|
|
727
1043
|
@beartype
|
|
728
1044
|
@require(lambda feature_key: isinstance(feature_key, str), "Must be string")
|
|
729
1045
|
@ensure(lambda result: isinstance(result, int), "Must return int")
|
|
@@ -55,6 +55,71 @@ class SpecKitScanner:
|
|
|
55
55
|
specify_dir = self.repo_path / self.SPECIFY_DIR
|
|
56
56
|
return specify_dir.exists() and specify_dir.is_dir()
|
|
57
57
|
|
|
58
|
+
@beartype
|
|
59
|
+
@ensure(lambda result: isinstance(result, tuple), "Must return tuple")
|
|
60
|
+
@ensure(lambda result: len(result) == 2, "Must return (bool, str) tuple")
|
|
61
|
+
def has_constitution(self) -> tuple[bool, str]:
|
|
62
|
+
"""
|
|
63
|
+
Check if constitution.md exists and is not empty.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Tuple of (exists_and_valid, error_message)
|
|
67
|
+
- exists_and_valid: True if constitution exists and has content
|
|
68
|
+
- error_message: Empty string if valid, otherwise error description
|
|
69
|
+
"""
|
|
70
|
+
memory_dir = self.repo_path / self.SPECIFY_MEMORY_DIR
|
|
71
|
+
constitution_file = memory_dir / "constitution.md"
|
|
72
|
+
|
|
73
|
+
if not memory_dir.exists():
|
|
74
|
+
return (
|
|
75
|
+
False,
|
|
76
|
+
f"Spec-Kit memory directory not found: {memory_dir}\n"
|
|
77
|
+
"The constitution must be created before syncing.\n"
|
|
78
|
+
"Run '/speckit.constitution' command first to create the project constitution.",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if not constitution_file.exists():
|
|
82
|
+
return (
|
|
83
|
+
False,
|
|
84
|
+
f"Constitution file not found: {constitution_file}\n"
|
|
85
|
+
"The constitution is required before syncing Spec-Kit artifacts.\n"
|
|
86
|
+
"Run '/speckit.constitution' command first to create the project constitution.",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Check if file is empty or only contains whitespace/placeholders
|
|
90
|
+
try:
|
|
91
|
+
content = constitution_file.read_text(encoding="utf-8").strip()
|
|
92
|
+
if not content:
|
|
93
|
+
return (
|
|
94
|
+
False,
|
|
95
|
+
f"Constitution file is empty: {constitution_file}\n"
|
|
96
|
+
"The constitution must be populated before syncing.\n"
|
|
97
|
+
"Run '/speckit.constitution' command to fill in the constitution template.",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Check if file only contains template placeholders (no actual content)
|
|
101
|
+
# Look for patterns like [PROJECT_NAME], [PRINCIPLE_1_NAME], etc.
|
|
102
|
+
placeholder_pattern = r"\[[A-Z_0-9]+\]"
|
|
103
|
+
placeholder_count = len(re.findall(placeholder_pattern, content))
|
|
104
|
+
# If more than 50% of lines contain placeholders, consider it a template
|
|
105
|
+
lines = [line.strip() for line in content.split("\n") if line.strip()]
|
|
106
|
+
if lines and placeholder_count > len(lines) * 0.5:
|
|
107
|
+
return (
|
|
108
|
+
False,
|
|
109
|
+
f"Constitution file contains only template placeholders: {constitution_file}\n"
|
|
110
|
+
"The constitution must be filled in before syncing.\n"
|
|
111
|
+
"Run '/speckit.constitution' command to replace placeholders with actual project principles.",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return (True, "")
|
|
115
|
+
except Exception as e:
|
|
116
|
+
return (
|
|
117
|
+
False,
|
|
118
|
+
f"Error reading constitution file: {constitution_file}\n"
|
|
119
|
+
f"Error: {e!s}\n"
|
|
120
|
+
"Please ensure the constitution file is readable and try again.",
|
|
121
|
+
)
|
|
122
|
+
|
|
58
123
|
@beartype
|
|
59
124
|
@ensure(lambda result: isinstance(result, dict), "Must return dictionary")
|
|
60
125
|
@ensure(lambda result: "is_speckit" in result, "Must include is_speckit key")
|