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,58 @@
|
|
|
1
|
+
# ELSPAIS QUICK START GUIDE
|
|
2
|
+
|
|
3
|
+
**elspais** validates requirements and traces them through code to tests.
|
|
4
|
+
Requirements live as Markdown files in your `spec/` directory.
|
|
5
|
+
|
|
6
|
+
## 1. Initialize Your Project
|
|
7
|
+
|
|
8
|
+
$ elspais init # Creates .elspais.toml
|
|
9
|
+
$ elspais init --template # Also creates example requirement
|
|
10
|
+
|
|
11
|
+
## 2. Write Your First Requirement
|
|
12
|
+
|
|
13
|
+
Create `spec/prd-auth.md`:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
# REQ-p00001: User Authentication
|
|
17
|
+
|
|
18
|
+
**Level**: PRD | **Status**: Active
|
|
19
|
+
|
|
20
|
+
**Purpose:** Enable secure user login.
|
|
21
|
+
|
|
22
|
+
## Assertions
|
|
23
|
+
|
|
24
|
+
A. The system SHALL authenticate users via email and password.
|
|
25
|
+
B. The system SHALL lock accounts after 5 failed login attempts.
|
|
26
|
+
|
|
27
|
+
*End* *User Authentication* | **Hash**: 00000000
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## 3. Validate and Update Hashes
|
|
31
|
+
|
|
32
|
+
$ elspais validate # Check format, links, hierarchy
|
|
33
|
+
$ elspais hash update # Compute content hashes
|
|
34
|
+
|
|
35
|
+
## 4. Create Implementing Requirements
|
|
36
|
+
|
|
37
|
+
DEV requirements implement PRD requirements:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
# REQ-d00001: Password Hashing
|
|
41
|
+
|
|
42
|
+
**Level**: DEV | **Status**: Active | **Implements**: REQ-p00001-A
|
|
43
|
+
|
|
44
|
+
## Assertions
|
|
45
|
+
|
|
46
|
+
A. The system SHALL use bcrypt with cost factor 12.
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 5. Generate Traceability Report
|
|
50
|
+
|
|
51
|
+
$ elspais trace --view # Interactive HTML tree
|
|
52
|
+
$ elspais trace --format html -o trace.html
|
|
53
|
+
|
|
54
|
+
## Next Steps
|
|
55
|
+
|
|
56
|
+
$ elspais docs format # Full format reference
|
|
57
|
+
$ elspais docs hierarchy # Learn about PRD/OPS/DEV
|
|
58
|
+
$ elspais docs all # Complete documentation
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# TRACEABILITY
|
|
2
|
+
|
|
3
|
+
## What is Traceability?
|
|
4
|
+
|
|
5
|
+
Traceability connects requirements to their implementations and tests:
|
|
6
|
+
|
|
7
|
+
**Requirement** -> **Assertion** -> **Code** -> **Test** -> **Result**
|
|
8
|
+
|
|
9
|
+
This answers: "How do we know this requirement is satisfied?"
|
|
10
|
+
|
|
11
|
+
## Generating Reports
|
|
12
|
+
|
|
13
|
+
$ elspais trace --view # Interactive HTML tree
|
|
14
|
+
$ elspais trace --format html # Basic HTML matrix
|
|
15
|
+
$ elspais trace --format csv # Spreadsheet export
|
|
16
|
+
$ elspais trace --graph # Full requirement->code->test graph
|
|
17
|
+
$ elspais trace --graph-json # Export graph as JSON
|
|
18
|
+
|
|
19
|
+
## Command Options
|
|
20
|
+
|
|
21
|
+
`--format {markdown,html,csv,both}` Output format
|
|
22
|
+
`--output PATH` Output file path
|
|
23
|
+
`--view` Interactive HTML traceability tree
|
|
24
|
+
`--embed-content` Embed full markdown in HTML (offline viewing)
|
|
25
|
+
`--graph` Use unified traceability graph
|
|
26
|
+
`--graph-json` Output graph as JSON
|
|
27
|
+
`--report NAME` Report preset (minimal, standard, full)
|
|
28
|
+
`--depth LEVEL` Max depth (requirements, assertions, implementation, full)
|
|
29
|
+
`--mode {core,sponsor,combined}` Report scope
|
|
30
|
+
`--sponsor NAME` Sponsor name for filtered reports
|
|
31
|
+
|
|
32
|
+
## Marking Code as Implementing
|
|
33
|
+
|
|
34
|
+
In Python, JavaScript, Go, etc., use comments:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
# Implements: REQ-d00001-A
|
|
38
|
+
def hash_password(plain: str) -> str:
|
|
39
|
+
...
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or:
|
|
43
|
+
```javascript
|
|
44
|
+
// Implements: REQ-d00001
|
|
45
|
+
function hashPassword(plain) { ... }
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Marking Tests as Validating
|
|
49
|
+
|
|
50
|
+
Reference requirement IDs in test docstrings or names:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
def test_password_uses_bcrypt():
|
|
54
|
+
"""REQ-d00001-A: Verify bcrypt with cost 12"""
|
|
55
|
+
...
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Or in test names:
|
|
59
|
+
```python
|
|
60
|
+
def test_REQ_d00001_A_bcrypt_cost():
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Coverage Indicators
|
|
64
|
+
|
|
65
|
+
In trace view:
|
|
66
|
+
**○** None - No code implements this assertion
|
|
67
|
+
**◐** Partial - Some assertions have implementations
|
|
68
|
+
**●** Full - All assertions have implementations
|
|
69
|
+
**⚡** Failure - Test failures detected
|
|
70
|
+
**◆** Changed - Modified vs main branch
|
|
71
|
+
|
|
72
|
+
## Depth Levels
|
|
73
|
+
|
|
74
|
+
Control how much detail is shown:
|
|
75
|
+
|
|
76
|
+
`--depth 0` or `requirements` Show only requirements
|
|
77
|
+
`--depth 1` or `assertions` Include assertions
|
|
78
|
+
`--depth 2` or `implementation` Include code references
|
|
79
|
+
`--depth full` Unlimited depth (tests, results)
|
|
80
|
+
|
|
81
|
+
## Understanding the Graph
|
|
82
|
+
|
|
83
|
+
$ elspais trace --graph-json > graph.json
|
|
84
|
+
|
|
85
|
+
The graph shows:
|
|
86
|
+
- Requirements and their assertions
|
|
87
|
+
- Which code files implement which assertions
|
|
88
|
+
- Which tests validate which requirements
|
|
89
|
+
- Test pass/fail status from JUnit/pytest results
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# VALIDATION
|
|
2
|
+
|
|
3
|
+
## Running Validation
|
|
4
|
+
|
|
5
|
+
$ elspais validate # Check all rules
|
|
6
|
+
$ elspais validate --fix # Auto-fix what's fixable
|
|
7
|
+
$ elspais validate --fix --dry-run # Preview fixes without applying
|
|
8
|
+
$ elspais validate -v # Verbose output
|
|
9
|
+
|
|
10
|
+
## Command Options
|
|
11
|
+
|
|
12
|
+
`--fix` Auto-fix hashes and formatting issues
|
|
13
|
+
`--dry-run` Preview fixes without modifying files (use with --fix)
|
|
14
|
+
`--skip-rule RULE` Skip validation rules (repeatable)
|
|
15
|
+
`-j, --json` Output requirements as JSON
|
|
16
|
+
|
|
17
|
+
## What Can Be Auto-Fixed
|
|
18
|
+
|
|
19
|
+
The `--fix` flag automatically corrects:
|
|
20
|
+
|
|
21
|
+
**Fixable:**
|
|
22
|
+
|
|
23
|
+
- Missing hash → Computes and inserts from assertion text
|
|
24
|
+
- Stale hash → Recomputes from current content
|
|
25
|
+
- Missing Status field → Adds default "Active"
|
|
26
|
+
|
|
27
|
+
**Not fixable (report only):**
|
|
28
|
+
|
|
29
|
+
- Broken references to non-existent requirements
|
|
30
|
+
- Orphaned requirements (no parent)
|
|
31
|
+
- Hierarchy violations
|
|
32
|
+
|
|
33
|
+
## What Gets Validated
|
|
34
|
+
|
|
35
|
+
**Format** - Header line structure, hash presence
|
|
36
|
+
**Hierarchy** - Implements relationships follow level rules
|
|
37
|
+
**Links** - Referenced requirements exist
|
|
38
|
+
**Hashes** - Content matches stored hash
|
|
39
|
+
**IDs** - No duplicate requirement IDs
|
|
40
|
+
|
|
41
|
+
## Skip Rule Patterns
|
|
42
|
+
|
|
43
|
+
Skip specific validation rules:
|
|
44
|
+
|
|
45
|
+
$ elspais validate --skip-rule hash.missing
|
|
46
|
+
$ elspais validate --skip-rule 'hash.*' # All hash rules
|
|
47
|
+
$ elspais validate --skip-rule hierarchy.* # All hierarchy rules
|
|
48
|
+
|
|
49
|
+
**Available Patterns:**
|
|
50
|
+
|
|
51
|
+
`hash.missing` Hash footer is missing
|
|
52
|
+
`hash.mismatch` Hash doesn't match content
|
|
53
|
+
`hash.*` All hash rules
|
|
54
|
+
`hierarchy.*` All hierarchy rules
|
|
55
|
+
`format.*` All format rules
|
|
56
|
+
|
|
57
|
+
## Common Validation Errors
|
|
58
|
+
|
|
59
|
+
**Missing hash**
|
|
60
|
+
Fix: $ elspais hash update
|
|
61
|
+
|
|
62
|
+
**Stale hash** (content changed)
|
|
63
|
+
Fix: $ elspais hash update after reviewing changes
|
|
64
|
+
|
|
65
|
+
**Broken link** (implements non-existent requirement)
|
|
66
|
+
Fix: Correct the ID or create the missing requirement
|
|
67
|
+
|
|
68
|
+
**Hierarchy violation** (PRD implements DEV)
|
|
69
|
+
Fix: Reverse the relationship or change levels
|
|
70
|
+
|
|
71
|
+
## Suppressing Warnings
|
|
72
|
+
|
|
73
|
+
For expected issues, add inline suppression:
|
|
74
|
+
|
|
75
|
+
```markdown
|
|
76
|
+
# elspais: expected-broken-links 2
|
|
77
|
+
**Implements**: REQ-future-001, REQ-future-002
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## JSON Output
|
|
81
|
+
|
|
82
|
+
For tooling and CI integration:
|
|
83
|
+
|
|
84
|
+
$ elspais validate -j > requirements.json
|
|
85
|
+
|
|
86
|
+
## CI Integration
|
|
87
|
+
|
|
88
|
+
Add to your CI pipeline:
|
|
89
|
+
|
|
90
|
+
```yaml
|
|
91
|
+
# .github/workflows/validate.yml
|
|
92
|
+
steps:
|
|
93
|
+
- uses: actions/checkout@v4
|
|
94
|
+
- run: pip install elspais
|
|
95
|
+
- run: elspais validate
|
|
96
|
+
```
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""GraphNode - Unified node representation for traceability graph.
|
|
2
|
+
|
|
3
|
+
This module provides the core data structures for Architecture 3.0:
|
|
4
|
+
- NodeKind: Enum of node types
|
|
5
|
+
- SourceLocation: Portable file location reference
|
|
6
|
+
- GraphNode: Unified node with typed content and edge management
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import deque
|
|
12
|
+
from dataclasses import InitVar, dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Callable, Iterator
|
|
16
|
+
from uuid import uuid4
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from elspais.graph.relations import Edge, EdgeKind
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NodeKind(Enum):
|
|
23
|
+
"""Types of nodes in the traceability graph."""
|
|
24
|
+
|
|
25
|
+
REQUIREMENT = "requirement"
|
|
26
|
+
ASSERTION = "assertion"
|
|
27
|
+
CODE = "code"
|
|
28
|
+
TEST = "test"
|
|
29
|
+
TEST_RESULT = "result"
|
|
30
|
+
USER_JOURNEY = "journey"
|
|
31
|
+
REMAINDER = "remainder"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class SourceLocation:
|
|
36
|
+
"""Portable reference to a location in a file.
|
|
37
|
+
|
|
38
|
+
Paths are stored relative to the repository root, enabling
|
|
39
|
+
consistent references across different working directories.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
path: str # Relative to repo root
|
|
43
|
+
line: int # 1-based line number
|
|
44
|
+
end_line: int | None = None
|
|
45
|
+
repo: str | None = None # For multi-repo: "CAL", None = core
|
|
46
|
+
|
|
47
|
+
def absolute(self, repo_root: Path) -> Path:
|
|
48
|
+
"""Resolve to absolute path."""
|
|
49
|
+
return repo_root / self.path
|
|
50
|
+
|
|
51
|
+
def __str__(self) -> str:
|
|
52
|
+
"""Return string representation for display."""
|
|
53
|
+
if self.repo:
|
|
54
|
+
return f"{self.repo}:{self.path}:{self.line}"
|
|
55
|
+
return f"{self.path}:{self.line}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class GraphNode:
|
|
60
|
+
"""A node in the traceability graph.
|
|
61
|
+
|
|
62
|
+
GraphNode is the unified representation for all entities in the
|
|
63
|
+
traceability graph. The `kind` field determines the expected
|
|
64
|
+
structure of `content`.
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
id: Unique identifier for this node (mutable via set_id()).
|
|
68
|
+
kind: The type of node (requirement, assertion, etc.).
|
|
69
|
+
source: Where this node is defined in source files.
|
|
70
|
+
uuid: Stable 32-char hex string for GUI/DOM referencing.
|
|
71
|
+
|
|
72
|
+
Use get_label()/set_label() for label access.
|
|
73
|
+
Use set_id() for ID mutations.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
id: str
|
|
77
|
+
kind: NodeKind
|
|
78
|
+
label: InitVar[str] = "" # Constructor parameter, stored in _label
|
|
79
|
+
source: SourceLocation | None = None
|
|
80
|
+
uuid: str = field(default_factory=lambda: uuid4().hex)
|
|
81
|
+
|
|
82
|
+
# Label stored internally - use get_label()/set_label() or label property
|
|
83
|
+
_label: str = field(default="", init=False)
|
|
84
|
+
|
|
85
|
+
# Internal storage (prefixed) - excluded from constructor
|
|
86
|
+
_children: list[GraphNode] = field(default_factory=list, init=False)
|
|
87
|
+
_parents: list[GraphNode] = field(default_factory=list, init=False, repr=False)
|
|
88
|
+
_outgoing_edges: list[Edge] = field(default_factory=list, init=False, repr=False)
|
|
89
|
+
_incoming_edges: list[Edge] = field(default_factory=list, init=False, repr=False)
|
|
90
|
+
_content: dict[str, Any] = field(default_factory=dict, init=False)
|
|
91
|
+
_metrics: dict[str, Any] = field(default_factory=dict, init=False)
|
|
92
|
+
|
|
93
|
+
def __post_init__(self, label: str) -> None:
|
|
94
|
+
"""Initialize internal label from constructor parameter."""
|
|
95
|
+
self._label = label
|
|
96
|
+
|
|
97
|
+
def set_id(self, value: str) -> None:
|
|
98
|
+
"""Set the node's unique identifier.
|
|
99
|
+
|
|
100
|
+
Use this method for mutations - it allows graph updates
|
|
101
|
+
without breaking the dataclass contract.
|
|
102
|
+
|
|
103
|
+
Note: This only changes the node's internal ID. The graph's
|
|
104
|
+
_index must be updated separately by the mutation method.
|
|
105
|
+
"""
|
|
106
|
+
# Dataclass field assignment - directly mutate the id attribute
|
|
107
|
+
object.__setattr__(self, "id", value)
|
|
108
|
+
|
|
109
|
+
# Label accessors - encapsulated for future hooks (e.g., index updates)
|
|
110
|
+
def get_label(self) -> str:
|
|
111
|
+
"""Get the node's display label."""
|
|
112
|
+
return self._label
|
|
113
|
+
|
|
114
|
+
def set_label(self, value: str) -> None:
|
|
115
|
+
"""Set the node's display label.
|
|
116
|
+
|
|
117
|
+
Use this method for mutations - it may trigger graph updates
|
|
118
|
+
in the future (e.g., search index, change tracking).
|
|
119
|
+
"""
|
|
120
|
+
self._label = value
|
|
121
|
+
|
|
122
|
+
# Iterator access
|
|
123
|
+
def iter_children(self) -> Iterator[GraphNode]:
|
|
124
|
+
"""Iterate over child nodes."""
|
|
125
|
+
yield from self._children
|
|
126
|
+
|
|
127
|
+
def iter_parents(self) -> Iterator[GraphNode]:
|
|
128
|
+
"""Iterate over parent nodes."""
|
|
129
|
+
yield from self._parents
|
|
130
|
+
|
|
131
|
+
def iter_outgoing_edges(self) -> Iterator[Edge]:
|
|
132
|
+
"""Iterate over outgoing edges."""
|
|
133
|
+
yield from self._outgoing_edges
|
|
134
|
+
|
|
135
|
+
def iter_incoming_edges(self) -> Iterator[Edge]:
|
|
136
|
+
"""Iterate over incoming edges."""
|
|
137
|
+
yield from self._incoming_edges
|
|
138
|
+
|
|
139
|
+
def iter_edges_by_kind(self, edge_kind: EdgeKind) -> Iterator[Edge]:
|
|
140
|
+
"""Iterate outgoing edges of a specific kind."""
|
|
141
|
+
for e in self._outgoing_edges:
|
|
142
|
+
if e.kind == edge_kind:
|
|
143
|
+
yield e
|
|
144
|
+
|
|
145
|
+
# Count and membership checks (avoid materializing lists)
|
|
146
|
+
def child_count(self) -> int:
|
|
147
|
+
"""Return number of children."""
|
|
148
|
+
return len(self._children)
|
|
149
|
+
|
|
150
|
+
def parent_count(self) -> int:
|
|
151
|
+
"""Return number of parents."""
|
|
152
|
+
return len(self._parents)
|
|
153
|
+
|
|
154
|
+
def has_child(self, node: GraphNode) -> bool:
|
|
155
|
+
"""Check if node is a child."""
|
|
156
|
+
return node in self._children
|
|
157
|
+
|
|
158
|
+
def has_parent(self, node: GraphNode) -> bool:
|
|
159
|
+
"""Check if node is a parent."""
|
|
160
|
+
return node in self._parents
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def is_root(self) -> bool:
|
|
164
|
+
"""True if this node has no parents."""
|
|
165
|
+
return len(self._parents) == 0
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def is_leaf(self) -> bool:
|
|
169
|
+
"""True if this node has no children."""
|
|
170
|
+
return len(self._children) == 0
|
|
171
|
+
|
|
172
|
+
# Field accessors (return single value)
|
|
173
|
+
def get_field(self, key: str, default: Any = None) -> Any:
|
|
174
|
+
"""Get a field from content."""
|
|
175
|
+
return self._content.get(key, default)
|
|
176
|
+
|
|
177
|
+
def set_field(self, key: str, value: Any) -> None:
|
|
178
|
+
"""Set a field in content."""
|
|
179
|
+
self._content[key] = value
|
|
180
|
+
|
|
181
|
+
def get_all_content(self) -> dict[str, Any]:
|
|
182
|
+
"""Get a copy of all content fields.
|
|
183
|
+
|
|
184
|
+
Returns a shallow copy for serialization purposes.
|
|
185
|
+
Prefer get_field() for accessing individual fields.
|
|
186
|
+
"""
|
|
187
|
+
return dict(self._content)
|
|
188
|
+
|
|
189
|
+
def get_metric(self, key: str, default: Any = None) -> Any:
|
|
190
|
+
"""Get a metric value."""
|
|
191
|
+
return self._metrics.get(key, default)
|
|
192
|
+
|
|
193
|
+
def set_metric(self, key: str, value: Any) -> None:
|
|
194
|
+
"""Set a metric value."""
|
|
195
|
+
self._metrics[key] = value
|
|
196
|
+
|
|
197
|
+
# Convenience properties for common fields
|
|
198
|
+
@property
|
|
199
|
+
def level(self) -> str | None:
|
|
200
|
+
"""Get the requirement level (PRD, OPS, DEV)."""
|
|
201
|
+
return self._content.get("level")
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def status(self) -> str | None:
|
|
205
|
+
"""Get the requirement status."""
|
|
206
|
+
return self._content.get("status")
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def hash(self) -> str | None:
|
|
210
|
+
"""Get the content hash."""
|
|
211
|
+
return self._content.get("hash")
|
|
212
|
+
|
|
213
|
+
def add_child(self, child: GraphNode) -> None:
|
|
214
|
+
"""Add a child node with bidirectional linking.
|
|
215
|
+
|
|
216
|
+
This is the simple form without edge type. For typed edges,
|
|
217
|
+
use link() instead.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
child: The child node to add.
|
|
221
|
+
"""
|
|
222
|
+
if child not in self._children:
|
|
223
|
+
self._children.append(child)
|
|
224
|
+
if self not in child._parents:
|
|
225
|
+
child._parents.append(self)
|
|
226
|
+
|
|
227
|
+
def remove_child(self, child: GraphNode) -> bool:
|
|
228
|
+
"""Remove a child node and clean up all links.
|
|
229
|
+
|
|
230
|
+
Removes the bidirectional node link and any edges between
|
|
231
|
+
this node and the child.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
child: The child node to remove.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
True if the child was found and removed, False otherwise.
|
|
238
|
+
"""
|
|
239
|
+
if child not in self._children:
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
# Remove bidirectional node links
|
|
243
|
+
self._children.remove(child)
|
|
244
|
+
if self in child._parents:
|
|
245
|
+
child._parents.remove(self)
|
|
246
|
+
|
|
247
|
+
# Remove outgoing edges to this child
|
|
248
|
+
self._outgoing_edges = [e for e in self._outgoing_edges if e.target is not child]
|
|
249
|
+
|
|
250
|
+
# Remove incoming edges from this parent on the child
|
|
251
|
+
child._incoming_edges = [e for e in child._incoming_edges if e.source is not self]
|
|
252
|
+
|
|
253
|
+
return True
|
|
254
|
+
|
|
255
|
+
def link(
|
|
256
|
+
self,
|
|
257
|
+
child: GraphNode,
|
|
258
|
+
edge_kind: EdgeKind,
|
|
259
|
+
assertion_targets: list[str] | None = None,
|
|
260
|
+
) -> Edge:
|
|
261
|
+
"""Create a typed edge to a child node.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
child: The child node to link.
|
|
265
|
+
edge_kind: The type of relationship.
|
|
266
|
+
assertion_targets: Optional list of assertion labels targeted.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
The created Edge.
|
|
270
|
+
"""
|
|
271
|
+
from elspais.graph.relations import Edge
|
|
272
|
+
|
|
273
|
+
# Create the edge
|
|
274
|
+
edge = Edge(
|
|
275
|
+
source=self,
|
|
276
|
+
target=child,
|
|
277
|
+
kind=edge_kind,
|
|
278
|
+
assertion_targets=assertion_targets or [],
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Add bidirectional node links
|
|
282
|
+
if child not in self._children:
|
|
283
|
+
self._children.append(child)
|
|
284
|
+
if self not in child._parents:
|
|
285
|
+
child._parents.append(self)
|
|
286
|
+
|
|
287
|
+
# Track edges
|
|
288
|
+
self._outgoing_edges.append(edge)
|
|
289
|
+
child._incoming_edges.append(edge)
|
|
290
|
+
|
|
291
|
+
return edge
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def depth(self) -> int:
|
|
295
|
+
"""Calculate depth from root (0 for roots).
|
|
296
|
+
|
|
297
|
+
For DAG structures, returns minimum depth (shortest path to root).
|
|
298
|
+
"""
|
|
299
|
+
if not self._parents:
|
|
300
|
+
return 0
|
|
301
|
+
return 1 + min(p.depth for p in self._parents)
|
|
302
|
+
|
|
303
|
+
def walk(self, order: str = "pre") -> Iterator[GraphNode]:
|
|
304
|
+
"""Iterate over this node and descendants.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
order: Traversal order:
|
|
308
|
+
- "pre": Parent first (depth-first, pre-order)
|
|
309
|
+
- "post": Children first (depth-first, post-order)
|
|
310
|
+
- "level": Breadth-first (level order)
|
|
311
|
+
|
|
312
|
+
Yields:
|
|
313
|
+
GraphNode instances in the specified order.
|
|
314
|
+
"""
|
|
315
|
+
if order == "pre":
|
|
316
|
+
yield from self._walk_preorder()
|
|
317
|
+
elif order == "post":
|
|
318
|
+
yield from self._walk_postorder()
|
|
319
|
+
elif order == "level":
|
|
320
|
+
yield from self._walk_level()
|
|
321
|
+
else:
|
|
322
|
+
raise ValueError(f"Unknown traversal order: {order}")
|
|
323
|
+
|
|
324
|
+
def _walk_preorder(self) -> Iterator[GraphNode]:
|
|
325
|
+
"""Pre-order traversal (parent before children)."""
|
|
326
|
+
yield self
|
|
327
|
+
for child in self._children:
|
|
328
|
+
yield from child._walk_preorder()
|
|
329
|
+
|
|
330
|
+
def _walk_postorder(self) -> Iterator[GraphNode]:
|
|
331
|
+
"""Post-order traversal (children before parent)."""
|
|
332
|
+
for child in self._children:
|
|
333
|
+
yield from child._walk_postorder()
|
|
334
|
+
yield self
|
|
335
|
+
|
|
336
|
+
def _walk_level(self) -> Iterator[GraphNode]:
|
|
337
|
+
"""Level-order (breadth-first) traversal."""
|
|
338
|
+
queue: deque[GraphNode] = deque([self])
|
|
339
|
+
while queue:
|
|
340
|
+
node = queue.popleft()
|
|
341
|
+
yield node
|
|
342
|
+
queue.extend(node._children)
|
|
343
|
+
|
|
344
|
+
def ancestors(self) -> Iterator[GraphNode]:
|
|
345
|
+
"""Iterate up through all ancestor paths (BFS).
|
|
346
|
+
|
|
347
|
+
For DAG structures, visits each unique ancestor once.
|
|
348
|
+
|
|
349
|
+
Yields:
|
|
350
|
+
Ancestor GraphNode instances.
|
|
351
|
+
"""
|
|
352
|
+
visited: set[str] = set()
|
|
353
|
+
queue: deque[GraphNode] = deque(self._parents)
|
|
354
|
+
while queue:
|
|
355
|
+
node = queue.popleft()
|
|
356
|
+
if node.id not in visited:
|
|
357
|
+
visited.add(node.id)
|
|
358
|
+
yield node
|
|
359
|
+
queue.extend(node._parents)
|
|
360
|
+
|
|
361
|
+
def find(self, predicate: Callable[[GraphNode], bool]) -> Iterator[GraphNode]:
|
|
362
|
+
"""Find all descendants matching predicate.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
predicate: Function that returns True for matching nodes.
|
|
366
|
+
|
|
367
|
+
Yields:
|
|
368
|
+
Matching GraphNode instances.
|
|
369
|
+
"""
|
|
370
|
+
for node in self.walk():
|
|
371
|
+
if predicate(node):
|
|
372
|
+
yield node
|
|
373
|
+
|
|
374
|
+
def find_by_kind(self, kind: NodeKind) -> Iterator[GraphNode]:
|
|
375
|
+
"""Find all descendants of a specific kind.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
kind: The NodeKind to filter by.
|
|
379
|
+
|
|
380
|
+
Yields:
|
|
381
|
+
GraphNode instances of the specified kind.
|
|
382
|
+
"""
|
|
383
|
+
return self.find(lambda n: n.kind == kind)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Graph module - Core graph data structures.
|
|
2
|
+
|
|
3
|
+
Exports:
|
|
4
|
+
- NodeKind: Enum of node types
|
|
5
|
+
- SourceLocation: Portable file location reference
|
|
6
|
+
- GraphNode: Unified node representation
|
|
7
|
+
- Edge: Typed edge between nodes
|
|
8
|
+
- EdgeKind: Enum of edge types
|
|
9
|
+
- BrokenReference: Reference to non-existent target (detection)
|
|
10
|
+
- CoverageSource: Enum for coverage origin type
|
|
11
|
+
- CoverageContribution: Single coverage claim on an assertion
|
|
12
|
+
- RollupMetrics: Aggregated coverage metrics for a requirement
|
|
13
|
+
|
|
14
|
+
Note: TraceGraph is in elspais.graph.builder (use graph.factory.build_graph() to construct)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from elspais.graph.annotators import annotate_coverage
|
|
18
|
+
from elspais.graph.GraphNode import (
|
|
19
|
+
GraphNode,
|
|
20
|
+
NodeKind,
|
|
21
|
+
SourceLocation,
|
|
22
|
+
)
|
|
23
|
+
from elspais.graph.metrics import CoverageContribution, CoverageSource, RollupMetrics
|
|
24
|
+
from elspais.graph.mutations import BrokenReference, MutationEntry, MutationLog
|
|
25
|
+
from elspais.graph.relations import Edge, EdgeKind
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"NodeKind",
|
|
29
|
+
"SourceLocation",
|
|
30
|
+
"GraphNode",
|
|
31
|
+
"Edge",
|
|
32
|
+
"EdgeKind",
|
|
33
|
+
"BrokenReference",
|
|
34
|
+
"MutationEntry",
|
|
35
|
+
"MutationLog",
|
|
36
|
+
"CoverageSource",
|
|
37
|
+
"CoverageContribution",
|
|
38
|
+
"RollupMetrics",
|
|
39
|
+
"annotate_coverage",
|
|
40
|
+
]
|