@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.
- package/README.md +36 -45
- package/dist/cli.js +37443 -36022
- package/package.json +2 -1
- package/dist/behaviors/agent-llm-bridge.js +0 -166
- package/dist/behaviors/architecture-guard.js +0 -256
- package/dist/behaviors/auto-doc.js +0 -247
- package/dist/behaviors/cascade-guard.js +0 -289
- package/dist/behaviors/change-narrative.js +0 -270
- package/dist/behaviors/convention-drift.js +0 -290
- package/dist/behaviors/framework.js +0 -235
- package/dist/behaviors/guard-formatter.js +0 -44
- package/dist/behaviors/incomplete-work.js +0 -270
- package/dist/behaviors/loop-breaker.js +0 -300
- package/dist/behaviors/session-continuity.js +0 -208
- package/dist/commands/branches.js +0 -97
- package/dist/commands/check-commit.js +0 -225
- package/dist/commands/compress-output.js +0 -64
- package/dist/commands/config-verify.js +0 -243
- package/dist/commands/daemon.js +0 -905
- package/dist/commands/dashboard.js +0 -52
- package/dist/commands/debug.js +0 -200
- package/dist/commands/enrich.js +0 -184
- package/dist/commands/exec.js +0 -233
- package/dist/commands/gain.js +0 -156
- package/dist/commands/hook.js +0 -88
- package/dist/commands/index.js +0 -88
- package/dist/commands/init.js +0 -74
- package/dist/commands/install.js +0 -505
- package/dist/commands/learn.js +0 -116
- package/dist/commands/manifest.js +0 -193
- package/dist/commands/rewind.js +0 -103
- package/dist/commands/serve.js +0 -19
- package/dist/commands/setup-wizard.js +0 -414
- package/dist/commands/skills.js +0 -64
- package/dist/commands/stats.js +0 -20
- package/dist/commands/status.js +0 -654
- package/dist/commands/timeline.js +0 -139
- package/dist/commands/uninstall.js +0 -230
- package/dist/components/App.js +0 -109
- package/dist/components/Banner.js +0 -12
- package/dist/components/ConfirmPrompt.js +0 -25
- package/dist/components/DriftSummary.js +0 -23
- package/dist/components/GradeBadge.js +0 -15
- package/dist/components/HealthCard.js +0 -18
- package/dist/components/InkSpinner.js +0 -22
- package/dist/components/InputBox.js +0 -17
- package/dist/components/KeyValue.js +0 -13
- package/dist/components/MessageList.js +0 -14
- package/dist/components/ProgressBar.js +0 -26
- package/dist/components/Section.js +0 -16
- package/dist/components/SessionSummaryCard.js +0 -73
- package/dist/components/StartupDisplay.js +0 -24
- package/dist/components/StatusDashboard.js +0 -57
- package/dist/components/StatusLine.js +0 -8
- package/dist/components/StepLine.js +0 -22
- package/dist/components/Theme.js +0 -20
- package/dist/components/ToolProgress.js +0 -8
- package/dist/components/ViolationList.js +0 -21
- package/dist/components/render.js +0 -13
- package/dist/config/agent-registry.js +0 -237
- package/dist/config/claude-settings-hooks.js +0 -304
- package/dist/config/hook-installer.js +0 -65
- package/dist/config/instruction-writer.js +0 -388
- package/dist/config/mcp-config-writer.js +0 -266
- package/dist/config/settings.js +0 -174
- package/dist/config/tool-detector.js +0 -42
- package/dist/config/value-surfacing.js +0 -119
- package/dist/core/context-assembly.js +0 -108
- package/dist/core/conversation.js +0 -33
- package/dist/core/local-chat-provider.js +0 -475
- package/dist/core/provider-factory.js +0 -55
- package/dist/core/providers.js +0 -90
- package/dist/core/query-engine.js +0 -174
- package/dist/daemon/api.js +0 -312
- package/dist/daemon/autostart.js +0 -119
- package/dist/daemon/bootstrap.js +0 -39
- package/dist/daemon/client.js +0 -164
- package/dist/daemon/detect-ci.js +0 -81
- package/dist/daemon/platform-linux.js +0 -146
- package/dist/daemon/platform-macos.js +0 -134
- package/dist/daemon/platform-windows.js +0 -116
- package/dist/daemon/process-manager.js +0 -299
- package/dist/daemon/protocol.js +0 -23
- package/dist/daemon/registry.js +0 -270
- package/dist/daemon/settings-schema.js +0 -72
- package/dist/daemon/system-health.js +0 -134
- package/dist/daemon/version-checker.js +0 -262
- package/dist/daemon/warm-start.js +0 -223
- package/dist/entrypoints/cli.js +0 -1043
- package/dist/entrypoints/daemon.js +0 -380
- package/dist/entrypoints/repl.js +0 -147
- package/dist/hooks/adapters/claude-code.js +0 -90
- package/dist/hooks/adapters/cline.js +0 -100
- package/dist/hooks/adapters/cursor.js +0 -98
- package/dist/hooks/hook-dedup.js +0 -79
- package/dist/hooks/hook-runner.js +0 -113
- package/dist/hooks/navigation-hooks.js +0 -175
- package/dist/hooks/prompt-hooks.js +0 -63
- package/dist/hooks/shell-hooks.js +0 -47
- package/dist/ignore.js +0 -111
- package/dist/intelligence/approach-suggester.js +0 -61
- package/dist/intelligence/ast-extractor.js +0 -2615
- package/dist/intelligence/ast-worker.js +0 -34
- package/dist/intelligence/background-indexer.js +0 -121
- package/dist/intelligence/blast-radius.js +0 -200
- package/dist/intelligence/community-detection.js +0 -691
- package/dist/intelligence/community-detector.js +0 -184
- package/dist/intelligence/computation-scheduler.js +0 -75
- package/dist/intelligence/confidence-propagation.js +0 -47
- package/dist/intelligence/convention-detector.js +0 -242
- package/dist/intelligence/convention-learner.js +0 -205
- package/dist/intelligence/convention-matcher.js +0 -205
- package/dist/intelligence/cozo-schema.js +0 -376
- package/dist/intelligence/decision-point-detector.js +0 -90
- package/dist/intelligence/deep-dive-tools.js +0 -586
- package/dist/intelligence/durability-scorer.js +0 -84
- package/dist/intelligence/exploration-cost.js +0 -204
- package/dist/intelligence/exploration-pattern-tracker.js +0 -61
- package/dist/intelligence/fact-generator.js +0 -322
- package/dist/intelligence/facts-schema.js +0 -90
- package/dist/intelligence/file-intelligence.js +0 -59
- package/dist/intelligence/graph-holder.js +0 -220
- package/dist/intelligence/graph-temporal-joiner.js +0 -238
- package/dist/intelligence/health-grade.js +0 -423
- package/dist/intelligence/health-grader.js +0 -200
- package/dist/intelligence/health-map-data.js +0 -259
- package/dist/intelligence/import-symbols.js +0 -136
- package/dist/intelligence/incremental-indexer.js +0 -658
- package/dist/intelligence/indexer/centrality.js +0 -62
- package/dist/intelligence/indexer/cfg-context.js +0 -95
- package/dist/intelligence/indexer/confidence.js +0 -34
- package/dist/intelligence/indexer/cross-file-resolver.js +0 -104
- package/dist/intelligence/indexer/edge-repair.js +0 -89
- package/dist/intelligence/indexer/entity-key.js +0 -17
- package/dist/intelligence/indexer/export-map.js +0 -132
- package/dist/intelligence/indexer/git-cochange.js +0 -128
- package/dist/intelligence/indexer/graph-patch.js +0 -147
- package/dist/intelligence/indexer/incremental.js +0 -78
- package/dist/intelligence/indexer/ingest.js +0 -160
- package/dist/intelligence/indexer/language-detect.js +0 -226
- package/dist/intelligence/indexer/metadata.js +0 -63
- package/dist/intelligence/indexer/mutation-tracker.js +0 -79
- package/dist/intelligence/indexer/orchestrator.js +0 -155
- package/dist/intelligence/indexer/plugin-interface.js +0 -31
- package/dist/intelligence/indexer/plugins/csharp.js +0 -440
- package/dist/intelligence/indexer/plugins/go.js +0 -335
- package/dist/intelligence/indexer/plugins/java.js +0 -370
- package/dist/intelligence/indexer/plugins/python.js +0 -358
- package/dist/intelligence/indexer/plugins/regex-fallback.js +0 -82
- package/dist/intelligence/indexer/plugins/ruby.js +0 -290
- package/dist/intelligence/indexer/plugins/rust.js +0 -484
- package/dist/intelligence/indexer/plugins/tier2-generic.js +0 -310
- package/dist/intelligence/indexer/plugins/typescript.js +0 -456
- package/dist/intelligence/indexer/resource-monitor.js +0 -93
- package/dist/intelligence/indexer/scip/decoder.js +0 -253
- package/dist/intelligence/indexer/scip/detector.js +0 -232
- package/dist/intelligence/indexer/scip/downloader.js +0 -427
- package/dist/intelligence/indexer/scip/fallback.js +0 -34
- package/dist/intelligence/indexer/scip/merger.js +0 -109
- package/dist/intelligence/indexer/scip/orchestrator.js +0 -433
- package/dist/intelligence/indexer/scip/runner.js +0 -98
- package/dist/intelligence/indexer/snapshot.js +0 -66
- package/dist/intelligence/indexer/test-detector.js +0 -196
- package/dist/intelligence/indexer/watch-integration.js +0 -61
- package/dist/intelligence/indexer/worker.js +0 -85
- package/dist/intelligence/local-convention-detector.js +0 -437
- package/dist/intelligence/local-embeddings.js +0 -190
- package/dist/intelligence/local-graph.js +0 -1946
- package/dist/intelligence/local-indexer.js +0 -1575
- package/dist/intelligence/local-llm.js +0 -163
- package/dist/intelligence/local-rule-generator.js +0 -154
- package/dist/intelligence/local-snapshot.js +0 -213
- package/dist/intelligence/negative-knowledge.js +0 -103
- package/dist/intelligence/persistent-db.js +0 -85
- package/dist/intelligence/query-router.js +0 -2556
- package/dist/intelligence/risk-classifier.js +0 -116
- package/dist/intelligence/rule-evaluator.js +0 -380
- package/dist/intelligence/rule-generator.js +0 -49
- package/dist/intelligence/search-index.js +0 -173
- package/dist/intelligence/semantic/docstring-extractor.js +0 -67
- package/dist/intelligence/semantic/embedding-store.js +0 -52
- package/dist/intelligence/semantic/enrichment-orchestrator.js +0 -48
- package/dist/intelligence/semantic/git-message-miner.js +0 -114
- package/dist/intelligence/semantic/identifier-tokenizer.js +0 -51
- package/dist/intelligence/semantic/node2vec-embeddings.js +0 -71
- package/dist/intelligence/semantic/node2vec-walks.js +0 -103
- package/dist/intelligence/semantic/path-domain-inference.js +0 -112
- package/dist/intelligence/semantic/similarity-engine.js +0 -60
- package/dist/intelligence/semantic/tfidf-vectors.js +0 -88
- package/dist/intelligence/session-brief-builder.js +0 -159
- package/dist/intelligence/session-context.js +0 -221
- package/dist/intelligence/session-health-monitor.js +0 -211
- package/dist/intelligence/session-narrative.js +0 -197
- package/dist/intelligence/session-pattern-analyzer.js +0 -218
- package/dist/intelligence/signal-scorer.js +0 -390
- package/dist/intelligence/signal-show-store.js +0 -182
- package/dist/intelligence/smart-truncate.js +0 -158
- package/dist/intelligence/subgraph-cache.js +0 -88
- package/dist/intelligence/temporal-facts.js +0 -494
- package/dist/intelligence/token-estimator.js +0 -100
- package/dist/intelligence/tool-injector.js +0 -87
- package/dist/intelligence/tree-sitter-loader.js +0 -71
- package/dist/intelligence/worker-pool.js +0 -116
- package/dist/proxy/arg-validator.js +0 -79
- package/dist/proxy/auto-bootstrap.js +0 -167
- package/dist/proxy/bridge.js +0 -147
- package/dist/proxy/budget-enforcer.js +0 -70
- package/dist/proxy/compression-quality-monitor.js +0 -160
- package/dist/proxy/compression-stats.js +0 -51
- package/dist/proxy/context-rot-detector.js +0 -137
- package/dist/proxy/drift-detector.js +0 -139
- package/dist/proxy/efficiency-tracker.js +0 -79
- package/dist/proxy/fact-ranking.js +0 -154
- package/dist/proxy/format-encoder.js +0 -266
- package/dist/proxy/http-transport.js +0 -90
- package/dist/proxy/lifecycle-actor.js +0 -55
- package/dist/proxy/lifecycle-machine.js +0 -187
- package/dist/proxy/log-tailer.js +0 -265
- package/dist/proxy/model-pricing.js +0 -98
- package/dist/proxy/network-firewall.js +0 -141
- package/dist/proxy/nudge-state.js +0 -93
- package/dist/proxy/output-compressor.js +0 -185
- package/dist/proxy/pid-lock.js +0 -291
- package/dist/proxy/proxy-context.js +0 -11
- package/dist/proxy/proxy.js +0 -2633
- package/dist/proxy/response-enrichment.js +0 -32
- package/dist/proxy/response-envelope.js +0 -313
- package/dist/proxy/session-dedup.js +0 -82
- package/dist/proxy/session-legend.js +0 -30
- package/dist/proxy/session-persistence.js +0 -210
- package/dist/proxy/session-resume.js +0 -94
- package/dist/proxy/session-stats.js +0 -513
- package/dist/proxy/shell-classifier.js +0 -1346
- package/dist/proxy/shell-compression-log.js +0 -93
- package/dist/proxy/shell-compressor.js +0 -390
- package/dist/proxy/shell-graph-boost.js +0 -202
- package/dist/proxy/shell-monitor-map.js +0 -18
- package/dist/proxy/shell-stats.js +0 -54
- package/dist/proxy/shell-strategies/cloud.js +0 -215
- package/dist/proxy/shell-strategies/diff.js +0 -159
- package/dist/proxy/shell-strategies/error-diagnostic.js +0 -796
- package/dist/proxy/shell-strategies/filter-dsl.js +0 -358
- package/dist/proxy/shell-strategies/git-status.js +0 -177
- package/dist/proxy/shell-strategies/key-value.js +0 -193
- package/dist/proxy/shell-strategies/log-text.js +0 -154
- package/dist/proxy/shell-strategies/omni.js +0 -188
- package/dist/proxy/shell-strategies/progress.js +0 -55
- package/dist/proxy/shell-strategies/redact.js +0 -76
- package/dist/proxy/shell-strategies/structured.js +0 -241
- package/dist/proxy/shell-strategies/tabular.js +0 -243
- package/dist/proxy/shell-strategies/test-results-types.js +0 -13
- package/dist/proxy/shell-strategies/test-results.js +0 -784
- package/dist/proxy/shell-strategies/tree-paths.js +0 -144
- package/dist/proxy/shell-strategies/yaml.js +0 -182
- package/dist/proxy/shell-tee.js +0 -111
- package/dist/proxy/signal-dedup.js +0 -171
- package/dist/proxy/startup-renderer.js +0 -158
- package/dist/proxy/task-token-display.js +0 -38
- package/dist/proxy/token-counter.js +0 -61
- package/dist/proxy/tool-clusters.js +0 -273
- package/dist/proxy/tool-definitions.js +0 -525
- package/dist/proxy/transport-mux.js +0 -229
- package/dist/proxy/wire-cap.js +0 -268
- package/dist/rules/developer.mozilla.org.json +0 -9
- package/dist/rules/github.com.json +0 -21
- package/dist/schemas/api/skills.js +0 -19
- package/dist/schemas/common/errors.js +0 -7
- package/dist/schemas/common/headers.js +0 -5
- package/dist/schemas/entities/edge.js +0 -25
- package/dist/schemas/entities/entity.js +0 -22
- package/dist/schemas/entities/rule.js +0 -18
- package/dist/schemas/index.js +0 -14
- package/dist/server/event-bus.js +0 -59
- package/dist/server/http.js +0 -156
- package/dist/server/middleware.js +0 -70
- package/dist/server/routes/drift.js +0 -97
- package/dist/server/routes/intelligence.js +0 -1217
- package/dist/server/routes/reasoning-quality.js +0 -444
- package/dist/server/routes/session.js +0 -86
- package/dist/server/routes/stream.js +0 -120
- package/dist/server/routes/system.js +0 -73
- package/dist/server/routes/temporal.js +0 -170
- package/dist/server/routes/timeline.js +0 -232
- package/dist/server/routes/token-flow.js +0 -403
- package/dist/skills/effectiveness-tracker.js +0 -93
- package/dist/skills/local-pack.js +0 -380
- package/dist/skills/resolver.js +0 -495
- package/dist/state-detector.js +0 -83
- package/dist/timeline/intent-detector.js +0 -263
- package/dist/timeline/loop-miner.js +0 -140
- package/dist/timeline/open-threads.js +0 -49
- package/dist/timeline/signal-reinforcer.js +0 -62
- package/dist/timeline/timeline-bootstrap.js +0 -151
- package/dist/timeline/timeline-store.js +0 -618
- package/dist/tools/coding/bash.js +0 -49
- package/dist/tools/coding/file-edit.js +0 -72
- package/dist/tools/coding/file-outline.js +0 -227
- package/dist/tools/coding/file-read-protocol.js +0 -425
- package/dist/tools/coding/file-read.js +0 -35
- package/dist/tools/coding/file-write.js +0 -43
- package/dist/tools/coding/glob-tool.js +0 -109
- package/dist/tools/coding/grep.js +0 -162
- package/dist/tools/coding/index.js +0 -27
- package/dist/tools/intelligence/index.js +0 -269
- package/dist/tools/intelligence/record-fact.js +0 -48
- package/dist/tools/intelligence/timeline-markers.js +0 -130
- package/dist/tools/registry.js +0 -47
- package/dist/tools/types.js +0 -8
- package/dist/tracking/auto-snapshot-triggers.js +0 -246
- package/dist/tracking/branch-context.js +0 -115
- package/dist/tracking/branch-snapshot.js +0 -217
- package/dist/tracking/causal-bridge.js +0 -317
- package/dist/tracking/circuit-breaker.js +0 -147
- package/dist/tracking/commit-watcher.js +0 -114
- package/dist/tracking/context-ledger.js +0 -119
- package/dist/tracking/correction-detector.js +0 -324
- package/dist/tracking/drift-tracker.js +0 -874
- package/dist/tracking/durability-tracker.js +0 -94
- package/dist/tracking/entity-rewind.js +0 -200
- package/dist/tracking/file-hash-state.js +0 -114
- package/dist/tracking/git-attribution.js +0 -132
- package/dist/tracking/git-trailers.js +0 -171
- package/dist/tracking/intelligence-counter.js +0 -46
- package/dist/tracking/intent-correlator.js +0 -202
- package/dist/tracking/intent-encoder.js +0 -52
- package/dist/tracking/intent-token-tracker.js +0 -159
- package/dist/tracking/ledger-archiver.js +0 -94
- package/dist/tracking/ledger-chains.js +0 -245
- package/dist/tracking/metrics-store.js +0 -361
- package/dist/tracking/native-watcher.js +0 -131
- package/dist/tracking/offline-rewind.js +0 -295
- package/dist/tracking/pending-violations.js +0 -74
- package/dist/tracking/persistence-effectiveness.js +0 -167
- package/dist/tracking/prompt-durability.js +0 -202
- package/dist/tracking/quality-signals.js +0 -213
- package/dist/tracking/redactor.js +0 -73
- package/dist/tracking/rewind-engine.js +0 -161
- package/dist/tracking/session-history.js +0 -128
- package/dist/tracking/session-receipt.js +0 -88
- package/dist/tracking/session-summary-writer.js +0 -157
- package/dist/tracking/shadow-ledger.js +0 -321
- package/dist/tracking/stash-manager.js +0 -258
- package/dist/tracking/timeline-fork.js +0 -213
- package/dist/tracking/timeline.js +0 -69
- package/dist/tracking/token-flow.js +0 -276
- package/dist/tracking/turn-segmenter.js +0 -122
- package/dist/tracking/weekly-accumulator.js +0 -179
- package/dist/tracking/working-snapshots.js +0 -188
- package/dist/tracking/workspace-manifest.js +0 -176
- package/dist/transport/http.js +0 -102
- package/dist/utils/counterfactual.js +0 -65
- package/dist/utils/deep-link.js +0 -34
- package/dist/utils/detect.js +0 -193
- package/dist/utils/exec.js +0 -73
- package/dist/utils/file-logger.js +0 -87
- package/dist/utils/format-error.js +0 -29
- package/dist/utils/git.js +0 -181
- package/dist/utils/log.js +0 -57
- package/dist/utils/logger.js +0 -35
- package/dist/utils/mcp-content-json.js +0 -8
- package/dist/utils/session-logger.js +0 -154
- package/dist/utils/startup-log.js +0 -512
- 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
|
-
}
|