htmlgraph 0.20.1__py3-none-any.whl → 0.27.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
  2. htmlgraph/.htmlgraph/agents.json +72 -0
  3. htmlgraph/.htmlgraph/htmlgraph.db +0 -0
  4. htmlgraph/__init__.py +51 -1
  5. htmlgraph/__init__.pyi +123 -0
  6. htmlgraph/agent_detection.py +26 -10
  7. htmlgraph/agent_registry.py +2 -1
  8. htmlgraph/analytics/__init__.py +8 -1
  9. htmlgraph/analytics/cli.py +86 -20
  10. htmlgraph/analytics/cost_analyzer.py +391 -0
  11. htmlgraph/analytics/cost_monitor.py +664 -0
  12. htmlgraph/analytics/cost_reporter.py +675 -0
  13. htmlgraph/analytics/cross_session.py +617 -0
  14. htmlgraph/analytics/dependency.py +10 -6
  15. htmlgraph/analytics/pattern_learning.py +771 -0
  16. htmlgraph/analytics/session_graph.py +707 -0
  17. htmlgraph/analytics/strategic/__init__.py +80 -0
  18. htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
  19. htmlgraph/analytics/strategic/pattern_detector.py +876 -0
  20. htmlgraph/analytics/strategic/preference_manager.py +709 -0
  21. htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
  22. htmlgraph/analytics/work_type.py +67 -27
  23. htmlgraph/analytics_index.py +53 -20
  24. htmlgraph/api/__init__.py +3 -0
  25. htmlgraph/api/cost_alerts_websocket.py +416 -0
  26. htmlgraph/api/main.py +2498 -0
  27. htmlgraph/api/static/htmx.min.js +1 -0
  28. htmlgraph/api/static/style-redesign.css +1344 -0
  29. htmlgraph/api/static/style.css +1079 -0
  30. htmlgraph/api/templates/dashboard-redesign.html +1366 -0
  31. htmlgraph/api/templates/dashboard.html +794 -0
  32. htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
  33. htmlgraph/api/templates/partials/activity-feed.html +1100 -0
  34. htmlgraph/api/templates/partials/agents-redesign.html +317 -0
  35. htmlgraph/api/templates/partials/agents.html +317 -0
  36. htmlgraph/api/templates/partials/event-traces.html +373 -0
  37. htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
  38. htmlgraph/api/templates/partials/features.html +578 -0
  39. htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
  40. htmlgraph/api/templates/partials/metrics.html +346 -0
  41. htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
  42. htmlgraph/api/templates/partials/orchestration.html +198 -0
  43. htmlgraph/api/templates/partials/spawners.html +375 -0
  44. htmlgraph/api/templates/partials/work-items.html +613 -0
  45. htmlgraph/api/websocket.py +538 -0
  46. htmlgraph/archive/__init__.py +24 -0
  47. htmlgraph/archive/bloom.py +234 -0
  48. htmlgraph/archive/fts.py +297 -0
  49. htmlgraph/archive/manager.py +583 -0
  50. htmlgraph/archive/search.py +244 -0
  51. htmlgraph/atomic_ops.py +560 -0
  52. htmlgraph/attribute_index.py +2 -1
  53. htmlgraph/bounded_paths.py +539 -0
  54. htmlgraph/builders/base.py +57 -2
  55. htmlgraph/builders/bug.py +19 -3
  56. htmlgraph/builders/chore.py +19 -3
  57. htmlgraph/builders/epic.py +19 -3
  58. htmlgraph/builders/feature.py +27 -3
  59. htmlgraph/builders/insight.py +2 -1
  60. htmlgraph/builders/metric.py +2 -1
  61. htmlgraph/builders/pattern.py +2 -1
  62. htmlgraph/builders/phase.py +19 -3
  63. htmlgraph/builders/spike.py +29 -3
  64. htmlgraph/builders/track.py +42 -1
  65. htmlgraph/cigs/__init__.py +81 -0
  66. htmlgraph/cigs/autonomy.py +385 -0
  67. htmlgraph/cigs/cost.py +475 -0
  68. htmlgraph/cigs/messages_basic.py +472 -0
  69. htmlgraph/cigs/messaging.py +365 -0
  70. htmlgraph/cigs/models.py +771 -0
  71. htmlgraph/cigs/pattern_storage.py +427 -0
  72. htmlgraph/cigs/patterns.py +503 -0
  73. htmlgraph/cigs/posttool_analyzer.py +234 -0
  74. htmlgraph/cigs/reporter.py +818 -0
  75. htmlgraph/cigs/tracker.py +317 -0
  76. htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
  77. htmlgraph/cli/.htmlgraph/agents.json +72 -0
  78. htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
  79. htmlgraph/cli/__init__.py +42 -0
  80. htmlgraph/cli/__main__.py +6 -0
  81. htmlgraph/cli/analytics.py +1424 -0
  82. htmlgraph/cli/base.py +685 -0
  83. htmlgraph/cli/constants.py +206 -0
  84. htmlgraph/cli/core.py +954 -0
  85. htmlgraph/cli/main.py +147 -0
  86. htmlgraph/cli/models.py +475 -0
  87. htmlgraph/cli/templates/__init__.py +1 -0
  88. htmlgraph/cli/templates/cost_dashboard.py +399 -0
  89. htmlgraph/cli/work/__init__.py +239 -0
  90. htmlgraph/cli/work/browse.py +115 -0
  91. htmlgraph/cli/work/features.py +568 -0
  92. htmlgraph/cli/work/orchestration.py +676 -0
  93. htmlgraph/cli/work/report.py +728 -0
  94. htmlgraph/cli/work/sessions.py +466 -0
  95. htmlgraph/cli/work/snapshot.py +559 -0
  96. htmlgraph/cli/work/tracks.py +486 -0
  97. htmlgraph/cli_commands/__init__.py +1 -0
  98. htmlgraph/cli_commands/feature.py +195 -0
  99. htmlgraph/cli_framework.py +115 -0
  100. htmlgraph/collections/__init__.py +2 -0
  101. htmlgraph/collections/base.py +197 -14
  102. htmlgraph/collections/bug.py +2 -1
  103. htmlgraph/collections/chore.py +2 -1
  104. htmlgraph/collections/epic.py +2 -1
  105. htmlgraph/collections/feature.py +2 -1
  106. htmlgraph/collections/insight.py +2 -1
  107. htmlgraph/collections/metric.py +2 -1
  108. htmlgraph/collections/pattern.py +2 -1
  109. htmlgraph/collections/phase.py +2 -1
  110. htmlgraph/collections/session.py +194 -0
  111. htmlgraph/collections/spike.py +13 -2
  112. htmlgraph/collections/task_delegation.py +241 -0
  113. htmlgraph/collections/todo.py +14 -1
  114. htmlgraph/collections/traces.py +487 -0
  115. htmlgraph/config/cost_models.json +56 -0
  116. htmlgraph/config.py +190 -0
  117. htmlgraph/context_analytics.py +2 -1
  118. htmlgraph/converter.py +116 -7
  119. htmlgraph/cost_analysis/__init__.py +5 -0
  120. htmlgraph/cost_analysis/analyzer.py +438 -0
  121. htmlgraph/dashboard.html +2246 -248
  122. htmlgraph/dashboard.html.backup +6592 -0
  123. htmlgraph/dashboard.html.bak +7181 -0
  124. htmlgraph/dashboard.html.bak2 +7231 -0
  125. htmlgraph/dashboard.html.bak3 +7232 -0
  126. htmlgraph/db/__init__.py +38 -0
  127. htmlgraph/db/queries.py +790 -0
  128. htmlgraph/db/schema.py +1788 -0
  129. htmlgraph/decorators.py +317 -0
  130. htmlgraph/dependency_models.py +2 -1
  131. htmlgraph/deploy.py +26 -27
  132. htmlgraph/docs/API_REFERENCE.md +841 -0
  133. htmlgraph/docs/HTTP_API.md +750 -0
  134. htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
  135. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
  136. htmlgraph/docs/README.md +532 -0
  137. htmlgraph/docs/__init__.py +77 -0
  138. htmlgraph/docs/docs_version.py +55 -0
  139. htmlgraph/docs/metadata.py +93 -0
  140. htmlgraph/docs/migrations.py +232 -0
  141. htmlgraph/docs/template_engine.py +143 -0
  142. htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
  143. htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
  144. htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
  145. htmlgraph/docs/templates/base_agents.md.j2 +78 -0
  146. htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
  147. htmlgraph/docs/version_check.py +163 -0
  148. htmlgraph/edge_index.py +2 -1
  149. htmlgraph/error_handler.py +544 -0
  150. htmlgraph/event_log.py +86 -37
  151. htmlgraph/event_migration.py +2 -1
  152. htmlgraph/file_watcher.py +12 -8
  153. htmlgraph/find_api.py +2 -1
  154. htmlgraph/git_events.py +67 -9
  155. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  156. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  157. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  158. htmlgraph/hooks/__init__.py +8 -0
  159. htmlgraph/hooks/bootstrap.py +169 -0
  160. htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
  161. htmlgraph/hooks/concurrent_sessions.py +208 -0
  162. htmlgraph/hooks/context.py +350 -0
  163. htmlgraph/hooks/drift_handler.py +525 -0
  164. htmlgraph/hooks/event_tracker.py +790 -99
  165. htmlgraph/hooks/git_commands.py +175 -0
  166. htmlgraph/hooks/installer.py +5 -1
  167. htmlgraph/hooks/orchestrator.py +327 -76
  168. htmlgraph/hooks/orchestrator_reflector.py +31 -4
  169. htmlgraph/hooks/post_tool_use_failure.py +32 -7
  170. htmlgraph/hooks/post_tool_use_handler.py +257 -0
  171. htmlgraph/hooks/posttooluse.py +92 -19
  172. htmlgraph/hooks/pretooluse.py +527 -7
  173. htmlgraph/hooks/prompt_analyzer.py +637 -0
  174. htmlgraph/hooks/session_handler.py +668 -0
  175. htmlgraph/hooks/session_summary.py +395 -0
  176. htmlgraph/hooks/state_manager.py +504 -0
  177. htmlgraph/hooks/subagent_detection.py +202 -0
  178. htmlgraph/hooks/subagent_stop.py +369 -0
  179. htmlgraph/hooks/task_enforcer.py +99 -4
  180. htmlgraph/hooks/validator.py +212 -91
  181. htmlgraph/ids.py +2 -1
  182. htmlgraph/learning.py +125 -100
  183. htmlgraph/mcp_server.py +2 -1
  184. htmlgraph/models.py +217 -18
  185. htmlgraph/operations/README.md +62 -0
  186. htmlgraph/operations/__init__.py +79 -0
  187. htmlgraph/operations/analytics.py +339 -0
  188. htmlgraph/operations/bootstrap.py +289 -0
  189. htmlgraph/operations/events.py +244 -0
  190. htmlgraph/operations/fastapi_server.py +231 -0
  191. htmlgraph/operations/hooks.py +350 -0
  192. htmlgraph/operations/initialization.py +597 -0
  193. htmlgraph/operations/initialization.py.backup +228 -0
  194. htmlgraph/operations/server.py +303 -0
  195. htmlgraph/orchestration/__init__.py +58 -0
  196. htmlgraph/orchestration/claude_launcher.py +179 -0
  197. htmlgraph/orchestration/command_builder.py +72 -0
  198. htmlgraph/orchestration/headless_spawner.py +281 -0
  199. htmlgraph/orchestration/live_events.py +377 -0
  200. htmlgraph/orchestration/model_selection.py +327 -0
  201. htmlgraph/orchestration/plugin_manager.py +140 -0
  202. htmlgraph/orchestration/prompts.py +137 -0
  203. htmlgraph/orchestration/spawner_event_tracker.py +383 -0
  204. htmlgraph/orchestration/spawners/__init__.py +16 -0
  205. htmlgraph/orchestration/spawners/base.py +194 -0
  206. htmlgraph/orchestration/spawners/claude.py +173 -0
  207. htmlgraph/orchestration/spawners/codex.py +435 -0
  208. htmlgraph/orchestration/spawners/copilot.py +294 -0
  209. htmlgraph/orchestration/spawners/gemini.py +471 -0
  210. htmlgraph/orchestration/subprocess_runner.py +36 -0
  211. htmlgraph/{orchestration.py → orchestration/task_coordination.py} +16 -8
  212. htmlgraph/orchestration.md +563 -0
  213. htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
  214. htmlgraph/orchestrator.py +2 -1
  215. htmlgraph/orchestrator_config.py +357 -0
  216. htmlgraph/orchestrator_mode.py +115 -4
  217. htmlgraph/parallel.py +2 -1
  218. htmlgraph/parser.py +86 -6
  219. htmlgraph/path_query.py +608 -0
  220. htmlgraph/pattern_matcher.py +636 -0
  221. htmlgraph/pydantic_models.py +476 -0
  222. htmlgraph/quality_gates.py +350 -0
  223. htmlgraph/query_builder.py +2 -1
  224. htmlgraph/query_composer.py +509 -0
  225. htmlgraph/reflection.py +443 -0
  226. htmlgraph/refs.py +344 -0
  227. htmlgraph/repo_hash.py +512 -0
  228. htmlgraph/repositories/__init__.py +292 -0
  229. htmlgraph/repositories/analytics_repository.py +455 -0
  230. htmlgraph/repositories/analytics_repository_standard.py +628 -0
  231. htmlgraph/repositories/feature_repository.py +581 -0
  232. htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
  233. htmlgraph/repositories/feature_repository_memory.py +607 -0
  234. htmlgraph/repositories/feature_repository_sqlite.py +858 -0
  235. htmlgraph/repositories/filter_service.py +620 -0
  236. htmlgraph/repositories/filter_service_standard.py +445 -0
  237. htmlgraph/repositories/shared_cache.py +621 -0
  238. htmlgraph/repositories/shared_cache_memory.py +395 -0
  239. htmlgraph/repositories/track_repository.py +552 -0
  240. htmlgraph/repositories/track_repository_htmlfile.py +619 -0
  241. htmlgraph/repositories/track_repository_memory.py +508 -0
  242. htmlgraph/repositories/track_repository_sqlite.py +711 -0
  243. htmlgraph/sdk/__init__.py +398 -0
  244. htmlgraph/sdk/__init__.pyi +14 -0
  245. htmlgraph/sdk/analytics/__init__.py +19 -0
  246. htmlgraph/sdk/analytics/engine.py +155 -0
  247. htmlgraph/sdk/analytics/helpers.py +178 -0
  248. htmlgraph/sdk/analytics/registry.py +109 -0
  249. htmlgraph/sdk/base.py +484 -0
  250. htmlgraph/sdk/constants.py +216 -0
  251. htmlgraph/sdk/core.pyi +308 -0
  252. htmlgraph/sdk/discovery.py +120 -0
  253. htmlgraph/sdk/help/__init__.py +12 -0
  254. htmlgraph/sdk/help/mixin.py +699 -0
  255. htmlgraph/sdk/mixins/__init__.py +15 -0
  256. htmlgraph/sdk/mixins/attribution.py +113 -0
  257. htmlgraph/sdk/mixins/mixin.py +410 -0
  258. htmlgraph/sdk/operations/__init__.py +12 -0
  259. htmlgraph/sdk/operations/mixin.py +427 -0
  260. htmlgraph/sdk/orchestration/__init__.py +17 -0
  261. htmlgraph/sdk/orchestration/coordinator.py +203 -0
  262. htmlgraph/sdk/orchestration/spawner.py +204 -0
  263. htmlgraph/sdk/planning/__init__.py +19 -0
  264. htmlgraph/sdk/planning/bottlenecks.py +93 -0
  265. htmlgraph/sdk/planning/mixin.py +211 -0
  266. htmlgraph/sdk/planning/parallel.py +186 -0
  267. htmlgraph/sdk/planning/queue.py +210 -0
  268. htmlgraph/sdk/planning/recommendations.py +87 -0
  269. htmlgraph/sdk/planning/smart_planning.py +319 -0
  270. htmlgraph/sdk/session/__init__.py +19 -0
  271. htmlgraph/sdk/session/continuity.py +57 -0
  272. htmlgraph/sdk/session/handoff.py +110 -0
  273. htmlgraph/sdk/session/info.py +309 -0
  274. htmlgraph/sdk/session/manager.py +103 -0
  275. htmlgraph/sdk/strategic/__init__.py +26 -0
  276. htmlgraph/sdk/strategic/mixin.py +563 -0
  277. htmlgraph/server.py +295 -107
  278. htmlgraph/session_hooks.py +300 -0
  279. htmlgraph/session_manager.py +285 -3
  280. htmlgraph/session_registry.py +587 -0
  281. htmlgraph/session_state.py +436 -0
  282. htmlgraph/session_warning.py +2 -1
  283. htmlgraph/sessions/__init__.py +23 -0
  284. htmlgraph/sessions/handoff.py +756 -0
  285. htmlgraph/system_prompts.py +450 -0
  286. htmlgraph/templates/orchestration-view.html +350 -0
  287. htmlgraph/track_builder.py +33 -1
  288. htmlgraph/track_manager.py +38 -0
  289. htmlgraph/transcript.py +18 -5
  290. htmlgraph/validation.py +115 -0
  291. htmlgraph/watch.py +2 -1
  292. htmlgraph/work_type_utils.py +2 -1
  293. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2246 -248
  294. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +95 -64
  295. htmlgraph-0.27.5.dist-info/RECORD +337 -0
  296. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
  297. htmlgraph/cli.py +0 -4839
  298. htmlgraph/sdk.py +0 -2359
  299. htmlgraph-0.20.1.dist-info/RECORD +0 -118
  300. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
  301. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  302. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  303. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  304. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
@@ -0,0 +1,608 @@
1
+ """
2
+ PathQuery DSL - Declarative Path Expression Language for HtmlGraph.
3
+
4
+ Provides a SQL/PGQ-inspired MATCH syntax for expressing graph traversal
5
+ patterns as declarative path expressions. Compiles path expressions into
6
+ calls against existing HtmlGraph graph methods and edge index lookups.
7
+
8
+ Syntax examples:
9
+ # Single-hop: find all features blocked by another feature
10
+ "(Feature)-[blocked_by]->(Feature)"
11
+
12
+ # Variable-length: find all transitive dependencies
13
+ "(Feature)-[depends_on]->+(Feature)"
14
+
15
+ # With node filter: find blocked high-priority features
16
+ "(Feature WHERE status='blocked')-[blocked_by]->(Feature WHERE priority='high')"
17
+
18
+ # Reverse direction
19
+ "(Feature)<-[blocks]-(Feature)"
20
+
21
+ # Multi-hop pattern
22
+ "(Session)-[touches]->(Feature)<-[blocked_by]-(Feature)"
23
+
24
+ # Any shortest path
25
+ "(Feature)-[blocked_by]->*(Feature)"
26
+
27
+ Usage:
28
+ from htmlgraph.path_query import PathQueryEngine
29
+
30
+ engine = PathQueryEngine()
31
+ results = engine.execute(graph, "(Feature)-[blocked_by]->(Feature)")
32
+ for result in results:
33
+ print(result.nodes, result.path_length)
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import re
39
+ from dataclasses import dataclass, field
40
+ from typing import TYPE_CHECKING
41
+
42
+ if TYPE_CHECKING:
43
+ from htmlgraph.graph import HtmlGraph
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Data models
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ @dataclass
52
+ class WhereClause:
53
+ """A single WHERE filter condition on a node pattern.
54
+
55
+ Attributes:
56
+ attribute: The node attribute name to filter on (e.g. 'status', 'priority').
57
+ value: The expected value (string comparison).
58
+ """
59
+
60
+ attribute: str
61
+ value: str
62
+
63
+
64
+ @dataclass
65
+ class NodePattern:
66
+ """Parsed representation of a node pattern in a path expression.
67
+
68
+ A node pattern looks like ``(Label)`` or ``(Label WHERE attr='val')``.
69
+
70
+ Attributes:
71
+ label: Optional node type label (e.g. 'Feature', 'Session').
72
+ An empty string means *any* node type.
73
+ filters: List of WHERE clause conditions to apply.
74
+ """
75
+
76
+ label: str = ""
77
+ filters: list[WhereClause] = field(default_factory=list)
78
+
79
+
80
+ @dataclass
81
+ class EdgePattern:
82
+ """Parsed representation of an edge pattern in a path expression.
83
+
84
+ An edge pattern looks like ``-[rel_type]->`` or ``<-[rel_type]-``.
85
+
86
+ Attributes:
87
+ relationship: The edge relationship type (e.g. 'blocked_by').
88
+ direction: ``'outgoing'`` for ``->``, ``'incoming'`` for ``<-``.
89
+ quantifier: ``None`` for single-hop, ``'+'`` for one-or-more
90
+ (transitive), ``'*'`` for zero-or-more (shortest path),
91
+ ``'?'`` for zero-or-one (optional).
92
+ """
93
+
94
+ relationship: str
95
+ direction: str = "outgoing" # "outgoing" (->) or "incoming" (<-)
96
+ quantifier: str | None = None # None, "+", "*", "?"
97
+
98
+
99
+ @dataclass
100
+ class PathExpression:
101
+ """Fully parsed path expression.
102
+
103
+ A path expression is an alternating sequence of *node patterns* and
104
+ *edge patterns*: ``node edge node [edge node ...]``.
105
+
106
+ Attributes:
107
+ nodes: Ordered list of node patterns.
108
+ edges: Ordered list of edge patterns. ``len(edges) == len(nodes) - 1``.
109
+ raw: The original expression string.
110
+ """
111
+
112
+ nodes: list[NodePattern] = field(default_factory=list)
113
+ edges: list[EdgePattern] = field(default_factory=list)
114
+ raw: str = ""
115
+
116
+
117
+ @dataclass
118
+ class PathResult:
119
+ """A single result from executing a path query.
120
+
121
+ Attributes:
122
+ nodes: The list of node IDs that form the matched path.
123
+ path_length: Number of hops (edges) in the path.
124
+ bindings: Mapping from pattern index to matched node ID(s).
125
+ """
126
+
127
+ nodes: list[str] = field(default_factory=list)
128
+ path_length: int = 0
129
+ bindings: dict[int, list[str]] = field(default_factory=dict)
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Parser
134
+ # ---------------------------------------------------------------------------
135
+
136
+ # Regex patterns for tokenising the DSL string.
137
+ _NODE_PATTERN = re.compile(
138
+ r"""\(
139
+ \s*
140
+ (?P<label>[A-Za-z_][A-Za-z0-9_]*)? # optional label
141
+ \s*
142
+ (?:WHERE\s+(?P<where>.+?))? # optional WHERE clause
143
+ \s*
144
+ \)""",
145
+ re.VERBOSE,
146
+ )
147
+
148
+ _WHERE_CONDITION = re.compile(
149
+ r"""(?P<attr>[A-Za-z_][A-Za-z0-9_.]*)\s*=\s*'(?P<val>[^']*)'""",
150
+ re.VERBOSE,
151
+ )
152
+
153
+ # Edge patterns. Arrows can appear in two forms:
154
+ # outgoing: -[rel]-> with optional quantifier after >
155
+ # incoming: <-[rel]- with optional quantifier after second -
156
+ _EDGE_OUTGOING = re.compile(
157
+ r"""-\[
158
+ \s*(?P<rel>[A-Za-z_][A-Za-z0-9_]*)\s*
159
+ \]->(?P<quant>[+*?])?""",
160
+ re.VERBOSE,
161
+ )
162
+
163
+ _EDGE_INCOMING = re.compile(
164
+ r"""<-\[
165
+ \s*(?P<rel>[A-Za-z_][A-Za-z0-9_]*)\s*
166
+ \]-(?P<quant>[+*?])?""",
167
+ re.VERBOSE,
168
+ )
169
+
170
+
171
+ class PathQueryError(Exception):
172
+ """Raised when a path expression cannot be parsed or executed."""
173
+
174
+
175
+ class PathQueryParser:
176
+ """Parses a path expression string into a :class:`PathExpression`.
177
+
178
+ The parser works by tokenising the input from left to right, alternating
179
+ between node patterns and edge patterns.
180
+
181
+ Example::
182
+
183
+ parser = PathQueryParser()
184
+ expr = parser.parse("(Feature)-[blocked_by]->(Feature)")
185
+ """
186
+
187
+ def parse(self, expression: str) -> PathExpression:
188
+ """Parse a path expression string.
189
+
190
+ Args:
191
+ expression: The path expression DSL string.
192
+
193
+ Returns:
194
+ A fully parsed :class:`PathExpression`.
195
+
196
+ Raises:
197
+ PathQueryError: If the expression is syntactically invalid.
198
+ """
199
+ expression = expression.strip()
200
+ if not expression:
201
+ raise PathQueryError("Empty path expression")
202
+
203
+ result = PathExpression(raw=expression)
204
+ pos = 0
205
+ expecting_node = True
206
+
207
+ while pos < len(expression):
208
+ # Skip whitespace
209
+ while pos < len(expression) and expression[pos].isspace():
210
+ pos += 1
211
+ if pos >= len(expression):
212
+ break
213
+
214
+ if expecting_node:
215
+ node, end = self._parse_node(expression, pos)
216
+ result.nodes.append(node)
217
+ pos = end
218
+ expecting_node = False
219
+ else:
220
+ edge, end = self._parse_edge(expression, pos)
221
+ result.edges.append(edge)
222
+ pos = end
223
+ expecting_node = True
224
+
225
+ # Validate structure: must have at least 2 nodes and 1 edge
226
+ if len(result.nodes) < 2:
227
+ raise PathQueryError(
228
+ f"Path expression must have at least two node patterns "
229
+ f"and one edge pattern, got {len(result.nodes)} node(s) "
230
+ f"and {len(result.edges)} edge(s): {expression!r}"
231
+ )
232
+ if len(result.edges) != len(result.nodes) - 1:
233
+ raise PathQueryError(
234
+ f"Mismatched nodes and edges: {len(result.nodes)} nodes, "
235
+ f"{len(result.edges)} edges in: {expression!r}"
236
+ )
237
+
238
+ return result
239
+
240
+ # -- private helpers ---------------------------------------------------
241
+
242
+ def _parse_node(self, expr: str, pos: int) -> tuple[NodePattern, int]:
243
+ """Parse a node pattern starting at *pos*.
244
+
245
+ Returns:
246
+ Tuple of (NodePattern, end_position).
247
+ """
248
+ m = _NODE_PATTERN.match(expr, pos)
249
+ if not m:
250
+ raise PathQueryError(
251
+ f"Expected node pattern at position {pos}: {expr[pos : pos + 30]!r}..."
252
+ )
253
+ label = m.group("label") or ""
254
+ where_str = m.group("where") or ""
255
+ filters = self._parse_where(where_str)
256
+ return NodePattern(label=label, filters=filters), m.end()
257
+
258
+ def _parse_edge(self, expr: str, pos: int) -> tuple[EdgePattern, int]:
259
+ """Parse an edge pattern starting at *pos*.
260
+
261
+ Returns:
262
+ Tuple of (EdgePattern, end_position).
263
+ """
264
+ # Try outgoing first
265
+ m = _EDGE_OUTGOING.match(expr, pos)
266
+ if m:
267
+ return (
268
+ EdgePattern(
269
+ relationship=m.group("rel"),
270
+ direction="outgoing",
271
+ quantifier=m.group("quant") or None,
272
+ ),
273
+ m.end(),
274
+ )
275
+
276
+ # Try incoming
277
+ m = _EDGE_INCOMING.match(expr, pos)
278
+ if m:
279
+ return (
280
+ EdgePattern(
281
+ relationship=m.group("rel"),
282
+ direction="incoming",
283
+ quantifier=m.group("quant") or None,
284
+ ),
285
+ m.end(),
286
+ )
287
+
288
+ raise PathQueryError(
289
+ f"Expected edge pattern at position {pos}: {expr[pos : pos + 30]!r}..."
290
+ )
291
+
292
+ @staticmethod
293
+ def _parse_where(where_str: str) -> list[WhereClause]:
294
+ """Parse the body of a WHERE clause into a list of conditions.
295
+
296
+ Supports ``AND``-separated conditions like
297
+ ``status='blocked' AND priority='high'``.
298
+ """
299
+ if not where_str.strip():
300
+ return []
301
+
302
+ clauses: list[WhereClause] = []
303
+ # Split on AND (case-insensitive)
304
+ parts = re.split(r"\s+AND\s+", where_str, flags=re.IGNORECASE)
305
+ for part in parts:
306
+ m = _WHERE_CONDITION.match(part.strip())
307
+ if not m:
308
+ raise PathQueryError(
309
+ f"Invalid WHERE condition: {part.strip()!r}. "
310
+ f"Expected format: attribute='value'"
311
+ )
312
+ clauses.append(WhereClause(attribute=m.group("attr"), value=m.group("val")))
313
+ return clauses
314
+
315
+
316
+ # ---------------------------------------------------------------------------
317
+ # Engine
318
+ # ---------------------------------------------------------------------------
319
+
320
+
321
+ class PathQueryEngine:
322
+ """Executes parsed :class:`PathExpression` objects against an HtmlGraph.
323
+
324
+ The engine maps DSL constructs to existing HtmlGraph operations:
325
+
326
+ * **Single-hop** edges use ``EdgeIndex.get_outgoing`` /
327
+ ``EdgeIndex.get_incoming`` for O(1) neighbour lookups.
328
+ * **Variable-length ``+``** (one-or-more) uses
329
+ ``HtmlGraph.transitive_deps`` or ``HtmlGraph.ancestors``.
330
+ * **Variable-length ``*``** (zero-or-more / shortest) uses
331
+ ``HtmlGraph.shortest_path``.
332
+ * **Optional ``?``** returns the direct hop if it exists, or the
333
+ source node alone otherwise.
334
+
335
+ Example::
336
+
337
+ engine = PathQueryEngine()
338
+ results = engine.execute(graph, "(Feature)-[blocked_by]->(Feature)")
339
+ """
340
+
341
+ def __init__(self) -> None:
342
+ self._parser = PathQueryParser()
343
+
344
+ def execute(
345
+ self, graph: HtmlGraph, expression: str | PathExpression
346
+ ) -> list[PathResult]:
347
+ """Execute a path expression against a graph.
348
+
349
+ Args:
350
+ graph: The :class:`HtmlGraph` instance to query.
351
+ expression: Either a DSL string or a pre-parsed
352
+ :class:`PathExpression`.
353
+
354
+ Returns:
355
+ List of :class:`PathResult` objects for every matching path.
356
+
357
+ Raises:
358
+ PathQueryError: If the expression is invalid or cannot be
359
+ executed.
360
+ """
361
+ if isinstance(expression, str):
362
+ expr = self._parser.parse(expression)
363
+ else:
364
+ expr = expression
365
+
366
+ # Ensure graph data is loaded
367
+ graph._ensure_loaded() # noqa: SLF001 — accessing private for lazy-load
368
+
369
+ # Seed: find all candidate start nodes
370
+ start_candidates = self._match_node_pattern(graph, expr.nodes[0])
371
+
372
+ # Walk each hop, expanding candidates
373
+ results: list[PathResult] = []
374
+ for start_id in start_candidates:
375
+ partial_paths: list[list[str]] = [[start_id]]
376
+ for hop_idx, edge_pat in enumerate(expr.edges):
377
+ target_node_pat = expr.nodes[hop_idx + 1]
378
+ next_partial: list[list[str]] = []
379
+
380
+ for path in partial_paths:
381
+ current_id = path[-1]
382
+ expanded = self._expand_hop(
383
+ graph, current_id, edge_pat, target_node_pat
384
+ )
385
+ for ext in expanded:
386
+ next_partial.append(path + ext)
387
+
388
+ partial_paths = next_partial
389
+
390
+ # Convert successful full paths to PathResult
391
+ for path in partial_paths:
392
+ results.append(
393
+ PathResult(
394
+ nodes=path,
395
+ path_length=len(path) - 1,
396
+ bindings={i: [nid] for i, nid in enumerate(path)},
397
+ )
398
+ )
399
+
400
+ return results
401
+
402
+ # -- private helpers ---------------------------------------------------
403
+
404
+ def _match_node_pattern(self, graph: HtmlGraph, pattern: NodePattern) -> list[str]:
405
+ """Return IDs of all nodes matching a :class:`NodePattern`."""
406
+ candidates: list[str] = []
407
+ for node_id, node in graph.nodes.items():
408
+ if pattern.label and node.type.lower() != pattern.label.lower():
409
+ continue
410
+ if not self._check_filters(node, pattern.filters):
411
+ continue
412
+ candidates.append(node_id)
413
+ return candidates
414
+
415
+ @staticmethod
416
+ def _check_filters(node: object, filters: list[WhereClause]) -> bool:
417
+ """Check whether *node* satisfies all WHERE filters."""
418
+ for f in filters:
419
+ val = _resolve_attribute(node, f.attribute)
420
+ if val is None or str(val) != f.value:
421
+ return False
422
+ return True
423
+
424
+ def _expand_hop(
425
+ self,
426
+ graph: HtmlGraph,
427
+ current_id: str,
428
+ edge_pat: EdgePattern,
429
+ target_pat: NodePattern,
430
+ ) -> list[list[str]]:
431
+ """Expand one hop from *current_id* following *edge_pat*.
432
+
433
+ Returns a list of path *extensions* (each a list of node IDs
434
+ **not** including *current_id* itself).
435
+ """
436
+ quant = edge_pat.quantifier
437
+
438
+ if quant is None:
439
+ return self._expand_single(graph, current_id, edge_pat, target_pat)
440
+ elif quant == "+":
441
+ return self._expand_transitive(graph, current_id, edge_pat, target_pat)
442
+ elif quant == "*":
443
+ return self._expand_shortest(graph, current_id, edge_pat, target_pat)
444
+ elif quant == "?":
445
+ return self._expand_optional(graph, current_id, edge_pat, target_pat)
446
+ else:
447
+ raise PathQueryError(f"Unknown quantifier: {quant!r}")
448
+
449
+ # -- single hop --------------------------------------------------------
450
+
451
+ def _expand_single(
452
+ self,
453
+ graph: HtmlGraph,
454
+ current_id: str,
455
+ edge_pat: EdgePattern,
456
+ target_pat: NodePattern,
457
+ ) -> list[list[str]]:
458
+ """Expand a single-hop (no quantifier) edge."""
459
+ neighbor_ids = self._direct_neighbors(graph, current_id, edge_pat)
460
+
461
+ extensions: list[list[str]] = []
462
+ for nid in neighbor_ids:
463
+ node = graph.get(nid)
464
+ if node is None:
465
+ continue
466
+ if target_pat.label and node.type.lower() != target_pat.label.lower():
467
+ continue
468
+ if not self._check_filters(node, target_pat.filters):
469
+ continue
470
+ extensions.append([nid])
471
+ return extensions
472
+
473
+ # -- transitive (one-or-more "+") --------------------------------------
474
+
475
+ def _expand_transitive(
476
+ self,
477
+ graph: HtmlGraph,
478
+ current_id: str,
479
+ edge_pat: EdgePattern,
480
+ target_pat: NodePattern,
481
+ ) -> list[list[str]]:
482
+ """Expand a variable-length ``+`` (one-or-more) edge.
483
+
484
+ Uses ``HtmlGraph.transitive_deps`` for outgoing edges and
485
+ ``HtmlGraph.ancestors`` for incoming (reversed) edges.
486
+ """
487
+ if edge_pat.direction == "outgoing":
488
+ reachable = graph.transitive_deps(current_id, edge_pat.relationship)
489
+ else:
490
+ reachable = set(graph.ancestors(current_id, edge_pat.relationship))
491
+
492
+ extensions: list[list[str]] = []
493
+ for nid in reachable:
494
+ node = graph.get(nid)
495
+ if node is None:
496
+ continue
497
+ if target_pat.label and node.type.lower() != target_pat.label.lower():
498
+ continue
499
+ if not self._check_filters(node, target_pat.filters):
500
+ continue
501
+ extensions.append([nid])
502
+ return extensions
503
+
504
+ # -- shortest (zero-or-more "*") ---------------------------------------
505
+
506
+ def _expand_shortest(
507
+ self,
508
+ graph: HtmlGraph,
509
+ current_id: str,
510
+ edge_pat: EdgePattern,
511
+ target_pat: NodePattern,
512
+ ) -> list[list[str]]:
513
+ """Expand ``*`` using ``HtmlGraph.shortest_path``.
514
+
515
+ Returns the shortest path to every reachable target node that
516
+ matches *target_pat*.
517
+ """
518
+ # Collect all candidates matching the target pattern
519
+ target_ids = self._match_node_pattern(graph, target_pat)
520
+
521
+ extensions: list[list[str]] = []
522
+ for tid in target_ids:
523
+ if tid == current_id:
524
+ # zero-length path (allowed by *)
525
+ extensions.append([tid])
526
+ continue
527
+
528
+ path = graph.shortest_path(
529
+ current_id, tid, relationship=edge_pat.relationship
530
+ )
531
+ if path is not None and len(path) >= 2:
532
+ # path includes current_id as first element; strip it
533
+ extensions.append(path[1:])
534
+
535
+ return extensions
536
+
537
+ # -- optional (zero-or-one "?") ----------------------------------------
538
+
539
+ def _expand_optional(
540
+ self,
541
+ graph: HtmlGraph,
542
+ current_id: str,
543
+ edge_pat: EdgePattern,
544
+ target_pat: NodePattern,
545
+ ) -> list[list[str]]:
546
+ """Expand ``?`` — match zero or one hop.
547
+
548
+ Returns direct-hop matches plus *current_id* itself if it matches
549
+ the target pattern (zero-hop case).
550
+ """
551
+ results = self._expand_single(graph, current_id, edge_pat, target_pat)
552
+
553
+ # Zero-hop: current node must match target pattern
554
+ current_node = graph.get(current_id)
555
+ if current_node is not None:
556
+ matches = True
557
+ if (
558
+ target_pat.label
559
+ and current_node.type.lower() != target_pat.label.lower()
560
+ ):
561
+ matches = False
562
+ if not self._check_filters(current_node, target_pat.filters):
563
+ matches = False
564
+ if matches:
565
+ results.append([current_id])
566
+
567
+ return results
568
+
569
+ # -- helpers -----------------------------------------------------------
570
+
571
+ @staticmethod
572
+ def _direct_neighbors(
573
+ graph: HtmlGraph,
574
+ node_id: str,
575
+ edge_pat: EdgePattern,
576
+ ) -> list[str]:
577
+ """Get direct neighbour IDs for a single-hop edge pattern."""
578
+ if edge_pat.direction == "outgoing":
579
+ refs = graph.edge_index.get_outgoing(node_id, edge_pat.relationship)
580
+ return [r.target_id for r in refs]
581
+ else:
582
+ refs = graph.edge_index.get_incoming(node_id, edge_pat.relationship)
583
+ return [r.source_id for r in refs]
584
+
585
+
586
+ # ---------------------------------------------------------------------------
587
+ # Helpers
588
+ # ---------------------------------------------------------------------------
589
+
590
+
591
+ def _resolve_attribute(node: object, attr: str) -> object | None:
592
+ """Resolve a possibly-dotted attribute path on *node*.
593
+
594
+ Supports plain attributes (``status``), nested dotted paths
595
+ (``properties.effort``), and dictionary access.
596
+ """
597
+ parts = attr.split(".")
598
+ current: object = node
599
+ for part in parts:
600
+ if current is None:
601
+ return None
602
+ if hasattr(current, part):
603
+ current = getattr(current, part)
604
+ elif isinstance(current, dict):
605
+ current = current.get(part)
606
+ else:
607
+ return None
608
+ return current