specfact-cli 0.4.0__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.

Potentially problematic release.


This version of specfact-cli might be problematic. Click here for more details.

Files changed (60) hide show
  1. specfact_cli/__init__.py +14 -0
  2. specfact_cli/agents/__init__.py +23 -0
  3. specfact_cli/agents/analyze_agent.py +392 -0
  4. specfact_cli/agents/base.py +95 -0
  5. specfact_cli/agents/plan_agent.py +202 -0
  6. specfact_cli/agents/registry.py +176 -0
  7. specfact_cli/agents/sync_agent.py +133 -0
  8. specfact_cli/analyzers/__init__.py +10 -0
  9. specfact_cli/analyzers/code_analyzer.py +775 -0
  10. specfact_cli/cli.py +397 -0
  11. specfact_cli/commands/__init__.py +7 -0
  12. specfact_cli/commands/enforce.py +87 -0
  13. specfact_cli/commands/import_cmd.py +355 -0
  14. specfact_cli/commands/init.py +119 -0
  15. specfact_cli/commands/plan.py +1090 -0
  16. specfact_cli/commands/repro.py +172 -0
  17. specfact_cli/commands/sync.py +408 -0
  18. specfact_cli/common/__init__.py +24 -0
  19. specfact_cli/common/logger_setup.py +673 -0
  20. specfact_cli/common/logging_utils.py +41 -0
  21. specfact_cli/common/text_utils.py +52 -0
  22. specfact_cli/common/utils.py +48 -0
  23. specfact_cli/comparators/__init__.py +10 -0
  24. specfact_cli/comparators/plan_comparator.py +391 -0
  25. specfact_cli/generators/__init__.py +13 -0
  26. specfact_cli/generators/plan_generator.py +105 -0
  27. specfact_cli/generators/protocol_generator.py +115 -0
  28. specfact_cli/generators/report_generator.py +200 -0
  29. specfact_cli/generators/workflow_generator.py +111 -0
  30. specfact_cli/importers/__init__.py +6 -0
  31. specfact_cli/importers/speckit_converter.py +773 -0
  32. specfact_cli/importers/speckit_scanner.py +704 -0
  33. specfact_cli/models/__init__.py +32 -0
  34. specfact_cli/models/deviation.py +105 -0
  35. specfact_cli/models/enforcement.py +150 -0
  36. specfact_cli/models/plan.py +97 -0
  37. specfact_cli/models/protocol.py +28 -0
  38. specfact_cli/modes/__init__.py +18 -0
  39. specfact_cli/modes/detector.py +126 -0
  40. specfact_cli/modes/router.py +153 -0
  41. specfact_cli/sync/__init__.py +11 -0
  42. specfact_cli/sync/repository_sync.py +279 -0
  43. specfact_cli/sync/speckit_sync.py +388 -0
  44. specfact_cli/utils/__init__.py +57 -0
  45. specfact_cli/utils/console.py +69 -0
  46. specfact_cli/utils/feature_keys.py +213 -0
  47. specfact_cli/utils/git.py +241 -0
  48. specfact_cli/utils/ide_setup.py +381 -0
  49. specfact_cli/utils/prompts.py +179 -0
  50. specfact_cli/utils/structure.py +496 -0
  51. specfact_cli/utils/yaml_utils.py +200 -0
  52. specfact_cli/validators/__init__.py +19 -0
  53. specfact_cli/validators/fsm.py +260 -0
  54. specfact_cli/validators/repro_checker.py +320 -0
  55. specfact_cli/validators/schema.py +200 -0
  56. specfact_cli-0.4.0.dist-info/METADATA +332 -0
  57. specfact_cli-0.4.0.dist-info/RECORD +60 -0
  58. specfact_cli-0.4.0.dist-info/WHEEL +4 -0
  59. specfact_cli-0.4.0.dist-info/entry_points.txt +2 -0
  60. specfact_cli-0.4.0.dist-info/licenses/LICENSE.md +55 -0
@@ -0,0 +1,115 @@
1
+ """Protocol generator using Jinja2 templates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from beartype import beartype
8
+ from icontract import ensure, require
9
+ from jinja2 import Environment, FileSystemLoader
10
+
11
+ from specfact_cli.models.protocol import Protocol
12
+
13
+
14
+ class ProtocolGenerator:
15
+ """
16
+ Generator for protocol YAML files.
17
+
18
+ Uses Jinja2 templates to render protocols from Protocol models.
19
+ """
20
+
21
+ @beartype
22
+ def __init__(self, templates_dir: Path | None = None) -> None:
23
+ """
24
+ Initialize protocol generator.
25
+
26
+ Args:
27
+ templates_dir: Directory containing Jinja2 templates (default: resources/templates)
28
+ """
29
+ if templates_dir is None:
30
+ # Default to resources/templates relative to project root
31
+ templates_dir = Path(__file__).parent.parent.parent.parent / "resources" / "templates"
32
+
33
+ self.templates_dir = Path(templates_dir)
34
+ self.env = Environment(
35
+ loader=FileSystemLoader(self.templates_dir),
36
+ trim_blocks=True,
37
+ lstrip_blocks=True,
38
+ )
39
+
40
+ @beartype
41
+ @require(lambda protocol: isinstance(protocol, Protocol), "Must be Protocol instance")
42
+ @require(lambda output_path: output_path is not None, "Output path must not be None")
43
+ @require(lambda protocol: len(protocol.states) > 0, "Protocol must have at least one state")
44
+ @ensure(lambda output_path: output_path.exists(), "Output file must exist after generation")
45
+ def generate(self, protocol: Protocol, output_path: Path) -> None:
46
+ """
47
+ Generate protocol YAML file from model.
48
+
49
+ Args:
50
+ protocol: Protocol model to generate from
51
+ output_path: Path to write the generated YAML file
52
+
53
+ Raises:
54
+ FileNotFoundError: If template file doesn't exist
55
+ IOError: If unable to write output file
56
+ """
57
+ # Convert model to dict, excluding None values
58
+ protocol_data = protocol.model_dump(exclude_none=True, mode="json")
59
+
60
+ # Render template
61
+ template = self.env.get_template("protocol.yaml.j2")
62
+ rendered = template.render(**protocol_data)
63
+
64
+ # Write to file
65
+ output_path.parent.mkdir(parents=True, exist_ok=True)
66
+ output_path.write_text(rendered, encoding="utf-8")
67
+
68
+ @beartype
69
+ @require(
70
+ lambda template_name: isinstance(template_name, str) and len(template_name) > 0,
71
+ "Template name must be non-empty string",
72
+ )
73
+ @require(lambda context: isinstance(context, dict), "Context must be dictionary")
74
+ @require(lambda output_path: output_path is not None, "Output path must not be None")
75
+ @ensure(lambda output_path: output_path.exists(), "Output file must exist after generation")
76
+ def generate_from_template(self, template_name: str, context: dict, output_path: Path) -> None:
77
+ """
78
+ Generate file from custom template.
79
+
80
+ Args:
81
+ template_name: Name of the template file
82
+ context: Context dictionary for template rendering
83
+ output_path: Path to write the generated file
84
+
85
+ Raises:
86
+ FileNotFoundError: If template file doesn't exist
87
+ IOError: If unable to write output file
88
+ """
89
+ template = self.env.get_template(template_name)
90
+ rendered = template.render(**context)
91
+
92
+ output_path.parent.mkdir(parents=True, exist_ok=True)
93
+ output_path.write_text(rendered, encoding="utf-8")
94
+
95
+ @beartype
96
+ @require(lambda protocol: isinstance(protocol, Protocol), "Must be Protocol instance")
97
+ @require(lambda protocol: len(protocol.states) > 0, "Protocol must have at least one state")
98
+ @ensure(lambda result: isinstance(result, str), "Must return string")
99
+ @ensure(lambda result: len(result) > 0, "Result must be non-empty")
100
+ def render_string(self, protocol: Protocol) -> str:
101
+ """
102
+ Render protocol to YAML string without writing to file.
103
+
104
+ Args:
105
+ protocol: Protocol model to render
106
+
107
+ Returns:
108
+ Rendered YAML string
109
+
110
+ Raises:
111
+ FileNotFoundError: If template file doesn't exist
112
+ """
113
+ protocol_data = protocol.model_dump(exclude_none=True, mode="json")
114
+ template = self.env.get_template("protocol.yaml.j2")
115
+ return template.render(**protocol_data)
@@ -0,0 +1,200 @@
1
+ """Report generator for validation reports and deviation reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from enum import Enum
7
+ from pathlib import Path
8
+
9
+ from beartype import beartype
10
+ from icontract import ensure, require
11
+ from jinja2 import Environment, FileSystemLoader
12
+
13
+ from specfact_cli.models.deviation import DeviationReport, ValidationReport
14
+ from specfact_cli.utils.yaml_utils import dump_yaml
15
+
16
+
17
+ class ReportFormat(str, Enum):
18
+ """Report output format."""
19
+
20
+ MARKDOWN = "markdown"
21
+ JSON = "json"
22
+ YAML = "yaml"
23
+
24
+
25
+ class ReportGenerator:
26
+ """
27
+ Generator for validation and deviation reports.
28
+
29
+ Supports multiple output formats: Markdown, JSON, YAML.
30
+ """
31
+
32
+ @beartype
33
+ def __init__(self, templates_dir: Path | None = None) -> None:
34
+ """
35
+ Initialize report generator.
36
+
37
+ Args:
38
+ templates_dir: Directory containing Jinja2 templates (default: resources/templates)
39
+ """
40
+ if templates_dir is None:
41
+ # Default to resources/templates relative to project root
42
+ templates_dir = Path(__file__).parent.parent.parent.parent / "resources" / "templates"
43
+
44
+ self.templates_dir = Path(templates_dir)
45
+ self.env = Environment(
46
+ loader=FileSystemLoader(self.templates_dir),
47
+ trim_blocks=True,
48
+ lstrip_blocks=True,
49
+ )
50
+
51
+ @beartype
52
+ @require(lambda report: isinstance(report, ValidationReport), "Must be ValidationReport instance")
53
+ @require(lambda output_path: output_path is not None, "Output path must not be None")
54
+ @require(lambda format: format in ReportFormat, "Format must be valid ReportFormat")
55
+ @ensure(lambda output_path: output_path.exists(), "Output file must exist after generation")
56
+ def generate_validation_report(
57
+ self, report: ValidationReport, output_path: Path, format: ReportFormat = ReportFormat.MARKDOWN
58
+ ) -> None:
59
+ """
60
+ Generate validation report file.
61
+
62
+ Args:
63
+ report: ValidationReport model to generate from
64
+ output_path: Path to write the generated report file
65
+ format: Output format (markdown, json, yaml)
66
+
67
+ Raises:
68
+ ValueError: If format is unsupported
69
+ IOError: If unable to write output file
70
+ """
71
+ output_path.parent.mkdir(parents=True, exist_ok=True)
72
+
73
+ if format == ReportFormat.MARKDOWN:
74
+ self._generate_markdown_report(report, output_path)
75
+ elif format == ReportFormat.JSON:
76
+ self._generate_json_report(report, output_path)
77
+ elif format == ReportFormat.YAML:
78
+ self._generate_yaml_report(report, output_path)
79
+ else:
80
+ raise ValueError(f"Unsupported format: {format}")
81
+
82
+ @beartype
83
+ @require(lambda report: isinstance(report, DeviationReport), "Must be DeviationReport instance")
84
+ @require(lambda output_path: output_path is not None, "Output path must not be None")
85
+ @require(lambda format: format in ReportFormat, "Format must be valid ReportFormat")
86
+ @ensure(lambda output_path: output_path.exists(), "Output file must exist after generation")
87
+ def generate_deviation_report(
88
+ self, report: DeviationReport, output_path: Path, format: ReportFormat = ReportFormat.MARKDOWN
89
+ ) -> None:
90
+ """
91
+ Generate deviation report file.
92
+
93
+ Args:
94
+ report: DeviationReport model to generate from
95
+ output_path: Path to write the generated report file
96
+ format: Output format (markdown, json, yaml)
97
+
98
+ Raises:
99
+ ValueError: If format is unsupported
100
+ IOError: If unable to write output file
101
+ """
102
+ output_path.parent.mkdir(parents=True, exist_ok=True)
103
+
104
+ if format == ReportFormat.MARKDOWN:
105
+ self._generate_deviation_markdown(report, output_path)
106
+ elif format == ReportFormat.JSON:
107
+ self._generate_json_report(report, output_path)
108
+ elif format == ReportFormat.YAML:
109
+ self._generate_yaml_report(report, output_path)
110
+ else:
111
+ raise ValueError(f"Unsupported format: {format}")
112
+
113
+ def _generate_markdown_report(self, report: ValidationReport, output_path: Path) -> None:
114
+ """Generate markdown validation report."""
115
+ lines = []
116
+ lines.append("# Validation Report\n")
117
+ lines.append(f"**Status**: {'✅ PASSED' if report.passed else '❌ FAILED'}\n")
118
+ total_count = len(report.deviations)
119
+ lines.append(f"**Total Deviations**: {total_count}\n")
120
+
121
+ if total_count > 0:
122
+ lines.append("\n## Deviations by Severity\n")
123
+ lines.append(f"- 🔴 **HIGH**: {report.high_count}")
124
+ lines.append(f"- 🟡 **MEDIUM**: {report.medium_count}")
125
+ lines.append(f"- 🔵 **LOW**: {report.low_count}\n")
126
+
127
+ lines.append("\n## Detailed Deviations\n")
128
+ for deviation in report.deviations:
129
+ lines.append(f"### {deviation.severity.value.upper()}: {deviation.description}\n")
130
+ if deviation.location:
131
+ lines.append(f"**Location**: `{deviation.location}`\n")
132
+ if deviation.fix_hint:
133
+ lines.append(f"**Fix Hint**: {deviation.fix_hint}\n")
134
+ lines.append("")
135
+
136
+ output_path.write_text("\n".join(lines), encoding="utf-8")
137
+
138
+ def _generate_deviation_markdown(self, report: DeviationReport, output_path: Path) -> None:
139
+ """Generate markdown deviation report."""
140
+ lines = []
141
+ lines.append("# Deviation Report\n")
142
+ lines.append(f"**Manual Plan**: {report.manual_plan}")
143
+ lines.append(f"**Auto Plan**: {report.auto_plan}")
144
+ lines.append(f"**Total Deviations**: {len(report.deviations)}\n")
145
+
146
+ # Group by type
147
+ by_type: dict = {}
148
+ for deviation in report.deviations:
149
+ type_key = deviation.type.value
150
+ if type_key not in by_type:
151
+ by_type[type_key] = []
152
+ by_type[type_key].append(deviation)
153
+
154
+ lines.append("\n## Deviations by Type\n")
155
+ for type_key, devs in by_type.items():
156
+ lines.append(f"### {type_key} ({len(devs)} issues)\n")
157
+ for dev in devs:
158
+ lines.append(f"- **{dev.severity.value.upper()}**: {dev.description}")
159
+ if dev.location:
160
+ lines.append(f" - Location: `{dev.location}`")
161
+ if dev.fix_hint:
162
+ lines.append(f" - Fix: {dev.fix_hint}")
163
+ lines.append("")
164
+
165
+ output_path.write_text("\n".join(lines), encoding="utf-8")
166
+
167
+ def _generate_json_report(self, report: ValidationReport | DeviationReport, output_path: Path) -> None:
168
+ """Generate JSON report."""
169
+ report_data = report.model_dump(mode="json")
170
+ output_path.write_text(json.dumps(report_data, indent=2), encoding="utf-8")
171
+
172
+ def _generate_yaml_report(self, report: ValidationReport | DeviationReport, output_path: Path) -> None:
173
+ """Generate YAML report."""
174
+ dump_yaml(report.model_dump(mode="json"), output_path)
175
+
176
+ def render_markdown_string(self, report: ValidationReport | DeviationReport) -> str:
177
+ """
178
+ Render report to markdown string without writing to file.
179
+
180
+ Args:
181
+ report: ValidationReport or DeviationReport model to render
182
+
183
+ Returns:
184
+ Rendered markdown string
185
+ """
186
+ from io import StringIO
187
+
188
+ output = StringIO()
189
+
190
+ if isinstance(report, ValidationReport):
191
+ output.write("# Validation Report\n\n")
192
+ output.write(f"**Status**: {'✅ PASSED' if report.passed else '❌ FAILED'}\n\n")
193
+ output.write(f"**Total Deviations**: {len(report.deviations)}\n\n")
194
+ elif isinstance(report, DeviationReport):
195
+ output.write("# Deviation Report\n\n")
196
+ output.write(f"**Manual Plan**: {report.manual_plan}\n")
197
+ output.write(f"**Auto Plan**: {report.auto_plan}\n")
198
+ output.write(f"**Total Deviations**: {len(report.deviations)}\n\n")
199
+
200
+ return output.getvalue()
@@ -0,0 +1,111 @@
1
+ """Workflow generator for GitHub Actions and Semgrep rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from beartype import beartype
10
+ from icontract import ensure, require
11
+ from jinja2 import Environment, FileSystemLoader
12
+
13
+
14
+ class WorkflowGenerator:
15
+ """
16
+ Generator for GitHub Actions workflows and Semgrep rules.
17
+
18
+ Uses Jinja2 templates to render workflows and copies Semgrep rules.
19
+ """
20
+
21
+ @beartype
22
+ def __init__(self, templates_dir: Path | None = None) -> None:
23
+ """
24
+ Initialize workflow generator.
25
+
26
+ Args:
27
+ templates_dir: Directory containing Jinja2 templates (default: resources/templates)
28
+ """
29
+ if templates_dir is None:
30
+ # Default to resources/templates relative to project root
31
+ templates_dir = Path(__file__).parent.parent.parent.parent / "resources" / "templates"
32
+
33
+ self.templates_dir = Path(templates_dir)
34
+ self.env = Environment(
35
+ loader=FileSystemLoader(self.templates_dir),
36
+ trim_blocks=True,
37
+ lstrip_blocks=True,
38
+ )
39
+
40
+ @beartype
41
+ @require(lambda output_path: output_path is not None, "Output path must not be None")
42
+ @require(lambda budget: budget > 0, "Budget must be positive")
43
+ @require(lambda python_version: python_version.startswith("3."), "Python version must be 3.x")
44
+ @ensure(lambda output_path: output_path.exists(), "Output file must exist after generation")
45
+ @ensure(lambda output_path: output_path.suffix == ".yml", "Output must be YAML file")
46
+ def generate_github_action(
47
+ self,
48
+ output_path: Path,
49
+ repo_name: str | None = None,
50
+ budget: int = 90,
51
+ python_version: str = "3.12",
52
+ ) -> None:
53
+ """
54
+ Generate GitHub Action workflow for SpecFact validation.
55
+
56
+ Args:
57
+ output_path: Path to write the workflow file (e.g., .github/workflows/specfact-gate.yml)
58
+ repo_name: Repository name for context
59
+ budget: Time budget in seconds for validation (must be > 0)
60
+ python_version: Python version for workflow (must be 3.x)
61
+
62
+ Raises:
63
+ FileNotFoundError: If template file doesn't exist
64
+ IOError: If unable to write output file
65
+ """
66
+ # Prepare context
67
+ context: dict[str, Any] = {
68
+ "repo_name": repo_name or "specfact-project",
69
+ "budget": budget,
70
+ "python_version": python_version,
71
+ }
72
+
73
+ # Render template
74
+ template = self.env.get_template("github-action.yml.j2")
75
+ rendered = template.render(**context)
76
+
77
+ # Ensure output directory exists
78
+ output_path.parent.mkdir(parents=True, exist_ok=True)
79
+
80
+ # Write workflow file
81
+ output_path.write_text(rendered, encoding="utf-8")
82
+
83
+ @beartype
84
+ @require(lambda output_path: output_path is not None, "Output path must not be None")
85
+ @ensure(lambda output_path: output_path.exists(), "Output file must exist after generation")
86
+ @ensure(lambda output_path: output_path.suffix in (".yml", ".yaml"), "Output must be YAML file")
87
+ def generate_semgrep_rules(self, output_path: Path, source_rules: Path | None = None) -> None:
88
+ """
89
+ Generate Semgrep async rules for the repository.
90
+
91
+ Args:
92
+ output_path: Path to write Semgrep rules (e.g., .semgrep/async-anti-patterns.yml)
93
+ source_rules: Path to source Semgrep rules (default: tools/semgrep/async.yml)
94
+
95
+ Raises:
96
+ FileNotFoundError: If source rules file doesn't exist
97
+ IOError: If unable to write output file
98
+ """
99
+ if source_rules is None:
100
+ # Default to tools/semgrep/async.yml relative to project root
101
+ source_rules = Path(__file__).parent.parent.parent.parent / "tools" / "semgrep" / "async.yml"
102
+
103
+ source_rules = Path(source_rules)
104
+ if not source_rules.exists():
105
+ raise FileNotFoundError(f"Source Semgrep rules not found: {source_rules}")
106
+
107
+ # Ensure output directory exists
108
+ output_path.parent.mkdir(parents=True, exist_ok=True)
109
+
110
+ # Copy source rules to output path
111
+ shutil.copy2(source_rules, output_path)
@@ -0,0 +1,6 @@
1
+ """Importers for converting external formats to SpecFact format."""
2
+
3
+ from specfact_cli.importers.speckit_converter import SpecKitConverter
4
+ from specfact_cli.importers.speckit_scanner import SpecKitScanner
5
+
6
+ __all__ = ["SpecKitConverter", "SpecKitScanner"]