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/utils/ide_setup.py
CHANGED
|
@@ -9,6 +9,8 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import os
|
|
11
11
|
import re
|
|
12
|
+
import site
|
|
13
|
+
import sys
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
from typing import Literal
|
|
14
16
|
|
|
@@ -112,10 +114,17 @@ IDE_CONFIG: dict[str, dict[str, str | bool | None]] = {
|
|
|
112
114
|
SPECFACT_COMMANDS = [
|
|
113
115
|
"specfact-import-from-code",
|
|
114
116
|
"specfact-plan-init",
|
|
117
|
+
"specfact-plan-add-feature",
|
|
118
|
+
"specfact-plan-add-story",
|
|
119
|
+
"specfact-plan-update-idea",
|
|
120
|
+
"specfact-plan-update-feature",
|
|
115
121
|
"specfact-plan-select",
|
|
116
122
|
"specfact-plan-promote",
|
|
117
123
|
"specfact-plan-compare",
|
|
124
|
+
"specfact-plan-review",
|
|
118
125
|
"specfact-sync",
|
|
126
|
+
"specfact-enforce",
|
|
127
|
+
"specfact-repro",
|
|
119
128
|
]
|
|
120
129
|
|
|
121
130
|
|
|
@@ -380,3 +389,164 @@ def create_vscode_settings(repo_path: Path, settings_file: str) -> Path | None:
|
|
|
380
389
|
|
|
381
390
|
console.print(f"[green]Updated:[/green] {settings_path}")
|
|
382
391
|
return settings_path
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@beartype
|
|
395
|
+
@ensure(
|
|
396
|
+
lambda result: isinstance(result, list) and all(isinstance(p, Path) for p in result), "Must return list of Paths"
|
|
397
|
+
)
|
|
398
|
+
def get_package_installation_locations(package_name: str) -> list[Path]:
|
|
399
|
+
"""
|
|
400
|
+
Get all possible installation locations for a Python package across different OS and installation types.
|
|
401
|
+
|
|
402
|
+
This function searches for package locations in:
|
|
403
|
+
- User site-packages (per-user installations: ~/.local/lib/python3.X/site-packages)
|
|
404
|
+
- System site-packages (global installations: /usr/lib/python3.X/site-packages, C:\\Python3X\\Lib\\site-packages)
|
|
405
|
+
- Virtual environments (venv, conda, etc.)
|
|
406
|
+
- uvx cache locations (~/.cache/uv/archive-v0/...)
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
package_name: Name of the package to locate (e.g., "specfact_cli")
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
List of Path objects representing possible package installation locations
|
|
413
|
+
|
|
414
|
+
Examples:
|
|
415
|
+
>>> locations = get_package_installation_locations("specfact_cli")
|
|
416
|
+
>>> len(locations) > 0
|
|
417
|
+
True
|
|
418
|
+
"""
|
|
419
|
+
locations: list[Path] = []
|
|
420
|
+
|
|
421
|
+
# Method 1: Use importlib.util.find_spec() to find the actual installed location
|
|
422
|
+
try:
|
|
423
|
+
import importlib.util
|
|
424
|
+
|
|
425
|
+
spec = importlib.util.find_spec(package_name)
|
|
426
|
+
if spec and spec.origin:
|
|
427
|
+
package_path = Path(spec.origin).parent.resolve()
|
|
428
|
+
locations.append(package_path)
|
|
429
|
+
except Exception:
|
|
430
|
+
pass
|
|
431
|
+
|
|
432
|
+
# Method 2: Check all site-packages directories (user + system)
|
|
433
|
+
try:
|
|
434
|
+
# User site-packages (per-user installation)
|
|
435
|
+
# Linux/macOS: ~/.local/lib/python3.X/site-packages
|
|
436
|
+
# Windows: %APPDATA%\\Python\\Python3X\\site-packages
|
|
437
|
+
user_site = site.getusersitepackages()
|
|
438
|
+
if user_site:
|
|
439
|
+
user_package_path = Path(user_site) / package_name
|
|
440
|
+
if user_package_path.exists():
|
|
441
|
+
locations.append(user_package_path.resolve())
|
|
442
|
+
except Exception:
|
|
443
|
+
pass
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
# System site-packages (global installation)
|
|
447
|
+
# Linux: /usr/lib/python3.X/dist-packages, /usr/local/lib/python3.X/dist-packages
|
|
448
|
+
# macOS: /Library/Frameworks/Python.framework/Versions/X/lib/pythonX.X/site-packages
|
|
449
|
+
# Windows: C:\\Python3X\\Lib\\site-packages
|
|
450
|
+
system_sites = site.getsitepackages()
|
|
451
|
+
for site_path in system_sites:
|
|
452
|
+
system_package_path = Path(site_path) / package_name
|
|
453
|
+
if system_package_path.exists():
|
|
454
|
+
locations.append(system_package_path.resolve())
|
|
455
|
+
except Exception:
|
|
456
|
+
pass
|
|
457
|
+
|
|
458
|
+
# Method 3: Check sys.path for additional locations (virtual environments, etc.)
|
|
459
|
+
for path_str in sys.path:
|
|
460
|
+
if not path_str or path_str == "":
|
|
461
|
+
continue
|
|
462
|
+
try:
|
|
463
|
+
path = Path(path_str).resolve()
|
|
464
|
+
if path.exists() and path.is_dir():
|
|
465
|
+
# Check if package is directly in this path
|
|
466
|
+
package_path = path / package_name
|
|
467
|
+
if package_path.exists():
|
|
468
|
+
locations.append(package_path.resolve())
|
|
469
|
+
# Check if this is a site-packages directory
|
|
470
|
+
if path.name == "site-packages" or "site-packages" in path.parts:
|
|
471
|
+
package_path = path / package_name
|
|
472
|
+
if package_path.exists():
|
|
473
|
+
locations.append(package_path.resolve())
|
|
474
|
+
except Exception:
|
|
475
|
+
continue
|
|
476
|
+
|
|
477
|
+
# Method 4: Check uvx cache locations (common on Linux/macOS/Windows)
|
|
478
|
+
# uvx stores packages in cache directories with varying structures
|
|
479
|
+
if sys.platform != "win32":
|
|
480
|
+
# Linux/macOS: ~/.cache/uv/archive-v0/.../lib/python3.X/site-packages/
|
|
481
|
+
uvx_cache_base = Path.home() / ".cache" / "uv" / "archive-v0"
|
|
482
|
+
if uvx_cache_base.exists():
|
|
483
|
+
for archive_dir in uvx_cache_base.iterdir():
|
|
484
|
+
if archive_dir.is_dir():
|
|
485
|
+
# Look for site-packages directories (rglob finds all matches)
|
|
486
|
+
for site_packages_dir in archive_dir.rglob("site-packages"):
|
|
487
|
+
if site_packages_dir.is_dir():
|
|
488
|
+
package_path = site_packages_dir / package_name
|
|
489
|
+
if package_path.exists():
|
|
490
|
+
locations.append(package_path.resolve())
|
|
491
|
+
else:
|
|
492
|
+
# Windows: Check %LOCALAPPDATA%\\uv\\cache\\archive-v0\\
|
|
493
|
+
localappdata = os.environ.get("LOCALAPPDATA")
|
|
494
|
+
if localappdata:
|
|
495
|
+
uvx_cache_base = Path(localappdata) / "uv" / "cache" / "archive-v0"
|
|
496
|
+
if uvx_cache_base.exists():
|
|
497
|
+
for archive_dir in uvx_cache_base.iterdir():
|
|
498
|
+
if archive_dir.is_dir():
|
|
499
|
+
# Look for site-packages directories
|
|
500
|
+
for site_packages_dir in archive_dir.rglob("site-packages"):
|
|
501
|
+
if site_packages_dir.is_dir():
|
|
502
|
+
package_path = site_packages_dir / package_name
|
|
503
|
+
if package_path.exists():
|
|
504
|
+
locations.append(package_path.resolve())
|
|
505
|
+
|
|
506
|
+
# Remove duplicates while preserving order
|
|
507
|
+
seen = set()
|
|
508
|
+
unique_locations: list[Path] = []
|
|
509
|
+
for loc in locations:
|
|
510
|
+
loc_str = str(loc)
|
|
511
|
+
if loc_str not in seen:
|
|
512
|
+
seen.add(loc_str)
|
|
513
|
+
unique_locations.append(loc)
|
|
514
|
+
|
|
515
|
+
return unique_locations
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@beartype
|
|
519
|
+
@require(lambda package_name: isinstance(package_name, str) and len(package_name) > 0, "Package name must be non-empty")
|
|
520
|
+
@ensure(
|
|
521
|
+
lambda result: result is None or (isinstance(result, Path) and result.exists()),
|
|
522
|
+
"Result must be None or existing Path",
|
|
523
|
+
)
|
|
524
|
+
def find_package_resources_path(package_name: str, resource_subpath: str) -> Path | None:
|
|
525
|
+
"""
|
|
526
|
+
Find the path to a resource within an installed package.
|
|
527
|
+
|
|
528
|
+
Searches across all possible installation locations (user, system, venv, uvx cache)
|
|
529
|
+
to find the package and then locates the resource subpath.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
package_name: Name of the package (e.g., "specfact_cli")
|
|
533
|
+
resource_subpath: Subpath within the package (e.g., "resources/prompts")
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
Path to the resource directory if found, None otherwise
|
|
537
|
+
|
|
538
|
+
Examples:
|
|
539
|
+
>>> path = find_package_resources_path("specfact_cli", "resources/prompts")
|
|
540
|
+
>>> path is None or path.exists()
|
|
541
|
+
True
|
|
542
|
+
"""
|
|
543
|
+
# Get all possible package installation locations
|
|
544
|
+
package_locations = get_package_installation_locations(package_name)
|
|
545
|
+
|
|
546
|
+
# Try each location
|
|
547
|
+
for package_path in package_locations:
|
|
548
|
+
resource_path = (package_path / resource_subpath).resolve()
|
|
549
|
+
if resource_path.exists():
|
|
550
|
+
return resource_path
|
|
551
|
+
|
|
552
|
+
return None
|
specfact_cli/utils/structure.py
CHANGED
|
@@ -30,6 +30,7 @@ class SpecFactStructure:
|
|
|
30
30
|
REPORTS_BROWNFIELD = f"{ROOT}/reports/brownfield"
|
|
31
31
|
REPORTS_COMPARISON = f"{ROOT}/reports/comparison"
|
|
32
32
|
REPORTS_ENFORCEMENT = f"{ROOT}/reports/enforcement"
|
|
33
|
+
REPORTS_ENRICHMENT = f"{ROOT}/reports/enrichment"
|
|
33
34
|
GATES_RESULTS = f"{ROOT}/gates/results"
|
|
34
35
|
CACHE = f"{ROOT}/cache"
|
|
35
36
|
|
|
@@ -76,6 +77,7 @@ class SpecFactStructure:
|
|
|
76
77
|
(base_path / cls.REPORTS_BROWNFIELD).mkdir(parents=True, exist_ok=True)
|
|
77
78
|
(base_path / cls.REPORTS_COMPARISON).mkdir(parents=True, exist_ok=True)
|
|
78
79
|
(base_path / cls.REPORTS_ENFORCEMENT).mkdir(parents=True, exist_ok=True)
|
|
80
|
+
(base_path / cls.REPORTS_ENRICHMENT).mkdir(parents=True, exist_ok=True)
|
|
79
81
|
(base_path / cls.GATES_RESULTS).mkdir(parents=True, exist_ok=True)
|
|
80
82
|
(base_path / cls.CACHE).mkdir(parents=True, exist_ok=True)
|
|
81
83
|
|
|
@@ -264,8 +266,10 @@ class SpecFactStructure:
|
|
|
264
266
|
except Exception:
|
|
265
267
|
pass
|
|
266
268
|
|
|
267
|
-
# Find all plan bundles
|
|
268
|
-
|
|
269
|
+
# Find all plan bundles, sorted by modification date (oldest first, newest last)
|
|
270
|
+
plan_files = list(plans_dir.glob("*.bundle.yaml"))
|
|
271
|
+
plan_files_sorted = sorted(plan_files, key=lambda p: p.stat().st_mtime, reverse=False)
|
|
272
|
+
for plan_file in plan_files_sorted:
|
|
269
273
|
if plan_file.name == "config.yaml":
|
|
270
274
|
continue
|
|
271
275
|
|
|
@@ -391,6 +395,175 @@ class SpecFactStructure:
|
|
|
391
395
|
directory.mkdir(parents=True, exist_ok=True)
|
|
392
396
|
return directory / f"{sanitized_name}.{timestamp}.bundle.yaml"
|
|
393
397
|
|
|
398
|
+
@classmethod
|
|
399
|
+
@beartype
|
|
400
|
+
@require(lambda plan_bundle_path: isinstance(plan_bundle_path, Path), "Plan bundle path must be Path")
|
|
401
|
+
@ensure(lambda result: isinstance(result, Path), "Must return Path")
|
|
402
|
+
def get_enrichment_report_path(cls, plan_bundle_path: Path, base_path: Path | None = None) -> Path:
|
|
403
|
+
"""
|
|
404
|
+
Get enrichment report path based on plan bundle path.
|
|
405
|
+
|
|
406
|
+
The enrichment report is named to match the plan bundle, replacing
|
|
407
|
+
`.bundle.yaml` with `.enrichment.md` and placing it in the enrichment reports directory.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
plan_bundle_path: Path to plan bundle file (e.g., `.specfact/plans/specfact-cli.2025-11-17T09-26-47.bundle.yaml`)
|
|
411
|
+
base_path: Base directory (default: current directory)
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Path to enrichment report (e.g., `.specfact/reports/enrichment/specfact-cli.2025-11-17T09-26-47.enrichment.md`)
|
|
415
|
+
|
|
416
|
+
Examples:
|
|
417
|
+
>>> plan = Path('.specfact/plans/specfact-cli.2025-11-17T09-26-47.bundle.yaml')
|
|
418
|
+
>>> SpecFactStructure.get_enrichment_report_path(plan)
|
|
419
|
+
Path('.specfact/reports/enrichment/specfact-cli.2025-11-17T09-26-47.enrichment.md')
|
|
420
|
+
"""
|
|
421
|
+
if base_path is None:
|
|
422
|
+
base_path = Path(".")
|
|
423
|
+
else:
|
|
424
|
+
# Normalize base_path to repository root (avoid recursive .specfact creation)
|
|
425
|
+
base_path = Path(base_path).resolve()
|
|
426
|
+
# If base_path contains .specfact, find the repository root
|
|
427
|
+
parts = base_path.parts
|
|
428
|
+
if ".specfact" in parts:
|
|
429
|
+
# Find the index of .specfact and go up to repository root
|
|
430
|
+
specfact_idx = parts.index(".specfact")
|
|
431
|
+
base_path = Path(*parts[:specfact_idx])
|
|
432
|
+
|
|
433
|
+
# Extract filename from plan bundle path
|
|
434
|
+
plan_filename = plan_bundle_path.name
|
|
435
|
+
|
|
436
|
+
# Replace .bundle.yaml with .enrichment.md
|
|
437
|
+
if plan_filename.endswith(".bundle.yaml"):
|
|
438
|
+
enrichment_filename = plan_filename.replace(".bundle.yaml", ".enrichment.md")
|
|
439
|
+
else:
|
|
440
|
+
# Fallback: append .enrichment.md if pattern doesn't match
|
|
441
|
+
enrichment_filename = f"{plan_bundle_path.stem}.enrichment.md"
|
|
442
|
+
|
|
443
|
+
directory = base_path / cls.REPORTS_ENRICHMENT
|
|
444
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
445
|
+
return directory / enrichment_filename
|
|
446
|
+
|
|
447
|
+
@classmethod
|
|
448
|
+
@beartype
|
|
449
|
+
@require(
|
|
450
|
+
lambda enrichment_report_path: isinstance(enrichment_report_path, Path), "Enrichment report path must be Path"
|
|
451
|
+
)
|
|
452
|
+
@ensure(lambda result: result is None or isinstance(result, Path), "Must return None or Path")
|
|
453
|
+
def get_plan_bundle_from_enrichment(
|
|
454
|
+
cls, enrichment_report_path: Path, base_path: Path | None = None
|
|
455
|
+
) -> Path | None:
|
|
456
|
+
"""
|
|
457
|
+
Get original plan bundle path from enrichment report path.
|
|
458
|
+
|
|
459
|
+
Derives the original plan bundle path by reversing the enrichment report naming convention.
|
|
460
|
+
The enrichment report is named to match the plan bundle, so we can reverse this.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
enrichment_report_path: Path to enrichment report (e.g., `.specfact/reports/enrichment/specfact-cli.2025-11-17T09-26-47.enrichment.md`)
|
|
464
|
+
base_path: Base directory (default: current directory)
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
Path to original plan bundle, or None if not found
|
|
468
|
+
|
|
469
|
+
Examples:
|
|
470
|
+
>>> enrichment = Path('.specfact/reports/enrichment/specfact-cli.2025-11-17T09-26-47.enrichment.md')
|
|
471
|
+
>>> SpecFactStructure.get_plan_bundle_from_enrichment(enrichment)
|
|
472
|
+
Path('.specfact/plans/specfact-cli.2025-11-17T09-26-47.bundle.yaml')
|
|
473
|
+
"""
|
|
474
|
+
if base_path is None:
|
|
475
|
+
base_path = Path(".")
|
|
476
|
+
else:
|
|
477
|
+
# Normalize base_path to repository root
|
|
478
|
+
base_path = Path(base_path).resolve()
|
|
479
|
+
parts = base_path.parts
|
|
480
|
+
if ".specfact" in parts:
|
|
481
|
+
specfact_idx = parts.index(".specfact")
|
|
482
|
+
base_path = Path(*parts[:specfact_idx])
|
|
483
|
+
|
|
484
|
+
# Extract filename from enrichment report path
|
|
485
|
+
enrichment_filename = enrichment_report_path.name
|
|
486
|
+
|
|
487
|
+
# Replace .enrichment.md with .bundle.yaml
|
|
488
|
+
if enrichment_filename.endswith(".enrichment.md"):
|
|
489
|
+
plan_filename = enrichment_filename.replace(".enrichment.md", ".bundle.yaml")
|
|
490
|
+
else:
|
|
491
|
+
# Fallback: try to construct from stem
|
|
492
|
+
plan_filename = f"{enrichment_report_path.stem}.bundle.yaml"
|
|
493
|
+
|
|
494
|
+
plan_path = base_path / cls.PLANS / plan_filename
|
|
495
|
+
return plan_path if plan_path.exists() else None
|
|
496
|
+
|
|
497
|
+
@classmethod
|
|
498
|
+
@beartype
|
|
499
|
+
@require(lambda original_plan_path: isinstance(original_plan_path, Path), "Original plan path must be Path")
|
|
500
|
+
@require(lambda base_path: base_path is None or isinstance(base_path, Path), "Base path must be None or Path")
|
|
501
|
+
@ensure(lambda result: isinstance(result, Path), "Must return Path")
|
|
502
|
+
def get_enriched_plan_path(cls, original_plan_path: Path, base_path: Path | None = None) -> Path:
|
|
503
|
+
"""
|
|
504
|
+
Get enriched plan bundle path based on original plan bundle path.
|
|
505
|
+
|
|
506
|
+
Creates a path for an enriched plan bundle with a clear "enriched" label and timestamp.
|
|
507
|
+
Format: `<name>.<original-timestamp>.enriched.<enrichment-timestamp>.bundle.yaml`
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
original_plan_path: Path to original plan bundle (e.g., `.specfact/plans/specfact-cli.2025-11-17T09-26-47.bundle.yaml`)
|
|
511
|
+
base_path: Base directory (default: current directory)
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
Path to enriched plan bundle (e.g., `.specfact/plans/specfact-cli.2025-11-17T09-26-47.enriched.2025-11-17T11-15-29.bundle.yaml`)
|
|
515
|
+
|
|
516
|
+
Examples:
|
|
517
|
+
>>> plan = Path('.specfact/plans/specfact-cli.2025-11-17T09-26-47.bundle.yaml')
|
|
518
|
+
>>> SpecFactStructure.get_enriched_plan_path(plan)
|
|
519
|
+
Path('.specfact/plans/specfact-cli.2025-11-17T09-26-47.enriched.2025-11-17T11-15-29.bundle.yaml')
|
|
520
|
+
"""
|
|
521
|
+
if base_path is None:
|
|
522
|
+
base_path = Path(".")
|
|
523
|
+
else:
|
|
524
|
+
# Normalize base_path to repository root
|
|
525
|
+
base_path = Path(base_path).resolve()
|
|
526
|
+
parts = base_path.parts
|
|
527
|
+
if ".specfact" in parts:
|
|
528
|
+
specfact_idx = parts.index(".specfact")
|
|
529
|
+
base_path = Path(*parts[:specfact_idx])
|
|
530
|
+
|
|
531
|
+
# Extract original plan filename
|
|
532
|
+
original_filename = original_plan_path.name
|
|
533
|
+
|
|
534
|
+
# Extract name and original timestamp from filename
|
|
535
|
+
# Format: <name>.<timestamp>.bundle.yaml
|
|
536
|
+
if original_filename.endswith(".bundle.yaml"):
|
|
537
|
+
name_with_timestamp = original_filename.replace(".bundle.yaml", "")
|
|
538
|
+
# Split name and timestamp (timestamp is after last dot before .bundle.yaml)
|
|
539
|
+
# Pattern: <name>.<timestamp> -> we want to insert .enriched.<new-timestamp>
|
|
540
|
+
parts_name = name_with_timestamp.rsplit(".", 1)
|
|
541
|
+
if len(parts_name) == 2:
|
|
542
|
+
# Has timestamp: <name>.<timestamp>
|
|
543
|
+
name_part = parts_name[0]
|
|
544
|
+
original_timestamp = parts_name[1]
|
|
545
|
+
else:
|
|
546
|
+
# No timestamp found, use whole name
|
|
547
|
+
name_part = name_with_timestamp
|
|
548
|
+
original_timestamp = None
|
|
549
|
+
else:
|
|
550
|
+
# Fallback: use stem
|
|
551
|
+
name_part = original_plan_path.stem
|
|
552
|
+
original_timestamp = None
|
|
553
|
+
|
|
554
|
+
# Generate new timestamp for enrichment
|
|
555
|
+
enrichment_timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
|
|
556
|
+
|
|
557
|
+
# Build enriched filename
|
|
558
|
+
if original_timestamp:
|
|
559
|
+
enriched_filename = f"{name_part}.{original_timestamp}.enriched.{enrichment_timestamp}.bundle.yaml"
|
|
560
|
+
else:
|
|
561
|
+
enriched_filename = f"{name_part}.enriched.{enrichment_timestamp}.bundle.yaml"
|
|
562
|
+
|
|
563
|
+
directory = base_path / cls.PLANS
|
|
564
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
565
|
+
return directory / enriched_filename
|
|
566
|
+
|
|
394
567
|
@classmethod
|
|
395
568
|
@beartype
|
|
396
569
|
@require(lambda base_path: base_path is None or isinstance(base_path, Path), "Base path must be None or Path")
|
|
@@ -462,6 +635,10 @@ This directory contains SpecFact CLI artifacts for contract-driven development.
|
|
|
462
635
|
- `plans/` - Plan bundles (versioned in git)
|
|
463
636
|
- `protocols/` - FSM protocol definitions (versioned)
|
|
464
637
|
- `reports/` - Analysis reports (gitignored)
|
|
638
|
+
- `brownfield/` - Brownfield import analysis reports
|
|
639
|
+
- `comparison/` - Plan comparison reports
|
|
640
|
+
- `enforcement/` - Enforcement validation reports
|
|
641
|
+
- `enrichment/` - LLM enrichment reports (matched to plan bundles by name/timestamp)
|
|
465
642
|
- `gates/` - Enforcement configuration and results
|
|
466
643
|
- `cache/` - Tool caches (gitignored)
|
|
467
644
|
|
specfact_cli/utils/yaml_utils.py
CHANGED
|
@@ -12,6 +12,7 @@ from typing import Any
|
|
|
12
12
|
from beartype import beartype
|
|
13
13
|
from icontract import ensure, require
|
|
14
14
|
from ruamel.yaml import YAML
|
|
15
|
+
from ruamel.yaml.scalarstring import DoubleQuotedScalarString
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
class YAMLUtils:
|
|
@@ -33,6 +34,9 @@ class YAMLUtils:
|
|
|
33
34
|
self.yaml.preserve_quotes = preserve_quotes
|
|
34
35
|
self.yaml.indent(mapping=indent_mapping, sequence=indent_sequence)
|
|
35
36
|
self.yaml.default_flow_style = False
|
|
37
|
+
# Configure to quote boolean-like strings to prevent YAML parsing issues
|
|
38
|
+
# YAML parsers interpret "Yes", "No", "True", "False", "On", "Off" as booleans
|
|
39
|
+
self.yaml.default_style = None # Let ruamel.yaml decide, but we'll quote manually
|
|
36
40
|
|
|
37
41
|
@beartype
|
|
38
42
|
@require(lambda file_path: isinstance(file_path, (Path, str)), "File path must be Path or str")
|
|
@@ -86,9 +90,38 @@ class YAMLUtils:
|
|
|
86
90
|
file_path = Path(file_path)
|
|
87
91
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
92
|
|
|
93
|
+
# Quote boolean-like strings to prevent YAML parsing issues
|
|
94
|
+
data = self._quote_boolean_like_strings(data)
|
|
95
|
+
|
|
89
96
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
90
97
|
self.yaml.dump(data, f)
|
|
91
98
|
|
|
99
|
+
@beartype
|
|
100
|
+
def _quote_boolean_like_strings(self, data: Any) -> Any:
|
|
101
|
+
"""
|
|
102
|
+
Recursively quote boolean-like strings to prevent YAML parsing issues.
|
|
103
|
+
|
|
104
|
+
YAML parsers interpret "Yes", "No", "True", "False", "On", "Off" as booleans
|
|
105
|
+
unless they're quoted. This function ensures these values are quoted.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
data: Data structure to process
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Data structure with boolean-like strings quoted
|
|
112
|
+
"""
|
|
113
|
+
# Boolean-like strings that YAML parsers interpret as booleans
|
|
114
|
+
boolean_like_strings = {"yes", "no", "true", "false", "on", "off", "Yes", "No", "True", "False", "On", "Off"}
|
|
115
|
+
|
|
116
|
+
if isinstance(data, dict):
|
|
117
|
+
return {k: self._quote_boolean_like_strings(v) for k, v in data.items()}
|
|
118
|
+
if isinstance(data, list):
|
|
119
|
+
return [self._quote_boolean_like_strings(item) for item in data]
|
|
120
|
+
if isinstance(data, str) and data in boolean_like_strings:
|
|
121
|
+
# Use DoubleQuotedScalarString to force quoting in YAML output
|
|
122
|
+
return DoubleQuotedScalarString(data)
|
|
123
|
+
return data
|
|
124
|
+
|
|
92
125
|
@beartype
|
|
93
126
|
@ensure(lambda result: isinstance(result, str), "Must return string")
|
|
94
127
|
def dump_string(self, data: Any) -> str:
|
|
@@ -605,8 +605,23 @@ class ReproChecker:
|
|
|
605
605
|
result.output = proc.stdout
|
|
606
606
|
result.error = proc.stderr
|
|
607
607
|
|
|
608
|
+
# Check if this is a CrossHair signature analysis limitation (not a real failure)
|
|
609
|
+
is_signature_issue = False
|
|
610
|
+
if tool.lower() == "crosshair" and proc.returncode != 0:
|
|
611
|
+
combined_output = f"{proc.stderr} {proc.stdout}".lower()
|
|
612
|
+
is_signature_issue = (
|
|
613
|
+
"wrong parameter order" in combined_output
|
|
614
|
+
or "keyword-only parameter" in combined_output
|
|
615
|
+
or "valueerror: wrong parameter" in combined_output
|
|
616
|
+
or ("signature" in combined_output and ("error" in combined_output or "failure" in combined_output))
|
|
617
|
+
)
|
|
618
|
+
|
|
608
619
|
if proc.returncode == 0:
|
|
609
620
|
result.status = CheckStatus.PASSED
|
|
621
|
+
elif is_signature_issue:
|
|
622
|
+
# CrossHair signature analysis limitation - treat as skipped, not failed
|
|
623
|
+
result.status = CheckStatus.SKIPPED
|
|
624
|
+
result.error = f"CrossHair signature analysis limitation (non-blocking, runtime contracts valid): {proc.stderr[:200] if proc.stderr else 'signature analysis limitation'}"
|
|
610
625
|
else:
|
|
611
626
|
result.status = CheckStatus.FAILED
|
|
612
627
|
|
|
@@ -648,7 +663,13 @@ class ReproChecker:
|
|
|
648
663
|
src_dir = self.repo_path / "src"
|
|
649
664
|
|
|
650
665
|
checks: list[tuple[str, str, list[str], int | None, bool]] = [
|
|
651
|
-
(
|
|
666
|
+
(
|
|
667
|
+
"Linting (ruff)",
|
|
668
|
+
"ruff",
|
|
669
|
+
["ruff", "check", "--output-format=full", "src/", "tests/", "tools/"],
|
|
670
|
+
None,
|
|
671
|
+
True,
|
|
672
|
+
),
|
|
652
673
|
]
|
|
653
674
|
|
|
654
675
|
# Add semgrep only if config exists
|
|
@@ -20,6 +20,13 @@ from specfact_cli.models.plan import PlanBundle
|
|
|
20
20
|
from specfact_cli.models.protocol import Protocol
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
# Try to use faster CLoader if available (C extension), fallback to SafeLoader
|
|
24
|
+
try:
|
|
25
|
+
from yaml import CLoader as YamlLoader # type: ignore[attr-defined]
|
|
26
|
+
except ImportError:
|
|
27
|
+
from yaml import SafeLoader as YamlLoader # type: ignore[assignment]
|
|
28
|
+
|
|
29
|
+
|
|
23
30
|
class SchemaValidator:
|
|
24
31
|
"""Schema validator for plan bundles and protocols."""
|
|
25
32
|
|
|
@@ -141,8 +148,10 @@ def validate_plan_bundle(
|
|
|
141
148
|
# Otherwise treat as path
|
|
142
149
|
path = plan_or_path
|
|
143
150
|
try:
|
|
144
|
-
with path.open("r") as f:
|
|
145
|
-
|
|
151
|
+
with path.open("r", encoding="utf-8") as f:
|
|
152
|
+
# Use CLoader for faster parsing (10-100x faster than SafeLoader)
|
|
153
|
+
# Falls back to SafeLoader if C extension not available
|
|
154
|
+
data = yaml.load(f, Loader=YamlLoader) # type: ignore[arg-type]
|
|
146
155
|
|
|
147
156
|
bundle = PlanBundle(**data)
|
|
148
157
|
return True, None, bundle
|
|
@@ -180,8 +189,10 @@ def validate_protocol(protocol_or_path: Protocol | Path) -> ValidationReport | t
|
|
|
180
189
|
# Otherwise treat as path
|
|
181
190
|
path = protocol_or_path
|
|
182
191
|
try:
|
|
183
|
-
with path.open("r") as f:
|
|
184
|
-
|
|
192
|
+
with path.open("r", encoding="utf-8") as f:
|
|
193
|
+
# Use CLoader for faster parsing (10-100x faster than SafeLoader)
|
|
194
|
+
# Falls back to SafeLoader if C extension not available
|
|
195
|
+
data = yaml.load(f, Loader=YamlLoader) # type: ignore[arg-type]
|
|
185
196
|
|
|
186
197
|
protocol = Protocol(**data)
|
|
187
198
|
return True, None, protocol
|