elspais 0.9.1__py3-none-any.whl → 0.11.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.
Files changed (75) hide show
  1. elspais/cli.py +123 -1
  2. elspais/commands/changed.py +160 -0
  3. elspais/commands/hash_cmd.py +72 -26
  4. elspais/commands/reformat_cmd.py +458 -0
  5. elspais/commands/trace.py +157 -3
  6. elspais/commands/validate.py +81 -18
  7. elspais/core/git.py +352 -0
  8. elspais/core/models.py +2 -0
  9. elspais/core/parser.py +68 -24
  10. elspais/reformat/__init__.py +50 -0
  11. elspais/reformat/detector.py +119 -0
  12. elspais/reformat/hierarchy.py +246 -0
  13. elspais/reformat/line_breaks.py +220 -0
  14. elspais/reformat/prompts.py +123 -0
  15. elspais/reformat/transformer.py +264 -0
  16. elspais/sponsors/__init__.py +432 -0
  17. elspais/trace_view/__init__.py +54 -0
  18. elspais/trace_view/coverage.py +183 -0
  19. elspais/trace_view/generators/__init__.py +12 -0
  20. elspais/trace_view/generators/base.py +329 -0
  21. elspais/trace_view/generators/csv.py +122 -0
  22. elspais/trace_view/generators/markdown.py +175 -0
  23. elspais/trace_view/html/__init__.py +31 -0
  24. elspais/trace_view/html/generator.py +1006 -0
  25. elspais/trace_view/html/templates/base.html +283 -0
  26. elspais/trace_view/html/templates/components/code_viewer_modal.html +14 -0
  27. elspais/trace_view/html/templates/components/file_picker_modal.html +20 -0
  28. elspais/trace_view/html/templates/components/legend_modal.html +69 -0
  29. elspais/trace_view/html/templates/components/review_panel.html +118 -0
  30. elspais/trace_view/html/templates/partials/review/help/help-panel.json +244 -0
  31. elspais/trace_view/html/templates/partials/review/help/onboarding.json +77 -0
  32. elspais/trace_view/html/templates/partials/review/help/tooltips.json +237 -0
  33. elspais/trace_view/html/templates/partials/review/review-comments.js +928 -0
  34. elspais/trace_view/html/templates/partials/review/review-data.js +961 -0
  35. elspais/trace_view/html/templates/partials/review/review-help.js +679 -0
  36. elspais/trace_view/html/templates/partials/review/review-init.js +177 -0
  37. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +429 -0
  38. elspais/trace_view/html/templates/partials/review/review-packages.js +1029 -0
  39. elspais/trace_view/html/templates/partials/review/review-position.js +540 -0
  40. elspais/trace_view/html/templates/partials/review/review-resize.js +115 -0
  41. elspais/trace_view/html/templates/partials/review/review-status.js +659 -0
  42. elspais/trace_view/html/templates/partials/review/review-sync.js +992 -0
  43. elspais/trace_view/html/templates/partials/review-styles.css +2238 -0
  44. elspais/trace_view/html/templates/partials/scripts.js +1741 -0
  45. elspais/trace_view/html/templates/partials/styles.css +1756 -0
  46. elspais/trace_view/models.py +353 -0
  47. elspais/trace_view/review/__init__.py +60 -0
  48. elspais/trace_view/review/branches.py +1149 -0
  49. elspais/trace_view/review/models.py +1205 -0
  50. elspais/trace_view/review/position.py +609 -0
  51. elspais/trace_view/review/server.py +1056 -0
  52. elspais/trace_view/review/status.py +470 -0
  53. elspais/trace_view/review/storage.py +1367 -0
  54. elspais/trace_view/scanning.py +213 -0
  55. elspais/trace_view/specs/README.md +84 -0
  56. elspais/trace_view/specs/tv-d00001-template-architecture.md +36 -0
  57. elspais/trace_view/specs/tv-d00002-css-extraction.md +37 -0
  58. elspais/trace_view/specs/tv-d00003-js-extraction.md +43 -0
  59. elspais/trace_view/specs/tv-d00004-build-embedding.md +40 -0
  60. elspais/trace_view/specs/tv-d00005-test-format.md +78 -0
  61. elspais/trace_view/specs/tv-d00010-review-data-models.md +33 -0
  62. elspais/trace_view/specs/tv-d00011-review-storage.md +33 -0
  63. elspais/trace_view/specs/tv-d00012-position-resolution.md +33 -0
  64. elspais/trace_view/specs/tv-d00013-git-branches.md +31 -0
  65. elspais/trace_view/specs/tv-d00014-review-api-server.md +31 -0
  66. elspais/trace_view/specs/tv-d00015-status-modifier.md +27 -0
  67. elspais/trace_view/specs/tv-d00016-js-integration.md +33 -0
  68. elspais/trace_view/specs/tv-p00001-html-generator.md +33 -0
  69. elspais/trace_view/specs/tv-p00002-review-system.md +29 -0
  70. {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/METADATA +78 -26
  71. elspais-0.11.0.dist-info/RECORD +101 -0
  72. elspais-0.9.1.dist-info/RECORD +0 -38
  73. {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/WHEEL +0 -0
  74. {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/entry_points.txt +0 -0
  75. {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/licenses/LICENSE +0 -0
elspais/cli.py CHANGED
@@ -10,7 +10,7 @@ from pathlib import Path
10
10
  from typing import List, Optional
11
11
 
12
12
  from elspais import __version__
13
- from elspais.commands import analyze, config_cmd, edit, hash_cmd, index, init, rules_cmd, trace, validate
13
+ from elspais.commands import analyze, changed, config_cmd, edit, hash_cmd, index, init, rules_cmd, trace, validate, reformat_cmd
14
14
 
15
15
 
16
16
  def create_parser() -> argparse.ArgumentParser:
@@ -97,6 +97,12 @@ Examples:
97
97
  action="store_true",
98
98
  help="Skip test scanning",
99
99
  )
100
+ validate_parser.add_argument(
101
+ "--mode",
102
+ choices=["core", "combined"],
103
+ default="combined",
104
+ help="core: skip sponsor repos, combined: include all (default: combined)",
105
+ )
100
106
 
101
107
  # trace command
102
108
  trace_parser = subparsers.add_parser(
@@ -115,6 +121,49 @@ Examples:
115
121
  help="Output file path",
116
122
  metavar="PATH",
117
123
  )
124
+ # trace-view enhanced options (requires elspais[trace-view])
125
+ trace_parser.add_argument(
126
+ "--view",
127
+ action="store_true",
128
+ help="Generate interactive HTML traceability view (requires trace-view extra)",
129
+ )
130
+ trace_parser.add_argument(
131
+ "--embed-content",
132
+ action="store_true",
133
+ help="Embed full requirement content in HTML output",
134
+ )
135
+ trace_parser.add_argument(
136
+ "--edit-mode",
137
+ action="store_true",
138
+ help="Include edit mode UI in HTML output",
139
+ )
140
+ trace_parser.add_argument(
141
+ "--review-mode",
142
+ action="store_true",
143
+ help="Include review mode UI in HTML output",
144
+ )
145
+ trace_parser.add_argument(
146
+ "--server",
147
+ action="store_true",
148
+ help="Start review server (requires trace-review extra)",
149
+ )
150
+ trace_parser.add_argument(
151
+ "--port",
152
+ type=int,
153
+ default=8080,
154
+ help="Port for review server (default: 8080)",
155
+ )
156
+ trace_parser.add_argument(
157
+ "--mode",
158
+ choices=["core", "sponsor", "combined"],
159
+ default="core",
160
+ help="Report mode: core, sponsor, or combined (default: core)",
161
+ )
162
+ trace_parser.add_argument(
163
+ "--sponsor",
164
+ help="Sponsor name for sponsor-specific reports",
165
+ metavar="NAME",
166
+ )
118
167
 
119
168
  # hash command
120
169
  hash_parser = subparsers.add_parser(
@@ -179,6 +228,28 @@ Examples:
179
228
  help="Implementation coverage report",
180
229
  )
181
230
 
231
+ # changed command
232
+ changed_parser = subparsers.add_parser(
233
+ "changed",
234
+ help="Detect git changes to spec files",
235
+ )
236
+ changed_parser.add_argument(
237
+ "--base-branch",
238
+ default="main",
239
+ help="Base branch for comparison (default: main)",
240
+ metavar="BRANCH",
241
+ )
242
+ changed_parser.add_argument(
243
+ "-j", "--json",
244
+ action="store_true",
245
+ help="Output as JSON",
246
+ )
247
+ changed_parser.add_argument(
248
+ "-a", "--all",
249
+ action="store_true",
250
+ help="Include all changed files (not just spec)",
251
+ )
252
+
182
253
  # version command
183
254
  version_parser = subparsers.add_parser(
184
255
  "version",
@@ -371,6 +442,53 @@ Examples:
371
442
  help="Content rule file name (e.g., 'AI-AGENT.md')",
372
443
  )
373
444
 
445
+ # reformat-with-claude command
446
+ reformat_parser = subparsers.add_parser(
447
+ "reformat-with-claude",
448
+ help="Reformat requirements using AI (Acceptance Criteria -> Assertions)",
449
+ )
450
+ reformat_parser.add_argument(
451
+ "--start-req",
452
+ help="Starting requirement ID (default: all PRD requirements)",
453
+ metavar="ID",
454
+ )
455
+ reformat_parser.add_argument(
456
+ "--depth",
457
+ type=int,
458
+ help="Maximum traversal depth (default: unlimited)",
459
+ )
460
+ reformat_parser.add_argument(
461
+ "--dry-run",
462
+ action="store_true",
463
+ help="Preview changes without applying",
464
+ )
465
+ reformat_parser.add_argument(
466
+ "--backup",
467
+ action="store_true",
468
+ help="Create .bak files before editing",
469
+ )
470
+ reformat_parser.add_argument(
471
+ "--force",
472
+ action="store_true",
473
+ help="Reformat even if already in new format",
474
+ )
475
+ reformat_parser.add_argument(
476
+ "--fix-line-breaks",
477
+ action="store_true",
478
+ help="Normalize line breaks (remove extra blank lines)",
479
+ )
480
+ reformat_parser.add_argument(
481
+ "--line-breaks-only",
482
+ action="store_true",
483
+ help="Only fix line breaks, skip AI-based reformatting",
484
+ )
485
+ reformat_parser.add_argument(
486
+ "--mode",
487
+ choices=["combined", "core-only", "local-only"],
488
+ default="combined",
489
+ help="Which repos to include in hierarchy (default: combined)",
490
+ )
491
+
374
492
  # mcp command
375
493
  mcp_parser = subparsers.add_parser(
376
494
  "mcp",
@@ -455,6 +573,8 @@ def main(argv: Optional[List[str]] = None) -> int:
455
573
  return index.run(args)
456
574
  elif args.command == "analyze":
457
575
  return analyze.run(args)
576
+ elif args.command == "changed":
577
+ return changed.run(args)
458
578
  elif args.command == "version":
459
579
  return version_command(args)
460
580
  elif args.command == "init":
@@ -465,6 +585,8 @@ def main(argv: Optional[List[str]] = None) -> int:
465
585
  return config_cmd.run(args)
466
586
  elif args.command == "rules":
467
587
  return rules_cmd.run(args)
588
+ elif args.command == "reformat-with-claude":
589
+ return reformat_cmd.run(args)
468
590
  elif args.command == "mcp":
469
591
  return mcp_command(args)
470
592
  else:
@@ -0,0 +1,160 @@
1
+ """
2
+ elspais.commands.changed - Git-based change detection for requirements.
3
+
4
+ Detects changes to requirement files using git:
5
+ - Uncommitted changes (modified or new files)
6
+ - Changes vs main/master branch
7
+ - Moved requirements (comparing current location to committed state)
8
+ """
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Dict, List, Optional
15
+
16
+ from elspais.config.defaults import DEFAULT_CONFIG
17
+ from elspais.config.loader import find_config_file, load_config
18
+ from elspais.core.git import (
19
+ GitChangeInfo,
20
+ MovedRequirement,
21
+ detect_moved_requirements,
22
+ filter_spec_files,
23
+ get_current_req_locations,
24
+ get_git_changes,
25
+ get_repo_root,
26
+ )
27
+
28
+
29
+ def load_configuration(args: argparse.Namespace) -> Optional[Dict]:
30
+ """Load configuration from file or use defaults."""
31
+ config_path = getattr(args, "config", None)
32
+ if config_path:
33
+ pass # Use provided path
34
+ else:
35
+ config_path = find_config_file(Path.cwd())
36
+
37
+ if config_path and config_path.exists():
38
+ try:
39
+ return load_config(config_path)
40
+ except Exception as e:
41
+ print(f"Error loading config: {e}", file=sys.stderr)
42
+ return None
43
+ else:
44
+ return DEFAULT_CONFIG
45
+
46
+
47
+ def run(args: argparse.Namespace) -> int:
48
+ """Run the changed command."""
49
+ # Get repository root
50
+ repo_root = get_repo_root()
51
+ if repo_root is None:
52
+ print("Error: Not in a git repository")
53
+ return 1
54
+
55
+ # Load config to get spec directory
56
+ config = load_configuration(args)
57
+ if config is None:
58
+ return 1
59
+
60
+ spec_dir = config.get("directories", {}).get("spec", "spec")
61
+ if isinstance(spec_dir, list):
62
+ spec_dir = spec_dir[0] if spec_dir else "spec"
63
+
64
+ base_branch = getattr(args, "base_branch", None) or "main"
65
+ json_output = getattr(args, "json", False)
66
+ show_all = getattr(args, "all", False)
67
+ quiet = getattr(args, "quiet", False)
68
+
69
+ # Get git change information
70
+ changes = get_git_changes(repo_root, spec_dir, base_branch)
71
+
72
+ # Filter to spec files only
73
+ spec_modified = filter_spec_files(changes.modified_files, spec_dir)
74
+ spec_untracked = filter_spec_files(changes.untracked_files, spec_dir)
75
+ spec_branch = filter_spec_files(changes.branch_changed_files, spec_dir)
76
+
77
+ # Detect moved requirements
78
+ current_locations = get_current_req_locations(repo_root, spec_dir)
79
+ moved = detect_moved_requirements(
80
+ changes.committed_req_locations, current_locations
81
+ )
82
+
83
+ # Build result
84
+ result = {
85
+ "repo_root": str(repo_root),
86
+ "spec_dir": spec_dir,
87
+ "base_branch": base_branch,
88
+ "uncommitted": {
89
+ "modified": sorted(spec_modified),
90
+ "untracked": sorted(spec_untracked),
91
+ "count": len(spec_modified) + len(spec_untracked),
92
+ },
93
+ "branch_changed": {
94
+ "files": sorted(spec_branch),
95
+ "count": len(spec_branch),
96
+ },
97
+ "moved_requirements": [
98
+ {
99
+ "req_id": m.req_id,
100
+ "old_path": m.old_path,
101
+ "new_path": m.new_path,
102
+ }
103
+ for m in moved
104
+ ],
105
+ }
106
+
107
+ # Include all files if --all flag is set
108
+ if show_all:
109
+ result["all_modified"] = sorted(changes.modified_files)
110
+ result["all_untracked"] = sorted(changes.untracked_files)
111
+ result["all_branch_changed"] = sorted(changes.branch_changed_files)
112
+
113
+ if json_output:
114
+ print(json.dumps(result, indent=2))
115
+ return 0
116
+
117
+ # Human-readable output
118
+ has_changes = False
119
+
120
+ if spec_modified or spec_untracked:
121
+ has_changes = True
122
+ if not quiet:
123
+ uncommitted_count = len(spec_modified) + len(spec_untracked)
124
+ print(f"Uncommitted spec changes: {uncommitted_count}")
125
+
126
+ if spec_modified:
127
+ print(f" Modified ({len(spec_modified)}):")
128
+ for f in sorted(spec_modified):
129
+ print(f" M {f}")
130
+
131
+ if spec_untracked:
132
+ print(f" New ({len(spec_untracked)}):")
133
+ for f in sorted(spec_untracked):
134
+ print(f" + {f}")
135
+ print()
136
+
137
+ if spec_branch:
138
+ has_changes = True
139
+ if not quiet:
140
+ print(f"Changed vs {base_branch}: {len(spec_branch)}")
141
+ for f in sorted(spec_branch):
142
+ print(f" {f}")
143
+ print()
144
+
145
+ if moved:
146
+ has_changes = True
147
+ if not quiet:
148
+ print(f"Moved requirements: {len(moved)}")
149
+ for m in moved:
150
+ print(f" REQ-{m.req_id}:")
151
+ print(f" from: {m.old_path}")
152
+ print(f" to: {m.new_path}")
153
+ print()
154
+
155
+ if not has_changes:
156
+ if not quiet:
157
+ print("No uncommitted changes to spec files")
158
+ return 0
159
+
160
+ return 0
@@ -104,8 +104,16 @@ def run_update(args: argparse.Namespace) -> int:
104
104
  else:
105
105
  print(f"Updating {len(updates)} hashes...")
106
106
  for req_id, req, new_hash in updates:
107
- update_hash_in_file(req, new_hash)
108
- print(f" ✓ {req_id}")
107
+ result = update_hash_in_file(req, new_hash)
108
+ if result['updated']:
109
+ print(f" ✓ {req_id}")
110
+ old_hash = result['old_hash'] or "(none)"
111
+ print(f" [INFO] Hash: {old_hash} -> {result['new_hash']}")
112
+ if result['title_fixed']:
113
+ print(f" [INFO] Title fixed: \"{result['old_title']}\" -> \"{req.title}\"")
114
+ else:
115
+ print(f" ✗ {req_id}")
116
+ print(f" [WARN] Could not find End marker to update")
109
117
 
110
118
  return 0
111
119
 
@@ -138,37 +146,75 @@ def load_requirements(args: argparse.Namespace) -> tuple:
138
146
  return config, requirements
139
147
 
140
148
 
141
- def update_hash_in_file(req: Requirement, new_hash: str) -> None:
149
+ def update_hash_in_file(req: Requirement, new_hash: str) -> dict:
142
150
  """Update the hash in the requirement's source file.
143
151
 
144
- The replacement is scoped to the specific requirement's end marker
145
- (identified by title) to avoid accidentally updating other requirements
146
- in the same file that might have the same hash value.
152
+ Finds the End marker by the old hash value, then replaces the entire line
153
+ with the correct title and new hash. This handles cases where the End
154
+ marker title doesn't match the header title.
155
+
156
+ Args:
157
+ req: Requirement object with file_path, title, and hash
158
+ new_hash: New hash value to write
159
+
160
+ Returns:
161
+ Dict with change info:
162
+ - 'updated': bool - whether file was modified
163
+ - 'old_hash': str - previous hash (or None)
164
+ - 'new_hash': str - new hash value
165
+ - 'title_fixed': bool - whether title was corrected
166
+ - 'old_title': str - previous title (if different)
147
167
  """
168
+ import re
169
+
170
+ result = {
171
+ 'updated': False,
172
+ 'old_hash': req.hash,
173
+ 'new_hash': new_hash,
174
+ 'title_fixed': False,
175
+ 'old_title': None,
176
+ }
177
+
148
178
  if not req.file_path:
149
- return
179
+ return result
150
180
 
151
181
  content = req.file_path.read_text(encoding="utf-8")
152
-
153
- import re
182
+ new_end_line = f"*End* *{req.title}* | **Hash**: {new_hash}"
154
183
 
155
184
  if req.hash:
156
- # Replace existing hash - SCOPED to this requirement's end marker
157
- # Match: *End* *Title* | **Hash**: oldhash
158
- # Replace hash only for THIS requirement (identified by title)
159
- content = re.sub(
160
- rf"(\*End\*\s+\*{re.escape(req.title)}\*\s*\|\s*)\*\*Hash\*\*:\s*{re.escape(req.hash)}",
161
- rf"\1**Hash**: {new_hash}",
162
- content,
163
- )
185
+ # Strategy: Try title first (most specific), then hash if title not found
186
+ # This handles both: (1) normal case, (2) mismatched title case
187
+
188
+ # First try: match by correct title (handles case where titles match)
189
+ pattern_by_title = rf"^\*End\*\s+\*{re.escape(req.title)}\*\s*\|\s*\*\*Hash\*\*:\s*[a-fA-F0-9]+\s*$"
190
+ if re.search(pattern_by_title, content, re.MULTILINE):
191
+ content, count = re.subn(pattern_by_title, new_end_line, content, flags=re.MULTILINE)
192
+ if count > 0:
193
+ result['updated'] = True
194
+ else:
195
+ # Second try: find by hash value (handles mismatched title)
196
+ # Pattern: *End* *AnyTitle* | **Hash**: oldhash
197
+ pattern_by_hash = rf"^\*End\*\s+\*([^*]+)\*\s*\|\s*\*\*Hash\*\*:\s*{re.escape(req.hash)}\s*$"
198
+ match = re.search(pattern_by_hash, content, re.MULTILINE)
199
+
200
+ if match:
201
+ old_title = match.group(1)
202
+ if old_title != req.title:
203
+ result['title_fixed'] = True
204
+ result['old_title'] = old_title
205
+
206
+ # Replace entire line (only first match to avoid affecting other reqs)
207
+ content = re.sub(pattern_by_hash, new_end_line, content, count=1, flags=re.MULTILINE)
208
+ result['updated'] = True
164
209
  else:
165
- # Add hash to end marker
210
+ # Add hash to end marker (no existing hash)
166
211
  # Pattern: *End* *Title* (without hash)
167
- # Add: | **Hash**: XXXX
168
- content = re.sub(
169
- rf"(\*End\*\s+\*{re.escape(req.title)}\*)(?!\s*\|\s*\*\*Hash\*\*)",
170
- rf"\1 | **Hash**: {new_hash}",
171
- content,
172
- )
173
-
174
- req.file_path.write_text(content, encoding="utf-8")
212
+ pattern = rf"^(\*End\*\s+\*{re.escape(req.title)}\*)(?!\s*\|\s*\*\*Hash\*\*)\s*$"
213
+ content, count = re.subn(pattern, new_end_line, content, flags=re.MULTILINE)
214
+ if count > 0:
215
+ result['updated'] = True
216
+
217
+ if result['updated']:
218
+ req.file_path.write_text(content, encoding="utf-8")
219
+
220
+ return result