@unerr-ai/unerr 0.2.1 → 0.2.3

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 (363) hide show
  1. package/README.md +36 -45
  2. package/dist/cli.js +37443 -36022
  3. package/package.json +2 -1
  4. package/dist/behaviors/agent-llm-bridge.js +0 -166
  5. package/dist/behaviors/architecture-guard.js +0 -256
  6. package/dist/behaviors/auto-doc.js +0 -247
  7. package/dist/behaviors/cascade-guard.js +0 -289
  8. package/dist/behaviors/change-narrative.js +0 -270
  9. package/dist/behaviors/convention-drift.js +0 -290
  10. package/dist/behaviors/framework.js +0 -235
  11. package/dist/behaviors/guard-formatter.js +0 -44
  12. package/dist/behaviors/incomplete-work.js +0 -270
  13. package/dist/behaviors/loop-breaker.js +0 -300
  14. package/dist/behaviors/session-continuity.js +0 -208
  15. package/dist/commands/branches.js +0 -97
  16. package/dist/commands/check-commit.js +0 -225
  17. package/dist/commands/compress-output.js +0 -64
  18. package/dist/commands/config-verify.js +0 -243
  19. package/dist/commands/daemon.js +0 -905
  20. package/dist/commands/dashboard.js +0 -52
  21. package/dist/commands/debug.js +0 -200
  22. package/dist/commands/enrich.js +0 -184
  23. package/dist/commands/exec.js +0 -233
  24. package/dist/commands/gain.js +0 -156
  25. package/dist/commands/hook.js +0 -88
  26. package/dist/commands/index.js +0 -88
  27. package/dist/commands/init.js +0 -74
  28. package/dist/commands/install.js +0 -505
  29. package/dist/commands/learn.js +0 -116
  30. package/dist/commands/manifest.js +0 -193
  31. package/dist/commands/rewind.js +0 -103
  32. package/dist/commands/serve.js +0 -19
  33. package/dist/commands/setup-wizard.js +0 -414
  34. package/dist/commands/skills.js +0 -64
  35. package/dist/commands/stats.js +0 -20
  36. package/dist/commands/status.js +0 -654
  37. package/dist/commands/timeline.js +0 -139
  38. package/dist/commands/uninstall.js +0 -230
  39. package/dist/components/App.js +0 -109
  40. package/dist/components/Banner.js +0 -12
  41. package/dist/components/ConfirmPrompt.js +0 -25
  42. package/dist/components/DriftSummary.js +0 -23
  43. package/dist/components/GradeBadge.js +0 -15
  44. package/dist/components/HealthCard.js +0 -18
  45. package/dist/components/InkSpinner.js +0 -22
  46. package/dist/components/InputBox.js +0 -17
  47. package/dist/components/KeyValue.js +0 -13
  48. package/dist/components/MessageList.js +0 -14
  49. package/dist/components/ProgressBar.js +0 -26
  50. package/dist/components/Section.js +0 -16
  51. package/dist/components/SessionSummaryCard.js +0 -73
  52. package/dist/components/StartupDisplay.js +0 -24
  53. package/dist/components/StatusDashboard.js +0 -57
  54. package/dist/components/StatusLine.js +0 -8
  55. package/dist/components/StepLine.js +0 -22
  56. package/dist/components/Theme.js +0 -20
  57. package/dist/components/ToolProgress.js +0 -8
  58. package/dist/components/ViolationList.js +0 -21
  59. package/dist/components/render.js +0 -13
  60. package/dist/config/agent-registry.js +0 -237
  61. package/dist/config/claude-settings-hooks.js +0 -304
  62. package/dist/config/hook-installer.js +0 -65
  63. package/dist/config/instruction-writer.js +0 -388
  64. package/dist/config/mcp-config-writer.js +0 -266
  65. package/dist/config/settings.js +0 -174
  66. package/dist/config/tool-detector.js +0 -42
  67. package/dist/config/value-surfacing.js +0 -119
  68. package/dist/core/context-assembly.js +0 -108
  69. package/dist/core/conversation.js +0 -33
  70. package/dist/core/local-chat-provider.js +0 -475
  71. package/dist/core/provider-factory.js +0 -55
  72. package/dist/core/providers.js +0 -90
  73. package/dist/core/query-engine.js +0 -174
  74. package/dist/daemon/api.js +0 -312
  75. package/dist/daemon/autostart.js +0 -119
  76. package/dist/daemon/bootstrap.js +0 -39
  77. package/dist/daemon/client.js +0 -164
  78. package/dist/daemon/detect-ci.js +0 -81
  79. package/dist/daemon/platform-linux.js +0 -146
  80. package/dist/daemon/platform-macos.js +0 -134
  81. package/dist/daemon/platform-windows.js +0 -116
  82. package/dist/daemon/process-manager.js +0 -299
  83. package/dist/daemon/protocol.js +0 -23
  84. package/dist/daemon/registry.js +0 -270
  85. package/dist/daemon/settings-schema.js +0 -72
  86. package/dist/daemon/system-health.js +0 -134
  87. package/dist/daemon/version-checker.js +0 -262
  88. package/dist/daemon/warm-start.js +0 -223
  89. package/dist/entrypoints/cli.js +0 -1043
  90. package/dist/entrypoints/daemon.js +0 -380
  91. package/dist/entrypoints/repl.js +0 -147
  92. package/dist/hooks/adapters/claude-code.js +0 -90
  93. package/dist/hooks/adapters/cline.js +0 -100
  94. package/dist/hooks/adapters/cursor.js +0 -98
  95. package/dist/hooks/hook-dedup.js +0 -79
  96. package/dist/hooks/hook-runner.js +0 -113
  97. package/dist/hooks/navigation-hooks.js +0 -175
  98. package/dist/hooks/prompt-hooks.js +0 -63
  99. package/dist/hooks/shell-hooks.js +0 -47
  100. package/dist/ignore.js +0 -111
  101. package/dist/intelligence/approach-suggester.js +0 -61
  102. package/dist/intelligence/ast-extractor.js +0 -2615
  103. package/dist/intelligence/ast-worker.js +0 -34
  104. package/dist/intelligence/background-indexer.js +0 -121
  105. package/dist/intelligence/blast-radius.js +0 -200
  106. package/dist/intelligence/community-detection.js +0 -691
  107. package/dist/intelligence/community-detector.js +0 -184
  108. package/dist/intelligence/computation-scheduler.js +0 -75
  109. package/dist/intelligence/confidence-propagation.js +0 -47
  110. package/dist/intelligence/convention-detector.js +0 -242
  111. package/dist/intelligence/convention-learner.js +0 -205
  112. package/dist/intelligence/convention-matcher.js +0 -205
  113. package/dist/intelligence/cozo-schema.js +0 -376
  114. package/dist/intelligence/decision-point-detector.js +0 -90
  115. package/dist/intelligence/deep-dive-tools.js +0 -586
  116. package/dist/intelligence/durability-scorer.js +0 -84
  117. package/dist/intelligence/exploration-cost.js +0 -204
  118. package/dist/intelligence/exploration-pattern-tracker.js +0 -61
  119. package/dist/intelligence/fact-generator.js +0 -322
  120. package/dist/intelligence/facts-schema.js +0 -90
  121. package/dist/intelligence/file-intelligence.js +0 -59
  122. package/dist/intelligence/graph-holder.js +0 -220
  123. package/dist/intelligence/graph-temporal-joiner.js +0 -238
  124. package/dist/intelligence/health-grade.js +0 -423
  125. package/dist/intelligence/health-grader.js +0 -200
  126. package/dist/intelligence/health-map-data.js +0 -259
  127. package/dist/intelligence/import-symbols.js +0 -136
  128. package/dist/intelligence/incremental-indexer.js +0 -658
  129. package/dist/intelligence/indexer/centrality.js +0 -62
  130. package/dist/intelligence/indexer/cfg-context.js +0 -95
  131. package/dist/intelligence/indexer/confidence.js +0 -34
  132. package/dist/intelligence/indexer/cross-file-resolver.js +0 -104
  133. package/dist/intelligence/indexer/edge-repair.js +0 -89
  134. package/dist/intelligence/indexer/entity-key.js +0 -17
  135. package/dist/intelligence/indexer/export-map.js +0 -132
  136. package/dist/intelligence/indexer/git-cochange.js +0 -128
  137. package/dist/intelligence/indexer/graph-patch.js +0 -147
  138. package/dist/intelligence/indexer/incremental.js +0 -78
  139. package/dist/intelligence/indexer/ingest.js +0 -160
  140. package/dist/intelligence/indexer/language-detect.js +0 -226
  141. package/dist/intelligence/indexer/metadata.js +0 -63
  142. package/dist/intelligence/indexer/mutation-tracker.js +0 -79
  143. package/dist/intelligence/indexer/orchestrator.js +0 -155
  144. package/dist/intelligence/indexer/plugin-interface.js +0 -31
  145. package/dist/intelligence/indexer/plugins/csharp.js +0 -440
  146. package/dist/intelligence/indexer/plugins/go.js +0 -335
  147. package/dist/intelligence/indexer/plugins/java.js +0 -370
  148. package/dist/intelligence/indexer/plugins/python.js +0 -358
  149. package/dist/intelligence/indexer/plugins/regex-fallback.js +0 -82
  150. package/dist/intelligence/indexer/plugins/ruby.js +0 -290
  151. package/dist/intelligence/indexer/plugins/rust.js +0 -484
  152. package/dist/intelligence/indexer/plugins/tier2-generic.js +0 -310
  153. package/dist/intelligence/indexer/plugins/typescript.js +0 -456
  154. package/dist/intelligence/indexer/resource-monitor.js +0 -93
  155. package/dist/intelligence/indexer/scip/decoder.js +0 -253
  156. package/dist/intelligence/indexer/scip/detector.js +0 -232
  157. package/dist/intelligence/indexer/scip/downloader.js +0 -427
  158. package/dist/intelligence/indexer/scip/fallback.js +0 -34
  159. package/dist/intelligence/indexer/scip/merger.js +0 -109
  160. package/dist/intelligence/indexer/scip/orchestrator.js +0 -433
  161. package/dist/intelligence/indexer/scip/runner.js +0 -98
  162. package/dist/intelligence/indexer/snapshot.js +0 -66
  163. package/dist/intelligence/indexer/test-detector.js +0 -196
  164. package/dist/intelligence/indexer/watch-integration.js +0 -61
  165. package/dist/intelligence/indexer/worker.js +0 -85
  166. package/dist/intelligence/local-convention-detector.js +0 -437
  167. package/dist/intelligence/local-embeddings.js +0 -190
  168. package/dist/intelligence/local-graph.js +0 -1946
  169. package/dist/intelligence/local-indexer.js +0 -1575
  170. package/dist/intelligence/local-llm.js +0 -163
  171. package/dist/intelligence/local-rule-generator.js +0 -154
  172. package/dist/intelligence/local-snapshot.js +0 -213
  173. package/dist/intelligence/negative-knowledge.js +0 -103
  174. package/dist/intelligence/persistent-db.js +0 -85
  175. package/dist/intelligence/query-router.js +0 -2556
  176. package/dist/intelligence/risk-classifier.js +0 -116
  177. package/dist/intelligence/rule-evaluator.js +0 -380
  178. package/dist/intelligence/rule-generator.js +0 -49
  179. package/dist/intelligence/search-index.js +0 -173
  180. package/dist/intelligence/semantic/docstring-extractor.js +0 -67
  181. package/dist/intelligence/semantic/embedding-store.js +0 -52
  182. package/dist/intelligence/semantic/enrichment-orchestrator.js +0 -48
  183. package/dist/intelligence/semantic/git-message-miner.js +0 -114
  184. package/dist/intelligence/semantic/identifier-tokenizer.js +0 -51
  185. package/dist/intelligence/semantic/node2vec-embeddings.js +0 -71
  186. package/dist/intelligence/semantic/node2vec-walks.js +0 -103
  187. package/dist/intelligence/semantic/path-domain-inference.js +0 -112
  188. package/dist/intelligence/semantic/similarity-engine.js +0 -60
  189. package/dist/intelligence/semantic/tfidf-vectors.js +0 -88
  190. package/dist/intelligence/session-brief-builder.js +0 -159
  191. package/dist/intelligence/session-context.js +0 -221
  192. package/dist/intelligence/session-health-monitor.js +0 -211
  193. package/dist/intelligence/session-narrative.js +0 -197
  194. package/dist/intelligence/session-pattern-analyzer.js +0 -218
  195. package/dist/intelligence/signal-scorer.js +0 -390
  196. package/dist/intelligence/signal-show-store.js +0 -182
  197. package/dist/intelligence/smart-truncate.js +0 -158
  198. package/dist/intelligence/subgraph-cache.js +0 -88
  199. package/dist/intelligence/temporal-facts.js +0 -494
  200. package/dist/intelligence/token-estimator.js +0 -100
  201. package/dist/intelligence/tool-injector.js +0 -87
  202. package/dist/intelligence/tree-sitter-loader.js +0 -71
  203. package/dist/intelligence/worker-pool.js +0 -116
  204. package/dist/proxy/arg-validator.js +0 -79
  205. package/dist/proxy/auto-bootstrap.js +0 -167
  206. package/dist/proxy/bridge.js +0 -147
  207. package/dist/proxy/budget-enforcer.js +0 -70
  208. package/dist/proxy/compression-quality-monitor.js +0 -160
  209. package/dist/proxy/compression-stats.js +0 -51
  210. package/dist/proxy/context-rot-detector.js +0 -137
  211. package/dist/proxy/drift-detector.js +0 -139
  212. package/dist/proxy/efficiency-tracker.js +0 -79
  213. package/dist/proxy/fact-ranking.js +0 -154
  214. package/dist/proxy/format-encoder.js +0 -266
  215. package/dist/proxy/http-transport.js +0 -90
  216. package/dist/proxy/lifecycle-actor.js +0 -55
  217. package/dist/proxy/lifecycle-machine.js +0 -187
  218. package/dist/proxy/log-tailer.js +0 -265
  219. package/dist/proxy/model-pricing.js +0 -98
  220. package/dist/proxy/network-firewall.js +0 -141
  221. package/dist/proxy/nudge-state.js +0 -93
  222. package/dist/proxy/output-compressor.js +0 -185
  223. package/dist/proxy/pid-lock.js +0 -291
  224. package/dist/proxy/proxy-context.js +0 -11
  225. package/dist/proxy/proxy.js +0 -2633
  226. package/dist/proxy/response-enrichment.js +0 -32
  227. package/dist/proxy/response-envelope.js +0 -313
  228. package/dist/proxy/session-dedup.js +0 -82
  229. package/dist/proxy/session-legend.js +0 -30
  230. package/dist/proxy/session-persistence.js +0 -210
  231. package/dist/proxy/session-resume.js +0 -94
  232. package/dist/proxy/session-stats.js +0 -513
  233. package/dist/proxy/shell-classifier.js +0 -1346
  234. package/dist/proxy/shell-compression-log.js +0 -93
  235. package/dist/proxy/shell-compressor.js +0 -390
  236. package/dist/proxy/shell-graph-boost.js +0 -202
  237. package/dist/proxy/shell-monitor-map.js +0 -18
  238. package/dist/proxy/shell-stats.js +0 -54
  239. package/dist/proxy/shell-strategies/cloud.js +0 -215
  240. package/dist/proxy/shell-strategies/diff.js +0 -159
  241. package/dist/proxy/shell-strategies/error-diagnostic.js +0 -796
  242. package/dist/proxy/shell-strategies/filter-dsl.js +0 -358
  243. package/dist/proxy/shell-strategies/git-status.js +0 -177
  244. package/dist/proxy/shell-strategies/key-value.js +0 -193
  245. package/dist/proxy/shell-strategies/log-text.js +0 -154
  246. package/dist/proxy/shell-strategies/omni.js +0 -188
  247. package/dist/proxy/shell-strategies/progress.js +0 -55
  248. package/dist/proxy/shell-strategies/redact.js +0 -76
  249. package/dist/proxy/shell-strategies/structured.js +0 -241
  250. package/dist/proxy/shell-strategies/tabular.js +0 -243
  251. package/dist/proxy/shell-strategies/test-results-types.js +0 -13
  252. package/dist/proxy/shell-strategies/test-results.js +0 -784
  253. package/dist/proxy/shell-strategies/tree-paths.js +0 -144
  254. package/dist/proxy/shell-strategies/yaml.js +0 -182
  255. package/dist/proxy/shell-tee.js +0 -111
  256. package/dist/proxy/signal-dedup.js +0 -171
  257. package/dist/proxy/startup-renderer.js +0 -158
  258. package/dist/proxy/task-token-display.js +0 -38
  259. package/dist/proxy/token-counter.js +0 -61
  260. package/dist/proxy/tool-clusters.js +0 -273
  261. package/dist/proxy/tool-definitions.js +0 -525
  262. package/dist/proxy/transport-mux.js +0 -229
  263. package/dist/proxy/wire-cap.js +0 -268
  264. package/dist/rules/developer.mozilla.org.json +0 -9
  265. package/dist/rules/github.com.json +0 -21
  266. package/dist/schemas/api/skills.js +0 -19
  267. package/dist/schemas/common/errors.js +0 -7
  268. package/dist/schemas/common/headers.js +0 -5
  269. package/dist/schemas/entities/edge.js +0 -25
  270. package/dist/schemas/entities/entity.js +0 -22
  271. package/dist/schemas/entities/rule.js +0 -18
  272. package/dist/schemas/index.js +0 -14
  273. package/dist/server/event-bus.js +0 -59
  274. package/dist/server/http.js +0 -156
  275. package/dist/server/middleware.js +0 -70
  276. package/dist/server/routes/drift.js +0 -97
  277. package/dist/server/routes/intelligence.js +0 -1217
  278. package/dist/server/routes/reasoning-quality.js +0 -444
  279. package/dist/server/routes/session.js +0 -86
  280. package/dist/server/routes/stream.js +0 -120
  281. package/dist/server/routes/system.js +0 -73
  282. package/dist/server/routes/temporal.js +0 -170
  283. package/dist/server/routes/timeline.js +0 -232
  284. package/dist/server/routes/token-flow.js +0 -403
  285. package/dist/skills/effectiveness-tracker.js +0 -93
  286. package/dist/skills/local-pack.js +0 -380
  287. package/dist/skills/resolver.js +0 -495
  288. package/dist/state-detector.js +0 -83
  289. package/dist/timeline/intent-detector.js +0 -263
  290. package/dist/timeline/loop-miner.js +0 -140
  291. package/dist/timeline/open-threads.js +0 -49
  292. package/dist/timeline/signal-reinforcer.js +0 -62
  293. package/dist/timeline/timeline-bootstrap.js +0 -151
  294. package/dist/timeline/timeline-store.js +0 -618
  295. package/dist/tools/coding/bash.js +0 -49
  296. package/dist/tools/coding/file-edit.js +0 -72
  297. package/dist/tools/coding/file-outline.js +0 -227
  298. package/dist/tools/coding/file-read-protocol.js +0 -425
  299. package/dist/tools/coding/file-read.js +0 -35
  300. package/dist/tools/coding/file-write.js +0 -43
  301. package/dist/tools/coding/glob-tool.js +0 -109
  302. package/dist/tools/coding/grep.js +0 -162
  303. package/dist/tools/coding/index.js +0 -27
  304. package/dist/tools/intelligence/index.js +0 -269
  305. package/dist/tools/intelligence/record-fact.js +0 -48
  306. package/dist/tools/intelligence/timeline-markers.js +0 -130
  307. package/dist/tools/registry.js +0 -47
  308. package/dist/tools/types.js +0 -8
  309. package/dist/tracking/auto-snapshot-triggers.js +0 -246
  310. package/dist/tracking/branch-context.js +0 -115
  311. package/dist/tracking/branch-snapshot.js +0 -217
  312. package/dist/tracking/causal-bridge.js +0 -317
  313. package/dist/tracking/circuit-breaker.js +0 -147
  314. package/dist/tracking/commit-watcher.js +0 -114
  315. package/dist/tracking/context-ledger.js +0 -119
  316. package/dist/tracking/correction-detector.js +0 -324
  317. package/dist/tracking/drift-tracker.js +0 -874
  318. package/dist/tracking/durability-tracker.js +0 -94
  319. package/dist/tracking/entity-rewind.js +0 -200
  320. package/dist/tracking/file-hash-state.js +0 -114
  321. package/dist/tracking/git-attribution.js +0 -132
  322. package/dist/tracking/git-trailers.js +0 -171
  323. package/dist/tracking/intelligence-counter.js +0 -46
  324. package/dist/tracking/intent-correlator.js +0 -202
  325. package/dist/tracking/intent-encoder.js +0 -52
  326. package/dist/tracking/intent-token-tracker.js +0 -159
  327. package/dist/tracking/ledger-archiver.js +0 -94
  328. package/dist/tracking/ledger-chains.js +0 -245
  329. package/dist/tracking/metrics-store.js +0 -361
  330. package/dist/tracking/native-watcher.js +0 -131
  331. package/dist/tracking/offline-rewind.js +0 -295
  332. package/dist/tracking/pending-violations.js +0 -74
  333. package/dist/tracking/persistence-effectiveness.js +0 -167
  334. package/dist/tracking/prompt-durability.js +0 -202
  335. package/dist/tracking/quality-signals.js +0 -213
  336. package/dist/tracking/redactor.js +0 -73
  337. package/dist/tracking/rewind-engine.js +0 -161
  338. package/dist/tracking/session-history.js +0 -128
  339. package/dist/tracking/session-receipt.js +0 -88
  340. package/dist/tracking/session-summary-writer.js +0 -157
  341. package/dist/tracking/shadow-ledger.js +0 -321
  342. package/dist/tracking/stash-manager.js +0 -258
  343. package/dist/tracking/timeline-fork.js +0 -213
  344. package/dist/tracking/timeline.js +0 -69
  345. package/dist/tracking/token-flow.js +0 -276
  346. package/dist/tracking/turn-segmenter.js +0 -122
  347. package/dist/tracking/weekly-accumulator.js +0 -179
  348. package/dist/tracking/working-snapshots.js +0 -188
  349. package/dist/tracking/workspace-manifest.js +0 -176
  350. package/dist/transport/http.js +0 -102
  351. package/dist/utils/counterfactual.js +0 -65
  352. package/dist/utils/deep-link.js +0 -34
  353. package/dist/utils/detect.js +0 -193
  354. package/dist/utils/exec.js +0 -73
  355. package/dist/utils/file-logger.js +0 -87
  356. package/dist/utils/format-error.js +0 -29
  357. package/dist/utils/git.js +0 -181
  358. package/dist/utils/log.js +0 -57
  359. package/dist/utils/logger.js +0 -35
  360. package/dist/utils/mcp-content-json.js +0 -8
  361. package/dist/utils/session-logger.js +0 -154
  362. package/dist/utils/startup-log.js +0 -512
  363. package/dist/utils/ui.js +0 -56
@@ -1,1946 +0,0 @@
1
- /**
2
- * CozoGraphStore — Local graph store backed by CozoDB.
3
- *
4
- * Provides read-only IGraphStore-like interface for local graph queries.
5
- * Loaded from msgpack snapshots loaded from local snapshots.
6
- */
7
- import { existsSync, readFileSync } from "node:fs";
8
- import { join } from "node:path";
9
- import { detectCommunities } from "./community-detection.js";
10
- import { initSchema } from "./cozo-schema.js";
11
- import { buildSearchIndex, searchLocal, tokenize } from "./search-index.js";
12
- /** Default timeout for read queries (ms). Prevents reads from hanging behind long writes. */
13
- const QUERY_TIMEOUT_MS = 2000;
14
- /** Timeout for write operations (ms). Prevents indefinite hangs on CozoDB lock contention. */
15
- const WRITE_TIMEOUT_MS = 10_000;
16
- export class CozoGraphStore {
17
- db;
18
- loaded = false;
19
- /** Serialized write queue — prevents concurrent CozoDB write contention. */
20
- writeChain = Promise.resolve();
21
- constructor(db) {
22
- this.db = db;
23
- }
24
- /**
25
- * Timeout-protected read query. Returns empty rows on timeout instead of hanging.
26
- * Use this for all tool-facing read paths to prevent stuck MCP calls.
27
- */
28
- async query(script, params, timeoutMs = QUERY_TIMEOUT_MS) {
29
- return Promise.race([
30
- this.db.run(script, params),
31
- new Promise((_, reject) => setTimeout(() => reject(new Error(`CozoDB query timeout after ${timeoutMs}ms`)), timeoutMs)),
32
- ]);
33
- }
34
- /**
35
- * Serialized, timeout-protected write operation.
36
- * Writes are queued so they don't contend with each other,
37
- * and reads (via query()) can detect contention via timeout.
38
- */
39
- async write(script, params) {
40
- let result;
41
- const op = this.writeChain.then(async () => {
42
- result = await Promise.race([
43
- this.db.run(script, params),
44
- new Promise((_, reject) => setTimeout(() => reject(new Error(`CozoDB write timeout after ${WRITE_TIMEOUT_MS}ms`)), WRITE_TIMEOUT_MS)),
45
- ]);
46
- });
47
- this.writeChain = op.catch(() => { }); // keep chain alive on failure
48
- await op;
49
- return result;
50
- }
51
- /**
52
- * Async factory — ensures CozoDB schema exists before returning the store.
53
- * Safe for both fresh and persistent databases.
54
- *
55
- * RC-6: Checks for concurrent DB access. If a daemon process already
56
- * owns the DB (detected via PID lock file), returns null so callers
57
- * can fall back to parse-only mode.
58
- */
59
- static async create(db, projectRoot) {
60
- // RC-6: Check if another process owns the DB
61
- if (projectRoot) {
62
- const pidPath = join(projectRoot, ".unerr", "state", "proxy.pid");
63
- if (existsSync(pidPath)) {
64
- try {
65
- const raw = readFileSync(pidPath, "utf-8").trim();
66
- let ownerPid;
67
- if (raw.startsWith("{")) {
68
- const data = JSON.parse(raw);
69
- ownerPid = typeof data.pid === "number" ? data.pid : undefined;
70
- }
71
- else {
72
- ownerPid = Number.parseInt(raw, 10);
73
- if (Number.isNaN(ownerPid))
74
- ownerPid = undefined;
75
- }
76
- if (ownerPid !== undefined && ownerPid !== process.pid) {
77
- try {
78
- process.kill(ownerPid, 0); // Check if alive (signal 0)
79
- throw new Error(`DB owned by daemon (PID ${ownerPid}). Use parse-only mode to avoid lock contention.`);
80
- }
81
- catch (killErr) {
82
- if (killErr instanceof Error &&
83
- killErr.message.startsWith("DB owned by daemon")) {
84
- throw killErr;
85
- }
86
- // Process not alive — stale PID file, safe to proceed
87
- process.stderr.write(`[unerr] Stale PID file detected (PID ${ownerPid} not alive). Proceeding with DB access.\n`);
88
- }
89
- }
90
- }
91
- catch (outerErr) {
92
- if (outerErr instanceof Error &&
93
- outerErr.message.startsWith("DB owned by daemon")) {
94
- throw outerErr;
95
- }
96
- // PID file parse error — proceed with DB access
97
- }
98
- }
99
- }
100
- const store = new CozoGraphStore(db);
101
- await initSchema(db);
102
- return store;
103
- }
104
- /**
105
- * Check if the graph has indexed data (entities relation is populated).
106
- * Used to determine if a persistent DB needs initial indexing.
107
- */
108
- async isPopulated() {
109
- const result = await this.query("?[count(key)] := *entities{key}");
110
- const count = result.rows[0]?.[0] ?? 0;
111
- return count > 0;
112
- }
113
- /**
114
- * Get the entity count without loading full data.
115
- */
116
- async getEntityCount() {
117
- const result = await this.query("?[count(key)] := *entities{key}");
118
- return result.rows[0]?.[0] ?? 0;
119
- }
120
- /**
121
- * Load a deserialized snapshot into CozoDB.
122
- */
123
- async loadSnapshot(envelope) {
124
- // Bulk insert entities (v3: includes risk fields)
125
- for (const entity of envelope.entities) {
126
- await this.write(`?[key, kind, name, file_path, start_line, end_line, signature, body, fan_in, fan_out, risk_level] <- [[$key, $kind, $name, $fp, $sl, $el, $sig, $body, $fi, $fo, $rl]]
127
- :put entities { key => kind, name, file_path, start_line, end_line, signature, body, fan_in, fan_out, risk_level }`, {
128
- key: entity.key,
129
- kind: entity.kind,
130
- name: entity.name,
131
- fp: entity.file_path,
132
- sl: entity.start_line ?? 0,
133
- el: entity.end_line ?? 0,
134
- sig: entity.signature ?? "",
135
- body: entity.body ?? "",
136
- fi: entity.fan_in ?? 0,
137
- fo: entity.fan_out ?? 0,
138
- rl: entity.risk_level ?? "normal",
139
- });
140
- // Build file index
141
- await this.write("?[file_path, entity_key] <- [[$fp, $key]] :put file_index { file_path, entity_key }", { fp: entity.file_path, key: entity.key });
142
- }
143
- // Bulk insert edges (v2: includes CFG control flow fields)
144
- for (const edge of envelope.edges) {
145
- await this.write("?[from_key, to_key, type, sequence_order, condition, branch_kind, is_loop, loop_kind, nesting_depth, is_try_guarded, is_error_handler, mutation_target, mutation_mode] <- [[$from, $to, $type, $seq, $cond, $br, $lp, $lk, $nd, $tg, $eh, $mt, $mm]] :put edges { from_key, to_key, type => sequence_order, condition, branch_kind, is_loop, loop_kind, nesting_depth, is_try_guarded, is_error_handler, mutation_target, mutation_mode }", {
146
- from: edge.from_key,
147
- to: edge.to_key,
148
- type: edge.type,
149
- seq: edge.seq ?? -1,
150
- cond: edge.cond ?? "",
151
- br: edge.br ?? "",
152
- lp: edge.lp ?? false,
153
- lk: edge.lk ?? "",
154
- nd: edge.nd ?? 0,
155
- tg: edge.tg ?? false,
156
- eh: edge.eh ?? false,
157
- mt: edge.mt ?? "",
158
- mm: edge.mm ?? "",
159
- });
160
- }
161
- // Load rules if present (v2 envelope)
162
- if (envelope.rules && envelope.rules.length > 0) {
163
- await this.loadRules(envelope.rules);
164
- }
165
- // Load patterns if present (v2 envelope)
166
- if (envelope.patterns && envelope.patterns.length > 0) {
167
- await this.loadPatterns(envelope.patterns);
168
- }
169
- // Load rule exceptions if present (Sprint 9.6)
170
- if (envelope.rule_exceptions && envelope.rule_exceptions.length > 0) {
171
- await this.loadRuleExceptions(envelope.rule_exceptions);
172
- }
173
- // Load justifications if present (v4 envelope / MV-03)
174
- if (envelope.justifications && envelope.justifications.length > 0) {
175
- await this.loadJustifications(envelope.justifications);
176
- }
177
- // Load inline justifications from entities (alternative path)
178
- for (const entity of envelope.entities) {
179
- if (entity.purpose || entity.taxonomy || entity.feature_area) {
180
- await this.write(`?[entity_key, purpose, taxonomy, feature_area, confidence] <-
181
- [[$ek, $purpose, $taxonomy, $fa, $conf]]
182
- :put justifications { entity_key => purpose, taxonomy, feature_area, confidence }`, {
183
- ek: entity.key,
184
- purpose: entity.purpose ?? "",
185
- taxonomy: entity.taxonomy ?? "",
186
- fa: entity.feature_area ?? "",
187
- conf: entity.justification_confidence ?? 0,
188
- });
189
- }
190
- }
191
- // Build search index
192
- await buildSearchIndex(this.db);
193
- // Leapfrog Sprint A: Community detection via Louvain
194
- await this.detectAndStoreCommunities();
195
- this.loaded = true;
196
- }
197
- /**
198
- * Leapfrog Sprint A.2: Run community detection and store results in CozoDB.
199
- *
200
- * Extracts all entities and edges from CozoDB, runs Louvain community detection,
201
- * then writes community assignments back to entities.community and the communities relation.
202
- * Called once at the end of loadSnapshot(). <200ms for 5K entities.
203
- */
204
- async detectAndStoreCommunities() {
205
- // Extract minimal entity data for community detection
206
- const entityResult = await this.query("?[key, file_path] := *entities{key, file_path}");
207
- const entities = entityResult.rows.map((row) => ({
208
- key: row[0],
209
- file_path: row[1],
210
- }));
211
- if (entities.length === 0)
212
- return;
213
- // Extract edges (all types contribute to community structure)
214
- // R.5: Include edge type so community detection can weight contains edges at 0.3
215
- const edgeResult = await this.query("?[from_key, to_key, type] := *edges{from_key, to_key, type}");
216
- const edges = edgeResult.rows.map((row) => ({
217
- from_key: row[0],
218
- to_key: row[1],
219
- type: row[2],
220
- }));
221
- // Run community detection
222
- const result = detectCommunities(entities, edges);
223
- // Write community assignments back to entities using :update (only changes community column)
224
- for (const [key, communityId] of result.assignments) {
225
- await this.write("?[key, community] <- [[$key, $cid]] :update entities { key => community }", { key, cid: communityId });
226
- }
227
- // Write community metadata
228
- for (const c of result.communities) {
229
- await this.write("?[id, label, size, cohesion] <- [[$id, $label, $size, $cohesion]] :put communities { id => label, size, cohesion }", { id: c.id, label: c.label, size: c.size, cohesion: c.cohesion });
230
- }
231
- }
232
- /**
233
- * Get a single entity by key.
234
- */
235
- async getEntity(key) {
236
- const result = await this.query("?[key, kind, name, fp, sl, el, sig, body, fi, fo, rl, community] := *entities{key, kind, name, file_path: fp, start_line: sl, end_line: el, signature: sig, body, fan_in: fi, fan_out: fo, risk_level: rl, community}, key = $key", { key });
237
- if (result.rows.length === 0)
238
- return null;
239
- const [k, kind, name, fp, sl, el, sig, body, fi, fo, rl, community] = result
240
- .rows[0];
241
- return {
242
- key: k,
243
- kind,
244
- name,
245
- file_path: fp,
246
- start_line: sl,
247
- end_line: el,
248
- signature: sig,
249
- body,
250
- fan_in: fi,
251
- fan_out: fo,
252
- risk_level: rl,
253
- community,
254
- };
255
- }
256
- /**
257
- * Get all entities that call the given entity.
258
- * Merges base edges with drift_edges (Task 6.4).
259
- */
260
- async getCallersOf(key) {
261
- // Defensive kind filter: only callable kinds (function, method, class) can be
262
- // callers. Pre-fix drift_edges may contain variable/interface/type rows from
263
- // the historical extractor bug that scanned full-file content per-entity.
264
- const result = await this.query(`base[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm] := *edges{from_key, to_key: $key, type: "calls"},
265
- *entities{key: from_key, kind, name, file_path: fp, start_line: sl, end_line: el, signature: sig, body, fan_in: fi, fan_out: fo, risk_level: rl, community: comm},
266
- kind != "variable", kind != "interface", kind != "type", kind != "enum", kind != "namespace",
267
- k = from_key
268
- drift[k] := *drift_edges[k, $key, "calls", ds, _], ds != "removed"
269
- drift_entity[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm] :=
270
- drift[k], *entities{key: k, kind, name, file_path: fp, start_line: sl, end_line: el, signature: sig, body, fan_in: fi, fan_out: fo, risk_level: rl, community: comm},
271
- kind != "variable", kind != "interface", kind != "type", kind != "enum", kind != "namespace"
272
- drift_overlay_entity[k, kind, name, fp, ls, el, sig, body, fi, fo, rl, comm] :=
273
- drift[k], *drift_overlay{key: k, name, kind, signature: sig, body, file_path: fp, line_start: ls},
274
- kind != "variable", kind != "interface", kind != "type", kind != "enum", kind != "namespace",
275
- not *entities{key: k}, el = 0, fi = 0, fo = 0, rl = "normal", comm = -1
276
- ?[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm] :=
277
- base[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm]
278
- ?[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm] :=
279
- drift_entity[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm],
280
- not base[k, _, _, _, _, _, _, _, _, _, _, _]
281
- ?[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm] :=
282
- drift_overlay_entity[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm],
283
- not base[k, _, _, _, _, _, _, _, _, _, _, _],
284
- not drift_entity[k, _, _, _, _, _, _, _, _, _, _, _]`, { key });
285
- return result.rows.map((row) => {
286
- const [k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm] = row;
287
- return {
288
- key: k,
289
- kind,
290
- name,
291
- file_path: fp,
292
- start_line: sl,
293
- end_line: el,
294
- signature: sig,
295
- body,
296
- fan_in: fi,
297
- fan_out: fo,
298
- risk_level: rl,
299
- community: comm,
300
- };
301
- });
302
- }
303
- /**
304
- * Get all entities called by the given entity.
305
- * Merges base edges with drift_edges (Task 6.4).
306
- */
307
- async getCalleesOf(key) {
308
- const result = await this.query(`base[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm] := *edges{from_key: $key, to_key, type: "calls"},
309
- *entities{key: to_key, kind, name, file_path: fp, start_line: sl, end_line: el, signature: sig, body, fan_in: fi, fan_out: fo, risk_level: rl, community: comm},
310
- kind != "variable", kind != "interface", kind != "type", kind != "enum", kind != "namespace",
311
- k = to_key
312
- drift[k] := *drift_edges[$key, k, "calls", ds, _], ds != "removed"
313
- drift_entity[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm] :=
314
- drift[k], *entities{key: k, kind, name, file_path: fp, start_line: sl, end_line: el, signature: sig, body, fan_in: fi, fan_out: fo, risk_level: rl, community: comm},
315
- kind != "variable", kind != "interface", kind != "type", kind != "enum", kind != "namespace"
316
- drift_overlay_entity[k, kind, name, fp, ls, el, sig, body, fi, fo, rl, comm] :=
317
- drift[k], *drift_overlay{key: k, name, kind, signature: sig, body, file_path: fp, line_start: ls},
318
- kind != "variable", kind != "interface", kind != "type", kind != "enum", kind != "namespace",
319
- not *entities{key: k}, el = 0, fi = 0, fo = 0, rl = "normal", comm = -1
320
- ?[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm] :=
321
- base[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm]
322
- ?[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm] :=
323
- drift_entity[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm],
324
- not base[k, _, _, _, _, _, _, _, _, _, _, _]
325
- ?[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm] :=
326
- drift_overlay_entity[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm],
327
- not base[k, _, _, _, _, _, _, _, _, _, _, _],
328
- not drift_entity[k, _, _, _, _, _, _, _, _, _, _, _]`, { key });
329
- return result.rows.map((row) => {
330
- const [k, kind, name, fp, sl, el, sig, body, fi, fo, rl, comm] = row;
331
- return {
332
- key: k,
333
- kind,
334
- name,
335
- file_path: fp,
336
- start_line: sl,
337
- end_line: el,
338
- signature: sig,
339
- body,
340
- fan_in: fi,
341
- fan_out: fo,
342
- risk_level: rl,
343
- community: comm,
344
- };
345
- });
346
- }
347
- /**
348
- * Compute blast radius for an entity using recursive Datalog traversal.
349
- *
350
- * Returns direct callers/callees counts, transitive dependent count at
351
- * the specified depth, and chokepoint detection (fan_in > 5 AND fan_out > 5).
352
- * Designed to complete in <5ms on graphs with 10K+ entities.
353
- */
354
- async getBlastRadius(entityKey, maxDepth = 2) {
355
- // Direct callers and callees (depth 1)
356
- const callersResult = await this.query(`?[k] := *edges{from_key, to_key: $key, type: "calls"}, k = from_key`, { key: entityKey });
357
- const calleesResult = await this.query(`?[k] := *edges{from_key: $key, to_key, type: "calls"}, k = to_key`, { key: entityKey });
358
- const directCallers = callersResult.rows.length;
359
- const directCallees = calleesResult.rows.length;
360
- // Split callers into production vs test
361
- const callerTestSplit = await this.query(`?[k, it] := *edges{from_key, to_key: $key, type: "calls"},
362
- *entities{key: from_key, is_test: it}, k = from_key`, { key: entityKey });
363
- let productionCallers = 0;
364
- let testCallers = 0;
365
- for (const row of callerTestSplit.rows) {
366
- if (row[1])
367
- testCallers++;
368
- else
369
- productionCallers++;
370
- }
371
- // Direct caller count IS the blast radius for signal purposes. Earlier
372
- // versions ran a recursive transitive walk (callers-of-callers up to
373
- // maxDepth) but: (a) the only `_meta.blast_radius.transitive_depth2`
374
- // consumer was dropped when MCP `_meta` was removed, and (b) the recursive
375
- // form was broken (two `?[]` heads). Keep `transitive_count` in the result
376
- // shape for back-compat, set to `directCallers` — depth-1 truth.
377
- const transitiveCount = directCallers;
378
- void maxDepth; // retained as a stable parameter for callers; one-hop now.
379
- // Chokepoint: fan_in > 5 AND fan_out > 5
380
- const entity = await this.getEntity(entityKey);
381
- const isChokepoint = entity !== null && entity.fan_in > 5 && entity.fan_out > 5;
382
- // Build summary string for _context
383
- const parts = [];
384
- if (productionCallers > 0)
385
- parts.push(`${productionCallers} production caller${productionCallers !== 1 ? "s" : ""}`);
386
- if (testCallers > 0)
387
- parts.push(`${testCallers} test caller${testCallers !== 1 ? "s" : ""}`);
388
- if (directCallees > 0)
389
- parts.push(`${directCallees} direct callee${directCallees !== 1 ? "s" : ""}`);
390
- if (isChokepoint)
391
- parts.push("CHOKEPOINT");
392
- const summary = parts.length > 0 ? parts.join(", ") : "No dependencies";
393
- return {
394
- direct_callers: directCallers,
395
- direct_callees: directCallees,
396
- production_callers: productionCallers,
397
- test_callers: testCallers,
398
- transitive_count: transitiveCount,
399
- transitive_depth: 1,
400
- is_chokepoint: isChokepoint,
401
- summary,
402
- };
403
- }
404
- /**
405
- * Sprint 3.1: Full N-hop blast radius returning entity details with depth.
406
- *
407
- * Traverses callers recursively up to maxDepth using CozoDB Datalog recursion,
408
- * then joins entity details (name, file_path) for each affected node.
409
- * Configurable max depth (default 2). <10ms on 10K-entity graphs at depth 3.
410
- */
411
- async getBlastRadiusEntities(entityKey, maxDepth = 2) {
412
- // Single-hop direct callers — same data, no broken recursion. The
413
- // consumer (`_meta.blast_radius.affected_entities.slice(0, 20)`) was
414
- // removed when MCP `_meta` was filtered out; this method survives for
415
- // backwards-compat and the file-siblings fallback below.
416
- void maxDepth; // depth-N walk would re-introduce the parser bug; depth-1 truth is sufficient.
417
- const directResult = await this.query(`?[target] := *edges{from_key, to_key: $root, type: "calls"}, target = from_key`, { root: entityKey });
418
- const depthMap = new Map();
419
- for (const row of directResult.rows) {
420
- const key = row[0];
421
- if (key === entityKey)
422
- continue;
423
- if (!depthMap.has(key))
424
- depthMap.set(key, 1);
425
- }
426
- if (depthMap.size === 0) {
427
- // R.6: File-level fallback — return sibling entities from the same file
428
- return this.getFileSiblings(entityKey);
429
- }
430
- // Batch-fetch entity details for all affected keys
431
- const entities = [];
432
- for (const [key, depth] of depthMap) {
433
- const entityResult = await this.query("?[k, name, fp] := *entities{key: k, name, file_path: fp}, k = $key", { key });
434
- if (entityResult.rows.length > 0) {
435
- const [, name, file] = entityResult.rows[0];
436
- entities.push({ key, name, file, depth });
437
- }
438
- }
439
- // Sort by depth ascending, then by key for deterministic output
440
- entities.sort((a, b) => a.depth - b.depth || a.key.localeCompare(b.key));
441
- return entities;
442
- }
443
- /**
444
- * R.6: Get sibling entities from the same file as the given entity.
445
- * Used as blast radius fallback when no callers exist.
446
- */
447
- async getFileSiblings(entityKey) {
448
- // Find the file path for this entity
449
- const fileResult = await this.query("?[fp] := *entities{key: $key, file_path: fp}", { key: entityKey });
450
- if (fileResult.rows.length === 0)
451
- return [];
452
- const filePath = fileResult.rows[0]?.[0];
453
- // Get all entities in the same file (via contains edges from the file entity)
454
- const siblingsResult = await this.query(`?[key, name, fp] := *edges{from_key: $fileKey, to_key: key, type: "contains"}, *entities{key, name, file_path: fp}, key != $entityKey`, { fileKey: `file:${filePath}`, entityKey });
455
- return siblingsResult.rows.map((row) => ({
456
- key: row[0],
457
- name: row[1],
458
- file: row[2],
459
- depth: 0, // Same file = depth 0 (adjacent)
460
- }));
461
- }
462
- // ── Sprint R.7: File-Level Queries ──────────────────────────────────
463
- /**
464
- * R.7: Get all entities contained within a file.
465
- */
466
- async getFileEntities(filePath) {
467
- const result = await this.query(`?[key, kind, name] := *edges{from_key: $fileKey, to_key: key, type: "contains"}, *entities{key, kind, name}`, { fileKey: `file:${filePath}` });
468
- return result.rows.map((row) => ({
469
- key: row[0],
470
- kind: row[1],
471
- name: row[2],
472
- }));
473
- }
474
- /**
475
- * R.7: Get files connected to the given file via imports or co-change edges.
476
- * Returns connected files with edge type and direction.
477
- */
478
- async getFileNeighbors(filePath) {
479
- const fileKey = `file:${filePath}`;
480
- // Outbound: this file imports others
481
- const outResult = await this.query("?[to_key, type] := *edges{from_key: $fk, to_key, type}, to_key != $fk", { fk: fileKey });
482
- // Inbound: others import this file
483
- const inResult = await this.query("?[from_key, type] := *edges{from_key, to_key: $fk, type}, from_key != $fk", { fk: fileKey });
484
- const outbound = [];
485
- const inbound = [];
486
- for (const row of outResult.rows) {
487
- const toKey = row[0];
488
- const edgeType = row[1];
489
- // Only include file→file edges (skip contains edges to entities)
490
- if (toKey.startsWith("file:")) {
491
- outbound.push({ file: toKey.slice(5), edgeType, direction: "out" });
492
- }
493
- }
494
- for (const row of inResult.rows) {
495
- const fromKey = row[0];
496
- const edgeType = row[1];
497
- if (fromKey.startsWith("file:")) {
498
- inbound.push({ file: fromKey.slice(5), edgeType, direction: "in" });
499
- }
500
- }
501
- // Interleave so both directions survive a small `limit` at the wire cap.
502
- // Without this, in/out are emitted as two contiguous runs and the default
503
- // 20-item cap can hide one side entirely.
504
- const neighbors = [];
505
- const maxLen = Math.max(outbound.length, inbound.length);
506
- for (let i = 0; i < maxLen; i++) {
507
- const o = outbound[i];
508
- if (o)
509
- neighbors.push(o);
510
- const inb = inbound[i];
511
- if (inb)
512
- neighbors.push(inb);
513
- }
514
- return neighbors;
515
- }
516
- // ── Sprint R.13: Test Coverage Query ──────────────────────────────
517
- /**
518
- * Find test entities that cover a given source entity.
519
- * Uses "tests" edges (direct) and optionally traverses callers for transitive coverage.
520
- */
521
- async getTestCoverage(entityKey, includeTransitive = true) {
522
- // Direct: tests edges pointing to this entity
523
- const directResult = await this.query(`?[k, name, fp] := *edges{from_key: k, to_key: $target, type: "tests"},
524
- *entities{key: k, name, file_path: fp}`, { target: entityKey });
525
- const results = [];
526
- const seenKeys = new Set();
527
- // Coverage is fundamentally a FILE-level signal — a test file either
528
- // exercises this entity or it doesn't, and which particular helper
529
- // function inside the file routes the call isn't actionable info for
530
- // the agent. Dedup by file_path to keep one (closest-depth) row per
531
- // covering test file.
532
- const seenFiles = new Set();
533
- for (const row of directResult.rows) {
534
- const key = row[0];
535
- const file = row[2];
536
- if (seenKeys.has(key) || seenFiles.has(file))
537
- continue;
538
- seenKeys.add(key);
539
- seenFiles.add(file);
540
- results.push({
541
- key,
542
- name: row[1],
543
- file,
544
- depth: 1,
545
- });
546
- }
547
- // Transitive: test entities that call something that calls the target
548
- if (includeTransitive) {
549
- const transitiveResult = await this.query(`?[k, name, fp] := *edges{from_key: mid, to_key: $target, type: "calls"},
550
- *edges{from_key: k, to_key: mid, type: "tests"},
551
- *entities{key: k, name, file_path: fp}`, { target: entityKey });
552
- for (const row of transitiveResult.rows) {
553
- const key = row[0];
554
- const file = row[2];
555
- if (seenKeys.has(key) || seenFiles.has(file))
556
- continue;
557
- seenKeys.add(key);
558
- seenFiles.add(file);
559
- results.push({
560
- key,
561
- name: row[1],
562
- file,
563
- depth: 2,
564
- });
565
- }
566
- }
567
- return results;
568
- }
569
- // ── Leapfrog Sprint A: Community Query Methods ─────────────────────
570
- /**
571
- * Get community metadata for an entity's community.
572
- * Returns null if entity has no community assignment (community == -1).
573
- */
574
- async getCommunityForEntity(entityKey) {
575
- const result = await this.query(`?[id, label, size, cohesion] :=
576
- *entities{key, community},
577
- key = $key, community >= 0,
578
- *communities[community, label, size, cohesion],
579
- id = community`, { key: entityKey });
580
- if (result.rows.length === 0)
581
- return null;
582
- const [id, label, size, cohesion] = result.rows[0];
583
- return { id, label, size, cohesion };
584
- }
585
- /**
586
- * Get cross-community edges for an entity.
587
- * Returns edges where the entity connects to entities in different communities.
588
- */
589
- async getCrossCommunityEdges(entityKey) {
590
- // Outbound cross-community edges
591
- const outbound = await this.query(`?[to_name, to_key, to_community, to_label, edge_type] :=
592
- *edges{from_key: $key, to_key: tk, type: edge_type},
593
- *entities{key: $key, community: c1},
594
- *entities{key: tk, name: to_name, community: to_community},
595
- c1 >= 0, to_community >= 0, c1 != to_community,
596
- *communities{id: to_community, label: to_label},
597
- to_key = tk`, { key: entityKey });
598
- // Inbound cross-community edges
599
- const inbound = await this.query(`?[from_name, from_key, from_community, from_label, edge_type] :=
600
- *edges{from_key: fk, to_key: $key, type: edge_type},
601
- *entities{key: $key, community: c1},
602
- *entities{key: fk, name: from_name, community: from_community},
603
- c1 >= 0, from_community >= 0, c1 != from_community,
604
- *communities{id: from_community, label: from_label},
605
- from_key = fk`, { key: entityKey });
606
- const edges = [];
607
- for (const row of outbound.rows) {
608
- const [name, key, cid, label, rel] = row;
609
- edges.push({
610
- entity_name: name,
611
- entity_key: key,
612
- entity_community_id: cid,
613
- entity_community_label: label,
614
- relation: rel,
615
- });
616
- }
617
- for (const row of inbound.rows) {
618
- const [name, key, cid, label, rel] = row;
619
- edges.push({
620
- entity_name: name,
621
- entity_key: key,
622
- entity_community_id: cid,
623
- entity_community_label: label,
624
- relation: `${rel} (inbound)`,
625
- });
626
- }
627
- return edges;
628
- }
629
- /**
630
- * Get all cross-community edges in the graph, sorted by surprise score.
631
- * Surprise = inverse of inter-community edge density between the two communities.
632
- */
633
- async getCrossBoundaryLinks(communityId, topN = 10) {
634
- // Get all cross-community edges
635
- const query = communityId !== undefined
636
- ? `?[fn, ff, fc, fl, tn, tf, tc, tl, et] :=
637
- *edges{from_key: fk, to_key: tk, type: et},
638
- *entities{key: fk, name: fn, file_path: ff, community: fc},
639
- *entities{key: tk, name: tn, file_path: tf, community: tc},
640
- fc >= 0, tc >= 0, fc != tc,
641
- (fc = $cid or tc = $cid),
642
- *communities{id: fc, label: fl},
643
- *communities{id: tc, label: tl}`
644
- : `?[fn, ff, fc, fl, tn, tf, tc, tl, et] :=
645
- *edges{from_key: fk, to_key: tk, type: et},
646
- *entities{key: fk, name: fn, file_path: ff, community: fc},
647
- *entities{key: tk, name: tn, file_path: tf, community: tc},
648
- fc >= 0, tc >= 0, fc != tc,
649
- *communities{id: fc, label: fl},
650
- *communities{id: tc, label: tl}`;
651
- const result = await this.query(query, communityId !== undefined ? { cid: communityId } : {});
652
- // Count edges per community pair for density calculation
653
- const pairCounts = new Map();
654
- for (const row of result.rows) {
655
- const fc = row[2];
656
- const tc = row[6];
657
- const pairKey = `${Math.min(fc, tc)}-${Math.max(fc, tc)}`;
658
- pairCounts.set(pairKey, (pairCounts.get(pairKey) ?? 0) + 1);
659
- }
660
- // Get max pair count for normalization
661
- const maxPairCount = Math.max(1, ...pairCounts.values());
662
- // Score and sort
663
- const scored = result.rows.map((row) => {
664
- const [fn, ff, fc, fl, tn, tf, tc, tl, et] = row;
665
- const pairKey = `${Math.min(fc, tc)}-${Math.max(fc, tc)}`;
666
- const density = (pairCounts.get(pairKey) ?? 0) / maxPairCount;
667
- return {
668
- from_name: fn,
669
- from_file: ff,
670
- from_community: fc,
671
- from_community_label: fl,
672
- to_name: tn,
673
- to_file: tf,
674
- to_community: tc,
675
- to_community_label: tl,
676
- edge_type: et,
677
- surprise_score: Math.round((1.0 - density) * 1000) / 1000,
678
- };
679
- });
680
- scored.sort((a, b) => b.surprise_score - a.surprise_score);
681
- return scored.slice(0, topN);
682
- }
683
- /**
684
- * Get critical nodes — highest degree entities excluding file-level hubs.
685
- * Degree ranking excluding kind=="file" and kind=="module".
686
- */
687
- async getCriticalNodes(topN = 10, communityId) {
688
- const query = communityId !== undefined
689
- ? `?[key, name, fp, fi, fo, degree, community, label, rl, kind] :=
690
- *entities{key, name, file_path: fp, fan_in: fi, fan_out: fo, community, risk_level: rl, kind},
691
- kind != "file", kind != "module",
692
- community = $cid, community >= 0,
693
- *communities{id: community, label},
694
- degree = fi + fo
695
- :order -degree
696
- :limit $top_n`
697
- : `?[key, name, fp, fi, fo, degree, community, label, rl, kind] :=
698
- *entities{key, name, file_path: fp, fan_in: fi, fan_out: fo, community, risk_level: rl, kind},
699
- kind != "file", kind != "module",
700
- community >= 0,
701
- *communities{id: community, label},
702
- degree = fi + fo
703
- :order -degree
704
- :limit $top_n`;
705
- const params = { top_n: topN };
706
- if (communityId !== undefined)
707
- params.cid = communityId;
708
- const result = await this.query(query, params);
709
- return result.rows.map((row) => {
710
- const [key, name, fp, fi, fo, degree, community, label, rl, kind] = row;
711
- return {
712
- key,
713
- name,
714
- file_path: fp,
715
- kind,
716
- fan_in: fi,
717
- fan_out: fo,
718
- degree,
719
- community,
720
- community_label: label,
721
- risk_level: rl,
722
- };
723
- });
724
- }
725
- /**
726
- * Get all communities with metadata.
727
- */
728
- async getAllCommunities() {
729
- const result = await this.query("?[id, label, size, cohesion] := *communities[id, label, size, cohesion] :order -size");
730
- return result.rows.map((row) => {
731
- const [id, label, size, cohesion] = row;
732
- return { id, label, size, cohesion };
733
- });
734
- }
735
- /**
736
- * Get all entities in a given file.
737
- */
738
- async getEntitiesByFile(filePath) {
739
- const result = await this.query(`?[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, community] := *file_index[$fp, ek],
740
- *entities{key: ek, kind, name, file_path: fp, start_line: sl, end_line: el, signature: sig, body, fan_in: fi, fan_out: fo, risk_level: rl, community},
741
- k = ek`, { fp: filePath });
742
- return result.rows.map((row) => {
743
- const [k, kind, name, fp, sl, el, sig, body, fi, fo, rl, community] = row;
744
- return {
745
- key: k,
746
- kind,
747
- name,
748
- file_path: fp,
749
- start_line: sl,
750
- end_line: el,
751
- signature: sig,
752
- body,
753
- fan_in: fi,
754
- fan_out: fo,
755
- risk_level: rl,
756
- community,
757
- };
758
- });
759
- }
760
- /**
761
- * Find a single entity by exact name match (first match across all files).
762
- * Used by drift edge resolution (Task 6.4).
763
- */
764
- async findEntityByName(name) {
765
- const result = await this.query("?[k, kind, name, fp, sl, el, sig, body, fi, fo, rl, community] := *entities{key: k, kind, name, file_path: fp, start_line: sl, end_line: el, signature: sig, body, fan_in: fi, fan_out: fo, risk_level: rl, community}, name = $name :limit 1", { name });
766
- if (result.rows.length === 0)
767
- return null;
768
- const [k, kind, n, fp, sl, el, sig, body, fi, fo, rl, community] = result
769
- .rows[0];
770
- return {
771
- key: k,
772
- kind,
773
- name: n,
774
- file_path: fp,
775
- start_line: sl,
776
- end_line: el,
777
- signature: sig,
778
- body,
779
- fan_in: fi,
780
- fan_out: fo,
781
- risk_level: rl,
782
- community,
783
- };
784
- }
785
- /**
786
- * Search entities by name query.
787
- */
788
- async searchEntities(query, limit = 20) {
789
- return await searchLocal(this.db, query, limit);
790
- }
791
- /**
792
- * Get import edges for a file.
793
- */
794
- async getImports(filePath) {
795
- // File-level import edges use "file:<path>" keys (created by local-indexer R.3).
796
- const fileKey = `file:${filePath}`;
797
- const result = await this.query(`?[to_key] := *edges{from_key: $fk, to_key, type: "imports"}`, { fk: fileKey });
798
- return result.rows.map((row) => {
799
- const raw = row[0];
800
- // Strip "file:" prefix from file-level keys
801
- const imported_file = raw.startsWith("file:") ? raw.slice(5) : raw;
802
- return { imported_file };
803
- });
804
- }
805
- /**
806
- * Bulk insert rules into CozoDB.
807
- */
808
- async loadRules(rules) {
809
- for (const rule of rules) {
810
- await this.write(`?[key, name, scope, severity, engine, query, message, file_glob, enabled, repo_id, status, target_kinds, ast_grep_fix, example, decay_score, evaluations, overrides] <- [[$key, $name, $scope, $severity, $engine, $query, $message, $fg, $enabled, $rid, $status, $tk, $agf, $ex, $ds, $evals, $ov]]
811
- :put rules { key => name, scope, severity, engine, query, message, file_glob, enabled, repo_id, status, target_kinds, ast_grep_fix, example, decay_score, evaluations, overrides }`, {
812
- key: rule.key,
813
- name: rule.name,
814
- scope: rule.scope,
815
- severity: rule.severity,
816
- engine: rule.engine,
817
- query: rule.query,
818
- message: rule.message,
819
- fg: rule.file_glob,
820
- enabled: rule.enabled,
821
- rid: rule.repo_id,
822
- status: rule.status ?? "active",
823
- tk: rule.target_kinds ?? "",
824
- agf: rule.ast_grep_fix ?? "",
825
- ex: rule.example ?? "",
826
- ds: rule.decay_score ?? 0.0,
827
- evals: rule.evaluations ?? 0,
828
- ov: rule.overrides ?? 0,
829
- });
830
- }
831
- }
832
- /**
833
- * Bulk insert patterns into CozoDB.
834
- */
835
- async loadPatterns(patterns) {
836
- for (const pattern of patterns) {
837
- await this.write(`?[key, name, kind, frequency, confidence, exemplar_keys, promoted_rule_key] <- [[$key, $name, $kind, $freq, $conf, $ek, $prk]]
838
- :put patterns { key => name, kind, frequency, confidence, exemplar_keys, promoted_rule_key }`, {
839
- key: pattern.key,
840
- name: pattern.name,
841
- kind: pattern.kind,
842
- freq: pattern.frequency,
843
- conf: pattern.confidence,
844
- ek: pattern.exemplar_keys.join(","),
845
- prk: pattern.promoted_rule_key,
846
- });
847
- }
848
- }
849
- /**
850
- * Check if rules exist in the local store.
851
- */
852
- async hasRules() {
853
- try {
854
- const result = await this.query("?[key] := *rules[key, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _] :limit 1");
855
- return result?.rows?.length > 0;
856
- }
857
- catch {
858
- return false;
859
- }
860
- }
861
- /**
862
- * Get rules, optionally filtered by file path glob matching.
863
- * Returns rules sorted by scope priority (workspace > branch > path > repo > org).
864
- */
865
- async getRules(filePath) {
866
- let result;
867
- try {
868
- result = await this.query("?[key, name, scope, severity, engine, query, message, fg, enabled, rid, status, tk, agf, ex, ds, evals, ov] := *rules[key, name, scope, severity, engine, query, message, fg, enabled, rid, status, tk, agf, ex, ds, evals, ov], enabled = true");
869
- }
870
- catch {
871
- return [];
872
- }
873
- if (!result?.rows)
874
- return [];
875
- const rules = result.rows.map((row) => {
876
- const [key, name, scope, severity, engine, query, message, file_glob, enabled, repo_id, status, target_kinds, ast_grep_fix, example, decay_score, evaluations, overrides,] = row;
877
- return {
878
- key,
879
- name,
880
- scope,
881
- severity,
882
- engine,
883
- query,
884
- message,
885
- file_glob,
886
- enabled,
887
- repo_id,
888
- status,
889
- target_kinds,
890
- ast_grep_fix,
891
- example,
892
- decay_score,
893
- evaluations,
894
- overrides,
895
- };
896
- });
897
- // Filter by file path glob if provided
898
- if (filePath) {
899
- return rules.filter((rule) => {
900
- if (!rule.file_glob)
901
- return true;
902
- return matchGlob(filePath, rule.file_glob);
903
- });
904
- }
905
- // Sort by scope priority
906
- const scopePriority = {
907
- workspace: 5,
908
- branch: 4,
909
- path: 3,
910
- repo: 2,
911
- org: 1,
912
- };
913
- rules.sort((a, b) => (scopePriority[b.scope] ?? 0) - (scopePriority[a.scope] ?? 0));
914
- return rules;
915
- }
916
- /**
917
- * Sprint 9.9: Get rule health summary for status display.
918
- *
919
- * Categories based on decay_score:
920
- * - healthy: decay_score < 0.3 (or no score)
921
- * - aging: 0.3 <= decay_score < 0.6
922
- * - decayed: decay_score >= 0.6
923
- * - dormant: evaluations == 0
924
- */
925
- async getRuleHealthSummary() {
926
- const rules = await this.getRules();
927
- let healthy = 0;
928
- let aging = 0;
929
- let decayed = 0;
930
- let dormant = 0;
931
- const warnings = [];
932
- for (const rule of rules) {
933
- const evals = rule.evaluations ?? 0;
934
- const overrides = rule.overrides ?? 0;
935
- const ds = rule.decay_score ?? 0;
936
- if (evals === 0) {
937
- dormant++;
938
- }
939
- else if (ds >= 0.6) {
940
- decayed++;
941
- const overrideRate = evals > 0 ? Math.round((overrides / evals) * 100) : 0;
942
- warnings.push({
943
- key: rule.key,
944
- name: rule.name,
945
- decayScore: ds,
946
- overrideRate,
947
- });
948
- }
949
- else if (ds >= 0.3) {
950
- aging++;
951
- }
952
- else {
953
- healthy++;
954
- }
955
- }
956
- // Sort warnings by decay score descending
957
- warnings.sort((a, b) => b.decayScore - a.decayScore);
958
- return {
959
- total: rules.length,
960
- healthy,
961
- aging,
962
- decayed,
963
- dormant,
964
- warnings: warnings.slice(0, 3),
965
- };
966
- }
967
- /**
968
- * Get all patterns.
969
- */
970
- async getPatterns() {
971
- const result = await this.query("?[key, name, kind, freq, conf, ek, prk] := *patterns[key, name, kind, freq, conf, ek, prk]");
972
- return result.rows.map((row) => {
973
- const [key, name, kind, frequency, confidence, exemplarKeysStr, promoted_rule_key,] = row;
974
- return {
975
- key,
976
- name,
977
- kind,
978
- frequency,
979
- confidence,
980
- exemplar_keys: exemplarKeysStr
981
- ? exemplarKeysStr.split(",").filter(Boolean)
982
- : [],
983
- promoted_rule_key,
984
- };
985
- });
986
- }
987
- /**
988
- * Health check — always up for local store.
989
- */
990
- healthCheck() {
991
- return { status: "up", latencyMs: 0 };
992
- }
993
- isLoaded() {
994
- return this.loaded;
995
- }
996
- // ── Justification Queries (MV-03) ──────────────────────────────
997
- /**
998
- * Bulk insert justifications into CozoDB.
999
- */
1000
- async loadJustifications(justifications) {
1001
- for (const j of justifications) {
1002
- await this.write(`?[entity_key, purpose, taxonomy, feature_area, confidence] <-
1003
- [[$ek, $purpose, $taxonomy, $fa, $conf]]
1004
- :put justifications { entity_key => purpose, taxonomy, feature_area, confidence }`, {
1005
- ek: j.entity_key,
1006
- purpose: j.purpose,
1007
- taxonomy: j.taxonomy,
1008
- fa: j.feature_area,
1009
- conf: j.confidence,
1010
- });
1011
- }
1012
- }
1013
- /**
1014
- * Get business context (justification) for an entity.
1015
- */
1016
- async getBusinessContext(entityKey) {
1017
- const result = await this.query(`?[ek, purpose, taxonomy, fa, conf] :=
1018
- *justifications[ek, purpose, taxonomy, fa, conf], ek = $ek`, { ek: entityKey });
1019
- if (result.rows.length === 0)
1020
- return null;
1021
- const [, purpose, taxonomy, feature_area, confidence] = result.rows[0];
1022
- const entity = await this.getEntity(entityKey);
1023
- return { purpose, taxonomy, feature_area, confidence, entity };
1024
- }
1025
- /**
1026
- * Get conventions: patterns with adherence rates computed from entity coverage.
1027
- */
1028
- async getConventions() {
1029
- const patterns = await this.getPatterns();
1030
- if (patterns.length === 0)
1031
- return [];
1032
- // Per-kind entity counts for accurate adherence computation
1033
- const kindResult = await this.query("?[kind, count(key)] := *entities{key, kind}");
1034
- const kindCounts = new Map();
1035
- for (const row of kindResult.rows) {
1036
- kindCounts.set(row[0], row[1]);
1037
- }
1038
- return patterns.map((p) => {
1039
- // Use the count of entities of the same kind for adherence
1040
- const kindCount = Math.max(kindCounts.get(p.kind) ?? 1, 1);
1041
- return {
1042
- name: p.name,
1043
- kind: p.kind,
1044
- frequency: p.frequency,
1045
- confidence: p.confidence,
1046
- // Adherence = (frequency / kindCount) capped at 1.0
1047
- adherence_rate: Math.min(1, p.frequency / kindCount),
1048
- };
1049
- });
1050
- }
1051
- /**
1052
- * Get top N conventions applicable to a specific entity's file path.
1053
- * Conventions are patterns + rules filtered by file_glob match,
1054
- * sorted by confidence descending, limited to top N.
1055
- */
1056
- async getConventionsForEntity(entityFilePath, limit = 3) {
1057
- // Get rules that apply to this file
1058
- const rules = await this.getRules(entityFilePath);
1059
- const conventions = [];
1060
- // Patterns as conventions (naming, structure)
1061
- const patterns = await this.getPatterns();
1062
- // Per-kind entity counts for accurate adherence
1063
- const kindResult = await this.query("?[kind, count(key)] := *entities{key, kind}");
1064
- const kindCounts = new Map();
1065
- for (const row of kindResult.rows) {
1066
- kindCounts.set(row[0], row[1]);
1067
- }
1068
- for (const p of patterns) {
1069
- const kindCount = Math.max(kindCounts.get(p.kind) ?? 1, 1);
1070
- conventions.push({
1071
- id: `pattern:${p.key}`,
1072
- name: p.name,
1073
- adherence_pct: Math.round(Math.min(1, p.frequency / kindCount) * 100),
1074
- rule: `${p.kind} pattern (${p.frequency} occurrences, ${Math.round(p.confidence * 100)}% confidence)`,
1075
- });
1076
- }
1077
- // Rules as conventions (severity-based ordering)
1078
- for (const r of rules) {
1079
- conventions.push({
1080
- id: `rule:${r.key}`,
1081
- name: r.name,
1082
- adherence_pct: 100, // Rules are prescriptive, not measured
1083
- rule: r.message || `${r.severity} rule: ${r.name}`,
1084
- });
1085
- }
1086
- // Sort by adherence_pct ascending (lowest adherence = most relevant to surface)
1087
- // then take top N
1088
- conventions.sort((a, b) => a.adherence_pct - b.adherence_pct);
1089
- return conventions.slice(0, limit);
1090
- }
1091
- /**
1092
- * Check if justifications exist in the local store.
1093
- */
1094
- async hasJustifications() {
1095
- const result = await this.query("?[ek] := *justifications[ek, _, _, _, _] :limit 1");
1096
- return result.rows.length > 0;
1097
- }
1098
- // ── Rule Exceptions (Sprint 9.6) ────────────────────────────────
1099
- /**
1100
- * Bulk insert rule exceptions into CozoDB.
1101
- */
1102
- async loadRuleExceptions(exceptions) {
1103
- for (const ex of exceptions) {
1104
- await this.write(`?[key, entity_key, rule_key, reason, granted_by, granted_at, expires_at, status, jira_ticket] <- [[$key, $ek, $rk, $reason, $gb, $ga, $ea, $status, $jt]]
1105
- :put rule_exceptions { key => entity_key, rule_key, reason, granted_by, granted_at, expires_at, status, jira_ticket }`, {
1106
- key: ex.key,
1107
- ek: ex.entity_key,
1108
- rk: ex.rule_key,
1109
- reason: ex.reason,
1110
- gb: ex.granted_by,
1111
- ga: ex.granted_at,
1112
- ea: ex.expires_at,
1113
- status: ex.status ?? "active",
1114
- jt: ex.jira_ticket ?? "",
1115
- });
1116
- }
1117
- }
1118
- /**
1119
- * Get active rule exceptions for an entity, optionally filtered by rule key.
1120
- * Returns only non-expired, active exceptions.
1121
- */
1122
- async getRuleExceptions(entityKey, ruleKey) {
1123
- const result = await this.query("?[key, ek, rk, reason, gb, ga, ea, status, jt] := *rule_exceptions[key, ek, rk, reason, gb, ga, ea, status, jt], ek = $ek, status = 'active'", { ek: entityKey });
1124
- const now = new Date().toISOString();
1125
- const exceptions = result.rows
1126
- .map((row) => {
1127
- const [key, entity_key, rule_key, reason, granted_by, granted_at, expires_at, status, jira_ticket,] = row;
1128
- return {
1129
- key,
1130
- entity_key,
1131
- rule_key,
1132
- reason,
1133
- granted_by,
1134
- granted_at,
1135
- expires_at,
1136
- status,
1137
- jira_ticket,
1138
- };
1139
- })
1140
- .filter((ex) => !ex.expires_at || ex.expires_at > now);
1141
- if (ruleKey) {
1142
- return exceptions.filter((ex) => ex.rule_key === ruleKey);
1143
- }
1144
- return exceptions;
1145
- }
1146
- /**
1147
- * Get all rule exceptions that are expiring within the given window (milliseconds).
1148
- * Used by _meta to surface "exception expiring soon" warnings.
1149
- */
1150
- async getExpiringExceptions(windowMs) {
1151
- const result = await this.query("?[key, ek, rk, reason, gb, ga, ea, status, jt] := *rule_exceptions[key, ek, rk, reason, gb, ga, ea, status, jt], status = 'active'");
1152
- const now = Date.now();
1153
- const threshold = new Date(now + windowMs).toISOString();
1154
- const nowIso = new Date(now).toISOString();
1155
- return result.rows
1156
- .map((row) => {
1157
- const [key, entity_key, rule_key, reason, granted_by, granted_at, expires_at, status, jira_ticket,] = row;
1158
- return {
1159
- key,
1160
- entity_key,
1161
- rule_key,
1162
- reason,
1163
- granted_by,
1164
- granted_at,
1165
- expires_at,
1166
- status,
1167
- jira_ticket,
1168
- };
1169
- })
1170
- .filter((ex) => ex.expires_at && ex.expires_at > nowIso && ex.expires_at <= threshold);
1171
- }
1172
- // ── Drift Overlay CRUD ──────────────────────────────────────────
1173
- /**
1174
- * Insert or update an entity in the drift overlay.
1175
- */
1176
- async upsertDriftEntity(entity) {
1177
- await this.write(`?[key, name, kind, signature, body, file_path, line_start, line_end, content_hash, drift_status, intent_id, modified_at, origin, previous_body, previous_signature] <-
1178
- [[$key, $name, $kind, $sig, $body, $fp, $ls, $le, $ch, $ds, $iid, $ma, $origin, $pb, $ps]]
1179
- :put drift_overlay { key => name, kind, signature, body, file_path, line_start, line_end, content_hash, drift_status, intent_id, modified_at, origin, previous_body, previous_signature }`, {
1180
- key: entity.key,
1181
- name: entity.name,
1182
- kind: entity.kind,
1183
- sig: entity.signature,
1184
- body: entity.body,
1185
- fp: entity.file_path,
1186
- ls: entity.line_start,
1187
- le: entity.line_end,
1188
- ch: entity.content_hash,
1189
- ds: entity.drift_status,
1190
- iid: entity.intent_id,
1191
- ma: entity.modified_at,
1192
- origin: entity.origin,
1193
- pb: entity.previous_body,
1194
- ps: entity.previous_signature,
1195
- });
1196
- }
1197
- /**
1198
- * Remove an entity from the drift overlay.
1199
- */
1200
- async removeDriftEntity(key) {
1201
- await this.write("?[key] <- [[$key]] :rm drift_overlay { key }", { key });
1202
- }
1203
- /**
1204
- * Get all drift overlay entities for a given file path.
1205
- */
1206
- async getDriftEntitiesForFile(filePath) {
1207
- const result = await this.query(`?[key, name, kind, sig, body, fp, ls, le, ch, ds, iid, ma, origin, pb, ps] :=
1208
- *drift_overlay[key, name, kind, sig, body, fp, ls, le, ch, ds, iid, ma, origin, pb, ps],
1209
- fp = $fp`, { fp: filePath });
1210
- return result.rows.map((row) => {
1211
- const [key, name, kind, signature, body, file_path, line_start, line_end, content_hash, drift_status, intent_id, modified_at, origin, previous_body, previous_signature,] = row;
1212
- return {
1213
- key,
1214
- name,
1215
- kind,
1216
- signature,
1217
- body,
1218
- file_path,
1219
- line_start,
1220
- line_end,
1221
- content_hash,
1222
- drift_status: drift_status,
1223
- intent_id,
1224
- modified_at,
1225
- origin: origin,
1226
- previous_body,
1227
- previous_signature,
1228
- };
1229
- });
1230
- }
1231
- /**
1232
- * Get a single drift entity by key.
1233
- */
1234
- async getDriftEntity(key) {
1235
- const result = await this.query(`?[key, name, kind, sig, body, fp, ls, le, ch, ds, iid, ma, origin, pb, ps] :=
1236
- *drift_overlay[key, name, kind, sig, body, fp, ls, le, ch, ds, iid, ma, origin, pb, ps],
1237
- key = $key`, { key });
1238
- if (result.rows.length === 0)
1239
- return null;
1240
- const [k, name, kind, signature, body, file_path, line_start, line_end, content_hash, drift_status, intent_id, modified_at, origin, previous_body, previous_signature,] = result.rows[0];
1241
- return {
1242
- key: k,
1243
- name,
1244
- kind,
1245
- signature,
1246
- body,
1247
- file_path,
1248
- line_start,
1249
- line_end,
1250
- content_hash,
1251
- drift_status: drift_status,
1252
- intent_id,
1253
- modified_at,
1254
- origin: origin,
1255
- previous_body,
1256
- previous_signature,
1257
- };
1258
- }
1259
- /**
1260
- * Get all drift overlay entities (for stash snapshot serialization).
1261
- */
1262
- async getAllDriftEntities() {
1263
- const result = await this.query(`?[key, name, kind, sig, body, fp, ls, le, ch, ds, iid, ma, origin, pb, ps] :=
1264
- *drift_overlay[key, name, kind, sig, body, fp, ls, le, ch, ds, iid, ma, origin, pb, ps]`);
1265
- return result.rows.map((row) => {
1266
- const [key, name, kind, signature, body, file_path, line_start, line_end, content_hash, drift_status, intent_id, modified_at, origin, previous_body, previous_signature,] = row;
1267
- return {
1268
- key,
1269
- name,
1270
- kind,
1271
- signature,
1272
- body,
1273
- file_path,
1274
- line_start,
1275
- line_end,
1276
- content_hash,
1277
- drift_status: drift_status,
1278
- intent_id,
1279
- modified_at,
1280
- origin: origin,
1281
- previous_body,
1282
- previous_signature,
1283
- };
1284
- });
1285
- }
1286
- /**
1287
- * Clear all drift overlay entries (branch switch, graph re-pull).
1288
- */
1289
- async clearDriftOverlay() {
1290
- // Get all keys, then remove them
1291
- const result = await this.write("?[key] := *drift_overlay[key, _, _, _, _, _, _, _, _, _, _, _, _, _, _]");
1292
- for (const row of result.rows) {
1293
- const [key] = row;
1294
- await this.write("?[key] <- [[$key]] :rm drift_overlay { key }", {
1295
- key,
1296
- });
1297
- }
1298
- // Also clear drift edges (same lifecycle — Task 6.4)
1299
- await this.clearDriftEdges();
1300
- }
1301
- // ── Sprint 6.4: Drift Edges CRUD ───────────────────────────────────
1302
- /**
1303
- * Insert or update an edge in the drift_edges overlay.
1304
- */
1305
- async upsertDriftEdge(edge) {
1306
- await this.write(`?[from_key, to_key, type, drift_status, modified_at] <-
1307
- [[$fk, $tk, $type, $ds, $ma]]
1308
- :put drift_edges { from_key, to_key, type => drift_status, modified_at }`, {
1309
- fk: edge.from_key,
1310
- tk: edge.to_key,
1311
- type: edge.type,
1312
- ds: edge.drift_status,
1313
- ma: edge.modified_at,
1314
- });
1315
- }
1316
- /**
1317
- * Remove an edge from the drift_edges overlay.
1318
- */
1319
- async removeDriftEdge(fromKey, toKey, type) {
1320
- await this.write("?[from_key, to_key, type] <- [[$fk, $tk, $type]] :rm drift_edges { from_key, to_key, type }", { fk: fromKey, tk: toKey, type });
1321
- }
1322
- /**
1323
- * Get all drift edges (for snapshot serialization).
1324
- */
1325
- async getAllDriftEdges() {
1326
- const result = await this.query("?[fk, tk, type, ds, ma] := *drift_edges[fk, tk, type, ds, ma]");
1327
- return result.rows.map((row) => {
1328
- const [from_key, to_key, type, drift_status, modified_at] = row;
1329
- return {
1330
- from_key,
1331
- to_key,
1332
- type,
1333
- drift_status: drift_status,
1334
- modified_at,
1335
- };
1336
- });
1337
- }
1338
- /**
1339
- * Clear all drift edges (branch switch, graph re-pull).
1340
- */
1341
- async clearDriftEdges() {
1342
- const result = await this.write("?[fk, tk, type] := *drift_edges[fk, tk, type, _, _]");
1343
- for (const row of result.rows) {
1344
- const [fk, tk, type] = row;
1345
- await this.write("?[from_key, to_key, type] <- [[$fk, $tk, $type]] :rm drift_edges { from_key, to_key, type }", { fk, tk, type });
1346
- }
1347
- }
1348
- // ── Sprint E-8: Delta Application (P5-EVO-19) ─────────────────────
1349
- /**
1350
- * Apply an incremental delta from the server to the local CozoDB graph.
1351
- * Called when a delta message is received via Redis pub/sub.
1352
- *
1353
- * Operations:
1354
- * 1. Upsert added/updated entities into :entities + :file_index
1355
- * 2. Remove deleted entity keys from :entities + :file_index
1356
- * 3. Upsert added edges into :edges, remove old edges for changed entities
1357
- * 4. Upsert updated justifications into :justifications
1358
- * 5. Rebuild search tokens for affected entities (incremental)
1359
- */
1360
- async applyDelta(delta) {
1361
- let applied = 0;
1362
- let deleted = 0;
1363
- let edgeCount = 0;
1364
- let justCount = 0;
1365
- // 1. Upsert added + updated entities
1366
- const allEntities = [...delta.entities.added, ...delta.entities.updated];
1367
- for (const entity of allEntities) {
1368
- await this.write(`?[key, kind, name, file_path, start_line, end_line, signature, body, fan_in, fan_out, risk_level] <- [[$key, $kind, $name, $fp, $sl, $el, $sig, $body, $fi, $fo, $rl]]
1369
- :put entities { key => kind, name, file_path, start_line, end_line, signature, body, fan_in, fan_out, risk_level }`, {
1370
- key: entity.key,
1371
- kind: entity.kind,
1372
- name: entity.name,
1373
- fp: entity.file_path,
1374
- sl: entity.start_line ?? 0,
1375
- el: entity.end_line ?? 0,
1376
- sig: entity.signature ?? "",
1377
- body: entity.body ?? "",
1378
- fi: entity.fan_in ?? 0,
1379
- fo: entity.fan_out ?? 0,
1380
- rl: entity.risk_level ?? "normal",
1381
- });
1382
- // Update file index
1383
- await this.write("?[file_path, entity_key] <- [[$fp, $key]] :put file_index { file_path, entity_key }", { fp: entity.file_path, key: entity.key });
1384
- applied++;
1385
- }
1386
- // 2. Delete removed entities
1387
- for (const key of delta.entities.deletedKeys) {
1388
- try {
1389
- await this.write("?[key] <- [[$key]] :rm entities { key }", { key });
1390
- // Clean up file_index entries for this entity
1391
- await this.write("?[fp, ek] := *file_index[fp, ek], ek = $key :rm file_index { file_path: fp, entity_key: ek }", { key });
1392
- deleted++;
1393
- }
1394
- catch {
1395
- // Entity may not exist locally — that's fine
1396
- }
1397
- }
1398
- // 3. Remove old edges for changed entities, then insert new edges
1399
- for (const edge of delta.edges.removed) {
1400
- try {
1401
- await this.write("?[from_key, to_key, type] <- [[$from, $to, $type]] :rm edges { from_key, to_key, type }", { from: edge.from_key, to: edge.to_key, type: edge.type });
1402
- }
1403
- catch {
1404
- // Edge may not exist
1405
- }
1406
- }
1407
- for (const edge of delta.edges.added) {
1408
- await this.write("?[from_key, to_key, type] <- [[$from, $to, $type]] :put edges { from_key, to_key, type }", { from: edge.from_key, to: edge.to_key, type: edge.type });
1409
- edgeCount++;
1410
- }
1411
- // 4. Upsert justifications
1412
- for (const j of delta.justifications.updated) {
1413
- await this.write(`?[entity_key, purpose, taxonomy, feature_area, confidence] <-
1414
- [[$ek, $purpose, $taxonomy, $fa, $conf]]
1415
- :put justifications { entity_key => purpose, taxonomy, feature_area, confidence }`, {
1416
- ek: j.entity_key,
1417
- purpose: j.purpose,
1418
- taxonomy: j.taxonomy,
1419
- fa: j.feature_area,
1420
- conf: j.confidence,
1421
- });
1422
- justCount++;
1423
- }
1424
- // 5. Rebuild search tokens for affected entities (incremental)
1425
- const affectedKeys = new Set([
1426
- ...allEntities.map((e) => e.key),
1427
- ...delta.entities.deletedKeys,
1428
- ]);
1429
- // Remove old search tokens for affected entities
1430
- for (const key of affectedKeys) {
1431
- try {
1432
- await this.write("?[token, ek] := *search_tokens[token, ek], ek = $key :rm search_tokens { token, entity_key: ek }", { key });
1433
- }
1434
- catch {
1435
- // May not exist
1436
- }
1437
- }
1438
- // Insert new search tokens for added/updated entities
1439
- for (const entity of allEntities) {
1440
- const tokens = tokenize(entity.name);
1441
- for (const token of tokens) {
1442
- await this.write("?[token, entity_key] <- [[$token, $key]] :put search_tokens { token, entity_key }", { token, key: entity.key });
1443
- }
1444
- }
1445
- // 6. Task 6.9: Prune stale overlay entries — base now matches local after delta
1446
- let expired = 0;
1447
- const overlayEntities = await this.getAllDriftEntities();
1448
- for (const overlay of overlayEntities) {
1449
- // Only prune modified entries (added/deleted have different semantics)
1450
- if (overlay.drift_status !== "modified")
1451
- continue;
1452
- const base = await this.getEntity(overlay.key);
1453
- if (base &&
1454
- base.body === overlay.body &&
1455
- base.signature === overlay.signature) {
1456
- await this.removeDriftEntity(overlay.key);
1457
- expired++;
1458
- }
1459
- }
1460
- return {
1461
- applied,
1462
- deleted,
1463
- edges: edgeCount,
1464
- justifications: justCount,
1465
- overlayExpired: expired,
1466
- };
1467
- }
1468
- /**
1469
- * Get aggregate counts of drift overlay entries by status.
1470
- */
1471
- async getDriftSummary() {
1472
- const result = await this.query("?[ds, count(key)] := *drift_overlay[key, _, _, _, _, _, _, _, _, ds, _, _, _, _, _]");
1473
- const summary = {
1474
- added: 0,
1475
- modified: 0,
1476
- deleted: 0,
1477
- dependency_changed: 0,
1478
- total: 0,
1479
- };
1480
- for (const row of result.rows) {
1481
- const [status, count] = row;
1482
- if (status === "added")
1483
- summary.added = count;
1484
- else if (status === "modified")
1485
- summary.modified = count;
1486
- else if (status === "deleted")
1487
- summary.deleted = count;
1488
- else if (status === "dependency_changed")
1489
- summary.dependency_changed = count;
1490
- }
1491
- summary.total =
1492
- summary.added +
1493
- summary.modified +
1494
- summary.deleted +
1495
- summary.dependency_changed;
1496
- return summary;
1497
- }
1498
- // ── Sprint 11: Phase 22 Blueprint Deep Dive Methods ──────────────
1499
- /**
1500
- * Load blueprint project data into CozoDB (from Butter-Sync or snapshot).
1501
- */
1502
- async loadDeepDiveProject(project) {
1503
- await this.write(`?[key, name, description, status, domain, stage, stack_recommendation, design_system, health_baseline, org_id, updated_at] <-
1504
- [[$key, $name, $description, $status, $domain, $stage, $stack_recommendation, $design_system, $health_baseline, $org_id, $updated_at]]
1505
- :put deep_dive_projects { key => name, description, status, domain, stage, stack_recommendation, design_system, health_baseline, org_id, updated_at }`, {
1506
- key: project.key,
1507
- name: project.name,
1508
- description: project.description,
1509
- status: project.status,
1510
- domain: JSON.stringify(project.domain ?? {}),
1511
- stage: JSON.stringify(project.stage ?? {}),
1512
- stack_recommendation: JSON.stringify(project.stackRecommendation ?? {}),
1513
- design_system: JSON.stringify(project.designSystem ?? ""),
1514
- health_baseline: JSON.stringify(project.healthBaseline ?? {}),
1515
- org_id: project.orgId ?? "",
1516
- updated_at: project.updatedAt ?? new Date().toISOString(),
1517
- });
1518
- }
1519
- /**
1520
- * Load blueprint slices for a project.
1521
- */
1522
- async loadDeepDiveSlices(slices) {
1523
- for (const s of slices) {
1524
- await this.write(`?[key, project_key, name, description, repo_target_id, parent_slice_key, dependencies, status, order, slice_type, data_model, api_surface, conventions, boundary_rules, user_flows, ui_design] <-
1525
- [[$key, $project_key, $name, $description, $repo_target_id, $parent_slice_key, $dependencies, $status, $order, $slice_type, $data_model, $api_surface, $conventions, $boundary_rules, $user_flows, $ui_design]]
1526
- :put deep_dive_slices { key => project_key, name, description, repo_target_id, parent_slice_key, dependencies, status, order, slice_type, data_model, api_surface, conventions, boundary_rules, user_flows, ui_design }`, {
1527
- key: s.key,
1528
- project_key: s.projectKey,
1529
- name: s.name,
1530
- description: s.description ?? "",
1531
- repo_target_id: s.repoTargetId ?? "",
1532
- parent_slice_key: s.parentSliceKey ?? "",
1533
- dependencies: JSON.stringify(s.dependencies ?? []),
1534
- status: s.status ?? "planned",
1535
- order: s.order ?? 0,
1536
- slice_type: s.sliceType ?? "",
1537
- data_model: JSON.stringify(s.dataModel ?? ""),
1538
- api_surface: JSON.stringify(s.apiSurface ?? ""),
1539
- conventions: JSON.stringify(s.conventions ?? []),
1540
- boundary_rules: JSON.stringify(s.boundaryRules ?? []),
1541
- user_flows: JSON.stringify(s.userFlows ?? ""),
1542
- ui_design: JSON.stringify(s.uiDesign ?? ""),
1543
- });
1544
- }
1545
- }
1546
- /**
1547
- * Load blueprint tasks for a project.
1548
- */
1549
- async loadDeepDiveTasks(tasks) {
1550
- for (const t of tasks) {
1551
- await this.write(`?[key, project_key, sprint_number, slice_name, description, status, estimated_effort, dependencies, boundary_rules, conventions, acceptance_criteria, completed_at, completed_files, checkpoint] <-
1552
- [[$key, $project_key, $sprint_number, $slice_name, $description, $status, $estimated_effort, $dependencies, $boundary_rules, $conventions, $acceptance_criteria, $completed_at, $completed_files, $checkpoint]]
1553
- :put deep_dive_tasks { key => project_key, sprint_number, slice_name, description, status, estimated_effort, dependencies, boundary_rules, conventions, acceptance_criteria, completed_at, completed_files, checkpoint }`, {
1554
- key: t.key,
1555
- project_key: t.projectKey,
1556
- sprint_number: t.sprintNumber,
1557
- slice_name: t.sliceName ?? "",
1558
- description: t.description ?? "",
1559
- status: t.status ?? "pending",
1560
- estimated_effort: t.estimatedEffort ?? "",
1561
- dependencies: JSON.stringify(t.dependencies ?? []),
1562
- boundary_rules: JSON.stringify(t.boundaryRules ?? []),
1563
- conventions: JSON.stringify(t.conventions ?? []),
1564
- acceptance_criteria: JSON.stringify(t.acceptanceCriteria ?? []),
1565
- completed_at: t.completedAt ?? "",
1566
- completed_files: JSON.stringify(t.completedFiles ?? []),
1567
- checkpoint: JSON.stringify(t.checkpoint ?? {}),
1568
- });
1569
- }
1570
- }
1571
- /**
1572
- * Load design system tokens for a project.
1573
- */
1574
- async loadDeepDiveDesignSystem(projectKey, tokens) {
1575
- await this.write(`?[project_key, tokens, updated_at] <-
1576
- [[$project_key, $tokens, $updated_at]]
1577
- :put deep_dive_design_system { project_key => tokens, updated_at }`, {
1578
- project_key: projectKey,
1579
- tokens: JSON.stringify(tokens ?? {}),
1580
- updated_at: new Date().toISOString(),
1581
- });
1582
- }
1583
- /**
1584
- * Get a blueprint project by key.
1585
- */
1586
- async getDeepDiveProject(key) {
1587
- const result = await this.query(`?[key, name, description, status, domain, stage, stack_recommendation, design_system, health_baseline, org_id, updated_at] :=
1588
- *deep_dive_projects[key, name, description, status, domain, stage, stack_recommendation, design_system, health_baseline, org_id, updated_at],
1589
- key = $key`, { key });
1590
- if (result.rows.length === 0)
1591
- return null;
1592
- const row = result.rows[0];
1593
- return {
1594
- key: row[0],
1595
- name: row[1],
1596
- description: row[2],
1597
- status: row[3],
1598
- domain: JSON.parse(row[4] || "{}"),
1599
- stage: JSON.parse(row[5] || "{}"),
1600
- stackRecommendation: JSON.parse(row[6] || "{}"),
1601
- designSystem: row[7] ? JSON.parse(row[7]) : null,
1602
- healthBaseline: JSON.parse(row[8] || "{}"),
1603
- orgId: row[9],
1604
- updatedAt: row[10],
1605
- };
1606
- }
1607
- /**
1608
- * Get the first active project (most recently updated).
1609
- */
1610
- async getActiveDeepDiveProject() {
1611
- const result = await this.query(`?[key, name, description, status, domain, stage, stack_recommendation, design_system, health_baseline, org_id, updated_at] :=
1612
- *deep_dive_projects[key, name, description, status, domain, stage, stack_recommendation, design_system, health_baseline, org_id, updated_at],
1613
- status != "draft"
1614
- :sort -updated_at
1615
- :limit 1`);
1616
- if (result.rows.length === 0)
1617
- return null;
1618
- const row = result.rows[0];
1619
- return {
1620
- key: row[0],
1621
- name: row[1],
1622
- description: row[2],
1623
- status: row[3],
1624
- domain: JSON.parse(row[4] || "{}"),
1625
- stage: JSON.parse(row[5] || "{}"),
1626
- stackRecommendation: JSON.parse(row[6] || "{}"),
1627
- designSystem: row[7] ? JSON.parse(row[7]) : null,
1628
- healthBaseline: JSON.parse(row[8] || "{}"),
1629
- orgId: row[9],
1630
- updatedAt: row[10],
1631
- };
1632
- }
1633
- /**
1634
- * Get all slices for a project, ordered by their order field.
1635
- */
1636
- async getDeepDiveSlices(projectKey) {
1637
- const result = await this.query(`?[key, project_key, name, description, repo_target_id, parent_slice_key, dependencies, status, order, slice_type, data_model, api_surface, conventions, boundary_rules, user_flows, ui_design] :=
1638
- *deep_dive_slices[key, project_key, name, description, repo_target_id, parent_slice_key, dependencies, status, order, slice_type, data_model, api_surface, conventions, boundary_rules, user_flows, ui_design],
1639
- project_key = $project_key
1640
- :sort order`, { project_key: projectKey });
1641
- return result.rows.map((row) => {
1642
- const [key, , name, description, repoTargetId, parentSliceKey, deps, status, order, sliceType, dataModel, apiSurface, conventions, boundaryRules, userFlows, uiDesign,] = row;
1643
- return {
1644
- key,
1645
- name,
1646
- description,
1647
- repoTargetId,
1648
- parentSliceKey,
1649
- dependencies: JSON.parse(deps || "[]"),
1650
- status,
1651
- order,
1652
- sliceType,
1653
- dataModel: dataModel ? JSON.parse(dataModel) : null,
1654
- apiSurface: apiSurface ? JSON.parse(apiSurface) : null,
1655
- conventions: JSON.parse(conventions || "[]"),
1656
- boundaryRules: JSON.parse(boundaryRules || "[]"),
1657
- userFlows: userFlows ? JSON.parse(userFlows) : null,
1658
- uiDesign: uiDesign ? JSON.parse(uiDesign) : null,
1659
- };
1660
- });
1661
- }
1662
- /**
1663
- * Get a single slice by key.
1664
- */
1665
- async getDeepDiveSlice(key) {
1666
- const result = await this.query(`?[key, project_key, name, description, repo_target_id, parent_slice_key, dependencies, status, order, slice_type, data_model, api_surface, conventions, boundary_rules, user_flows, ui_design] :=
1667
- *deep_dive_slices[key, project_key, name, description, repo_target_id, parent_slice_key, dependencies, status, order, slice_type, data_model, api_surface, conventions, boundary_rules, user_flows, ui_design],
1668
- key = $key`, { key });
1669
- if (result.rows.length === 0)
1670
- return null;
1671
- const [k, , name, description, repoTargetId, parentSliceKey, deps, status, order, sliceType, dataModel, apiSurface, conventions, boundaryRules, userFlows, uiDesign,] = result.rows[0];
1672
- return {
1673
- key: k,
1674
- name,
1675
- description,
1676
- repoTargetId,
1677
- parentSliceKey,
1678
- dependencies: JSON.parse(deps || "[]"),
1679
- status,
1680
- order,
1681
- sliceType,
1682
- dataModel: dataModel ? JSON.parse(dataModel) : null,
1683
- apiSurface: apiSurface ? JSON.parse(apiSurface) : null,
1684
- conventions: JSON.parse(conventions || "[]"),
1685
- boundaryRules: JSON.parse(boundaryRules || "[]"),
1686
- userFlows: userFlows ? JSON.parse(userFlows) : null,
1687
- uiDesign: uiDesign ? JSON.parse(uiDesign) : null,
1688
- };
1689
- }
1690
- /**
1691
- * Get all tasks for a project, optionally filtered by sprint number.
1692
- */
1693
- async getDeepDiveTasks(projectKey, sprintNumber) {
1694
- const query = sprintNumber != null
1695
- ? `?[key, project_key, sprint_number, slice_name, description, status, estimated_effort, dependencies, boundary_rules, conventions, acceptance_criteria, completed_at, completed_files, checkpoint] :=
1696
- *deep_dive_tasks[key, project_key, sprint_number, slice_name, description, status, estimated_effort, dependencies, boundary_rules, conventions, acceptance_criteria, completed_at, completed_files, checkpoint],
1697
- project_key = $project_key, sprint_number = $sprint_number
1698
- :sort key`
1699
- : `?[key, project_key, sprint_number, slice_name, description, status, estimated_effort, dependencies, boundary_rules, conventions, acceptance_criteria, completed_at, completed_files, checkpoint] :=
1700
- *deep_dive_tasks[key, project_key, sprint_number, slice_name, description, status, estimated_effort, dependencies, boundary_rules, conventions, acceptance_criteria, completed_at, completed_files, checkpoint],
1701
- project_key = $project_key
1702
- :sort sprint_number, key`;
1703
- const params = { project_key: projectKey };
1704
- if (sprintNumber != null)
1705
- params.sprint_number = sprintNumber;
1706
- const result = await this.query(query, params);
1707
- return result.rows.map((row) => {
1708
- const [key, , sprintNum, sliceName, description, status, estimatedEffort, deps, bRules, convs, criteria, completedAt, completedFiles, checkpoint,] = row;
1709
- return {
1710
- key,
1711
- sprintNumber: sprintNum,
1712
- sliceName,
1713
- description,
1714
- status,
1715
- estimatedEffort,
1716
- dependencies: JSON.parse(deps || "[]"),
1717
- boundaryRules: JSON.parse(bRules || "[]"),
1718
- conventions: JSON.parse(convs || "[]"),
1719
- acceptanceCriteria: JSON.parse(criteria || "[]"),
1720
- completedAt,
1721
- completedFiles: JSON.parse(completedFiles || "[]"),
1722
- checkpoint: JSON.parse(checkpoint || "{}"),
1723
- };
1724
- });
1725
- }
1726
- /**
1727
- * Mark a task as complete in the local graph.
1728
- */
1729
- async completeDeepDiveTask(taskKey, completedFiles) {
1730
- const result = await this.query(`?[key, project_key, sprint_number, slice_name, description, status, estimated_effort, dependencies, boundary_rules, conventions, acceptance_criteria, completed_at, completed_files, checkpoint] :=
1731
- *deep_dive_tasks[key, project_key, sprint_number, slice_name, description, status, estimated_effort, dependencies, boundary_rules, conventions, acceptance_criteria, completed_at, completed_files, checkpoint],
1732
- key = $key`, { key: taskKey });
1733
- if (result.rows.length === 0)
1734
- return false;
1735
- const row = result.rows[0];
1736
- await this.write(`?[key, project_key, sprint_number, slice_name, description, status, estimated_effort, dependencies, boundary_rules, conventions, acceptance_criteria, completed_at, completed_files, checkpoint] <-
1737
- [[$key, $project_key, $sprint_number, $slice_name, $description, "complete", $estimated_effort, $dependencies, $boundary_rules, $conventions, $acceptance_criteria, $completed_at, $completed_files, $checkpoint]]
1738
- :put deep_dive_tasks { key => project_key, sprint_number, slice_name, description, status, estimated_effort, dependencies, boundary_rules, conventions, acceptance_criteria, completed_at, completed_files, checkpoint }`, {
1739
- key: taskKey,
1740
- project_key: row[1],
1741
- sprint_number: row[2],
1742
- slice_name: row[3],
1743
- description: row[4],
1744
- estimated_effort: row[6],
1745
- dependencies: row[7],
1746
- boundary_rules: row[8],
1747
- conventions: row[9],
1748
- acceptance_criteria: row[10],
1749
- completed_at: new Date().toISOString(),
1750
- completed_files: JSON.stringify(completedFiles ?? []),
1751
- checkpoint: row[13],
1752
- });
1753
- return true;
1754
- }
1755
- /**
1756
- * Get design system tokens for a project.
1757
- */
1758
- async getDeepDiveDesignSystem(projectKey) {
1759
- const result = await this.query(`?[project_key, tokens, updated_at] :=
1760
- *deep_dive_design_system[project_key, tokens, updated_at],
1761
- project_key = $project_key`, { project_key: projectKey });
1762
- if (result.rows.length === 0)
1763
- return null;
1764
- const [, tokens] = result.rows[0];
1765
- return JSON.parse(tokens || "{}");
1766
- }
1767
- /**
1768
- * Check if any deep dive project exists (for dynamic tool loading).
1769
- */
1770
- async hasDeepDiveProject() {
1771
- const result = await this.query("?[key] := *deep_dive_projects[key, _, _, _, _, _, _, _, _, _, _] :limit 1");
1772
- return result.rows.length > 0;
1773
- }
1774
- /**
1775
- * Get deep dive project state for dynamic tool loading.
1776
- * Returns: "none" | "pre_approval" | "approved" | "building"
1777
- */
1778
- async getDeepDiveProjectState() {
1779
- const project = await this.getActiveDeepDiveProject();
1780
- if (!project)
1781
- return "none";
1782
- if (project.status === "building")
1783
- return "building";
1784
- if (project.status === "approved")
1785
- return "approved";
1786
- return "pre_approval";
1787
- }
1788
- // ── Leapfrog Sprint B: Correction Intelligence ─────────────────
1789
- /**
1790
- * Persist correction patterns from the detector into the CozoDB corrections relation.
1791
- * Uses :put for upsert behavior — re-running the detector updates existing patterns
1792
- * with higher occurrences and latest timestamps.
1793
- */
1794
- async persistCorrections(patterns) {
1795
- for (const p of patterns) {
1796
- // Check if existing pattern exists to merge occurrences
1797
- const existing = await this.query(`?[entity_key, error_type, correction_summary, confidence, occurrences, last_seen] :=
1798
- *corrections{entity_key: $ek, error_type: $et, correction_summary, confidence, occurrences, last_seen}`, { ek: p.entity_key, et: p.error_type });
1799
- const prevOcc = existing.rows.length > 0 ? existing.rows[0]?.[4] : 0;
1800
- const prevConf = existing.rows.length > 0 ? existing.rows[0]?.[3] : 0;
1801
- await this.write(`?[entity_key, error_type, correction_summary, confidence, occurrences, last_seen] <-
1802
- [[$ek, $et, $cs, $conf, $occ, $ls]]
1803
- :put corrections {
1804
- entity_key, error_type
1805
- =>
1806
- correction_summary, confidence, occurrences, last_seen
1807
- }`, {
1808
- ek: p.entity_key,
1809
- et: p.error_type,
1810
- cs: p.correction_summary,
1811
- conf: Math.max(p.confidence, prevConf),
1812
- occ: prevOcc + p.occurrences,
1813
- ls: p.last_seen,
1814
- });
1815
- }
1816
- }
1817
- /**
1818
- * Get corrections for a specific entity, filtered by minimum confidence.
1819
- * Returns results sorted by confidence descending. <1ms (indexed query).
1820
- */
1821
- async getCorrections(entityKey, minConfidence = 0.7) {
1822
- const result = await this.query(`?[entity_key, error_type, correction_summary, confidence, occurrences, last_seen] :=
1823
- *corrections{entity_key, error_type, correction_summary, confidence, occurrences, last_seen},
1824
- entity_key = $ek,
1825
- confidence >= $min_conf
1826
- :order -confidence`, { ek: entityKey, min_conf: minConfidence });
1827
- return result.rows.map((row) => ({
1828
- entity_key: row[0],
1829
- error_type: row[1],
1830
- correction_summary: row[2],
1831
- confidence: row[3],
1832
- occurrences: row[4],
1833
- last_seen: row[5],
1834
- }));
1835
- }
1836
- /**
1837
- * Get all corrections above a confidence threshold.
1838
- */
1839
- async getAllCorrections(minConfidence = 0.7) {
1840
- const result = await this.query(`?[entity_key, error_type, correction_summary, confidence, occurrences, last_seen] :=
1841
- *corrections{entity_key, error_type, correction_summary, confidence, occurrences, last_seen},
1842
- confidence >= $min_conf
1843
- :order -confidence`, { min_conf: minConfidence });
1844
- return result.rows.map((row) => ({
1845
- entity_key: row[0],
1846
- error_type: row[1],
1847
- correction_summary: row[2],
1848
- confidence: row[3],
1849
- occurrences: row[4],
1850
- last_seen: row[5],
1851
- }));
1852
- }
1853
- /**
1854
- * Local get_project_stats — pure CozoDB Datalog aggregation.
1855
- * Returns entity/edge/file/rule/drift counts and breakdown by kind.
1856
- * Target: <10ms.
1857
- */
1858
- async getLocalProjectStats() {
1859
- const entityCount = (await this.query("?[count(key)] := *entities{key}"))
1860
- .rows[0]?.[0] ?? 0;
1861
- const edgeCount = (await this.query("?[count(from_key)] := *edges{from_key}"))
1862
- .rows[0]?.[0] ?? 0;
1863
- const fileCount = (await this.query("?[count_unique(file_path)] := *entities{key, file_path}")).rows[0]?.[0] ?? 0;
1864
- const ruleCount = (await this.query("?[count(key)] := *rules{key, enabled}, enabled = true")).rows[0]?.[0] ?? 0;
1865
- const driftCount = (await this.query("?[count(key)] := *drift_overlay{key}"))
1866
- .rows[0]?.[0] ?? 0;
1867
- const communityCount = (await this.query("?[count(id)] := *communities{id}"))
1868
- .rows[0]?.[0] ?? 0;
1869
- const correctionCount = (await this.query("?[count(entity_key)] := *corrections{entity_key}"))
1870
- .rows[0]?.[0] ?? 0;
1871
- // Entity breakdown by kind
1872
- const entityByKind = {};
1873
- const kindRows = (await this.query("?[kind, count(key)] := *entities{key, kind}")).rows;
1874
- for (const row of kindRows) {
1875
- entityByKind[row[0]] = row[1];
1876
- }
1877
- // Edge breakdown by type
1878
- const edgeByType = {};
1879
- const typeRows = (await this.query("?[type, count(from_key)] := *edges{from_key, type}")).rows;
1880
- for (const row of typeRows) {
1881
- edgeByType[row[0]] = row[1];
1882
- }
1883
- // Top 10 files by entity count
1884
- const topFileRows = (await this.query("?[file_path, count(entity_key)] := *file_index{file_path, entity_key} :order -count(entity_key) :limit 10")).rows;
1885
- const topFiles = topFileRows.map((row) => ({
1886
- filePath: row[0],
1887
- entityCount: row[1],
1888
- }));
1889
- // Language breakdown — aggregate unique file paths by extension.
1890
- // CozoDB Datalog has no string-split, so we post-process in JS.
1891
- const languageBreakdown = {};
1892
- const filePathRows = (await this.query("?[file_path] := *file_index{file_path, entity_key}")).rows;
1893
- const seenPaths = new Set();
1894
- for (const row of filePathRows) {
1895
- const filePath = row[0];
1896
- if (seenPaths.has(filePath))
1897
- continue;
1898
- seenPaths.add(filePath);
1899
- const lastDot = filePath.lastIndexOf(".");
1900
- const lastSlash = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
1901
- const ext = lastDot > lastSlash && lastDot >= 0
1902
- ? filePath.slice(lastDot + 1).toLowerCase()
1903
- : "other";
1904
- languageBreakdown[ext] = (languageBreakdown[ext] ?? 0) + 1;
1905
- }
1906
- return {
1907
- entityCount,
1908
- edgeCount,
1909
- fileCount,
1910
- ruleCount,
1911
- driftCount,
1912
- communityCount,
1913
- correctionCount,
1914
- entityByKind,
1915
- edgeByType,
1916
- languageBreakdown,
1917
- topFiles,
1918
- };
1919
- }
1920
- /**
1921
- * Remove corrections not seen in the last N days.
1922
- * Returns the number of pruned entries.
1923
- */
1924
- async pruneCorrections(staleDays = 30) {
1925
- const cutoff = new Date(Date.now() - staleDays * 24 * 60 * 60 * 1000).toISOString();
1926
- const stale = await this.write(`?[entity_key, error_type] :=
1927
- *corrections{entity_key, error_type, last_seen},
1928
- last_seen < $cutoff`, { cutoff });
1929
- let pruned = 0;
1930
- for (const row of stale.rows) {
1931
- await this.write(`?[entity_key, error_type] <- [[$ek, $et]]
1932
- :rm corrections {entity_key, error_type}`, { ek: row[0], et: row[1] });
1933
- pruned++;
1934
- }
1935
- return pruned;
1936
- }
1937
- }
1938
- /** Simple glob matching — supports * and ** patterns. */
1939
- function matchGlob(filePath, glob) {
1940
- const regex = glob
1941
- .replace(/\./g, "\\.")
1942
- .replace(/\*\*/g, "{{GLOBSTAR}}")
1943
- .replace(/\*/g, "[^/]*")
1944
- .replace(/\{\{GLOBSTAR\}\}/g, ".*");
1945
- return new RegExp(`^${regex}$`).test(filePath);
1946
- }