specfact-cli 0.4.2__py3-none-any.whl → 0.6.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. specfact_cli/__init__.py +1 -1
  2. specfact_cli/agents/analyze_agent.py +2 -3
  3. specfact_cli/analyzers/__init__.py +2 -1
  4. specfact_cli/analyzers/ambiguity_scanner.py +601 -0
  5. specfact_cli/analyzers/code_analyzer.py +462 -30
  6. specfact_cli/analyzers/constitution_evidence_extractor.py +491 -0
  7. specfact_cli/analyzers/contract_extractor.py +419 -0
  8. specfact_cli/analyzers/control_flow_analyzer.py +281 -0
  9. specfact_cli/analyzers/requirement_extractor.py +337 -0
  10. specfact_cli/analyzers/test_pattern_extractor.py +330 -0
  11. specfact_cli/cli.py +151 -206
  12. specfact_cli/commands/constitution.py +281 -0
  13. specfact_cli/commands/enforce.py +42 -34
  14. specfact_cli/commands/import_cmd.py +481 -152
  15. specfact_cli/commands/init.py +224 -55
  16. specfact_cli/commands/plan.py +2133 -547
  17. specfact_cli/commands/repro.py +100 -78
  18. specfact_cli/commands/sync.py +701 -186
  19. specfact_cli/enrichers/constitution_enricher.py +765 -0
  20. specfact_cli/enrichers/plan_enricher.py +294 -0
  21. specfact_cli/importers/speckit_converter.py +364 -48
  22. specfact_cli/importers/speckit_scanner.py +65 -0
  23. specfact_cli/models/plan.py +42 -0
  24. specfact_cli/resources/mappings/node-async.yaml +49 -0
  25. specfact_cli/resources/mappings/python-async.yaml +47 -0
  26. specfact_cli/resources/mappings/speckit-default.yaml +82 -0
  27. specfact_cli/resources/prompts/specfact-enforce.md +185 -0
  28. specfact_cli/resources/prompts/specfact-import-from-code.md +626 -0
  29. specfact_cli/resources/prompts/specfact-plan-add-feature.md +188 -0
  30. specfact_cli/resources/prompts/specfact-plan-add-story.md +212 -0
  31. specfact_cli/resources/prompts/specfact-plan-compare.md +571 -0
  32. specfact_cli/resources/prompts/specfact-plan-init.md +531 -0
  33. specfact_cli/resources/prompts/specfact-plan-promote.md +352 -0
  34. specfact_cli/resources/prompts/specfact-plan-review.md +1276 -0
  35. specfact_cli/resources/prompts/specfact-plan-select.md +401 -0
  36. specfact_cli/resources/prompts/specfact-plan-update-feature.md +242 -0
  37. specfact_cli/resources/prompts/specfact-plan-update-idea.md +211 -0
  38. specfact_cli/resources/prompts/specfact-repro.md +268 -0
  39. specfact_cli/resources/prompts/specfact-sync.md +497 -0
  40. specfact_cli/resources/schemas/deviation.schema.json +61 -0
  41. specfact_cli/resources/schemas/plan.schema.json +204 -0
  42. specfact_cli/resources/schemas/protocol.schema.json +53 -0
  43. specfact_cli/resources/templates/github-action.yml.j2 +140 -0
  44. specfact_cli/resources/templates/plan.bundle.yaml.j2 +141 -0
  45. specfact_cli/resources/templates/pr-template.md.j2 +58 -0
  46. specfact_cli/resources/templates/protocol.yaml.j2 +24 -0
  47. specfact_cli/resources/templates/telemetry.yaml.example +35 -0
  48. specfact_cli/sync/__init__.py +10 -1
  49. specfact_cli/sync/watcher.py +268 -0
  50. specfact_cli/telemetry.py +440 -0
  51. specfact_cli/utils/acceptance_criteria.py +127 -0
  52. specfact_cli/utils/enrichment_parser.py +445 -0
  53. specfact_cli/utils/feature_keys.py +12 -3
  54. specfact_cli/utils/ide_setup.py +170 -0
  55. specfact_cli/utils/structure.py +179 -2
  56. specfact_cli/utils/yaml_utils.py +33 -0
  57. specfact_cli/validators/repro_checker.py +22 -1
  58. specfact_cli/validators/schema.py +15 -4
  59. specfact_cli-0.6.8.dist-info/METADATA +456 -0
  60. specfact_cli-0.6.8.dist-info/RECORD +99 -0
  61. {specfact_cli-0.4.2.dist-info → specfact_cli-0.6.8.dist-info}/entry_points.txt +1 -0
  62. specfact_cli-0.6.8.dist-info/licenses/LICENSE.md +202 -0
  63. specfact_cli-0.4.2.dist-info/METADATA +0 -370
  64. specfact_cli-0.4.2.dist-info/RECORD +0 -62
  65. specfact_cli-0.4.2.dist-info/licenses/LICENSE.md +0 -61
  66. {specfact_cli-0.4.2.dist-info → specfact_cli-0.6.8.dist-info}/WHEEL +0 -0
@@ -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
@@ -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
- for plan_file in sorted(plans_dir.glob("*.bundle.yaml"), reverse=True):
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
 
@@ -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
- ("Linting (ruff)", "ruff", ["ruff", "check", "src/", "tests/", "tools/"], None, True),
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
- data = yaml.safe_load(f)
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
- data = yaml.safe_load(f)
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