elspais 0.11.1__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 (148) hide show
  1. elspais/__init__.py +2 -11
  2. elspais/{sponsors/__init__.py → associates.py} +102 -58
  3. elspais/cli.py +395 -79
  4. elspais/commands/__init__.py +9 -3
  5. elspais/commands/analyze.py +121 -173
  6. elspais/commands/changed.py +15 -30
  7. elspais/commands/config_cmd.py +13 -16
  8. elspais/commands/edit.py +60 -44
  9. elspais/commands/example_cmd.py +319 -0
  10. elspais/commands/hash_cmd.py +167 -183
  11. elspais/commands/health.py +1177 -0
  12. elspais/commands/index.py +98 -114
  13. elspais/commands/init.py +103 -26
  14. elspais/commands/reformat_cmd.py +41 -444
  15. elspais/commands/rules_cmd.py +7 -3
  16. elspais/commands/trace.py +444 -321
  17. elspais/commands/validate.py +195 -415
  18. elspais/config/__init__.py +799 -5
  19. elspais/{core/content_rules.py → content_rules.py} +20 -3
  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 +47 -29
  58. elspais/mcp/__main__.py +5 -1
  59. elspais/mcp/file_mutations.py +138 -0
  60. elspais/mcp/server.py +2016 -247
  61. elspais/testing/__init__.py +4 -4
  62. elspais/testing/config.py +3 -0
  63. elspais/testing/mapper.py +1 -1
  64. elspais/testing/result_parser.py +25 -21
  65. elspais/testing/scanner.py +301 -12
  66. elspais/utilities/__init__.py +1 -0
  67. elspais/utilities/docs_loader.py +115 -0
  68. elspais/utilities/git.py +607 -0
  69. elspais/{core → utilities}/hasher.py +8 -22
  70. elspais/utilities/md_renderer.py +189 -0
  71. elspais/{core → utilities}/patterns.py +58 -57
  72. elspais/utilities/reference_config.py +626 -0
  73. elspais/validation/__init__.py +19 -0
  74. elspais/validation/format.py +264 -0
  75. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
  76. elspais-0.43.5.dist-info/RECORD +80 -0
  77. elspais/config/defaults.py +0 -173
  78. elspais/config/loader.py +0 -494
  79. elspais/core/__init__.py +0 -21
  80. elspais/core/git.py +0 -352
  81. elspais/core/models.py +0 -320
  82. elspais/core/parser.py +0 -640
  83. elspais/core/rules.py +0 -514
  84. elspais/mcp/context.py +0 -171
  85. elspais/mcp/serializers.py +0 -112
  86. elspais/reformat/__init__.py +0 -50
  87. elspais/reformat/detector.py +0 -119
  88. elspais/reformat/hierarchy.py +0 -246
  89. elspais/reformat/line_breaks.py +0 -220
  90. elspais/reformat/prompts.py +0 -123
  91. elspais/reformat/transformer.py +0 -264
  92. elspais/trace_view/__init__.py +0 -54
  93. elspais/trace_view/coverage.py +0 -183
  94. elspais/trace_view/generators/__init__.py +0 -12
  95. elspais/trace_view/generators/base.py +0 -329
  96. elspais/trace_view/generators/csv.py +0 -122
  97. elspais/trace_view/generators/markdown.py +0 -175
  98. elspais/trace_view/html/__init__.py +0 -31
  99. elspais/trace_view/html/generator.py +0 -1006
  100. elspais/trace_view/html/templates/base.html +0 -283
  101. elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
  102. elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
  103. elspais/trace_view/html/templates/components/legend_modal.html +0 -69
  104. elspais/trace_view/html/templates/components/review_panel.html +0 -118
  105. elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
  106. elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
  107. elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
  108. elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
  109. elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
  110. elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
  111. elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
  112. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
  113. elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
  114. elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
  115. elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
  116. elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
  117. elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
  118. elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
  119. elspais/trace_view/html/templates/partials/scripts.js +0 -1741
  120. elspais/trace_view/html/templates/partials/styles.css +0 -1756
  121. elspais/trace_view/models.py +0 -353
  122. elspais/trace_view/review/__init__.py +0 -60
  123. elspais/trace_view/review/branches.py +0 -1149
  124. elspais/trace_view/review/models.py +0 -1205
  125. elspais/trace_view/review/position.py +0 -609
  126. elspais/trace_view/review/server.py +0 -1056
  127. elspais/trace_view/review/status.py +0 -470
  128. elspais/trace_view/review/storage.py +0 -1367
  129. elspais/trace_view/scanning.py +0 -213
  130. elspais/trace_view/specs/README.md +0 -84
  131. elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
  132. elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
  133. elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
  134. elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
  135. elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
  136. elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
  137. elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
  138. elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
  139. elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
  140. elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
  141. elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
  142. elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
  143. elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
  144. elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
  145. elspais-0.11.1.dist-info/RECORD +0 -101
  146. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
  147. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
  148. {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,161 @@
1
+ """Mutation types for TraceGraph operations.
2
+
3
+ This module provides dataclasses for tracking graph mutations,
4
+ broken references, and other graph state changes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime
11
+ from typing import Any, Iterator
12
+ from uuid import uuid4
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class BrokenReference:
17
+ """A reference to a non-existent target node.
18
+
19
+ Captured during graph build when a link cannot be resolved.
20
+
21
+ Attributes:
22
+ source_id: ID of the node containing the reference.
23
+ target_id: ID that was referenced but doesn't exist.
24
+ edge_kind: Type of relationship ("implements", "refines", "validates").
25
+ """
26
+
27
+ source_id: str
28
+ target_id: str
29
+ edge_kind: str
30
+
31
+ def __str__(self) -> str:
32
+ """Human-readable representation."""
33
+ return f"{self.source_id} --[{self.edge_kind}]--> {self.target_id} (missing)"
34
+
35
+
36
+ @dataclass
37
+ class MutationEntry:
38
+ """Single mutation operation record.
39
+
40
+ Records a mutation for auditing and undo support. The before_state
41
+ contains enough information to reverse the operation.
42
+
43
+ Attributes:
44
+ id: Unique mutation ID (UUID4).
45
+ timestamp: When the mutation occurred.
46
+ operation: Operation type (e.g., "rename_node", "add_edge").
47
+ target_id: Primary target of the mutation.
48
+ before_state: State before mutation (for undo).
49
+ after_state: State after mutation.
50
+ affects_hash: Whether this mutation affects content hash.
51
+ """
52
+
53
+ operation: str
54
+ target_id: str
55
+ before_state: dict[str, Any]
56
+ after_state: dict[str, Any]
57
+ affects_hash: bool = False
58
+ id: str = field(default_factory=lambda: uuid4().hex)
59
+ timestamp: datetime = field(default_factory=datetime.now)
60
+
61
+ def __str__(self) -> str:
62
+ """Human-readable representation."""
63
+ return f"[{self.id[:8]}] {self.operation}({self.target_id})"
64
+
65
+
66
+ class MutationLog:
67
+ """Append-only mutation history.
68
+
69
+ Provides auditing and undo capabilities for graph mutations.
70
+ Entries are stored in chronological order.
71
+
72
+ Example:
73
+ >>> log = MutationLog()
74
+ >>> entry = MutationEntry(
75
+ ... operation="rename_node",
76
+ ... target_id="REQ-p00001",
77
+ ... before_state={"id": "REQ-p00001"},
78
+ ... after_state={"id": "REQ-p00002"},
79
+ ... )
80
+ >>> log.append(entry)
81
+ >>> list(log.iter_entries())
82
+ [MutationEntry(...)]
83
+ """
84
+
85
+ def __init__(self) -> None:
86
+ """Initialize an empty mutation log."""
87
+ self._entries: list[MutationEntry] = []
88
+
89
+ def append(self, entry: MutationEntry) -> None:
90
+ """Append a mutation entry to the log.
91
+
92
+ Args:
93
+ entry: The mutation record to append.
94
+ """
95
+ self._entries.append(entry)
96
+
97
+ def iter_entries(self) -> Iterator[MutationEntry]:
98
+ """Iterate over all entries in chronological order.
99
+
100
+ Yields:
101
+ MutationEntry instances in order of occurrence.
102
+ """
103
+ yield from self._entries
104
+
105
+ def __len__(self) -> int:
106
+ """Return the number of entries in the log."""
107
+ return len(self._entries)
108
+
109
+ def last(self) -> MutationEntry | None:
110
+ """Return the most recent entry, or None if empty."""
111
+ return self._entries[-1] if self._entries else None
112
+
113
+ def find_by_id(self, mutation_id: str) -> MutationEntry | None:
114
+ """Find an entry by its mutation ID.
115
+
116
+ Args:
117
+ mutation_id: The UUID of the mutation to find.
118
+
119
+ Returns:
120
+ The matching MutationEntry, or None if not found.
121
+ """
122
+ for entry in self._entries:
123
+ if entry.id == mutation_id:
124
+ return entry
125
+ return None
126
+
127
+ def entries_since(self, mutation_id: str) -> list[MutationEntry]:
128
+ """Get all entries since (and including) a specific mutation.
129
+
130
+ Useful for batch undo operations.
131
+
132
+ Args:
133
+ mutation_id: The UUID to start from.
134
+
135
+ Returns:
136
+ List of entries from the specified mutation to the most recent.
137
+
138
+ Raises:
139
+ ValueError: If the mutation_id is not found.
140
+ """
141
+ for i, entry in enumerate(self._entries):
142
+ if entry.id == mutation_id:
143
+ return list(self._entries[i:])
144
+ raise ValueError(f"Mutation {mutation_id} not found in log")
145
+
146
+ def pop(self) -> MutationEntry | None:
147
+ """Remove and return the most recent entry.
148
+
149
+ Used internally for undo operations. Does not log the removal.
150
+
151
+ Returns:
152
+ The removed entry, or None if log is empty.
153
+ """
154
+ return self._entries.pop() if self._entries else None
155
+
156
+ def clear(self) -> None:
157
+ """Clear all entries from the log."""
158
+ self._entries.clear()
159
+
160
+
161
+ __all__ = ["BrokenReference", "MutationEntry", "MutationLog"]
@@ -0,0 +1,156 @@
1
+ """MDparser - Line-claiming parser system.
2
+
3
+ This module provides the infrastructure for parsing markdown spec files
4
+ using a priority-based line-claiming system. Parsers are registered with
5
+ priorities, and lines are claimed in priority order.
6
+
7
+ Exports:
8
+ - LineClaimingParser: Protocol for parser implementations
9
+ - ParseContext: Context passed to parsers
10
+ - ParsedContent: Result of parsing a claimed region
11
+ - ParserRegistry: Manages parser registration and orchestration
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Iterator, Protocol, runtime_checkable
18
+
19
+
20
+ @dataclass
21
+ class ParseContext:
22
+ """Context passed to parsers during parsing.
23
+
24
+ Attributes:
25
+ file_path: Path to the file being parsed (relative to repo root).
26
+ config: Configuration dictionary for pattern matching, etc.
27
+ """
28
+
29
+ file_path: str
30
+ config: dict[str, Any] = field(default_factory=dict)
31
+
32
+
33
+ @dataclass
34
+ class ParsedContent:
35
+ """Result of parsing a claimed region.
36
+
37
+ Represents a contiguous block of lines claimed by a parser,
38
+ with the parsed content and metadata.
39
+
40
+ Attributes:
41
+ content_type: Type of content (e.g., "requirement", "comment").
42
+ start_line: First line number (1-indexed).
43
+ end_line: Last line number (1-indexed, inclusive).
44
+ raw_text: Original text of the claimed lines.
45
+ parsed_data: Structured data extracted by the parser.
46
+ """
47
+
48
+ content_type: str
49
+ start_line: int
50
+ end_line: int
51
+ raw_text: str
52
+ parsed_data: dict[str, Any] = field(default_factory=dict)
53
+
54
+ @property
55
+ def line_count(self) -> int:
56
+ """Number of lines in this content block."""
57
+ return self.end_line - self.start_line + 1
58
+
59
+
60
+ @runtime_checkable
61
+ class LineClaimingParser(Protocol):
62
+ """Protocol for line-claiming parsers.
63
+
64
+ Parsers claim lines in priority order (lower = earlier). Each parser
65
+ receives only the unclaimed lines and yields ParsedContent for any
66
+ lines it claims.
67
+ """
68
+
69
+ @property
70
+ def priority(self) -> int:
71
+ """Priority for this parser (lower = earlier)."""
72
+ ...
73
+
74
+ def claim_and_parse(
75
+ self,
76
+ lines: list[tuple[int, str]],
77
+ context: ParseContext,
78
+ ) -> Iterator[ParsedContent]:
79
+ """Claim and parse lines.
80
+
81
+ Args:
82
+ lines: List of (line_number, content) tuples for unclaimed lines.
83
+ context: Parsing context with file info and config.
84
+
85
+ Yields:
86
+ ParsedContent for each claimed region.
87
+ """
88
+ ...
89
+
90
+
91
+ class ParserRegistry:
92
+ """Registry for managing line-claiming parsers.
93
+
94
+ Parsers are registered and then called in priority order during parsing.
95
+ Each parser only sees lines not yet claimed by earlier parsers.
96
+ """
97
+
98
+ def __init__(self) -> None:
99
+ self.parsers: list[LineClaimingParser] = []
100
+
101
+ def register(self, parser: LineClaimingParser) -> None:
102
+ """Register a parser.
103
+
104
+ Args:
105
+ parser: A parser implementing LineClaimingParser protocol.
106
+ """
107
+ self.parsers.append(parser)
108
+
109
+ def get_ordered(self) -> list[LineClaimingParser]:
110
+ """Get parsers sorted by priority (ascending).
111
+
112
+ Returns:
113
+ List of parsers in priority order.
114
+ """
115
+ return sorted(self.parsers, key=lambda p: p.priority)
116
+
117
+ def parse_all(
118
+ self,
119
+ lines: list[tuple[int, str]],
120
+ context: ParseContext,
121
+ ) -> Iterator[ParsedContent]:
122
+ """Parse lines using all registered parsers in priority order.
123
+
124
+ Each parser receives only the lines not claimed by earlier parsers.
125
+
126
+ Args:
127
+ lines: List of (line_number, content) tuples.
128
+ context: Parsing context.
129
+
130
+ Yields:
131
+ ParsedContent from all parsers.
132
+ """
133
+ # Track which line numbers have been claimed
134
+ claimed_lines: set[int] = set()
135
+
136
+ for parser in self.get_ordered():
137
+ # Filter to only unclaimed lines
138
+ unclaimed = [(ln, text) for ln, text in lines if ln not in claimed_lines]
139
+
140
+ if not unclaimed:
141
+ break
142
+
143
+ # Let parser claim and parse
144
+ for content in parser.claim_and_parse(unclaimed, context):
145
+ # Mark lines as claimed
146
+ for ln in range(content.start_line, content.end_line + 1):
147
+ claimed_lines.add(ln)
148
+ yield content
149
+
150
+
151
+ __all__ = [
152
+ "LineClaimingParser",
153
+ "ParseContext",
154
+ "ParsedContent",
155
+ "ParserRegistry",
156
+ ]
@@ -0,0 +1,213 @@
1
+ """CodeParser - Priority 70 parser for code references.
2
+
3
+ Parses code comments containing requirement references.
4
+ Uses the shared reference_config infrastructure for configurable patterns.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any, Iterator
11
+
12
+ from elspais.graph.parsers import ParseContext, ParsedContent
13
+ from elspais.graph.parsers.config_helpers import is_empty_comment
14
+ from elspais.utilities.reference_config import (
15
+ ReferenceConfig,
16
+ ReferenceResolver,
17
+ build_block_header_pattern,
18
+ build_block_ref_pattern,
19
+ build_comment_pattern,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from elspais.utilities.patterns import PatternConfig
24
+
25
+
26
+ class CodeParser:
27
+ """Parser for code reference comments.
28
+
29
+ Priority: 70 (after requirements and journeys)
30
+
31
+ Recognizes comments like:
32
+ - # Implements: REQ-xxx
33
+ - # Validates: REQ-xxx
34
+ - // Implements: REQ-xxx (for JS/TS)
35
+ - // IMPLEMENTS REQUIREMENTS: (multiline block header)
36
+ - // REQ-xxx: Description (multiline block item)
37
+
38
+ Uses configurable patterns from ReferenceConfig for:
39
+ - Comment styles (# // -- etc.)
40
+ - Keywords (Implements, Validates, Tests, etc.)
41
+ - Separator characters (- _ etc.)
42
+ """
43
+
44
+ priority = 70
45
+
46
+ def __init__(
47
+ self,
48
+ pattern_config: PatternConfig | None = None,
49
+ reference_resolver: ReferenceResolver | None = None,
50
+ ) -> None:
51
+ """Initialize CodeParser with optional configuration.
52
+
53
+ Args:
54
+ pattern_config: Configuration for ID structure. If None, uses defaults.
55
+ reference_resolver: Resolver for file-specific reference config. If None,
56
+ uses default ReferenceConfig.
57
+ """
58
+ self._pattern_config = pattern_config
59
+ self._reference_resolver = reference_resolver
60
+
61
+ def _get_pattern_config(self, context: ParseContext) -> PatternConfig:
62
+ """Get pattern config from context or instance.
63
+
64
+ Args:
65
+ context: Parse context that may contain pattern config.
66
+
67
+ Returns:
68
+ PatternConfig to use for parsing.
69
+ """
70
+ # Try instance config first
71
+ if self._pattern_config is not None:
72
+ return self._pattern_config
73
+
74
+ # Try context config
75
+ if "pattern_config" in context.config:
76
+ return context.config["pattern_config"]
77
+
78
+ # Fall back to creating a default
79
+ from elspais.utilities.patterns import PatternConfig
80
+
81
+ return PatternConfig.from_dict(
82
+ {
83
+ "prefix": "REQ",
84
+ "types": {
85
+ "prd": {"id": "p", "name": "PRD"},
86
+ "ops": {"id": "o", "name": "OPS"},
87
+ "dev": {"id": "d", "name": "DEV"},
88
+ },
89
+ "id_format": {"style": "numeric", "digits": 5},
90
+ }
91
+ )
92
+
93
+ def _get_reference_config(
94
+ self, context: ParseContext, pattern_config: PatternConfig
95
+ ) -> ReferenceConfig:
96
+ """Get reference config for the current file.
97
+
98
+ Args:
99
+ context: Parse context with file path.
100
+ pattern_config: Pattern config (unused but available for consistency).
101
+
102
+ Returns:
103
+ ReferenceConfig for this file.
104
+ """
105
+ if self._reference_resolver is not None:
106
+ file_path = Path(context.file_path)
107
+ repo_root = Path(context.config.get("repo_root", "."))
108
+ return self._reference_resolver.resolve(file_path, repo_root)
109
+
110
+ # Try context config
111
+ if "reference_resolver" in context.config:
112
+ resolver: ReferenceResolver = context.config["reference_resolver"]
113
+ file_path = Path(context.file_path)
114
+ repo_root = Path(context.config.get("repo_root", "."))
115
+ return resolver.resolve(file_path, repo_root)
116
+
117
+ # Fall back to default config
118
+ return ReferenceConfig()
119
+
120
+ def claim_and_parse(
121
+ self,
122
+ lines: list[tuple[int, str]],
123
+ context: ParseContext,
124
+ ) -> Iterator[ParsedContent]:
125
+ """Claim and parse code reference comments.
126
+
127
+ Args:
128
+ lines: List of (line_number, content) tuples.
129
+ context: Parsing context.
130
+
131
+ Yields:
132
+ ParsedContent for each code reference.
133
+ """
134
+ # Get configs for this file
135
+ pattern_config = self._get_pattern_config(context)
136
+ ref_config = self._get_reference_config(context, pattern_config)
137
+
138
+ # Build patterns dynamically based on config
139
+ implements_pattern = build_comment_pattern(pattern_config, ref_config, "implements")
140
+ validates_pattern = build_comment_pattern(pattern_config, ref_config, "validates")
141
+ block_header_pattern = build_block_header_pattern(ref_config, "implements")
142
+ block_ref_pattern = build_block_ref_pattern(pattern_config, ref_config)
143
+
144
+ i = 0
145
+ while i < len(lines):
146
+ ln, text = lines[i]
147
+
148
+ # Check for single-line patterns first
149
+ impl_match = implements_pattern.search(text)
150
+ val_match = validates_pattern.search(text)
151
+
152
+ if impl_match or val_match:
153
+ parsed_data: dict[str, Any] = {
154
+ "implements": [],
155
+ "validates": [],
156
+ }
157
+
158
+ if impl_match:
159
+ refs = [r.strip() for r in impl_match.group("refs").split(",")]
160
+ parsed_data["implements"] = refs
161
+
162
+ if val_match:
163
+ refs = [r.strip() for r in val_match.group("refs").split(",")]
164
+ parsed_data["validates"] = refs
165
+
166
+ yield ParsedContent(
167
+ content_type="code_ref",
168
+ start_line=ln,
169
+ end_line=ln,
170
+ raw_text=text,
171
+ parsed_data=parsed_data,
172
+ )
173
+ i += 1
174
+ continue
175
+
176
+ # Check for multiline block header: // IMPLEMENTS REQUIREMENTS:
177
+ if block_header_pattern.search(text):
178
+ refs: list[str] = []
179
+ start_ln = ln
180
+ end_ln = ln
181
+ raw_lines = [text]
182
+ i += 1
183
+
184
+ # Collect REQ references from subsequent comment lines
185
+ while i < len(lines):
186
+ next_ln, next_text = lines[i]
187
+ ref_match = block_ref_pattern.match(next_text)
188
+ if ref_match:
189
+ refs.append(ref_match.group("ref"))
190
+ end_ln = next_ln
191
+ raw_lines.append(next_text)
192
+ i += 1
193
+ elif is_empty_comment(next_text, ref_config.comment_styles):
194
+ # Empty comment line, skip
195
+ i += 1
196
+ else:
197
+ # Non-comment line or different content, stop
198
+ break
199
+
200
+ if refs:
201
+ yield ParsedContent(
202
+ content_type="code_ref",
203
+ start_line=start_ln,
204
+ end_line=end_ln,
205
+ raw_text="\n".join(raw_lines),
206
+ parsed_data={
207
+ "implements": refs,
208
+ "validates": [],
209
+ },
210
+ )
211
+ continue
212
+
213
+ i += 1
@@ -0,0 +1,112 @@
1
+ """CommentsParser - Priority 0 parser for HTML comment blocks.
2
+
3
+ Claims HTML comments (single-line and multi-line) before any other parser
4
+ can process them. This prevents comment content from being interpreted
5
+ as requirements or other content types.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Iterator
12
+
13
+ from elspais.graph.parsers import ParseContext, ParsedContent
14
+
15
+
16
+ class CommentsParser:
17
+ """Parser for HTML comment blocks.
18
+
19
+ Priority: 0 (highest priority, runs first)
20
+
21
+ Handles:
22
+ - Single-line comments: <!-- comment -->
23
+ - Multi-line comments: <!-- ... -->
24
+ """
25
+
26
+ priority = 0
27
+
28
+ # Pattern for single-line comment
29
+ SINGLE_LINE_PATTERN = re.compile(r"<!--.*-->")
30
+
31
+ # Pattern for comment start
32
+ COMMENT_START = re.compile(r"<!--")
33
+
34
+ # Pattern for comment end
35
+ COMMENT_END = re.compile(r"-->")
36
+
37
+ def claim_and_parse(
38
+ self,
39
+ lines: list[tuple[int, str]],
40
+ context: ParseContext,
41
+ ) -> Iterator[ParsedContent]:
42
+ """Claim and parse HTML comment blocks.
43
+
44
+ Args:
45
+ lines: List of (line_number, content) tuples.
46
+ context: Parsing context.
47
+
48
+ Yields:
49
+ ParsedContent for each comment block.
50
+ """
51
+ # Build a dict for quick line lookup
52
+ line_map = dict(lines)
53
+ line_numbers = sorted(line_map.keys())
54
+
55
+ claimed: set[int] = set()
56
+ i = 0
57
+
58
+ while i < len(line_numbers):
59
+ ln = line_numbers[i]
60
+ if ln in claimed:
61
+ i += 1
62
+ continue
63
+
64
+ text = line_map[ln]
65
+
66
+ # Check for single-line comment
67
+ if self.SINGLE_LINE_PATTERN.search(text):
68
+ claimed.add(ln)
69
+ yield ParsedContent(
70
+ content_type="comment",
71
+ start_line=ln,
72
+ end_line=ln,
73
+ raw_text=text,
74
+ parsed_data={"comment_type": "single_line"},
75
+ )
76
+ i += 1
77
+ continue
78
+
79
+ # Check for multi-line comment start
80
+ if self.COMMENT_START.search(text) and not self.COMMENT_END.search(text):
81
+ # Find the closing -->
82
+ start_ln = ln
83
+ end_ln = None
84
+ collected_lines = [text]
85
+
86
+ for j in range(i + 1, len(line_numbers)):
87
+ next_ln = line_numbers[j]
88
+ # Only consider contiguous lines
89
+ if next_ln != line_numbers[j - 1] + 1:
90
+ # Gap in line numbers, can't be same comment
91
+ break
92
+ next_text = line_map[next_ln]
93
+ collected_lines.append(next_text)
94
+ if self.COMMENT_END.search(next_text):
95
+ end_ln = next_ln
96
+ break
97
+
98
+ if end_ln is not None:
99
+ # Found closing, claim all lines
100
+ for claim_ln in range(start_ln, end_ln + 1):
101
+ if claim_ln in line_map:
102
+ claimed.add(claim_ln)
103
+
104
+ yield ParsedContent(
105
+ content_type="comment",
106
+ start_line=start_ln,
107
+ end_line=end_ln,
108
+ raw_text="\n".join(collected_lines),
109
+ parsed_data={"comment_type": "multi_line"},
110
+ )
111
+
112
+ i += 1
@@ -0,0 +1,29 @@
1
+ """Shared helper functions for parsers."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def is_empty_comment(text: str, comment_styles: list[str]) -> bool:
7
+ """Check if a line is an empty comment.
8
+
9
+ An empty comment is a line that starts with a comment marker but has
10
+ no meaningful content after it. This includes decorative comment lines
11
+ like "# ----" or "// ======".
12
+
13
+ Args:
14
+ text: Line text to check.
15
+ comment_styles: List of comment style markers (e.g., ["#", "//"]).
16
+
17
+ Returns:
18
+ True if line is an empty comment.
19
+ """
20
+ stripped = text.strip()
21
+ for style in comment_styles:
22
+ if stripped.startswith(style):
23
+ # Remove the comment marker and check if remainder is empty
24
+ remainder = stripped[len(style) :].strip()
25
+ # Also handle trailing comment markers (for decorative comments)
26
+ remainder = remainder.rstrip("#/-").strip()
27
+ if not remainder:
28
+ return True
29
+ return False