specfact-cli 0.4.2__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 (62) hide show
  1. specfact_cli/__init__.py +14 -0
  2. specfact_cli/agents/__init__.py +24 -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 +11 -0
  9. specfact_cli/analyzers/code_analyzer.py +796 -0
  10. specfact_cli/cli.py +396 -0
  11. specfact_cli/commands/__init__.py +7 -0
  12. specfact_cli/commands/enforce.py +88 -0
  13. specfact_cli/commands/import_cmd.py +365 -0
  14. specfact_cli/commands/init.py +125 -0
  15. specfact_cli/commands/plan.py +1089 -0
  16. specfact_cli/commands/repro.py +192 -0
  17. specfact_cli/commands/sync.py +408 -0
  18. specfact_cli/common/__init__.py +25 -0
  19. specfact_cli/common/logger_setup.py +654 -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 +11 -0
  24. specfact_cli/comparators/plan_comparator.py +391 -0
  25. specfact_cli/generators/__init__.py +14 -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 +120 -0
  30. specfact_cli/importers/__init__.py +7 -0
  31. specfact_cli/importers/speckit_converter.py +773 -0
  32. specfact_cli/importers/speckit_scanner.py +711 -0
  33. specfact_cli/models/__init__.py +33 -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 +19 -0
  39. specfact_cli/modes/detector.py +126 -0
  40. specfact_cli/modes/router.py +153 -0
  41. specfact_cli/resources/semgrep/async.yml +285 -0
  42. specfact_cli/sync/__init__.py +12 -0
  43. specfact_cli/sync/repository_sync.py +279 -0
  44. specfact_cli/sync/speckit_sync.py +388 -0
  45. specfact_cli/utils/__init__.py +58 -0
  46. specfact_cli/utils/console.py +70 -0
  47. specfact_cli/utils/feature_keys.py +212 -0
  48. specfact_cli/utils/git.py +241 -0
  49. specfact_cli/utils/github_annotations.py +399 -0
  50. specfact_cli/utils/ide_setup.py +382 -0
  51. specfact_cli/utils/prompts.py +180 -0
  52. specfact_cli/utils/structure.py +497 -0
  53. specfact_cli/utils/yaml_utils.py +200 -0
  54. specfact_cli/validators/__init__.py +20 -0
  55. specfact_cli/validators/fsm.py +262 -0
  56. specfact_cli/validators/repro_checker.py +759 -0
  57. specfact_cli/validators/schema.py +196 -0
  58. specfact_cli-0.4.2.dist-info/METADATA +370 -0
  59. specfact_cli-0.4.2.dist-info/RECORD +62 -0
  60. specfact_cli-0.4.2.dist-info/WHEEL +4 -0
  61. specfact_cli-0.4.2.dist-info/entry_points.txt +2 -0
  62. specfact_cli-0.4.2.dist-info/licenses/LICENSE.md +61 -0
@@ -0,0 +1,399 @@
1
+ """
2
+ GitHub Action annotations and PR comment utilities.
3
+
4
+ This module provides utilities for creating GitHub Action annotations
5
+ and PR comments from SpecFact validation reports.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from beartype import beartype
16
+ from icontract import ensure, require
17
+
18
+
19
+ @beartype
20
+ @require(lambda message: isinstance(message, str) and len(message) > 0, "Message must be non-empty string")
21
+ @require(lambda level: level in ("notice", "warning", "error"), "Level must be notice, warning, or error")
22
+ @require(
23
+ lambda file: file is None or (isinstance(file, str) and len(file) > 0), "File must be None or non-empty string"
24
+ )
25
+ @require(lambda line: line is None or (isinstance(line, int) and line > 0), "Line must be None or positive integer")
26
+ @require(lambda col: col is None or (isinstance(col, int) and col > 0), "Column must be None or positive integer")
27
+ @require(
28
+ lambda title: title is None or (isinstance(title, str) and len(title) > 0), "Title must be None or non-empty string"
29
+ )
30
+ def create_annotation(
31
+ message: str,
32
+ level: str = "error",
33
+ file: str | None = None,
34
+ line: int | None = None,
35
+ col: int | None = None,
36
+ title: str | None = None,
37
+ ) -> None:
38
+ """
39
+ Create a GitHub Action annotation.
40
+
41
+ Args:
42
+ message: Annotation message
43
+ level: Annotation level (notice, warning, error)
44
+ file: Optional file path
45
+ line: Optional line number
46
+ col: Optional column number
47
+ title: Optional annotation title
48
+ """
49
+ # Format: ::level file=file,line=line,col=col,title=title::message
50
+ parts: list[str] = [f"::{level}"]
51
+
52
+ if file or line or col or title:
53
+ opts: list[str] = []
54
+ if file:
55
+ opts.append(f"file={file}")
56
+ if line:
57
+ opts.append(f"line={line}")
58
+ if col:
59
+ opts.append(f"col={col}")
60
+ if title:
61
+ opts.append(f"title={title}")
62
+ parts.append(",".join(opts))
63
+
64
+ parts.append(f"::{message}")
65
+
66
+ print("".join(parts), file=sys.stdout)
67
+
68
+
69
+ @beartype
70
+ @require(lambda report_path: report_path.exists(), "Report path must exist")
71
+ @require(lambda report_path: report_path.suffix in (".yaml", ".yml"), "Report must be YAML file")
72
+ @require(lambda report_path: report_path.is_file(), "Report path must be a file")
73
+ @ensure(lambda result: isinstance(result, dict), "Must return dictionary")
74
+ @ensure(lambda result: "checks" in result or "total_checks" in result, "Report must contain checks or total_checks")
75
+ def parse_repro_report(report_path: Path) -> dict[str, Any]:
76
+ """
77
+ Parse a repro report YAML file.
78
+
79
+ Args:
80
+ report_path: Path to repro report YAML file
81
+
82
+ Returns:
83
+ Parsed report dictionary with checks and metadata
84
+
85
+ Raises:
86
+ FileNotFoundError: If report file doesn't exist
87
+ ValueError: If report is not valid YAML or doesn't match expected structure
88
+ """
89
+ from specfact_cli.utils.yaml_utils import load_yaml
90
+
91
+ try:
92
+ report = load_yaml(report_path)
93
+ if not isinstance(report, dict):
94
+ raise ValueError(f"Report must be a dictionary, got {type(report)}")
95
+ return report
96
+ except Exception as e:
97
+ raise ValueError(f"Failed to parse repro report: {e}") from e
98
+
99
+
100
+ @beartype
101
+ @require(lambda report: isinstance(report, dict), "Report must be dictionary")
102
+ @require(lambda report: "checks" in report or "total_checks" in report, "Report must contain checks or total_checks")
103
+ @ensure(lambda result: isinstance(result, bool), "Must return boolean")
104
+ def create_annotations_from_report(report: dict[str, Any]) -> bool:
105
+ """
106
+ Create GitHub Action annotations from a repro report.
107
+
108
+ Args:
109
+ report: Repro report dictionary
110
+
111
+ Returns:
112
+ True if any failures found, False otherwise
113
+ """
114
+ checks = report.get("checks", [])
115
+ has_failures = False
116
+
117
+ for check in checks:
118
+ status = check.get("status", "unknown")
119
+ name = check.get("name", "Unknown check")
120
+ tool = check.get("tool", "unknown")
121
+ error = check.get("error", "")
122
+ output = check.get("output", "")
123
+
124
+ # Check if this is a CrossHair signature analysis limitation (not a real failure)
125
+ is_signature_issue = False
126
+ if tool.lower() == "crosshair" and status == "failed":
127
+ # Check for signature analysis limitation patterns
128
+ combined_output = f"{error} {output}".lower()
129
+ is_signature_issue = (
130
+ "wrong parameter order" in combined_output
131
+ or "keyword-only parameter" in combined_output
132
+ or "valueerror: wrong parameter" in combined_output
133
+ or ("signature" in combined_output and ("error" in combined_output or "failure" in combined_output))
134
+ )
135
+
136
+ if status == "failed" and not is_signature_issue:
137
+ has_failures = True
138
+
139
+ # Create error annotation
140
+ message = f"{name} ({tool}) failed"
141
+ if error:
142
+ message += f": {error}"
143
+ elif output:
144
+ # Truncate output for annotation
145
+ truncated = output[:500] + "..." if len(output) > 500 else output
146
+ message += f": {truncated}"
147
+
148
+ create_annotation(
149
+ message=message,
150
+ level="error",
151
+ title=f"{name} failed",
152
+ )
153
+ elif status == "failed" and is_signature_issue:
154
+ # CrossHair signature analysis limitation - treat as skipped, not failed
155
+ create_annotation(
156
+ message=f"{name} ({tool}) - signature analysis limitation (non-blocking, runtime contracts valid)",
157
+ level="notice",
158
+ title=f"{name} skipped (signature limitation)",
159
+ )
160
+ elif status == "timeout":
161
+ has_failures = True
162
+ create_annotation(
163
+ message=f"{name} ({tool}) timed out",
164
+ level="warning",
165
+ title=f"{name} timeout",
166
+ )
167
+ elif status == "skipped":
168
+ # Explicitly skipped checks - don't treat as failures
169
+ create_annotation(
170
+ message=f"{name} ({tool}) was skipped",
171
+ level="notice",
172
+ title=f"{name} skipped",
173
+ )
174
+
175
+ # Create summary annotation
176
+ total_checks = report.get("total_checks", 0)
177
+ passed_checks = report.get("passed_checks", 0)
178
+ failed_checks = report.get("failed_checks", 0)
179
+ timeout_checks = report.get("timeout_checks", 0)
180
+ budget_exceeded = report.get("budget_exceeded", False)
181
+
182
+ if budget_exceeded:
183
+ has_failures = True # Budget exceeded is a failure
184
+ create_annotation(
185
+ message="Validation budget exceeded",
186
+ level="error",
187
+ title="Budget exceeded",
188
+ )
189
+
190
+ summary = f"Validation summary: {passed_checks}/{total_checks} passed"
191
+ if failed_checks > 0:
192
+ summary += f", {failed_checks} failed"
193
+ if timeout_checks > 0:
194
+ summary += f", {timeout_checks} timed out"
195
+
196
+ level = "error" if has_failures else "notice"
197
+ create_annotation(
198
+ message=summary,
199
+ level=level,
200
+ title="Validation summary",
201
+ )
202
+
203
+ return has_failures
204
+
205
+
206
+ @beartype
207
+ @require(lambda report: isinstance(report, dict), "Report must be dictionary")
208
+ @require(lambda report: "total_checks" in report or "checks" in report, "Report must contain total_checks or checks")
209
+ @ensure(lambda result: isinstance(result, str), "Must return string")
210
+ @ensure(lambda result: len(result) > 0, "Comment must not be empty")
211
+ @ensure(lambda result: result.startswith("##"), "Comment must start with markdown header")
212
+ def generate_pr_comment(report: dict[str, Any]) -> str:
213
+ """
214
+ Generate a PR comment from a repro report.
215
+
216
+ Args:
217
+ report: Repro report dictionary
218
+
219
+ Returns:
220
+ Formatted PR comment markdown
221
+ """
222
+ lines: list[str] = []
223
+ lines.append("## SpecFact CLI Validation Report\n")
224
+
225
+ total_checks = report.get("total_checks", 0)
226
+ passed_checks = report.get("passed_checks", 0)
227
+ failed_checks = report.get("failed_checks", 0)
228
+ timeout_checks = report.get("timeout_checks", 0)
229
+ skipped_checks = report.get("skipped_checks", 0)
230
+ budget_exceeded = report.get("budget_exceeded", False)
231
+ total_duration = report.get("total_duration", 0.0)
232
+
233
+ # Summary
234
+ if failed_checks == 0 and timeout_checks == 0 and not budget_exceeded:
235
+ lines.append("✅ **All validations passed!**\n")
236
+ else:
237
+ lines.append("❌ **Validation issues detected**\n")
238
+
239
+ lines.append(f"**Duration**: {total_duration:.2f}s\n")
240
+ lines.append(f"**Checks**: {total_checks} total")
241
+ if passed_checks > 0:
242
+ lines.append(f" ({passed_checks} passed)")
243
+ if failed_checks > 0:
244
+ lines.append(f" ({failed_checks} failed)")
245
+ if timeout_checks > 0:
246
+ lines.append(f" ({timeout_checks} timed out)")
247
+ if skipped_checks > 0:
248
+ lines.append(f" ({skipped_checks} skipped)")
249
+ lines.append("\n\n")
250
+
251
+ # Failed checks (excluding signature analysis limitations)
252
+ checks = report.get("checks", [])
253
+ failed_checks_list = []
254
+ signature_issues_list = []
255
+
256
+ for check in checks:
257
+ if check.get("status") == "failed":
258
+ tool = check.get("tool", "unknown").lower()
259
+ error = check.get("error", "")
260
+ output = check.get("output", "")
261
+
262
+ # Check if this is a CrossHair signature analysis limitation
263
+ is_signature_issue = False
264
+ if tool == "crosshair":
265
+ combined_output = f"{error} {output}".lower()
266
+ is_signature_issue = (
267
+ "wrong parameter order" in combined_output
268
+ or "keyword-only parameter" in combined_output
269
+ or "valueerror: wrong parameter" in combined_output
270
+ or ("signature" in combined_output and ("error" in combined_output or "failure" in combined_output))
271
+ )
272
+
273
+ if is_signature_issue:
274
+ signature_issues_list.append(check)
275
+ else:
276
+ failed_checks_list.append(check)
277
+
278
+ if failed_checks_list:
279
+ lines.append("### ❌ Failed Checks\n\n")
280
+ for check in failed_checks_list:
281
+ name = check.get("name", "Unknown")
282
+ tool = check.get("tool", "unknown")
283
+ error = check.get("error")
284
+ output = check.get("output")
285
+
286
+ lines.append(f"#### {name} ({tool})\n\n")
287
+ if error:
288
+ lines.append(f"**Error**: `{error}`\n\n")
289
+ if output:
290
+ lines.append("<details>\n<summary>Output</summary>\n\n")
291
+ lines.append("```\n")
292
+ lines.append(output[:2000]) # Limit output size
293
+ if len(output) > 2000:
294
+ lines.append("\n... (truncated)")
295
+ lines.append("\n```\n\n")
296
+ lines.append("</details>\n\n")
297
+
298
+ # Add fix suggestions for Semgrep checks
299
+ if tool == "semgrep":
300
+ lines.append(
301
+ "💡 **Auto-fix available**: Run `specfact repro --fix` to apply automatic fixes for violations with fix capabilities.\n\n"
302
+ )
303
+
304
+ # Signature analysis limitations (non-blocking)
305
+ if signature_issues_list:
306
+ lines.append("### ⚠️ Signature Analysis Limitations (Non-blocking)\n\n")
307
+ lines.append(
308
+ "The following checks encountered CrossHair signature analysis limitations. "
309
+ "These are non-blocking issues related to complex function signatures (Typer decorators, keyword-only parameters) "
310
+ "and do not indicate actual contract violations. Runtime contracts remain valid.\n\n"
311
+ )
312
+ for check in signature_issues_list:
313
+ name = check.get("name", "Unknown")
314
+ tool = check.get("tool", "unknown")
315
+ lines.append(f"- **{name}** ({tool}) - signature analysis limitation\n")
316
+ lines.append("\n")
317
+
318
+ # Timeout checks
319
+ timeout_checks_list = [c for c in checks if c.get("status") == "timeout"]
320
+ if timeout_checks_list:
321
+ lines.append("### ⏱️ Timeout Checks\n\n")
322
+ for check in timeout_checks_list:
323
+ name = check.get("name", "Unknown")
324
+ tool = check.get("tool", "unknown")
325
+ lines.append(f"- **{name}** ({tool}) - timed out\n")
326
+ lines.append("\n")
327
+
328
+ # Budget exceeded
329
+ if budget_exceeded:
330
+ lines.append("### ⚠️ Budget Exceeded\n\n")
331
+ lines.append("The validation budget was exceeded. Consider increasing the budget or optimizing the checks.\n\n")
332
+
333
+ # Suggestions
334
+ if failed_checks > 0:
335
+ lines.append("### 💡 Suggestions\n\n")
336
+ lines.append("1. Review the failed checks above")
337
+ lines.append("2. Fix the issues in your code")
338
+ lines.append("3. Re-run validation: `specfact repro --budget 90`\n\n")
339
+ lines.append("To run in warn mode (non-blocking), set `mode: warn` in your workflow configuration.\n\n")
340
+
341
+ return "".join(lines)
342
+
343
+
344
+ @beartype
345
+ @ensure(lambda result: result in (0, 1), "Exit code must be 0 or 1")
346
+ def main() -> int:
347
+ """
348
+ Main entry point for GitHub annotations script.
349
+
350
+ Reads repro report from environment variable or default path,
351
+ creates annotations, and optionally generates PR comment.
352
+
353
+ Returns:
354
+ Exit code (0 = success/no failures, 1 = failures detected or error)
355
+ """
356
+ # Get report path from environment or use default
357
+ report_path_str = os.environ.get("SPECFACT_REPORT_PATH")
358
+ if report_path_str:
359
+ report_path = Path(report_path_str)
360
+ else:
361
+ # Default: look for latest report in .specfact/reports/enforcement/
362
+ default_dir = Path(".specfact/reports/enforcement")
363
+ if default_dir.exists():
364
+ reports = sorted(default_dir.glob("report-*.yaml"), key=lambda p: p.stat().st_mtime, reverse=True)
365
+ if reports:
366
+ report_path = reports[0]
367
+ else:
368
+ print("No repro report found", file=sys.stderr)
369
+ return 1
370
+ else:
371
+ print("No repro report directory found", file=sys.stderr)
372
+ return 1
373
+
374
+ if not report_path.exists():
375
+ print(f"Report file not found: {report_path}", file=sys.stderr)
376
+ return 1
377
+
378
+ # Parse report
379
+ report = parse_repro_report(report_path)
380
+
381
+ # Create annotations
382
+ has_failures = create_annotations_from_report(report)
383
+
384
+ # Generate PR comment if requested
385
+ if os.environ.get("GITHUB_EVENT_NAME") == "pull_request":
386
+ comment = generate_pr_comment(report)
387
+
388
+ # Write comment to file for GitHub Actions to use
389
+ comment_path = Path(".specfact/pr-comment.md")
390
+ comment_path.parent.mkdir(parents=True, exist_ok=True)
391
+ comment_path.write_text(comment, encoding="utf-8")
392
+
393
+ print(f"PR comment written to: {comment_path}", file=sys.stderr)
394
+
395
+ return 1 if has_failures else 0
396
+
397
+
398
+ if __name__ == "__main__":
399
+ sys.exit(main())