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,213 @@
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
+
10
+ from beartype import beartype
11
+
12
+
13
+ @beartype
14
+ def normalize_feature_key(key: str) -> str:
15
+ """
16
+ Normalize feature keys for comparison by removing prefixes and underscores.
17
+
18
+ Converts various formats to a canonical form:
19
+ - `000_CONTRACT_FIRST_TEST_MANAGER` -> `CONTRACTFIRSTTESTMANAGER`
20
+ - `FEATURE-CONTRACTFIRSTTESTMANAGER` -> `CONTRACTFIRSTTESTMANAGER`
21
+ - `FEATURE-001` -> `001`
22
+ - `CONTRACT_FIRST_TEST_MANAGER` -> `CONTRACTFIRSTTESTMANAGER`
23
+
24
+ Args:
25
+ key: Feature key in any format
26
+
27
+ Returns:
28
+ Normalized key (uppercase, no prefixes, no underscores)
29
+
30
+ Examples:
31
+ >>> normalize_feature_key("000_CONTRACT_FIRST_TEST_MANAGER")
32
+ 'CONTRACTFIRSTTESTMANAGER'
33
+ >>> normalize_feature_key("FEATURE-CONTRACTFIRSTTESTMANAGER")
34
+ 'CONTRACTFIRSTTESTMANAGER'
35
+ >>> normalize_feature_key("FEATURE-001")
36
+ '001'
37
+ """
38
+ # Remove common prefixes
39
+ key = key.replace("FEATURE-", "").replace("000_", "").replace("001_", "")
40
+
41
+ # Remove underscores and spaces, convert to uppercase
42
+ normalized = re.sub(r"[_\s-]", "", key).upper()
43
+
44
+ return normalized
45
+
46
+
47
+ @beartype
48
+ def to_sequential_key(key: str, index: int) -> str:
49
+ """
50
+ Convert any feature key to sequential format (FEATURE-001, FEATURE-002, ...).
51
+
52
+ Args:
53
+ key: Original feature key
54
+ index: Sequential index (1-based)
55
+
56
+ Returns:
57
+ Sequential feature key (e.g., FEATURE-001)
58
+
59
+ Examples:
60
+ >>> to_sequential_key("000_CONTRACT_FIRST_TEST_MANAGER", 1)
61
+ 'FEATURE-001'
62
+ >>> to_sequential_key("FEATURE-CONTRACTFIRSTTESTMANAGER", 5)
63
+ 'FEATURE-005'
64
+ """
65
+ return f"FEATURE-{index:03d}"
66
+
67
+
68
+ @beartype
69
+ def to_classname_key(class_name: str) -> str:
70
+ """
71
+ Convert class name to feature key format (FEATURE-CLASSNAME).
72
+
73
+ Args:
74
+ class_name: Class name (e.g., ContractFirstTestManager)
75
+
76
+ Returns:
77
+ Feature key (e.g., FEATURE-CONTRACTFIRSTTESTMANAGER)
78
+
79
+ Examples:
80
+ >>> to_classname_key("ContractFirstTestManager")
81
+ 'FEATURE-CONTRACTFIRSTTESTMANAGER'
82
+ >>> to_classname_key("CodeAnalyzer")
83
+ 'FEATURE-CODEANALYZER'
84
+ """
85
+ return f"FEATURE-{class_name.upper()}"
86
+
87
+
88
+ @beartype
89
+ def to_underscore_key(title: str, prefix: str = "000") -> str:
90
+ """
91
+ Convert feature title to underscore format (000_FEATURE_NAME).
92
+
93
+ Args:
94
+ title: Feature title (e.g., "Contract First Test Manager")
95
+ prefix: Prefix to use (default: "000")
96
+
97
+ Returns:
98
+ Feature key (e.g., 000_CONTRACT_FIRST_TEST_MANAGER)
99
+
100
+ Examples:
101
+ >>> to_underscore_key("Contract First Test Manager")
102
+ '000_CONTRACT_FIRST_TEST_MANAGER'
103
+ >>> to_underscore_key("User Authentication", "001")
104
+ '001_USER_AUTHENTICATION'
105
+ """
106
+ # Convert title to uppercase and replace spaces with underscores
107
+ key = title.upper().replace(" ", "_")
108
+
109
+ return f"{prefix}_{key}"
110
+
111
+
112
+ @beartype
113
+ def find_feature_by_normalized_key(features: list, target_key: str) -> dict | None:
114
+ """
115
+ Find a feature in a list by matching normalized keys.
116
+
117
+ Useful for comparing features across plans with different key formats.
118
+
119
+ Args:
120
+ features: List of feature dictionaries with 'key' field
121
+ target_key: Target key to find (will be normalized)
122
+
123
+ Returns:
124
+ Feature dictionary if found, None otherwise
125
+
126
+ Examples:
127
+ >>> features = [{"key": "000_CONTRACT_FIRST_TEST_MANAGER", "title": "..."}]
128
+ >>> find_feature_by_normalized_key(features, "FEATURE-CONTRACTFIRSTTESTMANAGER")
129
+ {'key': '000_CONTRACT_FIRST_TEST_MANAGER', 'title': '...'}
130
+ """
131
+ target_normalized = normalize_feature_key(target_key)
132
+
133
+ for feature in features:
134
+ if "key" not in feature:
135
+ continue
136
+
137
+ feature_normalized = normalize_feature_key(feature["key"])
138
+ if feature_normalized == target_normalized:
139
+ return feature
140
+
141
+ return None
142
+
143
+
144
+ @beartype
145
+ def convert_feature_keys(features: list, target_format: str = "sequential", start_index: int = 1) -> list:
146
+ """
147
+ Convert feature keys to a consistent format.
148
+
149
+ Args:
150
+ features: List of feature dictionaries with 'key' field
151
+ target_format: Target format ('sequential', 'classname', or 'underscore')
152
+ start_index: Starting index for sequential format (default: 1)
153
+
154
+ Returns:
155
+ List of features with converted keys
156
+
157
+ Examples:
158
+ >>> features = [{"key": "000_CONTRACT_FIRST_TEST_MANAGER", "title": "Contract First Test Manager"}]
159
+ >>> convert_feature_keys(features, "sequential")
160
+ [{'key': 'FEATURE-001', 'title': 'Contract First Test Manager', ...}]
161
+ """
162
+ converted = []
163
+ current_index = start_index
164
+
165
+ for feature in features:
166
+ if "key" not in feature:
167
+ continue
168
+
169
+ original_key = feature["key"]
170
+ title = feature.get("title", "")
171
+
172
+ if target_format == "sequential":
173
+ new_key = to_sequential_key(original_key, current_index)
174
+ current_index += 1
175
+ elif target_format == "classname":
176
+ # Extract class name from original key if possible
177
+ class_name = _extract_class_name(original_key, title)
178
+ new_key = to_classname_key(class_name)
179
+ elif target_format == "underscore":
180
+ prefix = str(current_index - 1).zfill(3)
181
+ new_key = to_underscore_key(title, prefix)
182
+ current_index += 1
183
+ else:
184
+ # Keep original key if format not recognized
185
+ new_key = original_key
186
+
187
+ new_feature = feature.copy()
188
+ new_feature["key"] = new_key
189
+ converted.append(new_feature)
190
+
191
+ return converted
192
+
193
+
194
+ def _extract_class_name(key: str, title: str) -> str:
195
+ """Extract class name from feature key or title."""
196
+ # Try to extract from key first
197
+ if "FEATURE-" in key:
198
+ class_part = key.replace("FEATURE-", "")
199
+ # Convert to PascalCase if needed
200
+ if "_" in class_part or "-" in class_part:
201
+ # Convert underscore/hyphen to PascalCase
202
+ parts = re.split(r"[_-]", class_part.lower())
203
+ return "".join(word.capitalize() for word in parts)
204
+ # Already class-like (uppercase), convert to PascalCase
205
+ return class_part.title()
206
+
207
+ # Fall back to title
208
+ if title:
209
+ # Convert title to PascalCase class name
210
+ parts = re.split(r"[_\s-]", title)
211
+ return "".join(word.capitalize() for word in parts)
212
+
213
+ 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]