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.
- elspais/__init__.py +2 -11
- elspais/{sponsors/__init__.py → associates.py} +102 -58
- elspais/cli.py +395 -79
- elspais/commands/__init__.py +9 -3
- elspais/commands/analyze.py +121 -173
- elspais/commands/changed.py +15 -30
- elspais/commands/config_cmd.py +13 -16
- elspais/commands/edit.py +60 -44
- elspais/commands/example_cmd.py +319 -0
- elspais/commands/hash_cmd.py +167 -183
- elspais/commands/health.py +1177 -0
- elspais/commands/index.py +98 -114
- elspais/commands/init.py +103 -26
- elspais/commands/reformat_cmd.py +41 -444
- elspais/commands/rules_cmd.py +7 -3
- elspais/commands/trace.py +444 -321
- elspais/commands/validate.py +195 -415
- elspais/config/__init__.py +799 -5
- elspais/{core/content_rules.py → content_rules.py} +20 -3
- elspais/docs/cli/assertions.md +67 -0
- elspais/docs/cli/commands.md +304 -0
- elspais/docs/cli/config.md +262 -0
- elspais/docs/cli/format.md +66 -0
- elspais/docs/cli/git.md +45 -0
- elspais/docs/cli/health.md +190 -0
- elspais/docs/cli/hierarchy.md +60 -0
- elspais/docs/cli/ignore.md +72 -0
- elspais/docs/cli/mcp.md +245 -0
- elspais/docs/cli/quickstart.md +58 -0
- elspais/docs/cli/traceability.md +89 -0
- elspais/docs/cli/validation.md +96 -0
- elspais/graph/GraphNode.py +383 -0
- elspais/graph/__init__.py +40 -0
- elspais/graph/annotators.py +927 -0
- elspais/graph/builder.py +1886 -0
- elspais/graph/deserializer.py +248 -0
- elspais/graph/factory.py +284 -0
- elspais/graph/metrics.py +127 -0
- elspais/graph/mutations.py +161 -0
- elspais/graph/parsers/__init__.py +156 -0
- elspais/graph/parsers/code.py +213 -0
- elspais/graph/parsers/comments.py +112 -0
- elspais/graph/parsers/config_helpers.py +29 -0
- elspais/graph/parsers/heredocs.py +225 -0
- elspais/graph/parsers/journey.py +131 -0
- elspais/graph/parsers/remainder.py +79 -0
- elspais/graph/parsers/requirement.py +347 -0
- elspais/graph/parsers/results/__init__.py +6 -0
- elspais/graph/parsers/results/junit_xml.py +229 -0
- elspais/graph/parsers/results/pytest_json.py +313 -0
- elspais/graph/parsers/test.py +305 -0
- elspais/graph/relations.py +78 -0
- elspais/graph/serialize.py +216 -0
- elspais/html/__init__.py +8 -0
- elspais/html/generator.py +731 -0
- elspais/html/templates/trace_view.html.j2 +2151 -0
- elspais/mcp/__init__.py +47 -29
- elspais/mcp/__main__.py +5 -1
- elspais/mcp/file_mutations.py +138 -0
- elspais/mcp/server.py +2016 -247
- elspais/testing/__init__.py +4 -4
- elspais/testing/config.py +3 -0
- elspais/testing/mapper.py +1 -1
- elspais/testing/result_parser.py +25 -21
- elspais/testing/scanner.py +301 -12
- elspais/utilities/__init__.py +1 -0
- elspais/utilities/docs_loader.py +115 -0
- elspais/utilities/git.py +607 -0
- elspais/{core → utilities}/hasher.py +8 -22
- elspais/utilities/md_renderer.py +189 -0
- elspais/{core → utilities}/patterns.py +58 -57
- elspais/utilities/reference_config.py +626 -0
- elspais/validation/__init__.py +19 -0
- elspais/validation/format.py +264 -0
- {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
- elspais-0.43.5.dist-info/RECORD +80 -0
- elspais/config/defaults.py +0 -173
- elspais/config/loader.py +0 -494
- elspais/core/__init__.py +0 -21
- elspais/core/git.py +0 -352
- elspais/core/models.py +0 -320
- elspais/core/parser.py +0 -640
- elspais/core/rules.py +0 -514
- elspais/mcp/context.py +0 -171
- elspais/mcp/serializers.py +0 -112
- elspais/reformat/__init__.py +0 -50
- elspais/reformat/detector.py +0 -119
- elspais/reformat/hierarchy.py +0 -246
- elspais/reformat/line_breaks.py +0 -220
- elspais/reformat/prompts.py +0 -123
- elspais/reformat/transformer.py +0 -264
- elspais/trace_view/__init__.py +0 -54
- elspais/trace_view/coverage.py +0 -183
- elspais/trace_view/generators/__init__.py +0 -12
- elspais/trace_view/generators/base.py +0 -329
- elspais/trace_view/generators/csv.py +0 -122
- elspais/trace_view/generators/markdown.py +0 -175
- elspais/trace_view/html/__init__.py +0 -31
- elspais/trace_view/html/generator.py +0 -1006
- elspais/trace_view/html/templates/base.html +0 -283
- elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
- elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
- elspais/trace_view/html/templates/components/legend_modal.html +0 -69
- elspais/trace_view/html/templates/components/review_panel.html +0 -118
- elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
- elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
- elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
- elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
- elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
- elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
- elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
- elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
- elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
- elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
- elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
- elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
- elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
- elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
- elspais/trace_view/html/templates/partials/scripts.js +0 -1741
- elspais/trace_view/html/templates/partials/styles.css +0 -1756
- elspais/trace_view/models.py +0 -353
- elspais/trace_view/review/__init__.py +0 -60
- elspais/trace_view/review/branches.py +0 -1149
- elspais/trace_view/review/models.py +0 -1205
- elspais/trace_view/review/position.py +0 -609
- elspais/trace_view/review/server.py +0 -1056
- elspais/trace_view/review/status.py +0 -470
- elspais/trace_view/review/storage.py +0 -1367
- elspais/trace_view/scanning.py +0 -213
- elspais/trace_view/specs/README.md +0 -84
- elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
- elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
- elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
- elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
- elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
- elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
- elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
- elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
- elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
- elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
- elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
- elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
- elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
- elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
- elspais-0.11.1.dist-info/RECORD +0 -101
- {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
- {elspais-0.11.1.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
- {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
|