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,58 @@
1
+ """
2
+ SpecFact CLI utilities.
3
+
4
+ This package contains utility functions for git operations,
5
+ YAML processing, console output, and interactive prompts.
6
+ """
7
+
8
+ from specfact_cli.utils.console import console, print_validation_report
9
+ from specfact_cli.utils.feature_keys import (
10
+ convert_feature_keys,
11
+ find_feature_by_normalized_key,
12
+ normalize_feature_key,
13
+ to_classname_key,
14
+ to_sequential_key,
15
+ to_underscore_key,
16
+ )
17
+ from specfact_cli.utils.git import GitOperations
18
+ from specfact_cli.utils.prompts import (
19
+ display_summary,
20
+ print_error,
21
+ print_info,
22
+ print_section,
23
+ print_success,
24
+ print_warning,
25
+ prompt_confirm,
26
+ prompt_dict,
27
+ prompt_list,
28
+ prompt_text,
29
+ )
30
+ from specfact_cli.utils.yaml_utils import YAMLUtils, dump_yaml, load_yaml, string_to_yaml, yaml_to_string
31
+
32
+
33
+ __all__ = [
34
+ "GitOperations",
35
+ "YAMLUtils",
36
+ "console",
37
+ "convert_feature_keys",
38
+ "display_summary",
39
+ "dump_yaml",
40
+ "find_feature_by_normalized_key",
41
+ "load_yaml",
42
+ "normalize_feature_key",
43
+ "print_error",
44
+ "print_info",
45
+ "print_section",
46
+ "print_success",
47
+ "print_validation_report",
48
+ "print_warning",
49
+ "prompt_confirm",
50
+ "prompt_dict",
51
+ "prompt_list",
52
+ "prompt_text",
53
+ "string_to_yaml",
54
+ "to_classname_key",
55
+ "to_sequential_key",
56
+ "to_underscore_key",
57
+ "yaml_to_string",
58
+ ]
@@ -0,0 +1,70 @@
1
+ """
2
+ Console output utilities.
3
+
4
+ This module provides helpers for rich console output.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from beartype import beartype
10
+ from icontract import require
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+
15
+ from specfact_cli.models.deviation import DeviationSeverity, ValidationReport
16
+
17
+
18
+ # Shared console instance
19
+ console = Console()
20
+
21
+
22
+ @beartype
23
+ @require(lambda report: isinstance(report, ValidationReport), "Report must be ValidationReport instance")
24
+ def print_validation_report(report: ValidationReport) -> None:
25
+ """
26
+ Print a formatted validation report.
27
+
28
+ Args:
29
+ report: Validation report to print
30
+ """
31
+ # Create summary table
32
+ table = Table(title="Validation Summary")
33
+ table.add_column("Severity", style="cyan")
34
+ table.add_column("Count", justify="right")
35
+
36
+ if report.high_count > 0:
37
+ table.add_row("HIGH", str(report.high_count), style="bold red")
38
+ if report.medium_count > 0:
39
+ table.add_row("MEDIUM", str(report.medium_count), style="yellow")
40
+ if report.low_count > 0:
41
+ table.add_row("LOW", str(report.low_count), style="blue")
42
+
43
+ console.print(table)
44
+
45
+ # Print deviations
46
+ if report.deviations:
47
+ console.print("\n[bold]Deviations:[/bold]\n")
48
+
49
+ for i, deviation in enumerate(report.deviations, 1):
50
+ severity_color = {
51
+ DeviationSeverity.HIGH: "bold red",
52
+ DeviationSeverity.MEDIUM: "yellow",
53
+ DeviationSeverity.LOW: "blue",
54
+ }[deviation.severity]
55
+
56
+ console.print(f"[{severity_color}]{i}. [{deviation.severity}][/{severity_color}] {deviation.description}")
57
+
58
+ if deviation.location:
59
+ console.print(f" [dim]Location: {deviation.location}[/dim]")
60
+
61
+ if hasattr(deviation, "fix_hint") and deviation.fix_hint:
62
+ console.print(f" [green]→ Suggestion: {deviation.fix_hint}[/green]")
63
+
64
+ console.print()
65
+
66
+ # Print overall result
67
+ if report.passed:
68
+ console.print(Panel("[bold green]✓ Validation PASSED[/bold green]", border_style="green"))
69
+ else:
70
+ console.print(Panel("[bold red]✗ Validation FAILED[/bold red]", border_style="red"))
@@ -0,0 +1,212 @@
1
+ """
2
+ Feature key normalization and conversion utilities.
3
+
4
+ Provides functions to normalize feature keys across different formats
5
+ to enable consistent comparison and merging of plans.
6
+ """
7
+
8
+ import re
9
+ from typing import Any
10
+
11
+ from beartype import beartype
12
+
13
+
14
+ @beartype
15
+ def normalize_feature_key(key: str) -> str:
16
+ """
17
+ Normalize feature keys for comparison by removing prefixes and underscores.
18
+
19
+ Converts various formats to a canonical form:
20
+ - `000_CONTRACT_FIRST_TEST_MANAGER` -> `CONTRACTFIRSTTESTMANAGER`
21
+ - `FEATURE-CONTRACTFIRSTTESTMANAGER` -> `CONTRACTFIRSTTESTMANAGER`
22
+ - `FEATURE-001` -> `001`
23
+ - `CONTRACT_FIRST_TEST_MANAGER` -> `CONTRACTFIRSTTESTMANAGER`
24
+
25
+ Args:
26
+ key: Feature key in any format
27
+
28
+ Returns:
29
+ Normalized key (uppercase, no prefixes, no underscores)
30
+
31
+ Examples:
32
+ >>> normalize_feature_key("000_CONTRACT_FIRST_TEST_MANAGER")
33
+ 'CONTRACTFIRSTTESTMANAGER'
34
+ >>> normalize_feature_key("FEATURE-CONTRACTFIRSTTESTMANAGER")
35
+ 'CONTRACTFIRSTTESTMANAGER'
36
+ >>> normalize_feature_key("FEATURE-001")
37
+ '001'
38
+ """
39
+ # Remove common prefixes
40
+ key = key.replace("FEATURE-", "").replace("000_", "").replace("001_", "")
41
+
42
+ # Remove underscores and spaces, convert to uppercase
43
+ return re.sub(r"[_\s-]", "", key).upper()
44
+
45
+
46
+ @beartype
47
+ def to_sequential_key(key: str, index: int) -> str:
48
+ """
49
+ Convert any feature key to sequential format (FEATURE-001, FEATURE-002, ...).
50
+
51
+ Args:
52
+ key: Original feature key
53
+ index: Sequential index (1-based)
54
+
55
+ Returns:
56
+ Sequential feature key (e.g., FEATURE-001)
57
+
58
+ Examples:
59
+ >>> to_sequential_key("000_CONTRACT_FIRST_TEST_MANAGER", 1)
60
+ 'FEATURE-001'
61
+ >>> to_sequential_key("FEATURE-CONTRACTFIRSTTESTMANAGER", 5)
62
+ 'FEATURE-005'
63
+ """
64
+ return f"FEATURE-{index:03d}"
65
+
66
+
67
+ @beartype
68
+ def to_classname_key(class_name: str) -> str:
69
+ """
70
+ Convert class name to feature key format (FEATURE-CLASSNAME).
71
+
72
+ Args:
73
+ class_name: Class name (e.g., ContractFirstTestManager)
74
+
75
+ Returns:
76
+ Feature key (e.g., FEATURE-CONTRACTFIRSTTESTMANAGER)
77
+
78
+ Examples:
79
+ >>> to_classname_key("ContractFirstTestManager")
80
+ 'FEATURE-CONTRACTFIRSTTESTMANAGER'
81
+ >>> to_classname_key("CodeAnalyzer")
82
+ 'FEATURE-CODEANALYZER'
83
+ """
84
+ return f"FEATURE-{class_name.upper()}"
85
+
86
+
87
+ @beartype
88
+ def to_underscore_key(title: str, prefix: str = "000") -> str:
89
+ """
90
+ Convert feature title to underscore format (000_FEATURE_NAME).
91
+
92
+ Args:
93
+ title: Feature title (e.g., "Contract First Test Manager")
94
+ prefix: Prefix to use (default: "000")
95
+
96
+ Returns:
97
+ Feature key (e.g., 000_CONTRACT_FIRST_TEST_MANAGER)
98
+
99
+ Examples:
100
+ >>> to_underscore_key("Contract First Test Manager")
101
+ '000_CONTRACT_FIRST_TEST_MANAGER'
102
+ >>> to_underscore_key("User Authentication", "001")
103
+ '001_USER_AUTHENTICATION'
104
+ """
105
+ # Convert title to uppercase and replace spaces with underscores
106
+ key = title.upper().replace(" ", "_")
107
+
108
+ return f"{prefix}_{key}"
109
+
110
+
111
+ @beartype
112
+ def find_feature_by_normalized_key(features: list, target_key: str) -> dict | None:
113
+ """
114
+ Find a feature in a list by matching normalized keys.
115
+
116
+ Useful for comparing features across plans with different key formats.
117
+
118
+ Args:
119
+ features: List of feature dictionaries with 'key' field
120
+ target_key: Target key to find (will be normalized)
121
+
122
+ Returns:
123
+ Feature dictionary if found, None otherwise
124
+
125
+ Examples:
126
+ >>> features = [{"key": "000_CONTRACT_FIRST_TEST_MANAGER", "title": "..."}]
127
+ >>> find_feature_by_normalized_key(features, "FEATURE-CONTRACTFIRSTTESTMANAGER")
128
+ {'key': '000_CONTRACT_FIRST_TEST_MANAGER', 'title': '...'}
129
+ """
130
+ target_normalized = normalize_feature_key(target_key)
131
+
132
+ for feature in features:
133
+ if "key" not in feature:
134
+ continue
135
+
136
+ feature_normalized = normalize_feature_key(feature["key"])
137
+ if feature_normalized == target_normalized:
138
+ return feature
139
+
140
+ return None
141
+
142
+
143
+ @beartype
144
+ def convert_feature_keys(features: list, target_format: str = "sequential", start_index: int = 1) -> list:
145
+ """
146
+ Convert feature keys to a consistent format.
147
+
148
+ Args:
149
+ features: List of feature dictionaries with 'key' field
150
+ target_format: Target format ('sequential', 'classname', or 'underscore')
151
+ start_index: Starting index for sequential format (default: 1)
152
+
153
+ Returns:
154
+ List of features with converted keys
155
+
156
+ Examples:
157
+ >>> features = [{"key": "000_CONTRACT_FIRST_TEST_MANAGER", "title": "Contract First Test Manager"}]
158
+ >>> convert_feature_keys(features, "sequential")
159
+ [{'key': 'FEATURE-001', 'title': 'Contract First Test Manager', ...}]
160
+ """
161
+ converted: list[dict[str, Any]] = []
162
+ current_index = start_index
163
+
164
+ for feature in features:
165
+ if "key" not in feature:
166
+ continue
167
+
168
+ original_key = feature["key"]
169
+ title = feature.get("title", "")
170
+
171
+ if target_format == "sequential":
172
+ new_key = to_sequential_key(original_key, current_index)
173
+ current_index += 1
174
+ elif target_format == "classname":
175
+ # Extract class name from original key if possible
176
+ class_name = _extract_class_name(original_key, title)
177
+ new_key = to_classname_key(class_name)
178
+ elif target_format == "underscore":
179
+ prefix = str(current_index - 1).zfill(3)
180
+ new_key = to_underscore_key(title, prefix)
181
+ current_index += 1
182
+ else:
183
+ # Keep original key if format not recognized
184
+ new_key = original_key
185
+
186
+ new_feature = feature.copy()
187
+ new_feature["key"] = new_key
188
+ converted.append(new_feature)
189
+
190
+ return converted
191
+
192
+
193
+ def _extract_class_name(key: str, title: str) -> str:
194
+ """Extract class name from feature key or title."""
195
+ # Try to extract from key first
196
+ if "FEATURE-" in key:
197
+ class_part = key.replace("FEATURE-", "")
198
+ # Convert to PascalCase if needed
199
+ if "_" in class_part or "-" in class_part:
200
+ # Convert underscore/hyphen to PascalCase
201
+ parts = re.split(r"[_-]", class_part.lower())
202
+ return "".join(word.capitalize() for word in parts)
203
+ # Already class-like (uppercase), convert to PascalCase
204
+ return class_part.title()
205
+
206
+ # Fall back to title
207
+ if title:
208
+ # Convert title to PascalCase class name
209
+ parts = re.split(r"[_\s-]", title)
210
+ return "".join(word.capitalize() for word in parts)
211
+
212
+ return "UnknownClass"
@@ -0,0 +1,241 @@
1
+ """
2
+ Git operations utilities.
3
+
4
+ This module provides helpers for common Git operations used by the CLI.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import git
13
+ from beartype import beartype
14
+ from git import Repo
15
+ from icontract import ensure, require
16
+
17
+
18
+ class GitOperations:
19
+ """Helper class for Git operations."""
20
+
21
+ @beartype
22
+ @require(lambda repo_path: isinstance(repo_path, (Path, str)), "Repo path must be Path or str")
23
+ def __init__(self, repo_path: Path | str = ".") -> None:
24
+ """
25
+ Initialize Git operations.
26
+
27
+ Args:
28
+ repo_path: Path to the Git repository
29
+ """
30
+ self.repo_path = Path(repo_path)
31
+ self.repo: Repo | None = None
32
+
33
+ if self._is_git_repo():
34
+ self.repo = Repo(self.repo_path)
35
+
36
+ def _is_git_repo(self) -> bool:
37
+ """
38
+ Check if path is a Git repository.
39
+
40
+ Returns:
41
+ True if path is a Git repository
42
+ """
43
+ try:
44
+ _ = Repo(self.repo_path)
45
+ return True
46
+ except git.exc.InvalidGitRepositoryError:
47
+ return False
48
+
49
+ def init(self) -> None:
50
+ """Initialize a new Git repository."""
51
+ self.repo = Repo.init(self.repo_path)
52
+
53
+ @beartype
54
+ @require(
55
+ lambda branch_name: isinstance(branch_name, str) and len(branch_name) > 0,
56
+ "Branch name must be non-empty string",
57
+ )
58
+ @require(lambda self: self.repo is not None, "Git repository must be initialized")
59
+ def create_branch(self, branch_name: str, checkout: bool = True) -> None:
60
+ """
61
+ Create a new branch.
62
+
63
+ Args:
64
+ branch_name: Name of the new branch
65
+ checkout: Whether to checkout the new branch
66
+
67
+ Raises:
68
+ ValueError: If repository is not initialized
69
+ """
70
+ if self.repo is None:
71
+ raise ValueError("Git repository not initialized")
72
+
73
+ new_branch = self.repo.create_head(branch_name)
74
+ if checkout:
75
+ new_branch.checkout()
76
+
77
+ @beartype
78
+ @require(
79
+ lambda branch_name: isinstance(branch_name, str) and len(branch_name) > 0,
80
+ "Branch name must be non-empty string",
81
+ )
82
+ @require(lambda self: self.repo is not None, "Git repository must be initialized")
83
+ def checkout(self, branch_name: str) -> None:
84
+ """
85
+ Checkout an existing branch.
86
+
87
+ Args:
88
+ branch_name: Name of the branch to checkout
89
+
90
+ Raises:
91
+ ValueError: If repository is not initialized
92
+ """
93
+ if self.repo is None:
94
+ raise ValueError("Git repository not initialized")
95
+
96
+ self.repo.heads[branch_name].checkout()
97
+
98
+ @beartype
99
+ @require(lambda files: isinstance(files, (list, Path, str)), "Files must be list, Path, or str")
100
+ @require(lambda self: self.repo is not None, "Git repository must be initialized")
101
+ def add(self, files: list[Path | str] | Path | str) -> None:
102
+ """
103
+ Add files to the staging area.
104
+
105
+ Args:
106
+ files: File(s) to add
107
+
108
+ Raises:
109
+ ValueError: If repository is not initialized
110
+ """
111
+ if self.repo is None:
112
+ raise ValueError("Git repository not initialized")
113
+
114
+ if isinstance(files, (Path, str)):
115
+ files = [files]
116
+
117
+ for file_path in files:
118
+ self.repo.index.add([str(file_path)])
119
+
120
+ @beartype
121
+ @require(lambda message: isinstance(message, str) and len(message) > 0, "Commit message must be non-empty string")
122
+ @require(lambda self: self.repo is not None, "Git repository must be initialized")
123
+ @ensure(lambda result: result is not None, "Must return commit object")
124
+ def commit(self, message: str) -> Any:
125
+ """
126
+ Commit staged changes.
127
+
128
+ Args:
129
+ message: Commit message
130
+
131
+ Returns:
132
+ Commit object
133
+
134
+ Raises:
135
+ ValueError: If repository is not initialized
136
+ """
137
+ if self.repo is None:
138
+ raise ValueError("Git repository not initialized")
139
+
140
+ return self.repo.index.commit(message)
141
+
142
+ @beartype
143
+ @require(lambda remote: isinstance(remote, str) and len(remote) > 0, "Remote name must be non-empty string")
144
+ @require(
145
+ lambda branch: branch is None or (isinstance(branch, str) and len(branch) > 0),
146
+ "Branch name must be None or non-empty string",
147
+ )
148
+ @require(lambda self: self.repo is not None, "Git repository must be initialized")
149
+ def push(self, remote: str = "origin", branch: str | None = None) -> None:
150
+ """
151
+ Push commits to remote repository.
152
+
153
+ Args:
154
+ remote: Remote name (default: origin)
155
+ branch: Branch name (default: current branch)
156
+
157
+ Raises:
158
+ ValueError: If repository is not initialized
159
+ """
160
+ if self.repo is None:
161
+ raise ValueError("Git repository not initialized")
162
+
163
+ if branch is None:
164
+ branch = self.repo.active_branch.name
165
+
166
+ origin = self.repo.remote(name=remote)
167
+ origin.push(branch)
168
+
169
+ @beartype
170
+ @require(lambda self: self.repo is not None, "Git repository must be initialized")
171
+ @ensure(lambda result: isinstance(result, str) and len(result) > 0, "Must return non-empty branch name")
172
+ def get_current_branch(self) -> str:
173
+ """
174
+ Get the name of the current branch.
175
+
176
+ Returns:
177
+ Current branch name
178
+
179
+ Raises:
180
+ ValueError: If repository is not initialized
181
+ """
182
+ if self.repo is None:
183
+ raise ValueError("Git repository not initialized")
184
+
185
+ return self.repo.active_branch.name
186
+
187
+ @beartype
188
+ @require(lambda self: self.repo is not None, "Git repository must be initialized")
189
+ @ensure(lambda result: isinstance(result, list), "Must return list")
190
+ @ensure(lambda result: all(isinstance(b, str) for b in result), "All items must be strings")
191
+ def list_branches(self) -> list[str]:
192
+ """
193
+ List all branches.
194
+
195
+ Returns:
196
+ List of branch names
197
+
198
+ Raises:
199
+ ValueError: If repository is not initialized
200
+ """
201
+ if self.repo is None:
202
+ raise ValueError("Git repository not initialized")
203
+
204
+ return [str(head) for head in self.repo.heads]
205
+
206
+ @beartype
207
+ @require(lambda self: self.repo is not None, "Git repository must be initialized")
208
+ @ensure(lambda result: isinstance(result, bool), "Must return boolean")
209
+ def is_clean(self) -> bool:
210
+ """
211
+ Check if the working directory is clean.
212
+
213
+ Returns:
214
+ True if working directory is clean
215
+
216
+ Raises:
217
+ ValueError: If repository is not initialized
218
+ """
219
+ if self.repo is None:
220
+ raise ValueError("Git repository not initialized")
221
+
222
+ return not self.repo.is_dirty()
223
+
224
+ @beartype
225
+ @require(lambda self: self.repo is not None, "Git repository must be initialized")
226
+ @ensure(lambda result: isinstance(result, list), "Must return list")
227
+ @ensure(lambda result: all(isinstance(f, str) for f in result), "All items must be strings")
228
+ def get_changed_files(self) -> list[str]:
229
+ """
230
+ Get list of changed files.
231
+
232
+ Returns:
233
+ List of changed file paths
234
+
235
+ Raises:
236
+ ValueError: If repository is not initialized
237
+ """
238
+ if self.repo is None:
239
+ raise ValueError("Git repository not initialized")
240
+
241
+ return [item.a_path for item in self.repo.index.diff(None) if item.a_path is not None]