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.
- specfact_cli/__init__.py +14 -0
- specfact_cli/agents/__init__.py +23 -0
- specfact_cli/agents/analyze_agent.py +392 -0
- specfact_cli/agents/base.py +95 -0
- specfact_cli/agents/plan_agent.py +202 -0
- specfact_cli/agents/registry.py +176 -0
- specfact_cli/agents/sync_agent.py +133 -0
- specfact_cli/analyzers/__init__.py +10 -0
- specfact_cli/analyzers/code_analyzer.py +775 -0
- specfact_cli/cli.py +397 -0
- specfact_cli/commands/__init__.py +7 -0
- specfact_cli/commands/enforce.py +87 -0
- specfact_cli/commands/import_cmd.py +355 -0
- specfact_cli/commands/init.py +119 -0
- specfact_cli/commands/plan.py +1090 -0
- specfact_cli/commands/repro.py +172 -0
- specfact_cli/commands/sync.py +408 -0
- specfact_cli/common/__init__.py +24 -0
- specfact_cli/common/logger_setup.py +673 -0
- specfact_cli/common/logging_utils.py +41 -0
- specfact_cli/common/text_utils.py +52 -0
- specfact_cli/common/utils.py +48 -0
- specfact_cli/comparators/__init__.py +10 -0
- specfact_cli/comparators/plan_comparator.py +391 -0
- specfact_cli/generators/__init__.py +13 -0
- specfact_cli/generators/plan_generator.py +105 -0
- specfact_cli/generators/protocol_generator.py +115 -0
- specfact_cli/generators/report_generator.py +200 -0
- specfact_cli/generators/workflow_generator.py +111 -0
- specfact_cli/importers/__init__.py +6 -0
- specfact_cli/importers/speckit_converter.py +773 -0
- specfact_cli/importers/speckit_scanner.py +704 -0
- specfact_cli/models/__init__.py +32 -0
- specfact_cli/models/deviation.py +105 -0
- specfact_cli/models/enforcement.py +150 -0
- specfact_cli/models/plan.py +97 -0
- specfact_cli/models/protocol.py +28 -0
- specfact_cli/modes/__init__.py +18 -0
- specfact_cli/modes/detector.py +126 -0
- specfact_cli/modes/router.py +153 -0
- specfact_cli/sync/__init__.py +11 -0
- specfact_cli/sync/repository_sync.py +279 -0
- specfact_cli/sync/speckit_sync.py +388 -0
- specfact_cli/utils/__init__.py +57 -0
- specfact_cli/utils/console.py +69 -0
- specfact_cli/utils/feature_keys.py +213 -0
- specfact_cli/utils/git.py +241 -0
- specfact_cli/utils/ide_setup.py +381 -0
- specfact_cli/utils/prompts.py +179 -0
- specfact_cli/utils/structure.py +496 -0
- specfact_cli/utils/yaml_utils.py +200 -0
- specfact_cli/validators/__init__.py +19 -0
- specfact_cli/validators/fsm.py +260 -0
- specfact_cli/validators/repro_checker.py +320 -0
- specfact_cli/validators/schema.py +200 -0
- specfact_cli-0.4.0.dist-info/METADATA +332 -0
- specfact_cli-0.4.0.dist-info/RECORD +60 -0
- specfact_cli-0.4.0.dist-info/WHEEL +4 -0
- specfact_cli-0.4.0.dist-info/entry_points.txt +2 -0
- 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)
|