raise-cli 2.2.1__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.
- raise_cli/__init__.py +38 -0
- raise_cli/__main__.py +30 -0
- raise_cli/adapters/__init__.py +91 -0
- raise_cli/adapters/declarative/__init__.py +26 -0
- raise_cli/adapters/declarative/adapter.py +267 -0
- raise_cli/adapters/declarative/discovery.py +94 -0
- raise_cli/adapters/declarative/expressions.py +150 -0
- raise_cli/adapters/declarative/reference/__init__.py +1 -0
- raise_cli/adapters/declarative/reference/github.yaml +143 -0
- raise_cli/adapters/declarative/schema.py +98 -0
- raise_cli/adapters/filesystem.py +299 -0
- raise_cli/adapters/mcp_bridge.py +10 -0
- raise_cli/adapters/mcp_confluence.py +246 -0
- raise_cli/adapters/mcp_jira.py +405 -0
- raise_cli/adapters/models.py +205 -0
- raise_cli/adapters/protocols.py +180 -0
- raise_cli/adapters/registry.py +90 -0
- raise_cli/adapters/sync.py +149 -0
- raise_cli/agents/__init__.py +14 -0
- raise_cli/agents/antigravity.yaml +8 -0
- raise_cli/agents/claude.yaml +8 -0
- raise_cli/agents/copilot.yaml +8 -0
- raise_cli/agents/copilot_plugin.py +124 -0
- raise_cli/agents/cursor.yaml +7 -0
- raise_cli/agents/roo.yaml +8 -0
- raise_cli/agents/windsurf.yaml +8 -0
- raise_cli/artifacts/__init__.py +30 -0
- raise_cli/artifacts/models.py +43 -0
- raise_cli/artifacts/reader.py +55 -0
- raise_cli/artifacts/renderer.py +104 -0
- raise_cli/artifacts/story_design.py +69 -0
- raise_cli/artifacts/writer.py +45 -0
- raise_cli/backlog/__init__.py +1 -0
- raise_cli/backlog/sync.py +115 -0
- raise_cli/cli/__init__.py +3 -0
- raise_cli/cli/commands/__init__.py +3 -0
- raise_cli/cli/commands/_resolve.py +153 -0
- raise_cli/cli/commands/adapters.py +362 -0
- raise_cli/cli/commands/artifact.py +137 -0
- raise_cli/cli/commands/backlog.py +333 -0
- raise_cli/cli/commands/base.py +31 -0
- raise_cli/cli/commands/discover.py +551 -0
- raise_cli/cli/commands/docs.py +130 -0
- raise_cli/cli/commands/doctor.py +177 -0
- raise_cli/cli/commands/gate.py +223 -0
- raise_cli/cli/commands/graph.py +1086 -0
- raise_cli/cli/commands/info.py +81 -0
- raise_cli/cli/commands/init.py +746 -0
- raise_cli/cli/commands/journal.py +167 -0
- raise_cli/cli/commands/mcp.py +524 -0
- raise_cli/cli/commands/memory.py +467 -0
- raise_cli/cli/commands/pattern.py +348 -0
- raise_cli/cli/commands/profile.py +59 -0
- raise_cli/cli/commands/publish.py +80 -0
- raise_cli/cli/commands/release.py +338 -0
- raise_cli/cli/commands/session.py +528 -0
- raise_cli/cli/commands/signal.py +410 -0
- raise_cli/cli/commands/skill.py +350 -0
- raise_cli/cli/commands/skill_set.py +145 -0
- raise_cli/cli/error_handler.py +158 -0
- raise_cli/cli/main.py +163 -0
- raise_cli/compat.py +66 -0
- raise_cli/config/__init__.py +41 -0
- raise_cli/config/agent_plugin.py +105 -0
- raise_cli/config/agent_registry.py +233 -0
- raise_cli/config/agents.py +120 -0
- raise_cli/config/ide.py +32 -0
- raise_cli/config/paths.py +379 -0
- raise_cli/config/settings.py +180 -0
- raise_cli/context/__init__.py +42 -0
- raise_cli/context/analyzers/__init__.py +16 -0
- raise_cli/context/analyzers/models.py +36 -0
- raise_cli/context/analyzers/protocol.py +43 -0
- raise_cli/context/analyzers/python.py +292 -0
- raise_cli/context/builder.py +1569 -0
- raise_cli/context/diff.py +213 -0
- raise_cli/context/extractors/__init__.py +13 -0
- raise_cli/context/extractors/skills.py +121 -0
- raise_cli/core/__init__.py +37 -0
- raise_cli/core/files.py +66 -0
- raise_cli/core/text.py +174 -0
- raise_cli/core/tools.py +441 -0
- raise_cli/discovery/__init__.py +50 -0
- raise_cli/discovery/analyzer.py +691 -0
- raise_cli/discovery/drift.py +355 -0
- raise_cli/discovery/scanner.py +1687 -0
- raise_cli/doctor/__init__.py +4 -0
- raise_cli/doctor/checks/__init__.py +1 -0
- raise_cli/doctor/checks/environment.py +110 -0
- raise_cli/doctor/checks/project.py +238 -0
- raise_cli/doctor/fix.py +80 -0
- raise_cli/doctor/models.py +56 -0
- raise_cli/doctor/protocol.py +43 -0
- raise_cli/doctor/registry.py +100 -0
- raise_cli/doctor/report.py +141 -0
- raise_cli/doctor/runner.py +95 -0
- raise_cli/engines/__init__.py +3 -0
- raise_cli/exceptions.py +215 -0
- raise_cli/gates/__init__.py +19 -0
- raise_cli/gates/builtin/__init__.py +1 -0
- raise_cli/gates/builtin/coverage.py +52 -0
- raise_cli/gates/builtin/lint.py +48 -0
- raise_cli/gates/builtin/tests.py +48 -0
- raise_cli/gates/builtin/types.py +48 -0
- raise_cli/gates/models.py +40 -0
- raise_cli/gates/protocol.py +41 -0
- raise_cli/gates/registry.py +141 -0
- raise_cli/governance/__init__.py +11 -0
- raise_cli/governance/extractor.py +412 -0
- raise_cli/governance/models.py +134 -0
- raise_cli/governance/parsers/__init__.py +35 -0
- raise_cli/governance/parsers/_convert.py +38 -0
- raise_cli/governance/parsers/adr.py +274 -0
- raise_cli/governance/parsers/backlog.py +356 -0
- raise_cli/governance/parsers/constitution.py +119 -0
- raise_cli/governance/parsers/epic.py +323 -0
- raise_cli/governance/parsers/glossary.py +316 -0
- raise_cli/governance/parsers/guardrails.py +345 -0
- raise_cli/governance/parsers/prd.py +112 -0
- raise_cli/governance/parsers/roadmap.py +118 -0
- raise_cli/governance/parsers/vision.py +116 -0
- raise_cli/graph/__init__.py +1 -0
- raise_cli/graph/backends/__init__.py +57 -0
- raise_cli/graph/backends/api.py +137 -0
- raise_cli/graph/backends/dual.py +139 -0
- raise_cli/graph/backends/pending.py +84 -0
- raise_cli/handlers/__init__.py +3 -0
- raise_cli/hooks/__init__.py +54 -0
- raise_cli/hooks/builtin/__init__.py +1 -0
- raise_cli/hooks/builtin/backlog.py +216 -0
- raise_cli/hooks/builtin/gate_bridge.py +83 -0
- raise_cli/hooks/builtin/jira_sync.py +127 -0
- raise_cli/hooks/builtin/memory.py +117 -0
- raise_cli/hooks/builtin/telemetry.py +72 -0
- raise_cli/hooks/emitter.py +184 -0
- raise_cli/hooks/events.py +262 -0
- raise_cli/hooks/protocol.py +38 -0
- raise_cli/hooks/registry.py +117 -0
- raise_cli/mcp/__init__.py +33 -0
- raise_cli/mcp/bridge.py +218 -0
- raise_cli/mcp/models.py +43 -0
- raise_cli/mcp/registry.py +77 -0
- raise_cli/mcp/schema.py +41 -0
- raise_cli/memory/__init__.py +58 -0
- raise_cli/memory/loader.py +247 -0
- raise_cli/memory/migration.py +241 -0
- raise_cli/memory/models.py +169 -0
- raise_cli/memory/writer.py +598 -0
- raise_cli/onboarding/__init__.py +103 -0
- raise_cli/onboarding/bootstrap.py +324 -0
- raise_cli/onboarding/claudemd.py +17 -0
- raise_cli/onboarding/conventions.py +742 -0
- raise_cli/onboarding/detection.py +374 -0
- raise_cli/onboarding/governance.py +443 -0
- raise_cli/onboarding/instructions.py +672 -0
- raise_cli/onboarding/manifest.py +201 -0
- raise_cli/onboarding/memory_md.py +399 -0
- raise_cli/onboarding/migration.py +207 -0
- raise_cli/onboarding/profile.py +624 -0
- raise_cli/onboarding/skill_conflict.py +100 -0
- raise_cli/onboarding/skill_manifest.py +176 -0
- raise_cli/onboarding/skills.py +437 -0
- raise_cli/onboarding/workflows.py +101 -0
- raise_cli/output/__init__.py +28 -0
- raise_cli/output/console.py +394 -0
- raise_cli/output/formatters/__init__.py +9 -0
- raise_cli/output/formatters/adapters.py +135 -0
- raise_cli/output/formatters/discover.py +439 -0
- raise_cli/output/formatters/skill.py +298 -0
- raise_cli/publish/__init__.py +3 -0
- raise_cli/publish/changelog.py +80 -0
- raise_cli/publish/check.py +179 -0
- raise_cli/publish/version.py +172 -0
- raise_cli/rai_base/__init__.py +22 -0
- raise_cli/rai_base/framework/__init__.py +7 -0
- raise_cli/rai_base/framework/methodology.yaml +233 -0
- raise_cli/rai_base/governance/__init__.py +1 -0
- raise_cli/rai_base/governance/architecture/__init__.py +1 -0
- raise_cli/rai_base/governance/architecture/domain-model.md +20 -0
- raise_cli/rai_base/governance/architecture/system-context.md +34 -0
- raise_cli/rai_base/governance/architecture/system-design.md +24 -0
- raise_cli/rai_base/governance/backlog.md +8 -0
- raise_cli/rai_base/governance/guardrails.md +17 -0
- raise_cli/rai_base/governance/prd.md +25 -0
- raise_cli/rai_base/governance/vision.md +16 -0
- raise_cli/rai_base/identity/__init__.py +8 -0
- raise_cli/rai_base/identity/core.md +119 -0
- raise_cli/rai_base/identity/perspective.md +119 -0
- raise_cli/rai_base/memory/__init__.py +7 -0
- raise_cli/rai_base/memory/patterns-base.jsonl +55 -0
- raise_cli/schemas/__init__.py +3 -0
- raise_cli/schemas/journal.py +49 -0
- raise_cli/schemas/session_state.py +117 -0
- raise_cli/session/__init__.py +5 -0
- raise_cli/session/bundle.py +820 -0
- raise_cli/session/close.py +268 -0
- raise_cli/session/journal.py +119 -0
- raise_cli/session/resolver.py +126 -0
- raise_cli/session/state.py +187 -0
- raise_cli/skills/__init__.py +44 -0
- raise_cli/skills/locator.py +141 -0
- raise_cli/skills/name_checker.py +199 -0
- raise_cli/skills/parser.py +145 -0
- raise_cli/skills/scaffold.py +212 -0
- raise_cli/skills/schema.py +132 -0
- raise_cli/skills/skillsets.py +195 -0
- raise_cli/skills/validator.py +197 -0
- raise_cli/skills_base/__init__.py +80 -0
- raise_cli/skills_base/contract-template.md +60 -0
- raise_cli/skills_base/preamble.md +37 -0
- raise_cli/skills_base/rai-architecture-review/SKILL.md +137 -0
- raise_cli/skills_base/rai-debug/SKILL.md +171 -0
- raise_cli/skills_base/rai-discover/SKILL.md +167 -0
- raise_cli/skills_base/rai-discover-document/SKILL.md +128 -0
- raise_cli/skills_base/rai-discover-scan/SKILL.md +147 -0
- raise_cli/skills_base/rai-discover-start/SKILL.md +145 -0
- raise_cli/skills_base/rai-discover-validate/SKILL.md +142 -0
- raise_cli/skills_base/rai-docs-update/SKILL.md +142 -0
- raise_cli/skills_base/rai-doctor/SKILL.md +120 -0
- raise_cli/skills_base/rai-epic-close/SKILL.md +165 -0
- raise_cli/skills_base/rai-epic-close/templates/retrospective.md +68 -0
- raise_cli/skills_base/rai-epic-design/SKILL.md +146 -0
- raise_cli/skills_base/rai-epic-design/templates/design.md +24 -0
- raise_cli/skills_base/rai-epic-design/templates/scope.md +76 -0
- raise_cli/skills_base/rai-epic-plan/SKILL.md +153 -0
- raise_cli/skills_base/rai-epic-plan/_references/sequencing-strategies.md +67 -0
- raise_cli/skills_base/rai-epic-plan/templates/plan-section.md +49 -0
- raise_cli/skills_base/rai-epic-run/SKILL.md +208 -0
- raise_cli/skills_base/rai-epic-start/SKILL.md +136 -0
- raise_cli/skills_base/rai-epic-start/templates/brief.md +34 -0
- raise_cli/skills_base/rai-mcp-add/SKILL.md +176 -0
- raise_cli/skills_base/rai-mcp-remove/SKILL.md +120 -0
- raise_cli/skills_base/rai-mcp-status/SKILL.md +147 -0
- raise_cli/skills_base/rai-problem-shape/SKILL.md +138 -0
- raise_cli/skills_base/rai-project-create/SKILL.md +144 -0
- raise_cli/skills_base/rai-project-onboard/SKILL.md +162 -0
- raise_cli/skills_base/rai-quality-review/SKILL.md +189 -0
- raise_cli/skills_base/rai-research/SKILL.md +143 -0
- raise_cli/skills_base/rai-research/references/research-prompt-template.md +317 -0
- raise_cli/skills_base/rai-session-close/SKILL.md +176 -0
- raise_cli/skills_base/rai-session-start/SKILL.md +110 -0
- raise_cli/skills_base/rai-story-close/SKILL.md +198 -0
- raise_cli/skills_base/rai-story-design/SKILL.md +203 -0
- raise_cli/skills_base/rai-story-design/references/tech-design-story-v2.md +293 -0
- raise_cli/skills_base/rai-story-implement/SKILL.md +115 -0
- raise_cli/skills_base/rai-story-plan/SKILL.md +135 -0
- raise_cli/skills_base/rai-story-review/SKILL.md +178 -0
- raise_cli/skills_base/rai-story-run/SKILL.md +282 -0
- raise_cli/skills_base/rai-story-start/SKILL.md +166 -0
- raise_cli/skills_base/rai-story-start/templates/story.md +38 -0
- raise_cli/skills_base/rai-welcome/SKILL.md +134 -0
- raise_cli/telemetry/__init__.py +42 -0
- raise_cli/telemetry/schemas.py +285 -0
- raise_cli/telemetry/writer.py +217 -0
- raise_cli/tier/__init__.py +0 -0
- raise_cli/tier/context.py +134 -0
- raise_cli/viz/__init__.py +7 -0
- raise_cli/viz/generator.py +406 -0
- raise_cli-2.2.1.dist-info/METADATA +433 -0
- raise_cli-2.2.1.dist-info/RECORD +264 -0
- raise_cli-2.2.1.dist-info/WHEEL +4 -0
- raise_cli-2.2.1.dist-info/entry_points.txt +40 -0
- raise_cli-2.2.1.dist-info/licenses/LICENSE +190 -0
- raise_cli-2.2.1.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,1569 @@
|
|
|
1
|
+
"""Unified graph builder for context integration.
|
|
2
|
+
|
|
3
|
+
This module provides the GraphBuilder class that merges governance,
|
|
4
|
+
memory, work, and skills into a single Graph for context queries.
|
|
5
|
+
|
|
6
|
+
Architecture: ADR-019 Unified Context Graph Architecture
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
16
|
+
|
|
17
|
+
from raise_cli.compat import portable_path
|
|
18
|
+
from raise_cli.config.agents import AgentConfig, get_agent_config
|
|
19
|
+
from raise_cli.config.paths import get_global_rai_dir, get_memory_dir, get_personal_dir
|
|
20
|
+
from raise_cli.context.extractors.skills import extract_all_skills
|
|
21
|
+
from raise_cli.core.text import STOPWORDS
|
|
22
|
+
from raise_cli.memory.models import MemoryScope
|
|
23
|
+
from raise_core.graph.engine import Graph
|
|
24
|
+
from raise_core.graph.models import GraphEdge, GraphNode
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from raise_cli.governance.extractor import GovernanceExtractor
|
|
28
|
+
from raise_cli.governance.models import Concept
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GraphBuilder:
|
|
32
|
+
"""Builds unified context graph from all sources.
|
|
33
|
+
|
|
34
|
+
Merges governance documents, memory JSONL files, work tracking,
|
|
35
|
+
and skill metadata into a single queryable graph.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
project_root: Root directory for the project.
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
>>> builder = GraphBuilder(Path("."))
|
|
42
|
+
>>> graph = builder.build()
|
|
43
|
+
>>> graph.node_count
|
|
44
|
+
50
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
project_root: Path | None = None,
|
|
50
|
+
*,
|
|
51
|
+
agent_config: AgentConfig | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Initialize builder with project root.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
project_root: Root directory for the project. Defaults to cwd.
|
|
57
|
+
agent_config: Agent configuration. Defaults to Claude.
|
|
58
|
+
"""
|
|
59
|
+
self.project_root = project_root or Path.cwd()
|
|
60
|
+
self.ide_config = agent_config or get_agent_config()
|
|
61
|
+
|
|
62
|
+
def build(self) -> Graph:
|
|
63
|
+
"""Build unified graph from all sources.
|
|
64
|
+
|
|
65
|
+
Loads concepts from governance, memory, work, skills, and components,
|
|
66
|
+
then builds a Graph with all nodes. After all nodes are loaded,
|
|
67
|
+
extracts structural nodes (bounded contexts, layers) and their edges.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Graph containing all concepts.
|
|
71
|
+
"""
|
|
72
|
+
graph = Graph()
|
|
73
|
+
|
|
74
|
+
# Load all sources
|
|
75
|
+
all_nodes: list[GraphNode] = []
|
|
76
|
+
all_nodes.extend(self.load_governance())
|
|
77
|
+
all_nodes.extend(self.load_memory())
|
|
78
|
+
all_nodes.extend(self.load_skills())
|
|
79
|
+
all_nodes.extend(self.load_components())
|
|
80
|
+
all_nodes.extend(self.load_artifacts())
|
|
81
|
+
all_nodes.extend(self.load_architecture())
|
|
82
|
+
all_nodes.extend(self.load_identity())
|
|
83
|
+
|
|
84
|
+
# Enrich module nodes with real code analysis (S16.1)
|
|
85
|
+
# Must run before add_concept() so graph gets enriched copies
|
|
86
|
+
self.load_code_structure(all_nodes)
|
|
87
|
+
|
|
88
|
+
# Warn on duplicate node IDs before adding (silent overwrites lose data)
|
|
89
|
+
seen_ids: dict[str, str] = {}
|
|
90
|
+
for node in all_nodes:
|
|
91
|
+
if node.id in seen_ids:
|
|
92
|
+
logging.warning(
|
|
93
|
+
"Duplicate node ID '%s' — '%s' will overwrite '%s'",
|
|
94
|
+
node.id,
|
|
95
|
+
node.source_file or "unknown",
|
|
96
|
+
seen_ids[node.id],
|
|
97
|
+
)
|
|
98
|
+
seen_ids[node.id] = node.source_file or "unknown"
|
|
99
|
+
|
|
100
|
+
# Add nodes to graph
|
|
101
|
+
for node in all_nodes:
|
|
102
|
+
graph.add_concept(node)
|
|
103
|
+
|
|
104
|
+
# Extract structural nodes and edges (E15 — bounded contexts, layers)
|
|
105
|
+
# Runs after all nodes loaded so module nodes exist for edge safety
|
|
106
|
+
node_by_id: dict[str, GraphNode] = {n.id: n for n in all_nodes}
|
|
107
|
+
structural_nodes: list[GraphNode] = []
|
|
108
|
+
structural_edges: list[GraphEdge] = []
|
|
109
|
+
|
|
110
|
+
bc_nodes, bc_edges = self._extract_bounded_contexts(all_nodes, node_by_id)
|
|
111
|
+
structural_nodes.extend(bc_nodes)
|
|
112
|
+
structural_edges.extend(bc_edges)
|
|
113
|
+
|
|
114
|
+
lyr_nodes, lyr_edges = self._extract_layers(all_nodes, node_by_id)
|
|
115
|
+
structural_nodes.extend(lyr_nodes)
|
|
116
|
+
structural_edges.extend(lyr_edges)
|
|
117
|
+
|
|
118
|
+
for node in structural_nodes:
|
|
119
|
+
graph.add_concept(node)
|
|
120
|
+
all_nodes.extend(structural_nodes)
|
|
121
|
+
|
|
122
|
+
# Update node_by_id with structural nodes for constraint edge safety
|
|
123
|
+
node_by_id.update({n.id: n for n in structural_nodes})
|
|
124
|
+
|
|
125
|
+
# Extract constraint edges (S15.3 — guardrail → BC/layer)
|
|
126
|
+
constraint_edges = self._extract_constraints(all_nodes, node_by_id)
|
|
127
|
+
structural_edges.extend(constraint_edges)
|
|
128
|
+
|
|
129
|
+
# Infer and add relationships
|
|
130
|
+
edges = self.infer_relationships(all_nodes)
|
|
131
|
+
for edge in edges:
|
|
132
|
+
graph.add_relationship(edge)
|
|
133
|
+
|
|
134
|
+
# Add structural edges (explicit, not inferred)
|
|
135
|
+
for edge in structural_edges:
|
|
136
|
+
graph.add_relationship(edge)
|
|
137
|
+
|
|
138
|
+
return graph
|
|
139
|
+
|
|
140
|
+
def load_governance(self) -> list[GraphNode]:
|
|
141
|
+
"""Load concepts from governance documents.
|
|
142
|
+
|
|
143
|
+
Uses GovernanceExtractor with registry-discovered parsers.
|
|
144
|
+
extract_all() returns list[GraphNode] directly — no conversion needed.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
List of GraphNode for governance concepts.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
extractor = self._get_governance_extractor()
|
|
151
|
+
return extractor.extract_all()
|
|
152
|
+
except Exception:
|
|
153
|
+
# Graceful degradation if governance extraction fails
|
|
154
|
+
return []
|
|
155
|
+
|
|
156
|
+
def load_memory(self) -> list[GraphNode]:
|
|
157
|
+
"""Load concepts from memory JSONL files across all tiers.
|
|
158
|
+
|
|
159
|
+
Loads from three directories with scope tracking:
|
|
160
|
+
- Global (~/.rai/): Universal patterns and calibration
|
|
161
|
+
- Project (.raise/rai/memory/): Shared project patterns
|
|
162
|
+
- Personal (.raise/rai/personal/): Developer-specific data
|
|
163
|
+
|
|
164
|
+
Sessions are only loaded from personal directory (developer-specific).
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
List of GraphNode for memory concepts with scope metadata.
|
|
168
|
+
"""
|
|
169
|
+
nodes: list[GraphNode] = []
|
|
170
|
+
|
|
171
|
+
# 1. Load from global directory (~/.rai/)
|
|
172
|
+
global_dir = get_global_rai_dir()
|
|
173
|
+
if global_dir.exists():
|
|
174
|
+
nodes.extend(
|
|
175
|
+
self._load_memory_from_dir(
|
|
176
|
+
global_dir, MemoryScope.GLOBAL, sessions=False
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# 2. Load from project directory (.raise/rai/memory/)
|
|
181
|
+
project_dir = get_memory_dir(self.project_root)
|
|
182
|
+
if project_dir.exists():
|
|
183
|
+
nodes.extend(
|
|
184
|
+
self._load_memory_from_dir(
|
|
185
|
+
project_dir, MemoryScope.PROJECT, sessions=False
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# 3. Load from personal directory (.raise/rai/personal/)
|
|
190
|
+
personal_dir = get_personal_dir(self.project_root)
|
|
191
|
+
if personal_dir.exists():
|
|
192
|
+
nodes.extend(
|
|
193
|
+
self._load_memory_from_dir(
|
|
194
|
+
personal_dir, MemoryScope.PERSONAL, sessions=True
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Apply precedence: personal > project > global
|
|
199
|
+
return self._deduplicate_by_precedence(nodes)
|
|
200
|
+
|
|
201
|
+
def _load_memory_from_dir(
|
|
202
|
+
self,
|
|
203
|
+
memory_dir: Path,
|
|
204
|
+
scope: MemoryScope,
|
|
205
|
+
sessions: bool = True,
|
|
206
|
+
) -> list[GraphNode]:
|
|
207
|
+
"""Load memory concepts from a single directory with scope.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
memory_dir: Directory containing JSONL files.
|
|
211
|
+
scope: Scope to assign to loaded concepts.
|
|
212
|
+
sessions: Whether to load sessions from this directory.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
List of GraphNode with scope in metadata.
|
|
216
|
+
"""
|
|
217
|
+
nodes: list[GraphNode] = []
|
|
218
|
+
|
|
219
|
+
# Load patterns
|
|
220
|
+
patterns_file = memory_dir / "patterns.jsonl"
|
|
221
|
+
if patterns_file.exists():
|
|
222
|
+
nodes.extend(self._load_jsonl(patterns_file, "pattern", scope))
|
|
223
|
+
|
|
224
|
+
# Load calibration
|
|
225
|
+
calibration_file = memory_dir / "calibration.jsonl"
|
|
226
|
+
if calibration_file.exists():
|
|
227
|
+
nodes.extend(self._load_jsonl(calibration_file, "calibration", scope))
|
|
228
|
+
|
|
229
|
+
# Load sessions (only if requested)
|
|
230
|
+
if sessions:
|
|
231
|
+
sessions_file = memory_dir / "sessions" / "index.jsonl"
|
|
232
|
+
if sessions_file.exists():
|
|
233
|
+
nodes.extend(self._load_jsonl(sessions_file, "session", scope))
|
|
234
|
+
|
|
235
|
+
return nodes
|
|
236
|
+
|
|
237
|
+
def _deduplicate_by_precedence(self, nodes: list[GraphNode]) -> list[GraphNode]:
|
|
238
|
+
"""Deduplicate nodes by ID using scope precedence.
|
|
239
|
+
|
|
240
|
+
When the same ID appears in multiple tiers, keep only the
|
|
241
|
+
highest-precedence version: personal > project > global.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
nodes: List of nodes potentially with duplicate IDs.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Deduplicated list with highest-precedence version of each ID.
|
|
248
|
+
"""
|
|
249
|
+
# Precedence order: higher number = higher priority
|
|
250
|
+
scope_priority = {
|
|
251
|
+
MemoryScope.GLOBAL.value: 1,
|
|
252
|
+
MemoryScope.PROJECT.value: 2,
|
|
253
|
+
MemoryScope.PERSONAL.value: 3,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
# Track best node for each ID
|
|
257
|
+
best_by_id: dict[str, GraphNode] = {}
|
|
258
|
+
|
|
259
|
+
for node in nodes:
|
|
260
|
+
node_scope = node.metadata.get("scope", MemoryScope.PROJECT.value)
|
|
261
|
+
node_priority = scope_priority.get(node_scope, 0)
|
|
262
|
+
|
|
263
|
+
if node.id not in best_by_id:
|
|
264
|
+
best_by_id[node.id] = node
|
|
265
|
+
else:
|
|
266
|
+
existing_scope = best_by_id[node.id].metadata.get(
|
|
267
|
+
"scope", MemoryScope.PROJECT.value
|
|
268
|
+
)
|
|
269
|
+
existing_priority = scope_priority.get(existing_scope, 0)
|
|
270
|
+
|
|
271
|
+
if node_priority > existing_priority:
|
|
272
|
+
best_by_id[node.id] = node
|
|
273
|
+
|
|
274
|
+
return list(best_by_id.values())
|
|
275
|
+
|
|
276
|
+
def load_skills(self) -> list[GraphNode]:
|
|
277
|
+
"""Load concepts from skill YAML frontmatter.
|
|
278
|
+
|
|
279
|
+
Parses SKILL.md files in the IDE's skill directory.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
List of GraphNode for skill concepts.
|
|
283
|
+
"""
|
|
284
|
+
raw_skills_dir = self.ide_config.skills_dir or ".claude/skills"
|
|
285
|
+
skills_dir = self.project_root / raw_skills_dir
|
|
286
|
+
return extract_all_skills(skills_dir)
|
|
287
|
+
|
|
288
|
+
def load_components(self) -> list[GraphNode]:
|
|
289
|
+
"""Load discovered components from validated JSON.
|
|
290
|
+
|
|
291
|
+
Reads components-validated.json from work/discovery directory.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
List of GraphNode for component concepts.
|
|
295
|
+
"""
|
|
296
|
+
validated_file = (
|
|
297
|
+
self.project_root / "work" / "discovery" / "components-validated.json"
|
|
298
|
+
)
|
|
299
|
+
if not validated_file.exists():
|
|
300
|
+
return []
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
raw: Any = json.loads(
|
|
304
|
+
validated_file.read_text(encoding="utf-8")
|
|
305
|
+
)
|
|
306
|
+
# Accept both {"components": [...]} wrapper and bare [...] array
|
|
307
|
+
if isinstance(raw, list):
|
|
308
|
+
components_list: list[dict[str, Any]] = raw # type: ignore[assignment]
|
|
309
|
+
else:
|
|
310
|
+
components_list = raw.get("components", [])
|
|
311
|
+
|
|
312
|
+
nodes: list[GraphNode] = []
|
|
313
|
+
for comp in components_list:
|
|
314
|
+
node = GraphNode(
|
|
315
|
+
id=comp.get("id", ""),
|
|
316
|
+
type="component",
|
|
317
|
+
content=comp.get("content", ""),
|
|
318
|
+
source_file=comp.get("source_file"),
|
|
319
|
+
created=comp.get("created", datetime.now(tz=UTC).isoformat()),
|
|
320
|
+
metadata=comp.get("metadata", {}),
|
|
321
|
+
)
|
|
322
|
+
nodes.append(node)
|
|
323
|
+
|
|
324
|
+
return nodes
|
|
325
|
+
except (json.JSONDecodeError, KeyError):
|
|
326
|
+
return []
|
|
327
|
+
|
|
328
|
+
def load_artifacts(self) -> list[GraphNode]:
|
|
329
|
+
"""Load typed skill artifacts from ``.raise/artifacts/``.
|
|
330
|
+
|
|
331
|
+
Each artifact becomes a GraphNode with type ``artifact``.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
List of GraphNode for artifact concepts.
|
|
335
|
+
"""
|
|
336
|
+
from raise_cli.artifacts.reader import read_all_artifacts
|
|
337
|
+
from raise_cli.artifacts.writer import (
|
|
338
|
+
_artifact_filename, # pyright: ignore[reportPrivateUsage]
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
artifacts_dir = self.project_root / ".raise" / "artifacts"
|
|
342
|
+
artifacts = read_all_artifacts(artifacts_dir)
|
|
343
|
+
|
|
344
|
+
nodes: list[GraphNode] = []
|
|
345
|
+
for artifact in artifacts:
|
|
346
|
+
work_id = (artifact.story or artifact.epic or "unknown").lower()
|
|
347
|
+
node_id = f"artifact-{work_id}-{artifact.artifact_type.value}"
|
|
348
|
+
|
|
349
|
+
# Extract summary from content (typed or dict)
|
|
350
|
+
if isinstance(artifact.content, dict): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
351
|
+
summary = artifact.content.get("summary", str(artifact.content))
|
|
352
|
+
else:
|
|
353
|
+
summary = getattr(artifact.content, "summary", str(artifact.content))
|
|
354
|
+
|
|
355
|
+
filename = _artifact_filename(artifact)
|
|
356
|
+
|
|
357
|
+
node = GraphNode(
|
|
358
|
+
id=node_id,
|
|
359
|
+
type="artifact",
|
|
360
|
+
content=summary,
|
|
361
|
+
source_file=f".raise/artifacts/{filename}",
|
|
362
|
+
created=artifact.created.isoformat(),
|
|
363
|
+
metadata={
|
|
364
|
+
"artifact_type": artifact.artifact_type.value,
|
|
365
|
+
"skill": artifact.skill,
|
|
366
|
+
"story": artifact.story,
|
|
367
|
+
"epic": artifact.epic,
|
|
368
|
+
"version": artifact.version,
|
|
369
|
+
},
|
|
370
|
+
)
|
|
371
|
+
nodes.append(node)
|
|
372
|
+
|
|
373
|
+
return nodes
|
|
374
|
+
|
|
375
|
+
def load_architecture(self) -> list[GraphNode]:
|
|
376
|
+
"""Load architecture nodes from documentation.
|
|
377
|
+
|
|
378
|
+
Scans both governance/architecture/*.md (architecture docs) and
|
|
379
|
+
governance/architecture/modules/*.md (module docs). Type-dispatches
|
|
380
|
+
by frontmatter ``type`` field.
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
List of GraphNode for architecture and module concepts.
|
|
384
|
+
"""
|
|
385
|
+
arch_dir = self.project_root / "governance" / "architecture"
|
|
386
|
+
if not arch_dir.exists():
|
|
387
|
+
return []
|
|
388
|
+
|
|
389
|
+
nodes: list[GraphNode] = []
|
|
390
|
+
|
|
391
|
+
# Scan parent directory for architecture docs
|
|
392
|
+
for md_file in sorted(arch_dir.glob("*.md")):
|
|
393
|
+
node = self._parse_architecture_doc(md_file)
|
|
394
|
+
if node:
|
|
395
|
+
nodes.append(node)
|
|
396
|
+
|
|
397
|
+
# Scan modules subdirectory for module docs
|
|
398
|
+
modules_dir = arch_dir / "modules"
|
|
399
|
+
if modules_dir.exists():
|
|
400
|
+
for md_file in sorted(modules_dir.glob("*.md")):
|
|
401
|
+
node = self._parse_architecture_doc(md_file)
|
|
402
|
+
if node:
|
|
403
|
+
nodes.append(node)
|
|
404
|
+
|
|
405
|
+
return nodes
|
|
406
|
+
|
|
407
|
+
def load_identity(self) -> list[GraphNode]:
|
|
408
|
+
"""Load Rai identity values and boundaries from core.md.
|
|
409
|
+
|
|
410
|
+
Extracts values (### N. Title) and boundaries (### I Will / ### I Won't)
|
|
411
|
+
as principle nodes tagged with always_on=True.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
List of GraphNode for identity concepts.
|
|
415
|
+
"""
|
|
416
|
+
identity_file = self.project_root / ".raise" / "rai" / "identity" / "core.md"
|
|
417
|
+
if not identity_file.exists():
|
|
418
|
+
return []
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
text = identity_file.read_text(encoding="utf-8")
|
|
422
|
+
except OSError:
|
|
423
|
+
return []
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
source_file = portable_path(identity_file, self.project_root)
|
|
427
|
+
except ValueError:
|
|
428
|
+
source_file = str(identity_file)
|
|
429
|
+
|
|
430
|
+
now = datetime.now(tz=UTC).isoformat()
|
|
431
|
+
nodes: list[GraphNode] = []
|
|
432
|
+
|
|
433
|
+
nodes.extend(self._extract_identity_values(text, source_file, now))
|
|
434
|
+
nodes.extend(self._extract_identity_boundaries(text, source_file, now))
|
|
435
|
+
|
|
436
|
+
return nodes
|
|
437
|
+
|
|
438
|
+
def load_code_structure(self, all_nodes: list[GraphNode]) -> None:
|
|
439
|
+
"""Enrich module nodes with real code analysis data.
|
|
440
|
+
|
|
441
|
+
Runs detected analyzers against the project source and merges
|
|
442
|
+
results into existing mod-* node metadata under code_* keys.
|
|
443
|
+
Does not create new nodes — only enriches existing ones.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
all_nodes: All nodes loaded so far (mutated in place).
|
|
447
|
+
"""
|
|
448
|
+
from raise_cli.context.analyzers.models import ModuleInfo
|
|
449
|
+
from raise_cli.context.analyzers.python import PythonAnalyzer
|
|
450
|
+
|
|
451
|
+
analyzers = [PythonAnalyzer(src_dir="src/raise_cli")]
|
|
452
|
+
|
|
453
|
+
code_modules: list[ModuleInfo] = []
|
|
454
|
+
for analyzer in analyzers:
|
|
455
|
+
if analyzer.detect(self.project_root):
|
|
456
|
+
code_modules.extend(analyzer.analyze_modules(self.project_root))
|
|
457
|
+
|
|
458
|
+
if not code_modules:
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
# Build lookup: module name → ModuleInfo
|
|
462
|
+
code_by_name: dict[str, ModuleInfo] = {m.name: m for m in code_modules}
|
|
463
|
+
|
|
464
|
+
# Enrich existing mod-* nodes
|
|
465
|
+
for node in all_nodes:
|
|
466
|
+
if node.type != "module":
|
|
467
|
+
continue
|
|
468
|
+
|
|
469
|
+
# Extract module name from node ID (mod-<name> → <name>)
|
|
470
|
+
mod_name = node.id.removeprefix("mod-")
|
|
471
|
+
info = code_by_name.get(mod_name)
|
|
472
|
+
if info is None:
|
|
473
|
+
continue
|
|
474
|
+
|
|
475
|
+
node.metadata["code_imports"] = info.imports
|
|
476
|
+
node.metadata["code_exports"] = info.exports
|
|
477
|
+
node.metadata["code_components"] = info.component_count
|
|
478
|
+
|
|
479
|
+
def _extract_identity_values(
|
|
480
|
+
self, text: str, source_file: str, now: str
|
|
481
|
+
) -> list[GraphNode]:
|
|
482
|
+
"""Extract values from identity core.md.
|
|
483
|
+
|
|
484
|
+
Matches ### N. Title patterns under ## Values section.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
text: Full file content.
|
|
488
|
+
source_file: Relative source path.
|
|
489
|
+
now: ISO timestamp.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
List of value GraphNodes.
|
|
493
|
+
"""
|
|
494
|
+
import re
|
|
495
|
+
|
|
496
|
+
nodes: list[GraphNode] = []
|
|
497
|
+
|
|
498
|
+
# Find values section
|
|
499
|
+
values_match = re.search(r"^## Values\b", text, re.MULTILINE)
|
|
500
|
+
if not values_match:
|
|
501
|
+
return nodes
|
|
502
|
+
|
|
503
|
+
# Find end of values section (next ## heading or EOF)
|
|
504
|
+
next_section = re.search(r"^## ", text[values_match.end() :], re.MULTILINE)
|
|
505
|
+
values_end = (
|
|
506
|
+
values_match.end() + next_section.start() if next_section else len(text)
|
|
507
|
+
)
|
|
508
|
+
values_text = text[values_match.end() : values_end]
|
|
509
|
+
|
|
510
|
+
# Match ### N. Title patterns
|
|
511
|
+
value_pattern = re.compile(r"^### (\d+)\.\s+(.+)$", re.MULTILINE)
|
|
512
|
+
matches = list(value_pattern.finditer(values_text))
|
|
513
|
+
|
|
514
|
+
for i, match in enumerate(matches):
|
|
515
|
+
num = match.group(1)
|
|
516
|
+
title = match.group(2).strip()
|
|
517
|
+
|
|
518
|
+
# Extract first bullet point as description
|
|
519
|
+
start = match.end()
|
|
520
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(values_text)
|
|
521
|
+
section_text = values_text[start:end]
|
|
522
|
+
|
|
523
|
+
bullet_match = re.search(r"^- (.+)$", section_text, re.MULTILINE)
|
|
524
|
+
description = bullet_match.group(1).strip() if bullet_match else ""
|
|
525
|
+
|
|
526
|
+
content = f"{title} — {description}" if description else title
|
|
527
|
+
|
|
528
|
+
nodes.append(
|
|
529
|
+
GraphNode(
|
|
530
|
+
id=f"RAI-VAL-{num}",
|
|
531
|
+
type="principle",
|
|
532
|
+
content=content,
|
|
533
|
+
source_file=source_file,
|
|
534
|
+
created=now,
|
|
535
|
+
metadata={
|
|
536
|
+
"always_on": True,
|
|
537
|
+
"identity_type": "value",
|
|
538
|
+
"value_number": num,
|
|
539
|
+
"value_name": title,
|
|
540
|
+
},
|
|
541
|
+
)
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
return nodes
|
|
545
|
+
|
|
546
|
+
def _extract_identity_boundaries(
|
|
547
|
+
self, text: str, source_file: str, now: str
|
|
548
|
+
) -> list[GraphNode]:
|
|
549
|
+
"""Extract boundaries from identity core.md.
|
|
550
|
+
|
|
551
|
+
Matches ### I Will and ### I Won't sections, extracts bullet items.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
text: Full file content.
|
|
555
|
+
source_file: Relative source path.
|
|
556
|
+
now: ISO timestamp.
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
List of boundary GraphNodes.
|
|
560
|
+
"""
|
|
561
|
+
import re
|
|
562
|
+
|
|
563
|
+
nodes: list[GraphNode] = []
|
|
564
|
+
|
|
565
|
+
# Find boundaries section
|
|
566
|
+
boundaries_match = re.search(r"^## Boundaries\b", text, re.MULTILINE)
|
|
567
|
+
if not boundaries_match:
|
|
568
|
+
return nodes
|
|
569
|
+
|
|
570
|
+
# Find end of boundaries section (next ## heading or EOF)
|
|
571
|
+
next_section = re.search(r"^## ", text[boundaries_match.end() :], re.MULTILINE)
|
|
572
|
+
boundaries_end = (
|
|
573
|
+
boundaries_match.end() + next_section.start() if next_section else len(text)
|
|
574
|
+
)
|
|
575
|
+
boundaries_text = text[boundaries_match.end() : boundaries_end]
|
|
576
|
+
|
|
577
|
+
# Extract "I Will" bullets
|
|
578
|
+
will_match = re.search(r"^### I Will\b", boundaries_text, re.MULTILINE)
|
|
579
|
+
wont_match = re.search(r"^### I Won't\b", boundaries_text, re.MULTILINE)
|
|
580
|
+
|
|
581
|
+
counter = 1
|
|
582
|
+
|
|
583
|
+
if will_match:
|
|
584
|
+
start = will_match.end()
|
|
585
|
+
end = wont_match.start() if wont_match else len(boundaries_text)
|
|
586
|
+
will_text = boundaries_text[start:end]
|
|
587
|
+
|
|
588
|
+
for bullet in re.finditer(r"^- (.+)$", will_text, re.MULTILINE):
|
|
589
|
+
nodes.append(
|
|
590
|
+
GraphNode(
|
|
591
|
+
id=f"RAI-BND-{counter}",
|
|
592
|
+
type="principle",
|
|
593
|
+
content=bullet.group(1).strip(),
|
|
594
|
+
source_file=source_file,
|
|
595
|
+
created=now,
|
|
596
|
+
metadata={
|
|
597
|
+
"always_on": True,
|
|
598
|
+
"identity_type": "boundary",
|
|
599
|
+
"boundary_kind": "will",
|
|
600
|
+
},
|
|
601
|
+
)
|
|
602
|
+
)
|
|
603
|
+
counter += 1
|
|
604
|
+
|
|
605
|
+
if wont_match:
|
|
606
|
+
start = wont_match.end()
|
|
607
|
+
# Find next ### or end
|
|
608
|
+
next_heading = re.search(r"^### ", boundaries_text[start:], re.MULTILINE)
|
|
609
|
+
end = start + next_heading.start() if next_heading else len(boundaries_text)
|
|
610
|
+
wont_text = boundaries_text[start:end]
|
|
611
|
+
|
|
612
|
+
for bullet in re.finditer(r"^- (.+)$", wont_text, re.MULTILINE):
|
|
613
|
+
nodes.append(
|
|
614
|
+
GraphNode(
|
|
615
|
+
id=f"RAI-BND-{counter}",
|
|
616
|
+
type="principle",
|
|
617
|
+
content=bullet.group(1).strip(),
|
|
618
|
+
source_file=source_file,
|
|
619
|
+
created=now,
|
|
620
|
+
metadata={
|
|
621
|
+
"always_on": True,
|
|
622
|
+
"identity_type": "boundary",
|
|
623
|
+
"boundary_kind": "wont",
|
|
624
|
+
},
|
|
625
|
+
)
|
|
626
|
+
)
|
|
627
|
+
counter += 1
|
|
628
|
+
|
|
629
|
+
return nodes
|
|
630
|
+
|
|
631
|
+
def _parse_architecture_doc(self, file_path: Path) -> GraphNode | None:
|
|
632
|
+
"""Parse an architecture doc's YAML frontmatter into a GraphNode.
|
|
633
|
+
|
|
634
|
+
Dispatches by frontmatter ``type`` field to produce the appropriate
|
|
635
|
+
node type (module or architecture).
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
file_path: Path to the markdown file.
|
|
639
|
+
|
|
640
|
+
Returns:
|
|
641
|
+
GraphNode if valid frontmatter found, None otherwise.
|
|
642
|
+
"""
|
|
643
|
+
try:
|
|
644
|
+
text = file_path.read_text(encoding="utf-8")
|
|
645
|
+
except OSError:
|
|
646
|
+
return None
|
|
647
|
+
|
|
648
|
+
# Extract YAML frontmatter between --- delimiters
|
|
649
|
+
if not text.startswith("---"):
|
|
650
|
+
return None
|
|
651
|
+
|
|
652
|
+
end = text.find("---", 3)
|
|
653
|
+
if end == -1:
|
|
654
|
+
return None
|
|
655
|
+
|
|
656
|
+
frontmatter_text = text[3:end].strip()
|
|
657
|
+
|
|
658
|
+
try:
|
|
659
|
+
import yaml
|
|
660
|
+
|
|
661
|
+
frontmatter_raw: Any = yaml.safe_load(frontmatter_text)
|
|
662
|
+
except Exception:
|
|
663
|
+
return None
|
|
664
|
+
|
|
665
|
+
if not isinstance(frontmatter_raw, dict):
|
|
666
|
+
return None
|
|
667
|
+
frontmatter = cast(dict[str, Any], frontmatter_raw)
|
|
668
|
+
|
|
669
|
+
# Build relative source path
|
|
670
|
+
try:
|
|
671
|
+
source_file = portable_path(file_path, self.project_root)
|
|
672
|
+
except ValueError:
|
|
673
|
+
source_file = str(file_path)
|
|
674
|
+
|
|
675
|
+
doc_type = frontmatter.get("type", "")
|
|
676
|
+
|
|
677
|
+
# Type-dispatch by frontmatter type
|
|
678
|
+
if doc_type == "module":
|
|
679
|
+
return self._parse_module_doc(frontmatter, source_file)
|
|
680
|
+
if doc_type == "architecture_context":
|
|
681
|
+
return self._parse_architecture_context(frontmatter, source_file)
|
|
682
|
+
if doc_type == "architecture_design":
|
|
683
|
+
return self._parse_architecture_design(frontmatter, source_file)
|
|
684
|
+
if doc_type == "architecture_domain_model":
|
|
685
|
+
return self._parse_architecture_domain_model(frontmatter, source_file)
|
|
686
|
+
# Skip architecture_index and unknown types
|
|
687
|
+
return None
|
|
688
|
+
|
|
689
|
+
def _parse_module_doc(
|
|
690
|
+
self, frontmatter: dict[str, Any], source_file: str
|
|
691
|
+
) -> GraphNode | None:
|
|
692
|
+
"""Parse a module-type architecture doc.
|
|
693
|
+
|
|
694
|
+
Args:
|
|
695
|
+
frontmatter: Parsed YAML frontmatter dict.
|
|
696
|
+
source_file: Relative path to the source file.
|
|
697
|
+
|
|
698
|
+
Returns:
|
|
699
|
+
GraphNode with type "module", or None if invalid.
|
|
700
|
+
"""
|
|
701
|
+
name = frontmatter.get("name", "")
|
|
702
|
+
if not name:
|
|
703
|
+
return None
|
|
704
|
+
|
|
705
|
+
metadata: dict[str, Any] = {}
|
|
706
|
+
for key in (
|
|
707
|
+
"depends_on",
|
|
708
|
+
"depended_by",
|
|
709
|
+
"entry_points",
|
|
710
|
+
"public_api",
|
|
711
|
+
"components",
|
|
712
|
+
"constraints",
|
|
713
|
+
"status",
|
|
714
|
+
):
|
|
715
|
+
if key in frontmatter:
|
|
716
|
+
metadata[key] = frontmatter[key]
|
|
717
|
+
|
|
718
|
+
return GraphNode(
|
|
719
|
+
id=f"mod-{name}",
|
|
720
|
+
type="module",
|
|
721
|
+
content=frontmatter.get("purpose", ""),
|
|
722
|
+
source_file=source_file,
|
|
723
|
+
created=frontmatter.get("last_validated", datetime.now(tz=UTC).isoformat()),
|
|
724
|
+
metadata=metadata,
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
def _parse_architecture_context(
|
|
728
|
+
self, frontmatter: dict[str, Any], source_file: str
|
|
729
|
+
) -> GraphNode:
|
|
730
|
+
"""Parse an architecture_context doc (system-context.md).
|
|
731
|
+
|
|
732
|
+
Synthesizes content from tech stack and external dependencies.
|
|
733
|
+
|
|
734
|
+
Args:
|
|
735
|
+
frontmatter: Parsed YAML frontmatter dict.
|
|
736
|
+
source_file: Relative path to the source file.
|
|
737
|
+
|
|
738
|
+
Returns:
|
|
739
|
+
GraphNode with type "architecture".
|
|
740
|
+
"""
|
|
741
|
+
# Synthesize content from tech stack
|
|
742
|
+
tech_stack: dict[str, str] = frontmatter.get("tech_stack", {})
|
|
743
|
+
tech_parts = [f"{k}: {v}" for k, v in tech_stack.items()]
|
|
744
|
+
tech_summary = ", ".join(tech_parts) if tech_parts else "No tech stack defined"
|
|
745
|
+
|
|
746
|
+
ext_deps: list[str] = frontmatter.get("external_dependencies", [])
|
|
747
|
+
deps_summary = ", ".join(ext_deps) if ext_deps else "none"
|
|
748
|
+
|
|
749
|
+
content = (
|
|
750
|
+
f"System context: {tech_summary}. External dependencies: {deps_summary}."
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
# Store all structured data in metadata
|
|
754
|
+
metadata: dict[str, Any] = {"arch_type": "architecture_context"}
|
|
755
|
+
for key in (
|
|
756
|
+
"tech_stack",
|
|
757
|
+
"external_dependencies",
|
|
758
|
+
"users",
|
|
759
|
+
"governed_by",
|
|
760
|
+
"project",
|
|
761
|
+
"version",
|
|
762
|
+
"status",
|
|
763
|
+
):
|
|
764
|
+
if key in frontmatter:
|
|
765
|
+
metadata[key] = frontmatter[key]
|
|
766
|
+
|
|
767
|
+
return GraphNode(
|
|
768
|
+
id="arch-context",
|
|
769
|
+
type="architecture",
|
|
770
|
+
content=content,
|
|
771
|
+
source_file=source_file,
|
|
772
|
+
created=datetime.now(tz=UTC).isoformat(),
|
|
773
|
+
metadata=metadata,
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
def _parse_architecture_design(
|
|
777
|
+
self, frontmatter: dict[str, Any], source_file: str
|
|
778
|
+
) -> GraphNode:
|
|
779
|
+
"""Parse an architecture_design doc (system-design.md).
|
|
780
|
+
|
|
781
|
+
Synthesizes content from layers and their module assignments.
|
|
782
|
+
|
|
783
|
+
Args:
|
|
784
|
+
frontmatter: Parsed YAML frontmatter dict.
|
|
785
|
+
source_file: Relative path to the source file.
|
|
786
|
+
|
|
787
|
+
Returns:
|
|
788
|
+
GraphNode with type "architecture".
|
|
789
|
+
"""
|
|
790
|
+
# Synthesize content from layers
|
|
791
|
+
layers: list[dict[str, Any]] = frontmatter.get("layers", [])
|
|
792
|
+
layer_parts: list[str] = []
|
|
793
|
+
for layer in layers:
|
|
794
|
+
name = layer.get("name", "unknown")
|
|
795
|
+
modules: list[str] = layer.get("modules", [])
|
|
796
|
+
layer_parts.append(f"{name}: {', '.join(modules)}")
|
|
797
|
+
|
|
798
|
+
layers_summary = ". ".join(layer_parts) if layer_parts else "No layers defined"
|
|
799
|
+
layer_names = ", ".join(layer.get("name", "") for layer in layers)
|
|
800
|
+
content = (
|
|
801
|
+
f"System design: {len(layers)} layers ({layer_names}). {layers_summary}."
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
# Store all structured data in metadata
|
|
805
|
+
metadata: dict[str, Any] = {"arch_type": "architecture_design"}
|
|
806
|
+
for key in (
|
|
807
|
+
"layers",
|
|
808
|
+
"architectural_decisions",
|
|
809
|
+
"distribution",
|
|
810
|
+
"guardrails_reference",
|
|
811
|
+
"constitution_reference",
|
|
812
|
+
"project",
|
|
813
|
+
"status",
|
|
814
|
+
):
|
|
815
|
+
if key in frontmatter:
|
|
816
|
+
metadata[key] = frontmatter[key]
|
|
817
|
+
|
|
818
|
+
return GraphNode(
|
|
819
|
+
id="arch-design",
|
|
820
|
+
type="architecture",
|
|
821
|
+
content=content,
|
|
822
|
+
source_file=source_file,
|
|
823
|
+
created=datetime.now(tz=UTC).isoformat(),
|
|
824
|
+
metadata=metadata,
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
def _parse_architecture_domain_model(
|
|
828
|
+
self, frontmatter: dict[str, Any], source_file: str
|
|
829
|
+
) -> GraphNode:
|
|
830
|
+
"""Parse an architecture_domain_model doc (domain-model.md).
|
|
831
|
+
|
|
832
|
+
Synthesizes content from bounded contexts and shared kernel.
|
|
833
|
+
|
|
834
|
+
Args:
|
|
835
|
+
frontmatter: Parsed YAML frontmatter dict.
|
|
836
|
+
source_file: Relative path to the source file.
|
|
837
|
+
|
|
838
|
+
Returns:
|
|
839
|
+
GraphNode with type "architecture".
|
|
840
|
+
"""
|
|
841
|
+
# Synthesize content from bounded contexts
|
|
842
|
+
bcs: list[Any] = frontmatter.get("bounded_contexts", [])
|
|
843
|
+
bc_names: list[str] = [bc.get("name", "unknown") if isinstance(bc, dict) else str(bc) for bc in bcs]
|
|
844
|
+
bc_summary = ", ".join(bc_names) if bc_names else "none defined"
|
|
845
|
+
|
|
846
|
+
shared: dict[str, Any] = frontmatter.get("shared_kernel", {})
|
|
847
|
+
shared_modules: list[str] = shared.get("modules", [])
|
|
848
|
+
shared_summary = ", ".join(shared_modules) if shared_modules else "none"
|
|
849
|
+
|
|
850
|
+
content = (
|
|
851
|
+
f"Domain model: {len(bcs)} bounded contexts — {bc_summary}. "
|
|
852
|
+
f"Shared kernel: {shared_summary}."
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
# Store all structured data in metadata
|
|
856
|
+
metadata: dict[str, Any] = {"arch_type": "architecture_domain_model"}
|
|
857
|
+
for key in (
|
|
858
|
+
"bounded_contexts",
|
|
859
|
+
"shared_kernel",
|
|
860
|
+
"application_layer",
|
|
861
|
+
"distribution",
|
|
862
|
+
"project",
|
|
863
|
+
"status",
|
|
864
|
+
):
|
|
865
|
+
if key in frontmatter:
|
|
866
|
+
metadata[key] = frontmatter[key]
|
|
867
|
+
|
|
868
|
+
return GraphNode(
|
|
869
|
+
id="arch-domain-model",
|
|
870
|
+
type="architecture",
|
|
871
|
+
content=content,
|
|
872
|
+
source_file=source_file,
|
|
873
|
+
created=datetime.now(tz=UTC).isoformat(),
|
|
874
|
+
metadata=metadata,
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
def _extract_bounded_contexts(
|
|
878
|
+
self,
|
|
879
|
+
all_nodes: list[GraphNode],
|
|
880
|
+
node_by_id: dict[str, GraphNode],
|
|
881
|
+
) -> tuple[list[GraphNode], list[GraphEdge]]:
|
|
882
|
+
"""Extract bounded context nodes and belongs_to edges from domain model.
|
|
883
|
+
|
|
884
|
+
Reads the arch-domain-model node's metadata to create bounded_context
|
|
885
|
+
nodes for each DDD context, shared kernel, application layer, and
|
|
886
|
+
distribution grouping.
|
|
887
|
+
|
|
888
|
+
Args:
|
|
889
|
+
all_nodes: All nodes loaded so far.
|
|
890
|
+
node_by_id: Lookup dict by node ID.
|
|
891
|
+
|
|
892
|
+
Returns:
|
|
893
|
+
Tuple of (bounded_context nodes, belongs_to edges).
|
|
894
|
+
"""
|
|
895
|
+
nodes: list[GraphNode] = []
|
|
896
|
+
edges: list[GraphEdge] = []
|
|
897
|
+
|
|
898
|
+
# Find the arch-domain-model node
|
|
899
|
+
dm_node = node_by_id.get("arch-domain-model")
|
|
900
|
+
if dm_node is None:
|
|
901
|
+
return nodes, edges
|
|
902
|
+
|
|
903
|
+
now = datetime.now(tz=UTC).isoformat()
|
|
904
|
+
|
|
905
|
+
# Extract bounded contexts
|
|
906
|
+
bcs: list[dict[str, Any]] = dm_node.metadata.get("bounded_contexts", [])
|
|
907
|
+
for bc in bcs:
|
|
908
|
+
bc_name: str = bc.get("name", "")
|
|
909
|
+
if not bc_name:
|
|
910
|
+
continue
|
|
911
|
+
bc_id = f"bc-{bc_name}"
|
|
912
|
+
nodes.append(
|
|
913
|
+
GraphNode(
|
|
914
|
+
id=bc_id,
|
|
915
|
+
type="bounded_context",
|
|
916
|
+
content=bc.get("description", ""),
|
|
917
|
+
source_file=dm_node.source_file,
|
|
918
|
+
created=now,
|
|
919
|
+
metadata={
|
|
920
|
+
"bc_type": "bounded_context",
|
|
921
|
+
"modules": bc.get("modules", []),
|
|
922
|
+
},
|
|
923
|
+
)
|
|
924
|
+
)
|
|
925
|
+
# Create belongs_to edges for modules in this BC
|
|
926
|
+
modules: list[str] = bc.get("modules", [])
|
|
927
|
+
for mod_name in modules:
|
|
928
|
+
mod_id = f"mod-{mod_name}"
|
|
929
|
+
if mod_id in node_by_id:
|
|
930
|
+
edges.append(
|
|
931
|
+
GraphEdge(
|
|
932
|
+
source=mod_id, target=bc_id, type="belongs_to", weight=1.0
|
|
933
|
+
)
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
# Extract shared kernel as a BC node
|
|
937
|
+
shared: dict[str, Any] = dm_node.metadata.get("shared_kernel", {})
|
|
938
|
+
if shared:
|
|
939
|
+
nodes.append(
|
|
940
|
+
GraphNode(
|
|
941
|
+
id="bc-shared-kernel",
|
|
942
|
+
type="bounded_context",
|
|
943
|
+
content=shared.get("description", ""),
|
|
944
|
+
source_file=dm_node.source_file,
|
|
945
|
+
created=now,
|
|
946
|
+
metadata={
|
|
947
|
+
"bc_type": "shared_kernel",
|
|
948
|
+
"modules": shared.get("modules", []),
|
|
949
|
+
},
|
|
950
|
+
)
|
|
951
|
+
)
|
|
952
|
+
for mod_name in shared.get("modules", []):
|
|
953
|
+
mod_id = f"mod-{mod_name}"
|
|
954
|
+
if mod_id in node_by_id:
|
|
955
|
+
edges.append(
|
|
956
|
+
GraphEdge(
|
|
957
|
+
source=mod_id,
|
|
958
|
+
target="bc-shared-kernel",
|
|
959
|
+
type="belongs_to",
|
|
960
|
+
weight=1.0,
|
|
961
|
+
)
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
# Extract application layer as a BC node
|
|
965
|
+
app_layer: dict[str, Any] = dm_node.metadata.get("application_layer", {})
|
|
966
|
+
if app_layer:
|
|
967
|
+
nodes.append(
|
|
968
|
+
GraphNode(
|
|
969
|
+
id="bc-application-layer",
|
|
970
|
+
type="bounded_context",
|
|
971
|
+
content=app_layer.get("description", ""),
|
|
972
|
+
source_file=dm_node.source_file,
|
|
973
|
+
created=now,
|
|
974
|
+
metadata={
|
|
975
|
+
"bc_type": "application_layer",
|
|
976
|
+
"modules": app_layer.get("modules", []),
|
|
977
|
+
},
|
|
978
|
+
)
|
|
979
|
+
)
|
|
980
|
+
for mod_name in app_layer.get("modules", []):
|
|
981
|
+
mod_id = f"mod-{mod_name}"
|
|
982
|
+
if mod_id in node_by_id:
|
|
983
|
+
edges.append(
|
|
984
|
+
GraphEdge(
|
|
985
|
+
source=mod_id,
|
|
986
|
+
target="bc-application-layer",
|
|
987
|
+
type="belongs_to",
|
|
988
|
+
weight=1.0,
|
|
989
|
+
)
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
# Extract distribution as a BC node
|
|
993
|
+
dist: dict[str, Any] = dm_node.metadata.get("distribution", {})
|
|
994
|
+
if dist:
|
|
995
|
+
nodes.append(
|
|
996
|
+
GraphNode(
|
|
997
|
+
id="bc-distribution",
|
|
998
|
+
type="bounded_context",
|
|
999
|
+
content=dist.get("description", ""),
|
|
1000
|
+
source_file=dm_node.source_file,
|
|
1001
|
+
created=now,
|
|
1002
|
+
metadata={
|
|
1003
|
+
"bc_type": "distribution",
|
|
1004
|
+
"modules": dist.get("modules", []),
|
|
1005
|
+
},
|
|
1006
|
+
)
|
|
1007
|
+
)
|
|
1008
|
+
for mod_name in dist.get("modules", []):
|
|
1009
|
+
mod_id = f"mod-{mod_name}"
|
|
1010
|
+
if mod_id in node_by_id:
|
|
1011
|
+
edges.append(
|
|
1012
|
+
GraphEdge(
|
|
1013
|
+
source=mod_id,
|
|
1014
|
+
target="bc-distribution",
|
|
1015
|
+
type="belongs_to",
|
|
1016
|
+
weight=1.0,
|
|
1017
|
+
)
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
return nodes, edges
|
|
1021
|
+
|
|
1022
|
+
def _extract_layers(
|
|
1023
|
+
self,
|
|
1024
|
+
all_nodes: list[GraphNode],
|
|
1025
|
+
node_by_id: dict[str, GraphNode],
|
|
1026
|
+
) -> tuple[list[GraphNode], list[GraphEdge]]:
|
|
1027
|
+
"""Extract layer nodes and in_layer edges from system design.
|
|
1028
|
+
|
|
1029
|
+
Reads the arch-design node's metadata to create layer nodes and
|
|
1030
|
+
in_layer edges linking modules to their architectural layer.
|
|
1031
|
+
|
|
1032
|
+
Args:
|
|
1033
|
+
all_nodes: All nodes loaded so far.
|
|
1034
|
+
node_by_id: Lookup dict by node ID.
|
|
1035
|
+
|
|
1036
|
+
Returns:
|
|
1037
|
+
Tuple of (layer nodes, in_layer edges).
|
|
1038
|
+
"""
|
|
1039
|
+
nodes: list[GraphNode] = []
|
|
1040
|
+
edges: list[GraphEdge] = []
|
|
1041
|
+
|
|
1042
|
+
# Find the arch-design node
|
|
1043
|
+
design_node = node_by_id.get("arch-design")
|
|
1044
|
+
if design_node is None:
|
|
1045
|
+
return nodes, edges
|
|
1046
|
+
|
|
1047
|
+
now = datetime.now(tz=UTC).isoformat()
|
|
1048
|
+
|
|
1049
|
+
layers: list[dict[str, Any]] = design_node.metadata.get("layers", [])
|
|
1050
|
+
for layer in layers:
|
|
1051
|
+
layer_name: str = layer.get("name", "")
|
|
1052
|
+
if not layer_name:
|
|
1053
|
+
continue
|
|
1054
|
+
layer_id = f"lyr-{layer_name}"
|
|
1055
|
+
nodes.append(
|
|
1056
|
+
GraphNode(
|
|
1057
|
+
id=layer_id,
|
|
1058
|
+
type="layer",
|
|
1059
|
+
content=layer.get("description", ""),
|
|
1060
|
+
source_file=design_node.source_file,
|
|
1061
|
+
created=now,
|
|
1062
|
+
metadata={"modules": layer.get("modules", [])},
|
|
1063
|
+
)
|
|
1064
|
+
)
|
|
1065
|
+
# Create in_layer edges for modules in this layer
|
|
1066
|
+
modules: list[str] = layer.get("modules", [])
|
|
1067
|
+
for mod_name in modules:
|
|
1068
|
+
mod_id = f"mod-{mod_name}"
|
|
1069
|
+
if mod_id in node_by_id:
|
|
1070
|
+
edges.append(
|
|
1071
|
+
GraphEdge(
|
|
1072
|
+
source=mod_id, target=layer_id, type="in_layer", weight=1.0
|
|
1073
|
+
)
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
return nodes, edges
|
|
1077
|
+
|
|
1078
|
+
def _extract_constraints(
|
|
1079
|
+
self,
|
|
1080
|
+
all_nodes: list[GraphNode],
|
|
1081
|
+
node_by_id: dict[str, GraphNode],
|
|
1082
|
+
) -> list[GraphEdge]:
|
|
1083
|
+
"""Extract constrained_by edges from guardrail scope metadata.
|
|
1084
|
+
|
|
1085
|
+
Reads ``constraint_scope`` from each guardrail node's metadata
|
|
1086
|
+
(set by the guardrails parser from YAML frontmatter). Creates
|
|
1087
|
+
``constrained_by`` edges from target nodes (BCs or layers) to
|
|
1088
|
+
guardrail nodes.
|
|
1089
|
+
|
|
1090
|
+
Args:
|
|
1091
|
+
all_nodes: All nodes loaded so far (including structural).
|
|
1092
|
+
node_by_id: Lookup dict by node ID.
|
|
1093
|
+
|
|
1094
|
+
Returns:
|
|
1095
|
+
List of constrained_by edges.
|
|
1096
|
+
"""
|
|
1097
|
+
edges: list[GraphEdge] = []
|
|
1098
|
+
bc_ids = [n.id for n in node_by_id.values() if n.type == "bounded_context"]
|
|
1099
|
+
|
|
1100
|
+
for node in all_nodes:
|
|
1101
|
+
if node.type != "guardrail":
|
|
1102
|
+
continue
|
|
1103
|
+
|
|
1104
|
+
scope: Any = node.metadata.get("constraint_scope")
|
|
1105
|
+
if scope is None:
|
|
1106
|
+
continue
|
|
1107
|
+
|
|
1108
|
+
if scope == "all_bounded_contexts":
|
|
1109
|
+
targets = bc_ids
|
|
1110
|
+
elif isinstance(scope, list):
|
|
1111
|
+
targets = [t for t in cast(list[str], scope) if t in node_by_id]
|
|
1112
|
+
else:
|
|
1113
|
+
continue
|
|
1114
|
+
|
|
1115
|
+
for target_id in targets:
|
|
1116
|
+
edges.append(
|
|
1117
|
+
GraphEdge(
|
|
1118
|
+
source=target_id,
|
|
1119
|
+
target=node.id,
|
|
1120
|
+
type="constrained_by",
|
|
1121
|
+
weight=1.0,
|
|
1122
|
+
)
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
return edges
|
|
1126
|
+
|
|
1127
|
+
def _get_governance_extractor(self) -> GovernanceExtractor:
|
|
1128
|
+
"""Get governance extractor instance.
|
|
1129
|
+
|
|
1130
|
+
Returns:
|
|
1131
|
+
GovernanceExtractor for this project.
|
|
1132
|
+
"""
|
|
1133
|
+
from raise_cli.governance.extractor import GovernanceExtractor
|
|
1134
|
+
|
|
1135
|
+
return GovernanceExtractor(project_root=self.project_root)
|
|
1136
|
+
|
|
1137
|
+
def _concept_to_node(self, concept: Concept) -> GraphNode:
|
|
1138
|
+
"""Convert governance Concept to GraphNode.
|
|
1139
|
+
|
|
1140
|
+
Args:
|
|
1141
|
+
concept: Governance concept to convert.
|
|
1142
|
+
|
|
1143
|
+
Returns:
|
|
1144
|
+
GraphNode with mapped fields.
|
|
1145
|
+
"""
|
|
1146
|
+
return GraphNode(
|
|
1147
|
+
id=concept.id,
|
|
1148
|
+
type=concept.type.value, # type: ignore[arg-type]
|
|
1149
|
+
content=concept.content,
|
|
1150
|
+
source_file=concept.file,
|
|
1151
|
+
created=datetime.now(tz=UTC).isoformat(),
|
|
1152
|
+
metadata=concept.metadata,
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
def _load_jsonl(
|
|
1156
|
+
self,
|
|
1157
|
+
file_path: Path,
|
|
1158
|
+
node_type: str,
|
|
1159
|
+
scope: MemoryScope = MemoryScope.PROJECT,
|
|
1160
|
+
) -> list[GraphNode]:
|
|
1161
|
+
"""Load concepts from a JSONL file.
|
|
1162
|
+
|
|
1163
|
+
Args:
|
|
1164
|
+
file_path: Path to JSONL file.
|
|
1165
|
+
node_type: Type to assign to nodes (pattern, calibration, session).
|
|
1166
|
+
scope: Memory scope to assign to loaded concepts.
|
|
1167
|
+
|
|
1168
|
+
Returns:
|
|
1169
|
+
List of GraphNode parsed from file.
|
|
1170
|
+
"""
|
|
1171
|
+
nodes: list[GraphNode] = []
|
|
1172
|
+
|
|
1173
|
+
# Try to make path relative, fallback to absolute
|
|
1174
|
+
try:
|
|
1175
|
+
source_file = portable_path(file_path, self.project_root)
|
|
1176
|
+
except ValueError:
|
|
1177
|
+
source_file = str(file_path)
|
|
1178
|
+
|
|
1179
|
+
for line in file_path.read_text(encoding="utf-8").splitlines():
|
|
1180
|
+
if not line.strip():
|
|
1181
|
+
continue
|
|
1182
|
+
|
|
1183
|
+
try:
|
|
1184
|
+
record: dict[str, Any] = json.loads(line)
|
|
1185
|
+
except json.JSONDecodeError:
|
|
1186
|
+
continue
|
|
1187
|
+
|
|
1188
|
+
node = self._memory_record_to_node(record, node_type, source_file, scope)
|
|
1189
|
+
if node:
|
|
1190
|
+
nodes.append(node)
|
|
1191
|
+
|
|
1192
|
+
return nodes
|
|
1193
|
+
|
|
1194
|
+
def _memory_record_to_node(
|
|
1195
|
+
self,
|
|
1196
|
+
record: dict[str, Any],
|
|
1197
|
+
node_type: str,
|
|
1198
|
+
source_file: str,
|
|
1199
|
+
scope: MemoryScope = MemoryScope.PROJECT,
|
|
1200
|
+
) -> GraphNode | None:
|
|
1201
|
+
"""Convert memory JSONL record to GraphNode.
|
|
1202
|
+
|
|
1203
|
+
Args:
|
|
1204
|
+
record: Parsed JSON record.
|
|
1205
|
+
node_type: Type of memory concept.
|
|
1206
|
+
source_file: Source file path.
|
|
1207
|
+
scope: Memory scope for this concept.
|
|
1208
|
+
|
|
1209
|
+
Returns:
|
|
1210
|
+
GraphNode or None if record is invalid.
|
|
1211
|
+
"""
|
|
1212
|
+
record_id = record.get("id")
|
|
1213
|
+
if not record_id:
|
|
1214
|
+
return None
|
|
1215
|
+
|
|
1216
|
+
# Build content based on type
|
|
1217
|
+
if node_type == "pattern":
|
|
1218
|
+
content = record.get("content", "")
|
|
1219
|
+
elif node_type == "calibration":
|
|
1220
|
+
# Calibration uses story + name (backward compat: old "feature" key)
|
|
1221
|
+
story = record.get("story") or record.get("feature", "")
|
|
1222
|
+
name = record.get("name", "")
|
|
1223
|
+
content = f"{story}: {name}" if story else name
|
|
1224
|
+
elif node_type == "session":
|
|
1225
|
+
content = record.get("topic", record.get("summary", ""))
|
|
1226
|
+
else:
|
|
1227
|
+
content = record.get("content", "")
|
|
1228
|
+
|
|
1229
|
+
# Get created date
|
|
1230
|
+
created = record.get("created") or record.get("date", "")
|
|
1231
|
+
if not created:
|
|
1232
|
+
created = datetime.now(tz=UTC).isoformat()
|
|
1233
|
+
|
|
1234
|
+
# Core fields to exclude from metadata
|
|
1235
|
+
core_fields = {"id", "type", "content", "created", "date"}
|
|
1236
|
+
|
|
1237
|
+
# Build metadata from remaining fields
|
|
1238
|
+
metadata: dict[str, Any] = {
|
|
1239
|
+
k: v for k, v in record.items() if k not in core_fields
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
# Add scope to metadata
|
|
1243
|
+
metadata["scope"] = scope.value
|
|
1244
|
+
|
|
1245
|
+
return GraphNode(
|
|
1246
|
+
id=str(record_id),
|
|
1247
|
+
type=node_type, # type: ignore[arg-type]
|
|
1248
|
+
content=str(content),
|
|
1249
|
+
source_file=source_file,
|
|
1250
|
+
created=str(created),
|
|
1251
|
+
metadata=metadata,
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
def infer_relationships(self, nodes: list[GraphNode]) -> list[GraphEdge]:
|
|
1255
|
+
"""Infer relationships between concepts.
|
|
1256
|
+
|
|
1257
|
+
Creates explicit edges (weight=1.0) from known fields and
|
|
1258
|
+
inferred edges (weight<1.0) from heuristics.
|
|
1259
|
+
|
|
1260
|
+
Args:
|
|
1261
|
+
nodes: List of concept nodes to analyze.
|
|
1262
|
+
|
|
1263
|
+
Returns:
|
|
1264
|
+
List of inferred GraphEdge objects.
|
|
1265
|
+
"""
|
|
1266
|
+
if not nodes:
|
|
1267
|
+
return []
|
|
1268
|
+
|
|
1269
|
+
edges: list[GraphEdge] = []
|
|
1270
|
+
|
|
1271
|
+
# Build lookup by ID for target resolution
|
|
1272
|
+
node_by_id: dict[str, GraphNode] = {n.id: n for n in nodes}
|
|
1273
|
+
|
|
1274
|
+
# Infer explicit edges
|
|
1275
|
+
edges.extend(self._infer_learned_from(nodes, node_by_id))
|
|
1276
|
+
edges.extend(self._infer_part_of(nodes, node_by_id))
|
|
1277
|
+
edges.extend(self._infer_skill_edges(nodes, node_by_id))
|
|
1278
|
+
edges.extend(self._infer_depends_on(nodes, node_by_id))
|
|
1279
|
+
edges.extend(self._infer_release_part_of(nodes, node_by_id))
|
|
1280
|
+
|
|
1281
|
+
# Infer heuristic edges
|
|
1282
|
+
edges.extend(self._infer_keyword_relationships(nodes))
|
|
1283
|
+
|
|
1284
|
+
return edges
|
|
1285
|
+
|
|
1286
|
+
def _infer_learned_from(
|
|
1287
|
+
self,
|
|
1288
|
+
nodes: list[GraphNode],
|
|
1289
|
+
node_by_id: dict[str, GraphNode],
|
|
1290
|
+
) -> list[GraphEdge]:
|
|
1291
|
+
"""Infer learned_from edges from pattern metadata.
|
|
1292
|
+
|
|
1293
|
+
Args:
|
|
1294
|
+
nodes: All concept nodes.
|
|
1295
|
+
node_by_id: Lookup dict by node ID.
|
|
1296
|
+
|
|
1297
|
+
Returns:
|
|
1298
|
+
List of learned_from edges.
|
|
1299
|
+
"""
|
|
1300
|
+
edges: list[GraphEdge] = []
|
|
1301
|
+
|
|
1302
|
+
for node in nodes:
|
|
1303
|
+
if node.type != "pattern":
|
|
1304
|
+
continue
|
|
1305
|
+
|
|
1306
|
+
learned_from = node.metadata.get("learned_from")
|
|
1307
|
+
if not learned_from:
|
|
1308
|
+
continue
|
|
1309
|
+
|
|
1310
|
+
# Find matching session by topic/story reference
|
|
1311
|
+
for candidate in nodes:
|
|
1312
|
+
if candidate.type != "session":
|
|
1313
|
+
continue
|
|
1314
|
+
|
|
1315
|
+
# Check if session topic mentions the story
|
|
1316
|
+
if str(learned_from) in candidate.content:
|
|
1317
|
+
edges.append(
|
|
1318
|
+
GraphEdge(
|
|
1319
|
+
source=node.id,
|
|
1320
|
+
target=candidate.id,
|
|
1321
|
+
type="learned_from",
|
|
1322
|
+
weight=1.0,
|
|
1323
|
+
)
|
|
1324
|
+
)
|
|
1325
|
+
break
|
|
1326
|
+
|
|
1327
|
+
return edges
|
|
1328
|
+
|
|
1329
|
+
def _infer_part_of(
|
|
1330
|
+
self,
|
|
1331
|
+
nodes: list[GraphNode],
|
|
1332
|
+
node_by_id: dict[str, GraphNode],
|
|
1333
|
+
) -> list[GraphEdge]:
|
|
1334
|
+
"""Infer part_of edges from story to epic.
|
|
1335
|
+
|
|
1336
|
+
Args:
|
|
1337
|
+
nodes: All concept nodes.
|
|
1338
|
+
node_by_id: Lookup dict by node ID.
|
|
1339
|
+
|
|
1340
|
+
Returns:
|
|
1341
|
+
List of part_of edges.
|
|
1342
|
+
"""
|
|
1343
|
+
edges: list[GraphEdge] = []
|
|
1344
|
+
|
|
1345
|
+
for node in nodes:
|
|
1346
|
+
if node.type != "story":
|
|
1347
|
+
continue
|
|
1348
|
+
|
|
1349
|
+
# Extract epic ID from story ID (e.g., F11.2 -> E11)
|
|
1350
|
+
story_id = node.id
|
|
1351
|
+
if story_id.startswith("F"):
|
|
1352
|
+
# Parse epic number from story ID
|
|
1353
|
+
parts = story_id[1:].split(".")
|
|
1354
|
+
if parts:
|
|
1355
|
+
epic_id = f"E{parts[0]}"
|
|
1356
|
+
if epic_id in node_by_id:
|
|
1357
|
+
edges.append(
|
|
1358
|
+
GraphEdge(
|
|
1359
|
+
source=node.id,
|
|
1360
|
+
target=epic_id,
|
|
1361
|
+
type="part_of",
|
|
1362
|
+
weight=1.0,
|
|
1363
|
+
)
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
return edges
|
|
1367
|
+
|
|
1368
|
+
def _infer_skill_edges(
|
|
1369
|
+
self,
|
|
1370
|
+
nodes: list[GraphNode],
|
|
1371
|
+
node_by_id: dict[str, GraphNode],
|
|
1372
|
+
) -> list[GraphEdge]:
|
|
1373
|
+
"""Infer edges from skill metadata (prerequisites, next).
|
|
1374
|
+
|
|
1375
|
+
Args:
|
|
1376
|
+
nodes: All concept nodes.
|
|
1377
|
+
node_by_id: Lookup dict by node ID.
|
|
1378
|
+
|
|
1379
|
+
Returns:
|
|
1380
|
+
List of skill relationship edges.
|
|
1381
|
+
"""
|
|
1382
|
+
edges: list[GraphEdge] = []
|
|
1383
|
+
|
|
1384
|
+
for node in nodes:
|
|
1385
|
+
if node.type != "skill":
|
|
1386
|
+
continue
|
|
1387
|
+
|
|
1388
|
+
# Prerequisites -> needs_context
|
|
1389
|
+
prereq = node.metadata.get("raise.prerequisites")
|
|
1390
|
+
if prereq:
|
|
1391
|
+
prereq_id = f"/{prereq}" if not str(prereq).startswith("/") else prereq
|
|
1392
|
+
if prereq_id in node_by_id:
|
|
1393
|
+
edges.append(
|
|
1394
|
+
GraphEdge(
|
|
1395
|
+
source=node.id,
|
|
1396
|
+
target=prereq_id,
|
|
1397
|
+
type="needs_context",
|
|
1398
|
+
weight=1.0,
|
|
1399
|
+
)
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
# Next -> related_to
|
|
1403
|
+
next_skill = node.metadata.get("raise.next")
|
|
1404
|
+
if next_skill:
|
|
1405
|
+
next_id = (
|
|
1406
|
+
f"/{next_skill}"
|
|
1407
|
+
if not str(next_skill).startswith("/")
|
|
1408
|
+
else next_skill
|
|
1409
|
+
)
|
|
1410
|
+
if next_id in node_by_id:
|
|
1411
|
+
edges.append(
|
|
1412
|
+
GraphEdge(
|
|
1413
|
+
source=node.id,
|
|
1414
|
+
target=next_id,
|
|
1415
|
+
type="related_to",
|
|
1416
|
+
weight=1.0,
|
|
1417
|
+
)
|
|
1418
|
+
)
|
|
1419
|
+
|
|
1420
|
+
return edges
|
|
1421
|
+
|
|
1422
|
+
def _infer_depends_on(
|
|
1423
|
+
self,
|
|
1424
|
+
nodes: list[GraphNode],
|
|
1425
|
+
node_by_id: dict[str, GraphNode],
|
|
1426
|
+
) -> list[GraphEdge]:
|
|
1427
|
+
"""Infer depends_on edges from module metadata.
|
|
1428
|
+
|
|
1429
|
+
Args:
|
|
1430
|
+
nodes: All concept nodes.
|
|
1431
|
+
node_by_id: Lookup dict by node ID.
|
|
1432
|
+
|
|
1433
|
+
Returns:
|
|
1434
|
+
List of depends_on edges between modules.
|
|
1435
|
+
"""
|
|
1436
|
+
edges: list[GraphEdge] = []
|
|
1437
|
+
|
|
1438
|
+
for node in nodes:
|
|
1439
|
+
if node.type != "module":
|
|
1440
|
+
continue
|
|
1441
|
+
|
|
1442
|
+
raw_deps: Any = node.metadata.get("depends_on", [])
|
|
1443
|
+
if not isinstance(raw_deps, list):
|
|
1444
|
+
continue
|
|
1445
|
+
deps = cast(list[str], raw_deps)
|
|
1446
|
+
|
|
1447
|
+
for dep_name in deps:
|
|
1448
|
+
target_id = f"mod-{dep_name}"
|
|
1449
|
+
if target_id in node_by_id:
|
|
1450
|
+
edges.append(
|
|
1451
|
+
GraphEdge(
|
|
1452
|
+
source=node.id,
|
|
1453
|
+
target=target_id,
|
|
1454
|
+
type="depends_on",
|
|
1455
|
+
weight=1.0,
|
|
1456
|
+
)
|
|
1457
|
+
)
|
|
1458
|
+
|
|
1459
|
+
return edges
|
|
1460
|
+
|
|
1461
|
+
def _infer_release_part_of(
|
|
1462
|
+
self,
|
|
1463
|
+
nodes: list[GraphNode],
|
|
1464
|
+
node_by_id: dict[str, GraphNode],
|
|
1465
|
+
) -> list[GraphEdge]:
|
|
1466
|
+
"""Infer part_of edges from epics to releases.
|
|
1467
|
+
|
|
1468
|
+
Uses the ``epics`` list in release node metadata to create
|
|
1469
|
+
part_of edges. Skips edges where the epic node doesn't exist.
|
|
1470
|
+
|
|
1471
|
+
Args:
|
|
1472
|
+
nodes: All concept nodes.
|
|
1473
|
+
node_by_id: Lookup dict by node ID.
|
|
1474
|
+
|
|
1475
|
+
Returns:
|
|
1476
|
+
List of part_of edges from epic to release.
|
|
1477
|
+
"""
|
|
1478
|
+
edges: list[GraphEdge] = []
|
|
1479
|
+
|
|
1480
|
+
for node in nodes:
|
|
1481
|
+
if node.type != "release":
|
|
1482
|
+
continue
|
|
1483
|
+
|
|
1484
|
+
epic_refs: Any = node.metadata.get("epics", [])
|
|
1485
|
+
if not isinstance(epic_refs, list):
|
|
1486
|
+
continue
|
|
1487
|
+
|
|
1488
|
+
for epic_ref in cast(list[str], epic_refs):
|
|
1489
|
+
epic_id = f"epic-{epic_ref.lower()}"
|
|
1490
|
+
if epic_id in node_by_id:
|
|
1491
|
+
edges.append(
|
|
1492
|
+
GraphEdge(
|
|
1493
|
+
source=epic_id,
|
|
1494
|
+
target=node.id,
|
|
1495
|
+
type="part_of",
|
|
1496
|
+
weight=1.0,
|
|
1497
|
+
)
|
|
1498
|
+
)
|
|
1499
|
+
|
|
1500
|
+
return edges
|
|
1501
|
+
|
|
1502
|
+
def _infer_keyword_relationships(
|
|
1503
|
+
self,
|
|
1504
|
+
nodes: list[GraphNode],
|
|
1505
|
+
) -> list[GraphEdge]:
|
|
1506
|
+
"""Infer related_to edges from shared keywords.
|
|
1507
|
+
|
|
1508
|
+
Args:
|
|
1509
|
+
nodes: All concept nodes.
|
|
1510
|
+
|
|
1511
|
+
Returns:
|
|
1512
|
+
List of keyword-based relationship edges.
|
|
1513
|
+
"""
|
|
1514
|
+
edges: list[GraphEdge] = []
|
|
1515
|
+
|
|
1516
|
+
# Extract keywords for each node
|
|
1517
|
+
node_keywords: dict[str, set[str]] = {}
|
|
1518
|
+
for node in nodes:
|
|
1519
|
+
keywords = self._extract_keywords(node)
|
|
1520
|
+
if keywords:
|
|
1521
|
+
node_keywords[node.id] = keywords
|
|
1522
|
+
|
|
1523
|
+
# Find pairs with shared keywords (at least 2)
|
|
1524
|
+
node_ids = list(node_keywords.keys())
|
|
1525
|
+
for i, id1 in enumerate(node_ids):
|
|
1526
|
+
for id2 in node_ids[i + 1 :]:
|
|
1527
|
+
shared = node_keywords[id1] & node_keywords[id2]
|
|
1528
|
+
if len(shared) >= 2:
|
|
1529
|
+
edges.append(
|
|
1530
|
+
GraphEdge(
|
|
1531
|
+
source=id1,
|
|
1532
|
+
target=id2,
|
|
1533
|
+
type="related_to",
|
|
1534
|
+
weight=0.5,
|
|
1535
|
+
metadata={"shared_keywords": list(shared)},
|
|
1536
|
+
)
|
|
1537
|
+
)
|
|
1538
|
+
|
|
1539
|
+
return edges
|
|
1540
|
+
|
|
1541
|
+
def _extract_keywords(self, node: GraphNode) -> set[str]:
|
|
1542
|
+
"""Extract keywords from a concept node.
|
|
1543
|
+
|
|
1544
|
+
Args:
|
|
1545
|
+
node: Concept node to extract keywords from.
|
|
1546
|
+
|
|
1547
|
+
Returns:
|
|
1548
|
+
Set of lowercase keywords.
|
|
1549
|
+
"""
|
|
1550
|
+
keywords: set[str] = set()
|
|
1551
|
+
|
|
1552
|
+
# From content
|
|
1553
|
+
if node.content:
|
|
1554
|
+
words = node.content.lower().split()
|
|
1555
|
+
for word in words:
|
|
1556
|
+
# Clean word (keep only alphanumeric)
|
|
1557
|
+
clean = "".join(c for c in word if c.isalnum())
|
|
1558
|
+
if len(clean) >= 4 and clean not in STOPWORDS:
|
|
1559
|
+
keywords.add(clean)
|
|
1560
|
+
|
|
1561
|
+
# From context metadata (for patterns)
|
|
1562
|
+
context_value: Any = node.metadata.get("context", [])
|
|
1563
|
+
if isinstance(context_value, list):
|
|
1564
|
+
context_list = cast(list[Any], context_value)
|
|
1565
|
+
for ctx in context_list:
|
|
1566
|
+
if isinstance(ctx, str):
|
|
1567
|
+
keywords.add(ctx.lower())
|
|
1568
|
+
|
|
1569
|
+
return keywords
|