elspais 0.11.2__py3-none-any.whl → 0.43.5__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 (147) hide show
  1. elspais/__init__.py +1 -10
  2. elspais/{sponsors/__init__.py → associates.py} +102 -56
  3. elspais/cli.py +366 -69
  4. elspais/commands/__init__.py +9 -3
  5. elspais/commands/analyze.py +118 -169
  6. elspais/commands/changed.py +12 -23
  7. elspais/commands/config_cmd.py +10 -13
  8. elspais/commands/edit.py +33 -13
  9. elspais/commands/example_cmd.py +319 -0
  10. elspais/commands/hash_cmd.py +161 -183
  11. elspais/commands/health.py +1177 -0
  12. elspais/commands/index.py +98 -115
  13. elspais/commands/init.py +99 -22
  14. elspais/commands/reformat_cmd.py +41 -433
  15. elspais/commands/rules_cmd.py +2 -2
  16. elspais/commands/trace.py +443 -324
  17. elspais/commands/validate.py +193 -411
  18. elspais/config/__init__.py +799 -5
  19. elspais/{core/content_rules.py → content_rules.py} +20 -2
  20. elspais/docs/cli/assertions.md +67 -0
  21. elspais/docs/cli/commands.md +304 -0
  22. elspais/docs/cli/config.md +262 -0
  23. elspais/docs/cli/format.md +66 -0
  24. elspais/docs/cli/git.md +45 -0
  25. elspais/docs/cli/health.md +190 -0
  26. elspais/docs/cli/hierarchy.md +60 -0
  27. elspais/docs/cli/ignore.md +72 -0
  28. elspais/docs/cli/mcp.md +245 -0
  29. elspais/docs/cli/quickstart.md +58 -0
  30. elspais/docs/cli/traceability.md +89 -0
  31. elspais/docs/cli/validation.md +96 -0
  32. elspais/graph/GraphNode.py +383 -0
  33. elspais/graph/__init__.py +40 -0
  34. elspais/graph/annotators.py +927 -0
  35. elspais/graph/builder.py +1886 -0
  36. elspais/graph/deserializer.py +248 -0
  37. elspais/graph/factory.py +284 -0
  38. elspais/graph/metrics.py +127 -0
  39. elspais/graph/mutations.py +161 -0
  40. elspais/graph/parsers/__init__.py +156 -0
  41. elspais/graph/parsers/code.py +213 -0
  42. elspais/graph/parsers/comments.py +112 -0
  43. elspais/graph/parsers/config_helpers.py +29 -0
  44. elspais/graph/parsers/heredocs.py +225 -0
  45. elspais/graph/parsers/journey.py +131 -0
  46. elspais/graph/parsers/remainder.py +79 -0
  47. elspais/graph/parsers/requirement.py +347 -0
  48. elspais/graph/parsers/results/__init__.py +6 -0
  49. elspais/graph/parsers/results/junit_xml.py +229 -0
  50. elspais/graph/parsers/results/pytest_json.py +313 -0
  51. elspais/graph/parsers/test.py +305 -0
  52. elspais/graph/relations.py +78 -0
  53. elspais/graph/serialize.py +216 -0
  54. elspais/html/__init__.py +8 -0
  55. elspais/html/generator.py +731 -0
  56. elspais/html/templates/trace_view.html.j2 +2151 -0
  57. elspais/mcp/__init__.py +45 -29
  58. elspais/mcp/__main__.py +5 -1
  59. elspais/mcp/file_mutations.py +138 -0
  60. elspais/mcp/server.py +1998 -244
  61. elspais/testing/__init__.py +3 -3
  62. elspais/testing/config.py +3 -0
  63. elspais/testing/mapper.py +1 -1
  64. elspais/testing/scanner.py +301 -12
  65. elspais/utilities/__init__.py +1 -0
  66. elspais/utilities/docs_loader.py +115 -0
  67. elspais/utilities/git.py +607 -0
  68. elspais/{core → utilities}/hasher.py +8 -22
  69. elspais/utilities/md_renderer.py +189 -0
  70. elspais/{core → utilities}/patterns.py +56 -51
  71. elspais/utilities/reference_config.py +626 -0
  72. elspais/validation/__init__.py +19 -0
  73. elspais/validation/format.py +264 -0
  74. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
  75. elspais-0.43.5.dist-info/RECORD +80 -0
  76. elspais/config/defaults.py +0 -179
  77. elspais/config/loader.py +0 -494
  78. elspais/core/__init__.py +0 -21
  79. elspais/core/git.py +0 -346
  80. elspais/core/models.py +0 -320
  81. elspais/core/parser.py +0 -639
  82. elspais/core/rules.py +0 -509
  83. elspais/mcp/context.py +0 -172
  84. elspais/mcp/serializers.py +0 -112
  85. elspais/reformat/__init__.py +0 -50
  86. elspais/reformat/detector.py +0 -112
  87. elspais/reformat/hierarchy.py +0 -247
  88. elspais/reformat/line_breaks.py +0 -218
  89. elspais/reformat/prompts.py +0 -133
  90. elspais/reformat/transformer.py +0 -266
  91. elspais/trace_view/__init__.py +0 -55
  92. elspais/trace_view/coverage.py +0 -183
  93. elspais/trace_view/generators/__init__.py +0 -12
  94. elspais/trace_view/generators/base.py +0 -334
  95. elspais/trace_view/generators/csv.py +0 -118
  96. elspais/trace_view/generators/markdown.py +0 -170
  97. elspais/trace_view/html/__init__.py +0 -33
  98. elspais/trace_view/html/generator.py +0 -1140
  99. elspais/trace_view/html/templates/base.html +0 -283
  100. elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
  101. elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
  102. elspais/trace_view/html/templates/components/legend_modal.html +0 -69
  103. elspais/trace_view/html/templates/components/review_panel.html +0 -118
  104. elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
  105. elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
  106. elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
  107. elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
  108. elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
  109. elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
  110. elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
  111. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
  112. elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
  113. elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
  114. elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
  115. elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
  116. elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
  117. elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
  118. elspais/trace_view/html/templates/partials/scripts.js +0 -1741
  119. elspais/trace_view/html/templates/partials/styles.css +0 -1756
  120. elspais/trace_view/models.py +0 -378
  121. elspais/trace_view/review/__init__.py +0 -63
  122. elspais/trace_view/review/branches.py +0 -1142
  123. elspais/trace_view/review/models.py +0 -1200
  124. elspais/trace_view/review/position.py +0 -591
  125. elspais/trace_view/review/server.py +0 -1032
  126. elspais/trace_view/review/status.py +0 -455
  127. elspais/trace_view/review/storage.py +0 -1343
  128. elspais/trace_view/scanning.py +0 -213
  129. elspais/trace_view/specs/README.md +0 -84
  130. elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
  131. elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
  132. elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
  133. elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
  134. elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
  135. elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
  136. elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
  137. elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
  138. elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
  139. elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
  140. elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
  141. elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
  142. elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
  143. elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
  144. elspais-0.11.2.dist-info/RECORD +0 -101
  145. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
  146. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
  147. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/licenses/LICENSE +0 -0
@@ -1,226 +1,204 @@
1
+ # Implements: REQ-int-d00003 (CLI Extension)
1
2
  """
2
- elspais.commands.hash_cmd - Hash management command.
3
+ elspais.commands.hash_cmd - Manage requirement hashes.
3
4
 
4
- Verify and update requirement hashes.
5
+ Uses the graph-based system for hash verification and updates.
5
6
  """
6
7
 
8
+ from __future__ import annotations
9
+
7
10
  import argparse
8
11
  import sys
9
- from pathlib import Path
10
12
 
11
- from elspais.config.defaults import DEFAULT_CONFIG
12
- from elspais.config.loader import find_config_file, get_spec_directories, load_config
13
- from elspais.core.hasher import calculate_hash, verify_hash
14
- from elspais.core.models import Requirement
15
- from elspais.core.parser import RequirementParser
16
- from elspais.core.patterns import PatternConfig
13
+ from elspais.graph import NodeKind
17
14
 
18
15
 
19
16
  def run(args: argparse.Namespace) -> int:
20
- """Run the hash command."""
21
- if not args.hash_action:
22
- print("Usage: elspais hash {verify|update}")
23
- return 1
24
-
25
- if args.hash_action == "verify":
26
- return run_verify(args)
27
- elif args.hash_action == "update":
28
- return run_update(args)
29
-
30
- return 1
31
-
32
-
33
- def run_verify(args: argparse.Namespace) -> int:
34
- """Verify all requirement hashes."""
35
- config, requirements = load_requirements(args)
36
- if not requirements:
37
- return 1
38
-
39
- hash_length = config.get("validation", {}).get("hash_length", 8)
40
- algorithm = config.get("validation", {}).get("hash_algorithm", "sha256")
41
-
42
- mismatches = []
43
- missing = []
17
+ """Run the hash command.
44
18
 
45
- for req_id, req in requirements.items():
46
- if not req.hash:
47
- missing.append(req_id)
48
- else:
49
- expected = calculate_hash(req.body, length=hash_length, algorithm=algorithm)
50
- if not verify_hash(req.body, req.hash, length=hash_length, algorithm=algorithm):
51
- mismatches.append((req_id, req.hash, expected))
19
+ Subcommands:
20
+ - verify: Check hashes match content
21
+ - update: Recalculate and update hashes
22
+ """
23
+ from elspais.graph.factory import build_graph
52
24
 
53
- # Report results
54
- if missing:
55
- print(f"Missing hashes: {len(missing)}")
56
- for req_id in missing:
57
- print(f" - {req_id}")
58
-
59
- if mismatches:
60
- print(f"\nHash mismatches: {len(mismatches)}")
61
- for req_id, current, expected in mismatches:
62
- print(f" - {req_id}: {current} (expected: {expected})")
63
-
64
- if not missing and not mismatches:
65
- print(f"✓ All {len(requirements)} hashes verified")
66
- return 0
25
+ spec_dir = getattr(args, "spec_dir", None)
26
+ config_path = getattr(args, "config", None)
67
27
 
68
- return 1 if mismatches else 0
28
+ graph = build_graph(
29
+ spec_dirs=[spec_dir] if spec_dir else None,
30
+ config_path=config_path,
31
+ )
69
32
 
33
+ action = getattr(args, "hash_action", None)
70
34
 
71
- def run_update(args: argparse.Namespace) -> int:
72
- """Update requirement hashes."""
73
- config, requirements = load_requirements(args)
74
- if not requirements:
35
+ if action == "verify":
36
+ return _verify_hashes(graph, args)
37
+ elif action == "update":
38
+ return _update_hashes(graph, args)
39
+ else:
40
+ print("Usage: elspais hash <verify|update>", file=sys.stderr)
75
41
  return 1
76
42
 
77
- hash_length = config.get("validation", {}).get("hash_length", 8)
78
- algorithm = config.get("validation", {}).get("hash_algorithm", "sha256")
79
-
80
- # Filter to specific requirement if specified
81
- if args.req_id:
82
- if args.req_id not in requirements:
83
- print(f"Requirement not found: {args.req_id}")
84
- return 1
85
- requirements = {args.req_id: requirements[args.req_id]}
86
43
 
87
- updates = []
44
+ def _get_requirement_body(node) -> str:
45
+ """Extract hashable body content from a requirement node.
88
46
 
89
- for req_id, req in requirements.items():
90
- expected = calculate_hash(req.body, length=hash_length, algorithm=algorithm)
91
- if req.hash != expected:
92
- updates.append((req_id, req, expected))
47
+ Per spec/requirements-spec.md:
48
+ > The hash SHALL be calculated from:
49
+ > - every line AFTER the Header line
50
+ > - every line BEFORE the Footer line
93
51
 
94
- if not updates:
95
- print("All hashes are up to date")
96
- return 0
52
+ The body_text is extracted during parsing and stored in the node.
53
+ This includes metadata, intro text, assertions - everything between
54
+ the header and footer markers.
97
55
 
98
- # Show or apply updates
99
- if args.dry_run:
100
- print(f"Would update {len(updates)} hashes:")
101
- for req_id, req, new_hash in updates:
102
- old_hash = req.hash or "(none)"
103
- print(f" {req_id}: {old_hash} -> {new_hash}")
104
- else:
105
- print(f"Updating {len(updates)} hashes...")
106
- for req_id, req, new_hash in updates:
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(" [WARN] Could not find End marker to update")
56
+ Args:
57
+ node: The requirement GraphNode.
117
58
 
118
- return 0
59
+ Returns:
60
+ Body text for hashing.
61
+ """
62
+ # Use the stored body_text which was extracted during parsing
63
+ return node.get_field("body_text", "")
119
64
 
120
65
 
121
- def load_requirements(args: argparse.Namespace) -> tuple:
122
- """Load configuration and requirements."""
123
- config_path = args.config or find_config_file(Path.cwd())
124
- if config_path and config_path.exists():
125
- config = load_config(config_path)
126
- else:
127
- config = DEFAULT_CONFIG
66
+ def _verify_hashes(graph, args) -> int:
67
+ """Verify all hashes match content."""
68
+ from elspais.utilities.hasher import calculate_hash
128
69
 
129
- spec_dirs = get_spec_directories(args.spec_dir, config)
130
- if not spec_dirs:
131
- print("Error: No spec directories found", file=sys.stderr)
132
- return config, {}
70
+ mismatches = []
71
+ missing = []
133
72
 
134
- pattern_config = PatternConfig.from_dict(config.get("patterns", {}))
135
- spec_config = config.get("spec", {})
136
- no_reference_values = spec_config.get("no_reference_values")
137
- skip_files = spec_config.get("skip_files", [])
138
- parser = RequirementParser(pattern_config, no_reference_values=no_reference_values)
73
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
74
+ stored_hash = node.hash
75
+ if not stored_hash:
76
+ missing.append(node.id)
77
+ continue
78
+
79
+ # Get body content from the node's assertions
80
+ body = _get_requirement_body(node)
81
+ if body:
82
+ computed = calculate_hash(body)
83
+ if computed != stored_hash:
84
+ mismatches.append(
85
+ {
86
+ "id": node.id,
87
+ "stored": stored_hash,
88
+ "computed": computed,
89
+ }
90
+ )
139
91
 
140
- try:
141
- requirements = parser.parse_directories(spec_dirs, skip_files=skip_files)
142
- except Exception as e:
143
- print(f"Error parsing requirements: {e}", file=sys.stderr)
144
- return config, {}
92
+ # Report results
93
+ if not getattr(args, "quiet", False):
94
+ if missing:
95
+ print(f"Missing hashes: {len(missing)}")
96
+ for req_id in missing[:10]: # Show first 10
97
+ print(f" {req_id}")
98
+ if len(missing) > 10:
99
+ print(f" ... and {len(missing) - 10} more")
100
+
101
+ if mismatches:
102
+ print(f"Hash mismatches: {len(mismatches)}")
103
+ for m in mismatches[:10]:
104
+ print(f" {m['id']}: stored={m['stored']} computed={m['computed']}")
105
+ if len(mismatches) > 10:
106
+ print(f" ... and {len(mismatches) - 10} more")
107
+
108
+ if not missing and not mismatches:
109
+ print("All hashes valid")
145
110
 
146
- return config, requirements
111
+ return 1 if mismatches else 0
147
112
 
148
113
 
149
- def update_hash_in_file(req: Requirement, new_hash: str) -> dict:
150
- """Update the hash in the requirement's source file.
114
+ def _update_hashes(graph, args) -> int:
115
+ """Update hashes in spec files.
151
116
 
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.
117
+ Finds requirements with mismatched hashes and updates them.
118
+ Supports --dry-run to preview changes without applying them.
119
+ Supports --req-id to update a specific requirement only.
120
+ """
121
+ from pathlib import Path
155
122
 
156
- Args:
157
- req: Requirement object with file_path, title, and hash
158
- new_hash: New hash value to write
123
+ from elspais.mcp.file_mutations import update_hash_in_file
124
+ from elspais.utilities.hasher import calculate_hash
159
125
 
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)
167
- """
168
- import re
126
+ dry_run = getattr(args, "dry_run", False)
127
+ target_req_id = getattr(args, "req_id", None)
128
+ json_output = getattr(args, "json_output", False)
169
129
 
170
- result = {
171
- "updated": False,
172
- "old_hash": req.hash,
173
- "new_hash": new_hash,
174
- "title_fixed": False,
175
- "old_title": None,
176
- }
130
+ # Get repo root from graph or default to cwd
131
+ repo_root = getattr(graph, "_repo_root", None) or Path.cwd()
177
132
 
178
- if not req.file_path:
179
- return result
133
+ updates = []
134
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
135
+ # Filter to specific requirement if requested
136
+ if target_req_id and node.id != target_req_id:
137
+ continue
138
+
139
+ stored_hash = node.hash
140
+ body = _get_requirement_body(node)
141
+
142
+ # Skip if no body content (can't compute hash)
143
+ if not body:
144
+ continue
145
+
146
+ computed_hash = calculate_hash(body)
147
+
148
+ # Check if hash needs updating
149
+ if stored_hash != computed_hash:
150
+ # Get file path from source location
151
+ source = node.source
152
+ if source is None:
153
+ continue
154
+
155
+ file_path = Path(repo_root) / source.path
156
+
157
+ updates.append(
158
+ {
159
+ "id": node.id,
160
+ "old_hash": stored_hash or "(none)",
161
+ "new_hash": computed_hash,
162
+ "file": str(file_path),
163
+ }
164
+ )
180
165
 
181
- content = req.file_path.read_text(encoding="utf-8")
182
- new_end_line = f"*End* *{req.title}* | **Hash**: {new_hash}"
166
+ # Handle dry run
167
+ if dry_run:
168
+ if json_output:
169
+ import json
183
170
 
184
- if req.hash:
185
- # Strategy: Try title first (most specific), then hash if title not found
186
- # This handles both: (1) normal case, (2) mismatched title case
171
+ print(json.dumps({"updates": updates, "count": len(updates)}, indent=2))
172
+ else:
173
+ if not updates:
174
+ print("All hashes are up to date.")
175
+ else:
176
+ print(f"Would update {len(updates)} hash(es):")
177
+ for u in updates:
178
+ print(f" {u['id']}: {u['old_hash']} -> {u['new_hash']}")
179
+ return 0
187
180
 
188
- # First try: match by correct title (handles case where titles match)
189
- pattern_by_title = (
190
- rf"^\*End\*\s+\*{re.escape(req.title)}\*\s*\|\s*\*\*Hash\*\*:\s*[a-fA-F0-9]+\s*$"
181
+ # Apply updates
182
+ updated_count = 0
183
+ for u in updates:
184
+ success = update_hash_in_file(
185
+ file_path=Path(u["file"]),
186
+ req_id=u["id"],
187
+ new_hash=u["new_hash"],
191
188
  )
192
- if re.search(pattern_by_title, content, re.MULTILINE):
193
- content, count = re.subn(pattern_by_title, new_end_line, content, flags=re.MULTILINE)
194
- if count > 0:
195
- result["updated"] = True
196
- else:
197
- # Second try: find by hash value (handles mismatched title)
198
- # Pattern: *End* *AnyTitle* | **Hash**: oldhash
199
- pattern_by_hash = (
200
- rf"^\*End\*\s+\*([^*]+)\*\s*\|\s*\*\*Hash\*\*:\s*{re.escape(req.hash)}\s*$"
201
- )
202
- match = re.search(pattern_by_hash, content, re.MULTILINE)
189
+ if success:
190
+ updated_count += 1
191
+ if not json_output:
192
+ print(f"Updated {u['id']}: {u['old_hash']} -> {u['new_hash']}")
203
193
 
204
- if match:
205
- old_title = match.group(1)
206
- if old_title != req.title:
207
- result["title_fixed"] = True
208
- result["old_title"] = old_title
194
+ if json_output:
195
+ import json
209
196
 
210
- # Replace entire line (only first match to avoid affecting other reqs)
211
- content = re.sub(
212
- pattern_by_hash, new_end_line, content, count=1, flags=re.MULTILINE
213
- )
214
- result["updated"] = True
197
+ print(json.dumps({"updated": updated_count, "total": len(updates)}, indent=2))
215
198
  else:
216
- # Add hash to end marker (no existing hash)
217
- # Pattern: *End* *Title* (without hash)
218
- pattern = rf"^(\*End\*\s+\*{re.escape(req.title)}\*)(?!\s*\|\s*\*\*Hash\*\*)\s*$"
219
- content, count = re.subn(pattern, new_end_line, content, flags=re.MULTILINE)
220
- if count > 0:
221
- result["updated"] = True
222
-
223
- if result["updated"]:
224
- req.file_path.write_text(content, encoding="utf-8")
199
+ if updated_count == 0:
200
+ print("No hashes needed updating.")
201
+ else:
202
+ print(f"Updated {updated_count} hash(es).")
225
203
 
226
- return result
204
+ return 0