elspais 0.11.0__py3-none-any.whl → 0.11.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 (53) hide show
  1. elspais/__init__.py +1 -1
  2. elspais/cli.py +75 -23
  3. elspais/commands/analyze.py +5 -6
  4. elspais/commands/changed.py +2 -6
  5. elspais/commands/config_cmd.py +4 -4
  6. elspais/commands/edit.py +32 -36
  7. elspais/commands/hash_cmd.py +24 -18
  8. elspais/commands/index.py +8 -7
  9. elspais/commands/init.py +4 -4
  10. elspais/commands/reformat_cmd.py +32 -43
  11. elspais/commands/rules_cmd.py +6 -2
  12. elspais/commands/trace.py +23 -19
  13. elspais/commands/validate.py +8 -10
  14. elspais/config/defaults.py +7 -1
  15. elspais/core/content_rules.py +0 -1
  16. elspais/core/git.py +4 -10
  17. elspais/core/parser.py +55 -56
  18. elspais/core/patterns.py +2 -6
  19. elspais/core/rules.py +10 -15
  20. elspais/mcp/__init__.py +2 -0
  21. elspais/mcp/context.py +1 -0
  22. elspais/mcp/serializers.py +1 -1
  23. elspais/mcp/server.py +54 -39
  24. elspais/reformat/__init__.py +13 -13
  25. elspais/reformat/detector.py +9 -16
  26. elspais/reformat/hierarchy.py +8 -7
  27. elspais/reformat/line_breaks.py +36 -38
  28. elspais/reformat/prompts.py +22 -12
  29. elspais/reformat/transformer.py +43 -41
  30. elspais/sponsors/__init__.py +0 -2
  31. elspais/testing/__init__.py +1 -1
  32. elspais/testing/result_parser.py +25 -21
  33. elspais/trace_view/__init__.py +4 -3
  34. elspais/trace_view/coverage.py +5 -5
  35. elspais/trace_view/generators/__init__.py +1 -1
  36. elspais/trace_view/generators/base.py +17 -12
  37. elspais/trace_view/generators/csv.py +2 -6
  38. elspais/trace_view/generators/markdown.py +3 -8
  39. elspais/trace_view/html/__init__.py +4 -2
  40. elspais/trace_view/html/generator.py +423 -289
  41. elspais/trace_view/models.py +25 -0
  42. elspais/trace_view/review/__init__.py +21 -18
  43. elspais/trace_view/review/branches.py +114 -121
  44. elspais/trace_view/review/models.py +232 -237
  45. elspais/trace_view/review/position.py +53 -71
  46. elspais/trace_view/review/server.py +264 -288
  47. elspais/trace_view/review/status.py +43 -58
  48. elspais/trace_view/review/storage.py +48 -72
  49. {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/METADATA +12 -9
  50. {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/RECORD +53 -53
  51. {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/WHEEL +0 -0
  52. {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/entry_points.txt +0 -0
  53. {elspais-0.11.0.dist-info → elspais-0.11.2.dist-info}/licenses/LICENSE +0 -0
elspais/__init__.py CHANGED
@@ -10,7 +10,7 @@ and supports multi-repository requirement management with configurable
10
10
  ID patterns and validation rules.
11
11
  """
12
12
 
13
- from importlib.metadata import version, PackageNotFoundError
13
+ from importlib.metadata import PackageNotFoundError, version
14
14
 
15
15
  try:
16
16
  __version__ = version("elspais")
elspais/cli.py CHANGED
@@ -10,7 +10,19 @@ 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, changed, config_cmd, edit, hash_cmd, index, init, rules_cmd, trace, validate, reformat_cmd
13
+ from elspais.commands import (
14
+ analyze,
15
+ changed,
16
+ config_cmd,
17
+ edit,
18
+ hash_cmd,
19
+ index,
20
+ init,
21
+ reformat_cmd,
22
+ rules_cmd,
23
+ trace,
24
+ validate,
25
+ )
14
26
 
15
27
 
16
28
  def create_parser() -> argparse.ArgumentParser:
@@ -22,9 +34,16 @@ def create_parser() -> argparse.ArgumentParser:
22
34
  epilog="""
23
35
  Examples:
24
36
  elspais validate # Validate all requirements
37
+ elspais validate --fix # Auto-fix fixable issues
25
38
  elspais trace --format html # Generate HTML traceability matrix
39
+ elspais trace --view # Interactive HTML view
26
40
  elspais hash update # Update all requirement hashes
41
+ elspais changed # Show uncommitted spec changes
42
+ elspais analyze hierarchy # Show requirement hierarchy tree
43
+ elspais config show # View current configuration
27
44
  elspais init # Create .elspais.toml configuration
45
+
46
+ For detailed command help: elspais <command> --help
28
47
  """,
29
48
  )
30
49
 
@@ -47,12 +66,14 @@ Examples:
47
66
  metavar="PATH",
48
67
  )
49
68
  parser.add_argument(
50
- "-v", "--verbose",
69
+ "-v",
70
+ "--verbose",
51
71
  action="store_true",
52
72
  help="Verbose output",
53
73
  )
54
74
  parser.add_argument(
55
- "-q", "--quiet",
75
+ "-q",
76
+ "--quiet",
56
77
  action="store_true",
57
78
  help="Suppress non-error output",
58
79
  )
@@ -64,6 +85,21 @@ Examples:
64
85
  validate_parser = subparsers.add_parser(
65
86
  "validate",
66
87
  help="Validate requirements format, links, and hashes",
88
+ formatter_class=argparse.RawDescriptionHelpFormatter,
89
+ epilog="""
90
+ Examples:
91
+ elspais validate # Validate all requirements
92
+ elspais validate --fix # Auto-fix hashes and formatting
93
+ elspais validate --skip-rule hash.* # Skip all hash rules
94
+ elspais validate -j # Output JSON for tooling
95
+ elspais validate --mode core # Exclude associated repo specs
96
+
97
+ Common rules to skip:
98
+ hash.missing Hash footer is missing
99
+ hash.mismatch Hash doesn't match content
100
+ hierarchy.* All hierarchy rules
101
+ format.* All format rules
102
+ """,
67
103
  )
68
104
  validate_parser.add_argument(
69
105
  "--fix",
@@ -79,11 +115,12 @@ Examples:
79
115
  validate_parser.add_argument(
80
116
  "--skip-rule",
81
117
  action="append",
82
- help="Skip specific validation rules",
118
+ help="Skip validation rules (can be repeated, e.g., hash.*, format.*)",
83
119
  metavar="RULE",
84
120
  )
85
121
  validate_parser.add_argument(
86
- "-j", "--json",
122
+ "-j",
123
+ "--json",
87
124
  action="store_true",
88
125
  help="Output requirements as JSON (hht_diary compatible format)",
89
126
  )
@@ -101,7 +138,7 @@ Examples:
101
138
  "--mode",
102
139
  choices=["core", "combined"],
103
140
  default="combined",
104
- help="core: skip sponsor repos, combined: include all (default: combined)",
141
+ help="Scope: core (this repo only), combined (include sponsor repos)",
105
142
  )
106
143
 
107
144
  # trace command
@@ -113,7 +150,7 @@ Examples:
113
150
  "--format",
114
151
  choices=["markdown", "html", "csv", "both"],
115
152
  default="both",
116
- help="Output format (default: both)",
153
+ help="Output format: markdown, html, csv, or both (markdown + csv)",
117
154
  )
118
155
  trace_parser.add_argument(
119
156
  "--output",
@@ -130,17 +167,17 @@ Examples:
130
167
  trace_parser.add_argument(
131
168
  "--embed-content",
132
169
  action="store_true",
133
- help="Embed full requirement content in HTML output",
170
+ help="Embed full requirement markdown in HTML for offline viewing",
134
171
  )
135
172
  trace_parser.add_argument(
136
173
  "--edit-mode",
137
174
  action="store_true",
138
- help="Include edit mode UI in HTML output",
175
+ help="Enable in-browser editing of implements and status fields",
139
176
  )
140
177
  trace_parser.add_argument(
141
178
  "--review-mode",
142
179
  action="store_true",
143
- help="Include review mode UI in HTML output",
180
+ help="Enable collaborative review with comments and flags",
144
181
  )
145
182
  trace_parser.add_argument(
146
183
  "--server",
@@ -168,7 +205,7 @@ Examples:
168
205
  # hash command
169
206
  hash_parser = subparsers.add_parser(
170
207
  "hash",
171
- help="Manage requirement hashes",
208
+ help="Manage requirement hashes (verify, update)",
172
209
  )
173
210
  hash_subparsers = hash_parser.add_subparsers(dest="hash_action")
174
211
 
@@ -195,7 +232,7 @@ Examples:
195
232
  # index command
196
233
  index_parser = subparsers.add_parser(
197
234
  "index",
198
- help="Manage INDEX.md file",
235
+ help="Manage INDEX.md file (validate, regenerate)",
199
236
  )
200
237
  index_subparsers = index_parser.add_subparsers(dest="index_action")
201
238
 
@@ -211,7 +248,7 @@ Examples:
211
248
  # analyze command
212
249
  analyze_parser = subparsers.add_parser(
213
250
  "analyze",
214
- help="Analyze requirement hierarchy",
251
+ help="Analyze requirement hierarchy (hierarchy, orphans, coverage)",
215
252
  )
216
253
  analyze_subparsers = analyze_parser.add_subparsers(dest="analyze_action")
217
254
 
@@ -221,7 +258,7 @@ Examples:
221
258
  )
222
259
  analyze_subparsers.add_parser(
223
260
  "orphans",
224
- help="Find orphaned requirements",
261
+ help="Find requirements with no parent (missing or invalid Implements)",
225
262
  )
226
263
  analyze_subparsers.add_parser(
227
264
  "coverage",
@@ -240,12 +277,14 @@ Examples:
240
277
  metavar="BRANCH",
241
278
  )
242
279
  changed_parser.add_argument(
243
- "-j", "--json",
280
+ "-j",
281
+ "--json",
244
282
  action="store_true",
245
283
  help="Output as JSON",
246
284
  )
247
285
  changed_parser.add_argument(
248
- "-a", "--all",
286
+ "-a",
287
+ "--all",
249
288
  action="store_true",
250
289
  help="Include all changed files (not just spec)",
251
290
  )
@@ -258,7 +297,7 @@ Examples:
258
297
  version_parser.add_argument(
259
298
  "check",
260
299
  nargs="?",
261
- help="Check for updates",
300
+ help="Check for updates (not yet implemented)",
262
301
  )
263
302
 
264
303
  # init command
@@ -286,6 +325,17 @@ Examples:
286
325
  edit_parser = subparsers.add_parser(
287
326
  "edit",
288
327
  help="Edit requirements in-place (implements, status, move)",
328
+ formatter_class=argparse.RawDescriptionHelpFormatter,
329
+ epilog="""
330
+ Examples:
331
+ elspais edit --req-id REQ-d00001 --status Draft
332
+ elspais edit --req-id REQ-d00001 --implements REQ-p00001,REQ-p00002
333
+ elspais edit --req-id REQ-d00001 --move-to roadmap/future.md
334
+ elspais edit --from-json edits.json
335
+
336
+ JSON batch format:
337
+ {"edits": [{"req_id": "...", "status": "...", "implements": [...]}]}
338
+ """,
289
339
  )
290
340
  edit_parser.add_argument(
291
341
  "--req-id",
@@ -326,7 +376,7 @@ Examples:
326
376
  # config command
327
377
  config_parser = subparsers.add_parser(
328
378
  "config",
329
- help="View and modify configuration",
379
+ help="View and modify configuration (show, get, set, ...)",
330
380
  )
331
381
  config_subparsers = config_parser.add_subparsers(dest="config_action")
332
382
 
@@ -341,7 +391,8 @@ Examples:
341
391
  metavar="SECTION",
342
392
  )
343
393
  config_show.add_argument(
344
- "-j", "--json",
394
+ "-j",
395
+ "--json",
345
396
  action="store_true",
346
397
  help="Output as JSON",
347
398
  )
@@ -356,7 +407,8 @@ Examples:
356
407
  help="Configuration key (dot-notation, e.g., 'patterns.prefix')",
357
408
  )
358
409
  config_get.add_argument(
359
- "-j", "--json",
410
+ "-j",
411
+ "--json",
360
412
  action="store_true",
361
413
  help="Output as JSON",
362
414
  )
@@ -372,7 +424,7 @@ Examples:
372
424
  )
373
425
  config_set.add_argument(
374
426
  "value",
375
- help="Value to set (type auto-detected: true/false, numbers, JSON arrays/objects, or string)",
427
+ help="Value to set (auto-detected: bool, number, JSON array/object, string)",
376
428
  )
377
429
 
378
430
  # config unset
@@ -422,7 +474,7 @@ Examples:
422
474
  # rules command
423
475
  rules_parser = subparsers.add_parser(
424
476
  "rules",
425
- help="View and manage content rules",
477
+ help="View and manage content rules (list, show)",
426
478
  )
427
479
  rules_subparsers = rules_parser.add_subparsers(dest="rules_action")
428
480
 
@@ -629,7 +681,7 @@ def mcp_command(args: argparse.Namespace) -> int:
629
681
  if hasattr(args, "spec_dir") and args.spec_dir:
630
682
  working_dir = args.spec_dir.parent
631
683
 
632
- print(f"Starting elspais MCP server...")
684
+ print("Starting elspais MCP server...")
633
685
  print(f"Working directory: {working_dir}")
634
686
  print(f"Transport: {args.transport}")
635
687
 
@@ -41,16 +41,14 @@ def run_hierarchy(args: argparse.Namespace) -> int:
41
41
 
42
42
  # Find root requirements (PRD with no implements)
43
43
  roots = [
44
- req for req in requirements.values()
44
+ req
45
+ for req in requirements.values()
45
46
  if req.level.upper() in ["PRD", "PRODUCT"] and not req.implements
46
47
  ]
47
48
 
48
49
  if not roots:
49
50
  # Fall back to all PRD requirements
50
- roots = [
51
- req for req in requirements.values()
52
- if req.level.upper() in ["PRD", "PRODUCT"]
53
- ]
51
+ roots = [req for req in requirements.values() if req.level.upper() in ["PRD", "PRODUCT"]]
54
52
 
55
53
  printed = set()
56
54
 
@@ -153,7 +151,8 @@ def run_coverage(args: argparse.Namespace) -> int:
153
151
 
154
152
  # List unimplemented PRD
155
153
  unimplemented = [
156
- req for req in requirements.values()
154
+ req
155
+ for req in requirements.values()
157
156
  if req.level.upper() in ["PRD", "PRODUCT"] and req.id not in implemented_prd
158
157
  ]
159
158
 
@@ -11,13 +11,11 @@ import argparse
11
11
  import json
12
12
  import sys
13
13
  from pathlib import Path
14
- from typing import Dict, List, Optional
14
+ from typing import Dict, Optional
15
15
 
16
16
  from elspais.config.defaults import DEFAULT_CONFIG
17
17
  from elspais.config.loader import find_config_file, load_config
18
18
  from elspais.core.git import (
19
- GitChangeInfo,
20
- MovedRequirement,
21
19
  detect_moved_requirements,
22
20
  filter_spec_files,
23
21
  get_current_req_locations,
@@ -76,9 +74,7 @@ def run(args: argparse.Namespace) -> int:
76
74
 
77
75
  # Detect moved requirements
78
76
  current_locations = get_current_req_locations(repo_root, spec_dir)
79
- moved = detect_moved_requirements(
80
- changes.committed_req_locations, current_locations
81
- )
77
+ moved = detect_moved_requirements(changes.committed_req_locations, current_locations)
82
78
 
83
79
  # Build result
84
80
  result = {
@@ -8,15 +8,14 @@ import argparse
8
8
  import json
9
9
  import sys
10
10
  from pathlib import Path
11
- from typing import Any, Dict, List, Optional, Tuple, Union
11
+ from typing import Any, Dict, List, Optional, Tuple
12
12
 
13
+ from elspais.config.defaults import DEFAULT_CONFIG
13
14
  from elspais.config.loader import (
14
15
  find_config_file,
15
16
  load_config,
16
- merge_configs,
17
17
  parse_toml,
18
18
  )
19
- from elspais.config.defaults import DEFAULT_CONFIG
20
19
 
21
20
 
22
21
  def run(args: argparse.Namespace) -> int:
@@ -255,6 +254,7 @@ def cmd_path(args: argparse.Namespace) -> int:
255
254
 
256
255
  # Helper functions
257
256
 
257
+
258
258
  def _get_config_path(args: argparse.Namespace) -> Optional[Path]:
259
259
  """Get configuration file path from args or by discovery."""
260
260
  if hasattr(args, "config") and args.config:
@@ -363,7 +363,7 @@ def _print_value(value: Any, prefix: str = "") -> None:
363
363
  if prefix:
364
364
  print(f"{prefix} = {'true' if value else 'false'}")
365
365
  else:
366
- print('true' if value else 'false')
366
+ print("true" if value else "false")
367
367
  elif isinstance(value, str):
368
368
  if prefix:
369
369
  print(f'{prefix} = "{value}"')
elspais/commands/edit.py CHANGED
@@ -22,7 +22,7 @@ def run(args: argparse.Namespace) -> int:
22
22
  from elspais.config.loader import find_config_file, get_spec_directories, load_config
23
23
 
24
24
  # Load configuration
25
- config_path = args.config if hasattr(args, 'config') else None
25
+ config_path = args.config if hasattr(args, "config") else None
26
26
  if config_path is None:
27
27
  config_path = find_config_file(Path.cwd())
28
28
  if config_path and config_path.exists():
@@ -31,7 +31,7 @@ def run(args: argparse.Namespace) -> int:
31
31
  config = DEFAULT_CONFIG
32
32
 
33
33
  # Get spec directories
34
- spec_dir = args.spec_dir if hasattr(args, 'spec_dir') and args.spec_dir else None
34
+ spec_dir = args.spec_dir if hasattr(args, "spec_dir") and args.spec_dir else None
35
35
  spec_dirs = get_spec_directories(spec_dir, config)
36
36
  if not spec_dirs:
37
37
  print("Error: No spec directories found", file=sys.stderr)
@@ -40,16 +40,16 @@ def run(args: argparse.Namespace) -> int:
40
40
  # Use first spec dir as base
41
41
  base_spec_dir = spec_dirs[0]
42
42
 
43
- dry_run = getattr(args, 'dry_run', False)
43
+ dry_run = getattr(args, "dry_run", False)
44
44
 
45
- validate_refs = getattr(args, 'validate_refs', False)
45
+ validate_refs = getattr(args, "validate_refs", False)
46
46
 
47
47
  # Handle batch mode
48
- if hasattr(args, 'from_json') and args.from_json:
48
+ if hasattr(args, "from_json") and args.from_json:
49
49
  return run_batch_edit(args.from_json, base_spec_dir, dry_run, validate_refs)
50
50
 
51
51
  # Handle single edit mode
52
- if hasattr(args, 'req_id') and args.req_id:
52
+ if hasattr(args, "req_id") and args.req_id:
53
53
  return run_single_edit(args, base_spec_dir, dry_run)
54
54
 
55
55
  print("Error: Must specify --req-id or --from-json", file=sys.stderr)
@@ -109,18 +109,18 @@ def run_single_edit(args: argparse.Namespace, spec_dir: Path, dry_run: bool) ->
109
109
  results = []
110
110
 
111
111
  # Apply implements change
112
- if hasattr(args, 'implements') and args.implements is not None:
112
+ if hasattr(args, "implements") and args.implements is not None:
113
113
  impl_list = [i.strip() for i in args.implements.split(",")]
114
114
  result = modify_implements(file_path, req_id, impl_list, dry_run=dry_run)
115
115
  results.append(("implements", result))
116
116
 
117
117
  # Apply status change
118
- if hasattr(args, 'status') and args.status:
118
+ if hasattr(args, "status") and args.status:
119
119
  result = modify_status(file_path, req_id, args.status, dry_run=dry_run)
120
120
  results.append(("status", result))
121
121
 
122
122
  # Apply move
123
- if hasattr(args, 'move_to') and args.move_to:
123
+ if hasattr(args, "move_to") and args.move_to:
124
124
  dest_path = spec_dir / args.move_to
125
125
  result = move_requirement(file_path, dest_path, req_id, dry_run=dry_run)
126
126
  results.append(("move", result))
@@ -157,14 +157,14 @@ def find_requirement_in_files(
157
157
  Dict with file_path, req_id, line_number, or None if not found
158
158
  """
159
159
  # Pattern to match requirement header
160
- pattern = re.compile(rf'^#\s*{re.escape(req_id)}:', re.MULTILINE)
160
+ pattern = re.compile(rf"^#\s*{re.escape(req_id)}:", re.MULTILINE)
161
161
 
162
162
  for md_file in spec_dir.rglob("*.md"):
163
163
  content = md_file.read_text()
164
164
  match = pattern.search(content)
165
165
  if match:
166
166
  # Count line number
167
- line_number = content[:match.start()].count('\n') + 1
167
+ line_number = content[: match.start()].count("\n") + 1
168
168
  return {
169
169
  "file_path": md_file,
170
170
  "req_id": req_id,
@@ -195,7 +195,7 @@ def modify_implements(
195
195
  content = file_path.read_text()
196
196
 
197
197
  # Find the requirement header
198
- req_pattern = re.compile(rf'^(#\s*{re.escape(req_id)}:[^\n]*\n)', re.MULTILINE)
198
+ req_pattern = re.compile(rf"^(#\s*{re.escape(req_id)}:[^\n]*\n)", re.MULTILINE)
199
199
  req_match = req_pattern.search(content)
200
200
 
201
201
  if not req_match:
@@ -203,9 +203,9 @@ def modify_implements(
203
203
 
204
204
  # Find the **Implements**: field after the header
205
205
  start_pos = req_match.end()
206
- search_region = content[start_pos:start_pos + 500]
206
+ search_region = content[start_pos : start_pos + 500]
207
207
 
208
- impl_pattern = re.compile(r'(\*\*Implements\*\*:\s*)([^|\n]+)')
208
+ impl_pattern = re.compile(r"(\*\*Implements\*\*:\s*)([^|\n]+)")
209
209
  impl_match = impl_pattern.search(search_region)
210
210
 
211
211
  if not impl_match:
@@ -272,7 +272,7 @@ def modify_status(
272
272
  content = file_path.read_text()
273
273
 
274
274
  # Find the requirement header
275
- req_pattern = re.compile(rf'^(#\s*{re.escape(req_id)}:[^\n]*\n)', re.MULTILINE)
275
+ req_pattern = re.compile(rf"^(#\s*{re.escape(req_id)}:[^\n]*\n)", re.MULTILINE)
276
276
  req_match = req_pattern.search(content)
277
277
 
278
278
  if not req_match:
@@ -280,9 +280,9 @@ def modify_status(
280
280
 
281
281
  # Find the **Status**: field after the header
282
282
  start_pos = req_match.end()
283
- search_region = content[start_pos:start_pos + 500]
283
+ search_region = content[start_pos : start_pos + 500]
284
284
 
285
- status_pattern = re.compile(r'(\*\*Status\*\*:\s*)(\w+)')
285
+ status_pattern = re.compile(r"(\*\*Status\*\*:\s*)(\w+)")
286
286
  status_match = status_pattern.search(search_region)
287
287
 
288
288
  if not status_match:
@@ -342,11 +342,8 @@ def move_requirement(
342
342
  # Find the requirement block
343
343
  # Pattern: # REQ-xxx: title ... *End* *title* | **Hash**: xxx\n---
344
344
  req_pattern = re.compile(
345
- rf'(^#\s*{re.escape(req_id)}:[^\n]*\n'
346
- rf'.*?'
347
- rf'\*End\*[^\n]*\n'
348
- rf'(?:---\n)?)',
349
- re.MULTILINE | re.DOTALL
345
+ rf"(^#\s*{re.escape(req_id)}:[^\n]*\n" rf".*?" rf"\*End\*[^\n]*\n" rf"(?:---\n)?)",
346
+ re.MULTILINE | re.DOTALL,
350
347
  )
351
348
 
352
349
  req_match = req_pattern.search(source_content)
@@ -361,9 +358,9 @@ def move_requirement(
361
358
  req_block = req_block.rstrip() + "\n---\n"
362
359
 
363
360
  # Remove from source
364
- new_source_content = source_content[:req_match.start()] + source_content[req_match.end():]
361
+ new_source_content = source_content[: req_match.start()] + source_content[req_match.end() :]
365
362
  # Clean up extra blank lines
366
- new_source_content = re.sub(r'\n{3,}', '\n\n', new_source_content)
363
+ new_source_content = re.sub(r"\n{3,}", "\n\n", new_source_content)
367
364
 
368
365
  # Add to destination
369
366
  dest_content = dest_file.read_text() if dest_file.exists() else ""
@@ -400,8 +397,9 @@ def collect_all_req_ids(spec_dir: Path) -> set:
400
397
  Set of requirement IDs found (short form, e.g., "p00001")
401
398
  """
402
399
  import re
400
+
403
401
  req_ids = set()
404
- pattern = re.compile(r'^#\s*(REQ-[A-Za-z0-9-]+):', re.MULTILINE)
402
+ pattern = re.compile(r"^#\s*(REQ-[A-Za-z0-9-]+):", re.MULTILINE)
405
403
 
406
404
  for md_file in spec_dir.rglob("*.md"):
407
405
  content = md_file.read_text()
@@ -453,11 +451,13 @@ def batch_edit(
453
451
  # Find the requirement
454
452
  location = find_requirement_in_files(spec_dir, req_id)
455
453
  if not location:
456
- results.append({
457
- "success": False,
458
- "req_id": req_id,
459
- "error": f"Requirement {req_id} not found",
460
- })
454
+ results.append(
455
+ {
456
+ "success": False,
457
+ "req_id": req_id,
458
+ "error": f"Requirement {req_id} not found",
459
+ }
460
+ )
461
461
  continue
462
462
 
463
463
  file_path = location["file_path"]
@@ -493,9 +493,7 @@ def batch_edit(
493
493
 
494
494
  # Apply status change
495
495
  if "status" in change:
496
- status_result = modify_status(
497
- file_path, req_id, change["status"], dry_run=dry_run
498
- )
496
+ status_result = modify_status(file_path, req_id, change["status"], dry_run=dry_run)
499
497
  if not status_result["success"]:
500
498
  result = status_result
501
499
  result["req_id"] = req_id
@@ -506,9 +504,7 @@ def batch_edit(
506
504
  # Apply move (must be last since it changes file location)
507
505
  if "move_to" in change:
508
506
  dest_path = spec_dir / change["move_to"]
509
- move_result = move_requirement(
510
- file_path, dest_path, req_id, dry_run=dry_run
511
- )
507
+ move_result = move_requirement(file_path, dest_path, req_id, dry_run=dry_run)
512
508
  if not move_result["success"]:
513
509
  result = move_result
514
510
  result["req_id"] = req_id
@@ -105,15 +105,15 @@ def run_update(args: argparse.Namespace) -> int:
105
105
  print(f"Updating {len(updates)} hashes...")
106
106
  for req_id, req, new_hash in updates:
107
107
  result = update_hash_in_file(req, new_hash)
108
- if result['updated']:
108
+ if result["updated"]:
109
109
  print(f" ✓ {req_id}")
110
- old_hash = result['old_hash'] or "(none)"
110
+ old_hash = result["old_hash"] or "(none)"
111
111
  print(f" [INFO] Hash: {old_hash} -> {result['new_hash']}")
112
- if result['title_fixed']:
112
+ if result["title_fixed"]:
113
113
  print(f" [INFO] Title fixed: \"{result['old_title']}\" -> \"{req.title}\"")
114
114
  else:
115
115
  print(f" ✗ {req_id}")
116
- print(f" [WARN] Could not find End marker to update")
116
+ print(" [WARN] Could not find End marker to update")
117
117
 
118
118
  return 0
119
119
 
@@ -168,11 +168,11 @@ def update_hash_in_file(req: Requirement, new_hash: str) -> dict:
168
168
  import re
169
169
 
170
170
  result = {
171
- 'updated': False,
172
- 'old_hash': req.hash,
173
- 'new_hash': new_hash,
174
- 'title_fixed': False,
175
- 'old_title': None,
171
+ "updated": False,
172
+ "old_hash": req.hash,
173
+ "new_hash": new_hash,
174
+ "title_fixed": False,
175
+ "old_title": None,
176
176
  }
177
177
 
178
178
  if not req.file_path:
@@ -186,35 +186,41 @@ def update_hash_in_file(req: Requirement, new_hash: str) -> dict:
186
186
  # This handles both: (1) normal case, (2) mismatched title case
187
187
 
188
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*$"
189
+ pattern_by_title = (
190
+ rf"^\*End\*\s+\*{re.escape(req.title)}\*\s*\|\s*\*\*Hash\*\*:\s*[a-fA-F0-9]+\s*$"
191
+ )
190
192
  if re.search(pattern_by_title, content, re.MULTILINE):
191
193
  content, count = re.subn(pattern_by_title, new_end_line, content, flags=re.MULTILINE)
192
194
  if count > 0:
193
- result['updated'] = True
195
+ result["updated"] = True
194
196
  else:
195
197
  # Second try: find by hash value (handles mismatched title)
196
198
  # Pattern: *End* *AnyTitle* | **Hash**: oldhash
197
- pattern_by_hash = rf"^\*End\*\s+\*([^*]+)\*\s*\|\s*\*\*Hash\*\*:\s*{re.escape(req.hash)}\s*$"
199
+ pattern_by_hash = (
200
+ rf"^\*End\*\s+\*([^*]+)\*\s*\|\s*\*\*Hash\*\*:\s*{re.escape(req.hash)}\s*$"
201
+ )
198
202
  match = re.search(pattern_by_hash, content, re.MULTILINE)
199
203
 
200
204
  if match:
201
205
  old_title = match.group(1)
202
206
  if old_title != req.title:
203
- result['title_fixed'] = True
204
- result['old_title'] = old_title
207
+ result["title_fixed"] = True
208
+ result["old_title"] = old_title
205
209
 
206
210
  # 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
211
+ content = re.sub(
212
+ pattern_by_hash, new_end_line, content, count=1, flags=re.MULTILINE
213
+ )
214
+ result["updated"] = True
209
215
  else:
210
216
  # Add hash to end marker (no existing hash)
211
217
  # Pattern: *End* *Title* (without hash)
212
218
  pattern = rf"^(\*End\*\s+\*{re.escape(req.title)}\*)(?!\s*\|\s*\*\*Hash\*\*)\s*$"
213
219
  content, count = re.subn(pattern, new_end_line, content, flags=re.MULTILINE)
214
220
  if count > 0:
215
- result['updated'] = True
221
+ result["updated"] = True
216
222
 
217
- if result['updated']:
223
+ if result["updated"]:
218
224
  req.file_path.write_text(content, encoding="utf-8")
219
225
 
220
226
  return result
elspais/commands/index.py CHANGED
@@ -58,6 +58,7 @@ def run_validate(args: argparse.Namespace) -> int:
58
58
  indexed_ids = set()
59
59
 
60
60
  import re
61
+
61
62
  for match in re.finditer(r"\|\s*([A-Z]+-(?:[A-Z]+-)?[a-zA-Z]?\d+)\s*\|", index_content):
62
63
  indexed_ids.add(match.group(1))
63
64
 
@@ -155,12 +156,12 @@ def generate_index(requirements: dict, config: dict) -> str:
155
156
 
156
157
  lines.append("")
157
158
 
158
- lines.extend([
159
- "---",
160
- "",
161
- "*Generated by elspais*",
162
- ])
159
+ lines.extend(
160
+ [
161
+ "---",
162
+ "",
163
+ "*Generated by elspais*",
164
+ ]
165
+ )
163
166
 
164
167
  return "\n".join(lines)
165
-
166
-